mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(billing): serialize reconciliation and record accepted cancellations
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-18
@@ -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) => {
|
||||||
|
// 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,
|
customer: customerId,
|
||||||
status: 'all',
|
status: 'all',
|
||||||
limit: 100,
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user