Shorten comments in payments subscription predicates

This commit is contained in:
Bilal Godil
2026-07-16 13:24:29 -07:00
parent 59ef500f7e
commit 8c6a7e77b3
6 changed files with 21 additions and 51 deletions
@@ -116,10 +116,9 @@ export const DELETE = createSmartRouteHandler({
s.productId === params.product_id && isActiveSubscription(s)
);
if (subscriptions.length === 0) {
// 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.
// Owned but no active sub: either already winding down (canceled
// with a future endedAt) or owned via OTP — don't claim the former
// is a one-time purchase.
const nowMillis = Date.now();
const windingDown = allSubs.find(s =>
s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis)
@@ -136,12 +135,9 @@ export const DELETE = createSmartRouteHandler({
for (const subscription of subscriptions) {
if (subscription.stripeSubscriptionId) {
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
// 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).
// Cancel at period end, not `subscriptions.cancel()` (immediate) —
// matches the confirm dialog and the local-sub branch below. The
// webhook syncs the local row when Stripe ends it.
await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
continue;
}
@@ -67,11 +67,9 @@ 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 until `endedAt`, and must keep rendering as a
// subscription ("Ends on <date>") rather than falling through to the
// one-time-purchase branch below.
// 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.
const nowMillis = Date.now();
const inEffectSubByProductId = new Map(
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const)
@@ -133,9 +131,7 @@ 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,
// 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.
// Already-winding-down subs can't be canceled again
is_cancelable: isActiveSubscription(sub) && !sub.cancelAtPeriodEnd,
} : null,
switch_options: switchOptions,
@@ -112,11 +112,8 @@ export const POST = createSmartRouteHandler({
customerId: params.customer_id,
});
// ownedProducts keys use '__null__' for inline products (null productId),
// so we normalize subscription productIds to match.
// 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.
// so we normalize subscription productIds to match. In-effect (not
// active): a canceled-at-period-end sub is still sub-backed, not an OTP.
const nowMillis = Date.now();
const subBackedProductIds = new Set(
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => s.productId ?? "__null__")
+7 -19
View File
@@ -117,13 +117,9 @@ export async function ensureProductIdOrInlineProduct(
// 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.
* The subscription will renew and can be acted on (canceled, switched). For
* "does this still entitle the customer" use `isSubscriptionInEffect` — a
* canceled sub keeps entitling until its future `endedAt`.
*/
export function isActiveSubscription(subscription: { status: string }): boolean {
const s = subscription.status;
@@ -131,18 +127,10 @@ export function isActiveSubscription(subscription: { status: string }): boolean
}
/**
* 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.
* The subscription still confers its product/items, regardless of whether it
* will renew. Deliberately status-agnostic and `endedAt`-based, mirroring
* Bulldozer's grant timefold (grants run from row insert until
* `endedAtMillis`; every terminal writer sets `endedAt`).
*/
export function isSubscriptionInEffect(
subscription: { endedAtMillis?: number | null, endedAt?: Date | null },
+1 -3
View File
@@ -159,9 +159,7 @@ export async function readBillingSubscriptionMapOrSkip(
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
// 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.
// grants until `endedAt`, so usage must be judged against that plan.
const nowMillis = Date.now();
const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis));
for (const planId of BASE_PLAN_IDS_BY_TIER) {
@@ -273,10 +273,7 @@ it("should report a canceled-at-period-end subscription as still in effect, and
});
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).
// Must not masquerade as a one-time purchase during the paid-through window
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
@@ -286,8 +283,6 @@ it("should report a canceled-at-period-end subscription as still in effect, and
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",