diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx
index 96cf3ff..0a6234f 100644
--- a/app/(dashboard)/settings/settings-page-client.tsx
+++ b/app/(dashboard)/settings/settings-page-client.tsx
@@ -33,11 +33,7 @@ import { cn } from '@/lib/utils';
import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog';
import type { CancellationReason } from '@/lib/cancellation-reasons';
-/**
- * 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.
- */
+/** Convert Stripe API units separately from the currency's display precision. */
function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
const currencyCode = currency.toUpperCase();
@@ -47,7 +43,10 @@ function formatInvoiceAmount(amountInMinorUnits: number, currency: string) {
currency: currencyCode,
});
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 {
return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`;
}
diff --git a/components/admin/cancellation-reasons-card.tsx b/components/admin/cancellation-reasons-card.tsx
index 7b77966..1978beb 100644
--- a/components/admin/cancellation-reasons-card.tsx
+++ b/components/admin/cancellation-reasons-card.tsx
@@ -78,7 +78,9 @@ export async function CancellationReasonsCard() {
{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')}`
+ : ''}
{getCancellationReasonLabel(row.reason)}
diff --git a/lib/analytics/billing-events.ts b/lib/analytics/billing-events.ts
index e7fc5f9..b5b9435 100644
--- a/lib/analytics/billing-events.ts
+++ b/lib/analytics/billing-events.ts
@@ -1,11 +1,7 @@
-// Turning Stripe state into funnel events.
-//
-// These four events are derived from a before/after comparison inside the sync
-// that already re-reads every subscription a customer has, rather than from the
-// 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.
+// Turning Stripe state and accepted cancellations into funnel events.
+// Sync compares before/after state; in-app cancellation also records acceptance
+// because its local claim can hide that transition. Shared cycle keys make both
+// paths and replayed webhooks count the same cancellation once.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
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 {
+ await recordEvent({
+ name: 'SUBSCRIPTION_CANCELED',
+ dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
+ userId: params.userId,
+ });
+}
+
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
- await recordEvent({
- name: 'SUBSCRIPTION_CANCELED',
- dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
+ await recordSubscriptionCancellation({
userId,
+ subscriptionId,
+ currentPeriodEnd: after.currentPeriodEnd,
});
}
diff --git a/lib/billing.ts b/lib/billing.ts
index 1183abb..4c06923 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -911,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
+ return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
+}
+
+async function writeStripeSubscriptionToUser(
+ subscription: Stripe.Subscription,
+ client: Prisma.TransactionClient
+) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
- const user = await db.user.findUnique({
+ const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
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.
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
- const updated = await db.user.update({
+ const updated = await client.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -986,7 +993,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
},
});
- await recordSubscriptionTransition({
+ const transition: Parameters[0] = {
userId: user.id,
subscriptionId: subscription.id,
before: {
@@ -1000,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
- });
+ };
- return updated;
+ return { updated, transition };
+}
+
+async function recordSyncedSubscription(
+ result: Awaited>
+) {
+ 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
@@ -1055,28 +1072,49 @@ export function selectAuthoritativeSubscription(
// 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
// 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) {
- const stripe = getStripe();
- const { data: subscriptions } = await stripe.subscriptions.list({
- customer: customerId,
- status: 'all',
- limit: 100,
- });
+ const result = await db.$transaction(
+ async (tx) => {
+ // Two-key advisory locks occupy a separate namespace from the one-key
+ // cancellation locks. Cancellation releases its lock before calling sync.
+ 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);
- if (!authoritative) {
- return markSubscriptionCanceledByCustomerId(customerId);
- }
-
- return syncStripeSubscriptionToUser(authoritative);
+ const authoritative = selectAuthoritativeSubscription(subscriptions);
+ return authoritative
+ ? writeStripeSubscriptionToUser(authoritative, tx)
+ : writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
+ },
+ // 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(
customerId: string,
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 },
select: {
id: true,
@@ -1098,7 +1136,7 @@ export async function markSubscriptionCanceledByCustomerId(
// date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = user.trialEndsAt ?? null;
- const updated = await db.user.update({
+ const updated = await client.user.update({
where: { id: user.id },
data: {
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
// "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.
- await recordSubscriptionTransition({
+ const transition: Parameters[0] = {
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
@@ -1130,9 +1168,9 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
- });
+ };
- return updated;
+ return { updated, transition };
}
/**
diff --git a/lib/cancellation.ts b/lib/cancellation.ts
index ecd8d3a..b3cc4bd 100644
--- a/lib/cancellation.ts
+++ b/lib/cancellation.ts
@@ -10,6 +10,7 @@ import {
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
import { logError } from '@/lib/logger';
+import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
export {
CANCELLATION_NOTE_MAX_LENGTH,
@@ -175,6 +176,14 @@ export async function cancelSubscription(params: {
const periodEndUnix = getSubscriptionPeriodEnd(original);
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) => {
// 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
diff --git a/tests/api/billing-cancel.test.ts b/tests/api/billing-cancel.test.ts
index 0884198..1a1e42a 100644
--- a/tests/api/billing-cancel.test.ts
+++ b/tests/api/billing-cancel.test.ts
@@ -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 { BillingSubscriptionStatus, type User } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe } from '@/lib/stripe';
+import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { POST as cancelRoute } from '@/app/api/billing/cancel/route';
import { GET as billingRoute } from '@/app/api/billing/route';
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);
+ });
+});
diff --git a/tests/api/billing-sync-concurrency.test.ts b/tests/api/billing-sync-concurrency.test.ts
new file mode 100644
index 0000000..d118ade
--- /dev/null
+++ b/tests/api/billing-sync-concurrency.test.ts
@@ -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((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 | 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 | 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 | 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 | 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 | undefined;
+ let committed: Awaited> | 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);
+ });
+});
diff --git a/tests/component/settings-currency.test.tsx b/tests/component/settings-currency.test.tsx
new file mode 100644
index 0000000..60eeb31
--- /dev/null
+++ b/tests/component/settings-currency.test.tsx
@@ -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();
+ 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`);
+ });
+});
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index 31450f4..7de40a4 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -36,6 +36,8 @@ import {
} from '@/lib/billing';
const dbMock = vi.hoisted(() => ({
+ $transaction: vi.fn(),
+ $executeRaw: vi.fn(),
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
@@ -941,6 +943,10 @@ describe('database backed billing helpers', () => {
vi.setSystemTime(NOW);
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true');
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
+ dbMock.$transaction
+ .mockReset()
+ .mockImplementation(async (work: (tx: typeof dbMock) => Promise) => work(dbMock));
+ dbMock.$executeRaw.mockReset().mockResolvedValue(0);
dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset();