This commit is contained in:
BilalG1 2026-07-19 00:01:54 -07:00 committed by GitHub
commit 8e2f57002a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 778 additions and 104 deletions

View File

@ -0,0 +1,19 @@
-- Terminal subscription rows written before endedAt was derived on every sync
-- (and terminal-on-first-sync rows, whose create path never set it) have
-- endedAt = NULL. The status-agnostic isSubscriptionInEffect predicate reads
-- NULL as "entitled forever", so these rows must be closed out.
--
-- endedAt = LEAST(currentPeriodEnd, now) mirrors getEndedAtForSync's
-- fallback when Stripe omits ended_at: the paid-through boundary if it's
-- already past, otherwise now. NOW() AT TIME ZONE 'UTC' yields a naive UTC
-- timestamp matching the timezone-less columns (which the app always writes
-- as UTC); bare NOW() would be shifted by the session offset on non-UTC
-- sessions when cast into "endedAt".
--
-- No batching/temp-index machinery: the affected population was measured in
-- production at 7 rows (2026-07-17), and a plain UPDATE only takes row locks
-- on matching rows, so the seq scan is safe regardless of table size.
UPDATE "Subscription"
SET "endedAt" = LEAST("currentPeriodEnd", NOW() AT TIME ZONE 'UTC')
WHERE "status" IN ('canceled', 'incomplete_expired', 'unpaid')
AND "endedAt" IS NULL;

View File

@ -0,0 +1,71 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
const insertSubscription = async (sql: Sql, options: {
tenancyId: string,
status: string,
currentPeriodEnd: Date,
endedAt?: Date | null,
}) => {
const id = randomUUID();
await sql`
INSERT INTO "Subscription" (
"id", "tenancyId", "customerId", "customerType", "product", "quantity",
"stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd",
"cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt"
) VALUES (
${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM',
${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1,
${`sub_${id}`}, ${options.status}::"SubscriptionStatus",
'2026-01-01', ${options.currentPeriodEnd},
false, ${options.endedAt ?? null}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC'
)
`;
return id;
};
export const preMigration = async (sql: Sql) => {
const tenancyId = randomUUID();
const pastPeriodEnd = new Date('2026-02-01T00:00:00Z');
// One row per terminal status, all with null endedAt and a past period end.
const canceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: pastPeriodEnd });
const incompleteExpiredId = await insertSubscription(sql, { tenancyId, status: 'incomplete_expired', currentPeriodEnd: pastPeriodEnd });
const unpaidId = await insertSubscription(sql, { tenancyId, status: 'unpaid', currentPeriodEnd: pastPeriodEnd });
// Terminal row whose period end is in the future: endedAt must be capped at
// the migration's NOW() (matching getEndedAtForSync's last-resort fallback),
// not scheduled for the future.
const futurePeriodEnd = new Date('2100-01-01T00:00:00Z');
const futureCanceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: futurePeriodEnd });
return { tenancyId, canceledId, incompleteExpiredId, unpaidId, futureCanceledId };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
// All comparisons stay server-side in the naive-UTC domain (createdAt was
// inserted with NOW() AT TIME ZONE 'UTC', matching the migration's write) —
// values never round-trip through JS Dates, whose parse/serialize is
// tz-offset-asymmetric for timezone-less columns, and no JS clock is
// compared against the server clock.
const rows = await sql`
SELECT
"id",
("endedAt" IS NOT NULL) AS "hasEndedAt",
("endedAt" = "currentPeriodEnd") AS "matchesPeriodEnd",
("endedAt" >= "createdAt" AND "endedAt" <= NOW() AT TIME ZONE 'UTC' AND "endedAt" < "currentPeriodEnd") AS "cappedAtNow"
FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid
`;
const byId = new Map(rows.map((row) => [row.id, row]));
// Past period end: backfilled to exactly currentPeriodEnd.
for (const id of [ctx.canceledId, ctx.incompleteExpiredId, ctx.unpaidId]) {
expect(byId.get(id)?.hasEndedAt).toBe(true);
expect(byId.get(id)?.matchesPeriodEnd).toBe(true);
}
// Future period end: capped at the migration's NOW(), not scheduled for 2100.
expect(byId.get(ctx.futureCanceledId)?.hasEndedAt).toBe(true);
expect(byId.get(ctx.futureCanceledId)?.cappedAtNow).toBe(true);
};

View File

@ -0,0 +1,60 @@
import { randomUUID } from 'crypto';
import type { Sql } from 'postgres';
import { expect } from 'vitest';
const insertSubscription = async (sql: Sql, options: {
tenancyId: string,
status: string,
endedAt: Date | null,
}) => {
const id = randomUUID();
await sql`
INSERT INTO "Subscription" (
"id", "tenancyId", "customerId", "customerType", "product", "quantity",
"stripeSubscriptionId", "status", "currentPeriodStart", "currentPeriodEnd",
"cancelAtPeriodEnd", "endedAt", "creationSource", "createdAt", "updatedAt"
) VALUES (
${id}::uuid, ${options.tenancyId}::uuid, ${`customer-${id}`}, 'TEAM',
${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1,
${`sub_${id}`}, ${options.status}::"SubscriptionStatus",
'2026-01-01', '2026-02-01',
false, ${options.endedAt}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC'
)
`;
return id;
};
export const preMigration = async (sql: Sql) => {
const tenancyId = randomUUID();
const existingEndedAt = new Date('2026-01-15T12:00:00Z');
// Non-terminal rows with null endedAt: live subscriptions, must stay null.
const activeId = await insertSubscription(sql, { tenancyId, status: 'active', endedAt: null });
const trialingId = await insertSubscription(sql, { tenancyId, status: 'trialing', endedAt: null });
// `incomplete` is non-terminal (Stripe expires it to incomplete_expired
// within ~24h, which arrives via webhook) — deliberately not backfilled.
const incompleteId = await insertSubscription(sql, { tenancyId, status: 'incomplete', endedAt: null });
const pastDueId = await insertSubscription(sql, { tenancyId, status: 'past_due', endedAt: null });
// Terminal row whose endedAt is already set: must keep its original value.
const alreadyEndedId = await insertSubscription(sql, { tenancyId, status: 'canceled', endedAt: existingEndedAt });
return { tenancyId, activeId, trialingId, incompleteId, pastDueId, alreadyEndedId, existingEndedAt };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
// Compare the already-set endedAt in SQL against the same JS Date param used
// at insert time — both cross the driver's tz-naive conversion identically,
// so equality holds regardless of the session time zone.
const rows = await sql`
SELECT "id", "endedAt", ("endedAt" = ${ctx.existingEndedAt}) AS "matchesOriginal"
FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid
`;
const byId = new Map(rows.map((row) => [row.id, row]));
for (const id of [ctx.activeId, ctx.trialingId, ctx.incompleteId, ctx.pastDueId]) {
expect(byId.get(id)?.endedAt).toBeNull();
}
expect(byId.get(ctx.alreadyEndedId)?.matchesOriginal).toBe(true);
};

View File

@ -7,7 +7,7 @@ import { REFUND_TXN_PREFIX } from "@/lib/payments/refund-txn-id";
import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
import { ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX } from "@/lib/payments/transaction-entry-indexes";
import type { ManualTransactionRow, TransactionEntryData } from "@/lib/payments/schema/types";
import { getStripeForAccount } from "@/lib/stripe";
import { getStripeForAccount, getStripeSubscriptionPeriodEnd } from "@/lib/stripe";
import type { Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@ -686,6 +686,7 @@ async function handleSubscriptionRefund(options: {
} else if (endAtPeriodEnd) {
// End at period end. Items follow natural lifecycle when sub-end fires
// at period boundary.
let stripePeriodEnd: Date | null = null;
if (!isTestMode && subscription.stripeSubscriptionId) {
const stripe = await getStripeForAccount({ tenancy });
// Idempotent guard, mirroring the endNow branch. The end-at-period-end
@ -697,9 +698,12 @@ async function handleSubscriptionRefund(options: {
// `bulldozerWriteManualTransaction` commits the ledger row, leaving the
// customer refunded with no record.
try {
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
const updated = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
// Same as the cancel route: Stripe's response is the authority on
// the boundary; the local row can be stale around a renewal
stripePeriodEnd = getStripeSubscriptionPeriodEnd(updated, { tenancyId: tenancy.id });
} catch (e: unknown) {
if (!isStripeSubscriptionAlreadyTerminalError(e)) {
throw e;
@ -711,7 +715,7 @@ async function handleSubscriptionRefund(options: {
data: {
cancelAtPeriodEnd: true,
canceledAt: subscription.canceledAt ?? now,
endedAt: subscription.endedAt ?? subscription.currentPeriodEnd,
endedAt: subscription.endedAt ?? stripePeriodEnd ?? subscription.currentPeriodEnd,
},
});
}

View File

@ -1,10 +1,10 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments";
import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isSubscriptionCancelable, 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";
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getStripeForAccount } from "@/lib/stripe";
import { getStripeForAccount, getStripeSubscriptionPeriodEnd } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
@ -80,13 +80,25 @@ export const DELETE = createSmartRouteHandler({
});
const allSubs = Object.values(subMap);
const nowMillis = Date.now();
// 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;
if (inEffect.status === "canceled" || inEffect.cancelAtPeriodEnd) {
throw new StatusError(400, "This subscription is already canceled and ends at the end of the current billing period.");
}
throw new StatusError(400, "This subscription cannot be canceled in its current state.");
};
let subscriptions;
if (query.subscription_id) {
// Cancel by subscription DB ID (used for inline products that have no product_id)
subscriptions = allSubs.filter(s =>
s.id === query.subscription_id && isActiveSubscription(s)
);
const matching = allSubs.filter(s => s.id === query.subscription_id);
subscriptions = matching.filter(s => isSubscriptionCancelable(s));
if (subscriptions.length === 0) {
throwIfUncancelableButInEffect(matching);
throw new StatusError(400, "No active subscription found with this ID for the given customer.");
}
} else {
@ -111,12 +123,13 @@ export const DELETE = createSmartRouteHandler({
throw new StatusError(400, "Customer does not have this product.");
}
// Find the active subscription to cancel
subscriptions = allSubs.filter(s =>
s.productId === params.product_id && isActiveSubscription(s)
);
// Find the cancelable subscriptions for this product
const matching = allSubs.filter(s => s.productId === params.product_id);
subscriptions = matching.filter(s => isSubscriptionCancelable(s));
if (subscriptions.length === 0) {
// Customer owns the product but via OTP, not subscription
// Owned but nothing cancelable: winding down / uncancelable state,
// or owned via OTP — don't claim the former is a one-time purchase.
throwIfUncancelableButInEffect(matching);
throw new StatusError(400, "This product is a one time purchase and cannot be canceled.");
}
}
@ -124,29 +137,49 @@ export const DELETE = createSmartRouteHandler({
const hasStripeSubscription = subscriptions.some((subscription) => subscription.stripeSubscriptionId);
const stripe = hasStripeSubscription ? await getStripeForAccount({ tenancy: auth.tenancy }) : undefined;
for (const subscription of subscriptions) {
let updatedSub;
if (subscription.stripeSubscriptionId) {
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
await stripeClient.subscriptions.cancel(subscription.stripeSubscriptionId);
continue;
}
await prisma.subscription.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: subscription.id,
// Cancel at period end, not `subscriptions.cancel()` (immediate) —
// 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 });
// 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({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: subscription.id,
},
},
},
data: {
status: SubscriptionStatus.canceled,
cancelAtPeriodEnd: true,
canceledAt: new Date(),
endedAt: new Date(subscription.currentPeriodEndMillis),
},
});
data: {
cancelAtPeriodEnd: true,
canceledAt: new Date(),
endedAt,
},
});
} else {
updatedSub = await prisma.subscription.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: subscription.id,
},
},
data: {
status: SubscriptionStatus.canceled,
cancelAtPeriodEnd: true,
canceledAt: new Date(),
endedAt: new Date(subscription.currentPeriodEndMillis),
},
});
}
// dual write - prisma and bulldozer
const updatedSub = await prisma.subscription.findUniqueOrThrow({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: subscription.id } },
});
await bulldozerWriteSubscription(updatedSub);
}

View File

@ -1,5 +1,6 @@
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments";
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isAddOnProduct, isSubscriptionCancelable, isSubscriptionInEffect, productToInlineProduct, subscriptionDisplayRank } from "@/lib/payments";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import type { SubscriptionRow } from "@/lib/payments/schema/types";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
@ -65,12 +66,22 @@ 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)
);
// 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)) {
if (!isSubscriptionInEffect(s, nowMillis)) continue;
const key = s.productId ?? "__null__";
const existing = inEffectSubByProductId.get(key);
if (existing == null || subscriptionDisplayRank(s) > subscriptionDisplayRank(existing)) {
inEffectSubByProductId.set(key, s);
}
}
// Build switch options per product line (available plan upgrades/downgrades)
const switchOptionsByProductLineId = new Map<string, Array<{ product_id: string, product: ReturnType<typeof productToInlineProduct> }>>();
@ -108,7 +119,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 +139,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,
is_cancelable: true,
is_cancelable: isSubscriptionCancelable(sub),
} : 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";
@ -112,15 +112,17 @@ export const POST = createSmartRouteHandler({
customerId: params.customer_id,
});
// 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__")
// 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__")
);
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");
@ -265,6 +267,10 @@ export const POST = createSmartRouteHandler({
payment_behavior: "error_if_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
default_payment_method: resolvedPaymentMethodId,
// 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,
price_data: {
@ -308,6 +314,10 @@ export const POST = createSmartRouteHandler({
currentPeriodStart: sanitizedUpdateDates.start,
currentPeriodEnd: sanitizedUpdateDates.end,
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
// Clear the eagerly-written wind-down marks; the webhook sync
// reconciles these anyway, this just covers the pre-webhook window
canceledAt: null,
endedAt: null,
},
});
// dual write - prisma and bulldozer

View File

@ -117,6 +117,31 @@ export const POST = createSmartRouteHandler({
if (conflictingSubscriptions.length > 0) {
const conflicting = conflictingSubscriptions[0];
if (conflicting.stripeSubscriptionId) {
// Reject purchases whose conflicting Stripe sub is winding down
// (canceled-at-period-end but still paid through). The branches below
// reuse that same Stripe sub — either re-pricing it in place
// (`default_incomplete`, paid later via Elements) or canceling it
// immediately for a one-time replacement — and neither is safe here:
// - We can't clear `cancel_at_period_end` at session creation:
// that runs before payment and `default_incomplete` has no
// rollback, so a declined card would silently reactivate an
// explicitly-canceled sub while leaving it re-priced.
// - We can't leave the flag set either: Stripe would still end the
// sub at the period boundary AFTER the customer paid the re-price
// invoice — they'd pay and lose the product anyway.
// Future fix (deliberately deferred, see PR #1773): clear
// `cancel_at_period_end` from the `invoice.paid` webhook once the
// re-price invoice is actually paid. Pending-cancel subs generate no
// renewal invoices, so a paid invoice with `invoice.created` after
// the sub's `canceledAt` can only be the re-purchase (and a
// late-delivered `invoice.paid` for the original creation invoice
// predates the cancel, so it can't undo it). Until then, wind-down
// replacements only work for non-Stripe subs (test mode / free
// plans), which are fully ended and re-created below instead of
// reused.
if (conflicting.status === "canceled" || conflicting.cancelAtPeriodEnd) {
throw new StatusError(400, "The current subscription is already canceled and remains active until the end of the billing period. This product can be purchased after the current subscription ends.");
}
const existingStripeSub = await stripe.subscriptions.retrieve(conflicting.stripeSubscriptionId);
const existingItem = existingStripeSub.items.data[0];
const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" });

View File

@ -1,7 +1,7 @@
import { KnownErrors } from '@hexclave/shared';
import { generateUuid } from '@hexclave/shared/dist/utils/uuids';
import { describe, expect, it } from 'vitest';
import { validatePurchaseSession } from './payments';
import { isSubscriptionCancelable, isSubscriptionInEffect, subscriptionDisplayRank, validatePurchaseSession } from './payments';
import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { globalPrismaClient } from "@/prisma-client";
@ -233,5 +233,75 @@ describe.sequential('validatePurchaseSession - purchase guards (real DB)', () =>
{ priceId: 'nonexistent' },
)).rejects.toThrowError('Price not found');
});
it('treats a canceled-at-period-end sub in the same line as a replaceable conflict, not an OTP', async () => {
const lineId = `line-winddown-${testId}`;
const subId = generateUuid();
const now = new Date();
const periodEnd = new Date(now.getTime() + 86400000);
const product = makeProduct({ productLineId: lineId });
await prisma.subscription.create({
data: {
id: subId, tenancyId, customerId, customerType: 'CUSTOM',
productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1,
stripeSubscriptionId: null, status: 'canceled',
currentPeriodStart: now, currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd,
creationSource: 'TEST_MODE',
},
});
await bulldozerWriteSubscription({
id: subId, tenancyId, customerId, customerType: 'CUSTOM',
productId: `prod-winddown-${testId}`, priceId: null, product: product as any, quantity: 1,
stripeSubscriptionId: null, status: 'canceled',
currentPeriodStart: now, currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: true, canceledAt: now, endedAt: periodEnd, refundedAt: null, productRevokedAt: null,
creationSource: 'TEST_MODE', createdAt: now,
});
const res = await callValidate(product, { productId: `prod-after-winddown-${testId}` });
expect(res.conflictingSubscriptions.map(s => s.id)).toContain(subId);
});
});
describe('isSubscriptionInEffect', () => {
const nowMillis = 1_800_000_000_000;
it('treats endedAt=null as in effect regardless of status', () => {
expect(isSubscriptionInEffect({ endedAtMillis: null }, nowMillis)).toBe(true);
expect(isSubscriptionInEffect({ endedAt: null }, nowMillis)).toBe(true);
});
it('is in effect strictly before endedAt, not at or after it', () => {
expect(isSubscriptionInEffect({ endedAtMillis: nowMillis + 1 }, nowMillis)).toBe(true);
expect(isSubscriptionInEffect({ endedAtMillis: nowMillis }, nowMillis)).toBe(false);
expect(isSubscriptionInEffect({ endedAtMillis: nowMillis - 1 }, nowMillis)).toBe(false);
});
it('accepts the Prisma Date shape', () => {
expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis + 1) }, nowMillis)).toBe(true);
expect(isSubscriptionInEffect({ endedAt: new Date(nowMillis) }, nowMillis)).toBe(false);
});
});
describe('isSubscriptionCancelable', () => {
it('is true only for active/trialing subs not already winding down', () => {
expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: false })).toBe(true);
expect(isSubscriptionCancelable({ status: 'trialing', cancelAtPeriodEnd: false })).toBe(true);
expect(isSubscriptionCancelable({ status: 'active', cancelAtPeriodEnd: true })).toBe(false);
expect(isSubscriptionCancelable({ status: 'canceled', cancelAtPeriodEnd: true })).toBe(false);
expect(isSubscriptionCancelable({ status: 'past_due', cancelAtPeriodEnd: false })).toBe(false);
});
});
describe('subscriptionDisplayRank', () => {
it('ranks cancelable above pending-cancel Stripe subs above wound-down subs', () => {
const cancelable = { status: 'active', cancelAtPeriodEnd: false };
// 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));
expect(subscriptionDisplayRank(stripePendingCancel)).toBeGreaterThan(subscriptionDisplayRank(localWindingDown));
});
});

View File

@ -116,11 +116,51 @@ export async function ensureProductIdOrInlineProduct(
// getCustomerPurchaseContext, OwnedProduct type, getOwnedProductsForCustomerLegacy
// were removed. All reads now go through customer-data.ts backed by Bulldozer.
/**
* 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;
return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing;
}
/**
* 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;
}
/**
* 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;
}
/**
* 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 },
nowMillis: number,
): boolean {
const endedAtMillis = "endedAtMillis" in subscription
? 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
@ -385,7 +425,7 @@ export async function validatePurchaseSession(options: {
quantity: number,
}): Promise<{
selectedPrice: SelectedPrice | undefined,
conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null }>,
conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null, status: SubscriptionStatus, cancelAtPeriodEnd: boolean }>,
}> {
const { prisma, tenancyId, customerType, customerId, product, productId, priceId, quantity } = options;
@ -431,7 +471,7 @@ export async function validatePurchaseSession(options: {
// conflict.
// TODO: swap this for bulldozer read when it's consistent
// Read subs from Prisma (not the bulldozer view) — the view can lag, so a duplicate request would otherwise miss the just-granted sub.
let conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null }> = [];
let conflictingSubscriptions: Array<{ id: string, stripeSubscriptionId: string | null, status: SubscriptionStatus, cancelAtPeriodEnd: boolean }> = [];
const productLineId = product.productLineId;
const addOnBaseProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : [];
const isStackableSelfMatch = (pid: string) =>
@ -441,14 +481,21 @@ 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.
const activeSubs = await prisma.subscription.findMany({
// 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,
customerType: typedToUppercase(customerType),
customerId,
status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] },
OR: [
{ status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] } },
{ status: SubscriptionStatus.canceled, endedAt: { gt: new Date() } },
],
},
select: { id: true, stripeSubscriptionId: true, productId: true, product: true },
select: { id: true, stripeSubscriptionId: true, productId: true, product: true, status: true, cancelAtPeriodEnd: true },
});
if (productId != null && product.stackable !== true) {
const activeOtp = await prisma.oneTimePurchase.findFirst({
@ -462,32 +509,32 @@ export async function validatePurchaseSession(options: {
},
select: { id: true },
});
if (activeOtp || activeSubs.some(s => s.productId === productId)) {
if (activeOtp || replaceableSubs.some(s => s.productId === productId)) {
throw new KnownErrors.ProductAlreadyGranted(productId, customerId);
}
}
if (productLineId) {
conflictingSubscriptions = activeSubs
conflictingSubscriptions = replaceableSubs
.filter(s =>
(s.product as Product).productLineId === productLineId
&& !addOnBaseProductIds.includes(s.productId ?? "")
&& !isStackableSelfMatch(s.productId ?? ""),
)
.map(s => ({ id: s.id, stripeSubscriptionId: s.stripeSubscriptionId }));
.map(s => ({ id: s.id, stripeSubscriptionId: s.stripeSubscriptionId, status: s.status, cancelAtPeriodEnd: s.cancelAtPeriodEnd }));
// If ownedProducts shows a same-line holding but there's no active subscription
// backing it, the customer owns via OTP — which can't be replaced by a switch.
// (We still rely on bulldozer here because OTPs aren't part of the duplicate-
// request race we're guarding above and OTP propagation lag is low-stakes.)
const activeSubProductIds = new Set(activeSubs.map(s => s.productId ?? "__null__"));
const subBackedProductIds = new Set(replaceableSubs.map(s => s.productId ?? "__null__"));
const otpInProductLine = Object.entries(ownedProducts).some(
([pid, p]) =>
p.productLineId === productLineId
&& p.quantity > 0
&& !addOnBaseProductIds.includes(pid)
&& !isStackableSelfMatch(pid)
&& !activeSubProductIds.has(pid),
&& !subBackedProductIds.has(pid),
);
if (otpInProductLine && conflictingSubscriptions.length === 0) {
// TODO: reconsider the coupling here between products and purchases. OTPs can

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,29 +146,15 @@ 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,
endedAtMillis?: number | null,
endedAt?: Date | null,
}): boolean => {
const productLineStillOccupiedBy = (sub: { product: ProductSnapshot } & ({ endedAtMillis: number | null } | { endedAt: Date | null })): 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,6 +1,6 @@
import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans";
import type { SubscriptionRow } from "./payments/schema/types";
import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip } from "./plan-usage";
import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip, resolveInEffectPlanSubscription } from "./plan-usage";
import { describe, expect, it } from "vitest";
function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow {
@ -189,3 +189,46 @@ describe("billing period selection", () => {
`);
});
});
describe("resolveInEffectPlanSubscription", () => {
// Fully deterministic now that the resolver takes the clock as a parameter
const nowMillis = 1_800_000_000_000;
const futureMillis = nowMillis + 30 * 86400000;
const pastMillis = nowMillis - 86400000;
it("keeps resolving a canceled-at-period-end plan sub until its endedAt passes", () => {
const windingDownTeam: SubscriptionRow = {
...createSubscriptionPeriod(pastMillis, futureMillis),
id: "sub_team",
productId: "team",
status: "canceled",
cancelAtPeriodEnd: true,
canceledAtMillis: pastMillis,
endedAtMillis: futureMillis,
};
const activeFree: SubscriptionRow = {
...createSubscriptionPeriod(pastMillis, futureMillis),
id: "sub_free",
productId: "free",
};
expect(resolveInEffectPlanSubscription({ sub_team: windingDownTeam, sub_free: activeFree }, nowMillis)?.id).toBe("sub_team");
});
it("falls back to the free plan sub once the canceled sub has ended", () => {
const endedTeam: SubscriptionRow = {
...createSubscriptionPeriod(pastMillis - 86400000, pastMillis),
id: "sub_team",
productId: "team",
status: "canceled",
cancelAtPeriodEnd: true,
canceledAtMillis: pastMillis - 86400000,
endedAtMillis: pastMillis,
};
const activeFree: SubscriptionRow = {
...createSubscriptionPeriod(pastMillis, futureMillis),
id: "sub_free",
productId: "free",
};
expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree }, nowMillis)?.id).toBe("sub_free");
});
});

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,
@ -157,10 +157,12 @@ export async function readBillingSubscriptionMapOrSkip(
}
}
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription);
export function resolveInEffectPlanSubscription(subscriptions: Record<string, SubscriptionRow>, nowMillis: number): SubscriptionRow | null {
// In-effect (not active): a canceled-at-period-end plan sub keeps its item
// grants until `endedAt`, so usage must be judged against that plan.
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;
}
@ -421,9 +423,9 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
customerType: "team",
customerId: ownerTeamId,
}));
const activePlanSubscription = resolveActivePlanSubscription(subscriptions);
const planId = resolveActivePlanId(activePlanSubscription);
const period = getPlanUsagePeriod(activePlanSubscription, now);
const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions, now.getTime());
const planId = resolveActivePlanId(inEffectPlanSubscription);
const period = getPlanUsagePeriod(inEffectPlanSubscription, now);
const [ownerTeamDisplayName, ownedScope, dashboardAdmins] = await Promise.all([
getOwnerTeamDisplayName(internalTenancy, ownerTeamId),
@ -441,7 +443,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
owner_team_id: ownerTeamId,
owner_team_display_name: ownerTeamDisplayName,
plan_id: planId,
plan_display_name: activePlanSubscription?.product.displayName ?? getPlanLabel(planId),
plan_display_name: inEffectPlanSubscription?.product.displayName ?? getPlanLabel(planId),
period_start_millis: period.start.getTime(),
period_end_millis: period.end.getTime(),
next_plan_id: getNextPlanId(planId),

View File

@ -0,0 +1,62 @@
import type Stripe from "stripe";
import { describe, expect, it } from "vitest";
import { getCanceledAtForSync, getEndedAtForSync } from "./stripe";
// 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,
endedAtSeconds?: number | null,
canceledAtSeconds?: number | null,
}): Stripe.Subscription {
return {
status: fields.status,
cancel_at_period_end: fields.cancelAtPeriodEnd ?? false,
ended_at: fields.endedAtSeconds ?? null,
canceled_at: fields.canceledAtSeconds ?? null,
} as unknown as Stripe.Subscription;
}
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)", () => {
expect(getEndedAtForSync(stripeSub({ status: "active" }), periodEnd)).toEqual({ endedAt: null });
});
it("schedules endedAt at Stripe's period end while a cancel is pending", () => {
expect(getEndedAtForSync(stripeSub({ status: "active", cancelAtPeriodEnd: true }), periodEnd)).toEqual({ endedAt: periodEnd });
});
it("uses Stripe's ended_at for terminal subs", () => {
const endedAtSeconds = 1_800_000_000;
expect(getEndedAtForSync(stripeSub({ status: "canceled", endedAtSeconds }), periodEnd)).toEqual({ endedAt: new Date(endedAtSeconds * 1000) });
});
it("falls back to the past period boundary for terminal subs without ended_at", () => {
const pastEnd = new Date(Date.now() - 86400000);
expect(getEndedAtForSync(stripeSub({ status: "incomplete_expired" }), pastEnd)).toEqual({ endedAt: pastEnd });
});
it("falls back to now for terminal subs whose period end is still in the future", () => {
const futureEnd = new Date(Date.now() + 86400000);
const before = Date.now();
const result = getEndedAtForSync(stripeSub({ status: "unpaid" }), futureEnd);
expect(result.endedAt).not.toBeNull();
expect(result.endedAt!.getTime()).toBeGreaterThanOrEqual(before);
expect(result.endedAt!.getTime()).toBeLessThanOrEqual(Date.now());
});
});
describe("getCanceledAtForSync", () => {
it("mirrors Stripe's canceled_at", () => {
const canceledAtSeconds = 1_800_000_000;
expect(getCanceledAtForSync(stripeSub({ status: "active", canceledAtSeconds }))).toEqual({ canceledAt: new Date(canceledAtSeconds * 1000) });
});
it("clears canceledAt when Stripe's is null (reactivation path)", () => {
expect(getCanceledAtForSync(stripeSub({ status: "active" }))).toEqual({ canceledAt: null });
});
});

View File

@ -236,9 +236,13 @@ const getTenancyFromStripeAccountIdOrThrow = async (stripe: Stripe, stripeAccoun
const TERMINAL_STRIPE_STATUSES = ["canceled", "incomplete_expired", "unpaid"] as const;
function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date } | {} {
export function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date | null } {
if (!TERMINAL_STRIPE_STATUSES.includes(subscription.status as typeof TERMINAL_STRIPE_STATUSES[number])) {
return {};
// 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
// a terminal status. If the webhook payload omits it (mocks, older API
@ -254,11 +258,23 @@ function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date
return { endedAt: new Date() };
}
function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date } | {} {
if (subscription.canceled_at) {
return { canceledAt: new Date(subscription.canceled_at * 1000) };
export function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date | null } {
// 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 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) {
return null;
}
return {};
const item = subscription.items.data[0];
return sanitizeStripePeriodDates(item.current_period_start, item.current_period_end, { subscriptionId: subscription.id, tenancyId: context.tenancyId }).end;
}
export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: string, stripeCustomerId: string) {
@ -333,6 +349,13 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
currentPeriodEnd: sanitizedDates.end,
currentPeriodStart: sanitizedDates.start,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
// 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"
},
});

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,187 @@ 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);
// 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",
});
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);
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 keep the active sub's cancel button when one quantity of a stackable product is canceled", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await configureProduct({
products: {
"seats-plan": {
displayName: "Seats Plan",
customerType: "user",
serverOnly: false,
stackable: true,
prices: {
monthly: {
USD: "1000",
interval: [1, "month"],
},
},
includedItems: {},
},
},
});
const { userId } = await Auth.fastSignUp();
const firstGrant = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "seats-plan" },
});
await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "seats-plan" },
});
// Cancel only the first quantity by subscription id — the second sub stays active
const firstSubscriptionId = (firstGrant.body as { subscription_id: string }).subscription_id;
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/seats-plan?subscription_id=${encodeURIComponent(firstSubscriptionId)}`, {
method: "DELETE",
accessType: "client",
});
expect(cancelResponse.status).toBe(200);
// The winding-down sub must not shadow the active one: the product stays cancelable
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
const item = (listResponse.body as { items: Array<{ id: string, type: string, quantity: number, subscription: { subscription_id: string, cancel_at_period_end: boolean, is_cancelable: boolean } | null }> }).items.find((i) => i.id === "seats-plan");
expect(item?.type).toBe("subscription");
expect(item?.subscription?.cancel_at_period_end).toBe(false);
expect(item?.subscription?.is_cancelable).toBe(true);
expect(item?.subscription?.subscription_id).not.toBe(firstSubscriptionId);
// Double-canceling the already-canceled quantity returns the dedicated error
const secondCancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/seats-plan?subscription_id=${encodeURIComponent(firstSubscriptionId)}`, {
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 allow granting another plan in the same product line while the old one winds down", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await configureProduct({
productLines: {
plans: { displayName: "Plans", customerType: "user" },
},
products: {
"plan-a": {
displayName: "Plan A",
customerType: "user",
productLineId: "plans",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
"plan-b": {
displayName: "Plan B",
customerType: "user",
productLineId: "plans",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "2000", interval: [1, "month"] } },
includedItems: {},
},
},
});
const { userId } = await Auth.fastSignUp();
await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "plan-a" },
});
const cancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/plan-a`, {
method: "DELETE",
accessType: "client",
});
expect(cancelResponse.status).toBe(200);
// The winding-down plan-a sub must count as a replaceable conflict, not as
// a one-time purchase blocking the line
const grantBResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
method: "POST",
accessType: "server",
body: { product_id: "plan-b" },
});
expect(grantBResponse.status).toBe(200);
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
accessType: "client",
});
const items = (listResponse.body as { items: Array<{ id: string, type: string }> }).items;
expect(items.find((i) => i.id === "plan-b")?.type).toBe("subscription");
});
it("should reject a client canceling someone else's subscription product", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
@ -401,8 +587,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 +1209,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">