chore: fix tests & cleanup

This commit is contained in:
Charlie Lamb
2026-01-27 18:42:59 +00:00
parent 46cf3e97b4
commit 690f8a2eb1
7 changed files with 734 additions and 585 deletions

View File

@@ -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",
);

View File

@@ -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({

View File

@@ -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

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
},
});
}
});

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
},
});
}
});

View File

@@ -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<Stripe.Customer>,
},
});
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<Stripe.Customer>,
},
});
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<Stripe.Invoice>,
},
});
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<Stripe.Invoice>,
},
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<Stripe.Customer>,
},
invoices: {
retrieveResult: {
id: "inv_test",
payments: {
data: [{ payment: { payment_intent: { id: "pi_123" } } }],
},
} as Partial<Stripe.Invoice>,
},
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<Stripe.Invoice>,
},
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<Stripe.Invoice>,
},
paymentIntents: {
retrieveResult: { receipt_email: null },
updateError: new Error("Stripe API error"),

View File

@@ -20,14 +20,22 @@ interface MockPaymentIntentsConfig {
interface MockInvoicesConfig {
listResult?: Partial<Stripe.ApiList<Stripe.Invoice>>;
retrieveResult?: Partial<Stripe.Invoice>;
voidInvoiceResult?: Partial<Stripe.Invoice>;
listError?: Error;
retrieveError?: Error;
voidInvoiceError?: Error;
}
interface MockCustomersConfig {
retrieveResult?: Partial<Stripe.Customer> | 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<Stripe.Invoice>;
},
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,
};
};