refactor stripe invoice created
This commit is contained in:
106
.claude/skills/workflows/SKILL.md
Normal file
106
.claude/skills/workflows/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: workflows
|
||||
description: Create async background tasks (workflows) using SQS or Hatchet. Use when building queue jobs, background processing, or async tasks.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Workflows are async tasks processed by background workers. Two runners:
|
||||
|
||||
| Runner | Use Case | Features |
|
||||
|--------|----------|----------|
|
||||
| **SQS** | Simple fire-and-forget tasks | Fast, no dependencies, max 15min delay |
|
||||
| **Hatchet** | Complex workflows needing retries, multi-step, or long delays | Typed outputs, configurable timeouts, observability |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Job Name
|
||||
|
||||
```typescript
|
||||
// server/src/queue/JobName.ts
|
||||
export enum JobName {
|
||||
// ... existing
|
||||
MyNewWorkflow = "my-new-workflow",
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define Payload & Register
|
||||
|
||||
```typescript
|
||||
// server/src/queue/workflows.ts
|
||||
|
||||
// Add payload type
|
||||
export type MyNewWorkflowPayload = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
// ... your fields
|
||||
};
|
||||
|
||||
// Add to registry
|
||||
const workflowRegistry = {
|
||||
// ... existing
|
||||
myNewWorkflow: {
|
||||
jobName: JobName.MyNewWorkflow,
|
||||
runner: "sqs", // or "hatchet"
|
||||
} as WorkflowConfig<MyNewWorkflowPayload>,
|
||||
};
|
||||
|
||||
// Add trigger function
|
||||
export const workflows = {
|
||||
// ... existing
|
||||
triggerMyNewWorkflow: (payload: MyNewWorkflowPayload, options?: TriggerOptions) =>
|
||||
triggerWorkflow({ name: "myNewWorkflow", payload, options }),
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Create Handler
|
||||
|
||||
**For SQS:** See [references/SQS.md](references/SQS.md)
|
||||
|
||||
**For Hatchet:** See [references/HATCHET.md](references/HATCHET.md)
|
||||
|
||||
### 4. Trigger from Code
|
||||
|
||||
```typescript
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
await workflows.triggerMyNewWorkflow({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
});
|
||||
|
||||
// With delay
|
||||
await workflows.triggerMyNewWorkflow(payload, { delayMs: 5000 });
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/src/
|
||||
├── queue/
|
||||
│ ├── JobName.ts # Job name enum
|
||||
│ ├── workflows.ts # Registry + triggers
|
||||
│ └── initWorkers.ts # SQS message routing
|
||||
└── internal/.../workflows/
|
||||
└── myNewWorkflow/
|
||||
├── myNewWorkflow.ts # Handler
|
||||
└── triggerMyNewWorkflow.ts # (optional) trigger helper
|
||||
```
|
||||
|
||||
## Required Payload Fields
|
||||
|
||||
All workflows must include:
|
||||
```typescript
|
||||
{
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId?: string; // optional but common
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [references/SQS.md](references/SQS.md) - SQS workflow implementation
|
||||
- [references/HATCHET.md](references/HATCHET.md) - Hatchet workflow implementation
|
||||
135
.claude/skills/workflows/references/HATCHET.md
Normal file
135
.claude/skills/workflows/references/HATCHET.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Hatchet Workflows
|
||||
|
||||
For complex workflows needing retries, multi-step, typed outputs, or long delays.
|
||||
|
||||
## Workflow Definition
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import { hatchet } from "@/external/hatchet/initHatchet.js";
|
||||
import { createWorkflowTask } from "@/queue/hatchetWorkflows/createWorkflowTask.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
|
||||
// 1. Define input/output types
|
||||
export type MyWorkflowInput = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
type MyWorkflowOutput = {
|
||||
myTask: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 2. Create workflow (only if Hatchet enabled)
|
||||
export const myWorkflow = hatchet?.workflow<MyWorkflowInput, MyWorkflowOutput>({
|
||||
name: JobName.MyWorkflow,
|
||||
});
|
||||
|
||||
// 3. Define task
|
||||
myWorkflow?.task({
|
||||
name: JobName.MyWorkflow,
|
||||
executionTimeout: "60s",
|
||||
fn: createWorkflowTask<MyWorkflowInput, MyWorkflowOutput["myTask"]>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
const { customerId } = input;
|
||||
|
||||
// Your logic here
|
||||
autumnContext.logger.info(`Processing ${customerId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Completed",
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Register Worker
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
export const initHatchetWorker = async () => {
|
||||
if (!hatchet) return;
|
||||
|
||||
const worker = await hatchet.worker("hatchet-worker", {
|
||||
workflows: [
|
||||
verifyCacheConsistency!,
|
||||
myWorkflow!, // Add here
|
||||
],
|
||||
});
|
||||
|
||||
worker.start().catch(console.error);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in queueUtils.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/queueUtils.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const hatchetWorkflows: Record<JobName, any> = {
|
||||
[JobName.VerifyCacheConsistency]: verifyCacheConsistency,
|
||||
[JobName.MyWorkflow]: myWorkflow, // Add here
|
||||
};
|
||||
```
|
||||
|
||||
## Triggering with Options
|
||||
|
||||
```typescript
|
||||
await workflows.triggerMyWorkflow(payload, {
|
||||
delayMs: 5000,
|
||||
metadata: {
|
||||
workflowId: generateId("workflow"),
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## createWorkflowTask Helper
|
||||
|
||||
Provides:
|
||||
- Automatic `AutumnContext` creation from input
|
||||
- Error handling with Sentry integration
|
||||
- Workflow logging context
|
||||
|
||||
```typescript
|
||||
createWorkflowTask<TInput, TOutput>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
// input: Your typed input
|
||||
// autumnContext: Full AutumnContext with logger, db, org, env, etc.
|
||||
return output;
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "hatchet"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create workflow file with `hatchet?.workflow()` + `.task()`
|
||||
6. ☐ Add to `hatchetWorkflows` map in `queueUtils.ts`
|
||||
7. ☐ Add to `initHatchetWorker` workflows array
|
||||
|
||||
## SQS vs Hatchet
|
||||
|
||||
| Feature | SQS | Hatchet |
|
||||
|---------|-----|---------|
|
||||
| Setup complexity | Lower | Higher |
|
||||
| Typed output | No | Yes |
|
||||
| Multi-step tasks | No | Yes |
|
||||
| Configurable timeout | 30s visibility | Per-task |
|
||||
| Max delay | 15 minutes | Unlimited |
|
||||
| Observability | CloudWatch | Hatchet UI |
|
||||
124
.claude/skills/workflows/references/SQS.md
Normal file
124
.claude/skills/workflows/references/SQS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# SQS Workflows
|
||||
|
||||
Simple async tasks processed by SQS workers.
|
||||
|
||||
## Handler Signature
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { MyWorkflowPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const myWorkflow = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: MyWorkflowPayload;
|
||||
}) => {
|
||||
const { customerId } = payload;
|
||||
|
||||
// Your logic here
|
||||
ctx.logger.info(`Processing ${customerId}`);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in initWorkers.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const processMessage = async ({ message, db }) => {
|
||||
// ... existing code
|
||||
|
||||
if (job.name === JobName.MyWorkflow) {
|
||||
if (!ctx) {
|
||||
workerLogger.error("No context found for my workflow job");
|
||||
return;
|
||||
}
|
||||
await myWorkflow({ ctx, payload: job.data });
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of handlers
|
||||
};
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts
|
||||
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { SendProductsUpdatedPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const sendProductsUpdated = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: SendProductsUpdatedPayload;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
const { customerProductId, scenario, customerId } = payload;
|
||||
|
||||
const fullCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
// ... build webhook payload
|
||||
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
eventType: "customer.products.updated",
|
||||
data: { scenario, customer, updated_product },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Trigger Helper (Optional)
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts
|
||||
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
export const billingPlanToSendProductsUpdated = async ({
|
||||
ctx,
|
||||
cusProduct,
|
||||
scenario,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusProduct: CustomerProduct;
|
||||
scenario: string;
|
||||
}) => {
|
||||
// Skip in tests if configured
|
||||
if (ctx.testOptions?.skipWebhooks) return;
|
||||
|
||||
await workflows.triggerSendProductsUpdated({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: cusProduct.customer_id,
|
||||
customerProductId: cusProduct.id,
|
||||
scenario,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "sqs"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create handler file
|
||||
6. ☐ Add case in `initWorkers.ts` `processMessage`
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +78,7 @@ Load these on-demand for detailed information:
|
||||
- [references/TRACK-CHECK.md](references/TRACK-CHECK.md) - Track/check endpoint testing, credit systems, Decimal.js
|
||||
- [references/EXPECTATIONS.md](references/EXPECTATIONS.md) - All expectation utilities
|
||||
- [references/GOTCHAS.md](references/GOTCHAS.md) - Common pitfalls, debugging, billing edge cases
|
||||
- [references/WEBHOOKS.md](references/WEBHOOKS.md) - Outbound webhook testing with Svix Play
|
||||
|
||||
## File Location
|
||||
|
||||
|
||||
@@ -280,6 +280,41 @@ await autumnV1.subscriptions.update({
|
||||
- Clearer test intent - reader immediately sees what's being tested
|
||||
- Less verification boilerplate - no need to verify setup worked
|
||||
- Faster test writing - `s.*` builders handle common patterns
|
||||
## 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
|
||||
|
||||
|
||||
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Outbound Webhook Testing
|
||||
|
||||
Test Autumn's outbound webhooks using Svix Play (free, no signup).
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import { generatePlayToken, getPlayWebhookUrl, waitForWebhook } from "./utils/svixPlayClient.js";
|
||||
import { createTestEndpoint, deleteTestEndpoint } from "./utils/svixTestEndpoint.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
playToken = await generatePlayToken();
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (!svixAppId) throw new Error("Svix not configured");
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl: getPlayWebhookUrl(playToken) });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
});
|
||||
```
|
||||
|
||||
## Test Pattern
|
||||
|
||||
```typescript
|
||||
test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated")}`, async () => {
|
||||
const customerId = "webhook-test";
|
||||
const freeDefault = products.base({ id: "free", items: [...], isDefault: true });
|
||||
|
||||
// Setup products only (no customer)
|
||||
const { autumnV1 } = await initScenario({
|
||||
setup: [s.products({ list: [freeDefault], prefix: customerId })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Create customer with webhooks enabled
|
||||
await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test",
|
||||
internalOptions: { disable_defaults: false, default_group: customerId },
|
||||
skipWebhooks: false, // Enable webhooks
|
||||
});
|
||||
|
||||
// Wait for webhook
|
||||
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (p) => p.type === "customer.products.updated" && p.data?.customer?.id === customerId,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.payload.data.scenario).toBe("new");
|
||||
});
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
| Normal Tests | Webhook Tests |
|
||||
|--------------|---------------|
|
||||
| `initScenario` creates customer | Create customer manually with `skipWebhooks: false` |
|
||||
| Immediate assertions | Poll with `waitForWebhook` (10-15s timeout) |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `generatePlayToken()` | Get Svix Play token |
|
||||
| `getPlayWebhookUrl(token)` | Get webhook URL |
|
||||
| `waitForWebhook({ token, predicate, timeoutMs })` | Poll for webhook |
|
||||
| `createTestEndpoint({ appId, playUrl })` | Register endpoint |
|
||||
| `deleteTestEndpoint({ appId, endpointId })` | Cleanup |
|
||||
|
||||
## Location
|
||||
|
||||
`server/tests/integration/billing/autumn-webhooks/`
|
||||
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# handleCreateCustomer Refactor Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Refactor `handleCreateCustomer` to:
|
||||
1. Eliminate race conditions causing duplicate customers or missing default products
|
||||
2. Clean up input types with a single source of truth
|
||||
3. Ensure comprehensive test coverage before making changes
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Type Cleanup
|
||||
|
||||
### Problem
|
||||
|
||||
Three overlapping types with duplicated ID validation logic:
|
||||
- `CreateCustomerSchema` in `shared/models/cusModels/cusModels.ts`
|
||||
- `CustomerDataSchema` in `shared/api/common/customerData.ts`
|
||||
- `CreateCustomerParamsSchema` in `shared/api/customers/customerOpModels.ts`
|
||||
|
||||
### Solution
|
||||
|
||||
Make `shared/api/common/customerData.ts` the single source of truth.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. `shared/api/common/customerData.ts` - Add CustomerIdSchema
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Reusable customer ID validation - can be used by attach, check, track, etc.
|
||||
export const CustomerIdSchema = z.string().refine(
|
||||
(val) => {
|
||||
if (val === "") return false;
|
||||
if (val.includes("@")) return false;
|
||||
if (val.includes(" ")) return false;
|
||||
if (val.includes(".")) return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(val);
|
||||
},
|
||||
{
|
||||
error: (issue) => {
|
||||
const input = issue.input as string;
|
||||
if (input === "") return { message: "can't be an empty string" };
|
||||
if (input.includes("@"))
|
||||
return {
|
||||
message: "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes(" "))
|
||||
return {
|
||||
message: "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes("."))
|
||||
return {
|
||||
message: "cannot contain periods. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0];
|
||||
return {
|
||||
message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const CustomerDataSchema = z
|
||||
.object({
|
||||
name: z.string().nullish().meta({ description: "Customer's name" }),
|
||||
email: z.string().nullish().meta({ description: "Customer's email address" }),
|
||||
fingerprint: z.string().nullish().meta({ internal: true }),
|
||||
metadata: z.record(z.any(), z.any()).nullish().meta({ internal: true }),
|
||||
stripe_id: z.string().nullish().meta({ internal: true }),
|
||||
disable_default: z.boolean().optional().meta({ internal: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CustomerData",
|
||||
description: "Customer details to set when creating a customer",
|
||||
});
|
||||
|
||||
export type CustomerData = z.infer<typeof CustomerDataSchema>;
|
||||
export type CustomerId = z.infer<typeof CustomerIdSchema>;
|
||||
```
|
||||
|
||||
#### 2. `shared/api/customers/customerOpModels.ts` - Use CustomerIdSchema
|
||||
|
||||
```typescript
|
||||
import { CustomerDataSchema, CustomerIdSchema } from "../common/customerData.js";
|
||||
|
||||
// Remove duplicate customerId const, use CustomerIdSchema instead
|
||||
|
||||
export const CreateCustomerParamsSchema = z.object({
|
||||
id: CustomerIdSchema.nullable().meta({
|
||||
description: "Your unique identifier for the customer",
|
||||
}),
|
||||
...CustomerDataSchema.shape,
|
||||
entity_id: z.string().optional().meta({ internal: true }),
|
||||
entity_data: EntityDataSchema.optional().meta({ internal: true }),
|
||||
});
|
||||
|
||||
export const UpdateCustomerParamsSchema = z.object({
|
||||
id: CustomerIdSchema.optional().meta({
|
||||
description: "New unique identifier for the customer.",
|
||||
}),
|
||||
// ... rest uses CustomerDataSchema fields
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. `shared/models/cusModels/cusModels.ts` - Remove CreateCustomerSchema
|
||||
|
||||
- Delete `CreateCustomerSchema` (lines 21-69)
|
||||
- Delete `CreateCustomer` type export (line 78)
|
||||
- Keep `CustomerSchema` and `Customer` type (used for DB model)
|
||||
|
||||
#### 4. `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New Signature
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
export const handleCreateCustomer = async ({
|
||||
ctx,
|
||||
cusData, // CreateCustomer type
|
||||
createDefaultProducts,
|
||||
defaultGroup,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusData: CreateCustomer;
|
||||
createDefaultProducts?: boolean;
|
||||
defaultGroup?: string;
|
||||
})
|
||||
|
||||
// NEW
|
||||
export const handleCreateCustomer = async ({
|
||||
ctx,
|
||||
customerId, // string | null
|
||||
customerData, // CustomerData
|
||||
options,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string | null;
|
||||
customerData?: CustomerData;
|
||||
options?: {
|
||||
createDefaultProducts?: boolean;
|
||||
defaultGroup?: string;
|
||||
};
|
||||
})
|
||||
```
|
||||
|
||||
#### 5. Update All Callers
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `getOrCreateCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `getOrCreateCachedFullCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `handlePostCustomerV2.ts` | Extract `id` from parsed body, pass rest as customerData |
|
||||
| `getOrCreateApiCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `createNewCustomer.ts` | Update import, accept new shape |
|
||||
|
||||
### Future Work (Not in This PR)
|
||||
|
||||
These files can later adopt `CustomerIdSchema` for validation:
|
||||
- `shared/api/balances/check/checkParams.ts` - `customer_id: CustomerIdSchema`
|
||||
- `shared/api/balances/track/trackParams.ts`
|
||||
- `shared/api/billing/attach/*`
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Test Structure
|
||||
|
||||
### File Organization
|
||||
|
||||
**3 new test files** using the modern `test.concurrent` + `initScenario` pattern:
|
||||
|
||||
| File | Theme |
|
||||
|------|-------|
|
||||
| `create-customer.test.ts` | Basic creation + email flows |
|
||||
| `create-customer-defaults.test.ts` | Default product attachment |
|
||||
| `create-customer-race.test.ts` | Race condition tests (low-level simulation) |
|
||||
|
||||
**Delete after migration:**
|
||||
- `create-customer1.test.ts` (old pattern)
|
||||
- `create-customer2.test.ts` (old pattern)
|
||||
|
||||
**Add to existing files:**
|
||||
- `check-race-condition2.test.ts` → Customer auto-creation race via /check
|
||||
- `track-race-condition5.test.ts` → Customer auto-creation race via /track
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Test Cases
|
||||
|
||||
### `create-customer.test.ts` - Basic Creation + Email Flows
|
||||
|
||||
| # | Test Name | Description | From |
|
||||
|---|-----------|-------------|------|
|
||||
| 1 | `create: basic with ID` | Create customer with ID, name, email | Migrate from create-customer1 |
|
||||
| 2 | `create: idempotent with same ID` | Create same customer twice returns existing | Migrate from create-customer1 |
|
||||
| 3 | `create: with expand params` | Create with expand returns invoices, trials_used, entities | Migrate from create-customer1 |
|
||||
| 4 | `create: concurrent same ID` | Promise.all two creates with same ID | Migrate from create-customer2 |
|
||||
| 5 | `create: null ID with email` | Create customer with id=null and valid email | NEW |
|
||||
| 6 | `create: null ID no email (error)` | Create with id=null and no email throws | NEW |
|
||||
| 7 | `create: null ID idempotent` | Create with id=null same email twice returns existing | NEW |
|
||||
| 8 | `create: null ID then add ID` | Create with id=null, then create with same email + ID updates existing | NEW |
|
||||
| 9 | `create: concurrent null ID same email` | Promise.all two creates with id=null, same email | NEW |
|
||||
|
||||
### `create-customer-defaults.test.ts` - Default Product Attachment
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 10 | `defaults: single free product` | Create customer with single default free product attached |
|
||||
| 11 | `defaults: multiple groups` | Two default free products in different groups, both attached |
|
||||
| 12 | `defaults: same group priority` | Two defaults in same group, priority: trial > paid > free |
|
||||
| 13 | `defaults: trial product` | Default trial attaches with status=trialing |
|
||||
| 14 | `defaults: paid product (legacy)` | Default paid with forcePaidDefault=true uses handleAddProduct |
|
||||
| 15 | `defaults: paid requires Stripe customer` | Default paid creates Stripe customer, sets stripe_id |
|
||||
|
||||
### `create-customer-race.test.ts` - Race Condition Tests (Low-Level)
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 16 | `race: stale cache detection` | Insert customer → concurrent request caches incomplete → getOrCreate detects stale |
|
||||
| 17 | `race: concurrent default loop` | Insert → start attaching defaults → concurrent 23505 → retry sees all defaults |
|
||||
| 18 | `race: concurrent same ID (API level)` | Promise.all creates with same ID, one gets 23505, both return same customer |
|
||||
| 19 | `race: concurrent email+ID update` | Customer exists id=null, two requests add ID via same email |
|
||||
| 20 | `race: concurrent Stripe customer` | Default paid: concurrent creates only create one Stripe customer |
|
||||
|
||||
### Entry Point Auto-Creation Race Tests
|
||||
|
||||
#### `check-race-condition2.test.ts`
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 21 | `check-autocreate: concurrent same customer_id` | Concurrent /check calls auto-creating same customer |
|
||||
|
||||
#### `track-race-condition5.test.ts`
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 22 | `track-autocreate: concurrent same customer_id` | Concurrent /track calls auto-creating same customer, usage correct |
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Test Implementation Pattern
|
||||
|
||||
### Modern Pattern: `test.concurrent` + `initScenario`
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "bun:test";
|
||||
import { CusExpand } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// BASIC CREATION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "create-basic-id",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Delete to test fresh create
|
||||
try { await autumnV1.customers.delete(customerId); } catch {}
|
||||
|
||||
const data = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
expect(data.id).toBe(customerId);
|
||||
expect(data.name).toBe("Test Customer");
|
||||
expect(data.email).toBe(`${customerId}@example.com`);
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "create-idempotent",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// First create
|
||||
const data1 = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
// Second create - should return existing
|
||||
const data2 = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
expect(data1.id).toBe(data2.id);
|
||||
expect(data1.internal_id).toBe(data2.internal_id);
|
||||
});
|
||||
```
|
||||
|
||||
### Low-Level Race Simulation Pattern
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "bun:test";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js";
|
||||
import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("race: stale cache detection")}`, async () => {
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 1000 });
|
||||
const freeDefault = products.base({ id: "free", items: [wordsItem], isDefault: true });
|
||||
|
||||
const { customerId, ctx, autumnV2 } = await initScenario({
|
||||
customerId: "race-stale-cache",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Delete customer so we can manually reproduce race
|
||||
try { await autumnV2.customers.delete(customerId); } catch {}
|
||||
await deleteCachedFullCustomer({ ctx, customerId, source: "test-cleanup" });
|
||||
|
||||
// STEP 1: Insert customer directly (bypassing handleCreateCustomer)
|
||||
const internalId = generateId("cus");
|
||||
await CusService.insert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
id: customerId,
|
||||
internal_id: internalId,
|
||||
org_id: ctx.org.id,
|
||||
env: ctx.env,
|
||||
name: customerId,
|
||||
email: `${customerId}@test.com`,
|
||||
metadata: {},
|
||||
created_at: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
// STEP 2: Simulate concurrent request caching incomplete customer
|
||||
const incompleteCustomer = await CusService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
await setCachedFullCustomer({
|
||||
ctx,
|
||||
fullCustomer: incompleteCustomer!,
|
||||
customerId,
|
||||
fetchTimeMs: Date.now(),
|
||||
source: "test-concurrent-request",
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
// STEP 3: Call actual function - should detect stale state
|
||||
const result = await getOrCreateCachedFullCustomer({
|
||||
ctx,
|
||||
params: { customer_id: customerId, feature_id: TestFeature.Words },
|
||||
source: "test-final-check",
|
||||
});
|
||||
|
||||
// Verify: Customer has default products
|
||||
expect(result.customer_products?.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Implementation Order
|
||||
|
||||
### Phase 1: Write Tests (RED)
|
||||
1. Create `create-customer.test.ts` - migrate old tests + add new null ID tests
|
||||
2. Create `create-customer-defaults.test.ts` - default product tests
|
||||
3. Create `create-customer-race.test.ts` - race condition tests
|
||||
4. Add tests to `check-race-condition2.test.ts` and `track-race-condition5.test.ts`
|
||||
5. Delete `create-customer1.test.ts` and `create-customer2.test.ts`
|
||||
6. Run tests - some will fail (documenting expected behavior)
|
||||
|
||||
### Phase 2: Type Cleanup
|
||||
1. Add `CustomerIdSchema` to `customerData.ts`
|
||||
2. Update `customerOpModels.ts` to use it
|
||||
3. Update `handleCreateCustomer` signature
|
||||
4. Update all callers
|
||||
5. Remove `CreateCustomerSchema` from `cusModels.ts`
|
||||
|
||||
### Phase 3: Fix Race Conditions (GREEN)
|
||||
1. Analyze failing tests
|
||||
2. Implement proper locking/transactions
|
||||
3. Potential fixes:
|
||||
- Use database transaction for insert + default products
|
||||
- Add advisory lock during customer creation
|
||||
- Detect stale cache by checking customer_products count
|
||||
|
||||
### Phase 4: Verify
|
||||
1. All tests pass
|
||||
2. Manual testing of concurrent scenarios
|
||||
3. Review for any remaining edge cases
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### To Create
|
||||
- `server/tests/integration/crud/customers/create-customer.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer-defaults.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer-race.test.ts`
|
||||
- `server/tests/integration/balances/check/check-race-condition2.test.ts`
|
||||
- `server/tests/balances/track/race-condition/track-race-condition5.test.ts`
|
||||
|
||||
### To Delete
|
||||
- `server/tests/integration/crud/customers/create-customer1.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer2.test.ts`
|
||||
|
||||
### Type Cleanup (Modify)
|
||||
- `shared/api/common/customerData.ts` - Add `CustomerIdSchema`
|
||||
- `shared/api/customers/customerOpModels.ts` - Use `CustomerIdSchema`, remove duplicate
|
||||
- `shared/models/cusModels/cusModels.ts` - Remove `CreateCustomerSchema`
|
||||
- `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New signature
|
||||
- `server/src/internal/customers/cusUtils/getOrCreateCustomer.ts`
|
||||
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts`
|
||||
- `server/src/internal/customers/cusUtils/createNewCustomer.ts`
|
||||
- `server/src/internal/customers/handlers/handlePostCustomerV2.ts`
|
||||
- `server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts`
|
||||
3
.superset/config.json
Normal file
3
.superset/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"setup": ["./.superset/setup.sh"]
|
||||
}
|
||||
131
.superset/setup.sh
Executable file
131
.superset/setup.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/bin/zsh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting Superset workspace setup for Autumn..."
|
||||
|
||||
# Check for Bun
|
||||
if ! command -v bun &> /dev/null; then
|
||||
echo "Error: Bun is not installed."
|
||||
echo "Please install Bun from https://bun.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bun found: $(bun --version)"
|
||||
|
||||
# Determine root path - use SUPERSET_ROOT_PATH if set, otherwise use git root
|
||||
if [ -n "$SUPERSET_ROOT_PATH" ]; then
|
||||
ROOT_PATH="$SUPERSET_ROOT_PATH"
|
||||
else
|
||||
# Fallback for manual testing - go up two directories from .superset/workspace
|
||||
ROOT_PATH="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
fi
|
||||
|
||||
echo "Root path: $ROOT_PATH"
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
bun install
|
||||
|
||||
# Copy .env files from root repo
|
||||
echo "Copying .env files from root repository..."
|
||||
|
||||
# Copy all .env* files from server/
|
||||
if [ -d "$ROOT_PATH/server" ]; then
|
||||
mkdir -p server
|
||||
for env_file in "$ROOT_PATH/server"/.env*; do
|
||||
if [ -f "$env_file" ]; then
|
||||
filename=$(basename "$env_file")
|
||||
cp "$env_file" "server/$filename"
|
||||
echo "Copied server/$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Warning: $ROOT_PATH/server directory not found"
|
||||
fi
|
||||
|
||||
# Copy all .env* files from vite/
|
||||
if [ -d "$ROOT_PATH/vite" ]; then
|
||||
mkdir -p vite
|
||||
for env_file in "$ROOT_PATH/vite"/.env*; do
|
||||
if [ -f "$env_file" ]; then
|
||||
filename=$(basename "$env_file")
|
||||
cp "$env_file" "vite/$filename"
|
||||
echo "Copied vite/$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Warning: $ROOT_PATH/vite directory not found"
|
||||
fi
|
||||
|
||||
# Copy all .env* files from shared/
|
||||
if [ -d "$ROOT_PATH/shared" ]; then
|
||||
mkdir -p shared
|
||||
for env_file in "$ROOT_PATH/shared"/.env*; do
|
||||
if [ -f "$env_file" ]; then
|
||||
filename=$(basename "$env_file")
|
||||
cp "$env_file" "shared/$filename"
|
||||
echo "Copied shared/$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Warning: $ROOT_PATH/shared directory not found"
|
||||
fi
|
||||
|
||||
# Copy all .sh files from root
|
||||
echo "Copying shell scripts from root repository..."
|
||||
for sh_file in "$ROOT_PATH"/*.sh; do
|
||||
if [ -f "$sh_file" ]; then
|
||||
filename=$(basename "$sh_file")
|
||||
# Skip conductor-setup.sh itself
|
||||
if [ "$filename" != "conductor-setup.sh" ]; then
|
||||
cp "$sh_file" "$filename"
|
||||
chmod +x "$filename"
|
||||
echo "Copied $filename"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy all .sh files from server/
|
||||
echo "Copying shell scripts from server directory..."
|
||||
if [ -d "$ROOT_PATH/server" ]; then
|
||||
mkdir -p server
|
||||
for sh_file in "$ROOT_PATH/server"/*.sh; do
|
||||
if [ -f "$sh_file" ]; then
|
||||
filename=$(basename "$sh_file")
|
||||
cp "$sh_file" "server/$filename"
|
||||
chmod +x "server/$filename"
|
||||
echo "Copied server/$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Warning: $ROOT_PATH/server directory not found"
|
||||
fi
|
||||
|
||||
# Copy all .sh files from server/shell/
|
||||
echo "Copying shell scripts from server/shell directory..."
|
||||
if [ -d "$ROOT_PATH/server/shell" ]; then
|
||||
mkdir -p server/shell
|
||||
for sh_file in "$ROOT_PATH/server/shell"/*.sh; do
|
||||
if [ -f "$sh_file" ]; then
|
||||
filename=$(basename "$sh_file")
|
||||
cp "$sh_file" "server/shell/$filename"
|
||||
chmod +x "server/shell/$filename"
|
||||
echo "Copied server/shell/$filename"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Warning: $ROOT_PATH/server/shell directory not found"
|
||||
fi
|
||||
|
||||
# Copy drizzle migration files
|
||||
if [ -d "$ROOT_PATH/shared/drizzle" ]; then
|
||||
echo "Copying database migration files..."
|
||||
mkdir -p shared/drizzle
|
||||
cp -r "$ROOT_PATH/shared/drizzle/"* shared/drizzle/
|
||||
echo "Copied migration files"
|
||||
fi
|
||||
|
||||
echo "Workspace setup complete!"
|
||||
echo ""
|
||||
echo "Next: Start the development server with 'bun run dev:bun'"
|
||||
48
bun.lock
48
bun.lock
@@ -29,9 +29,11 @@
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"ink": "^6.6.0",
|
||||
"inquirer": "^12.6.3",
|
||||
"ora": "^9.0.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"react": "^19.2.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
@@ -2014,7 +2016,7 @@
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
@@ -3108,7 +3110,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
|
||||
|
||||
@@ -3316,7 +3318,7 @@
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
|
||||
"react-cmdk": ["react-cmdk@1.3.9", "", { "dependencies": { "@headlessui/react": "^1.6.4", "@heroicons/react": "^2.0.13", "html-webpack-plugin": "^5.5.0" }, "peerDependencies": { "react": "^16.x || ^17.x || ^18.x", "react-dom": "^16.x || ^17.x || ^18.x" } }, "sha512-MSVmAQZ9iqY7hO3r++XP6yWSHzGfMDGMvY3qlDT8k5RiWoRFwO1CGPlsWzhvcUbPilErzsMKK7uB4McEcX4B6g=="],
|
||||
|
||||
@@ -3428,7 +3430,7 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
@@ -3906,12 +3908,16 @@
|
||||
|
||||
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@autumn/server/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
|
||||
|
||||
"@autumn/vite/@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="],
|
||||
|
||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
|
||||
"@autumn/vite/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@autumn/vite/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
||||
|
||||
"@autumn/vite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
@@ -4274,6 +4280,8 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.1.0", "", {}, "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA=="],
|
||||
|
||||
"@headlessui/react/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
@@ -4636,8 +4644,6 @@
|
||||
|
||||
"http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"ink/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"ink-spinner/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
@@ -4676,6 +4682,8 @@
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -4702,8 +4710,16 @@
|
||||
|
||||
"public-encrypt/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
|
||||
|
||||
"react-cmdk/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-confetti-explosion/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-day-picker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
|
||||
"react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-email/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
@@ -4720,8 +4736,6 @@
|
||||
|
||||
"renderkid/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
@@ -5258,8 +5272,6 @@
|
||||
|
||||
"gtoken/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"ink/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"langsmith/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"langsmith/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -5282,6 +5294,8 @@
|
||||
|
||||
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
||||
@@ -5352,6 +5366,8 @@
|
||||
|
||||
"react-email/glob/path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
|
||||
|
||||
"react-email/ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"react-email/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"react-email/ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
@@ -5630,8 +5646,6 @@
|
||||
|
||||
"css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||
|
||||
"ink/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"md5.js/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"md5.js/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
@@ -5642,6 +5656,10 @@
|
||||
|
||||
"nodemon/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
|
||||
@@ -5650,6 +5668,8 @@
|
||||
|
||||
"react-email/glob/path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"react-email/ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"react-email/ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
@@ -5746,6 +5766,10 @@
|
||||
|
||||
"@aws-sdk/credential-providers/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-uri-escape": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="],
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"ink": "^6.6.0",
|
||||
"inquirer": "^12.6.3",
|
||||
"ora": "^9.0.0",
|
||||
"p-limit": "^7.2.0"
|
||||
"p-limit": "^7.2.0",
|
||||
"react": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
|
||||
@@ -143,10 +143,11 @@ async function runTest() {
|
||||
|
||||
// Detect if we're already in the server directory (e.g., when run via server/run.sh)
|
||||
const cwd = process.cwd();
|
||||
const serverDir =
|
||||
const projectRoot =
|
||||
cwd.endsWith("/server") || cwd.endsWith("\\server")
|
||||
? cwd
|
||||
: resolve(cwd, "server");
|
||||
? resolve(cwd, "..")
|
||||
: cwd;
|
||||
const serverDir = resolve(projectRoot, "server");
|
||||
|
||||
// Handle special "setup" command
|
||||
if (scriptName === "setup") {
|
||||
@@ -175,7 +176,12 @@ async function runTest() {
|
||||
return;
|
||||
}
|
||||
|
||||
const shellScript = resolve(serverDir, "shell", `${scriptName}.sh`);
|
||||
const shellScript = resolve(
|
||||
projectRoot,
|
||||
"scripts",
|
||||
"testGroups",
|
||||
`${scriptName}.sh`,
|
||||
);
|
||||
|
||||
// First try to find a shell script
|
||||
if (existsSync(shellScript)) {
|
||||
@@ -186,7 +192,7 @@ async function runTest() {
|
||||
);
|
||||
|
||||
const child = spawn("bash", [shellScript, ...additionalArgs], {
|
||||
cwd: serverDir,
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_ENV: "production" },
|
||||
});
|
||||
|
||||
@@ -31,6 +31,11 @@ BUN_PARALLEL_COMPACT() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" --compact
|
||||
}
|
||||
|
||||
# V2 test runner - shows individual tests, better error display (Ink-based)
|
||||
BUN_PARALLEL_V2() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTestsV2.tsx "$@"
|
||||
}
|
||||
|
||||
# Setup function
|
||||
BUN_SETUP() {
|
||||
cd "$SERVER_DIR" && $BUN_CMD tests/setupMain.ts
|
||||
|
||||
@@ -9,33 +9,35 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
# Run tests using TypeScript runner with compact mode
|
||||
# Adjust --max to control concurren.cy (default: 6)
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/balances/track/basic' \
|
||||
'server/tests/balances/track/concurrency' \
|
||||
'server/tests/balances/track/breakdown' \
|
||||
'server/tests/balances/track/credit-systems' \
|
||||
'server/tests/balances/track/entity-products' \
|
||||
'server/tests/balances/track/legacy' \
|
||||
'server/tests/balances/track/allocated' \
|
||||
'server/tests/balances/track/entity-balances' \
|
||||
'server/tests/balances/track/negative' \
|
||||
'server/tests/balances/track/rollovers' \
|
||||
'server/tests/balances/track/race-condition' \
|
||||
'server/tests/balances/track/paid-allocated' \
|
||||
'server/tests/balances/track/edge-cases' \
|
||||
'server/tests/balances/check/breakdown' \
|
||||
'server/tests/balances/track/loose' \
|
||||
'server/tests/balances/check/basic' \
|
||||
'server/tests/balances/check/credit-systems' \
|
||||
'server/tests/balances/check/misc' \
|
||||
'server/tests/balances/check/prepaid' \
|
||||
'server/tests/balances/check/send-event' \
|
||||
'server/tests/balances/check/loose' \
|
||||
'server/tests/balances/set-usage' \
|
||||
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'integration/balances/check' \
|
||||
'balances/track/basic' \
|
||||
'balances/track/concurrency' \
|
||||
'balances/track/breakdown' \
|
||||
'balances/track/credit-systems' \
|
||||
'balances/track/entity-products' \
|
||||
'balances/track/legacy' \
|
||||
'balances/track/allocated' \
|
||||
'balances/track/entity-balances' \
|
||||
'balances/track/negative' \
|
||||
'balances/track/rollovers' \
|
||||
'balances/track/race-condition' \
|
||||
'balances/track/paid-allocated' \
|
||||
'balances/track/edge-cases' \
|
||||
'balances/check/breakdown' \
|
||||
'balances/track/loose' \
|
||||
'balances/check/credit-systems' \
|
||||
'balances/check/misc' \
|
||||
'balances/check/prepaid' \
|
||||
'balances/check/send-event' \
|
||||
'balances/check/loose' \
|
||||
'balances/set-usage' \
|
||||
--max=6
|
||||
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/balances/update/filters' \
|
||||
'server/tests/balances/update/update-combined' \
|
||||
'server/tests/balances/update/update-current-balance/basic' \
|
||||
|
||||
@@ -4,7 +4,7 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/attach/basic' \
|
||||
'server/tests/attach/upgrade' \
|
||||
'server/tests/attach/downgrade' \
|
||||
@@ -15,10 +15,9 @@ BUN_PARALLEL_COMPACT \
|
||||
'server/tests/integration/billing/invoice-action-required' \
|
||||
'server/tests/integration/billing/cancel' \
|
||||
'server/tests/integration/billing/cancel/add-ons' \
|
||||
'server/tests/renew' \
|
||||
--max=6
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/attach/entities' \
|
||||
--max=6
|
||||
# 'server/tests/external-psps/revenuecat' \
|
||||
|
||||
@@ -6,18 +6,25 @@ source "$(dirname "$0")/config.sh"
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
bun test:integration update-subscription/custom-plan
|
||||
bun test:integration update-subscription/discounts
|
||||
bun test:integration update-subscription/errors
|
||||
bun test:integration update-subscription/free-trial
|
||||
bun test:integration update-subscription/invoice
|
||||
bun test:integration update-subscription/multi-product
|
||||
bun test:integration update-subscription/update-quantity
|
||||
bun test:integration update-subscription/version-update
|
||||
# bun test:integration create-customer
|
||||
# bun test:integration update-subscription/custom-plan
|
||||
# bun test:integration update-subscription/discounts
|
||||
# bun test:integration update-subscription/errors
|
||||
# bun test:integration update-subscription/free-trial
|
||||
# bun test:integration update-subscription/invoice
|
||||
# bun test:integration update-subscription/multi-product
|
||||
# bun test:integration update-subscription/update-quantity
|
||||
# bun test:integration update-subscription/version-update
|
||||
|
||||
|
||||
# Adjust --max to control concurrency (default: 6)
|
||||
# BUN_PARALLEL_COMPACT \
|
||||
# 'server/tests/billing/update-subscription/custom-plan' \
|
||||
# --max=6
|
||||
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'update-subscription/custom-plan' \
|
||||
'update-subscription/discounts' \
|
||||
'update-subscription/errors' \
|
||||
'update-subscription/free-trial' \
|
||||
'update-subscription/invoice' \
|
||||
'update-subscription/multi-product' \
|
||||
'update-subscription/update-quantity' \
|
||||
'update-subscription/version-update' \
|
||||
--max=3
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { loadLocalEnv } from "@server/utils/envUtils.js";
|
||||
@@ -627,6 +628,9 @@ class TestRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Base paths for shorthand test paths (tried in order)
|
||||
const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"];
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
@@ -645,7 +649,23 @@ for (const arg of args) {
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
directories.push(arg);
|
||||
// Try to resolve the path - if it doesn't exist, try prepending base paths
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
// Try each base path in order
|
||||
for (const basePath of TEST_BASE_PATHS) {
|
||||
const withBase = `${basePath}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
724
scripts/testScripts/runTestsV2.ts
Normal file
724
scripts/testScripts/runTestsV2.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { loadLocalEnv } from "@server/utils/envUtils.js";
|
||||
import { spawn } from "bun";
|
||||
import chalk from "chalk";
|
||||
import dotenv from "dotenv";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(process.cwd(), "server", ".env") });
|
||||
|
||||
// Base path for shorthand test paths
|
||||
const INTEGRATION_TEST_BASE = "server/tests/integration/billing";
|
||||
|
||||
interface IndividualTest {
|
||||
name: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
duration?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
location?: string; // file:line for cmd+click
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestFileResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
tests: IndividualTest[];
|
||||
currentTest?: string;
|
||||
output: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
class TestRunnerV2 {
|
||||
private results: Map<string, TestFileResult> = new Map();
|
||||
private testFiles: string[] = [];
|
||||
private maxParallel: number = 6;
|
||||
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private spinnerIndex = 0;
|
||||
private renderInterval?: Timer;
|
||||
private startLine = 0;
|
||||
private lastRenderedLines = 0;
|
||||
|
||||
constructor(maxParallel?: number) {
|
||||
if (maxParallel) this.maxParallel = maxParallel;
|
||||
}
|
||||
|
||||
async collectTestFiles(directories: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
try {
|
||||
const files = await readdir(resolvedDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedDir, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error reading directory ${dir}:`), error);
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
private parseTestOutput(output: string, filePath: string): IndividualTest[] {
|
||||
const tests: IndividualTest[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
// Track where each test result appears
|
||||
// Error output appears BEFORE the (fail) line in bun test output
|
||||
let lastTestEndIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Match (pass) or (fail) test results
|
||||
const passMatch = line.match(
|
||||
/^\(pass\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
|
||||
);
|
||||
const failMatch = line.match(
|
||||
/^\(fail\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
|
||||
);
|
||||
|
||||
if (passMatch) {
|
||||
const [, name, duration] = passMatch;
|
||||
tests.push({
|
||||
name: name.trim(),
|
||||
status: "passed",
|
||||
duration: this.parseDuration(duration),
|
||||
});
|
||||
lastTestEndIndex = i;
|
||||
} else if (failMatch) {
|
||||
const [, name, duration] = failMatch;
|
||||
|
||||
// Look BACKWARDS from this line to find the error output
|
||||
// Error appears between the last test result and this (fail) line
|
||||
const errorStartIndex = lastTestEndIndex + 1;
|
||||
const errorLines = lines.slice(errorStartIndex, i);
|
||||
|
||||
const test: IndividualTest = {
|
||||
name: name.trim(),
|
||||
status: "failed",
|
||||
duration: this.parseDuration(duration),
|
||||
};
|
||||
|
||||
// Parse the error from the lines before this (fail)
|
||||
this.parseErrorFromLines(test, errorLines, filePath);
|
||||
|
||||
tests.push(test);
|
||||
lastTestEndIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
private parseErrorFromLines(
|
||||
test: IndividualTest,
|
||||
errorLines: string[],
|
||||
filePath: string,
|
||||
): void {
|
||||
const errorText = errorLines.join("\n");
|
||||
|
||||
// Find error message - look for "error:" line
|
||||
let errorMessage = "";
|
||||
for (const line of errorLines) {
|
||||
const errorMatch = line.match(/^error:\s*(.+)/i);
|
||||
if (errorMatch) {
|
||||
errorMessage = errorMatch[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Expected/Received for assertion errors
|
||||
const expectedMatch = errorText.match(/Expected:\s*(.+)/);
|
||||
const receivedMatch = errorText.match(/Received:\s*(.+)/);
|
||||
if (expectedMatch && receivedMatch) {
|
||||
errorMessage = `Expected: ${expectedMatch[1]}, Received: ${receivedMatch[1]}`;
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (errorText.includes("this test timed out")) {
|
||||
errorMessage = "Test timed out";
|
||||
}
|
||||
|
||||
// Find location - prioritize the test file itself in stack trace
|
||||
let location: string | undefined;
|
||||
const testFileName = basename(filePath);
|
||||
|
||||
for (const line of errorLines) {
|
||||
// Match stack trace lines like:
|
||||
// at async <anonymous> (/path/to/file.test.ts:38:29)
|
||||
// at functionName (/path/to/file.ts:123:45)
|
||||
const stackMatch = line.match(/at\s+.*?\(([^)]+\.ts):(\d+):\d+\)/);
|
||||
if (stackMatch) {
|
||||
const matchedFile = stackMatch[1];
|
||||
const lineNum = stackMatch[2];
|
||||
|
||||
// Prefer .test.ts files
|
||||
if (matchedFile.endsWith(".test.ts")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise take first server file if we don't have one yet
|
||||
if (!location && matchedFile.includes("/server/")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.error = {
|
||||
message: errorMessage || "Test failed",
|
||||
location,
|
||||
details: errorText.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
private parseDuration(duration: string): number {
|
||||
// Parse "123.45ms" or "1.23s" to milliseconds
|
||||
if (duration.endsWith("ms")) {
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
if (duration.endsWith("s")) {
|
||||
return Number.parseFloat(duration) * 1000;
|
||||
}
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
|
||||
private extractCurrentTest(output: string): string | null {
|
||||
// Look for the last test that started (before pass/fail)
|
||||
const lines = output.split("\n");
|
||||
|
||||
// Find last pass/fail to know what's completed
|
||||
let lastCompletedIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].match(/^\(pass\)/) || lines[i].match(/^\(fail\)/)) {
|
||||
lastCompletedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The "current" test would be indicated by the test that's running
|
||||
// Bun doesn't explicitly say which test is running, so we show the last completed
|
||||
if (lastCompletedIndex >= 0) {
|
||||
const match = lines[lastCompletedIndex].match(
|
||||
/^\((?:pass|fail)\)\s+(.+?)\s+\[/,
|
||||
);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hideCursor() {
|
||||
process.stdout.write("\x1B[?25l");
|
||||
}
|
||||
|
||||
private showCursor() {
|
||||
process.stdout.write("\x1B[?25h");
|
||||
}
|
||||
|
||||
private moveCursor(line: number, col: number = 0) {
|
||||
process.stdout.write(`\x1B[${line};${col}H`);
|
||||
}
|
||||
|
||||
private clearLine() {
|
||||
process.stdout.write("\x1B[2K");
|
||||
}
|
||||
|
||||
private clearToEndOfScreen() {
|
||||
process.stdout.write("\x1B[J");
|
||||
}
|
||||
|
||||
private truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
||||
const spinner = this.spinnerFrames[this.spinnerIndex];
|
||||
|
||||
let lineNum = this.startLine;
|
||||
|
||||
// Calculate stats - only count tests from COMPLETED files for accurate progress
|
||||
const runningFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "running",
|
||||
);
|
||||
const completedFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "passed" || r.status === "failed",
|
||||
);
|
||||
const pendingFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "pending",
|
||||
);
|
||||
|
||||
// Only count tests from completed files for stable progress
|
||||
const completedTests = completedFiles.flatMap(([_, r]) => r.tests);
|
||||
const passedTests = completedTests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const failedTests = completedTests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
// Header
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.cyan.bold(`Running ${this.testFiles.length} test files...\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
|
||||
// Show running files with their current test
|
||||
if (runningFiles.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.yellow.bold(`Running (${runningFiles.length}):\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of runningFiles) {
|
||||
const fileName = basename(file);
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
|
||||
// Show file with spinner
|
||||
let fileDisplay = ` ${chalk.cyan(spinner)} ${fileName}`;
|
||||
|
||||
// Show completed tests count for this file
|
||||
const filePassedCount = result.tests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const fileFailedCount = result.tests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
if (filePassedCount > 0 || fileFailedCount > 0) {
|
||||
fileDisplay += chalk.dim(
|
||||
` (${chalk.green(`✓${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(` ✗${fileFailedCount}`) : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Show current/last test
|
||||
const currentTest = this.extractCurrentTest(result.output);
|
||||
if (currentTest) {
|
||||
fileDisplay += chalk.dim(` › ${this.truncate(currentTest, 40)}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${fileDisplay}\n`);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Blank line after running
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show recently completed files (last 3)
|
||||
if (completedFiles.length > 0) {
|
||||
const recentCompleted = completedFiles.slice(-3);
|
||||
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.dim(
|
||||
`Completed (${completedFiles.length}/${this.testFiles.length} files):\n`,
|
||||
),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of recentCompleted) {
|
||||
const fileName = basename(file);
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
|
||||
const filePassedCount = result.tests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const fileFailedCount = result.tests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
const icon =
|
||||
result.status === "passed" ? chalk.green("✓") : chalk.red("✗");
|
||||
const nameColor = result.status === "passed" ? chalk.dim : chalk.white;
|
||||
|
||||
process.stdout.write(
|
||||
` ${icon} ${nameColor(fileName)} ${chalk.dim(`(${chalk.green(`✓${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(` ✗${fileFailedCount}`) : ""})`)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show inline errors from recently completed files (compact view)
|
||||
const recentFailedTests = completedFiles
|
||||
.flatMap(([file, result]) =>
|
||||
result.tests
|
||||
.filter((t) => t.status === "failed")
|
||||
.map((t) => ({ ...t, file })),
|
||||
)
|
||||
.slice(-2); // Show last 2 failures
|
||||
|
||||
if (recentFailedTests.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(chalk.red.bold(`Recent Failures:\n`));
|
||||
lineNum++;
|
||||
|
||||
for (const test of recentFailedTests) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.red("✗")} ${this.truncate(test.name, 50)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
if (test.error?.message) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.dim("→")} ${chalk.yellow(this.truncate(test.error.message, 60))}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
if (test.error?.location) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.dim("→")} ${chalk.cyan(test.error.location)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
}
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(chalk.dim("─".repeat(60) + "\n"));
|
||||
lineNum++;
|
||||
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
`${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completedFiles.length}/${this.testFiles.length} files`)} | ` +
|
||||
`${chalk.green(`✓ ${passedTests}`)} | ` +
|
||||
`${failedTests > 0 ? chalk.red(`✗ ${failedTests}`) : chalk.dim(`✗ ${failedTests}`)} | ` +
|
||||
`${chalk.dim(`${runningFiles.length} running`)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
// Clear remaining lines
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearToEndOfScreen();
|
||||
|
||||
this.lastRenderedLines = lineNum - this.startLine;
|
||||
}
|
||||
|
||||
async runTest(file: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Initialize as running
|
||||
const result: TestFileResult = {
|
||||
file,
|
||||
status: "running",
|
||||
tests: [],
|
||||
output: "",
|
||||
duration: 0,
|
||||
};
|
||||
this.results.set(file, result);
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "test", "--timeout", "0", file], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let output = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
result.output = output;
|
||||
|
||||
// Parse tests as they complete
|
||||
result.tests = this.parseTestOutput(output, file);
|
||||
this.results.set(file, result);
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
output += decoder.decode(chunk);
|
||||
result.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Final parse
|
||||
const tests = this.parseTestOutput(output, file);
|
||||
const hasFailures = tests.some((t) => t.status === "failed");
|
||||
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: hasFailures ? "failed" : "passed",
|
||||
tests,
|
||||
output,
|
||||
duration,
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: "failed",
|
||||
output: String(error),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.renderInterval) {
|
||||
clearInterval(this.renderInterval);
|
||||
}
|
||||
this.showCursor();
|
||||
}
|
||||
|
||||
private handleInterrupt() {
|
||||
this.cleanup();
|
||||
console.log(
|
||||
chalk.yellow.bold("\n\n⚠ Tests interrupted by user (Ctrl+C)\n"),
|
||||
);
|
||||
this.printSummary();
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
async run(directories: string[]): Promise<void> {
|
||||
this.testFiles = await this.collectTestFiles(directories);
|
||||
|
||||
if (this.testFiles.length === 0) {
|
||||
console.log(chalk.yellow("No test files found in specified directories"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize all tests as pending
|
||||
for (const file of this.testFiles) {
|
||||
this.results.set(file, {
|
||||
file,
|
||||
status: "pending",
|
||||
tests: [],
|
||||
output: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Setup SIGINT handler
|
||||
const sigintHandler = () => this.handleInterrupt();
|
||||
process.on("SIGINT", sigintHandler);
|
||||
|
||||
// Hide cursor and create initial space
|
||||
this.hideCursor();
|
||||
this.startLine = 1;
|
||||
|
||||
// Create some initial space
|
||||
for (let i = 0; i < 20; i++) {
|
||||
console.log();
|
||||
}
|
||||
process.stdout.write("\x1B[20A");
|
||||
|
||||
// Start rendering loop
|
||||
this.renderInterval = setInterval(() => this.render(), 100);
|
||||
|
||||
// Run tests with concurrency limit
|
||||
const limit = pLimit(this.maxParallel);
|
||||
const promises = this.testFiles.map((file) =>
|
||||
limit(() => this.runTest(file)),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Remove SIGINT handler
|
||||
process.off("SIGINT", sigintHandler);
|
||||
|
||||
// Final render and cleanup
|
||||
this.cleanup();
|
||||
this.render();
|
||||
|
||||
// Move past the rendered output
|
||||
process.stdout.write(`\x1B[${this.lastRenderedLines + 2}B`);
|
||||
|
||||
// Print summary
|
||||
this.printSummary();
|
||||
}
|
||||
|
||||
private printSummary() {
|
||||
const allTests = Array.from(this.results.values()).flatMap((r) => r.tests);
|
||||
const failedTests = allTests.filter((t) => t.status === "failed");
|
||||
const passedTests = allTests.filter((t) => t.status === "passed");
|
||||
const totalDuration = Array.from(this.results.values()).reduce(
|
||||
(sum, r) => sum + r.duration,
|
||||
0,
|
||||
);
|
||||
|
||||
console.log("\n");
|
||||
|
||||
if (failedTests.length === 0) {
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
`═${"═".repeat(68)}═\n` +
|
||||
` ✓ ALL ${passedTests.length} TESTS PASSED (${(totalDuration / 1000).toFixed(1)}s)\n` +
|
||||
`═${"═".repeat(68)}═\n`,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Failed tests summary
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`═${"═".repeat(68)}═\n` +
|
||||
` FAILED TESTS (${failedTests.length})\n` +
|
||||
`═${"═".repeat(68)}═`,
|
||||
),
|
||||
);
|
||||
|
||||
// Group failed tests by file
|
||||
const failedByFile = new Map<string, IndividualTest[]>();
|
||||
for (const [file, result] of this.results.entries()) {
|
||||
const fileFailed = result.tests.filter((t) => t.status === "failed");
|
||||
if (fileFailed.length > 0) {
|
||||
failedByFile.set(file, fileFailed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, tests] of failedByFile) {
|
||||
console.log(chalk.red.bold(`\n📁 ${basename(file)}`));
|
||||
console.log(chalk.dim("─".repeat(60)));
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(chalk.red(`\n ✗ ${test.name}`));
|
||||
|
||||
if (test.error?.location) {
|
||||
console.log(chalk.cyan(` ${test.error.location}`));
|
||||
}
|
||||
|
||||
if (test.error?.message) {
|
||||
console.log(chalk.yellow(`\n ${test.error.message}`));
|
||||
}
|
||||
|
||||
if (test.error?.details) {
|
||||
// Show a few lines of error details
|
||||
const detailLines = test.error.details
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.slice(0, 8);
|
||||
for (const line of detailLines) {
|
||||
console.log(chalk.dim(` ${this.truncate(line.trim(), 70)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n═${"═".repeat(68)}═\n` +
|
||||
` SUMMARY: ${chalk.green(`${passedTests.length} passed`)} | ${chalk.red(`${failedTests.length} failed`)} | ${(totalDuration / 1000).toFixed(1)}s\n` +
|
||||
`═${"═".repeat(68)}═\n`,
|
||||
),
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
maxParallel = Number.parseInt(arg.split("=")[1], 10);
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(chalk.red(`Unknown option: ${arg}`));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Try to resolve the path - if it doesn't exist, prepend the base path
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
const withBase = `${INTEGRATION_TEST_BASE}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error(chalk.red("Error: No test directories specified"));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
console.log("\nOptions:");
|
||||
console.log(" --max=N Set maximum parallel test files (default: 6)");
|
||||
console.log("\nExamples:");
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan",
|
||||
);
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan update-subscription/errors --max=4",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const runner = new TestRunnerV2(maxParallel);
|
||||
await runner.run(directories);
|
||||
697
scripts/testScripts/runTestsV2.tsx
Normal file
697
scripts/testScripts/runTestsV2.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { spawn } from "bun";
|
||||
import { Box, render, Text, useApp } from "ink";
|
||||
import pLimit from "p-limit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Base paths for shorthand test paths (tried in order)
|
||||
const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"];
|
||||
|
||||
// Track all running processes for cleanup
|
||||
const runningProcesses = new Set<ReturnType<typeof spawn>>();
|
||||
|
||||
// Ultra-kill on Ctrl+C
|
||||
process.on("SIGINT", () => {
|
||||
// Kill all running test processes immediately
|
||||
for (const proc of runningProcesses) {
|
||||
try {
|
||||
proc.kill(9); // SIGKILL
|
||||
} catch {
|
||||
// Process might already be dead
|
||||
}
|
||||
}
|
||||
runningProcesses.clear();
|
||||
|
||||
console.log("\n\n⚠️ Tests interrupted by user (Ctrl+C)\n");
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
interface IndividualTest {
|
||||
name: string;
|
||||
status: "passed" | "failed";
|
||||
duration?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
location?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestFileResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
tests: IndividualTest[];
|
||||
currentTest?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Output Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseTestOutput(output: string, filePath: string): IndividualTest[] {
|
||||
const tests: IndividualTest[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
let lastTestEndIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const passMatch = line.match(/^\(pass\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/);
|
||||
const failMatch = line.match(/^\(fail\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/);
|
||||
|
||||
if (passMatch) {
|
||||
const [, name, duration] = passMatch;
|
||||
tests.push({
|
||||
name: name.trim(),
|
||||
status: "passed",
|
||||
duration: parseDuration(duration),
|
||||
});
|
||||
lastTestEndIndex = i;
|
||||
} else if (failMatch) {
|
||||
const [, name, duration] = failMatch;
|
||||
|
||||
// Look BACKWARDS from this line to find the error output
|
||||
const errorStartIndex = lastTestEndIndex + 1;
|
||||
const errorLines = lines.slice(errorStartIndex, i);
|
||||
|
||||
const test: IndividualTest = {
|
||||
name: name.trim(),
|
||||
status: "failed",
|
||||
duration: parseDuration(duration),
|
||||
};
|
||||
|
||||
parseErrorFromLines(test, errorLines, filePath);
|
||||
tests.push(test);
|
||||
lastTestEndIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
function parseErrorFromLines(
|
||||
test: IndividualTest,
|
||||
errorLines: string[],
|
||||
filePath: string,
|
||||
): void {
|
||||
const errorText = errorLines.join("\n");
|
||||
|
||||
// Find error message - look for "error:" line
|
||||
let errorMessage = "";
|
||||
for (const line of errorLines) {
|
||||
const errorMatch = line.match(/^error:\s*(.+)/i);
|
||||
if (errorMatch) {
|
||||
errorMessage = errorMatch[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Expected/Received for assertion errors
|
||||
const expectedMatch = errorText.match(/Expected:\s*(.+)/);
|
||||
const receivedMatch = errorText.match(/Received:\s*(.+)/);
|
||||
if (expectedMatch && receivedMatch) {
|
||||
errorMessage = `Expected: ${expectedMatch[1]}, Received: ${receivedMatch[1]}`;
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (errorText.includes("this test timed out")) {
|
||||
errorMessage = "Test timed out";
|
||||
}
|
||||
|
||||
// Find location - prioritize the test file itself in stack trace
|
||||
let location: string | undefined;
|
||||
|
||||
for (const line of errorLines) {
|
||||
// Match stack trace lines like:
|
||||
// at async <anonymous> (/path/to/file.test.ts:38:29)
|
||||
// at functionName (/path/to/file.ts:123:45)
|
||||
const stackMatch = line.match(/at\s+.*?\(([^)]+\.ts):(\d+):\d+\)/);
|
||||
if (stackMatch) {
|
||||
const matchedFile = stackMatch[1];
|
||||
const lineNum = stackMatch[2];
|
||||
|
||||
// Prefer .test.ts files
|
||||
if (matchedFile.endsWith(".test.ts")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise take first server file if we don't have one yet
|
||||
if (!location && matchedFile.includes("/server/")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.error = {
|
||||
message: errorMessage || "Test failed",
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDuration(duration: string): number {
|
||||
if (duration.endsWith("ms")) {
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
if (duration.endsWith("s")) {
|
||||
return Number.parseFloat(duration) * 1000;
|
||||
}
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
|
||||
function extractCurrentTest(output: string): string | null {
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const match = lines[i].match(/^\((?:pass|fail)\)\s+(.+?)\s+\[/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Runner Logic
|
||||
// ============================================================================
|
||||
|
||||
async function collectTestFiles(directories: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
try {
|
||||
const files = await readdir(resolvedDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedDir, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
async function runTestFile(
|
||||
file: string,
|
||||
onUpdate: (result: TestFileResult) => void,
|
||||
): Promise<TestFileResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
const result: TestFileResult = {
|
||||
file,
|
||||
status: "running",
|
||||
tests: [],
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
onUpdate(result);
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "test", "--timeout", "0", file], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Track process for cleanup on SIGINT
|
||||
runningProcesses.add(proc);
|
||||
|
||||
let output = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
|
||||
// Update with parsed tests
|
||||
const tests = parseTestOutput(output, file);
|
||||
const currentTest = extractCurrentTest(output);
|
||||
|
||||
onUpdate({
|
||||
...result,
|
||||
tests,
|
||||
currentTest: currentTest || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
output += decoder.decode(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Remove from tracking
|
||||
runningProcesses.delete(proc);
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
const tests = parseTestOutput(output, file);
|
||||
const hasFailures = tests.some((t) => t.status === "failed");
|
||||
|
||||
const finalResult: TestFileResult = {
|
||||
file,
|
||||
status: hasFailures ? "failed" : "passed",
|
||||
tests,
|
||||
duration,
|
||||
};
|
||||
|
||||
onUpdate(finalResult);
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
const finalResult: TestFileResult = {
|
||||
file,
|
||||
status: "failed",
|
||||
tests: [],
|
||||
duration,
|
||||
};
|
||||
onUpdate(finalResult);
|
||||
return finalResult;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ink Components
|
||||
// ============================================================================
|
||||
|
||||
function Spinner() {
|
||||
const [frame, setFrame] = useState(0);
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((prev) => (prev + 1) % frames.length);
|
||||
}, 80);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return <Text color="cyan">{frames[frame]}</Text>;
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
interface CompletedFileProps {
|
||||
result: TestFileResult;
|
||||
}
|
||||
|
||||
function CompletedFile({ result }: CompletedFileProps) {
|
||||
const fileName = basename(result.file);
|
||||
const passedCount = result.tests.filter((t) => t.status === "passed").length;
|
||||
const failedCount = result.tests.filter((t) => t.status === "failed").length;
|
||||
|
||||
const icon = result.status === "passed" ? "✓" : "✗";
|
||||
const iconColor = result.status === "passed" ? "green" : "red";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={iconColor}>{icon} </Text>
|
||||
<Text dimColor={result.status === "passed"}>{fileName} </Text>
|
||||
<Text dimColor>
|
||||
(<Text color="green">✓{passedCount}</Text>
|
||||
{failedCount > 0 && <Text color="red"> ✗{failedCount}</Text>})
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface FailedTestProps {
|
||||
test: IndividualTest;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
function FailedTest({ test, fileName }: FailedTestProps) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="red">✗ </Text>
|
||||
<Text>{truncate(test.name, 60)}</Text>
|
||||
</Box>
|
||||
{test.error?.message && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>→ </Text>
|
||||
<Text color="yellow">{truncate(test.error.message, 70)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{test.error?.location && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>→ </Text>
|
||||
<Text color="cyan">{test.error.location}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningFileProps {
|
||||
result: TestFileResult;
|
||||
}
|
||||
|
||||
function RunningFile({ result }: RunningFileProps) {
|
||||
const fileName = basename(result.file);
|
||||
const passedCount = result.tests.filter((t) => t.status === "passed").length;
|
||||
const failedCount = result.tests.filter((t) => t.status === "failed").length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text> </Text>
|
||||
<Spinner />
|
||||
<Text> {fileName}</Text>
|
||||
{(passedCount > 0 || failedCount > 0) && (
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
(<Text color="green">✓{passedCount}</Text>
|
||||
{failedCount > 0 && <Text color="red"> ✗{failedCount}</Text>})
|
||||
</Text>
|
||||
)}
|
||||
{result.currentTest && (
|
||||
<Text dimColor> › {truncate(result.currentTest, 35)}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestRunnerAppProps {
|
||||
testFiles: string[];
|
||||
maxParallel: number;
|
||||
}
|
||||
|
||||
function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) {
|
||||
const { exit } = useApp();
|
||||
const [results, setResults] = useState<Map<string, TestFileResult>>(
|
||||
new Map(),
|
||||
);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
// Initialize all files as pending
|
||||
useEffect(() => {
|
||||
const initial = new Map<string, TestFileResult>();
|
||||
for (const file of testFiles) {
|
||||
initial.set(file, {
|
||||
file,
|
||||
status: "pending",
|
||||
tests: [],
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
setResults(initial);
|
||||
}, [testFiles]);
|
||||
|
||||
// Run tests
|
||||
useEffect(() => {
|
||||
const runAllTests = async () => {
|
||||
const limit = pLimit(maxParallel);
|
||||
|
||||
const updateResult = (result: TestFileResult) => {
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(result.file, result);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const promises = testFiles.map((file) =>
|
||||
limit(() => runTestFile(file, updateResult)),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
if (testFiles.length > 0) {
|
||||
runAllTests();
|
||||
}
|
||||
}, [testFiles, maxParallel]);
|
||||
|
||||
// Exit when complete
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
const allResults = Array.from(results.values());
|
||||
const failedTests = allResults.flatMap((r) =>
|
||||
r.tests.filter((t) => t.status === "failed"),
|
||||
);
|
||||
|
||||
// Small delay to ensure final render
|
||||
setTimeout(() => {
|
||||
exit();
|
||||
process.exit(failedTests.length > 0 ? 1 : 0);
|
||||
}, 100);
|
||||
}
|
||||
}, [isComplete, results, exit]);
|
||||
|
||||
const allResults = Array.from(results.values());
|
||||
const completedFiles = allResults.filter(
|
||||
(r) => r.status === "passed" || r.status === "failed",
|
||||
);
|
||||
const runningFiles = allResults.filter((r) => r.status === "running");
|
||||
|
||||
const completedTests = completedFiles.flatMap((r) => r.tests);
|
||||
const passedTests = completedTests.filter((t) => t.status === "passed");
|
||||
const failedTests = completedTests.filter((t) => t.status === "failed");
|
||||
|
||||
// Get ALL failures
|
||||
const allFailures = completedFiles.flatMap((r) =>
|
||||
r.tests
|
||||
.filter((t) => t.status === "failed")
|
||||
.map((t) => ({ test: t, fileName: basename(r.file), file: r.file })),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Text bold color="cyan">
|
||||
Running {testFiles.length} test files...
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
|
||||
{/* Running files */}
|
||||
{runningFiles.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="yellow">
|
||||
Running ({runningFiles.length}):
|
||||
</Text>
|
||||
{runningFiles.map((r) => (
|
||||
<RunningFile key={r.file} result={r} />
|
||||
))}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Completed files (last 3) */}
|
||||
{completedFiles.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>
|
||||
Completed ({completedFiles.length}/{testFiles.length} files):
|
||||
</Text>
|
||||
{completedFiles.slice(-3).map((r) => (
|
||||
<CompletedFile key={r.file} result={r} />
|
||||
))}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<Text dimColor>{"─".repeat(60)}</Text>
|
||||
<Box>
|
||||
{!isComplete && <Spinner />}
|
||||
{isComplete && <Text color="green">✓</Text>}
|
||||
<Text>
|
||||
{" "}
|
||||
Progress:{" "}
|
||||
<Text bold>
|
||||
{completedFiles.length}/{testFiles.length} files
|
||||
</Text>{" "}
|
||||
| <Text color="green">✓ {passedTests.length}</Text> |{" "}
|
||||
<Text color={failedTests.length > 0 ? "red" : undefined}>
|
||||
✗ {failedTests.length}
|
||||
</Text>
|
||||
{runningFiles.length > 0 && (
|
||||
<Text dimColor> | {runningFiles.length} running</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* ALL failures - shown below progress */}
|
||||
{allFailures.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="red">
|
||||
Failures ({allFailures.length}):
|
||||
</Text>
|
||||
{allFailures.map((f) => (
|
||||
<FailedTest
|
||||
key={`${f.file}-${f.test.name}`}
|
||||
test={f.test}
|
||||
fileName={f.fileName}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Final summary when complete */}
|
||||
{isComplete && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<FinalSummary results={allResults} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface FinalSummaryProps {
|
||||
results: TestFileResult[];
|
||||
}
|
||||
|
||||
function FinalSummary({ results }: FinalSummaryProps) {
|
||||
const allTests = results.flatMap((r) => r.tests);
|
||||
const passedTests = allTests.filter((t) => t.status === "passed");
|
||||
const failedTests = allTests.filter((t) => t.status === "failed");
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
const failedByFile = new Map<string, IndividualTest[]>();
|
||||
for (const result of results) {
|
||||
const fileFailed = result.tests.filter((t) => t.status === "failed");
|
||||
if (fileFailed.length > 0) {
|
||||
failedByFile.set(result.file, fileFailed);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedTests.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="green" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text color="green" bold>
|
||||
✓ ALL {passedTests.length} TESTS PASSED (
|
||||
{(totalDuration / 1000).toFixed(1)}s)
|
||||
</Text>
|
||||
<Text color="green" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
FAILED TESTS ({failedTests.length})
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
|
||||
{Array.from(failedByFile.entries()).map(([file, tests]) => (
|
||||
<Box key={file} flexDirection="column" marginTop={1}>
|
||||
<Text color="red" bold>
|
||||
📁 {basename(file)}
|
||||
</Text>
|
||||
<Text dimColor>{"─".repeat(50)}</Text>
|
||||
|
||||
{tests.map((test) => (
|
||||
<Box key={test.name} flexDirection="column" marginTop={1}>
|
||||
<Text color="red"> ✗ {test.name}</Text>
|
||||
{test.error?.location && (
|
||||
<Text color="cyan"> {test.error.location}</Text>
|
||||
)}
|
||||
{test.error?.message && (
|
||||
<Text color="yellow"> {test.error.message}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Text> </Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text bold>
|
||||
SUMMARY: <Text color="green">{passedTests.length} passed</Text> |{" "}
|
||||
<Text color="red">{failedTests.length} failed</Text> |{" "}
|
||||
{(totalDuration / 1000).toFixed(1)}s
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Entry Point
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
maxParallel = Number.parseInt(arg.split("=")[1], 10);
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.tsx <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Try to resolve the path - if it doesn't exist, try prepending base paths
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
// Try each base path in order
|
||||
for (const basePath of TEST_BASE_PATHS) {
|
||||
const withBase = `${basePath}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error("Error: No test directories specified");
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.tsx <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testFiles = await collectTestFiles(directories);
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.log("No test files found in specified directories");
|
||||
return;
|
||||
}
|
||||
|
||||
render(<TestRunnerApp testFiles={testFiles} maxParallel={maxParallel} />);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get project root directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/.."
|
||||
PROJECT_ROOT="$SERVER_DIR/.."
|
||||
|
||||
# Find bun executable
|
||||
if command -v bun &> /dev/null; then
|
||||
BUN_CMD="bun"
|
||||
elif [ -f "$HOME/.bun/bin/bun" ]; then
|
||||
BUN_CMD="$HOME/.bun/bin/bun"
|
||||
elif [ -f "/usr/local/bin/bun" ]; then
|
||||
BUN_CMD="/usr/local/bin/bun"
|
||||
else
|
||||
echo "Error: bun not found. Please install bun or add it to PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Setup function
|
||||
BUN_SETUP="$BUN_CMD tests/setupMain.ts"
|
||||
|
||||
# Test runner functions (using new TypeScript runner)
|
||||
BUN_PARALLEL() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@"
|
||||
}
|
||||
|
||||
BUN_PARALLEL_COMPACT() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@" --compact
|
||||
}
|
||||
|
||||
# Mocha command (for tests not yet migrated)
|
||||
MOCHA_CMD="npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts"
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/entities/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/update/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/track/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/roles/*.ts'
|
||||
|
||||
# # G4
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
# 'tests/advanced/coupons/*.ts' \
|
||||
# 'tests/attach/updateQuantity/*.ts' \
|
||||
# 'tests/advanced/referrals/*.ts' \
|
||||
# 'tests/advanced/rollovers/*.ts' \
|
||||
# 'tests/advanced/customInterval/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
# 'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/group/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/add/*.ts' \
|
||||
'tests/merged/downgrade/*.ts' \
|
||||
'tests/merged/prepaid/*.ts' \
|
||||
'tests/merged/separate/*.ts' \
|
||||
'tests/merged/upgrade/*.ts' \
|
||||
'tests/merged/trial/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/addOn/*.ts' \
|
||||
'tests/merged/group/*.ts' \
|
||||
'tests/core/cancel/*.ts' \
|
||||
'tests/core/multiAttach/*.ts' \
|
||||
'tests/core/multiAttach/multiInvoice/*.ts' \
|
||||
'tests/core/multiAttach/multiUpgrade/*.ts' \
|
||||
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward1.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward2.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward3.test.ts'
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/rollovers/*.ts'
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# npx mocha 'tests/alex/00_setup.ts' --timeout 10000000
|
||||
|
||||
MOCHA_PARALLEL=true npx mocha --parallel --timeout 10000000 \
|
||||
'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \
|
||||
'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \
|
||||
--ignore 'tests/alex/00_setup.ts'
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Parallel Test Runner
|
||||
# Runs all test groups in parallel, each with its own dedicated org
|
||||
|
||||
# Source shared configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/config.sh"
|
||||
|
||||
# Check for required environment variables
|
||||
if [ -z "$TEST_ORG_SECRET_KEY" ]; then
|
||||
echo "Error: TEST_ORG_SECRET_KEY environment variable is required"
|
||||
echo ""
|
||||
echo "This should be the secret key of your platform organization"
|
||||
echo "that has access to create/delete test organizations."
|
||||
echo ""
|
||||
echo "Add it to your server/.env file:"
|
||||
echo " TEST_ORG_SECRET_KEY=am_sk_test_..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run parallel test groups
|
||||
echo "Starting parallel test runner..."
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runParallelGroups.ts
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run Bun test files in parallel with proper error reporting
|
||||
# Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Error: No test directories specified"
|
||||
echo "Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
TEST_DIRS=()
|
||||
MAX_PARALLEL=6
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == --max=* ]]; then
|
||||
MAX_PARALLEL="${arg#*=}"
|
||||
else
|
||||
if [ ! -d "$arg" ]; then
|
||||
echo "Error: Directory '$arg' not found"
|
||||
exit 1
|
||||
fi
|
||||
TEST_DIRS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#TEST_DIRS[@]} -eq 0 ]; then
|
||||
echo "Error: No valid test directories specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
FAILED_DIR="$TEMP_DIR/failed"
|
||||
STATUS_DIR="$TEMP_DIR/status"
|
||||
mkdir -p "$FAILED_DIR" "$STATUS_DIR"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Spinner frames
|
||||
SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
SPINNER_FRAME=0
|
||||
NUM_SPINNER_FRAMES=${#SPINNER_FRAMES[@]}
|
||||
|
||||
# Collect all test files first
|
||||
TEST_FILES=()
|
||||
for TEST_DIR in "${TEST_DIRS[@]}"; do
|
||||
for test_file in "$TEST_DIR"/*.test.ts; do
|
||||
if [ -f "$test_file" ]; then
|
||||
TEST_FILES+=("$test_file")
|
||||
# Create status file
|
||||
echo "pending" > "$STATUS_DIR/$(basename "$test_file").status"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Check if any tests were found
|
||||
if [ ${#TEST_FILES[@]} -eq 0 ]; then
|
||||
echo "No test files found in specified directories"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Helper function to get status
|
||||
get_status() {
|
||||
local test_file="$1"
|
||||
local test_name=$(basename "$test_file")
|
||||
local status_file="$STATUS_DIR/${test_name}.status"
|
||||
if [ -f "$status_file" ]; then
|
||||
cat "$status_file"
|
||||
else
|
||||
echo "pending"
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to set status
|
||||
set_status() {
|
||||
local test_file="$1"
|
||||
local status="$2"
|
||||
local test_name=$(basename "$test_file")
|
||||
echo "$status" > "$STATUS_DIR/${test_name}.status"
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill all descendant processes
|
||||
pkill -P $$ 2>/dev/null || true
|
||||
pkill -f "bun test" 2>/dev/null || true
|
||||
jobs -p | while read pid; do kill -9 $pid 2>/dev/null || true; done
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Set up signal handlers
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Function to render the test list
|
||||
render_tests() {
|
||||
local line_num=1
|
||||
|
||||
# Save cursor position
|
||||
tput sc 2>/dev/null || true
|
||||
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
local test_name=$(basename "$test_file")
|
||||
local status=$(get_status "$test_file")
|
||||
local display_name="${test_name}"
|
||||
|
||||
# Move to the line
|
||||
tput cup $((line_num - 1)) 0 2>/dev/null || true
|
||||
|
||||
# Clear line
|
||||
tput el 2>/dev/null || true
|
||||
|
||||
case "$status" in
|
||||
"pending")
|
||||
echo -ne "${DIM}⋯${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"running")
|
||||
local frame_idx=$((SPINNER_FRAME % NUM_SPINNER_FRAMES))
|
||||
local spinner_char="${SPINNER_FRAMES[$frame_idx]}"
|
||||
echo -ne "${CYAN}${spinner_char}${NC} ${display_name}"
|
||||
;;
|
||||
"passed")
|
||||
echo -ne "${GREEN}✓${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"failed")
|
||||
echo -ne "${RED}✗${NC} ${display_name}"
|
||||
;;
|
||||
esac
|
||||
|
||||
((line_num++))
|
||||
done
|
||||
|
||||
# Restore cursor position
|
||||
tput rc 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to update spinner animation
|
||||
animate_spinner() {
|
||||
while true; do
|
||||
SPINNER_FRAME=$((SPINNER_FRAME + 1))
|
||||
render_tests
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
# Function to run a test
|
||||
run_test() {
|
||||
local test_file=$1
|
||||
local test_name=$(basename "$test_file")
|
||||
local output_file="$TEMP_DIR/$test_name.log"
|
||||
|
||||
# Mark as running
|
||||
set_status "$test_file" "running"
|
||||
|
||||
# Run the test
|
||||
if script -q /dev/null bash -c "FORCE_COLOR=3 bun test --timeout 0 '$test_file' 2>&1" > "$output_file"; then
|
||||
set_status "$test_file" "passed"
|
||||
return 0
|
||||
else
|
||||
set_status "$test_file" "failed"
|
||||
echo "$test_file|$output_file" > "$FAILED_DIR/$test_name.failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Hide cursor
|
||||
tput civis 2>/dev/null || true
|
||||
|
||||
# Initial render - create space for all tests
|
||||
echo ""
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Move cursor back up
|
||||
tput cuu ${#TEST_FILES[@]} 2>/dev/null || true
|
||||
|
||||
# Start spinner animation in background
|
||||
animate_spinner &
|
||||
SPINNER_PID=$!
|
||||
|
||||
# Run tests in parallel
|
||||
count=0
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
# Wait if we've hit max parallel
|
||||
while [ $(jobs -r | wc -l) -ge $((MAX_PARALLEL + 1)) ]; do
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
run_test "$test_file" &
|
||||
((count++))
|
||||
done
|
||||
|
||||
# Wait for all tests to complete (exclude spinner process)
|
||||
for job in $(jobs -p); do
|
||||
if [ "$job" != "$SPINNER_PID" ]; then
|
||||
wait $job 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Final render
|
||||
render_tests
|
||||
|
||||
# Move cursor below test list
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Report failures
|
||||
FAILED_COUNT=$(ls "$FAILED_DIR"/*.failed 2>/dev/null | wc -l)
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
echo -e "${RED}${BOLD}========================================"
|
||||
echo -e "FAILED TESTS ($FAILED_COUNT/${count}):"
|
||||
echo -e "========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Show detailed errors
|
||||
for failure_file in "$FAILED_DIR"/*.failed; do
|
||||
IFS='|' read -r test_file output_file < "$failure_file"
|
||||
echo -e "${RED}${BOLD}✗ $(basename $test_file)${NC}"
|
||||
echo -e "${DIM}─────────────────────────────────────────${NC}"
|
||||
cat "$output_file"
|
||||
echo ""
|
||||
done
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}${BOLD}✓ All tests passed!${NC} ${CYAN}($count tests)${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 0
|
||||
fi
|
||||
@@ -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"])[] = [],
|
||||
|
||||
32
server/src/external/autumn/autumnCli.ts
vendored
32
server/src/external/autumn/autumnCli.ts
vendored
@@ -13,6 +13,7 @@ import {
|
||||
type BillingResponse,
|
||||
type CheckQuery,
|
||||
type CreateBalanceParams,
|
||||
type CreateCustomerInternalOptions,
|
||||
type CreateCustomerParams,
|
||||
type CreateEntityParams,
|
||||
type CreateRewardProgram,
|
||||
@@ -414,16 +415,29 @@ export class AutumnInt {
|
||||
create: async ({
|
||||
withAutumnId = true,
|
||||
expand = [],
|
||||
internalOptions = {
|
||||
disable_defaults: true,
|
||||
},
|
||||
skipWebhooks,
|
||||
...customerData
|
||||
}: {
|
||||
withAutumnId?: boolean;
|
||||
expand?: CusExpand[];
|
||||
} & CreateCustomerParams) => {
|
||||
internalOptions?: CreateCustomerInternalOptions;
|
||||
skipWebhooks?: boolean;
|
||||
} & Omit<CreateCustomerParams, "internal_options">) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (skipWebhooks !== undefined) {
|
||||
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
|
||||
}
|
||||
|
||||
const data = await this.post(
|
||||
`/customers?with_autumn_id=${withAutumnId ? "true" : "false"}${expand && expand.length > 0 ? `&expand=${expand.join(",")}` : ""}`,
|
||||
{
|
||||
...customerData,
|
||||
internal_options: internalOptions,
|
||||
},
|
||||
Object.keys(headers).length > 0 ? headers : undefined,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -729,9 +743,21 @@ export class AutumnInt {
|
||||
subscriptions = {
|
||||
update: async (
|
||||
params: UpdateSubscriptionV0Params,
|
||||
{ timeout }: { timeout?: number } = {},
|
||||
{
|
||||
timeout,
|
||||
skipWebhooks,
|
||||
}: { timeout?: number; skipWebhooks?: boolean } = {},
|
||||
): Promise<BillingResponse> => {
|
||||
const data = await this.post(`/subscriptions/update`, params);
|
||||
const headers: Record<string, string> = {};
|
||||
if (skipWebhooks !== undefined) {
|
||||
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
|
||||
}
|
||||
|
||||
const data = await this.post(
|
||||
`/subscriptions/update`,
|
||||
params,
|
||||
Object.keys(headers).length > 0 ? headers : undefined,
|
||||
);
|
||||
if (timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./operations/createStripeCustomer.js";
|
||||
export * from "./operations/getExpandedStripeCustomer.js";
|
||||
export * from "./operations/getOrCreateStripeCustomer.js";
|
||||
export * from "./utils/convertStripeCustomer.js";
|
||||
|
||||
43
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal file
43
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Customer } from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { buildStripeCustomerIdempotencyKey } from "@/external/stripe/customers/utils/buildIdempotencyKey";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const createStripeCustomer = async ({
|
||||
ctx,
|
||||
customer,
|
||||
options = {},
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customer: Customer;
|
||||
options?: {
|
||||
testClockId?: string;
|
||||
};
|
||||
}) => {
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const idempotencyKey = buildStripeCustomerIdempotencyKey({
|
||||
ctx,
|
||||
customerId: customer.id || customer.internal_id,
|
||||
});
|
||||
|
||||
const stripeCustomer = await stripeCli.customers.create(
|
||||
{
|
||||
name: customer.name || undefined,
|
||||
email: customer.email || undefined,
|
||||
metadata: {
|
||||
autumn_id: customer.id || null,
|
||||
autumn_internal_id: customer.internal_id,
|
||||
},
|
||||
test_clock: options.testClockId,
|
||||
},
|
||||
idempotencyKey
|
||||
? {
|
||||
idempotencyKey,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return stripeCustomer;
|
||||
};
|
||||
95
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
95
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import { InternalError } from "@autumn/shared";
|
||||
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 function getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
errorOnNotFound,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeCustomerId: string;
|
||||
errorOnNotFound: true;
|
||||
}): Promise<ExpandedStripeCustomer>;
|
||||
export function getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
errorOnNotFound,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeCustomerId?: string;
|
||||
errorOnNotFound?: false;
|
||||
}): Promise<ExpandedStripeCustomer | undefined>;
|
||||
export async function getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId,
|
||||
errorOnNotFound = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
stripeCustomerId?: string;
|
||||
errorOnNotFound?: boolean;
|
||||
}): Promise<ExpandedStripeCustomer | undefined> {
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const getExpandedStripeCustomerOptional = async () => {
|
||||
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;
|
||||
};
|
||||
|
||||
const stripeCustomer = await getExpandedStripeCustomerOptional();
|
||||
if (!stripeCustomer && errorOnNotFound) {
|
||||
throw new InternalError({
|
||||
message: stripeCustomerId
|
||||
? `Stripe customer not found: ${stripeCustomerId}`
|
||||
: "Stripe customer id is required.",
|
||||
});
|
||||
}
|
||||
|
||||
return stripeCustomer;
|
||||
}
|
||||
57
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal file
57
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import { type Customer, ProcessorType } from "@autumn/shared";
|
||||
import { createStripeCustomer } from "@/external/stripe/customers/operations/createStripeCustomer";
|
||||
import { getExpandedStripeCustomer } from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
export const getOrCreateStripeCustomer = async ({
|
||||
ctx,
|
||||
customer,
|
||||
options = {
|
||||
updateDb: true,
|
||||
},
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customer: Customer;
|
||||
options?: {
|
||||
updateDb?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const { logger, db, org, env } = ctx;
|
||||
|
||||
const currentStripeCustomer = await getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId: customer.processor?.id,
|
||||
});
|
||||
|
||||
if (currentStripeCustomer) return currentStripeCustomer;
|
||||
|
||||
logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
ctx,
|
||||
customer,
|
||||
});
|
||||
|
||||
if (options.updateDb) {
|
||||
await CusService.update({
|
||||
db,
|
||||
idOrInternalId: customer.internal_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
};
|
||||
|
||||
return stripeCustomer;
|
||||
};
|
||||
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { hashString } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const buildStripeCustomerIdempotencyKey = ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): string => {
|
||||
const { org, env } = ctx;
|
||||
return hashString(
|
||||
`stripe-create-cus:${customerId}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
|
||||
);
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js"
|
||||
import { getSentryTags } from "../sentry/sentryUtils.js";
|
||||
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
|
||||
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
|
||||
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
|
||||
import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
|
||||
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
|
||||
import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js";
|
||||
import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js";
|
||||
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
|
||||
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
|
||||
@@ -57,17 +57,9 @@ export const handleStripeWebhookEvent = async (
|
||||
});
|
||||
break;
|
||||
|
||||
case "invoice.created": {
|
||||
const createdInvoice = event.data.object;
|
||||
await handleInvoiceCreated({
|
||||
db,
|
||||
org,
|
||||
data: createdInvoice,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
case "invoice.created":
|
||||
await handleStripeInvoiceCreated({ ctx, event });
|
||||
break;
|
||||
}
|
||||
|
||||
case "invoice.finalized": {
|
||||
await handleInvoiceFinalized({ ctx });
|
||||
|
||||
5
server/src/external/stripe/invoices/utils/classifyStripeInvoice.ts
vendored
Normal file
5
server/src/external/stripe/invoices/utils/classifyStripeInvoice.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const isStripeInvoiceForNewPeriod = (stripeInvoice: Stripe.Invoice) => {
|
||||
return stripeInvoice.billing_reason === "subscription_cycle";
|
||||
};
|
||||
194
server/src/external/stripe/stripeCusUtils.ts
vendored
194
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -2,18 +2,17 @@ import {
|
||||
type AppEnv,
|
||||
type Customer,
|
||||
ErrCode,
|
||||
hashString,
|
||||
type Organization,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { Stripe } from "stripe";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripeCustomer } from "@/external/stripe/customers";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext";
|
||||
import type { Logger } from "../logtail/logtailUtils";
|
||||
|
||||
export const getStripeCus = async ({
|
||||
stripeCli,
|
||||
@@ -30,190 +29,6 @@ export const getStripeCus = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const createStripeCusIfNotExists = async ({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
logger: Logger;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const getCurrentStripeCus = async () => {
|
||||
// 1. If no processor, create new customer
|
||||
if (!customer.processor?.id) return null;
|
||||
|
||||
try {
|
||||
const stripeCus = await stripeCli.customers.retrieve(
|
||||
customer.processor.id,
|
||||
{
|
||||
expand: [
|
||||
"test_clock",
|
||||
"invoice_settings.default_payment_method",
|
||||
"discount.source.coupon.applies_to",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// 2. If customer is deleted, create new customer
|
||||
if (stripeCus.deleted) return null;
|
||||
|
||||
// 3. If customer is not deleted, return customer
|
||||
return stripeCus as Stripe.Customer;
|
||||
} catch (_error) {
|
||||
// 4. If error, create new customer
|
||||
if (
|
||||
_error instanceof Stripe.errors.StripeError &&
|
||||
_error.code?.includes("resource_missing")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw _error;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Get current stripe customer
|
||||
const stripeCus = await getCurrentStripeCus();
|
||||
|
||||
if (stripeCus) return stripeCus;
|
||||
|
||||
// 2. If no current stripe customer, create new customer
|
||||
logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
const idempotencyKey = hashString(
|
||||
`stripe-create-cus:${customer.id || customer.internal_id}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
|
||||
);
|
||||
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
db,
|
||||
idOrInternalId: customer.internal_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
};
|
||||
|
||||
return stripeCustomer;
|
||||
|
||||
// let createNew = false;
|
||||
// const stripeCli = createStripeCli({ org, env });
|
||||
// if (!customer.processor || !customer.processor.id) {
|
||||
// createNew = true;
|
||||
// } else {
|
||||
// try {
|
||||
// const stripeCus = await stripeCli.customers.retrieve(
|
||||
// customer.processor.id,
|
||||
// {
|
||||
// expand: ["test_clock", "invoice_settings.default_payment_method"],
|
||||
// },
|
||||
// );
|
||||
// if (!stripeCus.deleted) {
|
||||
// return stripeCus as Stripe.Customer;
|
||||
// } else {
|
||||
// createNew = true;
|
||||
// }
|
||||
// } catch (_error) {
|
||||
// createNew = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (createNew) {
|
||||
// logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
// const stripeCustomer = await createStripeCustomer({
|
||||
// org,
|
||||
// env,
|
||||
// customer,
|
||||
// });
|
||||
|
||||
// await CusService.update({
|
||||
// db,
|
||||
// idOrInternalId: customer.internal_id,
|
||||
// orgId: org.id,
|
||||
// env,
|
||||
// update: {
|
||||
// processor: {
|
||||
// id: stripeCustomer.id,
|
||||
// type: ProcessorType.Stripe,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// customer.processor = {
|
||||
// id: stripeCustomer.id,
|
||||
// type: ProcessorType.Stripe,
|
||||
// };
|
||||
|
||||
// return stripeCustomer;
|
||||
// }
|
||||
};
|
||||
|
||||
export const createStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
metadata,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
testClockId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
try {
|
||||
const stripeCustomer = await stripeCli.customers.create(
|
||||
{
|
||||
name: customer.name || undefined,
|
||||
email: customer.email || undefined,
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
autumn_id: customer.id || null,
|
||||
autumn_internal_id: customer.internal_id,
|
||||
},
|
||||
test_clock: testClockId,
|
||||
},
|
||||
idempotencyKey
|
||||
? {
|
||||
idempotencyKey,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return stripeCustomer;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Error creating customer in Stripe. ${error.message}`,
|
||||
code: ErrCode.StripeCreateCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
@@ -321,10 +136,9 @@ export const attachPmToCus = async ({
|
||||
let stripeCusId = customer.processor?.id;
|
||||
if (!stripeCusId) {
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
ctx: { org, env, db } as any,
|
||||
customer,
|
||||
testClockId,
|
||||
options: { testClockId },
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const getLatestPeriodEnd = ({
|
||||
sub,
|
||||
subItems,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./operations/getExpandedStripeSubscription.js";
|
||||
export * from "./types/stripeDiscountTypes.js";
|
||||
export * from "./utils/convertStripeSubscription.js";
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { StripeExpandedDiscount } from "../types/stripeDiscountTypes";
|
||||
|
||||
export type ExpandedStripeSubscription = Stripe.Subscription & {
|
||||
schedule: Stripe.SubscriptionSchedule & {
|
||||
phases: Stripe.SubscriptionSchedule.Phase[];
|
||||
};
|
||||
|
||||
customer: Stripe.Customer;
|
||||
discounts: StripeExpandedDiscount[];
|
||||
latest_invoice: string;
|
||||
};
|
||||
|
||||
export const getExpandedStripeSubscription = async ({
|
||||
@@ -23,7 +25,11 @@ export const getExpandedStripeSubscription = async ({
|
||||
const expandedStripeSubscription = await stripeCli.subscriptions.retrieve(
|
||||
subscriptionId,
|
||||
{
|
||||
expand: ["schedule.phases", "customer.test_clock"],
|
||||
expand: [
|
||||
"schedule.phases",
|
||||
"customer.test_clock",
|
||||
"discounts.source.coupon.applies_to",
|
||||
],
|
||||
},
|
||||
);
|
||||
return expandedStripeSubscription as ExpandedStripeSubscription;
|
||||
|
||||
41
server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts
vendored
Normal file
41
server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Stripe discount with expanded coupon (including applies_to).
|
||||
* Used for subscription-level discounts where coupon is under source.coupon.
|
||||
*/
|
||||
export type StripeExpandedDiscount = Omit<Stripe.Discount, "source"> & {
|
||||
source: {
|
||||
coupon: Stripe.Coupon & {
|
||||
applies_to: Stripe.Coupon.AppliesTo | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer discount structure when expanded via "discount.coupon.applies_to".
|
||||
*
|
||||
* TODO: Investigate if this is the correct expand path or if it should be
|
||||
* "discount.source.coupon.applies_to" to match the actual Stripe API structure.
|
||||
*/
|
||||
export type StripeCustomerExpandedDiscount = Omit<Stripe.Discount, "coupon"> & {
|
||||
coupon: Stripe.Coupon & {
|
||||
applies_to: Stripe.Coupon.AppliesTo | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stripe subscription with discounts expanded.
|
||||
* Compatible type for setupStripeDiscountsForBilling.
|
||||
*/
|
||||
export type StripeSubscriptionWithDiscounts = Stripe.Subscription & {
|
||||
discounts: StripeExpandedDiscount[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Stripe customer with discount expanded.
|
||||
* Compatible type for setupStripeDiscountsForBilling.
|
||||
*/
|
||||
export type StripeCustomerWithDiscount = Stripe.Customer & {
|
||||
discount: StripeCustomerExpandedDiscount | null;
|
||||
};
|
||||
@@ -54,3 +54,28 @@ export const isStripeSubscriptionCanceled = (
|
||||
|
||||
return stripeSubscription.status === "canceled";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a Stripe subscription has any metered price items.
|
||||
*/
|
||||
export const stripeSubscriptionHasMeteredItems = (
|
||||
stripeSubscription?: Stripe.Subscription,
|
||||
) => {
|
||||
if (!stripeSubscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stripeSubscription.items.data.some(
|
||||
(item) => item.price.recurring?.usage_type === "metered",
|
||||
);
|
||||
};
|
||||
|
||||
export const isStripeSubscriptionVercel = (
|
||||
stripeSubscription?: Stripe.Subscription,
|
||||
) => {
|
||||
if (!stripeSubscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(stripeSubscription.metadata?.vercel_installation_id);
|
||||
};
|
||||
|
||||
97
server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts
vendored
Normal file
97
server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
ms,
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { ExpandedStripeCustomer } from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
|
||||
/**
|
||||
* Common fields between InvoiceCreatedContext and StripeSubscriptionDeletedContext.
|
||||
*
|
||||
* Discounts can be accessed from:
|
||||
* - `stripeSubscription.discounts` (subscription-level discounts)
|
||||
* - `stripeCustomer.discount` (customer-level discount)
|
||||
*/
|
||||
export interface BaseWebhookEventContext {
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
stripeCustomer: ExpandedStripeCustomer;
|
||||
fullCustomer: FullCustomer;
|
||||
customerProducts: FullCusProduct[];
|
||||
nowMs: number;
|
||||
paymentMethod: Stripe.PaymentMethod | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a BillingContext for generating arrear (usage-in-arrear) invoice line items
|
||||
* from Stripe webhook events.
|
||||
*
|
||||
* This function is used by both:
|
||||
* - `invoice.created` webhook: to add consumable usage line items to the renewal invoice
|
||||
* - `subscription.deleted` webhook: to create a final arrear invoice for usage
|
||||
*
|
||||
* @param eventContext - Common webhook context fields shared by both event types
|
||||
* @param eventContext.stripeSubscription - The expanded Stripe subscription
|
||||
* @param eventContext.fullCustomer - The full customer object from Autumn DB
|
||||
* @param eventContext.nowMs - Current time in ms (respecting test clocks)
|
||||
* @param eventContext.paymentMethod - Customer's payment method for the invoice
|
||||
*
|
||||
* @param periodEndMs - The end of the billing period being invoiced (in milliseconds).
|
||||
* If not provided, falls back to `eventContext.nowMs`.
|
||||
* - For `invoice.created`: use `secondsToMs(stripeInvoice.period_end)`
|
||||
* - For `subscription.deleted`: use `secondsToMs(stripeSubscription.ended_at)` if available
|
||||
*
|
||||
* @returns A BillingContext configured for arrear line item generation
|
||||
*
|
||||
* @remarks
|
||||
* **Why we use "just before" the period end:**
|
||||
*
|
||||
* The billing period calculation functions (`getCycleStart`, `getCycleEnd`) determine
|
||||
* which cycle a given timestamp falls into. If we pass exactly `periodEndMs` (e.g., Feb 1),
|
||||
* the functions will return the NEW cycle (Feb 1 - Mar 1) instead of the OLD cycle
|
||||
* (Jan 1 - Feb 1) that we actually want to bill for.
|
||||
*
|
||||
* By subtracting 30 minutes, we ensure we're still "within" the old cycle:
|
||||
* ```
|
||||
* periodEndMs = Feb 1 00:00:00
|
||||
* justBeforePeriodEndMs = Jan 31 23:30:00
|
||||
*
|
||||
* getCycleStart(now = Jan 31 23:30) → Jan 1 ✓ (old cycle)
|
||||
* getCycleEnd(now = Jan 31 23:30) → Feb 1 ✓ (old cycle)
|
||||
* ```
|
||||
*/
|
||||
export const buildBillingContextForArrearInvoice = ({
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
}: {
|
||||
eventContext: BaseWebhookEventContext;
|
||||
periodEndMs?: number;
|
||||
}): BillingContext => {
|
||||
const { stripeSubscription, fullCustomer, paymentMethod, nowMs } =
|
||||
eventContext;
|
||||
|
||||
// Use periodEndMs if provided, otherwise fall back to nowMs
|
||||
const effectivePeriodEndMs = periodEndMs ?? nowMs;
|
||||
|
||||
// Use "just before" period end so getCycleStart/getCycleEnd return the OLD cycle
|
||||
// that just ended, not the NEW cycle that's starting.
|
||||
// See JSDoc above for detailed explanation.
|
||||
const justBeforePeriodEndMs = effectivePeriodEndMs - ms.minutes(30);
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
featureQuantities: [],
|
||||
|
||||
currentEpochMs: justBeforePeriodEndMs,
|
||||
billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
resetCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
|
||||
stripeCustomer: stripeSubscription.customer,
|
||||
stripeSubscription,
|
||||
paymentMethod: paymentMethod ?? undefined,
|
||||
};
|
||||
};
|
||||
84
server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts
vendored
Normal file
84
server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { FullCusEntWithFullCusProduct, LineItem } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
|
||||
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
|
||||
import type { UpdateCustomerEntitlement } from "@/internal/billing/v2/types/autumnBillingPlan";
|
||||
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
|
||||
import {
|
||||
type BaseWebhookEventContext,
|
||||
buildBillingContextForArrearInvoice,
|
||||
} from "./buildBillingContextFromWebhook";
|
||||
import { logWebhookArrearLineItems } from "./logs/logWebhookArrearLineItems";
|
||||
|
||||
/**
|
||||
* Generates arrear (usage-in-arrear) line items from webhook event context.
|
||||
*
|
||||
* This function is used by both:
|
||||
* - `invoice.created` webhook: adds consumable usage line items to renewal invoice
|
||||
* - `subscription.deleted` webhook: creates final arrear invoice for usage
|
||||
*
|
||||
* @param ctx - Autumn context (for org currency, etc.)
|
||||
* @param eventContext - Common webhook context (stripeSubscription, stripeCustomer, fullCustomer, customerProducts, nowMs, paymentMethod)
|
||||
* @param periodEndMs - End of billing period (optional, falls back to nowMs)
|
||||
* @param cusEntFilter - Optional filter for multi-interval billing (invoice.created uses this)
|
||||
*
|
||||
* @returns Object with line items and the billing context used
|
||||
*/
|
||||
export const eventContextToArrearLineItems = ({
|
||||
ctx,
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
cusEntFilter,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: BaseWebhookEventContext;
|
||||
periodEndMs?: number;
|
||||
cusEntFilter?: (cusEnt: FullCusEntWithFullCusProduct) => boolean;
|
||||
}): {
|
||||
lineItems: LineItem[];
|
||||
updateCustomerEntitlements: UpdateCustomerEntitlement[];
|
||||
billingContext: BillingContext;
|
||||
} => {
|
||||
const billingContext = buildBillingContextForArrearInvoice({
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
});
|
||||
|
||||
// Collect line items from all customer products
|
||||
let lineItems: LineItem[] = [];
|
||||
const updateCustomerEntitlements: UpdateCustomerEntitlement[] = [];
|
||||
for (const customerProduct of eventContext.customerProducts) {
|
||||
const {
|
||||
lineItems: productLineItems,
|
||||
updateCustomerEntitlements: productUpdates,
|
||||
} = customerProductToArrearLineItems({
|
||||
ctx,
|
||||
customerProduct,
|
||||
billingContext,
|
||||
filters: { cusEntFilter },
|
||||
updateNextResetAt: true,
|
||||
});
|
||||
lineItems.push(...productLineItems);
|
||||
updateCustomerEntitlements.push(...productUpdates);
|
||||
}
|
||||
|
||||
// Apply discounts to line items
|
||||
const discounts = setupStripeDiscountsForBilling({
|
||||
stripeSubscription: eventContext.stripeSubscription,
|
||||
stripeCustomer: eventContext.stripeCustomer,
|
||||
});
|
||||
|
||||
if (discounts.length > 0) {
|
||||
lineItems = applyStripeDiscountsToLineItems({ lineItems, discounts });
|
||||
}
|
||||
|
||||
// Log the arrear line items and customer entitlement updates
|
||||
logWebhookArrearLineItems({
|
||||
ctx,
|
||||
lineItems,
|
||||
updateCustomerEntitlements,
|
||||
});
|
||||
|
||||
return { lineItems, updateCustomerEntitlements, billingContext };
|
||||
};
|
||||
@@ -1,3 +1,8 @@
|
||||
export {
|
||||
type BaseWebhookEventContext,
|
||||
buildBillingContextForArrearInvoice,
|
||||
} from "./buildBillingContextFromWebhook";
|
||||
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
|
||||
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";
|
||||
export {
|
||||
type SubscriptionEventContext,
|
||||
|
||||
32
server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts
vendored
Normal file
32
server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import { formatMs, type LineItem } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import type { UpdateCustomerEntitlement } from "@/internal/billing/v2/types/autumnBillingPlan";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
|
||||
export const logWebhookArrearLineItems = ({
|
||||
ctx,
|
||||
lineItems,
|
||||
updateCustomerEntitlements,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
lineItems: LineItem[];
|
||||
updateCustomerEntitlements: UpdateCustomerEntitlement[];
|
||||
}) => {
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
arrearLineItems: {
|
||||
lineItems: lineItems.map(
|
||||
(item) => `${item.description}: ${item.finalAmount}`,
|
||||
),
|
||||
updateCustomerEntitlements: updateCustomerEntitlements.map(
|
||||
(update) => ({
|
||||
featureId: update.customerEntitlement.entitlement.feature?.id,
|
||||
...update.updates,
|
||||
next_reset_at: formatMs(update.updates?.next_reset_at),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type Stripe from "stripe";
|
||||
import { processAllocatedPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated";
|
||||
import { processPrepaidPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated";
|
||||
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
|
||||
import { setupInvoiceCreatedContext } from "./setupInvoiceCreatedContext";
|
||||
import { processConsumablePricesForInvoiceCreated } from "./tasks/processConsumablePricesForInvoiceCreated";
|
||||
|
||||
export const handleStripeInvoiceCreated = async ({
|
||||
ctx,
|
||||
event,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
event: Stripe.InvoiceCreatedEvent;
|
||||
}) => {
|
||||
const eventContext = await setupInvoiceCreatedContext({ ctx, event });
|
||||
|
||||
if (!eventContext) {
|
||||
ctx.logger.debug("[invoice.created] Skipping - context not found");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.info(
|
||||
`[invoice.created] Processing for invoice ${eventContext.stripeInvoice.id}`,
|
||||
);
|
||||
|
||||
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
|
||||
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
|
||||
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
|
||||
|
||||
await upsertAutumnInvoice({ ctx, eventContext });
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type FullCusEntWithFullCusProduct, formatMs } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
|
||||
export const logPrepaidPriceProcessed = ({
|
||||
ctx,
|
||||
customerEntitlement,
|
||||
resetQuantity,
|
||||
newAllowance,
|
||||
nextResetAt,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
resetQuantity: number;
|
||||
newAllowance: number;
|
||||
nextResetAt: number;
|
||||
}) => {
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
prepaidPriceProcessed: {
|
||||
featureId: customerEntitlement.entitlement.feature?.id,
|
||||
cusEntId: customerEntitlement.id,
|
||||
resetQuantity,
|
||||
newAllowance,
|
||||
nextResetAt: formatMs(nextResetAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const logAllocatedPriceProcessed = ({
|
||||
ctx,
|
||||
customerEntitlement,
|
||||
replaceablesRemoved,
|
||||
balanceIncremented,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
replaceablesRemoved: number;
|
||||
balanceIncremented: number;
|
||||
}) => {
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
allocatedPriceProcessed: {
|
||||
featureId: customerEntitlement.entitlement.feature?.id,
|
||||
cusEntId: customerEntitlement.id,
|
||||
replaceablesRemoved,
|
||||
balanceIncremented,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
133
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts
vendored
Normal file
133
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
isCustomerProductOnStripeSubscription,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
type ExpandedStripeCustomer,
|
||||
getExpandedStripeCustomer,
|
||||
} from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import {
|
||||
type ExpandedStripeInvoice,
|
||||
getStripeInvoice,
|
||||
} from "@/external/stripe/invoices/operations/getStripeInvoice";
|
||||
import { stripeInvoiceToStripeSubscriptionId } from "@/external/stripe/invoices/utils/convertStripeInvoice";
|
||||
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils";
|
||||
import {
|
||||
type ExpandedStripeSubscription,
|
||||
getExpandedStripeSubscription,
|
||||
} from "@/external/stripe/subscriptions";
|
||||
import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
|
||||
|
||||
export interface InvoiceCreatedContext {
|
||||
stripeInvoice: ExpandedStripeInvoice<["discounts.source.coupon"]>;
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
stripeCustomer: ExpandedStripeCustomer;
|
||||
stripeSubscriptionId: string;
|
||||
fullCustomer: FullCustomer;
|
||||
customerProducts: FullCusProduct[];
|
||||
|
||||
/** Current time in ms, respecting test clocks */
|
||||
nowMs: number;
|
||||
/** Customer's payment method for paying arrear invoices */
|
||||
paymentMethod: Stripe.PaymentMethod | null;
|
||||
}
|
||||
|
||||
export const setupInvoiceCreatedContext = async ({
|
||||
ctx,
|
||||
event,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
event: Stripe.InvoiceCreatedEvent;
|
||||
}): Promise<InvoiceCreatedContext | null> => {
|
||||
const { stripeCli, fullCustomer, logger } = ctx;
|
||||
|
||||
// 1. Get expanded invoice
|
||||
const stripeInvoice = await getStripeInvoice({
|
||||
stripeClient: stripeCli,
|
||||
invoiceId: event.data.object.id!,
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
|
||||
// 2. Get subscription ID - return null if not a subscription invoice
|
||||
const stripeSubscriptionId =
|
||||
stripeInvoiceToStripeSubscriptionId(stripeInvoice);
|
||||
|
||||
if (!stripeSubscriptionId) {
|
||||
logger.info("[invoice.created] No subscription ID, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Check fullCustomer exists
|
||||
if (!fullCustomer) {
|
||||
logger.info("[invoice.created] fullCustomer not found, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Get customer products by subscription ID
|
||||
const currentCustomerProducts = fullCustomer.customer_products.filter((cp) =>
|
||||
isCustomerProductOnStripeSubscription({
|
||||
customerProduct: cp,
|
||||
stripeSubscriptionId,
|
||||
}),
|
||||
);
|
||||
|
||||
const customerProducts =
|
||||
await customerProductActions.expiredCache.getAndMerge({
|
||||
customerProducts: currentCustomerProducts,
|
||||
stripeSubscriptionId,
|
||||
});
|
||||
|
||||
if (customerProducts.length === 0) {
|
||||
logger.info(
|
||||
`[invoice.created] No customer products found for subscription ${stripeSubscriptionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 6. Update fullCustomer.customer_products with fresh data
|
||||
fullCustomer.customer_products = customerProducts;
|
||||
|
||||
// 3. Get expanded stripe subscription
|
||||
const stripeSubscription = await getExpandedStripeSubscription({
|
||||
ctx,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
});
|
||||
|
||||
// 4. Get expanded stripe customer (for discount info)
|
||||
const stripeCustomer = await getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId: stripeSubscription.customer.id,
|
||||
});
|
||||
|
||||
if (!stripeCustomer) {
|
||||
logger.info("[invoice.created] stripeCustomer not found, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Get current time (respecting test clocks)
|
||||
const nowMs = await stripeSubscriptionToNowMs({
|
||||
stripeSubscription,
|
||||
stripeCli: ctx.stripeCli,
|
||||
});
|
||||
|
||||
// 6. Get payment method for arrear invoices
|
||||
const paymentMethod = await getCusPaymentMethod({
|
||||
stripeCli: ctx.stripeCli,
|
||||
stripeId: stripeSubscription.customer.id,
|
||||
});
|
||||
|
||||
return {
|
||||
stripeInvoice,
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
stripeSubscriptionId,
|
||||
fullCustomer,
|
||||
customerProducts,
|
||||
nowMs,
|
||||
paymentMethod,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
BillingType,
|
||||
cusProductsToCusEnts,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
import { isStripeInvoiceForNewPeriod } from "@/external/stripe/invoices/utils/classifyStripeInvoice";
|
||||
import { isStripeSubscriptionVercel } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { InvoiceCreatedContext } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { findLinkedCusEnts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils";
|
||||
import { removeReplaceablesFromCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils";
|
||||
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService";
|
||||
import { logAllocatedPriceProcessed } from "../logs/logInvoiceCreatedPriceProcessing";
|
||||
|
||||
/**
|
||||
* Handle reset balance?
|
||||
*/
|
||||
|
||||
const processAllocatedPrice = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerEntitlement,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
}) => {
|
||||
const { db } = ctx;
|
||||
const { stripeInvoice } = eventContext;
|
||||
|
||||
const customerProduct = customerEntitlement.customer_product;
|
||||
const customerEntitlements = customerProduct?.customer_entitlements ?? [];
|
||||
|
||||
const isNewPeriod = isStripeInvoiceForNewPeriod(stripeInvoice);
|
||||
if (!isNewPeriod) return;
|
||||
|
||||
const feature = customerEntitlement.entitlement.feature;
|
||||
const replaceables = customerEntitlement.replaceables.filter(
|
||||
(r) => r.delete_next_cycle,
|
||||
);
|
||||
|
||||
if (replaceables.length === 0) return false;
|
||||
|
||||
const linkedCusEnts = findLinkedCusEnts({
|
||||
cusEnts: customerEntitlements,
|
||||
feature,
|
||||
});
|
||||
|
||||
for (const linkedCusEnt of linkedCusEnts) {
|
||||
const { newEntities } = removeReplaceablesFromCusEnt({
|
||||
cusEnt: linkedCusEnt,
|
||||
replaceableIds: replaceables.map((r) => r.id),
|
||||
});
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: linkedCusEnt.id,
|
||||
updates: {
|
||||
entities: newEntities,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await CusEntService.increment({
|
||||
db,
|
||||
id: customerEntitlement.id,
|
||||
amount: replaceables.length,
|
||||
});
|
||||
|
||||
await RepService.deleteInIds({
|
||||
db,
|
||||
ids: replaceables.map((r) => r.id),
|
||||
});
|
||||
|
||||
logAllocatedPriceProcessed({
|
||||
ctx,
|
||||
customerEntitlement,
|
||||
replaceablesRemoved: replaceables.length,
|
||||
balanceIncremented: replaceables.length,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const processAllocatedPricesForInvoiceCreated = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
}): Promise<void> => {
|
||||
const { stripeInvoice, customerProducts, stripeSubscription } = eventContext;
|
||||
|
||||
const isNewPeriod = isStripeInvoiceForNewPeriod(stripeInvoice);
|
||||
const isVercelSubscription = isStripeSubscriptionVercel(stripeSubscription);
|
||||
if (!isNewPeriod || isVercelSubscription) return;
|
||||
|
||||
const customerEntitlements = cusProductsToCusEnts({
|
||||
cusProducts: customerProducts,
|
||||
filters: {
|
||||
billingTypes: [BillingType.InArrearProrated],
|
||||
},
|
||||
});
|
||||
|
||||
for (const customerEntitlement of customerEntitlements) {
|
||||
await processAllocatedPrice({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerEntitlement,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { customerEntitlementShouldBeBilled, secondsToMs } from "@autumn/shared";
|
||||
import { eventContextToArrearLineItems } from "@/external/stripe/webhookHandlers/common";
|
||||
import { lineItemsToCreateInvoiceItemsParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
|
||||
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext";
|
||||
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
|
||||
|
||||
/**
|
||||
* Processes consumable (usage-in-arrear) prices for an invoice.
|
||||
* Adds usage line items to the invoice for the billing period.
|
||||
*
|
||||
* TODO: Handle conflict with entity consumable prices (Case B)
|
||||
* When a customer cancels end-of-cycle with entity-level consumables:
|
||||
* - subscription.deleted fires → creates arrear invoice via createInvoiceForArrearPrices
|
||||
* - invoice.created also fires → may try to add line items here
|
||||
* Risk: Double billing for entity-level consumables
|
||||
* Need to coordinate between the two handlers to prevent this.
|
||||
*/
|
||||
export const processConsumablePricesForInvoiceCreated = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
}): Promise<void> => {
|
||||
const { stripeInvoice } = eventContext;
|
||||
|
||||
if (stripeInvoice.billing_reason !== "subscription_cycle") return;
|
||||
|
||||
const invoicePeriodEndMs = secondsToMs(stripeInvoice.period_end);
|
||||
|
||||
const { lineItems, updateCustomerEntitlements } =
|
||||
eventContextToArrearLineItems({
|
||||
ctx,
|
||||
eventContext,
|
||||
periodEndMs: invoicePeriodEndMs,
|
||||
// Multi-interval filter: only bill entitlements whose cycle ends at this invoice
|
||||
cusEntFilter: (cusEnt) =>
|
||||
customerEntitlementShouldBeBilled({
|
||||
cusEnt,
|
||||
invoicePeriodEndMs,
|
||||
}),
|
||||
});
|
||||
|
||||
if (lineItems.length === 0) return;
|
||||
|
||||
await createStripeInvoiceItems({
|
||||
ctx,
|
||||
invoiceItems: lineItemsToCreateInvoiceItemsParams({
|
||||
stripeCustomerId: eventContext.stripeCustomer.id,
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
lineItems,
|
||||
}),
|
||||
});
|
||||
|
||||
await CusEntService.batchUpdate({
|
||||
db: ctx.db,
|
||||
data: updateCustomerEntitlements,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
addCusProductToCusEnt,
|
||||
BillingType,
|
||||
customerEntitlementToOptions,
|
||||
customerPriceToCustomerEntitlement,
|
||||
EntInterval,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullCustomerPrice,
|
||||
notNullish,
|
||||
} from "@autumn/shared";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
|
||||
import { isStripeSubscriptionVercel } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { InvoiceCreatedContext } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext";
|
||||
import { getCustomerPricesWithCustomerProducts } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/utils/getCustomerPricesWithCustomerProducts";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
|
||||
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils";
|
||||
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils";
|
||||
import { logPrepaidPriceProcessed } from "../logs/logInvoiceCreatedPriceProcessing";
|
||||
|
||||
/**
|
||||
* Handle reset balance?
|
||||
*/
|
||||
|
||||
const processPrepaidPrice = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerPrice,
|
||||
customerEntitlement,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
customerPrice: FullCustomerPrice;
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
}) => {
|
||||
const options = customerEntitlementToOptions({
|
||||
customerEntitlement,
|
||||
});
|
||||
|
||||
const customerProduct = customerEntitlement.customer_product;
|
||||
|
||||
const { stripeSubscription } = eventContext;
|
||||
const { db } = ctx;
|
||||
|
||||
if (!options) return;
|
||||
|
||||
const resetQuantity = (options?.upcoming_quantity || options?.quantity) ?? 0;
|
||||
const config = customerPrice.price.config;
|
||||
const billingUnits = config.billing_units || 1;
|
||||
const newAllowance =
|
||||
resetQuantity * billingUnits +
|
||||
(customerEntitlement.entitlement.allowance ?? 0);
|
||||
|
||||
const resetUpdate = getResetBalancesUpdate({
|
||||
cusEnt: customerEntitlement,
|
||||
allowance: newAllowance,
|
||||
});
|
||||
|
||||
const ent = customerEntitlement.entitlement;
|
||||
|
||||
const { end } = subToPeriodStartEnd({ sub: stripeSubscription });
|
||||
|
||||
const rolloverUpdate = getRolloverUpdates({
|
||||
cusEnt: customerEntitlement,
|
||||
nextResetAt: end * 1000,
|
||||
});
|
||||
|
||||
if (notNullish(options?.upcoming_quantity) && customerProduct) {
|
||||
const newOptions = customerProduct.options.map((o) => {
|
||||
if (o.feature_id === ent.feature_id) {
|
||||
return {
|
||||
...o,
|
||||
quantity: o.upcoming_quantity,
|
||||
upcoming_quantity: undefined,
|
||||
};
|
||||
}
|
||||
return o;
|
||||
});
|
||||
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: customerProduct.id,
|
||||
updates: {
|
||||
options: newOptions,
|
||||
},
|
||||
});
|
||||
|
||||
if (ent.interval === EntInterval.Lifetime) {
|
||||
const difference =
|
||||
(options?.quantity ?? 0) - (options?.upcoming_quantity ?? 0);
|
||||
await CusEntService.decrement({
|
||||
db,
|
||||
id: customerEntitlement.id,
|
||||
amount: difference,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (ent.interval === EntInterval.Lifetime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
|
||||
await RolloverService.insert({
|
||||
db,
|
||||
rows: rolloverUpdate.toInsert,
|
||||
fullCusEnt: customerEntitlement,
|
||||
});
|
||||
}
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: customerEntitlement.id,
|
||||
updates: {
|
||||
...resetUpdate,
|
||||
next_reset_at: end * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
logPrepaidPriceProcessed({
|
||||
ctx,
|
||||
customerEntitlement,
|
||||
resetQuantity,
|
||||
newAllowance,
|
||||
nextResetAt: end * 1000,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const processPrepaidPricesForInvoiceCreated = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
}): Promise<void> => {
|
||||
const { stripeInvoice, customerProducts, stripeSubscription } = eventContext;
|
||||
|
||||
const isNewPeriod = stripeInvoice.billing_reason === "subscription_cycle";
|
||||
const isVercelSubscription = isStripeSubscriptionVercel(stripeSubscription);
|
||||
if (!isNewPeriod || isVercelSubscription) return;
|
||||
|
||||
const customerPrices = getCustomerPricesWithCustomerProducts({
|
||||
customerProducts,
|
||||
filters: {
|
||||
billingType: BillingType.UsageInAdvance,
|
||||
},
|
||||
});
|
||||
|
||||
for (const customerPrice of customerPrices) {
|
||||
const cusProduct = customerPrice.customer_product;
|
||||
if (!cusProduct) continue;
|
||||
|
||||
const cusEnt = customerPriceToCustomerEntitlement({
|
||||
customerPrice,
|
||||
customerEntitlements: cusProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
if (!cusEnt) continue;
|
||||
|
||||
const cusEntWithProduct = addCusProductToCusEnt({
|
||||
cusEnt,
|
||||
cusProduct,
|
||||
});
|
||||
|
||||
await processPrepaidPrice({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerPrice,
|
||||
customerEntitlement: cusEntWithProduct,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService";
|
||||
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
|
||||
|
||||
/**
|
||||
* Upserts an Autumn invoice record from the Stripe invoice.created webhook.
|
||||
*
|
||||
* Behavior:
|
||||
* - Skips first invoice (billing_reason: subscription_create) - handled elsewhere
|
||||
* - Tries to update existing invoice by Stripe ID first
|
||||
* - If not found, creates a new invoice record
|
||||
*/
|
||||
export const upsertAutumnInvoice = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
}): Promise<void> => {
|
||||
const { stripeInvoice, customerProducts, fullCustomer } = eventContext;
|
||||
|
||||
// Skip first invoice (subscription_create)
|
||||
if (stripeInvoice.billing_reason === "subscription_create") {
|
||||
ctx.logger.debug(
|
||||
"[invoice.created] Skipping invoice upsert for subscription_create",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const productIds = [...new Set(customerProducts.map((cp) => cp.product.id))];
|
||||
const internalProductIds = [
|
||||
...new Set(customerProducts.map((cp) => cp.internal_product_id)),
|
||||
];
|
||||
const internalCustomerId = fullCustomer.internal_id;
|
||||
|
||||
// Entity ID - if all customer products have same entity, use it
|
||||
const internalEntityId =
|
||||
customerProducts.length > 0 &&
|
||||
customerProducts.every(
|
||||
(cp) => cp.internal_entity_id === customerProducts[0].internal_entity_id,
|
||||
)
|
||||
? customerProducts[0].internal_entity_id
|
||||
: null;
|
||||
|
||||
// Try update first
|
||||
const updated = await InvoiceService.updateByStripeId({
|
||||
db: ctx.db,
|
||||
stripeId: stripeInvoice.id,
|
||||
updates: {
|
||||
product_ids: productIds,
|
||||
internal_product_ids: internalProductIds,
|
||||
},
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
ctx.logger.debug(
|
||||
`[invoice.created] Updated existing invoice ${stripeInvoice.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new
|
||||
await InvoiceService.createInvoiceFromStripe({
|
||||
db: ctx.db,
|
||||
stripeInvoice,
|
||||
internalCustomerId,
|
||||
internalEntityId,
|
||||
org: ctx.org,
|
||||
productIds,
|
||||
internalProductIds,
|
||||
items: [],
|
||||
});
|
||||
|
||||
ctx.logger.debug(`[invoice.created] Created new invoice ${stripeInvoice.id}`);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type BillingType,
|
||||
type CustomerPriceWithCustomerProduct,
|
||||
type FullCusProduct,
|
||||
getBillingType,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const getCustomerPricesWithCustomerProducts = ({
|
||||
customerProducts,
|
||||
filters,
|
||||
}: {
|
||||
customerProducts: FullCusProduct[];
|
||||
filters?: {
|
||||
billingType?: BillingType;
|
||||
};
|
||||
}): CustomerPriceWithCustomerProduct[] => {
|
||||
const result: CustomerPriceWithCustomerProduct[] = [];
|
||||
|
||||
for (const customerProduct of customerProducts) {
|
||||
for (const customerPrice of customerProduct.customer_prices) {
|
||||
if (
|
||||
filters?.billingType &&
|
||||
getBillingType(customerPrice.price.config) !== filters.billingType
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...customerPrice,
|
||||
customer_product: customerProduct,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import type Stripe from "stripe";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
|
||||
import { logCustomerProductUpdates } from "../common";
|
||||
import { setupStripeSubscriptionDeletedContext } from "./setupStripeSubscriptionDeletedContext";
|
||||
import { createInvoiceForArrearPrices } from "./tasks/createInvoiceForArrearPrices";
|
||||
import { expireAndActivateCustomerProducts } from "./tasks/expireAndActivateCustomerProducts";
|
||||
import { processConsumablePricesForSubscriptionDeleted } from "./tasks/processConsumablePricesForSubscriptionDeleted";
|
||||
|
||||
/**
|
||||
* Handles Stripe subscription.deleted webhook.
|
||||
@@ -34,7 +34,7 @@ export const handleStripeSubscriptionDeleted = async ({
|
||||
logger.info(`[sub.deleted] Processing subscription.deleted`);
|
||||
|
||||
// Task 1: Create invoices for arrear prices (usage-based)
|
||||
await createInvoiceForArrearPrices({ ctx, eventContext });
|
||||
await processConsumablePricesForSubscriptionDeleted({ ctx, eventContext });
|
||||
|
||||
// Task 2: Expire customer products + delete scheduled + activate defaults
|
||||
await expireAndActivateCustomerProducts({ ctx, eventContext });
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { LineItem } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
|
||||
/**
|
||||
* Logs arrear invoice creation result for a subscription deletion.
|
||||
*/
|
||||
export const logArrearInvoice = ({
|
||||
ctx,
|
||||
invoiceId,
|
||||
paid,
|
||||
lineItems,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
invoiceId: string;
|
||||
paid: boolean;
|
||||
lineItems: LineItem[];
|
||||
}) => {
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
extras: {
|
||||
arrearInvoice: {
|
||||
invoiceId,
|
||||
paid,
|
||||
lineItems: lineItems.map(
|
||||
(item) => `${item.description}: ${item.finalAmount}`,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
import type {
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
InsertCustomerProduct,
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type InsertCustomerProduct,
|
||||
isCustomerProductOnStripeSubscription,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
type ExpandedStripeCustomer,
|
||||
getExpandedStripeCustomer,
|
||||
} from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils";
|
||||
import {
|
||||
type ExpandedStripeSubscription,
|
||||
@@ -15,6 +20,7 @@ import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhoo
|
||||
|
||||
export interface StripeSubscriptionDeletedContext {
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
stripeCustomer: ExpandedStripeCustomer;
|
||||
fullCustomer: FullCustomer;
|
||||
/** Customer products that are on this subscription */
|
||||
customerProducts: FullCusProduct[];
|
||||
@@ -57,7 +63,10 @@ export const setupStripeSubscriptionDeletedContext = async ({
|
||||
|
||||
// 1. Filter customer products on this subscription
|
||||
const customerProducts = fullCustomer.customer_products.filter((cp) =>
|
||||
cp.subscription_ids?.includes(stripeSubscriptionId),
|
||||
isCustomerProductOnStripeSubscription({
|
||||
customerProduct: cp,
|
||||
stripeSubscriptionId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (customerProducts.length === 0) {
|
||||
@@ -85,13 +94,24 @@ export const setupStripeSubscriptionDeletedContext = async ({
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
});
|
||||
|
||||
// 4. Get current time (respecting test clocks)
|
||||
// 4. Get expanded stripe customer (for discount info)
|
||||
const stripeCustomer = await getExpandedStripeCustomer({
|
||||
ctx,
|
||||
stripeCustomerId: stripeSubscription.customer.id,
|
||||
});
|
||||
|
||||
if (!stripeCustomer) {
|
||||
logger.warn("[sub.deleted] stripeCustomer not found, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Get current time (respecting test clocks)
|
||||
const nowMs = await stripeSubscriptionToNowMs({
|
||||
stripeSubscription,
|
||||
stripeCli: ctx.stripeCli,
|
||||
});
|
||||
|
||||
// 5. Get payment method for arrear invoices
|
||||
// 6. Get payment method for arrear invoices
|
||||
const paymentMethod = await getCusPaymentMethod({
|
||||
stripeCli: ctx.stripeCli,
|
||||
stripeId: stripeSubscription.customer.id,
|
||||
@@ -99,6 +119,7 @@ export const setupStripeSubscriptionDeletedContext = async ({
|
||||
|
||||
return {
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
fullCustomer,
|
||||
customerProducts,
|
||||
nowMs,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import {
|
||||
cusProductToProduct,
|
||||
type FullCusProduct,
|
||||
type LineItem,
|
||||
lineItemToCustomerEntitlement,
|
||||
} from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { lineItemsToInvoiceAddLinesParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
||||
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
|
||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||
import { resetUsageBalances } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems";
|
||||
import { logArrearInvoice } from "../logs/logArrearInvoices";
|
||||
import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext";
|
||||
import { buildBillingContextFromWebhook } from "../utils/buildBillingContextFromWebhook";
|
||||
|
||||
/** Tracks line items and their source customer product for balance reset */
|
||||
interface LineItemWithSource {
|
||||
lineItem: LineItem;
|
||||
customerProduct: FullCusProduct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single invoice for all usage-based (arrear) prices across all customer products
|
||||
* when a subscription is deleted.
|
||||
* Skips if the deletion was initiated by Autumn (e.g., during an upgrade flow).
|
||||
*/
|
||||
export const createInvoiceForArrearPrices = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: StripeSubscriptionDeletedContext;
|
||||
}): Promise<void> => {
|
||||
const { db } = ctx;
|
||||
const { stripeSubscription, fullCustomer, nowMs, paymentMethod } =
|
||||
eventContext;
|
||||
|
||||
// 1. Build billing context
|
||||
const billingContext = buildBillingContextFromWebhook({
|
||||
stripeSubscription,
|
||||
fullCustomer,
|
||||
nowMs,
|
||||
paymentMethod,
|
||||
});
|
||||
|
||||
// 2. Collect all line items across all customer products
|
||||
const lineItemsWithSource: LineItemWithSource[] = [];
|
||||
|
||||
for (const customerProduct of eventContext.customerProducts) {
|
||||
const lineItems = customerProductToArrearLineItems({
|
||||
ctx,
|
||||
customerProduct,
|
||||
billingContext,
|
||||
filters: {
|
||||
onlyV4Usage: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const lineItem of lineItems) {
|
||||
lineItemsWithSource.push({ lineItem, customerProduct });
|
||||
}
|
||||
}
|
||||
|
||||
if (lineItemsWithSource.length === 0) return;
|
||||
|
||||
// 3. Create, finalize, and pay a single invoice with all line items
|
||||
const allLineItems = lineItemsWithSource.map((item) => item.lineItem);
|
||||
const invoiceLines = lineItemsToInvoiceAddLinesParams({
|
||||
lineItems: allLineItems,
|
||||
});
|
||||
|
||||
const { paid, invoice } = await createInvoiceForBilling({
|
||||
ctx,
|
||||
billingContext,
|
||||
stripeInvoiceAction: {
|
||||
addLineParams: { lines: invoiceLines },
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Log the invoice (even if payment failed)
|
||||
logArrearInvoice({
|
||||
ctx,
|
||||
invoiceId: invoice.id,
|
||||
paid,
|
||||
lineItems: allLineItems,
|
||||
});
|
||||
|
||||
if (!paid) return;
|
||||
|
||||
// 5. Reset usage balances for all affected customer entitlements (only if payment succeeded)
|
||||
const cusEntIdsByProduct = groupCusEntIdsByProduct({ lineItemsWithSource });
|
||||
for (const [customerProduct, cusEntIds] of cusEntIdsByProduct) {
|
||||
await resetUsageBalances({
|
||||
db,
|
||||
cusEntIds,
|
||||
cusProduct: customerProduct,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Insert invoice into Autumn DB
|
||||
const fullProducts = eventContext.customerProducts.map((cp) =>
|
||||
cusProductToProduct({ cusProduct: cp }),
|
||||
);
|
||||
await upsertInvoiceFromBilling({
|
||||
ctx,
|
||||
stripeInvoice: invoice,
|
||||
fullProducts,
|
||||
fullCustomer,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups customer entitlement IDs by their source customer product.
|
||||
* Returns a Map for efficient iteration during balance reset.
|
||||
*/
|
||||
const groupCusEntIdsByProduct = ({
|
||||
lineItemsWithSource,
|
||||
}: {
|
||||
lineItemsWithSource: LineItemWithSource[];
|
||||
}): Map<FullCusProduct, string[]> => {
|
||||
const result = new Map<FullCusProduct, string[]>();
|
||||
|
||||
for (const { lineItem, customerProduct } of lineItemsWithSource) {
|
||||
const cusEnt = lineItemToCustomerEntitlement({
|
||||
lineItem,
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
if (!cusEnt) continue;
|
||||
|
||||
const existing = result.get(customerProduct) ?? [];
|
||||
existing.push(cusEnt.id);
|
||||
result.set(customerProduct, existing);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { isCustomerProductOnStripeSubscription } from "@autumn/shared";
|
||||
import {
|
||||
type FullCusProduct,
|
||||
isCustomerProductOnStripeSubscription,
|
||||
} from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
|
||||
import {
|
||||
@@ -13,6 +16,7 @@ import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptio
|
||||
* For each customer product on the deleted subscription:
|
||||
* 1. Expire the customer product and activate default if needed
|
||||
* 2. Delete any scheduled main customer product in the same group
|
||||
* 3. Cache expired products so invoice.created can access them
|
||||
*/
|
||||
export const expireAndActivateCustomerProducts = async ({
|
||||
ctx,
|
||||
@@ -28,6 +32,7 @@ export const expireAndActivateCustomerProducts = async ({
|
||||
`[sub.deleted] Processing ${customerProducts.length} customer products for subscription ${stripeSubscription.id}`,
|
||||
);
|
||||
|
||||
const expiredCustomerProducts: FullCusProduct[] = [];
|
||||
for (const customerProduct of customerProducts) {
|
||||
// 1. If not on stripe subscription, skip
|
||||
const onStripeSubscription = isCustomerProductOnStripeSubscription({
|
||||
@@ -44,6 +49,8 @@ export const expireAndActivateCustomerProducts = async ({
|
||||
fullCustomer,
|
||||
});
|
||||
|
||||
expiredCustomerProducts.push(customerProduct);
|
||||
|
||||
trackCustomerProductUpdate({
|
||||
eventContext,
|
||||
customerProduct,
|
||||
@@ -65,4 +72,13 @@ export const expireAndActivateCustomerProducts = async ({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Need to cache expired customer products to invoice.created can access them
|
||||
* invoice.created creates a final invoice for usage-based prices
|
||||
*/
|
||||
await customerProductActions.expiredCache.set({
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
customerProducts: expiredCustomerProducts,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { customerProductsToProducts, secondsToMs } from "@autumn/shared";
|
||||
import { stripeSubscriptionHasMeteredItems } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import { eventContextToArrearLineItems } from "@/external/stripe/webhookHandlers/common";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { lineItemsToInvoiceAddLinesParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext";
|
||||
|
||||
/**
|
||||
* Creates a single invoice for all usage-based (arrear) prices across all customer products
|
||||
* when a subscription is deleted.
|
||||
* Skips if the deletion was initiated by Autumn (e.g., during an upgrade flow).
|
||||
*/
|
||||
export const processConsumablePricesForSubscriptionDeleted = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: StripeSubscriptionDeletedContext;
|
||||
}): Promise<void> => {
|
||||
const { db } = ctx;
|
||||
const { stripeSubscription, fullCustomer, customerProducts } = eventContext;
|
||||
|
||||
// Check upcoming invoice
|
||||
if (stripeSubscriptionHasMeteredItems(stripeSubscription)) return;
|
||||
|
||||
// 1. Generate arrear line items
|
||||
// Use ended_at (when subscription was actually deleted) as the period end.
|
||||
// This handles mid-cycle cancellations correctly - we bill up to when they canceled,
|
||||
// not up to when the cycle would have ended.
|
||||
// Falls back to nowMs if ended_at is not available.
|
||||
const { lineItems, updateCustomerEntitlements, billingContext } =
|
||||
eventContextToArrearLineItems({
|
||||
ctx,
|
||||
eventContext,
|
||||
periodEndMs: stripeSubscription.ended_at
|
||||
? secondsToMs(stripeSubscription.ended_at)
|
||||
: undefined,
|
||||
// No cusEntFilter - bill all consumable entitlements on cancellation
|
||||
});
|
||||
|
||||
if (lineItems.length === 0) return;
|
||||
|
||||
// 2. Create, finalize, and pay a single invoice with all line items
|
||||
const invoiceLines = lineItemsToInvoiceAddLinesParams({ lineItems });
|
||||
|
||||
const { paid, invoice } = await createInvoiceForBilling({
|
||||
ctx,
|
||||
billingContext,
|
||||
stripeInvoiceAction: {
|
||||
addLineParams: { lines: invoiceLines },
|
||||
},
|
||||
});
|
||||
|
||||
if (!paid) return;
|
||||
|
||||
// 4. Reset usage balances for all affected customer entitlements (only if payment succeeded)
|
||||
await CusEntService.batchUpdate({
|
||||
db,
|
||||
data: updateCustomerEntitlements,
|
||||
});
|
||||
|
||||
await upsertInvoiceFromBilling({
|
||||
ctx,
|
||||
stripeInvoice: invoice,
|
||||
fullProducts: customerProductsToProducts({ customerProducts }),
|
||||
fullCustomer,
|
||||
});
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type FullCustomer, secondsToMs } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
|
||||
/**
|
||||
* Builds a minimal BillingContext from webhook context data.
|
||||
* Used for generating arrear line items when a subscription is deleted.
|
||||
*/
|
||||
export const buildBillingContextFromWebhook = ({
|
||||
stripeSubscription,
|
||||
fullCustomer,
|
||||
nowMs,
|
||||
paymentMethod,
|
||||
}: {
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
fullCustomer: FullCustomer;
|
||||
nowMs: number;
|
||||
paymentMethod?: Stripe.PaymentMethod | null;
|
||||
}): BillingContext => {
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
featureQuantities: [],
|
||||
|
||||
currentEpochMs: nowMs,
|
||||
billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
resetCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
|
||||
stripeCustomer: stripeSubscription.customer,
|
||||
stripeSubscription,
|
||||
paymentMethod: paymentMethod ?? undefined,
|
||||
};
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const setupStripeSubscriptionUpdatedContext = async ({
|
||||
subscriptionId: event.data.object.id,
|
||||
});
|
||||
|
||||
const previousAttributes = event.data.previous_attributes;
|
||||
const previousAttributes = event.data.previous_attributes ?? {};
|
||||
|
||||
// Get current time (respecting test clocks)
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface SubscriptionPreviousAttributes {
|
||||
|
||||
export interface StripeSubscriptionUpdatedContext {
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
previousAttributes?: SubscriptionPreviousAttributes;
|
||||
previousAttributes: SubscriptionPreviousAttributes;
|
||||
fullCustomer: FullCustomer;
|
||||
/** Mutable list of customer products - can be updated in place by tasks */
|
||||
customerProducts: FullCusProduct[];
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
} from "../../../stripeInvoiceUtils.js";
|
||||
import { lineItemInCusProduct } from "../../../stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { getStripeSubs } from "../../../stripeSubUtils.js";
|
||||
import { handleInvoicePaidMetadata } from "./handleInvoicePaidMetadata.js";
|
||||
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
|
||||
import { handleInvoicePaidMetadata } from "./handleInvoicePaidMetadata.js";
|
||||
|
||||
const handleOneOffInvoicePaid = async ({
|
||||
db,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { MetadataType } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { executeDeferredBillingPlan } from "@/internal/billing/v2/execute/executeDeferredBillingPlan";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
|
||||
import type { AttachParams } from "../../../../../internal/customers/cusProducts/AttachParams.js";
|
||||
import { MetadataService } from "../../../../../internal/metadata/MetadataService.js";
|
||||
import { executeDeferredBillingPlan } from "@/internal/billing/v2/execute/executeDeferredBillingPlan";
|
||||
import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted";
|
||||
import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid";
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type Stripe from "stripe";
|
||||
import { customerProductActions } from "@/internal/customers/cusProducts/actions/index.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
|
||||
import {
|
||||
getFullStripeSub,
|
||||
subIsPrematurelyCanceled,
|
||||
} from "../stripeSubUtils.js";
|
||||
} from "@/external/stripe/stripeSubUtils.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { handleCusProductDeleted } from "./handleSubDeleted/handleCusProductDeleted.js";
|
||||
|
||||
export const handleSubDeleted = async ({
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { webhookToAttachParams } from "@/external/stripe/webhookUtils/webhookUtils.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
|
||||
import { createUsageInvoice } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
@@ -17,9 +20,6 @@ import {
|
||||
activateDefaultProduct,
|
||||
activateFutureProduct,
|
||||
} from "@/internal/customers/cusProducts/cusProductUtils.js";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||
import { getCusPaymentMethod } from "../../stripeCusUtils.js";
|
||||
import { webhookToAttachParams } from "../../webhookUtils/webhookUtils.js";
|
||||
|
||||
export const handleCusProductDeleted = async ({
|
||||
ctx,
|
||||
|
||||
@@ -2,15 +2,14 @@ import {
|
||||
AppEnv,
|
||||
type Customer,
|
||||
cusProductToProduct,
|
||||
InternalError,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripeCustomer } from "@/external/stripe/customers";
|
||||
import { createCustomStripeCard } from "@/external/stripe/stripeCardUtils.js";
|
||||
import { createStripeCustomer } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { customerActions } from "@/internal/customers/actions/index.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { handleCreateCustomer } from "@/internal/customers/handlers/handleCreateCustomer.js";
|
||||
import {
|
||||
AuthError,
|
||||
getAuthorizationToken,
|
||||
@@ -45,10 +44,10 @@ export const handleUpsertInstallation = createRoute({
|
||||
throw new AuthError("Invalid claims");
|
||||
}
|
||||
|
||||
createdCustomer = await handleCreateCustomer({
|
||||
createdCustomer = await customerActions.createWithDefaults({
|
||||
ctx,
|
||||
cusData: {
|
||||
id: integrationConfigurationId,
|
||||
customerId: integrationConfigurationId,
|
||||
customerData: {
|
||||
email: body.account.contact.email,
|
||||
name: body.account.contact.name,
|
||||
processors: {
|
||||
@@ -61,16 +60,10 @@ export const handleUpsertInstallation = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdCustomer) {
|
||||
throw new InternalError({
|
||||
message: "Failed to create customer",
|
||||
});
|
||||
}
|
||||
|
||||
// Create test clock for sandbox/development environments
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
let testClockId: string | undefined;
|
||||
if (ctx.env === AppEnv.Sandbox) {
|
||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||
const testClock = await stripeCli.testHelpers.testClocks.create({
|
||||
frozen_time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
@@ -78,10 +71,13 @@ export const handleUpsertInstallation = createRoute({
|
||||
}
|
||||
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
ctx,
|
||||
customer: createdCustomer,
|
||||
testClockId,
|
||||
options: { testClockId },
|
||||
});
|
||||
|
||||
// Add vercel-specific metadata
|
||||
await stripeCli.customers.update(stripeCustomer.id, {
|
||||
metadata: {
|
||||
vercel_installation_id: integrationConfigurationId,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
addAppContextToLogs,
|
||||
addExtrasToLogs,
|
||||
} from "@/utils/logging/addContextToLogs";
|
||||
import { maskExtraLogs } from "@/utils/logging/maskExtraLogs.js";
|
||||
|
||||
export const parseCustomerIdFromUrl = ({
|
||||
url,
|
||||
@@ -115,8 +116,12 @@ const logResponse = async ({
|
||||
res: responseBody,
|
||||
});
|
||||
|
||||
if (Object.keys(ctx.extraLogs).length > 0) {
|
||||
ctx.logger.debug(`EXTRA LOGS:`, JSON.stringify(ctx.extraLogs, null, 2));
|
||||
if (
|
||||
Object.keys(ctx.extraLogs).length > 0 &&
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
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 +166,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);
|
||||
|
||||
@@ -72,8 +72,12 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
skipCache: false,
|
||||
|
||||
// Test params:
|
||||
skipCacheDeletion: c.req.header("x-skip-cache-deletion") === "true",
|
||||
extraLogs: {},
|
||||
|
||||
testOptions: {
|
||||
skipCacheDeletion: c.req.header("x-skip-cache-deletion") === "true",
|
||||
skipWebhooks: c.req.header("x-skip-webhooks") === "true",
|
||||
},
|
||||
});
|
||||
|
||||
// childLogger.info(`${method} ${path}`);
|
||||
|
||||
@@ -78,9 +78,8 @@ export const refreshCacheMiddleware = async (
|
||||
if (c.res.status < 200 || c.res.status >= 300) return;
|
||||
|
||||
const ctx = c.get("ctx");
|
||||
const { skipCacheDeletion } = ctx;
|
||||
|
||||
if (skipCacheDeletion) return;
|
||||
if (ctx.testOptions?.skipCacheDeletion) return;
|
||||
|
||||
const pathname = new URL(c.req.url).pathname.replace("/v1", "");
|
||||
const method = c.req.method;
|
||||
@@ -90,7 +89,7 @@ export const refreshCacheMiddleware = async (
|
||||
matchRoute({ url: pathname, method, pattern }),
|
||||
);
|
||||
|
||||
if (pathMatch && !skipCacheDeletion) {
|
||||
if (pathMatch) {
|
||||
const customerId = c.req.param("customer_id");
|
||||
if (customerId) {
|
||||
await deleteCachedFullCustomer({
|
||||
|
||||
@@ -36,10 +36,12 @@ export type RequestContext = {
|
||||
expand: string[];
|
||||
skipCache: boolean;
|
||||
|
||||
// For test...
|
||||
skipCacheDeletion?: boolean;
|
||||
|
||||
extraLogs: Record<string, unknown>;
|
||||
|
||||
testOptions?: {
|
||||
skipCacheDeletion?: boolean;
|
||||
skipWebhooks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AutumnContext = RequestContext;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fullCustomerToCustomerEntitlements,
|
||||
type EntityBalance,
|
||||
type EntityRolloverBalance,
|
||||
type FullCustomer,
|
||||
findCustomerEntitlementById,
|
||||
fullCustomerToCustomerEntitlements,
|
||||
tryCatch,
|
||||
} from "@autumn/shared";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SetupPaymentParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { getOrCreateStripeCustomer } from "@/external/stripe/customers";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
@@ -23,15 +23,12 @@ export const handleSetupPayment = createRoute({
|
||||
const customer = await getOrCreateCustomer({
|
||||
ctx,
|
||||
customerId: customer_id,
|
||||
customerData: customer_data as any,
|
||||
customerData: customer_data,
|
||||
});
|
||||
|
||||
await createStripeCusIfNotExists({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
await getOrCreateStripeCustomer({
|
||||
ctx,
|
||||
customer,
|
||||
logger,
|
||||
});
|
||||
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface TrialContext {
|
||||
trialEndsAt: number | null;
|
||||
customFreeTrial?: FreeTrial;
|
||||
appliesToBilling: boolean;
|
||||
cardRequired: boolean;
|
||||
}
|
||||
|
||||
export interface BillingContext {
|
||||
@@ -61,5 +62,3 @@ export interface UpdateSubscriptionBillingContext extends BillingContext {
|
||||
defaultProduct?: FullProduct; // for cancel flows
|
||||
cancelMode?: CancelMode; // for cancel flows
|
||||
}
|
||||
|
||||
// testClockFrozenTime?: number;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const updateCustomerEntitlements = async ({
|
||||
const { db, logger } = ctx;
|
||||
|
||||
for (const updateDetail of updates ?? []) {
|
||||
const { balanceChange, customerEntitlement } = updateDetail;
|
||||
const { balanceChange = 0, customerEntitlement } = updateDetail;
|
||||
|
||||
logger.debug(
|
||||
`updating customer entitlement ${customerEntitlement.id} by ${balanceChange}`,
|
||||
|
||||
@@ -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({
|
||||
@@ -45,9 +45,9 @@ export const executeAutumnBillingPlan = async ({
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.debug(
|
||||
`[execAutumnPlan] inserting new customer products: ${insertCustomerProducts.map((cp) => cp.product.id).join(", ")}`,
|
||||
);
|
||||
// ctx.logger.debug(
|
||||
// `[execAutumnPlan] inserting new customer products: ${insertCustomerProducts.map((cp) => cp.product.id).join(", ")}`,
|
||||
// );
|
||||
// 2. Insert new customer products
|
||||
await insertNewCusProducts({
|
||||
ctx,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeA
|
||||
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { BillingResult } from "@/internal/billing/v2/types/billingResult";
|
||||
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated";
|
||||
|
||||
export const executeBillingPlan = async ({
|
||||
ctx,
|
||||
@@ -20,8 +21,6 @@ export const executeBillingPlan = async ({
|
||||
billingContext,
|
||||
});
|
||||
|
||||
// console.log("stripeBillingResult", stripeBillingResult);
|
||||
|
||||
if (stripeBillingResult.deferred)
|
||||
return {
|
||||
stripe: stripeBillingResult,
|
||||
@@ -32,5 +31,12 @@ export const executeBillingPlan = async ({
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
});
|
||||
|
||||
// Queue webhooks after Autumn billing plan is executed
|
||||
await billingPlanToSendProductsUpdated({
|
||||
ctx,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
return { stripe: stripeBillingResult };
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export const evaluateStripeBillingPlan = async ({
|
||||
|
||||
let stripeInvoiceAction: StripeInvoiceAction | undefined;
|
||||
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
|
||||
if (createManualInvoice) {
|
||||
if (createManualInvoice && lineItems) {
|
||||
stripeInvoiceAction = buildStripeInvoiceAction({
|
||||
lineItems,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FullCustomer } from "@autumn/shared";
|
||||
import { createStripeCli } from "@server/external/connect/createStripeCli";
|
||||
import {
|
||||
createStripeCusIfNotExists,
|
||||
listCusPaymentMethods,
|
||||
} from "@server/external/stripe/stripeCusUtils";
|
||||
import { getOrCreateStripeCustomer } from "@server/external/stripe/customers";
|
||||
import { listCusPaymentMethods } from "@server/external/stripe/stripeCusUtils";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
@@ -14,15 +12,12 @@ export const fetchStripeCustomerForBilling = async ({
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
}) => {
|
||||
const { logger, db, org, env } = ctx;
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const stripeCus = await createStripeCusIfNotExists({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
const stripeCus = await getOrCreateStripeCustomer({
|
||||
ctx,
|
||||
customer: fullCus,
|
||||
logger,
|
||||
});
|
||||
|
||||
const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FullCusProduct, type FullCustomer } from "@autumn/shared";
|
||||
import type { FullCusProduct, FullCustomer } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
|
||||
import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling";
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type {
|
||||
StripeCustomerWithDiscount,
|
||||
StripeSubscriptionWithDiscounts,
|
||||
} from "@/external/stripe/subscriptions";
|
||||
import { subToDiscounts } from "../utils/discounts/subToDiscounts";
|
||||
|
||||
/**
|
||||
* Extracts discounts from already-fetched Stripe subscription or customer.
|
||||
* Subscription discounts take priority over customer discounts.
|
||||
*
|
||||
* TODO: Investigate if customer discount expand path should be
|
||||
* "discount.source.coupon.applies_to" instead of "discount.coupon.applies_to"
|
||||
*/
|
||||
export const setupStripeDiscountsForBilling = ({
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
}: {
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
stripeCustomer: Stripe.Customer;
|
||||
stripeSubscription?: StripeSubscriptionWithDiscounts;
|
||||
stripeCustomer: StripeCustomerWithDiscount;
|
||||
}): StripeDiscountWithCoupon[] => {
|
||||
const subscriptionDiscounts = subToDiscounts({ sub: stripeSubscription });
|
||||
|
||||
@@ -22,12 +28,16 @@ export const setupStripeDiscountsForBilling = ({
|
||||
const customerDiscount = stripeCustomer.discount;
|
||||
if (!customerDiscount) return [];
|
||||
|
||||
const coupon = customerDiscount.source?.coupon;
|
||||
const coupon = customerDiscount.coupon;
|
||||
if (!coupon || typeof coupon === "string") return [];
|
||||
|
||||
// Normalize to StripeDiscountWithCoupon format
|
||||
return [{
|
||||
...customerDiscount,
|
||||
source: { coupon },
|
||||
} as StripeDiscountWithCoupon];
|
||||
// Extract the coupon and put it under source.coupon
|
||||
const { coupon: _coupon, ...discountWithoutCoupon } = customerDiscount;
|
||||
return [
|
||||
{
|
||||
...discountWithoutCoupon,
|
||||
source: { coupon },
|
||||
} as StripeDiscountWithCoupon,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -7,10 +7,12 @@ import type Stripe from "stripe";
|
||||
const toStripeCreateInvoiceItemParams = ({
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
stripeInvoiceId,
|
||||
lineItem,
|
||||
}: {
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
stripeInvoiceId?: string;
|
||||
lineItem: LineItem;
|
||||
}): Stripe.InvoiceItemCreateParams => {
|
||||
const { finalAmount, description, context } = lineItem;
|
||||
@@ -19,6 +21,7 @@ const toStripeCreateInvoiceItemParams = ({
|
||||
return {
|
||||
customer: stripeCustomerId,
|
||||
subscription: stripeSubscriptionId,
|
||||
invoice: stripeInvoiceId,
|
||||
amount: atmnToStripeAmount({ amount: finalAmount }),
|
||||
currency,
|
||||
description,
|
||||
@@ -37,16 +40,19 @@ const toStripeCreateInvoiceItemParams = ({
|
||||
export const lineItemsToCreateInvoiceItemsParams = ({
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
stripeInvoiceId,
|
||||
lineItems,
|
||||
}: {
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
stripeInvoiceId?: string;
|
||||
lineItems: LineItem[];
|
||||
}): Stripe.InvoiceItemCreateParams[] => {
|
||||
return lineItems.map((lineItem) =>
|
||||
toStripeCreateInvoiceItemParams({
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
stripeInvoiceId,
|
||||
lineItem,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -19,9 +19,8 @@ export const buildStripeSubscriptionCreateAction = ({
|
||||
const { stripeCustomer, paymentMethod, trialContext } = billingContext;
|
||||
|
||||
const trialEndsAt = trialContext?.trialEndsAt;
|
||||
const freeTrial = trialContext?.freeTrial;
|
||||
|
||||
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
|
||||
const isFreeTrialWithCardRequired = trialContext?.cardRequired;
|
||||
const isCustomPaymentMethod = paymentMethod?.type === "custom";
|
||||
|
||||
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const setupTrialContext = ({
|
||||
freeTrial: null,
|
||||
trialEndsAt: null,
|
||||
appliesToBilling: newProductIsPaidRecurring,
|
||||
cardRequired: true,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
@@ -67,6 +68,7 @@ export const setupTrialContext = ({
|
||||
trialEndsAt,
|
||||
customFreeTrial: dbFreeTrial,
|
||||
appliesToBilling: newProductIsPaidRecurring,
|
||||
cardRequired: dbFreeTrial.card_required,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +86,7 @@ export const setupTrialContext = ({
|
||||
freeTrial: null,
|
||||
trialEndsAt: trialEndsAt,
|
||||
appliesToBilling: newProductIsPaidRecurring,
|
||||
cardRequired: true,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
@@ -96,6 +99,7 @@ export const setupTrialContext = ({
|
||||
freeTrial: customerProduct.free_trial, // can be undefined...
|
||||
trialEndsAt: customerProduct.trial_ends_at ?? null,
|
||||
appliesToBilling: false,
|
||||
cardRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
EntitlementSchema,
|
||||
EntityBalanceSchema,
|
||||
FeatureOptionsSchema,
|
||||
FreeTrialSchema,
|
||||
FullCusProductSchema,
|
||||
@@ -15,30 +16,42 @@ import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
|
||||
export const UpdateCustomerEntitlementSchema = z.object({
|
||||
customerEntitlement: FullCustomerEntitlementSchema,
|
||||
balanceChange: z.number(),
|
||||
balanceChange: z.number().optional(),
|
||||
|
||||
// For arrear billing:
|
||||
updates: z
|
||||
.object({
|
||||
next_reset_at: z.number().optional(),
|
||||
adjustment: z.number().optional(),
|
||||
entities: z.record(z.string(), EntityBalanceSchema).optional(),
|
||||
balance: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
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(),
|
||||
// Cancel fields (nullish to support uncancel - setting to null)
|
||||
canceled: z.boolean().nullish(),
|
||||
canceled_at: z.number().nullish(),
|
||||
ended_at: z.number().nullish(),
|
||||
}),
|
||||
}),
|
||||
updateCustomerProduct: z
|
||||
.object({
|
||||
customerProduct: FullCusProductSchema,
|
||||
updates: z.object({
|
||||
options: z.array(FeatureOptionsSchema).optional(),
|
||||
status: z.enum(CusProductStatus).optional(),
|
||||
// Cancel fields (nullish to support uncancel - setting to null)
|
||||
canceled: z.boolean().nullish(),
|
||||
canceled_at: z.number().nullish(),
|
||||
ended_at: z.number().nullish(),
|
||||
}),
|
||||
})
|
||||
.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)
|
||||
@@ -47,6 +60,10 @@ export const AutumnBillingPlanSchema = z.object({
|
||||
|
||||
export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
|
||||
|
||||
export type UpdateCustomerEntitlement = z.infer<
|
||||
typeof UpdateCustomerEntitlementSchema
|
||||
>;
|
||||
|
||||
export enum StripeBillingStage {
|
||||
InvoiceAction = "invoice_action",
|
||||
SubscriptionAction = "subscription_action",
|
||||
|
||||
@@ -15,12 +15,14 @@ export const applyCancelPlan = ({
|
||||
defaultCustomerProduct,
|
||||
productToDelete,
|
||||
cancelLineItems,
|
||||
existingCustomerProduct,
|
||||
}: {
|
||||
plan: AutumnBillingPlan;
|
||||
cancelUpdates: CancelUpdates;
|
||||
defaultCustomerProduct: FullCusProduct | undefined;
|
||||
productToDelete: FullCusProduct | undefined;
|
||||
cancelLineItems: LineItem[];
|
||||
existingCustomerProduct: FullCusProduct;
|
||||
}): AutumnBillingPlan => {
|
||||
// If we're inserting new customer products (custom plan), update THEM with cancel fields
|
||||
if (plan.insertCustomerProducts.length > 0) {
|
||||
@@ -35,12 +37,17 @@ export const applyCancelPlan = ({
|
||||
);
|
||||
} else {
|
||||
// Otherwise, update the existing customer product
|
||||
plan.updateCustomerProduct.updates = {
|
||||
...plan.updateCustomerProduct.updates,
|
||||
canceled: cancelUpdates.canceled,
|
||||
canceled_at: cancelUpdates.canceled_at,
|
||||
ended_at: cancelUpdates.ended_at,
|
||||
...(cancelUpdates.status && { status: cancelUpdates.status }),
|
||||
plan.updateCustomerProduct = {
|
||||
customerProduct:
|
||||
plan.updateCustomerProduct?.customerProduct ?? existingCustomerProduct,
|
||||
|
||||
updates: {
|
||||
...plan.updateCustomerProduct?.updates,
|
||||
canceled: cancelUpdates.canceled,
|
||||
canceled_at: cancelUpdates.canceled_at,
|
||||
ended_at: cancelUpdates.ended_at,
|
||||
...(cancelUpdates.status && { status: cancelUpdates.status }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +63,7 @@ export const applyCancelPlan = ({
|
||||
|
||||
// Merge cancel line items (prorated refunds for immediate cancellation)
|
||||
if (cancelLineItems.length > 0) {
|
||||
plan.lineItems = [...plan.lineItems, ...cancelLineItems];
|
||||
plan.lineItems = [...(plan.lineItems ?? []), ...cancelLineItems];
|
||||
}
|
||||
|
||||
return plan;
|
||||
|
||||
@@ -60,5 +60,6 @@ export const computeCancelPlan = ({
|
||||
defaultCustomerProduct,
|
||||
productToDelete,
|
||||
cancelLineItems,
|
||||
existingCustomerProduct: billingContext.customerProduct,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const finalizeUpdateSubscriptionPlan = ({
|
||||
// Filter line items based on trial state transitions
|
||||
plan.lineItems = filterLineItemsForTrialTransition({
|
||||
ctx,
|
||||
lineItems: plan.lineItems,
|
||||
lineItems: plan.lineItems ?? [],
|
||||
billingContext,
|
||||
});
|
||||
|
||||
|
||||
@@ -93,15 +93,19 @@ export const computeUpdateQuantityLineItems = ({
|
||||
...lineItemContext,
|
||||
direction: "refund",
|
||||
},
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
options: {
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
},
|
||||
});
|
||||
|
||||
const chargeLineItem = usagePriceToLineItem({
|
||||
cusEnt: newCustomerEntitlement,
|
||||
context: lineItemContext,
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
options: {
|
||||
shouldProrateOverride: shouldApplyProration,
|
||||
chargeImmediatelyOverride: chargeImmediately,
|
||||
},
|
||||
});
|
||||
|
||||
// Don't return line items if they sum to 0
|
||||
|
||||
@@ -69,6 +69,7 @@ export const handleUpdateSubscription = createRoute({
|
||||
stripe: stripeBillingPlan,
|
||||
},
|
||||
});
|
||||
|
||||
logStripeBillingResult({ ctx, result: billingResult.stripe });
|
||||
|
||||
const response = billingResultToResponse({
|
||||
|
||||
@@ -49,13 +49,14 @@ export const logUpdateSubscriptionPlan = ({
|
||||
plan.updateCustomerEntitlements
|
||||
?.map(
|
||||
(update) =>
|
||||
`${update.customerEntitlement.feature_id}: ${update.balanceChange > 0 ? "+" : ""}${update.balanceChange}`,
|
||||
`${update.customerEntitlement.feature_id}: ${(update.balanceChange ?? 0) > 0 ? "+" : ""}${update.balanceChange}`,
|
||||
)
|
||||
.join(", ") || "none",
|
||||
|
||||
lineItems: plan.lineItems.map(
|
||||
(item) => `${item.description}: ${item.finalAmount}`,
|
||||
),
|
||||
lineItems:
|
||||
plan.lineItems?.map(
|
||||
(item) => `${item.description}: ${item.finalAmount}`,
|
||||
) ?? "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +52,8 @@ export const autumnBillingPlanToFinalFullCustomer = ({
|
||||
for (const update of updateCustomerEntitlements) {
|
||||
const entitlement = entitlementById.get(update.customerEntitlement.id);
|
||||
if (entitlement) {
|
||||
entitlement.balance = (entitlement.balance ?? 0) + update.balanceChange;
|
||||
entitlement.balance =
|
||||
(entitlement.balance ?? 0) + (update.balanceChange ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ export const billingPlanToPreviewResponse = ({
|
||||
const { fullCustomer } = billingContext;
|
||||
|
||||
const autumnBillingPlan = billingPlan.autumn;
|
||||
const planLineItems = autumnBillingPlan.lineItems ?? [];
|
||||
|
||||
|
||||
const previewImmediateLineItems = autumnBillingPlan.lineItems.filter((line) => line.chargeImmediately).map((line) => ({
|
||||
description: line.description,
|
||||
amount: line.finalAmount,
|
||||
}));
|
||||
|
||||
const previewImmediateLineItems = planLineItems
|
||||
.filter((line) => line.chargeImmediately)
|
||||
.map((line) => ({
|
||||
description: line.description,
|
||||
amount: line.finalAmount,
|
||||
}));
|
||||
|
||||
const total = new Decimal(
|
||||
sumValues(previewImmediateLineItems.map((line) => line.amount)),
|
||||
|
||||
@@ -32,7 +32,6 @@ const logExistingUsages = ({
|
||||
};
|
||||
},
|
||||
);
|
||||
ctx.logger.debug(`[applyExistingUsages] existing usages:`, existinUsagesLogs);
|
||||
|
||||
addToExtraLogs({
|
||||
ctx,
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BillingType,
|
||||
cusPriceToCusEntWithCusProduct,
|
||||
cusProductToPrices,
|
||||
EntInterval,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullCusProduct,
|
||||
getCycleEnd,
|
||||
isConsumablePrice,
|
||||
isV4Usage,
|
||||
type LineItem,
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
usagePriceToLineItem,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateCustomerEntitlement } from "@/internal/billing/v2/types/autumnBillingPlan";
|
||||
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils";
|
||||
import type { BillingContext } from "../../billingContext";
|
||||
import { getLineItemBillingPeriod } from "./getLineItemBillingPeriod";
|
||||
|
||||
@@ -19,15 +23,23 @@ export const customerProductToArrearLineItems = ({
|
||||
customerProduct,
|
||||
billingContext,
|
||||
filters,
|
||||
updateNextResetAt,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerProduct: FullCusProduct;
|
||||
billingContext: BillingContext;
|
||||
filters: {
|
||||
onlyV4Usage?: boolean;
|
||||
/** Optional filter to skip specific entitlements (e.g., for multi-interval billing) */
|
||||
cusEntFilter?: (cusEnt: FullCusEntWithFullCusProduct) => boolean;
|
||||
};
|
||||
}) => {
|
||||
let lineItems: LineItem[] = [];
|
||||
updateNextResetAt: boolean;
|
||||
}): {
|
||||
lineItems: LineItem[];
|
||||
updateCustomerEntitlements: UpdateCustomerEntitlement[];
|
||||
} => {
|
||||
const lineItems: LineItem[] = [];
|
||||
const billedCusEnts: FullCusEntWithFullCusProduct[] = [];
|
||||
|
||||
let filteredPrices = cusProductToPrices({ cusProduct: customerProduct });
|
||||
|
||||
@@ -37,17 +49,13 @@ export const customerProductToArrearLineItems = ({
|
||||
);
|
||||
}
|
||||
|
||||
const updateCustomerEntitlements: UpdateCustomerEntitlement[] = [];
|
||||
|
||||
for (const cusPrice of customerProduct.customer_prices) {
|
||||
const price = cusPrice.price;
|
||||
|
||||
if (!isConsumablePrice(price)) continue;
|
||||
|
||||
// Calculate billing period
|
||||
const billingPeriod = getLineItemBillingPeriod({
|
||||
billingContext,
|
||||
price,
|
||||
});
|
||||
|
||||
const cusEnt = cusPriceToCusEntWithCusProduct({
|
||||
cusProduct: customerProduct,
|
||||
cusPrice,
|
||||
@@ -60,6 +68,15 @@ export const customerProductToArrearLineItems = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Apply optional filter (e.g., for multi-interval billing check)
|
||||
if (filters.cusEntFilter && !filters.cusEntFilter(cusEnt)) continue;
|
||||
|
||||
// Calculate billing period
|
||||
const billingPeriod = getLineItemBillingPeriod({
|
||||
billingContext,
|
||||
price,
|
||||
});
|
||||
|
||||
const context: LineItemContext = {
|
||||
price,
|
||||
product: customerProduct.product,
|
||||
@@ -72,10 +89,38 @@ export const customerProductToArrearLineItems = ({
|
||||
currency: orgToCurrency({ org: ctx.org }),
|
||||
};
|
||||
|
||||
lineItems.push(usagePriceToLineItem({ cusEnt, context }));
|
||||
const lineItem = usagePriceToLineItem({
|
||||
cusEnt,
|
||||
context,
|
||||
options: { includePeriodDescription: false },
|
||||
});
|
||||
|
||||
// Only include line items with non-zero amounts
|
||||
if (lineItem.amount !== 0) {
|
||||
lineItems.push(lineItem);
|
||||
}
|
||||
|
||||
// Update to make to customer entitlement.
|
||||
const resetBalancesUpdate = getResetBalancesUpdate({
|
||||
cusEnt,
|
||||
allowance: cusEnt.entitlement.allowance ?? 0,
|
||||
});
|
||||
|
||||
const nextResetAt = getCycleEnd({
|
||||
anchor: billingContext.billingCycleAnchorMs,
|
||||
interval: cusEnt.entitlement.interval ?? EntInterval.Month,
|
||||
intervalCount: cusEnt.entitlement.interval_count,
|
||||
now: billingPeriod?.end ?? billingContext.currentEpochMs,
|
||||
});
|
||||
|
||||
updateCustomerEntitlements.push({
|
||||
customerEntitlement: cusEnt,
|
||||
updates: {
|
||||
...resetBalancesUpdate,
|
||||
next_reset_at: updateNextResetAt ? nextResetAt : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
lineItems = lineItems.filter((item) => item.amount !== 0);
|
||||
|
||||
return lineItems;
|
||||
return { lineItems, updateCustomerEntitlements };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Converts an AutumnBillingPlan to sendProductsUpdated workflow triggers.
|
||||
* Derives scenario from product status.
|
||||
*/
|
||||
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan.js";
|
||||
import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext";
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
const deriveScenarioFromStatus = (status: string): string => {
|
||||
switch (status) {
|
||||
case CusProductStatus.Scheduled:
|
||||
return "scheduled";
|
||||
case CusProductStatus.Active:
|
||||
return "new";
|
||||
case CusProductStatus.Expired:
|
||||
return "expired";
|
||||
case CusProductStatus.PastDue:
|
||||
return "past_due";
|
||||
default:
|
||||
return "new";
|
||||
}
|
||||
};
|
||||
|
||||
export const billingPlanToSendProductsUpdated = async ({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
billingContext: BillingContext | CreateCustomerContext;
|
||||
}) => {
|
||||
// Skip webhooks if test option is set (used in integration tests)
|
||||
if (ctx.testOptions?.skipWebhooks) return;
|
||||
|
||||
const { fullCustomer } = billingContext;
|
||||
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const { insertCustomerProducts } = autumnBillingPlan;
|
||||
|
||||
// Queue for each inserted product
|
||||
for (const cusProduct of insertCustomerProducts) {
|
||||
const scenario = deriveScenarioFromStatus(cusProduct.status);
|
||||
|
||||
try {
|
||||
await workflows.triggerSendProductsUpdated({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
customerProductId: cusProduct.id,
|
||||
scenario,
|
||||
});
|
||||
|
||||
ctx.logger.info(
|
||||
`[billingPlanToSendProductsUpdated] Queued webhook for ${cusProduct.product.name}, scenario: ${scenario}`,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.logger.error(
|
||||
`[billingPlanToSendProductsUpdated] Failed to queue webhook for ${cusProduct.product.name}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Workflow: SendProductsUpdated
|
||||
*
|
||||
* Sends customer.products.updated webhook when billing plan executes.
|
||||
* Uses lean payload - fetches data from DB instead of receiving full objects.
|
||||
*/
|
||||
|
||||
import {
|
||||
AffectedResource,
|
||||
type ApiCustomer,
|
||||
type ApiEntityV1,
|
||||
type ApiPlan,
|
||||
ApiVersion,
|
||||
ApiVersionClass,
|
||||
addToExpand,
|
||||
applyResponseVersionChanges,
|
||||
CusExpand,
|
||||
type CustomerLegacyData,
|
||||
cusProductToProduct,
|
||||
type EntityLegacyData,
|
||||
enrichFullCustomerWithEntity,
|
||||
findCustomerProductById,
|
||||
InternalError,
|
||||
type PlanLegacyData,
|
||||
} from "@autumn/shared";
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
import { getApiEntityBase } from "@/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.js";
|
||||
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
|
||||
import type { SendProductsUpdatedPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const sendProductsUpdated = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: SendProductsUpdatedPayload;
|
||||
}) => {
|
||||
const { db, org, env, features } = ctx;
|
||||
const { customerProductId, scenario, customerId } = payload;
|
||||
|
||||
// Fetch FullCustomer
|
||||
const fullCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
allowNotFound: true,
|
||||
});
|
||||
|
||||
const customerProduct = findCustomerProductById({
|
||||
fullCustomer,
|
||||
customerProductId,
|
||||
});
|
||||
|
||||
if (!fullCustomer) {
|
||||
throw new InternalError({
|
||||
message: `[sendProductsUpdated] Customer ${customerId ?? ""} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!customerProduct) {
|
||||
throw new InternalError({
|
||||
message: `[sendProductsUpdated] Customer product ${customerProductId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const fullProduct = cusProductToProduct({ cusProduct: customerProduct });
|
||||
|
||||
enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId: customerProduct.internal_entity_id ?? "",
|
||||
});
|
||||
|
||||
ctx.apiVersion = new ApiVersionClass(ApiVersion.V1_2);
|
||||
|
||||
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
|
||||
ctx = addToExpand({
|
||||
ctx,
|
||||
add: [
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const { apiCustomer, legacyData: cusLegacyData } = await getApiCustomerBase({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
const versionedCustomer = applyResponseVersionChanges<
|
||||
ApiCustomer,
|
||||
CustomerLegacyData
|
||||
>({
|
||||
input: apiCustomer,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Customer,
|
||||
legacyData: cusLegacyData,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const apiPlan = await getPlanResponse({
|
||||
product: fullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
const versionedPlan = applyResponseVersionChanges<ApiPlan, PlanLegacyData>({
|
||||
input: apiPlan,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Product,
|
||||
legacyData: {
|
||||
features: ctx.features,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
|
||||
let entity: unknown | undefined;
|
||||
if (fullCustomer.entity) {
|
||||
const { apiEntity, legacyData } = await getApiEntityBase({
|
||||
ctx,
|
||||
entity: fullCustomer.entity,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
|
||||
input: apiEntity,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Entity,
|
||||
legacyData,
|
||||
ctx,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(
|
||||
`[sendProductsUpdated] Sending webhook for customer ${customerId}, product ${fullProduct.name}, scenario: ${scenario}`,
|
||||
);
|
||||
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
eventType: "customer.products.updated",
|
||||
data: {
|
||||
scenario,
|
||||
customer: versionedCustomer,
|
||||
entity,
|
||||
updated_product: versionedPlan,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
cusProductsToCusEnts,
|
||||
type FullCustomer,
|
||||
isBooleanCusEnt,
|
||||
isContUseFeature,
|
||||
isUnlimitedCusEnt,
|
||||
} from "@autumn/shared";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { FullCustomer } from "../../../../../shared/models/cusModels/fullCusModel";
|
||||
import { getSentryTags } from "../../../external/sentry/sentryUtils";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
|
||||
import { getApiCustomerBase } from "../../../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase";
|
||||
import type { VerifyCacheInput } from "./verifyCacheConsistencyWorkflow";
|
||||
import { getSentryTags } from "@/external/sentry/sentryUtils.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
import type { VerifyCacheInput } from "./verifyCacheConsistency.js";
|
||||
|
||||
export const checkForMisingBalance = async ({
|
||||
ctx,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user