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.
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.
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.
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.
An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.
Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.
Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.
/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
A Stripe customer can own several subscriptions. Two defects let that happen
and corrupt the user's billing state:
1. Checkout allowed a fresh subscription whenever the user was not ACTIVE/
TRIALING, so a PAST_DUE user started a brand-new subscription (Stripe
Checkout always creates one) instead of recovering the existing one.
Add hasRecoverableSubscription() (ACTIVE/TRIALING/PAST_DUE/UNPAID/
INCOMPLETE); block checkout and route these users to the billing portal
('Update Payment Method') both in the API guard and the settings UI.
2. Subscription webhooks trusted the event's single subscription, so an old
subscription's deletion could clobber a newer active one (marking the user
CANCELED / No access). Every subscription event now re-derives state from
the full set of the customer's Stripe subscriptions via
syncStripeCustomerSubscriptions() + selectAuthoritativeSubscription(),
making the sync order-independent and self-healing.
- Introduced a new logger utility (`logError`) to standardize error logging.
- Replaced all instances of `console.error` with `logError` in various API routes and libraries.
- Enhanced error logging to sanitize sensitive information, particularly for Prisma and Stripe errors.
- Ensured consistent error handling and logging practices throughout the codebase.
- Moved registration logic to a new client component for better separation of concerns.
- Integrated invite code requirement based on feature flags in the registration process.
- Enhanced dashboard functionality to conditionally enable Bunny uploads based on feature flags.
- Updated various components and API routes to check for Bunny uploads and Stripe billing feature flags.
- Added new feature flag utilities for managing feature toggles in the application.
- Added billing-related fields to the User model in the database.
- Implemented functions for managing billing access, including trial periods and subscription statuses.
- Created new billing utility functions for Stripe integration.
- Updated onboarding page to include billing overview and workspace creation eligibility.
- Enhanced route access checks to require billing access for certain actions.
- Implemented cleanup scripts for expired billing workspaces and associated media.
- Updated header component to conditionally show app navigation based on billing access.
- Added new migrations for billing-related database changes.