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