From 699c00e42c3dc682c0b1e6a67a37a1dc9ae5c4db Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 16 Apr 2026 16:20:29 +0100 Subject: [PATCH] chore: cleanup --- .gitignore | 13 + .gitmodules | 3 + .opencode/opencode.json | 37 +- .vscode/settings.json | 8 +- AGENTS.md | 441 ++++++++++++++---- ai | 1 + .../expireEndedCustomerProducts.ts | 13 +- .../computeImmediateMultiProductPlan.ts | 17 +- .../compute/computeCreateSchedulePlan.ts | 207 ++------ .../computeImmediatePhaseCustomerProducts.ts | 99 ++++ .../computeScheduledCustomerProducts.ts | 85 ++++ .../actions/createSchedule/createSchedule.ts | 78 +--- .../errors/handleCreateScheduleErrors.ts | 36 ++ .../errors/normalizeCreateSchedulePhases.ts | 73 +-- .../createSchedule/previewCreateSchedule.ts | 46 +- .../setupCreateScheduleBillingContext.ts | 48 +- .../setup/setupScheduledProductsContext.ts | 63 +++ .../billingContextToRecurringAndScheduled.ts | 36 ++ .../utils/materializeScheduledPhases.ts | 2 +- .../utils/persistCreateSchedule.ts | 107 +---- .../buildSharedSubscriptionTrialLineItems.ts | 2 +- ...ripeSubscriptionScheduleIdToBillingPlan.ts | 2 +- .../v2/execute/executeAutumnBillingPlan.ts | 2 +- .../autumnBillingPlanToFinalFullCustomer.ts | 2 +- .../productContextToAttachBillingContext.ts | 28 ++ ...ons.ts => customerProductPlanMutations.ts} | 24 + .../billingPlanToSendProductsUpdated.ts | 2 +- server/tests/_groups/temp.ts | 32 +- .../compute-create-schedule-plan.spec.ts | 46 +- .../create-schedule-params.spec.ts | 27 ++ .../normalize-create-schedule-phases.spec.ts | 84 +--- ...billing-plan-send-products-updated.spec.ts | 46 +- .../rate-limits/get-rate-limit-type.test.ts | 4 +- .../createSchedule/createScheduleParamsV0.ts | 51 +- .../context/createScheduleBillingContext.ts | 26 ++ shared/models/billingModels/context/index.ts | 1 + shared/models/cusModels/fullCusModel.ts | 4 - .../classifyCustomerProduct.ts | 5 +- ...erProductsToRecurringActiveAndScheduled.ts | 31 ++ shared/utils/cusProductUtils/index.ts | 1 + 40 files changed, 1100 insertions(+), 733 deletions(-) create mode 100644 .gitmodules create mode 160000 ai create mode 100644 server/src/internal/billing/v2/actions/createSchedule/compute/computeImmediatePhaseCustomerProducts.ts create mode 100644 server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts create mode 100644 server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts create mode 100644 server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts create mode 100644 server/src/internal/billing/v2/actions/createSchedule/utils/billingContextToRecurringAndScheduled.ts create mode 100644 server/src/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext.ts rename server/src/internal/billing/v2/utils/billingPlan/{customerProductMutations.ts => customerProductPlanMutations.ts} (64%) create mode 100644 shared/models/billingModels/context/createScheduleBillingContext.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/customerProductsToRecurringActiveAndScheduled.ts diff --git a/.gitignore b/.gitignore index a08658dba..dd7a7dc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,16 @@ TAKEHOME.md .openlogs + + +# project context (personal working state) +.context/ + +# ai-sync: generated files (source of truth is ai/) +.cursor/ +.claude/ +.codex/ +.agents/ +.mcp.json +.opencode/skills/ +AGENTS.md \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..43110b0b1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ai"] + path = ai + url = https://github.com/useautumn/ai diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 467800d9b..67b6a3efd 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,36 +1,37 @@ { "$schema": "https://opencode.ai/config.json", "mcp": { + "axiom": { + "type": "remote", + "url": "https://mcp.axiom.co/mcp" + }, "linear": { "type": "remote", "url": "https://mcp.linear.app/mcp", "oauth": {} }, + "trigger": { + "type": "local", + "command": [ + "bunx", + "trigger.dev@latest", + "mcp" + ] + }, + "tinybird": { + "type": "remote", + "url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}" + }, "mintlify": { "type": "remote", "url": "https://mintlify.com/docs/mcp" }, - "planetscale": { "type": "remote", "url": "https://mcp.pscale.dev/mcp/planetscale" - }, - "axiom": { - "type": "remote", - "url": "https://mcp.axiom.co/mcp" - }, - "tinybird": { - "type": "remote", - "url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}", - "oauth": false, - "enabled": true - }, - "vercel": { - "type": "remote", - "url": "https://mcp.vercel.com", - "enabled": true } }, - - "plugin": ["opencode-supermemory@latest"] + "plugin": [ + "opencode-supermemory@latest" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d4bad54d..d927606a7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,6 +32,12 @@ // "**/.claude": true, "**/.cursor": true, // "**/.github": true, - "**/.superset": true + "**/.superset": true, + ".claude": true, + ".codex": true, + ".agents": true, + ".cursor": true, + ".mcp.json": true, + ".zed": true } } diff --git a/AGENTS.md b/AGENTS.md index 38c630630..323c448f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,123 +1,380 @@ -# Basic rules -- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need -- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform. -- DO NOT alter .gitignore -- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary -- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. + -# Testing -- When writing tests, ALWAYS read: - 1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys - 2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests) -- When running tests, ALL server-side console logs go to the server's logs which you do not have access to. You must ask the user to paste you in the logs, instead of expecting the server logs to magically appear -in the test logs. Use your common sense +## Project Context System -# Linting and Codebase rules -- You can access the biome linter by running `bunx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write ` +Projects maintain state in `.context//` folders across sessions. Tasks are optional parallel workstreams within a project. -- Note, biome does not perform typechecking. For typechecks, either `cd` into the relevant workspace and run `bun ts`, or run `tsgo` directly. If you need to install it first, the npm package is `@typescript/native-preview`. Do not use `tsc` for workspace typechecks. +### Reading context (session start) +When the user mentions a project or task name and `.context//` exists: +1. Read project STATUS.md first (20-30 line "resume card") +2. If the project has `tasks/`, list active tasks +3. If the user mentions a specific task, read `tasks//STATUS.md` +4. Read the most recent session summary if more detail is needed +5. Do NOT read everything upfront. Use progressive disclosure. -- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead. +### Updating context (at breakpoints, NOT continuously) +Update at these moments ONLY: +- Phase or milestone completed +- Architectural decision made (append to DECISIONS.md) +- User says they're done or switching tasks +- Blocker discovered or resolved +- Task created, completed, or handed off -- This codebase uses Bun as its preferred package manager and Node runtime. +Do NOT update context during normal coding work. Work first, compact at breakpoints. -- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` +### Compaction quality +STATUS.md must be: +- Correct (reflects actual current state, not stale) +- Complete (no critical information missing) +- Concise (project < 30 lines, task < 20 lines) -- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier. +Always REWRITE STATUS.md completely rather than append. -- When creating "hooks" folders, don't nest them under "components" +### File structure +``` +.context// + STATUS.md -- project resume card (includes Active Tasks section) + PLAN.md -- phases and architecture + DECISIONS.md -- append-only decision log + sessions/ -- dated session summaries + tasks/ -- optional parallel workstreams + / + STATUS.md -- task resume card + DECISIONS.md +``` -- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +### Task handoff +When a task is being handed to another agent, ensure the task's STATUS.md is up to date -- it's the handoff artifact. -- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported. - ```typescript - // ❌ BAD - Unnecessary type definition for single-use params - type DoSomethingParams = { - ctx: AutumnContext; +# scripts-v2 Conventions + +## File naming +- Always kebab-case: `standardize-stripe-state.ts`, not `standardizeStripeState.ts` +- Scripts go in `runs/{org-name}/`: e.g. `runs/mintlify/fix-prices.ts` +- **Never use `index.ts`** for business logic — name files after what the function does + +## Imports +- `script` / `step`: from `"../lib"` or `"../../lib"` (relative to script) +- Types/enums: from `"@autumn/shared"` (AppEnv, FullProduct, Customer, etc.) +- Server services: from `"@autumn/server/src/internal/..."` (CusService, ProductService, etc.) +- Lib utilities: from `"@autumn-cloud/lib/..."` (createScriptContext, search helpers) + +## Function signatures +- Always use named object params: `fn({ db, orgId })` not `fn(db, orgId)` +- This applies to all functions, even single-argument ones + +## Three categories of functions + +### 1. Steps (`step()`) +Named phases of work. Declare their own `id` and typed input/output. Called via `s.run()`. + +```typescript +import { step, type ScriptContext } from "../../../lib"; + +export const standardizeBasePrices = step({ + id: "standardize-base-prices", + run: async ({ ctx, group, priceCache }: { + ctx: ScriptContext; + group: SubscriptionGroup; + priceCache: StripePriceCache; + }): Promise => { + // business logic — returns typed data + return mismatches; + }, +}); +``` + +### 2. Orchestrators (steps that compose other steps) +Their `run` function takes `s` in addition to `ctx` + payload. They call `s.run()` to compose sub-steps. + +```typescript +export const processCustomer = step({ + id: "process-customer", + run: async ({ s, ctx, customerId, priceCache }: { + s: ScriptUtils; + ctx: ScriptContext; customerId: string; - }; - const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... } + priceCache: StripePriceCache; + }) => { + const groups = await s.run(loadSubscriptionGroups, { customerId }); + const result = await s.run(standardizeBasePrices, { group, priceCache }); + return { customerId, status: "ok", ...result }; + }, +}); +``` - // ✅ GOOD - Inline object type - const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... } - ``` +### 3. Utilities (plain functions) +Helpers that aren't meaningful workflow phases. Regular async functions, no `step()`. +Examples: `formatSubscriptionUpdate`, `listStripeSubscriptions`, `buildCacheKey`. -- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. -- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better +## Params rules +- `dryRun` lives on `ctx.dryRun` — never pass as a separate param +- `data` is accessed via `s.data` in orchestrators only — never threaded to leaf steps +- If a step needs more than `ctx` + 3 business params, it's doing too much -- If you need the TypeScript CLI directly, run `tsgo` itself (package: `@typescript/native-preview`), not `npx tsc`. +## Result convention (Trigger.dev-style ok pattern) +- Every function returns typed data — never mutates caller's objects +- Fallible steps return a discriminated union: `{ ok: true; ... } | { ok: false; reason: string }` +- The caller checks `result.ok` — just like Trigger.dev's `triggerAndWait()` result +- Infallible steps return their typed data directly (e.g. `BasePriceMismatch[]`) -- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` +## Script structure +- `dryRun: true` by default. Flip to `false` only when ready to mutate +- All tunables go in the `params` block — never use CLI args or scattered top-of-file constants +- Use `s.step()` for inline phases, `s.run(step, payload)` for named step functions +- Use `s.batch()` for iterating over items — return objects from fn to collect results +- Batch fn receives `{ item, ctx, s }` — use `s.run()` inside batch for sub-steps +- Use `output: "csv"` on batch to auto-write results +- Use `checkpoint: true` on batch for resume across restarts +- Always use `ctx.logger.info()` — never `console.log()` -- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();` +## Large scripts — folder organization -- When referring to a `customer_entitlement` object (or plural `customer_entitlements`), always use the full name. Do not abbreviate to "entitlement" or "entitlements" as this will be confused with the separate `entitlement` object. +For non-trivial work, create a folder under `runs/{org-name}/` and split into three subfolders: -## Error Handling in API Routes -- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes -- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc. -- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared` -- The onError middleware automatically converts these errors to appropriate HTTP responses -- Examples: - ```typescript - // ❌ BAD - Don't do this - if (!org) { - return c.json({ message: "Org not found", code: "not_found" }, 404); - } +``` +runs/mintlify/standardize-stripe/ + standardize-stripe-state.ts # entrypoint (script) + config.ts # constants + orchestrators/ # steps that compose other steps via s.run() + process-customer.ts + process-subscription-group.ts + steps/ # leaf steps (pure business logic, no s) + load-customer-ids.ts + load-exclude-list.ts + load-subscription-groups.ts + guards.ts + check-customer-flags.ts + standardize-base-prices.ts + standardize-usage-prices.ts + update-stripe-subscription.ts + cancel-monthly-companion.ts + utils/ # plain functions (no step(), not logged) + find-or-create-matching-stripe-price.ts + format-subscription-update.ts + format-customer-result.ts +``` - // ✅ GOOD - Validation/expected errors use RecaseError - if (!org) { - throw new RecaseError({ - message: "Org not found", - code: ErrCode.NotFound, - statusCode: 404, - }); - } +**Folder rules:** +- **`orchestrators/`** — takes `{ s, ctx, ... }`, calls `s.run()` to compose steps +- **`steps/`** — takes `{ ctx, ... }`, returns typed data, no `s` +- **`utils/`** — plain functions, no `step()`, no logging +- Root: only the entrypoint and config - // ✅ GOOD - Internal/unexpected errors use InternalError - if (!upstash) { - throw new InternalError({ - message: "Upstash not configured", - code: "upstash_not_configured", - }); - } - ``` +**Entrypoint pattern:** +```typescript +run: async ({ s, ctx }) => { + const customerIds = await s.step({ + id: "load customers", + fn: () => loadCustomerIds({ ctx }), + }); -## Bad example -/ root --> components -|-> hooks -## Good example -/ root --> components --> hooks + await s.batch({ + id: "standardize", + items: customerIds, + output: "csv", + fn: async ({ item: customerId, s: batchS }) => { + return batchS.run(processCustomer, { customerId, priceCache }); + }, + }); +}, +``` -- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +## Export pattern (for Trigger.dev compatibility) +```typescript +const myScript = await script({ ... }); +export default myScript; +if (import.meta.main) await myScript.run(); +``` -- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. +# scripts-v2 API -- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better +## script() +Self-executing entry point. The file IS the execution — hit ctrl+enter to run via `./run.sh`. -# Figma MCP guidance -- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system +```typescript +import { script } from "../lib"; +import { AppEnv } from "@autumn/shared"; -## File Naming -DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal) +const myScript = await script({ + id: "org-name/script-name", + org: "autumn_org_id", + env: AppEnv.Sandbox, + dryRun: true, + loadProducts: true, + description: "What this does", -# Vite -## Components -- Always use v2 components from `@/components/v2/` (buttons, inputs, dialogs, sheets, selects, etc.) for new features. Old components in `@/components/ui/` are deprecated. + params: { + concurrency: 5, + limit: 1 as number | null, + only: null as string[] | null, + }, -## Sheets -- Use `Sheet.tsx` for overlay sheets (modal-style with backdrop). Use `SheetHeader`, `SheetFooter`, `SheetSection` from `SharedSheetComponents.tsx` for consistent styling. -- `InlineSheet.tsx` provides `SheetContainer` for inline sheets (embedded in page layout). It re-exports shared components for backwards compatibility. -- Both sheet types support the same header/footer/section components, ensuring consistent UI patterns across overlay and inline implementations. + run: async ({ s, ctx }) => { + // s = script utilities (step, batch, run, log, data) + // ctx = data and services (extends AutumnContext) + }, +}); -## Styling -- DO NOT hardcode styles when possible. Always try to reuse existing Tailwind classes or component patterns from similar components in the codebase. -- When adding interactive elements (hover, focus, active states), look for existing patterns in similar components and reuse those class combinations. -- Consistency is key - if a pattern exists, use it rather than creating a new one. +export default myScript; +if (import.meta.main) await myScript.run(); +``` -## Form Elements -- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system. +## step() + +Define a named step with typed input/output. Steps declare their own `id` — the caller just provides the business payload. + +```typescript +import { step, type ScriptContext } from "../lib"; + +export const loadCustomerGroups = step({ + id: "load-customer-groups", + run: async ({ ctx, customerId }: { + ctx: ScriptContext; + customerId: string; + }) => { + // business logic + return { fullCustomer, groups }; + }, +}); +``` + +Orchestrator steps (compose sub-steps) take `s` in addition to `ctx`: + +```typescript +export const processCustomer = step({ + id: "process-customer", + run: async ({ s, ctx, customerId }: { + s: ScriptUtils; + ctx: ScriptContext; + customerId: string; + }) => { + const { groups } = await s.run(loadCustomerGroups, { customerId }); + // ... compose more steps + }, +}); +``` + +## s.run() + +Execute a step, auto-injecting `ctx` and `s`. The caller only passes business-specific payload. + +```typescript +// step defined elsewhere with id and typed run function +const result = await s.run(standardizeBasePrices, { group, priceCache }); +// ^^ knows its own id ^^ only business payload +``` + +TypeScript strips `ctx` and `s` from the call-site — you get autocomplete on just the business params and a typed return value. + +## ctx (ScriptContext) + +Extends `AutumnContext` — pass directly to any server function. + +| Field | Type | Description | +|-------|------|-------------| +| `ctx.db` | DrizzleCli | Postgres database client | +| `ctx.stripe` | Stripe | Stripe client (env/Connect-aware) | +| `ctx.org` | Organization | Loaded org object | +| `ctx.env` | AppEnv | Live or Sandbox | +| `ctx.features` | Feature[] | All features for this org | +| `ctx.products` | FullProduct[] | All products (empty if loadProducts: false) | +| `ctx.dryRun` | boolean | Whether this is a dry run | +| `ctx.params` | P | Typed params from your params block | +| `ctx.logger` | Logger | Pino-based logger (info/warn/error/debug/child) | + +## s (ScriptUtils) + +| Field | Type | Description | +|-------|------|-------------| +| `s.step` | function | Inline named section with auto-logging | +| `s.batch` | function | Batch processor with output, checkpoint, buffered logging | +| `s.run` | function | Execute a `step()` with auto-injected ctx/s | +| `s.log` | ScriptLog | Logger with table/json/section formatting | +| `s.data` | ScriptData | Data layer: store, list, write, read | + +## s.step() + +For inline phases (not defined as separate `step()` functions): + +```typescript +const data = await s.step({ + id: "load customers", + fn: async () => CusService.getByOrg({ db: ctx.db, orgId: ctx.org.id, env: ctx.env }), +}); +``` + +## s.batch() + +Batch fn receives `{ item, ctx, s }` — use `s.run()` inside for sub-steps: + +```typescript +await s.batch({ + id: "process-customers", + items: customerIds, + output: "csv", + checkpoint: true, + + fn: async ({ item: customerId, ctx, s }) => { + return s.run(processCustomer, { customerId, priceCache }); + }, + + concurrency: 5, + limit: 10, + onError: "continue", +}); +``` + +### BatchResult + +```typescript +const result = await s.batch({ ... }); +result.processed // number of items successfully processed +result.skipped // number skipped (checkpoint or skipIf) +result.errors // number of errors (when onError: "continue") +result.rows // collected return values from fn +result.duration // total ms +``` + +## s.data (ScriptData) + +Data dir lives at `runs//data/` with four subdirs: +- `inputs/` — curated inputs (git-committed) +- `outputs/` — script results (git-committed) +- `state/` — checkpoints, resume tracking (gitignored) +- `logs/` — append-only audit trail (gitignored) + +```typescript +const finished = s.data.store("state/finished"); +finished.has("cus_123"); +finished.set("cus_123", true); +finished.flush(); + +const results = s.data.list("outputs/audit-results"); +results.push({ customerId: "cus_123", status: "ok" }); + +s.data.write("outputs/report.csv", csvContent); +const config = s.data.read("inputs/exclude-orgs.csv"); +``` + +## params pattern + +All tunables in one typed block. Edit and re-run — no CLI args. + +```typescript +params: { + concurrency: 5, + limit: 1 as number | null, + only: null as string[] | null, +}, +``` + +Batch automatically inherits `concurrency`, `limit`, `only` from params. + +## Running + +```bash +./run.sh runs/org-name/my-script.ts +LOCAL_OVERRIDE=true ./run.sh runs/org-name/my-script.ts +``` diff --git a/ai b/ai new file mode 160000 index 000000000..dd026a392 --- /dev/null +++ b/ai @@ -0,0 +1 @@ +Subproject commit dd026a3929072c1f37a7e6143582fcc6b655382d 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 50c9b6583..068f2e27c 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts @@ -1,8 +1,4 @@ -import { - customerProductHasActiveStatus, - type FullCusProduct, - 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 { expireAndActivateWithTracking } from "../../../common"; @@ -26,11 +22,8 @@ export const expireEndedCustomerProducts = async ({ const expiredCustomerProducts: FullCusProduct[] = []; for (const customerProduct of customerProducts) { - const shouldExpire = - hasCustomerProductEnded(customerProduct, { nowMs }) || - (customerProductHasActiveStatus(customerProduct) && - customerProduct.ended_at != null && - nowMs >= customerProduct.ended_at); + const shouldExpire = hasCustomerProductEnded(customerProduct, { nowMs }); + if (!shouldExpire) continue; logger.info( diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/computeImmediateMultiProductPlan.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/computeImmediateMultiProductPlan.ts index 4d7e0c473..2e731d25c 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/computeImmediateMultiProductPlan.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/computeImmediateMultiProductPlan.ts @@ -8,6 +8,7 @@ import { computeAttachNewCustomerProduct } from "@/internal/billing/v2/actions/a import { computeAttachTransitionUpdates } from "@/internal/billing/v2/actions/attach/compute/computeAttachTransitionUpdates"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; +import { productContextToAttachBillingContext } from "@/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext"; /** Compute the billing plan for immediate multi-product billing. */ export const computeImmediateMultiProductPlan = ({ @@ -28,18 +29,10 @@ export const computeImmediateMultiProductPlan = ({ let transitionContext: AttachBillingContext | undefined; const insertCustomerProducts = billingContext.productContexts.map( (productContext) => { - const attachBillingContext: AttachBillingContext = { - ...billingContext, - attachProduct: productContext.fullProduct, - fullProducts: [productContext.fullProduct], - featureQuantities: productContext.featureQuantities, - customPrices: productContext.customPrices, - customEnts: productContext.customEnts, - currentCustomerProduct: productContext.currentCustomerProduct, - scheduledCustomerProduct: productContext.scheduledCustomerProduct, - planTiming: "immediate", - externalId: productContext.externalId, - }; + const attachBillingContext = productContextToAttachBillingContext({ + billingContext, + productContext, + }); if (productContext.currentCustomerProduct) { transitionContext = attachBillingContext; diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts index 3e0ae6741..53c1b497f 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeCreateSchedulePlan.ts @@ -1,192 +1,70 @@ import type { - AttachBillingContext, AutumnBillingPlan, - CreateScheduleParamsV0, - FullCusProduct, - MultiAttachBillingContext, -} from "@autumn/shared"; -import { - CusProductStatus, - customerProductHasActiveStatus, - isCustomerProductOneOff, + CreateScheduleBillingContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { computeAttachNewCustomerProduct } from "@/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; -import { applyCustomerProductUpdate } from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +import { billingContextToRecurringAndScheduled } from "../utils/billingContextToRecurringAndScheduled"; +import { computeImmediatePhaseCustomerProducts } from "./computeImmediatePhaseCustomerProducts"; +import { computeScheduledCustomerProducts } from "./computeScheduledCustomerProducts"; + +export type SchedulePhasePlan = { + startsAt: number; + customerProductIds: string[]; +}; export type CreateSchedulePlanResult = { autumnBillingPlan: AutumnBillingPlan; - immediatePhaseCustomerProducts: FullCusProduct[]; + phases: SchedulePhasePlan[]; }; -const getExpireCustomerProductUpdate = ({ - customerProduct, - currentEpochMs, -}: { - customerProduct: FullCusProduct; - currentEpochMs: number; -}) => ({ - customerProduct, - updates: { - status: CusProductStatus.Expired, - ended_at: currentEpochMs, - canceled: true, - canceled_at: currentEpochMs, - scheduled_ids: [], - }, -}); - -const planReusesCurrentCustomerProduct = ({ - plan, -}: { - plan: CreateScheduleParamsV0["phases"][number]["plans"][number]; -}) => - plan.customize === undefined && - (plan.feature_quantities === undefined || - plan.feature_quantities.length === 0); - -/** Compute the exact immediate phase for create_schedule. */ +/** Compute the full create_schedule billing plan (immediate + scheduled phases). */ export const computeCreateSchedulePlan = ({ ctx, billingContext, - immediatePhase, - nextPhaseStartsAt, }: { ctx: AutumnContext; - billingContext: MultiAttachBillingContext; - immediatePhase: CreateScheduleParamsV0["phases"][number]; - nextPhaseStartsAt?: number; + billingContext: CreateScheduleBillingContext; }): CreateSchedulePlanResult => { - const currentRecurringCustomerProducts = - billingContext.fullCustomer.customer_products.filter( - (customerProduct) => - customerProductHasActiveStatus(customerProduct) && - !isCustomerProductOneOff(customerProduct), - ); - const scheduledRecurringCustomerProducts = - billingContext.fullCustomer.customer_products.filter( - (customerProduct) => - customerProduct.status === CusProductStatus.Scheduled && - !isCustomerProductOneOff(customerProduct), - ); + const nextPhaseStartsAt = billingContext.futurePhases[0]?.starts_at; + const { + recurringActive: currentRecurringCustomerProducts, + recurringScheduled: existingScheduledCustomerProducts, + } = billingContextToRecurringAndScheduled({ billingContext }); - const handledCurrentProductIds = new Set(); - const insertCustomerProducts: FullCusProduct[] = []; - const updateCustomerProducts: NonNullable< - AutumnBillingPlan["updateCustomerProducts"] - > = []; - const expiredCustomerProducts: FullCusProduct[] = []; - const immediatePhaseCustomerProducts: FullCusProduct[] = []; + const immediate = computeImmediatePhaseCustomerProducts({ + ctx, + billingContext, + currentRecurringCustomerProducts, + nextPhaseStartsAt, + }); - for (const [ - index, - productContext, - ] of billingContext.productContexts.entries()) { - const plan = immediatePhase.plans[index]; - if (!plan) continue; + const scheduled = computeScheduledCustomerProducts({ + ctx, + billingContext, + existingScheduledCustomerProducts, + }); - const sameProductCurrent = currentRecurringCustomerProducts.find( - (customerProduct) => - customerProduct.product.id === productContext.fullProduct.id && - !handledCurrentProductIds.has(customerProduct.id), - ); - - if (sameProductCurrent && planReusesCurrentCustomerProduct({ plan })) { - const updates = { - ended_at: nextPhaseStartsAt ?? null, - canceled: false, - canceled_at: null, - scheduled_ids: [], - }; - - handledCurrentProductIds.add(sameProductCurrent.id); - updateCustomerProducts.push({ - customerProduct: sameProductCurrent, - updates, - }); - immediatePhaseCustomerProducts.push( - applyCustomerProductUpdate({ - customerProduct: sameProductCurrent, - updates, - }), - ); - continue; - } - - const customerProductToReplace = - sameProductCurrent ?? productContext.currentCustomerProduct; - - if ( - customerProductToReplace && - !handledCurrentProductIds.has(customerProductToReplace.id) - ) { - handledCurrentProductIds.add(customerProductToReplace.id); - updateCustomerProducts.push( - getExpireCustomerProductUpdate({ - customerProduct: customerProductToReplace, - currentEpochMs: billingContext.currentEpochMs, - }), - ); - expiredCustomerProducts.push(customerProductToReplace); - } - - const attachBillingContext: AttachBillingContext = { - ...billingContext, - attachProduct: productContext.fullProduct, - fullProducts: [productContext.fullProduct], - featureQuantities: productContext.featureQuantities, - customPrices: productContext.customPrices, - customEnts: productContext.customEnts, - currentCustomerProduct: customerProductToReplace, - scheduledCustomerProduct: productContext.scheduledCustomerProduct, - planTiming: "immediate", - externalId: productContext.externalId, - }; - const newCustomerProduct = computeAttachNewCustomerProduct({ - ctx, - attachBillingContext, - }); - - newCustomerProduct.ended_at = nextPhaseStartsAt ?? null; - newCustomerProduct.scheduled_ids = []; - - insertCustomerProducts.push(newCustomerProduct); - immediatePhaseCustomerProducts.push(newCustomerProduct); - } - - for (const customerProduct of currentRecurringCustomerProducts) { - if (handledCurrentProductIds.has(customerProduct.id)) continue; - - handledCurrentProductIds.add(customerProduct.id); - updateCustomerProducts.push( - getExpireCustomerProductUpdate({ - customerProduct, - currentEpochMs: billingContext.currentEpochMs, - }), - ); - expiredCustomerProducts.push(customerProduct); - } + const allInsertCustomerProducts = [ + ...immediate.insertCustomerProducts, + ...scheduled.insertCustomerProducts, + ]; const { allLineItems, updateCustomerEntitlements } = buildAutumnLineItems({ ctx, - newCustomerProducts: insertCustomerProducts, - deletedCustomerProducts: expiredCustomerProducts, + newCustomerProducts: immediate.insertCustomerProducts, + deletedCustomerProducts: currentRecurringCustomerProducts, billingContext, - includeArrearLineItems: expiredCustomerProducts.length > 0, + includeArrearLineItems: currentRecurringCustomerProducts.length > 0, }); const autumnBillingPlan: AutumnBillingPlan = { customerId: billingContext.fullCustomer.id ?? billingContext.fullCustomer.internal_id, - insertCustomerProducts, - updateCustomerProducts: - updateCustomerProducts.length > 0 ? updateCustomerProducts : undefined, - deleteCustomerProducts: - scheduledRecurringCustomerProducts.length > 0 - ? scheduledRecurringCustomerProducts - : undefined, + insertCustomerProducts: allInsertCustomerProducts, + updateCustomerProducts: immediate.updateCustomerProducts, + deleteCustomerProducts: scheduled.deleteCustomerProducts, customPrices: billingContext.customPrices, customEntitlements: billingContext.customEnts, customFreeTrial: billingContext.trialContext?.customFreeTrial, @@ -201,8 +79,15 @@ export const computeCreateSchedulePlan = ({ autumnBillingPlan, }); + const immediatePhase: SchedulePhasePlan = { + startsAt: billingContext.immediatePhase.starts_at, + customerProductIds: immediate.insertCustomerProducts.map( + (customerProduct) => customerProduct.id, + ), + }; + return { autumnBillingPlan, - immediatePhaseCustomerProducts, + phases: [immediatePhase, ...scheduled.scheduledPhases], }; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeImmediatePhaseCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeImmediatePhaseCustomerProducts.ts new file mode 100644 index 000000000..0917d6401 --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeImmediatePhaseCustomerProducts.ts @@ -0,0 +1,99 @@ +import type { + AutumnBillingPlan, + CreateScheduleBillingContext, + FullCusProduct, +} from "@autumn/shared"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { computeAttachNewCustomerProduct } from "@/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct"; +import { productContextToAttachBillingContext } from "@/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext"; +import { applyScheduleTimingToCustomerProductPlan } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; + +type CustomerProductUpdate = NonNullable< + AutumnBillingPlan["updateCustomerProducts"] +>[number]; + +const expireCurrentRecurringCustomerProducts = ({ + customerProducts, + currentEpochMs, +}: { + customerProducts: FullCusProduct[]; + currentEpochMs: number; +}): CustomerProductUpdate[] => + customerProducts.map((customerProduct) => ({ + customerProduct, + updates: { + status: CusProductStatus.Expired, + ended_at: currentEpochMs, + canceled: true, + canceled_at: currentEpochMs, + scheduled_ids: [], + }, + })); + +const insertImmediateCustomerProducts = ({ + ctx, + billingContext, + expiredCustomerProducts, + nextPhaseStartsAt, +}: { + ctx: AutumnContext; + billingContext: CreateScheduleBillingContext; + expiredCustomerProducts: FullCusProduct[]; + nextPhaseStartsAt: number | undefined; +}): FullCusProduct[] => + billingContext.productContexts.map((productContext) => { + const expiredSameProduct = expiredCustomerProducts.find( + (customerProduct) => + customerProduct.product.id === productContext.fullProduct.id, + ); + + const attachBillingContext = productContextToAttachBillingContext({ + billingContext, + productContext, + currentCustomerProductOverride: expiredSameProduct, + }); + + const newCustomerProduct = computeAttachNewCustomerProduct({ + ctx, + attachBillingContext, + }); + + if (expiredSameProduct) { + newCustomerProduct.starts_at = expiredSameProduct.starts_at; + } + + applyScheduleTimingToCustomerProductPlan({ + result: { insertCustomerProduct: newCustomerProduct }, + endedAt: nextPhaseStartsAt ?? null, + }); + + return newCustomerProduct; + }); + +/** Compute the immediate-phase customer product expirations and insertions. */ +export const computeImmediatePhaseCustomerProducts = ({ + ctx, + billingContext, + currentRecurringCustomerProducts, + nextPhaseStartsAt, +}: { + ctx: AutumnContext; + billingContext: CreateScheduleBillingContext; + currentRecurringCustomerProducts: FullCusProduct[]; + nextPhaseStartsAt: number | undefined; +}) => { + const updateCustomerProducts = expireCurrentRecurringCustomerProducts({ + customerProducts: currentRecurringCustomerProducts, + currentEpochMs: billingContext.currentEpochMs, + }); + + const insertCustomerProducts = insertImmediateCustomerProducts({ + ctx, + billingContext, + expiredCustomerProducts: currentRecurringCustomerProducts, + nextPhaseStartsAt, + }); + + return { insertCustomerProducts, updateCustomerProducts }; +}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts new file mode 100644 index 000000000..31353653e --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts @@ -0,0 +1,85 @@ +import type { + CreateScheduleBillingContext, + FullCusProduct, + ScheduledPhaseContext, +} from "@autumn/shared"; +import { BillingVersion, CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; + +const initScheduledCustomerProduct = ({ + ctx, + billingContext, + phaseContext, + productContext, +}: { + ctx: AutumnContext; + billingContext: CreateScheduleBillingContext; + phaseContext: ScheduledPhaseContext; + productContext: ScheduledPhaseContext["productContexts"][number]; +}): FullCusProduct => { + return initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer: billingContext.fullCustomer, + fullProduct: productContext.fullProduct, + featureQuantities: productContext.featureQuantities, + resetCycleAnchor: phaseContext.startsAt, + freeTrial: null, + now: billingContext.currentEpochMs, + billingVersion: BillingVersion.V2, + }, + initOptions: { + startsAt: phaseContext.startsAt, + endedAt: phaseContext.endsAt, + status: CusProductStatus.Scheduled, + }, + }); +}; + +/** Build scheduled customer products to insert and existing ones to delete. */ +export const computeScheduledCustomerProducts = ({ + ctx, + billingContext, + existingScheduledCustomerProducts, +}: { + ctx: AutumnContext; + billingContext: CreateScheduleBillingContext; + existingScheduledCustomerProducts: FullCusProduct[]; +}) => { + const insertCustomerProducts: FullCusProduct[] = []; + const customPrices = []; + const customEntitlements = []; + const scheduledPhases: { startsAt: number; customerProductIds: string[] }[] = + []; + + for (const phaseContext of billingContext.scheduledPhaseContexts) { + const phaseCustomerProductIds: string[] = []; + + for (const productContext of phaseContext.productContexts) { + const customerProduct = initScheduledCustomerProduct({ + ctx, + billingContext, + phaseContext, + productContext, + }); + insertCustomerProducts.push(customerProduct); + phaseCustomerProductIds.push(customerProduct.id); + customPrices.push(...productContext.customPrices); + customEntitlements.push(...productContext.customEntitlements); + } + + scheduledPhases.push({ + startsAt: phaseContext.startsAt, + customerProductIds: phaseCustomerProductIds, + }); + } + + return { + insertCustomerProducts, + deleteCustomerProducts: existingScheduledCustomerProducts, + customPrices, + customEntitlements, + scheduledPhases, + }; +}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts index 787c937ac..811a897b0 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts @@ -1,22 +1,15 @@ -import { - type CreateScheduleParamsV0, - type CreateScheduleResponse, - RecaseError, +import type { + CreateScheduleParamsV0, + CreateScheduleResponse, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse"; -import { buildCreateScheduleExecutionPlan } from "./compute/buildCreateScheduleExecutionPlan"; import { computeCreateSchedulePlan } from "./compute/computeCreateSchedulePlan"; -import { - getCurrentCreateSchedulePhaseIndex, - normalizeCreateSchedulePhases, -} from "./errors/normalizeCreateSchedulePhases"; +import { handleCreateScheduleErrors } from "./errors/handleCreateScheduleErrors"; import { setupCreateScheduleBillingContext } from "./setup/setupCreateScheduleBillingContext"; -import { materializeScheduledPhases } from "./utils/materializeScheduledPhases"; import { persistCreateSchedule } from "./utils/persistCreateSchedule"; -import { resolveCurrentEpochMs } from "./utils/resolveCurrentEpochMs"; /** Create a schedule with immediate-phase billing and Autumn-managed future phases. */ export const createSchedule = async ({ @@ -26,73 +19,27 @@ export const createSchedule = async ({ ctx: AutumnContext; params: CreateScheduleParamsV0; }): Promise => { - const currentEpochMs = await resolveCurrentEpochMs({ - ctx, - customerId: params.customer_id, - }); - const normalizedPhases = normalizeCreateSchedulePhases({ - currentEpochMs, - phases: params.phases, - }); - const currentPhaseIndex = getCurrentCreateSchedulePhaseIndex({ - currentEpochMs, - phases: normalizedPhases, - }); - const immediatePhaseIndex = currentPhaseIndex === -1 ? 0 : currentPhaseIndex; - const immediatePhase = normalizedPhases[immediatePhaseIndex]; - const futurePhases = normalizedPhases.slice(immediatePhaseIndex + 1); - - if (!immediatePhase) { - throw new RecaseError({ - message: "At least one phase must be provided", - statusCode: 400, - }); - } - const billingContext = await setupCreateScheduleBillingContext({ ctx, params, - immediatePhase, }); - if (billingContext.checkoutMode) { - throw new RecaseError({ - message: "Please attach a payment method before creating a schedule.", - statusCode: 400, - }); - } + handleCreateScheduleErrors({ billingContext }); - const { - autumnBillingPlan: immediateAutumnBillingPlan, - immediatePhaseCustomerProducts, - } = computeCreateSchedulePlan({ + const { autumnBillingPlan, phases } = computeCreateSchedulePlan({ ctx, billingContext, - immediatePhase, - nextPhaseStartsAt: futurePhases[0]?.starts_at, - }); - const immediatePhaseCustomerProductIds = immediatePhaseCustomerProducts.map( - (customerProduct) => customerProduct.id, - ); - const futureScheduledPhases = await materializeScheduledPhases({ - ctx, - currentEpochMs, - fullCustomer: billingContext.fullCustomer, - phases: futurePhases, - }); - const autumnExecutionPlan = buildCreateScheduleExecutionPlan({ - immediateAutumnBillingPlan, - futureScheduledPhases, }); + const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, billingContext, - autumnBillingPlan: autumnExecutionPlan, + autumnBillingPlan, checkoutMode: billingContext.checkoutMode, }); const billingPlan = { - autumn: autumnExecutionPlan, + autumn: autumnBillingPlan, stripe: stripeBillingPlan, }; const billingResult = await executeBillingPlan({ @@ -104,12 +51,9 @@ export const createSchedule = async ({ const { insertedPhases, scheduleId } = await persistCreateSchedule({ ctx, params, - currentEpochMs, + currentEpochMs: billingContext.currentEpochMs, fullCustomer: billingContext.fullCustomer, - preservePastPhasesBefore: immediatePhase.starts_at, - immediatePhaseStartsAt: immediatePhase.starts_at, - immediatePhaseCustomerProductIds, - futureScheduledPhases, + phases, }); const billingResponse = billingResultToResponse({ diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts new file mode 100644 index 000000000..13d3c11d6 --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts @@ -0,0 +1,36 @@ +import { + type CreateScheduleBillingContext, + ms, + RecaseError, +} from "@autumn/shared"; + +const FIRST_PHASE_TOLERANCE_MS = ms.minutes(1); + +export const handleCreateScheduleErrors = ({ + billingContext, + isPreview = false, +}: { + billingContext: CreateScheduleBillingContext; + isPreview?: boolean; +}) => { + const { currentEpochMs, immediatePhase } = billingContext; + + if ( + immediatePhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS || + immediatePhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS + ) { + throw new RecaseError({ + message: "The first phase must start immediately", + statusCode: 400, + }); + } + + if (!billingContext.checkoutMode) return; + + throw new RecaseError({ + message: isPreview + ? "Please attach a payment method before creating a schedule." + : "create_schedule requires an immediately billable first phase; checkout flows are not supported yet", + statusCode: 400, + }); +}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/normalizeCreateSchedulePhases.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/normalizeCreateSchedulePhases.ts index f88824579..d4ca758b2 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/errors/normalizeCreateSchedulePhases.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/normalizeCreateSchedulePhases.ts @@ -1,76 +1,15 @@ -import { type CreateScheduleParamsV0, ms, RecaseError } from "@autumn/shared"; +import type { CreateScheduleParamsV0 } from "@autumn/shared"; -const FIRST_PHASE_TOLERANCE_MS = ms.minutes(1); type CreateSchedulePhase = CreateScheduleParamsV0["phases"][number]; -export const getCurrentCreateSchedulePhaseIndex = ({ - currentEpochMs, - phases, -}: { - currentEpochMs: number; - phases: CreateScheduleParamsV0["phases"]; -}) => { - for (let i = phases.length - 1; i >= 0; i--) { - if ((phases[i]?.starts_at ?? Number.POSITIVE_INFINITY) <= currentEpochMs) { - return i; - } - } - - return -1; -}; - -/** Sort phases and enforce the supported create_schedule shape. */ +/** Sort phases for downstream create_schedule setup and execution. */ export const normalizeCreateSchedulePhases = ({ - currentEpochMs, phases, }: { - currentEpochMs: number; phases: CreateScheduleParamsV0["phases"]; }): [CreateSchedulePhase, ...CreateSchedulePhase[]] => { - const sortedPhases = [...phases].sort((a, b) => a.starts_at - b.starts_at); - const [firstPhase] = sortedPhases; - - if (!firstPhase) { - throw new RecaseError({ - message: "At least one phase must be provided", - statusCode: 400, - }); - } - - for (const phase of sortedPhases) { - if (phase.plans.length === 0) { - throw new RecaseError({ - message: "Each phase must include at least one plan", - statusCode: 400, - }); - } - } - - for (let i = 1; i < sortedPhases.length; i++) { - const previousPhase = sortedPhases[i - 1]; - const currentPhase = sortedPhases[i]; - - if (previousPhase && currentPhase?.starts_at <= previousPhase.starts_at) { - throw new RecaseError({ - message: "Phase starts_at values must be strictly increasing", - statusCode: 400, - }); - } - } - - if ( - getCurrentCreateSchedulePhaseIndex({ - currentEpochMs, - phases: sortedPhases, - }) === -1 && - (firstPhase.starts_at < currentEpochMs - FIRST_PHASE_TOLERANCE_MS || - firstPhase.starts_at > currentEpochMs + FIRST_PHASE_TOLERANCE_MS) - ) { - throw new RecaseError({ - message: "The first phase must start immediately", - statusCode: 400, - }); - } - - return sortedPhases as [CreateSchedulePhase, ...CreateSchedulePhase[]]; + return [...phases].sort((a, b) => a.starts_at - b.starts_at) as [ + CreateSchedulePhase, + ...CreateSchedulePhase[], + ]; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index e8336e538..2ff85eef6 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -1,15 +1,13 @@ -import type { BillingPreviewResponse } from "@autumn/shared"; -import { type CreateScheduleParamsV0, RecaseError } from "@autumn/shared"; +import type { + BillingPreviewResponse, + CreateScheduleParamsV0, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { computeCreateSchedulePlan } from "./compute/computeCreateSchedulePlan"; -import { - getCurrentCreateSchedulePhaseIndex, - normalizeCreateSchedulePhases, -} from "./errors/normalizeCreateSchedulePhases"; +import { handleCreateScheduleErrors } from "./errors/handleCreateScheduleErrors"; import { setupCreateScheduleBillingContext } from "./setup/setupCreateScheduleBillingContext"; -import { resolveCurrentEpochMs } from "./utils/resolveCurrentEpochMs"; /** Preview the immediate-phase billing cost for a create_schedule call. */ export const previewCreateSchedule = async ({ @@ -19,45 +17,19 @@ export const previewCreateSchedule = async ({ ctx: AutumnContext; params: CreateScheduleParamsV0; }): Promise => { - const currentEpochMs = await resolveCurrentEpochMs({ - ctx, - customerId: params.customer_id, - }); - const normalizedPhases = normalizeCreateSchedulePhases({ - currentEpochMs, - phases: params.phases, - }); - const currentPhaseIndex = getCurrentCreateSchedulePhaseIndex({ - currentEpochMs, - phases: normalizedPhases, - }); - const immediatePhase = - normalizedPhases[currentPhaseIndex === -1 ? 0 : currentPhaseIndex]; - - if (!immediatePhase) { - throw new RecaseError({ - message: "At least one phase must be provided", - statusCode: 400, - }); - } - const billingContext = await setupCreateScheduleBillingContext({ ctx, params, - immediatePhase, }); - if (billingContext.checkoutMode) { - throw new RecaseError({ - message: "Please attach a payment method before creating a schedule.", - statusCode: 400, - }); - } + handleCreateScheduleErrors({ + billingContext, + isPreview: true, + }); const { autumnBillingPlan } = computeCreateSchedulePlan({ ctx, billingContext, - immediatePhase, }); const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index c7449a316..0a8c6864d 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -1,22 +1,27 @@ import type { + CreateScheduleBillingContext, CreateScheduleParamsV0, - MultiAttachBillingContext, MultiAttachParamsV0, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupImmediateMultiProductBillingContext } from "../../common/immediateMultiProduct/setupImmediateMultiProductBillingContext"; +import { normalizeCreateSchedulePhases } from "../errors/normalizeCreateSchedulePhases"; import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans"; +import { setupScheduledProductsContext } from "./setupScheduledProductsContext"; /** Build billing context for the immediate phase. */ export const setupCreateScheduleBillingContext = async ({ ctx, params, - immediatePhase, }: { ctx: AutumnContext; params: CreateScheduleParamsV0; - immediatePhase: CreateScheduleParamsV0["phases"][number]; -}): Promise => { +}): Promise => { + const normalizedPhases = normalizeCreateSchedulePhases({ + phases: params.phases, + }); + const [immediatePhase, ...futurePhases] = normalizedPhases; + const immediateParams = { customer_id: params.customer_id, entity_id: params.entity_id, @@ -38,5 +43,38 @@ export const setupCreateScheduleBillingContext = async ({ fullProducts: billingContext.fullProducts, }); - return billingContext; + const scheduledPhaseContexts = await setupScheduledProductsContext({ + ctx, + phases: futurePhases, + }); + + const scheduledCustomPrices = scheduledPhaseContexts.flatMap((phase) => + phase.productContexts.flatMap( + (productContext) => productContext.customPrices, + ), + ); + const scheduledCustomEntitlements = scheduledPhaseContexts.flatMap((phase) => + phase.productContexts.flatMap( + (productContext) => productContext.customEntitlements, + ), + ); + + return { + ...billingContext, + customPrices: [ + ...(billingContext.customPrices ?? []), + ...scheduledCustomPrices, + ], // combine custom prices from immediate and scheduled phases + customEnts: [ + ...(billingContext.customEnts ?? []), + ...scheduledCustomEntitlements, + ], // combine custom prices and entitlements from immediate and scheduled phases + isCustom: + billingContext.isCustom || + scheduledCustomPrices.length > 0 || + scheduledCustomEntitlements.length > 0, + immediatePhase, + futurePhases, + scheduledPhaseContexts, + }; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts new file mode 100644 index 000000000..3eb4027c3 --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts @@ -0,0 +1,63 @@ +import type { + CreateScheduleParamsV0, + ScheduledPhaseContext, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; +import { setupAttachProductContext } from "../../attach/setup/setupAttachProductContext"; +import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedulePhasePlans"; + +/** Resolve product + feature quantity context for each plan in each scheduled phase. */ +export const setupScheduledProductsContext = async ({ + ctx, + phases, +}: { + ctx: AutumnContext; + phases: CreateScheduleParamsV0["phases"][number][]; +}): Promise => + Promise.all( + phases.map(async (phase, index) => { + const nextPhaseStartsAt = phases[index + 1]?.starts_at; + + const productContexts = await Promise.all( + phase.plans.map(async (plan) => { + const { + fullProduct, + customPrices = [], + customEnts: customEntitlements = [], + } = await setupAttachProductContext({ + ctx, + params: plan, + }); + + const featureQuantities = setupFeatureQuantitiesContext({ + ctx, + featureQuantitiesParams: { + feature_quantities: plan.feature_quantities, + }, + fullProduct, + initializeUndefinedQuantities: true, + }); + + return { + fullProduct, + customPrices, + customEntitlements, + featureQuantities, + }; + }), + ); + + validateCreateSchedulePhasePlans({ + fullProducts: productContexts.map( + (productContext) => productContext.fullProduct, + ), + }); + + return { + startsAt: phase.starts_at, + endsAt: nextPhaseStartsAt, + productContexts, + }; + }), + ); diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/billingContextToRecurringAndScheduled.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/billingContextToRecurringAndScheduled.ts new file mode 100644 index 000000000..08305b6f2 --- /dev/null +++ b/server/src/internal/billing/v2/actions/createSchedule/utils/billingContextToRecurringAndScheduled.ts @@ -0,0 +1,36 @@ +import type { CreateScheduleBillingContext, FullCusProduct } from "@autumn/shared"; +import { + CusProductStatus, + customerProductHasActiveStatus, + isCusProductOnEntity, + isCustomerProductOneOff, +} from "@autumn/shared"; + +/** Split the billing context's customer products into recurring-active and recurring-scheduled, scoped to the schedule's entity level. */ +export const billingContextToRecurringAndScheduled = ({ + billingContext, +}: { + billingContext: CreateScheduleBillingContext; +}): { + recurringActive: FullCusProduct[]; + recurringScheduled: FullCusProduct[]; +} => { + const internalEntityId = + billingContext.fullCustomer.entity?.internal_id; + const recurringActive: FullCusProduct[] = []; + const recurringScheduled: FullCusProduct[] = []; + + for (const customerProduct of billingContext.fullCustomer.customer_products) { + if (isCustomerProductOneOff(customerProduct)) continue; + if (!isCusProductOnEntity({ cusProduct: customerProduct, internalEntityId })) + continue; + + if (customerProductHasActiveStatus(customerProduct)) { + recurringActive.push(customerProduct); + } else if (customerProduct.status === CusProductStatus.Scheduled) { + recurringScheduled.push(customerProduct); + } + } + + return { recurringActive, recurringScheduled }; +}; diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts index cd8e3c323..3d38fe288 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/utils/materializeScheduledPhases.ts @@ -32,7 +32,7 @@ export const materializeScheduledPhases = async ({ ctx: AutumnContext; currentEpochMs: number; fullCustomer: FullCustomer; - phases: CreateScheduleParamsV0["phases"]; + phases: CreateScheduleParamsV0["phases"][number][]; }): Promise => { return await Promise.all( phases.map(async (phase, index) => { diff --git a/server/src/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule.ts index 3d3a0ea9c..701934513 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule.ts @@ -6,11 +6,10 @@ import { schedulePhases, schedules, } from "@autumn/shared"; -import { and, eq, inArray, isNull, lt } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { generateId } from "@/utils/genUtils"; -import type { MaterializedScheduledPhase } from "./materializeScheduledPhases"; const getExistingScheduleState = async ({ ctx, @@ -81,93 +80,23 @@ const deleteExistingSchedules = async ({ await ctx.db.delete(schedules).where(inArray(schedules.id, scheduleIds)); }; -const loadPreservedPastPhases = async ({ - ctx, - internalCustomerId, - internalEntityId, - preservePastPhasesBefore, -}: { - ctx: AutumnContext; - internalCustomerId: string; - internalEntityId?: string; - preservePastPhasesBefore: number; -}) => { - const existingSchedules = await ctx.db - .select({ id: schedules.id }) - .from(schedules) - .where( - and( - eq(schedules.internal_customer_id, internalCustomerId), - internalEntityId - ? eq(schedules.internal_entity_id, internalEntityId) - : isNull(schedules.internal_entity_id), - ), - ); - - if (existingSchedules.length === 0) return []; - - const existingPhases = await ctx.db - .select({ - starts_at: schedulePhases.starts_at, - customer_product_ids: schedulePhases.customer_product_ids, - }) - .from(schedulePhases) - .where( - and( - inArray( - schedulePhases.schedule_id, - existingSchedules.map((schedule) => schedule.id), - ), - lt(schedulePhases.starts_at, preservePastPhasesBefore), - ), - ); - - const dedupedPhases = new Map(); - - for (const phase of existingPhases) { - if (phase.starts_at >= preservePastPhasesBefore) continue; - if (dedupedPhases.has(phase.starts_at)) continue; - dedupedPhases.set(phase.starts_at, phase.customer_product_ids); - } - - return [...dedupedPhases.entries()] - .sort(([startsAtA], [startsAtB]) => startsAtA - startsAtB) - .map(([starts_at, customer_product_ids]) => ({ - phase_id: generateId("phase"), - starts_at, - customer_product_ids, - })); -}; - /** Persist the schedule rows and scheduled customer products. */ export const persistCreateSchedule = async ({ ctx, params, currentEpochMs, fullCustomer, - preservePastPhasesBefore, - immediatePhaseStartsAt, - immediatePhaseCustomerProductIds, - futureScheduledPhases, + phases, }: { ctx: AutumnContext; params: CreateScheduleParamsV0; currentEpochMs: number; fullCustomer: FullCustomer; - preservePastPhasesBefore: number; - immediatePhaseStartsAt: number; - immediatePhaseCustomerProductIds: string[]; - futureScheduledPhases: MaterializedScheduledPhase[]; + phases: { startsAt: number; customerProductIds: string[] }[]; }) => { return await ctx.db.transaction(async (tx) => { const txDb = tx as unknown as DrizzleCli; const txCtx = { ...ctx, db: txDb }; - const preservedPastPhases = await loadPreservedPastPhases({ - ctx: txCtx, - internalCustomerId: fullCustomer.internal_id, - internalEntityId: fullCustomer.entity?.internal_id ?? undefined, - preservePastPhasesBefore, - }); const existingScheduleState = await getExistingScheduleState({ ctx: txCtx, @@ -175,6 +104,11 @@ export const persistCreateSchedule = async ({ internalEntityId: fullCustomer.entity?.internal_id ?? undefined, }); + await deleteExistingSchedules({ + ctx: txCtx, + ...existingScheduleState, + }); + const scheduleId = generateId("sched"); await txDb.insert(schedules).values({ id: scheduleId, @@ -187,21 +121,11 @@ export const persistCreateSchedule = async ({ created_at: currentEpochMs, }); - const insertedPhases = [ - ...preservedPastPhases, - { - phase_id: generateId("phase"), - starts_at: immediatePhaseStartsAt, - customer_product_ids: immediatePhaseCustomerProductIds, - }, - ...futureScheduledPhases.map((phase) => ({ - phase_id: generateId("phase"), - starts_at: phase.starts_at, - customer_product_ids: phase.customerProducts.map( - (customerProduct) => customerProduct.id, - ), - })), - ]; + const insertedPhases = phases.map((phase) => ({ + phase_id: generateId("phase"), + starts_at: phase.startsAt, + customer_product_ids: phase.customerProductIds, + })); await txDb.insert(schedulePhases).values( insertedPhases.map((phase) => ({ @@ -213,11 +137,6 @@ export const persistCreateSchedule = async ({ })), ); - await deleteExistingSchedules({ - ctx: txCtx, - ...existingScheduleState, - }); - return { scheduleId, insertedPhases, diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts index c81290411..f93ba10af 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts @@ -12,7 +12,7 @@ import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingCont import { getDeleteCustomerProducts, getUpdateCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems"; const formatLineItem = (item: LineItem) => ({ diff --git a/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts b/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts index 549fed869..259b59d9c 100644 --- a/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan.ts @@ -3,7 +3,7 @@ import type { AutumnBillingPlan, StripeBillingPlan, } from "@autumn/shared"; -import { getUpdateCustomerProducts } from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +import { getUpdateCustomerProducts } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; export const addStripeSubscriptionScheduleIdToBillingPlan = ({ autumnBillingPlan, diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index f0820e92e..a864b3819 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -5,7 +5,7 @@ import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutum import { getDeleteCustomerProducts, getUpdateCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements"; import { customerProductActions } from "@/internal/customers/cusProducts/actions"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts index 5ff14fe14..2f594a078 100644 --- a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -3,7 +3,7 @@ import { applyCustomerProductUpdate, getDeleteCustomerProducts, getUpdateCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; export const autumnBillingPlanToFinalFullCustomer = ({ billingContext, diff --git a/server/src/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext.ts b/server/src/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext.ts new file mode 100644 index 000000000..de4daf8d4 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/productContextToAttachBillingContext.ts @@ -0,0 +1,28 @@ +import type { + AttachBillingContext, + MultiAttachBillingContext, + MultiAttachProductContext, +} from "@autumn/shared"; + +/** Build an AttachBillingContext from a multi-product billing context and a single product context. */ +export const productContextToAttachBillingContext = ({ + billingContext, + productContext, + currentCustomerProductOverride, +}: { + billingContext: MultiAttachBillingContext; + productContext: MultiAttachProductContext; + currentCustomerProductOverride?: AttachBillingContext["currentCustomerProduct"]; +}): AttachBillingContext => ({ + ...billingContext, + attachProduct: productContext.fullProduct, + fullProducts: [productContext.fullProduct], + featureQuantities: productContext.featureQuantities, + customPrices: productContext.customPrices, + customEnts: productContext.customEnts, + currentCustomerProduct: + currentCustomerProductOverride ?? productContext.currentCustomerProduct, + scheduledCustomerProduct: productContext.scheduledCustomerProduct, + planTiming: "immediate", + externalId: productContext.externalId, +}); diff --git a/server/src/internal/billing/v2/utils/billingPlan/customerProductMutations.ts b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts similarity index 64% rename from server/src/internal/billing/v2/utils/billingPlan/customerProductMutations.ts rename to server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts index 1d93b7264..5db6e131b 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/customerProductMutations.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.ts @@ -37,6 +37,30 @@ export const applyCustomerProductUpdate = ({ canceled: updates.canceled ?? customerProduct.canceled, }); +type CustomerProductUpdate = NonNullable< + AutumnBillingPlan["updateCustomerProducts"] +>[number]; + +/** Apply schedule phase end timing to a customer product plan result. */ +export const applyScheduleTimingToCustomerProductPlan = ({ + result, + endedAt, +}: { + result: { + insertCustomerProduct?: FullCusProduct; + updateCustomerProduct?: CustomerProductUpdate; + }; + endedAt: number | null; +}) => { + if (result.insertCustomerProduct) { + result.insertCustomerProduct.ended_at = endedAt; + result.insertCustomerProduct.scheduled_ids = []; + } else if (result.updateCustomerProduct) { + result.updateCustomerProduct.updates.ended_at = endedAt; + result.updateCustomerProduct.updates.scheduled_ids = []; + } +}; + export const getExpiredUpdatedCustomerProducts = ({ autumnBillingPlan, }: { diff --git a/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts b/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts index 99f82907f..54caab9ff 100644 --- a/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts +++ b/server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts @@ -22,7 +22,7 @@ import type { CreateCustomerContext } from "@/internal/customers/actions/createW import { getExpiredUpdatedCustomerProducts, getUpdateCustomerProducts, -} from "@/internal/billing/v2/utils/billingPlan/customerProductMutations"; +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { workflows } from "@/queue/workflows.js"; // ============================================================================ diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 53ed51d8e..dcf55f502 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -2,33 +2,13 @@ import type { TestGroup } from "./types"; export const temp: TestGroup = { name: "temp", - description: "Billing cycle anchor tests (attach + update subscription)", + description: "Create schedule unit tests", tier: "domain", paths: [ - // Attach — reset ("now") - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-reset.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-reset-entities.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-no-carry-over.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts", - - // Attach — scheduled (all tests skipped — scheduled anchor not yet supported) - // "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-schedule.test.ts", - // "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-schedule-entities.test.ts", - - // Attach — new plan (tests 1 & 2 skipped, test 3 "now" is active) - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-new-plan.test.ts", - "integration/billing/attach/params/billing-cycle-anchor/billing-cycle-anchor-new-plan-entities.test.ts", - - // Attach — line items & errors - "integration/billing/attach/invoice-line-items/billing-cycle-anchor-reset-line-items.test.ts", - "integration/billing/attach/errors/attach-billing-cycle-anchor-errors.test.ts", - - // Update subscription — reset ("now") - "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-no-partial-refund.test.ts", - "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-errors.test.ts", - "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-with-changes.test.ts", - - // Update subscription — anchor drift - "integration/billing/update-subscription/custom-plan/update-free-to-free-anchor-drift.test.ts", + "unit/billing/create-schedule/create-schedule-params.spec.ts", + "unit/billing/create-schedule/compute-create-schedule-plan.spec.ts", + "unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts", + "unit/billing/create-schedule/validate-create-schedule-phase-plans.spec.ts", + "integration/billing/create-schedule/create-schedule-basic.test.ts", ], }; diff --git a/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts b/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts index aa759ce17..36237a19d 100644 --- a/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts +++ b/server/tests/unit/billing/create-schedule/compute-create-schedule-plan.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { BillingVersion, + type CreateScheduleBillingContext, CusProductStatus, - type MultiAttachBillingContext, } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; @@ -13,23 +13,24 @@ import { computeCreateSchedulePlan } from "@/internal/billing/v2/actions/createS const createBillingContext = ({ productContexts, + immediatePhase, + futurePhases = [], currentEpochMs = Date.now(), -}: Pick & { +}: Pick & { + futurePhases?: CreateScheduleBillingContext["futurePhases"]; currentEpochMs?: number; -}): MultiAttachBillingContext => { +}): CreateScheduleBillingContext => { const fullProducts = productContexts.map( (productContext) => productContext.fullProduct, ); - const currentCustomerProducts = productContexts.flatMap((productContext) => - [ - ...(productContext.currentCustomerProduct - ? [productContext.currentCustomerProduct] - : []), - ...(productContext.scheduledCustomerProduct - ? [productContext.scheduledCustomerProduct] - : []), - ], - ); + const currentCustomerProducts = productContexts.flatMap((productContext) => [ + ...(productContext.currentCustomerProduct + ? [productContext.currentCustomerProduct] + : []), + ...(productContext.scheduledCustomerProduct + ? [productContext.scheduledCustomerProduct] + : []), + ]); return { ...contexts.createBilling({ @@ -45,6 +46,9 @@ const createBillingContext = ({ customEnts: [], isCustom: false, billingVersion: BillingVersion.V2, + immediatePhase, + futurePhases, + scheduledPhaseContexts: [], }; }; @@ -76,15 +80,15 @@ describe(chalk.yellowBright("computeCreateSchedulePlan"), () => { featureQuantities: [], }, ], + immediatePhase: { + starts_at: Date.now(), + plans: [{ plan_id: baseProduct.id }, { plan_id: addonProduct.id }], + }, }); const result = computeCreateSchedulePlan({ ctx, billingContext, - immediatePhase: { - starts_at: Date.now(), - plans: [{ plan_id: baseProduct.id }, { plan_id: addonProduct.id }], - }, }); expect(result.autumnBillingPlan.insertCustomerProducts).toHaveLength(2); @@ -147,15 +151,15 @@ describe(chalk.yellowBright("computeCreateSchedulePlan"), () => { scheduledCustomerProduct, }, ], + immediatePhase: { + starts_at: currentEpochMs, + plans: [{ plan_id: newProduct.id }], + }, }); const result = computeCreateSchedulePlan({ ctx, billingContext, - immediatePhase: { - starts_at: currentEpochMs, - plans: [{ plan_id: newProduct.id }], - }, }); expect(result.autumnBillingPlan.insertCustomerProducts).toHaveLength(1); diff --git a/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts index 608037b23..f6fe3d9ff 100644 --- a/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts +++ b/server/tests/unit/billing/create-schedule/create-schedule-params.spec.ts @@ -72,4 +72,31 @@ describe(chalk.yellowBright("CreateScheduleParamsV0Schema"), () => { }), ).toThrow("subscription_id is not supported for create_schedule"); }); + + test("rejects empty phases", () => { + expect(() => + CreateScheduleParamsV0Schema.parse({ + customer_id: "cus_123", + phases: [], + }), + ).toThrow(); + }); + + test("rejects duplicate phase starts_at values", () => { + expect(() => + CreateScheduleParamsV0Schema.parse({ + customer_id: "cus_123", + phases: [ + { + starts_at: 1_000, + plans: [{ plan_id: "base" }], + }, + { + starts_at: 1_000, + plans: [{ plan_id: "pro" }], + }, + ], + }), + ).toThrow("Phase starts_at values must be strictly increasing"); + }); }); diff --git a/server/tests/unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts b/server/tests/unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts index 50d798aa5..3e7ded11f 100644 --- a/server/tests/unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts +++ b/server/tests/unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts @@ -1,77 +1,27 @@ import { describe, expect, test } from "bun:test"; -import { ms } from "@autumn/shared"; +import type { CreateScheduleParamsV0 } from "@autumn/shared"; import chalk from "chalk"; import { normalizeCreateSchedulePhases } from "@/internal/billing/v2/actions/createSchedule/errors/normalizeCreateSchedulePhases"; describe(chalk.yellowBright("normalizeCreateSchedulePhases"), () => { - describe(chalk.cyan("sorting and acceptance"), () => { - test("sorts phases by starts_at when the first effective phase starts now", () => { - const currentEpochMs = Date.now(); - const phases = [ - { - starts_at: currentEpochMs + ms.days(30), - plans: [{ plan_id: "pro" }], - }, - { - starts_at: currentEpochMs, - plans: [{ plan_id: "base" }], - }, - ]; + test("sorts phases by starts_at", () => { + const phases = [ + { + starts_at: 2_592_001_000, + plans: [{ plan_id: "pro" }], + }, + { + starts_at: 1_000, + plans: [{ plan_id: "base" }], + }, + ] as CreateScheduleParamsV0["phases"]; - const result = normalizeCreateSchedulePhases({ - currentEpochMs, - phases, - }); - - expect(result.map((phase) => phase.starts_at)).toEqual([ - currentEpochMs, - currentEpochMs + ms.days(30), - ]); + const result = normalizeCreateSchedulePhases({ + phases, }); - test("accepts historical phases before the current effective phase", () => { - const currentEpochMs = 1_000_000; - const phases = [ - { - starts_at: currentEpochMs - ms.days(30), - plans: [{ plan_id: "old" }], - }, - { - starts_at: currentEpochMs - ms.days(15), - plans: [{ plan_id: "current" }], - }, - { - starts_at: currentEpochMs + ms.days(15), - plans: [{ plan_id: "future" }], - }, - ]; - - const result = normalizeCreateSchedulePhases({ - currentEpochMs, - phases, - }); - - expect(result.map((phase) => phase.starts_at)).toEqual([ - currentEpochMs - ms.days(30), - currentEpochMs - ms.days(15), - currentEpochMs + ms.days(15), - ]); - }); - }); - - describe(chalk.cyan("validation errors"), () => { - test("rejects a single phase that starts in the future", () => { - expect(() => - normalizeCreateSchedulePhases({ - currentEpochMs: 1_000_000, - phases: [ - { - starts_at: 1_000_000 + ms.minutes(2), - plans: [{ plan_id: "pro" }], - }, - ], - }), - ).toThrow("The first phase must start immediately"); - }); + expect(result.map((phase) => phase.starts_at)).toEqual([ + 1_000, 2_592_001_000, + ]); }); }); diff --git a/server/tests/unit/billing/update-subscription/billing-plan-send-products-updated.spec.ts b/server/tests/unit/billing/update-subscription/billing-plan-send-products-updated.spec.ts index 16b3b84c0..ec54ed71b 100644 --- a/server/tests/unit/billing/update-subscription/billing-plan-send-products-updated.spec.ts +++ b/server/tests/unit/billing/update-subscription/billing-plan-send-products-updated.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { AttachScenario, CusProductStatus } from "@autumn/shared"; +import { AttachScenario, CusProductStatus, type Price } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import { prices } from "@tests/utils/fixtures/db/prices"; @@ -151,13 +151,7 @@ describe(chalk.yellowBright("billingPlanToSendProductsUpdated"), () => { customerPrices: [ prices.createCustomer({ customerProductId: "cus_prod_expired_1", - price: { - ...prices.createFixed({ id: "price_old_1" }), - config: { - ...prices.createFixed({ id: "price_old_1" }).config, - amount: 100, - }, - }, + price: prices.createFixed({ id: "price_old_1" }), }), ], }), @@ -170,13 +164,13 @@ describe(chalk.yellowBright("billingPlanToSendProductsUpdated"), () => { customerPrices: [ prices.createCustomer({ customerProductId: "cus_prod_expired_2", - price: { - ...prices.createFixed({ id: "price_old_2" }), - config: { - ...prices.createFixed({ id: "price_old_2" }).config, - amount: 300, - }, - }, + price: { + ...prices.createFixed({ id: "price_old_2" }), + config: { + ...prices.createFixed({ id: "price_old_2" }).config, + amount: 300, + } as Price["config"], + }, }), ], }), @@ -190,13 +184,13 @@ describe(chalk.yellowBright("billingPlanToSendProductsUpdated"), () => { customerPrices: [ prices.createCustomer({ customerProductId: "cus_prod_new_1", - price: { - ...prices.createFixed({ id: "price_new_1" }), - config: { - ...prices.createFixed({ id: "price_new_1" }).config, - amount: 200, - }, - }, + price: { + ...prices.createFixed({ id: "price_new_1" }), + config: { + ...prices.createFixed({ id: "price_new_1" }).config, + amount: 200, + } as Price["config"], + }, }), ], }), @@ -206,13 +200,7 @@ describe(chalk.yellowBright("billingPlanToSendProductsUpdated"), () => { customerPrices: [ prices.createCustomer({ customerProductId: "cus_prod_new_2", - price: { - ...prices.createFixed({ id: "price_new_2" }), - config: { - ...prices.createFixed({ id: "price_new_2" }).config, - amount: 100, - }, - }, + price: prices.createFixed({ id: "price_new_2" }), }), ], }), diff --git a/server/tests/unit/rate-limits/get-rate-limit-type.test.ts b/server/tests/unit/rate-limits/get-rate-limit-type.test.ts index e600f0fa0..0e5ad32a2 100644 --- a/server/tests/unit/rate-limits/get-rate-limit-type.test.ts +++ b/server/tests/unit/rate-limits/get-rate-limit-type.test.ts @@ -86,12 +86,12 @@ describe("getRateLimitType", () => { getRateLimitType( createContext({ method: "POST", path: "/v1/attach/preview" }), ), - ).toBe(RateLimitType.Attach); + ).toBe(RateLimitType.General); expect( getRateLimitType( createContext({ method: "POST", path: "/v1/billing.preview_update" }), ), - ).toBe(RateLimitType.Attach); + ).toBe(RateLimitType.General); expect( getRateLimitType( createContext({ diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index a6cf79d57..9b11f8377 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -56,17 +56,46 @@ export const CreateSchedulePhaseSchema = z.object({ }), }); -export const CreateScheduleParamsV0Schema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer to create the schedule for.", - }), - entity_id: z.string().optional().meta({ - description: "Optional entity ID for an entity-scoped schedule.", - }), - phases: z.array(CreateSchedulePhaseSchema).min(1).meta({ - description: "Ordered phase definitions for the schedule.", - }), -}); +export const CreateScheduleParamsV0Schema = z + .object({ + customer_id: z.string().meta({ + description: "The ID of the customer to create the schedule for.", + }), + entity_id: z.string().optional().meta({ + description: "Optional entity ID for an entity-scoped schedule.", + }), + phases: z + .tuple([CreateSchedulePhaseSchema]) + .rest(CreateSchedulePhaseSchema) + .meta({ + description: "Ordered phase definitions for the schedule.", + }), + }) + .refine( + (data) => { + const sortedPhases = [...data.phases].sort( + (a, b) => a.starts_at - b.starts_at, + ); + + for (let index = 1; index < sortedPhases.length; index++) { + const previousPhase = sortedPhases[index - 1]; + const currentPhase = sortedPhases[index]; + + if ( + previousPhase && + currentPhase?.starts_at <= previousPhase.starts_at + ) { + return false; + } + } + + return true; + }, + { + message: "Phase starts_at values must be strictly increasing", + path: ["phases"], + }, + ); export type CreateScheduleParamsV0 = z.infer< typeof CreateScheduleParamsV0Schema diff --git a/shared/models/billingModels/context/createScheduleBillingContext.ts b/shared/models/billingModels/context/createScheduleBillingContext.ts new file mode 100644 index 000000000..42d394971 --- /dev/null +++ b/shared/models/billingModels/context/createScheduleBillingContext.ts @@ -0,0 +1,26 @@ +import type { Entitlement, FeatureOptions, Price } from "@autumn/shared"; +import type { CreateScheduleParamsV0 } from "../../../api/billing/createSchedule/createScheduleParamsV0"; +import type { FullProduct } from "../../productModels/productModels"; +import type { MultiAttachBillingContext } from "./multiAttachBillingContext"; + +type CreateSchedulePhase = CreateScheduleParamsV0["phases"][number]; + +export interface ScheduledProductContext { + fullProduct: FullProduct; + customPrices: Price[]; + customEntitlements: Entitlement[]; + featureQuantities: FeatureOptions[]; +} + +export interface ScheduledPhaseContext { + startsAt: number; + endsAt: number | undefined; + productContexts: ScheduledProductContext[]; +} + +export interface CreateScheduleBillingContext + extends MultiAttachBillingContext { + immediatePhase: CreateSchedulePhase; + futurePhases: CreateSchedulePhase[]; + scheduledPhaseContexts: ScheduledPhaseContext[]; +} diff --git a/shared/models/billingModels/context/index.ts b/shared/models/billingModels/context/index.ts index d10c570a8..568e99162 100644 --- a/shared/models/billingModels/context/index.ts +++ b/shared/models/billingModels/context/index.ts @@ -1,5 +1,6 @@ export * from "./attachBillingContext"; export * from "./billingContext"; export * from "./billingContextOverride"; +export * from "./createScheduleBillingContext"; export * from "./multiAttachBillingContext"; export * from "./updateSubscriptionBillingContext"; diff --git a/shared/models/cusModels/fullCusModel.ts b/shared/models/cusModels/fullCusModel.ts index c589ebfa6..fb6290542 100644 --- a/shared/models/cusModels/fullCusModel.ts +++ b/shared/models/cusModels/fullCusModel.ts @@ -11,10 +11,6 @@ import type { Schedule, SchedulePhase, } from "../scheduleModels/scheduleTable.js"; -import { - type SchedulePhase, - type Schedule, -} from "../scheduleModels/scheduleTable.js"; import { type Subscription, SubscriptionSchema, diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index bf09b7abf..5ad5d6a60 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -121,9 +121,8 @@ export const hasCustomerProductEnded = ( const nowMs = params?.nowMs ?? Date.now(); const hasEnded = - isCustomerProductCanceling(cp) && - notNullish(cp.ended_at) && - nowMs >= cp.ended_at; + // isCustomerProductCanceling(cp) && + notNullish(cp.ended_at) && nowMs >= cp.ended_at; return hasEnded; }; diff --git a/shared/utils/cusProductUtils/convertCusProduct/customerProductsToRecurringActiveAndScheduled.ts b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToRecurringActiveAndScheduled.ts new file mode 100644 index 000000000..a57b4de57 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToRecurringActiveAndScheduled.ts @@ -0,0 +1,31 @@ +import { CusProductStatus } from "@models/cusProductModels/cusProductEnums"; +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; +import { + customerProductHasActiveStatus, + isCustomerProductOneOff, +} from "../classifyCustomerProduct/classifyCustomerProduct"; + +/** Split customer products into recurring-active and recurring-scheduled buckets. */ +export const customerProductsToRecurringActiveAndScheduled = ({ + customerProducts, +}: { + customerProducts: FullCusProduct[]; +}): { + recurringActive: FullCusProduct[]; + recurringScheduled: FullCusProduct[]; +} => { + const recurringActive: FullCusProduct[] = []; + const recurringScheduled: FullCusProduct[] = []; + + for (const customerProduct of customerProducts) { + if (isCustomerProductOneOff(customerProduct)) continue; + + if (customerProductHasActiveStatus(customerProduct)) { + recurringActive.push(customerProduct); + } else if (customerProduct.status === CusProductStatus.Scheduled) { + recurringScheduled.push(customerProduct); + } + } + + return { recurringActive, recurringScheduled }; +}; diff --git a/shared/utils/cusProductUtils/index.ts b/shared/utils/cusProductUtils/index.ts index 94b41fa96..b18304d26 100644 --- a/shared/utils/cusProductUtils/index.ts +++ b/shared/utils/cusProductUtils/index.ts @@ -5,6 +5,7 @@ export * from "./classifyCustomerProduct/cpBuilder"; export * from "./convertCusProduct"; export * from "./convertCusProduct/cusProductToConvertedFeatureOptions"; export * from "./convertCusProduct/cusProductToFeatureOptions"; +export * from "./convertCusProduct/customerProductsToRecurringActiveAndScheduled"; export * from "./convertCusProduct/customerProductsToStripeSubscriptionIds"; export * from "./cusProductConstants"; export * from "./cusProductUtils";