merged with dev

This commit is contained in:
John Yeo
2026-01-28 09:47:19 +00:00
1025 changed files with 49732 additions and 24518 deletions

View File

@@ -1,15 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "npx ultracite fix"
}
]
}
]
}
}

View 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

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

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

View File

@@ -13,6 +13,11 @@ Write integration tests for the Autumn billing system using the `initScenario` p
## Before Writing Any Test
**ALWAYS check for duplicate test scenarios FIRST:**
1. Search the test directory for similar scenarios using `Grep` with relevant keywords (e.g., `new_billing_subscription`, `cancel.*addon`, feature names)
2. If a duplicate or very similar scenario exists, **WARN the user and ask for confirmation** before proceeding
3. Only proceed with writing the test after confirming it's not a duplicate
**ALWAYS read these codebase files FIRST:**
1. `server/tests/TEST_GUIDE.md` - Core patterns, fixtures, scenario builder
2. For billing tests: `server/tests/integration/billing/update-subscription/BILLING_GUIDE.md`
@@ -20,17 +25,29 @@ Write integration tests for the Autumn billing system using the `initScenario` p
## Critical Rules
**DO:**
- Use `test.concurrent()` for isolated, parallel tests
- **ALWAYS use `test.concurrent()` for ALL tests** - never use plain `test()`. This enables parallel execution.
- Use `initScenario` with `s.*` builders
- 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>()`
- **USE UTILITY FUNCTIONS WHENEVER POSSIBLE** - the shorter the code, the better. Check `server/tests/integration/billing/utils/` for existing utilities like `expectCustomerProducts`, `expectProductScheduled`, `expectCustomerInvoiceCorrect`, etc.
**DON'T:**
- Use plain `test()` - **ALWAYS use `test.concurrent()`**
- 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
- Write manual assertion loops when a utility function exists
## AutumnInt Response Types
| Client | customers.get | entities.get | check |
|--------|---------------|--------------|-------|
| `autumnV1` | `ApiCustomerV3` | `ApiEntityV0` | `CheckResponseV1` |
| `autumnV2` | `ApiCustomer` | `ApiEntityV1` | `CheckResponseV2` |
## Minimal Template
@@ -69,6 +86,8 @@ 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
- [references/STRIPE-BEHAVIORS.md](references/STRIPE-BEHAVIORS.md) - Stripe webhook behaviors for consumables, trials, cancellations
## File Location

View File

@@ -62,6 +62,52 @@ await autumnV1.track({
- **Entity Products**: `attach({ entity_id })` - product belongs to entity
- **Per-Entity Features**: `entity_feature_id` in item config - balance distributed to entities
### Per-Entity Features: Billing Implications
For per-entity consumable features:
1. **Base price is charged ONCE** (at customer level), not per entity
2. **Overage is SUMMED across all entities FIRST, then rounded up** to billing units
3. **Each entity has its own included usage** that resets independently
```typescript
// Per-entity consumable: 100 included per entity, $0.10/unit overage
const perEntityConsumable = items.consumableMessages({
includedUsage: 100,
entityFeatureId: TestFeature.Users, // Makes it per-entity
});
const pro = products.pro({ // $20/month base
items: [perEntityConsumable],
});
// Attach ONCE to customer (NOT to each entity)
s.attach({ productId: pro.id }) // No entityIndex!
// Track to specific entities
s.track({ featureId: TestFeature.Messages, value: 150, entityIndex: 0 }) // 50 overage
s.track({ featureId: TestFeature.Messages, value: 250, entityIndex: 1 }) // 150 overage
// Invoice calculation:
// - Base price: $20 (single, not per entity)
// - Entity 1 overage: 50
// - Entity 2 overage: 150
// - Total overage: 50 + 150 = 200 → rounded to billing units → 200 * $0.10 = $20
// - Total invoice: $20 + $20 = $40
```
**Billing Units Rounding**: For per-entity consumables with `billingUnits > 1`, ALL entity overages are **SUMMED FIRST**, then the **TOTAL** is rounded up to billing units:
```typescript
// billingUnits=10, $1/10 units
// Entity 1: 55 overage
// Entity 2: 23 overage
// Total: 55 + 23 = 78 → ceil(78/10) = 8 → 8 * $1 = $8
// NOT: ceil(55/10) + ceil(23/10) = 6 + 3 = $9 ❌
```
**Common Mistake**: Don't attach per-entity feature products to each entity separately - this creates multiple subscriptions with multiple base charges!
### What are Entities?
Entities are sub-units of a customer that can have their own:

View File

@@ -5,7 +5,7 @@
```typescript
import { expectCustomerFeatureCorrect, expectCustomerFeatureExists } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectCustomerProducts, expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
@@ -80,9 +80,25 @@ expectCustomerInvoiceCorrect({
## Product State Expectations
### `expectCustomerProducts` (Batch Check - Preferred)
Verify multiple product states in a single call. Use this when checking 2+ products.
```typescript
await expectCustomerProducts({
customer, // Or customerId
active: [pro.id, addon.id], // Products that should be active
canceling: [premium.id], // Products that should be canceling
scheduled: [free.id], // Products that should be scheduled
notPresent: [oldProduct.id], // Products that should not exist
});
```
All arrays are optional - only include the states you need to verify.
### `expectProductActive`
Verify product is active for customer/entity.
Verify a single product is active. For multiple products, prefer `expectProducts`.
```typescript
await expectProductActive({

View File

@@ -109,20 +109,36 @@ items.oneOffPrice({ price?: number }) // Default: $50 one-time
## Product Fixtures (`products.*`)
### `products.base()`
### `products.base()` — FREE Product
No base price. Use for free products or custom pricing.
**This IS your free product fixture.** No base price = free. Don't use `constructProduct()` for free products.
```typescript
products.base({
items: ProductItem[],
id?: string, // Default: "base"
isDefault?: boolean, // Default: false
isDefault?: boolean, // Default: false — set true for default free tier
isAddOn?: boolean, // Default: false
trialDays?: number, // Optional trial
})
```
**Common usage:**
```typescript
// Free default product
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
isDefault: true, // Makes it the default fallback product
});
// Custom-priced product (not free, not pro)
const premium = products.base({
id: "premium",
items: [items.monthlyMessages(), items.monthlyPrice({ price: 50 })],
});
```
### `products.pro()`
**Includes $20/month base price.** Don't add `monthlyPrice()`.

View File

@@ -238,6 +238,84 @@ const {
} = await initScenario({ ... });
```
## Setup vs Test Body
**Rule:** Put setup actions in `initScenario.actions`, keep only the behavior under test in the test body.
Ask: "What is the test actually testing?" Everything else is setup.
```typescript
// ❌ BAD - Downgrade is setup, not what we're testing
const { autumnV1 } = await initScenario({
actions: [s.attach({ productId: premium.id })],
});
// Setup in test body (wrong place)
await autumnV1.attach({ customer_id: customerId, product_id: pro.id });
// The actual test: cancel behavior
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: "end_of_cycle",
});
// ✅ GOOD - Setup in initScenario, only test behavior in body
const { autumnV1 } = await initScenario({
actions: [
s.attach({ productId: premium.id }),
s.attach({ productId: pro.id }), // Downgrade is setup
],
});
// The actual test: cancel behavior
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: "end_of_cycle",
});
```
**Benefits:**
- 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
**Critical:** `Date.now()` doesn't change when using test clocks. Use `advancedTo`:

View File

@@ -0,0 +1,81 @@
# Stripe Behaviors Reference
How Stripe handles billing events and how Autumn responds to them.
## Consumable (Arrear) Billing
Consumable items are charged in arrears - usage is tracked during a billing period and charged at the end.
### Renewals (invoice.created)
For regular billing cycle renewals, we use the `invoice.created` webhook to add consumable line items.
**Handler:** `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts`
**How it works:**
1. Stripe fires `invoice.created` at the start of each billing cycle
2. We check if it's a periodic invoice (`billing_reason === "subscription_cycle"`)
3. We calculate usage for the previous period and add line items to the draft invoice
4. Stripe then finalizes and charges the invoice
### Last Invoice (Cancellation)
When a subscription is canceled, the handling differs between customer-level and entity-level products.
#### Customer-Level Products (Stripe Metered Items)
**Stripe Behavior:** Stripe creates an EXTRA invoice after the subscription is canceled because metered items (usage-based) need final usage to be billed.
**Handler:** `invoice.created` still applies - same as renewals
**Important:** If a trial ends (not a cancellation), Stripe does NOT create an extra invoice. We detect this by checking if `current_period_start === trial_end` and skip consumable charges in that case.
```typescript
// From processConsumablePricesForInvoiceCreated.ts
const hasTrialJustEnded = ({ stripeSubscription }) => {
const trialEnd = stripeSubscription.trial_end;
if (!trialEnd) return false;
const periodStart = getLatestPeriodStart({ sub: stripeSubscription });
return trialEnd === periodStart;
};
```
#### Entity-Level Products (Non-Metered)
**Stripe Behavior:** Stripe does NOT create an extra invoice because we use empty price items ($0 placeholder prices for entity subscriptions).
**Handler:** `server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts`
**How it works:**
1. When `subscription.deleted` fires, we check if the subscription has metered items
2. If NO metered items (entity-level), we manually create an invoice for arrear charges
3. We skip this if:
- Subscription has metered items (Stripe handles it via `invoice.created`)
- It was an immediate cancellation (no overage charged on immediate cancels)
- It was a trial cancellation (`ended_at === trial_end`)
```typescript
// From processConsumablePricesForSubscriptionDeleted.ts
const wasTrialCancellation = (stripeSubscription) => {
const trialEnd = stripeSubscription.trial_end;
const endedAt = stripeSubscription.ended_at;
if (!trialEnd || !endedAt) return false;
return trialEnd === endedAt;
};
```
### Summary Table
| Scenario | Customer-Level (Metered) | Entity-Level (Non-Metered) |
|----------|--------------------------|----------------------------|
| **Renewal** | `invoice.created` | `invoice.created` |
| **Cancel End-of-Cycle** | Stripe creates extra invoice → `invoice.created` | No extra invoice → `subscription.deleted` creates invoice |
| **Cancel Immediately** | No overage charged | No overage charged |
| **Trial Ends** | No extra invoice, skip consumable charges | No extra invoice, skip consumable charges |
| **Cancel at Trial End** | Skip consumable charges | Skip consumable charges |
### Key Differences
1. **Metered vs Non-Metered:** Stripe only creates an extra final invoice for subscriptions with metered items
2. **Trial Handling:** Both paths skip billing when trial ends - trial usage is free
3. **Immediate Cancel:** Neither path bills for overage on immediate cancellations

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

10
.opencode/opencode.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"linear": {
"type": "remote",
"url": "https://mcp.linear.app/mcp",
"oauth": {}
}
}
}

View File

@@ -0,0 +1,261 @@
# Cancel Implementation Plan
This document outlines the implementation plan for adding `cancel` support to the update subscription endpoint.
## Overview
The `cancel` parameter allows users to:
- Schedule a subscription cancellation at the end of the current billing cycle (`'end_of_cycle'`)
- Cancel immediately (`'immediately'`)
These can be optionally combined with other subscription updates (like custom plan changes or quantity updates).
---
## API Parameter
```typescript
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional()
```
- `'end_of_cycle'` - Schedule cancellation at cycle end
- `'immediately'` - Cancel now
- `null` - Uncancel (future work)
- `undefined` - No cancel action
---
## Key Behaviors
### 1. `cancel: 'end_of_cycle'`
- Sets `canceled: true`, `canceled_at: currentEpochMs`, `ended_at: cycleEnd` on customer product
- Inserts scheduled default product (starts at `cycleEnd`) for main products
- Deletes any existing scheduled product in the group
### 2. `cancel: 'immediately'`
- Sets `canceled: true`, `canceled_at: currentEpochMs`, `ended_at: currentEpochMs`, `status: Expired`
- Inserts active default product for main products
- Deletes any existing scheduled product in the group
### 3. Combining with `items` (custom plan)
- Cancel updates are applied to the NEW inserted customer products
- Example: `cancel: 'end_of_cycle'` + `items` = switch to custom plan AND schedule cancellation
### 4. Default products
- Default products are FREE - no Stripe subscription needed
- Add-ons do NOT trigger default products
### 5. Existing scheduled products
- If there's already a scheduled customer product (downgrade in progress), it gets deleted
- Uses `findMainScheduledCustomerProductByGroup`
---
## Architecture
### Compute Layer Structure
```
server/src/internal/billing/v2/updateSubscription/compute/cancel/
├── computeCancelPlan.ts # Orchestrator - main entry point
├── computeEndOfCycleMs.ts # Step 1: Calculate cycle end timestamp
├── computeCancelUpdates.ts # Step 2: Build cancel field updates
├── computeDefaultCustomerProduct.ts # Step 3: Create default product to insert
├── computeCustomerProductToDelete.ts # Step 4: Find scheduled product to delete
└── applyCancelPlan.ts # Apply computed values to the plan
```
### Flow
```typescript
computeCancelPlan({ ctx, billingContext, params, plan }) {
if (!params.cancel) return plan;
// Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ ... });
// Step 2: Build cancel updates for customer product
const cancelUpdates = computeCancelUpdates({ cancelMode, endOfCycleMs, currentEpochMs });
// Step 3: Create default product (if applicable)
const defaultProduct = computeDefaultCustomerProduct({ ..., endOfCycleMs });
// Step 4: Find existing scheduled product to delete
const productToDelete = computeCustomerProductToDelete({ ... });
// Apply all computed values to the plan
return applyCancelPlan({ plan, cancelUpdates, defaultProduct, productToDelete });
}
```
---
## Implementation Status
### Completed
#### 1. Updated params schema
**File:** `shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts`
```typescript
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional(),
```
#### 2. Updated `AutumnBillingPlan` schema
**File:** `server/src/internal/billing/v2/types/autumnBillingPlan.ts`
- Changed cancel fields from `.optional()` to `.nullish()` to support setting to `null` for uncancel
#### 3. Updated `setupDefaultProductContext`
**File:** `server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts`
- Now checks for `params.cancel` instead of old `params.cancel_end_of_cycle`
---
#### 4. Cancel compute layer (DONE)
**Folder:** `server/src/internal/billing/v2/updateSubscription/compute/cancel/`
| File | Status | Description |
|------|--------|-------------|
| `computeEndOfCycleMs.ts` | Done | Calculate cycle end timestamp |
| `computeCancelUpdates.ts` | Done | Build cancel field updates |
| `computeDefaultCustomerProduct.ts` | Done | Create default product to insert |
| `computeCustomerProductToDelete.ts` | Done | Find scheduled product to delete |
| `applyCancelPlan.ts` | Done | Apply computed values to plan |
| `computeCancelPlan.ts` | Done | Orchestrator function |
#### 5. Integrated into `computeUpdateSubscriptionPlan`
**File:** `server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts`
- Calls `computeCancelPlan` after computing the base plan (quantity/custom)
---
## Stripe Integration
### Overview
The Stripe layer needs to handle cancellation by:
1. Setting `cancel_at` timestamp on the subscription (for simple cancel scenarios)
2. Using subscription schedules with `end_behavior: "cancel"` (for multi-phase scenarios)
3. Releasing existing schedules when transitioning to simple cancel
### Key Insight: Phase-Based Detection
When we build Stripe phases from customer products:
- **Phase 1**: Current products with items (now → `ended_at`)
- **Phase 2**: Empty (no items) if all products are canceling
If Phase 2 is empty, it signals a "cancel at end" scenario. The `cancel_at` timestamp is Phase 2's `start_date`.
### Scenarios
#### Scenario 1: Simple cancel (no future phases with items)
- Customer has Pro plan, cancels at end of cycle
- No other products/entities continue
- **Stripe action**: Set `cancel_at` on subscription directly
#### Scenario 2: Cancel with schedule (multi-entity or downgrade)
- Entity A on Pro, Entity B on Pro
- Entity A cancels at end of cycle
- **Stripe action**: Update schedule with Phase 1 (both entities) → Phase 2 (Entity B only)
#### Scenario 3: Cancel when schedule exists (but results in simple cancel)
- Schedule exists managing a downgrade
- User cancels the whole thing
- **Stripe action**: Release schedule + set `cancel_at` on subscription
### Implementation
#### 1. `buildStripeSubscriptionScheduleAction` - New Return Type
```typescript
interface SubscriptionScheduleBuildResult {
scheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number; // Unix ms timestamp to set on subscription
}
```
The function detects:
- If trailing empty phase exists → `shouldCancelAtEnd = true`
- If only 1 phase starting now + shouldCancelAtEnd:
- Release schedule (if exists) + return `subscriptionCancelAt`
- If multiple phases with items:
- Return schedule action with `end_behavior: "cancel"` if shouldCancelAtEnd
#### 2. New `release` Action Type
Added to `StripeSubscriptionScheduleActionSchema`:
```typescript
z.object({
type: z.literal("release"),
stripeSubscriptionScheduleId: z.string(),
})
```
#### 3. `cancel_at` in Subscription Actions
Both `buildStripeSubscriptionUpdateAction` and `buildStripeSubscriptionCreateAction` accept `subscriptionCancelAt` param and include it in Stripe params.
For updates, only set if different from current `stripeSubscription.cancel_at`.
### Files Modified
| File | Change |
|------|--------|
| `types/stripeBillingPlan/stripeSubscriptionScheduleAction.ts` | Add `release` action type |
| `actionBuilders/buildStripeSubscriptionScheduleAction.ts` | New return type, detect cancel scenarios |
| `actionBuilders/evaluateStripeBillingPlan.ts` | Pass `subscriptionCancelAt` to subscription builder |
| `actionBuilders/buildStripeSubscriptionAction.ts` | Pass `subscriptionCancelAt` to create/update builders |
| `utils/subscriptions/buildStripeSubscriptionUpdateAction.ts` | Add `cancel_at` to params |
| `utils/subscriptions/buildStripeSubscriptionCreateAction.ts` | Add `cancel_at` to params |
| `execute/executeStripeSubscriptionScheduleAction.ts` | Handle `release` action |
### Execution Order
Current order (subscription → schedule) is maintained. If Stripe doesn't allow setting `cancel_at` while schedule exists, we'll revisit.
---
### Remaining Work (Future)
#### 1. Validation / Error Handling
**File:** `server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts`
Add validation for:
- Cannot cancel free products with `'end_of_cycle'` (use `'immediately'` instead)
- Cannot cancel if already canceled (or handle gracefully)
#### 2. Execute Layer - Persist Cancel Fields
Ensure the update logic persists cancel fields to DB.
#### 3. Uncancel (`cancel: null`)
- Clear cancel fields
- Delete scheduled default product
- Unset `cancel_at` on Stripe subscription
#### 4. Proration for `cancel: 'immediately'`
- Add `prorate` option support
#### 5. Atomicity for schedule release + subscription update
- Currently executing subscription action before schedule release
- If Stripe requires schedule release first, need to handle potential failure state
---
## Test Cases
1. **Basic `cancel: 'end_of_cycle'`** - Cancel a paid subscription at end of cycle
2. **Basic `cancel: 'immediately'`** - Cancel a paid subscription immediately
3. **Cancel + custom plan** - Update to custom plan AND set cancel
4. **Cancel with existing scheduled product** - Should delete the scheduled product
5. **Cancel add-on** - Should NOT create default product
6. **Cancel free product** - Should throw error for `'end_of_cycle'`
---
## Dependencies
- `getLargestInterval` from `server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts`
- `getCycleEnd` from `@autumn/shared`
- `cusProductToPrices` from `@autumn/shared`
- `findMainScheduledCustomerProductByGroup` from `@autumn/shared`
- `initFullCustomerProduct` from `server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts`
- `getFreeDefaultProductByGroup` from `server/src/internal/customers/cusProducts/cusProductUtils.ts`

View File

@@ -0,0 +1,439 @@
# handleCreateCustomer Refactor Plan
## Overview
Refactor `handleCreateCustomer` to:
1. Eliminate race conditions causing duplicate customers or missing default products
2. Clean up input types with a single source of truth
3. Ensure comprehensive test coverage before making changes
---
## Part 1: Type Cleanup
### Problem
Three overlapping types with duplicated ID validation logic:
- `CreateCustomerSchema` in `shared/models/cusModels/cusModels.ts`
- `CustomerDataSchema` in `shared/api/common/customerData.ts`
- `CreateCustomerParamsSchema` in `shared/api/customers/customerOpModels.ts`
### Solution
Make `shared/api/common/customerData.ts` the single source of truth.
### Changes
#### 1. `shared/api/common/customerData.ts` - Add CustomerIdSchema
```typescript
import { z } from "zod/v4";
// Reusable customer ID validation - can be used by attach, check, track, etc.
export const CustomerIdSchema = z.string().refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
error: (issue) => {
const input = issue.input as string;
if (input === "") return { message: "can't be an empty string" };
if (input.includes("@"))
return {
message: "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.",
};
if (input.includes(" "))
return {
message: "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.",
};
if (input.includes("."))
return {
message: "cannot contain periods. Use only letters, numbers, underscores, and hyphens.",
};
const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0];
return {
message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`,
};
},
},
);
export const CustomerDataSchema = z
.object({
name: z.string().nullish().meta({ description: "Customer's name" }),
email: z.string().nullish().meta({ description: "Customer's email address" }),
fingerprint: z.string().nullish().meta({ internal: true }),
metadata: z.record(z.any(), z.any()).nullish().meta({ internal: true }),
stripe_id: z.string().nullish().meta({ internal: true }),
disable_default: z.boolean().optional().meta({ internal: true }),
})
.meta({
id: "CustomerData",
description: "Customer details to set when creating a customer",
});
export type CustomerData = z.infer<typeof CustomerDataSchema>;
export type CustomerId = z.infer<typeof CustomerIdSchema>;
```
#### 2. `shared/api/customers/customerOpModels.ts` - Use CustomerIdSchema
```typescript
import { CustomerDataSchema, CustomerIdSchema } from "../common/customerData.js";
// Remove duplicate customerId const, use CustomerIdSchema instead
export const CreateCustomerParamsSchema = z.object({
id: CustomerIdSchema.nullable().meta({
description: "Your unique identifier for the customer",
}),
...CustomerDataSchema.shape,
entity_id: z.string().optional().meta({ internal: true }),
entity_data: EntityDataSchema.optional().meta({ internal: true }),
});
export const UpdateCustomerParamsSchema = z.object({
id: CustomerIdSchema.optional().meta({
description: "New unique identifier for the customer.",
}),
// ... rest uses CustomerDataSchema fields
});
```
#### 3. `shared/models/cusModels/cusModels.ts` - Remove CreateCustomerSchema
- Delete `CreateCustomerSchema` (lines 21-69)
- Delete `CreateCustomer` type export (line 78)
- Keep `CustomerSchema` and `Customer` type (used for DB model)
#### 4. `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New Signature
```typescript
// OLD
export const handleCreateCustomer = async ({
ctx,
cusData, // CreateCustomer type
createDefaultProducts,
defaultGroup,
}: {
ctx: AutumnContext;
cusData: CreateCustomer;
createDefaultProducts?: boolean;
defaultGroup?: string;
})
// NEW
export const handleCreateCustomer = async ({
ctx,
customerId, // string | null
customerData, // CustomerData
options,
}: {
ctx: AutumnContext;
customerId: string | null;
customerData?: CustomerData;
options?: {
createDefaultProducts?: boolean;
defaultGroup?: string;
};
})
```
#### 5. Update All Callers
| File | Change |
|------|--------|
| `getOrCreateCustomer.ts` | Pass `customerId` and `customerData` separately |
| `getOrCreateCachedFullCustomer.ts` | Pass `customerId` and `customerData` separately |
| `handlePostCustomerV2.ts` | Extract `id` from parsed body, pass rest as customerData |
| `getOrCreateApiCustomer.ts` | Pass `customerId` and `customerData` separately |
| `createNewCustomer.ts` | Update import, accept new shape |
### Future Work (Not in This PR)
These files can later adopt `CustomerIdSchema` for validation:
- `shared/api/balances/check/checkParams.ts` - `customer_id: CustomerIdSchema`
- `shared/api/balances/track/trackParams.ts`
- `shared/api/billing/attach/*`
---
## Part 2: Test Structure
### File Organization
**3 new test files** using the modern `test.concurrent` + `initScenario` pattern:
| File | Theme |
|------|-------|
| `create-customer.test.ts` | Basic creation + email flows |
| `create-customer-defaults.test.ts` | Default product attachment |
| `create-customer-race.test.ts` | Race condition tests (low-level simulation) |
**Delete after migration:**
- `create-customer1.test.ts` (old pattern)
- `create-customer2.test.ts` (old pattern)
**Add to existing files:**
- `check-race-condition2.test.ts` → Customer auto-creation race via /check
- `track-race-condition5.test.ts` → Customer auto-creation race via /track
---
## Part 3: Test Cases
### `create-customer.test.ts` - Basic Creation + Email Flows
| # | Test Name | Description | From |
|---|-----------|-------------|------|
| 1 | `create: basic with ID` | Create customer with ID, name, email | Migrate from create-customer1 |
| 2 | `create: idempotent with same ID` | Create same customer twice returns existing | Migrate from create-customer1 |
| 3 | `create: with expand params` | Create with expand returns invoices, trials_used, entities | Migrate from create-customer1 |
| 4 | `create: concurrent same ID` | Promise.all two creates with same ID | Migrate from create-customer2 |
| 5 | `create: null ID with email` | Create customer with id=null and valid email | NEW |
| 6 | `create: null ID no email (error)` | Create with id=null and no email throws | NEW |
| 7 | `create: null ID idempotent` | Create with id=null same email twice returns existing | NEW |
| 8 | `create: null ID then add ID` | Create with id=null, then create with same email + ID updates existing | NEW |
| 9 | `create: concurrent null ID same email` | Promise.all two creates with id=null, same email | NEW |
### `create-customer-defaults.test.ts` - Default Product Attachment
| # | Test Name | Description |
|---|-----------|-------------|
| 10 | `defaults: single free product` | Create customer with single default free product attached |
| 11 | `defaults: multiple groups` | Two default free products in different groups, both attached |
| 12 | `defaults: same group priority` | Two defaults in same group, priority: trial > paid > free |
| 13 | `defaults: trial product` | Default trial attaches with status=trialing |
| 14 | `defaults: paid product (legacy)` | Default paid with forcePaidDefault=true uses handleAddProduct |
| 15 | `defaults: paid requires Stripe customer` | Default paid creates Stripe customer, sets stripe_id |
### `create-customer-race.test.ts` - Race Condition Tests (Low-Level)
| # | Test Name | Description |
|---|-----------|-------------|
| 16 | `race: stale cache detection` | Insert customer → concurrent request caches incomplete → getOrCreate detects stale |
| 17 | `race: concurrent default loop` | Insert → start attaching defaults → concurrent 23505 → retry sees all defaults |
| 18 | `race: concurrent same ID (API level)` | Promise.all creates with same ID, one gets 23505, both return same customer |
| 19 | `race: concurrent email+ID update` | Customer exists id=null, two requests add ID via same email |
| 20 | `race: concurrent Stripe customer` | Default paid: concurrent creates only create one Stripe customer |
### Entry Point Auto-Creation Race Tests
#### `check-race-condition2.test.ts`
| # | Test Name | Description |
|---|-----------|-------------|
| 21 | `check-autocreate: concurrent same customer_id` | Concurrent /check calls auto-creating same customer |
#### `track-race-condition5.test.ts`
| # | Test Name | Description |
|---|-----------|-------------|
| 22 | `track-autocreate: concurrent same customer_id` | Concurrent /track calls auto-creating same customer, usage correct |
---
## Part 4: Test Implementation Pattern
### Modern Pattern: `test.concurrent` + `initScenario`
```typescript
import { expect, test } from "bun:test";
import { CusExpand } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// BASIC CREATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "create-basic-id",
setup: [s.customer({ testClock: false })],
actions: [],
});
// Delete to test fresh create
try { await autumnV1.customers.delete(customerId); } catch {}
const data = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
expect(data.id).toBe(customerId);
expect(data.name).toBe("Test Customer");
expect(data.email).toBe(`${customerId}@example.com`);
});
test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "create-idempotent",
setup: [s.customer({ testClock: false })],
actions: [],
});
// First create
const data1 = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
// Second create - should return existing
const data2 = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
expect(data1.id).toBe(data2.id);
expect(data1.internal_id).toBe(data2.internal_id);
});
```
### Low-Level Race Simulation Pattern
```typescript
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js";
import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js";
import { generateId } from "@/utils/genUtils.js";
test.concurrent(`${chalk.yellowBright("race: stale cache detection")}`, async () => {
const wordsItem = items.monthlyWords({ includedUsage: 1000 });
const freeDefault = products.base({ id: "free", items: [wordsItem], isDefault: true });
const { customerId, ctx, autumnV2 } = await initScenario({
customerId: "race-stale-cache",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeDefault] }),
],
actions: [],
});
// Delete customer so we can manually reproduce race
try { await autumnV2.customers.delete(customerId); } catch {}
await deleteCachedFullCustomer({ ctx, customerId, source: "test-cleanup" });
// STEP 1: Insert customer directly (bypassing handleCreateCustomer)
const internalId = generateId("cus");
await CusService.insert({
db: ctx.db,
data: {
id: customerId,
internal_id: internalId,
org_id: ctx.org.id,
env: ctx.env,
name: customerId,
email: `${customerId}@test.com`,
metadata: {},
created_at: Date.now(),
},
});
// STEP 2: Simulate concurrent request caching incomplete customer
const incompleteCustomer = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
withEntities: true,
withSubs: true,
});
await setCachedFullCustomer({
ctx,
fullCustomer: incompleteCustomer!,
customerId,
fetchTimeMs: Date.now(),
source: "test-concurrent-request",
overwrite: true,
});
// STEP 3: Call actual function - should detect stale state
const result = await getOrCreateCachedFullCustomer({
ctx,
params: { customer_id: customerId, feature_id: TestFeature.Words },
source: "test-final-check",
});
// Verify: Customer has default products
expect(result.customer_products?.length).toBeGreaterThan(0);
});
```
---
## Part 5: Implementation Order
### Phase 1: Write Tests (RED)
1. Create `create-customer.test.ts` - migrate old tests + add new null ID tests
2. Create `create-customer-defaults.test.ts` - default product tests
3. Create `create-customer-race.test.ts` - race condition tests
4. Add tests to `check-race-condition2.test.ts` and `track-race-condition5.test.ts`
5. Delete `create-customer1.test.ts` and `create-customer2.test.ts`
6. Run tests - some will fail (documenting expected behavior)
### Phase 2: Type Cleanup
1. Add `CustomerIdSchema` to `customerData.ts`
2. Update `customerOpModels.ts` to use it
3. Update `handleCreateCustomer` signature
4. Update all callers
5. Remove `CreateCustomerSchema` from `cusModels.ts`
### Phase 3: Fix Race Conditions (GREEN)
1. Analyze failing tests
2. Implement proper locking/transactions
3. Potential fixes:
- Use database transaction for insert + default products
- Add advisory lock during customer creation
- Detect stale cache by checking customer_products count
### Phase 4: Verify
1. All tests pass
2. Manual testing of concurrent scenarios
3. Review for any remaining edge cases
---
## Files Summary
### To Create
- `server/tests/integration/crud/customers/create-customer.test.ts`
- `server/tests/integration/crud/customers/create-customer-defaults.test.ts`
- `server/tests/integration/crud/customers/create-customer-race.test.ts`
- `server/tests/integration/balances/check/check-race-condition2.test.ts`
- `server/tests/balances/track/race-condition/track-race-condition5.test.ts`
### To Delete
- `server/tests/integration/crud/customers/create-customer1.test.ts`
- `server/tests/integration/crud/customers/create-customer2.test.ts`
### Type Cleanup (Modify)
- `shared/api/common/customerData.ts` - Add `CustomerIdSchema`
- `shared/api/customers/customerOpModels.ts` - Use `CustomerIdSchema`, remove duplicate
- `shared/models/cusModels/cusModels.ts` - Remove `CreateCustomerSchema`
- `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New signature
- `server/src/internal/customers/cusUtils/getOrCreateCustomer.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts`
- `server/src/internal/customers/cusUtils/createNewCustomer.ts`
- `server/src/internal/customers/handlers/handlePostCustomerV2.ts`
- `server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts`

View File

@@ -0,0 +1,312 @@
# Invoice Created Webhook Refactor Plan
This document outlines the implementation plan for refactoring the `invoice.created` webhook handler, adding expired customer products caching, and creating the `upsertAutumnInvoice` function.
## Overview
The `invoice.created` webhook needs several enhancements:
1. **Expired Customer Products Cache** - When `subscription.deleted` expires customer products, cache them so `invoice.created` can still access them for processing prepaid/allocated prices
2. **Upsert Autumn Invoice** - Create/update Autumn invoice records on `invoice.created` (skip first invoice)
3. **Test Coverage** - Migrate/create tests for prepaid and allocated price processing
---
## Phase A: Expired Customer Products Cache System ✅ COMPLETED
**Goal:** Allow `subscription.deleted` to cache expired customer products so `invoice.created` can access them.
**Problem:** When a subscription is deleted, we expire customer products in our DB. But `invoice.created` may fire shortly after and needs those customer products to process prepaid/allocated prices correctly. Currently, `getByStripeSubId` with `ALL_STATUSES` fetches expired products, but there's a race condition risk.
**Solution:** Cache expired customer products in Redis when they're expired, then merge them in `setupInvoiceCreatedContext`.
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| A1 | Create `setExpiredCustomerProductsCache.ts` | `server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts` |
| A2 | Create `getExpiredCustomerProductsCache.ts` | `server/src/internal/customers/cusProducts/actions/expiredCache/getExpiredCustomerProductsCache.ts` |
| A3 | Create `expiredCache/index.ts` barrel export | `server/src/internal/customers/cusProducts/actions/expiredCache/index.ts` |
| A4 | Update `actions/index.ts` to add `expiredCache: { set, get }` | `server/src/internal/customers/cusProducts/actions/index.ts` |
| A5 | Update `expireAndActivateCustomerProducts.ts` to call `expiredCache.set()` at end | `server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts` |
| A6 | Update `setupInvoiceCreatedContext.ts` to call `expiredCache.get()` and merge | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts` |
### File Details
#### A1: `setExpiredCustomerProductsCache.ts`
```typescript
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const setExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
customerProducts,
}: {
stripeSubscriptionId: string;
customerProducts: FullCusProduct[];
}): Promise<void> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
// 5 minute TTL - enough time for invoice.created to process
await CacheManager.setJson(key, customerProducts, 300);
};
```
#### A2: `getExpiredCustomerProductsCache.ts`
```typescript
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const getExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
}: {
stripeSubscriptionId: string;
}): Promise<FullCusProduct[] | null> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
return await CacheManager.getJson<FullCusProduct[]>(key);
};
```
#### A3: `expiredCache/index.ts`
```typescript
export { setExpiredCustomerProductsCache } from "./setExpiredCustomerProductsCache";
export { getExpiredCustomerProductsCache } from "./getExpiredCustomerProductsCache";
```
#### A4: Updated `actions/index.ts`
```typescript
import { activateScheduledCustomerProduct } from "./activateScheduled";
import { deleteScheduledCustomerProduct } from "./deleteScheduledCustomerProduct";
import { expireCustomerProductAndActivateDefault } from "./expireAndActivateDefault";
import { setExpiredCustomerProductsCache, getExpiredCustomerProductsCache } from "./expiredCache";
export const customerProductActions = {
expireAndActivateDefault: expireCustomerProductAndActivateDefault,
activateScheduled: activateScheduledCustomerProduct,
deleteScheduled: deleteScheduledCustomerProduct,
expiredCache: {
set: setExpiredCustomerProductsCache,
get: getExpiredCustomerProductsCache,
},
};
export {
expireCustomerProductAndActivateDefault,
activateScheduledCustomerProduct,
deleteScheduledCustomerProduct,
};
```
#### A5: Changes to `expireAndActivateCustomerProducts.ts`
At the end of the function, after processing all customer products:
```typescript
// Cache the expired products for invoice.created
await customerProductActions.expiredCache.set({
stripeSubscriptionId: stripeSubscription.id,
customerProducts,
});
```
#### A6: Changes to `setupInvoiceCreatedContext.ts`
After fetching customer products from DB (~line 77):
```typescript
// Merge in any cached expired customer products
const cachedExpired = await customerProductActions.expiredCache.get({
stripeSubscriptionId,
});
if (cachedExpired && cachedExpired.length > 0) {
const existingIds = new Set(customerProducts.map(cp => cp.id));
const expiredToAdd = cachedExpired.filter(cp => !existingIds.has(cp.id));
customerProducts.push(...expiredToAdd);
logger.info(
`[invoice.created] Added ${expiredToAdd.length} cached expired products`,
);
}
```
---
## Phase B: Upsert Autumn Invoice on invoice.created ✅ COMPLETED
**Goal:** Create/update Autumn invoice record when Stripe sends `invoice.created` webhook, but skip the first invoice (`billing_reason: subscription_create`).
**Reference:** Similar to `upsertInvoiceFromBilling.ts` but adapted for webhook context.
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| B1 | Create `upsertAutumnInvoice.ts` task | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts` |
| B2 | Update `handleStripeInvoiceCreated.ts` to call `upsertAutumnInvoice()` | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts` |
### File Details
#### B1: `upsertAutumnInvoice.ts`
```typescript
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
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) return;
// Create new
await InvoiceService.createInvoiceFromStripe({
db: ctx.db,
stripeInvoice,
internalCustomerId,
internalEntityId,
org: ctx.org,
productIds,
internalProductIds,
items: [],
});
};
```
#### B2: Changes to `handleStripeInvoiceCreated.ts`
Add import and call after price processing:
```typescript
import { upsertAutumnInvoice } from "./tasks/upsertAutumnInvoice";
// ... existing code ...
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
// Upsert Autumn invoice record
await upsertAutumnInvoice({ ctx, eventContext });
```
---
## Phase C: Migrate/Create Tests for invoice.created Prepaid & Allocated Prices
**Goal:** Ensure test coverage for the refactored `processPrepaidPricesForInvoiceCreated.ts` and `processAllocatedPricesForInvoiceCreated.ts`.
**Location:** `server/tests/integration/billing/stripe-webhooks/invoice-created/`
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| C1 | Create `invoice-created-prepaid.test.ts` | `server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid.test.ts` |
| C2 | Create `invoice-created-allocated.test.ts` | `server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-allocated.test.ts` |
### Test Scenarios
#### C1: `invoice-created-prepaid.test.ts`
Tests for `processPrepaidPricesForInvoiceCreated.ts` (UsageInAdvance billing type).
**Scenarios to test:**
1. **Basic prepaid reset** - Attach with quantity → advance cycle → verify balance resets to quantity * billingUnits
2. **Prepaid with upcoming_quantity** - Set upcoming_quantity mid-cycle → advance cycle → verify balance resets to new quantity
3. **Prepaid lifetime interval** - Lifetime prepaid should NOT reset on cycle (handled specially)
4. **Prepaid with rollover** - If rollover is configured, verify rollover records are created
**Reference existing tests:** `/server/tests/attach/prepaid/prepaid1.test.ts`, `prepaid3.test.ts`
#### C2: `invoice-created-allocated.test.ts`
Tests for `processAllocatedPricesForInvoiceCreated.ts` (InArrearProrated billing type).
**Scenarios to test:**
1. **Replaceables deleted on cycle** - Add seats mid-cycle (creates replaceables with `delete_next_cycle: true`) → advance cycle → verify replaceables removed and balance incremented
2. **No replaceables** - Normal cycle without mid-cycle changes → verify no changes
3. **Multiple linked entitlements** - Replaceables affect multiple linked customer entitlements
**Reference existing tests:** `/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts` (line 279: "replaceables deleted at end of cycle")
---
## Open Questions
1. **TTL for cache:** Is 5 minutes (300 seconds) appropriate, or should it be longer?
2. **Invoice items:** When creating the Autumn invoice via `upsertAutumnInvoice`, should we populate the `items` array (by calling `getInvoiceItems()`), or leave it empty?
3. **Entity ID logic:** If customer products span multiple entities, what should `internal_entity_id` be? Current plan: only set if ALL customer products have the same entity.
4. **Test migration:** Should we migrate existing tests from `/tests/attach/prepaid/` or create fresh tests following the new `initScenario` pattern?
---
## Dependencies
### Phase A
- `CacheManager` from `@/utils/cacheUtils/CacheManager`
- `FullCusProduct` from `@autumn/shared`
### Phase B
- `InvoiceService` from `@/internal/invoices/InvoiceService`
- `InvoiceCreatedContext` from `setupInvoiceCreatedContext`
### Phase C
- `initScenario`, `s` from `@tests/utils/testInitUtils/initScenario`
- `items`, `products` from test fixtures
- `advanceToNextInvoice` from test utilities
---
## File Summary
| Phase | New Files | Modified Files |
|-------|-----------|----------------|
| A | `expiredCache/setExpiredCustomerProductsCache.ts`<br>`expiredCache/getExpiredCustomerProductsCache.ts`<br>`expiredCache/index.ts` | `actions/index.ts`<br>`expireAndActivateCustomerProducts.ts`<br>`setupInvoiceCreatedContext.ts` |
| B | `tasks/upsertAutumnInvoice.ts` | `handleStripeInvoiceCreated.ts` |
| C | `invoice-created-prepaid.test.ts`<br>`invoice-created-allocated.test.ts` | - |

3
.superset/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"setup": ["./.superset/setup.sh"]
}

138
.superset/setup.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/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 run.sh explicitly from root
if [ -f "$ROOT_PATH/run.sh" ]; then
cp "$ROOT_PATH/run.sh" "run.sh"
chmod +x "run.sh"
echo "Copied run.sh"
fi
# 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'"

124
AGENTS.md Normal file
View File

@@ -0,0 +1,124 @@
# Basic rules
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
- DO NOT alter .gitignore
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
# Testing
- When writing tests, ALWAYS read:
1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys
2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests)
- When running tests, ALL server-side console logs go to the server's logs which you do not have access to. You must ask the user to paste you in the logs, instead of expecting the server logs to magically appear
in the test logs. Use your common sense
# Linting and Codebase rules
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsgo --noEmit --skipLibCheck <folder or file path>`
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.
- This codebase uses Bun as its preferred package manager and Node runtime.
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported.
```typescript
// ❌ BAD - Unnecessary type definition for single-use params
type DoSomethingParams = {
ctx: AutumnContext;
customerId: string;
};
const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... }
// ✅ GOOD - Inline object type
const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... }
```
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
- When referring to a `customer_entitlement` object (or plural `customer_entitlements`), always use the full name. Do not abbreviate to "entitlement" or "entitlements" as this will be confused with the separate `entitlement` object.
## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared`
- The onError middleware automatically converts these errors to appropriate HTTP responses
- Examples:
```typescript
// ❌ BAD - Don't do this
if (!org) {
return c.json({ message: "Org not found", code: "not_found" }, 404);
}
// ✅ GOOD - Validation/expected errors use RecaseError
if (!org) {
throw new RecaseError({
message: "Org not found",
code: ErrCode.NotFound,
statusCode: 404,
});
}
// ✅ GOOD - Internal/unexpected errors use InternalError
if (!upstash) {
throw new InternalError({
message: "Upstash not configured",
code: "upstash_not_configured",
});
}
```
## Bad example
/ root
-> components
|-> hooks
## Good example
/ root
-> components
-> hooks
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
# Figma MCP guidance
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
## File Naming
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)
# Vite
## Components
- Always use v2 components from `@/components/v2/` (buttons, inputs, dialogs, sheets, selects, etc.) for new features. Old components in `@/components/ui/` are deprecated.
## Sheets
- Use `Sheet.tsx` for overlay sheets (modal-style with backdrop). Use `SheetHeader`, `SheetFooter`, `SheetSection` from `SharedSheetComponents.tsx` for consistent styling.
- `InlineSheet.tsx` provides `SheetContainer` for inline sheets (embedded in page layout). It re-exports shared components for backwards compatibility.
- Both sheet types support the same header/footer/section components, ensuring consistent UI patterns across overlay and inline implementations.
## Styling
- DO NOT hardcode styles when possible. Always try to reuse existing Tailwind classes or component patterns from similar components in the codebase.
- When adding interactive elements (hover, focus, active states), look for existing patterns in similar components and reuse those class combinations.
- Consistency is key - if a pattern exists, use it rather than creating a new one.
## Form Elements
- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system.

View File

@@ -16,7 +16,7 @@ in the test logs. Use your common sense
# Linting and Codebase rules
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsgo --noEmit --skipLibCheck <folder or file path>`
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.

802
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
[test]
preload = ["./server/tests/setup-integration-tests.ts"]
timeout = 0
# Preload env override for all bun runs (from workspace root)
preload = ["./scripts/preload-env.ts"]
[test]
preload = ["./scripts/preload-env.ts", "./server/tests/setup-integration-tests.ts"]
timeout = 0
[test.env]
NODE_ENV = "test"

View File

@@ -1,6 +1,6 @@
{
"scripts": {
"setup": "zsh conductor-setup.sh",
"run": "bun run dev:bun"
}
}
"scripts": {
"setup": "zsh conductor-setup.sh",
"run": "bun run dev:bun"
}
}

View File

@@ -18,6 +18,14 @@
"@better-auth/dash": "0.1.6"
}
},
"overrides": {
"@better-auth/core": "1.4.12",
"better-auth": "1.4.12"
},
"resolutions": {
"@better-auth/core": "1.4.12",
"better-auth": "1.4.12"
},
"type": "module",
"scripts": {
"dev": "bun scripts/dev.ts",
@@ -40,13 +48,18 @@
"q": "lsof -ti:8080 -ti:3000 | xargs kill -9"
},
"dependencies": {
"@better-auth/core": "1.4.12",
"@better-auth/oauth-provider": "1.4.12",
"@wooorm/starry-night": "^3.8.0",
"ag-charts-react": "^12.3.0",
"better-auth": "1.4.12",
"chalk": "^5.6.2",
"tailwind-scrollbar-hide": "^4.0.0",
"drizzle-orm": "catalog:"
"drizzle-orm": "catalog:",
"posthog-node": "^5.24.1",
"tailwind-scrollbar-hide": "^4.0.0"
},
"devDependencies": {
"@better-auth/cli": "^1.4.12",
"@biomejs/biome": "^2.2.7",
"@types/node": "^24.9.1",
"concurrently": "^9.2.1",

View File

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

5
scripts/preload-env.ts Normal file
View File

@@ -0,0 +1,5 @@
// Preload script - runs BEFORE main script imports are evaluated
// This allows local .env to override Infisical secrets
import { loadLocalEnv } from "@server/utils/envUtils.js";
loadLocalEnv();

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { randomBytes } from "crypto";
import { writeFileSync, copyFileSync, readFileSync } from "fs";
import inquirer from "inquirer";
import { spawnSync } from "child_process";
import chalk from "chalk";
import { spawnSync } from "child_process";
import { randomBytes } from "crypto";
import { copyFileSync, readFileSync, writeFileSync } from "fs";
import inquirer from "inquirer";
const genUrlSafeBase64 = (bytes) => {
return randomBytes(bytes)
@@ -253,7 +253,7 @@ async function main() {
};
let databaseUrl = "";
let stripeWebhookVars = [];
const stripeWebhookVars = [];
databaseUrl = await handleDatabaseSetup();
// stripeWebhookVars = await handleLocalRunSetup();

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { randomBytes } from "crypto";
import { writeFileSync, copyFileSync } from "fs";
import chalk from "chalk";
import { randomBytes } from "crypto";
import { copyFileSync, writeFileSync } from "fs";
const genUrlSafeBase64 = (bytes) => {
return randomBytes(bytes)
@@ -26,8 +26,8 @@ async function main() {
STRIPE_WEBHOOK_URL: process.env.STRIPE_WEBHOOK_URL,
};
let databaseUrl = process.env.DATABASE_URL;
let stripeWebhookVars = [];
const databaseUrl = process.env.DATABASE_URL;
const stripeWebhookVars = [];
// stripeWebhookVars = await handleLocalRunSetup();
// Step 11: Write to server/.env

View File

@@ -1,5 +1,5 @@
import inquirer from "inquirer";
import chalk from "chalk";
import inquirer from "inquirer";
/**
* Prompts user for Stripe test API key
@@ -111,7 +111,9 @@ export async function setupTunnelUrl(): Promise<string> {
),
);
console.log(
chalk.cyan("You can use tools like ngrok, localtunnel, or Cloudflare Tunnel."),
chalk.cyan(
"You can use tools like ngrok, localtunnel, or Cloudflare Tunnel.",
),
);
console.log(chalk.cyan("Example: https://your-subdomain.ngrok.io\n"));

View File

@@ -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" },
});
@@ -266,14 +272,9 @@ async function runTest() {
console.log(chalk.green(`✓ Found: ${testFile.relative}\n`));
// Detect test framework
const framework = detectTestFramework({ filePath: testFile.path });
const frameworkLabel = framework === "bun" ? "Bun" : "Mocha";
console.log(chalk.cyan(`🧪 Running test file with ${frameworkLabel}...\n`));
if (framework !== "bun") {
console.error(chalk.red("❌ Mocha tests are deprecated"));
process.exit(1);
}
const frameworkLabel = "Bun";
console.log(chalk.cyan(`🧪 Running test file with ${frameworkLabel}...\n`));
// Run the test file with the appropriate framework, wrapped with Infisical
// Respect NODE_ENV from parent process (e.g., development for logging)

View File

@@ -1,133 +0,0 @@
# Test Groups
Organized test suites for the Autumn test framework.
## Usage
From the project root:
```bash
# Run a specific test group
./scripts/testGroups/g1.sh
# Run with setup
./scripts/testGroups/g1.sh setup
```
From the server directory (legacy):
```bash
./run.sh /path/to/test/group/script.sh
```
## Test Groups
### G1: Upgrade & Downgrade Tests ✅ (Migrated to Bun)
**Status:** Fully migrated to Bun + TypeScript runner
**Tests:**
- `server/tests/check/basic/*.test.ts`
- `server/tests/attach/basic/*.test.ts`
- `server/tests/attach/upgrade/*.test.ts`
- `server/tests/attach/downgrade/*.test.ts`
- `server/tests/attach/free/*.test.ts`
- `server/tests/attach/addOn/*.test.ts`
- `server/tests/attach/entities/*.test.ts`
- `server/tests/attach/checkout/*.test.ts`
**Features:**
- Live spinner showing current test
- Beautiful success/failure indicators
- Concise error reports
- **Compact mode** for large test suites (reduces screen overflow)
### G2: Migrations, Versions & Others ⏳ (Mocha)
**Status:** Uses Mocha (pending migration)
**Tests:**
- Migrations
- Version updates
- Prepaid features
- Interval upgrades
### G3: Continuous Use Tests ⏳ (Mocha)
**Status:** Uses Mocha (pending migration)
**Tests:**
- Entity management
- Usage tracking
- Updates
- Roles
### G4: Merged & Core Tests ⏳ (Mocha)
**Status:** Uses Mocha (pending migration)
**Tests:**
- Merged subscriptions
- Core cancellation
- Multi-attach scenarios
### G5: Advanced Features ⏳ (Mocha)
**Status:** Uses Mocha (pending migration)
**Tests:**
- Multi-feature
- Coupons
- Referrals
- Rollovers
- Usage limits
### G6: Alex Integration Tests ⏳ (Mocha)
**Status:** Uses Mocha (pending migration)
**Tests:**
- End-to-end scenarios
- Product switching
- Topups
## Migration Progress
- ✅ Test runner built (TypeScript + Bun)
- ✅ G1 tests migrated to Bun
- ⏳ G2-G6 pending migration
## Compact Mode 📦
When running many tests (dozens of test files), the standard output can overflow your terminal screen. Use compact mode to see only:
- Recently completed tests (last 3 with tick marks)
- Failed tests summary (updated in real-time)
- Currently running tests (up to 6) with their active test case
- A single line with test stats (progress, passed, failed)
- Full error details at the end
### Usage
**In shell scripts (recommended):**
```bash
# Use BUN_PARALLEL_COMPACT instead of BUN_PARALLEL
BUN_PARALLEL_COMPACT \
'server/tests/check/basic' \
'server/tests/attach/basic'
```
**Direct command line:**
```bash
# Add --compact flag
bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact
# Combine with other options
bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact --max=10
```
### When to Use Compact Mode
- ✅ Running 20+ test files (like in g1.sh)
- ✅ CI/CD pipelines with limited scrollback
- ✅ When you only care about failures
- ❌ Debugging specific tests (use full mode to see progress)
- ❌ Running < 10 test files
## Adding New Test Groups
1. Create a new script in `scripts/testGroups/`
2. Use the TypeScript runner for new tests:
```bash
bun scripts/testScripts/runTests.ts server/tests/your/tests
```
3. For large test suites, use `BUN_PARALLEL_COMPACT` instead of `BUN_PARALLEL`
4. Update this README with the test group details

7
scripts/testGroups/all.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_V2 \
'integration/billing/update-subscription' \
# 'integration/billing/stripe-webhooks' \
# 'integration/crud/customers' \

View File

@@ -2,23 +2,12 @@
# Shared configuration for test groups
export TEST_FILE_CONCURRENCY=${TEST_FILE_CONCURRENCY:-3}
# Get project root directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SERVER_DIR="$PROJECT_ROOT/server"
# # Find bun executable (check common locations)
# 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
BUN_CMD="infisical run --env=dev -- bun"
# Test runner function
@@ -31,13 +20,13 @@ 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 "$@" --max="$TEST_FILE_CONCURRENCY"
}
# Setup function
BUN_SETUP() {
cd "$SERVER_DIR" && $BUN_CMD tests/setupMain.ts
}
# Mocha function (for tests not yet migrated)
MOCHA_CMD() {
cd "$SERVER_DIR" && npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts "$@"
}

View File

@@ -9,33 +9,37 @@ 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' \
export TEST_FILE_CONCURRENCY=6
BUN_PARALLEL_V2 \
'integration/balances/check' \
'integration/balances/track' \
'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' \

View File

@@ -2,23 +2,31 @@
source "$(dirname "$0")/config.sh"
export TEST_FILE_CONCURRENCY=6
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
'server/tests/integration/billing/invoice-action-required' \
'server/tests/integration/billing/cancel' \
'server/tests/integration/billing/cancel/add-ons' \
'server/tests/renew' \
BUN_PARALLEL_V2 \
'attach/basic' \
'attach/upgrade' \
'attach/downgrade' \
'attach/free' \
'attach/addOn' \
'attach/checkout' \
'attach/migrations' \
'attach/others' \
'attach/upgradeOld' \
'attach/response' \
'interval/upgrade' \
'interval/multiSub' \
'billing/new-billing-subscription' \
'billing/invoice-action-required' \
'billing/legacy/attach' \
--max=6
BUN_PARALLEL_COMPACT \
'server/tests/attach/entities' \
--max=6
# 'server/tests/external-psps/revenuecat' \
# From attach/migrations is new stuff...
BUN_PARALLEL_V2 \
'server/tests/attach/entities'
# 'attach/updateEnts' \
# 'attach/newVersion' \

View File

@@ -1,30 +1,22 @@
#!/bin/bash
# Test Group 3: Migrations, Versions & Others
# Description: Tests for migrations, version updates, and miscellaneous features
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
export TEST_FILE_CONCURRENCY=6
BUN_PARALLEL_V2 \
'merged/downgrade' \
'merged/separate' \
'merged/add' \
'merged/group' \
'merged/prepaid' \
'merged/upgrade' \
'merged/addOn' \
'merged/trial' \
BUN_PARALLEL_COMPACT \
'server/tests/attach/migrations' \
'server/tests/attach/others' \
'server/tests/attach/newVersion' \
'server/tests/attach/upgradeOld' \
'server/tests/attach/updateEnts' \
'server/tests/attach/prepaid' \
'server/tests/attach/response' \
'server/tests/interval/upgrade' \
'server/tests/interval/multiSub' \
'server/tests/integration/billing/new-billing-subscription' \
'server/tests/integration/billing/invoice-action-required/new-subscription' \
--max=6
# deprecated tests(?)
# 'server/tests/core/multiAttach' \
# 'server/tests/core/multiAttach/multiInvoice' \
# 'server/tests/core/multiAttach/multiUpgrade' \
# 'sever/tests/core/multiAttach/multiReward'

View File

@@ -3,25 +3,23 @@
# Source shared configuration
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT \
'server/tests/merged/downgrade' \
'server/tests/merged/separate' \
'server/tests/merged/add' \
'server/tests/merged/group' \
'server/tests/merged/prepaid' \
'server/tests/merged/upgrade' \
'server/tests/merged/addOn' \
'server/tests/merged/trial' \
'server/tests/core/cancel' \
--max=6 \
'server/tests/advanced/coupons' \
'server/tests/advanced/misc' \
'server/tests/attach/updateQuantity' \
'server/tests/attach/multiProduct' \
'server/tests/advanced/multiFeature' \
'server/tests/advanced/referrals' \
'server/tests/advanced/rollovers' \
'server/tests/advanced/customInterval' \
'server/tests/advanced/usageLimit' \
--max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/usage'
# # 'server/tests/crud/plan'
# deprecated tests(?)
# 'server/tests/core/multiAttach' \
# 'server/tests/core/multiAttach/multiInvoice' \
# 'server/tests/core/multiAttach/multiUpgrade' \
# 'sever/tests/core/multiAttach/multiReward'
# # 'server/tests/advanced/referrals/paid' \

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# Source shared configuration
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT \
'server/tests/advanced/coupons' \
'server/tests/advanced/misc' \
'server/tests/attach/updateQuantity' \
'server/tests/attach/multiProduct' \
'server/tests/advanced/multiFeature' \
'server/tests/advanced/referrals' \
'server/tests/advanced/rollovers' \
'server/tests/advanced/customInterval' \
'server/tests/advanced/usageLimit' \
--max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/usage'
# # 'server/tests/crud/plan'
# # 'server/tests/advanced/referrals/paid' \

View File

@@ -0,0 +1,10 @@
#!/bin/bash
# Source shared configuration
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_V2 \
'stripe-webhooks/invoice-created' \
'stripe-webhooks/subscription-deleted'\
'stripe-webhooks/subscription-updated' \
--max=3

View File

@@ -6,18 +6,29 @@ 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/invoice' \
# 'update-subscription/custom-plan' \
# 'update-subscription/discounts' \
# 'update-subscription/errors' \
# 'update-subscription/free-trial' \
# 'update-subscription/multi-product' \
# 'update-subscription/update-quantity' \
# 'update-subscription/version-update' \
# 'update-subscription/cancel/uncancel' \
# 'update-subscription/cancel/immediately' \
# 'update-subscription/cancel/end-of-cycle' \
# --max=3

View File

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

View File

@@ -0,0 +1,757 @@
#!/usr/bin/env bun
import { existsSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { basename, join, 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 path for shorthand test paths
const INTEGRATION_TEST_BASE = "server/tests";
/**
* Recursively search for a folder by name within a base directory.
* Returns the first matching folder path, or null if not found.
*/
async function findFolderByName(
basePath: string,
folderName: string,
): Promise<string | null> {
try {
const entries = await readdir(basePath);
for (const entry of entries) {
const fullPath = join(basePath, entry);
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
if (entry === folderName) {
return fullPath;
}
const found = await findFolderByName(fullPath, folderName);
if (found) return found;
}
}
} catch {
// Ignore errors
}
return null;
}
// 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}`;
}
}
}
// Fall back to the test file path if no location found in stack trace
if (!location) {
location = filePath;
}
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[] = [];
const collectRecursive = async (dirPath: string): Promise<void> => {
try {
const entries = await readdir(dirPath);
for (const entry of entries) {
const fullPath = join(dirPath, entry);
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
await collectRecursive(fullPath);
} else if (entry.endsWith(".test.ts")) {
testFiles.push(fullPath);
}
}
} catch (error) {
console.error(`Error reading directory ${dirPath}:`, error);
}
};
for (const dir of directories) {
const resolvedDir = resolve(process.cwd(), dir);
await collectRecursive(resolvedDir);
}
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) + "...";
}
function toRelativePath(absolutePath: string): string {
const workspaceRoot = process.cwd();
if (absolutePath.startsWith(workspaceRoot)) {
return absolutePath.slice(workspaceRoot.length + 1);
}
return absolutePath;
}
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">{toRelativePath(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"> {toRelativePath(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 = process.env.TEST_FILE_CONCURRENCY
? Number.parseInt(process.env.TEST_FILE_CONCURRENCY, 10)
: 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 in order of priority:
// 1. Exact path from cwd
// 2. Path under INTEGRATION_TEST_BASE
// 3. Search for folder name within INTEGRATION_TEST_BASE
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;
} else {
// Search for the folder by name within the base directory
const folderName = basename(arg);
const baseFullPath = resolve(process.cwd(), INTEGRATION_TEST_BASE);
const found = await findFolderByName(baseFullPath, folderName);
if (found) {
// Convert back to relative path
resolvedPath = found.replace(`${process.cwd()}/`, "");
}
}
}
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();

9
server/bunfig.toml Normal file
View File

@@ -0,0 +1,9 @@
# Server-specific config (for run.sh which executes from server/)
preload = ["../scripts/preload-env.ts"]
[test]
preload = ["../scripts/preload-env.ts", "./tests/setup-integration-tests.ts"]
timeout = 0
[test.env]
NODE_ENV = "test"

View File

@@ -1,6 +1,5 @@
import { AppEnv } from "autumn-js";
import { initScript } from "../src/utils/scriptUtils/scriptUtils";
import Stripe from "stripe";
export const test = async () => {

View File

@@ -24,6 +24,7 @@
"parallel-tests:verbose": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --verbose",
"parallel-tests:debug": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --debug",
"clear-master": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMasterOrg.ts",
"cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts",
"ts": "bunx tsgo --build --noEmit",
"test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts"
},
@@ -42,6 +43,7 @@
"@aws-sdk/client-sqs": "^3.926.0",
"@axiomhq/pino": "^1.3.1",
"@better-auth/dash": "catalog:",
"@better-auth/oauth-provider": "^1.4.12",
"@clickhouse/client": "^1.11.2",
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
@@ -76,7 +78,7 @@
"arctic": "^3.7.0",
"autumn-js": "^0.1.8",
"axios": "^1.8.3",
"better-auth": "^1.2.9",
"better-auth": "^1.4.12",
"body-parser": "^1.20.3",
"bullmq": "^5.56.2",
"chai": "^5.1.2",
@@ -133,7 +135,7 @@
"@types/express": "^5.0.3",
"@types/lodash-es": "^4.17.12",
"@types/mocha": "^10.0.10",
"@types/node": "^24.9.1",
"@types/node": "^25.0.7",
"@types/pg": "^8.11.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
@@ -145,7 +147,7 @@
"react-email": "4.0.16",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.4",
"tsx": "^4.21.0",
"typescript": "^5.7.3"
}
}

4
server/preload-env.ts Normal file
View File

@@ -0,0 +1,4 @@
// Preload script - runs BEFORE main script imports are evaluated
// This allows local .env to override Infisical secrets
import { loadLocalEnv } from "@/utils/envUtils.js";
loadLocalEnv();

View File

@@ -1,26 +1,25 @@
#!/bin/bash
# Run current file
# npx tsx scripts/alex.ts
filename="$1"
if [[ "$filename" == *"shell"* ]]; then
"$filename" "${@:2}"
elif [[ "$filename" == *"/tests/"* ]]; then
# Extract everything after "/tests/"
path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///')
# Remove .ts extension if present
path_after_tests="${path_after_tests%.ts}"
# Use scripts/test.ts which auto-detects framework
NODE_ENV=development infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests"
elif [[ "$filename" == *".test.ts" ]]; then
# Test files: use bun test (preload configured in bunfig.toml)
NODE_ENV=development infisical run --env=dev -- bun test --timeout 0 "$filename"
elif [[ "$filename" == *".sh"* ]]; then
"$filename"
elif [[ "$filename" == *"/scripts/"* ]]; then
# Run scripts with infisical prod environment
infisical run --env=prod -- bun "$filename"
else
infisical run -- bun "$filename"
# Regular scripts (preload configured in bunfig.toml allows .env to override Infisical)
infisical run --env=dev -- bun "$filename"
fi
# OLD: Using scripts/test.ts for test file matching (deprecated)
# elif [[ "$filename" == *"/tests/"* ]]; then
# # Extract everything after "/tests/"
# path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///')
# # Remove .ts extension if present
# path_after_tests="${path_after_tests%.ts}"
# # Use scripts/test.ts which auto-detects framework
# NODE_ENV=development infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,18 @@
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
/**
* Check if an error is a Postgres unique constraint violation (error code 23505).
*/
export const isUniqueConstraintError = (error: unknown): boolean => {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "23505"
);
};
export const buildConflictUpdateColumns = <T extends PgTable>(
table: T,
excludeColumns: (keyof T["_"]["columns"])[] = [],

View File

@@ -9,9 +9,15 @@ import postgres from "postgres";
export const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
export const initDrizzle = (params?: { maxConnections?: number, replica?: boolean }) => {
export const initDrizzle = (params?: {
maxConnections?: number;
replica?: boolean;
}) => {
const maxConnections = params?.maxConnections || 10;
const dbUrl = (params?.replica ? process.env.DATABASE_REPLICA_URL : process.env.DATABASE_URL) ?? "";
const dbUrl =
(params?.replica
? process.env.DATABASE_REPLICA_URL
: process.env.DATABASE_URL) ?? "";
const client = postgres(dbUrl, {
max: maxConnections,
});

View File

@@ -13,6 +13,7 @@ import {
type BillingResponse,
type CheckQuery,
type CreateBalanceParams,
type CreateCustomerInternalOptions,
type CreateCustomerParams,
type CreateEntityParams,
type CreateRewardProgram,
@@ -258,13 +259,26 @@ export class AutumnInt {
return data;
}
async attach(params: AttachBodyV0, headers?: Record<string, string>) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/attach`, params, headers);
async attach(
params: AttachBodyV0,
{
skipWebhooks,
idempotencyKey,
}: { skipWebhooks?: boolean; idempotencyKey?: string } = {},
) {
const headers: Record<string, string> = {};
if (skipWebhooks !== undefined) {
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
}
if (idempotencyKey !== undefined) {
headers["idempotency-key"] = idempotencyKey;
}
const data = await this.post(
`/attach`,
params,
Object.keys(headers).length > 0 ? headers : undefined,
);
return data;
}
@@ -414,16 +428,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 +756,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));
}

View File

@@ -1,6 +1,5 @@
import { Redis } from "@upstash/redis";
const upstash = new Redis({
url: process.env.CLOUD_UPSTASH_REDIS_REST_URL,
token: process.env.CLOUD_UPSTASH_REDIS_REST_TOKEN,

View File

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

View File

@@ -0,0 +1,49 @@
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";
import type { ExpandedStripeCustomer } from "./getExpandedStripeCustomer";
export const createStripeCustomer = async ({
ctx,
customer,
options = {},
}: {
ctx: AutumnContext;
customer: Customer;
options?: {
testClockId?: string;
};
}): Promise<ExpandedStripeCustomer> => {
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,
expand: [
"test_clock",
"invoice_settings.default_payment_method",
"discount.source.coupon.applies_to",
],
},
idempotencyKey
? {
idempotencyKey,
}
: undefined,
);
return stripeCustomer as ExpandedStripeCustomer;
};

View 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 { StripeCustomerWithDiscount } from "@/external/stripe/subscriptions";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
/**
* Stripe Customer with expanded fields for billing operations.
* Extends `StripeCustomerWithDiscount` with additional expanded fields.
*
* @see https://docs.stripe.com/changelog/clover/2025-09-30/add-discount-source-property
*/
export type ExpandedStripeCustomer = Omit<
StripeCustomerWithDiscount,
"test_clock" | "invoice_settings"
> & {
test_clock: Stripe.TestHelpers.TestClock | null;
invoice_settings: Omit<
Stripe.Customer.InvoiceSettings,
"default_payment_method"
> & {
default_payment_method: Stripe.PaymentMethod | 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.source.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;
}

View File

@@ -0,0 +1,60 @@
import { type Customer, ProcessorType } from "@autumn/shared";
import { createStripeCustomer } from "@/external/stripe/customers/operations/createStripeCustomer";
import {
type ExpandedStripeCustomer,
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;
};
}): Promise<ExpandedStripeCustomer> => {
const { logger, db, org, env } = ctx;
const currentStripeCustomer = await getExpandedStripeCustomer({
ctx,
stripeCustomerId: customer.processor?.id,
});
if (currentStripeCustomer) return currentStripeCustomer;
logger.info(`Creating new stripe customer for ${customer.id}`);
const stripeCustomer = await createStripeCustomer({
ctx,
customer,
});
if (options.updateDb) {
await CusService.update({
db,
idOrInternalId: customer.internal_id,
orgId: org.id,
env,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
}
customer.processor = {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
};
return stripeCustomer;
};

View File

@@ -0,0 +1,15 @@
import { hashString } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const buildStripeCustomerIdempotencyKey = ({
ctx,
customerId,
}: {
ctx: AutumnContext;
customerId: string;
}): string => {
const { org, env } = ctx;
return hashString(
`stripe-create-cus:${customerId}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
);
};

View File

@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/bun";
import type { Context } from "hono";
import { Stripe } from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { handleStripeInvoicePaid } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.js";
import { handleStripeSubscriptionUpdated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.js";
import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js";
@@ -10,11 +10,11 @@ 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 { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
import type {
StripeWebhookContext,
@@ -33,41 +33,22 @@ export const handleStripeWebhookEvent = async (
const event = stripeEvent;
try {
const stripeCli = createStripeCli({ org, env });
switch (event.type) {
case "customer.subscription.created":
await handleSubCreated({ ctx });
break;
case "customer.subscription.updated": {
await handleStripeSubscriptionUpdated({ ctx });
case "customer.subscription.updated":
await handleStripeSubscriptionUpdated({ ctx, event });
break;
}
case "customer.subscription.deleted":
await handleSubDeleted({
ctx,
stripeCli,
data: event.data.object,
});
await handleStripeSubscriptionDeleted({ ctx, event });
break;
case "checkout.session.completed": {
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
ctx,
db,
data: checkoutSession,
org,
env,
});
case "invoice.paid":
await handleStripeInvoicePaid({ ctx, event });
break;
}
case "invoice.paid": {
await handleStripeInvoicePaid({ ctx });
break;
}
case "invoice.updated":
await handleInvoiceUpdated({
@@ -76,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 });
@@ -107,6 +80,18 @@ export const handleStripeWebhookEvent = async (
case "customer.discount.deleted":
await handleCusDiscountDeleted({ ctx });
break;
case "checkout.session.completed": {
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
ctx,
db,
data: checkoutSession,
org,
env,
});
break;
}
}
} catch (error) {
Sentry.captureException(error, {

View File

@@ -0,0 +1,5 @@
import type Stripe from "stripe";
export const isStripeInvoiceForNewPeriod = (stripeInvoice: Stripe.Invoice) => {
return stripeInvoice.billing_reason === "subscription_cycle";
};

View File

@@ -1,4 +1,5 @@
import type Stripe from "stripe";
import { getStripeInvoice } from "@/external/stripe/invoices/operations/getStripeInvoice";
export const stripeInvoiceToStripeSubscriptionId = (
stripeInvoice: Stripe.Invoice,
@@ -6,3 +7,24 @@ export const stripeInvoiceToStripeSubscriptionId = (
const subId = stripeInvoice.parent?.subscription_details?.subscription;
return subId as string | undefined;
};
/**
* Finds the payment intent from a specific invoice.
*/
export const stripeInvoiceIdToPaymentIntent = async ({
stripeClient,
invoiceId,
}: {
stripeClient: Stripe;
invoiceId: string;
}): Promise<string | null> => {
const invoice = await getStripeInvoice({
stripeClient,
invoiceId,
expand: ["payments.data.payment.payment_intent"],
});
const firstPayment = invoice.payments?.data?.[0];
const payment = firstPayment?.payment;
return payment?.payment_intent.id ?? null;
};

View File

@@ -2,18 +2,17 @@ import {
type AppEnv,
type Customer,
ErrCode,
hashString,
type Organization,
ProcessorType,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { Stripe } from "stripe";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCustomer } from "@/external/stripe/customers";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext";
import type { Logger } from "../logtail/logtailUtils";
export const getStripeCus = async ({
stripeCli,
@@ -30,190 +29,6 @@ export const getStripeCus = async ({
}
};
export const createStripeCusIfNotExists = async ({
db,
org,
env,
customer,
logger,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
customer: Customer;
logger: Logger;
}) => {
const stripeCli = createStripeCli({ org, env });
const getCurrentStripeCus = async () => {
// 1. If no processor, create new customer
if (!customer.processor?.id) return null;
try {
const stripeCus = await stripeCli.customers.retrieve(
customer.processor.id,
{
expand: [
"test_clock",
"invoice_settings.default_payment_method",
"discount.source.coupon.applies_to",
],
},
);
// 2. If customer is deleted, create new customer
if (stripeCus.deleted) return null;
// 3. If customer is not deleted, return customer
return stripeCus as Stripe.Customer;
} catch (_error) {
// 4. If error, create new customer
if (
_error instanceof Stripe.errors.StripeError &&
_error.code?.includes("resource_missing")
) {
return null;
}
throw _error;
}
};
// 1. Get current stripe customer
const stripeCus = await getCurrentStripeCus();
if (stripeCus) return stripeCus;
// 2. If no current stripe customer, create new customer
logger.info(`Creating new stripe customer for ${customer.id}`);
const idempotencyKey = hashString(
`stripe-create-cus:${customer.id || customer.internal_id}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
);
const stripeCustomer = await createStripeCustomer({
org,
env,
customer,
idempotencyKey,
});
await CusService.update({
db,
idOrInternalId: customer.internal_id,
orgId: org.id,
env,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
customer.processor = {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
};
return stripeCustomer;
// let createNew = false;
// const stripeCli = createStripeCli({ org, env });
// if (!customer.processor || !customer.processor.id) {
// createNew = true;
// } else {
// try {
// const stripeCus = await stripeCli.customers.retrieve(
// customer.processor.id,
// {
// expand: ["test_clock", "invoice_settings.default_payment_method"],
// },
// );
// if (!stripeCus.deleted) {
// return stripeCus as Stripe.Customer;
// } else {
// createNew = true;
// }
// } catch (_error) {
// createNew = true;
// }
// }
// if (createNew) {
// logger.info(`Creating new stripe customer for ${customer.id}`);
// const stripeCustomer = await createStripeCustomer({
// org,
// env,
// customer,
// });
// await CusService.update({
// db,
// idOrInternalId: customer.internal_id,
// orgId: org.id,
// env,
// update: {
// processor: {
// id: stripeCustomer.id,
// type: ProcessorType.Stripe,
// },
// },
// });
// customer.processor = {
// id: stripeCustomer.id,
// type: ProcessorType.Stripe,
// };
// return stripeCustomer;
// }
};
export const createStripeCustomer = async ({
org,
env,
customer,
testClockId,
metadata,
idempotencyKey,
}: {
org: Organization;
env: AppEnv;
customer: Customer;
testClockId?: string;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
}) => {
const stripeCli = createStripeCli({ org, env });
try {
const stripeCustomer = await stripeCli.customers.create(
{
name: customer.name || undefined,
email: customer.email || undefined,
metadata: {
...(metadata || {}),
autumn_id: customer.id || null,
autumn_internal_id: customer.internal_id,
},
test_clock: testClockId,
},
idempotencyKey
? {
idempotencyKey,
}
: undefined,
);
return stripeCustomer;
} catch (error: any) {
throw new RecaseError({
message: `Error creating customer in Stripe. ${error.message}`,
code: ErrCode.StripeCreateCustomerFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};
export const deleteStripeCustomer = async ({
org,
env,
@@ -321,10 +136,9 @@ export const attachPmToCus = async ({
let stripeCusId = customer.processor?.id;
if (!stripeCusId) {
const stripeCustomer = await createStripeCustomer({
org,
env,
ctx: { org, env, db } as any,
customer,
testClockId,
options: { testClockId },
});
await CusService.update({

View File

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

View File

@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { stripeLoggerMiddleware } from "@/external/stripe/webhookMiddlewares/stripeLoggerMiddleware.js";
import { handleStripeWebhookEvent } from "./handleStripeWebhookEvent.js";
import { stripeConnectSeederMiddleware } from "./webhookMiddlewares/stripeConnectSeederMiddleware.js";
import { stripeIdempotencyMiddleware } from "./webhookMiddlewares/stripeIdempotencyMiddleware.js";
import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacySeederMiddleware.js";
import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js";
import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js";
@@ -16,6 +17,7 @@ stripeWebhookRouter.post(
stripeWebhookRefreshMiddleware,
stripeToAutumnCustomerMiddleware,
stripeLoggerMiddleware,
stripeIdempotencyMiddleware,
handleStripeWebhookEvent,
);
@@ -26,5 +28,6 @@ stripeWebhookRouter.post(
stripeWebhookRefreshMiddleware,
stripeToAutumnCustomerMiddleware,
stripeLoggerMiddleware,
stripeIdempotencyMiddleware,
handleStripeWebhookEvent,
);

View File

@@ -0,0 +1,4 @@
export * from "./operations/getStripeActiveSubscriptionSchedule";
export * from "./utils/convertStripeSubscriptionScheduleUtils";
export * from "./utils/logStripeSchedulePhaseUtils";

View File

@@ -0,0 +1,19 @@
import type Stripe from "stripe";
export const getStripeActiveSubscriptionSchedule = async ({
stripeClient,
subscriptionScheduleId,
}: {
stripeClient: Stripe;
subscriptionScheduleId: string;
}): Promise<Stripe.SubscriptionSchedule | undefined> => {
const schedule = await stripeClient.subscriptionSchedules.retrieve(
subscriptionScheduleId,
);
if (schedule.status === "canceled" || schedule.status === "released") {
return undefined;
}
return schedule;
};

View File

@@ -1,2 +1,3 @@
export * from "./operations/getExpandedStripeSubscription.js";
export * from "./types/stripeDiscountTypes.js";
export * from "./utils/convertStripeSubscription.js";

View File

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

View File

@@ -0,0 +1,45 @@
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.source.coupon.applies_to".
*
* Uses the `source.coupon` structure introduced in Stripe API version 2025-09-30.clover.
*
* @see https://docs.stripe.com/changelog/clover/2025-09-30/add-discount-source-property
*/
export type StripeCustomerExpandedDiscount = Omit<Stripe.Discount, "source"> & {
source: {
coupon: Stripe.Coupon & {
applies_to: Stripe.Coupon.AppliesTo | null;
};
type: "coupon";
};
};
/**
* 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;
};

View File

@@ -1,5 +1,6 @@
import { notNullish } from "@autumn/shared";
import type Stripe from "stripe";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
/** Stripe subscription that is trialing with guaranteed trial_end */
export type TrialingStripeSubscription = Stripe.Subscription & {
@@ -54,3 +55,58 @@ 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);
};
/**
* Checks if a Stripe subscription was canceled immediately (not at end of period).
*
* For Stripe dashboard-initiated cancellations:
* - "Cancel at end of period" → cancel_at_period_end = true → returns false
* - "Cancel immediately" → cancel_at_period_end = false → returns true
*
* Note: This is only reliable for external (non-Autumn) cancellations.
* Autumn-initiated cancellations use a lock mechanism and are filtered out
* before this check in the subscription.deleted handler.
*/
export const wasImmediateStripeCancellation = (
stripeSubscription?: Stripe.Subscription,
): boolean => {
if (!stripeSubscription) return false;
if (!stripeSubscription.ended_at) return false;
const latestPeriodEnd = getLatestPeriodEnd({ sub: stripeSubscription });
const differenceInSeconds = Math.abs(
stripeSubscription.ended_at - latestPeriodEnd,
);
return differenceInSeconds > 20;
// // If cancel_at_period_end is true, it was an end-of-period cancellation
// // If false, it was an immediate cancellation
// return !stripeSubscription.cancel_at_period_end;
};

View File

@@ -129,3 +129,13 @@ export const stripeSubscriptionToNowMs = async ({
return Date.now();
};
export const stripeSubscriptionToScheduleId = ({
stripeSubscription,
}: {
stripeSubscription: ExpandedStripeSubscription;
}): string | null => {
return typeof stripeSubscription.schedule === "string"
? stripeSubscription.schedule
: (stripeSubscription.schedule?.id ?? null);
};

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

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

View File

@@ -0,0 +1,11 @@
export {
type BaseWebhookEventContext,
buildBillingContextForArrearInvoice,
} from "./buildBillingContextFromWebhook";
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";
export {
type SubscriptionEventContext,
trackCustomerProductDeletion,
trackCustomerProductUpdate,
} from "./trackCustomerProductUpdate";

View File

@@ -0,0 +1,65 @@
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
import type { StripeSubscriptionDeletedContext } from "../handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext";
import type { StripeSubscriptionUpdatedContext } from "../handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext";
type EventContext =
| StripeSubscriptionUpdatedContext
| StripeSubscriptionDeletedContext;
const hasDeletedCustomerProducts = (
context: EventContext,
): context is StripeSubscriptionDeletedContext => {
return "deletedCustomerProducts" in context;
};
/**
* Logs all customer product updates and deletions in a structured format for easy querying in Axiom.
* Called at the end of subscription handlers to provide a summary.
*/
export const logCustomerProductUpdates = ({
ctx,
eventContext,
logPrefix,
}: {
ctx: StripeWebhookContext;
eventContext: EventContext;
logPrefix: string;
}): void => {
const { logger } = ctx;
const { updatedCustomerProducts } = eventContext;
const updates = updatedCustomerProducts.map(
({ customerProduct, updates }) => ({
id: customerProduct.id,
productId: customerProduct.product.id,
productName: customerProduct.product.name,
statusBefore: customerProduct.status,
updates,
}),
);
const deletions = hasDeletedCustomerProducts(eventContext)
? eventContext.deletedCustomerProducts.map((customerProduct) => ({
id: customerProduct.id,
productId: customerProduct.product.id,
productName: customerProduct.product.name,
status: customerProduct.status,
}))
: [];
if (updates.length === 0 && deletions.length === 0) return;
addToExtraLogs({
ctx,
extras: {
updates,
deletions,
},
});
// logger.info(`${logPrefix} Customer product changes`, {
// updates,
// deletions,
// });
};

View File

@@ -0,0 +1,36 @@
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) => {
const hasDiscount =
item.finalAmount !== undefined && item.finalAmount !== item.amount;
return hasDiscount
? `${item.description}: ${item.amount}${item.finalAmount} (discounted)`
: `${item.description}: ${item.amount}`;
}),
updateCustomerEntitlements: updateCustomerEntitlements.map(
(update) => ({
featureId: update.customerEntitlement.entitlement.feature?.id,
...update.updates,
next_reset_at: formatMs(update.updates?.next_reset_at),
}),
),
},
},
});
};

View File

@@ -0,0 +1,84 @@
import type { FullCusProduct, InsertCustomerProduct } from "@autumn/shared";
import type { StripeSubscriptionDeletedContext } from "../handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext";
import type { StripeSubscriptionUpdatedContext } from "../handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext";
export type SubscriptionEventContext =
| StripeSubscriptionUpdatedContext
| StripeSubscriptionDeletedContext;
/**
* Tracks a customer product update for subscription event workflows.
* - Adds to updatedCustomerProducts list for logging/audit
* - Updates customerProducts array in place so subsequent tasks see the change
* - Updates fullCustomer.customer_products so actions can see the change
*/
export const trackCustomerProductUpdate = ({
eventContext,
customerProduct,
updates,
}: {
eventContext: SubscriptionEventContext;
customerProduct: FullCusProduct;
updates: Partial<InsertCustomerProduct>;
}): FullCusProduct => {
const { customerProducts, fullCustomer, updatedCustomerProducts } =
eventContext;
// Track the update for logging
updatedCustomerProducts.push({ customerProduct, updates });
// Create updated product
const updatedProduct = { ...customerProduct, ...updates } as FullCusProduct;
// Update in customerProducts array
const idx = customerProducts.findIndex((cp) => cp.id === customerProduct.id);
if (idx >= 0) {
customerProducts[idx] = updatedProduct;
}
// Also update in fullCustomer.customer_products so actions can see the change
const fullCustomerIdx = fullCustomer.customer_products.findIndex(
(cp) => cp.id === customerProduct.id,
);
if (fullCustomerIdx >= 0) {
fullCustomer.customer_products[fullCustomerIdx] = updatedProduct;
}
return updatedProduct;
};
/**
* Tracks a customer product deletion for subscription event workflows.
* - Adds to deletedCustomerProducts list for logging/audit (only for deleted context)
* - Removes from customerProducts array in place so subsequent tasks see the change
* - Removes from fullCustomer.customer_products so actions can see the change
*/
export const trackCustomerProductDeletion = ({
eventContext,
customerProduct,
}: {
eventContext:
| StripeSubscriptionDeletedContext
| StripeSubscriptionUpdatedContext;
customerProduct: FullCusProduct;
}): void => {
const { customerProducts, fullCustomer, deletedCustomerProducts } =
eventContext;
// Track the deletion for logging
deletedCustomerProducts.push(customerProduct);
// Remove from customerProducts array
const idx = customerProducts.findIndex((cp) => cp.id === customerProduct.id);
if (idx >= 0) {
customerProducts.splice(idx, 1);
}
// Also remove from fullCustomer.customer_products so actions can see the change
const fullCustomerIdx = fullCustomer.customer_products.findIndex(
(cp) => cp.id === customerProduct.id,
);
if (fullCustomerIdx >= 0) {
fullCustomer.customer_products.splice(fullCustomerIdx, 1);
}
};

View File

@@ -7,7 +7,7 @@ import {
findPriceFromStripeId,
} from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { constructSub } from "@/internal/subscriptions/subUtils.js";
import { initSubscription } from "@/internal/subscriptions/utils/initSubscription.js";
import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js";
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
@@ -30,9 +30,8 @@ export const handleCheckoutSub = async ({
await SubService.createSub({
db,
sub: constructSub({
sub: initSubscription({
stripeId: subscription.id,
usageFeatures: attachParams.itemSets?.[0]?.usageFeatures || [],
orgId: org.id,
env: attachParams.customer.env,
currentPeriodStart: start,

View File

@@ -161,7 +161,7 @@ export const handleInvoiceCreated = async ({
env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.Expired,
// CusProductStatus.Expired,
CusProductStatus.PastDue,
],
});
@@ -173,52 +173,12 @@ export const handleInvoiceCreated = async ({
return;
}
const internalEntityId = activeProducts.find(
(p) => p.internal_entity_id,
)?.internal_entity_id;
await FeatureService.list({
db,
orgId: org.id,
env,
});
if (internalEntityId) {
// try {
// let stripeCli = createStripeCli({ org, env });
// let entity = await EntityService.getByInternalId({
// db,
// internalId: internalEntityId,
// });
// let feature = features.find(
// (f) => f.internal_id == entity?.internal_feature_id
// );
// let entDetails = "";
// if (entity.name) {
// entDetails = `${entity.name}${
// entity.id ? ` (ID: ${entity.id})` : ""
// }`;
// } else if (entity.id) {
// entDetails = `${entity.id}`;
// }
// if (entDetails && feature) {
// await stripeCli.invoices.update(invoice.id!, {
// description: `${getFeatureName({
// feature,
// plural: false,
// capitalize: true,
// })}: ${entDetails}`,
// });
// }
// } catch (error: any) {
// if (
// error.message != "Finalized invoices can't be updated in this way"
// ) {
// logger.error(`Failed to add entity ID to invoice description`, error);
// }
// }
}
const stripeSubs = await getStripeSubs({
stripeCli: createStripeCli({ org, env }),
subIds: activeProducts.flatMap((p) => p.subscription_ids || []),

View File

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

View File

@@ -0,0 +1,55 @@
import { type FullCusEntWithFullCusProduct, formatMs } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { appendToExtraLogs } from "@/utils/logging/addToExtraLogs";
export const logPrepaidPriceProcessed = ({
ctx,
customerEntitlement,
previousQuantity,
resetQuantity,
newAllowance,
nextResetAt,
}: {
ctx: StripeWebhookContext;
customerEntitlement: FullCusEntWithFullCusProduct;
previousQuantity: number;
resetQuantity: number;
newAllowance: number;
nextResetAt: number;
}) => {
appendToExtraLogs({
ctx,
key: "prepaidPricesProcessed",
value: {
featureId: customerEntitlement.entitlement.feature?.id,
cusEntId: customerEntitlement.id,
previousQuantity,
resetQuantity,
newAllowance,
nextResetAt: formatMs(nextResetAt),
},
});
};
export const logAllocatedPriceProcessed = ({
ctx,
customerEntitlement,
replaceablesRemoved,
balanceIncremented,
}: {
ctx: StripeWebhookContext;
customerEntitlement: FullCusEntWithFullCusProduct;
replaceablesRemoved: number;
balanceIncremented: number;
}) => {
appendToExtraLogs({
ctx,
key: "allocatedPricesProcessed",
value: {
featureId: customerEntitlement.entitlement.feature?.id,
cusEntId: customerEntitlement.id,
replaceablesRemoved,
balanceIncremented,
},
});
};

View File

@@ -0,0 +1,154 @@
import {
type FullCusProduct,
type FullCustomer,
isCustomerProductOnStripeSubscription,
isCustomerProductOnStripeSubscriptionSchedule,
} 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,
stripeSubscriptionToScheduleId,
} 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;
}
// 3. Get expanded stripe subscription
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: stripeSubscriptionId,
});
// 5. Get customer products by subscription ID
const currentCustomerProducts = fullCustomer.customer_products.filter(
(cp) => {
const onStripeSubscription = isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId,
});
return onStripeSubscription;
},
);
const customerProducts =
await customerProductActions.expiredCache.getAndMerge({
customerProducts: currentCustomerProducts,
stripeSubscriptionId,
});
const scheduledCustomerProducts = fullCustomer.customer_products.filter(
(cp) => {
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
return isCustomerProductOnStripeSubscriptionSchedule({
customerProduct: cp,
stripeSubscriptionScheduleId: scheduleId,
});
},
);
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,
...scheduledCustomerProducts,
];
// 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,
};
};

View File

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

View File

@@ -0,0 +1,108 @@
import { customerEntitlementShouldBeBilled, secondsToMs } from "@autumn/shared";
import { getLatestPeriodStart } from "@/external/stripe/stripeSubUtils/convertSubUtils";
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 { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils";
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
/**
* Checks if the subscription's trial just ended.
* When a trial ends, Stripe creates the first real billing period where
* `current_period_start` equals `trial_end`. In this case, we should skip
* billing for consumable usage since trial usage is free.
*/
const hasTrialJustEnded = ({
stripeSubscription,
}: {
stripeSubscription: InvoiceCreatedContext["stripeSubscription"];
}): boolean => {
const trialEnd = stripeSubscription.trial_end;
if (!trialEnd) return false;
const periodStart = getLatestPeriodStart({ sub: stripeSubscription });
return trialEnd === periodStart;
};
/**
* 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, stripeSubscription } = eventContext;
const isPeriodicInvoice =
stripeInvoice.billing_reason === "subscription_cycle";
const trialJustEnded = hasTrialJustEnded({ stripeSubscription });
if (!isPeriodicInvoice) return;
if (trialJustEnded) {
ctx.logger.info(
"[invoice.created] Trial just ended, skipping consumable charges",
);
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) {
await createStripeInvoiceItems({
ctx,
invoiceItems: lineItemsToCreateInvoiceItemsParams({
stripeCustomerId: eventContext.stripeCustomer.id,
stripeInvoiceId: stripeInvoice.id,
lineItems,
}),
});
}
await CusEntService.batchUpdate({
db: ctx.db,
data: updateCustomerEntitlements,
});
// Handle rollovers
updateCustomerEntitlements.forEach(async (update) => {
const rolloverUpdates = getRolloverUpdates({
cusEnt: update.customerEntitlement,
nextResetAt: Date.now(),
});
await RolloverService.insert({
db: ctx.db,
rows: rolloverUpdates.toInsert,
fullCusEnt: update.customerEntitlement,
});
});
};

View File

@@ -0,0 +1,178 @@
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 previousQuantity = options?.quantity ?? 0;
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,
previousQuantity,
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,
});
}
};

View File

@@ -0,0 +1,103 @@
import { cp } from "@autumn/shared";
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions";
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, stripeSubscription } =
eventContext;
// Skip first invoice (subscription_create)
if (stripeInvoice.billing_reason !== "subscription_cycle") {
ctx.logger.debug(
"[invoice.created] Skipping invoice upsert for non periodic invoice",
);
return;
}
// Add scheduled customer products that have started
const startedScheduledCustomerProducts =
fullCustomer.customer_products.filter((customerProduct) => {
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
const { valid: hasStarted } = cp(customerProduct)
.onStripeSubscription({
stripeSubscriptionId: stripeSubscription.id,
})
.or.onStripeSchedule({
stripeSubscriptionScheduleId: scheduleId,
})
.scheduled()
.hasStarted({ nowMs: eventContext.nowMs });
return hasStarted;
});
const allCustomerProducts = [
...customerProducts,
...startedScheduledCustomerProducts,
];
const productIds = [
...new Set(allCustomerProducts.map((cp) => cp.product.id)),
];
const internalProductIds = [
...new Set(allCustomerProducts.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}`);
};

View File

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

View File

@@ -1,3 +1,4 @@
import type Stripe from "stripe";
import { convertToChargeAutomatically } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/convertToChargeAutomatically.js";
import { queueCheckoutRewardTasks } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/queueCheckoutRewardTasks.js";
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/upsertAutumnInvoice.js";
@@ -8,10 +9,15 @@ import { handleStripeInvoiceMetadata } from "./tasks/handleStripeInvoiceMetadata
export const handleStripeInvoicePaid = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.InvoicePaidEvent;
}) => {
const invoicePaidContext = await setupStripeInvoicePaidContext({ ctx });
const invoicePaidContext = await setupStripeInvoicePaidContext({
ctx,
event,
});
if (!invoicePaidContext) {
ctx.logger.warn("[invoice.paid] invoicePaidContext not found, skipping");

View File

@@ -1,9 +1,13 @@
import { cp, type FullCusProduct } from "@autumn/shared";
import {
type FullCusProduct,
isCustomerProductOnStripeSubscription,
} from "@autumn/shared";
import type Stripe from "stripe";
import {
type ExpandedStripeInvoice,
getStripeInvoice,
} from "@/external/stripe/invoices/operations/getStripeInvoice.js";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { stripeInvoiceToStripeSubscriptionId } from "../../invoices/utils/convertStripeInvoice";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
@@ -15,12 +19,14 @@ export interface StripeInvoicePaidContext {
export const setupStripeInvoicePaidContext = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.InvoicePaidEvent;
}): Promise<StripeInvoicePaidContext | null> => {
const { stripeEvent, stripeCli } = ctx;
const { stripeCli } = ctx;
const invoiceData = stripeEvent.data.object as Stripe.Invoice;
const invoiceData = event.data.object;
const stripeInvoice = await getStripeInvoice({
stripeClient: stripeCli,
@@ -36,16 +42,19 @@ export const setupStripeInvoicePaidContext = async ({
let customerProducts: FullCusProduct[] | undefined;
if (fullCustomer && stripeSubscriptionId) {
customerProducts = fullCustomer.customer_products.filter(
(customerProduct) => {
const { valid } = cp(customerProduct)
.paid()
.recurring()
.onStripeSubscription({ stripeSubscriptionId });
return valid;
},
customerProducts = fullCustomer.customer_products.filter((cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId,
}),
);
customerProducts = await customerProductActions.expiredCache.getAndMerge({
customerProducts,
stripeSubscriptionId,
});
fullCustomer.customer_products = customerProducts;
}
return {

View File

@@ -0,0 +1,48 @@
import type Stripe from "stripe";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
import { logCustomerProductUpdates } from "../common";
import { setupStripeSubscriptionDeletedContext } from "./setupStripeSubscriptionDeletedContext";
import { expireAndActivateCustomerProducts } from "./tasks/expireAndActivateCustomerProducts";
import { processConsumablePricesForSubscriptionDeleted } from "./tasks/processConsumablePricesForSubscriptionDeleted";
/**
* Handles Stripe subscription.deleted webhook.
*
* NOTE: Previously there was a race condition concern where subscription.updated
* might expire products before subscription.deleted arrives. For now we assume
* this is not an issue, but monitor if problems arise.
*/
export const handleStripeSubscriptionDeleted = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.CustomerSubscriptionDeletedEvent;
}) => {
const { logger } = ctx;
const eventContext = await setupStripeSubscriptionDeletedContext({
ctx,
event,
});
if (!eventContext) {
logger.debug("[sub.deleted] Skipping - context not found or locked");
return;
}
logger.info(`[sub.deleted] Processing subscription.deleted`);
// Task 1: Create invoices for arrear prices (usage-based)
await processConsumablePricesForSubscriptionDeleted({ ctx, eventContext });
// Task 2: Expire customer products + delete scheduled + activate defaults
await expireAndActivateCustomerProducts({ ctx, eventContext });
// Task 3: Log all customer product updates and deletions
logCustomerProductUpdates({
ctx,
eventContext,
logPrefix: "[sub.deleted]",
});
};

View File

@@ -0,0 +1,130 @@
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,
getExpandedStripeSubscription,
} from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
export interface StripeSubscriptionDeletedContext {
stripeSubscription: ExpandedStripeSubscription;
stripeCustomer: ExpandedStripeCustomer;
fullCustomer: FullCustomer;
/** Customer products that are on this subscription */
customerProducts: FullCusProduct[];
/** Current time in ms, respecting test clocks */
nowMs: number;
/** Customer's payment method for paying arrear invoices */
paymentMethod: Stripe.PaymentMethod | null;
/** Tracks all updates made to customer products during this handler */
updatedCustomerProducts: {
customerProduct: FullCusProduct;
updates: Partial<InsertCustomerProduct>;
}[];
/** Tracks all deletions made to customer products during this handler */
deletedCustomerProducts: FullCusProduct[];
}
/**
* Sets up context for the subscription deleted handler.
*
* Returns null if:
* - No fullCustomer in context
* - No customer products found for this subscription
* - Lock exists on the subscription (Autumn initiated the deletion)
*/
export const setupStripeSubscriptionDeletedContext = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.CustomerSubscriptionDeletedEvent;
}): Promise<StripeSubscriptionDeletedContext | null> => {
const { fullCustomer, logger } = ctx;
if (!fullCustomer) {
logger.warn("[sub.deleted] fullCustomer not found, skipping");
return null;
}
const stripeSubscriptionId = event.data.object.id;
// 1. Filter customer products on this subscription
const customerProducts = fullCustomer.customer_products.filter((cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId,
}),
);
if (customerProducts.length === 0) {
logger.info(
`[sub.deleted] No customer products found for subscription ${stripeSubscriptionId}`,
);
return null;
}
// 2. Check lock - if Autumn initiated this deletion, skip
const lock = await getStripeSubscriptionLock({
stripeSubscriptionId,
});
if (lock) {
logger.info(
`[sub.deleted] Skipping - lock found on subscription ${stripeSubscriptionId}`,
);
return null;
}
// 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.warn("[sub.deleted] 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 {
stripeSubscription,
stripeCustomer,
fullCustomer,
customerProducts,
nowMs,
paymentMethod,
updatedCustomerProducts: [],
deletedCustomerProducts: [],
};
};

View File

@@ -0,0 +1,113 @@
import {
type FullCusProduct,
findMainScheduledCustomerProductByGroup,
isCustomerProductFree,
isCustomerProductOnStripeSubscription,
isCustomerProductPaid,
} from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import {
trackCustomerProductDeletion,
trackCustomerProductUpdate,
} from "../../common";
import type { StripeSubscriptionDeletedContext } from "../setupStripeSubscriptionDeletedContext";
/**
* Handles customer product state changes when a subscription is deleted.
*
* 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,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: StripeSubscriptionDeletedContext;
}): Promise<void> => {
const { logger } = ctx;
const { customerProducts, fullCustomer, stripeSubscription } = eventContext;
logger.info(
`[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({
customerProduct,
stripeSubscriptionId: stripeSubscription.id,
});
if (!onStripeSubscription) continue;
// 2. Expire and activate default product if needed
const { updates } = await customerProductActions.expireAndActivateDefault({
ctx,
customerProduct,
fullCustomer,
});
expiredCustomerProducts.push(customerProduct);
trackCustomerProductUpdate({
eventContext,
customerProduct,
updates,
});
// Find scheduled main product in the same group
const scheduledCustomerProduct = findMainScheduledCustomerProductByGroup({
fullCustomer,
productGroup: customerProduct.product.group,
internalEntityId: customerProduct.internal_entity_id ?? undefined,
});
if (scheduledCustomerProduct) {
const scheduledIsFreeCustomerProduct = isCustomerProductFree(
scheduledCustomerProduct,
);
const scheduledIsPaidCustomerProduct = isCustomerProductPaid(
scheduledCustomerProduct,
);
if (scheduledIsFreeCustomerProduct) {
const { updates: activateScheduledUpdates } =
await customerProductActions.activateScheduled({
ctx,
customerProduct: scheduledCustomerProduct,
fullCustomer,
});
trackCustomerProductUpdate({
eventContext,
customerProduct: scheduledCustomerProduct,
updates: activateScheduledUpdates,
});
} else if (scheduledIsPaidCustomerProduct) {
await CusProductService.delete({
db: ctx.db,
cusProductId: scheduledCustomerProduct.id,
});
trackCustomerProductDeletion({
eventContext,
customerProduct: scheduledCustomerProduct,
});
}
}
}
/**
* 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,
});
};

View File

@@ -0,0 +1,112 @@
import { customerProductsToProducts, secondsToMs } from "@autumn/shared";
import {
stripeSubscriptionHasMeteredItems,
wasImmediateStripeCancellation,
} 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";
/**
* Checks if the subscription was canceled during/at trial end.
* When a trialing subscription is canceled at period end, `ended_at` equals `trial_end`.
* In this case, we should skip arrear charges since trial usage is free.
*/
const wasTrialCancellation = (
stripeSubscription: StripeSubscriptionDeletedContext["stripeSubscription"],
): boolean => {
const trialEnd = stripeSubscription.trial_end;
const endedAt = stripeSubscription.ended_at;
if (!trialEnd || !endedAt) return false;
// If ended_at equals trial_end, the subscription was canceled at trial end
return trialEnd === endedAt;
};
/**
* Creates a single invoice for all usage-based (arrear) prices across all customer products
* when a subscription is deleted.
*
* Skips creating an arrear invoice if:
* 1. The subscription has metered items (Stripe handles metered billing automatically)
* 2. The cancellation was immediate (not end-of-period) - we don't charge overage on immediate cancels
* 3. The subscription was canceled at trial end - trial usage is free
*
* Note: Autumn-initiated deletions are filtered out before this via the lock mechanism
* in setupStripeSubscriptionDeletedContext.
*/
export const processConsumablePricesForSubscriptionDeleted = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: StripeSubscriptionDeletedContext;
}): Promise<void> => {
const { db } = ctx;
const { stripeSubscription, fullCustomer, customerProducts } = eventContext;
// Skip if subscription has metered items - Stripe handles metered billing automatically
if (stripeSubscriptionHasMeteredItems(stripeSubscription)) return;
// Skip if this was an immediate cancellation (not end-of-period)
// We only bill arrear usage when the subscription naturally ends at period end
// This matches the behavior of customer-level consumables (metered) where
// Stripe also doesn't charge overage on immediate cancels
if (wasImmediateStripeCancellation(stripeSubscription)) return;
// Skip if the subscription was canceled at trial end - trial usage is free
if (wasTrialCancellation(stripeSubscription)) {
ctx.logger.info(
"[subscription.deleted] Subscription canceled at trial end, skipping consumable charges",
);
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) {
// 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 },
},
});
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: customerProductsToProducts({ customerProducts }),
fullCustomer,
});
if (!paid) return;
}
// 4. Reset usage balances for all affected customer entitlements (only if payment succeeded)
await CusEntService.batchUpdate({
db,
data: updateCustomerEntitlements,
});
};

View File

@@ -1,22 +1,26 @@
import { formatMs } from "@autumn/shared";
import type Stripe from "stripe";
import { handleStripeSubscriptionCanceled } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/handleStripeSubscriptionCanceled.js";
import { syncAutumnSubscription } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/syncAutumnSubscription.js";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
import { logCustomerProductUpdates } from "../common";
import { setupStripeSubscriptionUpdatedContext } from "./setupStripeSubscriptionUpdatedContext.js";
import { handleCancelOnPastDue } from "./tasks/handleCancelOnPastDue.js";
import { handleSchedulePhaseChanges } from "./tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.js";
import { handleStripeSubscriptionRenewed } from "./tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.js";
import { syncCustomerProductStatus } from "./tasks/syncCustomerProductStatus/syncCustomerProductStatus.js";
import { logCustomerProductUpdates } from "./utils/logCustomerProductUpdates.js";
export const handleStripeSubscriptionUpdated = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.CustomerSubscriptionUpdatedEvent;
}) => {
const subscriptionUpdatedContext =
await setupStripeSubscriptionUpdatedContext({
ctx,
event,
});
ctx.logger.debug(
@@ -33,7 +37,7 @@ export const handleStripeSubscriptionUpdated = async ({
// 1. Handle schedule phase changes
await handleSchedulePhaseChanges({
ctx,
subscriptionUpdatedContext,
eventContext: subscriptionUpdatedContext,
});
// 2. Sync status from Stripe to customer products (sends webhook event too)
@@ -68,6 +72,7 @@ export const handleStripeSubscriptionUpdated = async ({
// 6. Log all customer product updates
logCustomerProductUpdates({
ctx,
subscriptionUpdatedContext,
eventContext: subscriptionUpdatedContext,
logPrefix: "[sub.updated]",
});
};

View File

@@ -3,17 +3,16 @@ import { createStripeCli } from "@/external/connect/createStripeCli";
import { getExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription.js";
import { stripeSubscriptionToNowMs } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
import type {
StripeSubscriptionUpdatedContext,
SubscriptionPreviousAttributes,
} from "./stripeSubscriptionUpdatedContext.js";
import type { StripeSubscriptionUpdatedContext } from "./stripeSubscriptionUpdatedContext.js";
export const setupStripeSubscriptionUpdatedContext = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.CustomerSubscriptionUpdatedEvent;
}): Promise<StripeSubscriptionUpdatedContext | null> => {
const { stripeEvent, fullCustomer, org, env } = ctx;
const { fullCustomer, org, env } = ctx;
if (!fullCustomer) {
ctx.logger.warn("[sub.updated] fullCustomer not found, skipping");
@@ -22,11 +21,10 @@ export const setupStripeSubscriptionUpdatedContext = async ({
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: (stripeEvent.data.object as Stripe.Subscription).id,
subscriptionId: event.data.object.id,
});
const previousAttributes = stripeEvent.data
.previous_attributes as SubscriptionPreviousAttributes;
const previousAttributes = event.data.previous_attributes ?? {};
// Get current time (respecting test clocks)
const stripeCli = createStripeCli({ org, env });
@@ -42,5 +40,6 @@ export const setupStripeSubscriptionUpdatedContext = async ({
customerProducts: [...fullCustomer.customer_products],
nowMs,
updatedCustomerProducts: [],
deletedCustomerProducts: [],
};
};

View File

@@ -1,4 +1,8 @@
import type { FullCusProduct, FullCustomer } from "@autumn/shared";
import type {
FullCusProduct,
FullCustomer,
InsertCustomerProduct,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
@@ -25,6 +29,8 @@ export interface StripeSubscriptionUpdatedContext {
updatedCustomerProducts: {
customerProduct: FullCusProduct;
updates: Partial<FullCusProduct>;
updates: Partial<InsertCustomerProduct>;
}[];
/** Tracks all deletions made to customer products during this handler */
deletedCustomerProducts: FullCusProduct[];
}

Some files were not shown because too many files have changed in this diff Show More