merged with dev
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npx ultracite fix"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
106
.claude/skills/workflows/SKILL.md
Normal file
106
.claude/skills/workflows/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: workflows
|
||||
description: Create async background tasks (workflows) using SQS or Hatchet. Use when building queue jobs, background processing, or async tasks.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Workflows are async tasks processed by background workers. Two runners:
|
||||
|
||||
| Runner | Use Case | Features |
|
||||
|--------|----------|----------|
|
||||
| **SQS** | Simple fire-and-forget tasks | Fast, no dependencies, max 15min delay |
|
||||
| **Hatchet** | Complex workflows needing retries, multi-step, or long delays | Typed outputs, configurable timeouts, observability |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Job Name
|
||||
|
||||
```typescript
|
||||
// server/src/queue/JobName.ts
|
||||
export enum JobName {
|
||||
// ... existing
|
||||
MyNewWorkflow = "my-new-workflow",
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define Payload & Register
|
||||
|
||||
```typescript
|
||||
// server/src/queue/workflows.ts
|
||||
|
||||
// Add payload type
|
||||
export type MyNewWorkflowPayload = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
// ... your fields
|
||||
};
|
||||
|
||||
// Add to registry
|
||||
const workflowRegistry = {
|
||||
// ... existing
|
||||
myNewWorkflow: {
|
||||
jobName: JobName.MyNewWorkflow,
|
||||
runner: "sqs", // or "hatchet"
|
||||
} as WorkflowConfig<MyNewWorkflowPayload>,
|
||||
};
|
||||
|
||||
// Add trigger function
|
||||
export const workflows = {
|
||||
// ... existing
|
||||
triggerMyNewWorkflow: (payload: MyNewWorkflowPayload, options?: TriggerOptions) =>
|
||||
triggerWorkflow({ name: "myNewWorkflow", payload, options }),
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Create Handler
|
||||
|
||||
**For SQS:** See [references/SQS.md](references/SQS.md)
|
||||
|
||||
**For Hatchet:** See [references/HATCHET.md](references/HATCHET.md)
|
||||
|
||||
### 4. Trigger from Code
|
||||
|
||||
```typescript
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
await workflows.triggerMyNewWorkflow({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
});
|
||||
|
||||
// With delay
|
||||
await workflows.triggerMyNewWorkflow(payload, { delayMs: 5000 });
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/src/
|
||||
├── queue/
|
||||
│ ├── JobName.ts # Job name enum
|
||||
│ ├── workflows.ts # Registry + triggers
|
||||
│ └── initWorkers.ts # SQS message routing
|
||||
└── internal/.../workflows/
|
||||
└── myNewWorkflow/
|
||||
├── myNewWorkflow.ts # Handler
|
||||
└── triggerMyNewWorkflow.ts # (optional) trigger helper
|
||||
```
|
||||
|
||||
## Required Payload Fields
|
||||
|
||||
All workflows must include:
|
||||
```typescript
|
||||
{
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId?: string; // optional but common
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [references/SQS.md](references/SQS.md) - SQS workflow implementation
|
||||
- [references/HATCHET.md](references/HATCHET.md) - Hatchet workflow implementation
|
||||
135
.claude/skills/workflows/references/HATCHET.md
Normal file
135
.claude/skills/workflows/references/HATCHET.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Hatchet Workflows
|
||||
|
||||
For complex workflows needing retries, multi-step, typed outputs, or long delays.
|
||||
|
||||
## Workflow Definition
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import { hatchet } from "@/external/hatchet/initHatchet.js";
|
||||
import { createWorkflowTask } from "@/queue/hatchetWorkflows/createWorkflowTask.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
|
||||
// 1. Define input/output types
|
||||
export type MyWorkflowInput = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
type MyWorkflowOutput = {
|
||||
myTask: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 2. Create workflow (only if Hatchet enabled)
|
||||
export const myWorkflow = hatchet?.workflow<MyWorkflowInput, MyWorkflowOutput>({
|
||||
name: JobName.MyWorkflow,
|
||||
});
|
||||
|
||||
// 3. Define task
|
||||
myWorkflow?.task({
|
||||
name: JobName.MyWorkflow,
|
||||
executionTimeout: "60s",
|
||||
fn: createWorkflowTask<MyWorkflowInput, MyWorkflowOutput["myTask"]>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
const { customerId } = input;
|
||||
|
||||
// Your logic here
|
||||
autumnContext.logger.info(`Processing ${customerId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Completed",
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Register Worker
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
export const initHatchetWorker = async () => {
|
||||
if (!hatchet) return;
|
||||
|
||||
const worker = await hatchet.worker("hatchet-worker", {
|
||||
workflows: [
|
||||
verifyCacheConsistency!,
|
||||
myWorkflow!, // Add here
|
||||
],
|
||||
});
|
||||
|
||||
worker.start().catch(console.error);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in queueUtils.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/queueUtils.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const hatchetWorkflows: Record<JobName, any> = {
|
||||
[JobName.VerifyCacheConsistency]: verifyCacheConsistency,
|
||||
[JobName.MyWorkflow]: myWorkflow, // Add here
|
||||
};
|
||||
```
|
||||
|
||||
## Triggering with Options
|
||||
|
||||
```typescript
|
||||
await workflows.triggerMyWorkflow(payload, {
|
||||
delayMs: 5000,
|
||||
metadata: {
|
||||
workflowId: generateId("workflow"),
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## createWorkflowTask Helper
|
||||
|
||||
Provides:
|
||||
- Automatic `AutumnContext` creation from input
|
||||
- Error handling with Sentry integration
|
||||
- Workflow logging context
|
||||
|
||||
```typescript
|
||||
createWorkflowTask<TInput, TOutput>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
// input: Your typed input
|
||||
// autumnContext: Full AutumnContext with logger, db, org, env, etc.
|
||||
return output;
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "hatchet"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create workflow file with `hatchet?.workflow()` + `.task()`
|
||||
6. ☐ Add to `hatchetWorkflows` map in `queueUtils.ts`
|
||||
7. ☐ Add to `initHatchetWorker` workflows array
|
||||
|
||||
## SQS vs Hatchet
|
||||
|
||||
| Feature | SQS | Hatchet |
|
||||
|---------|-----|---------|
|
||||
| Setup complexity | Lower | Higher |
|
||||
| Typed output | No | Yes |
|
||||
| Multi-step tasks | No | Yes |
|
||||
| Configurable timeout | 30s visibility | Per-task |
|
||||
| Max delay | 15 minutes | Unlimited |
|
||||
| Observability | CloudWatch | Hatchet UI |
|
||||
124
.claude/skills/workflows/references/SQS.md
Normal file
124
.claude/skills/workflows/references/SQS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# SQS Workflows
|
||||
|
||||
Simple async tasks processed by SQS workers.
|
||||
|
||||
## Handler Signature
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { MyWorkflowPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const myWorkflow = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: MyWorkflowPayload;
|
||||
}) => {
|
||||
const { customerId } = payload;
|
||||
|
||||
// Your logic here
|
||||
ctx.logger.info(`Processing ${customerId}`);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in initWorkers.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const processMessage = async ({ message, db }) => {
|
||||
// ... existing code
|
||||
|
||||
if (job.name === JobName.MyWorkflow) {
|
||||
if (!ctx) {
|
||||
workerLogger.error("No context found for my workflow job");
|
||||
return;
|
||||
}
|
||||
await myWorkflow({ ctx, payload: job.data });
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of handlers
|
||||
};
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts
|
||||
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { SendProductsUpdatedPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const sendProductsUpdated = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: SendProductsUpdatedPayload;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
const { customerProductId, scenario, customerId } = payload;
|
||||
|
||||
const fullCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
// ... build webhook payload
|
||||
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
eventType: "customer.products.updated",
|
||||
data: { scenario, customer, updated_product },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Trigger Helper (Optional)
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts
|
||||
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
export const billingPlanToSendProductsUpdated = async ({
|
||||
ctx,
|
||||
cusProduct,
|
||||
scenario,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusProduct: CustomerProduct;
|
||||
scenario: string;
|
||||
}) => {
|
||||
// Skip in tests if configured
|
||||
if (ctx.testOptions?.skipWebhooks) return;
|
||||
|
||||
await workflows.triggerSendProductsUpdated({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: cusProduct.customer_id,
|
||||
customerProductId: cusProduct.id,
|
||||
scenario,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "sqs"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create handler file
|
||||
6. ☐ Add case in `initWorkers.ts` `processMessage`
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
81
.claude/skills/write-test/references/STRIPE-BEHAVIORS.md
Normal file
81
.claude/skills/write-test/references/STRIPE-BEHAVIORS.md
Normal 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
|
||||
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Outbound Webhook Testing
|
||||
|
||||
Test Autumn's outbound webhooks using Svix Play (free, no signup).
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import { generatePlayToken, getPlayWebhookUrl, waitForWebhook } from "./utils/svixPlayClient.js";
|
||||
import { createTestEndpoint, deleteTestEndpoint } from "./utils/svixTestEndpoint.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
playToken = await generatePlayToken();
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (!svixAppId) throw new Error("Svix not configured");
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl: getPlayWebhookUrl(playToken) });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
});
|
||||
```
|
||||
|
||||
## Test Pattern
|
||||
|
||||
```typescript
|
||||
test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated")}`, async () => {
|
||||
const customerId = "webhook-test";
|
||||
const freeDefault = products.base({ id: "free", items: [...], isDefault: true });
|
||||
|
||||
// Setup products only (no customer)
|
||||
const { autumnV1 } = await initScenario({
|
||||
setup: [s.products({ list: [freeDefault], prefix: customerId })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Create customer with webhooks enabled
|
||||
await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test",
|
||||
internalOptions: { disable_defaults: false, default_group: customerId },
|
||||
skipWebhooks: false, // Enable webhooks
|
||||
});
|
||||
|
||||
// Wait for webhook
|
||||
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (p) => p.type === "customer.products.updated" && p.data?.customer?.id === customerId,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.payload.data.scenario).toBe("new");
|
||||
});
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
| Normal Tests | Webhook Tests |
|
||||
|--------------|---------------|
|
||||
| `initScenario` creates customer | Create customer manually with `skipWebhooks: false` |
|
||||
| Immediate assertions | Poll with `waitForWebhook` (10-15s timeout) |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `generatePlayToken()` | Get Svix Play token |
|
||||
| `getPlayWebhookUrl(token)` | Get webhook URL |
|
||||
| `waitForWebhook({ token, predicate, timeoutMs })` | Poll for webhook |
|
||||
| `createTestEndpoint({ appId, playUrl })` | Register endpoint |
|
||||
| `deleteTestEndpoint({ appId, endpointId })` | Cleanup |
|
||||
|
||||
## Location
|
||||
|
||||
`server/tests/integration/billing/autumn-webhooks/`
|
||||
10
.opencode/opencode.json
Normal file
10
.opencode/opencode.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"linear": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.linear.app/mcp",
|
||||
"oauth": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
261
.opencode/plans/cancel-implementation.md
Normal file
261
.opencode/plans/cancel-implementation.md
Normal 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`
|
||||
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
439
.opencode/plans/handleCreateCustomer-refactor.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# handleCreateCustomer Refactor Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Refactor `handleCreateCustomer` to:
|
||||
1. Eliminate race conditions causing duplicate customers or missing default products
|
||||
2. Clean up input types with a single source of truth
|
||||
3. Ensure comprehensive test coverage before making changes
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Type Cleanup
|
||||
|
||||
### Problem
|
||||
|
||||
Three overlapping types with duplicated ID validation logic:
|
||||
- `CreateCustomerSchema` in `shared/models/cusModels/cusModels.ts`
|
||||
- `CustomerDataSchema` in `shared/api/common/customerData.ts`
|
||||
- `CreateCustomerParamsSchema` in `shared/api/customers/customerOpModels.ts`
|
||||
|
||||
### Solution
|
||||
|
||||
Make `shared/api/common/customerData.ts` the single source of truth.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. `shared/api/common/customerData.ts` - Add CustomerIdSchema
|
||||
|
||||
```typescript
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Reusable customer ID validation - can be used by attach, check, track, etc.
|
||||
export const CustomerIdSchema = z.string().refine(
|
||||
(val) => {
|
||||
if (val === "") return false;
|
||||
if (val.includes("@")) return false;
|
||||
if (val.includes(" ")) return false;
|
||||
if (val.includes(".")) return false;
|
||||
return /^[a-zA-Z0-9_-]+$/.test(val);
|
||||
},
|
||||
{
|
||||
error: (issue) => {
|
||||
const input = issue.input as string;
|
||||
if (input === "") return { message: "can't be an empty string" };
|
||||
if (input.includes("@"))
|
||||
return {
|
||||
message: "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes(" "))
|
||||
return {
|
||||
message: "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
if (input.includes("."))
|
||||
return {
|
||||
message: "cannot contain periods. Use only letters, numbers, underscores, and hyphens.",
|
||||
};
|
||||
const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0];
|
||||
return {
|
||||
message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const CustomerDataSchema = z
|
||||
.object({
|
||||
name: z.string().nullish().meta({ description: "Customer's name" }),
|
||||
email: z.string().nullish().meta({ description: "Customer's email address" }),
|
||||
fingerprint: z.string().nullish().meta({ internal: true }),
|
||||
metadata: z.record(z.any(), z.any()).nullish().meta({ internal: true }),
|
||||
stripe_id: z.string().nullish().meta({ internal: true }),
|
||||
disable_default: z.boolean().optional().meta({ internal: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CustomerData",
|
||||
description: "Customer details to set when creating a customer",
|
||||
});
|
||||
|
||||
export type CustomerData = z.infer<typeof CustomerDataSchema>;
|
||||
export type CustomerId = z.infer<typeof CustomerIdSchema>;
|
||||
```
|
||||
|
||||
#### 2. `shared/api/customers/customerOpModels.ts` - Use CustomerIdSchema
|
||||
|
||||
```typescript
|
||||
import { CustomerDataSchema, CustomerIdSchema } from "../common/customerData.js";
|
||||
|
||||
// Remove duplicate customerId const, use CustomerIdSchema instead
|
||||
|
||||
export const CreateCustomerParamsSchema = z.object({
|
||||
id: CustomerIdSchema.nullable().meta({
|
||||
description: "Your unique identifier for the customer",
|
||||
}),
|
||||
...CustomerDataSchema.shape,
|
||||
entity_id: z.string().optional().meta({ internal: true }),
|
||||
entity_data: EntityDataSchema.optional().meta({ internal: true }),
|
||||
});
|
||||
|
||||
export const UpdateCustomerParamsSchema = z.object({
|
||||
id: CustomerIdSchema.optional().meta({
|
||||
description: "New unique identifier for the customer.",
|
||||
}),
|
||||
// ... rest uses CustomerDataSchema fields
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. `shared/models/cusModels/cusModels.ts` - Remove CreateCustomerSchema
|
||||
|
||||
- Delete `CreateCustomerSchema` (lines 21-69)
|
||||
- Delete `CreateCustomer` type export (line 78)
|
||||
- Keep `CustomerSchema` and `Customer` type (used for DB model)
|
||||
|
||||
#### 4. `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New Signature
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
export const handleCreateCustomer = async ({
|
||||
ctx,
|
||||
cusData, // CreateCustomer type
|
||||
createDefaultProducts,
|
||||
defaultGroup,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusData: CreateCustomer;
|
||||
createDefaultProducts?: boolean;
|
||||
defaultGroup?: string;
|
||||
})
|
||||
|
||||
// NEW
|
||||
export const handleCreateCustomer = async ({
|
||||
ctx,
|
||||
customerId, // string | null
|
||||
customerData, // CustomerData
|
||||
options,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string | null;
|
||||
customerData?: CustomerData;
|
||||
options?: {
|
||||
createDefaultProducts?: boolean;
|
||||
defaultGroup?: string;
|
||||
};
|
||||
})
|
||||
```
|
||||
|
||||
#### 5. Update All Callers
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `getOrCreateCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `getOrCreateCachedFullCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `handlePostCustomerV2.ts` | Extract `id` from parsed body, pass rest as customerData |
|
||||
| `getOrCreateApiCustomer.ts` | Pass `customerId` and `customerData` separately |
|
||||
| `createNewCustomer.ts` | Update import, accept new shape |
|
||||
|
||||
### Future Work (Not in This PR)
|
||||
|
||||
These files can later adopt `CustomerIdSchema` for validation:
|
||||
- `shared/api/balances/check/checkParams.ts` - `customer_id: CustomerIdSchema`
|
||||
- `shared/api/balances/track/trackParams.ts`
|
||||
- `shared/api/billing/attach/*`
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Test Structure
|
||||
|
||||
### File Organization
|
||||
|
||||
**3 new test files** using the modern `test.concurrent` + `initScenario` pattern:
|
||||
|
||||
| File | Theme |
|
||||
|------|-------|
|
||||
| `create-customer.test.ts` | Basic creation + email flows |
|
||||
| `create-customer-defaults.test.ts` | Default product attachment |
|
||||
| `create-customer-race.test.ts` | Race condition tests (low-level simulation) |
|
||||
|
||||
**Delete after migration:**
|
||||
- `create-customer1.test.ts` (old pattern)
|
||||
- `create-customer2.test.ts` (old pattern)
|
||||
|
||||
**Add to existing files:**
|
||||
- `check-race-condition2.test.ts` → Customer auto-creation race via /check
|
||||
- `track-race-condition5.test.ts` → Customer auto-creation race via /track
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Test Cases
|
||||
|
||||
### `create-customer.test.ts` - Basic Creation + Email Flows
|
||||
|
||||
| # | Test Name | Description | From |
|
||||
|---|-----------|-------------|------|
|
||||
| 1 | `create: basic with ID` | Create customer with ID, name, email | Migrate from create-customer1 |
|
||||
| 2 | `create: idempotent with same ID` | Create same customer twice returns existing | Migrate from create-customer1 |
|
||||
| 3 | `create: with expand params` | Create with expand returns invoices, trials_used, entities | Migrate from create-customer1 |
|
||||
| 4 | `create: concurrent same ID` | Promise.all two creates with same ID | Migrate from create-customer2 |
|
||||
| 5 | `create: null ID with email` | Create customer with id=null and valid email | NEW |
|
||||
| 6 | `create: null ID no email (error)` | Create with id=null and no email throws | NEW |
|
||||
| 7 | `create: null ID idempotent` | Create with id=null same email twice returns existing | NEW |
|
||||
| 8 | `create: null ID then add ID` | Create with id=null, then create with same email + ID updates existing | NEW |
|
||||
| 9 | `create: concurrent null ID same email` | Promise.all two creates with id=null, same email | NEW |
|
||||
|
||||
### `create-customer-defaults.test.ts` - Default Product Attachment
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 10 | `defaults: single free product` | Create customer with single default free product attached |
|
||||
| 11 | `defaults: multiple groups` | Two default free products in different groups, both attached |
|
||||
| 12 | `defaults: same group priority` | Two defaults in same group, priority: trial > paid > free |
|
||||
| 13 | `defaults: trial product` | Default trial attaches with status=trialing |
|
||||
| 14 | `defaults: paid product (legacy)` | Default paid with forcePaidDefault=true uses handleAddProduct |
|
||||
| 15 | `defaults: paid requires Stripe customer` | Default paid creates Stripe customer, sets stripe_id |
|
||||
|
||||
### `create-customer-race.test.ts` - Race Condition Tests (Low-Level)
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 16 | `race: stale cache detection` | Insert customer → concurrent request caches incomplete → getOrCreate detects stale |
|
||||
| 17 | `race: concurrent default loop` | Insert → start attaching defaults → concurrent 23505 → retry sees all defaults |
|
||||
| 18 | `race: concurrent same ID (API level)` | Promise.all creates with same ID, one gets 23505, both return same customer |
|
||||
| 19 | `race: concurrent email+ID update` | Customer exists id=null, two requests add ID via same email |
|
||||
| 20 | `race: concurrent Stripe customer` | Default paid: concurrent creates only create one Stripe customer |
|
||||
|
||||
### Entry Point Auto-Creation Race Tests
|
||||
|
||||
#### `check-race-condition2.test.ts`
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 21 | `check-autocreate: concurrent same customer_id` | Concurrent /check calls auto-creating same customer |
|
||||
|
||||
#### `track-race-condition5.test.ts`
|
||||
|
||||
| # | Test Name | Description |
|
||||
|---|-----------|-------------|
|
||||
| 22 | `track-autocreate: concurrent same customer_id` | Concurrent /track calls auto-creating same customer, usage correct |
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Test Implementation Pattern
|
||||
|
||||
### Modern Pattern: `test.concurrent` + `initScenario`
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "bun:test";
|
||||
import { CusExpand } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// BASIC CREATION TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "create-basic-id",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Delete to test fresh create
|
||||
try { await autumnV1.customers.delete(customerId); } catch {}
|
||||
|
||||
const data = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
expect(data.id).toBe(customerId);
|
||||
expect(data.name).toBe("Test Customer");
|
||||
expect(data.email).toBe(`${customerId}@example.com`);
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => {
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
customerId: "create-idempotent",
|
||||
setup: [s.customer({ testClock: false })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// First create
|
||||
const data1 = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
// Second create - should return existing
|
||||
const data2 = await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
});
|
||||
|
||||
expect(data1.id).toBe(data2.id);
|
||||
expect(data1.internal_id).toBe(data2.internal_id);
|
||||
});
|
||||
```
|
||||
|
||||
### Low-Level Race Simulation Pattern
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "bun:test";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js";
|
||||
import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("race: stale cache detection")}`, async () => {
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 1000 });
|
||||
const freeDefault = products.base({ id: "free", items: [wordsItem], isDefault: true });
|
||||
|
||||
const { customerId, ctx, autumnV2 } = await initScenario({
|
||||
customerId: "race-stale-cache",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Delete customer so we can manually reproduce race
|
||||
try { await autumnV2.customers.delete(customerId); } catch {}
|
||||
await deleteCachedFullCustomer({ ctx, customerId, source: "test-cleanup" });
|
||||
|
||||
// STEP 1: Insert customer directly (bypassing handleCreateCustomer)
|
||||
const internalId = generateId("cus");
|
||||
await CusService.insert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
id: customerId,
|
||||
internal_id: internalId,
|
||||
org_id: ctx.org.id,
|
||||
env: ctx.env,
|
||||
name: customerId,
|
||||
email: `${customerId}@test.com`,
|
||||
metadata: {},
|
||||
created_at: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
// STEP 2: Simulate concurrent request caching incomplete customer
|
||||
const incompleteCustomer = await CusService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
await setCachedFullCustomer({
|
||||
ctx,
|
||||
fullCustomer: incompleteCustomer!,
|
||||
customerId,
|
||||
fetchTimeMs: Date.now(),
|
||||
source: "test-concurrent-request",
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
// STEP 3: Call actual function - should detect stale state
|
||||
const result = await getOrCreateCachedFullCustomer({
|
||||
ctx,
|
||||
params: { customer_id: customerId, feature_id: TestFeature.Words },
|
||||
source: "test-final-check",
|
||||
});
|
||||
|
||||
// Verify: Customer has default products
|
||||
expect(result.customer_products?.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Implementation Order
|
||||
|
||||
### Phase 1: Write Tests (RED)
|
||||
1. Create `create-customer.test.ts` - migrate old tests + add new null ID tests
|
||||
2. Create `create-customer-defaults.test.ts` - default product tests
|
||||
3. Create `create-customer-race.test.ts` - race condition tests
|
||||
4. Add tests to `check-race-condition2.test.ts` and `track-race-condition5.test.ts`
|
||||
5. Delete `create-customer1.test.ts` and `create-customer2.test.ts`
|
||||
6. Run tests - some will fail (documenting expected behavior)
|
||||
|
||||
### Phase 2: Type Cleanup
|
||||
1. Add `CustomerIdSchema` to `customerData.ts`
|
||||
2. Update `customerOpModels.ts` to use it
|
||||
3. Update `handleCreateCustomer` signature
|
||||
4. Update all callers
|
||||
5. Remove `CreateCustomerSchema` from `cusModels.ts`
|
||||
|
||||
### Phase 3: Fix Race Conditions (GREEN)
|
||||
1. Analyze failing tests
|
||||
2. Implement proper locking/transactions
|
||||
3. Potential fixes:
|
||||
- Use database transaction for insert + default products
|
||||
- Add advisory lock during customer creation
|
||||
- Detect stale cache by checking customer_products count
|
||||
|
||||
### Phase 4: Verify
|
||||
1. All tests pass
|
||||
2. Manual testing of concurrent scenarios
|
||||
3. Review for any remaining edge cases
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### To Create
|
||||
- `server/tests/integration/crud/customers/create-customer.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer-defaults.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer-race.test.ts`
|
||||
- `server/tests/integration/balances/check/check-race-condition2.test.ts`
|
||||
- `server/tests/balances/track/race-condition/track-race-condition5.test.ts`
|
||||
|
||||
### To Delete
|
||||
- `server/tests/integration/crud/customers/create-customer1.test.ts`
|
||||
- `server/tests/integration/crud/customers/create-customer2.test.ts`
|
||||
|
||||
### Type Cleanup (Modify)
|
||||
- `shared/api/common/customerData.ts` - Add `CustomerIdSchema`
|
||||
- `shared/api/customers/customerOpModels.ts` - Use `CustomerIdSchema`, remove duplicate
|
||||
- `shared/models/cusModels/cusModels.ts` - Remove `CreateCustomerSchema`
|
||||
- `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New signature
|
||||
- `server/src/internal/customers/cusUtils/getOrCreateCustomer.ts`
|
||||
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts`
|
||||
- `server/src/internal/customers/cusUtils/createNewCustomer.ts`
|
||||
- `server/src/internal/customers/handlers/handlePostCustomerV2.ts`
|
||||
- `server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts`
|
||||
312
.opencode/plans/invoice-created-refactor.md
Normal file
312
.opencode/plans/invoice-created-refactor.md
Normal 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
3
.superset/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"setup": ["./.superset/setup.sh"]
|
||||
}
|
||||
138
.superset/setup.sh
Executable file
138
.superset/setup.sh
Executable 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
124
AGENTS.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
17
package.json
17
package.json
@@ -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",
|
||||
|
||||
@@ -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
5
scripts/preload-env.ts
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
7
scripts/testGroups/all.sh
Executable 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' \
|
||||
@@ -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 "$@"
|
||||
}
|
||||
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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' \
|
||||
@@ -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'
|
||||
|
||||
@@ -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' \
|
||||
@@ -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' \
|
||||
10
scripts/testGroups/stripe-webhooks.sh
Executable file
10
scripts/testGroups/stripe-webhooks.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
757
scripts/testScripts/runTestsV2.tsx
Normal file
757
scripts/testScripts/runTestsV2.tsx
Normal 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
9
server/bunfig.toml
Normal 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"
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppEnv } from "autumn-js";
|
||||
import { initScript } from "../src/utils/scriptUtils/scriptUtils";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const test = async () => {
|
||||
|
||||
|
||||
@@ -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
4
server/preload-env.ts
Normal 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();
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get project root directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/.."
|
||||
PROJECT_ROOT="$SERVER_DIR/.."
|
||||
|
||||
# Find bun executable
|
||||
if command -v bun &> /dev/null; then
|
||||
BUN_CMD="bun"
|
||||
elif [ -f "$HOME/.bun/bin/bun" ]; then
|
||||
BUN_CMD="$HOME/.bun/bin/bun"
|
||||
elif [ -f "/usr/local/bin/bun" ]; then
|
||||
BUN_CMD="/usr/local/bin/bun"
|
||||
else
|
||||
echo "Error: bun not found. Please install bun or add it to PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Setup function
|
||||
BUN_SETUP="$BUN_CMD tests/setupMain.ts"
|
||||
|
||||
# Test runner functions (using new TypeScript runner)
|
||||
BUN_PARALLEL() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@"
|
||||
}
|
||||
|
||||
BUN_PARALLEL_COMPACT() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@" --compact
|
||||
}
|
||||
|
||||
# Mocha command (for tests not yet migrated)
|
||||
MOCHA_CMD="npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts"
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/entities/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/update/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/track/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/roles/*.ts'
|
||||
|
||||
# # G4
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
# 'tests/advanced/coupons/*.ts' \
|
||||
# 'tests/attach/updateQuantity/*.ts' \
|
||||
# 'tests/advanced/referrals/*.ts' \
|
||||
# 'tests/advanced/rollovers/*.ts' \
|
||||
# 'tests/advanced/customInterval/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
# 'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/group/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/add/*.ts' \
|
||||
'tests/merged/downgrade/*.ts' \
|
||||
'tests/merged/prepaid/*.ts' \
|
||||
'tests/merged/separate/*.ts' \
|
||||
'tests/merged/upgrade/*.ts' \
|
||||
'tests/merged/trial/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/addOn/*.ts' \
|
||||
'tests/merged/group/*.ts' \
|
||||
'tests/core/cancel/*.ts' \
|
||||
'tests/core/multiAttach/*.ts' \
|
||||
'tests/core/multiAttach/multiInvoice/*.ts' \
|
||||
'tests/core/multiAttach/multiUpgrade/*.ts' \
|
||||
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward1.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward2.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward3.test.ts'
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/rollovers/*.ts'
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# npx mocha 'tests/alex/00_setup.ts' --timeout 10000000
|
||||
|
||||
MOCHA_PARALLEL=true npx mocha --parallel --timeout 10000000 \
|
||||
'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \
|
||||
'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \
|
||||
--ignore 'tests/alex/00_setup.ts'
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Parallel Test Runner
|
||||
# Runs all test groups in parallel, each with its own dedicated org
|
||||
|
||||
# Source shared configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/config.sh"
|
||||
|
||||
# Check for required environment variables
|
||||
if [ -z "$TEST_ORG_SECRET_KEY" ]; then
|
||||
echo "Error: TEST_ORG_SECRET_KEY environment variable is required"
|
||||
echo ""
|
||||
echo "This should be the secret key of your platform organization"
|
||||
echo "that has access to create/delete test organizations."
|
||||
echo ""
|
||||
echo "Add it to your server/.env file:"
|
||||
echo " TEST_ORG_SECRET_KEY=am_sk_test_..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run parallel test groups
|
||||
echo "Starting parallel test runner..."
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runParallelGroups.ts
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run Bun test files in parallel with proper error reporting
|
||||
# Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Error: No test directories specified"
|
||||
echo "Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
TEST_DIRS=()
|
||||
MAX_PARALLEL=6
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == --max=* ]]; then
|
||||
MAX_PARALLEL="${arg#*=}"
|
||||
else
|
||||
if [ ! -d "$arg" ]; then
|
||||
echo "Error: Directory '$arg' not found"
|
||||
exit 1
|
||||
fi
|
||||
TEST_DIRS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#TEST_DIRS[@]} -eq 0 ]; then
|
||||
echo "Error: No valid test directories specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
FAILED_DIR="$TEMP_DIR/failed"
|
||||
STATUS_DIR="$TEMP_DIR/status"
|
||||
mkdir -p "$FAILED_DIR" "$STATUS_DIR"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Spinner frames
|
||||
SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
SPINNER_FRAME=0
|
||||
NUM_SPINNER_FRAMES=${#SPINNER_FRAMES[@]}
|
||||
|
||||
# Collect all test files first
|
||||
TEST_FILES=()
|
||||
for TEST_DIR in "${TEST_DIRS[@]}"; do
|
||||
for test_file in "$TEST_DIR"/*.test.ts; do
|
||||
if [ -f "$test_file" ]; then
|
||||
TEST_FILES+=("$test_file")
|
||||
# Create status file
|
||||
echo "pending" > "$STATUS_DIR/$(basename "$test_file").status"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Check if any tests were found
|
||||
if [ ${#TEST_FILES[@]} -eq 0 ]; then
|
||||
echo "No test files found in specified directories"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Helper function to get status
|
||||
get_status() {
|
||||
local test_file="$1"
|
||||
local test_name=$(basename "$test_file")
|
||||
local status_file="$STATUS_DIR/${test_name}.status"
|
||||
if [ -f "$status_file" ]; then
|
||||
cat "$status_file"
|
||||
else
|
||||
echo "pending"
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to set status
|
||||
set_status() {
|
||||
local test_file="$1"
|
||||
local status="$2"
|
||||
local test_name=$(basename "$test_file")
|
||||
echo "$status" > "$STATUS_DIR/${test_name}.status"
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill all descendant processes
|
||||
pkill -P $$ 2>/dev/null || true
|
||||
pkill -f "bun test" 2>/dev/null || true
|
||||
jobs -p | while read pid; do kill -9 $pid 2>/dev/null || true; done
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Set up signal handlers
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Function to render the test list
|
||||
render_tests() {
|
||||
local line_num=1
|
||||
|
||||
# Save cursor position
|
||||
tput sc 2>/dev/null || true
|
||||
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
local test_name=$(basename "$test_file")
|
||||
local status=$(get_status "$test_file")
|
||||
local display_name="${test_name}"
|
||||
|
||||
# Move to the line
|
||||
tput cup $((line_num - 1)) 0 2>/dev/null || true
|
||||
|
||||
# Clear line
|
||||
tput el 2>/dev/null || true
|
||||
|
||||
case "$status" in
|
||||
"pending")
|
||||
echo -ne "${DIM}⋯${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"running")
|
||||
local frame_idx=$((SPINNER_FRAME % NUM_SPINNER_FRAMES))
|
||||
local spinner_char="${SPINNER_FRAMES[$frame_idx]}"
|
||||
echo -ne "${CYAN}${spinner_char}${NC} ${display_name}"
|
||||
;;
|
||||
"passed")
|
||||
echo -ne "${GREEN}✓${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"failed")
|
||||
echo -ne "${RED}✗${NC} ${display_name}"
|
||||
;;
|
||||
esac
|
||||
|
||||
((line_num++))
|
||||
done
|
||||
|
||||
# Restore cursor position
|
||||
tput rc 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to update spinner animation
|
||||
animate_spinner() {
|
||||
while true; do
|
||||
SPINNER_FRAME=$((SPINNER_FRAME + 1))
|
||||
render_tests
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
# Function to run a test
|
||||
run_test() {
|
||||
local test_file=$1
|
||||
local test_name=$(basename "$test_file")
|
||||
local output_file="$TEMP_DIR/$test_name.log"
|
||||
|
||||
# Mark as running
|
||||
set_status "$test_file" "running"
|
||||
|
||||
# Run the test
|
||||
if script -q /dev/null bash -c "FORCE_COLOR=3 bun test --timeout 0 '$test_file' 2>&1" > "$output_file"; then
|
||||
set_status "$test_file" "passed"
|
||||
return 0
|
||||
else
|
||||
set_status "$test_file" "failed"
|
||||
echo "$test_file|$output_file" > "$FAILED_DIR/$test_name.failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Hide cursor
|
||||
tput civis 2>/dev/null || true
|
||||
|
||||
# Initial render - create space for all tests
|
||||
echo ""
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Move cursor back up
|
||||
tput cuu ${#TEST_FILES[@]} 2>/dev/null || true
|
||||
|
||||
# Start spinner animation in background
|
||||
animate_spinner &
|
||||
SPINNER_PID=$!
|
||||
|
||||
# Run tests in parallel
|
||||
count=0
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
# Wait if we've hit max parallel
|
||||
while [ $(jobs -r | wc -l) -ge $((MAX_PARALLEL + 1)) ]; do
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
run_test "$test_file" &
|
||||
((count++))
|
||||
done
|
||||
|
||||
# Wait for all tests to complete (exclude spinner process)
|
||||
for job in $(jobs -p); do
|
||||
if [ "$job" != "$SPINNER_PID" ]; then
|
||||
wait $job 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Final render
|
||||
render_tests
|
||||
|
||||
# Move cursor below test list
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Report failures
|
||||
FAILED_COUNT=$(ls "$FAILED_DIR"/*.failed 2>/dev/null | wc -l)
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
echo -e "${RED}${BOLD}========================================"
|
||||
echo -e "FAILED TESTS ($FAILED_COUNT/${count}):"
|
||||
echo -e "========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Show detailed errors
|
||||
for failure_file in "$FAILED_DIR"/*.failed; do
|
||||
IFS='|' read -r test_file output_file < "$failure_file"
|
||||
echo -e "${RED}${BOLD}✗ $(basename $test_file)${NC}"
|
||||
echo -e "${DIM}─────────────────────────────────────────${NC}"
|
||||
cat "$output_file"
|
||||
echo ""
|
||||
done
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}${BOLD}✓ All tests passed!${NC} ${CYAN}($count tests)${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,6 +1,18 @@
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Check if an error is a Postgres unique constraint violation (error code 23505).
|
||||
*/
|
||||
export const isUniqueConstraintError = (error: unknown): boolean => {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "23505"
|
||||
);
|
||||
};
|
||||
|
||||
export const buildConflictUpdateColumns = <T extends PgTable>(
|
||||
table: T,
|
||||
excludeColumns: (keyof T["_"]["columns"])[] = [],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
59
server/src/external/autumn/autumnCli.ts
vendored
59
server/src/external/autumn/autumnCli.ts
vendored
@@ -13,6 +13,7 @@ import {
|
||||
type BillingResponse,
|
||||
type CheckQuery,
|
||||
type CreateBalanceParams,
|
||||
type CreateCustomerInternalOptions,
|
||||
type CreateCustomerParams,
|
||||
type CreateEntityParams,
|
||||
type CreateRewardProgram,
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
1
server/src/external/redis/initUpstash.ts
vendored
1
server/src/external/redis/initUpstash.ts
vendored
@@ -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,
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./operations/createStripeCustomer.js";
|
||||
export * from "./operations/getExpandedStripeCustomer.js";
|
||||
export * from "./operations/getOrCreateStripeCustomer.js";
|
||||
export * from "./utils/convertStripeCustomer.js";
|
||||
|
||||
49
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal file
49
server/src/external/stripe/customers/operations/createStripeCustomer.ts
vendored
Normal 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;
|
||||
};
|
||||
95
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
95
server/src/external/stripe/customers/operations/getExpandedStripeCustomer.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import { InternalError } from "@autumn/shared";
|
||||
import { tryCatch } from "@shared/utils";
|
||||
import Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { 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;
|
||||
}
|
||||
60
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal file
60
server/src/external/stripe/customers/operations/getOrCreateStripeCustomer.ts
vendored
Normal 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;
|
||||
};
|
||||
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
15
server/src/external/stripe/customers/utils/buildIdempotencyKey.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { hashString } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const buildStripeCustomerIdempotencyKey = ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customerId: string;
|
||||
}): string => {
|
||||
const { org, env } = ctx;
|
||||
return hashString(
|
||||
`stripe-create-cus:${customerId}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
|
||||
);
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
5
server/src/external/stripe/invoices/utils/classifyStripeInvoice.ts
vendored
Normal file
5
server/src/external/stripe/invoices/utils/classifyStripeInvoice.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const isStripeInvoiceForNewPeriod = (stripeInvoice: Stripe.Invoice) => {
|
||||
return stripeInvoice.billing_reason === "subscription_cycle";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
194
server/src/external/stripe/stripeCusUtils.ts
vendored
194
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -2,18 +2,17 @@ import {
|
||||
type AppEnv,
|
||||
type Customer,
|
||||
ErrCode,
|
||||
hashString,
|
||||
type Organization,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { Stripe } from "stripe";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { createStripeCustomer } from "@/external/stripe/customers";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext";
|
||||
import type { Logger } from "../logtail/logtailUtils";
|
||||
|
||||
export const getStripeCus = async ({
|
||||
stripeCli,
|
||||
@@ -30,190 +29,6 @@ export const getStripeCus = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const createStripeCusIfNotExists = async ({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
logger: Logger;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const getCurrentStripeCus = async () => {
|
||||
// 1. If no processor, create new customer
|
||||
if (!customer.processor?.id) return null;
|
||||
|
||||
try {
|
||||
const stripeCus = await stripeCli.customers.retrieve(
|
||||
customer.processor.id,
|
||||
{
|
||||
expand: [
|
||||
"test_clock",
|
||||
"invoice_settings.default_payment_method",
|
||||
"discount.source.coupon.applies_to",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// 2. If customer is deleted, create new customer
|
||||
if (stripeCus.deleted) return null;
|
||||
|
||||
// 3. If customer is not deleted, return customer
|
||||
return stripeCus as Stripe.Customer;
|
||||
} catch (_error) {
|
||||
// 4. If error, create new customer
|
||||
if (
|
||||
_error instanceof Stripe.errors.StripeError &&
|
||||
_error.code?.includes("resource_missing")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw _error;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Get current stripe customer
|
||||
const stripeCus = await getCurrentStripeCus();
|
||||
|
||||
if (stripeCus) return stripeCus;
|
||||
|
||||
// 2. If no current stripe customer, create new customer
|
||||
logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
const idempotencyKey = hashString(
|
||||
`stripe-create-cus:${customer.id || customer.internal_id}:${org.id}:${env}:${Math.floor(Date.now() / 5000)}`,
|
||||
);
|
||||
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
db,
|
||||
idOrInternalId: customer.internal_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
};
|
||||
|
||||
return stripeCustomer;
|
||||
|
||||
// let createNew = false;
|
||||
// const stripeCli = createStripeCli({ org, env });
|
||||
// if (!customer.processor || !customer.processor.id) {
|
||||
// createNew = true;
|
||||
// } else {
|
||||
// try {
|
||||
// const stripeCus = await stripeCli.customers.retrieve(
|
||||
// customer.processor.id,
|
||||
// {
|
||||
// expand: ["test_clock", "invoice_settings.default_payment_method"],
|
||||
// },
|
||||
// );
|
||||
// if (!stripeCus.deleted) {
|
||||
// return stripeCus as Stripe.Customer;
|
||||
// } else {
|
||||
// createNew = true;
|
||||
// }
|
||||
// } catch (_error) {
|
||||
// createNew = true;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (createNew) {
|
||||
// logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
// const stripeCustomer = await createStripeCustomer({
|
||||
// org,
|
||||
// env,
|
||||
// customer,
|
||||
// });
|
||||
|
||||
// await CusService.update({
|
||||
// db,
|
||||
// idOrInternalId: customer.internal_id,
|
||||
// orgId: org.id,
|
||||
// env,
|
||||
// update: {
|
||||
// processor: {
|
||||
// id: stripeCustomer.id,
|
||||
// type: ProcessorType.Stripe,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// customer.processor = {
|
||||
// id: stripeCustomer.id,
|
||||
// type: ProcessorType.Stripe,
|
||||
// };
|
||||
|
||||
// return stripeCustomer;
|
||||
// }
|
||||
};
|
||||
|
||||
export const createStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
metadata,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
testClockId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotencyKey?: string;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
try {
|
||||
const stripeCustomer = await stripeCli.customers.create(
|
||||
{
|
||||
name: customer.name || undefined,
|
||||
email: customer.email || undefined,
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
autumn_id: customer.id || null,
|
||||
autumn_internal_id: customer.internal_id,
|
||||
},
|
||||
test_clock: testClockId,
|
||||
},
|
||||
idempotencyKey
|
||||
? {
|
||||
idempotencyKey,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return stripeCustomer;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Error creating customer in Stripe. ${error.message}`,
|
||||
code: ErrCode.StripeCreateCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
@@ -321,10 +136,9 @@ export const attachPmToCus = async ({
|
||||
let stripeCusId = customer.processor?.id;
|
||||
if (!stripeCusId) {
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
ctx: { org, env, db } as any,
|
||||
customer,
|
||||
testClockId,
|
||||
options: { testClockId },
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const getLatestPeriodEnd = ({
|
||||
sub,
|
||||
subItems,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
4
server/src/external/stripe/subscriptionSchedules/index.ts
vendored
Normal file
4
server/src/external/stripe/subscriptionSchedules/index.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./operations/getStripeActiveSubscriptionSchedule";
|
||||
|
||||
export * from "./utils/convertStripeSubscriptionScheduleUtils";
|
||||
export * from "./utils/logStripeSchedulePhaseUtils";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./operations/getExpandedStripeSubscription.js";
|
||||
export * from "./types/stripeDiscountTypes.js";
|
||||
export * from "./utils/convertStripeSubscription.js";
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { StripeExpandedDiscount } from "../types/stripeDiscountTypes";
|
||||
|
||||
export type ExpandedStripeSubscription = Stripe.Subscription & {
|
||||
schedule: Stripe.SubscriptionSchedule & {
|
||||
phases: Stripe.SubscriptionSchedule.Phase[];
|
||||
};
|
||||
|
||||
customer: Stripe.Customer;
|
||||
discounts: StripeExpandedDiscount[];
|
||||
latest_invoice: string;
|
||||
};
|
||||
|
||||
export const getExpandedStripeSubscription = async ({
|
||||
@@ -23,7 +25,11 @@ export const getExpandedStripeSubscription = async ({
|
||||
const expandedStripeSubscription = await stripeCli.subscriptions.retrieve(
|
||||
subscriptionId,
|
||||
{
|
||||
expand: ["schedule.phases", "customer.test_clock"],
|
||||
expand: [
|
||||
"schedule.phases",
|
||||
"customer.test_clock",
|
||||
"discounts.source.coupon.applies_to",
|
||||
],
|
||||
},
|
||||
);
|
||||
return expandedStripeSubscription as ExpandedStripeSubscription;
|
||||
|
||||
45
server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts
vendored
Normal file
45
server/src/external/stripe/subscriptions/types/stripeDiscountTypes.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
97
server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts
vendored
Normal file
97
server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
ms,
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { ExpandedStripeCustomer } from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
|
||||
/**
|
||||
* Common fields between InvoiceCreatedContext and StripeSubscriptionDeletedContext.
|
||||
*
|
||||
* Discounts can be accessed from:
|
||||
* - `stripeSubscription.discounts` (subscription-level discounts)
|
||||
* - `stripeCustomer.discount` (customer-level discount)
|
||||
*/
|
||||
export interface BaseWebhookEventContext {
|
||||
stripeSubscription: ExpandedStripeSubscription;
|
||||
stripeCustomer: ExpandedStripeCustomer;
|
||||
fullCustomer: FullCustomer;
|
||||
customerProducts: FullCusProduct[];
|
||||
nowMs: number;
|
||||
paymentMethod: Stripe.PaymentMethod | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a BillingContext for generating arrear (usage-in-arrear) invoice line items
|
||||
* from Stripe webhook events.
|
||||
*
|
||||
* This function is used by both:
|
||||
* - `invoice.created` webhook: to add consumable usage line items to the renewal invoice
|
||||
* - `subscription.deleted` webhook: to create a final arrear invoice for usage
|
||||
*
|
||||
* @param eventContext - Common webhook context fields shared by both event types
|
||||
* @param eventContext.stripeSubscription - The expanded Stripe subscription
|
||||
* @param eventContext.fullCustomer - The full customer object from Autumn DB
|
||||
* @param eventContext.nowMs - Current time in ms (respecting test clocks)
|
||||
* @param eventContext.paymentMethod - Customer's payment method for the invoice
|
||||
*
|
||||
* @param periodEndMs - The end of the billing period being invoiced (in milliseconds).
|
||||
* If not provided, falls back to `eventContext.nowMs`.
|
||||
* - For `invoice.created`: use `secondsToMs(stripeInvoice.period_end)`
|
||||
* - For `subscription.deleted`: use `secondsToMs(stripeSubscription.ended_at)` if available
|
||||
*
|
||||
* @returns A BillingContext configured for arrear line item generation
|
||||
*
|
||||
* @remarks
|
||||
* **Why we use "just before" the period end:**
|
||||
*
|
||||
* The billing period calculation functions (`getCycleStart`, `getCycleEnd`) determine
|
||||
* which cycle a given timestamp falls into. If we pass exactly `periodEndMs` (e.g., Feb 1),
|
||||
* the functions will return the NEW cycle (Feb 1 - Mar 1) instead of the OLD cycle
|
||||
* (Jan 1 - Feb 1) that we actually want to bill for.
|
||||
*
|
||||
* By subtracting 30 minutes, we ensure we're still "within" the old cycle:
|
||||
* ```
|
||||
* periodEndMs = Feb 1 00:00:00
|
||||
* justBeforePeriodEndMs = Jan 31 23:30:00
|
||||
*
|
||||
* getCycleStart(now = Jan 31 23:30) → Jan 1 ✓ (old cycle)
|
||||
* getCycleEnd(now = Jan 31 23:30) → Feb 1 ✓ (old cycle)
|
||||
* ```
|
||||
*/
|
||||
export const buildBillingContextForArrearInvoice = ({
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
}: {
|
||||
eventContext: BaseWebhookEventContext;
|
||||
periodEndMs?: number;
|
||||
}): BillingContext => {
|
||||
const { stripeSubscription, fullCustomer, paymentMethod, nowMs } =
|
||||
eventContext;
|
||||
|
||||
// Use periodEndMs if provided, otherwise fall back to nowMs
|
||||
const effectivePeriodEndMs = periodEndMs ?? nowMs;
|
||||
|
||||
// Use "just before" period end so getCycleStart/getCycleEnd return the OLD cycle
|
||||
// that just ended, not the NEW cycle that's starting.
|
||||
// See JSDoc above for detailed explanation.
|
||||
const justBeforePeriodEndMs = effectivePeriodEndMs - ms.minutes(30);
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
fullProducts: [],
|
||||
featureQuantities: [],
|
||||
|
||||
currentEpochMs: justBeforePeriodEndMs,
|
||||
billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
resetCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
|
||||
|
||||
stripeCustomer: stripeSubscription.customer,
|
||||
stripeSubscription,
|
||||
paymentMethod: paymentMethod ?? undefined,
|
||||
};
|
||||
};
|
||||
84
server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts
vendored
Normal file
84
server/src/external/stripe/webhookHandlers/common/eventContextToArrearLineItems.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { FullCusEntWithFullCusProduct, LineItem } from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
|
||||
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
|
||||
import type { UpdateCustomerEntitlement } from "@/internal/billing/v2/types/autumnBillingPlan";
|
||||
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
|
||||
import {
|
||||
type BaseWebhookEventContext,
|
||||
buildBillingContextForArrearInvoice,
|
||||
} from "./buildBillingContextFromWebhook";
|
||||
import { logWebhookArrearLineItems } from "./logs/logWebhookArrearLineItems";
|
||||
|
||||
/**
|
||||
* Generates arrear (usage-in-arrear) line items from webhook event context.
|
||||
*
|
||||
* This function is used by both:
|
||||
* - `invoice.created` webhook: adds consumable usage line items to renewal invoice
|
||||
* - `subscription.deleted` webhook: creates final arrear invoice for usage
|
||||
*
|
||||
* @param ctx - Autumn context (for org currency, etc.)
|
||||
* @param eventContext - Common webhook context (stripeSubscription, stripeCustomer, fullCustomer, customerProducts, nowMs, paymentMethod)
|
||||
* @param periodEndMs - End of billing period (optional, falls back to nowMs)
|
||||
* @param cusEntFilter - Optional filter for multi-interval billing (invoice.created uses this)
|
||||
*
|
||||
* @returns Object with line items and the billing context used
|
||||
*/
|
||||
export const eventContextToArrearLineItems = ({
|
||||
ctx,
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
cusEntFilter,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: BaseWebhookEventContext;
|
||||
periodEndMs?: number;
|
||||
cusEntFilter?: (cusEnt: FullCusEntWithFullCusProduct) => boolean;
|
||||
}): {
|
||||
lineItems: LineItem[];
|
||||
updateCustomerEntitlements: UpdateCustomerEntitlement[];
|
||||
billingContext: BillingContext;
|
||||
} => {
|
||||
const billingContext = buildBillingContextForArrearInvoice({
|
||||
eventContext,
|
||||
periodEndMs,
|
||||
});
|
||||
|
||||
// Collect line items from all customer products
|
||||
let lineItems: LineItem[] = [];
|
||||
const updateCustomerEntitlements: UpdateCustomerEntitlement[] = [];
|
||||
for (const customerProduct of eventContext.customerProducts) {
|
||||
const {
|
||||
lineItems: productLineItems,
|
||||
updateCustomerEntitlements: productUpdates,
|
||||
} = customerProductToArrearLineItems({
|
||||
ctx,
|
||||
customerProduct,
|
||||
billingContext,
|
||||
filters: { cusEntFilter },
|
||||
updateNextResetAt: true,
|
||||
});
|
||||
lineItems.push(...productLineItems);
|
||||
updateCustomerEntitlements.push(...productUpdates);
|
||||
}
|
||||
|
||||
// Apply discounts to line items
|
||||
const discounts = setupStripeDiscountsForBilling({
|
||||
stripeSubscription: eventContext.stripeSubscription,
|
||||
stripeCustomer: eventContext.stripeCustomer,
|
||||
});
|
||||
|
||||
if (discounts.length > 0) {
|
||||
lineItems = applyStripeDiscountsToLineItems({ lineItems, discounts });
|
||||
}
|
||||
|
||||
// Log the arrear line items and customer entitlement updates
|
||||
logWebhookArrearLineItems({
|
||||
ctx,
|
||||
lineItems,
|
||||
updateCustomerEntitlements,
|
||||
});
|
||||
|
||||
return { lineItems, updateCustomerEntitlements, billingContext };
|
||||
};
|
||||
11
server/src/external/stripe/webhookHandlers/common/index.ts
vendored
Normal file
11
server/src/external/stripe/webhookHandlers/common/index.ts
vendored
Normal 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";
|
||||
65
server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts
vendored
Normal file
65
server/src/external/stripe/webhookHandlers/common/logCustomerProductUpdates.ts
vendored
Normal 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,
|
||||
// });
|
||||
};
|
||||
36
server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts
vendored
Normal file
36
server/src/external/stripe/webhookHandlers/common/logs/logWebhookArrearLineItems.ts
vendored
Normal 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),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
84
server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts
vendored
Normal file
84
server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts
vendored
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || []),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type Stripe from "stripe";
|
||||
import { processAllocatedPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated";
|
||||
import { processPrepaidPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated";
|
||||
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
|
||||
import { setupInvoiceCreatedContext } from "./setupInvoiceCreatedContext";
|
||||
import { processConsumablePricesForInvoiceCreated } from "./tasks/processConsumablePricesForInvoiceCreated";
|
||||
|
||||
export const handleStripeInvoiceCreated = async ({
|
||||
ctx,
|
||||
event,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
event: Stripe.InvoiceCreatedEvent;
|
||||
}) => {
|
||||
const eventContext = await setupInvoiceCreatedContext({ ctx, event });
|
||||
|
||||
if (!eventContext) {
|
||||
ctx.logger.debug("[invoice.created] Skipping - context not found");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.info(
|
||||
`[invoice.created] Processing for invoice ${eventContext.stripeInvoice.id}`,
|
||||
);
|
||||
|
||||
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
|
||||
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
|
||||
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
|
||||
|
||||
await upsertAutumnInvoice({ ctx, eventContext });
|
||||
};
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
});
|
||||
};
|
||||
154
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts
vendored
Normal file
154
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts
vendored
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
BillingType,
|
||||
cusProductsToCusEnts,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
import { isStripeInvoiceForNewPeriod } from "@/external/stripe/invoices/utils/classifyStripeInvoice";
|
||||
import { isStripeSubscriptionVercel } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { InvoiceCreatedContext } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { findLinkedCusEnts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils";
|
||||
import { removeReplaceablesFromCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils";
|
||||
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService";
|
||||
import { logAllocatedPriceProcessed } from "../logs/logInvoiceCreatedPriceProcessing";
|
||||
|
||||
/**
|
||||
* Handle reset balance?
|
||||
*/
|
||||
|
||||
const processAllocatedPrice = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerEntitlement,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
customerEntitlement: FullCusEntWithFullCusProduct;
|
||||
}) => {
|
||||
const { db } = ctx;
|
||||
const { stripeInvoice } = eventContext;
|
||||
|
||||
const customerProduct = customerEntitlement.customer_product;
|
||||
const customerEntitlements = customerProduct?.customer_entitlements ?? [];
|
||||
|
||||
const isNewPeriod = isStripeInvoiceForNewPeriod(stripeInvoice);
|
||||
if (!isNewPeriod) return;
|
||||
|
||||
const feature = customerEntitlement.entitlement.feature;
|
||||
const replaceables = customerEntitlement.replaceables.filter(
|
||||
(r) => r.delete_next_cycle,
|
||||
);
|
||||
|
||||
if (replaceables.length === 0) return false;
|
||||
|
||||
const linkedCusEnts = findLinkedCusEnts({
|
||||
cusEnts: customerEntitlements,
|
||||
feature,
|
||||
});
|
||||
|
||||
for (const linkedCusEnt of linkedCusEnts) {
|
||||
const { newEntities } = removeReplaceablesFromCusEnt({
|
||||
cusEnt: linkedCusEnt,
|
||||
replaceableIds: replaceables.map((r) => r.id),
|
||||
});
|
||||
|
||||
await CusEntService.update({
|
||||
db,
|
||||
id: linkedCusEnt.id,
|
||||
updates: {
|
||||
entities: newEntities,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await CusEntService.increment({
|
||||
db,
|
||||
id: customerEntitlement.id,
|
||||
amount: replaceables.length,
|
||||
});
|
||||
|
||||
await RepService.deleteInIds({
|
||||
db,
|
||||
ids: replaceables.map((r) => r.id),
|
||||
});
|
||||
|
||||
logAllocatedPriceProcessed({
|
||||
ctx,
|
||||
customerEntitlement,
|
||||
replaceablesRemoved: replaceables.length,
|
||||
balanceIncremented: replaceables.length,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const processAllocatedPricesForInvoiceCreated = async ({
|
||||
ctx,
|
||||
eventContext,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
eventContext: InvoiceCreatedContext;
|
||||
}): Promise<void> => {
|
||||
const { stripeInvoice, customerProducts, stripeSubscription } = eventContext;
|
||||
|
||||
const isNewPeriod = isStripeInvoiceForNewPeriod(stripeInvoice);
|
||||
const isVercelSubscription = isStripeSubscriptionVercel(stripeSubscription);
|
||||
if (!isNewPeriod || isVercelSubscription) return;
|
||||
|
||||
const customerEntitlements = cusProductsToCusEnts({
|
||||
cusProducts: customerProducts,
|
||||
filters: {
|
||||
billingTypes: [BillingType.InArrearProrated],
|
||||
},
|
||||
});
|
||||
|
||||
for (const customerEntitlement of customerEntitlements) {
|
||||
await processAllocatedPrice({
|
||||
ctx,
|
||||
eventContext,
|
||||
customerEntitlement,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
103
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts
vendored
Normal file
103
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts
vendored
Normal 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}`);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]",
|
||||
});
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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]",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user