updated create customer flow with new billing pattern
This commit is contained in:
@@ -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<ApiCustomerV3>()`, `autumnV1.check<CheckResponseV1>()`
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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<T>()` | `ApiCustomerV3` |
|
||||
| `autumnV1` | `.entities.get<T>()` | `ApiEntityV0` |
|
||||
| `autumnV1` | `.check<T>()` | `CheckResponseV1` |
|
||||
| `autumnV2` | `.customers.get<T>()` | `ApiCustomer` |
|
||||
| `autumnV2` | `.entities.get<T>()` | `ApiEntityV1` |
|
||||
| `autumnV2` | `.check<T>()` | `CheckResponseV2` |
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Use generic types
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const checkRes = await autumnV1.check<CheckResponseV1>({ ... });
|
||||
const entity = await autumnV2.entities.get<ApiEntityV1>(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`:
|
||||
|
||||
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
@@ -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<typeof CustomerDataSchema>;
|
||||
export type CustomerId = z.infer<typeof CustomerIdSchema>;
|
||||
```
|
||||
|
||||
#### 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`
|
||||
@@ -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 = <T extends PgTable>(
|
||||
table: T,
|
||||
excludeColumns: (keyof T["_"]["columns"])[] = [],
|
||||
|
||||
6
server/src/external/autumn/autumnCli.ts
vendored
6
server/src/external/autumn/autumnCli.ts
vendored
@@ -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<CreateCustomerParams, "internal_options">) => {
|
||||
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;
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./operations/createStripeCustomer.js";
|
||||
export * from "./operations/getExpandedStripeCustomer.js";
|
||||
export * from "./operations/getOrCreateStripeCustomer.js";
|
||||
export * from "./utils/convertStripeCustomer.js";
|
||||
|
||||
43
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal file
43
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal file
@@ -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;
|
||||
};
|
||||
61
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
61
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
@@ -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<Stripe.Discount, "coupon"> & {
|
||||
coupon: Stripe.Coupon & {
|
||||
applies_to: Stripe.Coupon.AppliesTo | null;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
};
|
||||
|
||||
export const getExpandedStripeCustomer = async ({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeCustomerId?: string;
|
||||
}): Promise<ExpandedStripeCustomer | undefined> => {
|
||||
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;
|
||||
};
|
||||
57
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal file
57
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal file
@@ -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;
|
||||
};
|
||||
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
@@ -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)}`,
|
||||
);
|
||||
};
|
||||
194
server/src/external/stripe/stripeCusUtils.ts
vendored
194
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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<string, unknown>;
|
||||
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({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const getLatestPeriodEnd = ({
|
||||
sub,
|
||||
subItems,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<HonoEnv>, 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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -20,8 +20,6 @@ export const executeBillingPlan = async ({
|
||||
billingContext,
|
||||
});
|
||||
|
||||
// console.log("stripeBillingResult", stripeBillingResult);
|
||||
|
||||
if (stripeBillingResult.deferred)
|
||||
return {
|
||||
stripe: stripeBillingResult,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const evaluateStripeBillingPlan = async ({
|
||||
|
||||
let stripeInvoiceAction: StripeInvoiceAction | undefined;
|
||||
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
|
||||
if (createManualInvoice) {
|
||||
if (createManualInvoice && lineItems) {
|
||||
stripeInvoiceAction = buildStripeInvoiceAction({
|
||||
lineItems,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const handleUpdateSubscription = createRoute({
|
||||
stripe: stripeBillingPlan,
|
||||
},
|
||||
});
|
||||
|
||||
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||
|
||||
const response = billingResultToResponse({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -32,7 +32,6 @@ const logExistingUsages = ({
|
||||
};
|
||||
},
|
||||
);
|
||||
ctx.logger.debug(`[applyExistingUsages] existing usages:`, existinUsagesLogs);
|
||||
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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<FullCustomer> => {
|
||||
// 1. Setup
|
||||
const context = await setupCreateCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData,
|
||||
internalOptions,
|
||||
});
|
||||
|
||||
logCreateCustomerContext({ ctx, context });
|
||||
|
||||
// 3. Execute
|
||||
return executeCreateCustomerPlan({ ctx, context });
|
||||
};
|
||||
@@ -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<ExecuteAutumnResult> => {
|
||||
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 };
|
||||
};
|
||||
@@ -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<FullCustomer> => {
|
||||
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;
|
||||
};
|
||||
@@ -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<Stripe.Subscription | undefined> => {
|
||||
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;
|
||||
};
|
||||
@@ -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})`,
|
||||
) ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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<CreateCustomerContext> => {
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<DefaultProductsContext> => {
|
||||
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<string, FullProduct> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
5
server/src/internal/customers/actions/index.ts
Normal file
5
server/src/internal/customers/actions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createCustomerWithDefaults } from "./createWithDefaults/createCustomerWithDefaults.js";
|
||||
|
||||
export const customerActions = {
|
||||
createWithDefaults: createCustomerWithDefaults,
|
||||
} as const;
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiCustomer>({
|
||||
schema: ApiCustomerSchema,
|
||||
data: rest,
|
||||
});
|
||||
|
||||
const normalizedLegacyData = normalizeFromSchema<CustomerLegacyData>({
|
||||
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<ApiCustomer>({
|
||||
expand: ctx.expand,
|
||||
target: apiCustomer,
|
||||
});
|
||||
|
||||
return {
|
||||
apiCustomer: {
|
||||
...filteredApiCustomer,
|
||||
rewards: filteredApiCustomer.rewards ?? undefined,
|
||||
referrals: filteredApiCustomer.referrals ?? undefined,
|
||||
payment_method: filteredApiCustomer.payment_method ?? undefined,
|
||||
},
|
||||
legacyData,
|
||||
};
|
||||
};
|
||||
@@ -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<string, any>;
|
||||
};
|
||||
}): Promise<boolean> => {
|
||||
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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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<TrackParams | CheckParams, "customer_id"> & {
|
||||
customer_id: string | null;
|
||||
};
|
||||
source?: string;
|
||||
internalOptions?: CreateCustomerInternalOptions;
|
||||
}): Promise<FullCustomer> => {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
49
server/src/internal/customers/cusUtils/initCustomer.ts
Normal file
49
server/src/internal/customers/cusUtils/initCustomer.ts
Normal file
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Stripe.Customer | undefined>[] = [
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -133,7 +133,7 @@ export const handleVersionProductV2 = async ({
|
||||
} as FullProduct,
|
||||
org,
|
||||
env,
|
||||
logger: console,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
|
||||
@@ -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<undefined> => {
|
||||
if (!isStripeConnected({ org, env })) return;
|
||||
|
||||
await checkStripeProductExists({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -38,9 +38,9 @@ export const apiRouter = new Hono<HonoEnv>();
|
||||
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);
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
18
server/src/utils/logging/maskExtraLogs.ts
Normal file
18
server/src/utils/logging/maskExtraLogs.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Fields to mask in extra logs (replace with "[MASKED]") */
|
||||
const MASKED_FIELDS = ["fullCustomer"];
|
||||
|
||||
export const maskExtraLogs = (
|
||||
extraLogs: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const masked: Record<string, unknown> = {};
|
||||
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<string, unknown>);
|
||||
} else {
|
||||
masked[key] = value;
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
|
||||
@@ -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<ApiCustomer>(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<ApiCustomer>(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<ApiCustomer>(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);
|
||||
});
|
||||
736
server/tests/integration/balances/check/check-basic.test.ts
Normal file
736
server/tests/integration/balances/check/check-basic.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
182
server/tests/integration/balances/check/check-public-key.test.ts
Normal file
182
server/tests/integration/balances/check/check-public-key.test.ts
Normal file
@@ -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<CheckResponseV1>({
|
||||
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<ApiCustomerV3>(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<ApiCustomerV3>(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<CheckResponseV1>({
|
||||
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<ApiCustomerV3>(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<CheckResponseV1>({
|
||||
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<ApiCustomerV3>(customerId);
|
||||
|
||||
// Balance and usage should remain unchanged
|
||||
expect(customerAfterInsufficient.features[TestFeature.Messages].balance).toBe(850);
|
||||
expect(customerAfterInsufficient.features[TestFeature.Messages].usage).toBe(150);
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomer>(
|
||||
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);
|
||||
});
|
||||
@@ -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<ApiCustomer>(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<ApiCustomer>(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<ApiCustomer>(newCustomerId);
|
||||
expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(100);
|
||||
});
|
||||
@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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);
|
||||
});
|
||||
@@ -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<ApiCustomerV3>(
|
||||
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<ApiCustomerV3>(
|
||||
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<ApiCustomerV3>(
|
||||
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<ApiCustomerV3>(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");
|
||||
});
|
||||
@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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,
|
||||
});
|
||||
});
|
||||
@@ -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<Customer>(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<string, number>,
|
||||
);
|
||||
|
||||
// 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<string, number>,
|
||||
);
|
||||
|
||||
for (const [_productId, count] of Object.entries(productCounts)) {
|
||||
expect(count).toBe(1);
|
||||
}
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
178
server/tests/integration/crud/customers/create-customer.test.ts
Normal file
178
server/tests/integration/crud/customers/create-customer.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof initCustomerV3>>["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<ReturnType<typeof initCustomerV3>>["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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof CustomerDataSchema>;
|
||||
export type CreateCustomerInternalOptions = z.infer<
|
||||
typeof CreateCustomerInternalOptionsSchema
|
||||
>;
|
||||
|
||||
38
shared/api/common/customerId.ts
Normal file
38
shared/api/common/customerId.ts
Normal file
@@ -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<typeof CustomerIdSchema>;
|
||||
34
shared/api/customers/createCustomerParams.ts
Normal file
34
shared/api/customers/createCustomerParams.ts
Normal file
@@ -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<typeof CreateCustomerParamsSchema>;
|
||||
@@ -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<typeof CreateCustomerParamsSchema>;
|
||||
export type UpdateCustomerParams = z.infer<typeof UpdateCustomerParamsSchema>;
|
||||
export type ListCustomersQuery = z.infer<typeof ListCustomersQuerySchema>;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof CustomerSchema>;
|
||||
|
||||
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<typeof CustomerSchema>;
|
||||
// export type CustomerData = z.infer<typeof CustomerDataSchema>;
|
||||
export type CustomerResponse = z.infer<typeof CustomerResponseSchema>;
|
||||
export type CreateCustomer = z.infer<typeof CreateCustomerSchema>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user