From 3ba48cfddbd5c704f561859792dde1fe00af54f5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 13 Jan 2026 08:48:09 +0000 Subject: [PATCH] wip --- .claude/skills/linear-ticket.md | 88 ++++++ .gitignore | 4 +- AGENTS.md | 147 --------- .../compute/finalizeUpdateSubscriptionPlan.ts | 10 +- .../custom-plan/update-one-off.test.ts | 278 ++++++++++++++++++ .../update-feature-quantity-errors.test.ts | 2 +- .../errors/update-one-off-errors.test.ts | 0 server/tests/utils/fixtures/items.ts | 30 ++ server/tests/utils/fixtures/products.ts | 20 ++ .../tests/utils/testInitUtils/initScenario.ts | 9 + .../updateSubscriptionV0Params.ts | 6 +- 11 files changed, 441 insertions(+), 153 deletions(-) create mode 100644 .claude/skills/linear-ticket.md delete mode 100644 AGENTS.md create mode 100644 server/tests/billing/update-subscription/custom-plan/update-one-off.test.ts create mode 100644 server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts diff --git a/.claude/skills/linear-ticket.md b/.claude/skills/linear-ticket.md new file mode 100644 index 000000000..b6b4de742 --- /dev/null +++ b/.claude/skills/linear-ticket.md @@ -0,0 +1,88 @@ +# Task Refinement Agent + +You refine rough engineering thoughts into structured, actionable tickets. + +## Important: Use Linear MCP + +**This skill creates ACTUAL Linear tickets using the Linear MCP server.** Do not just write markdown files - use the `create_issue` tool from the `user-Linear` MCP server to create real tickets. + +Before creating: +1. Use `list_teams` to find the appropriate team +2. Use `list_projects` to find the relevant project (if any) +3. Use `create_issue` with the formatted ticket content as the description + +## Process + +1. **Parse Intent** - Identify core objective and constraints +2. **Search Codebase** - Find relevant files, patterns, existing implementations, related tests, reusable utilities +3. **Get GitHub Permalinks** - Run `git remote get-url origin` and `git rev-parse HEAD` to build permalinks +4. **Structure & Output** - Format as ticket below +5. **Create in Linear** - Use the Linear MCP `create_issue` tool to create the actual ticket +6. **Flag Gaps** - Call out ambiguities, edge cases, missing considerations, questions + +## GitHub Permalinks + +**All file and function references must be clickable GitHub permalinks.** Don't use raw paths. + +Format: `[filename.ts](https://github.com/org/repo/blob/{commit_sha}/path/to/file.ts#L{line})` + +Examples: +- File: [`updateParams.ts`](https://github.com/org/repo/blob/abc123/shared/api/updateParams.ts) +- Function: [`useMyHook`](https://github.com/org/repo/blob/abc123/vite/src/hooks/useMyHook.ts#L42) +- Line range: [`EditHeader`](https://github.com/org/repo/blob/abc123/vite/src/EditHeader.tsx#L47-L70) + +To find line numbers, use grep: `grep -n "export function useMyHook" path/to/file.ts` + +**⚠️ Gotcha**: Use paths relative to the git root, NOT the full filesystem path. Run `git rev-parse --show-toplevel` to find the git root. If the workspace is `/Users/me/project/.conductor/cayenne/` and that's the git root, then `vite/src/hooks/useMyHook.ts` is correct, NOT `.conductor/cayenne/vite/src/hooks/useMyHook.ts`. + +## Ticket Format + +``` +## Summary +[One line - what needs to be done] + +## Context +[Why this is needed - 2-3 sentences max] + +## Plan + +### Phase 1: [Phase Name] + +**Why**: [Explain WHY this phase is needed and point to relevant files in the codebase] + +1. [Step with specific file/function] +2. [Step] + +> **🎨 Design Challenge** (optional): [If there are design decisions to explore, add as a blockquote challenge within the phase - NOT as a separate phase] + +--- + +### Phase 2: [Phase Name] + +**Why**: [Context for this phase] + +3. [Step] +4. [Step] + +--- + +**Testing** +- [ ] [Test case] + +**Questions** +- [ ] [Any clarifications needed before starting] +``` + +Note: Don't include a separate "Implementation Notes" section - all file/function references should be inlined with GitHub permalinks in the relevant phase. + +## Guidelines + +- **Be concise** - No fluff. Every word earns its place. +- **Use GitHub permalinks** - All file/function references must be clickable links, not raw paths +- **Don't assume** - List unknowns as questions +- **Follow patterns** - Find how similar things are done, suggest reuse +- **Think full lifecycle** - Create, update, delete, error states +- **Each phase needs a Why** - Explain the reasoning and point to relevant codebase files +- **No redundant phases** - Consolidate similar work into single phases +- **Design discussions are footnotes** - Put design challenges as blockquotes within implementation phases, not as separate phases +- **Inline references** - Don't have a separate "Implementation Notes" section; link files/functions where they're mentioned diff --git a/.gitignore b/.gitignore index c18eecc97..4f34f8f39 100644 --- a/.gitignore +++ b/.gitignore @@ -99,7 +99,7 @@ supabase/ # 1. Start supabase locally # 2. Load files -.claude/ + migration.sh @@ -114,3 +114,5 @@ interview credentials/ server/chrome + + diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index cc3b0911e..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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. -- Do NOT use "any" type. -- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity. - -# 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 - -# 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 ` - -- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck ` - -- 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. - -- This codebase uses Bun as its preferred package manager and Node runtime. - -- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` - -- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";` - -- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` - -## Import Conventions -- **DO NOT use `.js` extensions** in import paths. Example: - - ✅ `import { foo } from "@autumn/shared"` - - ❌ `import { foo } from "@autumn/shared/index.js"` -- **Use aliased paths** where possible (e.g., `@autumn/shared`, `@api/`, `@models/`, `@/`) -- **Import from the full path**, not barrel files (index.ts). Import directly from the source file: - - ✅ `import { CusProduct } from "@models/cusProductModels/cusProductModels"` - - ❌ `import { CusProduct } from "@models/cusProductModels"` (via index.ts re-export) - -- 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. - -- When creating "hooks" folders, don't nest them under "components" - -- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. - -- 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; - customerId: string; - }; - const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... } - - // ✅ GOOD - Inline object type - const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... } - ``` - -- 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 - -- Do not run "npx tsc" - run "tsc" instead as it is installed globally. - -- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` - -- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();` - -- 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. - -## 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); - } - - // ✅ GOOD - Validation/expected errors use RecaseError - if (!org) { - throw new RecaseError({ - message: "Org not found", - code: ErrCode.NotFound, - statusCode: 404, - }); - } - - // ✅ GOOD - Internal/unexpected errors use InternalError - if (!upstash) { - throw new InternalError({ - message: "Upstash not configured", - code: "upstash_not_configured", - }); - } - ``` - -## Bad example -/ root --> components -|-> hooks -## Good example -/ root --> components --> hooks - -- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. - -- 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 - -- Do not run "npx tsc" - run "tsc" instead as it is installed globally. - -# 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 - -## 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) - -## File Moving/Renaming -When restructuring, moving, or renaming files, **ALWAYS use terminal commands** (`mv`, `mkdir`) instead of rewriting files. This preserves git history and ensures no lines/logic are accidentally lost or changed - -## Deleting Files -- **NEVER use `rm` commands unless the file is confirmed to be unused** -- **ALWAYS ask for user approval before running any `rm` or `rm -rf` commands** -- Before deleting, verify the file has no imports/references in the codebase - -# 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. - -## 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. - -## 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. - -## 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. \ No newline at end of file diff --git a/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index 0a8dd92eb..e719398d3 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -1,4 +1,7 @@ -import { filterUnchangedPricesFromLineItems } from "@autumn/shared"; +import { + filterUnchangedPricesFromLineItems, + isCustomerProductOneOff, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems"; @@ -43,5 +46,10 @@ export const finalizeUpdateSubscriptionPlan = ({ }); } + // Guard: if current customer product is one off, make sure there are no line items. + if (isCustomerProductOneOff(billingContext.customerProduct)) { + plan.lineItems = []; + } + return plan; }; diff --git a/server/tests/billing/update-subscription/custom-plan/update-one-off.test.ts b/server/tests/billing/update-subscription/custom-plan/update-one-off.test.ts new file mode 100644 index 000000000..fde6c972d --- /dev/null +++ b/server/tests/billing/update-subscription/custom-plan/update-one-off.test.ts @@ -0,0 +1,278 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ONE-OFF PRODUCT UPDATES +// +// These tests cover updates to one-off products (products with interval: null). +// One-off products are purchased once and do not have recurring charges. +// +// Test scenarios: +// - Updating included usage on one-off message features +// - One-off products with prepaid items +// ═══════════════════════════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════════════════════════ +// ONE-OFF PRODUCT WITH FREE MESSAGES +// ═══════════════════════════════════════════════════════════════════════════════ + +// Update included usage on one-off product with free messages +test.concurrent(`${chalk.yellowBright("one-off: update included usage on free messages")}`, async () => { + const oldIncludedUsage = 100; + const messagesItem = items.monthlyMessages({ + includedUsage: oldIncludedUsage, + }); + const oneOffProduct = products.oneOff({ + items: [messagesItem], + id: "one-off-free", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "one-off-update-included", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProduct] }), + ], + actions: [s.attach({ productId: oneOffProduct.id })], + }); + + // Track some usage + const messagesUsed = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Update to increase included usage + const newIncludedUsage = 200; + const updatedMessagesItem = items.monthlyMessages({ + includedUsage: newIncludedUsage, + }); + + // Must include one-off price item to prevent transition to free product + const oneOffPriceItem = constructPriceItem({ price: 10, interval: null }); + + const updateParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + items: [updatedMessagesItem, oneOffPriceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No charge for updating included usage on one-off product + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Updated included usage, usage preserved + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newIncludedUsage, + balance: newIncludedUsage - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only initial one-off invoice + latestTotal: 10, // One-off product price + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ONE-OFF PRODUCT WITH BOOLEAN FEATURES +// ═══════════════════════════════════════════════════════════════════════════════ + +// Add boolean feature to one-off product +test.concurrent(`${chalk.yellowBright("one-off: add boolean feature")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const oneOffProduct = products.oneOff({ + items: [messagesItem], + id: "one-off-no-dashboard", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "one-off-add-boolean", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProduct] }), + ], + actions: [s.attach({ productId: oneOffProduct.id })], + }); + + // Update to add dashboard boolean feature + const dashboardItem = items.dashboard(); + const oneOffPriceItem = constructPriceItem({ price: 10, interval: null }); + + const updateParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + items: [messagesItem, dashboardItem, oneOffPriceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No charge for adding boolean feature + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Verify dashboard feature is now enabled + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Dashboard, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only initial one-off invoice + latestTotal: 10, + }); +}); + +// Remove boolean feature from one-off product +test.concurrent(`${chalk.yellowBright("one-off: remove boolean feature")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const dashboardItem = items.dashboard(); + const oneOffProduct = products.oneOff({ + items: [messagesItem, dashboardItem], + id: "one-off-with-dashboard", + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "one-off-remove-boolean", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProduct] }), + ], + actions: [s.attach({ productId: oneOffProduct.id })], + }); + + // Update to remove dashboard boolean feature + const oneOffPriceItem = constructPriceItem({ price: 10, interval: null }); + + const updateParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + items: [messagesItem, oneOffPriceItem], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No charge for removing boolean feature + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Verify dashboard feature is removed + expect(customer.features[TestFeature.Dashboard]).toBeUndefined(); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only initial one-off invoice + latestTotal: 10, + }); +}); + +// Update prepaid item included usage on one-off product +test.concurrent(`${chalk.yellowBright("one-off: update prepaid item included usage")}`, async () => { + const billingUnits = 100; + const price = 10; + const oldIncludedUsage = 50; + const prepaidItem = items.oneOffMessages({ + includedUsage: oldIncludedUsage, + billingUnits, + price, + }); + const oneOffProduct = products.base({ + items: [prepaidItem], + id: "one-off-prepaid-included", + }); + + const quantity = 200; // 2 packs + + const { customerId, autumnV1 } = await initScenario({ + customerId: "one-off-prepaid-included", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProduct] }), + ], + actions: [ + s.attach({ + productId: oneOffProduct.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + timeout: 4000, + }), + ], + }); + + // Track some usage + const messagesUsed = 100; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsed, + }, + { timeout: 2000 }, + ); + + // Update included usage from 50 to 100 (same quantity) + const newIncludedUsage = 100; + const updatedPrepaidItem = items.oneOffMessages({ + includedUsage: newIncludedUsage, + billingUnits, + price, + }); + + const updateParams = { + customer_id: customerId, + product_id: oneOffProduct.id, + items: [updatedPrepaidItem], + options: [{ feature_id: TestFeature.Messages, quantity }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No charge for changing included usage (same packs) + expect(preview.total).toBe(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Total included = newIncludedUsage + quantity = 100 + 200 = 300 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newIncludedUsage + quantity, + balance: newIncludedUsage + quantity - messagesUsed, + usage: messagesUsed, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, // Only initial invoice (no new charge) + latestTotal: (quantity / billingUnits) * price, // 2 packs for initial + }); +}); diff --git a/server/tests/billing/update-subscription/errors/update-feature-quantity-errors.test.ts b/server/tests/billing/update-subscription/errors/update-feature-quantity-errors.test.ts index 006b2f206..d057f7d8a 100644 --- a/server/tests/billing/update-subscription/errors/update-feature-quantity-errors.test.ts +++ b/server/tests/billing/update-subscription/errors/update-feature-quantity-errors.test.ts @@ -126,7 +126,7 @@ test.concurrent(`${chalk.yellowBright("error: negative quantity for prepaid feat options: [{ feature_id: TestFeature.Messages, quantity: 5 }], }), ], - }); + }); // Try to update with negative quantity const updateParams = { diff --git a/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts b/server/tests/billing/update-subscription/errors/update-one-off-errors.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 3f704147c..676fca1fb 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -180,6 +180,33 @@ const prepaidUsers = ({ includedUsage, }) as LimitedItem; +// ═══════════════════════════════════════════════════════════════════ +// ONE-OFF (interval: null, no recurring charges) +// ═══════════════════════════════════════════════════════════════════ + +/** + * One-off messages - purchase units once (no recurring charges) + * @param includedUsage - Free units included (default: 0) + * @param billingUnits - Units per pack (default: 100) + * @param price - Price per pack (default: 10) + */ +const oneOffMessages = ({ + includedUsage = 0, + billingUnits = 100, + price = 10, +}: { + includedUsage?: number; + billingUnits?: number; + price?: number; +} = {}): LimitedItem => + constructPrepaidItem({ + featureId: TestFeature.Messages, + price, + billingUnits, + includedUsage, + isOneOff: true, + }) as LimitedItem; + // ═══════════════════════════════════════════════════════════════════ // CONSUMABLE / PAY-PER-USE (overage pricing) // ═══════════════════════════════════════════════════════════════════ @@ -264,6 +291,9 @@ export const items = { prepaidMessages, prepaidUsers, + // One-off + oneOffMessages, + // Consumable consumableMessages, diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index e61e9fd65..582880090 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -127,10 +127,30 @@ const baseWithTrial = ({ }, }); +/** + * One-off product - one-time purchase with $10 base price + * @param items - Product items (features) + * @param id - Product ID (default: "one-off") + */ +const oneOff = ({ + items, + id = "one-off", +}: { + items: ProductItem[]; + id?: string; +}): ProductV2 => + constructProduct({ + id, + items: [...items], + type: "one_off", + isDefault: false, + }); + export const products = { base, baseWithTrial, pro, proAnnual, proWithTrial, + oneOff, } as const; diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 8814dd2f7..7ff320808 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -35,6 +35,7 @@ type AttachAction = { entityIndex?: number; options?: FeatureOption[]; newBillingSubscription?: boolean; + timeout?: number; }; type CancelAction = { @@ -150,22 +151,26 @@ const entities = ({ * @param entityIndex - Optional entity index (0-based) to attach to (omit for customer-level) * @param options - Optional feature options (e.g., prepaid quantity) * @param newBillingSubscription - Create a separate Stripe subscription for this product + * @param timeout - Optional timeout in milliseconds for the attach request * @example s.attach({ productId: "pro" }) // customer-level * @example s.attach({ productId: "pro", entityIndex: 0 }) // attach to first entity (ent-1) * @example s.attach({ productId: "free", entityIndex: 1 }) // attach to second entity (ent-2) * @example s.attach({ productId: "pro", options: [{ feature_id: "messages", quantity: 100 }] }) * @example s.attach({ productId: "addon", newBillingSubscription: true }) // separate subscription + * @example s.attach({ productId: "pro", timeout: 5000 }) // with timeout */ const attach = ({ productId, entityIndex, options, newBillingSubscription, + timeout, }: { productId: string; entityIndex?: number; options?: FeatureOption[]; newBillingSubscription?: boolean; + timeout?: number; }): ConfigFn => { return (config) => ({ ...config, @@ -177,6 +182,7 @@ const attach = ({ entityIndex, options, newBillingSubscription, + timeout, }, ], }); @@ -413,6 +419,9 @@ export const initScenario = async ({ options: action.options, new_billing_subscription: action.newBillingSubscription, }); + if (action.timeout) { + await new Promise((resolve) => setTimeout(resolve, action.timeout)); + } } else if (action.type === "cancel") { const prefixedProductId = `${action.productId}_${customerId}`; diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index 503e486b3..cc0f88048 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -25,9 +25,9 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({ items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override) free_trial: CreateFreeTrialSchema.nullable().optional(), - reset_billing_cycle_anchor: z.boolean().optional(), - new_billing_subscription: z.boolean().optional(), - prorate_billing: z.boolean().optional(), + // reset_billing_cycle_anchor: z.boolean().optional(), + // new_billing_subscription: z.boolean().optional(), + // prorate_billing: z.boolean().optional(), }); export const UpdateSubscriptionV0ParamsSchema =