mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Shorten comments in subscription wind-down handling
This commit is contained in:
parent
45ddd3877d
commit
7e1bad8c0d
@ -702,8 +702,7 @@ async function handleSubscriptionRefund(options: {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
// Same as the cancel route: Stripe's response is the authority on
|
||||
// the period boundary; the local row can be stale around a renewal
|
||||
// whose webhook hasn't synced yet.
|
||||
// the boundary; the local row can be stale around a renewal
|
||||
stripePeriodEnd = getStripeSubscriptionPeriodEnd(updated, { tenancyId: tenancy.id });
|
||||
} catch (e: unknown) {
|
||||
if (!isStripeSubscriptionAlreadyTerminalError(e)) {
|
||||
|
||||
@ -81,9 +81,8 @@ export const DELETE = createSmartRouteHandler({
|
||||
const allSubs = Object.values(subMap);
|
||||
|
||||
const nowMillis = Date.now();
|
||||
// Explains why a matched-but-uncancelable sub can't be canceled: already
|
||||
// winding down (double cancel), or in-effect in a state we don't cancel
|
||||
// from (e.g. past_due). No-op when no in-effect sub matches.
|
||||
// Gives double-cancels and past_due-style states an honest error; no-op
|
||||
// when no in-effect sub matches.
|
||||
const throwIfUncancelableButInEffect = (candidates: typeof allSubs): void => {
|
||||
const inEffect = candidates.find(s => isSubscriptionInEffect(s, nowMillis));
|
||||
if (!inEffect) return;
|
||||
@ -142,18 +141,13 @@ export const DELETE = createSmartRouteHandler({
|
||||
if (subscription.stripeSubscriptionId) {
|
||||
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
|
||||
// Cancel at period end, not `subscriptions.cancel()` (immediate) —
|
||||
// matches the confirm dialog and the local-sub branch below. Update
|
||||
// the local row eagerly (mirroring the refund route) so the product
|
||||
// list reflects the wind-down before the webhook sync arrives; the
|
||||
// Stripe sub stays `active` with cancel_at_period_end until Stripe
|
||||
// ends it at the period boundary.
|
||||
// matches the confirm dialog and the local-sub branch below. The
|
||||
// eager local write (mirroring the refund route) shows the wind-down
|
||||
// before the webhook sync arrives.
|
||||
const updated = await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
// Take the boundary from Stripe's response, not the bulldozer
|
||||
// snapshot read at request start — around a renewal whose webhook
|
||||
// hasn't synced yet, the snapshot's period end is the previous
|
||||
// (possibly already-past) one. The snapshot fallback only covers
|
||||
// item-less mocked responses; the webhook sync reconciles endedAt
|
||||
// either way.
|
||||
// Stripe's response is the authority on the boundary — the bulldozer
|
||||
// snapshot can be a stale pre-renewal period end. Snapshot fallback
|
||||
// only covers item-less mocked responses.
|
||||
const endedAt = getStripeSubscriptionPeriodEnd(updated, { tenancyId: auth.tenancy.id })
|
||||
?? new Date(subscription.currentPeriodEndMillis);
|
||||
updatedSub = await prisma.subscription.update({
|
||||
|
||||
@ -68,13 +68,10 @@ export const GET = createSmartRouteHandler({
|
||||
]);
|
||||
// Deprecated: map productId → subscription for backward-compat fields.
|
||||
// ownedProducts keys use '__null__' for inline products (null productId),
|
||||
// so we normalize subscription productIds to match. In-effect (not
|
||||
// active): a canceled-at-period-end sub still backs its owned product
|
||||
// and must not fall through to the one-time-purchase branch below.
|
||||
// When several in-effect subs share a productId (e.g. one quantity of a
|
||||
// stackable product was canceled by subscription_id), prefer the highest
|
||||
// subscriptionDisplayRank (cancelable > active > in-effect) so a
|
||||
// winding-down sub can't shadow one the customer can still act on.
|
||||
// so we normalize subscription productIds to match. In-effect, not
|
||||
// active-only: a winding-down sub still backs its product and must not
|
||||
// fall through to the one_time branch below; productId ties pick the
|
||||
// highest subscriptionDisplayRank.
|
||||
const nowMillis = Date.now();
|
||||
const inEffectSubByProductId = new Map<string, SubscriptionRow>();
|
||||
for (const s of Object.values(subMap)) {
|
||||
|
||||
@ -267,10 +267,9 @@ export const POST = createSmartRouteHandler({
|
||||
payment_behavior: "error_if_incomplete",
|
||||
payment_settings: { save_default_payment_method: "on_subscription" },
|
||||
default_payment_method: resolvedPaymentMethodId,
|
||||
// Paying to switch plans is an explicit "keep me subscribed": clear a
|
||||
// pending cancel-at-period-end, otherwise the new plan the customer
|
||||
// just paid for still dies at the period boundary. Stripe persists
|
||||
// the flag across item updates unless reset.
|
||||
// Paying to switch is "keep me subscribed" — Stripe persists a
|
||||
// pending cancel across item updates unless reset, and the new plan
|
||||
// would otherwise still die at the period boundary.
|
||||
cancel_at_period_end: false,
|
||||
items: [{
|
||||
id: existingItem.id,
|
||||
@ -315,10 +314,8 @@ export const POST = createSmartRouteHandler({
|
||||
currentPeriodStart: sanitizedUpdateDates.start,
|
||||
currentPeriodEnd: sanitizedUpdateDates.end,
|
||||
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
|
||||
// Clear the wind-down marks the cancel route wrote eagerly. The
|
||||
// webhook sync now reconciles these from Stripe's state anyway;
|
||||
// the manual clear only covers the pre-webhook window so the
|
||||
// products list doesn't show "Ends on" right after a switch.
|
||||
// Clear the eagerly-written wind-down marks; the webhook sync
|
||||
// reconciles these anyway, this just covers the pre-webhook window
|
||||
canceledAt: null,
|
||||
endedAt: null,
|
||||
},
|
||||
|
||||
@ -131,14 +131,10 @@ export const POST = createSmartRouteHandler({
|
||||
payment_behavior: 'default_incomplete',
|
||||
payment_settings: { save_default_payment_method: 'on_subscription' },
|
||||
expand: ['latest_invoice.confirmation_secret'],
|
||||
// Note: we deliberately do NOT clear cancel_at_period_end here.
|
||||
// This update runs at session creation, before the customer has
|
||||
// paid (default_incomplete leaves the invoice open, and unlike
|
||||
// the switch route's error_if_incomplete there is no rollback on
|
||||
// payment failure) — clearing it now would reactivate an
|
||||
// explicitly-canceled sub on a declined card. The webhook clears
|
||||
// the flag on invoice.paid, once the re-purchase has actually
|
||||
// been paid for.
|
||||
// Deliberately NOT clearing cancel_at_period_end here: this runs
|
||||
// before payment (default_incomplete has no rollback), so a
|
||||
// declined card would reactivate an explicitly-canceled sub. The
|
||||
// webhook clears the flag on invoice.paid instead.
|
||||
items: [{
|
||||
id: existingItem.id,
|
||||
price_data: {
|
||||
|
||||
@ -296,9 +296,8 @@ describe('isSubscriptionCancelable', () => {
|
||||
describe('subscriptionDisplayRank', () => {
|
||||
it('ranks cancelable above pending-cancel Stripe subs above wound-down subs', () => {
|
||||
const cancelable = { status: 'active', cancelAtPeriodEnd: false };
|
||||
// A pending-cancel Stripe sub stays `active` — this is the tier that an
|
||||
// active-only preference gets wrong (it would tie with the cancelable
|
||||
// sibling and shadow it depending on iteration order).
|
||||
// Pending-cancel Stripe subs stay `active` — the tier an active-only
|
||||
// preference gets wrong
|
||||
const stripePendingCancel = { status: 'active', cancelAtPeriodEnd: true };
|
||||
const localWindingDown = { status: 'canceled', cancelAtPeriodEnd: true };
|
||||
expect(subscriptionDisplayRank(cancelable)).toBeGreaterThan(subscriptionDisplayRank(stripePendingCancel));
|
||||
|
||||
@ -127,22 +127,19 @@ export function isActiveSubscription(subscription: { status: string }): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The subscription can be canceled (again): it is active and not already
|
||||
* winding down. Keep this in sync between the product list's `is_cancelable`
|
||||
* field and the cancel route's filter, so `is_cancelable: false` always
|
||||
* implies the DELETE returns 400.
|
||||
* Active and not already winding down. Used by both the product list's
|
||||
* `is_cancelable` and the cancel route's filter — keep them in sync so
|
||||
* `is_cancelable: false` always implies the DELETE returns 400.
|
||||
*/
|
||||
export function isSubscriptionCancelable(subscription: { status: string, cancelAtPeriodEnd: boolean }): boolean {
|
||||
return isActiveSubscription(subscription) && !subscription.cancelAtPeriodEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank for picking the representative subscription when several in-effect
|
||||
* subs share a productId (stackable products): cancelable > merely-active >
|
||||
* merely-in-effect. Cancelable > active matters for Stripe subs — a
|
||||
* pending-cancel Stripe sub stays `active` (with cancelAtPeriodEnd), so an
|
||||
* active-only preference would let it shadow a cancelable sibling and report
|
||||
* is_cancelable: false while a DELETE would in fact succeed.
|
||||
* Picks the representative sub when several in-effect subs share a productId
|
||||
* (stackable products): cancelable > active > in-effect. The middle tier is
|
||||
* for pending-cancel Stripe subs, which stay `active` and would otherwise
|
||||
* shadow a cancelable sibling.
|
||||
*/
|
||||
export function subscriptionDisplayRank(subscription: { status: string, cancelAtPeriodEnd: boolean }): number {
|
||||
return isSubscriptionCancelable(subscription) ? 2 : isActiveSubscription(subscription) ? 1 : 0;
|
||||
@ -484,11 +481,10 @@ export async function validatePurchaseSession(options: {
|
||||
// a duplicate request during lag would slip past it and silently re-grant.
|
||||
// We query Prisma directly so the sub guard is symmetric with the OTP guard
|
||||
// and works for products with no productLineId.
|
||||
// Includes canceled-at-period-end subs (canceled with a future endedAt) —
|
||||
// they still back their product, so a purchase in the same line must treat
|
||||
// them as replaceable conflicts, not as OTPs. Deliberately narrower than
|
||||
// isSubscriptionInEffect: incomplete/past_due subs are excluded so a retry
|
||||
// after a failed payment isn't rejected as ProductAlreadyGranted below.
|
||||
// Includes canceled-at-period-end subs: they still back their product, so
|
||||
// a same-line purchase must treat them as replaceable conflicts, not OTPs.
|
||||
// Narrower than isSubscriptionInEffect so a payment retry of an incomplete
|
||||
// sub isn't rejected as ProductAlreadyGranted below.
|
||||
const replaceableSubs = await prisma.subscription.findMany({
|
||||
where: {
|
||||
tenancyId,
|
||||
|
||||
@ -2,11 +2,9 @@ import type Stripe from "stripe";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCanceledAtForSync, getEndedAtForSync } from "./stripe";
|
||||
|
||||
// The helpers only read status / cancel_at_period_end / ended_at /
|
||||
// canceled_at, but are typed against the full Stripe.Subscription. Building a
|
||||
// real Stripe.Subscription in a unit test is impractical, so we cast a
|
||||
// minimal object; a missing field the helpers start relying on would surface
|
||||
// as a failing assertion here, not as a silent wrong value.
|
||||
// Minimal cast: the helpers only read status / cancel_at_period_end /
|
||||
// ended_at / canceled_at, and a newly-relied-on field would surface as a
|
||||
// failing assertion here rather than a silent wrong value.
|
||||
function stripeSub(fields: {
|
||||
status: Stripe.Subscription.Status,
|
||||
cancelAtPeriodEnd?: boolean,
|
||||
@ -25,9 +23,6 @@ describe("getEndedAtForSync", () => {
|
||||
const periodEnd = new Date("2026-08-16T00:00:00Z");
|
||||
|
||||
it("clears endedAt for a non-terminal sub without a pending cancel (reactivation path)", () => {
|
||||
// Reversing a pending cancellation via Stripe's Dashboard/API must clear
|
||||
// the endedAt the cancel route wrote eagerly — otherwise bulldozer still
|
||||
// ends the grants at the old boundary while Stripe keeps billing.
|
||||
expect(getEndedAtForSync(stripeSub({ status: "active" }), periodEnd)).toEqual({ endedAt: null });
|
||||
});
|
||||
|
||||
|
||||
@ -238,13 +238,10 @@ const TERMINAL_STRIPE_STATUSES = ["canceled", "incomplete_expired", "unpaid"] as
|
||||
|
||||
export function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date | null } {
|
||||
if (!TERMINAL_STRIPE_STATUSES.includes(subscription.status as typeof TERMINAL_STRIPE_STATUSES[number])) {
|
||||
// Non-terminal: reconcile the locally scheduled end with Stripe's flag.
|
||||
// The cancel route eagerly writes endedAt = period end; Stripe is the
|
||||
// authority on both whether that end is still scheduled (the pending
|
||||
// cancel can be reversed via Stripe's Dashboard/API) and on the exact
|
||||
// boundary (the eager write may have used a stale pre-renewal period
|
||||
// end). isSubscriptionInEffect treats endedAt == null as "no end
|
||||
// scheduled", so clearing it here is what reactivates entitlements.
|
||||
// Non-terminal: reconcile the eagerly-written wind-down endedAt with
|
||||
// Stripe's flag — a pending cancel can be reversed via Stripe's
|
||||
// Dashboard/API, and clearing endedAt here is what reactivates
|
||||
// entitlements.
|
||||
return { endedAt: subscription.cancel_at_period_end ? sanitizedEnd : null };
|
||||
}
|
||||
// Prefer Stripe's `ended_at` — real Stripe always sets it on transitions into
|
||||
@ -262,18 +259,15 @@ export function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEn
|
||||
}
|
||||
|
||||
export function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date | null } {
|
||||
// Mirror Stripe exactly, including null: reversing a pending cancellation
|
||||
// clears canceled_at on Stripe's side, and the local mark must follow.
|
||||
// Mirror Stripe including null, so reversing a pending cancel clears the local mark
|
||||
return { canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitized period end of the subscription's first item, or null when the
|
||||
* response carries no items (mocked Stripe). Used to eagerly write the
|
||||
* wind-down `endedAt` from Stripe's authoritative response instead of a
|
||||
* possibly stale (pre-renewal) local or bulldozer period end. Items with
|
||||
* invalid period values fall through to sanitizeStripePeriodDates' own
|
||||
* now→now+1mo fallback (plus a captureError), not to the caller's fallback.
|
||||
* response has no items (mocked Stripe). Lets wind-down writers take the
|
||||
* boundary from Stripe's authoritative response instead of a possibly stale
|
||||
* (pre-renewal) local period end.
|
||||
*/
|
||||
export function getStripeSubscriptionPeriodEnd(subscription: Stripe.Subscription, context: { tenancyId: string }): Date | null {
|
||||
if (subscription.items.data.length === 0) {
|
||||
@ -355,17 +349,11 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
currentPeriodEnd: sanitizedDates.end,
|
||||
currentPeriodStart: sanitizedDates.start,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
// Same endedAt/canceledAt derivation as the update branch: the first
|
||||
// sync we see for a sub can already be terminal (out-of-order
|
||||
// webhooks, or a purchase-session sub whose first local write is a
|
||||
// late webhook). Without this, a terminal row is created with
|
||||
// endedAt = null, which isSubscriptionInEffect reads as "entitled
|
||||
// forever". Note: rows created before this derivation existed can
|
||||
// still hold that bad shape (terminal status, endedAt = null) and
|
||||
// only self-heal when another webhook for the same customer sweeps
|
||||
// them — a backfill migration (status IN terminal AND endedAt IS
|
||||
// NULL) is tracked as a follow-up, since it also needs the bulldozer
|
||||
// rows re-emitted.
|
||||
// Same derivation as the update branch: the first sync for a sub can
|
||||
// already be terminal (out-of-order webhooks), and a terminal row
|
||||
// created with endedAt = null reads as "entitled forever" to
|
||||
// isSubscriptionInEffect. Pre-existing rows in that shape need a
|
||||
// backfill (follow-up; also requires re-emitting bulldozer rows).
|
||||
...getEndedAtForSync(subscription, sanitizedDates.end),
|
||||
...getCanceledAtForSync(subscription),
|
||||
creationSource: "PURCHASE_PAGE"
|
||||
@ -384,20 +372,13 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
}
|
||||
|
||||
/**
|
||||
* If a paid invoice belongs to a pending-cancel subscription and was issued
|
||||
* AFTER the cancellation, the customer has just paid to keep the sub (the
|
||||
* purchase-session conflict-reuse path re-prices the winding-down sub and
|
||||
* lets the customer pay via Stripe Elements) — so clear cancel_at_period_end
|
||||
* and re-sync, which also clears the local endedAt/canceledAt wind-down
|
||||
* marks via getEndedAtForSync/getCanceledAtForSync.
|
||||
*
|
||||
* The invoice.created > canceledAt guard is what makes this safe: a
|
||||
* late-delivered invoice.paid for the ORIGINAL creation invoice predates the
|
||||
* cancel and must not undo it, and pending-cancel subs generate no renewal
|
||||
* invoices, so a paid invoice issued after canceledAt can only be the
|
||||
* re-purchase. We can't clear the flag at session creation instead — that
|
||||
* runs before payment (default_incomplete has no rollback), and a declined
|
||||
* card would silently reactivate an explicitly-canceled sub.
|
||||
* A paid invoice issued AFTER a sub's cancellation can only be the
|
||||
* purchase-session re-price of a winding-down sub (pending-cancel subs
|
||||
* generate no renewal invoices), so paying it means "keep me subscribed":
|
||||
* clear cancel_at_period_end and re-sync. The created-after-cancel guard
|
||||
* also keeps a late-delivered invoice.paid for the original creation invoice
|
||||
* from undoing a cancel. Clearing at session creation instead would
|
||||
* reactivate on declined cards (default_incomplete has no rollback).
|
||||
*/
|
||||
export async function reactivateSubscriptionIfRepurchasePaid(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) {
|
||||
if (invoice.status !== "paid") {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user