mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Reconcile Stripe wind-down state in webhook sync
Follow-ups from further review of the wind-down handling: - getEndedAtForSync now reconciles endedAt on NON-terminal syncs too: cancel_at_period_end true schedules Stripe's period end, false clears it. This repairs the eagerly-written endedAt when a pending cancel is reversed through Stripe's Dashboard/API (previously sticky — Stripe kept billing while bulldozer still ended the grants) and when the eager write used a stale pre-renewal boundary. getCanceledAtForSync now mirrors Stripe's canceled_at including null for the same reason. - Apply the endedAt/canceledAt derivation to the webhook upsert's create branch. The first sync for a sub can already be terminal (out-of-order webhooks, or purchase-session subs whose first local write is a webhook); such rows were created with endedAt null, which isSubscriptionInEffect reads as entitled forever. A backfill for pre-existing rows in that shape is noted as a follow-up (it needs the bulldozer rows re-emitted, so it isn't a plain SQL migration). - Source the cancel and refund routes' eager endedAt from the Stripe update response (new getStripeSubscriptionPeriodEnd helper) instead of the possibly stale local/bulldozer period end. - Clear cancel_at_period_end on invoice.paid when the paid invoice was issued after canceledAt — that can only be the purchase-session re-price of a winding-down sub, so paying it means "keep me subscribed". Deliberately NOT cleared at session creation: that runs before payment (default_incomplete has no rollback), and a declined card would silently reactivate an explicitly-canceled sub while leaving it re-priced. The created-after-cancel guard also makes a late-delivered invoice.paid for the original creation invoice unable to undo a cancel. - Rank representative subs cancelable > active > in-effect (subscriptionDisplayRank) in the products list: a pending-cancel Stripe sub stays active, so the previous active-only preference could let it shadow a cancelable sibling of a stackable product and report is_cancelable false while DELETE would succeed. - Thread the caller's clock through resolveInEffectPlanSubscription instead of calling Date.now() inside, keeping plan selection and usage queries on the same instant. - Unit tests for the sync helpers, the display rank, and deterministic clocks in the plan-usage tests.
This commit is contained in:
parent
7f4a15078d
commit
45ddd3877d
@ -3,7 +3,7 @@ import { bulldozerWriteOneTimePurchase } from "@/lib/payments/bulldozer-dual-wri
|
||||
import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "@/lib/stripe-webhook-events";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { listPermissions } from "@/lib/permissions";
|
||||
import { getHexclaveStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
|
||||
import { getHexclaveStripe, getStripeForAccount, reactivateSubscriptionIfRepurchasePaid, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
|
||||
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
|
||||
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
|
||||
import { getTenancy, type Tenancy } from "@/lib/tenancies";
|
||||
@ -342,6 +342,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
|
||||
await upsertStripeInvoice(stripe, accountId, invoice);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.paid") {
|
||||
// A paid invoice issued after a cancel means the customer paid to keep
|
||||
// the sub (purchase-session re-price) — clear the pending cancel.
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await reactivateSubscriptionIfRepurchasePaid(stripe, accountId, invoice);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_succeeded") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
|
||||
@ -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,13 @@ 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 period boundary; the local row can be stale around a renewal
|
||||
// whose webhook hasn't synced yet.
|
||||
stripePeriodEnd = getStripeSubscriptionPeriodEnd(updated, { tenancyId: tenancy.id });
|
||||
} catch (e: unknown) {
|
||||
if (!isStripeSubscriptionAlreadyTerminalError(e)) {
|
||||
throw e;
|
||||
@ -711,7 +716,7 @@ async function handleSubscriptionRefund(options: {
|
||||
data: {
|
||||
cancelAtPeriodEnd: true,
|
||||
canceledAt: subscription.canceledAt ?? now,
|
||||
endedAt: subscription.endedAt ?? subscription.currentPeriodEnd,
|
||||
endedAt: subscription.endedAt ?? stripePeriodEnd ?? subscription.currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ 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";
|
||||
@ -147,7 +147,15 @@ export const DELETE = createSmartRouteHandler({
|
||||
// list reflects the wind-down before the webhook sync arrives; the
|
||||
// Stripe sub stays `active` with cancel_at_period_end until Stripe
|
||||
// ends it at the period boundary.
|
||||
await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
const updated = await stripeClient.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
// Take the boundary from Stripe's response, not the bulldozer
|
||||
// snapshot read at request start — around a renewal whose webhook
|
||||
// hasn't synced yet, the snapshot's period end is the previous
|
||||
// (possibly already-past) one. The snapshot fallback only covers
|
||||
// item-less mocked responses; the webhook sync reconciles endedAt
|
||||
// either way.
|
||||
const endedAt = getStripeSubscriptionPeriodEnd(updated, { tenancyId: auth.tenancy.id })
|
||||
?? new Date(subscription.currentPeriodEndMillis);
|
||||
updatedSub = await prisma.subscription.update({
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
@ -158,7 +166,7 @@ export const DELETE = createSmartRouteHandler({
|
||||
data: {
|
||||
cancelAtPeriodEnd: true,
|
||||
canceledAt: new Date(),
|
||||
endedAt: new Date(subscription.currentPeriodEndMillis),
|
||||
endedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, isSubscriptionCancelable, isSubscriptionInEffect, 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";
|
||||
@ -72,15 +72,16 @@ export const GET = createSmartRouteHandler({
|
||||
// active): a canceled-at-period-end sub still backs its owned product
|
||||
// and must not fall through to the one-time-purchase branch below.
|
||||
// When several in-effect subs share a productId (e.g. one quantity of a
|
||||
// stackable product was canceled by subscription_id), prefer an active
|
||||
// one so the winding-down sub can't shadow it and hide the cancel button.
|
||||
// stackable product was canceled by subscription_id), prefer the highest
|
||||
// subscriptionDisplayRank (cancelable > active > in-effect) so a
|
||||
// winding-down sub can't shadow one the customer can still act on.
|
||||
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 || (!isActiveSubscription(existing) && isActiveSubscription(s))) {
|
||||
if (existing == null || subscriptionDisplayRank(s) > subscriptionDisplayRank(existing)) {
|
||||
inEffectSubByProductId.set(key, s);
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,8 +315,10 @@ export const POST = createSmartRouteHandler({
|
||||
currentPeriodStart: sanitizedUpdateDates.start,
|
||||
currentPeriodEnd: sanitizedUpdateDates.end,
|
||||
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
|
||||
// Clear the wind-down marks the cancel route wrote eagerly —
|
||||
// without this the reactivated sub still ends at the old boundary.
|
||||
// Clear the wind-down marks the cancel route wrote eagerly. The
|
||||
// webhook sync now reconciles these from Stripe's state anyway;
|
||||
// the manual clear only covers the pre-webhook window so the
|
||||
// products list doesn't show "Ends on" right after a switch.
|
||||
canceledAt: null,
|
||||
endedAt: null,
|
||||
},
|
||||
|
||||
@ -131,6 +131,14 @@ export const POST = createSmartRouteHandler({
|
||||
payment_behavior: 'default_incomplete',
|
||||
payment_settings: { save_default_payment_method: 'on_subscription' },
|
||||
expand: ['latest_invoice.confirmation_secret'],
|
||||
// Note: we deliberately do NOT clear cancel_at_period_end here.
|
||||
// This update runs at session creation, before the customer has
|
||||
// paid (default_incomplete leaves the invoice open, and unlike
|
||||
// the switch route's error_if_incomplete there is no rollback on
|
||||
// payment failure) — clearing it now would reactivate an
|
||||
// explicitly-canceled sub on a declined card. The webhook clears
|
||||
// the flag on invoice.paid, once the re-purchase has actually
|
||||
// been paid for.
|
||||
items: [{
|
||||
id: existingItem.id,
|
||||
price_data: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { KnownErrors } from '@hexclave/shared';
|
||||
import { generateUuid } from '@hexclave/shared/dist/utils/uuids';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSubscriptionCancelable, isSubscriptionInEffect, validatePurchaseSession } from './payments';
|
||||
import { isSubscriptionCancelable, isSubscriptionInEffect, subscriptionDisplayRank, validatePurchaseSession } from './payments';
|
||||
import { bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
|
||||
@ -293,3 +293,16 @@ describe('isSubscriptionCancelable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriptionDisplayRank', () => {
|
||||
it('ranks cancelable above pending-cancel Stripe subs above wound-down subs', () => {
|
||||
const cancelable = { status: 'active', cancelAtPeriodEnd: false };
|
||||
// A pending-cancel Stripe sub stays `active` — this is the tier that an
|
||||
// active-only preference gets wrong (it would tie with the cancelable
|
||||
// sibling and shadow it depending on iteration order).
|
||||
const stripePendingCancel = { status: 'active', cancelAtPeriodEnd: true };
|
||||
const localWindingDown = { status: 'canceled', cancelAtPeriodEnd: true };
|
||||
expect(subscriptionDisplayRank(cancelable)).toBeGreaterThan(subscriptionDisplayRank(stripePendingCancel));
|
||||
expect(subscriptionDisplayRank(stripePendingCancel)).toBeGreaterThan(subscriptionDisplayRank(localWindingDown));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -136,6 +136,18 @@ export function isSubscriptionCancelable(subscription: { status: string, cancelA
|
||||
return isActiveSubscription(subscription) && !subscription.cancelAtPeriodEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank for picking the representative subscription when several in-effect
|
||||
* subs share a productId (stackable products): cancelable > merely-active >
|
||||
* merely-in-effect. Cancelable > active matters for Stripe subs — a
|
||||
* pending-cancel Stripe sub stays `active` (with cancelAtPeriodEnd), so an
|
||||
* active-only preference would let it shadow a cancelable sibling and report
|
||||
* is_cancelable: false while a DELETE would in fact succeed.
|
||||
*/
|
||||
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
|
||||
|
||||
@ -191,8 +191,10 @@ describe("billing period selection", () => {
|
||||
});
|
||||
|
||||
describe("resolveInEffectPlanSubscription", () => {
|
||||
const futureMillis = Date.now() + 30 * 86400000;
|
||||
const pastMillis = Date.now() - 86400000;
|
||||
// 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 = {
|
||||
@ -209,7 +211,7 @@ describe("resolveInEffectPlanSubscription", () => {
|
||||
id: "sub_free",
|
||||
productId: "free",
|
||||
};
|
||||
expect(resolveInEffectPlanSubscription({ sub_team: windingDownTeam, sub_free: activeFree })?.id).toBe("sub_team");
|
||||
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", () => {
|
||||
@ -227,6 +229,6 @@ describe("resolveInEffectPlanSubscription", () => {
|
||||
id: "sub_free",
|
||||
productId: "free",
|
||||
};
|
||||
expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree })?.id).toBe("sub_free");
|
||||
expect(resolveInEffectPlanSubscription({ sub_team: endedTeam, sub_free: activeFree }, nowMillis)?.id).toBe("sub_free");
|
||||
});
|
||||
});
|
||||
|
||||
@ -157,10 +157,9 @@ export async function readBillingSubscriptionMapOrSkip(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInEffectPlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
|
||||
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 nowMillis = Date.now();
|
||||
const inEffectSubscriptions = Object.values(subscriptions).filter((candidate) => isSubscriptionInEffect(candidate, nowMillis));
|
||||
for (const planId of BASE_PLAN_IDS_BY_TIER) {
|
||||
const subscription = inEffectSubscriptions.find((candidate) => candidate.productId === planId);
|
||||
@ -424,7 +423,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
|
||||
customerType: "team",
|
||||
customerId: ownerTeamId,
|
||||
}));
|
||||
const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions);
|
||||
const inEffectPlanSubscription = resolveInEffectPlanSubscription(subscriptions, now.getTime());
|
||||
const planId = resolveActivePlanId(inEffectPlanSubscription);
|
||||
const period = getPlanUsagePeriod(inEffectPlanSubscription, now);
|
||||
|
||||
|
||||
67
apps/backend/src/lib/stripe.test.ts
Normal file
67
apps/backend/src/lib/stripe.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type Stripe from "stripe";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCanceledAtForSync, getEndedAtForSync } from "./stripe";
|
||||
|
||||
// The helpers only read status / cancel_at_period_end / ended_at /
|
||||
// canceled_at, but are typed against the full Stripe.Subscription. Building a
|
||||
// real Stripe.Subscription in a unit test is impractical, so we cast a
|
||||
// minimal object; a missing field the helpers start relying on would surface
|
||||
// as a failing assertion here, not as 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)", () => {
|
||||
// Reversing a pending cancellation via Stripe's Dashboard/API must clear
|
||||
// the endedAt the cancel route wrote eagerly — otherwise bulldozer still
|
||||
// ends the grants at the old boundary while Stripe keeps billing.
|
||||
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,16 @@ 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 locally scheduled end with Stripe's flag.
|
||||
// The cancel route eagerly writes endedAt = period end; Stripe is the
|
||||
// authority on both whether that end is still scheduled (the pending
|
||||
// cancel can be reversed via Stripe's Dashboard/API) and on the exact
|
||||
// boundary (the eager write may have used a stale pre-renewal period
|
||||
// end). isSubscriptionInEffect treats endedAt == null as "no end
|
||||
// scheduled", so clearing it 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 +261,26 @@ 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 exactly, including null: reversing a pending cancellation
|
||||
// clears canceled_at on Stripe's side, and the local mark must follow.
|
||||
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 carries no items (mocked Stripe). Used to eagerly write the
|
||||
* wind-down `endedAt` from Stripe's authoritative response instead of a
|
||||
* possibly stale (pre-renewal) local or bulldozer period end. Items with
|
||||
* invalid period values fall through to sanitizeStripePeriodDates' own
|
||||
* now→now+1mo fallback (plus a captureError), not to the caller's fallback.
|
||||
*/
|
||||
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 +355,19 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
currentPeriodEnd: sanitizedDates.end,
|
||||
currentPeriodStart: sanitizedDates.start,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
// Same endedAt/canceledAt derivation as the update branch: the first
|
||||
// sync we see for a sub can already be terminal (out-of-order
|
||||
// webhooks, or a purchase-session sub whose first local write is a
|
||||
// late webhook). Without this, a terminal row is created with
|
||||
// endedAt = null, which isSubscriptionInEffect reads as "entitled
|
||||
// forever". Note: rows created before this derivation existed can
|
||||
// still hold that bad shape (terminal status, endedAt = null) and
|
||||
// only self-heal when another webhook for the same customer sweeps
|
||||
// them — a backfill migration (status IN terminal AND endedAt IS
|
||||
// NULL) is tracked as a follow-up, since it also needs the bulldozer
|
||||
// rows re-emitted.
|
||||
...getEndedAtForSync(subscription, sanitizedDates.end),
|
||||
...getCanceledAtForSync(subscription),
|
||||
creationSource: "PURCHASE_PAGE"
|
||||
},
|
||||
});
|
||||
@ -348,6 +383,57 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a paid invoice belongs to a pending-cancel subscription and was issued
|
||||
* AFTER the cancellation, the customer has just paid to keep the sub (the
|
||||
* purchase-session conflict-reuse path re-prices the winding-down sub and
|
||||
* lets the customer pay via Stripe Elements) — so clear cancel_at_period_end
|
||||
* and re-sync, which also clears the local endedAt/canceledAt wind-down
|
||||
* marks via getEndedAtForSync/getCanceledAtForSync.
|
||||
*
|
||||
* The invoice.created > canceledAt guard is what makes this safe: a
|
||||
* late-delivered invoice.paid for the ORIGINAL creation invoice predates the
|
||||
* cancel and must not undo it, and pending-cancel subs generate no renewal
|
||||
* invoices, so a paid invoice issued after canceledAt can only be the
|
||||
* re-purchase. We can't clear the flag at session creation instead — that
|
||||
* runs before payment (default_incomplete has no rollback), and a declined
|
||||
* card would silently reactivate an explicitly-canceled sub.
|
||||
*/
|
||||
export async function reactivateSubscriptionIfRepurchasePaid(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) {
|
||||
if (invoice.status !== "paid") {
|
||||
return;
|
||||
}
|
||||
const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? [];
|
||||
const invoiceSubscriptionIds = invoiceLines
|
||||
.map((line) => line.parent?.subscription_item_details?.subscription)
|
||||
.filter((subscription): subscription is string => !!subscription);
|
||||
if (invoiceSubscriptionIds.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const stripeSubscriptionId = invoiceSubscriptionIds[0];
|
||||
const tenancy = await getTenancyFromStripeAccountIdOrThrow(stripe, stripeAccountId);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const localSub = await prisma.subscription.findUnique({
|
||||
where: {
|
||||
tenancyId_stripeSubscriptionId: {
|
||||
tenancyId: tenancy.id,
|
||||
stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
select: { cancelAtPeriodEnd: true, canceledAt: true },
|
||||
});
|
||||
if (localSub == null || !localSub.cancelAtPeriodEnd) {
|
||||
return;
|
||||
}
|
||||
if (localSub.canceledAt == null || invoice.created * 1000 <= localSub.canceledAt.getTime()) {
|
||||
return;
|
||||
}
|
||||
await stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: false });
|
||||
if (typeof invoice.customer === "string") {
|
||||
await syncStripeSubscriptions(stripe, stripeAccountId, invoice.customer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertStripeInvoice(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) {
|
||||
const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? [];
|
||||
const invoiceSubscriptionIds = invoiceLines
|
||||
|
||||
Loading…
Reference in New Issue
Block a user