85
server/src/internal/customers/cancel/handleCancelV2.ts
Normal file
85
server/src/internal/customers/cancel/handleCancelV2.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user