Treat canceled-at-period-end subscriptions as still in effect

Canceling a subscription without a Stripe backing (test mode, free plans)
writes status=canceled with endedAt set to the end of the current period,
so the customer keeps their entitlements until then. But every read path
classified subscriptions with isActiveSubscription (active/trialing only),
so during the paid-through window the owned product fell through to the
one-time-purchase branch: the account settings payments tab showed
"One-time purchase" with no end date and no way to tell the plan was
winding down, and plan usage reported the free plan while the paid plan's
quotas were still in effect.

Introduce isSubscriptionInEffect (status-agnostic, endedAt-based — the
same semantics Bulldozer's grant timefold and ensure-free-plan already
use) and use it at the read sites: product list classification, plan
usage resolution, and the switch route's sub-vs-OTP occupancy check.
Write sites keep isActiveSubscription so wound-down subs can't be
re-canceled or switched from. The product list now also computes
is_cancelable instead of hardcoding it, double-canceling returns a
dedicated error instead of claiming the product is an OTP, and the
Stripe cancel path uses cancel_at_period_end instead of canceling
immediately, matching the promise made by the confirmation dialog.
The account settings panels render "Ends on <date>" for winding-down
subscriptions.
This commit is contained in:
Bilal Godil 2026-07-16 13:19:07 -07:00
parent 32daff06f5
commit 59ef500f7e
9 changed files with 192 additions and 44 deletions

View File

@ -1,5 +1,5 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments";
import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription, isSubscriptionInEffect } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan";
@ -116,7 +116,17 @@ export const DELETE = createSmartRouteHandler({
s.productId === params.product_id && isActiveSubscription(s)
);
if (subscriptions.length === 0) {
// Customer owns the product but via OTP, not subscription
// Owned but with no active sub: either it's already winding down
// (canceled with a future endedAt — still in effect, not
// re-cancelable), or the customer owns it via OTP. Distinguish the
// two so a double-cancel doesn't claim the product is an OTP.
const nowMillis = Date.now();
const windingDown = allSubs.find(s =>
s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis)
);
if (windingDown) {
throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period.");
}
throw new StatusError(400, "This product is a one time purchase and cannot be canceled.");
}
}
@ -126,7 +136,13 @@ export const DELETE = createSmartRouteHandler({
for (const subscription of subscriptions) {
if (subscription.stripeSubscriptionId) {
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
await stripeClient.subscriptions.cancel(subscription.stripeSubscriptionId);
// Cancel at period end (not `subscriptions.cancel()`, which ends the
// sub immediately) — the confirm dialog promises the customer keeps
// what they paid for until the period ends, and the local-sub branch
// below implements the same semantics via a future `endedAt`. The
// Stripe webhook syncs the local row (and flips it to `canceled`
// with `endedAt` set when Stripe ends it at the period boundary).
await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
continue;
}
await prisma.subscription.update({

View File

@ -1,4 +1,4 @@
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments";
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionInEffect, productToInlineProduct } from "@/lib/payments";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@ -65,11 +65,16 @@ export const GET = createSmartRouteHandler({
customerId: params.customer_id,
}),
]);
// Deprecated: map productId → active subscription for backward-compat fields.
// Deprecated: map productId → subscription for backward-compat fields.
// ownedProducts keys use '__null__' for inline products (null productId),
// so we normalize subscription productIds to match.
const activeSubByProductId = new Map(
Object.values(subMap).filter(s => isActiveSubscription(s)).map(s => [s.productId ?? "__null__", s] as const)
// In-effect (not active): a canceled-at-period-end sub still backs its
// owned product until `endedAt`, and must keep rendering as a
// subscription ("Ends on <date>") rather than falling through to the
// one-time-purchase branch below.
const nowMillis = Date.now();
const inEffectSubByProductId = new Map(
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const)
);
// Build switch options per product line (available plan upgrades/downgrades)
@ -108,7 +113,7 @@ export const GET = createSmartRouteHandler({
? (switchOptionsByProductLineId.get(productLineId) ?? []).filter((option) => option.product_id !== productId)
: undefined;
// Deprecated fields for backward compat
const sub = activeSubByProductId.get(productId);
const sub = inEffectSubByProductId.get(productId);
const type = sub ? "subscription" as const : "one_time" as const;
return {
@ -128,7 +133,10 @@ export const GET = createSmartRouteHandler({
subscription_id: sub.id,
current_period_end: sub.currentPeriodEndMillis ? new Date(sub.currentPeriodEndMillis).toISOString() : null,
cancel_at_period_end: sub.cancelAtPeriodEnd,
is_cancelable: true,
// A sub that's already winding down (locally canceled, or a
// Stripe sub with cancel_at_period_end set) can't be canceled
// again — the DELETE route would 400 on it anyway.
is_cancelable: isActiveSubscription(sub) && !sub.cancelAtPeriodEnd,
} : null,
switch_options: switchOptions,
},

View File

@ -1,4 +1,4 @@
import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, grantProductToCustomer, isActiveSubscription, isAddOnProduct } from "@/lib/payments";
import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionInEffect } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
@ -113,14 +113,19 @@ export const POST = createSmartRouteHandler({
});
// ownedProducts keys use '__null__' for inline products (null productId),
// so we normalize subscription productIds to match.
const activeSubProductIds = new Set(
Object.values(subMap).filter(s => isActiveSubscription(s)).map(s => s.productId ?? "__null__")
// In-effect (not active): this set classifies owned products as
// sub-backed vs OTP. A canceled-at-period-end sub still backs its
// product until `endedAt`; treating it as an OTP here would wrongly
// block switching for the rest of the paid-through window.
const nowMillis = Date.now();
const subBackedProductIds = new Set(
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => s.productId ?? "__null__")
);
const hasOtpInProductLine = Object.entries(ownedProducts).some(
([productId, p]) => productId !== body.from_product_id
&& p.productLineId === fromProduct.productLineId
&& p.quantity > 0
&& !activeSubProductIds.has(productId)
&& !subBackedProductIds.has(productId)
);
if (hasOtpInProductLine) {
throw new StatusError(400, "Customer already has a one-time purchase in this product line");

View File

@ -116,11 +116,44 @@ export async function ensureProductIdOrInlineProduct(
// getCustomerPurchaseContext, OwnedProduct type, getOwnedProductsForCustomerLegacy
// were removed. All reads now go through customer-data.ts backed by Bulldozer.
/**
* Lifecycle/actionability predicate: the subscription is in a state where it
* will renew and can be acted on (canceled, switched). This is deliberately
* NOT the same question as "does the customer currently have the product"
* a locally-canceled sub keeps entitling the customer until `endedAt` (we
* model cancel-at-period-end as `status: canceled` + future `endedAt`). Use
* `isSubscriptionInEffect` for entitlement questions; using this one there
* makes still-paid-for products masquerade as one-time purchases.
*/
export function isActiveSubscription(subscription: { status: string }): boolean {
const s = subscription.status;
return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing;
}
/**
* Entitlement predicate: the subscription still confers its product/items on
* the customer, regardless of whether it will renew. Mirrors Bulldozer's
* grant semantics (subscription-start emits on row insert regardless of
* status; subscription-end emits at `endedAtMillis`), which is why it is
* deliberately status-agnostic: `canceled` subs stay in effect until their
* future `endedAt` passes, and `incomplete`/`past_due`/`unpaid` subs that
* arrive mid-Stripe-flow count too (they either transition to `active` or to
* a terminal status with `endedAt` set every terminal writer sets
* `endedAt`, so `endedAt == null` means "no end scheduled").
*
* Accepts both row shapes so Bulldozer reads (`endedAtMillis`) and Prisma
* reads (`endedAt`) can share the one definition.
*/
export function isSubscriptionInEffect(
subscription: { endedAtMillis?: number | null, endedAt?: Date | null },
nowMillis: number,
): boolean {
const endedAtMillis = subscription.endedAtMillis != null
? subscription.endedAtMillis
: subscription.endedAt != null ? subscription.endedAt.getTime() : null;
return endedAtMillis == null || endedAtMillis > nowMillis;
}
/**
* True when the given product config / snapshot declares itself as an add-on
* to one or more other products. Add-ons share a product line with their base

View File

@ -1,5 +1,5 @@
import { CustomerType, PrismaClient, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client";
import { isAddOnProduct } from "@/lib/payments";
import { isAddOnProduct, isSubscriptionInEffect } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import type { ProductSnapshot } from "@/lib/payments/schema/types";
@ -146,17 +146,10 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi
// Snapshot-based "occupies the free plan's product line" predicate. We
// treat a sub as occupying the line iff its captured product snapshot
// lives in that line, isn't an add-on, and HASN'T ENDED YET (endedAt in
// the future or absent). Crucially we do NOT gate on `status` —
// `incomplete` / `past_due` / `unpaid` subs that arrive mid-Stripe-flow
// still reserve the line (they will either transition to `active` or to
// a terminal status with `endedAt` set), and this matches the semantics
// that `ownedProducts` derives via the Subscription TimeFold (see
// `subscription-timefold-algo.ts` — `subscription-start` emits on row
// insert regardless of status; `subscription-end` emits at
// `endedAtMillis`). Treating only active/trialing as occupying would
// (and did) cause the free plan to be double-granted on top of a
// just-created incomplete paid sub.
// lives in that line, isn't an add-on, and is still in effect
// (`isSubscriptionInEffect` — status-agnostic, endedAt-based; see its doc
// comment for why gating on `status` here would (and did) cause the free
// plan to be double-granted on top of a just-created incomplete paid sub).
const nowMillis = Date.now();
const productLineStillOccupiedBy = (sub: {
product: ProductSnapshot,
@ -165,10 +158,7 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi
}): boolean => {
if (sub.product.productLineId !== freeProductLineId) return false;
if (isAddOnProduct(sub.product)) return false;
const endedAtMillis = sub.endedAtMillis != null
? sub.endedAtMillis
: sub.endedAt != null ? sub.endedAt.getTime() : null;
return endedAtMillis == null || endedAtMillis > nowMillis;
return isSubscriptionInEffect(sub, nowMillis);
};
// Fast path: read the customer's synchronous subscription LFold. Note

View File

@ -1,7 +1,7 @@
import { VerificationCodeType } from "@/generated/prisma/client";
import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse";
import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { isActiveSubscription } from "@/lib/payments";
import { isSubscriptionInEffect } from "@/lib/payments";
import {
arePlanLimitsEnforced,
getBillingTeamId,
@ -158,9 +158,14 @@ export async function readBillingSubscriptionMapOrSkip(
}
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription);
// In-effect (not active): a canceled-at-period-end plan sub keeps its item
// grants until `endedAt`, so usage limits must be judged against that plan
// — otherwise this page reports "Free" while the customer's actual quotas
// are still the paid plan's.
const nowMillis = Date.now();
const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis));
for (const planId of BASE_PLAN_IDS_BY_TIER) {
const subscription = activeSubscriptions.find((candidate) => candidate.productId === planId);
const subscription = inEffectSubscriptions.find((candidate) => candidate.productId === planId);
if (subscription != null) {
return subscription;
}

View File

@ -366,12 +366,18 @@ function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, cust
const isCancelable = isSubscription && !!product.subscription?.isCancelable;
const canSwitchPlans = isSubscription && defaultPaymentMethod && !!product.id && (product.switchOptions?.length ?? 0) > 0;
const renewsAt = isSubscription ? (product.subscription?.currentPeriodEnd ?? null) : null;
const endsAtPeriodEnd = isSubscription && !!product.subscription?.cancelAtPeriodEnd;
const formattedPeriodEnd = renewsAt
? new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)
: null;
const subtitle =
product.type === "one_time"
? "One-time purchase"
: renewsAt
? `Renews on ${new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)}`
: "Subscription";
: endsAtPeriodEnd
? (formattedPeriodEnd ? `Ends on ${formattedPeriodEnd}` : "Ends at the end of the billing period")
: formattedPeriodEnd
? `Renews on ${formattedPeriodEnd}`
: "Subscription";
return (
<div key={product.id ?? `${product.displayName}-${index}`} className="flex items-center justify-between gap-4 p-3 bg-zinc-50/50 dark:bg-zinc-900/50 border border-black/[0.04] dark:border-white/[0.04] rounded-xl">

View File

@ -221,8 +221,13 @@ it("should allow a signed-in user to cancel their own subscription product", asy
"stackable": false,
},
"quantity": 1,
"subscription": null,
"type": "one_time",
"subscription": {
"cancel_at_period_end": true,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": false,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
],
"pagination": { "next_cursor": null },
@ -232,6 +237,70 @@ it("should allow a signed-in user to cancel their own subscription product", asy
`);
});
it("should report a canceled-at-period-end subscription as still in effect, and reject a second cancel", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await configureProduct({
products: {
"pro-plan": {
displayName: "Pro Plan",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
});
const { userId } = await Auth.fastSignUp();
await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: {
product_id: "pro-plan",
},
});
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/pro-plan`, {
method: "DELETE",
accessType: "client",
});
expect(cancelResponse.status).toBe(200);
// The product must NOT masquerade as a one-time purchase during the
// paid-through window (it used to: the list route only recognized
// active/trialing subs, so a canceled-with-future-endedAt sub fell through
// to the OTP branch).
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
expect(listResponse.status).toBe(200);
const item = (listResponse.body as { items: Array<{ id: string, type: string, subscription: { cancel_at_period_end: boolean, is_cancelable: boolean } | null }> }).items.find((i) => i.id === "pro-plan");
expect(item?.type).toBe("subscription");
expect(item?.subscription?.cancel_at_period_end).toBe(true);
expect(item?.subscription?.is_cancelable).toBe(false);
// Canceling again must not claim the product is a one-time purchase, nor
// silently rewrite the wind-down state.
const secondCancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/pro-plan`, {
method: "DELETE",
accessType: "client",
});
expect(secondCancelResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "This subscription is already canceled and ends at the end of the current billing period.",
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should reject a client canceling someone else's subscription product", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
@ -401,8 +470,13 @@ it("should cancel all stackable subscription quantities", async ({ expect }) =>
"stackable": true,
},
"quantity": 2,
"subscription": null,
"type": "one_time",
"subscription": {
"cancel_at_period_end": true,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": false,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
],
"pagination": { "next_cursor": null },
@ -1018,8 +1092,13 @@ it("should allow canceling an inline product subscription via subscription_id",
"stackable": false,
},
"quantity": 1,
"subscription": null,
"type": "one_time",
"subscription": {
"cancel_at_period_end": true,
"current_period_end": <stripped field 'current_period_end'>,
"is_cancelable": false,
"subscription_id": "<stripped UUID>",
},
"type": "subscription",
},
],
"pagination": { "next_cursor": null },

View File

@ -354,12 +354,18 @@ function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, cust
const isCancelable = isSubscription && !!product.subscription?.isCancelable;
const canSwitchPlans = isSubscription && defaultPaymentMethod && !!product.id && (product.switchOptions?.length ?? 0) > 0;
const renewsAt = isSubscription ? (product.subscription?.currentPeriodEnd ?? null) : null;
const endsAtPeriodEnd = isSubscription && !!product.subscription?.cancelAtPeriodEnd;
const formattedPeriodEnd = renewsAt
? new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)
: null;
const subtitle =
product.type === "one_time"
? t("One-time purchase")
: renewsAt
? `${t("Renews on")} ${new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)}`
: t("Subscription");
: endsAtPeriodEnd
? (formattedPeriodEnd ? `${t("Ends on")} ${formattedPeriodEnd}` : t("Ends at the end of the billing period"))
: formattedPeriodEnd
? `${t("Renews on")} ${formattedPeriodEnd}`
: t("Subscription");
return (
<div key={product.id ?? `${product.displayName}-${index}`} className="flex items-start justify-between gap-4">