Skip to content

fix(console): activate Lite subscription in invoice.payment_succeeded to handle 3DS/SCA#28409

Open
PanAchy wants to merge 6 commits into
anomalyco:devfrom
PanAchy:fix/billing-subscription-activation
Open

fix(console): activate Lite subscription in invoice.payment_succeeded to handle 3DS/SCA#28409
PanAchy wants to merge 6 commits into
anomalyco:devfrom
PanAchy:fix/billing-subscription-activation

Conversation

@PanAchy
Copy link
Copy Markdown
Contributor

@PanAchy PanAchy commented May 19, 2026

Issue for this PR

Closes #28408

Type of change

  • Bug fix

What does this PR do?

Lite subscription activation was handled in customer.subscription.created, which reads default_payment_method from the subscription object. In 3DS/SCA flows Stripe attaches the payment method asynchronously after the redirect, so default_payment_method is null when this event fires. The handler threw before writing anything to the DB. Stripe retried for 72 hours then stopped — the user's card was charged but BillingTable.lite was never set and LiteTable was never populated.

The fix moves activation to invoice.payment_succeeded with billing_reason === "subscription_create", which fires only after Stripe confirms payment and is correct for both immediate-payment and 3DS/SCA flows.

customer.subscription.created now only records customerID and liteSubscriptionID. All activation logic — BillingTable.lite, LiteTable insert, payment method fields, coupon redemption — happens in invoice.payment_succeeded.

Workspace/user metadata is read from the Stripe subscription object (always available) rather than from the webhook payload, making activation independent of whether customer.subscription.created succeeded.

Two additional correctness fixes are included:

  • userID is validated before the PaymentTable insert so a missing userID cannot produce an orphan payment row that blocks retries.
  • Coupon redemption is moved outside the !billing.lite guard so that if the activation transaction commits but redemption fails, a Stripe retry can still redeem the coupon (redeemCoupon is idempotent on timeRedeemed).

How did you verify your code works?

  • Ran bun typecheck in packages/console — no errors.
  • Confirmed invoice.payment_succeeded with billing_reason === "subscription_create" fires after payment confirmation in both the immediate and 3DS/SCA Stripe flows (Stripe docs).
  • Confirmed subscription metadata (workspaceID, userID, userEmail, coupon) is always set at subscription creation time and available on the subscription object regardless of webhook ordering.
  • No automated test added: webhook handlers require a live Stripe + SST + DB environment outside the current test conventions.

Screenshots / recordings

If this is a UI change, please include a screenshot or recording.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

If you do not follow this template your PR will be automatically rejected.

PanAchy added 3 commits May 19, 2026 18:28
Billing.generateReceiptUrl was calling Stripe with a caller-supplied
paymentID without checking that the payment belongs to the caller's
workspace. Any authenticated user who knew a payment intent ID from
another workspace could retrieve that workspace's Stripe receipt URL
(billing email, card last-4, amount, date) — a classic IDOR.

Fix: look up the payment in PaymentTable scoped to Actor.workspace()
before calling Stripe. Throw if not found.
… to handle 3DS/SCA

In 3DS/SCA payment flows Stripe attaches the payment method asynchronously
after the checkout redirect, so default_payment_method is null at the time
customer.subscription.created fires. The previous guard
  if (!paymentMethodID) throw new Error('Payment method ID not found')
caused the entire activation block to be skipped. Stripe retries the webhook
for 72 hours and then gives up, leaving the user permanently locked out of
Lite with no recovery path.

Fix:
- Move lite subscription activation (BillingTable.lite, LiteTable insert,
  coupon redemption) from customer.subscription.created to
  invoice.payment_succeeded with billing_reason === 'subscription_create'.
  Payment confirmation is the correct trigger; it fires for both immediate-
  payment and 3DS/SCA flows.
- Look up workspaceID, userID, userEmail, and coupon directly from the
  Stripe subscription metadata instead of BillingTable, making activation
  independent of whether customer.subscription.created succeeded.
- Split subscription_create and subscription_cycle branches in
  invoice.payment_succeeded so activation logic is isolated.
- Add an idempotency guard (billing.lite check) so Stripe retries are safe.
- customer.subscription.created now only records customerID and
  liteSubscriptionID; activation is deferred to payment confirmation.
@github-actions
Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Found 1 potentially related PR:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lite subscription never activates for 3DS/SCA payments causing users to be permanently locked out

1 participant