mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge b0367ca48d into 7ed89a2a9c
This commit is contained in:
commit
8e2f57002a
@ -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;
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
62
apps/backend/src/lib/stripe.test.ts
Normal file
62
apps/backend/src/lib/stripe.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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"
|
||||
},
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user