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