fix(billing): serialize reconciliation and record accepted cancellations

This commit is contained in:
2026-09-08 16:32:11 +03:00
parent 57061b5a5d
commit 1c24336b6a
9 changed files with 676 additions and 42 deletions
@@ -33,11 +33,7 @@ import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons'; import type { CancellationReason } from '@/lib/cancellation-reasons';
/** /** Convert Stripe API units separately from the currency's display precision. */
* Stripe reports amounts in the currency's smallest unit, and how many of those make a
* whole unit differs per currency: two for USD, none for JPY. The formatter knows the
* exponent, so it decides the divisor instead of a hardcoded 100.
*/
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
const currencyCode = currency.toUpperCase(); const currencyCode = currency.toUpperCase();
@@ -47,7 +43,10 @@ function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
currency: currencyCode, currency: currencyCode,
}); });
const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2; const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
return formatter.format(amountInMinorUnits / 10 ** fractionDigits); // Stripe retains two-decimal API amounts for ISK/UGX despite their zero-decimal display.
// https://docs.stripe.com/currencies#special-cases
const apiExponent = currencyCode === 'ISK' || currencyCode === 'UGX' ? 2 : fractionDigits;
return formatter.format(amountInMinorUnits / 10 ** apiExponent);
} catch { } catch {
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`; return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
} }
@@ -78,7 +78,9 @@ export async function CancellationReasonsCard() {
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{format(row.createdAt, 'MMM dd, yyyy')} {format(row.createdAt, 'MMM dd, yyyy')}
{row.periodEnd ? ` · access until ${format(row.periodEnd, 'MMM dd')}` : ''} {row.periodEnd
? ` · billing period ends ${format(row.periodEnd, 'MMM dd')}`
: ''}
</span> </span>
</div> </div>
<p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p> <p className="text-muted-foreground">{getCancellationReasonLabel(row.reason)}</p>
+20 -11
View File
@@ -1,11 +1,7 @@
// Turning Stripe state into funnel events. // Turning Stripe state and accepted cancellations into funnel events.
// // Sync compares before/after state; in-app cancellation also records acceptance
// These four events are derived from a before/after comparison inside the sync // because its local claim can hide that transition. Shared cycle keys make both
// that already re-reads every subscription a customer has, rather than from the // paths and replayed webhooks count the same cancellation once.
// webhook event types. That is deliberate: webhooks arrive out of order and get
// replayed, and `customer.subscription.updated` fires for changes that mean
// nothing here. Comparing the row we are about to overwrite with the row we are
// writing is order-independent, and the dedupe keys make a replay a no-op.
import type { BillingSubscriptionStatus } from '@prisma/client'; import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record'; import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0); return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
} }
/** Shared by accepted in-app cancellations and sync; recordEvent logs write failures. */
export async function recordSubscriptionCancellation(params: {
userId: string;
subscriptionId: string;
currentPeriodEnd: Date | null;
}): Promise<void> {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
userId: params.userId,
});
}
export async function recordSubscriptionTransition(params: { export async function recordSubscriptionTransition(params: {
userId: string; userId: string;
subscriptionId: string; subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd; const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED'; const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) { if (startedCanceling || becameCanceled) {
await recordEvent({ await recordSubscriptionCancellation({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
userId, userId,
subscriptionId,
currentPeriodEnd: after.currentPeriodEnd,
}); });
} }
+61 -23
View File
@@ -911,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
} }
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) { export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
}
async function writeStripeSubscriptionToUser(
subscription: Stripe.Subscription,
client: Prisma.TransactionClient
) {
const customerId = const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await db.user.findUnique({ const user = await client.user.findUnique({
where: { stripeCustomerId: customerId }, where: { stripeCustomerId: customerId },
select: { select: {
id: true, id: true,
@@ -964,7 +971,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// which is cleared again as soon as the subscription goes back to active. // which is cleared again as soon as the subscription goes back to active.
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus); const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
const updated = await db.user.update({ const updated = await client.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
@@ -986,7 +993,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
}, },
}); });
await recordSubscriptionTransition({ const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id, userId: user.id,
subscriptionId: subscription.id, subscriptionId: subscription.id,
before: { before: {
@@ -1000,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd, trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd, currentPeriodEnd: effectiveCurrentPeriodEnd,
}, },
}); };
return updated; return { updated, transition };
}
async function recordSyncedSubscription(
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
) {
if (!result) return null;
// Analytics uses its own connection. Run it after commit, not while a billing
// transaction holds a connection and other syncs are queued on its advisory lock.
await recordSubscriptionTransition(result.transition);
return result.updated;
} }
// A single Stripe customer can own several subscriptions at once (e.g. after // A single Stripe customer can own several subscriptions at once (e.g. after
@@ -1055,28 +1072,49 @@ export function selectAuthoritativeSubscription(
// Source-of-truth sync: instead of trusting a single subscription from a webhook // Source-of-truth sync: instead of trusting a single subscription from a webhook
// event body (which may be an OLD subscription being deleted while a NEWER one is // event body (which may be an OLD subscription being deleted while a NEWER one is
// active), re-list ALL of the customer's subscriptions from Stripe and sync the // active), re-list ALL of the customer's subscriptions from Stripe and sync the
// authoritative one. This is order-independent and self-healing. // authoritative one. The customer lock covers the Stripe read as well as the mirror
// write: locking only after the read would still let a delayed older response win.
export async function syncStripeCustomerSubscriptions(customerId: string) { export async function syncStripeCustomerSubscriptions(customerId: string) {
const stripe = getStripe(); const result = await db.$transaction(
const { data: subscriptions } = await stripe.subscriptions.list({ async (tx) => {
customer: customerId, // Two-key advisory locks occupy a separate namespace from the one-key
status: 'all', // cancellation locks. Cancellation releases its lock before calling sync.
limit: 100, await tx.$executeRaw`
}); SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
`;
const { data: subscriptions } = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const authoritative = selectAuthoritativeSubscription(subscriptions); const authoritative = selectAuthoritativeSubscription(subscriptions);
if (!authoritative) { return authoritative
return markSubscriptionCanceledByCustomerId(customerId); ? writeStripeSubscriptionToUser(authoritative, tx)
} : writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
},
return syncStripeSubscriptionToUser(authoritative); // Bound lock and connection occupancy. A slow Stripe call or lock wait fails
// this sync; writes through the expired transaction cannot overwrite a newer sync.
{ maxWait: 10_000, timeout: 30_000 }
);
return recordSyncedSubscription(result);
} }
export async function markSubscriptionCanceledByCustomerId( export async function markSubscriptionCanceledByCustomerId(
customerId: string, customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null } options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) { ) {
const user = await db.user.findUnique({ return recordSyncedSubscription(
await writeSubscriptionCanceledByCustomerId(customerId, options, db)
);
}
async function writeSubscriptionCanceledByCustomerId(
customerId: string,
options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
client: Prisma.TransactionClient
) {
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId }, where: { stripeCustomerId: customerId },
select: { select: {
id: true, id: true,
@@ -1098,7 +1136,7 @@ export async function markSubscriptionCanceledByCustomerId(
// date, which is also what the cancellation copy in settings promises. // date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = user.trialEndsAt ?? null; const preservedTrialEnd = user.trialEndsAt ?? null;
const updated = await db.user.update({ const updated = await client.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED, subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -1116,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId(
// uses the period end being cleared here, which is the same one the earlier // uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the // "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two. // portal and then reached the end of their term produces one cancellation, not two.
await recordSubscriptionTransition({ const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id, userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id, subscriptionId: user.stripeSubscriptionId ?? user.id,
before: { before: {
@@ -1130,9 +1168,9 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd, trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null, currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
}, },
}); };
return updated; return { updated, transition };
} }
/** /**
+9
View File
@@ -10,6 +10,7 @@ import {
voidOpenSubscriptionInvoices, voidOpenSubscriptionInvoices,
} from '@/lib/billing'; } from '@/lib/billing';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
export { export {
CANCELLATION_NOTE_MAX_LENGTH, CANCELLATION_NOTE_MAX_LENGTH,
@@ -175,6 +176,14 @@ export async function cancelSubscription(params: {
const periodEndUnix = getSubscriptionPeriodEnd(original); const periodEndUnix = getSubscriptionPeriodEnd(original);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd; const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
// The paid claim already set the local flag, and another subscription may
// drive customer sync. Record acceptance directly with the same cycle key.
await recordSubscriptionCancellation({
userId: params.userId,
subscriptionId,
currentPeriodEnd: periodEnd,
});
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
// The paid mirror's claim does not cover other subscriptions. Serialize every // The paid mirror's claim does not cover other subscriptions. Serialize every
// reason write and reuse only a row written during this request, so a resumed // reason write and reuse only a row written during this request, so a resumed
+146 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { BillingSubscriptionStatus, type User } from '@prisma/client'; import { BillingSubscriptionStatus, type User } from '@prisma/client';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe'; import { getStripe } from '@/lib/stripe';
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route'; import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
import { GET as billingRoute } from '@/app/api/billing/route'; import { GET as billingRoute } from '@/app/api/billing/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request'; import { apiRequest, callRoute, readData, readError } from '../helpers/request';
@@ -677,3 +678,147 @@ describe('repeated and concurrent cancellation reasons', () => {
} }
}); });
}); });
describe('cancellation analytics through the route', () => {
beforeEach(() => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
});
it.each(['canceled subscription', 'empty customer'] as const)(
'records paid cancellation before sync and deduplicates %s deletion in the same cycle',
async (deletion) => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Leaving after this project' })
);
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(original.id, {
cancel_at_period_end: true,
cancellation_details: { feedback: 'other' },
});
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
userId: user.id,
stripeSubscriptionId: original.id,
reason: 'OTHER',
note: 'Leaving after this project',
});
const where = { userId: user.id, name: 'SUBSCRIPTION_CANCELED' as const };
// This must exist before any later transition can conceal the missing event.
const accepted = await db.analyticsEvent.findMany({ where });
expect(accepted).toHaveLength(1);
expect(accepted[0].dedupeKey).toBe(
`SUBSCRIPTION_CANCELED:${original.id}:${original.items.data[0].current_period_end * 1000}`
);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
stripe.list.mockResolvedValue({
data:
deletion === 'empty customer'
? []
: [{ ...original, status: 'canceled', cancel_at_period_end: false }],
has_more: false,
});
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
await syncStripeCustomerSubscriptions(user.stripeCustomerId!);
const replayed = await db.analyticsEvent.findMany({ where });
expect(replayed.map((event) => event.id)).toEqual([accepted[0].id]);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).subscriptionStatus).toBe(
BillingSubscriptionStatus.CANCELED
);
}
);
it('records cancellation of a paid subscription that does not drive the customer mirror', async () => {
const user = await createSubscribedUser({ stripeCancelAtPeriodEnd: true });
signedInAs(user);
const authoritative = subscription(user);
const other = subscription(user, {
id: 'sub_analytics_other',
cancel_at_period_end: false,
created: unix(-60 * DAY),
});
const stripe = stubStripe([authoritative, other]);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledExactlyOnceWith(other.id, expect.anything());
const events = await db.analyticsEvent.findMany({
where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' },
});
expect(events).toHaveLength(1);
expect(events[0].dedupeKey).toBe(
`SUBSCRIPTION_CANCELED:sub_analytics_other:${other.items.data[0].current_period_end * 1000}`
);
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeSubscriptionId).toBe(
authoritative.id
);
});
it('records no cancellation event when Stripe rejects the paid cancellation', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
stripe.update.mockRejectedValueOnce(
Object.assign(new Error('Stripe rejected cancellation'), {
type: 'StripeInvalidRequestError',
})
);
const response = await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }));
expect(response.status).toBe(409);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(
await db.analyticsEvent.count({ where: { userId: user.id, name: 'SUBSCRIPTION_CANCELED' } })
).toBe(0);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(0);
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(false);
});
it('keeps the cancellation and reason when analytics recording fails', async () => {
const user = await createSubscribedUser();
signedInAs(user);
const original = subscription(user);
const stripe = stubStripe([original]);
const recording = vi
.spyOn(db.analyticsEvent, 'createMany')
.mockRejectedValue(new Error('Analytics unavailable'));
try {
const response = await callRoute(
cancelRoute,
cancelRequest({ reason: 'OTHER', note: 'Keep this answer' })
);
expect(response.status).toBe(200);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(recording).toHaveBeenCalledWith(
expect.objectContaining({
data: [expect.objectContaining({ name: 'SUBSCRIPTION_CANCELED', userId: user.id })],
})
);
expect(await db.subscriptionCancellation.findFirstOrThrow()).toMatchObject({
reason: 'OTHER',
note: 'Keep this answer',
});
expect(
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).stripeCancelAtPeriodEnd
).toBe(true);
} finally {
recording.mockRestore();
}
});
it('still cancels without recording analytics when the feature is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const user = await createSubscribedUser();
signedInAs(user);
const stripe = stubStripe([subscription(user)]);
expect((await callRoute(cancelRoute, cancelRequest({ reason: 'OTHER' }))).status).toBe(200);
expect(stripe.update).toHaveBeenCalledTimes(1);
expect(await db.subscriptionCancellation.count({ where: { userId: user.id } })).toBe(1);
expect(await db.analyticsEvent.count({ where: { userId: user.id } })).toBe(0);
});
});
+339
View File
@@ -0,0 +1,339 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type Stripe from 'stripe';
import { Pool } from 'pg';
import {
buildBillingAccessWhereInput,
hasBillingAccess,
syncStripeCustomerSubscriptions,
} from '@/lib/billing';
import { getStripe } from '@/lib/stripe';
import { db } from '../helpers/db';
import { createUser } from '../factories';
// Real PostgreSQL persistence and advisory locks; only Stripe responses are emulated.
// The held response models transport delay, not Stripe's actual webhook scheduling.
const CUSTOMER = 'cus_sync_concurrency';
const PRICE = 'price_sync_concurrency';
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
function subscription(
status: 'active' | 'canceled',
id = 'sub_paid',
customer = CUSTOMER
): Stripe.Subscription {
const now = Math.floor(Date.now() / 1000);
return {
id,
customer,
status,
created: now - 86_400,
trial_end: null,
cancel_at: null,
cancel_at_period_end: false,
ended_at: status === 'canceled' ? now - 60 : null,
canceled_at: status === 'canceled' ? now - 60 : null,
items: {
data: [
{
price: { id: PRICE },
current_period_start: now - 86_400,
current_period_end: now + 30 * 86_400,
},
],
},
} as Stripe.Subscription;
}
async function seed(status: 'ACTIVE' | 'CANCELED' = 'CANCELED', customer = CUSTOMER) {
return createUser({
stripeCustomerId: customer,
stripeSubscriptionId: `sub_seed_${customer}`,
stripePriceId: PRICE,
subscriptionStatus: status,
stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 86_400_000),
trialEndsAt: null,
billingTrialConsumedAt: new Date(Date.now() - 60 * 86_400_000),
billingAccessEndedAt: status === 'CANCELED' ? new Date(Date.now() - 60_000) : null,
});
}
function installStripe() {
const list = vi.fn<
(params: Stripe.SubscriptionListParams) => Promise<{
data: Stripe.Subscription[];
has_more: boolean;
}>
>();
vi.mocked(getStripe).mockReturnValue({ subscriptions: { list } } as unknown as Stripe);
return list;
}
async function waitForQueuedSync(customer = CUSTOMER) {
// Observe a real waiter rather than sleeping and assuming the other request ran.
// Replacing the database lock with a process-local mutex fails this assertion.
await vi.waitFor(
async () => {
const rows = await db.$queryRaw<{ waiting: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory' AND NOT granted
AND classid = hashtext('stripe-subscription-sync')::oid
AND objid = hashtext(${customer})::oid AND objsubid = 2
) AS waiting
`;
expect(rows).toEqual([{ waiting: true }]);
},
{ timeout: 2_000, interval: 20 }
);
}
async function assertAccess(userId: string, expected: boolean) {
const stored = await db.user.findUniqueOrThrow({ where: { id: userId } });
expect(hasBillingAccess(stored)).toBe(expected);
expect(
await db.user.findMany({
where: { AND: [{ id: userId }, buildBillingAccessWhereInput()] },
select: { id: true },
})
).toEqual(expected ? [{ id: userId }] : []);
return stored;
}
beforeEach(() => {
vi.stubEnv('STRIPE_PRICE_ID', PRICE);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
});
describe('customer-wide Stripe sync serialization', () => {
it.each(['canceled-then-paid', 'paid-then-canceled', 'empty-then-paid'] as const)(
'keeps the newer snapshot for overlapping %s reads',
async (order) => {
const paidFirst = order === 'paid-then-canceled';
const user = await seed(paidFirst ? 'ACTIVE' : 'CANCELED');
const stale =
order === 'empty-then-paid'
? []
: [subscription(paidFirst ? 'active' : 'canceled', paidFirst ? 'sub_paid' : 'sub_old')];
const fresh = subscription(paidFirst ? 'canceled' : 'active');
const list = installStripe();
const entered = deferred();
const release = deferred();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
return { data: stale, has_more: false };
});
list.mockImplementationOnce(async () => {
// Reading Stripe for the next sync must wait for the previous mirror commit.
const previous = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(previous.stripeSubscriptionId).toBe(stale[0]?.id ?? null);
return { data: [fresh], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
// Attach handlers immediately so assertion failures still drain both requests.
void first.catch(() => {});
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await waitForQueuedSync();
expect(list).toHaveBeenCalledTimes(1);
const unchanged = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(unchanged.stripeSubscriptionId).toBe(user.stripeSubscriptionId);
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).resolves.not.toBeNull();
await expect(second!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(2);
const stored = await assertAccess(user.id, !paidFirst);
expect(stored.subscriptionStatus).toBe(paidFirst ? 'CANCELED' : 'ACTIVE');
expect(stored.stripeSubscriptionId).toBe('sub_paid');
expect(
await db.analyticsEvent.findMany({
where: { userId: user.id },
select: { name: true },
})
).toEqual([{ name: paidFirst ? 'SUBSCRIPTION_CANCELED' : 'SUBSCRIPTION_STARTED' }]);
}
);
it('honors the customer lock held by an independent database connection before reading Stripe', async () => {
const user = await seed();
const list = installStripe().mockResolvedValue({
data: [subscription('active')],
has_more: false,
});
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
const connection = await pool.connect();
let pending: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
try {
await connection.query('BEGIN');
await connection.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [
'stripe-subscription-sync',
CUSTOMER,
]);
pending = syncStripeCustomerSubscriptions(CUSTOMER);
void pending.catch(() => {});
await waitForQueuedSync();
expect(list).not.toHaveBeenCalled();
} finally {
await connection.query('ROLLBACK');
connection.release();
await pool.end();
if (pending) await Promise.allSettled([pending]);
}
await expect(pending!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(1);
await assertAccess(user.id, true);
});
it('lets a different customer sync while the first customer waits on Stripe', async () => {
await seed();
const otherCustomer = 'cus_sync_independent';
const otherUser = await seed('CANCELED', otherCustomer);
const entered = deferred();
const release = deferred();
const list = installStripe().mockImplementation(async ({ customer }) => {
if (customer === CUSTOMER) {
entered.resolve();
await release.promise;
}
return { data: [subscription('active', `sub_${customer}`, customer)], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
let secondFinished = false;
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(otherCustomer);
void second.then(
() => {
secondFinished = true;
},
() => {
secondFinished = true;
}
);
await vi.waitFor(() => expect(secondFinished).toBe(true), { timeout: 2_000 });
await expect(second).resolves.not.toBeNull();
await assertAccess(otherUser.id, true);
expect(list).toHaveBeenCalledTimes(2);
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).resolves.not.toBeNull();
});
it('releases a failed sync for its queued successor without changing the original mirror', async () => {
const user = await seed();
const entered = deferred();
const release = deferred();
const failure = new Error('Emulated Stripe read failure');
const list = installStripe();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
throw failure;
});
list.mockImplementationOnce(async () => {
expect(await db.user.findUniqueOrThrow({ where: { id: user.id } })).toEqual(user);
return { data: [subscription('active')], has_more: false };
});
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
try {
await entered.promise;
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await waitForQueuedSync();
} finally {
release.resolve();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
await expect(first).rejects.toBe(failure);
await expect(second!).resolves.not.toBeNull();
expect(list).toHaveBeenCalledTimes(2);
await assertAccess(user.id, true);
});
it('cannot overwrite a newer mirror when a Stripe response arrives after transaction expiry', async () => {
const user = await seed();
const entered = deferred();
const release = deferred();
const callbackFinished = deferred();
const list = installStripe();
list.mockImplementationOnce(async () => {
entered.resolve();
await release.promise;
return { data: [subscription('canceled', 'sub_stale')], has_more: false };
});
list.mockResolvedValueOnce({ data: [subscription('active', 'sub_new')], has_more: false });
// Keep the real transaction and expiry machinery, shortening only the first
// request's deadline. Its callback can outlive rollback while Stripe is held.
const transact = db.$transaction.bind(db);
const transaction = vi.spyOn(db, '$transaction').mockImplementationOnce((callback, options) =>
transact(
async (tx) => {
try {
return await callback(tx);
} finally {
callbackFinished.resolve();
}
},
{ ...options, timeout: 200 }
)
);
const first = syncStripeCustomerSubscriptions(CUSTOMER);
void first.catch(() => {});
let second: ReturnType<typeof syncStripeCustomerSubscriptions> | undefined;
let committed: Awaited<ReturnType<typeof assertAccess>> | undefined;
try {
await entered.promise;
// Wait for PostgreSQL to release A's lock, not an assumed sleep duration.
await vi.waitFor(
async () => {
const rows = await db.$queryRaw<{ held: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory' AND granted
AND classid = hashtext('stripe-subscription-sync')::oid
AND objid = hashtext(${CUSTOMER})::oid AND objsubid = 2
) AS held
`;
expect(rows).toEqual([{ held: false }]);
},
{ timeout: 3_000, interval: 20 }
);
second = syncStripeCustomerSubscriptions(CUSTOMER);
void second.catch(() => {});
await expect(second).resolves.not.toBeNull();
committed = await assertAccess(user.id, true);
expect(committed.stripeSubscriptionId).toBe('sub_new');
} finally {
release.resolve();
// The outer promise may reject on expiry before its callback finishes.
// Drain both so a late global-client write cannot escape the assertions.
await Promise.allSettled([first, ...(second ? [second] : [])]);
await callbackFinished.promise;
transaction.mockRestore();
}
await expect(first).rejects.toMatchObject({ code: 'P2028' });
expect(list).toHaveBeenCalledTimes(2);
expect(await assertAccess(user.id, true)).toEqual(committed);
});
});
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import SettingsPage from '@/app/(dashboard)/settings/settings-page-client';
// API scaling expectations are literal, independent of production Intl logic.
// USD, JPY, KRW: https://docs.stripe.com/currencies#zero-decimal
// ISK, UGX: https://docs.stripe.com/currencies#special-cases
// KWD: https://support.stripe.com/questions/which-payments-methods-and-products-are-available-in-the-uae?locale=en-GB
// KWD support is account/region dependent. This is a synthetic component fixture,
// not evidence that the configured billing account accepts KWD invoices.
const cases = [
{ currency: 'usd', amountDue: 1099, expected: '$10.99' },
{ currency: 'jpy', amountDue: 500, expected: '¥500' },
{ currency: 'krw', amountDue: 500, expected: '₩500' },
{ currency: 'kwd', amountDue: 12340, expected: 'KWD 12.340' },
{ currency: 'isk', amountDue: 500, expected: 'ISK 5' },
{ currency: 'ugx', amountDue: 500, expected: 'UGX 5' },
];
const NumberFormat = Intl.NumberFormat;
beforeEach(() => {
// Pin the locale while retaining the real currency precision and formatting.
vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (locales, options) {
return new NumberFormat(locales ?? 'en-US', options);
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('actual Settings invoice display against Stripe currency contract', () => {
it.each(cases)('$currency amount_due=$amountDue displays $expected', async (fixture) => {
expect(new Intl.NumberFormat().resolvedOptions().locale).toBe('en-US');
const fetchMock = vi.fn(async (url: string) => {
if (url !== '/api/billing') return { ok: false };
return {
ok: true,
json: async () => ({
data: {
isEnabled: true,
isConfigured: true,
status: 'ready',
checkoutAvailable: false,
portalAvailable: false,
cancelAvailable: false,
cancelIsImmediate: true,
needsPaymentFix: true,
openInvoice: {
id: 'in_currency_fixture',
hostedInvoiceUrl: null,
amountDue: fixture.amountDue,
currency: fixture.currency,
attemptCount: 1,
nextPaymentAttempt: null,
},
workspaceCreation: { canCreateWorkspace: true, canStartTrial: false },
subscription: {
status: 'PAST_DUE',
label: 'Past due',
hasActiveSubscription: false,
hasRecoverableSubscription: true,
hasActiveTrial: false,
hasBillingAccess: true,
isPaid: false,
priceId: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
cancelAt: null,
trialEndsAt: null,
billingAccessEndedAt: null,
storageCleanupEligibleAt: null,
},
},
}),
};
});
vi.stubGlobal('fetch', fetchMock);
render(<SettingsPage billingOnly />);
const banner = await screen.findByText(/^A payment of .* did not go through$/);
const actual = banner.textContent!.replace(/\s+/g, ' ');
expect(fetchMock.mock.calls.some(([url]) => url === '/api/billing')).toBe(true);
expect(actual).toBe(`A payment of ${fixture.expected} did not go through`);
});
});
+6
View File
@@ -36,6 +36,8 @@ import {
} from '@/lib/billing'; } from '@/lib/billing';
const dbMock = vi.hoisted(() => ({ const dbMock = vi.hoisted(() => ({
$transaction: vi.fn(),
$executeRaw: vi.fn(),
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() }, workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() }, workspaceMember: { count: vi.fn() },
@@ -941,6 +943,10 @@ describe('database backed billing helpers', () => {
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE); vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
dbMock.$transaction
.mockReset()
.mockImplementation(async (work: (tx: typeof dbMock) => Promise<unknown>) => work(dbMock));
dbMock.$executeRaw.mockReset().mockResolvedValue(0);
dbMock.user.findUnique.mockReset(); dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset(); dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset(); dbMock.user.updateMany.mockReset();