From 690f8a2eb1733f50695a940ac57301c2b8bbf753 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 27 Jan 2026 18:42:59 +0000 Subject: [PATCH] chore: fix tests & cleanup --- .../tasks/sendEmailReceipt.ts | 43 +- .../handlers/handleUpdateCustomerV2.ts | 8 - .../invoice-paid/send-email-receipt.test.ts | 59 +- ...subscription-deleted-void-invoices.test.ts | 558 ++++++++++++++++++ .../subscription-deleted.test.ts | 529 ----------------- .../invoice-paid/send-email-receipt.spec.ts | 78 ++- server/tests/utils/fixtures/stripe/clients.ts | 44 ++ 7 files changed, 734 insertions(+), 585 deletions(-) create mode 100644 server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-void-invoices.test.ts diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts index b296973ba..f12288c1e 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.ts @@ -1,6 +1,6 @@ +import { stripeInvoiceIdToPaymentIntent } from "@/external/stripe/invoices/utils/convertStripeInvoice.js"; import type { StripeInvoicePaidContext } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.js"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js"; -import { nullish } from "@/utils/genUtils.js"; /** * Sends an email receipt to the customer by setting `receipt_email` on the PaymentIntent. @@ -16,20 +16,38 @@ export const sendEmailReceipt = async ({ const { stripeCli, logger, fullCustomer } = ctx; const { stripeInvoice } = invoicePaidContext; - // 1. Check if customer exists and has email receipts enabled - if (!fullCustomer) { - logger.debug("[invoice.paid] No fullCustomer, skipping email receipt"); + const stripeCustomerId = fullCustomer?.processor?.id; + if (!stripeCustomerId) { + logger.debug( + "[invoice.paid] Customer has no Stripe ID, skipping email receipt", + ); return; } - if (!fullCustomer.send_email_receipts) { + const stripeCustomer = await stripeCli.customers.retrieve(stripeCustomerId); + + // 1. Check if customer exists and has email receipts enabled + if (!stripeCustomer) { + logger.debug("[invoice.paid] No stripeCustomer, skipping email receipt"); + return; + } + // Check if customer is deleted + if (stripeCustomer.deleted) { + logger.debug( + "[invoice.paid] Stripe customer is deleted, skipping email receipt", + ); + return; + } + + if (!fullCustomer?.send_email_receipts) { logger.debug( "[invoice.paid] Customer has email receipts disabled, skipping", ); return; } - const customerEmail = fullCustomer.email; + const customerEmail = stripeCustomer.email; + if (!customerEmail) { logger.debug( "[invoice.paid] Customer has no email, skipping email receipt", @@ -37,14 +55,13 @@ export const sendEmailReceipt = async ({ return; } - // 2. Extract payment intent ID from the invoice - const payments = stripeInvoice.payments; - const firstPayment = payments?.data?.[0]; - const paymentIntentId = firstPayment?.payment?.payment_intent as - | string - | undefined; + // 2. Get payment intent ID from the invoice + const paymentIntentId = await stripeInvoiceIdToPaymentIntent({ + stripeClient: stripeCli, + invoiceId: stripeInvoice.id, + }); - if (nullish(paymentIntentId)) { + if (!paymentIntentId) { logger.debug( "[invoice.paid] No payment intent found on invoice, skipping email receipt", ); diff --git a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts index 502756b28..021a67698 100644 --- a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts @@ -14,7 +14,6 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { notNullish } from "@/utils/genUtils.js"; import { CusService } from "../CusService.js"; import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; -import { deleteCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import { getOrSetCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; export const handleUpdateCustomerV2 = createRoute({ @@ -129,13 +128,6 @@ export const handleUpdateCustomerV2 = createRoute({ update: updateData, }); - // Invalidate cache after DB update - await deleteCachedFullCustomer({ - customerId: customer_id, - ctx, - source: "handleUpdateCustomerV2", - }); - // Skip cache to get fresh data after update ctx.skipCache = true; const fullCustomer = await getOrSetCachedFullCustomer({ diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts index bd7c64b08..4a52c1184 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts @@ -39,7 +39,7 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when send_email_re }); // Step 1: Create customer and attach product (initial invoice.paid fires here) - const { ctx, customer, testClockId } = await initScenario({ + const { ctx, customer, testClockId, autumnV1 } = await initScenario({ customerId, setup: [ s.customer({ testClock: true, paymentMethod: "success" }), @@ -53,21 +53,16 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when send_email_re expect(testClockId).toBeDefined(); // Step 2: Update customer to enable email receipts BEFORE the next invoice.paid - await CusService.update({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - update: { - send_email_receipts: true, - email: testEmail, - }, + // Use the API to ensure the email is synced to Stripe + await autumnV1.customers.update(customerId, { + send_email_receipts: true, + email: testEmail, }); // Small delay to ensure DB write is committed before webhook reads it await timeout(1000); - // Verify the update was applied + // Verify the update was applied (both Autumn and Stripe) const updatedCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customerId, @@ -77,6 +72,14 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when send_email_re expect(updatedCustomer.send_email_receipts).toBe(true); expect(updatedCustomer.email).toBe(testEmail); + // Verify Stripe customer also has the email + const stripeCustomer = await ctx.stripeCli.customers.retrieve( + stripeCustomerId!, + ); + if (!stripeCustomer.deleted) { + expect(stripeCustomer.email).toBe(testEmail); + } + // Step 3: Advance to next billing cycle - this triggers invoice.paid webhook // which should now set receipt_email on the PaymentIntent await advanceToNextInvoice({ @@ -223,7 +226,7 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo }); // Step 1: Create customer and attach product - const { ctx, customer, testClockId } = await initScenario({ + const { ctx, customer, testClockId, autumnV1 } = await initScenario({ customerId, setup: [ s.customer({ testClock: true, paymentMethod: "success" }), @@ -236,22 +239,21 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo expect(stripeCustomerId).toBeDefined(); expect(testClockId).toBeDefined(); - // Step 2: Enable email receipts but clear the email address - await CusService.update({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - update: { - send_email_receipts: true, - email: "", // Empty email - }, + // Step 2: Enable email receipts via API, then clear Stripe customer's email + // (API validates email format, so we clear Stripe email directly) + await autumnV1.customers.update(customerId, { + send_email_receipts: true, }); - // Small delay to ensure DB write is committed + // Clear the email on the Stripe customer directly + await ctx.stripeCli.customers.update(stripeCustomerId!, { + email: "", + }); + + // Small delay to ensure changes are committed await timeout(1000); - // Verify the update was applied + // Verify send_email_receipts was enabled const updatedCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customerId, @@ -259,7 +261,14 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo env: ctx.env, }); expect(updatedCustomer.send_email_receipts).toBe(true); - expect(updatedCustomer.email).toBe(""); + + // Verify Stripe customer has no email + const stripeCustomer = await ctx.stripeCli.customers.retrieve( + stripeCustomerId!, + ); + if (!stripeCustomer.deleted) { + expect(stripeCustomer.email).toBeNull(); + } // Step 3: Advance to next billing cycle - this triggers invoice.paid webhook // Since customer has no email, receipt_email should NOT be set diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-void-invoices.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-void-invoices.test.ts new file mode 100644 index 000000000..51d9462b8 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-void-invoices.test.ts @@ -0,0 +1,558 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Void open invoices when subscription cancelled after payment failure +// ═══════════════════════════════════════════════════════════════════════════════ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, ApiVersion } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli"; +import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils"; +import { CusService } from "@/internal/customers/CusService"; +import { OrgService } from "@/internal/orgs/OrgService"; +import { timeout } from "@/utils/genUtils"; + +/** + * Scenario: + * - Init pro ($20/mo) and free (default) products + * - Attach pro to customer with successful payment + * - Switch to a failing payment method + * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') + * - Cancel subscription via Stripe (simulating Stripe's eventual cancellation via dunning) + * + * Expected Result: + * - Open invoices from the failed payment are voided by our webhook handler + * - Pro is removed + * - Free default becomes active + * + * This tests the void_invoices_on_subscription_deletion org config feature. + * Note: Stripe doesn't auto-cancel subscriptions on payment failure - it marks them as + * past_due. This test simulates what happens when the subscription is eventually cancelled + * (either by Stripe's dunning rules, or manually) and verifies open invoices are voided. + */ +test(`${chalk.yellowBright("sub.deleted: void open invoices on subscription cancel after payment failure")}`, async () => { + const customerId = "sub-deleted-void-invoices"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + // Initialize scenario with test clock enabled + const { ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free, pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Save original org config and enable void_invoices_on_subscription_deletion + // This must be set in the database because webhooks read config from DB, not request headers + const originalOrgConfig = ctx.org.config; + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: true, + }, + }, + }); + + try { + const autumnV1 = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.orgSecretKey, + }); + + // Verify pro is active after initial attach + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + // Get the customer record to access Stripe customer ID + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + // Switch to a failing payment method + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: customer!, + }); + + // Get the failing payment method and set it on the subscription + // (subscription has its own default_payment_method which takes precedence over customer's) + const paymentMethods = await ctx.stripeCli.paymentMethods.list({ + customer: customer!.processor?.id, + }); + const failingPaymentMethod = paymentMethods.data[0]; + + await ctx.stripeCli.subscriptions.update(subscriptionId, { + default_payment_method: failingPaymentMethod.id, + }); + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify that an open invoice exists (payment failed) + const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + const openInvoicesBeforeCancel = invoicesBeforeCancel.data.filter( + (inv) => inv.status === "open", + ); + expect(openInvoicesBeforeCancel.length).toBeGreaterThan(0); + + // Cancel subscription via Stripe (simulating Stripe's eventual cancellation via dunning) + // This triggers subscription.deleted webhook which should void open invoices + await ctx.stripeCli.subscriptions.cancel(subscriptionId); + + // Wait for webhook to process + await timeout(8000); + + // Verify that open invoices are now voided + const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + // Check that there are no 'open' invoices remaining (they should be voided) + const openInvoicesAfterCancel = invoicesAfterCancel.data.filter( + (inv) => inv.status === "open", + ); + expect(openInvoicesAfterCancel.length).toBe(0); + + // Verify voided invoices exist (proving voiding happened) + const voidedInvoices = invoicesAfterCancel.data.filter( + (inv) => inv.status === "void", + ); + expect(voidedInvoices.length).toBeGreaterThan(0); + + // Verify pro is gone and free is active + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [pro.id], + active: [free.id], + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + } finally { + // Restore original org config + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: originalOrgConfig, + }, + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6: Verify invoices are NOT voided when config is disabled +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Init pro ($20/mo) and free (default) products + * - Attach pro to customer with successful payment + * - Switch to a failing payment method + * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') + * - Cancel subscription via Stripe + * - Config void_invoices_on_subscription_deletion is FALSE (default) + * + * Expected Result: + * - Open invoice remains 'open' (NOT voided) + * - Pro is removed + * - Free default becomes active + * + * This is a negative test to verify the feature is correctly gated by the config flag. + */ +test(`${chalk.yellowBright("sub.deleted: open invoices NOT voided when config disabled")}`, async () => { + const customerId = "sub-deleted-void-disabled"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + // Initialize scenario with test clock enabled + // NOTE: We do NOT enable void_invoices_on_subscription_deletion (default is false) + const { ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free, pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: false, + }, + }, + }); + + const autumnV1 = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.orgSecretKey, + }); + + // Verify pro is active after initial attach + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + // Get the customer record to access Stripe customer ID + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + // Switch to a failing payment method + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: customer!, + }); + + // Get the failing payment method and set it on the subscription + const paymentMethods = await ctx.stripeCli.paymentMethods.list({ + customer: customer!.processor?.id, + }); + const failingPaymentMethod = paymentMethods.data[0]; + + await ctx.stripeCli.subscriptions.update(subscriptionId, { + default_payment_method: failingPaymentMethod.id, + }); + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Wait for Stripe to process the billing and payment attempt + await timeout(4000); + + // Verify that an open invoice exists (payment failed) + const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + const openInvoicesBeforeCancel = invoicesBeforeCancel.data.filter( + (inv) => inv.status === "open", + ); + expect(openInvoicesBeforeCancel.length).toBeGreaterThan(0); + + // Cancel subscription via Stripe + await ctx.stripeCli.subscriptions.cancel(subscriptionId); + + // Wait for webhook to process + await timeout(12000); + + // Verify that open invoices are still open (NOT voided, because config is disabled) + const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + const openInvoicesAfterCancel = invoicesAfterCancel.data.filter( + (inv) => inv.status === "open", + ); + expect(openInvoicesAfterCancel.length).toBeGreaterThan(0); + + // Verify NO voided invoices (feature is disabled) + const voidedInvoices = invoicesAfterCancel.data.filter( + (inv) => inv.status === "void", + ); + expect(voidedInvoices.length).toBe(0); + + // Verify pro is gone and free is active + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [pro.id], + active: [free.id], + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 7: Only open invoices are voided (paid invoices unchanged) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Init pro ($20/mo) and free (default) products + * - Attach pro to customer with successful payment (1st invoice paid) + * - Switch to a failing payment method + * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') + * - Cancel subscription via Stripe + * - Config void_invoices_on_subscription_deletion is TRUE + * + * Expected Result: + * - Open invoice is voided + * - Paid invoice remains paid (unchanged) + * - Exactly 1 voided invoice, exactly 1 paid invoice, 0 open invoices + * + * This verifies the feature only voids 'open' invoices, not all invoices. + */ +test(`${chalk.yellowBright("sub.deleted: only open invoices voided, paid invoices unchanged")}`, async () => { + const customerId = "sub-deleted-void-multiple"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + // Initialize scenario with test clock enabled + const { ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [free, pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Save original org config and enable void_invoices_on_subscription_deletion + const originalOrgConfig = ctx.org.config; + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: { + ...ctx.org.config, + void_invoices_on_subscription_deletion: true, + }, + }, + }); + + try { + const autumnV1 = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.orgSecretKey, + }); + + // Verify pro is active after initial attach (1st invoice is paid) + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + // Get the customer record to access Stripe customer ID + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + // Verify we have 1 paid invoice from the initial subscription + const invoicesAfterAttach = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + const paidInvoicesInitial = invoicesAfterAttach.data.filter( + (inv) => inv.status === "paid", + ); + expect(paidInvoicesInitial.length).toBe(1); + + // Switch to a failing payment method + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: customer!, + }); + + // Get the failing payment method and set it on the subscription + const paymentMethods = await ctx.stripeCli.paymentMethods.list({ + customer: customer!.processor?.id, + }); + const failingPaymentMethod = paymentMethods.data[0]; + + await ctx.stripeCli.subscriptions.update(subscriptionId, { + default_payment_method: failingPaymentMethod.id, + }); + + // Advance to next billing cycle - payment will fail, creating an open invoice + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Wait for Stripe to process the billing and payment attempt + await timeout(4000); + + // Verify invoice statuses before cancellation: 1 paid, 1 open + const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + const paidBeforeCancel = invoicesBeforeCancel.data.filter( + (inv) => inv.status === "paid", + ); + const openBeforeCancel = invoicesBeforeCancel.data.filter( + (inv) => inv.status === "open", + ); + + expect(paidBeforeCancel.length).toBe(1); + expect(openBeforeCancel.length).toBe(1); + + // Cancel subscription via Stripe + await ctx.stripeCli.subscriptions.cancel(subscriptionId); + + // Wait for webhook to process + await timeout(8000); + + // Verify invoice statuses after cancellation + const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ + customer: customer!.processor?.id, + subscription: subscriptionId, + }); + + const paidAfterCancel = invoicesAfterCancel.data.filter( + (inv) => inv.status === "paid", + ); + const openAfterCancel = invoicesAfterCancel.data.filter( + (inv) => inv.status === "open", + ); + const voidedAfterCancel = invoicesAfterCancel.data.filter( + (inv) => inv.status === "void", + ); + + // Exactly 1 paid invoice (unchanged) + expect(paidAfterCancel.length).toBe(1); + + // Exactly 0 open invoices (all voided) + expect(openAfterCancel.length).toBe(0); + + // Exactly 1 voided invoice + expect(voidedAfterCancel.length).toBe(1); + + // Verify pro is gone and free is active + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [pro.id], + active: [free.id], + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + } finally { + // Restore original org config + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + config: originalOrgConfig, + }, + }); + } +}); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts index f07e6c97a..ffbbe9b39 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts @@ -400,532 +400,3 @@ test(`${chalk.yellowBright("sub.deleted: cancel subscription with add-on via Str env: ctx.env, }); }); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: Void open invoices when subscription cancelled after payment failure -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Init pro ($20/mo) and free (default) products - * - Attach pro to customer with successful payment - * - Switch to a failing payment method - * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') - * - Cancel subscription via Stripe (simulating Stripe's eventual cancellation via dunning) - * - * Expected Result: - * - Open invoices from the failed payment are voided by our webhook handler - * - Pro is removed - * - Free default becomes active - * - * This tests the void_invoices_on_subscription_deletion org config feature. - * Note: Stripe doesn't auto-cancel subscriptions on payment failure - it marks them as - * past_due. This test simulates what happens when the subscription is eventually cancelled - * (either by Stripe's dunning rules, or manually) and verifies open invoices are voided. - */ -test(`${chalk.yellowBright("sub.deleted: void open invoices on subscription cancel after payment failure")}`, async () => { - const customerId = "sub-deleted-void-invoices"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const free = products.base({ - id: "free", - items: [messagesItem], - isDefault: true, - }); - - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - // Initialize scenario with test clock enabled - const { ctx, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [free, pro] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - // Save original org config and enable void_invoices_on_subscription_deletion - // This must be set in the database because webhooks read config from DB, not request headers - const originalOrgConfig = ctx.org.config; - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - config: { - ...ctx.org.config, - void_invoices_on_subscription_deletion: true, - }, - }, - }); - - try { - const autumnV1 = new AutumnInt({ - version: ApiVersion.V1_2, - secretKey: ctx.orgSecretKey, - }); - - // Verify pro is active after initial attach - const customerAfterAttach = - await autumnV1.customers.get(customerId); - await expectProductActive({ - customer: customerAfterAttach, - productId: pro.id, - }); - - // Get subscription ID - const subscriptionId = await getSubscriptionId({ - ctx, - customerId, - productId: pro.id, - }); - - // Get the customer record to access Stripe customer ID - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - // Switch to a failing payment method - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: customer!, - }); - - // Get the failing payment method and set it on the subscription - // (subscription has its own default_payment_method which takes precedence over customer's) - const paymentMethods = await ctx.stripeCli.paymentMethods.list({ - customer: customer!.processor?.id, - }); - const failingPaymentMethod = paymentMethods.data[0]; - - await ctx.stripeCli.subscriptions.update(subscriptionId, { - default_payment_method: failingPaymentMethod.id, - }); - - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Verify that an open invoice exists (payment failed) - const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - const openInvoicesBeforeCancel = invoicesBeforeCancel.data.filter( - (inv) => inv.status === "open", - ); - expect(openInvoicesBeforeCancel.length).toBeGreaterThan(0); - - // Cancel subscription via Stripe (simulating Stripe's eventual cancellation via dunning) - // This triggers subscription.deleted webhook which should void open invoices - await ctx.stripeCli.subscriptions.cancel(subscriptionId); - - // Wait for webhook to process - await timeout(8000); - - // Verify that open invoices are now voided - const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - // Check that there are no 'open' invoices remaining (they should be voided) - const openInvoicesAfterCancel = invoicesAfterCancel.data.filter( - (inv) => inv.status === "open", - ); - expect(openInvoicesAfterCancel.length).toBe(0); - - // Verify voided invoices exist (proving voiding happened) - const voidedInvoices = invoicesAfterCancel.data.filter( - (inv) => inv.status === "void", - ); - expect(voidedInvoices.length).toBeGreaterThan(0); - - // Verify pro is gone and free is active - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer: customerAfterCancel, - notPresent: [pro.id], - active: [free.id], - }); - - // Verify no Stripe subscription exists - await expectNoStripeSubscription({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - } finally { - // Restore original org config - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - config: originalOrgConfig, - }, - }); - } -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 6: Verify invoices are NOT voided when config is disabled -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Init pro ($20/mo) and free (default) products - * - Attach pro to customer with successful payment - * - Switch to a failing payment method - * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') - * - Cancel subscription via Stripe - * - Config void_invoices_on_subscription_deletion is FALSE (default) - * - * Expected Result: - * - Open invoice remains 'open' (NOT voided) - * - Pro is removed - * - Free default becomes active - * - * This is a negative test to verify the feature is correctly gated by the config flag. - */ -test(`${chalk.yellowBright("sub.deleted: open invoices NOT voided when config disabled")}`, async () => { - const customerId = "sub-deleted-void-disabled"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const free = products.base({ - id: "free", - items: [messagesItem], - isDefault: true, - }); - - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - // Initialize scenario with test clock enabled - // NOTE: We do NOT enable void_invoices_on_subscription_deletion (default is false) - const { ctx, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [free, pro] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - const autumnV1 = new AutumnInt({ - version: ApiVersion.V1_2, - secretKey: ctx.orgSecretKey, - }); - - // Verify pro is active after initial attach - const customerAfterAttach = - await autumnV1.customers.get(customerId); - await expectProductActive({ - customer: customerAfterAttach, - productId: pro.id, - }); - - // Get subscription ID - const subscriptionId = await getSubscriptionId({ - ctx, - customerId, - productId: pro.id, - }); - - // Get the customer record to access Stripe customer ID - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - // Switch to a failing payment method - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: customer!, - }); - - // Get the failing payment method and set it on the subscription - const paymentMethods = await ctx.stripeCli.paymentMethods.list({ - customer: customer!.processor?.id, - }); - const failingPaymentMethod = paymentMethods.data[0]; - - await ctx.stripeCli.subscriptions.update(subscriptionId, { - default_payment_method: failingPaymentMethod.id, - }); - - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Wait for Stripe to process the billing and payment attempt - await timeout(4000); - - // Verify that an open invoice exists (payment failed) - const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - const openInvoicesBeforeCancel = invoicesBeforeCancel.data.filter( - (inv) => inv.status === "open", - ); - expect(openInvoicesBeforeCancel.length).toBeGreaterThan(0); - - // Cancel subscription via Stripe - await ctx.stripeCli.subscriptions.cancel(subscriptionId); - - // Wait for webhook to process - await timeout(8000); - - // Verify that open invoices are still open (NOT voided, because config is disabled) - const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - const openInvoicesAfterCancel = invoicesAfterCancel.data.filter( - (inv) => inv.status === "open", - ); - expect(openInvoicesAfterCancel.length).toBeGreaterThan(0); - - // Verify NO voided invoices (feature is disabled) - const voidedInvoices = invoicesAfterCancel.data.filter( - (inv) => inv.status === "void", - ); - expect(voidedInvoices.length).toBe(0); - - // Verify pro is gone and free is active - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer: customerAfterCancel, - notPresent: [pro.id], - active: [free.id], - }); - - // Verify no Stripe subscription exists - await expectNoStripeSubscription({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 7: Only open invoices are voided (paid invoices unchanged) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Scenario: - * - Init pro ($20/mo) and free (default) products - * - Attach pro to customer with successful payment (1st invoice paid) - * - Switch to a failing payment method - * - Advance test clock to next billing cycle (payment fails, invoice goes to 'open') - * - Cancel subscription via Stripe - * - Config void_invoices_on_subscription_deletion is TRUE - * - * Expected Result: - * - Open invoice is voided - * - Paid invoice remains paid (unchanged) - * - Exactly 1 voided invoice, exactly 1 paid invoice, 0 open invoices - * - * This verifies the feature only voids 'open' invoices, not all invoices. - */ -test(`${chalk.yellowBright("sub.deleted: only open invoices voided, paid invoices unchanged")}`, async () => { - const customerId = "sub-deleted-void-multiple"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const free = products.base({ - id: "free", - items: [messagesItem], - isDefault: true, - }); - - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - // Initialize scenario with test clock enabled - const { ctx, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [free, pro] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - // Save original org config and enable void_invoices_on_subscription_deletion - const originalOrgConfig = ctx.org.config; - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - config: { - ...ctx.org.config, - void_invoices_on_subscription_deletion: true, - }, - }, - }); - - try { - const autumnV1 = new AutumnInt({ - version: ApiVersion.V1_2, - secretKey: ctx.orgSecretKey, - }); - - // Verify pro is active after initial attach (1st invoice is paid) - const customerAfterAttach = - await autumnV1.customers.get(customerId); - await expectProductActive({ - customer: customerAfterAttach, - productId: pro.id, - }); - - // Get subscription ID - const subscriptionId = await getSubscriptionId({ - ctx, - customerId, - productId: pro.id, - }); - - // Get the customer record to access Stripe customer ID - const customer = await CusService.get({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - // Verify we have 1 paid invoice from the initial subscription - const invoicesAfterAttach = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - const paidInvoicesInitial = invoicesAfterAttach.data.filter( - (inv) => inv.status === "paid", - ); - expect(paidInvoicesInitial.length).toBe(1); - - // Switch to a failing payment method - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: customer!, - }); - - // Get the failing payment method and set it on the subscription - const paymentMethods = await ctx.stripeCli.paymentMethods.list({ - customer: customer!.processor?.id, - }); - const failingPaymentMethod = paymentMethods.data[0]; - - await ctx.stripeCli.subscriptions.update(subscriptionId, { - default_payment_method: failingPaymentMethod.id, - }); - - // Advance to next billing cycle - payment will fail, creating an open invoice - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Wait for Stripe to process the billing and payment attempt - await timeout(4000); - - // Verify invoice statuses before cancellation: 1 paid, 1 open - const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - const paidBeforeCancel = invoicesBeforeCancel.data.filter( - (inv) => inv.status === "paid", - ); - const openBeforeCancel = invoicesBeforeCancel.data.filter( - (inv) => inv.status === "open", - ); - - expect(paidBeforeCancel.length).toBe(1); - expect(openBeforeCancel.length).toBe(1); - - // Cancel subscription via Stripe - await ctx.stripeCli.subscriptions.cancel(subscriptionId); - - // Wait for webhook to process - await timeout(8000); - - // Verify invoice statuses after cancellation - const invoicesAfterCancel = await ctx.stripeCli.invoices.list({ - customer: customer!.processor?.id, - subscription: subscriptionId, - }); - - const paidAfterCancel = invoicesAfterCancel.data.filter( - (inv) => inv.status === "paid", - ); - const openAfterCancel = invoicesAfterCancel.data.filter( - (inv) => inv.status === "open", - ); - const voidedAfterCancel = invoicesAfterCancel.data.filter( - (inv) => inv.status === "void", - ); - - // Exactly 1 paid invoice (unchanged) - expect(paidAfterCancel.length).toBe(1); - - // Exactly 0 open invoices (all voided) - expect(openAfterCancel.length).toBe(0); - - // Exactly 1 voided invoice - expect(voidedAfterCancel.length).toBe(1); - - // Verify pro is gone and free is active - const customerAfterCancel = - await autumnV1.customers.get(customerId); - - await expectCustomerProducts({ - customer: customerAfterCancel, - notPresent: [pro.id], - active: [free.id], - }); - - // Verify no Stripe subscription exists - await expectNoStripeSubscription({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); - } finally { - // Restore original org config - await OrgService.update({ - db: ctx.db, - orgId: ctx.org.id, - updates: { - config: originalOrgConfig, - }, - }); - } -}); diff --git a/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts index 69c9ac44b..98a865d61 100644 --- a/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts +++ b/server/tests/unit/webhooks/invoice-paid/send-email-receipt.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { AppEnv, type FullCustomer } from "@autumn/shared"; import { stripeClients } from "@tests/utils/fixtures/stripe/clients"; import chalk from "chalk"; +import type Stripe from "stripe"; import type { StripeInvoicePaidContext } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext"; import { sendEmailReceipt } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; @@ -119,14 +120,19 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { expect(mockCli._calls.paymentIntents.update).toHaveLength(0); }); - test("returns when customer has no email", async () => { - const mockCli = stripeClients.createMockStripeClient(); + test("returns when stripe customer has no email", async () => { + const mockCli = stripeClients.createMockStripeClient({ + customers: { + retrieveResult: { + id: "cus_mock", + email: null, + } as Partial, + }, + }); const ctx = { stripeCli: mockCli, logger: createMockLogger(), - fullCustomer: createMockFullCustomer({ - email: undefined as unknown as string, - }), + fullCustomer: createMockFullCustomer(), } as unknown as StripeWebhookContext; await sendEmailReceipt({ @@ -138,12 +144,19 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { expect(mockCli._calls.paymentIntents.update).toHaveLength(0); }); - test("returns when customer has empty string email", async () => { - const mockCli = stripeClients.createMockStripeClient(); + test("returns when stripe customer has empty string email", async () => { + const mockCli = stripeClients.createMockStripeClient({ + customers: { + retrieveResult: { + id: "cus_mock", + email: "", + } as Partial, + }, + }); const ctx = { stripeCli: mockCli, logger: createMockLogger(), - fullCustomer: createMockFullCustomer({ email: "" }), + fullCustomer: createMockFullCustomer(), } as unknown as StripeWebhookContext; await sendEmailReceipt({ @@ -156,7 +169,14 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { }); test("returns when invoice has no payments", async () => { - const mockCli = stripeClients.createMockStripeClient(); + const mockCli = stripeClients.createMockStripeClient({ + invoices: { + retrieveResult: { + id: "inv_test", + payments: { data: [] }, + } as unknown as Partial, + }, + }); const ctx = { stripeCli: mockCli, logger: createMockLogger(), @@ -176,6 +196,14 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { describe(chalk.cyan("PaymentIntent already has receipt_email"), () => { test("returns without updating when receipt_email already set", async () => { const mockCli = stripeClients.createMockStripeClient({ + invoices: { + retrieveResult: { + id: "inv_test", + payments: { + data: [{ payment: { payment_intent: { id: "pi_123" } } }], + }, + } as Partial, + }, paymentIntents: { retrieveResult: { receipt_email: "existing@example.com" }, }, @@ -198,12 +226,26 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { describe(chalk.cyan("Success - sets receipt_email"), () => { test("updates PaymentIntent with customer email when all conditions met", async () => { + const customerEmail = "customer@example.com"; const mockCli = stripeClients.createMockStripeClient({ + customers: { + retrieveResult: { + id: "cus_mock", + email: customerEmail, + } as Partial, + }, + invoices: { + retrieveResult: { + id: "inv_test", + payments: { + data: [{ payment: { payment_intent: { id: "pi_123" } } }], + }, + } as Partial, + }, paymentIntents: { retrieveResult: { receipt_email: null }, }, }); - const customerEmail = "customer@example.com"; const ctx = { stripeCli: mockCli, logger: createMockLogger(), @@ -228,6 +270,14 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { describe(chalk.cyan("Error handling"), () => { test("does not throw when paymentIntents.retrieve fails", async () => { const mockCli = stripeClients.createMockStripeClient({ + invoices: { + retrieveResult: { + id: "inv_test", + payments: { + data: [{ payment: { payment_intent: { id: "pi_123" } } }], + }, + } as Partial, + }, paymentIntents: { retrieveError: new Error("Stripe API error"), }, @@ -249,6 +299,14 @@ describe(chalk.yellowBright("sendEmailReceipt"), () => { test("does not throw when paymentIntents.update fails", async () => { const mockCli = stripeClients.createMockStripeClient({ + invoices: { + retrieveResult: { + id: "inv_test", + payments: { + data: [{ payment: { payment_intent: { id: "pi_123" } } }], + }, + } as Partial, + }, paymentIntents: { retrieveResult: { receipt_email: null }, updateError: new Error("Stripe API error"), diff --git a/server/tests/utils/fixtures/stripe/clients.ts b/server/tests/utils/fixtures/stripe/clients.ts index f6ecad345..d1149f801 100644 --- a/server/tests/utils/fixtures/stripe/clients.ts +++ b/server/tests/utils/fixtures/stripe/clients.ts @@ -20,14 +20,22 @@ interface MockPaymentIntentsConfig { interface MockInvoicesConfig { listResult?: Partial>; + retrieveResult?: Partial; voidInvoiceResult?: Partial; listError?: Error; + retrieveError?: Error; voidInvoiceError?: Error; } +interface MockCustomersConfig { + retrieveResult?: Partial | Stripe.DeletedCustomer; + retrieveError?: Error; +} + interface MockStripeClientConfig { paymentIntents?: MockPaymentIntentsConfig; invoices?: MockInvoicesConfig; + customers?: MockCustomersConfig; } interface MockStripeClientCalls { @@ -37,8 +45,12 @@ interface MockStripeClientCalls { }; invoices: { list: Stripe.InvoiceListParams[]; + retrieve: string[]; voidInvoice: string[]; }; + customers: { + retrieve: string[]; + }; } // ═══════════════════════════════════════════════════════════════════ @@ -56,12 +68,17 @@ const createMockStripeClient = (config: MockStripeClientConfig = {}) => { }, invoices: { list: [], + retrieve: [], voidInvoice: [], }, + customers: { + retrieve: [], + }, }; const paymentIntentsConfig = config.paymentIntents ?? {}; const invoicesConfig = config.invoices ?? {}; + const customersConfig = config.customers ?? {}; return { paymentIntents: { @@ -89,6 +106,22 @@ const createMockStripeClient = (config: MockStripeClientConfig = {}) => { data: [], }) as Stripe.ApiList; }, + retrieve: async (id: string, _params?: Stripe.InvoiceRetrieveParams) => { + calls.invoices.retrieve.push(id); + if (invoicesConfig.retrieveError) throw invoicesConfig.retrieveError; + return (invoicesConfig.retrieveResult ?? { + id: "inv_mock", + payments: { + data: [ + { + payment: { + payment_intent: { id: "pi_mock" }, + }, + }, + ], + }, + }) as Stripe.Invoice; + }, voidInvoice: async (id: string) => { calls.invoices.voidInvoice.push(id); if (invoicesConfig.voidInvoiceError) @@ -96,6 +129,17 @@ const createMockStripeClient = (config: MockStripeClientConfig = {}) => { return (invoicesConfig.voidInvoiceResult ?? {}) as Stripe.Invoice; }, }, + customers: { + retrieve: async (id: string) => { + calls.customers.retrieve.push(id); + if (customersConfig.retrieveError) throw customersConfig.retrieveError; + return (customersConfig.retrieveResult ?? { + id: "cus_mock", + email: "mock@example.com", + deleted: false, + }) as Stripe.Customer | Stripe.DeletedCustomer; + }, + }, _calls: calls, }; };