Files
cfw-autumn/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-past-due-void.test.ts
2026-06-15 14:43:19 +01:00

401 lines
14 KiB
TypeScript

/**
* TDD (feature) for: void-on-cancel orgs cancel past_due customers immediately.
*
* Contract under test:
* New behavior:
* - flag void_invoices_on_subscription_deletion ON + product past_due + cancel_action "cancel_end_of_cycle"
* -> resolves to an IMMEDIATE cancel: product removed now, default/free active now (not scheduled).
* Side effects:
* - that immediate cancel voids the subscription's open/uncollectible invoices INLINE
* (the Autumn cancel sets the sub:<id> lock, so the subscription.deleted webhook is skipped).
* Gating (unchanged behavior, control cases):
* - flag OFF + past_due + cancel_end_of_cycle -> still scheduled-cancel, open invoice NOT voided.
* - flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle (canceling + free scheduled).
*
* Pre-impl red: the PRIMARY test fails because cancel_end_of_cycle on a past_due sub today
* schedules the cancel (pro stays, free not active) and never voids the open invoice on the
* Autumn-initiated path.
*
* past_due setup: attach with a good card, swap the SUBSCRIPTION's default_payment_method to a
* failing card and advance the test clock so the renewal charge fails (produces a real open
* invoice in Stripe). The customer_product.status is then flipped to past_due directly in
* Postgres (+ cache bust) rather than waiting on the subscription.updated webhook -- there is no
* public API to set past_due, and the webhook sync is the flakiest part of a local run. This
* mirrors balances/cron/past-due-reset.test.ts. What we're actually testing is the cancel
* behavior, not the past_due sync.
*
* These tests mutate shared org.config, so they run serially (plain `test`) and restore in `finally`.
*/
import { expect, test } from "bun:test";
import type { Stripe } from "stripe";
import type { ApiCustomerV3 } from "@autumn/shared";
import { CusProductStatus, customerProducts } from "@autumn/shared";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductPastDue,
expectProductScheduled,
} 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 { eq } from "drizzle-orm";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils";
import { CusService } from "@/internal/customers/CusService";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { OrgService } from "@/internal/orgs/OrgService";
import { timeout } from "@/utils/genUtils";
/**
* Fail the renewal to create a real open invoice, then force the product into past_due
* directly in Postgres (no webhook dependency). Returns the Stripe customer + subscription ids.
*/
const driveProductPastDue = async ({
ctx,
testClockId,
customerId,
productId,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
testClockId: string;
customerId: string;
productId: string;
}) => {
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId,
});
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
await attachFailedPaymentMethod({
stripeCli: ctx.stripeCli,
customer: customer!,
});
const paymentMethods = await ctx.stripeCli.paymentMethods.list({
customer: customer!.processor?.id,
});
await ctx.stripeCli.subscriptions.update(subscriptionId, {
default_payment_method: paymentMethods.data[0].id,
});
await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId });
await timeout(4000);
// Flip the customer_product to past_due directly (no public API; webhook sync is unreliable
// locally), then bust the cache so the HTTP server re-reads it from Postgres.
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const customerProduct = fullCustomer.customer_products.find(
(cp) => cp.product.id === productId,
);
await ctx.db
.update(customerProducts)
.set({ status: CusProductStatus.PastDue })
.where(eq(customerProducts.id, customerProduct!.id));
await deleteCachedFullCustomer({ ctx, customerId });
return { subscriptionId, stripeCustomerId: customer!.processor?.id };
};
// ═══════════════════════════════════════════════════════════════════════════════
// PRIMARY (pre-impl RED): flag on + past_due + cancel_end_of_cycle -> immediate + void
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc past_due: flag on -> cancels immediately and voids open invoice")}`, async () => {
const customerId = "cancel-pastdue-void-immediate";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
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 { subscriptionId, stripeCustomerId } = await driveProductPastDue({
ctx,
testClockId: testClockId!,
customerId,
productId: pro.id,
});
// Precondition: product is past_due with an open (unpaid) invoice
const customerBeforeCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductPastDue({
customer: customerBeforeCancel,
productId: pro.id,
});
const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesBeforeCancel.data.filter((inv) => inv.status === "open").length,
).toBeGreaterThan(0);
// ── Action: cancel_end_of_cycle on a past_due sub with the flag on ──
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Read-consistency wait for the inline void (NOT a webhook wait).
await timeout(3000);
// ── Contract assertion 1: immediate downgrade (not scheduled) ──
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [pro.id],
active: [free.id],
});
// ── Contract assertion 2: Stripe subscription cancelled now ──
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
// ── Contract assertion 3: open invoice voided inline ──
const invoicesAfterCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "open").length,
).toBe(0);
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "void").length,
).toBeGreaterThan(0);
// ── Contract assertion 4: no proration credit issued for the unpaid cycle ──
// The customer never paid this cycle, so cancelling must not refund/credit them.
const allInvoices = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
});
expect(
allInvoices.data.filter((inv) => (inv.total ?? 0) < 0).length,
).toBe(0);
const stripeCustomer = (await ctx.stripeCli.customers.retrieve(
stripeCustomerId!,
)) as Stripe.Customer;
expect(stripeCustomer.balance).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// CONTROL A: flag OFF + past_due + cancel_end_of_cycle -> unchanged (no immediate, no void)
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc past_due: flag off -> stays scheduled, invoice not voided")}`, async () => {
const customerId = "cancel-pastdue-void-flag-off";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
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: false,
},
},
});
try {
const { subscriptionId, stripeCustomerId } = await driveProductPastDue({
ctx,
testClockId: testClockId!,
customerId,
productId: pro.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
await timeout(3000);
// Not immediately removed: a Stripe subscription still exists (cancel scheduled at period end)
const subsAfterCancel = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
});
expect(subsAfterCancel.data.length).toBeGreaterThan(0);
// Open invoice is NOT voided when the flag is off
const invoicesAfterCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "open").length,
).toBeGreaterThan(0);
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "void").length,
).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// CONTROL B: flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc not past_due: flag on -> normal end-of-cycle (no immediate)")}`, async () => {
const customerId = "cancel-pastdue-void-not-pastdue";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
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 customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Active (paid) sub: flag must NOT force immediate; normal end-of-cycle applies.
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Side-effect contract: a paid, non-past_due cancel must NOT void any invoice.
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
const invoices = await ctx.stripeCli.invoices.list({
customer: customer!.processor?.id,
subscription: subscriptionId,
});
expect(invoices.data.filter((inv) => inv.status === "void").length).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});