diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index b168f0647..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "npx ultracite fix" - } - ] - } - ] - } -} diff --git a/.claude/skills/write-test/SKILL.md b/.claude/skills/write-test/SKILL.md index 9ac3bdd06..aa2fc426e 100644 --- a/.claude/skills/write-test/SKILL.md +++ b/.claude/skills/write-test/SKILL.md @@ -13,6 +13,11 @@ Write integration tests for the Autumn billing system using the `initScenario` p ## Before Writing Any Test +**ALWAYS check for duplicate test scenarios FIRST:** +1. Search the test directory for similar scenarios using `Grep` with relevant keywords (e.g., `new_billing_subscription`, `cancel.*addon`, feature names) +2. If a duplicate or very similar scenario exists, **WARN the user and ask for confirmation** before proceeding +3. Only proceed with writing the test after confirming it's not a duplicate + **ALWAYS read these codebase files FIRST:** 1. `server/tests/TEST_GUIDE.md` - Core patterns, fixtures, scenario builder 2. For billing tests: `server/tests/integration/billing/update-subscription/BILLING_GUIDE.md` @@ -20,7 +25,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p ## Critical Rules **DO:** -- Use `test.concurrent()` for isolated, parallel tests +- **ALWAYS use `test.concurrent()` for ALL tests** - never use plain `test()`. This enables parallel execution. - Use `initScenario` with `s.*` builders - Use `product.id` in `s.attach()` (never string literals) - Use `Decimal.js` for balance calculations in track tests @@ -29,6 +34,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p - **USE UTILITY FUNCTIONS WHENEVER POSSIBLE** - the shorter the code, the better. Check `server/tests/integration/billing/utils/` for existing utilities like `expectCustomerProducts`, `expectProductScheduled`, `expectCustomerInvoiceCorrect`, etc. **DON'T:** +- Use plain `test()` - **ALWAYS use `test.concurrent()`** - Use `describe/beforeAll/test` (legacy pattern) - Use `Date.now()` with test clocks (use `advancedTo`) - Share state between tests diff --git a/bunfig.toml b/bunfig.toml index 5da1024a7..23310895a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,7 +1,9 @@ -[test] -preload = ["./server/tests/setup-integration-tests.ts"] -timeout = 0 +# Preload env override for all bun runs (from workspace root) +preload = ["./scripts/preload-env.ts"] +[test] +preload = ["./scripts/preload-env.ts", "./server/tests/setup-integration-tests.ts"] +timeout = 0 [test.env] NODE_ENV = "test" \ No newline at end of file diff --git a/scripts/preload-env.ts b/scripts/preload-env.ts new file mode 100644 index 000000000..2a5fc5c7a --- /dev/null +++ b/scripts/preload-env.ts @@ -0,0 +1,5 @@ +// Preload script - runs BEFORE main script imports are evaluated +// This allows local .env to override Infisical secrets +import { loadLocalEnv } from "@server/utils/envUtils.js"; + +loadLocalEnv(); diff --git a/scripts/test.ts b/scripts/test.ts index 6a7506027..5cb9910a0 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -272,14 +272,9 @@ async function runTest() { console.log(chalk.green(`โœ“ Found: ${testFile.relative}\n`)); // Detect test framework - const framework = detectTestFramework({ filePath: testFile.path }); - const frameworkLabel = framework === "bun" ? "Bun" : "Mocha"; - console.log(chalk.cyan(`๐Ÿงช Running test file with ${frameworkLabel}...\n`)); - if (framework !== "bun") { - console.error(chalk.red("โŒ Mocha tests are deprecated")); - process.exit(1); - } + const frameworkLabel = "Bun"; + console.log(chalk.cyan(`๐Ÿงช Running test file with ${frameworkLabel}...\n`)); // Run the test file with the appropriate framework, wrapped with Infisical // Respect NODE_ENV from parent process (e.g., development for logging) diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh new file mode 100644 index 000000000..acdc80efe --- /dev/null +++ b/scripts/testGroups/all.sh @@ -0,0 +1,5 @@ +BUN_PARALLEL_V2 \ + 'stripe-webhooks' \ + 'update-subscription'\ + 'crud/customers' + --max=3 \ No newline at end of file diff --git a/scripts/testGroups/stripe-webhooks.sh b/scripts/testGroups/stripe-webhooks.sh index ee5af9dc1..00bbafc32 100755 --- a/scripts/testGroups/stripe-webhooks.sh +++ b/scripts/testGroups/stripe-webhooks.sh @@ -5,5 +5,6 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_V2 \ 'stripe-webhooks/invoice-created' \ - 'stripe-webhooks/subscription-deleted' + 'stripe-webhooks/subscription-deleted'\ + 'stripe-webhooks/subscription-updated' --max=3 diff --git a/scripts/testGroups/update-subscription.sh b/scripts/testGroups/update-subscription.sh index ac2d7a6a5..9e28f9fe8 100755 --- a/scripts/testGroups/update-subscription.sh +++ b/scripts/testGroups/update-subscription.sh @@ -27,6 +27,8 @@ BUN_PARALLEL_V2 \ 'update-subscription/multi-product' \ 'update-subscription/update-quantity' \ 'update-subscription/version-update' \ - 'update-subscription/uncancel' \ + 'update-subscription/cancel/uncancel' \ + 'update-subscription/cancel/immediately' \ + 'update-subscription/cancel/end-of-cycle' \ --max=2 diff --git a/server/bunfig.toml b/server/bunfig.toml new file mode 100644 index 000000000..286240bd9 --- /dev/null +++ b/server/bunfig.toml @@ -0,0 +1,9 @@ +# Server-specific config (for run.sh which executes from server/) +preload = ["../scripts/preload-env.ts"] + +[test] +preload = ["../scripts/preload-env.ts", "./tests/setup-integration-tests.ts"] +timeout = 0 + +[test.env] +NODE_ENV = "test" diff --git a/server/package.json b/server/package.json index 35fac8771..478724c2b 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,7 @@ "parallel-tests:verbose": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --verbose", "parallel-tests:debug": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --debug", "clear-master": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMasterOrg.ts", - "cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMasterOrg.ts", + "cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts", "ts": "bunx tsgo --build --noEmit", "test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts" }, diff --git a/server/preload-env.ts b/server/preload-env.ts new file mode 100644 index 000000000..8c96a11eb --- /dev/null +++ b/server/preload-env.ts @@ -0,0 +1,4 @@ +// Preload script - runs BEFORE main script imports are evaluated +// This allows local .env to override Infisical secrets +import { loadLocalEnv } from "@/utils/envUtils.js"; +loadLocalEnv(); diff --git a/server/run.sh b/server/run.sh index 7d234dfa5..09907bea3 100755 --- a/server/run.sh +++ b/server/run.sh @@ -1,26 +1,25 @@ #!/bin/bash # Run current file -# npx tsx scripts/alex.ts filename="$1" - - if [[ "$filename" == *"shell"* ]]; then "$filename" "${@:2}" -elif [[ "$filename" == *"/tests/"* ]]; then - # Extract everything after "/tests/" - path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///') - # Remove .ts extension if present - path_after_tests="${path_after_tests%.ts}" - # Use scripts/test.ts which auto-detects framework - NODE_ENV=development infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests" - +elif [[ "$filename" == *".test.ts" ]]; then + # Test files: use bun test (preload configured in bunfig.toml) + NODE_ENV=development infisical run --env=dev -- bun test --timeout 0 "$filename" elif [[ "$filename" == *".sh"* ]]; then "$filename" -elif [[ "$filename" == *"/scripts/"* ]]; then - # Run scripts with infisical prod environment - infisical run --env=prod -- bun "$filename" else - infisical run -- bun "$filename" + # Regular scripts (preload configured in bunfig.toml allows .env to override Infisical) + infisical run --env=dev -- bun "$filename" fi +# OLD: Using scripts/test.ts for test file matching (deprecated) +# elif [[ "$filename" == *"/tests/"* ]]; then +# # Extract everything after "/tests/" +# path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///') +# # Remove .ts extension if present +# path_after_tests="${path_after_tests%.ts}" +# # Use scripts/test.ts which auto-detects framework +# NODE_ENV=development infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests" + diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index c1c127103..d827af9a1 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -259,13 +259,20 @@ export class AutumnInt { return data; } - async attach(params: AttachBodyV0, headers?: Record) { - // const data = await this.post(`/attach`, { - // customer_id: customerId, - // product_id: productId, - // options: toSnakeCase(options), - // }); - const data = await this.post(`/attach`, params, headers); + async attach( + params: AttachBodyV0, + { skipWebhooks }: { skipWebhooks?: boolean } = {}, + ) { + const headers: Record = {}; + if (skipWebhooks !== undefined) { + headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false"; + } + + const data = await this.post( + `/attach`, + params, + Object.keys(headers).length > 0 ? headers : undefined, + ); return data; } diff --git a/server/src/external/stripe/stripeWebhookRouter.ts b/server/src/external/stripe/stripeWebhookRouter.ts index 23d4286c7..f1cbe08ac 100644 --- a/server/src/external/stripe/stripeWebhookRouter.ts +++ b/server/src/external/stripe/stripeWebhookRouter.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { stripeLoggerMiddleware } from "@/external/stripe/webhookMiddlewares/stripeLoggerMiddleware.js"; import { handleStripeWebhookEvent } from "./handleStripeWebhookEvent.js"; import { stripeConnectSeederMiddleware } from "./webhookMiddlewares/stripeConnectSeederMiddleware.js"; +import { stripeIdempotencyMiddleware } from "./webhookMiddlewares/stripeIdempotencyMiddleware.js"; import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacySeederMiddleware.js"; import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js"; import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js"; @@ -16,6 +17,7 @@ stripeWebhookRouter.post( stripeWebhookRefreshMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, + stripeIdempotencyMiddleware, handleStripeWebhookEvent, ); @@ -26,5 +28,6 @@ stripeWebhookRouter.post( stripeWebhookRefreshMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, + stripeIdempotencyMiddleware, handleStripeWebhookEvent, ); diff --git a/server/src/external/stripe/subscriptionSchedules/index.ts b/server/src/external/stripe/subscriptionSchedules/index.ts new file mode 100644 index 000000000..14011b70d --- /dev/null +++ b/server/src/external/stripe/subscriptionSchedules/index.ts @@ -0,0 +1,4 @@ +export * from "./operations/getStripeActiveSubscriptionSchedule"; + +export * from "./utils/convertStripeSubscriptionScheduleUtils"; +export * from "./utils/logStripeSchedulePhaseUtils"; diff --git a/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts b/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts new file mode 100644 index 000000000..f368c3d0b --- /dev/null +++ b/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts @@ -0,0 +1,19 @@ +import type Stripe from "stripe"; + +export const getStripeActiveSubscriptionSchedule = async ({ + stripeClient, + subscriptionScheduleId, +}: { + stripeClient: Stripe; + subscriptionScheduleId: string; +}): Promise => { + const schedule = await stripeClient.subscriptionSchedules.retrieve( + subscriptionScheduleId, + ); + + if (schedule.status === "canceled" || schedule.status === "released") { + return undefined; + } + + return schedule; +}; diff --git a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts index 9f5dd1b1b..7a99a2df3 100644 --- a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts +++ b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts @@ -129,3 +129,13 @@ export const stripeSubscriptionToNowMs = async ({ return Date.now(); }; + +export const stripeSubscriptionToScheduleId = ({ + stripeSubscription, +}: { + stripeSubscription: ExpandedStripeSubscription; +}): string | null => { + return typeof stripeSubscription.schedule === "string" + ? stripeSubscription.schedule + : (stripeSubscription.schedule?.id ?? null); +}; diff --git a/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts b/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts index 36b0819ca..9417e0f94 100644 --- a/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts +++ b/server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts @@ -16,9 +16,13 @@ export const logWebhookArrearLineItems = ({ ctx, extras: { arrearLineItems: { - lineItems: lineItems.map( - (item) => `${item.description}: ${item.finalAmount}`, - ), + lineItems: lineItems.map((item) => { + const hasDiscount = + item.finalAmount !== undefined && item.finalAmount !== item.amount; + return hasDiscount + ? `${item.description}: ${item.amount} โ†’ ${item.finalAmount} (discounted)` + : `${item.description}: ${item.amount}`; + }), updateCustomerEntitlements: updateCustomerEntitlements.map( (update) => ({ featureId: update.customerEntitlement.entitlement.feature?.id, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts index 79d625aa1..a2b25ad3d 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts @@ -2,6 +2,7 @@ import { type FullCusProduct, type FullCustomer, isCustomerProductOnStripeSubscription, + isCustomerProductOnStripeSubscriptionSchedule, } from "@autumn/shared"; import type Stripe from "stripe"; import { @@ -18,7 +19,10 @@ import { type ExpandedStripeSubscription, getExpandedStripeSubscription, } from "@/external/stripe/subscriptions"; -import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import { + stripeSubscriptionToNowMs, + stripeSubscriptionToScheduleId, +} from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext"; @@ -67,12 +71,22 @@ export const setupInvoiceCreatedContext = async ({ return null; } + // 3. Get expanded stripe subscription + const stripeSubscription = await getExpandedStripeSubscription({ + ctx, + subscriptionId: stripeSubscriptionId, + }); + // 5. Get customer products by subscription ID - const currentCustomerProducts = fullCustomer.customer_products.filter((cp) => - isCustomerProductOnStripeSubscription({ - customerProduct: cp, - stripeSubscriptionId, - }), + const currentCustomerProducts = fullCustomer.customer_products.filter( + (cp) => { + const onStripeSubscription = isCustomerProductOnStripeSubscription({ + customerProduct: cp, + stripeSubscriptionId, + }); + + return onStripeSubscription; + }, ); const customerProducts = @@ -81,6 +95,16 @@ export const setupInvoiceCreatedContext = async ({ stripeSubscriptionId, }); + const scheduledCustomerProducts = fullCustomer.customer_products.filter( + (cp) => { + const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription }); + return isCustomerProductOnStripeSubscriptionSchedule({ + customerProduct: cp, + stripeSubscriptionScheduleId: scheduleId, + }); + }, + ); + if (customerProducts.length === 0) { logger.info( `[invoice.created] No customer products found for subscription ${stripeSubscriptionId}`, @@ -89,13 +113,10 @@ export const setupInvoiceCreatedContext = async ({ } // 6. Update fullCustomer.customer_products with fresh data - fullCustomer.customer_products = customerProducts; - - // 3. Get expanded stripe subscription - const stripeSubscription = await getExpandedStripeSubscription({ - ctx, - subscriptionId: stripeSubscriptionId, - }); + fullCustomer.customer_products = [ + ...customerProducts, + ...scheduledCustomerProducts, + ]; // 4. Get expanded stripe customer (for discount info) const stripeCustomer = await getExpandedStripeCustomer({ diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts index 14288fdfe..6e060cfed 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts @@ -1,3 +1,5 @@ +import { cp } from "@autumn/shared"; +import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { InvoiceService } from "@/internal/invoices/InvoiceService"; import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext"; @@ -17,7 +19,8 @@ export const upsertAutumnInvoice = async ({ ctx: StripeWebhookContext; eventContext: InvoiceCreatedContext; }): Promise => { - const { stripeInvoice, customerProducts, fullCustomer } = eventContext; + const { stripeInvoice, customerProducts, fullCustomer, stripeSubscription } = + eventContext; // Skip first invoice (subscription_create) if (stripeInvoice.billing_reason !== "subscription_cycle") { @@ -27,9 +30,34 @@ export const upsertAutumnInvoice = async ({ return; } - const productIds = [...new Set(customerProducts.map((cp) => cp.product.id))]; + // Add scheduled customer products that have started + const startedScheduledCustomerProducts = + fullCustomer.customer_products.filter((customerProduct) => { + const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription }); + + const { valid: hasStarted } = cp(customerProduct) + .onStripeSubscription({ + stripeSubscriptionId: stripeSubscription.id, + }) + .or.onStripeSchedule({ + stripeSubscriptionScheduleId: scheduleId, + }) + .scheduled() + .hasStarted({ nowMs: eventContext.nowMs }); + + return hasStarted; + }); + + const allCustomerProducts = [ + ...customerProducts, + ...startedScheduledCustomerProducts, + ]; + + const productIds = [ + ...new Set(allCustomerProducts.map((cp) => cp.product.id)), + ]; const internalProductIds = [ - ...new Set(customerProducts.map((cp) => cp.internal_product_id)), + ...new Set(allCustomerProducts.map((cp) => cp.internal_product_id)), ]; const internalCustomerId = fullCustomer.internal_id; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts index 2d390c7be..9e076f8b0 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts @@ -1,4 +1,4 @@ -import { hasCustomerProductEnded } from "@autumn/shared"; +import { type FullCusProduct, hasCustomerProductEnded } from "@autumn/shared"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import { trackCustomerProductUpdate } from "../../../common/trackCustomerProductUpdate"; @@ -7,6 +7,7 @@ import type { StripeSubscriptionUpdatedContext } from "../../stripeSubscriptionU /** * Expires customer products that have ended (based on ended_at time). * Also activates default product if no other active product exists in the same group. + * Caches expired products so invoice.created can access them for usage-based billing. */ export const expireEndedCustomerProducts = async ({ ctx, @@ -16,7 +17,10 @@ export const expireEndedCustomerProducts = async ({ eventContext: StripeSubscriptionUpdatedContext; }): Promise => { const { logger } = ctx; - const { customerProducts, fullCustomer, nowMs } = eventContext; + const { customerProducts, fullCustomer, stripeSubscription, nowMs } = + eventContext; + + const expiredCustomerProducts: FullCusProduct[] = []; for (const customerProduct of customerProducts) { if (!hasCustomerProductEnded(customerProduct, { nowMs })) continue; @@ -31,10 +35,20 @@ export const expireEndedCustomerProducts = async ({ fullCustomer, }); + expiredCustomerProducts.push(customerProduct); + trackCustomerProductUpdate({ eventContext, customerProduct, updates, }); } + + // Cache expired products so invoice.created can access them for usage-based billing + if (expiredCustomerProducts.length > 0) { + await customerProductActions.expiredCache.set({ + stripeSubscriptionId: stripeSubscription.id, + customerProducts: expiredCustomerProducts, + }); + } }; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts new file mode 100644 index 000000000..3c203637b --- /dev/null +++ b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts @@ -0,0 +1,62 @@ +import { tryCatch } from "@autumn/shared"; +import type { Context, Next } from "hono"; +import { redis } from "@/external/redis/initRedis"; +import type { StripeWebhookHonoEnv } from "./stripeWebhookContext"; + +const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Middleware that prevents duplicate processing of Stripe webhook events. + * Uses Redis SET NX PX to atomically check and set event ID with expiry, + * ensuring only one instance processes each event even with concurrent deliveries. + * + * If Redis is unavailable or errors, the middleware allows the request through (fail-open). + */ +export const stripeIdempotencyMiddleware = async ( + c: Context, + next: Next, +) => { + const ctx = c.get("ctx"); + const { stripeEvent, org, env } = ctx; + + const idempotencyKey = `stripe:webhook:${org.id}:${env}:${stripeEvent.id}`; + + // Fail open if Redis not ready + if (redis.status !== "ready") { + await next(); + return; + } + + // Atomically try to set the key with expiry + // NX = only set if key doesn't exist, PX = set expiry in milliseconds + // Returns "OK" if set, null if key already exists + const { data: result, error } = await tryCatch( + redis.set( + idempotencyKey, + Date.now().toString(), + "PX", + IDEMPOTENCY_TTL_MS, + "NX", + ), + ); + + if (error) { + // Redis error - fail open + ctx.logger.warn( + `[stripeIdempotencyMiddleware] Redis error, allowing through: ${error}`, + ); + await next(); + return; + } + + if (result === null) { + // Key already exists - duplicate event + ctx.logger.info( + `[stripeIdempotencyMiddleware] Duplicate webhook event detected, skipping: ${stripeEvent.id}`, + ); + return c.json({ received: true, duplicate: true }, 200); + } + + // Lock acquired ("OK"), proceed with processing + await next(); +}; diff --git a/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts b/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts index b2363f096..12e9ccffa 100644 --- a/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts @@ -1,4 +1,4 @@ -import { cp } from "@autumn/shared"; +import { CusProductStatus, cp } from "@autumn/shared"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; export const addStripeSubscriptionScheduleIdToBillingPlan = ({ @@ -15,4 +15,14 @@ export const addStripeSubscriptionScheduleIdToBillingPlan = ({ customerProduct.scheduled_ids = [stripeSubscriptionScheduleId]; } + + // Add to update customer product + if (autumnBillingPlan.updateCustomerProduct) { + const { updates } = autumnBillingPlan.updateCustomerProduct; + const isExpiring = updates.status === CusProductStatus.Expired; + + if (!isExpiring) { + updates.scheduled_ids = [stripeSubscriptionScheduleId]; + } + } }; diff --git a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts index 37483fe89..097139583 100644 --- a/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts +++ b/server/src/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors.ts @@ -12,6 +12,11 @@ export const handleStripeBillingPlanErrors = ({ }) => { // If there's an existing subscription schedule, validate it has current_phase.start_date // This is required for schedule updates (Stripe requires anchoring phases to the current phase start) + + console.log( + "Billing context stripe subscription schedule", + billingContext.stripeSubscriptionSchedule, + ); if (billingContext.stripeSubscriptionSchedule) { const currentPhaseStart = billingContext.stripeSubscriptionSchedule.current_phase?.start_date; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index 4e035b82c..75c5f349c 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -62,7 +62,7 @@ export const executeStripeBillingPlan = async ({ // Otherwise Stripe rejects the cancel_at update while schedule still manages subscription const isReleaseAction = stripeSubscriptionScheduleAction?.type === "release"; - if (isReleaseAction) { + if (isReleaseAction && !resumeAfterSubscriptionAction) { await executeStripeSubscriptionScheduleAction({ ctx, billingContext, diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index 6693b40b5..218af4b7e 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -20,29 +20,70 @@ const toCreatePhase = ( /** * Builds phases for updating a schedule that was created from a subscription. - * The first phase must use the schedule's actual current phase start_date. + * The first phase must use the schedule's actual current phase start_date AND items. + * Stripe doesn't allow modifying items in an active phase, so we preserve them exactly. */ const buildAnchoredPhases = ({ params, - currentPhaseStart, + existingSchedule, }: { params: { phases?: Stripe.SubscriptionScheduleUpdateParams.Phase[] }; - currentPhaseStart: number; + existingSchedule: Stripe.SubscriptionSchedule; }): Stripe.SubscriptionScheduleUpdateParams.Phase[] => { const inputPhases = params.phases ?? []; if (inputPhases.length === 0) return []; - // First phase: preserve all fields but override start_date (can't modify current phase start) - // Future phases: keep as-is + const currentPhaseStart = existingSchedule.current_phase?.start_date; + if (!currentPhaseStart) { + throw new Error("Cannot update schedule: missing current phase start_date"); + } + + // Map existing items to update format (response type -> request type) + const existingFirstPhaseItems: Stripe.SubscriptionScheduleUpdateParams.Phase["items"] = + existingSchedule.phases[0]?.items.map((item) => ({ + price: typeof item.price === "string" ? item.price : item.price?.id, + quantity: item.quantity ?? undefined, + })); + + // First phase: preserve start_date AND items from existing schedule + // Stripe doesn't allow modifying items in an active/in-progress phase + // The actual current state is managed by the subscription, not the schedule + // Future phases: keep as-is (these define what happens at phase transitions) return [ { ...inputPhases[0], start_date: currentPhaseStart, + items: existingFirstPhaseItems ?? inputPhases[0].items, }, ...inputPhases.slice(1), ]; }; +/** + * Creates a schedule from an existing subscription and updates it with phases. + * This is the standard pattern for both "create" and "update" actions. + */ +const createScheduleFromSubscription = async ({ + stripeCli, + subscriptionId, + params, +}: { + stripeCli: Stripe; + subscriptionId: string; + params: Stripe.SubscriptionScheduleUpdateParams; +}): Promise => { + const schedule = await stripeCli.subscriptionSchedules.create({ + from_subscription: subscriptionId, + }); + + const phases = buildAnchoredPhases({ params, existingSchedule: schedule }); + + return await stripeCli.subscriptionSchedules.update(schedule.id, { + phases, + end_behavior: params.end_behavior, + }); +}; + export const executeStripeSubscriptionScheduleAction = async ({ ctx, billingContext, @@ -71,24 +112,12 @@ export const executeStripeSubscriptionScheduleAction = async ({ case "create": { const { params } = subscriptionScheduleAction; - // If there's an existing subscription, create schedule from it then update with phases + // If there's an existing subscription, create schedule from it if (stripeSubscription) { - const schedule = await stripeCli.subscriptionSchedules.create({ - from_subscription: stripeSubscription.id, - }); - - const currentPhaseStart = schedule.phases[0]?.start_date; - if (!currentPhaseStart) { - throw new Error( - "Cannot create schedule: missing current phase start_date", - ); - } - - const phases = buildAnchoredPhases({ params, currentPhaseStart }); - - return await stripeCli.subscriptionSchedules.update(schedule.id, { - phases, - end_behavior: params.end_behavior, + return await createScheduleFromSubscription({ + stripeCli, + subscriptionId: stripeSubscription.id, + params, }); } @@ -104,19 +133,29 @@ export const executeStripeSubscriptionScheduleAction = async ({ const { stripeSubscriptionScheduleId, params } = subscriptionScheduleAction; - // current_phase.start_date is validated in handleStripeBillingPlanErrors - const currentPhaseStart = billingContext.stripeSubscriptionSchedule - ?.current_phase?.start_date as number; - - const phases = buildAnchoredPhases({ params, currentPhaseStart }); - - return await stripeCli.subscriptionSchedules.update( + // Always release + recreate to avoid "can't modify active phase" errors + // The subscription may have been updated first, changing its items + await stripeCli.subscriptionSchedules.release( stripeSubscriptionScheduleId, - { - ...params, - phases, - }, ); + + // Get the subscription ID from the existing schedule + const subscriptionId = + billingContext.stripeSubscriptionSchedule?.subscription; + if (!subscriptionId) { + throw new Error( + "Cannot update schedule: no subscription ID found on existing schedule", + ); + } + + return await createScheduleFromSubscription({ + stripeCli, + subscriptionId: + typeof subscriptionId === "string" + ? subscriptionId + : subscriptionId.id, + params, + }); } case "release": diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts index 6215888c6..3f30cba6c 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts @@ -5,6 +5,7 @@ import { } from "@autumn/shared"; import { createStripeCli } from "@server/external/connect/createStripeCli"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import { getStripeActiveSubscriptionSchedule } from "@/external/stripe/subscriptionSchedules/index"; export const fetchStripeSubscriptionScheduleForBilling = async ({ ctx, @@ -26,9 +27,10 @@ export const fetchStripeSubscriptionScheduleForBilling = async ({ // 1. If we have a subscription schedule ID, just retrieve that if (subscriptionScheduleId) { - const schedule = await stripeCli.subscriptionSchedules.retrieve( + const schedule = await getStripeActiveSubscriptionSchedule({ + stripeClient: stripeCli, subscriptionScheduleId, - ); + }); return schedule; } @@ -44,7 +46,10 @@ export const fetchStripeSubscriptionScheduleForBilling = async ({ if (!scheduleId) return undefined; - const schedule = await stripeCli.subscriptionSchedules.retrieve(scheduleId); + const schedule = await getStripeActiveSubscriptionSchedule({ + stripeClient: stripeCli, + subscriptionScheduleId: scheduleId, + }); return schedule; }; diff --git a/server/src/internal/billing/v2/types/autumnBillingPlan.ts b/server/src/internal/billing/v2/types/autumnBillingPlan.ts index 0c84510a6..fea8d46ef 100644 --- a/server/src/internal/billing/v2/types/autumnBillingPlan.ts +++ b/server/src/internal/billing/v2/types/autumnBillingPlan.ts @@ -42,6 +42,8 @@ export const AutumnBillingPlanSchema = z.object({ canceled: z.boolean().nullish(), canceled_at: z.number().nullish(), ended_at: z.number().nullish(), + + scheduled_ids: z.array(z.string()).optional(), }), }) .optional(), diff --git a/server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts index 7563b0244..043acd6ea 100644 --- a/server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts @@ -25,11 +25,12 @@ export const setupDefaultProductContext = async ({ if (nullish(params.cancel_action)) return undefined; // Add-ons don't trigger default products - const { valid: isMainAndCustomerScoped } = cp(customerProduct) + const { valid: isMainCustomerScopedAndPaid } = cp(customerProduct) .main() + .paidRecurring() .customerScoped(); - if (!isMainAndCustomerScoped) return undefined; + if (!isMainCustomerScopedAndPaid) return undefined; const defaultProduct = await getFreeDefaultProductByGroup({ ctx, diff --git a/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts b/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts index 1abbbb6da..69d50910f 100644 --- a/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts +++ b/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts @@ -1,30 +1,98 @@ /** * Converts an AutumnBillingPlan to sendProductsUpdated workflow triggers. - * Derives scenario from product status. + * Handles: + * - New/active product inserts (scenario: "new") + * - Cancel updates (scenario: "cancel" or "downgrade" based on scheduled product) + * - Uncancel updates (scenario: "renew") + * - Filters out scheduled products from insert webhooks */ -import { CusProductStatus } from "@autumn/shared"; +import { + AttachScenario, + CusProductStatus, + type FullCusProduct, + isCustomerProductFree, + isCustomerProductScheduled, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan.js"; import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext"; import { workflows } from "@/queue/workflows.js"; -const deriveScenarioFromStatus = (status: string): string => { - switch (status) { - case CusProductStatus.Scheduled: - return "scheduled"; - case CusProductStatus.Active: - return "new"; - case CusProductStatus.Expired: - return "expired"; - case CusProductStatus.PastDue: - return "past_due"; - default: - return "new"; - } +// ============================================================================ +// HELPERS +// ============================================================================ + +/** Check if any scheduled product in the list is paid (not free) */ +const hasPaidScheduledProduct = ({ + customerProducts, +}: { + customerProducts: FullCusProduct[]; +}): boolean => { + return customerProducts.some( + (cp) => + cp.status === CusProductStatus.Scheduled && !isCustomerProductFree(cp), + ); }; +/** + * Get the webhook scenario for an updateCustomerProduct, or null if no webhook needed. + * - Cancel: canceled=true with timestamps set โ†’ "cancel" or "downgrade" + * - Uncancel: canceled=false with timestamps cleared โ†’ "renew" + */ +const getUpdateScenario = ({ + updates, + insertCustomerProducts, +}: { + updates: { + canceled?: boolean | null; + canceled_at?: number | null; + ended_at?: number | null; + }; + insertCustomerProducts: FullCusProduct[]; +}): AttachScenario | null => { + // Cancel: canceled=true with timestamps set + if ( + updates.canceled === true && + updates.canceled_at != null && + updates.ended_at != null + ) { + return hasPaidScheduledProduct({ customerProducts: insertCustomerProducts }) + ? AttachScenario.Downgrade + : AttachScenario.Cancel; + } + + // Uncancel: canceled=false with timestamps cleared + if ( + updates.canceled === false && + updates.canceled_at === null && + updates.ended_at === null + ) { + return AttachScenario.Renew; + } + + return null; +}; + +// /** Derive webhook scenario from customer product status (for inserts) */ +// const deriveScenarioFromStatus = ({ status }: { status: string }): string => { +// switch (status) { +// case CusProductStatus.Active: +// return AttachScenario.New; +// case CusProductStatus.Expired: +// return AttachScenario.Expired; +// case CusProductStatus.PastDue: +// return AttachScenario.PastDue; +// default: +// return AttachScenario.New; +// } +// }; + +// ============================================================================ +// MAIN FUNCTION +// ============================================================================ + export const billingPlanToSendProductsUpdated = async ({ ctx, autumnBillingPlan, @@ -34,18 +102,46 @@ export const billingPlanToSendProductsUpdated = async ({ autumnBillingPlan: AutumnBillingPlan; billingContext: BillingContext | CreateCustomerContext; }) => { - // Skip webhooks if test option is set (used in integration tests) if (ctx.testOptions?.skipWebhooks) return; const { fullCustomer } = billingContext; - const customerId = fullCustomer.id ?? fullCustomer.internal_id; + const { insertCustomerProducts, updateCustomerProduct } = autumnBillingPlan; - const { insertCustomerProducts } = autumnBillingPlan; + // A. Handle cancel/uncancel webhook for updateCustomerProduct + if (updateCustomerProduct) { + const scenario = getUpdateScenario({ + updates: updateCustomerProduct.updates, + insertCustomerProducts, + }); - // Queue for each inserted product + if (scenario) { + try { + await workflows.triggerSendProductsUpdated({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + customerProductId: updateCustomerProduct.customerProduct.id, + scenario, + }); + + ctx.logger.info( + `[billingPlanToSendProductsUpdated] Queued ${scenario} webhook for ${updateCustomerProduct.customerProduct.product.name}`, + ); + } catch (error) { + ctx.logger.error( + `[billingPlanToSendProductsUpdated] Failed to queue ${scenario} webhook for ${updateCustomerProduct.customerProduct.product.name}: ${error}`, + ); + } + } + } + + // B. Queue webhooks for inserted products (excluding scheduled ones) for (const cusProduct of insertCustomerProducts) { - const scenario = deriveScenarioFromStatus(cusProduct.status); + if (isCustomerProductScheduled(cusProduct)) continue; + + // const scenario = deriveScenarioFromStatus({ status: cusProduct.status }); + const scenario = AttachScenario.New; try { await workflows.triggerSendProductsUpdated({ diff --git a/server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts b/server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts index d221be0fa..d1a380b4c 100644 --- a/server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts +++ b/server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts @@ -1,4 +1,8 @@ -import type { FullCusProduct } from "@autumn/shared"; +import { + cusProductToPrices, + type FullCusProduct, + isConsumablePrice, +} from "@autumn/shared"; import { CacheManager } from "@/utils/cacheUtils/CacheManager"; const getExpiredCacheKey = (stripeSubscriptionId: string) => @@ -17,6 +21,12 @@ export const setExpiredCustomerProductsCache = async ({ stripeSubscriptionId: string; customerProducts: FullCusProduct[]; }): Promise => { + // Filter customer products only for those with usage based prices + const usageBasedCustomerProducts = customerProducts.filter((cp) => { + const prices = cusProductToPrices({ cusProduct: cp }); + return prices.some((p) => isConsumablePrice(p)); + }); + const key = getExpiredCacheKey(stripeSubscriptionId); - await CacheManager.setJson(key, customerProducts, 300); + await CacheManager.setJson(key, usageBasedCustomerProducts, 300); }; diff --git a/server/src/utils/envUtils.ts b/server/src/utils/envUtils.ts index 2798e226f..d16e00fed 100644 --- a/server/src/utils/envUtils.ts +++ b/server/src/utils/envUtils.ts @@ -15,14 +15,15 @@ export const loadLocalEnv = () => { // Load local .env file FIRST - these will take precedence over Infisical const result = config({ path: envPath }); if (result.parsed) { - console.log( + // Use stderr so output doesn't pollute stdout for scripts using shell substitution + console.error( `๐Ÿ“„ Loading ${Object.keys(result.parsed).length} variables from ${envFileName}`, ); for (const [key, value] of Object.entries(result.parsed)) { process.env[key] = value; } } else { - console.log( + console.error( `โ„น๏ธ No ${envFileName} file found (using only Infisical secrets)`, ); } diff --git a/server/tests/_temp/seed-scenarios.ts b/server/tests/_temp/seed-scenarios.ts new file mode 100644 index 000000000..62cf4852c --- /dev/null +++ b/server/tests/_temp/seed-scenarios.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env bun + +/** + * Seed script for manual dashboard testing of cancel scenarios. + * + * Creates: + * A. Pro product with consumable words, allocated workflows, and prepaid messages + * B. Recurring add-on + * C. Free product with free messages + * D. One-time plan with prepaid messages + * E. Premium product (same as pro but with higher prices) + * F. Customer with 2 entity users + * + * Run: bun server/tests/_temp/seed-scenarios.ts + */ + +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { createTestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; + +const SEED_PREFIX = "seed-cancel-test"; + +async function seedScenarios() { + // Clear and setup org first + + // await clearMasterOrg(); + + const ctx = await createTestContext(); + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // PRODUCT DEFINITIONS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + // A. Pro product with consumable words, allocated workflows, and prepaid messages + const proProduct = products.pro({ + id: "pro", + items: [ + items.consumableWords({ includedUsage: 100 }), // 100 free words, then $0.05/word overage + items.allocatedWorkflows({ includedUsage: 3 }), // 3 free workflows, $10/workflow overage + items.prepaidMessages({ + includedUsage: 50, + billingUnits: 100, + price: 10, + }), // 50 free messages, $10/100 pack + ], + }); + + // B. Recurring add-on ($20/month with extra messages) + const recurringAddOn = products.recurringAddOn({ + id: "addon", + items: [items.monthlyMessages({ includedUsage: 500 })], // 500 extra messages per month + }); + + // C. Free product with free messages + const freeProduct = products.base({ + id: "free", + items: [items.monthlyMessages({ includedUsage: 100 })], // 100 free messages/month + isDefault: true, + }); + + // D. One-time plan with prepaid messages + const oneTimeProduct = products.oneOff({ + id: "one-time", + items: [ + items.oneOffMessages({ includedUsage: 0, billingUnits: 500, price: 25 }), + ], // $25 for 500 messages (one-time) + }); + + // E. Premium product (same features as pro but with premium pricing) + const premiumProduct = products.base({ + id: "premium", + items: [ + items.monthlyPrice({ price: 50 }), // $50/month base (vs pro's $20) + items.consumableWords({ includedUsage: 500 }), // 500 free words (vs pro's 100) + items.allocatedWorkflows({ includedUsage: 10 }), // 10 free workflows (vs pro's 3) + items.prepaidMessages({ + includedUsage: 200, + billingUnits: 100, + price: 8, + }), // 200 free messages, $8/100 pack + ], + }); + + const allProducts = [ + proProduct, + recurringAddOn, + freeProduct, + oneTimeProduct, + premiumProduct, + ]; + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // INITIALIZE SCENARIO + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + await initScenario({ + ctx, + customerId: SEED_PREFIX, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: allProducts }), + s.entities({ count: 2, featureId: TestFeature.Users }), // F. 2 entity users + ], + actions: [ + // Attach the pro product to the customer + s.attach({ + productId: proProduct.id, + options: [{ feature_id: "messages", quantity: 100 }], + }), + s.attach({ + productId: recurringAddOn.id, + }), + + s.attach({ + productId: premiumProduct.id, + entityIndex: 0, + options: [{ feature_id: "messages", quantity: 300 }], + }), + ], + }); + + process.exit(0); +} + +seedScenarios() + .catch((error) => { + console.error("Seed script failed:", error); + process.exit(1); + }) + .finally(() => { + process.exit(0); + }); diff --git a/server/tests/attach/basic/basic3.test.ts b/server/tests/attach/basic/basic3.test.ts deleted file mode 100644 index 88d8cc808..000000000 --- a/server/tests/attach/basic/basic3.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, CusProductStatus } from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { - initBasicSharedProducts, - sharedDefaultFree, - sharedProProduct, -} from "./sharedProducts.js"; - -const testCase = "basic3"; -const customerId = testCase; - -describe(`${chalk.yellowBright("basic3: Testing cancel through Stripe at period end and now")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - let stripeCli: Stripe; - - beforeAll(async () => { - stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); - - // Explicitly ensure shared products exist - await initBasicSharedProducts(); - - // Then create customer with payment method - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - withDefault: true, - }); - }); - - test("should attach pro product", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: sharedProProduct.id, - }); - - const res = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ - sent: sharedProProduct, - cusRes: res, - }); - }); - - test("should cancel pro product (at period end)", async () => { - const cusRes: any = await AutumnCli.getCustomer(customerId); - - const proProduct = cusRes.products.find( - (p: any) => p.id === sharedProProduct.id, - ); - - for (const subId of proProduct.subscription_ids) { - await stripeCli.subscriptions.update(subId, { - cancel_at_period_end: true, - }); - } - await timeout(5000); - }); - - test("should have pro product active, and canceled_at != null, and free scheduled", async () => { - const cusRes: any = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ - sent: sharedProProduct, - cusRes: cusRes, - }); - - const proProduct = cusRes.products.find( - (p: any) => p.id === sharedProProduct.id, - ); - expect(proProduct.canceled_at).not.toBe(null); - expect(proProduct.status).toBe(CusProductStatus.Active); - - const freeProduct = cusRes.products.find( - (p: any) => p.id === sharedDefaultFree.id, - ); - expect(freeProduct).toBeDefined(); - expect(freeProduct.status).toBe(CusProductStatus.Scheduled); - }); - - test("should cancel pro product (now)", async () => { - const cusRes: any = await AutumnCli.getCustomer(customerId); - const proProduct = cusRes.products.find( - (p: any) => p.id === sharedProProduct.id, - ); - - for (const subId of proProduct.subscription_ids) { - await stripeCli.subscriptions.cancel(subId); - } - await timeout(8000); - }); - - test("should have free product active, and no pro product", async () => { - const cusRes: any = await AutumnCli.getCustomer(customerId); - await expectCustomerV0Correct({ - sent: sharedDefaultFree, - cusRes: cusRes, - }); - }); -}); diff --git a/server/tests/attach/basic/basic7.test.ts b/server/tests/attach/basic/basic7.test.ts deleted file mode 100644 index a3a3304e3..000000000 --- a/server/tests/attach/basic/basic7.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - ApiVersion, - CusProductStatus, - type FixedPriceConfig, - FreeTrialDuration, - ProductItemInterval, -} from "@autumn/shared"; -import { AutumnCli } from "@tests/cli/AutumnCli.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { convertProductV2ToV1 } from "@/internal/products/productUtils/productV2Utils/convertProductV2ToV1.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Pro product with trial (matches global products.proWithTrial) -const proWithTrial = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], - freeTrial: { - length: 7, - duration: FreeTrialDuration.Day, - unique_fingerprint: true, - card_required: true, - }, -}); - -const testCase = "basic7"; -const customerId = testCase; - -describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer)")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - // Create products FIRST before customer creation - await initProductsV0({ - ctx, - products: [proWithTrial], - prefix: testCase, - customerId, - }); - - // Then create customer with payment method - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - }); - - test("should attach pro with trial and have correct product & invoice", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: proWithTrial.id, - }); - - const customer = await AutumnCli.getCustomer(customerId); - - await expectCustomerV0Correct({ - sent: proWithTrial, - cusRes: customer, - status: CusProductStatus.Trialing, - }); - - const invoices = customer.invoices; - expect(invoices.length).toBe(1); - expect(invoices[0].total).toBe(0); - }); - - test("should cancel pro with trial", async () => { - await autumnV1.cancel({ - customer_id: customerId, - product_id: proWithTrial.id, - cancel_immediately: true, - }); - await timeout(5000); - }); - - test("should be able to attach pro with trial again (renewal flow)", async () => { - await autumnV1.attach({ - customer_id: customerId, - product_id: proWithTrial.id, - }); - - const customer = await AutumnCli.getCustomer(customerId); - - await expectCustomerV0Correct({ - sent: proWithTrial, - cusRes: customer, - }); - - const invoices = customer.invoices; - expect(invoices.length).toBe(2); - - // Get price from converted product - const proWithTrialV1 = convertProductV2ToV1({ - productV2: proWithTrial, - orgId: ctx.org.id, - features: ctx.features, - }); - - expect(invoices[0].total).toBe( - (proWithTrialV1.prices[0].config as FixedPriceConfig).amount, - ); - }); -}); diff --git a/server/tests/clearMaster.ts b/server/tests/clearMaster.ts new file mode 100644 index 000000000..e8569218e --- /dev/null +++ b/server/tests/clearMaster.ts @@ -0,0 +1,4 @@ +import { clearMasterOrg } from "./clearMasterOrg.js"; + +await clearMasterOrg(); +process.exit(0); diff --git a/server/tests/clearMasterOrg.ts b/server/tests/clearMasterOrg.ts index 970b79353..5cc497590 100644 --- a/server/tests/clearMasterOrg.ts +++ b/server/tests/clearMasterOrg.ts @@ -11,7 +11,7 @@ import { redis } from "@/external/redis/initRedis.js"; import { clearOrg } from "./utils/setup/clearOrg.js"; import { setupOrg } from "./utils/setup/setupOrg.js"; -async function main() { +export const clearMasterOrg = async () => { console.log(chalk.blue("\n๐Ÿงน Clearing Master Org...\n")); try { @@ -52,7 +52,7 @@ async function main() { console.error(chalk.red("\nโŒ Error:"), error); process.exit(1); } -} +}; -await main(); -process.exit(0); +// await main(); +// process.exit(0); diff --git a/server/tests/core/cancel/cancel5.test.ts b/server/tests/core/cancel/cancel5.test.ts deleted file mode 100644 index 55ded98b4..000000000 --- a/server/tests/core/cancel/cancel5.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { beforeAll, describe, expect, it } from "bun:test"; -import { - CusProductStatus, - LegacyVersion, - ProductItemInterval, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const freeProd = constructProduct({ - type: "free", - isDefault: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 5, - interval: ProductItemInterval.Month, - }), - ], -}); - -const proProd = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }), - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, - interval: ProductItemInterval.Month, - }), - constructFeatureItem({ - featureId: TestFeature.Admin, - unlimited: true, - }), - ], -}); - -const testCase = "cancel5"; -describe(`${chalk.yellowBright("cancel5: Testing cancel for trial products")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [freeProd, proProd], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - withDefault: true, - }); - - stripeCli = ctx.stripeCli; - }); - - it("should attach pro", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: proProd.id, - }); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: proProd, - }); - }); - - let sub: Stripe.Subscription | undefined; - - it("should cancel pro product through stripe CLI", async () => { - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - sub = await cusProductToSub({ - cusProduct: fullCus.customer_products?.find( - (cp) => cp.product.id === proProd.id, - ), - stripeCli, - }); - - await stripeCli.subscriptions.update(sub!.id, { - cancel_at_period_end: true, - }); - - await timeout(4000); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: proProd, - isCanceled: true, - }); - - expectProductAttached({ - customer, - product: freeProd, - status: CusProductStatus.Scheduled, - }); - }); - - it("should renew pro produce through stripe CLI and have it update correctly", async () => { - await stripeCli.subscriptions.update(sub!.id, { - cancel_at_period_end: false, - }); - - await timeout(4000); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: proProd, - status: CusProductStatus.Active, - }); - - expect( - customer.products.filter((p) => p.group === proProd.group).length, - ).toBe(1); - }); -}); diff --git a/server/tests/core/cancel/mergedCancel1.test.ts b/server/tests/core/cancel/mergedCancel1.test.ts deleted file mode 100644 index af5839903..000000000 --- a/server/tests/core/cancel/mergedCancel1.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type AppEnv, - AttachScenario, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -const ops = [ - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - skipSubCheck: true, - }, - { - entityId: "2", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, -]; - -const cancels = [ - { - entityId: "1", - product: premium, - }, - { - entityId: "2", - product: premium, - shouldBeCanceled: true, - }, -]; - -const renewals = [ - { - entityId: "1", - product: premium, - }, - - { - entityId: "2", - product: premium, - }, -]; - -const testCase = "mergedCancel1"; -describe(`${chalk.yellowBright("mergedCancel1: Merged cancel")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let testClockId: string; - let curUnix: number; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [premium], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - testClockId = res.testClockId!; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - test("should run operations", async () => { - await autumn.entities.create(customerId, entities); - - for (let index = 0; index < ops.length; index++) { - const op = ops[index]; - try { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - skipSubCheck: op.skipSubCheck, - entityId: op.entityId, - }); - } catch (error) { - console.log(`Operation failed: ${op.product.id}, index: ${index}`); - throw error; - } - } - }); - - test("should track usage cancel, advance test clock and have correct invoice", async () => { - for (const cancel of cancels) { - await autumn.cancel({ - customer_id: customerId, - product_id: cancel.product.id, - entity_id: cancel.entityId, - cancel_immediately: false, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - shouldBeCanceled: cancel.shouldBeCanceled, - }); - } - }); - - test("should renew both entities", async () => { - for (const renewal of renewals) { - const checkout = await autumn.checkout({ - customer_id: customerId, - product_id: renewal.product.id, - entity_id: renewal.entityId, - }); - - expect(checkout.product.scenario).toBe(AttachScenario.Renew); - expect(checkout.total).toBe(0); - - const attach = await autumn.attach({ - customer_id: customerId, - product_id: renewal.product.id, - entity_id: renewal.entityId, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - }); - } - }); -}); diff --git a/server/tests/core/cancel/mergedCancel2.test.ts b/server/tests/core/cancel/mergedCancel2.test.ts deleted file mode 100644 index 66699d133..000000000 --- a/server/tests/core/cancel/mergedCancel2.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeAll, describe, it } from "bun:test"; -import { CusProductStatus, LegacyVersion } from "@autumn/shared"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Premium, Premium -// Cancel End, Cancel Immediately -// Results: Canceled sub - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -const ops = [ - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, -]; - -const cancels = [ - { - entityId: "1", - product: premium, - }, - { - entityId: "2", - product: premium, - cancelImmediately: true, - shouldBeCanceled: true, - }, -]; - -const testCase = "mergedCancel2"; -describe(`${chalk.yellowBright("mergedCancel2: Testing cancel immediately")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [premium], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: false, - }); - - stripeCli = ctx.stripeCli; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - it("should run operations", async () => { - await autumn.entities.create(customerId, entities); - - for (let index = 0; index < ops.length; index++) { - const op = ops[index]; - try { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: op.entityId, - }); - } catch (error) { - console.log(`Operation failed: ${op.product.id}, index: ${index}`); - throw error; - } - } - }); - - it("should track usage cancel, advance test clock and have correct invoice", async () => { - for (const cancel of cancels) { - await autumn.cancel({ - customer_id: customerId, - product_id: cancel.product.id, - entity_id: cancel.entityId, - cancel_immediately: cancel.cancelImmediately ?? false, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - shouldBeCanceled: cancel.shouldBeCanceled, - }); - } - }); -}); diff --git a/server/tests/core/cancel/mergedCancel3.test.ts b/server/tests/core/cancel/mergedCancel3.test.ts deleted file mode 100644 index f4d65598c..000000000 --- a/server/tests/core/cancel/mergedCancel3.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { beforeAll, describe, it } from "bun:test"; -import { CusProductStatus, LegacyVersion } from "@autumn/shared"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Premium, Premium -// Cancel Immediately, Cancel Immediately -// Results: No sub - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); - -const ops = [ - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, -]; - -const cancels = [ - { - entityId: "1", - product: premium, - cancelImmediately: true, - }, - { - entityId: "2", - product: premium, - cancelImmediately: true, - shouldBeCanceled: true, - skipSubCheck: true, - }, -]; - -const testCase = "mergedCancel3"; -describe(`${chalk.yellowBright("mergedCancel3: Testing cancel immediately")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [premium], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: false, - }); - - stripeCli = ctx.stripeCli; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - it("should run operations", async () => { - await autumn.entities.create(customerId, entities); - - for (let index = 0; index < ops.length; index++) { - const op = ops[index]; - try { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entityId: op.entityId, - }); - } catch (error) { - console.log(`Operation failed: ${op.product.id}, index: ${index}`); - throw error; - } - } - }); - - it("should track usage cancel, advance test clock and have correct invoice", async () => { - for (const cancel of cancels) { - await autumn.cancel({ - customer_id: customerId, - product_id: cancel.product.id, - entity_id: cancel.entityId, - cancel_immediately: cancel.cancelImmediately ?? false, - }); - - if (cancel.skipSubCheck) continue; - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - shouldBeCanceled: cancel.shouldBeCanceled, - }); - } - }); -}); diff --git a/server/tests/integration/billing/autumn-webhooks/customer-products-updated.test.ts b/server/tests/integration/billing/autumn-webhooks/customer-products-updated.test.ts index cc78b4ad2..acc788b1c 100644 --- a/server/tests/integration/billing/autumn-webhooks/customer-products-updated.test.ts +++ b/server/tests/integration/billing/autumn-webhooks/customer-products-updated.test.ts @@ -1,8 +1,11 @@ /** * Integration tests for customer.products.updated webhook. * - * Verifies that webhooks are sent correctly when customers are created - * with default products. + * Verifies that webhooks are sent correctly for: + * - Customer creation with default products + * - Cancel end of cycle (scenario: cancel) + * - Uncancel (scenario: renew) + * - Entity-level cancel and uncancel * * Uses Svix Play (https://www.svix.com/play/) to receive and verify webhooks. */ @@ -10,7 +13,10 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import type { ApiCustomerV3, ApiEntityV0, ApiProduct } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductActive, + expectProductCanceling, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -75,13 +81,12 @@ afterAll(async () => { }); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -// WEBHOOK TESTS +// WEBHOOK TESTS - CUSTOMER CREATION // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on create with default product")}`, async () => { const customerId = "webhook-create-default"; - // Setup: create a default product for this test const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const freeDefault = products.base({ id: "free-default", @@ -89,7 +94,6 @@ test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on cre isDefault: true, }); - // Only setup products, don't create customer yet const { autumnV1 } = await initScenario({ setup: [ s.deleteCustomer({ customerId }), @@ -104,12 +108,11 @@ test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on cre name: "Webhook Test Customer", internalOptions: { disable_defaults: false, - default_group: customerId, // Only attach products with this group/prefix + default_group: customerId, }, skipWebhooks: false, }); - // Wait for webhook to arrive at Svix Play const result = await waitForWebhook({ token: playToken, predicate: (payload) => @@ -118,36 +121,22 @@ test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on cre timeoutMs: 15000, }); - // Verify webhook was received expect(result).not.toBeNull(); expect(result?.payload.type).toBe("customer.products.updated"); const { data } = result!.payload; - // Verify scenario expect(data.scenario).toBe("new"); - - // Verify customer in webhook payload expect(data.customer).toBeDefined(); expect(data.customer.id).toBe(customerId); expect(data.customer.name).toBe("Webhook Test Customer"); - - // Verify updated_product in webhook payload expect(data.updated_product).toBeDefined(); expect(data.updated_product.id).toBe(freeDefault.id); expect(data.updated_product.is_default).toBe(true); - - // No entity for customer-level product expect(data.entity).toBeUndefined(); - // Also verify the customer state via API const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ - customer, - productId: freeDefault.id, - }); - + await expectProductActive({ customer, productId: freeDefault.id }); expectCustomerFeatureCorrect({ customer, featureId: TestFeature.Messages, @@ -156,3 +145,275 @@ test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on cre usage: 0, }); }); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// WEBHOOK TESTS - CANCEL +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +test.concurrent(`${chalk.yellowBright("webhook: cancel end of cycle (no default product) - scenario: cancel")}`, async () => { + const customerId = "webhook-cancel-no-default"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Initialize customer, attach pro, then cancel at end of cycle + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.updateSubscription({ + productId: pro.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "cancel", + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + + expect(data.scenario).toBe("cancel"); + expect(data.updated_product).toBeDefined(); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer).toBeDefined(); + expect(data.customer.id).toBe(customerId); + expect(data.entity).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("webhook: cancel end of cycle (with free default product) - scenario: cancel")}`, async () => { + const customerId = "webhook-cancel-with-default"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const freeDefault = products.base({ + id: "free-default", + items: [messagesItem], + isDefault: true, + }); + + // Initialize customer, attach pro, then cancel at end of cycle + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, freeDefault] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.updateSubscription({ + productId: pro.id, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "cancel", + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + + // Scenario is "cancel" (not "downgrade") since scheduled product is FREE + expect(data.scenario).toBe("cancel"); + expect(data.updated_product).toBeDefined(); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer).toBeDefined(); + expect(data.customer.id).toBe(customerId); + expect(data.entity).toBeUndefined(); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// WEBHOOK TESTS - UNCANCEL +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +test.concurrent(`${chalk.yellowBright("webhook: uncancel - scenario: renew")}`, async () => { + const customerId = "webhook-uncancel"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Initialize customer, attach pro, cancel, then uncancel + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.updateSubscription({ + productId: pro.id, + cancelAction: "cancel_end_of_cycle", + }), + s.updateSubscription({ productId: pro.id, cancelAction: "uncancel" }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "renew", + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + + expect(data.scenario).toBe("renew"); + expect(data.updated_product).toBeDefined(); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer).toBeDefined(); + expect(data.customer.id).toBe(customerId); + expect(data.entity).toBeUndefined(); + + // Verify product is now active (not canceling) + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// WEBHOOK TESTS - ENTITY-LEVEL CANCEL/UNCANCEL +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +test.concurrent(`${chalk.yellowBright("webhook: entity cancel end of cycle - scenario: cancel")}`, async () => { + const customerId = "webhook-entity-cancel"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Initialize customer, attach pro to entity, then cancel at end of cycle + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.updateSubscription({ + productId: pro.id, + entityIndex: 0, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const entityId = entities[0].id; + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "cancel" && + payload.data?.entity?.id === entityId, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + + expect(data.scenario).toBe("cancel"); + expect(data.updated_product).toBeDefined(); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer).toBeDefined(); + expect(data.customer.id).toBe(customerId); + + // Verify entity is included in webhook + expect(data.entity).toBeDefined(); + expect(data.entity?.id).toBe(entityId); + + // Verify entity product is canceling + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductCanceling({ customer: entity, productId: pro.id }); +}); + +test.concurrent(`${chalk.yellowBright("webhook: entity uncancel - scenario: renew")}`, async () => { + const customerId = "webhook-entity-uncancel"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Initialize customer, attach pro to entity, cancel, then uncancel + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.updateSubscription({ + productId: pro.id, + entityIndex: 0, + cancelAction: "cancel_end_of_cycle", + }), + s.updateSubscription({ + productId: pro.id, + entityIndex: 0, + cancelAction: "uncancel", + }), + ], + }); + + const entityId = entities[0].id; + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "renew" && + payload.data?.entity?.id === entityId, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + + expect(data.scenario).toBe("renew"); + expect(data.updated_product).toBeDefined(); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer).toBeDefined(); + expect(data.customer.id).toBe(customerId); + + // Verify entity is included in webhook + expect(data.entity).toBeDefined(); + expect(data.entity?.id).toBe(entityId); + + // Verify entity product is now active (not canceling) + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ customer: entity, productId: pro.id }); +}); diff --git a/server/tests/integration/billing/legacy/attach/attach-update-quantity.test.ts b/server/tests/integration/billing/legacy/attach/attach-update-quantity.test.ts index f0f898429..34aeec083 100644 --- a/server/tests/integration/billing/legacy/attach/attach-update-quantity.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-update-quantity.test.ts @@ -42,7 +42,7 @@ import chalk from "chalk"; * - Immediate proration invoice for the upgrade * - Balance increases by 100 (the additional quantity) */ -test(`${chalk.yellowBright("attach: quantity upgrade mid-cycle with prorate immediately")}`, async () => { +test.concurrent(`${chalk.yellowBright("attach: quantity upgrade mid-cycle with prorate immediately")}`, async () => { const customerId = "attach-qty-upgrade-mid-cycle"; const prepaidItem = items.prepaidMessages({ @@ -135,7 +135,7 @@ test(`${chalk.yellowBright("attach: quantity upgrade mid-cycle with prorate imme * - After increase to 400: balance becomes 400, 2 invoices (initial + proration) * - After decrease to 200: balance stays 400, upcoming_quantity = 2 */ -test(`${chalk.yellowBright("attach: quantity decrease โ†’ increase โ†’ decrease flow")}`, async () => { +test.concurrent(`${chalk.yellowBright("attach: quantity decrease โ†’ increase โ†’ decrease flow")}`, async () => { const customerId = "attach-qty-dec-inc-dec"; const prepaidItem = items.prepaidMessages({ @@ -254,7 +254,7 @@ test(`${chalk.yellowBright("attach: quantity decrease โ†’ increase โ†’ decrease * - Entity 1: Add-on quantity is 200 immediately * - Entity 2: Add-on quantity stays 300 with next_cycle_quantity of 200 */ -test(`${chalk.yellowBright("attach: prepaid add-on with entities - upgrade and downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("attach: prepaid add-on with entities - upgrade and downgrade")}`, async () => { const customerId = "attach-prepaid-addon-entities"; // Pro product with monthly messages @@ -379,7 +379,7 @@ test(`${chalk.yellowBright("attach: prepaid add-on with entities - upgrade and d * - Subscription item quantity is updated to 4 immediately * - Product item quantity shows 4 (the new quantity takes effect on Stripe) */ -test(`${chalk.yellowBright("attach: quantity upgrade with prorate-next-cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("attach: quantity upgrade with prorate-next-cycle")}`, async () => { const customerId = "attach-qty-upgrade-prorate-next"; const prepaidItem = items.prepaidMessages({ diff --git a/server/tests/integration/billing/renew/renew-addon1.test.ts b/server/tests/integration/billing/renew/renew-addon1.test.ts deleted file mode 100644 index ce91411f7..000000000 --- a/server/tests/integration/billing/renew/renew-addon1.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, CusProductStatus } from "@autumn/shared"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const pro = constructProduct({ - type: "pro", - isDefault: false, - - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - }), - ], -}); -const monthlyAddOn = constructProduct({ - id: "monthlyAddOn", - type: "pro", - isAddOn: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 300, - }), - ], -}); - -describe(`${chalk.yellowBright("renew-addon1: Attach pro + monthly add on, cancel monthly add on end of cycle, then renew")}`, () => { - const customerId = "renew-addon1"; - const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - await initProductsV0({ - ctx, - products: [pro, monthlyAddOn], - prefix: customerId, - }); - }); - - test("should attach pro and monthly add on, cancel monthly add on end of cycle, then renew monthly add on", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: monthlyAddOn.id, - }); - - await autumn.cancel({ - customer_id: customerId, - product_id: monthlyAddOn.id, - cancel_immediately: false, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: monthlyAddOn.id, - }); - - await timeout(4000); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.Active, - }); - expectProductAttached({ - customer, - product: monthlyAddOn, - status: CusProductStatus.Active, - }); - - const monthlyAddOnProduct = customer.products.find( - (p) => p.id === monthlyAddOn.id, - ); - - console.log("monthlyAddOnProduct", monthlyAddOnProduct); - expect(monthlyAddOnProduct?.canceled_at).toBeNull(); - - // 1. Subs should be correct - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - shouldBeCanceled: false, - }); - }); -}); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-discounts.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-discounts.test.ts new file mode 100644 index 000000000..01521c68f --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-discounts.test.ts @@ -0,0 +1,439 @@ +/** + * Invoice Created Webhook Tests - Consumable Discounts + * + * Tests that verify discounts are correctly applied to consumable (usage-in-arrear) + * overage charges during billing cycle renewals. + * + * Key behaviors tested: + * - Customer-level discounts apply to all consumable overages + * - Subscription-level discounts apply to all consumable overages + * - Product-specific discounts (applies_to.products) only apply to matching products + * - Discounts are calculated by Autumn before creating invoice items (discountable: false on Stripe) + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + applyCustomerDiscount, + applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, +} from "@tests/integration/billing/utils/discounts/discountTestUtils"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +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 { ProductService } from "@/internal/products/ProductService"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Customer-level discount applies to consumable overage +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has Pro with consumable messages (100 included, $0.10/unit overage) + * - Pro has a $20/month base price + * - Apply 20% customer-level discount (applies to all subscriptions) + * - Track 200 messages (100 overage) + * - Advance to next billing cycle + * + * Expected Result: + * - Initial invoice: $20 * 0.8 = $16 (base with discount) + * - Renewal invoice: $16 base + ($10 overage * 0.8) = $16 + $8 = $24 + * - Discount applies to both base price AND overage + */ +test.concurrent(`${chalk.yellowBright("invoice.created discount: customer-level discount applies to consumable overage")}`, async () => { + const customerId = "inv-disc-cus-level"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Apply customer-level discount (20% off) + const { stripeCli, stripeCustomerId } = await getStripeSubscription({ + customerId, + }); + + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await applyCustomerDiscount({ + stripeCli, + customerId: stripeCustomerId, + couponId: coupon.id, + }); + + // Track 200 messages (100 overage = $10 before discount) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Verify usage tracked + const customerAfterTrack = + await autumnV1.customers.get(customerId); + expect(customerAfterTrack.features[TestFeature.Messages].balance).toBe(-100); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Calculate expected amounts + // Base price: $20 * 0.8 = $16 + // Overage: 100 * $0.10 * 0.8 = $8 + // Total: $16 + $8 = $24 + const discountMultiplier = 0.8; + const basePrice = 20; + const overageAmount = 100 * 0.1; // 100 units * $0.10 + const expectedTotal = Math.round( + basePrice * discountMultiplier + overageAmount * discountMultiplier, + ); + + // Should have 2 invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 2, + latestTotal: expectedTotal, + latestInvoiceProductId: pro.id, + }); + + // Balance should be reset to 100 (included usage) + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 2: Subscription-level discount applies to consumable overage +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has Pro with consumable messages (100 included, $0.10/unit overage) + * - Pro has a $20/month base price + * - Apply 25% subscription-level discount + * - Track 300 messages (200 overage) + * - Advance to next billing cycle + * + * Expected Result: + * - Renewal invoice: ($20 base + $20 overage) * 0.75 = $30 + * - Discount applies to both base price AND overage + */ +test.concurrent(`${chalk.yellowBright("invoice.created discount: subscription-level discount applies to consumable overage")}`, async () => { + const customerId = "inv-disc-sub-level"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Apply subscription-level discount (25% off) + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); + + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 25, + }); + + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); + + // Track 300 messages (200 overage = $20 before discount) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 300, + }); + + // Verify usage tracked + const customerAfterTrack = + await autumnV1.customers.get(customerId); + expect(customerAfterTrack.features[TestFeature.Messages].balance).toBe(-200); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Calculate expected amounts + // Base price: $20 * 0.75 = $15 + // Overage: 200 * $0.10 * 0.75 = $15 + // Total: $15 + $15 = $30 + const discountMultiplier = 0.75; + const basePrice = 20; + const overageAmount = 200 * 0.1; // 200 units * $0.10 + const expectedTotal = Math.round( + basePrice * discountMultiplier + overageAmount * discountMultiplier, + ); + + // Should have 2 invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 2, + latestTotal: expectedTotal, + latestInvoiceProductId: pro.id, + }); + + // Balance should be reset to 100 (included usage) + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 3: Discount on base price only does NOT apply to consumable overage +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has Pro with consumable messages (100 included, $0.10/unit overage) + * - Pro has a $20/month base price + * - Apply 50% discount that only applies to the BASE PRICE Stripe product + * - Track 200 messages (100 overage) + * - Advance to next billing cycle + * + * Expected Result: + * - Renewal invoice: ($20 base * 0.5) + $10 overage = $10 + $10 = $20 + * - Discount does NOT apply to overage (different Stripe product) + */ +test.concurrent(`${chalk.yellowBright("invoice.created discount: base price only discount does NOT apply to consumable")}`, async () => { + const customerId = "inv-disc-base-only"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Get subscription for applying discount + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); + + // Get the product's processor ID (used for base price line items) + const fullProduct = await ProductService.getFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + idOrInternalId: pro.id, + }); + + const basePriceProductId = fullProduct?.processor?.id; + if (!basePriceProductId) { + throw new Error("Could not find base price Stripe product ID"); + } + + // Create coupon that ONLY applies to the base price product (product.processor.id) + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 50, + appliesToProducts: [basePriceProductId], + }); + + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); + + // Track 200 messages (100 overage = $10) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Verify usage tracked + const customerAfterTrack = + await autumnV1.customers.get(customerId); + expect(customerAfterTrack.features[TestFeature.Messages].balance).toBe(-100); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Calculate expected amounts + // Base price: $20 * 0.5 = $10 (discounted) + // Overage: 100 * $0.10 = $10 (NOT discounted - different product) + // Total: $10 + $10 = $20 + const discountedBase = 20 * 0.5; + const overageAmount = 100 * 0.1; // No discount applied + const expectedTotal = discountedBase + overageAmount; + + // Should have 2 invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 2, + latestTotal: expectedTotal, + latestInvoiceProductId: pro.id, + }); + + // Balance should be reset to 100 (included usage) + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 4: Discount on consumable price only applies to overage +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has Pro with consumable messages (100 included, $0.10/unit overage) + * - Pro has a $20/month base price + * - Apply 50% discount that only applies to the CONSUMABLE Stripe product + * - Track 200 messages (100 overage) + * - Advance to next billing cycle + * + * Expected Result: + * - Renewal invoice: $20 base + ($10 overage * 0.5) = $20 + $5 = $25 + * - Discount ONLY applies to overage (matching Stripe product) + */ +test.concurrent(`${chalk.yellowBright("invoice.created discount: consumable price only discount applies to overage")}`, async () => { + const customerId = "inv-disc-cons-only"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Get subscription for applying discount + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); + + // Get the consumable price's stripe_product_id from the product config + const fullProduct = await ProductService.getFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + idOrInternalId: pro.id, + }); + + // Find the consumable price and get its stripe_product_id + const consumablePrice = fullProduct?.prices.find( + (price) => price.config?.stripe_product_id, + ); + + const consumableProductId = consumablePrice?.config?.stripe_product_id; + if (!consumableProductId) { + throw new Error("Could not find consumable Stripe product ID"); + } + + // Create coupon that ONLY applies to the consumable product (price.config.stripe_product_id) + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 50, + appliesToProducts: [consumableProductId], + }); + + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); + + // Track 200 messages (100 overage = $10 before discount) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Verify usage tracked + const customerAfterTrack = + await autumnV1.customers.get(customerId); + expect(customerAfterTrack.features[TestFeature.Messages].balance).toBe(-100); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + withPause: true, + }); + + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Calculate expected amounts + // Base price: $20 (NOT discounted - doesn't match coupon's applies_to) + // Overage: 100 * $0.10 * 0.5 = $5 (discounted) + // Total: $20 + $5 = $25 + const basePrice = 20; // No discount + const discountedOverage = 100 * 0.1 * 0.5; + const expectedTotal = basePrice + discountedOverage; + + // Should have 2 invoices + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 2, + latestTotal: expectedTotal, + latestInvoiceProductId: pro.id, + }); + + // Balance should be reset to 100 (included usage) + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); +}); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-edge-cases.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-edge-cases.test.ts new file mode 100644 index 000000000..285ec2d6e --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-edge-cases.test.ts @@ -0,0 +1,233 @@ +/** + * Invoice Created Webhook Tests - Consumable Edge Cases + * + * Tests for edge case scenarios involving consumable (usage-in-arrear) prices + * during downgrades, multiple subscriptions, and complex billing scenarios. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Downgrade with consumable - overage billed to correct product +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Premium ($50/mo) and Pro ($20/mo) both have consumable messages (100 included, $0.10/unit) + * - Customer starts on Premium + * - Track 200 messages (100 overage) + * - Downgrade to Pro (schedules Pro, Premium becomes canceling) + * - Advance to next billing cycle + * + * Expected Result: + * - Initial invoice: $50 (premium base price) + * - After cycle: Pro base ($20) + Premium overage (100 * $0.10 = $10) = $30 + * - Customer should be on Pro after cycle + * - Balance should be reset to 100 (Pro's included usage) + */ +test.concurrent(`${chalk.yellowBright("invoice.created consumable edge: downgrade with overage billed to previous product")}`, async () => { + const customerId = "inv-created-cons-downgrade"; + + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + + // Premium product ($50/mo) with consumable + const premium = constructProduct({ + id: "premium", + items: [consumableItem], + type: "premium", + isDefault: false, + }); + + // Pro product ($20/mo) with consumable - same group as premium (default) + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [ + // 1. Start on Premium + s.attach({ productId: premium.id }), + // 2. Track into overage while on Premium + s.track({ featureId: TestFeature.Messages, value: 200 }), + // 3. Downgrade to Pro (schedules Pro, Premium becomes canceling) + s.attach({ productId: pro.id }), + // 4. Advance to next billing cycle + s.advanceToNextInvoice({ withPause: true }), + ], + }); + + // Calculate expected overage from Premium: (200 - 100) * $0.10 = $10 + const expectedOverage = calculateExpectedInvoiceAmount({ + items: premium.items, + usage: [{ featureId: TestFeature.Messages, value: 200 }], + options: { includeFixed: false, onlyArrear: true }, + }); + + expect(expectedOverage).toBe(10); + + // Verify final state + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Customer should now be on Pro (downgrade completed) + await expectCustomerProducts({ + customer: customerAfterAdvance, + active: [pro.id], + notPresent: [premium.id], + }); + + // Should have 2 invoices: + // 1. Initial ($50 premium) + // 2. Renewal: Pro base ($20) + Premium overage ($10) = $30 + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 2, + latestTotal: 20 + expectedOverage, // $20 Pro base + $10 Premium overage + latestInvoiceProductIds: [pro.id, premium.id], // Pro (new base) + Premium (overage) + }); + + // Balance should be reset to Pro's included usage (100) + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 2: Addon with separate subscription + consumable +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Pro ($20/mo) with consumable messages (100 included, $0.10/unit) + * - Recurring Addon ($20/mo) with consumable words (50 included, $0.05/unit) + * - Addon attached with new_billing_subscription: true (separate Stripe subscription) + * - Track 200 messages (100 overage) and 150 words (100 overage) + * - Advance to next billing cycle + * + * Expected Result: + * - Pro invoice: $20 base + $10 message overage = $30 + * - Addon invoice: $20 base + $5 word overage = $25 + * - Each subscription's invoice has its own product's overage + */ +test.concurrent(`${chalk.yellowBright("invoice.created consumable edge: addon separate subscription with consumable")}`, async () => { + const customerId = "inv-created-cons-addon-sep-sub"; + + const consumableMessagesItem = items.consumableMessages({ + includedUsage: 100, + }); + const consumableWordsItem = items.consumableWords({ includedUsage: 50 }); + + const pro = products.pro({ + id: "pro", + items: [consumableMessagesItem], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [consumableWordsItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + // 1. Attach Pro + s.attach({ productId: pro.id }), + // 2. Attach Addon on separate subscription + s.attach({ productId: addon.id, newBillingSubscription: true }), + // 3. Track usage on both features + s.track({ featureId: TestFeature.Messages, value: 200 }), + s.track({ featureId: TestFeature.Words, value: 150 }), + // 4. Advance to next billing cycle (both subscriptions) + s.advanceToNextInvoice(), + ], + }); + + // Calculate expected overages + const expectedMessagesOverage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Messages, value: 200 }], + options: { includeFixed: false, onlyArrear: true }, + }); + expect(expectedMessagesOverage).toBe(10); // (200 - 100) * $0.10 + + const expectedWordsOverage = calculateExpectedInvoiceAmount({ + items: addon.items, + usage: [{ featureId: TestFeature.Words, value: 150 }], + options: { includeFixed: false, onlyArrear: true }, + }); + expect(expectedWordsOverage).toBe(5); // (150 - 50) * $0.05 + + // Verify final state + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + // Both products should be active + await expectProductActive({ + customer: customerAfterAdvance, + productId: pro.id, + }); + await expectProductActive({ + customer: customerAfterAdvance, + productId: addon.id, + }); + + // Should have 4 invoices: + // 1. Initial Pro ($20) + // 2. Initial Addon ($20) + // 3. Pro renewal: $20 base + $10 overage = $30 + // 4. Addon renewal: $20 base + $5 overage = $25 + expectCustomerInvoiceCorrect({ + customer: customerAfterAdvance, + count: 4, + }); + + // Verify both balances are reset correctly + expect(customerAfterAdvance.features[TestFeature.Messages].balance).toBe(100); + expect(customerAfterAdvance.features[TestFeature.Words].balance).toBe(50); + + // Verify each renewal invoice has correct amounts + const invoices = customerAfterAdvance.invoices ?? []; + + // Get the two most recent invoices (renewals) + const sortedInvoices = [...invoices].sort( + (a, b) => (b.created_at ?? 0) - (a.created_at ?? 0), + ); + const [latestInvoice, secondLatestInvoice] = sortedInvoices; + + // Find Pro and Addon renewal invoices by plan_ids + const proRenewalInvoice = [latestInvoice, secondLatestInvoice].find((inv) => + inv.product_ids?.includes(pro.id), + ); + const addonRenewalInvoice = [latestInvoice, secondLatestInvoice].find((inv) => + inv.product_ids?.includes(addon.id), + ); + + // Pro renewal: $20 base + $10 overage = $30 + expect(proRenewalInvoice).toBeDefined(); + expect(proRenewalInvoice?.total).toBe(20 + expectedMessagesOverage); + + // Addon renewal: $20 base + $5 overage = $25 + expect(addonRenewalInvoice).toBeDefined(); + expect(addonRenewalInvoice?.total).toBe(20 + expectedWordsOverage); +}); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-trial.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-trial.test.ts index 2cb4992f9..2162f7573 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-trial.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-trial.test.ts @@ -39,7 +39,7 @@ import chalk from "chalk"; * - First invoice after trial: $20 base only (no $15 overage charge for trial usage) * - Balance should be reset to 100 (included usage) */ -test(`${chalk.yellowBright("invoice.created trial: customer-level overage during trial โ†’ no charge after trial ends")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created trial: customer-level overage during trial โ†’ no charge after trial ends")}`, async () => { const customerId = "inv-trial-cus-overage"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -111,7 +111,7 @@ test(`${chalk.yellowBright("invoice.created trial: customer-level overage during * - First invoice after trial: $20 base only (no $10 overage charge) * - Entity balance should be reset to 100 */ -test(`${chalk.yellowBright("invoice.created trial: entity-level overage during trial โ†’ no charge after trial ends")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created trial: entity-level overage during trial โ†’ no charge after trial ends")}`, async () => { const customerId = "inv-trial-ent-overage"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -216,7 +216,7 @@ test(`${chalk.yellowBright("invoice.created trial: entity-level overage during t * - NO overage charges for either entity's trial usage * - Both entity balances should be reset to 100 */ -test(`${chalk.yellowBright("invoice.created trial: multiple entities with overage during trial โ†’ no charge after trial ends")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created trial: multiple entities with overage during trial โ†’ no charge after trial ends")}`, async () => { const customerId = "inv-trial-multi-ent"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts index 6c4f4e109..538026e87 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts @@ -36,7 +36,7 @@ import chalk from "chalk"; * - Total second invoice: $20 + $15.05 = $35.05 * - Balance should be reset to 100 (included usage) */ -test(`${chalk.yellowBright("invoice.created consumable: attach โ†’ track decimal overage โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: attach โ†’ track decimal overage โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-decimal"; // Create consumable messages with 100 included @@ -107,7 +107,7 @@ test(`${chalk.yellowBright("invoice.created consumable: attach โ†’ track decimal * - After cycle: invoice should only include base price ($20), no overage * - Balance should be reset to 100 (included usage) */ -test(`${chalk.yellowBright("invoice.created consumable: no overage - track within included โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: no overage - track within included โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-no-ovg"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -160,7 +160,7 @@ test(`${chalk.yellowBright("invoice.created consumable: no overage - track withi * - Initial invoice: $20 (pro base price) * - After cycle: $20 base + $90 overage (900 * $0.10) = $110 */ -test(`${chalk.yellowBright("invoice.created consumable: large overage โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: large overage โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-large"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -222,7 +222,7 @@ test(`${chalk.yellowBright("invoice.created consumable: large overage โ†’ advanc * - Price: 6 billing units * $1 = $6 overage * - Total second invoice: $20 base + $6 overage = $26 */ -test(`${chalk.yellowBright("invoice.created consumable: billing units rounding up โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: billing units rounding up โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-billing-units"; // Create consumable with billingUnits=10, $1 per 10 units @@ -292,7 +292,7 @@ test(`${chalk.yellowBright("invoice.created consumable: billing units rounding u * - Overage charge: 6.5 * $0.10 = $0.65 * - Total second invoice: $20 base + $0.65 overage = $20.65 */ -test(`${chalk.yellowBright("invoice.created consumable: multiple decimal tracks accumulate โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: multiple decimal tracks accumulate โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-multi-decimal"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -361,7 +361,7 @@ test(`${chalk.yellowBright("invoice.created consumable: multiple decimal tracks * - Words overage: 100 * $0.05 = $5 * - Total second invoice: $20 base + $10 + $5 = $35 */ -test(`${chalk.yellowBright("invoice.created consumable: multiple features โ†’ different overages โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created consumable: multiple features โ†’ different overages โ†’ advance cycle")}`, async () => { const customerId = "inv-created-cons-multi-feat"; // Create two consumable items with different pricing diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts index 7b2dcbfd7..ab087b48c 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts @@ -46,7 +46,7 @@ import { addMonths } from "date-fns"; * - Overage billed exactly once via invoice line items * - Balance resets after cycle */ -test(`${chalk.yellowBright("invoice.created entity: regular renewal - overage billed once")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created entity: regular renewal - overage billed once")}`, async () => { const customerId = "inv-created-ent-renewal"; // Entity-level consumable messages @@ -140,7 +140,7 @@ test(`${chalk.yellowBright("invoice.created entity: regular renewal - overage bi * is rounded up to billing units INDIVIDUALLY, then summed. * This is DIFFERENT from per-entity features where total is summed first then rounded. */ -test(`${chalk.yellowBright("invoice.created entity: billing units - each entity rounded individually โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created entity: billing units - each entity rounded individually โ†’ advance cycle")}`, async () => { const customerId = "inv-ent-billing-units"; // Consumable with billingUnits=10, $1 per 10 units @@ -249,7 +249,7 @@ test(`${chalk.yellowBright("invoice.created entity: billing units - each entity * - Total overage: $16 * - Renewal invoice: $20 + $50 + $16 = $86 */ -test(`${chalk.yellowBright("invoice.created entity: 2 entities, 2 different products โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created entity: 2 entities, 2 different products โ†’ advance cycle")}`, async () => { const customerId = "inv-ent-2prod-2ent"; // Pro: $1 per 10 units, 100 included @@ -371,7 +371,7 @@ test(`${chalk.yellowBright("invoice.created entity: 2 entities, 2 different prod * - Total overage: $29 * - Renewal invoice: $20*2 + $50*2 + $29 = $169 */ -test(`${chalk.yellowBright("invoice.created entity: 4 entities, 2 products (2 each) โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created entity: 4 entities, 2 products (2 each) โ†’ advance cycle")}`, async () => { const customerId = "inv-ent-4ent-2prod"; // Pro: $1 per 10 units, 100 included diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts index b8d24df9c..d6225c83c 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts @@ -38,7 +38,7 @@ import { addMonths } from "date-fns"; * - Month 1 & 2: Only messages overage billed, words accumulate * - Month 3: BOTH messages AND words overage billed, both reset */ -test(`${chalk.yellowBright("invoice.created multi-interval: monthly + quarterly consumables over 3 months")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created multi-interval: monthly + quarterly consumables over 3 months")}`, async () => { const customerId = "inv-created-multi-interval"; const pro = products.pro({ diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-per-entity-consumable.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-per-entity-consumable.test.ts index 2f50c5c2f..c25b2ed90 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-per-entity-consumable.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-per-entity-consumable.test.ts @@ -48,7 +48,7 @@ import chalk from "chalk"; * - Final invoice: $20 base (single) + $45 overage = $65 * - All entity balances reset to 100 after cycle */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: 3 entities with varying overage โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: 3 entities with varying overage โ†’ advance cycle")}`, async () => { const customerId = "inv-pe-cons-3ent"; // Create per-entity consumable messages (100 included per entity) @@ -172,7 +172,7 @@ test(`${chalk.yellowBright("invoice.created per-entity consumable: 3 entities wi * IMPORTANT: For per-entity consumables, ALL entity overages are SUMMED FIRST, * then the TOTAL is rounded up to billing units. NOT rounded per-entity then summed. */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: billing units - sum then round โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: billing units - sum then round โ†’ advance cycle")}`, async () => { const customerId = "inv-pe-cons-billing-units"; // Create per-entity consumable with billingUnits=10, $1 per 10 units @@ -254,7 +254,7 @@ test(`${chalk.yellowBright("invoice.created per-entity consumable: billing units * - Only Entity 3 has overage: 100 * $0.10 = $10 * - Final invoice: $20 base (single) + $10 overage = $30 */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: mixed usage - some in overage, some within included")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: mixed usage - some in overage, some within included")}`, async () => { const customerId = "inv-pe-cons-mixed"; const perEntityConsumable = items.consumableMessages({ @@ -349,7 +349,7 @@ test(`${chalk.yellowBright("invoice.created per-entity consumable: mixed usage - * - No overage charges * - Final invoice: $20 base (single) only */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: no overage - all within included โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: no overage - all within included โ†’ advance cycle")}`, async () => { const customerId = "inv-pe-cons-no-ovg"; const perEntityConsumable = items.consumableMessages({ @@ -440,7 +440,7 @@ test(`${chalk.yellowBright("invoice.created per-entity consumable: no overage - * NOTE: For per-entity consumables, ALL entity overages are SUMMED FIRST, * then the TOTAL is rounded up to billing units. */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: decimal usage - sum then round โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: decimal usage - sum then round โ†’ advance cycle")}`, async () => { const customerId = "inv-pe-cons-decimal"; const perEntityConsumable = items.consumableMessages({ @@ -530,7 +530,7 @@ test(`${chalk.yellowBright("invoice.created per-entity consumable: decimal usage * IMPORTANT: For per-entity consumables, ALL entity overages are SUMMED FIRST, * then the TOTAL is rounded up to billing units. NOT rounded per-entity then summed. */ -test(`${chalk.yellowBright("invoice.created per-entity consumable: billing units partial - sum then round โ†’ advance cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created per-entity consumable: billing units partial - sum then round โ†’ advance cycle")}`, async () => { const customerId = "inv-pe-cons-partial-round"; // $2 per 25 units, 100 included per entity diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid-legacy.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid-legacy.test.ts index 84c009120..85fff403c 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid-legacy.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid-legacy.test.ts @@ -51,7 +51,7 @@ import { addMonths } from "date-fns"; * Note: The attach quantity change flow (decrease โ†’ increase โ†’ decrease) is * tested separately in attach-update-quantity.test.ts */ -test(`${chalk.yellowBright("invoice.created prepaid: quantity downgrade - balance resets on cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created prepaid: quantity downgrade - balance resets on cycle")}`, async () => { const customerId = "inv-created-prepaid-qty-downgrade"; // Prepaid messages: $12.50 per 100 units, on_decrease: none (sets upcoming_quantity) @@ -141,7 +141,7 @@ test(`${chalk.yellowBright("invoice.created prepaid: quantity downgrade - balanc * - No immediate invoice for the upgrade (prorate_next_cycle) * - Balance resets to 400 on cycle renewal */ -test(`${chalk.yellowBright("invoice.created prepaid: quantity upgrade prorate-next-cycle")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created prepaid: quantity upgrade prorate-next-cycle")}`, async () => { const customerId = "inv-created-prepaid-prorate-next"; // Prepaid messages: $12.50 per 100 units, on_increase: prorate_next_cycle @@ -223,7 +223,7 @@ test(`${chalk.yellowBright("invoice.created prepaid: quantity upgrade prorate-ne * Expected Result: * - Balance resets to 300 (original quantity) */ -test(`${chalk.yellowBright("invoice.created prepaid: basic reset after usage")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created prepaid: basic reset after usage")}`, async () => { const customerId = "inv-created-prepaid-basic-reset"; const prepaidItem = items.prepaidMessages({ @@ -322,7 +322,7 @@ test(`${chalk.yellowBright("invoice.created prepaid: basic reset after usage")}` * - Balance after cycle: 0 (3 seats - 3 used = 0 available) * - Quantity is 3, upcoming_quantity is cleared */ -test(`${chalk.yellowBright("invoice.created prepaid: continuous-use seats downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("invoice.created prepaid: continuous-use seats downgrade")}`, async () => { const customerId = "inv-created-prepaid-seats-downgrade"; // Prepaid users/seats: $10 per seat, billingUnits=1 diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice-discounts.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice-discounts.test.ts new file mode 100644 index 000000000..6e8be5ab6 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice-discounts.test.ts @@ -0,0 +1,573 @@ +/** + * Subscription Deleted Invoice Tests - Discounts on Consumable Overages + * + * Tests that verify discounts are correctly applied to consumable (usage-in-arrear) + * overage charges when a subscription is deleted at the end of the billing period. + * + * Key behaviors tested: + * - Customer-level discounts apply to final arrear invoice + * - Subscription-level discounts apply to final arrear invoice + * - Product-specific discounts (applies_to.products) only apply to matching products + * - Entity-level consumables (non-metered) create arrear invoice via subscription.deleted + * + * Note: Entity-level consumables use invoice line items (not Stripe metered prices), + * so Autumn handles the final arrear invoice creation in subscription.deleted webhook. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + applyCustomerDiscount, + applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, +} from "@tests/integration/billing/utils/discounts/discountTestUtils"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { getEntitySubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { ProductService } from "@/internal/products/ProductService"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Customer-level discount applies to final arrear invoice +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Entity has Pro with entity-level consumable messages (100 included, $0.10/unit) + * - Pro has a $20/month base price + * - Apply 20% customer-level discount (applies to all subscriptions) + * - Track 500 messages (400 overage) + * - Cancel subscription at period end via Stripe client + * - Advance test clock to trigger subscription.deleted + * + * Expected Result: + * - Initial invoice: $20 * 0.8 = $16 + * - Final arrear invoice: 400 * $0.10 * 0.8 = $32 + * - Discount applies to overage + */ +test.concurrent(`${chalk.yellowBright("sub.deleted discount: customer-level discount applies to arrear invoice")}`, async () => { + const customerId = "sub-del-disc-cus"; + + const consumableItem = items.consumableMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Apply customer-level discount (20% off) + const { stripeCli, stripeCustomerId } = await getStripeSubscription({ + customerId, + }); + + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await applyCustomerDiscount({ + stripeCli, + customerId: stripeCustomerId, + couponId: coupon.id, + }); + + // Track 500 messages (400 overage = $40 before discount) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 500, + }); + + // Verify usage tracked + const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); + expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(-400); + + // Get subscription ID for entity + const subscriptionId = await getEntitySubscriptionId({ + ctx, + customerId, + entityId, + productId: pro.id, + }); + + // Cancel subscription at PERIOD END via Stripe client + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + // Verify subscription is scheduled for cancellation + const subAfterSchedule = + await ctx.stripeCli.subscriptions.retrieve(subscriptionId); + expect(subAfterSchedule.cancel_at_period_end).toBe(true); + + // Advance test clock to period end (triggers subscription.deleted) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 15, + }); + + // Verify product is removed from entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify final arrear invoice with discount + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Expected: 400 overage * $0.10 * 0.8 = $32 + const expectedArrearTotal = Math.round(400 * 0.1 * 0.8); + + // Should have 2 invoices: initial ($16) + arrear ($32) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + latestTotal: expectedArrearTotal, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 2: Subscription-level discount applies to final arrear invoice +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Entity has Pro with entity-level consumable messages (100 included, $0.10/unit) + * - Pro has a $20/month base price + * - Apply 30% subscription-level discount + * - Track 300 messages (200 overage) + * - Cancel subscription at period end via Stripe client + * - Advance test clock to trigger subscription.deleted + * + * Expected Result: + * - Final arrear invoice: 200 * $0.10 * 0.7 = $14 + * - Discount applies to overage + */ +test.concurrent(`${chalk.yellowBright("sub.deleted discount: subscription-level discount applies to arrear invoice")}`, async () => { + const customerId = "sub-del-disc-sub"; + + const consumableItem = items.consumableMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Get subscription ID for entity + const subscriptionId = await getEntitySubscriptionId({ + ctx, + customerId, + entityId, + productId: pro.id, + }); + + // Apply subscription-level discount (30% off) + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 30, + }); + + await applySubscriptionDiscount({ + stripeCli: ctx.stripeCli, + subscriptionId, + couponIds: [coupon.id], + }); + + // Track 300 messages (200 overage = $20 before discount) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 300, + }); + + // Verify usage tracked + const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); + expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(-200); + + // Cancel subscription at PERIOD END via Stripe client + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + // Advance test clock to period end (triggers subscription.deleted) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 15, + }); + + // Verify product is removed from entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify final arrear invoice with discount + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Expected: 200 overage * $0.10 * 0.7 = $14 + const expectedArrearTotal = Math.round(200 * 0.1 * 0.7); + + // Should have 2 invoices: initial ($14 with 30% off of $20) + arrear ($14) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + latestTotal: expectedArrearTotal, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 3: Base price only discount does NOT apply to arrear invoice +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Entity has Pro with entity-level consumable messages (100 included, $0.10/unit) + * - Pro has a $20/month base price + * - Apply 50% discount that only applies to the BASE PRICE Stripe product + * - Track 200 messages (100 overage) + * - Cancel subscription at period end via Stripe client + * - Advance test clock to trigger subscription.deleted + * + * Expected Result: + * - Final arrear invoice: 100 * $0.10 = $10 (NO discount - different product) + */ +test.concurrent(`${chalk.yellowBright("sub.deleted discount: base price only discount does NOT apply to arrear")}`, async () => { + const customerId = "sub-del-disc-base"; + + const consumableItem = items.consumableMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Get subscription ID for entity + const subscriptionId = await getEntitySubscriptionId({ + ctx, + customerId, + entityId, + productId: pro.id, + }); + + // Get the product's processor ID (used for base price line items) + const fullProduct = await ProductService.getFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + idOrInternalId: pro.id, + }); + + const basePriceProductId = fullProduct?.processor?.id; + if (!basePriceProductId) { + throw new Error("Could not find base price Stripe product ID"); + } + + // Create coupon that ONLY applies to the base price product (product.processor.id) + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 50, + appliesToProducts: [basePriceProductId], + }); + + await applySubscriptionDiscount({ + stripeCli: ctx.stripeCli, + subscriptionId, + couponIds: [coupon.id], + }); + + // Track 200 messages (100 overage = $10) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 200, + }); + + // Verify usage tracked + const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); + expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(-100); + + // Cancel subscription at PERIOD END via Stripe client + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + // Advance test clock to period end (triggers subscription.deleted) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 15, + }); + + // Verify product is removed from entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify final arrear invoice WITHOUT discount (wrong product) + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Expected: 100 overage * $0.10 = $10 (NO discount) + const expectedArrearTotal = 100 * 0.1; + + // Should have 2 invoices: initial ($10 = $20 * 0.5) + arrear ($10 no discount) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + latestTotal: expectedArrearTotal, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 4: Consumable price only discount applies to arrear invoice +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Entity has Pro with entity-level consumable messages (100 included, $0.10/unit) + * - Pro has a $20/month base price + * - Apply 40% discount that only applies to the CONSUMABLE Stripe product + * - Track 300 messages (200 overage) + * - Cancel subscription at period end via Stripe client + * - Advance test clock to trigger subscription.deleted + * + * Expected Result: + * - Final arrear invoice: 200 * $0.10 * 0.6 = $12 + * - Discount applies ONLY to overage (matching product) + */ +test.concurrent(`${chalk.yellowBright("sub.deleted discount: consumable price only discount applies to arrear")}`, async () => { + const customerId = "sub-del-disc-cons"; + + const consumableItem = items.consumableMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const pro = products.pro({ + id: "pro", + items: [consumableItem], + }); + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + // Verify pro is active on entity + const entity = await autumnV1.entities.get(customerId, entityId); + await expectProductActive({ + customer: entity, + productId: pro.id, + }); + + // Get subscription ID for entity + const subscriptionId = await getEntitySubscriptionId({ + ctx, + customerId, + entityId, + productId: pro.id, + }); + + // Get the consumable price's stripe_product_id from the product config + const fullProduct = await ProductService.getFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + idOrInternalId: pro.id, + }); + + // Find the consumable price and get its stripe_product_id + const consumablePrice = fullProduct?.prices.find( + (price) => price.config?.stripe_product_id, + ); + + const consumableProductId = consumablePrice?.config?.stripe_product_id; + if (!consumableProductId) { + throw new Error("Could not find consumable Stripe product ID"); + } + + // Create coupon that ONLY applies to the consumable product (price.config.stripe_product_id) + const coupon = await createPercentCoupon({ + stripeCli: ctx.stripeCli, + percentOff: 40, + appliesToProducts: [consumableProductId], + }); + + await applySubscriptionDiscount({ + stripeCli: ctx.stripeCli, + subscriptionId, + couponIds: [coupon.id], + }); + + // Track 300 messages (200 overage = $20 before discount) + await autumnV1.track({ + customer_id: customerId, + entity_id: entityId, + feature_id: TestFeature.Messages, + value: 300, + }); + + // Verify usage tracked + const entityAfterTrack = await autumnV1.entities.get(customerId, entityId); + expect(entityAfterTrack.features[TestFeature.Messages].balance).toBe(-200); + + // Cancel subscription at PERIOD END via Stripe client + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + // Advance test clock to period end (triggers subscription.deleted) + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 15, + }); + + // Verify product is removed from entity + const entityAfterCancel = await autumnV1.entities.get(customerId, entityId); + await expectProductNotPresent({ + customer: entityAfterCancel, + productId: pro.id, + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); + + // Verify final arrear invoice with discount + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + // Expected: 200 overage * $0.10 * 0.6 = $12 + const expectedArrearTotal = Math.round(200 * 0.1 * 0.6); + + // Should have 2 invoices: initial ($20 no discount) + arrear ($12 with discount) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 2, + latestTotal: expectedArrearTotal, + }); +}); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts index d241c478e..25e37705b 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts @@ -50,7 +50,7 @@ import { timeout } from "@/utils/genUtils"; * - Autumn does NOT create a final arrear invoice (metered + immediate cancel) * - Only the initial attach invoice exists */ -test(`${chalk.yellowBright("sub.deleted invoice: customer consumable โ†’ Stripe cancel immediately โ†’ no final invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: customer consumable โ†’ Stripe cancel immediately โ†’ no final invoice")}`, async () => { const customerId = "sub-del-inv-cus-imm"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -153,7 +153,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: customer consumable โ†’ Stripe * This matches the behavior of customer-level consumables where immediate * cancellation does not charge overage. */ -test(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ Stripe cancel immediately โ†’ no final invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ Stripe cancel immediately โ†’ no final invoice")}`, async () => { const customerId = "sub-del-inv-ent-imm"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -263,7 +263,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ Stripe ca * This tests that the wasImmediateStripeCancellation check works correctly * even when subscription items have different period ends. */ -test(`${chalk.yellowBright("sub.deleted invoice: multi-interval โ†’ advance 1 month โ†’ Stripe cancel immediately โ†’ no invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: multi-interval โ†’ advance 1 month โ†’ Stripe cancel immediately โ†’ no invoice")}`, async () => { const customerId = "sub-del-inv-multi-int"; // Multi-interval: monthly consumable + annual base price @@ -383,7 +383,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: multi-interval โ†’ advance 1 mo * - Autumn does NOT create a final arrear invoice (immediate cancel) * - Only initial attach + renewal invoices exist */ -test(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ advance 1 month โ†’ Stripe cancel immediately โ†’ no invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ advance 1 month โ†’ Stripe cancel immediately โ†’ no invoice")}`, async () => { const customerId = "sub-del-inv-ent-adv"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -497,7 +497,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ advance 1 * - Autumn does NOT create an arrear invoice (trial usage is free) * - No invoices created (trial = no charge) */ -test(`${chalk.yellowBright("sub.deleted invoice: customer trial consumable โ†’ cancel at period end โ†’ NO arrear invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: customer trial consumable โ†’ cancel at period end โ†’ NO arrear invoice")}`, async () => { const customerId = "sub-del-inv-cus-trial"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -607,7 +607,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: customer trial consumable โ†’ c * - Autumn does NOT create an arrear invoice (trial usage is free) * - No invoices created (trial = no charge) */ -test(`${chalk.yellowBright("sub.deleted invoice: entity trial consumable โ†’ cancel at period end โ†’ NO arrear invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: entity trial consumable โ†’ cancel at period end โ†’ NO arrear invoice")}`, async () => { const customerId = "sub-del-inv-ent-trial"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -726,7 +726,7 @@ test(`${chalk.yellowBright("sub.deleted invoice: entity trial consumable โ†’ can * This is the opposite of the immediate cancel tests - end-of-period cancellation * should bill any accumulated overage. */ -test(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ Stripe cancel at period end โ†’ CREATES arrear invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted invoice: entity consumable โ†’ Stripe cancel at period end โ†’ CREATES arrear invoice")}`, async () => { const customerId = "sub-del-inv-ent-eop"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts index 7a6a9265c..74452a263 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts @@ -42,7 +42,7 @@ import { timeout } from "@/utils/genUtils"; * - Free default becomes active * - No Stripe subscription exists */ -test(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe (with default)")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe (with default)")}`, async () => { const customerId = "sub-deleted-basic"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -123,7 +123,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe ( * - Free default becomes active * - No Stripe subscription exists */ -test(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")}`, async () => { const customerId = "sub-deleted-after-eoc"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -226,7 +226,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")} * - Free default becomes active * - No Stripe subscription exists */ -test(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Stripe")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Stripe")}`, async () => { const customerId = "sub-deleted-downgrade"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -321,7 +321,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Str * - Free default becomes active * - No Stripe subscription exists */ -test(`${chalk.yellowBright("sub.deleted: cancel subscription with add-on via Stripe")}`, async () => { +test.concurrent(`${chalk.yellowBright("sub.deleted: cancel subscription with add-on via Stripe")}`, async () => { const customerId = "sub-deleted-addon"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts new file mode 100644 index 000000000..8e363cdb9 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts @@ -0,0 +1,383 @@ +/** + * Subscription Updated Webhook - Uncancel Tests + * + * Tests for handling the `customer.subscription.updated` Stripe webhook event + * when a subscription's cancel_at_period_end is set to false (uncancel/renew). + * + * These tests simulate uncanceling subscriptions directly through the Stripe client + * (not through Autumn's uncancel API) to verify the webhook handler works correctly. + * + * The webhook handler should: + * - Remove canceled_at from customer products + * - Remove scheduled default products + * - Mark the subscription as no longer canceling + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, + expectProductCanceling, + expectProductNotPresent, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Uncancel via Stripe CLI (cancel_at_period_end: false) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Init pro ($20/mo) and free (default) products + * - Attach pro to customer + * - Cancel pro via Autumn API (end_of_cycle) - Pro is canceling, Free is scheduled + * - Uncancel via Stripe CLI (cancel_at_period_end: false) + * + * Expected Result: + * - Pro is active (no longer canceling) + * - Free scheduled product is removed + * - Only pro remains in customer products + */ +test.concurrent(`${chalk.yellowBright("sub.updated: uncancel via Stripe CLI (cancel_at_period_end: false)")}`, async () => { + const customerId = "sub-updated-uncancel-basic"; + + 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] }), + ], + 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 via Autumn API + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + // Uncancel via Stripe CLI (simulating external renewal) + await ctx.stripeCli.subscriptions.update(subscriptionId, { + // cancel_at_period_end: false, + cancel_at: null, + }); + + // Wait for webhook to process + await timeout(8000); + + // Verify pro is active (no longer canceling) + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Verify free scheduled product is removed + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: free.id, + }); + + // Verify only 1 product in customer's group + const productsInGroup = customerAfterUncancel.products.filter( + (p) => p.group === pro.group, + ); + expect(productsInGroup.length).toBe(1); + expect(productsInGroup[0].id).toBe(pro.id); + + // Verify subscription is not canceling + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 2: Uncancel pro with add-on via Stripe CLI +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Init pro ($20/mo), free (default), and recurring add-on ($20/mo) products + * - Attach pro and add-on to customer + * - Cancel pro via Autumn API (end_of_cycle) - Pro is canceling, Free is scheduled + * - Uncancel via Stripe CLI (cancel_at_period_end: false) + * + * Expected Result: + * - Pro is active (no longer canceling) + * - Add-on remains active + * - Free scheduled product is removed + */ +test.concurrent(`${chalk.yellowBright("sub.updated: uncancel pro with add-on via Stripe CLI")}`, async () => { + const customerId = "sub-updated-uncancel-addon"; + + 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 addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + ], + }); + + // Verify pro and add-on are active + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterAttach, + active: [pro.id, addon.id], + }); + + // Cancel pro at end of cycle via Autumn API + // await autumnV1.subscriptions.update({ + // customer_id: customerId, + // product_id: pro.id, + // cancel_action: "cancel_end_of_cycle", + // }); + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + await timeout(5000); + + // Verify pro is canceling, addon is active, free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: addon.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Uncancel via Stripe CLI (simulating external renewal) + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: false, + }); + + // Wait for webhook to process + await timeout(5000); + + // Verify pro is active (no longer canceling), addon still active + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterUncancel, + active: [pro.id, addon.id], + notPresent: [free.id], + }); + + // Verify subscription is not canceling + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 3: Cancel via Stripe CLI then uncancel via Stripe CLI +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Init pro ($20/mo) and free (default) products + * - Attach pro to customer + * - Cancel via Stripe CLI (cancel_at_period_end: true) + * - Uncancel via Stripe CLI (cancel_at_period_end: false) + * + * Expected Result: + * - After cancel: Pro is canceling, Free is scheduled + * - After uncancel: Pro is active, Free is removed + */ +test.concurrent(`${chalk.yellowBright("sub.updated: cancel pro then uncancel pro via Stripe CLI")}`, async () => { + const customerId = "sub-updated-cancel-uncancel-cli"; + + 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] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify pro is active + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + + // Get subscription ID + const subscriptionId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + // Cancel via Stripe CLI (simulating external cancellation) + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + // Wait for webhook to process + await timeout(5000); + + // Verify pro is canceling and free is scheduled + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectProductCanceling({ + customer: customerAfterCancel, + productId: pro.id, + }); + await expectProductScheduled({ + customer: customerAfterCancel, + productId: free.id, + }); + + // Uncancel via Stripe CLI (simulating external renewal) + await ctx.stripeCli.subscriptions.update(subscriptionId, { + cancel_at_period_end: false, + }); + + // Wait for webhook to process + await timeout(5000); + + // Verify pro is active (no longer canceling) + const customerAfterUncancel = + await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer: customerAfterUncancel, + productId: pro.id, + }); + + // Verify free scheduled product is removed + await expectProductNotPresent({ + customer: customerAfterUncancel, + productId: free.id, + }); + + // Verify only 1 product in customer's group + const productsInGroup = customerAfterUncancel.products.filter( + (p) => p.group === pro.group, + ); + expect(productsInGroup.length).toBe(1); + expect(productsInGroup[0].id).toBe(pro.id); + + // Verify subscription is not canceling + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + shouldBeCanceled: false, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts new file mode 100644 index 000000000..74e816054 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts @@ -0,0 +1,329 @@ +/** + * Billing Behavior: next_cycle_only Cancel Tests + * + * Tests for canceling subscriptions with billing_behavior: 'next_cycle_only'. + * When canceling immediately with next_cycle_only, no new invoice should be + * generated (no proration credits issued). + * + * Key behaviors: + * - Immediate cancel with next_cycle_only creates NO new invoice + * - Product is removed from customer + * - Stripe subscription is canceled + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// CANCEL WITH next_cycle_only - NO NEW INVOICE +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer attaches Pro with prepaid messages (5 units @ $10 = $50) + * - Immediately cancel with billing_behavior: 'next_cycle_only' + * + * Expected Result: + * - Only 1 invoice exists (initial attach invoice) + * - NO proration credit invoice is created + * - Product is removed from customer + * - Stripe subscription is canceled + */ +test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: immediate cancel creates no new invoice")}`, async () => { + const billingUnits = 1; + const pricePerUnit = 10; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }); + const pro = products.base({ id: "pro", items: [prepaidItem] }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "bb-cancel-no-invoice", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ + productId: "pro", + options: [{ feature_id: TestFeature.Messages, quantity: 5 }], + }), + ], + }); + + // Verify initial state: 1 invoice from attach + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features?.[TestFeature.Messages]?.balance).toBe(5); + expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestTotal: 50, // 5 units @ $10 + }); + + // Preview the cancel with next_cycle_only - should be $0 (no proration) + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + billing_behavior: "next_cycle_only" as const, + }; + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + expect(preview.total).toBe(0); + + // Execute the cancel + await autumnV1.subscriptions.update(cancelParams); + + const customerAfter = await autumnV1.customers.get(customerId); + + // Product should be removed + await expectProductNotPresent({ + customer: customerAfter, + productId: pro.id, + }); + + // NO new invoice should be created - still just 1 invoice + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: 50, // Same initial invoice + }); + + // Stripe subscription should be canceled + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// COMPARISON: Default cancel vs next_cycle_only cancel +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Two customers attach Pro with prepaid messages + * - One cancels with default behavior (prorate_immediately) + * - One cancels with next_cycle_only + * + * Expected Result: + * - Default cancel: 2 invoices (attach + proration credit) + * - next_cycle_only cancel: 1 invoice (only attach) + */ +test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: comparison with default prorate_immediately")}`, async () => { + const billingUnits = 1; + const pricePerUnit = 10; + + const prepaidDefault = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }); + const prepaidDeferred = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }); + + const proDefault = products.base({ + id: "pro-default", + items: [prepaidDefault], + }); + const proDeferred = products.base({ + id: "pro-deferred", + items: [prepaidDeferred], + }); + + // Setup customer with default behavior + const { + customerId: customerDefault, + autumnV1: autumnDefault, + ctx: ctxDefault, + } = await initScenario({ + customerId: "bb-cancel-compare-default", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proDefault] }), + ], + actions: [ + s.attach({ + productId: "pro-default", + options: [{ feature_id: TestFeature.Messages, quantity: 5 }], + }), + ], + }); + + // Setup customer with deferred behavior + const { + customerId: customerDeferred, + autumnV1: autumnDeferred, + ctx: ctxDeferred, + } = await initScenario({ + customerId: "bb-cancel-compare-deferred", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proDeferred] }), + ], + actions: [ + s.attach({ + productId: "pro-deferred", + options: [{ feature_id: TestFeature.Messages, quantity: 5 }], + }), + ], + }); + + // Preview with default behavior (prorate_immediately) - should have negative total (refund) + const defaultParams = { + customer_id: customerDefault, + product_id: proDefault.id, + cancel_action: "cancel_immediately" as const, + // No billing_behavior = defaults to prorate_immediately + }; + const previewDefault = + await autumnDefault.subscriptions.previewUpdate(defaultParams); + expect(previewDefault.total).toBeLessThan(0); // Should be negative (proration credit) + + // Preview with next_cycle_only - should be $0 + const deferredParams = { + customer_id: customerDeferred, + product_id: proDeferred.id, + cancel_action: "cancel_immediately" as const, + billing_behavior: "next_cycle_only" as const, + }; + const previewDeferred = + await autumnDeferred.subscriptions.previewUpdate(deferredParams); + expect(previewDeferred.total).toBe(0); // No proration with next_cycle_only + + // Cancel with default behavior (prorate_immediately) + await autumnDefault.subscriptions.update(defaultParams); + + // Cancel with next_cycle_only + await autumnDeferred.subscriptions.update(deferredParams); + + const customerDefaultAfter = + await autumnDefault.customers.get(customerDefault); + const customerDeferredAfter = + await autumnDeferred.customers.get(customerDeferred); + + // Both should have product removed + await expectProductNotPresent({ + customer: customerDefaultAfter, + productId: proDefault.id, + }); + await expectProductNotPresent({ + customer: customerDeferredAfter, + productId: proDeferred.id, + }); + + // Default cancel should have 2 invoices (attach + proration credit) + expectCustomerInvoiceCorrect({ + customer: customerDefaultAfter, + count: 2, + }); + + // next_cycle_only cancel should have 1 invoice (only attach) + expectCustomerInvoiceCorrect({ + customer: customerDeferredAfter, + count: 1, + }); + + // Both should have no Stripe subscription + await expectNoStripeSubscription({ + db: ctxDefault.db, + customerId: customerDefault, + org: ctxDefault.org, + env: ctxDefault.env, + }); + await expectNoStripeSubscription({ + db: ctxDeferred.db, + customerId: customerDeferred, + org: ctxDeferred.org, + env: ctxDeferred.env, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// CANCEL WITH MONTHLY PRICE (not prepaid) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer attaches Pro with monthly price ($20/month) + * - Immediately cancel with billing_behavior: 'next_cycle_only' + * + * Expected Result: + * - Only 1 invoice exists (initial attach invoice) + * - NO proration credit invoice is created + */ +test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: monthly price - no proration credit")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "bb-cancel-monthly-no-credit", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: "pro" })], + }); + + // Verify initial state + const customerBefore = + await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestTotal: 20, + }); + + // Preview the cancel with next_cycle_only - should be $0 (no proration credit) + const cancelParams = { + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + billing_behavior: "next_cycle_only" as const, + }; + const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); + expect(preview.total).toBe(0); + + // Execute the cancel + await autumnV1.subscriptions.update(cancelParams); + + const customerAfter = await autumnV1.customers.get(customerId); + + // Product should be removed + await expectProductNotPresent({ + customer: customerAfter, + productId: pro.id, + }); + + // NO new invoice - still just 1 invoice (no proration credit) + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: 20, + }); + + // Stripe subscription should be canceled + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-addon.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-addon.test.ts index 16dae970a..cf22616a2 100644 --- a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-addon.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-addon.test.ts @@ -45,7 +45,7 @@ import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/u * - Add-on is removed * - Free default is still not present */ -test(`${chalk.yellowBright("cancel addon EOC: addon canceling, pro active, free default not scheduled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel addon EOC: addon canceling, pro active, free default not scheduled")}`, async () => { const customerId = "cancel-addon-eoc-basic"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -162,7 +162,7 @@ test(`${chalk.yellowBright("cancel addon EOC: addon canceling, pro active, free * - Free is active * - Add-on is still active */ -test(`${chalk.yellowBright("cancel addon EOC: cancel pro, addon persists with free scheduled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel addon EOC: cancel pro, addon persists with free scheduled")}`, async () => { const customerId = "cancel-addon-pro-free-scheduled"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -281,7 +281,7 @@ test(`${chalk.yellowBright("cancel addon EOC: cancel pro, addon persists with fr * - Pro is still active with its subscription * - Add-on is removed */ -test(`${chalk.yellowBright("cancel addon EOC: separate subscription (new_billing_subscription), correct sub canceled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel addon EOC: separate subscription (new_billing_subscription), correct sub canceled")}`, async () => { const customerId = "cancel-addon-eoc-separate-sub"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -421,7 +421,7 @@ test(`${chalk.yellowBright("cancel addon EOC: separate subscription (new_billing * - Pro and Add-on 2 are still active * - Add-on 1 is removed */ -test(`${chalk.yellowBright("cancel addon EOC: multiple addons, cancel one, other persists")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel addon EOC: multiple addons, cancel one, other persists")}`, async () => { const customerId = "cancel-addon-eoc-multiple"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -543,7 +543,7 @@ test(`${chalk.yellowBright("cancel addon EOC: multiple addons, cancel one, other * - Entity's Pro is still active * - Entity's Add-on is removed */ -test(`${chalk.yellowBright("cancel addon EOC: entity-level addon cancel")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel addon EOC: entity-level addon cancel")}`, async () => { const customerId = "cancel-addon-eoc-entity"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); 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 new file mode 100644 index 000000000..56d8c3056 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-edge-cases.test.ts @@ -0,0 +1,303 @@ +/** + * Cancel End-of-Cycle Edge Cases + * + * Tests for edge case scenarios when canceling subscriptions at end of cycle. + * Focuses on multi-subscription scenarios (new_billing_subscription) and + * verifying correct Stripe subscription handling. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductActive, + expectProductCanceling, + expectProductNotPresent, +} 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 { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Cancel both pro and addon EOC (separate subscriptions) - verify correct subs canceled +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Pro product ($20/mo) + * - Recurring add-on product ($20/mo) attached with new_billing_subscription: true + * - User cancels Pro at end of cycle + * - User cancels Add-on at end of cycle + * + * Expected Result: + * - Two separate Stripe subscriptions exist initially + * - After canceling Pro EOC, only Pro's subscription should be marked as canceling + * - After canceling Add-on EOC, Add-on's subscription should also be marked as canceling + * - After advancing to next invoice: + * - Both products are removed + * - No Stripe subscriptions remain + */ +test.concurrent(`${chalk.yellowBright("cancel EOC edge: cancel both pro and addon (separate subs) - correct subs canceled")}`, async () => { + const customerId = "cancel-eoc-both-separate-subs"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + + const { autumnV1, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id, newBillingSubscription: true }), + ], + }); + + // Get both subscription IDs + const proSubId = await getSubscriptionId({ + ctx, + customerId, + productId: pro.id, + }); + + const addonSubId = await getSubscriptionId({ + ctx, + customerId, + productId: addon.id, + }); + + // Verify they are different subscriptions + expect(proSubId).not.toBe(addonSubId); + + // Verify both subscriptions are active initially + const proSubBefore = await ctx.stripeCli.subscriptions.retrieve(proSubId); + const addonSubBefore = await ctx.stripeCli.subscriptions.retrieve(addonSubId); + + expect(isStripeSubscriptionCanceling(proSubBefore)).toBe(false); + expect(isStripeSubscriptionCanceling(addonSubBefore)).toBe(false); + + // Cancel pro at end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Verify only Pro subscription is canceling, Add-on is NOT + const proSubAfterProCancel = + await ctx.stripeCli.subscriptions.retrieve(proSubId); + const addonSubAfterProCancel = + await ctx.stripeCli.subscriptions.retrieve(addonSubId); + + expect(isStripeSubscriptionCanceling(proSubAfterProCancel)).toBe(true); + expect(isStripeSubscriptionCanceling(addonSubAfterProCancel)).toBe(false); + + // Verify customer product states after pro cancel + const customerAfterProCancel = + await autumnV1.customers.get(customerId); + + await expectProductCanceling({ + customer: customerAfterProCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: customerAfterProCancel, + productId: addon.id, + }); + + // Now cancel add-on at end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: addon.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Verify BOTH subscriptions are now canceling + const proSubAfterBothCancel = + await ctx.stripeCli.subscriptions.retrieve(proSubId); + const addonSubAfterBothCancel = + await ctx.stripeCli.subscriptions.retrieve(addonSubId); + + expect(isStripeSubscriptionCanceling(proSubAfterBothCancel)).toBe(true); + expect(isStripeSubscriptionCanceling(addonSubAfterBothCancel)).toBe(true); + + // Verify customer product states after both cancels + const customerAfterBothCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterBothCancel, + canceling: [pro.id, addon.id], + }); + + // Advance to next billing cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Verify state after cycle - both products should be removed + const customerAfterAdvance = + await autumnV1.customers.get(customerId); + + await expectProductNotPresent({ + customer: customerAfterAdvance, + productId: pro.id, + }); + await expectProductNotPresent({ + customer: customerAfterAdvance, + productId: addon.id, + }); + + // No products should remain + expect(customerAfterAdvance.products.length).toBe(0); + + // Verify no Stripe subscriptions exist + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 2: Entity cancel EOC -> uncancel -> cancel EOC again +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Pro product ($20/mo) attached to entity 1 and entity 2 + * - Cancel entity 1 at end of cycle + * - Uncancel entity 1 + * - Cancel entity 1 at end of cycle AGAIN + * + * Expected Result: + * - After first cancel: entity 1 is canceling, entity 2 is active + * - After uncancel: entity 1 is active again, entity 2 is active + * - After second cancel: entity 1 is canceling again, entity 2 is still active + */ +test.concurrent(`${chalk.yellowBright("cancel EOC edge: entity cancel -> uncancel -> cancel again")}`, async () => { + const customerId = "cancel-eoc-uncancel-cancel-again"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: "users" }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + + // Verify both entities have pro active + const entity1Initial = await autumnV1.entities.get(customerId, entity1Id); + const entity2Initial = await autumnV1.entities.get(customerId, entity2Id); + + await expectProductActive({ customer: entity1Initial, productId: pro.id }); + await expectProductActive({ customer: entity2Initial, productId: pro.id }); + + // Step 1: Cancel entity 1 at end of cycle + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Verify entity 1 is canceling, entity 2 is still active + const entity1AfterCancel = await autumnV1.entities.get(customerId, entity1Id); + const entity2AfterCancel = await autumnV1.entities.get(customerId, entity2Id); + + await expectProductCanceling({ + customer: entity1AfterCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterCancel, + productId: pro.id, + }); + + // Step 2: Uncancel entity 1 + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel_action: "uncancel", + }); + + // Verify entity 1 is active again, entity 2 is still active + const entity1AfterUncancel = await autumnV1.entities.get( + customerId, + entity1Id, + ); + const entity2AfterUncancel = await autumnV1.entities.get( + customerId, + entity2Id, + ); + + await expectProductActive({ + customer: entity1AfterUncancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterUncancel, + productId: pro.id, + }); + + // Step 3: Cancel entity 1 at end of cycle AGAIN + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entity1Id, + product_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); + + // Verify entity 1 is canceling again, entity 2 is still active + const entity1AfterSecondCancel = await autumnV1.entities.get( + customerId, + entity1Id, + ); + const entity2AfterSecondCancel = await autumnV1.entities.get( + customerId, + entity2Id, + ); + + await expectProductCanceling({ + customer: entity1AfterSecondCancel, + productId: pro.id, + }); + await expectProductActive({ + customer: entity2AfterSecondCancel, + productId: pro.id, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts index 3a4f27fc2..1e9859a71 100644 --- a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial-entities.test.ts @@ -44,7 +44,7 @@ import chalk from "chalk"; * - No overage charges (trial usage not billed) * - No subscription remains */ -test(`${chalk.yellowBright("cancel trial EOC entities: single entity consumable overage not charged")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: single entity consumable overage not charged")}`, async () => { const customerId = "cancel-trial-eoc-single-ent-overage"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -146,7 +146,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: single entity consumable * - Entity 2's product should still be trialing, then active after trial ends * - Invoice after trial ends should only be for 1 entity ($20) */ -test(`${chalk.yellowBright("cancel trial EOC entities: cancel one entity, other still trialing")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel one entity, other still trialing")}`, async () => { const customerId = "cancel-trial-eoc-ent-one"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -275,7 +275,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: cancel one entity, other * - Entity 1's overage during trial should NOT be charged * - Invoice should only include entity 2's base price ($20) */ -test(`${chalk.yellowBright("cancel trial EOC entities: consumable overage during trial not charged")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: consumable overage during trial not charged")}`, async () => { const customerId = "cancel-trial-eoc-ent-overage"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -400,7 +400,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: consumable overage during * - Both products removed * - No subscription */ -test(`${chalk.yellowBright("cancel trial EOC entities: cancel both entities EOC")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel both entities EOC")}`, async () => { const customerId = "cancel-trial-eoc-ent-both"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -534,7 +534,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: cancel both entities EOC" * - Entity 1's product is removed * - Entity 2 is on pro (active) */ -test(`${chalk.yellowBright("cancel trial EOC entities: cancel one, attach pro to other (next cycle)")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel one, attach pro to other (next cycle)")}`, async () => { const customerId = "cancel-trial-eoc-ent-downgrade"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -673,7 +673,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: cancel one, attach pro to * - Entity 1's product is removed * - Entity 2 is on proTrial (active, no longer trialing - trial ended) */ -test(`${chalk.yellowBright("cancel trial EOC entities: cancel entity 1, attach proTrial to entity 2")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel entity 1, attach proTrial to entity 2")}`, async () => { const customerId = "cancel-trial-eoc-ent-attach"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -803,7 +803,7 @@ test(`${chalk.yellowBright("cancel trial EOC entities: cancel entity 1, attach p * - Entity 1's product is removed * - Entity 2's product is still active */ -test(`${chalk.yellowBright("cancel trial EOC entities: cancel entity 1 EOC after trial ends")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC entities: cancel entity 1 EOC after trial ends")}`, async () => { const customerId = "cancel-trial-eoc-ent-after-trial"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial.test.ts b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial.test.ts index 96d66bdd1..6294ea2bb 100644 --- a/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle-trial.test.ts @@ -40,7 +40,7 @@ import chalk from "chalk"; * - Product is not present * - No invoice created (was free during trial) */ -test(`${chalk.yellowBright("cancel trial EOC: basic cancel, preview.next_cycle null, no invoice after advance")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC: basic cancel, preview.next_cycle null, no invoice after advance")}`, async () => { const customerId = "cancel-trial-eoc-basic"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -155,7 +155,7 @@ test(`${chalk.yellowBright("cancel trial EOC: basic cancel, preview.next_cycle n * - Stripe doesn't create an extra invoice when trial ends */ -test(`${chalk.yellowBright("cancel trial EOC: with consumable messages, no overage invoice")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC: with consumable messages, no overage invoice")}`, async () => { const customerId = "cancel-trial-eoc-consumable"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); @@ -257,7 +257,7 @@ test(`${chalk.yellowBright("cancel trial EOC: with consumable messages, no overa * - Neither premium nor pro is present * - No invoice created */ -test(`${chalk.yellowBright("cancel trial EOC: premium trial with pro scheduled, cancel removes scheduled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC: premium trial with pro scheduled, cancel removes scheduled")}`, async () => { const customerId = "cancel-trial-eoc-scheduled"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -379,7 +379,7 @@ test(`${chalk.yellowBright("cancel trial EOC: premium trial with pro scheduled, * - Pro is not present * - No paid invoice created */ -test(`${chalk.yellowBright("cancel trial EOC: with free default, free scheduled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial EOC: with free default, free scheduled")}`, async () => { const customerId = "cancel-trial-eoc-free-default"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts new file mode 100644 index 000000000..504ca40c8 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts @@ -0,0 +1,113 @@ +/** + * Cancel Immediately Edge Cases + * + * Tests for edge case scenarios when canceling subscriptions immediately. + * Focuses on complex multi-product scenarios with subscription schedules. + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + expectCustomerProducts, + expectProductCanceling, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 1: Cancel pro immediately after entity cancel/uncancel/cancel cycle +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Pro ($20/mo) and recurring add-on ($20/mo) attached to customer + * - Premium ($50/mo) attached to entity + * - Cancel entity (premium) at end of cycle + * - Uncancel entity + * - Cancel entity again at end of cycle + * - Try to cancel pro on customer immediately with next_cycle_only + * + * This tests the edge case where a subscription schedule exists from the entity + * cancel operations, and then we try to cancel a different product immediately. + */ +test.concurrent(`${chalk.yellowBright("cancel immediately edge: cancel pro after entity cancel/uncancel/cancel cycle")}`, async () => { + const customerId = "cancel-imm-edge-entity-cycle"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyMessages({ includedUsage: 300 })], + }); + + const premiumPriceItem = items.monthlyPrice({ price: 50 }); + const premium = products.base({ + id: "premium", + items: [messagesItem, premiumPriceItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro, addon, premium] }), + s.entities({ count: 1, featureId: "users" }), + ], + actions: [ + // A. Attach pro and recurring add-on to customer + s.attach({ productId: pro.id }), + s.attach({ productId: addon.id }), + // B. Attach premium to entity + s.attach({ productId: premium.id, entityIndex: 0, timeout: 3000 }), + s.updateSubscription({ + productId: premium.id, + entityIndex: 0, + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + // Step 4: Try to cancel pro on customer immediately with next_cycle_only + // This is the edge case - there's a subscription schedule from the entity operations, + // and we're trying to cancel a different product immediately + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + cancel_action: "cancel_immediately", + billing_behavior: "next_cycle_only", + }); + + // Verify pro is removed, addon still active + const customerAfterCancel = + await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [pro.id], + active: [addon.id], + canceling: [premium.id], + }); + + // Entity premium should still be canceling + const entityFinal = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductCanceling({ + customer: entityFinal, + productId: premium.id, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + // shouldBeCanceled: true, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-entities.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-entities.test.ts new file mode 100644 index 000000000..e457ca51c --- /dev/null +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-entities.test.ts @@ -0,0 +1,272 @@ +/** + * Cancel Immediately Entity Tests + * + * Tests for canceling products immediately when multiple entities have separate subscriptions. + * These tests verify that canceling a free/one-off product on the customer doesn't affect + * entity subscriptions. + * + * Key behaviors: + * - Canceling free default product on customer doesn't affect entity subscriptions + * - Canceling one-off product on customer doesn't affect entity subscriptions + * - No new invoices should be created when canceling free/one-off products + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, + expectProductNotPresent, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST A: Cancel free default product - entity subscriptions unchanged +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has free default product attached + * - Entity 1 has pro plan ($20/mo) + * - Entity 2 has pro plan ($20/mo) + * - Cancel free default product on customer immediately + * + * Expected Result: + * - Free default product is removed from customer + * - Entity 1 still has pro plan active + * - Entity 2 still has pro plan active + * - Stripe subscription is unchanged (still has both entity items) + * - No new invoice created (free product has no billing) + */ +test.concurrent(`${chalk.yellowBright("cancel immediately entities: free default product - entity subscriptions unchanged")}`, async () => { + const customerId = "cancel-imm-entity-free"; + + 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, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Attach free default to customer (no entity) + s.attach({ productId: free.id }), + // Attach pro to both entities + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + // Verify initial state - customer has free, entities have pro + const customerBefore = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerBefore, + active: [free.id], + }); + + const entity1Before = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductActive({ + customer: entity1Before, + productId: pro.id, + }); + + const entity2Before = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductActive({ + customer: entity2Before, + productId: pro.id, + }); + + // Should have 2 invoices (one for each entity's pro attach) + expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 2, + latestTotal: 20, // Pro base price + }); + + // Cancel free default product immediately (on customer, not entity) + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + cancel_action: "cancel_immediately", + }); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Verify customer no longer has free product + const customerAfter = await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfter, + productId: free.id, + }); + + // Verify entity 1 still has pro + const entity1After = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductActive({ + customer: entity1After, + productId: pro.id, + }); + + // Verify entity 2 still has pro + const entity2After = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductActive({ + customer: entity2After, + productId: pro.id, + }); + + // No new invoice should have been created (free product cancellation doesn't generate invoices) + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 2, // Same as before + }); + + // Stripe subscription should be unchanged + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST B: Cancel one-off product - entity subscriptions unchanged +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Customer has one-off product attached (one-time purchase) + * - Entity 1 has pro plan ($20/mo) + * - Entity 2 has pro plan ($20/mo) + * - Cancel one-off product on customer immediately + * + * Expected Result: + * - One-off product is removed from customer + * - Entity 1 still has pro plan active + * - Entity 2 still has pro plan active + * - Stripe subscription is unchanged (still has both entity items) + * - No new invoice created (one-off cancellation doesn't generate refund invoices) + */ +test.concurrent(`${chalk.yellowBright("cancel immediately entities: one-off product - entity subscriptions unchanged")}`, async () => { + const customerId = "cancel-imm-entity-oneoff"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const oneOff = products.oneOff({ + id: "one-off", + items: [messagesItem], + }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOff, pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Attach one-off to customer (no entity) + s.attach({ productId: oneOff.id }), + // Attach pro to both entities + s.attach({ productId: pro.id, entityIndex: 0 }), + s.attach({ productId: pro.id, entityIndex: 1 }), + ], + }); + + // Verify initial state - customer has one-off, entities have pro + const customerBefore = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerBefore, + active: [oneOff.id], + }); + + const entity1Before = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductActive({ + customer: entity1Before, + productId: pro.id, + }); + + const entity2Before = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductActive({ + customer: entity2Before, + productId: pro.id, + }); + + // Should have 3 invoices (one for one-off, one for each entity's pro attach) + expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 3, + latestTotal: 20, // Pro base price (last attach) + }); + + // Cancel one-off product immediately (on customer, not entity) + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: oneOff.id, + cancel_action: "cancel_immediately", + }); + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Verify customer no longer has one-off product + const customerAfter = await autumnV1.customers.get(customerId); + await expectProductNotPresent({ + customer: customerAfter, + productId: oneOff.id, + }); + + // Verify entity 1 still has pro + const entity1After = await autumnV1.entities.get(customerId, entities[0].id); + await expectProductActive({ + customer: entity1After, + productId: pro.id, + }); + + // Verify entity 2 still has pro + const entity2After = await autumnV1.entities.get(customerId, entities[1].id); + await expectProductActive({ + customer: entity2After, + productId: pro.id, + }); + + // No new invoice should have been created (one-off cancellation doesn't generate invoices) + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 3, // Same as before + }); + + // Stripe subscription should be unchanged + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial-entities.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial-entities.test.ts index 7410f7400..fa3e4edeb 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial-entities.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial-entities.test.ts @@ -38,7 +38,7 @@ import chalk from "chalk"; * - Entity 1's product is still trialing * - Stripe subscription is still trialing (entity 1 remains) */ -test(`${chalk.yellowBright("cancel trial immediately entity: one entity, other still trialing")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately entity: one entity, other still trialing")}`, async () => { const customerId = "cancel-trial-imm-entity-1"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -127,7 +127,7 @@ test(`${chalk.yellowBright("cancel trial immediately entity: one entity, other s * - Both products are removed * - Stripe subscription is canceled (no entities remain) */ -test(`${chalk.yellowBright("cancel trial immediately entity: both entities, subscription canceled")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately entity: both entities, subscription canceled")}`, async () => { const customerId = "cancel-trial-imm-entity-2"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -219,7 +219,7 @@ test(`${chalk.yellowBright("cancel trial immediately entity: both entities, subs * - Entities 1 and 2 are canceling but still trialing * - Subscription is trialing but scheduled to cancel */ -test(`${chalk.yellowBright("cancel trial immediately entity: mixed EOC + immediately on 3 entities")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately entity: mixed EOC + immediately on 3 entities")}`, async () => { const customerId = "cancel-trial-imm-entity-3"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -233,7 +233,7 @@ test(`${chalk.yellowBright("cancel trial immediately entity: mixed EOC + immedia const { autumnV1, ctx, entities } = await initScenario({ customerId, setup: [ - s.customer({ paymentMethod: "success" }), + s.customer({ paymentMethod: "success", testClock: false }), s.products({ list: [proTrial] }), s.entities({ count: 3, featureId: TestFeature.Users }), ], @@ -332,7 +332,7 @@ test(`${chalk.yellowBright("cancel trial immediately entity: mixed EOC + immedia * - After re-attach: product active (not trialing - trial already used) * - Full price invoice created */ -test(`${chalk.yellowBright("cancel trial immediately entity: cancel then re-attach same entity")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately entity: cancel then re-attach same entity")}`, async () => { const customerId = "cancel-trial-imm-entity-4"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial.test.ts index a0ed6eb9f..bbdc1c2da 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-trial.test.ts @@ -39,7 +39,7 @@ import chalk from "chalk"; * - No Stripe subscription (canceled) * - No new invoices (trial was free) */ -test(`${chalk.yellowBright("cancel trial immediately: basic cancel")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately: basic cancel")}`, async () => { const customerId = "cancel-trial-imm-basic"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -128,7 +128,7 @@ test(`${chalk.yellowBright("cancel trial immediately: basic cancel")}`, async () * - Free default becomes active immediately * - No paid invoices */ -test(`${chalk.yellowBright("cancel trial immediately: with free default")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately: with free default")}`, async () => { const customerId = "cancel-trial-imm-default"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -203,7 +203,7 @@ test(`${chalk.yellowBright("cancel trial immediately: with free default")}`, asy * * Note: This tests the `unique_fingerprint` behavior - customer already used trial */ -test(`${chalk.yellowBright("cancel trial immediately: re-attach charges full price (no duplicate trial)")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately: re-attach charges full price (no duplicate trial)")}`, async () => { const customerId = "cancel-trial-imm-reattach"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -295,7 +295,7 @@ test(`${chalk.yellowBright("cancel trial immediately: re-attach charges full pri * - Pro scheduled is also removed (no base product anymore) * - No products attached (unless free default exists) */ -test(`${chalk.yellowBright("cancel trial immediately: with scheduled downgrade, both removed")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately: with scheduled downgrade, both removed")}`, async () => { const customerId = "cancel-trial-imm-scheduled"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); @@ -387,7 +387,7 @@ test(`${chalk.yellowBright("cancel trial immediately: with scheduled downgrade, * - NO overage invoice (usage was free during trial) * - Only the initial $0 trial invoice exists */ -test(`${chalk.yellowBright("cancel trial immediately: with consumable usage, no overage charged")}`, async () => { +test.concurrent(`${chalk.yellowBright("cancel trial immediately: with consumable usage, no overage charged")}`, async () => { const customerId = "cancel-trial-imm-consumable"; const consumableItem = items.consumableMessages({ includedUsage: 100 }); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts index 76bf00c89..17d0448a9 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts @@ -682,3 +682,103 @@ test.concurrent(`${chalk.yellowBright("cancel immediately: one-off prepaid produ env: ctx.env, }); }); + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TEST 8: Cancel one-off product immediately (with free default) - should NOT attach default +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** + * Scenario: + * - Free default product exists + * - User purchases one-off prepaid messages (2 packs = 200 messages) + * - User cancels the one-off product immediately + * + * Expected Result: + * - One-off product is removed immediately + * - Free default product is NOT attached (one-off cancellation shouldn't trigger default) + * - No products attached + * - No Stripe subscription + */ +test.concurrent(`${chalk.yellowBright("cancel immediately: one-off product does not create free default")}`, async () => { + const customerId = "cancel-imm-oneoff-no-default"; + + const messagesItem = items.monthlyMessages({ includedUsage: 50 }); + + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const oneOffMessagesItem = items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + + const oneOffProduct = products.base({ + id: "oneoff", + items: [oneOffMessagesItem], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, oneOffProduct] }), + ], + actions: [ + s.attach({ + productId: oneOffProduct.id, + options: [{ feature_id: "messages", quantity: 200 }], // 2 packs + }), + ], + }); + + // Verify one-off product is active and free is NOT attached + const customerAfterAttach = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterAttach, + active: [oneOffProduct.id], + notPresent: [free.id], + }); + expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 20, // 2 packs * $10 = $20 + }); + + // Cancel one-off product immediately + const cancelParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + cancel_action: "cancel_immediately" as const, + }; + await autumnV1.subscriptions.update(cancelParams); + + // Verify one-off product is gone and free default is NOT attached + const customerAfterCancel = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterCancel, + notPresent: [oneOffProduct.id, free.id], + }); + + // No products should be attached (default should NOT be triggered for one-off) + expect(customerAfterCancel.products.length).toBe(0); + + // Verify no new invoice created (no refund for one-off) + expectCustomerInvoiceCorrect({ + customer: customerAfterCancel, + count: 1, // Still just the original invoice + }); + + // Verify no Stripe subscription exists + await expectNoStripeSubscription({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts similarity index 100% rename from server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-errors.test.ts rename to server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts diff --git a/server/tests/integration/billing/utils/expectCustomerInvoiceCorrect.ts b/server/tests/integration/billing/utils/expectCustomerInvoiceCorrect.ts index 9380d6d78..c09021d8c 100644 --- a/server/tests/integration/billing/utils/expectCustomerInvoiceCorrect.ts +++ b/server/tests/integration/billing/utils/expectCustomerInvoiceCorrect.ts @@ -15,6 +15,7 @@ export const expectCustomerInvoiceCorrect = async ({ latestTotal, latestStatus, latestInvoiceProductId, + latestInvoiceProductIds, }: { customerId?: string; customer?: ApiCustomerV3; @@ -22,6 +23,7 @@ export const expectCustomerInvoiceCorrect = async ({ latestTotal?: number; latestStatus?: "paid" | "draft" | "open" | "void"; latestInvoiceProductId?: string; + latestInvoiceProductIds?: string[]; }) => { const customer = providedCustomer ? providedCustomer @@ -46,4 +48,10 @@ export const expectCustomerInvoiceCorrect = async ({ if (latestInvoiceProductId !== undefined && invoices.length > 0) { expect(invoices[0].product_ids).toContain(latestInvoiceProductId); } + + if (latestInvoiceProductIds !== undefined && invoices.length > 0) { + for (const productId of latestInvoiceProductIds) { + expect(invoices[0].product_ids).toContain(productId); + } + } }; diff --git a/server/tests/merged/addOn/mergedAddOn1.test.ts b/server/tests/merged/addOn/mergedAddOn1.test.ts deleted file mode 100644 index a51dc9de2..000000000 --- a/server/tests/merged/addOn/mergedAddOn1.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.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 { - constructArrearItem, - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; - -// UNCOMMENT FROM HERE - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Credits })], - type: "pro", -}); - -const billingUnits = 100; -const addOn = constructRawProduct({ - id: "addOn", - items: [ - constructPrepaidItem({ - featureId: TestFeature.Credits, - billingUnits, - price: 10, - }), - constructArrearItem({ - featureId: TestFeature.Words, - billingUnits: 100, - }), - ], - isAddOn: true, -}); - -const ops = [ - { - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - product: addOn, - results: [ - { product: pro, status: CusProductStatus.Active }, - { product: addOn, status: CusProductStatus.Active }, - ], - options: [ - { - feature_id: TestFeature.Credits, - quantity: billingUnits * 3, - }, - ], - otherProducts: [pro], - }, - - // Update quantity - { - product: addOn, - results: [ - { product: pro, status: CusProductStatus.Active }, - { product: addOn, status: CusProductStatus.Active }, - ], - options: [ - { - feature_id: TestFeature.Credits, - quantity: billingUnits * 5, - }, - ], - otherProducts: [pro], - }, -]; - -const testCase = "mergedAddOn1"; -describe(`${chalk.yellowBright("mergedAddOn1: Adding an add on")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let testClockId: string; - let curUnix: number; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, addOn], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - testClockId = res.testClockId!; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - test("should run operations", async () => { - await autumn.entities.create(customerId, entities); - - for (let index = 0; index < ops.length; index++) { - const op = ops[index]; - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entities, - options: op.options, - otherProducts: op.otherProducts, - }); - - for (const result of op.results) { - // const entity = await autumn.entities.get(customerId, op.entityId); - const cus = await autumn.customers.get(customerId); - expectProductAttached({ - customer: cus, - product: result.product, - status: result.status, - }); - } - } - }); - - test("should cancel add on product and have correct sub items", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: addOn.id, - cancel_immediately: false, - }); - - await expectSubToBeCorrect({ - customerId, - db, - org, - env, - }); - - const customer = await autumn.customers.get(customerId); - - expectProductAttached({ - customer, - product: addOn, - status: CusProductStatus.Active, - }); - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.Active, - }); - }); - - test("should advance to next invoice and have no add on product", async () => { - await advanceToNextInvoice({ - stripeCli, - testClockId, - }); - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.Active, - }); - const products = customer.products.filter((p) => p.group === addOn.group); - expect(products.length).toBe(1); - }); -}); diff --git a/server/tests/merged/trial/mergedTrial4.test.ts b/server/tests/merged/trial/mergedTrial4.test.ts deleted file mode 100644 index 463e8d237..000000000 --- a/server/tests/merged/trial/mergedTrial4.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { beforeAll, describe, it } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", - trial: true, -}); - -const pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - trial: true, -}); - -const ops = [ - { - entityId: "1", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, -]; - -const testCase = "mergedTrial4"; -describe(`${chalk.yellowBright("mergedTrial4: Testing cancel immediately on merged sub trial")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - it("should attach pro trial for entity 1 and entity 2", async () => { - await autumn.entities.create(customerId, entities); - - for (const op of ops) { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entityId: op.entityId, - }); - } - }); - - it("should cancel one of subs immediately and have sub still trialing", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - entity_id: "2", - cancel_immediately: true, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - shouldBeTrialing: true, - }); - }); -}); diff --git a/server/tests/merged/trial/mergedTrial5.test.ts b/server/tests/merged/trial/mergedTrial5.test.ts deleted file mode 100644 index 1b6c01aee..000000000 --- a/server/tests/merged/trial/mergedTrial5.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { beforeAll, describe, it } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js"; - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", - trial: true, -}); - -const pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - trial: true, -}); - -const ops = [ - { - entityId: "1", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "3", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, -]; - -const testCase = "mergedTrial5"; -describe(`${chalk.yellowBright("mergedTrial5: Testing cancel at end of cycle and cancel immediately on merged sub trial")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - }); - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "Entity 3", - feature_id: TestFeature.Users, - }, - ]; - - it("should attach pro trial for 3 entities", async () => { - await autumn.entities.create(customerId, entities); - - for (const op of ops) { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entityId: op.entityId, - waitForInvoice: 3000, - }); - } - }); - // return; - - it("should cancel one sub end of cycle", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - entity_id: "2", - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - shouldBeTrialing: true, - }); - }); - - it("should cancel one sub immediately", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - entity_id: "3", - cancel_immediately: true, - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - shouldBeTrialing: true, - }); - }); - - it("should cancel last sub at end of cycle", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - entity_id: "1", - }); - - await expectSubToBeCorrect({ - db, - customerId, - org, - env, - shouldBeTrialing: true, - shouldBeCanceled: true, - }); - }); -}); diff --git a/server/tests/merged/trial/trial3.test.ts b/server/tests/merged/trial/trial3.test.ts deleted file mode 100644 index 1b97a0bca..000000000 --- a/server/tests/merged/trial/trial3.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { beforeAll, describe, expect, it } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - type FullCusProduct, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.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 { CusService } from "@/internal/customers/CusService.js"; -import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -// Pro Trial -// Trial Finishes -// Premium Trial - -const pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", - trial: true, -}); - -const premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", - trial: true, -}); - -const ops = [ - { - product: pro, - results: [{ product: pro, status: CusProductStatus.Trialing }], - }, - // { - // entityId: "2", - // product: premium, - // results: [{ product: premium, status: CusProductStatus.Active }], - // }, -]; - -const testCase = "trial3"; -describe(`${chalk.yellowBright("trial3: Testing cancel trial product")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let testClockId: string; - let curUnix: number; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - testClockId = res.testClockId!; - }); - - it("should attach first trial, and advance clock past trial", async () => { - for (const op of ops) { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - }); - } - - const customer = await autumn.customers.get(customerId); - expectProductAttached({ - customer, - product: pro, - status: CusProductStatus.Trialing, - }); - }); - - let cusProduct: FullCusProduct; - - it("should have canceled trial product at the end of cycle", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - }); - - const fullCus = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - }); - - cusProduct = fullCus.customer_products.find( - (p) => p.product.id === pro.id, - )!; - const sub = await cusProductToSub({ - cusProduct, - stripeCli, - }); - // console.log(`cancel at period end: ${sub?.cancel_at_period_end}`); - // console.log(`cancel at: ${sub?.cancel_at}`); - // console.log(`canceled at: ${sub?.canceled_at}`); - const canceled = sub?.canceled_at || sub?.cancel_at; - expect(canceled).toBeDefined(); - }); - - it("should have sub not canceled if renew product", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - const sub = await cusProductToSub({ - cusProduct, - stripeCli, - }); - expect(sub?.cancel_at_period_end).toBe(false); - }); - it("should be canceled completely", async () => { - await autumn.cancel({ - customer_id: customerId, - product_id: pro.id, - cancel_immediately: true, - }); - - const customer = await autumn.customers.get(customerId); - const proProduct = customer.products.find((p) => p.id === pro.id); - expect(proProduct).toBeUndefined(); - - const sub = await cusProductToSub({ - cusProduct, - stripeCli, - }); - expect(sub?.status).toBe("canceled"); - }); -}); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index af8c0827b..321adf268 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -333,6 +333,21 @@ const allocatedUsers = ({ includedUsage, }) as LimitedItem; +/** + * Allocated workflows - prorated billing on change ($10/workflow) + * @param includedUsage - Free workflows included (default: 0) + */ +const allocatedWorkflows = ({ + includedUsage = 0, +}: { + includedUsage?: number; +} = {}): LimitedItem => + constructArrearProratedItem({ + featureId: TestFeature.Workflows, + pricePerUnit: 10, + includedUsage, + }) as LimitedItem; + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // BASE PRICES // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -399,6 +414,7 @@ export const items = { // Allocated allocatedUsers, + allocatedWorkflows, // Base prices monthlyPrice, diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index c3dc1226c..d19580a87 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -9,7 +9,7 @@ import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js" import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { hoursToFinalizeInvoice } from "../constants.js"; import { advanceTestClock as advanceTestClockFn } from "../stripeUtils.js"; -import ctx from "./createTestContext.js"; +import defaultCtx, { type TestContext } from "./createTestContext.js"; // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // TYPES @@ -608,6 +608,7 @@ export async function initScenario(params: { customerId: string; setup: ConfigFn[]; actions: ConfigFn[]; + ctx?: TestContext; }): Promise<{ customerId: string; autumnV1: AutumnInt; @@ -615,7 +616,7 @@ export async function initScenario(params: { autumnV2: AutumnInt; testClockId: string | undefined; customer: Awaited>["customer"]; - ctx: typeof ctx; + ctx: TestContext; entities: GeneratedEntity[]; advancedTo: number; }>; @@ -625,6 +626,7 @@ export async function initScenario(params: { customerId?: undefined; setup: ConfigFn[]; actions: ConfigFn[]; + ctx?: TestContext; }): Promise<{ customerId: undefined; autumnV1: AutumnInt; @@ -632,7 +634,7 @@ export async function initScenario(params: { autumnV2: AutumnInt; testClockId: undefined; customer: null; - ctx: typeof ctx; + ctx: TestContext; entities: GeneratedEntity[]; advancedTo: number; }>; @@ -642,11 +644,15 @@ export async function initScenario({ customerId, setup, actions, + ctx: ctxOverride, }: { customerId?: string; setup: ConfigFn[]; actions: ConfigFn[]; + ctx?: TestContext; }) { + // Use provided context or fall back to default + const ctx = ctxOverride ?? defaultCtx; // Build config from setup and actions const config = [...setup, ...actions].reduce((c, fn) => fn(c), defaultConfig); diff --git a/shared/api/billing/common/billingBehavior.ts b/shared/api/billing/common/billingBehavior.ts new file mode 100644 index 000000000..239e0579b --- /dev/null +++ b/shared/api/billing/common/billingBehavior.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +export const BillingBehaviorSchema = z.enum([ + "prorate_immediately", + "next_cycle_only", +]); + +export type BillingBehavior = z.infer; diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index 6e2e0b503..445cefccc 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -6,6 +6,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo import { CancelActionSchema } from "../../common/cancelMode"; import { CustomerDataSchema } from "../../common/customerData"; import { EntityDataSchema } from "../../models"; +import { BillingBehaviorSchema } from "../common/billingBehavior"; import { RefundBehaviorSchema } from "../common/refundBehavior"; export const ExtUpdateSubscriptionV0ParamsSchema = z.object({ @@ -33,9 +34,7 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({ // Billing behavior for subscription updates: // - 'prorate_immediately' (default): Invoice line items are charged immediately // - 'next_cycle_only': Do NOT create any charges due to the update - billing_behavior: z - .enum(["prorate_immediately", "next_cycle_only"]) - .optional(), + billing_behavior: BillingBehaviorSchema.optional(), // Refund behavior for negative invoice totals (downgrades): // - 'grant_invoice_credits' (default): Apply credits to customer balance diff --git a/shared/index.ts b/shared/index.ts index 317dfdd06..76a898a77 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -3,6 +3,8 @@ import * as schemas from "./db/schema.js"; export { schemas }; export * from "./api/apiUtils.js"; +// Billing common schemas +export * from "./api/billing/common/billingBehavior.js"; // Cursor pagination utilities export * from "./api/common/cursorPaginationSchemas.js"; // API MODELS diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index 61714c0a8..8f8881fa2 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,5 +1,5 @@ import { InternalError } from "@api/errors"; -import { formatMs, ms } from "@utils/common"; +import { ms } from "@utils/common"; import type { EntityBalance, FullCustomerEntitlement, @@ -93,11 +93,5 @@ export const customerEntitlementShouldBeBilled = ({ const TOLERANCE_MS = ms.minutes(30); - console.log("--------------------------------"); - console.log("nextResetAt", formatMs(nextResetAt)); - console.log("invoicePeriodEndMs", formatMs(invoicePeriodEndMs)); - - console.log("--------------------------------"); - return nextResetAt <= invoicePeriodEndMs + TOLERANCE_MS; }; diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index cf2a94117..691d711f1 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -174,8 +174,9 @@ export const isCustomerProductOnStripeSubscriptionSchedule = ({ stripeSubscriptionScheduleId, }: { customerProduct: FullCusProduct; - stripeSubscriptionScheduleId: string; + stripeSubscriptionScheduleId: string | null; }) => { + if (!stripeSubscriptionScheduleId) return false; return customerProduct.scheduled_ids?.includes(stripeSubscriptionScheduleId); }; diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts index 11b3653cd..2343bd135 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/cpBuilder.ts @@ -214,7 +214,7 @@ class CustomerProductChecker { onStripeSchedule({ stripeSubscriptionScheduleId, }: { - stripeSubscriptionScheduleId: string; + stripeSubscriptionScheduleId: string | null; }) { this.pendingPredicates.push( (cp) => diff --git a/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx b/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx new file mode 100644 index 000000000..a1f1815df --- /dev/null +++ b/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx @@ -0,0 +1,78 @@ +import { cp } from "@autumn/shared"; +import { CalendarCheckIcon, LightningIcon } from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/react"; +import { useUpdateSubscriptionFormContext } from "@/components/forms/update-subscription-v2"; +import { COLLAPSE_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants"; +import { PanelButton } from "@/components/v2/buttons/PanelButton"; +import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; + +export function BillingBehaviorSection() { + const { form, formValues, formContext } = useUpdateSubscriptionFormContext(); + const { customerProduct } = formContext; + + const cancelAction = formValues.cancelAction; + const billingBehavior = formValues.billingBehavior ?? "prorate_immediately"; + + // Only show billing behavior options when: + // 1. Cancel action is "cancel_immediately" + // 2. Product is NOT free or one-off (has recurring billing) + const { valid: isFreeOrOneOff } = cp(customerProduct).free().or.oneOff(); + const showBillingBehavior = + cancelAction === "cancel_immediately" && !isFreeOrOneOff; + + return ( + + {showBillingBehavior && ( + + +
+
+ + form.setFieldValue("billingBehavior", "prorate_immediately") + } + icon={} + /> +
+
+ Prorate immediately +
+
+ Issue a prorated credit or refund for the unused portion of + the billing period. +
+
+
+ +
+ + form.setFieldValue("billingBehavior", "next_cycle_only") + } + icon={} + /> +
+
+ Next cycle only +
+
+ No charges or credits issued. Access ends at the current + period's end. +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/vite/src/components/forms/cancel-subscription/index.ts b/vite/src/components/forms/cancel-subscription/index.ts index b38befd2a..ca8a9b5b1 100644 --- a/vite/src/components/forms/cancel-subscription/index.ts +++ b/vite/src/components/forms/cancel-subscription/index.ts @@ -1,3 +1,4 @@ +export { BillingBehaviorSection } from "./components/BillingBehaviorSection"; export { CancelFooter } from "./components/CancelFooter"; export { CancelModeSection } from "./components/CancelModeSection"; export { CancelPreviewSection } from "./components/CancelPreviewSection"; diff --git a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx index b00d02187..0ff0ba7ad 100644 --- a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx +++ b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx @@ -217,6 +217,7 @@ export function UpdateSubscriptionFormProvider({ items: formValues.items, version: formValues.version, cancelAction: formValues.cancelAction, + billingBehavior: formValues.billingBehavior, refundBehavior: formValues.refundBehavior, }); diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts index ef6645779..7c2d245b7 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts @@ -55,6 +55,7 @@ export function useUpdateSubscriptionForm({ version: currentVersion, items: null, cancelAction: null, + billingBehavior: null, refundBehavior: null, ...defaultOverrides, } as UpdateSubscriptionForm, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts index 979032471..610ba2305 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -29,6 +29,7 @@ export function useUpdateSubscriptionRequestBody({ version, items, cancelAction, + billingBehavior, refundBehavior, } = formValues; @@ -44,8 +45,13 @@ export function useUpdateSubscriptionRequestBody({ if (cancelAction) { requestBody.cancel_action = cancelAction; - if (cancelAction === "cancel_immediately" && refundBehavior) { - requestBody.refund_behavior = refundBehavior; + if (cancelAction === "cancel_immediately") { + if (billingBehavior) { + requestBody.billing_behavior = billingBehavior; + } + if (refundBehavior) { + requestBody.refund_behavior = refundBehavior; + } } return requestBody; diff --git a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts index 02ca1ff87..904014e39 100644 --- a/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts +++ b/vite/src/components/forms/update-subscription-v2/updateSubscriptionFormSchema.ts @@ -1,7 +1,19 @@ -import { FreeTrialDuration, type ProductItem } from "@autumn/shared"; +import { + type BillingBehavior, + BillingBehaviorSchema, + FreeTrialDuration, + type ProductItem, +} from "@autumn/shared"; import { CancelActionSchema } from "node_modules/@autumn/shared/api/common/cancelMode"; import { z } from "zod/v4"; -import { RefundBehaviorSchema } from "@/components/forms/update-subscription-v2/types/refundBehaviourSchema"; +import { + RefundBehaviorSchema, + type RefundBehaviorValue, +} from "@/components/forms/update-subscription-v2/types/refundBehaviourSchema"; + +export type BillingBehaviorValue = BillingBehavior; +export type { RefundBehaviorValue }; +export type CancelActionValue = z.infer; export const UpdateSubscriptionFormSchema = z.object({ prepaidOptions: z.record(z.string(), z.number().nonnegative()), @@ -16,6 +28,7 @@ export const UpdateSubscriptionFormSchema = z.object({ items: z.custom().nullable(), cancelAction: CancelActionSchema.nullable(), + billingBehavior: BillingBehaviorSchema.nullable(), refundBehavior: RefundBehaviorSchema.nullable(), }); diff --git a/vite/src/components/forms/update-subscription/get-update-subscription-body.ts b/vite/src/components/forms/update-subscription/get-update-subscription-body.ts index 3b0318ba0..7657ac66b 100644 --- a/vite/src/components/forms/update-subscription/get-update-subscription-body.ts +++ b/vite/src/components/forms/update-subscription/get-update-subscription-body.ts @@ -1,4 +1,5 @@ import type { + BillingBehavior, CreateFreeTrial, FeatureOptions, ProductItem, @@ -22,6 +23,7 @@ export const getUpdateSubscriptionBody = ({ freeTrial, items, cancelAction, + billingBehavior, refundBehavior, }: { customerId: string; @@ -39,6 +41,7 @@ export const getUpdateSubscriptionBody = ({ items?: ProductItem[] | null; // Cancel action fields cancelAction?: CancelActionValue | null; + billingBehavior?: BillingBehavior | null; refundBehavior?: RefundBehaviorValue | null; }) => { // For cancel actions, only include cancellation-related fields @@ -48,6 +51,10 @@ export const getUpdateSubscriptionBody = ({ product_id: product.id, entity_id: entityId || undefined, cancel_action: cancelAction, + billing_behavior: + cancelAction === "cancel_immediately" + ? billingBehavior || undefined + : undefined, refund_behavior: cancelAction === "cancel_immediately" ? refundBehavior || undefined diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts index 1c2eecfb2..2795a2275 100644 --- a/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts +++ b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts @@ -1,3 +1,4 @@ +import type { BillingBehavior } from "@autumn/shared"; import { AppEnv, type CreateFreeTrial, @@ -34,6 +35,7 @@ interface UpdateSubscriptionBodyBuilderParams { // Cancel action fields cancelAction?: CancelActionValue | null; + billingBehavior?: BillingBehavior | null; refundBehavior?: RefundBehaviorValue | null; } @@ -119,6 +121,7 @@ export function useUpdateSubscriptionBodyBuilder( freeTrial: mergedParams.freeTrial, items: mergedParams.items, cancelAction: mergedParams.cancelAction, + billingBehavior: mergedParams.billingBehavior, refundBehavior: mergedParams.refundBehavior, }); }, diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts index 0a3fe6c6a..68a7b8a01 100644 --- a/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts +++ b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts @@ -1,4 +1,5 @@ import type { + BillingBehavior, CreateFreeTrial, PreviewUpdateSubscriptionResponse, ProductItem, @@ -22,6 +23,7 @@ export function useUpdateSubscriptionPreview({ items, version, cancelAction, + billingBehavior, refundBehavior, }: { updateSubscriptionFormContext: UpdateSubscriptionFormContext; @@ -31,6 +33,7 @@ export function useUpdateSubscriptionPreview({ items?: ProductItem[] | null; version?: number; cancelAction?: CancelActionValue | null; + billingBehavior?: BillingBehavior | null; refundBehavior?: RefundBehaviorValue | null; }) { const { customerId, product, entityId } = updateSubscriptionFormContext; @@ -45,6 +48,7 @@ export function useUpdateSubscriptionPreview({ freeTrial, items, cancelAction, + billingBehavior, refundBehavior, }); diff --git a/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx index 31bb1c353..8a1a78bdc 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionCancelSheet.tsx @@ -1,13 +1,14 @@ import { CusProductStatus, + cp, type FullCusProduct, type ProductV2, } from "@autumn/shared"; import { useMemo } from "react"; +import { BillingBehaviorSection } from "@/components/forms/cancel-subscription/components/BillingBehaviorSection"; import { CancelFooter } from "@/components/forms/cancel-subscription/components/CancelFooter"; import { CancelModeSection } from "@/components/forms/cancel-subscription/components/CancelModeSection"; import { CancelPreviewSection } from "@/components/forms/cancel-subscription/components/CancelPreviewSection"; -import { RefundBehaviorSection } from "@/components/forms/cancel-subscription/components/RefundBehaviorSection"; import { type UpdateSubscriptionFormContext, UpdateSubscriptionFormProvider, @@ -66,7 +67,7 @@ function SheetContent() { )} - {/* */} + @@ -100,6 +101,15 @@ export function SubscriptionCancelSheet() { [customer, cusProduct, productV2, prepaidItems, currentVersion], ); + // Free products and one-time plans (no subscription) must use "cancel_immediately" + // Products with subscriptions default to "cancel_end_of_cycle" to allow end-of-cycle cancellation + // const hasSubscription = + // cusProduct?.subscription_ids && cusProduct.subscription_ids.length > 0; + const { valid: isFreeOrOneOff } = cp(cusProduct).free().or.oneOff(); + const defaultCancelAction = isFreeOrOneOff + ? "cancel_immediately" + : "cancel_end_of_cycle"; + if (!cusProduct) { return (
@@ -125,7 +135,7 @@ export function SubscriptionCancelSheet() { diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index 65dbe3294..acc35fc8c 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -1,10 +1,7 @@ import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared"; import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; -import type { AxiosError } from "axios"; import { useMemo, useState } from "react"; -import { toast } from "sonner"; import { Table } from "@/components/general/table"; import { SectionTag } from "@/components/v2/badges/SectionTag"; @@ -12,7 +9,6 @@ import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery"; import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery"; @@ -118,32 +114,8 @@ export function CustomerProductsTable() { setTransferOpen(true); }; - const axiosInstance = useAxiosInstance(); - const queryClient = useQueryClient(); - - const uncancelMutation = useMutation({ - mutationFn: async (product: FullCusProduct) => { - const response = await axiosInstance.post("/v1/subscriptions/update", { - customer_id: customer.id, - product_id: product.product.id, - cancel_action: "uncancel", - }); - return response.data; - }, - onSuccess: () => { - toast.success("Subscription uncanceled successfully"); - queryClient.invalidateQueries({ queryKey: ["customer", customer.id] }); - }, - onError: (error) => { - toast.error( - (error as AxiosError<{ message: string }>)?.response?.data?.message ?? - "Failed to uncancel subscription", - ); - }, - }); - const handleUncancelClick = (product: FullCusProduct) => { - uncancelMutation.mutate(product); + setSheet({ type: "subscription-uncancel", itemId: product.id }); }; const handleRowClick = (cusProduct: FullCusProduct) => {