diff --git a/fluxer_api/src/api/admin/controllers/BillingAdminController.ts b/fluxer_api/src/api/admin/controllers/BillingAdminController.ts index b29c393e7..561e77e04 100644 --- a/fluxer_api/src/api/admin/controllers/BillingAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BillingAdminController.ts @@ -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 diff --git a/fluxer_api/src/api/admin/tests/AdminBillingOverview.test.ts b/fluxer_api/src/api/admin/tests/AdminBillingOverview.test.ts index 74fa0cb1c..342ec84ff 100644 --- a/fluxer_api/src/api/admin/tests/AdminBillingOverview.test.ts +++ b/fluxer_api/src/api/admin/tests/AdminBillingOverview.test.ts @@ -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', diff --git a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts index 029c81023..259f8e31c 100644 --- a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts +++ b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts @@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => { const inv = stripeFixture({ 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', diff --git a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.ts b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.ts index 2752c8941..c0d98f45f 100644 --- a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.ts +++ b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.ts @@ -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; diff --git a/fluxer_api/src/api/billing/repositories/BillingCheckoutSessionRepository.ts b/fluxer_api/src/api/billing/repositories/BillingCheckoutSessionRepository.ts index eeee0f296..600d463d4 100644 --- a/fluxer_api/src/api/billing/repositories/BillingCheckoutSessionRepository.ts +++ b/fluxer_api/src/api/billing/repositories/BillingCheckoutSessionRepository.ts @@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository { cs: Stripe.Checkout.Session, hints?: { knownUserId?: bigint; + eventCreated?: number; }, ): Promise<{ changed: boolean; diff --git a/fluxer_api/src/api/billing/repositories/BillingRefundRepository.ts b/fluxer_api/src/api/billing/repositories/BillingRefundRepository.ts index 645fd0cf4..3c42999f7 100644 --- a/fluxer_api/src/api/billing/repositories/BillingRefundRepository.ts +++ b/fluxer_api/src/api/billing/repositories/BillingRefundRepository.ts @@ -71,6 +71,7 @@ export class BillingRefundRepository { invoiceId?: string; customerId?: string; userId?: bigint; + livemode?: boolean; }, ): Promise<{ changed: boolean; diff --git a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts index 900ad7d3c..0d8c22960 100644 --- a/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts +++ b/fluxer_api/src/api/stripe/StripeSubscriptionPeriod.ts @@ -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; -} diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 936ab3d3d..54d0870e5 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -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) { diff --git a/fluxer_api/src/api/stripe/services/StripeSubscriptionReconciler.ts b/fluxer_api/src/api/stripe/services/StripeSubscriptionReconciler.ts index 134d10b24..610e60865 100644 --- a/fluxer_api/src/api/stripe/services/StripeSubscriptionReconciler.ts +++ b/fluxer_api/src/api/stripe/services/StripeSubscriptionReconciler.ts @@ -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; } } } diff --git a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts index ca7291c76..e80b050d0 100644 --- a/fluxer_api/src/api/stripe/services/StripeWebhookService.ts +++ b/fluxer_api/src/api/stripe/services/StripeWebhookService.ts @@ -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 { + 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> { + 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 { + 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): Promise { try { await fn(); diff --git a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts index 7014ade87..5e1fdd9b2 100644 --- a/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeRefundService.test.ts @@ -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', diff --git a/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts b/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts index a7a6117a0..4f712ab54 100644 --- a/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripeWebhookInvoice.test.ts @@ -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, diff --git a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts index 209c3836f..d67e14630 100644 --- a/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts +++ b/fluxer_api/src/api/test/msw/handlers/StripeApiHandlers.ts @@ -59,7 +59,7 @@ interface StripeApiMockConfig { subscriptionsListEmpty?: boolean; charges?: Record>; customers?: Record>; - invoices?: Record>; + invoices?: Record & {subscriptionId?: string}>; paymentIntents?: Record>; paymentMethods?: Record>; setupIntents?: Record>; @@ -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,