Merge branch 'feat/attach-v2' into dev

This commit is contained in:
John Yeo
2026-02-06 12:29:24 -08:00
856 changed files with 75468 additions and 6081 deletions

View File

@@ -125,3 +125,15 @@ Biome's linter will catch most issues automatically. Focus your attention on:
---
Most formatting and common issues are automatically fixed by Biome. Run `npx ultracite fix` before committing to ensure compliance.
---
## Type Checking
After making edits to server code, run `bun ts` in the `server/` package to check for type errors:
```bash
cd server && bun ts
```
This ensures TypeScript compilation succeeds before committing changes.

View File

@@ -16,13 +16,16 @@ Should Autumn create a manual invoice?
1. Is this a subscription CREATE action?
└── YES → NO manual invoice (Stripe creates one automatically)
2. Is this a subscription UPDATE that removes a trial?
2. No subscription AND no subscription action? (one-time product)
└── YES → Create standalone invoice
3. Is this a subscription UPDATE that removes a trial?
└── YES → NO manual invoice (Stripe creates one automatically)
3. Is there an existing subscription being updated?
└── NO → NO manual invoice (nothing to invoice against)
4. Is there an existing subscription being updated?
└── NO → NO manual invoice (edge case guard)
4. Otherwise:
5. Otherwise:
└── YES → Create manual invoice
```
@@ -46,18 +49,24 @@ export const shouldCreateManualStripeInvoice = ({
const isCreateAction = stripeSubscriptionAction?.type === "create";
if (isCreateAction) return false;
// Case 2: No subscription exists → Nothing to invoice against
const { stripeSubscription } = billingContext;
// Case 2: No subscription and no subscription action → create standalone invoice for one-time products
if (!stripeSubscription && !stripeSubscriptionAction) {
return true;
}
// Case 3: No subscription but has subscription action (shouldn't happen, but guard)
if (!stripeSubscription) return false;
// Case 3: Update removes trial → Stripe handles invoice
// Case 4: Update removes trial → Stripe handles invoice
const updateWillCreateInvoice = willStripeSubscriptionUpdateCreateInvoice({
billingContext,
stripeSubscriptionAction,
});
if (updateWillCreateInvoice) return false;
// Case 4: Otherwise → We create manual invoice
// Case 5: Otherwise → We create manual invoice
return true;
};
```

View File

@@ -28,10 +28,12 @@ Write integration tests for the Autumn billing system using the `initScenario` p
- **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 `product.id` in expectations too (initScenario already prefixes with customerId)
- 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.
- **Set up all prerequisite state in `initScenario` actions** - the test body should only call the single action being tested
**DON'T:**
- Use plain `test()` - **ALWAYS use `test.concurrent()`**
@@ -41,6 +43,10 @@ Write integration tests for the Autumn billing system using the `initScenario` p
- 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
- Use `${product.id}_${customerId}` for productId - just use `product.id` (already prefixed)
- **Call multiple setup actions in the test body** - put prerequisite attaches/tracks in `initScenario` actions, test body should only call the action being tested
- **Use prepaid `includedUsage` that's NOT a multiple of `billingUnits`** - Stripe requires integer tier values (e.g., `includedUsage: 50` with `billingUnits: 100` = 0.5, which Stripe rejects)
- **Use tiered pricing without `"inf"` on the last tier** - Stripe requires the last tier to have `to: "inf"` as a catch-all. Always use: `tiers: [{ to: 500, amount: 10 }, { to: "inf", amount: 5 }]`
## AutumnInt Response Types
@@ -76,6 +82,35 @@ test.concurrent(`${chalk.yellowBright("feature: description")}`, async () => {
});
```
## Test Structure: Scenario vs Action
**Key principle:** Set up all prerequisite state in `initScenario`, test body only calls the action being tested.
```typescript
// ✅ GOOD - Testing "attach one-time after pro"
// Pre-existing pro product set up in initScenario actions
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [s.attach({ productId: pro.id })], // Prerequisite state
});
// Test body only tests the ONE action we care about
const preview = await autumnV1.billing.previewAttach({ customer_id: customerId, product_id: oneOff.id });
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
// ... verify results
// ❌ BAD - Multiple attaches in test body
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [], // Empty!
});
await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id }); // Should be in initScenario
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
```
## References
Load these on-demand for detailed information:
@@ -85,6 +120,7 @@ Load these on-demand for detailed information:
- [references/ENTITIES.md](references/ENTITIES.md) - Entity-based testing (multi-tenant, per-entity billing)
- [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/PRORATION.md](references/PRORATION.md) - Proration utilities for mid-cycle upgrade/downgrade testing
- [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
@@ -95,6 +131,19 @@ Tests: `server/tests/integration/billing/` organized by feature area.
## Run Tests
Run a single test file:
```bash
bun test path/to/file.test.ts
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts
```
Run a specific test by name pattern:
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts -t "test 3"
```
Run with longer timeout (for slow tests):
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts --timeout 60000
```
**Note**: Only run one test at a time during development to avoid test clock conflicts.

View File

@@ -80,25 +80,69 @@ expectCustomerInvoiceCorrect({
## Product State Expectations
### `expectCustomerProducts` (Batch Check - Preferred)
### Product States Are Mutually Exclusive
Verify multiple product states in a single call. Use this when checking 2+ products.
**CRITICAL:** `active` and `canceling` are **mutually exclusive** states:
- **`active`**: Product is active and NOT scheduled for cancellation
- **`canceling`**: Product is scheduled for cancellation at end of billing cycle (has `canceled_at` set)
A product CANNOT be both `active` and `canceling`. When a downgrade is scheduled:
- The current product becomes `canceling` (NOT active)
- The new product becomes `scheduled`
### `expectCustomerProducts` (Batch Check - PREFERRED)
Verify multiple product states in a single call. **Always 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
active: [pro.id, addon.id], // Products that are active (NOT canceling)
canceling: [premium.id], // Products scheduled for cancellation
scheduled: [free.id], // Products waiting to become active
notPresent: [oldProduct.id], // Products that should not exist
});
```
All arrays are optional - only include the states you need to verify.
**Example - scheduled downgrade from Pro to Free with add-on:**
```typescript
// ✅ CORRECT - canceling and active are separate
await expectCustomerProducts({
customer,
canceling: [pro.id], // Pro is canceling (NOT active)
active: [recurringAddon.id], // Add-on remains active
scheduled: [free.id], // Free is scheduled
});
// ❌ WRONG - Pro cannot be both active and canceling
await expectCustomerProducts({
customer,
active: [pro.id, recurringAddon.id], // WRONG: pro is canceling, not active
canceling: [pro.id],
scheduled: [free.id],
});
```
**Example - upgrade from pro to premium:**
```typescript
// ✅ GOOD - batch check
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id, free.id],
});
// ❌ BAD - multiple individual calls (don't do this)
await expectProductActive({ customer, productId: premium.id });
await expectProductNotPresent({ customer, productId: pro.id });
await expectProductNotPresent({ customer, productId: free.id });
```
### `expectProductActive`
Verify a single product is active. For multiple products, prefer `expectProducts`.
Verify a single product is active. **For multiple products, prefer `expectCustomerProducts`.**
```typescript
await expectProductActive({
@@ -224,13 +268,19 @@ expectPreviewNextCycleCorrect({
});
```
## Subscription Verification
## Subscription Verification (CRITICAL)
**ALWAYS verify Stripe subscription state after EVERY `billing.attach()` call!**
This ensures the Stripe subscription state matches Autumn's internal state.
### `expectSubToBeCorrect`
Deep verification of subscription state in database.
Deep verification of subscription state in database. **Use for paid products.**
```typescript
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
await expectSubToBeCorrect({
db: ctx.db,
customerId,
@@ -246,6 +296,31 @@ await expectSubToBeCorrect({
});
```
### `expectNoStripeSubscription`
Verify customer has no active Stripe subscriptions. **Use for free products OR after downgrading to free.**
```typescript
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
```
### When to Use Which
| Scenario | Utility |
|----------|---------|
| Attached paid product | `expectSubToBeCorrect` |
| Attached free product | `expectNoStripeSubscription` |
| Upgraded free → paid | `expectSubToBeCorrect` |
| Downgraded paid → free (after cycle) | `expectNoStripeSubscription` |
| Scheduled downgrade (before cycle) | `expectSubToBeCorrect` (sub still exists until cycle end) |
## Complete Example
```typescript
@@ -335,6 +410,30 @@ test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => {
});
```
## Rollover Expectations
### `expectCustomerRolloverCorrect`
Verify customer feature rollover state.
```typescript
import { expectCustomerRolloverCorrect, expectNoRollovers } from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";
// Check rollover balances
expectCustomerRolloverCorrect({
customer,
featureId: TestFeature.Messages,
expectedRollovers: [{ balance: 150 }], // Array of expected rollovers
totalBalance: 550, // Optional: verify total balance
});
// Verify NO rollovers exist
expectNoRollovers({
customer,
featureId: TestFeature.Messages,
});
```
## Time Utilities
```typescript

View File

@@ -48,6 +48,33 @@ items.unlimitedMessages() // No usage cap
items.lifetimeMessages({ includedUsage?: number }) // Default: 100, never resets
```
### Rollover Features
```typescript
import { RolloverExpiryDurationType } from "@autumn/shared";
items.monthlyMessagesWithRollover({
includedUsage?: number, // Default: 100
rolloverConfig: {
max: number | null, // Maximum rollover amount (null = unlimited)
length: number, // Number of periods to keep rollovers
duration: RolloverExpiryDurationType, // Month, Year, etc.
},
})
```
**Example:**
```typescript
const messagesWithRollover = items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig: {
max: 500,
length: 1,
duration: RolloverExpiryDurationType.Month,
},
});
```
### Prepaid (purchase upfront)
```typescript

View File

@@ -8,6 +8,51 @@ Quick reference for common mistakes. Each gotcha follows the format:
## Setup & Initialization
### NEVER Call `initScenario` Twice - Use Single Scenario for Multiple Customers
**CRITICAL:** When testing scenarios with multiple customers, **NEVER** call `initScenario` multiple times. Instead, use a single `initScenario` call and create additional customers using the autumn client directly.
```typescript
// ❌ WRONG - Calling initScenario twice
const { autumnV1: autumnA } = await initScenario({
customerId: customerIdA,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [s.billing.attach({ productId: "pro" })],
});
const { autumnV1: autumnB } = await initScenario({
customerId: customerIdB,
setup: [s.customer({ paymentMethod: "success" })], // DON'T DO THIS!
actions: [s.billing.attach({ productId: "pro" })],
});
// ✅ RIGHT - Single initScenario, create additional customers manually
const { autumnV1, ctx } = await initScenario({
customerId: customerIdA,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: "pro" }),
s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }),
],
});
// Create second customer using the autumn client
await autumnV1.customers.create(customerIdB, { ... });
await autumnV1.attach({
customer_id: customerIdB,
product_id: pro.id,
});
```
**Why?**
- `initScenario` creates test context, Stripe test clocks, and products with prefixes
- Calling it twice can cause conflicts with product IDs, test clocks, and org state
- The second call may try to recreate products that already exist
- Use the autumn client from the first `initScenario` to manage additional customers
### Payment Method Required for Paid Features
```typescript
// WRONG
@@ -28,6 +73,22 @@ s.attach({ productId: pro.id })
```
Products are prefixed by `initScenario`. Always use `product.id`.
### Product IDs in Expectations - Just Use `product.id`
```typescript
// WRONG - Double prefix (initScenario already adds customerId prefix)
expectProductActive({
customer,
productId: `${pro.id}_${customerId}`, // Will fail!
});
// RIGHT - Just use product.id directly
expectProductActive({
customer,
productId: pro.id,
});
```
`initScenario` already prefixes product IDs with `customerId`. When verifying products, just use `product.id` directly.
### Multiple Products Need Unique IDs
```typescript
// WRONG - Same default ID
@@ -136,6 +197,26 @@ const fromDb = await autumnV1.customers.get(customerId, { skip_cache: "true" });
## Prepaid Features
### includedUsage Must Be Multiple of billingUnits
```typescript
// WRONG - 50 / 100 = 0.5, invalid for Stripe tiers
const invalidItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 50, // NOT a multiple of billingUnits!
billingUnits: 100,
price: 10,
});
// RIGHT - 0, 100, 200, etc. are valid
const validItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 200, // 200 / 100 = 2, valid integer
billingUnits: 100,
price: 10,
});
```
When Stripe tiered pricing is created, `up_to` for the first tier = `includedUsage / billingUnits`. Stripe requires `up_to` to be a positive integer or "inf". If this results in a decimal (e.g., 50/100=0.5), Stripe rejects it with: `Invalid tiers[0][up_to]: must be one of inf`.
### Quantity Required on Attach
```typescript
// WRONG
@@ -193,6 +274,33 @@ expectCustomerFeatureCorrect({
## Billing & Invoices
### Trial Invoice Count
When a Stripe subscription is created (even with a trial), Stripe generates a $0 invoice:
```typescript
// WRONG - Trial subscription DOES create an invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Wrong! Trial creates $0 invoice
});
// RIGHT - Trial subscription creates 1 invoice with $0 total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// RIGHT - Free product (no Stripe subscription) has no invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Correct for free products
});
```
Rules:
- **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0`
- **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`)
- **Free product (no Stripe subscription)**: `count: 0` is correct
### Consumable Overage: Not Charged on Update
```typescript
expect(preview.total).toBe(0); // Even with existing overage
@@ -269,6 +377,108 @@ expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
---
## Preview & Next Cycle
### Use `expectPreviewNextCycleCorrect` with Exact `startsAt`
```typescript
// WRONG - Approximate timing
expectPreviewNextCycleCorrect({
preview,
total: 20,
startsAt: Date.now() + ms.months(1), // Wrong base time!
});
// RIGHT - Use advancedTo from initScenario + addMonths
const { advancedTo } = await initScenario({ ... });
expectPreviewNextCycleCorrect({
preview,
total: 20,
startsAt: addMonths(advancedTo, 1).getTime(), // Exact next cycle start
});
```
`advancedTo` is the test clock time after initScenario completes. Use `addMonths(advancedTo, 1)` for next month's cycle start.
### Do NOT Create New `initScenario` to Advance Test Clock
```typescript
// WRONG - Creating new initScenario loses test context
const { autumnV1 } = await initScenario({ customerId, ... });
// ... do some tests ...
const { autumnV1: autumnV1After } = await initScenario({
customerId,
actions: [s.billing.attach(...), s.advanceToNextInvoice()], // BAD!
});
// RIGHT - Use helpers on existing ctx, or include all actions in single initScenario
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [...],
actions: [
s.billing.attach({ productId: pro.id }),
s.billing.attach({ productId: free.id }), // Schedule downgrade
s.advanceToNextInvoice(),
],
});
```
Creating a new `initScenario` with the same `customerId` may cause issues because it tries to recreate the customer/products.
### Prepaid `next_cycle.total` Depends on Quantity
```typescript
// If prepaid billingUnits: 100, price: 10, quantity: 200
// next_cycle.total = (200 / 100) * 10 = $20
// WRONG - Assuming fixed price
expectPreviewNextCycleCorrect({ preview, total: 10 });
// RIGHT - Calculate based on quantity
const expectedTotal = (quantity / billingUnits) * price;
expectPreviewNextCycleCorrect({ preview, total: expectedTotal });
```
For prepaid features, `next_cycle.total` reflects the price for the quantity that will be purchased.
---
---
## Resetting Feature Usage (Rollovers)
### Free Features vs Paid Features Reset Differently
**Free features (no price):** Use `s.resetFeature()` - simulates cycle reset without advancing test clock:
```typescript
// Free product with rollover
const free = products.base({ id: "free", items: [freeMessagesWithRollover] });
const { autumnV1 } = await initScenario({
customerId,
actions: [
s.billing.attach({ productId: free.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
s.resetFeature({ featureId: TestFeature.Messages, productId: free.id }),
],
});
```
**Paid features (consumable, prepaid, base price):** Use `s.advanceToNextInvoice()` - advances test clock and triggers Stripe subscription renewal:
```typescript
// Paid product with consumable or prepaid
const pro = products.pro({ id: "pro", items: [consumableMessages] });
const { autumnV1 } = await initScenario({
customerId,
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceToNextInvoice(), // Triggers Stripe renewal → resets usage
],
});
```
### Why the Difference?
- **Free products**: No Stripe subscription exists. Usage must be reset manually via `s.resetFeature()` which simulates the cron job.
- **Paid products**: Stripe subscription exists. Advancing the test clock triggers `invoice.paid` webhook which resets usage.
---
## Quick Reference
| Context | Import |

View File

@@ -0,0 +1,246 @@
# Proration Utilities
When testing mid-cycle upgrades/downgrades, use the proration utilities to calculate exact expected amounts.
**Location:** `@tests/integration/billing/utils/proration/`
## Import
```typescript
import {
getBillingPeriod,
calculateProration,
calculateProratedDiff
} from "@tests/integration/billing/utils/proration";
```
## `calculateProratedDiff` (Most Common)
Calculate net charge for upgrade/downgrade. Works for base prices, prepaid, and allocated features.
```typescript
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Calculate prorated difference for base price upgrade
const expectedCharge = calculateProratedDiff({
customer: customerBefore,
advancedTo, // From initScenario
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
```
### Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `customer` | `ApiCustomerV3` | Yes | Customer object from API |
| `advancedTo` | `number` | Yes | Current time from initScenario |
| `oldAmount` | `number` | Yes | Old/current price (credited) |
| `newAmount` | `number` | Yes | New price (charged) |
| `productId` | `string` | No | Filter by specific product ID |
| `interval` | `"month" \| "year"` | No | Filter by billing interval |
| `entityId` | `string` | No | For entity-level products |
| `entityIndex` | `number` | No | 0-based index → "ent-1", "ent-2" |
### Multi-Product/Multi-Interval/Entity Examples
```typescript
// Filter by product ID (when customer has multiple products)
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
productId: "pro",
});
// Filter by billing interval (for dual subscriptions - monthly + annual)
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
interval: "month",
});
// Entity-level product
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
entityId: "ent-1",
});
// Or using entityIndex (0-based → "ent-1")
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
entityIndex: 0,
});
```
## Key Behaviors
| Feature Type | Prorated on Upgrade? | Use calculateProratedDiff? |
|--------------|---------------------|----------------------------|
| Base price | ✅ Yes | ✅ Yes |
| Prepaid | ✅ Yes | ✅ Yes |
| Allocated | ✅ Yes | ✅ Yes |
| Consumable (arrear) | ❌ No - full amount | ❌ No - add separately |
## Mixed Prorated + Non-Prorated (Consumable Arrear)
Consumable/arrear charges are **NEVER prorated** - add them separately:
```typescript
// Base price is prorated
const proratedBase = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20,
newAmount: 50,
});
// Consumable arrear is NOT prorated - full amount
const arrearOverage = 5; // 100 overage × $0.05
const expectedTotal = proratedBase + arrearOverage;
expect(preview.total).toBeCloseTo(expectedTotal, 0);
```
## `getBillingPeriod`
Get the raw billing period from customer's subscription (for custom calculations):
```typescript
import { getBillingPeriod } from "@tests/integration/billing/utils/proration";
const period = getBillingPeriod({ customer });
// Returns: { start: number, end: number } in milliseconds
// With filters
const monthlyPeriod = getBillingPeriod({
customer,
interval: "month",
});
const entityPeriod = getBillingPeriod({
customer,
entityIndex: 0,
});
```
## `calculateProration`
Calculate prorated amount for a single price (not the difference):
```typescript
import { calculateProration } from "@tests/integration/billing/utils/proration";
const proratedCharge = calculateProration({
customer,
advancedTo,
amount: 50, // Full price
});
// Returns prorated amount for remaining period
```
## Complete Example
```typescript
test.concurrent(`${chalk.yellowBright("mid-cycle upgrade with consumable arrear")}`, async () => {
const customerId = "mid-cycle-upgrade-arrear";
const proConsumable = items.consumableWords({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proConsumable] });
const premiumConsumable = items.consumableWords({ includedUsage: 1000 });
const premium = products.premium({ id: "premium", items: [premiumConsumable] });
const { autumnV1, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Words, value: 300 }), // 100 overage
s.advanceTestClock({ days: 15 }),
],
});
// Get customer to extract billing period
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Calculate prorated base price difference
const proratedBaseDiff = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
// Consumable arrear is NOT prorated - full amount
const arrearOverage = 5; // 100 overage × $0.05
const expectedTotal = proratedBaseDiff + arrearOverage;
// Preview
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBeCloseTo(expectedTotal, 0);
// Attach
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
```
## Why Use These Utilities?
1. **Correct billing period**: Gets actual `current_period_start/end` from Stripe subscription (not estimated with `ms.days(30)`)
2. **Precision**: Uses `Decimal.js` internally - no floating point errors
3. **Auto-flooring**: Automatically floors `advancedTo` to match Stripe's frozen_time calculation
4. **Multi-subscription support**: Handles monthly/annual dual subscriptions, entity products, etc.
## Anti-Pattern (DON'T DO THIS)
```typescript
// ❌ BAD - estimating billing period manually
const periodStart = advancedTo - ms.days(15);
const periodEnd = periodStart + ms.days(30); // Wrong! Months vary
// ✅ GOOD - use the utility
const expectedTotal = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20,
newAmount: 50,
});
```

View File

@@ -123,6 +123,36 @@ Remove all payment methods from customer.
s.removePaymentMethod()
```
### `s.resetFeature({ ... })`
Reset a feature's usage cycle to simulate end-of-cycle rollover creation.
**Use this for FREE products** (no Stripe subscription) to create rollovers.
For PAID products, use `s.advanceToNextInvoice()` instead.
```typescript
s.resetFeature({
featureId: TestFeature.Messages, // Required: feature to reset
productId?: "free", // Optional: product ID (defaults to customerId as group)
timeout?: 2000, // Optional: wait time after reset (default: 2000ms)
})
```
**Example - Creating rollovers on a free product:**
```typescript
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [
s.billing.attach({ productId: free.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
s.resetFeature({ featureId: TestFeature.Messages, productId: free.id }), // Creates rollover
],
});
```
## Complete Example
```typescript

24
.github/workflows/server-typecheck.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Server Type Check
on:
pull_request:
jobs:
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- name: Install dependencies
run: bun install
- name: Run TypeScript type check
run: cd server && bun ts

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
cd server && bun ts

View File

@@ -0,0 +1,314 @@
# V2 Checkout Session Completed Implementation Plan
## Overview
Implement the V2 flow for `checkout.session.completed` webhook handler. The V2 flow uses the new billing plan architecture where:
1. Billing plan is stored in metadata during checkout session creation
2. When checkout completes, we modify the billing plan based on checkout results
3. Execute the deferred billing plan (which now handles invoice/subscription upserts)
## Current State
- ✅ Main entry point created: `handleStripeCheckoutSessionCompleted.ts`
- ✅ Context setup created: `setupCheckoutSessionCompletedContext.ts`
- ✅ Legacy files moved to `legacy/` folder
- ⏳ V2 flow returns early with "not yet implemented" log
## Architecture Changes
### 1. Extend AutumnBillingPlan Schema
**File:** `server/src/internal/billing/v2/types/autumnBillingPlan.ts`
Add two new optional fields:
```typescript
export const AutumnBillingPlanSchema = z.object({
// ...existing fields...
// NEW: Insert operations for subscription and invoice
insertSubscription: SubscriptionSchema.optional(),
upsertInvoice: InvoiceSchema.optional(),
});
```
**Rationale:** By adding these to the billing plan, we can:
- Use the same `executeAutumnBillingPlan` for all flows
- Keep billing operations centralized
- Allow both immediate execution and deferred execution to use the same path
### 2. Update executeAutumnBillingPlan
**File:** `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts`
Add at the end:
```typescript
// 6. Insert subscription (if provided)
if (autumnBillingPlan.insertSubscription) {
await SubService.upsert({
db,
subscription: autumnBillingPlan.insertSubscription,
});
}
// 7. Upsert invoice (if provided)
if (autumnBillingPlan.upsertInvoice) {
await InvoiceService.upsert({
db,
invoice: autumnBillingPlan.upsertInvoice,
});
}
```
### 3. Add Upsert Methods to Services
**File:** `server/src/internal/subscriptions/SubService.ts`
```typescript
static async upsert({
db,
subscription,
}: {
db: DrizzleCli;
subscription: Subscription;
}) {
const updateColumns = buildConflictUpdateColumns(subscriptions, ["id"]);
await db
.insert(subscriptions)
.values(subscription)
.onConflictDoUpdate({
target: subscriptions.stripe_id,
set: updateColumns,
});
}
```
**File:** `server/src/internal/invoices/InvoiceService.ts`
```typescript
static async upsert({
db,
invoice,
}: {
db: DrizzleCli;
invoice: Invoice;
}) {
const updateColumns = buildConflictUpdateColumns(invoices, ["id"]);
await db
.insert(invoices)
.values(invoice as any)
.onConflictDoUpdate({
target: invoices.stripe_id,
set: updateColumns,
});
}
```
### 4. Modify upsertInvoiceFromBilling and upsertSubscriptionFromBilling
These functions currently call services directly. Change them to **build** the Autumn objects and add to the billing plan instead.
**File:** `server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts`
Change from:
```typescript
export const upsertSubscriptionFromBilling = async ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}) => {
// ... calls SubService directly
}
```
To:
```typescript
export const buildSubscriptionFromStripe = ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}): Subscription => {
const earliestPeriodEnd = getEarliestPeriodEnd({ sub: stripeSubscription });
const currentPeriodStart = getLatestPeriodStart({ sub: stripeSubscription });
return {
id: generateId("sub"),
stripe_id: stripeSubscription.id,
stripe_schedule_id: stripeSubscription.schedule as string | null,
created_at: stripeSubscription.created * 1000,
usage_features: [],
org_id: ctx.org.id,
env: ctx.env,
current_period_start: currentPeriodStart,
current_period_end: earliestPeriodEnd,
};
};
// Keep old function for backward compatibility, but call the new one
export const upsertSubscriptionFromBilling = async ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}) => {
const subscription = buildSubscriptionFromStripe({ ctx, stripeSubscription });
await SubService.upsert({ db: ctx.db, subscription });
};
```
**File:** `server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts`
Similar pattern - add `buildInvoiceFromStripe` that returns `Invoice` object.
---
## Checkout Session Completed Tasks
### Task Structure
```
handleStripeCheckoutSessionCompleted/
├── handleStripeCheckoutSessionCompleted.ts # Main entry
├── setupCheckoutSessionCompletedContext.ts # Already done
├── legacy/ # Already done
└── tasks/
├── modifyStripeSubscriptionFromCheckout.ts # Task 1
├── updateBillingPlanFromCheckout.ts # Task 2
├── queueCheckoutRewardTasks.ts # Task 3
└── updateCustomerFromCheckout.ts # Task 4
```
### Main Handler Flow
```typescript
// handleStripeCheckoutSessionCompleted.ts
if (checkoutContext) {
const { metadata, stripeSubscription, stripeInvoice, stripeCheckoutSession } = checkoutContext;
const billingPlanData = metadata.data as DeferredAutumnBillingPlanData;
// 1. Modify Stripe subscription (swap metered→empty, migrate to flexible)
if (stripeSubscription) {
await modifyStripeSubscriptionFromCheckout({ ctx, checkoutContext });
}
// 2. Update billing plan with checkout data (adds insertSubscription, upsertInvoice)
const updatedBillingPlanData = updateBillingPlanFromCheckout({
ctx,
checkoutContext,
billingPlanData,
});
// 3. Execute deferred billing plan with updated data
await executeDeferredBillingPlanFromCheckout({
ctx,
metadata,
billingPlanData: updatedBillingPlanData,
});
// 4. Queue checkout reward tasks
await queueCheckoutRewardTasks({ ctx, checkoutContext });
// 5. Update customer name/email
await updateCustomerFromCheckout({ ctx, checkoutContext });
return;
}
```
### Task 1: modifyStripeSubscriptionFromCheckout
**Purpose:** Modify the Stripe subscription after checkout creates it.
**Actions:**
1. Swap metered prices → empty prices (for entity-attached products)
2. Migrate subscription to flexible billing mode
**Note:** Leave a TODO comment for "Create Autumn Subscription" - will be handled by billing plan now.
### Task 2: updateBillingPlanFromCheckout
**Purpose:** Modify the billing plan based on checkout results.
**Actions:**
1. Extract prepaid quantities from checkout line items → update `insertCustomerProducts` (handle later)
2. Build `insertSubscription` from Stripe subscription using `buildSubscriptionFromStripe`
3. Build `upsertInvoice` from Stripe invoice using `buildInvoiceFromStripe`
4. Return new `DeferredAutumnBillingPlanData` with updated `billingPlan.autumn`
### Task 3: queueCheckoutRewardTasks
**Purpose:** Queue reward jobs for each product.
**Actions:**
- For each product in `billingPlan.autumn.insertCustomerProducts`
- Queue `JobName.TriggerCheckoutReward` with customer/product/subId
### Task 4: updateCustomerFromCheckout
**Purpose:** Sync customer name/email from Stripe checkout details.
**Actions:**
- If customer is missing name in Autumn but has it in checkout → update
- If customer is missing email in Autumn but has it in checkout → update
---
## Implementation Order
### Phase 1: Schema & Service Updates
1. Add `insertSubscription` and `upsertInvoice` to `AutumnBillingPlanSchema`
2. Add `SubService.upsert()` method
3. Add `InvoiceService.upsert()` method
4. Update `executeAutumnBillingPlan` to handle new fields
### Phase 2: Build Functions
5. Create `buildSubscriptionFromStripe` in upsertSubscriptionFromBilling.ts
6. Create `buildInvoiceFromStripe` in upsertInvoiceFromBilling.ts
7. Update existing `upsertSubscriptionFromBilling` to use new builder
8. Update existing `upsertInvoiceFromBilling` to use new builder
### Phase 3: Checkout Tasks
9. Create `modifyStripeSubscriptionFromCheckout.ts`
10. Create `updateBillingPlanFromCheckout.ts`
11. Create `queueCheckoutRewardTasks.ts`
12. Create `updateCustomerFromCheckout.ts`
### Phase 4: Wire It Up
13. Update `handleStripeCheckoutSessionCompleted.ts` to call tasks
14. Test the full flow
---
## Files to Modify
| File | Changes |
|------|---------|
| `server/src/internal/billing/v2/types/autumnBillingPlan.ts` | Add `insertSubscription`, `upsertInvoice` fields |
| `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts` | Handle new upsert fields |
| `server/src/internal/subscriptions/SubService.ts` | Add `upsert()` method |
| `server/src/internal/invoices/InvoiceService.ts` | Add `upsert()` method |
| `server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts` | Add `buildSubscriptionFromStripe` |
| `server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts` | Add `buildInvoiceFromStripe` |
## New Files to Create
| File | Purpose |
|------|---------|
| `handleStripeCheckoutSessionCompleted/tasks/modifyStripeSubscriptionFromCheckout.ts` | Swap metered prices, migrate to flexible |
| `handleStripeCheckoutSessionCompleted/tasks/updateBillingPlanFromCheckout.ts` | Build subscription/invoice, update billing plan |
| `handleStripeCheckoutSessionCompleted/tasks/queueCheckoutRewardTasks.ts` | Queue reward jobs |
| `handleStripeCheckoutSessionCompleted/tasks/updateCustomerFromCheckout.ts` | Sync customer name/email |
---
## Deferred Items
- **Prepaid quantities extraction:** Will handle later (Task A from original analysis)
- **Allocated prices:** Skip for now, add comment
- **Idempotency check:** Removed per user feedback

View File

@@ -24,5 +24,11 @@
"editor.defaultFormatter": "biomejs.biome"
},
"postman.settings.dotenv-detection-notification-visibility": false,
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.exclude": {
// "**/.claude": true,
"**/.cursor": true,
"**/.github": true,
"**/.superset": true,
}
}

View File

@@ -0,0 +1,52 @@
---
description: "d3k assistant for debugging web apps"
---
# d3k Commands
d3k captures browser and server logs in a unified log file. Use these commands:
## Viewing Errors and Logs
```bash
d3k errors # Show recent errors (browser + server combined)
d3k errors --context # Show errors + user actions that preceded them
d3k errors -n 20 # Show last 20 errors
d3k logs # Show recent logs (browser + server combined)
d3k logs --type browser # Browser logs only
d3k logs --type server # Server logs only
```
## Other Commands
```bash
d3k fix # Deep analysis of application errors
d3k fix --focus build # Focus on build errors
d3k crawl # Discover app URLs
d3k crawl --depth all # Exhaustive crawl
d3k find-component "nav" # Find React component source
d3k restart # Restart dev server (rarely needed)
```
## Browser Interaction
To click elements, navigate, or take screenshots, use `d3k agent-browser --cdp $(d3k cdp-port)`:
```bash
d3k agent-browser --cdp $(d3k cdp-port) open http://localhost:3000/page
d3k agent-browser --cdp $(d3k cdp-port) snapshot -i # Get element refs (@e1, @e2)
d3k agent-browser --cdp $(d3k cdp-port) click @e2
d3k agent-browser --cdp $(d3k cdp-port) fill @e3 "text"
d3k agent-browser --cdp $(d3k cdp-port) screenshot /tmp/shot.png
```
## Fix Workflow
1. `d3k errors --context` - See errors and what triggered them
2. Fix the code
3. `d3k agent-browser --cdp $(d3k cdp-port) open <url>` then `click @e1` to replay
4. `d3k errors` - Verify fix worked

24
apps/checkout/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
apps/checkout/README.md Normal file
View File

@@ -0,0 +1,3 @@
# React + TypeScript + Vite + shadcn/ui
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.

50
apps/checkout/biome.json Normal file
View File

@@ -0,0 +1,50 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"experimentalScannerIgnores": ["dist/**", "public/**"],
"includes": ["!src/components/ui/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"a11y": {
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off"
},
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"
},
"suspicious": {
"noArrayIndexKey": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

1205
apps/checkout/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "phosphor",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

13
apps/checkout/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
{
"name": "checkout",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@autumn/shared": "workspace:*",
"@base-ui/react": "^1.1.0",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi-client": "catalog:",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"motion": "^12.29.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"shadcn": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"use-debounce": "^10.1.0",
"vite-tsconfig-paths": "^6.0.5"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "~5.9.3",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,17 @@
import { checkoutContract } from "@autumn/shared";
import { createORPCClient } from "@orpc/client";
import type { ContractRouterClient } from "@orpc/contract";
import type { JsonifiedClient } from "@orpc/openapi-client";
import { OpenAPILink } from "@orpc/openapi-client/fetch";
const link = new OpenAPILink(checkoutContract, {
url: import.meta.env.VITE_API_URL || "http://localhost:8080",
});
export const checkoutApi: JsonifiedClient<
ContractRouterClient<typeof checkoutContract>
> = createORPCClient(link);
// Usage:
// const { preview } = await checkoutApi.getCheckout({ checkout_id: "co_xxx" });
// const result = await checkoutApi.confirmCheckout({ checkout_id: "co_xxx" });

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 937 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,140 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export const BackgroundBeams = React.memo(
({ className }: { className?: string }) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
];
return (
<div
className={cn(
"absolute inset-0 flex h-full w-full items-center justify-center [mask-repeat:no-repeat] [mask-size:40px]",
className,
)}
>
<svg
className="pointer-events-none absolute z-0 h-full w-full"
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483M-30 -589C-30 -589 38 -184 502 -57C966 70 1034 475 1034 475M-23 -597C-23 -597 45 -192 509 -65C973 62 1041 467 1041 467M-16 -605C-16 -605 52 -200 516 -73C980 54 1048 459 1048 459M-9 -613C-9 -613 59 -208 523 -81C987 46 1055 451 1055 451M-2 -621C-2 -621 66 -216 530 -89C994 38 1062 443 1062 443M5 -629C5 -629 73 -224 537 -97C1001 30 1069 435 1069 435M12 -637C12 -637 80 -232 544 -105C1008 22 1076 427 1076 427M19 -645C19 -645 87 -240 551 -113C1015 14 1083 419 1083 419"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.05"
strokeWidth="0.5"
></path>
{paths.map((path, index) => (
<motion.path
key={`path-` + index}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeWidth="0.5"
></motion.path>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: Math.random() * 10,
}}
>
<stop stopColor="var(--primary)" stopOpacity="0"></stop>
<stop stopColor="var(--primary)"></stop>
<stop offset="32.5%" stopColor="var(--primary)"></stop>
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0"></stop>
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--border)"></stop>
<stop offset="0.243243" stopColor="var(--border)"></stop>
<stop offset="0.43594" stopColor="var(--background)" stopOpacity="0"></stop>
</radialGradient>
</defs>
</svg>
</div>
);
},
);
BackgroundBeams.displayName = "BackgroundBeams";

View File

@@ -0,0 +1,87 @@
import { LayoutGroup, motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants, listContainerVariants } from "@/lib/animations";
import { ConfirmSection } from "./confirm/ConfirmSection";
import { CheckoutBackground } from "./layout/CheckoutBackground";
import { CheckoutHeader } from "./layout/CheckoutHeader";
import { OrderSummarySection } from "./order-summary/OrderSummarySection";
import { PlanSection } from "./plan/PlanSection";
import { CheckoutErrorState } from "./states/CheckoutErrorState";
import { CheckoutSuccessState } from "./states/CheckoutSuccessState";
export function CheckoutContent() {
const { confirmResult, status, isSandbox } = useCheckoutContext();
// Handle success state
if (confirmResult) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutSuccessState result={confirmResult} />
</motion.div>
);
}
// Handle error state
if (status.error) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutErrorState
message={
status.error instanceof Error
? status.error.message
: "Failed to load checkout"
}
/>
</motion.div>
);
}
// Main checkout view
return (
<CheckoutBackground isSandbox={isSandbox}>
<motion.div
className="flex flex-col gap-8 w-full"
initial="initial"
animate="animate"
variants={listContainerVariants}
>
{/* Header */}
<motion.div variants={fadeUpVariants} transition={STANDARD_TRANSITION}>
<CheckoutHeader />
</motion.div>
{/* Main content - two columns */}
<LayoutGroup>
<div className="flex flex-col lg:flex-row gap-8 w-full max-w-4xl mx-auto">
<PlanSection />
{/* Vertical separator - visible only on desktop */}
<Separator
orientation="vertical"
className="hidden lg:block h-auto self-stretch"
/>
<Separator
orientation="horizontal"
className="block lg:hidden h-auto self-stretch"
/>
<OrderSummarySection />
</div>
</LayoutGroup>
<Separator />
<ConfirmSection />
</motion.div>
</CheckoutBackground>
);
}

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton for the bottom section (amount due + confirm button) */
export function BottomSectionSkeleton() {
return (
<div className="flex flex-col gap-6">
{/* Amount summary */}
<div className="flex flex-col gap-1">
{/* Amount due today */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16" />
</div>
{/* Total due next cycle */}
<div className="flex items-center justify-between">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3.5 w-14" />
</div>
</div>
{/* Button */}
<Skeleton className="h-12 w-full rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { format } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import { CrossfadeContainer } from "@/components/motion/CrossfadeContainer";
import { Button } from "@/components/ui/button";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { FAST_TRANSITION, STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { BottomSectionSkeleton } from "./BottomSectionSkeleton";
import { CheckoutFooter } from "../layout/CheckoutFooter";
function getButtonText({
isPending,
isUpdating,
total,
nextCycleTotal,
isSubscription,
hasActiveTrial,
}: {
isPending: boolean;
isUpdating: boolean;
total: number;
nextCycleTotal: number;
isSubscription: boolean;
hasActiveTrial: boolean;
}): string {
if (isPending) return "Processing...";
if (isUpdating) return "Updating...";
if (hasActiveTrial) return "Confirm and start trial";
if (total === 0) {
return nextCycleTotal > 0 ? "Confirm and Subscribe" : "Confirm";
}
return isSubscription ? "Pay and Subscribe" : "Pay";
}
export function ConfirmSection() {
const {
status,
total,
currency,
preview,
isSubscription,
hasActiveTrial,
handleConfirm,
} = useCheckoutContext();
return (
<div className="flex flex-col gap-4">
<motion.div
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.15 }}
>
<CrossfadeContainer
isLoading={status.isLoading}
skeleton={<BottomSectionSkeleton />}
className="flex flex-col gap-6"
>
{/* Amount summary */}
<div className="flex flex-col gap-1">
{/* Amount due today */}
<div className="flex items-center justify-between">
<span className="text-base font-medium text-foreground">
Amount due today
</span>
<span className="text-lg font-medium text-foreground tabular-nums">
{formatAmount(total, currency)}
</span>
</div>
{/* Amount next cycle / Amount due on trial end */}
{preview?.next_cycle && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{preview.next_cycle.starts_at
? `Amount due on ${format(preview.next_cycle.starts_at, "do MMMM yyyy")}`
: "Total due next cycle"}
</span>
<span className="tabular-nums">
{formatAmount(preview.next_cycle.total, currency)}
</span>
</div>
)}
</div>
{/* Confirm button */}
<motion.div
whileTap={{ scale: 0.98 }}
transition={FAST_TRANSITION}
className="pt-4"
>
<Button
className="w-full h-12 text-base font-medium rounded-lg"
onClick={handleConfirm}
disabled={status.isConfirming || status.isUpdating}
>
{getButtonText({
isPending: status.isConfirming,
isUpdating: status.isUpdating,
total,
nextCycleTotal: preview?.next_cycle?.total ?? 0,
isSubscription,
hasActiveTrial,
})}
</Button>
</motion.div>
{/* Error message */}
<AnimatePresence>
{status.confirmError && (
<motion.p
className="text-sm text-destructive text-center"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={FAST_TRANSITION}
>
{status.confirmError instanceof Error
? status.confirmError.message
: "Failed to confirm checkout"}
</motion.p>
)}
</AnimatePresence>
</CrossfadeContainer>
</motion.div>
<motion.div
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.2 }}
>
<CheckoutFooter />
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { motion } from "motion/react";
/**
* Full-screen background wrapper with subtle diagonal gradients from primary color.
* Includes entrance animation for the content container.
*/
export function CardBackground({ children }: { children: ReactNode }) {
return (
<div className="bg-card relative overflow-hidden">
{/* Top-right diagonal gradient */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--foreground)_4%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 0.6 }}
/>
{/* Bottom-left diagonal gradient (lighter) */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--foreground)_2%,var(--background))_0%,transparent_45%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 0.2 }}
transition={{ duration: 0.6, delay: 0.1 }}
/>
<div className="relative z-10">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { motion } from "motion/react";
import { BackgroundBeams } from "@/components/bg/background-beams";
import { SandboxBanner } from "@/components/checkout/layout/SandboxBanner";
import { SLOW_TRANSITION, SPRING_TRANSITION } from "@/lib/animations";
interface CheckoutBackgroundProps {
children: ReactNode;
isSandbox?: boolean;
}
/**
* Full-screen background wrapper with subtle diagonal gradients from primary color.
* Includes entrance animation for the content container.
*/
export function CheckoutBackground({ children, isSandbox }: CheckoutBackgroundProps) {
return (
<div className="h-screen bg-background relative overflow-hidden flex items-center justify-center p-8">
{/* Top-right diagonal gradient */}
<motion.div
className="fixed inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--primary)_8%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
/>
{/* Bottom-left diagonal gradient (lighter) */}
<motion.div
className="fixed inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--primary)_6%,var(--background))_0%,transparent_45%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
/>
{/* Animated beams */}
<BackgroundBeams className="fixed inset-0 pointer-events-none opacity-6" />
{/* Frosted glass content container */}
<motion.div
layout
className="relative z-10 w-full max-w-2xl lg:max-w-3xl xl:max-w-4xl max-h-full border border-border rounded-2xl bg-card/50 backdrop-blur-xl overflow-auto [scrollbar-width:thin] [scrollbar-color:color-mix(in_oklch,var(--foreground)_20%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb]:rounded-full"
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
// Use spring for layout changes (height/width)
layout: SPRING_TRANSITION,
// Use slow transition for initial entrance
...SLOW_TRANSITION,
}}
>
{/* Sandbox banner - outside padding, inside scrolling container */}
{isSandbox && <SandboxBanner />}
{/* Padded content wrapper */}
<div className="p-8">
{children}
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import autumnLogo from "@/assets/autumn.svg";
import { cn } from "@/lib/utils";
const transitionClass = "transition-all duration-300";
export function CheckoutFooter() {
return (
<a
href="https://useautumn.com"
target="_blank"
rel="noopener noreferrer"
className="w-fit mx-auto flex items-center justify-center gap-0.5 group focus-visible:underline outline-none"
>
<span className={cn("text-xs text-muted-foreground group-hover:text-foreground", transitionClass)}>Powered by</span>
<img
src={autumnLogo}
alt="Autumn"
className={cn("h-4.5 w-4.5 grayscale group-hover:grayscale-0", transitionClass)}
/>
<span className={cn("text-xs text-muted-foreground group-hover:text-foreground", transitionClass)}>Autumn</span>
</a>
);
}

View File

@@ -0,0 +1,55 @@
import { motion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { GENTLE_SPRING, STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutHeader() {
const { org, status, headerDescription } = useCheckoutContext();
const isLoading = status.isLoading;
return (
<div className="flex flex-col gap-4">
{/* Org branding */}
{isLoading ? (
<Skeleton className="h-5 w-32" />
) : org ? (
<motion.div
className="flex items-center gap-2 min-w-0"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
{org.logo && (
<motion.img
src={org.logo}
alt={org.name}
className="h-6 w-6 rounded-full object-cover shrink-0"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={GENTLE_SPRING}
/>
)}
<span className="text-sm text-muted-foreground truncate">{org.name}</span>
</motion.div>
) : null}
{/* Title and description */}
<div className="flex flex-col gap-2">
<h1 className="text-2xl text-foreground tracking-tight">
Confirm your order
</h1>
{isLoading ? (
<Skeleton className="h-5 w-80" />
) : headerDescription ? (
<motion.p
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
{headerDescription}
</motion.p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { ArrowRightIcon, FlaskIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
export function SandboxBanner() {
return (
<div className="w-full h-10 text-sm flex items-center justify-between px-8 text-sandbox border-b border-sandbox/20 rounded-t-2xl relative overflow-hidden">
{/* Top-right diagonal gradient */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--sandbox)_15%,var(--background))_0%,transparent_60%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
/>
{/* Bottom-left diagonal gradient */}
<motion.div
className="absolute inset-0 pointer-events-none bg-[linear-gradient(315deg,color-mix(in_oklch,var(--sandbox)_10%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
/>
{/* Left content */}
<div className="relative z-10 flex items-center gap-1">
<FlaskIcon className="h-4 w-4" weight="fill" />
<p className="tracking-tight">Sandbox</p>
</div>
{/* Right link */}
<a
href="https://docs.useautumn.com"
target="_blank"
rel="noopener noreferrer"
className="relative z-10 flex items-center gap-2 transition-all group"
>
<span className="group-hover:text-foreground transition-all duration-300 tracking-tight">View docs</span>
<ArrowRightIcon className="h-3.5 w-3.5 group-hover:text-foreground transition-transform duration-300 group-hover:-rotate-45" weight="bold" />
</a>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import type { PreviewLineItem } from "@autumn/shared";
import { motion } from "motion/react";
import { useMemo } from "react";
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { PlanGroupSection } from "@/components/checkout/plan/PlanGroupSection";
import { FreeTrialSection } from "@/components/checkout/trial/FreeTrialSection";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { LAYOUT_TRANSITION } from "@/lib/animations";
interface PlanGroup {
planId: string;
planName: string;
items: PreviewLineItem[];
type: "incoming" | "outgoing";
}
export function OrderSummary() {
const { preview, incoming = [], outgoing = [], freeTrial, trialAvailable } =
useCheckoutContext();
// Early return if no preview data yet
if (!preview) return null;
const { line_items, total, currency, next_cycle } = preview;
const hasNoImmediateCharges = line_items.length === 0 && total === 0;
const showNextCycleBreakdown = hasNoImmediateCharges && next_cycle;
// Use next cycle line items when showing next cycle breakdown, otherwise use immediate line items
const displayLineItems: PreviewLineItem[] = showNextCycleBreakdown
? next_cycle.line_items
: line_items;
// Build a map of plan_id -> plan_name from incoming and outgoing
const planNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const change of [...outgoing, ...incoming]) {
map.set(change.plan.id, change.plan.name || change.plan.id);
}
return map;
}, [incoming, outgoing]);
// Group line items by plan_id
const planGroups = useMemo((): PlanGroup[] => {
const groupMap = new Map<string, PreviewLineItem[]>();
for (const item of displayLineItems) {
const planId = item.plan_id;
if (!groupMap.has(planId)) {
groupMap.set(planId, []);
}
groupMap.get(planId)!.push(item);
}
// Convert to array, with outgoing plans first (credits), then incoming plans
const outgoingIds = new Set(outgoing.map((c) => c.plan.id));
const incomingIds = new Set(incoming.map((c) => c.plan.id));
const groups: PlanGroup[] = [];
// Add outgoing plan groups first (including those with no line items like free plans)
for (const change of outgoing) {
const planId = change.plan.id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "outgoing",
});
}
// Add incoming plan groups (including those with no line items)
for (const change of incoming) {
const planId = change.plan.id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "incoming",
});
}
// Add any remaining line item groups that weren't in incoming/outgoing
for (const [planId, items] of groupMap) {
if (!outgoingIds.has(planId) && !incomingIds.has(planId)) {
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "incoming",
});
}
}
return groups;
}, [displayLineItems, outgoing, incoming, planNameMap]);
const showFreeTrial = freeTrial && trialAvailable;
return (
<motion.div
layout
className="flex flex-col gap-4 min-w-0"
transition={{ layout: LAYOUT_TRANSITION }}
>
{/* Unified card containing all sections */}
<motion.div
layout
layoutId="order-summary-card"
transition={{ layout: LAYOUT_TRANSITION }}
className="rounded-lg border border-border overflow-hidden"
>
<CardBackground>
{planGroups.map((group, groupIndex) => (
<div key={group.planId}>
{/* Separator between sections */}
{groupIndex > 0 && <Separator />}
<PlanGroupSection
planId={group.planId}
planName={group.planName}
items={group.items}
currency={currency}
type={group.type}
/>
</div>
))}
{/* Free trial section */}
{showFreeTrial && (
<>
<Separator />
<FreeTrialSection
freeTrial={freeTrial}
trialAvailable={trialAvailable}
/>
</>
)}
</CardBackground>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,33 @@
import { motion } from "motion/react";
import { CrossfadeContainer } from "@/components/motion/CrossfadeContainer";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { FAST_TRANSITION, STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { OrderSummary } from "./OrderSummary";
import { OrderSummarySkeleton } from "./OrderSummarySkeleton";
import { SectionHeader } from "../shared/SectionHeader";
export function OrderSummarySection() {
const { status } = useCheckoutContext();
return (
<motion.div
className="flex flex-col gap-4 w-full lg:flex-1 min-w-0"
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.1 }}
>
<SectionHeader title="Order Summary" />
<motion.div
animate={{ opacity: status.isUpdating ? 0.6 : 1 }}
transition={FAST_TRANSITION}
>
<CrossfadeContainer
isLoading={status.isLoading}
skeleton={<OrderSummarySkeleton />}
>
<OrderSummary />
</CrossfadeContainer>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,41 @@
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton section that matches PlanGroupSection structure */
function PlanGroupSectionSkeleton() {
return (
<div>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-background/50">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-16" />
</div>
{/* Line item */}
<div className="px-3 py-2">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1.5 min-w-0">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="h-2 w-32" />
</div>
<Skeleton className="h-2.5 w-12 shrink-0" />
</div>
</div>
</div>
);
}
/** Skeleton that matches OrderSummary unified card layout */
export function OrderSummarySkeleton() {
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* First plan section */}
<PlanGroupSectionSkeleton />
<Separator />
{/* Second plan section */}
<PlanGroupSectionSkeleton />
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type { PreviewLineItem } from "@autumn/shared";
import { motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { FAST_TRANSITION } from "@/lib/animations";
import { formatAmount, formatPeriodRange } from "@/utils/formatUtils";
type PlanChangeType = "incoming" | "outgoing";
function LineItemAmount({ item, currency }: { item: PreviewLineItem; currency: string }) {
const totalDiscount = item.discounts.reduce((sum, d) => sum + d.amountOff, 0);
const hasDiscount = totalDiscount > 0;
const originalAmount = item.amount + totalDiscount;
return (
<motion.div
key={item.amount}
className="flex items-center gap-1.5 shrink-0"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{hasDiscount && (
<span className="text-xs tabular-nums text-muted-foreground/60 line-through">
{formatAmount(originalAmount, currency)}
</span>
)}
<span className="text-xs tabular-nums text-muted-foreground">
{formatAmount(item.amount, currency)}
</span>
</motion.div>
);
}
interface PlanGroupSectionProps {
planId: string;
planName: string;
items: PreviewLineItem[];
currency: string;
type: PlanChangeType;
}
export function PlanGroupSection({
planId,
planName,
items,
currency,
type,
}: PlanGroupSectionProps) {
const groupTotal = items.reduce((sum, item) => sum + item.amount, 0);
// Sort items so base price appears first
const sortedItems = [...items].sort((a, b) => {
if (a.is_base && !b.is_base) return -1;
if (!a.is_base && b.is_base) return 1;
return 0;
});
return (
<div className="overflow-hidden">
{/* Plan header */}
<div className="px-3 py-2.5 border-b bg-background/50">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground truncate">
{planName}
</span>
</div>
<span className="text-sm font-semibold tabular-nums text-foreground shrink-0">
{formatAmount(groupTotal, currency)}
</span>
</div>
</div>
{/* Line items for this plan */}
<div className="px-3">
{sortedItems.length === 0 ? (
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-xs text-muted-foreground">
{type === "outgoing" ? "No charges" : "Free"}
</span>
<span className="text-xs tabular-nums text-muted-foreground shrink-0">
{formatAmount(0, currency)}
</span>
</div>
) : (
sortedItems.map((item, itemIndex) => (
<div key={`${item.title}-${itemIndex}`}>
<div className="flex items-center justify-between gap-4 py-2">
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground truncate">
{item.is_base ? "Base Price" : item.title}
</span>
{!item.is_base && item.total_quantity > 1 && (
<motion.span
key={item.total_quantity}
className="text-xs text-muted-foreground shrink-0"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={FAST_TRANSITION}
>
x{item.total_quantity}
</motion.span>
)}
</div>
{item.effective_period && (
<span className="text-xs text-muted-foreground/60">
{formatPeriodRange(item.effective_period.start, item.effective_period.end)}
</span>
)}
</div>
<LineItemAmount item={item} currency={currency} />
</div>
{itemIndex < sortedItems.length - 1 && (
<Separator className="opacity-50" />
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { motion } from "motion/react";
import { CrossfadeContainer } from "@/components/motion/CrossfadeContainer";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants } from "@/lib/animations";
import { PlanSelectionCard } from "./PlanSelectionCard";
import { PlanSelectionCardSkeleton } from "./PlanSelectionCardSkeleton";
import { SectionHeader } from "../shared/SectionHeader";
export function PlanSection() {
const { incoming, status } = useCheckoutContext();
return (
<motion.div
className="flex flex-col gap-4 w-full lg:flex-1 min-w-0"
variants={fadeUpVariants}
transition={{ ...STANDARD_TRANSITION, delay: 0.05 }}
>
<SectionHeader title="Plan Details" />
<CrossfadeContainer
isLoading={status.isLoading}
skeleton={<PlanSelectionCardSkeleton />}
>
{incoming?.map((change) => (
<PlanSelectionCard key={change.plan.id} change={change} />
))}
</CrossfadeContainer>
</motion.div>
);
}

View File

@@ -0,0 +1,310 @@
import type { ApiPlanFeature, CheckoutChange } from "@autumn/shared";
import { CheckIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import {
FAST_TRANSITION,
LAYOUT_TRANSITION,
STANDARD_TRANSITION,
listContainerVariants,
listItemVariants,
} from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { QuantityInput } from "../shared/QuantityInput";
function categorizeFeatures(features: ApiPlanFeature[]): {
prepaid: ApiPlanFeature[];
payPerUse: ApiPlanFeature[];
included: ApiPlanFeature[];
} {
const prepaid: ApiPlanFeature[] = [];
const payPerUse: ApiPlanFeature[] = [];
const included: ApiPlanFeature[] = [];
for (const feature of features) {
if (!feature.price) {
included.push(feature);
continue;
}
if (feature.price.usage_model === "prepaid") {
prepaid.push(feature);
} else {
payPerUse.push(feature);
}
}
return { prepaid, payPerUse, included };
}
function formatInterval(interval: string): string {
switch (interval) {
case "month":
return "mo";
case "year":
return "yr";
case "week":
return "wk";
case "day":
return "day";
default:
return interval;
}
}
function getFeatureName(feature: ApiPlanFeature): string {
return feature.feature?.name || feature.feature_id;
}
function getFeatureUnitDisplay(
feature: ApiPlanFeature,
plural: boolean,
): string {
const display = feature.feature?.display;
if (display) {
return plural ? display.plural : display.singular;
}
return plural ? "units" : "unit";
}
interface PlanSelectionCardProps {
change: CheckoutChange;
}
export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
const { currency, quantities, handleQuantityChange } = useCheckoutContext();
const { plan, feature_quantities } = change;
const { prepaid, payPerUse, included } = categorizeFeatures(plan.features);
const hasPricedFeatures = prepaid.length > 0 || payPerUse.length > 0;
const hasIncludedFeatures = included.length > 0;
// Show included features only when there are no priced features
const showIncludedFeatures = !hasPricedFeatures && hasIncludedFeatures;
return (
<motion.div
layout
layoutId={`plan-selection-${plan.id}`}
transition={{ layout: LAYOUT_TRANSITION }}
>
<Card className="py-0 gap-0">
<CardBackground>
{/* Plan header */}
<motion.div
className="flex items-center px-3 py-2.5 border-b bg-background/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<span className="text-sm text-foreground truncate">{plan.name}</span>
</motion.div>
{hasPricedFeatures && (
<motion.div
className="flex flex-col"
variants={listContainerVariants}
initial="initial"
animate="animate"
>
{/* Prepaid features - show quantity selector */}
<AnimatePresence>
{prepaid.map((feature, index) => {
const price = feature.price;
if (!price) return null;
const quantityInfo = feature_quantities.find(
(fq) => fq.feature_id === feature.feature_id,
);
const currentQuantity =
quantities[feature.feature_id] ?? quantityInfo?.quantity ?? 0;
const billingUnits = price.billing_units || 1;
const unitPrice = price.amount || 0;
const units = currentQuantity / billingUnits;
const totalPrice = units * unitPrice;
const intervalLabel = formatInterval(price.interval || "month");
return (
<motion.div
key={feature.feature_id}
variants={listItemVariants}
layout
transition={{ layout: LAYOUT_TRANSITION }}
>
{index > 0 && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs text-foreground truncate">
{getFeatureName(feature)}
</span>
<span className="text-xs text-muted-foreground truncate">
{formatAmount(unitPrice, currency)} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
: `${billingUnits} ${getFeatureUnitDisplay(feature, true)}`}
</span>
</div>
<div className="flex items-center gap-4 shrink-0">
<motion.span
key={totalPrice}
className="text-xs text-muted-foreground tabular-nums"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(totalPrice, currency)}/{intervalLabel}
</motion.span>
<QuantityInput
value={currentQuantity}
onChange={(value) =>
handleQuantityChange(
feature.feature_id,
value,
billingUnits,
)
}
min={0}
max={
price.max_purchase
? price.max_purchase * billingUnits
: undefined
}
step={billingUnits}
/>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
{/* Pay-per-use features - show rate with checkmark */}
<AnimatePresence>
{payPerUse.map((feature, index) => {
const price = feature.price;
if (!price) return null;
const billingUnits = price.billing_units || 1;
// Handle tiered pricing
let priceDisplay: string;
if (price.tiers && price.tiers.length > 0) {
const firstTier = price.tiers[0];
const tierPrice =
firstTier?.unit_price ?? firstTier?.flat_price ?? 0;
priceDisplay = `From ${formatAmount(tierPrice, currency)}`;
} else {
priceDisplay = formatAmount(price.amount || 0, currency);
}
// Show separator if there are prepaid features before, or if not the first pay-per-use
const showSeparator = index > 0 || prepaid.length > 0;
return (
<motion.div
key={feature.feature_id}
variants={listItemVariants}
layout
transition={{ layout: LAYOUT_TRANSITION }}
>
{showSeparator && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between gap-4 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<motion.div
className="shrink-0"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 20,
delay: 0.1 + index * 0.05,
}}
>
<CheckIcon className="h-3.5 w-3.5 text-muted-foreground" />
</motion.div>
<span className="text-xs text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{priceDisplay} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
: `${billingUnits} ${getFeatureUnitDisplay(feature, true)}`}
</span>
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Included features - shown only when there are no priced features */}
{showIncludedFeatures && (
<motion.div
className="flex flex-col"
variants={listContainerVariants}
initial="initial"
animate="animate"
>
{included.map((feature, index) => (
<motion.div
key={feature.feature_id}
variants={listItemVariants}
layout
transition={{ layout: LAYOUT_TRANSITION }}
>
{index > 0 && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between gap-4 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<motion.div
className="shrink-0"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 20,
delay: 0.1 + index * 0.05,
}}
>
<CheckIcon className="h-3.5 w-3.5 text-muted-foreground" />
</motion.div>
<span className="text-xs text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
</div>
{feature.granted_balance > 0 || feature.unlimited ? (
<span className="text-xs text-muted-foreground shrink-0">
{feature.unlimited
? "Unlimited"
: `${feature.granted_balance} included`}
</span>
) : null}
</div>
</motion.div>
))}
</motion.div>
)}
</CardBackground>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,38 @@
import { CardBackground } from "@/components/checkout/layout/CardBackground";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
/** Skeleton that matches PlanSelectionCard layout */
export function PlanSelectionCardSkeleton() {
return (
<Card className="py-0 gap-0 flex-1">
<CardBackground>
{/* Plan header */}
<div className="flex items-center px-3 py-2.5 border-b bg-background/50">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
</div>
</div>
{/* Feature rows - 2 rows for a balanced skeleton */}
{[0, 1].map((i) => (
<div key={`feature-skeleton-${i}`}>
{i > 0 && (
<div className="px-3">
<Separator />
</div>
)}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
))}
</CardBackground>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
import { Minus, Plus } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useRef, useState } from "react";
import { FAST_TRANSITION } from "@/lib/animations";
interface QuantityInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
}
export function QuantityInput({
value,
onChange,
min = 0,
max = 999999,
step = 1,
disabled = false,
}: QuantityInputProps) {
const [inputValue, setInputValue] = useState(value.toString());
const prevValue = useRef(value);
const handleDecrement = () => {
const newValue = Math.max(min, value - step);
onChange(newValue);
setInputValue(newValue.toString());
prevValue.current = newValue;
};
const handleIncrement = () => {
const newValue = Math.min(max, value + step);
onChange(newValue);
setInputValue(newValue.toString());
prevValue.current = newValue;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setInputValue(raw);
const parsed = Number.parseInt(raw, 10);
if (!Number.isNaN(parsed) && parsed >= min && parsed <= max) {
onChange(parsed);
prevValue.current = parsed;
}
};
const handleBlur = () => {
// On blur, sync input value with actual value
setInputValue(value.toString());
};
// Sync external value changes
if (
value.toString() !== inputValue &&
document.activeElement?.tagName !== "INPUT"
) {
setInputValue(value.toString());
}
const isAtMin = value <= min;
const isAtMax = value >= max;
return (
<motion.div
className="flex items-center border border-border rounded-lg overflow-hidden shadow-[0_4px_4px_0_rgba(0,0,0,0.02),inset_0_-3px_4px_0_rgba(0,0,0,0.04)]"
animate={{
opacity: disabled ? 0.6 : 1,
}}
transition={FAST_TRANSITION}
>
{/* Decrement button */}
<motion.button
type="button"
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-r border-border"
onClick={handleDecrement}
disabled={disabled || isAtMin}
whileTap={{ scale: 0.9 }}
transition={FAST_TRANSITION}
>
<Minus className="h-2.5 w-2.5" weight="bold" />
</motion.button>
{/* Number display */}
<div className="w-10 h-6 flex items-center justify-center overflow-hidden relative">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="w-full h-full text-center text-xs font-medium tabular-nums text-foreground bg-transparent border-none focus:outline-none focus:ring-0 disabled:opacity-50"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
disabled={disabled}
/>
</div>
{/* Increment button */}
<motion.button
type="button"
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border-l border-border"
onClick={handleIncrement}
disabled={disabled || isAtMax}
whileTap={{ scale: 0.9 }}
transition={FAST_TRANSITION}
>
<Plus className="h-2.5 w-2.5" weight="bold" />
</motion.button>
</motion.div>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
interface SectionHeaderProps {
title: string;
subheading?: string;
rightContent?: ReactNode;
}
export function SectionHeader({
title,
subheading,
rightContent,
}: SectionHeaderProps) {
return (
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center justify-between gap-4">
<span className="text-foreground truncate">{title}</span>
{rightContent && <div className="shrink-0">{rightContent}</div>}
</div>
{subheading && (
<span className="text-xs text-muted-foreground line-clamp-2">{subheading}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { WarningIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutErrorState({ message }: { message: string }) {
return (
<CheckoutBackground>
<motion.div
className="flex flex-col items-start gap-1"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
<div className="flex items-center gap-2">
<WarningIcon className="h-4 w-4 text-destructive shrink-0" weight="bold" />
<span className="text-foreground tracking-tight">Something went wrong</span>
</div>
<p className="text-xs text-muted-foreground pl-6">{message}</p>
</motion.div>
</CheckoutBackground>
);
}

View File

@@ -0,0 +1,31 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { CheckIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutSuccessState({
result,
}: {
result: ConfirmCheckoutResponse;
}) {
return (
<CheckoutBackground>
<motion.div
className="flex flex-col items-start gap-1"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
<div className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-primary shrink-0" weight="bold" />
<span className="text-foreground tracking-tight">Purchase complete</span>
</div>
<p className="text-xs text-muted-foreground pl-6">
Your order has been confirmed
{result.invoice_id && <span className="text-muted-foreground"> · {result.invoice_id}</span>}
</p>
</motion.div>
</CheckoutBackground>
);
}

View File

@@ -0,0 +1,25 @@
import type { ApiFreeTrialV2 } from "@autumn/shared";
import { formatTrialDuration } from "@/utils/trialUtils";
interface FreeTrialSectionProps {
freeTrial: ApiFreeTrialV2;
trialAvailable: boolean;
}
export function FreeTrialSection({ freeTrial, trialAvailable }: FreeTrialSectionProps) {
const duration = formatTrialDuration({
duration_type: freeTrial.duration_type,
duration_length: freeTrial.duration_length,
});
return (
<div className="flex items-center justify-between gap-4 px-3 py-2">
<span className="text-xs text-muted-foreground">
Free Trial
</span>
<span className="text-xs text-muted-foreground shrink-0">
{duration}
</span>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { CROSSFADE_TRANSITION, LAYOUT_TRANSITION } from "@/lib/animations";
import { cn } from "@/lib/utils";
interface CrossfadeContainerProps {
isLoading: boolean;
skeleton: ReactNode;
children: ReactNode;
className?: string;
}
/**
* Smoothly crossfades between skeleton and content with height animation.
* Uses popLayout mode for overlapping exit/enter animations.
*/
export function CrossfadeContainer({
isLoading,
skeleton,
children,
className,
}: CrossfadeContainerProps) {
return (
<motion.div
layout
transition={{ layout: LAYOUT_TRANSITION }}
className={cn("relative", className)}
>
<AnimatePresence mode="popLayout">
{isLoading ? (
<motion.div
key="skeleton"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { duration: 0.2, ease: [0.4, 0, 1, 1] },
}}
>
{skeleton}
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
...CROSSFADE_TRANSITION,
delay: 0.05,
},
}}
exit={{
opacity: 0,
transition: { duration: 0.15 },
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "checkout-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,51 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-[0_4px_4px_0_rgba(0,0,0,0.04),inset_0_-3px_4px_0_rgba(0,0,0,0.04)] hover:shadow-[0_6px_8px_0_rgba(0,0,0,0.08),inset_0_-3px_4px_0_rgba(0,0,0,0.04)] hover:brightness-105 active:shadow-[0_2px_2px_0_rgba(0,0,0,0.04),inset_0_-1px_2px_0_rgba(0,0,0,0.06)] active:brightness-95",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const cardVariants = {
default: "ring-foreground/10 bg-card ring-1 shadow-[0_0_10px_2px_rgba(0,0,0,0.02)]",
muted: "bg-muted/50",
}
function Card({
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<"div"> & {
size?: "default" | "sm"
variant?: "default" | "muted"
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
cardVariants[variant],
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Use shimmer effect instead of pulse */
shimmer?: boolean;
}
/**
* Skeleton loading placeholder with optional shimmer effect.
* Shimmer provides a Stripe-style loading animation.
*/
function Skeleton({ className, shimmer = true, ...props }: SkeletonProps) {
return (
<div
className={cn(
"rounded-md bg-muted relative overflow-hidden",
shimmer && "skeleton-shimmer",
!shimmer && "animate-pulse",
className,
)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { type CheckoutState, useCheckoutState } from "@/hooks/useCheckoutState";
const CheckoutContext = createContext<CheckoutState | null>(null);
export function CheckoutProvider({
checkoutId,
children,
}: {
checkoutId: string;
children: ReactNode;
}) {
const state = useCheckoutState({ checkoutId });
return (
<CheckoutContext.Provider value={state}>
{children}
</CheckoutContext.Provider>
);
}
export function useCheckoutContext() {
const ctx = useContext(CheckoutContext);
if (!ctx) {
throw new Error("useCheckoutContext must be used within CheckoutProvider");
}
return ctx;
}

View File

@@ -0,0 +1,45 @@
import type { GetCheckoutResponse } from "@autumn/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { checkoutApi } from "@/api/checkoutClient";
export const checkoutKeys = {
all: ["checkout"] as const,
detail: (checkoutId: string) => [...checkoutKeys.all, checkoutId] as const,
};
export function useCheckout({ checkoutId }: { checkoutId: string }) {
return useQuery({
queryKey: checkoutKeys.detail(checkoutId),
queryFn: () => checkoutApi.getCheckout({ checkout_id: checkoutId }),
enabled: !!checkoutId,
});
}
export function usePreviewCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: { feature_id: string; quantity: number }[]) =>
checkoutApi.previewCheckout({ checkout_id: checkoutId, options }),
onSuccess: (data) => {
// Update the checkout query cache with new preview data
queryClient.setQueryData(
checkoutKeys.detail(checkoutId),
data as GetCheckoutResponse,
);
},
});
}
export function useConfirmCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => checkoutApi.confirmCheckout({ checkout_id: checkoutId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: checkoutKeys.detail(checkoutId),
});
},
});
}

View File

@@ -0,0 +1,136 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { useCallback, useMemo, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
useCheckout,
useConfirmCheckout,
usePreviewCheckout,
} from "@/hooks/useCheckout";
import { buildHeaderDescription } from "@/utils/buildHeaderDescription";
function buildOptionsArray(
incoming: { feature_quantities: { feature_id: string; quantity: number }[] }[],
quantities: Record<string, number>,
): { feature_id: string; quantity: number }[] {
const options: { feature_id: string; quantity: number }[] = [];
for (const change of incoming) {
for (const fq of change.feature_quantities) {
const quantity = quantities[fq.feature_id] ?? fq.quantity;
options.push({
feature_id: fq.feature_id,
quantity,
});
}
}
return options;
}
export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
// === Raw state ===
const [confirmResult, setConfirmResult] =
useState<ConfirmCheckoutResponse | null>(null);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// === API hooks ===
const { data: checkoutData, isLoading, error } = useCheckout({ checkoutId });
const previewMutation = usePreviewCheckout({ checkoutId });
const confirmMutation = useConfirmCheckout({ checkoutId });
// === Debounced preview ===
const debouncedPreview = useDebouncedCallback(
(options: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate(options);
},
600,
);
// === Derived values ===
const derivedState = useMemo(() => {
const { env, preview, incoming, outgoing, org, entity } = checkoutData ?? {};
const incomingPlan = incoming?.[0]?.plan;
const freeTrial = incomingPlan?.free_trial;
const trialAvailable =
incomingPlan?.customer_eligibility?.trial_available ?? false;
const headerDescription = buildHeaderDescription({
preview,
incoming,
outgoing,
entity: entity ?? undefined,
freeTrial,
trialAvailable,
});
return {
env,
preview,
incoming,
outgoing,
org,
entity,
currency: preview?.currency ?? "usd",
total: preview?.total ?? 0,
primaryPlanName: incomingPlan?.name || "Order",
isSubscription: incoming?.some((c) => c.plan.price?.interval) ?? false,
freeTrial,
trialAvailable,
hasActiveTrial: !!(freeTrial && trialAvailable),
isSandbox: env === "sandbox",
headerDescription,
};
}, [checkoutData]);
// === Callbacks ===
const handleQuantityChange = useCallback(
(featureId: string, quantity: number, _billingUnits: number) => {
setQuantities((prev) => ({ ...prev, [featureId]: quantity }));
if (checkoutData) {
const newQuantities = { ...quantities, [featureId]: quantity };
const options = buildOptionsArray(checkoutData.incoming, newQuantities);
debouncedPreview(options);
}
},
[checkoutData, quantities, debouncedPreview],
);
const handleConfirm = useCallback(() => {
confirmMutation.mutate(undefined, {
onSuccess: (result) => {
setConfirmResult(result);
},
});
}, [confirmMutation]);
// === Status flags ===
const status = useMemo(
() => ({
isLoading,
isUpdating: previewMutation.isPending,
isConfirming: confirmMutation.isPending,
error,
confirmError: confirmMutation.error,
}),
[
isLoading,
previewMutation.isPending,
confirmMutation.isPending,
error,
confirmMutation.error,
],
);
return {
checkoutId,
...derivedState,
quantities,
confirmResult,
status,
handleQuantityChange,
handleConfirm,
};
}
export type CheckoutState = ReturnType<typeof useCheckoutState>;

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useTheme } from "@/components/theme-provider";
/**
* Dev-only hook that toggles dark/light mode when "t" is pressed.
* Only active when NODE_ENV is "development".
*/
export function useDevThemeToggle() {
const { theme, setTheme } = useTheme();
useEffect(() => {
if (import.meta.env.MODE !== "development") return;
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
if (e.key === "t") {
// Get the actual applied theme (resolve "system" to actual value)
const currentTheme =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
// Toggle to the opposite
setTheme(currentTheme === "dark" ? "light" : "dark");
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [theme, setTheme]);
}

194
apps/checkout/src/index.css Normal file
View File

@@ -0,0 +1,194 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--background: #e8e8e8;
--foreground: #09090b;
--card: #fafafa;
--card-foreground: #09090b;
--popover: #fafafa;
--popover-foreground: #09090b;
--primary: #504af6;
--primary-foreground: #ffffff;
--secondary: #fafafa;
--secondary-foreground: #3f3f46;
--muted: #e4e4e7;
--muted-foreground: #52525b;
--accent: #fafafa;
--accent-foreground: #18181b;
--destructive: #dc2626;
--border: #d4d4d8;
--input: #d4d4d8;
--ring: #504af6;
--chart-1: #a78bfa;
--chart-2: #818cf8;
--chart-3: #6366f1;
--chart-4: #504af6;
--chart-5: #4338ca;
--radius: 0.625rem;
--sidebar: #fafafa;
--sidebar-foreground: #09090b;
--sidebar-primary: #504af6;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #fafafa;
--sidebar-accent-foreground: #18181b;
--sidebar-border: #e4e4e7;
--sidebar-ring: #504af6;
--sandbox: #0ea5e9;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.59 0.2 277);
--primary-foreground: oklch(0.96 0.02 272);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.79 0.1 275);
--chart-2: oklch(0.68 0.16 277);
--chart-3: oklch(0.59 0.2 277);
--chart-4: oklch(0.51 0.23 277);
--chart-5: oklch(0.46 0.21 277);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.68 0.16 277);
--sidebar-primary-foreground: oklch(0.96 0.02 272);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--sandbox: #0f9bff;
}
@theme inline {
--font-sans: "Inter", "Inter Fallback", sans-serif;
--color-sandbox: var(--sandbox);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply font-sans bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* ============================================
Shimmer Animation for Skeletons
============================================ */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.skeleton-shimmer {
position: relative;
overflow: hidden;
}
.skeleton-shimmer::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
/* Dark mode shimmer adjustment */
.dark .skeleton-shimmer::after {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 100%
);
}
/* ============================================
Reduced Motion Preferences
============================================ */
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer::after {
animation: none;
}
.animate-pulse {
animation: none;
}
/* Disable all motion library animations */
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,92 @@
import type { Transition, Variants } from "motion/react";
/**
* Core transition presets following Linear-style timing.
* Uses custom ease-out curve [0.32, 0.72, 0, 1] for snappy, professional feel.
*/
/** Fast transition for micro-interactions (150ms) */
export const FAST_TRANSITION: Transition = {
duration: 0.15,
ease: [0.32, 0.72, 0, 1],
};
/** Standard transition for most UI elements (250ms) */
export const STANDARD_TRANSITION: Transition = {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
};
/** Slower transition for larger elements (350ms) */
export const SLOW_TRANSITION: Transition = {
duration: 0.35,
ease: [0.32, 0.72, 0, 1],
};
/** Spring transition for bouncy, organic feel */
export const SPRING_TRANSITION: Transition = {
type: "spring",
stiffness: 500,
damping: 40,
mass: 1,
};
/** Gentle layout transition for skeleton-to-content morphing and height changes */
export const LAYOUT_TRANSITION: Transition = {
type: "spring",
stiffness: 150,
damping: 25,
mass: 1,
};
/** Crossfade transition for skeleton/content swaps */
export const CROSSFADE_TRANSITION: Transition = {
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
};
/** Gentle spring for success animations */
export const GENTLE_SPRING: Transition = {
type: "spring",
stiffness: 300,
damping: 25,
mass: 1,
};
/** Stagger settings for list animations */
const STAGGER_CHILDREN = {
staggerChildren: 0.05,
delayChildren: 0.08,
};
/** Fade with upward slide (8px) */
export const fadeUpVariants: Variants = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -4 },
};
/** Container variant with staggered children */
export const listContainerVariants: Variants = {
initial: { opacity: 0 },
animate: {
opacity: 1,
transition: STAGGER_CHILDREN,
},
exit: { opacity: 0 },
};
/** List item variant (used with container) */
export const listItemVariants: Variants = {
initial: { opacity: 0, y: 10 },
animate: {
opacity: 1,
y: 0,
transition: STANDARD_TRANSITION,
},
exit: {
opacity: 0,
y: -5,
transition: FAST_TRANSITION,
},
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,56 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { CheckoutBackground } from "./components/checkout/layout/CheckoutBackground";
import { ThemeProvider } from "./components/theme-provider";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card";
import { useDevThemeToggle } from "./hooks/useDevThemeToggle";
import { CheckoutPage } from "./pages/CheckoutPage";
import "./index.css";
function DevTools() {
useDevThemeToggle();
return null;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 1000 * 60, // 1 minute
},
},
});
createRoot(document.getElementById("root")!).render(
<ThemeProvider defaultTheme="system" storageKey="checkout-theme">
<DevTools />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/c/:checkoutId" element={<CheckoutPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ThemeProvider>,
);
function NotFound() {
return (
<CheckoutBackground>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Page not found</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
The checkout page you're looking for doesn't exist.
</p>
</CardContent>
</Card>
</div>
</CheckoutBackground>
);
}

View File

@@ -0,0 +1,19 @@
import { useParams } from "react-router-dom";
import { CheckoutContent } from "@/components/checkout/CheckoutContent";
import { CheckoutErrorState } from "@/components/checkout/states/CheckoutErrorState";
import { CheckoutProvider } from "@/contexts/CheckoutContext";
export function CheckoutPage() {
const { checkoutId: checkoutIdParam } = useParams<{ checkoutId: string }>();
const checkoutId = checkoutIdParam ?? "";
if (!checkoutId) {
return <CheckoutErrorState message="Missing checkout ID" />;
}
return (
<CheckoutProvider checkoutId={checkoutId}>
<CheckoutContent />
</CheckoutProvider>
);
}

View File

@@ -0,0 +1,189 @@
import type {
ApiFreeTrialV2,
BillingPreviewResponse,
CheckoutChange,
CheckoutEntity,
LineItemDiscount,
} from "@autumn/shared";
import { format } from "date-fns";
import { formatAmount } from "./formatUtils";
/**
* Builds a phrase describing applied discounts.
* Examples: "Discount code 20OFF applied for 20% off.", "Discount codes 20OFF (20% off) and SAVE10 ($10 off) applied."
*/
function buildDiscountPhrase({
lineItems,
currency,
}: {
lineItems: BillingPreviewResponse["line_items"];
currency: string;
}): string {
// Collect all discounts from line items
const allDiscounts = lineItems.flatMap((item) => item.discounts);
if (allDiscounts.length === 0) return "";
// Deduplicate by couponName (or stripeCouponId as fallback)
const uniqueDiscounts = new Map<string, LineItemDiscount>();
for (const discount of allDiscounts) {
const key = discount.couponName || discount.stripeCouponId || "unknown";
if (!uniqueDiscounts.has(key)) {
uniqueDiscounts.set(key, discount);
}
}
const discountList = Array.from(uniqueDiscounts.values());
if (discountList.length === 0) return "";
// Format each discount
const formatDiscount = (d: LineItemDiscount): string => {
const name = d.couponName || d.stripeCouponId || "Discount";
if (d.percentOff) {
return `${name} applied for ${d.percentOff}% off`;
}
return `${name} applied for ${formatAmount(d.amountOff, currency)} off`;
};
if (discountList.length === 1) {
return `Discount code ${formatDiscount(discountList[0])}.`;
}
// Multiple discounts
const formatted = discountList.map(formatDiscount);
const lastDiscount = formatted.pop();
return `Discount codes ${formatted.join(", ")} and ${lastDiscount}.`;
}
/**
* Builds the action phrase based on the checkout scenario.
* Examples: "Upgrading from Pro to Enterprise", "Subscribing to Enterprise", "Purchasing Starter"
*/
function buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
}: {
scenario?: string;
outgoingPlanName?: string;
incomingPlanName?: string;
isRecurring: boolean;
}): string {
if (outgoingPlanName) {
const toClause = incomingPlanName ? ` to ${incomingPlanName}` : "";
if (scenario === "upgrade") {
return `Upgrading from ${outgoingPlanName}${toClause}`;
}
if (scenario === "downgrade") {
return `Downgrading from ${outgoingPlanName}${toClause}`;
}
return `Changing from ${outgoingPlanName}${toClause}`;
}
if (incomingPlanName) {
return isRecurring
? `Subscribing to ${incomingPlanName}`
: `Purchasing ${incomingPlanName}`;
}
return isRecurring ? "New subscription" : "New purchase";
}
/**
* Formats the free trial duration into a human-readable string.
* Examples: "14-day", "1-month", "7-day"
*/
function formatTrialDuration(freeTrial: ApiFreeTrialV2): string {
const { duration_length, duration_type } = freeTrial;
return `${duration_length}-${duration_type}`;
}
/**
* Builds the header description for the checkout page.
* Returns a natural sentence describing the checkout action, amount, and timing.
*/
export function buildHeaderDescription({
preview,
incoming,
outgoing,
entity,
freeTrial,
trialAvailable,
}: {
preview?: BillingPreviewResponse;
incoming?: CheckoutChange[];
outgoing?: CheckoutChange[];
entity?: CheckoutEntity;
freeTrial?: ApiFreeTrialV2 | null;
trialAvailable?: boolean;
}): string | undefined {
if (!preview) return undefined;
const { total, currency, line_items, next_cycle } = preview;
const change = incoming?.[0];
const scenario = change?.plan.customer_eligibility?.scenario;
const outgoingPlanName = outgoing?.[0]?.plan.name;
const incomingPlanName = change?.plan.name;
const isRecurring = !!change?.plan.price?.interval;
const entityName = entity?.name || entity?.id;
const hasActiveTrial = freeTrial && trialAvailable;
// Determine if this is a scheduled change (no immediate charges, changes next cycle)
const isScheduledChange =
line_items.length === 0 && total === 0 && next_cycle;
// Build discount phrase
const discountPhrase = buildDiscountPhrase({ lineItems: line_items, currency });
// Build the action phrase
let action = buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
});
// Add entity if present
if (entityName) {
action += ` for ${entityName}`;
}
// Build trial phrase if applicable
const trialDuration = hasActiveTrial
? formatTrialDuration(freeTrial)
: null;
// Handle negative amounts (refund/credit from previous plan)
if (total < 0) {
const creditAmount = formatAmount(Math.abs(total), currency);
let sentence = `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} You'll receive a ${creditAmount} credit for unused time on your previous plan.`;
if (hasActiveTrial && next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
sentence += ` Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`;
} else if (next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
sentence += ` Your next charge of ${nextAmount} is on ${nextDate}.`;
}
return sentence;
}
// Handle free trial (no immediate payment, trial starts)
if (hasActiveTrial && next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`;
}
// Handle scheduled changes (no immediate charges)
if (isScheduledChange) {
const effectiveDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today. Changes take effect ${effectiveDate}.`;
}
// Standard format
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today.`;
}

View File

@@ -0,0 +1,14 @@
import { format } from "date-fns";
export function formatAmount(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount);
}
/** Formats a period range from millisecond timestamps (e.g., "3rd February 4th March") */
export function formatPeriodRange(startMs: number, endMs: number): string {
const formatDate = (ms: number) => format(new Date(ms), "do MMMM");
return `${formatDate(startMs)} ${formatDate(endMs)}`;
}

View File

@@ -0,0 +1,15 @@
/**
* Formats a trial duration into a human-readable string
* @example formatTrialDuration({ duration_type: "day", duration_length: 14 }) // "14 days"
* @example formatTrialDuration({ duration_type: "month", duration_length: 1 }) // "1 month"
*/
export function formatTrialDuration({
duration_type,
duration_length,
}: {
duration_type: "day" | "month" | "year";
duration_length: number;
}): string {
const unit = duration_length === 1 ? duration_type : `${duration_type}s`;
return `${duration_length} ${unit}`;
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,24 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [react(), tsconfigPaths(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
optimizeDeps: {
exclude: ["@autumn/shared", "zod/v4"],
},
server: {
host: "0.0.0.0",
port: Number.parseInt(process.env.VITE_PORT || "3001", 10),
fs: {
allow: [".."],
},
},
});

522
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,12 @@
"scripts": {
"entry": ["**/*.{ts,js}"],
"project": ["**/*.{ts,js}"]
},
"apps/checkout": {
"entry": ["src/main.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/components/ui/**", "src/hooks/**"],
"ignoreDependencies": ["shadcn", "tailwindcss", "tw-animate-css"]
}
},
"ignoreBinaries": ["infisical", "lsof", "serve"]

View File

@@ -6,7 +6,8 @@
"server",
"shared",
"vite",
"scripts"
"scripts",
"apps/checkout"
],
"catalog": {
"stripe": "19.3.0-beta.1",
@@ -15,7 +16,10 @@
"@sentry/bun": "10.25.0",
"@clickhouse/client": "1.11.2",
"@date-fns/utc": "2.1.0",
"@better-auth/dash": "0.1.6"
"@better-auth/dash": "0.1.6",
"@orpc/contract": "^1.0.0",
"@orpc/client": "^1.0.0",
"@orpc/openapi-client": "^1.0.0"
}
},
"overrides": {
@@ -48,7 +52,8 @@
"q": "lsof -ti:8080 -ti:3000 | xargs kill -9",
"knip": "knip",
"knip:fix": "knip --fix",
"knip:fix-all": "knip --fix --allow-remove-files"
"knip:fix-all": "knip --fix --allow-remove-files",
"prepare": "husky"
},
"dependencies": {
"@aws-sdk/client-firehose": "^3.975.0",
@@ -70,6 +75,7 @@
"@types/node": "^24.9.1",
"concurrently": "^9.2.1",
"dotenv": "^16.6.1",
"husky": "^9.1.7",
"inquirer": "^12.10.0"
}
}

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
const VITE_PORT = 3000;
const SERVER_PORT = 8080;
const CHECKOUT_PORT = 3001;
/**
* Read environment variable from .env file
@@ -53,6 +54,18 @@ async function startDev() {
rmSync(viteCachePath, { recursive: true, force: true });
}
// Clear checkout Vite cache
const checkoutCachePath = join(
projectRoot,
"apps/checkout",
"node_modules",
".vite",
);
if (existsSync(checkoutCachePath)) {
console.log("🧹 Clearing Checkout Vite cache...\n");
rmSync(checkoutCachePath, { recursive: true, force: true });
}
console.log("🚀 Starting development servers in watch mode...\n");
// Use cmd on Windows, sh on Unix
@@ -63,16 +76,17 @@ async function startDev() {
const serverCmd = `cd server && set SERVER_PORT=${SERVER_PORT} && bun dev`;
const workersCmd = `cd server && bun workers:dev`;
const viteCmd = `cd vite && set VITE_PORT=${VITE_PORT} && bun dev`;
const checkoutCmd = `cd apps/checkout && set VITE_PORT=${CHECKOUT_PORT} && bun dev`;
shellArgs = [
"cmd",
"/c",
`bunx concurrently -n server,workers,vite -c green,yellow,blue "${serverCmd}" "${workersCmd}" "${viteCmd}"`,
`bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "${serverCmd}" "${workersCmd}" "${viteCmd}" "${checkoutCmd}"`,
];
} else {
shellArgs = [
"sh",
"-c",
`bunx concurrently -n server,workers,vite -c green,yellow,blue "cd server && SERVER_PORT=${SERVER_PORT} bun dev" "cd server && bun workers:dev" "cd vite && VITE_PORT=${VITE_PORT} bun dev"`,
`bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "cd server && SERVER_PORT=${SERVER_PORT} bun dev" "cd server && bun workers:dev" "cd vite && VITE_PORT=${VITE_PORT} bun dev" "cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`,
];
}
@@ -82,6 +96,7 @@ async function startDev() {
...process.env,
VITE_PORT: VITE_PORT.toString(),
SERVER_PORT: SERVER_PORT.toString(),
CHECKOUT_PORT: CHECKOUT_PORT.toString(),
},
stdout: "inherit",
stderr: "inherit",

View File

@@ -4,4 +4,9 @@ source "$(dirname "$0")/config.sh"
BUN_PARALLEL_V2 \
'integration/billing/update-subscription' \
# 'integration/billing/stripe-webhooks' \
# 'integration/crud/customers' \
# 'integration/billing/autumn-webhooks' \
# 'integration/crud/customers' \
# 'integration/billing/attach' \
# 'integration/cron/one-off-cleanup' \

14
scripts/testGroups/attach.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Exit immediately if a command exits with a non-zero status
set -e
BUN_PARALLEL_V2 \
'attach/new-plan' \
'attach/immediate-switch' \
'attach/scheduled-switch' \
'attach/free-trial'

View File

@@ -6,12 +6,12 @@ export TEST_FILE_CONCURRENCY=6
BUN_PARALLEL_V2 \
'attach/basic' \
'attach/migrations' \
'attach/upgrade' \
'attach/downgrade' \
'attach/free' \
'attach/addOn' \
'attach/checkout' \
'attach/migrations' \
'attach/others' \
'attach/upgradeOld' \
'attach/response' \

View File

@@ -7,15 +7,15 @@ 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
# 'server/tests/advanced/rollovers' \
# 'server/tests/advanced/misc' \
# 'server/tests/attach/updateQuantity' \
# 'server/tests/attach/multiProduct' \
# 'server/tests/advanced/multiFeature' \
# 'server/tests/advanced/customInterval' \
# 'server/tests/advanced/usageLimit' \
# --max=6
# BUN_PARALLEL_COMPACT \

View File

@@ -6,16 +6,6 @@ source "$(dirname "$0")/config.sh"
# Exit immediately if a command exits with a non-zero status
set -e
# 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
BUN_PARALLEL_V2 \

View File

@@ -24,8 +24,10 @@
"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"
},
"mocha": {
"node-option": [

View File

@@ -9,7 +9,9 @@ import {
type ApiCusProductV3,
type ApiEntityV0,
type AttachBodyV0,
type AttachParamsV0Input,
type BalancesUpdateParams,
type BillingPreviewResponse,
type BillingResponse,
type CheckQuery,
type CreateBalanceParams,
@@ -22,7 +24,9 @@ import {
ErrCode,
type LegacyVersion,
type OrgConfig,
type ProductItem,
type RewardRedemption,
type SetupPaymentParams,
type TrackParams,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
@@ -667,6 +671,29 @@ export class AutumnInt {
});
return data;
},
list: async (params: { customer_id: string; entity_id?: string }) => {
const data = await this.post(`/events/list`, params);
return data;
},
aggregate: async (params: {
customer_id: string;
entity_id?: string;
feature_id?: string;
}) => {
const data = await this.post(`/events/aggregate`, params);
return data;
},
query: async (params: {
customer_id: string;
entity_id?: string;
feature_id?: string;
}) => {
const data = await this.post(`/query`, params);
return data;
},
};
stripe = {
@@ -809,4 +836,53 @@ export class AutumnInt {
return data;
},
};
billing = {
attach: async (
params: Omit<AttachParamsV0Input, "items"> & { items?: ProductItem[] },
{
skipWebhooks,
idempotencyKey,
timeout,
}: {
skipWebhooks?: boolean;
idempotencyKey?: string;
timeout?: number;
} = {},
) => {
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(
`/billing/attach`,
{ redirect_mode: "if_required", ...params },
Object.keys(headers).length > 0 ? headers : undefined,
);
if (timeout) {
await new Promise((resolve) => setTimeout(resolve, timeout));
}
return data;
},
previewAttach: async (
params: Omit<AttachParamsV0Input, "items"> & { items?: ProductItem[] },
): Promise<BillingPreviewResponse> => {
const data = await this.post(`/billing/preview_attach`, {
...params,
redirect_mode: "if_required",
});
return data;
},
setupPayment: async (params: SetupPaymentParams) => {
const data = await this.post(`/setup_payment`, params);
return data;
},
};
}

View File

@@ -2,10 +2,9 @@ import type { WebhookExpiration } from "@puzzmo/revenue-cat-webhook-types";
import { CusProductStatus, ErrCode, RecaseError } from "@shared/index";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { activateDefaultProduct } from "@/internal/customers/cusProducts/cusProductUtils";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
import { isOneOff } from "@/internal/products/productUtils";
export const handleExpiration = async ({
event,
@@ -49,16 +48,23 @@ export const handleExpiration = async ({
logger.info(`Expired cus_product: ${curSameProduct.id}`);
// Activate default product if this was a main product
const isMain = !product.is_add_on;
const isOneOffProduct = isOneOff(product.prices);
await customerProductActions.activateFreeSuccessor({
ctx,
fromCustomerProduct: curSameProduct,
fullCustomer: customer,
});
if (isMain && !isOneOffProduct) {
await activateDefaultProduct({
ctx,
productGroup: product.group,
fullCus: customer,
curCusProduct: curSameProduct,
});
}
// Activate default product if this was a main product
// const isMain = !product.is_add_on;
// const isOneOffProduct = isOneOff(product.prices);
// if (isMain && !isOneOffProduct) {
// // await activateDefaultProduct({
// // ctx,
// // productGroup: product.group,
// // fullCus: customer,
// // curCusProduct: curSameProduct,
// // });
// }
};

View File

@@ -0,0 +1,16 @@
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const getStripeCheckoutSession = async ({
ctx,
checkoutSessionId,
}: {
ctx: AutumnContext;
checkoutSessionId: string;
}): Promise<Stripe.Checkout.Session> => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
return stripeCli.checkout.sessions.retrieve(checkoutSessionId);
};

View File

@@ -0,0 +1,51 @@
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
// Map expand strings to their expanded types
type CheckoutSessionExpandMap = {
line_items: { line_items: Stripe.ApiList<Stripe.LineItem> };
subscription: { subscription: Stripe.Subscription | null };
invoice: { invoice: Stripe.Invoice | null };
customer: { customer: Stripe.Customer | Stripe.DeletedCustomer | null };
payment_intent: { payment_intent: Stripe.PaymentIntent | null };
setup_intent: { setup_intent: Stripe.SetupIntent | null };
};
type CheckoutSessionExpandKey = keyof CheckoutSessionExpandMap;
// Converts union to intersection: A | B → A & B
type UnionToIntersection<U> = (
U extends unknown
? (x: U) => void
: never
) extends (x: infer R) => void
? R
: never;
export type ExpandedStripeCheckoutSession<
T extends CheckoutSessionExpandKey[],
> = Stripe.Checkout.Session &
UnionToIntersection<CheckoutSessionExpandMap[T[number]]>;
/** Dynamically typed Stripe checkout session based on expand params */
export const getStripeCheckoutSession = async <
T extends CheckoutSessionExpandKey[],
>({
ctx,
checkoutSessionId,
expand,
}: {
ctx: AutumnContext;
checkoutSessionId: string;
expand: T;
}): Promise<ExpandedStripeCheckoutSession<T>> => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const checkoutSession = await stripeCli.checkout.sessions.retrieve(
checkoutSessionId,
{ expand: expand as string[] },
);
return checkoutSession as unknown as ExpandedStripeCheckoutSession<T>;
};

View File

@@ -0,0 +1,57 @@
import { type FullProduct, type Price, priceToEnt } from "@autumn/shared";
import type Stripe from "stripe";
import { stripeCheckoutSessionUtils } from "@/external/stripe/checkoutSessions/utils";
import { stripeItemToFeatureOptionsQuantity } from "@/external/stripe/common/utils/stripeItemToFeatureOptionsQuantity";
export const stripeCheckoutSessionToSubscriptionId = async ({
stripeCheckoutSession,
}: {
stripeCheckoutSession: Stripe.Checkout.Session;
}) => {
return typeof stripeCheckoutSession.subscription === "string"
? stripeCheckoutSession.subscription
: (stripeCheckoutSession.subscription?.id ?? null);
};
export const stripeCheckoutSessionToInvoiceId = async ({
stripeCheckoutSession,
}: {
stripeCheckoutSession: Stripe.Checkout.Session;
}) => {
return typeof stripeCheckoutSession.invoice === "string"
? stripeCheckoutSession.invoice
: (stripeCheckoutSession.invoice?.id ?? null);
};
export const stripeCheckoutSessionToFeatureOptionsQuantity = ({
stripeCheckoutSession,
price,
product,
}: {
stripeCheckoutSession: Stripe.Checkout.Session;
price: Price;
product: FullProduct;
}) => {
const lineItem = stripeCheckoutSessionUtils.find.lineItemByAutumnPrice({
lineItems: stripeCheckoutSession.line_items?.data ?? [],
price,
product,
});
const entitlement = priceToEnt({
price,
entitlements: product.entitlements,
});
if (lineItem?.quantity && entitlement) {
const featureOptionsQuantity = stripeItemToFeatureOptionsQuantity({
itemQuantity: lineItem.quantity,
price,
product,
});
return featureOptionsQuantity;
}
return lineItem?.quantity ?? 0;
};

View File

@@ -0,0 +1,68 @@
import {
InternalError,
isFixedPrice,
type Price,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
type FindCheckoutLineItemParams = {
lineItems: Stripe.LineItem[];
price: Price;
product: Product;
};
// Overload: errorOnNotFound = true → guaranteed LineItem
export function findCheckoutLineItemByAutumnPrice(
params: FindCheckoutLineItemParams & { errorOnNotFound: true },
): Stripe.LineItem;
// Overload: errorOnNotFound = false/undefined → LineItem | undefined
export function findCheckoutLineItemByAutumnPrice(
params: FindCheckoutLineItemParams & { errorOnNotFound?: false },
): Stripe.LineItem | undefined;
// Implementation
export function findCheckoutLineItemByAutumnPrice({
lineItems,
price,
product,
errorOnNotFound,
}: FindCheckoutLineItemParams & { errorOnNotFound?: boolean }):
| Stripe.LineItem
| undefined {
const stripeProductId = product.processor?.id;
let result: Stripe.LineItem | undefined;
if (isFixedPrice(price)) {
const config = price.config;
result = lineItems.find((li) => {
return (
config.stripe_price_id === li.price?.id ||
(stripeProductId && li.price?.product === stripeProductId)
);
});
} else {
const config = price.config as UsagePriceConfig;
result = lineItems.find((li) => {
return (
config.stripe_price_id === li.price?.id ||
config.stripe_product_id === li.price?.product ||
config.stripe_empty_price_id === li.price?.id ||
config.stripe_prepaid_price_v2_id === li.price?.id
);
});
}
if (errorOnNotFound && !result) {
throw new InternalError({
message: `Checkout line item not found for price: ${price.id}`,
});
}
return result;
}

View File

@@ -0,0 +1,17 @@
import {
stripeCheckoutSessionToFeatureOptionsQuantity,
stripeCheckoutSessionToInvoiceId,
stripeCheckoutSessionToSubscriptionId,
} from "@/external/stripe/checkoutSessions/utils/convertStripeCheckoutSession";
import { findCheckoutLineItemByAutumnPrice } from "@/external/stripe/checkoutSessions/utils/findCheckoutLineItem";
export const stripeCheckoutSessionUtils = {
convert: {
toSubscriptionId: stripeCheckoutSessionToSubscriptionId,
toInvoiceId: stripeCheckoutSessionToInvoiceId,
toFeatureOptionsQuantity: stripeCheckoutSessionToFeatureOptionsQuantity,
},
find: {
lineItemByAutumnPrice: findCheckoutLineItemByAutumnPrice,
},
};

View File

@@ -0,0 +1,25 @@
import { type FullProduct, type Price, priceToEnt } from "@autumn/shared";
import { priceToAllowanceInPacks } from "@shared/utils/productUtils/priceUtils/convertPrice/priceToAllowanceInPacks";
import { Decimal } from "decimal.js";
export const stripeItemToFeatureOptionsQuantity = ({
itemQuantity,
price,
product,
}: {
itemQuantity: number;
price: Price;
product: FullProduct;
}) => {
const entitlement = priceToEnt({
price,
entitlements: product.entitlements,
});
const allowanceInPacks = priceToAllowanceInPacks({
price,
entitlement,
});
return new Decimal(itemQuantity).sub(allowanceInPacks).toNumber();
};

View File

@@ -127,6 +127,8 @@ export const createStripePrepaid = async ({
};
}
console.log("priceAmountData", priceAmountData);
stripePrice = await stripeCli.prices.create({
...productData,
currency: orgToCurrency({ org }),

View File

@@ -0,0 +1,82 @@
import {
type FullProduct,
type Price,
priceToEnt,
priceUtils,
type UsagePriceConfig,
} from "@autumn/shared";
import { PriceService } from "@server/internal/products/prices/PriceService";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const createStripePrepaidPriceV2 = async ({
ctx,
price,
product,
currentStripeProduct,
}: {
ctx: AutumnContext;
price: Price;
product: FullProduct;
currentStripeProduct?: Stripe.Product;
}) => {
const { org, db, env } = ctx;
// 1. If no entitlement, re-use current stripe price
const entitlement = priceToEnt({
price,
entitlements: product.entitlements,
});
if (!entitlement?.allowance) {
price.config = {
...(price.config as UsagePriceConfig),
stripe_prepaid_price_v2_id: price.config.stripe_price_id,
};
await PriceService.update({
db,
id: price.id!,
update: {
config: {
...(price.config as UsagePriceConfig),
stripe_prepaid_price_v2_id: price.config.stripe_price_id,
},
},
});
return;
}
const stripeCreatePriceParams = priceUtils.convert.toStripeCreatePriceParams({
price,
product,
org,
currentStripeProduct,
});
console.log("stripeCreatePriceParams", stripeCreatePriceParams);
const stripeCli = createStripeCli({ org, env });
const stripePrice = await stripeCli.prices.create(stripeCreatePriceParams);
price.config = {
...(price.config as UsagePriceConfig),
stripe_prepaid_price_v2_id: stripePrice.id,
// stripe_product_id: stripePrice.product as string,
};
await PriceService.update({
db,
id: price.id!,
update: {
config: {
...(price.config as UsagePriceConfig),
stripe_prepaid_price_v2_id: stripePrice.id,
// stripe_product_id: stripePrice.product as string,
},
},
});
};

View File

@@ -1,19 +1,18 @@
import {
BillingType,
type EntitlementWithFeature,
type Organization,
type FullProduct,
type Price,
type Product,
priceUtils,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@server/db/initDrizzle";
import { PriceService } from "@server/internal/products/prices/PriceService";
import {
getBillingType,
getPriceEntitlement,
priceIsOneOffAndTiered,
} from "@server/internal/products/prices/priceUtils";
import { getBillingType } from "@server/internal/products/prices/priceUtils";
import Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripePrepaidPriceV2 } from "@/external/stripe/createStripePrice/createStripePrepaidPriceV2.js";
import { getStripePrice } from "@/external/stripe/prices/operations/getStripePrice.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
import {
createStripeArrearProrated,
@@ -83,49 +82,57 @@ const checkCurStripePrice = async ({
}
}
let stripePrepaidPriceV2: Stripe.Price | undefined;
if (config.stripe_prepaid_price_v2_id) {
stripePrepaidPriceV2 = undefined;
} else {
stripePrepaidPriceV2 = await getStripePrice({
stripeClient: stripeCli,
stripePriceId: config.stripe_prepaid_price_v2_id ?? undefined,
});
}
return {
stripePrice,
stripePrepaidPriceV2,
stripeProd,
};
};
export const createStripePriceIFNotExist = async ({
db,
stripeCli,
ctx,
price,
entitlements,
product,
org,
logger,
internalEntityId,
useCheckout = false,
}: {
db: DrizzleCli;
stripeCli: Stripe;
ctx: AutumnContext;
price: Price;
entitlements: EntitlementWithFeature[];
product: Product;
org: Organization;
logger: any;
product: FullProduct;
internalEntityId?: string;
useCheckout?: boolean;
}) => {
// Fetch latest price data...
const { org, logger, db, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const billingType = getBillingType(price.config!);
const { stripePrice, stripeProd } = await checkCurStripePrice({
price,
stripeCli,
currency: org.default_currency || "usd",
});
const { stripePrice, stripePrepaidPriceV2, stripeProd } =
await checkCurStripePrice({
price,
stripeCli,
currency: org.default_currency || "usd",
});
const config = price.config! as UsagePriceConfig;
config.stripe_price_id = stripePrice?.id;
config.stripe_product_id = stripeProd?.id;
const relatedEnt = getPriceEntitlement(price, entitlements);
const isOneOffAndTiered = priceIsOneOffAndTiered(price, relatedEnt);
const isOneOffAndTiered = priceUtils.isTieredOneOff({ price, product });
// 1. If fixed price, just create price
if (
@@ -168,6 +175,16 @@ export const createStripePriceIFNotExist = async ({
curStripeProd: stripeProd,
});
}
if (!isOneOffAndTiered && !stripePrepaidPriceV2) {
logger.info(`Creating stripe v2 prepaid price`);
await createStripePrepaidPriceV2({
ctx,
price,
product,
currentStripeProduct: stripePrepaidPriceV2,
});
}
}
if (billingType === BillingType.InArrearProrated) {

View File

@@ -8,10 +8,10 @@ import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
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 { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js";
import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js";
import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js";
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
@@ -82,14 +82,7 @@ export const handleStripeWebhookEvent = async (
break;
case "checkout.session.completed": {
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
ctx,
db,
data: checkoutSession,
org,
env,
});
await handleStripeCheckoutSessionCompleted({ ctx, event });
break;
}
}

View File

@@ -55,8 +55,8 @@ export const priceToStripeItem = ({
withEntity = false,
isCheckout = false,
apiVersion,
// productOptions,
fromVercel = false,
isPrepaidPriceV2 = false,
}: {
price: Price;
relatedEnt?: EntitlementWithFeature;
@@ -69,6 +69,7 @@ export const priceToStripeItem = ({
apiVersion?: ApiVersion;
// productOptions?: ProductOptions | undefined;
fromVercel?: boolean;
isPrepaidPriceV2?: boolean;
}) => {
// TODO: Implement this
const billingType = getBillingType(price.config!);
@@ -125,8 +126,10 @@ export const priceToStripeItem = ({
else if (billingType === BillingType.UsageInAdvance) {
lineItem = priceToUsageInAdvance({
price,
entitlement: relatedEnt,
options,
isCheckout,
isPrepaidPriceV2,
});
}

View File

@@ -1,9 +1,10 @@
import type {
EntitlementWithFeature,
FeatureOptions,
Organization,
Price,
UsagePriceConfig,
import {
type EntitlementWithFeature,
type FeatureOptions,
featureOptionUtils,
type Organization,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { orgToCurrency } from "@server/internal/orgs/orgUtils";
import { getPriceForOverage } from "@server/internal/products/prices/priceUtils";
@@ -49,15 +50,33 @@ export const priceToOneOffAndTiered = ({
export const priceToUsageInAdvance = ({
price,
entitlement,
options,
isCheckout,
isPrepaidPriceV2 = false,
}: {
price: Price;
entitlement: EntitlementWithFeature;
options: FeatureOptions | undefined | null;
isCheckout: boolean;
isPrepaidPriceV2?: boolean;
}) => {
const config = price.config as UsagePriceConfig;
const optionsQuantity = options?.quantity;
if (isPrepaidPriceV2) {
const quantity = featureOptionUtils.convert.toV2StripeQuantity({
featureOptions: options ?? undefined,
price,
entitlement,
});
return {
price: config.stripe_prepaid_price_v2_id,
quantity: quantity,
};
}
const optionsQuantity = options?.upcoming_quantity ?? options?.quantity;
let finalQuantity = optionsQuantity;
// 1. If adjustable quantity is set, use that, else if quantity is undefined, adjustable is true, else false

View File

@@ -0,0 +1,45 @@
import { InternalError, tryCatch } from "@autumn/shared";
import Stripe from "stripe";
export async function getStripePrice({
stripeClient,
stripePriceId,
errorOnNotFound = false,
}: {
stripeClient: Stripe;
stripePriceId?: string;
errorOnNotFound?: boolean;
}): Promise<Stripe.Price | undefined> {
const getStripePriceOptional = async () => {
if (!stripePriceId) return undefined;
const { data: stripePrice, error } = await tryCatch(
stripeClient.prices.retrieve(stripePriceId),
);
if (error) {
if (
error instanceof Stripe.errors.StripeError &&
error.code?.includes("resource_missing")
) {
return undefined;
}
throw error;
}
if (stripePrice.deleted) return undefined;
return stripePrice;
};
const stripePrice = await getStripePriceOptional();
if (!stripePrice && errorOnNotFound) {
throw new InternalError({
message: stripePriceId
? `Stripe price not found: ${stripePriceId}`
: "Stripe customer id is required.",
});
}
return stripePrice;
}

View File

@@ -1,14 +1,8 @@
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { initProductInStripe } from "@/internal/products/productUtils.js";
async function ensureStripeProducts({ ctx }: { ctx: AutumnContext }) {
await ensureStripeProductsWithEnv({
ctx,
});
}
export async function ensureStripeProductsWithEnv({
ctx,
}: {
@@ -24,10 +18,8 @@ export async function ensureStripeProductsWithEnv({
});
const stripeCli = createStripeCli({ org, env });
// Fetch updated org data to ensure we have the latest Stripe configuration
const products = await stripeCli.products.list({ limit: 100 });
const updatedOrg = await OrgService.get({ db, orgId: org.id });
const batchInit: Promise<void>[] = [];
for (const fullProduct of fullProducts) {
@@ -42,10 +34,7 @@ export async function ensureStripeProductsWithEnv({
try {
await initProductInStripe({
db,
org: updatedOrg,
env,
logger,
ctx,
product: fullProduct,
});

View File

@@ -0,0 +1,7 @@
import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice";
export const stripeSubscriptionItemUtils = {
find: {
byAutumnPrice: findSubscriptionItemByAutumnPrice,
},
};

View File

@@ -0,0 +1,70 @@
import {
InternalError,
isFixedPrice,
type Price,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
type FindSubscriptionItemParams = {
stripeSubscriptionItems: Stripe.SubscriptionItem[];
price: Price;
product: Product;
};
// Overload: errorOnNotFound = true → guaranteed SubscriptionItem
export function findSubscriptionItemByAutumnPrice(
params: FindSubscriptionItemParams & { errorOnNotFound: true },
): Stripe.SubscriptionItem;
// Overload: errorOnNotFound = false/undefined → SubscriptionItem | undefined
export function findSubscriptionItemByAutumnPrice(
params: FindSubscriptionItemParams & { errorOnNotFound?: false },
): Stripe.SubscriptionItem | undefined;
// Implementation
export function findSubscriptionItemByAutumnPrice({
stripeSubscriptionItems,
price,
product,
errorOnNotFound,
}: FindSubscriptionItemParams & { errorOnNotFound?: boolean }):
| Stripe.SubscriptionItem
| undefined {
const stripeProductId = product.processor?.id;
let result: Stripe.SubscriptionItem | undefined;
if (isFixedPrice(price)) {
const config = price.config;
result = stripeSubscriptionItems.find((si) => {
return (
config.stripe_price_id === si.price?.id ||
(stripeProductId && si.price?.product === stripeProductId)
);
});
} else {
const config = price.config as UsagePriceConfig;
result = stripeSubscriptionItems.find(
(si: Stripe.SubscriptionItem | Stripe.LineItem) => {
return (
config.stripe_price_id === si.price?.id ||
config.stripe_product_id === si.price?.product ||
config.stripe_empty_price_id === si.price?.id ||
config.stripe_prepaid_price_v2_id === si.price?.id
);
},
);
}
if (errorOnNotFound && !result) {
throw new InternalError({
message: `Stripe subscription item not found for price: ${price.id}`,
});
}
return result;
}

View File

@@ -133,9 +133,11 @@ export const stripeSubscriptionToNowMs = async ({
export const stripeSubscriptionToScheduleId = ({
stripeSubscription,
}: {
stripeSubscription: ExpandedStripeSubscription;
}): string | null => {
stripeSubscription?: Stripe.Subscription;
}): string | undefined => {
if (!stripeSubscription) return undefined;
return typeof stripeSubscription.schedule === "string"
? stripeSubscription.schedule
: (stripeSubscription.schedule?.id ?? null);
: (stripeSubscription.schedule?.id ?? undefined);
};

View File

@@ -7,7 +7,7 @@ import {
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";
import type { BillingContext } from "@autumn/shared";
/**
* Common fields between InvoiceCreatedContext and StripeSubscriptionDeletedContext.

View File

@@ -1,9 +1,9 @@
import type { FullCusEntWithFullCusProduct, LineItem } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingContext } from "@autumn/shared";
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 type { UpdateCustomerEntitlement } from "@autumn/shared";
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
import {
type BaseWebhookEventContext,

View File

@@ -0,0 +1,64 @@
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import type { StripeSubscriptionDeletedContext } from "../handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext";
import type { StripeSubscriptionUpdatedContext } from "../handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext";
import {
trackCustomerProductInsertion,
trackCustomerProductUpdate,
} from "./trackCustomerProductUpdate";
type SubscriptionEventContext =
| StripeSubscriptionUpdatedContext
| StripeSubscriptionDeletedContext;
/**
* Expires a customer product and activates a free successor (scheduled or default).
* Handles all tracking for updates and insertions.
*
* @returns The expired customer product (with updates applied)
*/
export const expireAndActivateWithTracking = async ({
ctx,
eventContext,
customerProduct,
}: {
ctx: StripeWebhookContext;
eventContext: SubscriptionEventContext;
customerProduct: FullCusProduct;
}): Promise<{ expiredCustomerProduct: FullCusProduct }> => {
const { fullCustomer } = eventContext;
const { updates, activatedCustomerProduct, insertedCustomerProduct } =
await customerProductActions.expireAndActivateDefault({
ctx,
customerProduct,
fullCustomer,
});
// Track expired product (UPDATE)
const expiredCustomerProduct = trackCustomerProductUpdate({
eventContext,
customerProduct,
updates,
});
// Track activated scheduled product (UPDATE: scheduled → active)
if (activatedCustomerProduct) {
trackCustomerProductUpdate({
eventContext,
customerProduct: activatedCustomerProduct,
updates: { status: CusProductStatus.Active },
});
}
// Track inserted default product (INSERT)
if (insertedCustomerProduct) {
trackCustomerProductInsertion({
eventContext,
customerProduct: insertedCustomerProduct,
});
}
return { expiredCustomerProduct };
};

View File

@@ -1,6 +1,8 @@
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking";
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";
export {
trackCustomerProductDeletion,
trackCustomerProductInsertion,
trackCustomerProductUpdate,
} from "./trackCustomerProductUpdate";

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