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.
Every subscription fixture in the suite carries current_period_end at the top
level, which is the location the pinned API version no longer uses. So the item
level read, the reason this code exists, had no test at all and every other case
passed through the legacy fallback instead.
Covers both locations for the period and for the invoice's subscription link,
the null case the webhook relies on to leave a one-off invoice alone, and the
retry-window bound through the payload shape production actually sends.
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.
An account that signs up through an invitation works on the inviter's
billing, so handing it a trial at signup spent its only trial before it
owned anything. The trial is now held back for collaborators and claimed
only explicitly: a Start Free Trial button on the new-workspace and
billing screens calls the new POST /api/billing/trial endpoint, which
grants the once-per-account trial atomically. Nothing starts the clock
as a side effect, and pure collaborators no longer see a trial-ending
banner about work that is not theirs.
The cardless trial writes trialEndsAt and nothing else, because there is no
Stripe subscription behind it to report trialing. subscriptionStatus stays
FREE, so every admin view reading that column alone showed a live trial as a
free account: On Trial sat at zero, Free Users counted the trials, the user
table badged them Free with an open-ended Active access, the Trialing filter
returned nobody and the growth scoreboard left them out of the paid accounts
table.
Access has always been resolved from the date (hasBillingAccess), so the
display now follows the same date through getEffectiveBillingStatus. Only FREE
is overridden: any other status means Stripe has an opinion worth showing.
isPaidTier read a future stripeCurrentPeriodEnd as proof of payment, and
Stripe stamps a current period on an incomplete subscription all the
same. A checkout whose first charge failed therefore carried a period
end a month out with nothing paid behind it, and every ceiling the
cardless trial puts on an unpaid account (200 GB of storage, unlimited
projects, unlimited workspaces) came off with it.
Written as a deny list of the two statuses that mean no charge has ever
gone through, so a real customer whose renewal failed keeps the full
plan for the period they already paid for.
hasBillingAccess reads the same column the same way and is deliberately
left alone: being wrong there locks a paying customer out, and the SQL
in buildBillingAccessWhereInput has to move with it.
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.
The expired-owner filter expressed "no billing access" as
NOT: buildBillingAccessWhereInput(now). Prisma renders that as
NOT (status IN ('ACTIVE','TRIALING') OR "trialEndsAt" > $1 OR
"stripeCurrentPeriodEnd" > $2), and a SQL comparison against NULL is
unknown rather than false, so for a row with both dates empty the OR is
NULL and NOT NULL is NULL: the row is never returned.
Both dates empty is exactly what a canceled subscriber looks like, since
markSubscriptionCanceledByCustomerId clears trialEndsAt and Stripe no
longer reports current_period_end on the subscription. The scheduled
cleanup therefore matched nobody at all while reporting success, and
media of owners fifteen days past their grace period stayed in Bunny and
R2 indefinitely.
Each branch now names NULL explicitly. Disabling Stripe also selects
nobody instead of falling through to NOT {}, which Prisma drops
entirely: that left a filter keyed on the grace period alone, so a
self-hosted deployment running the cleanup would delete workspaces of
users it never charged.
The unit tests could not catch this, because as an object the old filter
reads correctly and no SQL is produced. The new coverage lives in
tests/api and runs against Postgres.
The repo had no automated tests. Every change was verified by hand.
Adds four layers, 2023 tests in total, runnable with one command:
- 1191 unit tests over the pure logic in lib/, including the full
computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
mocked, including a data-driven sweep asserting that none of the 60
route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build
Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.
The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.
Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.
Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.