updated create customer flow with new billing pattern

This commit is contained in:
John Yeo
2026-01-20 17:40:13 +00:00
parent 16b083ff39
commit dd5e183064
95 changed files with 4681 additions and 1551 deletions

View File

@@ -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

View File

@@ -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`:

View 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`

View File

@@ -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"])[] = [],

View File

@@ -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;

View File

@@ -1 +1,4 @@
export * from "./operations/createStripeCustomer.js";
export * from "./operations/getExpandedStripeCustomer.js";
export * from "./operations/getOrCreateStripeCustomer.js";
export * from "./utils/convertStripeCustomer.js";

View 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;
};

View 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;
};

View 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;
};

View 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)}`,
);
};

View File

@@ -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({

View File

@@ -1,5 +1,4 @@
import type Stripe from "stripe";
export const getLatestPeriodEnd = ({
sub,
subItems,

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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({

View File

@@ -20,8 +20,6 @@ export const executeBillingPlan = async ({
billingContext,
});
// console.log("stripeBillingResult", stripeBillingResult);
if (stripeBillingResult.deferred)
return {
stripe: stripeBillingResult,

View File

@@ -50,7 +50,7 @@ export const evaluateStripeBillingPlan = async ({
let stripeInvoiceAction: StripeInvoiceAction | undefined;
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
if (createManualInvoice) {
if (createManualInvoice && lineItems) {
stripeInvoiceAction = buildStripeInvoiceAction({
lineItems,
});

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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,
};
}

View File

@@ -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)

View File

@@ -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,
});

View File

@@ -69,6 +69,7 @@ export const handleUpdateSubscription = createRoute({
stripe: stripeBillingPlan,
},
});
logStripeBillingResult({ ctx, result: billingResult.stripe });
const response = billingResultToResponse({

View File

@@ -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",
},
},
});

View File

@@ -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)),

View File

@@ -32,7 +32,6 @@ const logExistingUsages = ({
};
},
);
ctx.logger.debug(`[applyExistingUsages] existing usages:`, existinUsagesLogs);
addToExtraLogs({
ctx,

View File

@@ -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,

View File

@@ -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: {},
});
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 });
};

View File

@@ -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 };
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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})`,
) ?? [],
},
},
});
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -0,0 +1,5 @@
import { createCustomerWithDefaults } from "./createWithDefaults/createCustomerWithDefaults.js";
export const customerActions = {
createWithDefaults: createCustomerWithDefaults,
} as const;

View File

@@ -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,
}),
);
}

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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,
};
};

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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

View File

@@ -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,
};
};

View File

@@ -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) {

View 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: [],
};
};

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
});
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -133,7 +133,7 @@ export const handleVersionProductV2 = async ({
} as FullProduct,
org,
env,
logger: console,
logger: ctx.logger,
});
await addTaskToQueue({

View File

@@ -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({

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,

View File

@@ -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 }),
});
};

View File

@@ -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);

View File

@@ -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`);

View 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;
};

View File

@@ -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({

View File

@@ -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

View File

@@ -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,
// });

View File

@@ -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);
});

View 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,
});
});

View 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);
},
);

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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");
});

View File

@@ -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,
});
});

View File

@@ -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,
});
});

View 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,
});
},
});
});

View File

@@ -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([]);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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,

View File

@@ -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,
};
};
}

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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
>;

View 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>;

View 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>;

View File

@@ -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>;

View File

@@ -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 },
},
},
},
},
},
};

View File

@@ -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";

View File

@@ -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>;

View File

@@ -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();

View File

@@ -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;
}