mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): mirror Stripe billing data using the shapes the pinned API version sends (#1708)
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user