diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts new file mode 100644 index 000000000..c200ff511 --- /dev/null +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -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, + }); + }, +}); diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index f3f38fcd4..2e1a1f08c 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -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: { diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 25b644122..fb3df37b3 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -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(), }; diff --git a/server/tests/attach/others/others1.test.ts b/server/tests/attach/others/others1.test.ts index a6066f7c8..80bfdabf5 100644 --- a/server/tests/attach/others/others1.test.ts +++ b/server/tests/attach/others/others1.test.ts @@ -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({ diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-edge-cases.test.ts index 56d8c3056..b2192a151 100644 --- a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-edge-cases.test.ts @@ -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(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(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(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, + }); +}); diff --git a/server/tests/utils/testProductUtils/testProductUtils.ts b/server/tests/utils/testProductUtils/testProductUtils.ts index 5f335c2de..08c8b1d57 100644 --- a/server/tests/utils/testProductUtils/testProductUtils.ts +++ b/server/tests/utils/testProductUtils/testProductUtils.ts @@ -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; } }