chore: fix tests & cleanup
This commit is contained in:
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user