Commit Graph
4 Commits
Author SHA1 Message Date
yusufipek 57061b5a5d fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
2026-09-08 15:49:37 +03:00
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00
yusufipek 85855a6d52 fix(billing): close the gaps the code and security reviews found
Follow-up on the same change, from a high-effort code review and security
review run over the diff.

Access gate:
- Scope both period-end guards to the period-end branch of hasBillingAccess
  instead of the top of the function. A cutoff is only ever cleared by a Stripe
  sync, so checking it first meant a stale one from a lapsed subscription
  outranked a freshly started cardless trial: the account burned its
  once-per-account trial and got nothing. buildBillingAccessWhereInput mirrors
  the same shape.
- Refuse a period end carried by an INCOMPLETE or INCOMPLETE_EXPIRED
  subscription, the rejection isPaidTier already makes. The cutoff is
  deliberately left null while a trial is live, so a trial user who abandoned a
  checkout kept the failed subscription's period once the trial ran out.
- Apply the cutoff in isPaidTier too, so it cannot say "paid" for a period
  where hasBillingAccess says access is over. That split left a locked-out
  account with no banner explaining it and able to create workspaces it could
  not then see. Both callers now select the field.

Lifecycle:
- Cancel through syncStripeCustomerSubscriptions rather than writing the single
  cancelled subscription, so a customer holding a second live subscription is
  not locked out of an account they are still being billed for.
- Ignore invoice events with no subscription. A one-off invoice against a
  customer record left by an abandoned checkout was marking the account
  canceled and booking a churn event for a subscription that never existed.
- Fall back to a window measured from now when a subscription behind on payment
  reports no period start, rather than falling through to "access ended", which
  locked out the customer that branch exists to keep in.
- Let a paused subscription run to its period end; it was being ended at once.
- Collapse BLOCKING_STRIPE_STATUSES into LIVE_STRIPE_STATUSES and include
  incomplete. The two sets were identical, which offered a Cancel button that
  always returned "No subscription to cancel" and left the Stripe-side checkout
  guard weaker than the mirror check it backs up.

UI and ops:
- cancelIsImmediate from the API, so the confirmation says what will actually
  happen to an incomplete subscription instead of promising the period end.
- The access banner reads "ended on" once the date has passed.
- The resync script selects the way the write path selects, over the customer's
  whole set. Filtering to live subscriptions first made the dry run disagree
  with the real run and skipped canceled and incomplete customers entirely,
  who are exactly the stale mirrors the script exists for.

Three existing tests asserted the behaviour this fixes: that a canceled
subscription keeps access to its reported period end, and that the cutoff is
ignored while that period runs. Both rest on the premise that a future period
end means a paid period, which is what is not true. They now assert the bound,
alongside new cases for the retry window, the trial-versus-stale-cutoff
ordering, and a never-paid period.
2026-09-08 13:51:59 +03:00
yusufipek fe1faeced4 fix(billing): pin the Stripe API version and stop unpaid periods granting access
The Stripe client was built without an apiVersion, so the SDK followed whatever
version it shipped with. Two fields moved in the Basil API version: the billing
period went from the subscription onto its items, and the invoice link to its
subscription went under parent.subscription_details. Both reads returned
undefined without failing, which left stripeCurrentPeriodEnd null for every
subscriber and left the app with no invoice handling at all. A customer whose
card failed saw nothing about the invoice that was still retrying, and a
cancellation did nothing to stop those retries.

- Pin the API version, with `satisfies` so an SDK bump is a compile error here
  before it is a null read in production.
- Read the period off subscription items and the subscription off invoice
  parents, keeping the legacy fields as a fallback for older payloads.
- Handle invoice.paid, invoice.payment_failed, invoice.voided and
  invoice.marked_uncollectible through the existing customer-wide resync, so
  the mirror reflects payment health during dunning rather than after it.
- Add an in-app cancellation route: at period end when the subscription is
  paid, immediately plus voiding the open invoices when it is not, because
  cancelling alone does not stop collection on an invoice already issued.
- Ask Stripe, not just the local mirror, before opening checkout.
- Show the open invoice, the retry date and a payment-method-update shortcut in
  settings, and put a confirmation in front of cancellation.

Access no longer rests on the reported period alone. Stripe advances the period
when it issues the renewal invoice, paid or not, and the period survives
cancellation, so once the period field started being read correctly that check
would have handed a full free month to anyone whose renewal failed, and the new
cancel route would have let them void the invoice and keep the month. Access now
follows the subscription status, billingAccessEndedAt is enforced as a hard
cutoff in both hasBillingAccess and the query that mirrors it, and a subscription
behind on payment keeps access for Stripe's retry window rather than for the
period it never paid for.
2026-09-08 13:30:42 +03:00