Merge pull request #634 from useautumn/fix/cancel

fix: cancel
This commit is contained in:
John Yeo
2026-01-27 11:56:59 +00:00
committed by GitHub
6 changed files with 212 additions and 9 deletions

View File

@@ -0,0 +1,85 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
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";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan";
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors";
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionContext";
import { logUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionPlan";
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
export const handleCancelV2 = createRoute({
// body: CancelBodySchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const {
customer_id,
product_id,
entity_id,
cancel_immediately = false,
prorate: bodyProrate = true,
customer_product_id,
} = await c.req.json();
const updateSubscriptionBody: UpdateSubscriptionV0Params = {
customer_id,
product_id,
entity_id,
cancel_action: cancel_immediately
? "cancel_immediately"
: "cancel_end_of_cycle",
billing_behavior: bodyProrate ? "prorate_immediately" : "next_cycle_only",
};
ctx.logger.info(
`=============== RUNNING CANCEL FOR ${customer_id} ===============`,
);
const billingContext = await setupUpdateSubscriptionBillingContext({
ctx,
params: updateSubscriptionBody,
});
logUpdateSubscriptionContext({ ctx, billingContext });
const autumnBillingPlan = await computeUpdateSubscriptionPlan({
ctx,
billingContext,
params: updateSubscriptionBody,
});
logUpdateSubscriptionPlan({ ctx, plan: autumnBillingPlan, billingContext });
await handleUpdateSubscriptionErrors({
ctx,
billingContext,
autumnBillingPlan,
params: updateSubscriptionBody,
});
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,
autumnBillingPlan,
});
logStripeBillingPlan({ ctx, stripeBillingPlan, billingContext });
const billingResult = await executeBillingPlan({
ctx,
billingContext,
billingPlan: {
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
});
logStripeBillingResult({ ctx, result: billingResult.stripe });
return c.json({
success: true,
customer_id: customer_id,
product_id: product_id,
});
},
});

View File

@@ -14,7 +14,17 @@ import {
import type { DrizzleCli } from "@server/db/initDrizzle";
import RecaseError from "@server/utils/errorUtils";
import { notNullish } from "@server/utils/genUtils";
import { and, desc, eq, exists, inArray, ne, or, sql } from "drizzle-orm";
import {
and,
desc,
eq,
exists,
inArray,
isNull,
ne,
or,
sql,
} from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import { getLatestProducts } from "./productUtils";
@@ -131,7 +141,11 @@ export class ProductService {
eq(products.env, env),
eq(products.is_default, true),
ne(products.archived, true),
group ? eq(products.group, group) : undefined,
group === "" || group === null
? or(isNull(products.group), eq(products.group, ""))
: notNullish(group)
? eq(products.group, group)
: undefined,
inIds ? inArray(products.id, inIds) : undefined,
),
with: {

View File

@@ -175,7 +175,7 @@ export const constructProduct = ({
is_add_on: isAddOn,
is_default: (type === "free" && isDefault) || forcePaidDefault,
version,
group: group || "",
group: group ?? null,
free_trial: freeTrialConfig as FreeTrial,
created_at: Date.now(),
};

View File

@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
import { beforeAll, describe, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import {
expectDowngradeCorrect,
@@ -7,8 +7,6 @@ import {
} from "@tests/utils/expectUtils/expectScheduleUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
@@ -46,6 +44,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing trials: pro with trial -> p
ctx,
products: [free, pro, premium],
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({

View File

@@ -301,3 +301,107 @@ test.concurrent(`${chalk.yellowBright("cancel EOC edge: entity cancel -> uncance
productId: pro.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Cancel pro EOC with empty group - free default NOT scheduled
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Free default product with prefixed group (standard behavior)
* - Pro product with group: "" (explicitly empty - no group)
* - User cancels Pro at end of cycle
*
* Expected Result:
* - Pro should be canceling
* - Free default should NOT be scheduled (different groups: "" vs prefixed)
* - After advancing to next cycle:
* - Pro is removed
* - Free is NOT present (wasn't scheduled because groups don't match)
*/
test.concurrent(`${chalk.yellowBright("cancel EOC edge: pro with empty group - free default NOT scheduled")}`, async () => {
const customerId = "cancel-eoc-empty-group-no-default";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
// Free is the default product - will get prefixed group from initScenario
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
// Pro with explicit empty group - should NOT match free's prefixed group
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
// Explicitly set empty group (will be preserved due to our fix)
pro.group = "";
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify pro is active
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
// Cancel pro at end of cycle
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Verify pro is canceling and free is NOT scheduled (groups don't match)
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
canceling: [pro.id],
notPresent: [free.id], // Free should NOT be scheduled because groups don't match
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// After advancing, pro should be gone and free should NOT be present
const customerAfterAdvance =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotPresent({
customer: customerAfterAdvance,
productId: pro.id,
});
// Free should also not be present (wasn't scheduled)
await expectProductNotPresent({
customer: customerAfterAdvance,
productId: free.id,
});
// No products should remain
expect(customerAfterAdvance.products.length).toBe(0);
// Verify no Stripe subscription exists
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -20,8 +20,9 @@ export const addPrefixToProducts = ({
for (const product of products) {
product.id = `${product.id}_${prefix}`;
product.name = `${product.name} ${prefix}`;
// Only set group to prefix if not already defined
if (!product.group) {
// Only set group to prefix if not explicitly defined (null/undefined)
// Preserve empty string "" as an explicit "no group" value
if (product.group === null || product.group === undefined) {
product.group = prefix;
}
}