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:
Bilal Godil 2026-07-16 17:46:56 -07:00
parent 7f4a15078d
commit 45ddd3877d
12 changed files with 237 additions and 27 deletions

View File

@ -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;

View File

@ -7,7 +7,7 @@ import { REFUND_TXN_PREFIX } from "@/lib/payments/refund-txn-id";
import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
import { ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX } from "@/lib/payments/transaction-entry-indexes";
import type { ManualTransactionRow, TransactionEntryData } from "@/lib/payments/schema/types";
import { getStripeForAccount } from "@/lib/stripe";
import { getStripeForAccount, getStripeSubscriptionPeriodEnd } from "@/lib/stripe";
import type { Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@ -686,6 +686,7 @@ async function handleSubscriptionRefund(options: {
} else if (endAtPeriodEnd) {
// End at period end. Items follow natural lifecycle when sub-end fires
// at period boundary.
let stripePeriodEnd: Date | null = null;
if (!isTestMode && subscription.stripeSubscriptionId) {
const stripe = await getStripeForAccount({ tenancy });
// Idempotent guard, mirroring the endNow branch. The end-at-period-end
@ -697,9 +698,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,
},
});
}

View File

@ -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 {

View File

@ -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);
}
}

View File

@ -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,
},

View File

@ -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: {

View File

@ -1,7 +1,7 @@
import { KnownErrors } from '@hexclave/shared';
import { generateUuid } from '@hexclave/shared/dist/utils/uuids';
import { describe, expect, it } from 'vitest';
import { 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));
});
});

View File

@ -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

View File

@ -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");
});
});

View File

@ -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);

View 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 });
});
});

View File

@ -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
* nownow+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