cancel past due subs and void unpaid invoice on cancel
This commit is contained in:
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -26,7 +26,7 @@ env:
|
||||
# staging repo (autumn-staging) -> us-east-1
|
||||
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
|
||||
# Add short-lived PR branches here when you need staging without merging to dev.
|
||||
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage fix/usage-limit-ai-credit-dimension feat/analytics-week-bin
|
||||
STAGING_DEPLOY_BRANCH_ALLOWLIST: feat/past-due-cancel-immediate-void
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
|
||||
73
server/src/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription.ts
vendored
Normal file
73
server/src/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Stripe } from "stripe";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { invoiceActions } from "@/internal/invoices/actions";
|
||||
|
||||
const VOIDABLE_STATUSES: Stripe.Invoice.Status[] = ["open", "uncollectible"];
|
||||
|
||||
/**
|
||||
* Void every open/uncollectible invoice on a Stripe subscription, then mirror the new status
|
||||
* into Autumn's invoice records. Per-invoice failures are tolerated (the subscription.deleted
|
||||
* webhook can race this for the same invoices, and Stripe rejects voiding an already-voided one)
|
||||
* but are counted and returned so the caller can surface a partial failure.
|
||||
*/
|
||||
export const voidOpenInvoicesForStripeSubscription = async ({
|
||||
ctx,
|
||||
stripeCli,
|
||||
customerId,
|
||||
stripeCustomerId,
|
||||
subscriptionId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeCli: Stripe;
|
||||
customerId: string;
|
||||
stripeCustomerId: string;
|
||||
subscriptionId: string;
|
||||
}): Promise<{ voided: number; failed: number }> => {
|
||||
const { logger } = ctx;
|
||||
|
||||
const voidableInvoices: Stripe.Invoice[] = [];
|
||||
let startingAfter: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const page = await stripeCli.invoices.list({
|
||||
customer: stripeCustomerId,
|
||||
subscription: subscriptionId,
|
||||
limit: 100,
|
||||
starting_after: startingAfter,
|
||||
});
|
||||
|
||||
for (const invoice of page.data) {
|
||||
if (VOIDABLE_STATUSES.includes(invoice.status ?? "draft")) {
|
||||
voidableInvoices.push(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.has_more) break;
|
||||
startingAfter = page.data[page.data.length - 1]?.id;
|
||||
if (!startingAfter) break;
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
await Promise.all(
|
||||
voidableInvoices.map(async (invoice) => {
|
||||
try {
|
||||
const voidedInvoice = await stripeCli.invoices.voidInvoice(invoice.id);
|
||||
await invoiceActions.updateFromStripe({
|
||||
ctx,
|
||||
customerId,
|
||||
stripeInvoice: voidedInvoice,
|
||||
});
|
||||
logger.info(
|
||||
`[voidOpenInvoicesForStripeSubscription] Voided invoice ${invoice.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
logger.warn(
|
||||
`[voidOpenInvoicesForStripeSubscription] Failed to void invoice ${invoice.id}: ${error}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { voided: voidableInvoices.length - failed, failed };
|
||||
};
|
||||
@@ -15,7 +15,10 @@ import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v
|
||||
import { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities";
|
||||
import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund";
|
||||
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
||||
import { setupCancelAction } from "@/internal/billing/v2/setup/setupCancelMode";
|
||||
import {
|
||||
setupCancelAction,
|
||||
shouldForcePastDueImmediateCancel,
|
||||
} from "@/internal/billing/v2/setup/setupCancelMode";
|
||||
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||
import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
|
||||
@@ -171,7 +174,18 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
const cancelAction = setupCancelAction({ params });
|
||||
const cancelAction = setupCancelAction({
|
||||
params,
|
||||
org: ctx.org,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
// A past_due cancel forced to immediate must not refund the unpaid cycle.
|
||||
const forcePastDueImmediate = shouldForcePastDueImmediateCancel({
|
||||
params,
|
||||
org: ctx.org,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
let checkoutMode = setupAttachCheckoutMode({
|
||||
paymentMethod,
|
||||
@@ -221,7 +235,9 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
||||
billingCycleAnchorMs,
|
||||
resetCycleAnchorMs,
|
||||
requestedBillingCycleAnchor: params.billing_cycle_anchor,
|
||||
requestedProrationBehavior: setupIgnoreProrationBehavior({ intent })
|
||||
requestedProrationBehavior: forcePastDueImmediate
|
||||
? "none"
|
||||
: setupIgnoreProrationBehavior({ intent })
|
||||
? undefined
|
||||
: params.proration_behavior,
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/up
|
||||
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
||||
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
||||
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
|
||||
import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel";
|
||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
||||
@@ -114,6 +115,13 @@ export async function updateSubscription({
|
||||
billingPlan,
|
||||
});
|
||||
|
||||
await voidInvoicesOnImmediateCancel({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
billingResult,
|
||||
});
|
||||
|
||||
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
BillingPlan,
|
||||
BillingResult,
|
||||
UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { voidOpenInvoicesForStripeSubscription } from "@/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
// Void-on-cancel must run inline for Autumn-initiated immediate cancels: the sub:<id> lock makes
|
||||
// the subscription.deleted webhook (which normally does the voiding) skip itself for our cancels.
|
||||
export const voidInvoicesOnImmediateCancel = async ({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
billingResult,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
billingPlan: BillingPlan;
|
||||
billingResult: BillingResult;
|
||||
}): Promise<void> => {
|
||||
if (!ctx.org.config.void_invoices_on_subscription_deletion) return;
|
||||
if (billingContext.cancelAction !== "cancel_immediately") return;
|
||||
if (billingResult.stripe.deferred) return;
|
||||
|
||||
// Only void when the whole Stripe subscription was actually cancelled — a partial item
|
||||
// removal (e.g. cancelling one product while an add-on stays live on the same subscription)
|
||||
// produces an "update" action, and voiding the surviving product's open invoices would be wrong.
|
||||
if (billingPlan.stripe.subscriptionAction?.type !== "cancel") return;
|
||||
|
||||
const { stripeSubscription, stripeCustomer, fullCustomer } = billingContext;
|
||||
if (!stripeSubscription || !stripeCustomer) return;
|
||||
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const { failed } = await voidOpenInvoicesForStripeSubscription({
|
||||
ctx,
|
||||
stripeCli,
|
||||
customerId: fullCustomer.id ?? fullCustomer.internal_id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
});
|
||||
|
||||
if (failed > 0) {
|
||||
ctx.logger.error(
|
||||
`[voidInvoicesOnImmediateCancel] ${failed} invoice(s) failed to void for subscription ${stripeSubscription.id}; they may still be open in Stripe`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,37 @@
|
||||
import type { CancelAction, UpdateSubscriptionV1Params } from "@autumn/shared";
|
||||
import type {
|
||||
CancelAction,
|
||||
FullCusProduct,
|
||||
Organization,
|
||||
UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Setup cancel action from params
|
||||
* @param params - The params
|
||||
* cancel_action param maps directly to internal cancel action
|
||||
* - cancel_action: "cancel_immediately" means cancel immediately
|
||||
* - cancel_action: "cancel_end_of_cycle" means cancel at end of cycle
|
||||
* - cancel_action: "uncancel" means remove scheduled cancellation
|
||||
* - cancel_action: undefined means no cancel operation
|
||||
* @returns The cancel action
|
||||
*/
|
||||
export const setupCancelAction = ({
|
||||
// Void-on-cancel orgs cancel a past_due end-of-cycle request immediately: the customer is in a
|
||||
// cycle they never paid for, so there is no paid period to honor. The unpaid invoice is voided
|
||||
// downstream (voidInvoicesOnImmediateCancel) and proration credits are suppressed by the caller
|
||||
// (no refund is owed for a cycle that was never paid).
|
||||
export const shouldForcePastDueImmediateCancel = ({
|
||||
params,
|
||||
org,
|
||||
customerProduct,
|
||||
}: {
|
||||
params: UpdateSubscriptionV1Params;
|
||||
}): CancelAction | undefined => {
|
||||
return params.cancel_action;
|
||||
};
|
||||
org: Organization;
|
||||
customerProduct?: FullCusProduct;
|
||||
}): boolean =>
|
||||
params.cancel_action === "cancel_end_of_cycle" &&
|
||||
org.config.void_invoices_on_subscription_deletion &&
|
||||
customerProduct?.status === CusProductStatus.PastDue;
|
||||
|
||||
export const setupCancelAction = ({
|
||||
params,
|
||||
org,
|
||||
customerProduct,
|
||||
}: {
|
||||
params: UpdateSubscriptionV1Params;
|
||||
org: Organization;
|
||||
customerProduct?: FullCusProduct;
|
||||
}): CancelAction | undefined =>
|
||||
shouldForcePastDueImmediateCancel({ params, org, customerProduct })
|
||||
? "cancel_immediately"
|
||||
: params.cancel_action;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
|
||||
import { Scopes } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
|
||||
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors";
|
||||
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
||||
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
||||
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
|
||||
import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel";
|
||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
||||
@@ -98,6 +99,13 @@ export const handleCancelV2 = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
await voidInvoicesOnImmediateCancel({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
billingResult,
|
||||
});
|
||||
|
||||
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||
|
||||
return c.json({
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user