fix(api): mirror Stripe billing data using the shapes the pinned API version sends (#1708)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-18 02:35:58 +02:00
1 parent 1922d56188
commit 7e1ca9ed6a
13 files changed
+160 -153

No files matched your search

@@ -742,6 +742,9 @@ class BillingAdminControllerService {
refundTarget,
subscription,
});
if (refundDecision.amountCents !== null && !refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
const intentId = await this.billingRepository.actionIntents.create({
userId: BigInt(syncedTargetUser.id),
actorAdminId: BigInt(params.adminUserId),
@@ -758,10 +761,7 @@ class BillingAdminControllerService {
await this.billingRepository.actionIntents.markStage(intentId, 'sub_canceled', {
sub_canceled_at: new Date(),
});
if (refundDecision.amountCents !== null) {
if (!refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
if (refundDecision.amountCents !== null && refundTarget) {
refund = await this.stripe.refunds.create(
{
...(refundTarget.paymentIntentId
@@ -283,7 +283,7 @@ describe('Admin billing overview', () => {
invoices: {
[params.invoiceId]: {
customer: params.stripeCustomerId,
subscription: params.stripeSubscriptionId,
subscriptionId: params.stripeSubscriptionId,
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
billing_reason: 'subscription_cycle',
@@ -359,7 +359,7 @@ describe('Admin billing overview', () => {
invoices: {
in_local_checkout_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
@@ -396,7 +396,7 @@ describe('Admin billing overview', () => {
},
in_renewal_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_cycle',
@@ -545,7 +545,7 @@ describe('Admin billing overview', () => {
invoices: {
[invoiceId]: {
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
subscriptionId: stripeSubscriptionId,
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
@@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => {
const inv = stripeFixture<Stripe.Invoice>({
id: 'in_1',
customer: 'cus_1',
subscription: 'sub_1',
parent: {type: 'subscription_details', subscription_details: {subscription: 'sub_1'}},
status: 'paid',
number: 'INV-001',
currency: 'usd',
@@ -94,28 +94,6 @@ export interface StripeSubscriptionPayload {
trial_start?: number | null;
}
interface StripeInvoiceCompatibility extends Stripe.Invoice {
subscription?: StripeExpandableId;
tax?: number | null;
application_fee_amount?: number | null;
}
interface StripePaymentIntentCompatibility extends Stripe.PaymentIntent {
invoice?: StripeExpandableId;
}
interface StripeChargeCompatibility extends Stripe.Charge {
invoice?: StripeExpandableId;
}
type StripeRefundCompatibility = Stripe.Refund & {
livemode?: boolean | null;
};
interface StripeCheckoutSessionCompatibility extends Stripe.Checkout.Session {
completed_at?: number | null;
}
function createBillingSubscriptionItemValue(
itemId: string,
priceId: string,
@@ -280,6 +258,14 @@ export function computeStripeUpdatedAt(
return new Date(max * 1000);
}
function sumInvoiceTotalTaxes(inv: Stripe.Invoice): bigint | null {
const taxes = inv.total_taxes ?? null;
if (taxes === null) {
return null;
}
return BigInt(taxes.reduce((total, tax) => total + (tax.amount ?? 0), 0));
}
function idOf(
ref:
| string
@@ -689,9 +675,7 @@ export function mapStripeInvoiceToRow(
throw new BillingMappingError('Invoice is missing customer', inv.id);
}
const now = new Date();
const invoice = inv as StripeInvoiceCompatibility;
const subscriptionId =
idOf(invoice.subscription ?? null) ?? idOf(invoice.parent?.subscription_details?.subscription ?? null);
const subscriptionId = idOf(inv.parent?.subscription_details?.subscription ?? null);
const userIdFromMetadata = parseUserIdFromMetadata(inv.metadata);
const userId = hints?.knownUserId ?? userIdFromMetadata;
const transitions = inv.status_transitions ?? null;
@@ -718,12 +702,11 @@ export function mapStripeInvoiceToRow(
amount_paid: typeof inv.amount_paid === 'number' ? BigInt(inv.amount_paid) : null,
amount_remaining: typeof inv.amount_remaining === 'number' ? BigInt(inv.amount_remaining) : null,
subtotal: typeof inv.subtotal === 'number' ? BigInt(inv.subtotal) : null,
tax: typeof invoice.tax === 'number' ? BigInt(invoice.tax) : null,
tax: sumInvoiceTotalTaxes(inv),
total: typeof inv.total === 'number' ? BigInt(inv.total) : null,
starting_balance: typeof inv.starting_balance === 'number' ? BigInt(inv.starting_balance) : null,
ending_balance: typeof inv.ending_balance === 'number' ? BigInt(inv.ending_balance) : null,
application_fee_amount:
typeof invoice.application_fee_amount === 'number' ? BigInt(invoice.application_fee_amount) : null,
application_fee_amount: null,
attempt_count: inv.attempt_count ?? null,
attempted: inv.attempted ?? null,
auto_advance: inv.auto_advance ?? null,
@@ -818,7 +801,6 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
throw new BillingMappingError('PaymentIntent is missing id');
}
const now = new Date();
const paymentIntent = pi as StripePaymentIntentCompatibility;
const customerId = idOf(
pi.customer as
| string
@@ -828,7 +810,7 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
| null
| undefined,
);
const invoiceId = idOf(paymentIntent.invoice ?? null);
const invoiceId = null;
const paymentMethodId = idOf(
pi.payment_method as
| string
@@ -892,7 +874,6 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
throw new BillingMappingError('Charge is missing id');
}
const now = new Date();
const charge = c as StripeChargeCompatibility;
const customerId = idOf(
c.customer as
| string
@@ -911,7 +892,7 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
| null
| undefined,
);
const invoiceId = idOf(charge.invoice ?? null);
const invoiceId = null;
const paymentMethodId = c.payment_method ?? null;
const card = c.payment_method_details?.card ?? null;
const billingDetails = c.billing_details ?? null;
@@ -974,6 +955,7 @@ export function mapStripeRefundToRow(
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): {
primary: BillingRefundRow;
@@ -990,7 +972,6 @@ export function mapStripeRefundToRow(
const invoiceId = hints?.invoiceId ?? null;
const customerId = hints?.customerId ?? null;
const userId = hints?.userId ?? null;
const refund = r as StripeRefundCompatibility;
const primary: BillingRefundRow = {
provider_id: r.id,
charge_id: chargeId,
@@ -1005,7 +986,7 @@ export function mapStripeRefundToRow(
receipt_number: r.receipt_number ?? null,
failure_reason: r.failure_reason ?? null,
description: r.description ?? null,
livemode: refund.livemode ?? null,
livemode: hints?.livemode ?? null,
metadata: safeMetadata(r.metadata),
stripe_created_at: unixToDate(r.created),
stripe_updated_at: computeStripeUpdatedAt({created: r.created}),
@@ -1049,16 +1030,17 @@ export function mapStripeCheckoutSessionToRow(
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): {
primary: BillingCheckoutSessionRow;
byCustomer: BillingCheckoutSessionByCustomerRow | null;
} {
const completedAt = cs.status === 'complete' ? (hints?.eventCreated ?? null) : null;
if (!cs.id) {
throw new BillingMappingError('Checkout session is missing id');
}
const now = new Date();
const checkoutSession = cs as StripeCheckoutSessionCompatibility;
const customerId = idOf(
cs.customer as
| string
@@ -1124,14 +1106,14 @@ export function mapStripeCheckoutSessionToRow(
amount_total: typeof cs.amount_total === 'number' ? BigInt(cs.amount_total) : null,
currency: cs.currency ?? null,
expires_at: unixToDate(cs.expires_at),
completed_at: unixToDate(checkoutSession.completed_at ?? null),
completed_at: completedAt === null ? null : unixToDate(completedAt),
livemode: cs.livemode ?? null,
client_reference_id: cs.client_reference_id ?? null,
metadata: safeMetadata(cs.metadata),
stripe_created_at: unixToDate(cs.created),
stripe_updated_at: computeStripeUpdatedAt({
created: cs.created,
completed_at: checkoutSession.completed_at ?? null,
completed_at: completedAt,
}),
mirrored_at: now,
} as BillingCheckoutSessionRow;
@@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository {
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): Promise<{
changed: boolean;
@@ -71,6 +71,7 @@ export class BillingRefundRepository {
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): Promise<{
changed: boolean;
@@ -115,15 +115,3 @@ export function getFirstInvoicePaymentIntentId(invoice: Stripe.Invoice | null):
}
return paymentIntent.id ?? null;
}
export function getFirstInvoicePaymentIntentLatestChargeId(invoice: Stripe.Invoice | null): string | null {
const paymentIntent = getFirstInvoicePaymentIntent(invoice);
if (!paymentIntent || typeof paymentIntent === 'string') {
return null;
}
const latestCharge = paymentIntent.latest_charge ?? null;
if (typeof latestCharge === 'string') {
return latestCharge;
}
return latestCharge?.id ?? null;
}
@@ -39,7 +39,6 @@ interface RefundTarget {
type StripeInvoiceWithPayments = Stripe.Invoice & {
customer?: string | Stripe.Customer | null;
subscription?: string | Stripe.Subscription | null;
payments?: {
data?: Array<{
payment?: {
@@ -54,6 +53,10 @@ type StripeInvoiceWithPayments = Stripe.Invoice & {
} | null;
};
function resolveInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
return extractId(invoice.parent?.subscription_details?.subscription ?? null);
}
function getInvoicePaymentRef(invoice: Stripe.Invoice): {
chargeId: string | null;
paymentIntentId: string | null;
@@ -132,7 +135,7 @@ export class StripeRefundService {
chargeId: ref.chargeId,
paymentIntentId: ref.paymentIntentId,
paidAt,
subscriptionId: extractId((invoice as StripeInvoiceWithPayments).subscription),
subscriptionId: resolveInvoiceSubscriptionId(invoice),
};
}
} catch (error) {
@@ -260,37 +260,13 @@ export class StripeSubscriptionReconciler {
}
getPriceIdFromInvoice(invoice: Stripe.Invoice): string | null {
type InvoiceLineWithPrice = Stripe.InvoiceLineItem & {
price?: string | Stripe.Price | null;
pricing?: {
price_details?: {
price?: string;
};
};
parent?: {
subscription_item_details?: {
price?: {
price?: string;
};
};
};
};
if (!invoice.lines?.data?.length) {
return null;
}
for (const line of invoice.lines.data) {
const lineWithPrice = line as InvoiceLineWithPrice;
const directPriceId = extractId(lineWithPrice.price);
if (directPriceId) {
return directPriceId;
}
const nestedPriceId = lineWithPrice.pricing?.price_details?.price;
if (nestedPriceId) {
return extractId(nestedPriceId);
}
const parentNestedPriceId = lineWithPrice.parent?.subscription_item_details?.price?.price;
if (parentNestedPriceId) {
return extractId(parentNestedPriceId);
const priceId = extractId(line.pricing?.price_details?.price ?? null);
if (priceId) {
return priceId;
}
}
return null;
@@ -328,39 +304,15 @@ export class StripeSubscriptionReconciler {
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
type InvoiceWithSubscription = Stripe.Invoice & {
subscription?: string | Stripe.Subscription;
};
const invoiceWithSubscription = invoice as InvoiceWithSubscription;
const directSubscription = invoiceWithSubscription.subscription;
if (directSubscription) {
return extractId(directSubscription);
}
type InvoiceWithParent = Stripe.Invoice & {
parent?: {
subscription_details?: {
subscription?: string;
};
};
};
type InvoiceLineWithParent = Stripe.InvoiceLineItem & {
parent?: {
subscription_item_details?: {
subscription?: string;
};
};
};
const invoiceWithParent = invoice as InvoiceWithParent;
const parentSubscription = invoiceWithParent.parent?.subscription_details?.subscription;
const parentSubscription = extractId(invoice.parent?.subscription_details?.subscription ?? null);
if (parentSubscription) {
return extractId(parentSubscription);
return parentSubscription;
}
if (invoice.lines?.data?.length) {
for (const line of invoice.lines.data) {
const lineWithParent = line as InvoiceLineWithParent;
const subscriptionId = lineWithParent.parent?.subscription_item_details?.subscription;
const subscriptionId = line.parent?.subscription_item_details?.subscription ?? null;
if (subscriptionId) {
return extractId(subscriptionId);
return subscriptionId;
}
}
}
@@ -150,7 +150,7 @@ export class StripeWebhookService {
case 'checkout.session.completed': {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession),
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession, {eventCreated: event.created}),
);
if (checkoutSession.metadata?.verification_type === 'uk_age_verification' && this.ageVerificationService) {
await this.ageVerificationService.completeVerification(checkoutSession);
@@ -165,44 +165,48 @@ export class StripeWebhookService {
}
case 'checkout.session.async_payment_succeeded': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
await this.checkoutHandler.handleAsyncPaymentSucceeded(cs);
break;
}
case 'checkout.session.async_payment_failed': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
await this.checkoutHandler.handleAsyncPaymentFailed(cs);
break;
}
case 'invoice.paid':
case 'invoice.payment_succeeded': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentSucceeded(event.id, inv);
break;
}
case 'invoice.payment_failed': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentFailed(inv);
break;
}
case 'invoice.payment_action_required': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentActionRequired(inv);
break;
}
case 'invoice.finalization_failed': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoiceFinalizationFailed(inv);
break;
}
case 'invoice.updated': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoiceUpdated(inv);
break;
}
@@ -253,11 +257,12 @@ export class StripeWebhookService {
case 'charge.refunded': {
const c = event.data.object as Stripe.Charge;
await this.safeMirrorUpsert(event, () => this.billingRepository.charges.upsertFromStripe(c));
const refunds = c.refunds?.data ?? [];
const refunds = await this.listChargeRefunds(c);
for (const r of refunds) {
await this.safeMirrorUpsert(event, () =>
this.billingRepository.refunds.upsertFromStripe(r, {
customerId: typeof c.customer === 'string' ? c.customer : (c.customer?.id ?? undefined),
livemode: event.livemode,
}),
);
}
@@ -324,7 +329,13 @@ export class StripeWebhookService {
case 'refund.updated':
case 'refund.failed': {
const r = event.data.object as Stripe.Refund;
await this.safeMirrorUpsert(event, () => this.billingRepository.refunds.upsertFromStripe(r));
const customerId = await this.resolveRefundCustomerId(r);
await this.safeMirrorUpsert(event, () =>
this.billingRepository.refunds.upsertFromStripe(r, {
customerId: customerId ?? undefined,
livemode: event.livemode,
}),
);
break;
}
case 'invoice.created':
@@ -332,12 +343,14 @@ export class StripeWebhookService {
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
break;
}
case 'checkout.session.expired': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
break;
}
case 'charge.dispute.updated':
@@ -353,6 +366,54 @@ export class StripeWebhookService {
}
}
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
if (chargeId !== null) {
const chargeRow = await this.billingRepository.charges.findById(chargeId);
if (chargeRow?.customer_id != null) {
return chargeRow.customer_id;
}
}
const paymentIntentId =
typeof refund.payment_intent === 'string' ? refund.payment_intent : (refund.payment_intent?.id ?? null);
if (paymentIntentId !== null) {
const paymentIntentRow = await this.billingRepository.paymentIntents.findById(paymentIntentId);
if (paymentIntentRow?.customer_id != null) {
return paymentIntentRow.customer_id;
}
}
return null;
}
private async listChargeRefunds(charge: Stripe.Charge): Promise<Array<Stripe.Refund>> {
const inlined = charge.refunds?.data ?? [];
if (inlined.length > 0 || charge.id == null || this.stripe == null) {
return inlined;
}
try {
const listed = await this.stripe.refunds.list({charge: charge.id, limit: 100});
return listed.data;
} catch (listErr) {
Logger.warn({listErr, chargeId: charge.id}, 'Failed to list refunds for charge; skipping refund mirror');
return [];
}
}
private async mirrorInvoice(event: Stripe.Event, inv: Stripe.Invoice): Promise<void> {
let hydrated = inv;
if (inv.payments === undefined && inv.id != null && this.stripe != null) {
try {
hydrated = await this.stripe.invoices.retrieve(inv.id, {expand: ['payments.data.payment']});
} catch (hydrateErr) {
Logger.warn(
{hydrateErr, eventId: event.id, invoiceId: inv.id},
'Failed to hydrate invoice payments; mirroring invoice without payment rows',
);
}
}
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(hydrated));
}
private async safeMirrorUpsert(event: Stripe.Event, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
@@ -22,7 +22,7 @@ interface MockStripeInvoice {
id: string;
object: 'invoice';
customer: string;
subscription: string | null;
parent: {subscription_details: {subscription: string}} | null;
amount_due: number;
amount_paid: number;
currency: string;
@@ -66,7 +66,7 @@ function buildInvoice(opts: {
id: opts.id,
object: 'invoice',
customer: customerId,
subscription: subscriptionId,
parent: subscriptionId == null ? null : {subscription_details: {subscription: subscriptionId}},
amount_due: 2500,
amount_paid: 2500,
currency: 'usd',
@@ -166,7 +166,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -217,7 +217,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -252,7 +252,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: invoiceId,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -300,7 +300,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_create',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -351,7 +351,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -372,7 +372,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -422,7 +422,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -460,7 +460,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_update',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
amount_paid: 0,
amount_due: 0,
total: 0,
@@ -503,7 +503,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_update',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
amount_paid: 1250,
amount_due: 1250,
total: 1250,
@@ -59,7 +59,7 @@ interface StripeApiMockConfig {
subscriptionsListEmpty?: boolean;
charges?: Record<string, Partial<MockStripeCharge>>;
customers?: Record<string, Partial<MockStripeCustomer>>;
invoices?: Record<string, Partial<MockStripeInvoice>>;
invoices?: Record<string, Partial<MockStripeInvoice> & {subscriptionId?: string}>;
paymentIntents?: Record<string, Partial<MockStripePaymentIntent>>;
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
@@ -284,7 +284,10 @@ interface MockStripeInvoice {
url: string;
};
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void' | null;
subscription: string | null;
parent: {
type: 'subscription_details';
subscription_details: {subscription: string};
} | null;
}
interface MockStripeValueList {
@@ -571,13 +574,14 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
});
}
for (const [invoiceId, overrides] of Object.entries(config.invoices ?? {})) {
const {subscriptionId, ...invoiceOverrides} = overrides;
const defaultInvoice = createDefaultInvoice(invoiceId, {
customerId: overrides.customer ?? 'cus_test_1',
subscriptionId: overrides.subscription ?? 'sub_test_1',
subscriptionId: subscriptionId ?? 'sub_test_1',
});
invoiceStore.set(invoiceId, {
...defaultInvoice,
...overrides,
...invoiceOverrides,
id: invoiceId,
object: 'invoice',
payments: overrides.payments ?? defaultInvoice.payments,
@@ -739,7 +743,10 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
url: `/v1/invoices/${id}/payments`,
},
status: 'paid',
subscription: subscriptionId,
parent:
subscriptionId === null
? null
: {type: 'subscription_details', subscription_details: {subscription: subscriptionId}},
};
}
function getPaymentIntent(paymentIntentId: string): MockStripePaymentIntent {
@@ -838,8 +845,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
object: 'subscription',
customer: subState.customer,
status: subState.status,
current_period_start: subState.current_period_start,
current_period_end: subState.current_period_end,
latest_invoice: subState.latest_invoice ? getInvoice(subState.latest_invoice) : null,
trial_end: subState.trial_end,
items: {
@@ -1070,7 +1075,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
const limit = Number.parseInt(requestUrl.searchParams.get('limit') ?? '10', 10);
const sortedInvoices = [...invoiceStore.values()]
.filter((invoice) => !customerId || invoice.customer === customerId)
.filter((invoice) => !subscriptionId || invoice.subscription === subscriptionId)
.filter((invoice) => !subscriptionId || invoice.parent?.subscription_details?.subscription === subscriptionId)
.sort((left, right) => right.created - left.created);
const startIndex = startingAfter ? sortedInvoices.findIndex((invoice) => invoice.id === startingAfter) + 1 : 0;
const pagedInvoices = sortedInvoices.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + limit);
@@ -1460,7 +1465,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
object: 'subscription',
customer,
status: 'active',
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
trial_end: null,
items: {
object: 'list',
@@ -1482,6 +1486,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
livemode: false,
},
quantity: 1,
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
current_period_end: currentPeriodEnd,
},
],
@@ -1879,11 +1884,13 @@ export function createInvoicePaidEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_paid: options.amountPaid ?? 2500,
currency: options.currency ?? 'usd',
status: 'paid',
paid: true,
},
},
};
@@ -1902,7 +1909,10 @@ export function createInvoicePaymentFailedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: 'open',
paid: false,
@@ -1926,7 +1936,10 @@ export function createInvoicePaymentActionRequiredEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: 'open',
paid: false,
@@ -1950,7 +1963,10 @@ export function createInvoiceFinalizationFailedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
status: 'draft',
paid: false,
last_finalization_error: {
@@ -1980,7 +1996,10 @@ export function createInvoiceUpdatedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: options.status ?? 'open',
paid: options.paid ?? false,