diff --git a/.claude/skills/write-test/SKILL.md b/.claude/skills/write-test/SKILL.md index ef7056f76..6d330de93 100644 --- a/.claude/skills/write-test/SKILL.md +++ b/.claude/skills/write-test/SKILL.md @@ -25,12 +25,21 @@ Write integration tests for the Autumn billing system using the `initScenario` p - Use `product.id` in `s.attach()` (never string literals) - Use `Decimal.js` for balance calculations in track tests - Unique `customerId` per test +- Use generic types with `AutumnInt`: `autumnV1.customers.get()`, `autumnV1.check()` **DON'T:** - Use `describe/beforeAll/test` (legacy pattern) - Use `Date.now()` with test clocks (use `advancedTo`) - Share state between tests - Use raw arithmetic for balance calculations (floating point errors) +- Use `as unknown as Type` casting - use generic types instead + +## AutumnInt Response Types + +| Client | customers.get | entities.get | check | +|--------|---------------|--------------|-------| +| `autumnV1` | `ApiCustomerV3` | `ApiEntityV0` | `CheckResponseV1` | +| `autumnV2` | `ApiCustomer` | `ApiEntityV1` | `CheckResponseV2` | ## Minimal Template diff --git a/.claude/skills/write-test/references/SCENARIO.md b/.claude/skills/write-test/references/SCENARIO.md index a40efedc1..c9b2e58a4 100644 --- a/.claude/skills/write-test/references/SCENARIO.md +++ b/.claude/skills/write-test/references/SCENARIO.md @@ -238,6 +238,42 @@ const { } = await initScenario({ ... }); ``` +## AutumnInt Generic Types (IMPORTANT) + +**ALWAYS use generic type parameters** when calling `AutumnInt` methods to get proper type safety: + +| Client | Method | Type Parameter | +|--------|--------|----------------| +| `autumnV1` | `.customers.get()` | `ApiCustomerV3` | +| `autumnV1` | `.entities.get()` | `ApiEntityV0` | +| `autumnV1` | `.check()` | `CheckResponseV1` | +| `autumnV2` | `.customers.get()` | `ApiCustomer` | +| `autumnV2` | `.entities.get()` | `ApiEntityV1` | +| `autumnV2` | `.check()` | `CheckResponseV2` | + +```typescript +// ✅ GOOD - Use generic types +const customer = await autumnV1.customers.get(customerId); +const checkRes = await autumnV1.check({ ... }); +const entity = await autumnV2.entities.get(entityId); + +// ❌ BAD - Casting with `as unknown as` +const customer = await autumnV1.customers.get(customerId) as unknown as ApiCustomerV3; +const checkRes = (await autumnV1.check({ ... })) as unknown as CheckResponseV1; +``` + +Import the types from `@autumn/shared`: +```typescript +import { + type ApiCustomerV3, + type ApiCustomer, + type ApiEntityV0, + type ApiEntityV1, + type CheckResponseV1, + type CheckResponseV2, +} from "@autumn/shared"; +``` + ## Test Clock Timing **Critical:** `Date.now()` doesn't change when using test clocks. Use `advancedTo`: diff --git a/.opencode/plans/handleCreateCustomer-refactor.md b/.opencode/plans/handleCreateCustomer-refactor.md new file mode 100644 index 000000000..0a87d5897 --- /dev/null +++ b/.opencode/plans/handleCreateCustomer-refactor.md @@ -0,0 +1,439 @@ +# handleCreateCustomer Refactor Plan + +## Overview + +Refactor `handleCreateCustomer` to: +1. Eliminate race conditions causing duplicate customers or missing default products +2. Clean up input types with a single source of truth +3. Ensure comprehensive test coverage before making changes + +--- + +## Part 1: Type Cleanup + +### Problem + +Three overlapping types with duplicated ID validation logic: +- `CreateCustomerSchema` in `shared/models/cusModels/cusModels.ts` +- `CustomerDataSchema` in `shared/api/common/customerData.ts` +- `CreateCustomerParamsSchema` in `shared/api/customers/customerOpModels.ts` + +### Solution + +Make `shared/api/common/customerData.ts` the single source of truth. + +### Changes + +#### 1. `shared/api/common/customerData.ts` - Add CustomerIdSchema + +```typescript +import { z } from "zod/v4"; + +// Reusable customer ID validation - can be used by attach, check, track, etc. +export const CustomerIdSchema = z.string().refine( + (val) => { + if (val === "") return false; + if (val.includes("@")) return false; + if (val.includes(" ")) return false; + if (val.includes(".")) return false; + return /^[a-zA-Z0-9_-]+$/.test(val); + }, + { + error: (issue) => { + const input = issue.input as string; + if (input === "") return { message: "can't be an empty string" }; + if (input.includes("@")) + return { + message: "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.", + }; + if (input.includes(" ")) + return { + message: "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.", + }; + if (input.includes(".")) + return { + message: "cannot contain periods. Use only letters, numbers, underscores, and hyphens.", + }; + const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0]; + return { + message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`, + }; + }, + }, +); + +export const CustomerDataSchema = z + .object({ + name: z.string().nullish().meta({ description: "Customer's name" }), + email: z.string().nullish().meta({ description: "Customer's email address" }), + fingerprint: z.string().nullish().meta({ internal: true }), + metadata: z.record(z.any(), z.any()).nullish().meta({ internal: true }), + stripe_id: z.string().nullish().meta({ internal: true }), + disable_default: z.boolean().optional().meta({ internal: true }), + }) + .meta({ + id: "CustomerData", + description: "Customer details to set when creating a customer", + }); + +export type CustomerData = z.infer; +export type CustomerId = z.infer; +``` + +#### 2. `shared/api/customers/customerOpModels.ts` - Use CustomerIdSchema + +```typescript +import { CustomerDataSchema, CustomerIdSchema } from "../common/customerData.js"; + +// Remove duplicate customerId const, use CustomerIdSchema instead + +export const CreateCustomerParamsSchema = z.object({ + id: CustomerIdSchema.nullable().meta({ + description: "Your unique identifier for the customer", + }), + ...CustomerDataSchema.shape, + entity_id: z.string().optional().meta({ internal: true }), + entity_data: EntityDataSchema.optional().meta({ internal: true }), +}); + +export const UpdateCustomerParamsSchema = z.object({ + id: CustomerIdSchema.optional().meta({ + description: "New unique identifier for the customer.", + }), + // ... rest uses CustomerDataSchema fields +}); +``` + +#### 3. `shared/models/cusModels/cusModels.ts` - Remove CreateCustomerSchema + +- Delete `CreateCustomerSchema` (lines 21-69) +- Delete `CreateCustomer` type export (line 78) +- Keep `CustomerSchema` and `Customer` type (used for DB model) + +#### 4. `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New Signature + +```typescript +// OLD +export const handleCreateCustomer = async ({ + ctx, + cusData, // CreateCustomer type + createDefaultProducts, + defaultGroup, +}: { + ctx: AutumnContext; + cusData: CreateCustomer; + createDefaultProducts?: boolean; + defaultGroup?: string; +}) + +// NEW +export const handleCreateCustomer = async ({ + ctx, + customerId, // string | null + customerData, // CustomerData + options, +}: { + ctx: AutumnContext; + customerId: string | null; + customerData?: CustomerData; + options?: { + createDefaultProducts?: boolean; + defaultGroup?: string; + }; +}) +``` + +#### 5. Update All Callers + +| File | Change | +|------|--------| +| `getOrCreateCustomer.ts` | Pass `customerId` and `customerData` separately | +| `getOrCreateCachedFullCustomer.ts` | Pass `customerId` and `customerData` separately | +| `handlePostCustomerV2.ts` | Extract `id` from parsed body, pass rest as customerData | +| `getOrCreateApiCustomer.ts` | Pass `customerId` and `customerData` separately | +| `createNewCustomer.ts` | Update import, accept new shape | + +### Future Work (Not in This PR) + +These files can later adopt `CustomerIdSchema` for validation: +- `shared/api/balances/check/checkParams.ts` - `customer_id: CustomerIdSchema` +- `shared/api/balances/track/trackParams.ts` +- `shared/api/billing/attach/*` + +--- + +## Part 2: Test Structure + +### File Organization + +**3 new test files** using the modern `test.concurrent` + `initScenario` pattern: + +| File | Theme | +|------|-------| +| `create-customer.test.ts` | Basic creation + email flows | +| `create-customer-defaults.test.ts` | Default product attachment | +| `create-customer-race.test.ts` | Race condition tests (low-level simulation) | + +**Delete after migration:** +- `create-customer1.test.ts` (old pattern) +- `create-customer2.test.ts` (old pattern) + +**Add to existing files:** +- `check-race-condition2.test.ts` → Customer auto-creation race via /check +- `track-race-condition5.test.ts` → Customer auto-creation race via /track + +--- + +## Part 3: Test Cases + +### `create-customer.test.ts` - Basic Creation + Email Flows + +| # | Test Name | Description | From | +|---|-----------|-------------|------| +| 1 | `create: basic with ID` | Create customer with ID, name, email | Migrate from create-customer1 | +| 2 | `create: idempotent with same ID` | Create same customer twice returns existing | Migrate from create-customer1 | +| 3 | `create: with expand params` | Create with expand returns invoices, trials_used, entities | Migrate from create-customer1 | +| 4 | `create: concurrent same ID` | Promise.all two creates with same ID | Migrate from create-customer2 | +| 5 | `create: null ID with email` | Create customer with id=null and valid email | NEW | +| 6 | `create: null ID no email (error)` | Create with id=null and no email throws | NEW | +| 7 | `create: null ID idempotent` | Create with id=null same email twice returns existing | NEW | +| 8 | `create: null ID then add ID` | Create with id=null, then create with same email + ID updates existing | NEW | +| 9 | `create: concurrent null ID same email` | Promise.all two creates with id=null, same email | NEW | + +### `create-customer-defaults.test.ts` - Default Product Attachment + +| # | Test Name | Description | +|---|-----------|-------------| +| 10 | `defaults: single free product` | Create customer with single default free product attached | +| 11 | `defaults: multiple groups` | Two default free products in different groups, both attached | +| 12 | `defaults: same group priority` | Two defaults in same group, priority: trial > paid > free | +| 13 | `defaults: trial product` | Default trial attaches with status=trialing | +| 14 | `defaults: paid product (legacy)` | Default paid with forcePaidDefault=true uses handleAddProduct | +| 15 | `defaults: paid requires Stripe customer` | Default paid creates Stripe customer, sets stripe_id | + +### `create-customer-race.test.ts` - Race Condition Tests (Low-Level) + +| # | Test Name | Description | +|---|-----------|-------------| +| 16 | `race: stale cache detection` | Insert customer → concurrent request caches incomplete → getOrCreate detects stale | +| 17 | `race: concurrent default loop` | Insert → start attaching defaults → concurrent 23505 → retry sees all defaults | +| 18 | `race: concurrent same ID (API level)` | Promise.all creates with same ID, one gets 23505, both return same customer | +| 19 | `race: concurrent email+ID update` | Customer exists id=null, two requests add ID via same email | +| 20 | `race: concurrent Stripe customer` | Default paid: concurrent creates only create one Stripe customer | + +### Entry Point Auto-Creation Race Tests + +#### `check-race-condition2.test.ts` + +| # | Test Name | Description | +|---|-----------|-------------| +| 21 | `check-autocreate: concurrent same customer_id` | Concurrent /check calls auto-creating same customer | + +#### `track-race-condition5.test.ts` + +| # | Test Name | Description | +|---|-----------|-------------| +| 22 | `track-autocreate: concurrent same customer_id` | Concurrent /track calls auto-creating same customer, usage correct | + +--- + +## Part 4: Test Implementation Pattern + +### Modern Pattern: `test.concurrent` + `initScenario` + +```typescript +import { expect, test } from "bun:test"; +import { CusExpand } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// BASIC CREATION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-basic-id", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Delete to test fresh create + try { await autumnV1.customers.delete(customerId); } catch {} + + const data = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + }); + + expect(data.id).toBe(customerId); + expect(data.name).toBe("Test Customer"); + expect(data.email).toBe(`${customerId}@example.com`); +}); + +test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => { + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-idempotent", + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // First create + const data1 = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + }); + + // Second create - should return existing + const data2 = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + }); + + expect(data1.id).toBe(data2.id); + expect(data1.internal_id).toBe(data2.internal_id); +}); +``` + +### Low-Level Race Simulation Pattern + +```typescript +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; +import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js"; +import { generateId } from "@/utils/genUtils.js"; + +test.concurrent(`${chalk.yellowBright("race: stale cache detection")}`, async () => { + const wordsItem = items.monthlyWords({ includedUsage: 1000 }); + const freeDefault = products.base({ id: "free", items: [wordsItem], isDefault: true }); + + const { customerId, ctx, autumnV2 } = await initScenario({ + customerId: "race-stale-cache", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + // Delete customer so we can manually reproduce race + try { await autumnV2.customers.delete(customerId); } catch {} + await deleteCachedFullCustomer({ ctx, customerId, source: "test-cleanup" }); + + // STEP 1: Insert customer directly (bypassing handleCreateCustomer) + const internalId = generateId("cus"); + await CusService.insert({ + db: ctx.db, + data: { + id: customerId, + internal_id: internalId, + org_id: ctx.org.id, + env: ctx.env, + name: customerId, + email: `${customerId}@test.com`, + metadata: {}, + created_at: Date.now(), + }, + }); + + // STEP 2: Simulate concurrent request caching incomplete customer + const incompleteCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + withEntities: true, + withSubs: true, + }); + + await setCachedFullCustomer({ + ctx, + fullCustomer: incompleteCustomer!, + customerId, + fetchTimeMs: Date.now(), + source: "test-concurrent-request", + overwrite: true, + }); + + // STEP 3: Call actual function - should detect stale state + const result = await getOrCreateCachedFullCustomer({ + ctx, + params: { customer_id: customerId, feature_id: TestFeature.Words }, + source: "test-final-check", + }); + + // Verify: Customer has default products + expect(result.customer_products?.length).toBeGreaterThan(0); +}); +``` + +--- + +## Part 5: Implementation Order + +### Phase 1: Write Tests (RED) +1. Create `create-customer.test.ts` - migrate old tests + add new null ID tests +2. Create `create-customer-defaults.test.ts` - default product tests +3. Create `create-customer-race.test.ts` - race condition tests +4. Add tests to `check-race-condition2.test.ts` and `track-race-condition5.test.ts` +5. Delete `create-customer1.test.ts` and `create-customer2.test.ts` +6. Run tests - some will fail (documenting expected behavior) + +### Phase 2: Type Cleanup +1. Add `CustomerIdSchema` to `customerData.ts` +2. Update `customerOpModels.ts` to use it +3. Update `handleCreateCustomer` signature +4. Update all callers +5. Remove `CreateCustomerSchema` from `cusModels.ts` + +### Phase 3: Fix Race Conditions (GREEN) +1. Analyze failing tests +2. Implement proper locking/transactions +3. Potential fixes: + - Use database transaction for insert + default products + - Add advisory lock during customer creation + - Detect stale cache by checking customer_products count + +### Phase 4: Verify +1. All tests pass +2. Manual testing of concurrent scenarios +3. Review for any remaining edge cases + +--- + +## Files Summary + +### To Create +- `server/tests/integration/crud/customers/create-customer.test.ts` +- `server/tests/integration/crud/customers/create-customer-defaults.test.ts` +- `server/tests/integration/crud/customers/create-customer-race.test.ts` +- `server/tests/integration/balances/check/check-race-condition2.test.ts` +- `server/tests/balances/track/race-condition/track-race-condition5.test.ts` + +### To Delete +- `server/tests/integration/crud/customers/create-customer1.test.ts` +- `server/tests/integration/crud/customers/create-customer2.test.ts` + +### Type Cleanup (Modify) +- `shared/api/common/customerData.ts` - Add `CustomerIdSchema` +- `shared/api/customers/customerOpModels.ts` - Use `CustomerIdSchema`, remove duplicate +- `shared/models/cusModels/cusModels.ts` - Remove `CreateCustomerSchema` +- `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New signature +- `server/src/internal/customers/cusUtils/getOrCreateCustomer.ts` +- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts` +- `server/src/internal/customers/cusUtils/createNewCustomer.ts` +- `server/src/internal/customers/handlers/handlePostCustomerV2.ts` +- `server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts` diff --git a/server/src/db/dbUtils.ts b/server/src/db/dbUtils.ts index 3c6b3c3be..d62eedc02 100644 --- a/server/src/db/dbUtils.ts +++ b/server/src/db/dbUtils.ts @@ -1,6 +1,18 @@ import { getTableColumns, type SQL, sql } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; +/** + * Check if an error is a Postgres unique constraint violation (error code 23505). + */ +export const isUniqueConstraintError = (error: unknown): boolean => { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "23505" + ); +}; + export const buildConflictUpdateColumns = ( table: T, excludeColumns: (keyof T["_"]["columns"])[] = [], diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 1f017ba04..ad4a9794a 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -13,6 +13,7 @@ import { type BillingResponse, type CheckQuery, type CreateBalanceParams, + type CreateCustomerInternalOptions, type CreateCustomerParams, type CreateEntityParams, type CreateRewardProgram, @@ -414,15 +415,18 @@ export class AutumnInt { create: async ({ withAutumnId = true, expand = [], + internalOptions, ...customerData }: { withAutumnId?: boolean; expand?: CusExpand[]; - } & CreateCustomerParams) => { + internalOptions?: CreateCustomerInternalOptions; + } & Omit) => { const data = await this.post( `/customers?with_autumn_id=${withAutumnId ? "true" : "false"}${expand && expand.length > 0 ? `&expand=${expand.join(",")}` : ""}`, { ...customerData, + internal_options: internalOptions, }, ); return data; diff --git a/server/src/external/stripe/customers/index.ts b/server/src/external/stripe/customers/index.ts index a13d26214..58e957d6b 100644 --- a/server/src/external/stripe/customers/index.ts +++ b/server/src/external/stripe/customers/index.ts @@ -1 +1,4 @@ +export * from "./operations/createStripeCustomer.js"; +export * from "./operations/getExpandedStripeCustomer.js"; +export * from "./operations/getOrCreateStripeCustomer.js"; export * from "./utils/convertStripeCustomer.js"; diff --git a/server/src/external/stripe/customers/operations/createStripeCustomer.ts b/server/src/external/stripe/customers/operations/createStripeCustomer.ts new file mode 100644 index 000000000..5471e254b --- /dev/null +++ b/server/src/external/stripe/customers/operations/createStripeCustomer.ts @@ -0,0 +1,43 @@ +import type { Customer } from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { buildStripeCustomerIdempotencyKey } from "@/external/stripe/customers/utils/buildIdempotencyKey"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const createStripeCustomer = async ({ + ctx, + customer, + options = {}, +}: { + ctx: AutumnContext; + customer: Customer; + options?: { + testClockId?: string; + }; +}) => { + const { org, env } = ctx; + const stripeCli = createStripeCli({ org, env }); + + const idempotencyKey = buildStripeCustomerIdempotencyKey({ + ctx, + customerId: customer.id || customer.internal_id, + }); + + const stripeCustomer = await stripeCli.customers.create( + { + name: customer.name || undefined, + email: customer.email || undefined, + metadata: { + autumn_id: customer.id || null, + autumn_internal_id: customer.internal_id, + }, + test_clock: options.testClockId, + }, + idempotencyKey + ? { + idempotencyKey, + } + : undefined, + ); + + return stripeCustomer; +}; diff --git a/server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts b/server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts new file mode 100644 index 000000000..d5320f174 --- /dev/null +++ b/server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts @@ -0,0 +1,61 @@ +import { tryCatch } from "@shared/utils"; +import Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export type ExpandedStripeCustomer = Omit< + Stripe.Customer, + "test_clock" | "invoice_settings" | "discount" +> & { + test_clock: Stripe.TestHelpers.TestClock | null; + invoice_settings: Omit< + Stripe.Customer.InvoiceSettings, + "default_payment_method" + > & { + default_payment_method: Stripe.PaymentMethod | null; + }; + discount: + | (Omit & { + coupon: Stripe.Coupon & { + applies_to: Stripe.Coupon.AppliesTo | null; + }; + }) + | null; +}; + +export const getExpandedStripeCustomer = async ({ + ctx, + stripeCustomerId, +}: { + ctx: AutumnContext; + stripeCustomerId?: string; +}): Promise => { + const { org, env } = ctx; + const stripeCli = createStripeCli({ org, env }); + + if (!stripeCustomerId) return undefined; + + const { data: stripeCustomer, error } = await tryCatch( + stripeCli.customers.retrieve(stripeCustomerId, { + expand: [ + "test_clock", + "invoice_settings.default_payment_method", + "discount.coupon.applies_to", + ], + }), + ); + + if (error) { + if ( + error instanceof Stripe.errors.StripeError && + error.code?.includes("resource_missing") + ) { + return undefined; + } + throw error; + } + + if (stripeCustomer.deleted) return undefined; + + return stripeCustomer as ExpandedStripeCustomer; +}; diff --git a/server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts b/server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts new file mode 100644 index 000000000..69010c0c9 --- /dev/null +++ b/server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts @@ -0,0 +1,57 @@ +import { type Customer, ProcessorType } from "@autumn/shared"; +import { createStripeCustomer } from "@/external/stripe/customers/operations/createStripeCustomer"; +import { getExpandedStripeCustomer } from "@/external/stripe/customers/operations/getExpandedStripeCustomer"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusService } from "@/internal/customers/CusService"; + +export const getOrCreateStripeCustomer = async ({ + ctx, + customer, + options = { + updateDb: true, + }, +}: { + ctx: AutumnContext; + customer: Customer; + options?: { + updateDb?: boolean; + }; +}) => { + const { logger, db, org, env } = ctx; + + const currentStripeCustomer = await getExpandedStripeCustomer({ + ctx, + stripeCustomerId: customer.processor?.id, + }); + + if (currentStripeCustomer) return currentStripeCustomer; + + logger.info(`Creating new stripe customer for ${customer.id}`); + + const stripeCustomer = await createStripeCustomer({ + ctx, + customer, + }); + + if (options.updateDb) { + await CusService.update({ + db, + idOrInternalId: customer.internal_id, + orgId: org.id, + env, + update: { + processor: { + id: stripeCustomer.id, + type: ProcessorType.Stripe, + }, + }, + }); + } + + customer.processor = { + id: stripeCustomer.id, + type: ProcessorType.Stripe, + }; + + return stripeCustomer; +}; diff --git a/server/src/external/stripe/customers/utils/buildIdempotencyKey.ts b/server/src/external/stripe/customers/utils/buildIdempotencyKey.ts new file mode 100644 index 000000000..a4915c69d --- /dev/null +++ b/server/src/external/stripe/customers/utils/buildIdempotencyKey.ts @@ -0,0 +1,15 @@ +import { hashString } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const buildStripeCustomerIdempotencyKey = ({ + ctx, + customerId, +}: { + ctx: AutumnContext; + customerId: string; +}): string => { + const { org, env } = ctx; + return hashString( + `stripe-create-cus:${customerId}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`, + ); +}; diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index fbffe906d..37afde7b1 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -2,18 +2,17 @@ import { type AppEnv, type Customer, ErrCode, - hashString, type Organization, ProcessorType, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import { Stripe } from "stripe"; +import type { Stripe } from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createStripeCustomer } from "@/external/stripe/customers"; import { CusService } from "@/internal/customers/CusService.js"; import RecaseError from "@/utils/errorUtils.js"; import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext"; -import type { Logger } from "../logtail/logtailUtils"; export const getStripeCus = async ({ stripeCli, @@ -30,190 +29,6 @@ export const getStripeCus = async ({ } }; -export const createStripeCusIfNotExists = async ({ - db, - org, - env, - customer, - logger, -}: { - db: DrizzleCli; - org: Organization; - env: AppEnv; - customer: Customer; - logger: Logger; -}) => { - const stripeCli = createStripeCli({ org, env }); - - const getCurrentStripeCus = async () => { - // 1. If no processor, create new customer - if (!customer.processor?.id) return null; - - try { - const stripeCus = await stripeCli.customers.retrieve( - customer.processor.id, - { - expand: [ - "test_clock", - "invoice_settings.default_payment_method", - "discount.source.coupon.applies_to", - ], - }, - ); - - // 2. If customer is deleted, create new customer - if (stripeCus.deleted) return null; - - // 3. If customer is not deleted, return customer - return stripeCus as Stripe.Customer; - } catch (_error) { - // 4. If error, create new customer - if ( - _error instanceof Stripe.errors.StripeError && - _error.code?.includes("resource_missing") - ) { - return null; - } - throw _error; - } - }; - - // 1. Get current stripe customer - const stripeCus = await getCurrentStripeCus(); - - if (stripeCus) return stripeCus; - - // 2. If no current stripe customer, create new customer - logger.info(`Creating new stripe customer for ${customer.id}`); - const idempotencyKey = hashString( - `stripe-create-cus:${customer.id || customer.internal_id}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`, - ); - - const stripeCustomer = await createStripeCustomer({ - org, - env, - customer, - idempotencyKey, - }); - - await CusService.update({ - db, - idOrInternalId: customer.internal_id, - orgId: org.id, - env, - update: { - processor: { - id: stripeCustomer.id, - type: ProcessorType.Stripe, - }, - }, - }); - - customer.processor = { - id: stripeCustomer.id, - type: ProcessorType.Stripe, - }; - - return stripeCustomer; - - // let createNew = false; - // const stripeCli = createStripeCli({ org, env }); - // if (!customer.processor || !customer.processor.id) { - // createNew = true; - // } else { - // try { - // const stripeCus = await stripeCli.customers.retrieve( - // customer.processor.id, - // { - // expand: ["test_clock", "invoice_settings.default_payment_method"], - // }, - // ); - // if (!stripeCus.deleted) { - // return stripeCus as Stripe.Customer; - // } else { - // createNew = true; - // } - // } catch (_error) { - // createNew = true; - // } - // } - - // if (createNew) { - // logger.info(`Creating new stripe customer for ${customer.id}`); - // const stripeCustomer = await createStripeCustomer({ - // org, - // env, - // customer, - // }); - - // await CusService.update({ - // db, - // idOrInternalId: customer.internal_id, - // orgId: org.id, - // env, - // update: { - // processor: { - // id: stripeCustomer.id, - // type: ProcessorType.Stripe, - // }, - // }, - // }); - - // customer.processor = { - // id: stripeCustomer.id, - // type: ProcessorType.Stripe, - // }; - - // return stripeCustomer; - // } -}; - -export const createStripeCustomer = async ({ - org, - env, - customer, - testClockId, - metadata, - idempotencyKey, -}: { - org: Organization; - env: AppEnv; - customer: Customer; - testClockId?: string; - metadata?: Record; - idempotencyKey?: string; -}) => { - const stripeCli = createStripeCli({ org, env }); - - try { - const stripeCustomer = await stripeCli.customers.create( - { - name: customer.name || undefined, - email: customer.email || undefined, - metadata: { - ...(metadata || {}), - autumn_id: customer.id || null, - autumn_internal_id: customer.internal_id, - }, - test_clock: testClockId, - }, - idempotencyKey - ? { - idempotencyKey, - } - : undefined, - ); - - return stripeCustomer; - } catch (error: any) { - throw new RecaseError({ - message: `Error creating customer in Stripe. ${error.message}`, - code: ErrCode.StripeCreateCustomerFailed, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } -}; - export const deleteStripeCustomer = async ({ org, env, @@ -321,10 +136,9 @@ export const attachPmToCus = async ({ let stripeCusId = customer.processor?.id; if (!stripeCusId) { const stripeCustomer = await createStripeCustomer({ - org, - env, + ctx: { org, env, db } as any, customer, - testClockId, + options: { testClockId }, }); await CusService.update({ diff --git a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts index b0530b3ee..9d0b330dc 100644 --- a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts @@ -1,5 +1,4 @@ import type Stripe from "stripe"; - export const getLatestPeriodEnd = ({ sub, subItems, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts index e48e764c5..70d808178 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts @@ -7,7 +7,7 @@ import { findPriceFromStripeId, } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; -import { constructSub } from "@/internal/subscriptions/subUtils.js"; +import { initSubscription } from "@/internal/subscriptions/utils/initSubscription.js"; import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js"; import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js"; @@ -30,9 +30,8 @@ export const handleCheckoutSub = async ({ await SubService.createSub({ db, - sub: constructSub({ + sub: initSubscription({ stripeId: subscription.id, - usageFeatures: attachParams.itemSets?.[0]?.usageFeatures || [], orgId: org.id, env: attachParams.customer.env, currentPeriodStart: start, diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index b16c11d91..8d7f1bc44 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -2,15 +2,14 @@ import { AppEnv, type Customer, cusProductToProduct, - InternalError, ProcessorType, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createStripeCustomer } from "@/external/stripe/customers"; import { createCustomStripeCard } from "@/external/stripe/stripeCardUtils.js"; -import { createStripeCustomer } from "@/external/stripe/stripeCusUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { customerActions } from "@/internal/customers/actions/index.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { handleCreateCustomer } from "@/internal/customers/handlers/handleCreateCustomer.js"; import { AuthError, getAuthorizationToken, @@ -45,10 +44,10 @@ export const handleUpsertInstallation = createRoute({ throw new AuthError("Invalid claims"); } - createdCustomer = await handleCreateCustomer({ + createdCustomer = await customerActions.createWithDefaults({ ctx, - cusData: { - id: integrationConfigurationId, + customerId: integrationConfigurationId, + customerData: { email: body.account.contact.email, name: body.account.contact.name, processors: { @@ -61,16 +60,10 @@ export const handleUpsertInstallation = createRoute({ }, }); - if (!createdCustomer) { - throw new InternalError({ - message: "Failed to create customer", - }); - } - // Create test clock for sandbox/development environments + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); let testClockId: string | undefined; if (ctx.env === AppEnv.Sandbox) { - const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const testClock = await stripeCli.testHelpers.testClocks.create({ frozen_time: Math.floor(Date.now() / 1000), }); @@ -78,10 +71,13 @@ export const handleUpsertInstallation = createRoute({ } const stripeCustomer = await createStripeCustomer({ - org: ctx.org, - env: ctx.env, + ctx, customer: createdCustomer, - testClockId, + options: { testClockId }, + }); + + // Add vercel-specific metadata + await stripeCli.customers.update(stripeCustomer.id, { metadata: { vercel_installation_id: integrationConfigurationId, }, diff --git a/server/src/honoMiddlewares/analyticsMiddleware.ts b/server/src/honoMiddlewares/analyticsMiddleware.ts index 651ecced3..80567f03d 100644 --- a/server/src/honoMiddlewares/analyticsMiddleware.ts +++ b/server/src/honoMiddlewares/analyticsMiddleware.ts @@ -5,6 +5,7 @@ import { addAppContextToLogs, addExtrasToLogs, } from "@/utils/logging/addContextToLogs"; +import { maskExtraLogs } from "@/utils/logging/maskExtraLogs.js"; export const parseCustomerIdFromUrl = ({ url, @@ -116,7 +117,8 @@ const logResponse = async ({ }); if (Object.keys(ctx.extraLogs).length > 0) { - ctx.logger.debug(`EXTRA LOGS:`, JSON.stringify(ctx.extraLogs, null, 2)); + const maskedLogs = maskExtraLogs(ctx.extraLogs); + ctx.logger.debug(`EXTRA LOGS: ${JSON.stringify(maskedLogs, null, 2)}`); } } catch (error) { console.error("Failed to log response to logtail"); @@ -161,9 +163,12 @@ export const analyticsMiddleware = async (c: Context, next: Next) => { // Execute the request await next(); + // Re-fetch ctx after next() since handlers may have replaced it via c.set("ctx", {...}) + const finalCtx = c.get("ctx"); + // Log response asynchronously without blocking (runs after response is sent) Promise.resolve() - .then(() => logResponse({ ctx, c, skipUrls })) + .then(() => logResponse({ ctx: finalCtx, c, skipUrls })) .catch((error) => { console.error("Failed to log response to logtail"); console.error(error); diff --git a/server/src/internal/billing/handlers/handleSetupPayment.ts b/server/src/internal/billing/handlers/handleSetupPayment.ts index 9c2f86e82..c98d8c4e2 100644 --- a/server/src/internal/billing/handlers/handleSetupPayment.ts +++ b/server/src/internal/billing/handlers/handleSetupPayment.ts @@ -4,7 +4,7 @@ import { SetupPaymentParamsSchema, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -23,15 +23,12 @@ export const handleSetupPayment = createRoute({ const customer = await getOrCreateCustomer({ ctx, customerId: customer_id, - customerData: customer_data as any, + customerData: customer_data, }); - await createStripeCusIfNotExists({ - db, - org, - env, + await getOrCreateStripeCustomer({ + ctx, customer, - logger, }); const stripeCli = createStripeCli({ org, env }); diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index 1265fe703..9136605ce 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -23,6 +23,7 @@ export interface TrialContext { trialEndsAt: number | null; customFreeTrial?: FreeTrial; appliesToBilling: boolean; + cardRequired: boolean; } export interface BillingContext { @@ -56,5 +57,3 @@ export interface BillingContext { export interface UpdateSubscriptionBillingContext extends BillingContext { customerProduct: FullCusProduct; // target customer product } - -// testClockFrozenTime?: number; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index cebb4124d..82a259cda 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -24,19 +24,19 @@ export const executeAutumnBillingPlan = async ({ customFreeTrial, } = autumnBillingPlan; - ctx.logger.debug( - `[executeAutumnBillingPlan] inserting ${customEntitlements.length} custom entitlements and ${customPrices.length} custom prices`, - ); + if (customEntitlements) { + await EntitlementService.insert({ + db, + data: customEntitlements, + }); + } - await EntitlementService.insert({ - db, - data: customEntitlements, - }); - - await PriceService.insert({ - db, - data: customPrices, - }); + if (customPrices) { + await PriceService.insert({ + db, + data: customPrices, + }); + } if (customFreeTrial) { await FreeTrialService.insert({ diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index e4eb490db..61e7f682e 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -20,8 +20,6 @@ export const executeBillingPlan = async ({ billingContext, }); - // console.log("stripeBillingResult", stripeBillingResult); - if (stripeBillingResult.deferred) return { stripe: stripeBillingResult, diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index 930724342..bcd70c6cb 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -50,7 +50,7 @@ export const evaluateStripeBillingPlan = async ({ let stripeInvoiceAction: StripeInvoiceAction | undefined; let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined; - if (createManualInvoice) { + if (createManualInvoice && lineItems) { stripeInvoiceAction = buildStripeInvoiceAction({ lineItems, }); diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts index 46122979e..cb854e5d1 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts @@ -1,9 +1,7 @@ import type { FullCustomer } from "@autumn/shared"; import { createStripeCli } from "@server/external/connect/createStripeCli"; -import { - createStripeCusIfNotExists, - listCusPaymentMethods, -} from "@server/external/stripe/stripeCusUtils"; +import { getOrCreateStripeCustomer } from "@server/external/stripe/customers"; +import { listCusPaymentMethods } from "@server/external/stripe/stripeCusUtils"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type Stripe from "stripe"; @@ -14,15 +12,12 @@ export const fetchStripeCustomerForBilling = async ({ ctx: AutumnContext; fullCus: FullCustomer; }) => { - const { logger, db, org, env } = ctx; + const { org, env } = ctx; const stripeCli = createStripeCli({ org, env }); - const stripeCus = await createStripeCusIfNotExists({ - db, - org, - env, + const stripeCus = await getOrCreateStripeCustomer({ + ctx, customer: fullCus, - logger, }); const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts index 85cb34d80..9b02a45a3 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -17,9 +17,8 @@ export const buildStripeSubscriptionCreateAction = ({ const { stripeCustomer, paymentMethod, trialContext } = billingContext; const trialEndsAt = trialContext?.trialEndsAt; - const freeTrial = trialContext?.freeTrial; - const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required); + const isFreeTrialWithCardRequired = trialContext?.cardRequired; const isCustomPaymentMethod = paymentMethod?.type === "custom"; const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = { diff --git a/server/src/internal/billing/v2/setup/setupTrialContext.ts b/server/src/internal/billing/v2/setup/setupTrialContext.ts index b4e0bd7e3..d82135613 100644 --- a/server/src/internal/billing/v2/setup/setupTrialContext.ts +++ b/server/src/internal/billing/v2/setup/setupTrialContext.ts @@ -42,6 +42,7 @@ export const setupTrialContext = ({ freeTrial: null, trialEndsAt: null, appliesToBilling: newProductIsPaidRecurring, + cardRequired: true, }; } else { return undefined; @@ -67,6 +68,7 @@ export const setupTrialContext = ({ trialEndsAt, customFreeTrial: dbFreeTrial, appliesToBilling: newProductIsPaidRecurring, + cardRequired: dbFreeTrial.card_required, }; } @@ -84,6 +86,7 @@ export const setupTrialContext = ({ freeTrial: null, trialEndsAt: trialEndsAt, appliesToBilling: newProductIsPaidRecurring, + cardRequired: true, }; } else { return undefined; @@ -96,6 +99,7 @@ export const setupTrialContext = ({ freeTrial: customerProduct.free_trial, // can be undefined... trialEndsAt: customerProduct.trial_ends_at ?? null, appliesToBilling: false, + cardRequired: true, }; } diff --git a/server/src/internal/billing/v2/types/autumnBillingPlan.ts b/server/src/internal/billing/v2/types/autumnBillingPlan.ts index 8852b0b48..041282ac5 100644 --- a/server/src/internal/billing/v2/types/autumnBillingPlan.ts +++ b/server/src/internal/billing/v2/types/autumnBillingPlan.ts @@ -21,20 +21,23 @@ export const UpdateCustomerEntitlementSchema = z.object({ export const AutumnBillingPlanSchema = z.object({ insertCustomerProducts: z.array(FullCusProductSchema), - updateCustomerProduct: z.object({ - customerProduct: FullCusProductSchema, - updates: z.object({ - options: z.array(FeatureOptionsSchema).optional(), - status: z.enum(CusProductStatus).optional(), - }), - }), + updateCustomerProduct: z + .object({ + customerProduct: FullCusProductSchema, + updates: z.object({ + options: z.array(FeatureOptionsSchema).optional(), + status: z.enum(CusProductStatus).optional(), + }), + }) + .optional(), + deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling) - customPrices: z.array(PriceSchema), // Custom prices to insert - customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert + customPrices: z.array(PriceSchema).optional(), // Custom prices to insert + customEntitlements: z.array(EntitlementSchema).optional(), // Custom entitlements to insert customFreeTrial: FreeTrialSchema.optional(), // Custom free trial to insert - lineItems: z.array(LineItemSchema), + lineItems: z.array(LineItemSchema).optional(), updateCustomerEntitlements: z .array(UpdateCustomerEntitlementSchema) diff --git a/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index e719398d3..dbc8ac8cb 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -21,7 +21,7 @@ export const finalizeUpdateSubscriptionPlan = ({ // Filter line items based on trial state transitions plan.lineItems = filterLineItemsForTrialTransition({ ctx, - lineItems: plan.lineItems, + lineItems: plan.lineItems ?? [], billingContext, }); diff --git a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts index 92bc816db..b965d7de6 100644 --- a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts @@ -69,6 +69,7 @@ export const handleUpdateSubscription = createRoute({ stripe: stripeBillingPlan, }, }); + logStripeBillingResult({ ctx, result: billingResult.stripe }); const response = billingResultToResponse({ diff --git a/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionPlan.ts index e3ebfe67d..fc251f96c 100644 --- a/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionPlan.ts @@ -17,20 +17,24 @@ export const logUpdateSubscriptionPlan = ({ billingContext, }); - const formatCustomerProduct = (cp: { product_id: string; product: { name: string } }) => - `${cp.product.name} (${cp.product_id})`; + const formatCustomerProduct = (cp: { + product_id: string; + product: { name: string }; + }) => `${cp.product.name} (${cp.product_id})`; addToExtraLogs({ ctx, extras: { autumnBillingPlan: { - insertCustomerProducts: plan.insertCustomerProducts - .map(formatCustomerProduct) - .join(", ") || "none", + insertCustomerProducts: + plan.insertCustomerProducts.map(formatCustomerProduct).join(", ") || + "none", updateCustomerProduct: plan.updateCustomerProduct ? { - product: formatCustomerProduct(plan.updateCustomerProduct.customerProduct), + product: formatCustomerProduct( + plan.updateCustomerProduct.customerProduct, + ), updates: plan.updateCustomerProduct.updates, } : "none", @@ -41,16 +45,18 @@ export const logUpdateSubscriptionPlan = ({ trialTransition: `${isTrialing ? "trialing" : "not trialing"} -> ${willBeTrialing ? "will trial" : "no trial"}`, - updateCustomerEntitlements: plan.updateCustomerEntitlements - ?.map( - (update) => - `${update.customerEntitlement.feature_id}: ${update.balanceChange > 0 ? "+" : ""}${update.balanceChange}`, - ) - .join(", ") || "none", + updateCustomerEntitlements: + plan.updateCustomerEntitlements + ?.map( + (update) => + `${update.customerEntitlement.feature_id}: ${update.balanceChange > 0 ? "+" : ""}${update.balanceChange}`, + ) + .join(", ") || "none", - lineItems: plan.lineItems.map( - (item) => `${item.description}: ${item.finalAmount}`, - ), + lineItems: + plan.lineItems?.map( + (item) => `${item.description}: ${item.finalAmount}`, + ) ?? "none", }, }, }); diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index 07f32ebc1..73e0ef29a 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -21,13 +21,14 @@ export const billingPlanToPreviewResponse = ({ const { fullCustomer } = billingContext; const autumnBillingPlan = billingPlan.autumn; + const planLineItems = autumnBillingPlan.lineItems ?? []; - - const previewImmediateLineItems = autumnBillingPlan.lineItems.filter((line) => line.chargeImmediately).map((line) => ({ - description: line.description, - amount: line.finalAmount, - })); - + const previewImmediateLineItems = planLineItems + .filter((line) => line.chargeImmediately) + .map((line) => ({ + description: line.description, + amount: line.finalAmount, + })); const total = new Decimal( sumValues(previewImmediateLineItems.map((line) => line.amount)), diff --git a/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts index 3b4bc0a7c..2a94ed9fb 100644 --- a/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts @@ -32,7 +32,6 @@ const logExistingUsages = ({ }; }, ); - ctx.logger.debug(`[applyExistingUsages] existing usages:`, existinUsagesLogs); addToExtraLogs({ ctx, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts index 9f690fcfb..26d4b7c6b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts @@ -13,7 +13,6 @@ import { initCustomerPrice } from "./initCustomerPrice"; import { initCustomerProduct } from "./initCustomerProduct"; export const initFullCustomerProduct = ({ - // biome-ignore lint/correctness/noUnusedFunctionParameters: will need it at some point ctx, initContext, initOptions, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct.ts new file mode 100644 index 000000000..baf992444 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct.ts @@ -0,0 +1,76 @@ +import { + addDuration, + type FeatureOptions, + FreeTrialDuration, + type FullCusProduct, + type FullCustomer, + type FullProduct, + findFeatureByIdOrInternalId, + type InitFullCustomerProductContext, + isPrepaidPrice, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initFullCustomerProduct } from "./initFullCustomerProduct"; + +export const initFullCustomerProductFromProduct = ({ + ctx, + initContext, +}: { + ctx: AutumnContext; + initContext: { + fullCustomer: FullCustomer; + fullProduct: FullProduct; + currentEpochMs: number; + featureQuantities?: FeatureOptions[]; + }; +}): FullCusProduct => { + const { fullCustomer, fullProduct, currentEpochMs } = initContext; + + const freeTrial = fullProduct.free_trial ?? null; + let trialEndsAt: number | undefined; + // const now = initOptions?.currentEpochMs ?? Date.now(); + + if (freeTrial) { + trialEndsAt = addDuration({ + now: currentEpochMs, + durationType: freeTrial.duration ?? FreeTrialDuration.Day, + durationLength: freeTrial.length ?? 1, + }); + } + + const featureQuantities: FeatureOptions[] = []; + const prices = fullProduct.prices; + + for (const price of prices) { + if (isPrepaidPrice(price)) { + const feature = findFeatureByIdOrInternalId({ + features: ctx.features, + featureIdOrInternalId: price.config.feature_id, + }); + + if (!feature) continue; + + featureQuantities.push({ + feature_id: feature.id, + internal_feature_id: feature.internal_id, + quantity: 0, + }); + } + } + + const newInitContext: InitFullCustomerProductContext = { + fullCustomer, + fullProduct, + featureQuantities, + resetCycleAnchor: "now", + freeTrial, + trialEndsAt, + now: currentEpochMs, + }; + + return initFullCustomerProduct({ + ctx, + initContext: newInitContext, + initOptions: {}, + }); +}; diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 3d7a0bf59..39e031f93 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -9,10 +9,19 @@ import { ErrCode, type FullCusProduct, type FullCustomer, + InternalError, type Organization, RecaseError, } from "@autumn/shared"; -import { and, eq, ilike, or, sql, type Table } from "drizzle-orm"; +import { + and, + eq, + getTableColumns, + ilike, + or, + sql, + type Table, +} from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withSpan } from "../analytics/tracer/spanUtils.js"; @@ -207,10 +216,7 @@ export class CusService { } static async insert({ db, data }: { db: DrizzleCli; data: Customer }) { - const results = await db - .insert(customers) - .values(data as any) - .returning(); + const results = await db.insert(customers).values(data).returning(); // If insert succeeded, return the new customer if (results && results.length > 0) { @@ -234,6 +240,117 @@ export class CusService { return null; } + /** + * Upsert a customer using the email + null ID constraint. + * + * If a customer with the same (org_id, env, email) exists with id = NULL, + * update that row with the new customer data (including the new ID). + * Otherwise, insert a new row. + * + * Returns { customer, wasUpdate } to indicate if an existing row was updated. + */ + static async upsert({ + db, + data, + }: { + db: DrizzleCli; + data: Customer; + }): Promise<{ customer: Customer; wasUpdate: boolean }> { + const columns = getTableColumns(customers); + const columnNames = Object.values(columns).map((col) => col.name); + + // Build values array, handling jsonb columns specially + const values = Object.entries(columns).map(([key, col]) => { + const value = data[key as keyof Customer]; + // jsonb columns need JSON.stringify + if (col.dataType === "json") { + const jsonValue = + value !== undefined && value !== null + ? JSON.stringify(value) + : col.default !== undefined + ? "{}" + : "null"; + return sql`${jsonValue}::jsonb`; + } + return sql`${value ?? null}`; + }); + + // Build UPDATE SET clauses + const excludeFromUpdate = ["internal_id", "org_id", "env", "created_at"]; + + // For ON CONFLICT - uses EXCLUDED.column_name + const updateColsExcluded = Object.values(columns) + .filter((col) => !excludeFromUpdate.includes(col.name)) + .map((col) => sql.raw(`${col.name} = EXCLUDED.${col.name}`)); + + // For CTE claim UPDATE - uses direct values + const updateColsValues = Object.entries(columns) + .filter(([_, col]) => !excludeFromUpdate.includes(col.name)) + .map(([key, col]) => { + const value = data[key as keyof Customer]; + if (col.dataType === "json") { + const jsonValue = + value !== undefined && value !== null + ? JSON.stringify(value) + : col.default !== undefined + ? "{}" + : "null"; + return sql`${sql.raw(col.name)} = ${jsonValue}::jsonb`; + } + return sql`${sql.raw(col.name)} = ${value ?? null}`; + }); + + // Conflict target differs based on incoming id: + // - id != NULL: Use cus_id_constraint (handles ID collisions) + // - id = NULL: Use partial index (handles email collisions for null-id rows) + const conflictClause = + data.id !== null + ? sql`ON CONFLICT ON CONSTRAINT cus_id_constraint` + : sql`ON CONFLICT (org_id, env, lower(email)) WHERE id IS NULL AND email IS NOT NULL AND email != ''`; + + // CTE handles all cases: + // - Case A (id=NULL → id=NULL same email): claim updates existing + // - Case B (id=x → id=x): insert_new conflicts on cus_id_constraint, upserts + // - Case C (id=NULL → id=y same email): claim updates existing, sets new id + const results = await db.execute< + Customer & { xmax: string; was_claim: boolean } + >(sql` + WITH claim AS ( + UPDATE customers + SET ${sql.join(updateColsValues, sql`, `)} + WHERE org_id = ${data.org_id} + AND env = ${data.env} + AND id IS NULL + AND email IS NOT NULL + AND lower(email) = lower(${data.email ?? ""}) + RETURNING *, xmax::text, true as was_claim + ), + insert_new AS ( + INSERT INTO customers (${sql.raw(columnNames.join(", "))}) + SELECT ${sql.join(values, sql`, `)} + WHERE NOT EXISTS (SELECT 1 FROM claim) + ${conflictClause} + DO UPDATE SET ${sql.join(updateColsExcluded, sql`, `)} + RETURNING *, xmax::text, false as was_claim + ) + SELECT * FROM claim + UNION ALL + SELECT * FROM insert_new + `); + + if (results && results.length > 0) { + const { xmax, was_claim, ...customer } = results[0]; + // wasUpdate if: claimed existing row OR xmax indicates update + const wasUpdate = was_claim || xmax !== "0"; + return { customer: customer as Customer, wasUpdate }; + } + + throw new InternalError({ + message: + "[CusService.upsert] Failed to insert customer, no results returned", + }); + } + static async update({ db, idOrInternalId, diff --git a/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts new file mode 100644 index 000000000..85af1de34 --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/createCustomerContext.ts @@ -0,0 +1,12 @@ +import type { FullCustomer, FullProduct } from "@autumn/shared"; +import type { TrialContext } from "@/internal/billing/v2/billingContext"; + +export interface CreateCustomerContextFree { + fullCustomer: FullCustomer; + fullProducts: FullProduct[]; + currentEpochMs: number; + trialContext?: TrialContext; + hasPaidProducts: boolean; +} + +export type CreateCustomerContext = CreateCustomerContextFree; diff --git a/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts b/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts new file mode 100644 index 000000000..e8a8b75db --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts @@ -0,0 +1,47 @@ +import type { + CreateCustomerInternalOptions, + CustomerData, + FullCustomer, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { executeCreateCustomerPlan } from "./execute/executeCreateCustomerPlan.js"; +import { logCreateCustomerContext } from "./logs/logCreateCustomer.js"; +import { setupCreateCustomer } from "./setup/setupCreateCustomer.js"; + +/** + * Create a customer and attach default products. + * + * Flow: + * 1. Setup: init customer, fetch defaults, setup Stripe (if paid) + * 2. Compute: build customer products, autumn plan, stripe plan + * 3. Execute: transaction + Stripe + build final customer + * + * Idempotency: + * - Email exists with id=NULL, new request has id=NULL: Returns existing customer + * - Email exists with id=NULL, new request has ID: Claims the row (sets ID) + * - Customer ID already exists: Returns existing customer + */ +export const createCustomerWithDefaults = async ({ + ctx, + customerId, + customerData, + internalOptions, +}: { + ctx: AutumnContext; + customerId: string | null; + customerData?: CustomerData; + internalOptions?: CreateCustomerInternalOptions; +}): Promise => { + // 1. Setup + const context = await setupCreateCustomer({ + ctx, + customerId, + customerData, + internalOptions, + }); + + logCreateCustomerContext({ ctx, context }); + + // 3. Execute + return executeCreateCustomerPlan({ ctx, context }); +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts new file mode 100644 index 000000000..7a81cd86a --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts @@ -0,0 +1,85 @@ +import { + CustomerAlreadyExistsError, + type FullCustomer, + tryCatch, +} from "@autumn/shared"; +import { isUniqueConstraintError } from "@/db/dbUtils.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan.js"; +import { CusService } from "../../../CusService.js"; + +export type ExecuteAutumnResult = + | { type: "created"; fullCustomer: FullCustomer } + | { type: "existing"; fullCustomer: FullCustomer }; + +/** + * Execute the Autumn (DB) part of customer creation. + * + * 1. Transaction: upsert customer + insert customer products + * 2. Handle race conditions by returning existing customer + * + * Returns discriminated union to indicate if customer was created or already existed. + */ +export const executeAutumnCreateCustomerPlan = async ({ + ctx, + fullCustomer, + autumnBillingPlan, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + autumnBillingPlan: AutumnBillingPlan; +}): Promise => { + const { db, logger } = ctx; + + const { data: newFullCustomer, error } = await tryCatch( + db.transaction(async (tx) => { + const txDb = tx as unknown as DrizzleCli; + + const upsertResult = await CusService.upsert({ + db: txDb, + data: fullCustomer, + }); + + if (upsertResult.wasUpdate) { + fullCustomer.internal_id = upsertResult.customer.internal_id; + throw new CustomerAlreadyExistsError({ + customerId: fullCustomer.id || fullCustomer.internal_id, + }); + } + + await executeAutumnBillingPlan({ + ctx: { ...ctx, db: txDb }, + autumnBillingPlan, + }); + + return { + ...fullCustomer, + customer_products: autumnBillingPlan.insertCustomerProducts, + }; + }), + ); + + // Handle existing customer (from upsert or race condition) + if (error) { + if ( + error instanceof CustomerAlreadyExistsError || + isUniqueConstraintError(error) + ) { + logger.info( + `Customer already exists, returning existing: ${fullCustomer.id || fullCustomer.email}`, + ); + const existingCustomer = await CusService.getFull({ + db, + idOrInternalId: fullCustomer.id || fullCustomer.internal_id, + orgId: ctx.org.id, + env: ctx.env, + }); + return { type: "existing", fullCustomer: existingCustomer }; + } + throw error; + } + + return { type: "created", fullCustomer: newFullCustomer }; +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeCreateCustomerPlan.ts new file mode 100644 index 000000000..06b23fcaa --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeCreateCustomerPlan.ts @@ -0,0 +1,68 @@ +import type { FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan.js"; +import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct.js"; +import type { CreateCustomerContext } from "../createCustomerContext.js"; +import { logAutumnPlanResult } from "../logs/logCreateCustomer.js"; +import { executeAutumnCreateCustomerPlan } from "./executeAutumnCreateCustomerPlan.js"; +import { executeStripeCreateCustomerPlan } from "./executeStripeCreateCustomerPlan.js"; + +/** + * Execute step for creating a customer with defaults. + * + * Flow: + * 1. Compute: build customer products + autumn billing plan + * 2. Execute Autumn: DB transaction (upsert customer + insert products) + * 3. Execute Stripe: create Stripe customer + subscription (if paid products) + * + * Handles idempotency: + * - If customer already exists (wasUpdate or race condition), returns existing customer + * - Otherwise creates new customer with products and Stripe subscription + */ +export const executeCreateCustomerPlan = async ({ + ctx, + context, +}: { + ctx: AutumnContext; + context: CreateCustomerContext; +}): Promise => { + const { fullCustomer, fullProducts, currentEpochMs } = context; + + // 1. Compute autumn billing plan (no Stripe customer needed yet) + const insertCustomerProducts = fullProducts.map((product) => + initFullCustomerProductFromProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct: product, + currentEpochMs, + }, + }), + ); + + const autumnBillingPlan: AutumnBillingPlan = { + insertCustomerProducts, + }; + + // 2. Execute Autumn (DB) - handles race conditions + const autumnResult = await executeAutumnCreateCustomerPlan({ + ctx, + fullCustomer, + autumnBillingPlan, + }); + + logAutumnPlanResult({ ctx, result: autumnResult }); + + // If customer already existed, return it (no Stripe work needed) + if (autumnResult.type === "existing") return autumnResult.fullCustomer; + if (!context.hasPaidProducts) return autumnResult.fullCustomer; + + // must pass in old full customer to ensure subscription plan is correctly determined... + await executeStripeCreateCustomerPlan({ + ctx, + context, + autumnBillingPlan, + }); + + return context.fullCustomer; +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeStripeCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeStripeCreateCustomerPlan.ts new file mode 100644 index 000000000..57b48e11d --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeStripeCreateCustomerPlan.ts @@ -0,0 +1,92 @@ +import type Stripe from "stripe"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers/index.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js"; +import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js"; +import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan.js"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe.js"; +import type { CreateCustomerContext } from "../createCustomerContext.js"; + +/** + * Execute the Stripe part of customer creation. + * + * 1. Get or create Stripe customer (idempotent based on internal_id) + * 2. Evaluate Stripe billing plan + * 3. Execute Stripe billing plan (create subscription) + * + * Must be called AFTER executeAutumnCreateCustomerPlan succeeds to ensure + * we have the correct internal_id for idempotency. + */ +export const executeStripeCreateCustomerPlan = async ({ + ctx, + context, + autumnBillingPlan, +}: { + ctx: AutumnContext; + context: CreateCustomerContext; + autumnBillingPlan: AutumnBillingPlan; +}): Promise => { + const { fullCustomer, fullProducts, trialContext } = context; + + // 1. Get or create Stripe customer (idempotent) + const stripeCustomer = await getOrCreateStripeCustomer({ + ctx, + customer: fullCustomer, + }); + + // 2. Build billing context with Stripe customer + const billingContext = { + fullCustomer, + stripeCustomer, + fullProducts, + featureQuantities: [], + currentEpochMs: Date.now(), + billingCycleAnchorMs: "now" as const, + resetCycleAnchorMs: "now" as const, + trialContext, + customPrices: [], + customEnts: [], + isCustom: false, + }; + + // 3. Evaluate Stripe billing plan + const stripeBillingPlan = await evaluateStripeBillingPlan({ + ctx, + billingContext, + autumnBillingPlan, + }); + + logStripeBillingPlan({ + ctx, + stripeBillingPlan, + billingContext, + }); + + // 4. Execute Stripe billing plan + const { stripeSubscription } = await executeStripeBillingPlan({ + ctx, + billingPlan: { autumn: autumnBillingPlan, stripe: stripeBillingPlan }, + billingContext, + }); + + if (stripeSubscription) { + for (const cusProduct of autumnBillingPlan.insertCustomerProducts) { + await CusProductService.update({ + db: ctx.db, + cusProductId: cusProduct.id, + updates: { subscription_ids: cusProduct.subscription_ids }, + }); + } + + context.fullCustomer.subscriptions = [ + initSubscriptionFromStripe({ ctx, stripeSubscription }), + ]; + + context.fullCustomer.customer_products = + autumnBillingPlan.insertCustomerProducts; + } + + return stripeSubscription; +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/logs/logCreateCustomer.ts b/server/src/internal/customers/actions/createWithDefaults/logs/logCreateCustomer.ts new file mode 100644 index 000000000..cdb406665 --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/logs/logCreateCustomer.ts @@ -0,0 +1,51 @@ +import { formatMs } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; +import type { CreateCustomerContext } from "../createCustomerContext"; +import type { ExecuteAutumnResult } from "../execute/executeAutumnCreateCustomerPlan"; + +export const logCreateCustomerContext = ({ + ctx, + context, +}: { + ctx: AutumnContext; + context: CreateCustomerContext; +}) => { + const { fullCustomer, fullProducts, currentEpochMs, trialContext, hasPaidProducts } = context; + + addToExtraLogs({ + ctx, + extras: { + createCustomerContext: { + customer: `${fullCustomer.id ?? fullCustomer.internal_id} | ${fullCustomer.email ?? "no email"}`, + products: fullProducts.map((p) => `${p.id} (v${p.version})`).join(", ") || "none", + hasPaidProducts, + currentEpochMs: formatMs(currentEpochMs), + trialContext: trialContext + ? `ends at: ${formatMs(trialContext.trialEndsAt)}, free trial: ${trialContext.freeTrial?.id ?? "none"}, card required: ${trialContext.cardRequired}` + : "none", + }, + }, + }); +}; + +export const logAutumnPlanResult = ({ + ctx, + result, +}: { + ctx: AutumnContext; + result: ExecuteAutumnResult; +}) => { + addToExtraLogs({ + ctx, + extras: { + autumnPlanResult: { + type: result.type, + internalId: result.fullCustomer.internal_id, + customerProducts: result.fullCustomer.customer_products?.map( + (cp) => `${cp.product_id} (status: ${cp.status})`, + ) ?? [], + }, + }, + }); +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts new file mode 100644 index 000000000..7914c7d3c --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomer.ts @@ -0,0 +1,61 @@ +import { + type CreateCustomerInternalOptions, + type CustomerData, + RecaseError, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { initFullCustomer } from "../../../cusUtils/initCustomer.js"; +import type { CreateCustomerContext } from "../createCustomerContext.js"; +import { setupCreateCustomerTrialContext } from "./setupCreateCustomerTrialContext.js"; +import { setupDefaultProductsContext } from "./setupDefaultProductsContext.js"; + +/** + * Setup step for creating a customer with defaults. + * + * 1. Init full customer + * 2. Fetch default products + * 3. Setup Stripe customer + trial context IF paid products exist + */ +export const setupCreateCustomer = async ({ + ctx, + customerId, + customerData, + internalOptions, +}: { + ctx: AutumnContext; + customerId: string | null; + customerData?: CustomerData; + internalOptions?: CreateCustomerInternalOptions; +}): Promise => { + // 1. Validate + if (!customerId && !customerData?.email) { + throw new RecaseError({ + message: "Either customer ID or email is required", + }); + } + + // 2. Init full customer + const fullCustomer = initFullCustomer({ ctx, customerId, customerData }); + + // 3. Fetch default products + const { fullProducts, paidProducts, hasPaidProducts } = + await setupDefaultProductsContext({ ctx, internalOptions }); + + const currentEpochMs = Date.now(); + + // 6. Setup trial context + const trialContext = setupCreateCustomerTrialContext({ + paidProducts, + currentEpochMs, + }); + + // 7. Return paid context (extends BillingContext) + return { + fullCustomer, + fullProducts, + + currentEpochMs, + trialContext, + hasPaidProducts, + }; +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts new file mode 100644 index 000000000..3c2eafbe4 --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupCreateCustomerTrialContext.ts @@ -0,0 +1,37 @@ +import { + addDuration, + FreeTrialDuration, + type FullProduct, + InternalError, +} from "@autumn/shared"; +import type { TrialContext } from "@/internal/billing/v2/billingContext.js"; + +export const setupCreateCustomerTrialContext = ({ + paidProducts, + currentEpochMs, +}: { + paidProducts: FullProduct[]; + currentEpochMs: number; +}): TrialContext | undefined => { + if (!paidProducts?.length) return undefined; + + const trial = paidProducts.find((p) => p.is_default && Boolean(p.free_trial)); + + if (!trial) { + throw new InternalError({ + message: + "[setupCreateCustomerTrialContext] No trial product found for paid defaults", + }); + } + + return { + freeTrial: trial.free_trial, + trialEndsAt: addDuration({ + now: currentEpochMs, + durationType: trial.free_trial?.duration ?? FreeTrialDuration.Day, + durationLength: trial.free_trial?.length, + }), + appliesToBilling: true, + cardRequired: false, + }; +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/setup/setupDefaultProductsContext.ts b/server/src/internal/customers/actions/createWithDefaults/setup/setupDefaultProductsContext.ts new file mode 100644 index 000000000..536fd89d2 --- /dev/null +++ b/server/src/internal/customers/actions/createWithDefaults/setup/setupDefaultProductsContext.ts @@ -0,0 +1,71 @@ +import { + type CreateCustomerInternalOptions, + type FullProduct, + isFreeProduct, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js"; + +export interface DefaultProductsContext { + fullProducts: FullProduct[]; + paidProducts: FullProduct[]; + hasPaidProducts: boolean; +} + +export const setupDefaultProductsContext = async ({ + ctx, + internalOptions, +}: { + ctx: AutumnContext; + internalOptions?: CreateCustomerInternalOptions; +}): Promise => { + const { db, org, env } = ctx; + + const defaultProds = await ProductService.listDefault({ + db, + orgId: org.id, + env, + }); + + const groups = new Set(defaultProds.map((p) => p.group)); + const groupToDefaultProd: Record = {}; + + for (const group of groups) { + const defaultProdsInGroup = defaultProds.filter((p) => p.group === group); + + if (defaultProdsInGroup.length === 0) continue; + + defaultProdsInGroup.sort((a, _b) => { + if (isDefaultTrialFullProduct({ product: a })) return -1; + if (!isFreeProduct({ prices: a.prices })) return -1; + return 0; + }); + + groupToDefaultProd[group] = defaultProdsInGroup[0]; + } + + let selectedProducts: FullProduct[] = []; + + if (internalOptions?.default_group) { + const defaultProd = groupToDefaultProd[internalOptions.default_group]; + selectedProducts = defaultProd ? [defaultProd] : []; + } else if (internalOptions?.disable_defaults) { + selectedProducts = []; + } else { + selectedProducts = Object.values(groupToDefaultProd); + } + + // Get paid products (for billing context) + const paidProducts = selectedProducts.filter( + (p) => + !isFreeProduct({ prices: p.prices }) && + isDefaultTrialFullProduct({ product: p }), + ); + + return { + fullProducts: selectedProducts, + paidProducts, + hasPaidProducts: paidProducts.length > 0, + }; +}; diff --git a/server/src/internal/customers/actions/index.ts b/server/src/internal/customers/actions/index.ts new file mode 100644 index 000000000..4860ff9aa --- /dev/null +++ b/server/src/internal/customers/actions/index.ts @@ -0,0 +1,5 @@ +import { createCustomerWithDefaults } from "./createWithDefaults/createCustomerWithDefaults.js"; + +export const customerActions = { + createWithDefaults: createCustomerWithDefaults, +} as const; diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index dbf1afa88..545837929 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -6,10 +6,8 @@ import { } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; -import { - createStripeCusIfNotExists, - getCusPaymentMethod, -} from "@/external/stripe/stripeCusUtils.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; +import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { @@ -158,12 +156,9 @@ export const checkStripeConnections = async ({ if (createCus) { batchProductUpdates.push( - createStripeCusIfNotExists({ - db, - org, - env, + getOrCreateStripeCustomer({ + ctx, customer, - logger, }), ); } diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts index ccd66d7ca..2680d3022 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts @@ -1,11 +1,9 @@ import type { Customer } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type Stripe from "stripe"; -import { - createStripeCusIfNotExists, - listCusPaymentMethods, -} from "@/external/stripe/stripeCusUtils.js"; -import { createStripeCli } from "../../../../../../external/connect/createStripeCli"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; +import { listCusPaymentMethods } from "@/external/stripe/stripeCusUtils.js"; export const getStripeCusData = async ({ ctx, @@ -20,15 +18,12 @@ export const getStripeCusData = async ({ return { stripeCus: undefined, paymentMethod: undefined, now: undefined }; } - const { logger, db, org, env } = ctx; + const { org, env } = ctx; const stripeCli = createStripeCli({ org, env }); - const stripeCus = await createStripeCusIfNotExists({ - db, - org, - env, + const stripeCus = await getOrCreateStripeCustomer({ + ctx, customer, - logger, }); const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index dbfbc368b..276166581 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -80,12 +80,9 @@ export const activateDefaultProduct = async ({ // Initialize Stripe customer and products if needed (for paid non-trial products) if (!defaultIsFree) { await initStripeCusAndProducts({ - db, - org, - env, + ctx, customer: fullCus, products: [defaultProd], - logger, }); } diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts deleted file mode 100644 index e403b984d..000000000 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - ApiBaseEntitySchema, - type ApiCustomer, - ApiCustomerSchema, - type AppEnv, - addToExpand, - CusExpand, - type CustomerLegacyData, - CustomerLegacyDataSchema, - filterOutEntitiesFromFullCustomer, - filterPlanAndFeatureExpand, -} from "@autumn/shared"; -import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; -import type { Redis } from "ioredis"; -import { redis } from "../../../../external/redis/initRedis.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { tryRedisRead } from "../../../../utils/cacheUtils/cacheUtils.js"; -import { normalizeFromSchema } from "../../../../utils/cacheUtils/normalizeFromSchema.js"; -import { CusService } from "../../CusService.js"; -import { getApiCustomerBase } from "../apiCusUtils/getApiCustomerBase.js"; -import { setCachedApiCustomer } from "./setCachedApiCustomer.js"; - -export const buildCachedApiCustomerKey = ({ - customerId, - orgId, - env, -}: { - customerId: string; - orgId: string; - env: string; -}) => { - return `{${orgId}}:${env}:customer:${CACHE_CUSTOMER_VERSION}:${customerId}`; -}; - -/** - * Get ApiCustomer from Redis cache - * If not found, fetch from DB, cache it, and return - * If skipCache is true, always fetch from DB - */ -export const getCachedApiCustomer = async ({ - ctx, - customerId, - skipEntityMerge = false, - source, - redisInstance, - cacheVersion, -}: { - ctx: AutumnContext; - customerId: string; - skipEntityMerge?: boolean; // If true, returns only customer's own features (no entity merging) - source?: string; - redisInstance?: Redis; // Optional redis instance for cross-region sync - cacheVersion?: string; // Optional cache version override (for sync) -}): Promise<{ apiCustomer: ApiCustomer; legacyData: CustomerLegacyData }> => { - const { org, env, db, skipCache } = ctx; - const redisClient = redisInstance || redis; - - const getExpandedApiCustomer = async () => { - // await redis.del( - // buildCachedApiCustomerKey({ customerId, orgId: org.id, env }), - // ); - // Try to get from cache using Lua script (unless skipCache is true) - if (!skipCache) { - const cachedResult = await tryRedisRead(() => - (redisClient as typeof redis).getCustomer( - cacheVersion || "", - org.id, - env, - customerId, - skipEntityMerge ? "true" : "false", - ), - ); - - if (cachedResult) { - const parsed = JSON.parse(cachedResult as string) as ApiCustomer & { - legacyData: CustomerLegacyData; - }; - - // Extract legacyData before normalization (not in schema) - const { legacyData, ...rest } = parsed; - - // Normalize the data based on schema - const normalized = normalizeFromSchema({ - schema: ApiCustomerSchema, - data: rest, - }); - - const normalizedLegacyData = normalizeFromSchema({ - schema: CustomerLegacyDataSchema, - data: legacyData, - }); - - return { - // ← This returns from getCachedApiCustomer! - apiCustomer: ApiCustomerSchema.parse(normalized), - legacyData: normalizedLegacyData, - }; - } - } - - // Cache miss or skipCache - fetch from DB - // Record timestamp before Postgres fetch for stale write prevention - - const fetchTimeMs = Date.now(); - - // Include invoices: - const fullCus = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env: env as AppEnv, - withEntities: true, - withSubs: true, - expand: [CusExpand.Invoices], - }); - - // Build ApiCustomer (base only, no expand) to return - const ctxWithExpand = addToExpand({ - ctx, - add: [CusExpand.Invoices, CusExpand.Entities], - }); - const { apiCustomer, legacyData } = await getApiCustomerBase({ - ctx: ctxWithExpand, - fullCus, - withAutumnId: true, - }); - - try { - apiCustomer.entities = fullCus.entities.map((e) => - ApiBaseEntitySchema.parse(e), - ); - } catch (error) { - ctx.logger.error( - `[getCachedApiCustomer] Error parsing entities: ${error}`, - ); - } - - const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({ - ctx, - fullCus: filterOutEntitiesFromFullCustomer({ fullCus }), - withAutumnId: true, - }); - - // Store customer and entity caches (only if not skipping cache) - if (!skipCache) { - await setCachedApiCustomer({ - ctx, - fullCus, - customerId, - source, - fetchTimeMs, - }); - } - - return { - apiCustomer: ApiCustomerSchema.parse( - skipEntityMerge ? masterApiCustomer : apiCustomer, - ), - legacyData, - }; - }; - - const { apiCustomer, legacyData } = await getExpandedApiCustomer(); - - const filteredApiCustomer = filterPlanAndFeatureExpand({ - expand: ctx.expand, - target: apiCustomer, - }); - - return { - apiCustomer: { - ...filteredApiCustomer, - rewards: filteredApiCustomer.rewards ?? undefined, - referrals: filteredApiCustomer.referrals ?? undefined, - payment_method: filteredApiCustomer.payment_method ?? undefined, - }, - legacyData, - }; -}; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts deleted file mode 100644 index 6ee214dc8..000000000 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ApiCustomer, FullCustomer } from "@autumn/shared"; -import { redis } from "../../../../external/redis/initRedis.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; - -/** - * Update customer detail fields in Redis cache if key exists - * Returns true if cache was updated, false if cache key doesn't exist - */ -export const setCachedApiCusDetails = async ({ - ctx, - customer, - updates, -}: { - ctx: AutumnContext; - customer: FullCustomer | ApiCustomer; - updates: { - name?: string; - email?: string; - fingerprint?: string; - metadata?: Record; - }; -}): Promise => { - const { org, env, logger } = ctx; - - const customerId = customer.id || (customer as FullCustomer).internal_id; - - let wasUpdated = false; - - // Try to update cache - await tryRedisWrite(async () => { - const result = await redis.setCustomerDetails( - JSON.stringify(updates), - org.id, - env, - customerId, - ); - - if (result === "OK") { - wasUpdated = true; - logger.info( - `Updated customer details cache for customer ${customerId}`, - updates, - ); - } else { - logger.info( - `Customer cache not found for customer ${customerId}, skipping cache update`, - ); - } - }); - - return wasUpdated; -}; diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index e1df9b1e5..18c27b07d 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -62,11 +62,13 @@ export const createNewCustomer = async ({ customer, nextResetAt, createDefaultProducts = true, + defaultGroup, }: { ctx: AutumnContext; customer: CreateCustomer; nextResetAt?: number; createDefaultProducts?: boolean; + defaultGroup?: string; }) => { const { db, org, env, logger } = ctx; @@ -135,23 +137,23 @@ export const createNewCustomer = async ({ defaultProds, }); - for (const group in groupToDefaultProd) { + // Filter to only the specified group if defaultGroup is provided + const groupsToProcess = defaultGroup + ? Object.keys(groupToDefaultProd).filter((g) => g === defaultGroup) + : Object.keys(groupToDefaultProd); + + for (const group of groupsToProcess) { const defaultProd = groupToDefaultProd[group]; logger.debug( `[createNewCustomer] Creating default product with ID: ${defaultProd?.id}`, ); if (!isFreeProduct(defaultProd.prices)) { - let stripeCli = null; - - stripeCli = createStripeCli({ org, env }); + const stripeCli = createStripeCli({ org, env }); await initStripeCusAndProducts({ - db, - org, - env, + ctx, customer: newCustomer, products: nonFreeProds, - logger, }); const optionsList = defaultProd.prices diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts index 5a8a71abe..8ce49916b 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts @@ -1,15 +1,16 @@ import { type AppEnv, type CheckParams, + type CreateCustomerInternalOptions, CusExpand, type Entity, type FullCustomer, type TrackParams, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { customerActions } from "@/internal/customers/actions/index.js"; import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; import { CusService } from "../../CusService.js"; -import { handleCreateCustomer } from "../../handlers/handleCreateCustomer.js"; import { updateCustomerDetails } from "../cusUtils.js"; import { deleteCachedFullCustomer } from "./deleteCachedFullCustomer.js"; import { getCachedFullCustomer } from "./getCachedFullCustomer.js"; @@ -22,12 +23,14 @@ export const getOrCreateCachedFullCustomer = async ({ ctx, params, source, + internalOptions, }: { ctx: AutumnContext; params: Omit & { customer_id: string | null; }; source?: string; + internalOptions?: CreateCustomerInternalOptions; }): Promise => { const { org, env, db, skipCache, logger } = ctx; const { @@ -73,50 +76,12 @@ export const getOrCreateCachedFullCustomer = async ({ // 3. Create if not found if (!fullCustomer) { - try { - fullCustomer = (await handleCreateCustomer({ - ctx, - cusData: { - id: customerId, - name: customerData?.name, - email: customerData?.email, - fingerprint: customerData?.fingerprint, - metadata: customerData?.metadata || {}, - stripe_id: customerData?.stripe_id, - }, - createDefaultProducts: customerData?.disable_default !== true, - })) as FullCustomer; - - fullCustomer = await CusService.getFull({ - db, - idOrInternalId: customerId || fullCustomer.internal_id, - orgId: org.id, - env: env as AppEnv, - withEntities: true, - withSubs: true, - entityId, - expand: [CusExpand.Invoices], - }); - // biome-ignore lint/suspicious/noExplicitAny: it's fine. - } catch (error: any) { - if (error?.code === "23505" && customerId) { - ctx.logger.debug( - `[getOrCreateCachedFullCustomer] insert customer duplicate key error`, - ); - fullCustomer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env: env as AppEnv, - withEntities: true, - withSubs: true, - entityId, - expand: [CusExpand.Invoices], - }); - } else { - throw error; - } - } + fullCustomer = await customerActions.createWithDefaults({ + ctx, + customerId, + customerData, + internalOptions, + }); } // 4. Update customer details if provided diff --git a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts deleted file mode 100644 index 300a2804b..000000000 --- a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - ApiBaseEntitySchema, - type ApiCustomer, - type Customer, - type CustomerData, - type CustomerLegacyData, - CustomerNotFoundError, - type EntityData, -} from "@autumn/shared"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { autoCreateEntity } from "../../entities/handlers/handleCreateEntity/autoCreateEntity.js"; -import { CusService } from "../CusService.js"; -import { handleCreateCustomer } from "../handlers/handleCreateCustomer.js"; -import { getApiCustomerBase } from "./apiCusUtils/getApiCustomerBase.js"; -import { updateCustomerDetails } from "./cusUtils.js"; -import { deleteCachedFullCustomer } from "./fullCustomerCacheUtils/deleteCachedFullCustomer.js"; -import { getOrSetCachedFullCustomer } from "./fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; - -export const getOrCreateApiCustomer = async ({ - ctx, - customerId, - customerData, - entityId, - entityData, -}: { - ctx: AutumnContext; - customerId: string | null; - customerData?: CustomerData; - entityId?: string; - entityData?: EntityData; -}): Promise<{ apiCustomer: ApiCustomer; legacyData?: CustomerLegacyData }> => { - // ======================================== - // Phase 1: Get or Create Customer - // ======================================== - let apiCustomer: ApiCustomer; - let legacyData: CustomerLegacyData | undefined; - - // Path A: customerId is NULL - always create new customer - if (!customerId) { - const newCustomer = await handleCreateCustomer({ - ctx, - cusData: { - id: null, - name: customerData?.name, - email: customerData?.email, - fingerprint: customerData?.fingerprint, - metadata: customerData?.metadata || {}, - stripe_id: customerData?.stripe_id, - }, - createDefaultProducts: customerData?.disable_default !== true, - }); - - const fullCus = await getOrSetCachedFullCustomer({ - ctx, - customerId: newCustomer.id || newCustomer.internal_id, - source: "getOrCreateApiCustomer", - }); - const res = await getApiCustomerBase({ ctx, fullCus }); - apiCustomer = res.apiCustomer; - legacyData = res.legacyData; - } - // Path B: customerId is NOT NULL - try to get, create if not found - else { - // Try to get existing customer from cache/DB - let apiCustomerOrUndefined: ApiCustomer | undefined; - - try { - const fullCus = await getOrSetCachedFullCustomer({ - ctx, - customerId, - source: "getOrCreateApiCustomer", - }); - const res = await getApiCustomerBase({ ctx, fullCus }); - apiCustomerOrUndefined = res.apiCustomer; - legacyData = res.legacyData; - } catch (_error) { - if (_error instanceof CustomerNotFoundError) { - // Customer doesn't exist yet - } else { - throw _error; - } - } - - // If customer not found, create it - if (!apiCustomerOrUndefined) { - // Race conditions are now handled gracefully at the DB level with ON CONFLICT - let newCustomer: Customer | undefined; - try { - newCustomer = await handleCreateCustomer({ - ctx, - cusData: { - id: customerId, - name: customerData?.name, - email: customerData?.email, - fingerprint: customerData?.fingerprint, - metadata: customerData?.metadata || {}, - stripe_id: customerData?.stripe_id, - }, - createDefaultProducts: customerData?.disable_default !== true, - }); - - newCustomer = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes( - "duplicate key value violates unique constraint", - ) && - customerId - ) { - ctx.logger.info( - `[getOrCreateApiCustomer] Customer ${customerId} already exists, fetching existing customer`, - ); - - const existingCustomer = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - // Race condition, don't set in cache - ctx.skipCache = true; - - if (existingCustomer) newCustomer = existingCustomer; - } else { - throw error; - } - } - - const fullCus = await getOrSetCachedFullCustomer({ - ctx, - customerId: newCustomer?.id || newCustomer?.internal_id || "", - source: "getOrCreateApiCustomer", - }); - const res = await getApiCustomerBase({ ctx, fullCus }); - apiCustomerOrUndefined = res.apiCustomer; - legacyData = res.legacyData; - } - - apiCustomer = apiCustomerOrUndefined; - } - - // ======================================== - // Phase 2: Update Customer Details - // ======================================== - const updated = await updateCustomerDetails({ - ctx, - customer: apiCustomer, - customerData, - }); - - // If updated, invalidate cache and get the latest ApiCustomer - if (updated) { - await deleteCachedFullCustomer({ - customerId: apiCustomer.id || "", - ctx, - source: "getOrCreateApiCustomer", - }); - const fullCus = await getOrSetCachedFullCustomer({ - ctx, - customerId: apiCustomer.id || "", - source: "getOrCreateApiCustomer", - }); - const res = await getApiCustomerBase({ ctx, fullCus }); - apiCustomer = res.apiCustomer; - legacyData = res.legacyData; - } - - // AUTO CREATE ENTITY - - if ( - entityId && - customerId && - !apiCustomer.entities?.some((e) => e.id === entityId) - ) { - ctx.logger.info( - `[getOrCreateApiCustomer] Auto creating entity ${entityId} for customer ${customerId}`, - ); - - const newEntity = await autoCreateEntity({ - ctx, - customerId: customerId || "", - entityId, - entityData: { - name: entityData?.name, - feature_id: entityData?.feature_id || "", - }, - }); - - await deleteCachedFullCustomer({ - customerId, - ctx, - source: "getOrCreateApiCustomer", - }); - - // Warm up the cache - await getOrSetCachedFullCustomer({ - ctx, - customerId, - source: "getOrCreateApiCustomer", - }); - - const apiEntity = ApiBaseEntitySchema.parse(newEntity); - apiCustomer.entities = [...(apiCustomer.entities || []), apiEntity]; - } - - return { - apiCustomer, - legacyData, - }; -}; diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 4725832f2..deea6bc35 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -6,10 +6,10 @@ import { type EntityData, type FullCustomer, } from "@autumn/shared"; +import { customerActions } from "@/internal/customers/actions/index.js"; import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { CusService } from "../CusService.js"; -import { handleCreateCustomer } from "../handlers/handleCreateCustomer.js"; import { updateCustomerDetails } from "./cusUtils.js"; export const getOrCreateCustomer = async ({ @@ -65,49 +65,54 @@ export const getOrCreateCustomer = async ({ } if (!customer) { - try { - customer = (await handleCreateCustomer({ - ctx, - cusData: { - id: customerId, - name: customerData?.name, - email: customerData?.email, - fingerprint: customerData?.fingerprint, - metadata: customerData?.metadata || {}, - stripe_id: customerData?.stripe_id, - // default_product_id: customerData?.default_product_id, - }, - createDefaultProducts: customerData?.disable_default !== true, - })) as FullCustomer; + customer = await customerActions.createWithDefaults({ + ctx, + customerId, + customerData, + }); + // try { + // customer = (await handleCreateCustomer({ + // ctx, + // cusData: { + // id: customerId, + // name: customerData?.name, + // email: customerData?.email, + // fingerprint: customerData?.fingerprint, + // metadata: customerData?.metadata || {}, + // stripe_id: customerData?.stripe_id, + // // default_product_id: customerData?.default_product_id, + // }, + // createDefaultProducts: customerData?.disable_default !== true, + // })) as FullCustomer; - customer = await CusService.getFull({ - db, - idOrInternalId: customerId || customer.internal_id, - orgId: org.id, - env, - inStatuses, - withEntities, - entityId, - expand, - withSubs: true, - }); - } catch (error: any) { - if (error?.code === "23505" && customerId) { - customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses, - withEntities, - entityId, - expand, - withSubs: true, - }); - } else { - throw error; - } - } + // customer = await CusService.getFull({ + // db, + // idOrInternalId: customerId || customer.internal_id, + // orgId: org.id, + // env, + // inStatuses, + // withEntities, + // entityId, + // expand, + // withSubs: true, + // }); + // } catch (error: any) { + // if (error?.code === "23505" && customerId) { + // customer = await CusService.getFull({ + // db, + // idOrInternalId: customerId, + // orgId: org.id, + // env, + // inStatuses, + // withEntities, + // entityId, + // expand, + // withSubs: true, + // }); + // } else { + // throw error; + // } + // } } if (!skipUpdate) { diff --git a/server/src/internal/customers/cusUtils/initCustomer.ts b/server/src/internal/customers/cusUtils/initCustomer.ts new file mode 100644 index 000000000..15db03418 --- /dev/null +++ b/server/src/internal/customers/cusUtils/initCustomer.ts @@ -0,0 +1,49 @@ +import type { Customer, CustomerData, FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { generateId } from "@/utils/genUtils.js"; + +/** + * Build a Customer object ready for insertion. + */ +export const initCustomer = ({ + ctx, + customerId, + customerData, +}: { + ctx: AutumnContext; + customerId: string | null; + customerData?: CustomerData; +}): Customer => { + const { org, env } = ctx; + const internalId = generateId("cus"); + + return { + internal_id: internalId, + id: customerId, + org_id: org.id, + env, + name: customerData?.name || "", + email: customerData?.email || "", + fingerprint: customerData?.fingerprint, + metadata: customerData?.metadata ?? {}, + created_at: Date.now(), + processor: null, + }; +}; + +export const initFullCustomer = ({ + ctx, + customerId, + customerData, +}: { + ctx: AutumnContext; + customerId: string | null; + customerData?: CustomerData; +}): FullCustomer => { + return { + ...initCustomer({ ctx, customerId, customerData }), + customer_products: [], + entities: [], + extra_customer_entitlements: [], + }; +}; diff --git a/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts b/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts index 8437703f7..fb56e9c4c 100644 --- a/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts +++ b/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts @@ -4,7 +4,7 @@ import { RecaseError, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { RewardService } from "../../rewards/RewardService.js"; import { CusService } from "../CusService.js"; @@ -47,12 +47,9 @@ export const handleAddCouponToCusV2 = createRoute({ legacyVersion: true, }); - await createStripeCusIfNotExists({ - db, - org, - env, + await getOrCreateStripeCustomer({ + ctx, customer, - logger, }); // Attach coupon to customer diff --git a/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts b/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts index cbe0dc716..95f16b785 100644 --- a/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts +++ b/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts @@ -1,6 +1,6 @@ import { type Customer, InternalError } from "@autumn/shared"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; -import { createStripeCusIfNotExists } from "../../../../external/stripe/stripeCusUtils"; +import { getOrCreateStripeCustomer } from "../../../../external/stripe/customers"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { toSuccessUrl } from "../../../orgs/orgUtils/convertOrgUtils"; import { createDefaultPortalConfig } from "./createDefaultPortalConfig"; @@ -20,27 +20,12 @@ export const createBillingPortalSession = async ({ const stripeCli = createStripeCli({ org, env }); // Determine the Stripe customer ID to use - let stripeCustomerId: string; + const stripeCustomer = await getOrCreateStripeCustomer({ + ctx, + customer, + }); - if (!customer.processor?.id) { - const newCus = await createStripeCusIfNotExists({ - db, - org, - env, - customer, - logger, - }); - - if (!newCus) { - throw new InternalError({ - message: `Failed to create Stripe customer`, - }); - } - - stripeCustomerId = newCus.id; - } else { - stripeCustomerId = customer.processor.id; - } + const stripeCustomerId = stripeCustomer.id; // 1. Try to create billing portal session diff --git a/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts b/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts index a08b16429..f42be8e1f 100644 --- a/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts +++ b/server/src/internal/customers/handlers/handleBillingPortal/handleGetBillingPortal.ts @@ -1,7 +1,8 @@ import { ErrCode, RecaseError } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; -import { createStripeCusIfNotExists } from "../../../../external/stripe/stripeCusUtils"; +import { getOrCreateStripeCustomer } from "../../../../external/stripe/customers"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { routeHandler } from "../../../../utils/routerUtils"; import { OrgService } from "../../../orgs/OrgService"; import { toSuccessUrl } from "../../../orgs/orgUtils/convertOrgUtils"; @@ -35,24 +36,12 @@ export const handleGetBillingPortal = (req: any, res: any) => const stripeCli = createStripeCli({ org, env: req.env }); - let stripeCusId: string = customer.processor?.id; - if (!customer.processor?.id) { - const newCus = await createStripeCusIfNotExists({ - db: req.db, - org, - env: req.env, - customer, - logger: req.logger, - }); + const stripeCustomer = await getOrCreateStripeCustomer({ + ctx: req as AutumnContext, + customer, + }); - if (!newCus) { - throw new RecaseError({ - message: `Failed to create Stripe customer`, - }); - } - - stripeCusId = newCus.id; - } + const stripeCusId = stripeCustomer.id; const portal = await stripeCli.billingPortal.sessions.create({ customer: stripeCusId, diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index e79fa1b71..5cd62aa01 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -1,15 +1,13 @@ import { - type AppEnv, type CreateCustomer, CreateCustomerSchema, type Customer, ErrCode, type FullProduct, - type Organization, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import type { Stripe } from "stripe"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; import { CusService } from "@/internal/customers/CusService.js"; import { initProductInStripe } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -18,27 +16,20 @@ import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { createNewCustomer } from "../cusUtils/createNewCustomer.js"; export const initStripeCusAndProducts = async ({ - db, - org, - env, + ctx, customer, products, - logger, }: { - db: DrizzleCli; - org: Organization; - env: AppEnv; + ctx: AutumnContext; customer: Customer; products: FullProduct[]; - logger: any; }) => { - const batchInit: any[] = [ - createStripeCusIfNotExists({ - db, - org, - env, + const { db, org, env, logger } = ctx; + + const batchInit: Promise[] = [ + getOrCreateStripeCustomer({ + ctx, customer, - logger, }), ]; @@ -61,10 +52,12 @@ const handleIdIsNull = async ({ ctx, newCus, createDefaultProducts, + defaultGroup, }: { ctx: AutumnContext; newCus: CreateCustomer; createDefaultProducts?: boolean; + defaultGroup?: string; }) => { const { db, org, env, logger } = ctx; @@ -106,6 +99,7 @@ const handleIdIsNull = async ({ ctx, customer: newCus, createDefaultProducts, + defaultGroup, }); return createdCustomer; @@ -116,10 +110,12 @@ export const handleCreateCustomerWithId = async ({ ctx, newCus, createDefaultProducts = true, + defaultGroup, }: { ctx: AutumnContext; newCus: CreateCustomer; createDefaultProducts?: boolean; + defaultGroup?: string; }) => { const { db, org, env, logger } = ctx; @@ -175,6 +171,7 @@ export const handleCreateCustomerWithId = async ({ ctx, customer: newCus, createDefaultProducts, + defaultGroup, }); }; @@ -182,10 +179,12 @@ export const handleCreateCustomer = async ({ ctx, cusData, createDefaultProducts = true, + defaultGroup, }: { ctx: AutumnContext; cusData: CreateCustomer; createDefaultProducts?: boolean; + defaultGroup?: string; }) => { const newCus = CreateCustomerSchema.parse(cusData); @@ -197,12 +196,14 @@ export const handleCreateCustomer = async ({ ctx, newCus, createDefaultProducts, + defaultGroup, }); } else { createdCustomer = await handleCreateCustomerWithId({ ctx, newCus, createDefaultProducts, + defaultGroup, }); } diff --git a/server/src/internal/customers/handlers/handlePostCustomerV2.ts b/server/src/internal/customers/handlers/handlePostCustomerV2.ts index 363b34b23..391e3a6cd 100644 --- a/server/src/internal/customers/handlers/handlePostCustomerV2.ts +++ b/server/src/internal/customers/handlers/handlePostCustomerV2.ts @@ -5,13 +5,12 @@ import { CreateCustomerParamsSchema, CreateCustomerQuerySchema, CusExpand, + CustomerDataSchema, V0_2_InvoicesAlwaysExpanded, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; import { getOrCreateCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; -import { getOrSetCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; -import { handleCreateCustomer } from "./handleCreateCustomer.js"; export const handlePostCustomer = createRoute({ versionedQuery: { @@ -39,20 +38,18 @@ export const handlePostCustomer = createRoute({ const start = Date.now(); + const customerData = CustomerDataSchema.parse(createCusParams); + const fullCustomer = await getOrCreateCachedFullCustomer({ ctx, params: { customer_id: createCusParams.id, - customer_data: { - name: createCusParams.name, - email: createCusParams.email, - fingerprint: createCusParams.fingerprint, - metadata: createCusParams.metadata || {}, - stripe_id: createCusParams.stripe_id, - disable_default: createCusParams.disable_default, - }, + customer_data: customerData, + entity_id: createCusParams.entity_id, + entity_data: createCusParams.entity_data, }, source: "handlePostCustomer", + internalOptions: createCusParams.internal_options, }); const apiCustomer = await getApiCustomer({ diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts index 1127b9cf0..7030d0ec5 100644 --- a/server/src/internal/events/EventsAggregationService.ts +++ b/server/src/internal/events/EventsAggregationService.ts @@ -305,7 +305,7 @@ export class EventsAggregationService { }>; const distinctCount = Number(distinctJson.data[0]?.distinct_count ?? 0); - if (distinctCount > 30) { + if (distinctCount > 100) { throw new RecaseError({ message: `Too many distinct group values (${distinctCount}). Maximum allowed is 30. Please choose a property with fewer unique values.`, code: ErrCode.InvalidInputs, diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 22918efbb..4907999a8 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -133,7 +133,7 @@ export const handleVersionProductV2 = async ({ } as FullProduct, org, env, - logger: console, + logger: ctx.logger, }); await addTaskToQueue({ diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 90c6fde3b..8812c796e 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -32,6 +32,7 @@ import RecaseError from "@server/utils/errorUtils.js"; import { generateId, notNullish } from "@server/utils/genUtils.js"; import { Decimal } from "decimal.js"; import { Stripe } from "stripe"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import type { AttachParams, InsertCusProductParams, @@ -533,9 +534,9 @@ export const initProductInStripe = async ({ db: DrizzleCli; org: Organization; env: AppEnv; - logger: any; + logger: Logger; product: FullProduct; -}) => { +}): Promise => { if (!isStripeConnected({ org, env })) return; await checkStripeProductExists({ diff --git a/server/src/internal/rewards/referralUtils.ts b/server/src/internal/rewards/referralUtils.ts index dce2c38a5..a647c0be1 100644 --- a/server/src/internal/rewards/referralUtils.ts +++ b/server/src/internal/rewards/referralUtils.ts @@ -8,7 +8,7 @@ import { import { StatusCodes } from "http-status-codes"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import RecaseError from "@/utils/errorUtils.js"; import { CusService } from "../customers/CusService.js"; @@ -102,12 +102,9 @@ export const triggerRedemption = async ({ legacyVersion: true, }); - await createStripeCusIfNotExists({ - db, - customer: customer, - org, - env, - logger, + await getOrCreateStripeCustomer({ + ctx, + customer, }); const stripeCusId = customer.processor.id; diff --git a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts index 6b9972baf..529a71b41 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts @@ -12,7 +12,7 @@ import { import { StatusCodes } from "http-status-codes"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { getOrCreateStripeCustomer } from "@/external/stripe/customers"; import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js"; import { rewardProgramToAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/convertToParams.js"; import { getCustomerSub } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; @@ -138,12 +138,9 @@ export const triggerFreePaidProduct = async ({ } } else { // Create stripe customer if not exists - await createStripeCusIfNotExists({ - db, + await getOrCreateStripeCustomer({ + ctx, customer: fullCus, - org, - env, - logger, }); await handleAddProduct({ diff --git a/server/src/internal/subscriptions/subUtils.ts b/server/src/internal/subscriptions/utils/initSubscription.ts similarity index 86% rename from server/src/internal/subscriptions/subUtils.ts rename to server/src/internal/subscriptions/utils/initSubscription.ts index 7b801f851..13e49d86b 100644 --- a/server/src/internal/subscriptions/subUtils.ts +++ b/server/src/internal/subscriptions/utils/initSubscription.ts @@ -1,10 +1,9 @@ import type { AppEnv, Subscription } from "@autumn/shared"; import { generateId } from "@/utils/genUtils.js"; -export const constructSub = ({ +export const initSubscription = ({ stripeId, stripeScheduleId, - usageFeatures, orgId, env, currentPeriodStart, @@ -12,7 +11,6 @@ export const constructSub = ({ }: { stripeId?: string; stripeScheduleId?: string; - usageFeatures: string[]; orgId: string; env: AppEnv; currentPeriodStart?: number; @@ -23,7 +21,7 @@ export const constructSub = ({ stripe_id: stripeId || null, stripe_schedule_id: stripeScheduleId || null, created_at: Date.now(), - usage_features: usageFeatures, + usage_features: [], org_id: orgId, env: env, current_period_start: currentPeriodStart || null, diff --git a/server/src/internal/subscriptions/utils/initSubscriptionFromStripe.ts b/server/src/internal/subscriptions/utils/initSubscriptionFromStripe.ts new file mode 100644 index 000000000..1b648999a --- /dev/null +++ b/server/src/internal/subscriptions/utils/initSubscriptionFromStripe.ts @@ -0,0 +1,37 @@ +import type { Subscription } from "@shared/models/subModels/subModels"; +import type Stripe from "stripe"; +import { + getEarliestPeriodStart, + getLatestPeriodEnd, +} from "@/external/stripe/stripeSubUtils/convertSubUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initSubscription } from "@/internal/subscriptions/utils/initSubscription"; + +/** + * Creates a Subscription object from a Stripe subscription. + */ +export const initSubscriptionFromStripe = ({ + ctx, + stripeSubscription, +}: { + ctx: AutumnContext; + stripeSubscription: Stripe.Subscription; +}): Subscription => { + const { org, env } = ctx; + + const subscriptionScheduleId = + typeof stripeSubscription.schedule === "string" + ? stripeSubscription.schedule + : typeof stripeSubscription.schedule === "object" + ? stripeSubscription.schedule?.id + : undefined; + + return initSubscription({ + stripeId: stripeSubscription.id, + stripeScheduleId: subscriptionScheduleId, + orgId: org.id, + env, + currentPeriodStart: getEarliestPeriodStart({ sub: stripeSubscription }), + currentPeriodEnd: getLatestPeriodEnd({ sub: stripeSubscription }), + }); +}; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index cf0c92028..3260d12ab 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -38,9 +38,9 @@ export const apiRouter = new Hono(); apiRouter.use("*", secretKeyMiddleware); apiRouter.use("*", orgConfigMiddleware); apiRouter.use("*", apiVersionMiddleware); +apiRouter.use("*", refreshCacheMiddleware); apiRouter.use("*", analyticsMiddleware); apiRouter.use("*", rateLimitMiddleware); -apiRouter.use("*", refreshCacheMiddleware); apiRouter.use("*", queryMiddleware()); apiRouter.use("*", idempotencyMiddleware); diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts index dfadd0ee4..91b9f77f0 100644 --- a/server/src/utils/importUtils/addProductFromSubs.ts +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -1,5 +1,4 @@ import { - BillingInterval, type CusProductStatus, type EntitlementWithFeature, type FullCustomer, @@ -12,10 +11,11 @@ import type Stripe from "stripe"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { stripeToAutumnSubStatus } from "@/external/stripe/stripeSubUtils.js"; import { subToAutumnInterval } from "@/external/stripe/utils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { PriceService } from "@/internal/products/prices/PriceService.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; -import { constructSub } from "@/internal/subscriptions/subUtils.js"; +import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe.js"; import { notNullish } from "../genUtils.js"; import type { ExtendedRequest } from "../models/Request.js"; @@ -142,14 +142,9 @@ export const addProductFromSubs = async ({ if (subFromDb.length === 0) { await SubService.createSub({ db, - sub: constructSub({ - stripeId: sub.id, - usageFeatures: - subInterval.interval === BillingInterval.Month ? usageFeatures : [], - orgId: org.id, - env, - currentPeriodStart: start, - currentPeriodEnd: end, + sub: initSubscriptionFromStripe({ + ctx: req as unknown as AutumnContext, + stripeSubscription: sub, }), }); logger.info(`Created sub ${sub.id} in DB`); diff --git a/server/src/utils/logging/maskExtraLogs.ts b/server/src/utils/logging/maskExtraLogs.ts new file mode 100644 index 000000000..e62e6d877 --- /dev/null +++ b/server/src/utils/logging/maskExtraLogs.ts @@ -0,0 +1,18 @@ +/** Fields to mask in extra logs (replace with "[MASKED]") */ +const MASKED_FIELDS = ["fullCustomer"]; + +export const maskExtraLogs = ( + extraLogs: Record, +): Record => { + const masked: Record = {}; + for (const [key, value] of Object.entries(extraLogs)) { + if (MASKED_FIELDS.includes(key)) { + masked[key] = "[MASKED]"; + } else if (value && typeof value === "object" && !Array.isArray(value)) { + masked[key] = maskExtraLogs(value as Record); + } else { + masked[key] = value; + } + } + return masked; +}; diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index 83a475acd..224db96e6 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -10,11 +10,10 @@ import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnInt } from "@/external/autumn/autumnCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createStripeCustomer } from "@/external/stripe/customers"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { - attachPmToCus, - createStripeCustomer, -} from "../../external/stripe/stripeCusUtils.js"; +import { attachPmToCus } from "../../external/stripe/stripeCusUtils.js"; export const createCusInStripe = async ({ customer, @@ -30,10 +29,9 @@ export const createCusInStripe = async ({ testClockId?: string; }) => { const stripeCustomer = await createStripeCustomer({ - org, - env, + ctx: { org, env, db } as AutumnContext, customer, - testClockId, + options: { testClockId }, }); await CusService.update({ diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index d93129b05..b757a60a5 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -13,7 +13,7 @@ export const initCustomerV3 = async ({ attachPm, withTestClock = true, withDefault = false, - defaultProductId, + defaultGroup, }: { ctx: TestContext; customerId: string; @@ -21,7 +21,7 @@ export const initCustomerV3 = async ({ customerData?: CustomerData; withTestClock?: boolean; withDefault?: boolean; - defaultProductId?: string; + defaultGroup?: string; }) => { const name = customerId; const email = `${customerId}@example.com`; @@ -58,11 +58,12 @@ export const initCustomerV3 = async ({ id: customerId, name, email, - // @ts-expect-error fingerprint: customerData?.fingerprint, stripe_id: stripeCus.id, - disable_default: !withDefault, - default_product_id: defaultProductId, + internalOptions: { + disable_defaults: !withDefault, + default_group: defaultGroup, + }, }); // 3. Attach payment method diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 66e0c317f..d758ee2e4 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,154 +1,47 @@ -import { beforeAll, describe } from "bun:test"; -import { - ApiVersion, - BillingInterval, - type FullProduct, - isConsumablePrice, - isFixedPrice, -} from "@autumn/shared"; +import { expect, test } from "bun:test"; import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; -import type { Stripe } from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { ProductService } from "@/internal/products/ProductService"; -import { constructPriceItem } from "@/internal/products/product-items/productItemUtils"; -import { - constructArrearItem, - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -const prepaidUsersItem = constructPrepaidItem({ - featureId: TestFeature.Users, - billingUnits: 1, - price: 10, -}); +test.concurrent(`${chalk.yellowBright("temp: concurrent entitled calls")}`, async () => { + const wordsItem = items.monthlyWords({ includedUsage: 200 }); + const free = products.base({ + id: "free", + items: [wordsItem], + isDefault: true, + }); -const free = constructProduct({ - type: "free", - items: [ - constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 500, - }), - ], -}); + const { customerId, autumnV1 } = await initScenario({ + customerId: "temp-entitled-concurrent", + setup: [s.customer({ withDefault: true }), s.products({ list: [free] })], + actions: [], + }); -const growthYearly = constructRawProduct({ - id: "growth-yearly", - items: [ - constructArrearItem({ + // Call /entitled 5 times concurrently + const entitledPromises = Array.from({ length: 5 }, () => + autumnV1.entitled({ + customerId, featureId: TestFeature.Words, - includedUsage: 0, - price: 1, - billingUnits: 100, }), - constructPriceItem({ - price: 2000, - interval: BillingInterval.Year, - }), - ], -}); + ); -const testCase = "temp"; + const entitledResults = await Promise.all(entitledPromises); -const buildSubscriptionItems = ({ - fullProduct, -}: { - fullProduct: FullProduct; -}): Stripe.SubscriptionScheduleCreateParams.Phase.Item[] => { - return fullProduct.prices.map((p) => { - if (isConsumablePrice(p)) { - return { - price: p.config.stripe_empty_price_id ?? undefined, - quantity: 0, - }; - } - return { - price: p.config.stripe_price_id ?? undefined, - quantity: 1, - }; + // Verify all entitled calls succeeded + for (const result of entitledResults) { + expect(result.allowed).toBe(true); + } + + // Call /events with value 25 + await autumnV1.events.send({ + customerId, + featureId: TestFeature.Words, + value: 25, }); -}; -describe(`${chalk.yellowBright("temp: add on")}`, () => { - const customerId = testCase; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - // await initCustomerV3({ - // ctx, - // customerId, - // withTestClock: true, - // attachPm: "success", - // }); - - // await initProductsV0({ - // ctx, - // products: [free, growthYearly], - // prefix: testCase, - // }); - - const { stripeCli } = ctx; - - const growthYearly = await ProductService.getFull({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - idOrInternalId: "growth-yearly_temp", - }); - - // const basePrice = growthYearly.prices.find(isFixedPrice) - - // const emptyPrice = await stripeCli.prices.create({ - // product: growthYearly?.processor?.id, - // unit_amount: 0, - // currency: "usd", - // recurring: { - // ...(billingIntervalToStripe({ - // interval: BillingInterval.Year, - // intervalCount: 1, - // }) as any), - // }, - // }); - - // console.log(emptyPrice); - - const newSubscription = await stripeCli.subscriptions.create({ - customer: "cus_ToYUVA6XSJrMa8", - items: [ - { - price: "price_1SqvPM5NEqgjQ4gyNktukeYr", - quantity: 1, - }, - ], - billing_mode: { type: "flexible" }, - billing_cycle_anchor: Math.floor(new Date("2026-12-26").getTime() / 1000), - }); - - await stripeCli.subscriptions.update(newSubscription.id, { - items: [ - { - id: newSubscription.items.data[0].id, - deleted: true, - }, - ...buildSubscriptionItems({ fullProduct: growthYearly }) - ], - proration_behavior: "none", - }); - }); + // Verify balance is now 200 - 25 = 175 + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Words].balance).toBe(175); }); - -// await createReward({ -// db: ctx.db, -// orgId: ctx.org.id, -// env: ctx.env, -// autumn: autumnV1, -// reward, -// // productId: pro.id, -// }); diff --git a/server/tests/balances/track/race-condition/track-race-condition5.test.ts b/server/tests/balances/track/race-condition/track-race-condition5.test.ts new file mode 100644 index 000000000..e303bf8ea --- /dev/null +++ b/server/tests/balances/track/race-condition/track-race-condition5.test.ts @@ -0,0 +1,206 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Race condition scenario: Concurrent /track calls auto-creating the same customer + * + * When two /track requests arrive simultaneously for a customer that doesn't exist: + * - Both should succeed + * - Only one customer should be created + * - Usage should be tracked correctly (total of both requests) + */ +test.concurrent(`${chalk.yellowBright("track-race-condition5: concurrent /track calls should auto-create customer once")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId: "track-race-condition5-setup", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + // Use a unique customer ID that doesn't exist yet + const newCustomerId = `track-race-new-${Date.now()}`; + + // Delete any existing customer (cleanup from previous runs) + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /track calls for non-existent customer + const [res1, res2] = await Promise.all([ + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 5, + customer_data: { + name: "Auto Created Customer", + email: `${newCustomerId}@example.com`, + }, + }), + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 3, + customer_data: { + name: "Auto Created Customer", + email: `${newCustomerId}@example.com`, + }, + }), + ]); + + // Both should succeed + expect(res1).toBeDefined(); + expect(res2).toBeDefined(); + + // Wait for Redis sync to complete + await timeout(2000); + + // Verify customer was created + const customer = await autumnV2.customers.get(newCustomerId, { + skip_cache: "true", + }); + expect(customer.id).toBe(newCustomerId); + expect(customer.name).toBe("Auto Created Customer"); + + // Usage should be sum of both requests (5 + 3 = 8) + // Balance should be 100 - 8 = 92 + const balance = customer.balances?.[TestFeature.Messages]?.current_balance; + expect(balance).toBe(92); +}); + +/** + * Race condition scenario: Concurrent /track calls with different values + * + * Tests that concurrent track requests correctly accumulate usage. + */ +test.concurrent(`${chalk.yellowBright("track-race-condition5: concurrent /track calls should accumulate usage correctly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId: "track-race-condition5-accumulate", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + const newCustomerId = `track-race-accumulate-${Date.now()}`; + + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /track calls with different values + await Promise.all([ + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 10, + customer_data: { name: "Accumulate Test" }, + }), + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 20, + customer_data: { name: "Accumulate Test" }, + }), + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 30, + customer_data: { name: "Accumulate Test" }, + }), + ]); + + // Wait for Redis sync to complete + await timeout(2000); + + // Verify total usage is accumulated correctly (10 + 20 + 30 = 60) + const customer = await autumnV2.customers.get(newCustomerId, { + skip_cache: "true", + }); + + // Balance should be 1000 - 60 = 940 + expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(940); + expect(customer.balances?.[TestFeature.Messages]?.usage).toBe(60); +}); + +/** + * Race condition scenario: Concurrent /track calls that would exceed balance + * + * Tests that concurrent track requests handle balance correctly when total would exceed limit. + */ +test.concurrent(`${chalk.yellowBright("track-race-condition5: concurrent /track calls handle balance limits correctly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId: "track-race-condition5-limits", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + const newCustomerId = `track-race-limits-${Date.now()}`; + + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /track calls that together would exceed balance + // 50 + 60 = 110 > 100 limit + await Promise.all([ + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 50, + customer_data: { name: "Limits Test" }, + }), + autumnV1.track({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + value: 60, + customer_data: { name: "Limits Test" }, + }), + ]); + + // Wait for Redis sync to complete + await timeout(2000); + + // Verify usage tracking + const customer = await autumnV2.customers.get(newCustomerId, { + skip_cache: "true", + }); + + // Total usage should be 50 + 60 = 110 (allowed to exceed since no overage restrictions) + const balance = customer.balances?.[TestFeature.Messages]; + expect(balance?.usage).toBe(110); + // Balance would be negative (100 - 110 = -10) if allowed, or capped at 0 + expect(balance?.current_balance).toBeLessThanOrEqual(0); +}); diff --git a/server/tests/integration/balances/check/check-basic.test.ts b/server/tests/integration/balances/check/check-basic.test.ts new file mode 100644 index 000000000..302a627e0 --- /dev/null +++ b/server/tests/integration/balances/check/check-basic.test.ts @@ -0,0 +1,736 @@ +import { expect, test } from "bun:test"; +import { + type ApiBalanceBreakdown, + ApiVersion, + type CheckResponseV0, + type CheckResponseV1, + type CheckResponseV2, + EntInterval, + type LimitedItem, + ResetInterval, + SuccessCode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + constructArrearItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: No feature attached +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-no-feature: /check when no feature attached")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "free", + items: [dashboardItem, messagesItem], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-no-feature", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [], // Don't attach product + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resV2).toEqual({ + allowed: false, + customer_id: customerId, + required_balance: 1, + balance: null, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + expect(resV1).toStrictEqual({ + allowed: false, + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 1, + code: SuccessCode.FeatureFound, + }); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(false); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(0); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Boolean feature +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-boolean: /check on boolean feature")}`, async () => { + const dashboardItem = items.dashboard(); + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "free", + items: [dashboardItem, messagesItem], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-boolean", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + plan_id: freeProd.id, + feature_id: TestFeature.Dashboard, + unlimited: false, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + max_purchase: null, + overage_allowed: false, + reset: null, + breakdown: [ + { + current_balance: 0, + granted_balance: 0, + max_purchase: null, + overage_allowed: false, + plan_id: freeProd.id, + purchased_balance: 0, + reset: null, + usage: 0, + }, + ], + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponseV1; + + expect(resV1).toStrictEqual({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + code: SuccessCode.FeatureFound, + allowed: true, + interval: null, + interval_count: null, + balance: 0, + included_usage: 0, + usage: 0, + next_reset_at: null, + overage_allowed: false, + required_balance: 1, + unlimited: false, + breakdown: [ + { + balance: 0, + included_usage: 0, + interval: null, + interval_count: null, + next_reset_at: null, + overage_allowed: false, + usage: 0, + }, + ], + }); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponseV0; + + expect(resV0).toStrictEqual({ + allowed: true, + balances: [ + { + feature_id: TestFeature.Dashboard, + balance: null, + }, + ], + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Metered feature +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-metered: /check on metered feature")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-metered", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + feature_id: "messages", + unlimited: false, + granted_balance: 1000, + purchased_balance: 0, + current_balance: 1000, + usage: 0, + max_purchase: null, + overage_allowed: false, + reset: { + interval: ResetInterval.Month, + }, + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + const expectedResV1 = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 1, + code: SuccessCode.FeatureFound, + interval: EntInterval.Month, + interval_count: 1, + unlimited: false, + balance: 1000, + usage: 0, + included_usage: 1000, + overage_allowed: false, + }; + + for (const key in expectedResV1) { + expect(resV1[key as keyof CheckResponseV1]).toBe( + expectedResV1[key as keyof typeof expectedResV1], + ); + } + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(resV0).toStrictEqual({ + allowed: true, + balances: [ + { + feature_id: TestFeature.Messages, + required: 1, + balance: 1000, + }, + ], + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Unlimited feature +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-unlimited: /check on unlimited feature")}`, async () => { + const messagesItem = items.unlimitedMessages(); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-unlimited", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + plan_id: freeProd.id, + feature_id: "messages", + unlimited: true, + granted_balance: 0, + purchased_balance: 0, + current_balance: 0, + usage: 0, + overage_allowed: false, + max_purchase: null, + reset: null, + breakdown: [ + { + current_balance: 0, + granted_balance: 0, + max_purchase: null, + overage_allowed: false, + plan_id: freeProd.id, + purchased_balance: 0, + reset: null, + usage: 0, + }, + ], + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + const expectedResV1 = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: true, + usage: 0, + included_usage: 0, + next_reset_at: null, + overage_allowed: false, + balance: 0, + interval: null, + interval_count: null, + breakdown: [ + { + balance: 0, + included_usage: 0, + interval: null, + interval_count: null, + next_reset_at: null, + overage_allowed: false, + usage: 0, + }, + ], + }; + + expect(expectedResV1).toMatchObject(resV1); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(true); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(1); + expect(resV0.balances[0]).toStrictEqual({ + balance: null, + feature_id: TestFeature.Messages, + unlimited: true, + usage_allowed: false, + required: null, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Usage-based (arrear) feature +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-usage-based: /check on usage-based feature")}`, async () => { + const messagesFeature = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, + }) as LimitedItem; + + const proProd = products.base({ + id: "pro", + items: [messagesFeature], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-usage-based", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [s.attach({ productId: proProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + feature_id: "messages", + unlimited: false, + granted_balance: messagesFeature.included_usage, + purchased_balance: 0, + current_balance: messagesFeature.included_usage, + usage: 0, + max_purchase: null, + overage_allowed: true, + reset: { + interval: ResetInterval.Month, + }, + }, + }); + expect(resV2.balance?.reset?.resets_at).toBeDefined(); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + const expectedResV1 = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: false, + balance: messagesFeature.included_usage, + usage: 0, + included_usage: messagesFeature.included_usage, + overage_allowed: true, + interval: messagesFeature.interval, + interval_count: 1, + }; + + expect(resV1).toMatchObject(expectedResV1); + expect(resV1.next_reset_at).toBeDefined(); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(true); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(1); + expect(resV0.balances[0]).toMatchObject({ + balance: messagesFeature.included_usage, + feature_id: TestFeature.Messages, + unlimited: false, + usage_allowed: true, + required: null, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Multiple balances (one_off + monthly) +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-multiple-balances: /check on feature with multiple balances")}`, async () => { + const monthlyMessages = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, + }) as LimitedItem; + + const lifetimeMessages = constructFeatureItem({ + featureId: TestFeature.Messages, + interval: null, + includedUsage: 1000, + }) as LimitedItem; + + const proProd = products.pro({ + id: "pro", + items: [monthlyMessages, lifetimeMessages], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-multiple-balances", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [s.attach({ productId: proProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + const expectedLifetimeBreakdown: ApiBalanceBreakdown = { + id: expect.any(String), + plan_id: proProd.id, + granted_balance: 1000, + purchased_balance: 0, + current_balance: 1000, + usage: 0, + max_purchase: null, + overage_allowed: false, + reset: { + interval: ResetInterval.OneOff, + resets_at: null, + }, + prepaid_quantity: 0, + expires_at: null, + }; + + const expectedMonthlyBreakdown = { + granted_balance: 100, + purchased_balance: 0, + current_balance: 100, + usage: 0, + max_purchase: null, + reset: { + interval: ResetInterval.Month, + }, + }; + + const actualMonthlyBreakdown = resV2.balance?.breakdown?.[0]; + const actualLifetimeBreakdown = resV2.balance?.breakdown?.[1]; + + expect(actualMonthlyBreakdown).toMatchObject(expectedMonthlyBreakdown); + expect(actualLifetimeBreakdown).toMatchObject(expectedLifetimeBreakdown); + expect(actualMonthlyBreakdown?.reset?.resets_at).toBeDefined(); + + expect(resV2).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + feature_id: TestFeature.Messages, + unlimited: false, + granted_balance: + monthlyMessages.included_usage + lifetimeMessages.included_usage, + purchased_balance: 0, + current_balance: + monthlyMessages.included_usage + lifetimeMessages.included_usage, + usage: 0, + max_purchase: null, + overage_allowed: true, + reset: { + interval: "multiple", + resets_at: null, + }, + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + const totalIncludedUsage = + monthlyMessages.included_usage + lifetimeMessages.included_usage; + + const lifetimeBreakdownV1 = { + balance: lifetimeMessages.included_usage, + included_usage: lifetimeMessages.included_usage, + interval: "lifetime", + interval_count: 1, + next_reset_at: null, + usage: 0, + }; + + const monthlyBreakdownV1 = { + balance: monthlyMessages.included_usage, + included_usage: monthlyMessages.included_usage, + interval: "month", + interval_count: 1, + usage: 0, + }; + + const expectedResV1 = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: false, + balance: totalIncludedUsage, + interval: "multiple", + interval_count: null, + usage: 0, + included_usage: totalIncludedUsage, + overage_allowed: true, + }; + + expect(resV1).toMatchObject(expectedResV1); + expect(resV1.breakdown).toHaveLength(2); + expect(resV1.breakdown?.[0]).toMatchObject(monthlyBreakdownV1); + expect(resV1.breakdown?.[1]).toMatchObject(lifetimeBreakdownV1); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(true); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(1); + expect(resV0.balances[0]).toMatchObject({ + balance: monthlyMessages.included_usage + lifetimeMessages.included_usage, + feature_id: TestFeature.Messages, + required: null, + unlimited: false, + usage_allowed: true, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Feature with usage limits +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("check-usage-limits: /check on feature with usage limits")}`, async () => { + const messagesFeature = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, + usageLimit: 500, + }) as LimitedItem; + + const proProd = products.pro({ + id: "pro", + items: [messagesFeature], + }); + + const autumnV0 = new AutumnInt({ version: ApiVersion.V0_2 }); + + const { customerId, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-usage-limits", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [proProd] }), + ], + actions: [s.attach({ productId: proProd.id })], + }); + + // v2 response + const resV2 = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: messagesFeature.usage_limit! + 1, + })) as unknown as CheckResponseV2; + + expect(resV2).toMatchObject({ + allowed: false, + customer_id: customerId, + required_balance: messagesFeature.usage_limit! + 1, + balance: { + feature_id: "messages", + unlimited: false, + granted_balance: messagesFeature.included_usage, + purchased_balance: 0, + current_balance: messagesFeature.included_usage, + usage: 0, + max_purchase: + messagesFeature.usage_limit! - messagesFeature.included_usage, + overage_allowed: true, + reset: { + interval: "month", + }, + }, + }); + + // v1 response + const resV1 = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: messagesFeature.usage_limit! + 1, + })) as unknown as CheckResponseV1; + + const expectedResV1 = { + allowed: false, + customer_id: customerId, + balance: messagesFeature.included_usage, + feature_id: TestFeature.Messages as string, + required_balance: messagesFeature.usage_limit! + 1, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: messagesFeature.included_usage, + overage_allowed: false, + usage_limit: messagesFeature.usage_limit!, + interval: "month", + interval_count: 1, + }; + + expect(resV1).toMatchObject(expectedResV1); + expect(resV1.next_reset_at).toBeDefined(); + + // v0 response + const resV0 = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: messagesFeature.usage_limit! + 1, + })) as unknown as CheckResponseV0; + + expect(resV0.allowed).toBe(false); + expect(resV0.balances).toBeDefined(); + expect(resV0.balances).toHaveLength(1); + expect(resV0.balances[0]).toMatchObject({ + balance: messagesFeature.included_usage, + required: messagesFeature.usage_limit! + 1, + feature_id: TestFeature.Messages, + }); +}); diff --git a/server/tests/integration/balances/check/check-public-key.test.ts b/server/tests/integration/balances/check/check-public-key.test.ts new file mode 100644 index 000000000..d292abaae --- /dev/null +++ b/server/tests/integration/balances/check/check-public-key.test.ts @@ -0,0 +1,182 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + ApiVersion, + AppEnv, + type CheckResponseV1, + SuccessCode, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { generatePublishableKey } from "@/utils/encryptUtils.js"; + +// ═══════════════════════════════════════════════════════════════════ +// Helper to set up public key test scenario +// ═══════════════════════════════════════════════════════════════════ + +async function setupPublicKeyScenario({ customerId }: { customerId: string }) { + const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId: cusId, autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Ensure test_pkey is set on the org + if (!ctx.org.test_pkey) { + const testPkey = generatePublishableKey(AppEnv.Sandbox); + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { test_pkey: testPkey }, + }); + ctx.org.test_pkey = testPkey; + } + + if (!ctx.org.test_pkey.startsWith("am_pk")) { + throw new Error( + `test_pkey "${ctx.org.test_pkey}" does not start with "am_pk". Expected format: am_pk_test_...`, + ); + } + + const autumnPublic = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.org.test_pkey, + }); + + return { customerId: cusId, autumnV1, autumnPublic }; +} + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: Public key works for /check endpoint +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("check-public-key: /check works with public key")}`, + async () => { + const { customerId, autumnPublic } = await setupPublicKeyScenario({ + customerId: "check-public-key", + }); + + const checkRes = await autumnPublic.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 100, + }); + + expect(checkRes).toMatchObject({ + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + balance: 1000, + required_balance: 100, + code: SuccessCode.FeatureFound, + usage: 0, + included_usage: 1000, + overage_allowed: false, + }); + expect(checkRes.next_reset_at).toBeDefined(); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: send_event blocked with public key +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("check-public-key-send-event-blocked: send_event with public key should error")}`, + async () => { + const { customerId, autumnV1, autumnPublic } = await setupPublicKeyScenario( + { customerId: "check-public-key-send-event-blocked" }, + ); + + const customerBefore = + await autumnV1.customers.get(customerId); + const balanceBefore = customerBefore.features[TestFeature.Messages].balance; + const usageBefore = customerBefore.features[TestFeature.Messages].usage; + + await expectAutumnError({ + func: async () => { + await autumnPublic.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 50, + send_event: true, + }); + }, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + + expect(customerAfter.features[TestFeature.Messages].balance).toBe( + balanceBefore, + ); + expect(customerAfter.features[TestFeature.Messages].usage).toBe( + usageBefore, + ); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// CHECK: send_event works with secret key +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("check-send-event: send_event with secret key tracks usage")}`, + async () => { + const { customerId, autumnV1 } = await setupPublicKeyScenario({ + customerId: "check-send-event", + }); + + // Should track usage when send_event: true with secret key + const checkRes = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 150, + send_event: true, + }); + + expect(checkRes.allowed).toBe(true); + expect(checkRes.balance).toBe(1000 - 150); + + await timeout(2000); + + const customerAfter = + await autumnV1.customers.get(customerId); + + expect(customerAfter.features[TestFeature.Messages].balance).toBe(850); + expect(customerAfter.features[TestFeature.Messages].usage).toBe(150); + + // Should NOT track when allowed: false (insufficient balance) + const checkResInsufficient = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 900, // More than available (850) + send_event: true, + }); + + expect(checkResInsufficient.allowed).toBe(false); + + await timeout(2000); + + const customerAfterInsufficient = + await autumnV1.customers.get(customerId); + + // Balance and usage should remain unchanged + expect(customerAfterInsufficient.features[TestFeature.Messages].balance).toBe(850); + expect(customerAfterInsufficient.features[TestFeature.Messages].usage).toBe(150); + }, +); diff --git a/server/tests/integration/balances/check/check-race-condition1.test.ts b/server/tests/integration/balances/check/check-race-condition1.test.ts new file mode 100644 index 000000000..ce55b60cb --- /dev/null +++ b/server/tests/integration/balances/check/check-race-condition1.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; +import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js"; +import { generateId } from "@/utils/genUtils.js"; + +/** + * Race condition scenario: + * A. Request 1: Gets up to CusService.insert (customer created, but default products NOT attached yet) + * B. Request 2: Calls CusService.getFull, finds customer WITHOUT default products, caches it + * Final state: Cache has customer without default products (stale) + */ +test.concurrent(`${chalk.yellowBright("check-race-condition1: cache should not contain stale customer without default products")}`, async () => { + const wordsItem = items.monthlyWords({ includedUsage: 1000 }); + const freeDefault = products.base({ + id: "free", + items: [wordsItem], + isDefault: true, + }); + + const customerId = "check-race-condition1"; + const { autumnV2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + // Delete the customer so we can manually reproduce the race condition + try { + await autumnV2.customers.delete(customerId); + } catch {} + + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "test-cleanup", + }); + + // ═══════════════════════════════════════════════════════════════════ + // STEP A: Simulate Request 1 - insert customer WITHOUT default products + // (This simulates the state after CusService.insert but BEFORE default products are attached) + // ═══════════════════════════════════════════════════════════════════ + const internalId = generateId("cus"); + await CusService.insert({ + db: ctx.db, + data: { + id: customerId, + internal_id: internalId, + org_id: ctx.org.id, + env: ctx.env, + name: customerId, + email: `${customerId}@test.com`, + metadata: {}, + created_at: Date.now(), + processor: null, + }, + }); + + // ═══════════════════════════════════════════════════════════════════ + // STEP B: Simulate Request 2 - fetch from DB and cache (customer exists but NO default products) + // This is what happens when a parallel request queries while Request 1 is still attaching products + // ═══════════════════════════════════════════════════════════════════ + const customerWithoutDefaults = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + withEntities: true, + withSubs: true, + }); + + // Cache this incomplete customer (simulating what Request 2 would do) + await setCachedFullCustomer({ + ctx, + fullCustomer: customerWithoutDefaults!, + customerId, + fetchTimeMs: Date.now(), + source: "test-request-2", + overwrite: true, + }); + + // ═══════════════════════════════════════════════════════════════════ + // STEP C: Now call getOrCreateCachedFullCustomer - this should detect the stale cache + // and return the customer with default products + // ═══════════════════════════════════════════════════════════════════ + const fullCustomer = await getOrCreateCachedFullCustomer({ + ctx, + params: { + customer_id: customerId, + feature_id: TestFeature.Words, + }, + source: "test-final-check", + }); + + // The customer should have default products attached + expect(fullCustomer.customer_products?.length).toBeGreaterThan(0); + + // Verify via API (skip cache to get fresh data from DB) + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "test-verify", + }); + + const customerFromApi = await autumnV2.customers.get( + customerId, + { skip_cache: "true" }, + ); + + // Should have the words balance from the default product + const wordsBalance = customerFromApi.balances?.[TestFeature.Words]; + expect(wordsBalance).toBeDefined(); + expect(wordsBalance?.current_balance).toBe(1000); +}); diff --git a/server/tests/integration/balances/check/check-race-condition2.test.ts b/server/tests/integration/balances/check/check-race-condition2.test.ts new file mode 100644 index 000000000..7bfbe48bd --- /dev/null +++ b/server/tests/integration/balances/check/check-race-condition2.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Race condition scenario: Concurrent /check calls auto-creating the same customer + * + * When two /check requests arrive simultaneously for a customer that doesn't exist: + * - Both should succeed + * - Only one customer should be created + * - Both should return valid check responses + */ +test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check calls should auto-create customer once")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { ctx, autumnV1, autumnV2 } = await initScenario({ + customerId: "check-race-condition2-setup", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + // Use a unique customer ID that doesn't exist yet + const newCustomerId = `check-race-new-${Date.now()}`; + + // Delete any existing customer (cleanup from previous runs) + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /check calls for non-existent customer + const [res1, res2] = await Promise.all([ + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + customer_data: { + name: "Auto Created Customer", + email: `${newCustomerId}@example.com`, + }, + }), + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + customer_data: { + name: "Auto Created Customer", + email: `${newCustomerId}@example.com`, + }, + }), + ]); + + // Both should return allowed (since default product gives 100 messages) + expect(res1.allowed).toBe(true); + expect(res2.allowed).toBe(true); + + // Verify customer was created + const customer = await autumnV2.customers.get(newCustomerId); + expect(customer.id).toBe(newCustomerId); + expect(customer.name).toBe("Auto Created Customer"); + expect(customer.email).toBe(`${newCustomerId}@example.com`); + + // Verify default product was attached + expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(100); +}); + +/** + * Race condition scenario: Concurrent /check calls with different customer_data + * + * When two /check requests arrive simultaneously with different customer_data, + * one wins and the other should return the same customer (not create duplicate). + */ +test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check with different data should not create duplicates")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId: "check-race-condition2-diff-data", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + const newCustomerId = `check-race-diff-${Date.now()}`; + + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /check calls with different customer_data + const [res1, res2] = await Promise.all([ + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + customer_data: { + name: "Name from request 1", + email: `${newCustomerId}-1@example.com`, + }, + }), + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + customer_data: { + name: "Name from request 2", + email: `${newCustomerId}-2@example.com`, + }, + }), + ]); + + // Both should succeed + expect(res1.allowed).toBe(true); + expect(res2.allowed).toBe(true); + + // Verify only one customer was created (not two) + const customer = await autumnV2.customers.get(newCustomerId); + expect(customer.id).toBe(newCustomerId); + + // Name should be from one of the requests (whichever won the race) + expect(["Name from request 1", "Name from request 2"]).toContain( + customer.name ?? "", + ); +}); + +/** + * Race condition scenario: Concurrent /check calls for same customer with required_balance + * + * Tests that concurrent check requests don't cause issues with balance calculation. + */ +test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check with required_balance should work correctly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId: "check-race-condition2-balance", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + const newCustomerId = `check-race-balance-${Date.now()}`; + + try { + await autumnV1.customers.delete(newCustomerId); + } catch {} + + // Concurrent /check calls with required_balance + const [res1, res2, res3] = await Promise.all([ + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + required_balance: 50, + customer_data: { name: "Balance Test" }, + }), + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + required_balance: 50, + customer_data: { name: "Balance Test" }, + }), + autumnV1.check({ + customer_id: newCustomerId, + feature_id: TestFeature.Messages, + required_balance: 50, + customer_data: { name: "Balance Test" }, + }), + ]); + + // All should be allowed (100 >= 50) + expect(res1.allowed).toBe(true); + expect(res2.allowed).toBe(true); + expect(res3.allowed).toBe(true); + + // Customer should have 100 balance (no usage tracked) + const customer = await autumnV2.customers.get(newCustomerId); + expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(100); +}); diff --git a/server/tests/integration/crud/customers/create-customer-defaults.test.ts b/server/tests/integration/crud/customers/create-customer-defaults.test.ts new file mode 100644 index 000000000..f1027fd8e --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer-defaults.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + calculateTrialEndMs, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// DEFAULT FREE PRODUCT TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("defaults: single free product")}`, async () => { + const customerId = "defaults-single-free"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [freeDefault] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ + customer, + productId: `free_${customerId}`, + }); + + expect(customer.features[TestFeature.Messages].balance).toBe(100); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// MULTIPLE GROUPS TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("defaults: multiple groups")}`, async () => { + const customerId = "defaults-multi-group"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const wordsItem = items.monthlyWords({ includedUsage: 500 }); + + const freeGroup1 = { + ...products.base({ + id: "free-group1", + items: [messagesItem], + isDefault: true, + }), + group: "group1", + }; + + const freeGroup2 = { + ...products.base({ + id: "free-group2", + items: [wordsItem], + isDefault: true, + }), + group: "group2", + }; + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [freeGroup1, freeGroup2] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + // Both products from different groups should be attached + await expectProductActive({ + customer, + productId: `free-group1_${customerId}`, + }); + + await expectProductActive({ + customer, + productId: `free-group2_${customerId}`, + }); + + // Verify both feature balances + expect(customer.features[TestFeature.Messages].balance).toBe(100); + expect(customer.features[TestFeature.Words].balance).toBe(500); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// FREE PRODUCT WITH TRIAL TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("defaults: free product with 7-day trial")}`, async () => { + const customerId = "defaults-free-trial"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeTrialDefault = products.base({ + id: "free-trial", + items: [messagesItem], + isDefault: true, + trialDays: 7, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [freeTrialDefault] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + // Product should be attached and in trialing status + await expectProductTrialing({ + customer, + productId: `free-trial_${customerId}`, + trialEndsAt: calculateTrialEndMs({ trialDays: 7 }), + }); + + // Verify feature balance is still available during trial + expect(customer.features[TestFeature.Messages].balance).toBe(100); +}); diff --git a/server/tests/integration/crud/customers/create-customer-null-id.test.ts b/server/tests/integration/crud/customers/create-customer-null-id.test.ts new file mode 100644 index 000000000..27175f182 --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer-null-id.test.ts @@ -0,0 +1,273 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// NULL ID CONSTRAINT TESTS +// Tests for the partial unique index: (org_id, env, lower(email)) WHERE id IS NULL +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("null-id: duplicate null ID + same email returns existing with products")}`, async () => { + const defaultGroup = "null-dup-test"; + const email = "null-dup-test@example.com"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + // Use prefix for product prefixing, no customer is created by initScenario + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ email }), + s.products({ list: [freeDefault], prefix: defaultGroup }), + ], + actions: [], + }); + + // First create with null ID - should get default product + const data1 = await autumnV1.customers.create({ + id: null, + name: "First Customer", + email, + withAutumnId: true, + internalOptions: { default_group: defaultGroup }, + }); + + expect(data1.id).toBeNull(); + expect(data1.email).toBe(email); + + // Verify first create has default product + const customer1 = await autumnV1.customers.get( + data1.autumn_id!, + ); + await expectProductActive({ customer: customer1, productId: freeDefault.id }); + expect(customer1.features[TestFeature.Messages].balance).toBe(100); + + // Second create with null ID and same email - should return existing (idempotent) + const data2 = await autumnV1.customers.create({ + id: null, + name: "Second Customer", + email, + withAutumnId: true, + internalOptions: { default_group: defaultGroup }, + }); + + // Should return the same customer + expect(data2.autumn_id).toBe(data1.autumn_id); + expect(data2.email).toBe(email); + // Name should be updated (upsert behavior) + expect(data2.name).toBe("First Customer"); + + // Verify second create also returns customer with default product + const customer2 = await autumnV1.customers.get( + data2.autumn_id!, + ); + await expectProductActive({ customer: customer2, productId: freeDefault.id }); + expect(customer2.features[TestFeature.Messages].balance).toBe(100); +}); + +test.concurrent(`${chalk.yellowBright("null-id: multiple customers with different emails allowed")}`, async () => { + const emailA = "null-multi-a-test@example.com"; + const emailB = "null-multi-b-test@example.com"; + const emailC = "null-multi-c-test@example.com"; + + // No products or customer needed - just need autumnV1 client + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ email: emailA }), + s.deleteCustomer({ email: emailB }), + s.deleteCustomer({ email: emailC }), + ], + actions: [], + }); + + // Create multiple customers with null ID but different emails + const data1 = await autumnV1.customers.create({ + id: null, + name: "Customer A", + email: emailA, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + const data2 = await autumnV1.customers.create({ + id: null, + name: "Customer B", + email: emailB, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + const data3 = await autumnV1.customers.create({ + id: null, + name: "Customer C", + email: emailC, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + // All should be created with different autumn_ids + expect(data1.id).toBeNull(); + expect(data2.id).toBeNull(); + expect(data3.id).toBeNull(); + expect(data1.autumn_id).not.toBe(data2.autumn_id); + expect(data2.autumn_id).not.toBe(data3.autumn_id); +}); + +test.concurrent(`${chalk.yellowBright("null-id: claim with ID returns existing customer with products")}`, async () => { + const defaultGroup = "null-claim-products-test"; + const email = "null-claim-test@example.com"; + const newId = "claimed-id-test"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + // Use prefix for product prefixing, no customer is created by initScenario + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ email }), + s.deleteCustomer({ customerId: newId }), + s.products({ list: [freeDefault], prefix: defaultGroup }), + ], + actions: [], + }); + + // First create with null ID - should get default product + const data1 = await autumnV1.customers.create({ + id: null, + name: "Null ID Customer", + email, + withAutumnId: true, + internalOptions: { default_group: defaultGroup }, + }); + + expect(data1.id).toBeNull(); + expect(data1.autumn_id).toBeDefined(); + + // Verify default product was attached + const customer1 = await autumnV1.customers.get( + data1.autumn_id!, + ); + await expectProductActive({ customer: customer1, productId: freeDefault.id }); + expectCustomerFeatureCorrect({ + customer: customer1, + featureId: TestFeature.Messages, + balance: 100, + }); + + // Second create with same email but now with an ID - should claim and return existing + const data2 = await autumnV1.customers.create({ + id: newId, + name: "Now Has ID", + email, + withAutumnId: true, + internalOptions: { default_group: defaultGroup }, + }); + + // Should return the same customer with the new ID set + expect(data2.id).toBe(newId); + expect(data2.autumn_id).toBe(data1.autumn_id); + + // Verify the customer can be fetched with the new ID + const claimedCustomer = await autumnV1.customers.get(newId); + expect(claimedCustomer.id).toBe(newId); + + // Should still have the default product + await expectProductActive({ + customer: claimedCustomer, + productId: freeDefault.id, + }); + expectCustomerFeatureCorrect({ + customer: claimedCustomer, + featureId: TestFeature.Messages, + balance: 100, + }); +}); + +test(`${chalk.yellowBright("null-id: claim existing email-null customer with ID")}`, async () => { + const email = "same-email-test@example.com"; + const newId = "with-id-test"; + + // No products or customer needed - just need autumnV1 client + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ email }), + s.deleteCustomer({ customerId: newId }), + ], + actions: [], + }); + + // Create customer with null ID + const data1 = await autumnV1.customers.create({ + id: null, + name: "Null ID Customer", + email, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + expect(data1.id).toBeNull(); + + // Create another customer with same email but WITH an ID + // This should claim the null-id customer (upsert sets the ID) + const data2 = await autumnV1.customers.create({ + id: newId, + name: "Has ID Customer", + email, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + // Should be the same customer with the new ID + expect(data2.autumn_id).toBe(data1.autumn_id); + expect(data2.id).toBe(newId); +}); + +test(`${chalk.yellowBright("null-id: case-insensitive email matching returns existing")}`, async () => { + const emailLower = "case-test@example.com"; + const emailUpper = "CASE-TEST@EXAMPLE.COM"; + + // No products or customer needed - just need autumnV1 client + const { autumnV1 } = await initScenario({ + setup: [s.deleteCustomer({ email: emailLower })], + actions: [], + }); + + // Create with lowercase email + const data1 = await autumnV1.customers.create({ + id: null, + name: "Lowercase Email", + email: emailLower, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + expect(data1.id).toBeNull(); + + // Try to create with uppercase email - should return existing (case-insensitive match) + const data2 = await autumnV1.customers.create({ + id: null, + name: "Uppercase Email", + email: emailUpper, + withAutumnId: true, + internalOptions: { disable_defaults: true }, + }); + + // Should return the same customer + expect(data2.autumn_id).toBe(data1.autumn_id); + // Name should be updated (upsert behavior) + expect(data2.name).toBe("Uppercase Email"); +}); diff --git a/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts b/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts new file mode 100644 index 000000000..7a90bc70d --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; +import { + calculateTrialEndMs, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing.js"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { FreeTrialDuration } from "autumn-js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// DEFAULT TRIAL PRODUCT TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("paid-defaults: trial product")}`, async () => { + const customerId = "paid-defaults-trial"; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + + const trialDefault = products.defaultTrial({ + id: "trial-pro", + items: [messagesItem], + trialDays: 14, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [trialDefault] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer, + productId: trialDefault.id, + trialEndsAt: calculateTrialEndMs({ trialDays: 14 }), + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 500, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + subCount: 1, + }); +}); + +test.concurrent(`${chalk.yellowBright("paid-defaults: trial product with prepaid messages")}`, async () => { + const customerId = "paid-defaults-trial-prepaid"; + + const prepaidMessagesItem = items.prepaidMessages({ + includedUsage: 200, + price: 10, + }); + + const trialDefault = products.base({ + id: "trial-prepaid", + items: [prepaidMessagesItem], + isDefault: true, + freeTrial: { + length: 7, + duration: FreeTrialDuration.Day, + cardRequired: false, + uniqueFingerprint: false, + }, + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [trialDefault] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductTrialing({ + customer, + productId: trialDefault.id, + trialEndsAt: calculateTrialEndMs({ trialDays: 7 }), + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + }); + + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customer.id ?? "", + orgId: ctx.org.id, + env: ctx.env, + }); + + expect(fullCustomer.customer_products.length).toBe(1); + expect(fullCustomer.customer_products[0].options?.[0]).toMatchObject({ + quantity: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + subCount: 1, + }); +}); + +test.concurrent(`${chalk.yellowBright("paid-defaults: same group priority (trial > paid > free)")}`, async () => { + const customerId = "paid-defaults-priority"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const messagesItemHigh = items.monthlyMessages({ includedUsage: 1000 }); + + // Free default in same group + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + // Trial default in same group - should take priority + const trialDefault = products.defaultTrial({ + id: "trial-pro", + items: [messagesItemHigh], + trialDays: 7, + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, withDefault: true }), + s.products({ list: [freeDefault, trialDefault] }), + ], + actions: [], + }); + + const customer = await autumnV1.customers.get(customerId); + + // Trial product should be attached (higher priority than free) + await expectProductTrialing({ + customer, + productId: trialDefault.id, + trialEndsAt: calculateTrialEndMs({ trialDays: 7 }), + }); + + // Balance should reflect trial product's higher allowance + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 1000, + }); +}); diff --git a/server/tests/integration/crud/customers/create-customer-race.test.ts b/server/tests/integration/crud/customers/create-customer-race.test.ts new file mode 100644 index 000000000..f54e27960 --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer-race.test.ts @@ -0,0 +1,286 @@ +import { expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing.js"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import type { Customer } from "autumn-js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// RACE CONDITION TESTS +// Tests for concurrent customer creation with default products +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("race: concurrent create same ID returns same customer")}`, async () => { + const customerId = "race-same-id-test"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeDefault = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + + // Use customerId for product prefixing, but no customer is created + const { autumnV1 } = await initScenario({ + setup: [ + s.deleteCustomer({ customerId }), + s.products({ list: [freeDefault], prefix: customerId }), + ], + actions: [], + }); + + // Concurrent creates with same ID + const results = await Promise.all([ + autumnV1.customers.create({ + id: customerId, + name: "Concurrent 1", + email: `${customerId}-1@example.com`, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + autumnV1.customers.create({ + id: customerId, + name: "Concurrent 2", + email: `${customerId}-2@example.com`, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + autumnV1.customers.create({ + id: customerId, + name: "Concurrent 3", + email: `${customerId}-3@example.com`, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + ]); + + // All should return the same customer + const autumnIds = results.map((r) => r.autumn_id); + expect(new Set(autumnIds).size).toBe(1); // All same autumn_id + + // All should have the same customer ID, free default product, and balance of 100 + for (const result of results) { + expect(result.id).toBe(customerId); + await expectProductActive({ customer: result, productId: freeDefault.id }); + expectCustomerFeatureCorrect({ + customer: result, + featureId: TestFeature.Messages, + balance: 100, + }); + } + + // Get the customer and verify default product + const customer = await autumnV1.customers.get(customerId); + expectProductAttached({ + customer, + productId: freeDefault.id, + status: CusProductStatus.Active, + }); + expect(customer.features[TestFeature.Messages].balance).toBe(100); + + // Verify no duplicate customer_products in DB + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + // Count products with the same product_id + const productCounts = fullCustomer.customer_products.reduce( + (acc, cp) => { + acc[cp.product.id] = (acc[cp.product.id] || 0) + 1; + return acc; + }, + {} as Record, + ); + + // Should only have one of each product + for (const [_productId, count] of Object.entries(productCounts)) { + expect(count).toBe(1); + } +}); + +test.concurrent(`${chalk.yellowBright("race: concurrent null ID same email returns same customer")}`, async () => { + const email = "race-null-test@example.com"; + + // No products or customer needed - just need autumnV1 client + const { autumnV1 } = await initScenario({ + setup: [s.deleteCustomer({ email })], + actions: [], + }); + + // Concurrent creates with null ID and same email + const results = await Promise.all([ + autumnV1.customers.create({ + id: null, + name: "Concurrent Null 1", + email, + withAutumnId: true, + internalOptions: { + disable_defaults: true, + }, + }), + autumnV1.customers.create({ + id: null, + name: "Concurrent Null 2", + email, + withAutumnId: true, + internalOptions: { + disable_defaults: true, + }, + }), + autumnV1.customers.create({ + id: null, + name: "Concurrent Null 3", + email, + withAutumnId: true, + internalOptions: { + disable_defaults: true, + }, + }), + ]); + + // All should succeed and return the same customer (idempotent) + const autumnIds = results.map((r) => r.autumn_id); + expect(new Set(autumnIds).size).toBe(1); + + // All should have the same email + for (const result of results) { + expect(result.email).toBe(email); + expect(result.id).toBeNull(); + } +}); + +test.concurrent(`${chalk.yellowBright("race: concurrent create with default trial creates only 1 Stripe customer and subscription")}`, async () => { + const customerId = "race-default-trial-test"; + const email = `${customerId}@example.com`; + + const messagesItem = items.monthlyMessages({ includedUsage: 500 }); + const trialDefault = products.defaultTrial({ + id: "trial-pro", + items: [messagesItem], + trialDays: 14, + cardRequired: false, + }); + + const { autumnV1, ctx } = await initScenario({ + setup: [ + s.deleteCustomer({ customerId }), + s.products({ list: [trialDefault], prefix: customerId }), + ], + actions: [], + }); + + // Concurrent creates with same ID and same params - should all return the same customer + // NOTE: All requests must have identical params for idempotency to work correctly with Stripe + const results = await Promise.all([ + autumnV1.customers.create({ + id: customerId, + name: "Concurrent Trial", + email, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + autumnV1.customers.create({ + id: customerId, + name: "Concurrent Trial", + email, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + autumnV1.customers.create({ + id: customerId, + name: "Concurrent Trial", + email, + withAutumnId: true, + internalOptions: { + default_group: customerId, + }, + }), + ]); + + // 1. All responses should return the same customer (same autumn_id) + const autumnIds = results.map((r) => r.autumn_id); + const stripeCustomerIds = results + .map((r) => r.stripe_id) + .filter((id) => id !== null); + expect(new Set(autumnIds).size).toBe(1); + expect(new Set(stripeCustomerIds).size).toBe(1); + + // Each response should have the correct customer ID, email, and trial product attached + for (const result of results) { + expect(result.id).toBe(customerId); + expect(result.email).toBe(email); + await expectProductTrialing({ + customer: result, + productId: trialDefault.id, + }); + expectCustomerFeatureCorrect({ + customer: result, + featureId: TestFeature.Messages, + balance: 500, + }); + } + + // Get the full customer to verify Stripe data + const fullCustomer = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + // 2. Verify only 1 Stripe customer was created + const stripeCustomerId = fullCustomer.processor?.id; + expect(stripeCustomerId).toBeDefined(); + + // 3. Verify only 1 Stripe subscription was created + const subscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId!, + status: "all", + }); + expect(subscriptions.data.length).toBe(1); + + // Verify the subscription is in trialing status + expect(subscriptions.data[0].status).toBe("trialing"); + + // Verify no duplicate customer_products in DB + const productCounts = fullCustomer.customer_products.reduce( + (acc, cp) => { + acc[cp.product.id] = (acc[cp.product.id] || 0) + 1; + return acc; + }, + {} as Record, + ); + + for (const [_productId, count] of Object.entries(productCounts)) { + expect(count).toBe(1); + } + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/crud/customers/create-customer.test.ts b/server/tests/integration/crud/customers/create-customer.test.ts new file mode 100644 index 000000000..b637f7861 --- /dev/null +++ b/server/tests/integration/crud/customers/create-customer.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from "bun:test"; +import { CusExpand, ErrCode } from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// BASIC CREATION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => { + const customerId = "create-basic-id"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Delete to test fresh create + try { + await autumnV1.customers.delete(customerId); + } catch {} + + const data = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + withAutumnId: false, + }); + + expect(data.id).toBe(customerId); + expect(data.name).toBe("Test Customer"); + expect(data.email).toBe(`${customerId}@example.com`); + expect(data.autumn_id).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => { + const customerId = "create-idempotent"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Delete first + try { + await autumnV1.customers.delete(customerId); + } catch {} + + // First create + const data1 = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + withAutumnId: true, + }); + + // Second create - should return existing + const data2 = await autumnV1.customers.create({ + id: customerId, + name: "Test Customer", + email: `${customerId}@example.com`, + withAutumnId: true, + }); + + expect(data1.id).toBe(data2.id); + expect(data1.autumn_id).toBe(data2.autumn_id); +}); + +test.concurrent(`${chalk.yellowBright("create: with expand params")}`, async () => { + const customerId = "create-expand"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Delete first + try { + await autumnV1.customers.delete(customerId); + } catch {} + + const data = await autumnV1.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + withAutumnId: false, + expand: [CusExpand.Invoices, CusExpand.TrialsUsed, CusExpand.Entities], + }); + + expect(data.invoices).toEqual([]); + expect(data.trials_used).toEqual([]); + expect(data.entities).toEqual([]); +}); + +test.concurrent(`${chalk.yellowBright("create: concurrent same ID")}`, async () => { + const customerId = "create-concurrent-id"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + // Delete first + try { + await autumnV1.customers.delete(customerId); + } catch {} + + // Concurrent creates with same ID + const [data1, data2] = await Promise.all([ + autumnV1.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + withAutumnId: true, + }), + autumnV1.customers.create({ + id: customerId, + name: customerId, + email: `${customerId}@example.com`, + withAutumnId: true, + }), + ]); + + // Both should return same customer + expect(data1.id).toBe(customerId); + expect(data2.id).toBe(customerId); + expect(data1.autumn_id).toBe(data2.autumn_id); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// NULL ID BASIC TESTS +// More comprehensive null ID tests are in create-customer-null-id.test.ts +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: null ID with email")}`, async () => { + const customerId = "create-null-id-email"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + const email = "create-null-id-test@example.com"; + + const data = await autumnV1.customers.create({ + id: null, + name: "Null ID Customer", + email, + withAutumnId: true, + }); + + expect(data.id).toBeNull(); + expect(data.name).toBe("Null ID Customer"); + expect(data.email).toBe(email); + expect(data.autumn_id).toBeDefined(); +}); + +test.concurrent(`${chalk.yellowBright("create: null ID no email (error)")}`, async () => { + const customerId = "create-null-id-no-email"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false })], + actions: [], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidCustomer, + errMessage: "Email is required when `id` is null", + func: async () => { + await autumnV1.customers.create({ + id: null, + name: "Null ID Customer", + withAutumnId: false, + }); + }, + }); +}); diff --git a/server/tests/integration/crud/customers/create-customer1.test.ts b/server/tests/integration/crud/customers/create-customer1.test.ts deleted file mode 100644 index 2aed9879f..000000000 --- a/server/tests/integration/crud/customers/create-customer1.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, CusExpand } from "@autumn/shared"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; - -const testCase = "create-customer1"; -const customerId = testCase; - -describe(`${chalk.yellowBright("create-customer1: Testing create customer")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - try { - await autumnV1.customers.delete(customerId); - } catch {} - }); - - test("should create customer with expand params", async () => { - const data = await autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - }); - - expect(data.id).toBe(customerId); - expect(data.name).toBe(customerId); - expect(data.email).toBe(`${customerId}@example.com`); - expect(data.autumn_id).toBeUndefined(); - }); - - test("should return customer when call again", async () => { - const data = await autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - }); - - expect(data.id).toBe(customerId); - expect(data.name).toBe(customerId); - expect(data.email).toBe(`${customerId}@example.com`); - expect(data.autumn_id).toBeUndefined(); - }); - - test("should return expanded params if provided", async () => { - const data = await autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - expand: [CusExpand.Invoices, CusExpand.TrialsUsed, CusExpand.Entities], - }); - - expect(data.invoices).toEqual([]); - expect(data.trials_used).toEqual([]); - expect(data.entities).toEqual([]); - }); -}); diff --git a/server/tests/integration/crud/customers/create-customer2.test.ts b/server/tests/integration/crud/customers/create-customer2.test.ts deleted file mode 100644 index 2c30822fa..000000000 --- a/server/tests/integration/crud/customers/create-customer2.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, CusExpand } from "@autumn/shared"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; - -const testCase = "create-customer2"; -const customerId = testCase; - -describe(`${chalk.yellowBright("create-customer2: Testing create customer concurrently (should have no race conditions)")}`, () => { - const autumnV1 = new AutumnInt({ - secretKey: ctx.orgSecretKey, - version: ApiVersion.V1_2, - }); - - beforeAll(async () => { - try { - await autumnV1.customers.delete(customerId); - } catch {} - }); - - test("should create customer with expand params", async () => { - const [data1, data2] = await Promise.all([ - autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - }), - autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - }), - ]); - - expect(data1.id).toBe(customerId); - expect(data1.name).toBe(customerId); - expect(data1.email).toBe(`${customerId}@example.com`); - expect(data1.autumn_id).toBeUndefined(); - - expect(data2.id).toBe(customerId); - expect(data2.name).toBe(customerId); - expect(data2.email).toBe(`${customerId}@example.com`); - expect(data2.autumn_id).toBeUndefined(); - }); - - test("should return customer when call again", async () => { - const data = await autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - }); - - expect(data.id).toBe(customerId); - expect(data.name).toBe(customerId); - expect(data.email).toBe(`${customerId}@example.com`); - expect(data.autumn_id).toBeUndefined(); - }); - - test("should return expanded params if provided", async () => { - const data = await autumnV1.customers.create({ - id: customerId, - name: customerId, - email: `${customerId}@example.com`, - withAutumnId: false, - expand: [CusExpand.Invoices, CusExpand.TrialsUsed, CusExpand.Entities], - }); - - expect(data.invoices).toEqual([]); - expect(data.trials_used).toEqual([]); - expect(data.entities).toEqual([]); - }); -}); diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index d0cc2c34c..c5474f206 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -1,9 +1,11 @@ import { + BillingInterval, type FreeTrial, FreeTrialDuration, type ProductItem, type ProductV2, } from "@autumn/shared"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructProduct, constructRawProduct, @@ -14,7 +16,8 @@ import { * @param items - Product items (features) * @param id - Product ID (default: "base") * @param isDefault - Whether this is a default product (default: false) - * @param trialDays - Optional number of trial days + * @param trialDays - Optional number of trial days (shorthand) + * @param freeTrial - Optional full free trial config (overrides trialDays) */ const base = ({ items, @@ -22,23 +25,39 @@ const base = ({ isDefault = false, isAddOn = false, trialDays, + freeTrial, }: { items: ProductItem[]; id?: string; isDefault?: boolean; isAddOn?: boolean; trialDays?: number; + freeTrial?: { + length: number; + duration: FreeTrialDuration; + cardRequired?: boolean; + uniqueFingerprint?: boolean; + }; }): ProductV2 => ({ ...constructRawProduct({ id, items, isAddOn }), is_default: isDefault, - ...(trialDays && { - free_trial: { - length: trialDays, - duration: FreeTrialDuration.Day, - unique_fingerprint: false, - card_required: true, - } as unknown as FreeTrial, - }), + ...(freeTrial + ? { + free_trial: { + length: freeTrial.length, + duration: freeTrial.duration, + unique_fingerprint: freeTrial.uniqueFingerprint ?? false, + card_required: freeTrial.cardRequired ?? true, + } as unknown as FreeTrial, + } + : trialDays && { + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: true, + } as unknown as FreeTrial, + }), }); /** @@ -139,6 +158,40 @@ const baseWithTrial = ({ } as unknown as FreeTrial, }); +/** + * Default trial product - $20/month product with trial that's set as default + * @param items - Product items (features) + * @param id - Product ID (default: "default-trial") + * @param trialDays - Number of trial days (default: 7) + * @param cardRequired - Whether card is required for trial (default: false) + */ +const defaultTrial = ({ + items, + id = "default-trial", + trialDays = 7, + cardRequired = false, +}: { + items: ProductItem[]; + id?: string; + trialDays?: number; + cardRequired?: boolean; +}): ProductV2 => ({ + ...constructRawProduct({ + id, + items: [ + ...items, + constructPriceItem({ price: 20, interval: BillingInterval.Month }), + ], + }), + is_default: true, + free_trial: { + length: trialDays, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: cardRequired, + } as unknown as FreeTrial, +}); + /** * One-off product - one-time purchase with $10 base price * @param items - Product items (features) @@ -161,6 +214,7 @@ const oneOff = ({ export const products = { base, baseWithTrial, + defaultTrial, pro, proAnnual, proWithTrial, diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 3049c765f..b10092fff 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -3,6 +3,7 @@ import type { CustomerData } from "autumn-js"; import { addHours, addMonths } from "date-fns"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { removeAllPaymentMethods } from "@/external/stripe/customers/paymentMethods/operations/removeAllPaymentMethods.js"; +import { CusService } from "@/internal/customers/CusService.js"; import { attachPaymentMethod as attachPaymentMethodFn } from "@/utils/scriptUtils/initCustomer.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; @@ -71,14 +72,22 @@ type ScenarioAction = | AttachPaymentMethodAction | RemovePaymentMethodAction; +type CleanupConfig = { + customerIdsToDelete: string[]; + emailsToDelete: string[]; +}; + type ScenarioConfig = { testClock: boolean; attachPm?: "success" | "fail" | "authenticate"; customerData?: CustomerData; withDefault: boolean; + defaultGroup?: string; products: ProductV2[]; + productPrefix?: string; entityConfig?: EntityConfig; customerIds?: string[]; + cleanup: CleanupConfig; actions: ScenarioAction[]; }; @@ -110,19 +119,23 @@ const generateEntities = (config: EntityConfig): GeneratedEntity[] => { * @param paymentMethod - Attach payment method: "success", "fail", or "authenticate" * @param data - Customer metadata (fingerprint, name, email, etc.) * @param withDefault - Attach the default product on creation (default: false) + * @param defaultGroup - The product group to use for default product selection * @example s.customer({ paymentMethod: "success" }) * @example s.customer({ paymentMethod: "success", data: { name: "Test" } }) + * @example s.customer({ withDefault: true, defaultGroup: "enterprise" }) */ const customer = ({ testClock = true, paymentMethod, data, withDefault, + defaultGroup, }: { testClock?: boolean; paymentMethod?: "success" | "fail" | "authenticate"; data?: CustomerData; withDefault?: boolean; + defaultGroup?: string; }): ConfigFn => { return (config) => ({ ...config, @@ -130,6 +143,7 @@ const customer = ({ attachPm: paymentMethod ?? config.attachPm, customerData: data ?? config.customerData, withDefault: withDefault ?? config.withDefault, + defaultGroup: defaultGroup ?? config.defaultGroup, }); }; @@ -137,19 +151,25 @@ const customer = ({ * Define products to create for this test scenario. * Products are prefixed with customerId for test isolation. * @param list - Array of ProductV2 objects + * @param prefix - Optional custom prefix for product IDs (defaults to customerId or "shared") * @param customerIdsToDelete - Array of customer IDs to delete before creating products + * @example s.products({ list: [pro, free] }) + * @example s.products({ list: [freeDefault], prefix: "my-prefix" }) // custom prefix when no customerId * @example s.products({ list: [pro, free], customerIdsToDelete: [customerId] }) */ const products = ({ list, + prefix, customerIdsToDelete, }: { list: ProductV2[]; + prefix?: string; customerIdsToDelete?: string[]; }): ConfigFn => { return (config) => ({ ...config, products: list, + productPrefix: prefix, customerIds: customerIdsToDelete, }); }; @@ -329,6 +349,40 @@ const removePaymentMethod = (): ConfigFn => { }); }; +/** + * Delete a customer before the test runs. + * Uses API to clear cache. Silently ignores if customer doesn't exist. + * @param customerId - Delete by customer ID + * @param email - Delete all customers with this email + * @example s.deleteCustomer({ customerId: "test-customer" }) + * @example s.deleteCustomer({ email: "test@example.com" }) + */ +const deleteCustomer = ( + params: { customerId: string } | { email: string }, +): ConfigFn => { + return (config) => { + if ("customerId" in params) { + return { + ...config, + cleanup: { + ...config.cleanup, + customerIdsToDelete: [ + ...config.cleanup.customerIdsToDelete, + params.customerId, + ], + }, + }; + } + return { + ...config, + cleanup: { + ...config.cleanup, + emailsToDelete: [...config.cleanup.emailsToDelete, params.email], + }, + }; + }; +}; + /** * Scenario configuration functions. * Import and use with initScenario to configure test setup. @@ -355,6 +409,7 @@ export const s = { advanceTestClock, attachPaymentMethod, removePaymentMethod, + deleteCustomer, } as const; // ═══════════════════════════════════════════════════════════════════ @@ -364,7 +419,13 @@ export const s = { const defaultConfig: ScenarioConfig = { testClock: false, withDefault: false, + defaultGroup: undefined, products: [], + productPrefix: undefined, + cleanup: { + customerIdsToDelete: [], + emailsToDelete: [], + }, actions: [], }; @@ -373,7 +434,7 @@ const defaultConfig: ScenarioConfig = { * Uses functional composition for flexible configuration. * Actions are executed in the exact order they appear in the actions array. * - * @param customerId - Unique identifier used as customer ID and product prefix + * @param customerId - Unique identifier used as customer ID and product prefix. If not provided, customer creation is skipped. * @param setup - Configuration functions (customer, products, entities) * @param actions - Action functions (attach, cancel, advanceTestClock) - executed in order * @returns autumnV1, autumnV2, ctx, testClockId, customer, entities, advancedTo @@ -392,6 +453,12 @@ const defaultConfig: ScenarioConfig = { * ], * }); * + * // Products only (no customer) - useful for null ID tests + * const { autumnV1 } = await initScenario({ + * setup: [s.products({ list: [freeDefault] })], + * actions: [], + * }); + * * // Interleaved actions - executed in order * const { autumnV1, ctx, advancedTo } = await initScenario({ * customerId: "interleaved-test", @@ -408,15 +475,48 @@ const defaultConfig: ScenarioConfig = { * }); * ``` */ -export const initScenario = async ({ +// Overload: when customerId is provided, return type has customerId: string +export async function initScenario(params: { + customerId: string; + setup: ConfigFn[]; + actions: ConfigFn[]; +}): Promise<{ + customerId: string; + autumnV1: AutumnInt; + autumnV2: AutumnInt; + testClockId: string | undefined; + customer: Awaited>["customer"]; + ctx: typeof ctx; + entities: GeneratedEntity[]; + advancedTo: number; +}>; + +// Overload: when customerId is not provided, return type has customerId: undefined +export async function initScenario(params: { + customerId?: undefined; + setup: ConfigFn[]; + actions: ConfigFn[]; +}): Promise<{ + customerId: undefined; + autumnV1: AutumnInt; + autumnV2: AutumnInt; + testClockId: undefined; + customer: null; + ctx: typeof ctx; + entities: GeneratedEntity[]; + advancedTo: number; +}>; + +// Implementation +export async function initScenario({ customerId, setup, actions, }: { - customerId: string; + customerId?: string; setup: ConfigFn[]; actions: ConfigFn[]; -}) => { +}) { // Build config from setup and actions const config = [...setup, ...actions].reduce((c, fn) => fn(c), defaultConfig); @@ -425,25 +525,65 @@ export const initScenario = async ({ ? generateEntities(config.entityConfig) : []; + // Create a cleanup autumn client + const cleanupAutumn = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.orgSecretKey, + }); + + // 0. Run cleanup - delete customers by ID and email before test + for (const customerIdToDelete of config.cleanup.customerIdsToDelete) { + try { + await cleanupAutumn.customers.delete(customerIdToDelete); + } catch {} + } + + for (const emailToDelete of config.cleanup.emailsToDelete) { + const customers = await CusService.getByEmail({ + db: ctx.db, + email: emailToDelete, + orgId: ctx.org.id, + env: ctx.env, + }); + + for (const customerToDelete of customers) { + try { + await cleanupAutumn.customers.delete(customerToDelete.internal_id); + } catch {} + } + } + // 1. Initialize products & delete previous customers (prefix = customerId for isolation) + // Priority: explicit productPrefix > customerId > "shared" + const productPrefix = config.productPrefix ?? customerId ?? "shared"; if (config.products.length > 0) { await initProductsV0({ ctx, products: config.products, - prefix: customerId, - customerIds: config.customerIds ?? [customerId], + prefix: productPrefix, + customerIds: config.customerIds ?? (customerId ? [customerId] : []), }); } - // 2. Initialize customer - const { testClockId, customer } = await initCustomerV3({ - ctx, - customerId, - customerData: config.customerData, - attachPm: config.attachPm, - withTestClock: config.testClock, - withDefault: config.withDefault, - }); + // 2. Initialize customer (only if customerId is provided) + let testClockId: string | undefined; + let customer: Awaited>["customer"] | null = + null; + + if (customerId) { + const result = await initCustomerV3({ + ctx, + customerId, + customerData: config.customerData, + attachPm: config.attachPm, + withTestClock: config.testClock, + withDefault: config.withDefault, + // Default group matches the product prefix (customerId) used in initProductsV0 + defaultGroup: config.defaultGroup ?? customerId, + }); + testClockId = result.testClockId; + customer = result.customer; + } // 3. Create autumn clients const autumnV1 = new AutumnInt({ @@ -456,8 +596,13 @@ export const initScenario = async ({ secretKey: ctx.orgSecretKey, }); - // 4. Create entities if any + // 4. Create entities if any (requires customerId) if (generatedEntities.length > 0) { + if (!customerId) { + throw new Error( + "Cannot create entities: customerId is required when using s.entities()", + ); + } const entityDefs = generatedEntities.map((e) => ({ id: e.id, name: e.name, @@ -471,7 +616,12 @@ export const initScenario = async ({ for (const action of config.actions) { if (action.type === "attach") { - const prefixedProductId = `${action.productId}_${customerId}`; + if (!customerId) { + throw new Error( + "Cannot attach product: customerId is required when using s.attach()", + ); + } + const prefixedProductId = `${action.productId}_${productPrefix}`; // Resolve entityIndex to entityId let entityId: string | undefined; @@ -495,7 +645,12 @@ export const initScenario = async ({ await new Promise((resolve) => setTimeout(resolve, action.timeout)); } } else if (action.type === "cancel") { - const prefixedProductId = `${action.productId}_${customerId}`; + if (!customerId) { + throw new Error( + "Cannot cancel product: customerId is required when using s.cancel()", + ); + } + const prefixedProductId = `${action.productId}_${productPrefix}`; // Resolve entityIndex to entityId let entityId: string | undefined; @@ -581,4 +736,4 @@ export const initScenario = async ({ entities: generatedEntities, advancedTo, }; -}; +} diff --git a/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts b/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts index ec8e48953..dbacb3ce5 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts @@ -8,7 +8,7 @@ import { ApiCustomerV3Schema, BillingPortalParamsSchema, BillingPortalResultSchema, - CreateCustomerParamsSchema, + ExtCreateCustomerParamsSchema, ListCustomersQuerySchema, ListCustomersResponseSchema, UpdateCustomerParamsSchema, @@ -58,7 +58,7 @@ export const customersOpenApi = { }, requestBody: { content: { - "application/json": { schema: CreateCustomerParamsSchema }, + "application/json": { schema: ExtCreateCustomerParamsSchema }, }, }, responses: { diff --git a/shared/api/_openapi2.0_/customersOpenApi.ts b/shared/api/_openapi2.0_/customersOpenApi.ts index 328dcb0cd..4d4014203 100644 --- a/shared/api/_openapi2.0_/customersOpenApi.ts +++ b/shared/api/_openapi2.0_/customersOpenApi.ts @@ -10,12 +10,11 @@ import { // // examples: [PLAN_EXAMPLE], // }); +import { ExtCreateCustomerParamsSchema } from "../customers/createCustomerParams.js"; import { ListCustomersV2ParamsSchema } from "../customers/crud/listCustomersParamsV2.js"; import { - CreateCustomerParamsSchema, CreateCustomerQuerySchema, GetCustomerQuerySchema, - // ListCustomersResponseSchema, UpdateCustomerParamsSchema, } from "../customers/customerOpModels.js"; import { createPagePaginatedResponseSchema } from "../models.js"; @@ -30,7 +29,7 @@ export const customersOpenApi = { }, requestBody: { content: { - "application/json": { schema: CreateCustomerParamsSchema }, + "application/json": { schema: ExtCreateCustomerParamsSchema }, }, }, responses: { diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index 9f82a5185..ad46977f3 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ExternalProcessorsSchema } from "../../models/genModels/processorSchemas.js"; // Base schema without top-level .meta() to avoid side effects during imports // Individual field descriptions are kept as they don't cause registry conflicts @@ -7,20 +8,23 @@ export const CustomerDataSchema = z name: z.string().nullish().meta({ description: "Customer's name", }), - email: z.string().nullish().meta({ + email: z.email({ message: "not a valid email address" }).nullish().meta({ description: "Customer's email address", }), fingerprint: z.string().nullish().meta({ - internal: true, + description: + "Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse", }), - metadata: z.record(z.any(), z.any()).nullish().meta({ - internal: true, + metadata: z.record(z.string(), z.any()).nullish().meta({ + description: "Additional metadata for the customer", }), stripe_id: z.string().nullish().meta({ - internal: true, + description: "Stripe customer ID if you already have one", }), - disable_default: z.boolean().optional().meta({ + + processors: ExternalProcessorsSchema.nullish().meta({ internal: true, + description: "External processors for the customer", }), }) .meta({ @@ -28,4 +32,17 @@ export const CustomerDataSchema = z description: "Customer details to set when creating a customer", }); +// for internal use only +export const CreateCustomerInternalOptionsSchema = z.object({ + default_group: z.string().optional().meta({ + description: "The group of products to attach to the customer", + }), + disable_defaults: z.boolean().optional().meta({ + description: "Whether to disable default products", + }), +}); + export type CustomerData = z.infer; +export type CreateCustomerInternalOptions = z.infer< + typeof CreateCustomerInternalOptionsSchema +>; diff --git a/shared/api/common/customerId.ts b/shared/api/common/customerId.ts new file mode 100644 index 000000000..ffe30a094 --- /dev/null +++ b/shared/api/common/customerId.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +export const CustomerIdSchema = z.string().refine( + (val) => { + if (val === "") return false; + if (val.includes("@")) return false; + if (val.includes(" ")) return false; + if (val.includes(".")) return false; + return /^[a-zA-Z0-9_-]+$/.test(val); + }, + { + error: (issue) => { + const input = issue.input as string; + if (input === "") return { message: "can't be an empty string" }; + if (input.includes("@")) + return { + message: + "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.", + }; + if (input.includes(" ")) + return { + message: + "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.", + }; + if (input.includes(".")) + return { + message: + "cannot contain periods. Use only letters, numbers, underscores, and hyphens.", + }; + const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0]; + return { + message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`, + }; + }, + }, +); + +export type CustomerId = z.infer; diff --git a/shared/api/customers/createCustomerParams.ts b/shared/api/customers/createCustomerParams.ts new file mode 100644 index 000000000..217930afc --- /dev/null +++ b/shared/api/customers/createCustomerParams.ts @@ -0,0 +1,34 @@ +import { CustomerIdSchema } from "@api/common/customerId.js"; +import { z } from "zod/v4"; +import { + CreateCustomerInternalOptionsSchema, + CustomerDataSchema, +} from "../common/customerData.js"; +import { EntityDataSchema } from "../common/entityData.js"; + +// Create Customer Params (based on handlePostCustomer logic) +export const ExtCreateCustomerParamsSchema = z + .object({ + id: CustomerIdSchema.nullable().meta({ + description: "Your unique identifier for the customer", + }), + }) + .extend(CustomerDataSchema.shape) + .extend({ + entity_id: z.string().optional().meta({ + internal: true, + }), + entity_data: EntityDataSchema.optional().meta({ + internal: true, + }), + }); + +export const CreateCustomerParamsSchema = ExtCreateCustomerParamsSchema.extend({ + internal_options: CreateCustomerInternalOptionsSchema.optional(), +}); + +export type ExtCreateCustomerParams = z.infer< + typeof ExtCreateCustomerParamsSchema +>; + +export type CreateCustomerParams = z.infer; diff --git a/shared/api/customers/customerOpModels.ts b/shared/api/customers/customerOpModels.ts index 94f054e00..a2c03f9f3 100644 --- a/shared/api/customers/customerOpModels.ts +++ b/shared/api/customers/customerOpModels.ts @@ -1,6 +1,7 @@ import { CusExpand } from "@models/cusModels/cusExpand.js"; import { z } from "zod/v4"; -import { EntityDataSchema } from "../common/entityData.js"; +import { CustomerDataSchema } from "../common/customerData.js"; +import { CustomerIdSchema } from "../common/customerId.js"; import { queryStringArray } from "../common/queryHelpers.js"; export const GetCustomerQuerySchema = z.object({ @@ -21,102 +22,14 @@ export const CreateCustomerQuerySchema = z.object({ }), }); -const customerId = z.string().refine( - (val) => { - if (val === "") return false; - if (val.includes("@")) return false; - if (val.includes(" ")) return false; - if (val.includes(".")) return false; - return /^[a-zA-Z0-9_-]+$/.test(val); - }, - { - error: (issue) => { - const input = issue.input as string; - if (input === "") return { message: "can't be an empty string" }; - if (input.includes("@")) - return { - message: - "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.", - }; - if (input.includes(" ")) - return { - message: - "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.", - }; - if (input.includes(".")) - return { - message: - "cannot contain periods. Use only letters, numbers, underscores, and hyphens.", - }; - const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0]; - return { - message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`, - }; - }, - }, -); - -// Create Customer Params (based on handlePostCustomer logic) -export const CreateCustomerParamsSchema = z.object({ - id: customerId.nullable().meta({ - description: "Your unique identifier for the customer", - }), - - name: z.string().nullish().meta({ - description: "Customer's name", - }), - - email: z.email({ message: "not a valid email address" }).nullish().meta({ - description: "Customer's email address", - }), - - fingerprint: z.string().optional().meta({ - description: - "Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse", - }), - - metadata: z.record(z.string(), z.any()).nullish().meta({ - description: "Additional metadata for the customer", - }), - - stripe_id: z.string().optional().meta({ - description: "Stripe customer ID if you already have one", - }), - - entity_id: z.string().optional().meta({ - internal: true, - }), - entity_data: EntityDataSchema.optional().meta({ - internal: true, - }), - disable_default: z.boolean().optional().meta({ - internal: true, - }), -}); - // Update Customer Params (based on handleUpdateCustomer logic) -export const UpdateCustomerParamsSchema = z.object({ - id: customerId.optional().meta({ - description: "New unique identifier for the customer.", - }), - name: z.string().nullish().meta({ - description: "The customer's name.", - }), - email: z.email({ message: "not a valid email address" }).nullish().meta({ - description: "Customer's email address", - }), - fingerprint: z.string().nullish().meta({ - description: - "Unique identifier (eg, serial number) to detect duplicate customers.", - }), - metadata: z.record(z.any(), z.any()).nullish().meta({ - description: - "Additional metadata for the customer (set individual keys to null to delete them).", - }), - stripe_id: z.string().nullish().meta({ - description: "Stripe customer ID.", - }), -}); +export const UpdateCustomerParamsSchema = z + .object({ + id: CustomerIdSchema.optional().meta({ + description: "New unique identifier for the customer", + }), + }) + .extend(CustomerDataSchema.shape); // List Customers Query (based on the docs) export const ListCustomersQuerySchema = z.object({ @@ -176,7 +89,6 @@ export const GetBillingPortalResponseSchema = z.object({ }), }); -export type CreateCustomerParams = z.infer; export type UpdateCustomerParams = z.infer; export type ListCustomersQuery = z.infer; diff --git a/shared/api/customers/customersOpenApi.ts b/shared/api/customers/customersOpenApi.ts deleted file mode 100644 index 6a2e96c81..000000000 --- a/shared/api/customers/customersOpenApi.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { z } from "zod/v4"; -import { UpdateBalancesParamsSchema } from "../balances/prevVersions/legacyUpdateBalanceModels.js"; -import { SuccessResponseSchema } from "../common/commonResponses.js"; -import { ApiCustomerSchema } from "./apiCustomer.js"; -import { - CreateCustomerParamsSchema, - CreateCustomerQuerySchema, - GetCustomerQuerySchema, - ListCustomersQuerySchema, - ListCustomersResponseSchema, - UpdateCustomerParamsSchema, -} from "./customerOpModels.js"; - -// Note: The meta with id is added in openapi.ts to avoid duplicate registration -// This schema is exported through the main index and should not have an id here -export const ApiCustomerWithMeta = ApiCustomerSchema; - -export const customerOps = { - "/customers": { - get: { - summary: "List Customers", - tags: ["customers"], - requestParams: { - query: ListCustomersQuerySchema, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { schema: ListCustomersResponseSchema }, - }, - }, - }, - }, - post: { - summary: "Create Customer", - tags: ["customers"], - requestParams: { - query: CreateCustomerQuerySchema, - }, - requestBody: { - content: { - "application/json": { schema: CreateCustomerParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - }, - "/customers/{customer_id}": { - get: { - summary: "Get Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - query: GetCustomerQuerySchema, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - post: { - summary: "Update Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - query: z.object({ - expand: z.string().optional(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateCustomerParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: ApiCustomerWithMeta } }, - }, - }, - }, - delete: { - summary: "Delete Customer", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: SuccessResponseSchema, - }, - }, - }, - }, - }, - }, - "/customers/{customer_id}/balances": { - post: { - summary: "Update Feature Balances", - description: - "Update or set feature balances for a customer. Can set specific balance values or make features unlimited.", - tags: ["customers"], - requestParams: { - path: z.object({ - customer_id: z.string(), - }), - }, - requestBody: { - content: { - "application/json": { schema: UpdateBalancesParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { schema: SuccessResponseSchema }, - }, - }, - }, - }, - }, -}; diff --git a/shared/api/models.ts b/shared/api/models.ts index 7e78a0944..cc6fc12f8 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -12,6 +12,7 @@ export * from "./utils/zodToJSDoc.js"; export * from "./customers/apiCustomer.js"; export * from "./customers/components/apiCusReferral.js"; export * from "./customers/components/apiCusUpcomingInvoice.js"; +export * from "./customers/createCustomerParams.js"; export * from "./customers/cusFeatures/apiBalance.js"; export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV0.js"; export * from "./customers/cusFeatures/previousVersions/apiCusFeatureV1.js"; diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index 6f5dbfb73..a811858ed 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -12,12 +12,14 @@ export const CustomerSchema = z.object({ internal_id: z.string(), org_id: z.string(), created_at: z.number(), - env: z.nativeEnum(AppEnv), + env: z.enum(AppEnv), processor: z.any(), processors: ExternalProcessorsSchema.nullish(), metadata: z.record(z.any(), z.any()).nullish().default({}), }); +export type Customer = z.infer; + export const CreateCustomerSchema = z.object({ id: z .string() @@ -68,19 +70,4 @@ export const CreateCustomerSchema = z.object({ processors: ExternalProcessorsSchema.nullish(), }); -// export const CustomerDataSchema = z.object({ -// name: z.string().nullish(), -// email: z.string().nullish(), -// fingerprint: z.string().nullish(), -// metadata: z.record(z.any(), z.any()).nullish(), -// stripe_id: z.string().nullish(), -// }); - -export const CustomerResponseSchema = CustomerSchema.omit({ - org_id: true, -}); - -export type Customer = z.infer; -// export type CustomerData = z.infer; -export type CustomerResponse = z.infer; export type CreateCustomer = z.infer; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index bd1de341b..3d6922978 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -6,12 +6,10 @@ import { pgTable, text, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; import { collatePgColumn } from "../../db/utils.js"; -import type { - ExternalProcessors, - VercelProcessor, -} from "../genModels/processorSchemas.js"; +import type { ExternalProcessors } from "../genModels/processorSchemas.js"; import { organizations } from "../orgModels/orgTable.js"; export type CustomerProcessor = { @@ -43,6 +41,12 @@ export const customers = pgTable( foreignColumns: [organizations.id], name: "customers_org_id_fkey", }).onDelete("cascade"), + // Ensure only ONE customer per (org, env, email) can have id = NULL + uniqueIndex("customers_email_null_id_unique") + .on(table.org_id, table.env, sql`lower(${table.email})`) + .where( + sql`${table.id} IS NULL AND ${table.email} IS NOT NULL AND ${table.email} != ''`, + ), ], ).enableRLS(); diff --git a/shared/utils/featureUtils/findFeatureUtils.ts b/shared/utils/featureUtils/findFeatureUtils.ts index b0a284efa..4f46d2c58 100644 --- a/shared/utils/featureUtils/findFeatureUtils.ts +++ b/shared/utils/featureUtils/findFeatureUtils.ts @@ -70,3 +70,42 @@ export function findFeatureById({ return result; } + +// Overload: errorOnNotFound = true → guaranteed Feature +export function findFeatureByIdOrInternalId(params: { + features: Feature[]; + featureIdOrInternalId: string; + errorOnNotFound: true; +}): Feature; + +// Overload: errorOnNotFound = false/undefined → Feature | undefined +export function findFeatureByIdOrInternalId(params: { + features: Feature[]; + featureIdOrInternalId: string; + errorOnNotFound?: false; +}): Feature | undefined; + +// Implementation +export function findFeatureByIdOrInternalId({ + features, + featureIdOrInternalId, + errorOnNotFound, +}: { + features: Feature[]; + featureIdOrInternalId: string; + errorOnNotFound?: boolean; +}): Feature | undefined { + const result = features.find( + (feature) => + feature.id === featureIdOrInternalId || + feature.internal_id === featureIdOrInternalId, + ); + + if (errorOnNotFound && !result) { + throw new InternalError({ + message: `Feature not found for id or internal_id: ${featureIdOrInternalId}`, + }); + } + + return result; +}