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
|
# staging repo (autumn-staging) -> us-east-1
|
||||||
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
|
# 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.
|
# 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:
|
jobs:
|
||||||
checks:
|
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 { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities";
|
||||||
import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund";
|
import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund";
|
||||||
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
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 { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||||
import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
|
import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
|
||||||
@@ -171,7 +174,18 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
|||||||
customerProduct,
|
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({
|
let checkoutMode = setupAttachCheckoutMode({
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
@@ -221,9 +235,11 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
|||||||
billingCycleAnchorMs,
|
billingCycleAnchorMs,
|
||||||
resetCycleAnchorMs,
|
resetCycleAnchorMs,
|
||||||
requestedBillingCycleAnchor: params.billing_cycle_anchor,
|
requestedBillingCycleAnchor: params.billing_cycle_anchor,
|
||||||
requestedProrationBehavior: setupIgnoreProrationBehavior({ intent })
|
requestedProrationBehavior: forcePastDueImmediate
|
||||||
? undefined
|
? "none"
|
||||||
: params.proration_behavior,
|
: setupIgnoreProrationBehavior({ intent })
|
||||||
|
? undefined
|
||||||
|
: params.proration_behavior,
|
||||||
|
|
||||||
invoiceMode,
|
invoiceMode,
|
||||||
featureQuantities,
|
featureQuantities,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/up
|
|||||||
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
||||||
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
||||||
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
|
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 { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
||||||
@@ -114,6 +115,13 @@ export async function updateSubscription({
|
|||||||
billingPlan,
|
billingPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await voidInvoicesOnImmediateCancel({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
billingPlan,
|
||||||
|
billingResult,
|
||||||
|
});
|
||||||
|
|
||||||
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||||
|
|
||||||
return {
|
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";
|
||||||
|
|
||||||
/**
|
// Void-on-cancel orgs cancel a past_due end-of-cycle request immediately: the customer is in a
|
||||||
* Setup cancel action from params
|
// cycle they never paid for, so there is no paid period to honor. The unpaid invoice is voided
|
||||||
* @param params - The params
|
// downstream (voidInvoicesOnImmediateCancel) and proration credits are suppressed by the caller
|
||||||
* cancel_action param maps directly to internal cancel action
|
// (no refund is owed for a cycle that was never paid).
|
||||||
* - cancel_action: "cancel_immediately" means cancel immediately
|
export const shouldForcePastDueImmediateCancel = ({
|
||||||
* - 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 = ({
|
|
||||||
params,
|
params,
|
||||||
|
org,
|
||||||
|
customerProduct,
|
||||||
}: {
|
}: {
|
||||||
params: UpdateSubscriptionV1Params;
|
params: UpdateSubscriptionV1Params;
|
||||||
}): CancelAction | undefined => {
|
org: Organization;
|
||||||
return params.cancel_action;
|
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 type { UpdateSubscriptionV1Params } from "@autumn/shared";
|
||||||
|
import { Scopes } from "@autumn/shared";
|
||||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||||
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
|
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
|
||||||
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors";
|
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors";
|
||||||
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
|
||||||
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
|
||||||
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
|
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 { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
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 });
|
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||||
|
|
||||||
return c.json({
|
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