mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Shorten comments in payments subscription predicates
This commit is contained in:
+6
-10
@@ -116,10 +116,9 @@ export const DELETE = createSmartRouteHandler({
|
||||
s.productId === params.product_id && isActiveSubscription(s)
|
||||
);
|
||||
if (subscriptions.length === 0) {
|
||||
// Owned but with no active sub: either it's already winding down
|
||||
// (canceled with a future endedAt — still in effect, not
|
||||
// re-cancelable), or the customer owns it via OTP. Distinguish the
|
||||
// two so a double-cancel doesn't claim the product is an OTP.
|
||||
// Owned but no active sub: either already winding down (canceled
|
||||
// with a future endedAt) or owned via OTP — don't claim the former
|
||||
// is a one-time purchase.
|
||||
const nowMillis = Date.now();
|
||||
const windingDown = allSubs.find(s =>
|
||||
s.productId === params.product_id && isSubscriptionInEffect(s, nowMillis)
|
||||
@@ -136,12 +135,9 @@ export const DELETE = createSmartRouteHandler({
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.stripeSubscriptionId) {
|
||||
const stripeClient = stripe ?? throwErr(500, "Stripe client missing for subscription cancellation.");
|
||||
// Cancel at period end (not `subscriptions.cancel()`, which ends the
|
||||
// sub immediately) — the confirm dialog promises the customer keeps
|
||||
// what they paid for until the period ends, and the local-sub branch
|
||||
// below implements the same semantics via a future `endedAt`. The
|
||||
// Stripe webhook syncs the local row (and flips it to `canceled`
|
||||
// with `endedAt` set when Stripe ends it at the period boundary).
|
||||
// Cancel at period end, not `subscriptions.cancel()` (immediate) —
|
||||
// matches the confirm dialog and the local-sub branch below. The
|
||||
// webhook syncs the local row when Stripe ends it.
|
||||
await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
+4
-8
@@ -67,11 +67,9 @@ export const GET = createSmartRouteHandler({
|
||||
]);
|
||||
// Deprecated: map productId → subscription for backward-compat fields.
|
||||
// ownedProducts keys use '__null__' for inline products (null productId),
|
||||
// so we normalize subscription productIds to match.
|
||||
// In-effect (not active): a canceled-at-period-end sub still backs its
|
||||
// owned product until `endedAt`, and must keep rendering as a
|
||||
// subscription ("Ends on <date>") rather than falling through to the
|
||||
// one-time-purchase branch below.
|
||||
// so we normalize subscription productIds to match. In-effect (not
|
||||
// active): a canceled-at-period-end sub still backs its owned product
|
||||
// and must not fall through to the one-time-purchase branch below.
|
||||
const nowMillis = Date.now();
|
||||
const inEffectSubByProductId = new Map(
|
||||
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => [s.productId ?? "__null__", s] as const)
|
||||
@@ -133,9 +131,7 @@ export const GET = createSmartRouteHandler({
|
||||
subscription_id: sub.id,
|
||||
current_period_end: sub.currentPeriodEndMillis ? new Date(sub.currentPeriodEndMillis).toISOString() : null,
|
||||
cancel_at_period_end: sub.cancelAtPeriodEnd,
|
||||
// A sub that's already winding down (locally canceled, or a
|
||||
// Stripe sub with cancel_at_period_end set) can't be canceled
|
||||
// again — the DELETE route would 400 on it anyway.
|
||||
// Already-winding-down subs can't be canceled again
|
||||
is_cancelable: isActiveSubscription(sub) && !sub.cancelAtPeriodEnd,
|
||||
} : null,
|
||||
switch_options: switchOptions,
|
||||
|
||||
+2
-5
@@ -112,11 +112,8 @@ export const POST = createSmartRouteHandler({
|
||||
customerId: params.customer_id,
|
||||
});
|
||||
// ownedProducts keys use '__null__' for inline products (null productId),
|
||||
// so we normalize subscription productIds to match.
|
||||
// In-effect (not active): this set classifies owned products as
|
||||
// sub-backed vs OTP. A canceled-at-period-end sub still backs its
|
||||
// product until `endedAt`; treating it as an OTP here would wrongly
|
||||
// block switching for the rest of the paid-through window.
|
||||
// so we normalize subscription productIds to match. In-effect (not
|
||||
// active): a canceled-at-period-end sub is still sub-backed, not an OTP.
|
||||
const nowMillis = Date.now();
|
||||
const subBackedProductIds = new Set(
|
||||
Object.values(subMap).filter(s => isSubscriptionInEffect(s, nowMillis)).map(s => s.productId ?? "__null__")
|
||||
|
||||
@@ -117,13 +117,9 @@ export async function ensureProductIdOrInlineProduct(
|
||||
// were removed. All reads now go through customer-data.ts backed by Bulldozer.
|
||||
|
||||
/**
|
||||
* Lifecycle/actionability predicate: the subscription is in a state where it
|
||||
* will renew and can be acted on (canceled, switched). This is deliberately
|
||||
* NOT the same question as "does the customer currently have the product" —
|
||||
* a locally-canceled sub keeps entitling the customer until `endedAt` (we
|
||||
* model cancel-at-period-end as `status: canceled` + future `endedAt`). Use
|
||||
* `isSubscriptionInEffect` for entitlement questions; using this one there
|
||||
* makes still-paid-for products masquerade as one-time purchases.
|
||||
* The subscription will renew and can be acted on (canceled, switched). For
|
||||
* "does this still entitle the customer" use `isSubscriptionInEffect` — a
|
||||
* canceled sub keeps entitling until its future `endedAt`.
|
||||
*/
|
||||
export function isActiveSubscription(subscription: { status: string }): boolean {
|
||||
const s = subscription.status;
|
||||
@@ -131,18 +127,10 @@ export function isActiveSubscription(subscription: { status: string }): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Entitlement predicate: the subscription still confers its product/items on
|
||||
* the customer, regardless of whether it will renew. Mirrors Bulldozer's
|
||||
* grant semantics (subscription-start emits on row insert regardless of
|
||||
* status; subscription-end emits at `endedAtMillis`), which is why it is
|
||||
* deliberately status-agnostic: `canceled` subs stay in effect until their
|
||||
* future `endedAt` passes, and `incomplete`/`past_due`/`unpaid` subs that
|
||||
* arrive mid-Stripe-flow count too (they either transition to `active` or to
|
||||
* a terminal status with `endedAt` set — every terminal writer sets
|
||||
* `endedAt`, so `endedAt == null` means "no end scheduled").
|
||||
*
|
||||
* Accepts both row shapes so Bulldozer reads (`endedAtMillis`) and Prisma
|
||||
* reads (`endedAt`) can share the one definition.
|
||||
* The subscription still confers its product/items, regardless of whether it
|
||||
* will renew. Deliberately status-agnostic and `endedAt`-based, mirroring
|
||||
* Bulldozer's grant timefold (grants run from row insert until
|
||||
* `endedAtMillis`; every terminal writer sets `endedAt`).
|
||||
*/
|
||||
export function isSubscriptionInEffect(
|
||||
subscription: { endedAtMillis?: number | null, endedAt?: Date | null },
|
||||
|
||||
@@ -159,9 +159,7 @@ export async function readBillingSubscriptionMapOrSkip(
|
||||
|
||||
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
|
||||
// In-effect (not active): a canceled-at-period-end plan sub keeps its item
|
||||
// grants until `endedAt`, so usage limits must be judged against that plan
|
||||
// — otherwise this page reports "Free" while the customer's actual quotas
|
||||
// are still the paid plan's.
|
||||
// grants until `endedAt`, so usage must be judged against that plan.
|
||||
const nowMillis = Date.now();
|
||||
const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis));
|
||||
for (const planId of BASE_PLAN_IDS_BY_TIER) {
|
||||
|
||||
@@ -273,10 +273,7 @@ it("should report a canceled-at-period-end subscription as still in effect, and
|
||||
});
|
||||
expect(cancelResponse.status).toBe(200);
|
||||
|
||||
// The product must NOT masquerade as a one-time purchase during the
|
||||
// paid-through window (it used to: the list route only recognized
|
||||
// active/trialing subs, so a canceled-with-future-endedAt sub fell through
|
||||
// to the OTP branch).
|
||||
// Must not masquerade as a one-time purchase during the paid-through window
|
||||
const listResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
|
||||
accessType: "client",
|
||||
});
|
||||
@@ -286,8 +283,6 @@ it("should report a canceled-at-period-end subscription as still in effect, and
|
||||
expect(item?.subscription?.cancel_at_period_end).toBe(true);
|
||||
expect(item?.subscription?.is_cancelable).toBe(false);
|
||||
|
||||
// Canceling again must not claim the product is a one-time purchase, nor
|
||||
// silently rewrite the wind-down state.
|
||||
const secondCancelResponse = await niceBackendFetch(`/api/v1/payments/products/user/${userId}/pro-plan`, {
|
||||
method: "DELETE",
|
||||
accessType: "client",
|
||||
|
||||
Reference in New Issue
Block a user