Merge branch 'feat/attach-v2' into dev
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
246
.claude/skills/write-test/references/PRORATION.md
Normal file
246
.claude/skills/write-test/references/PRORATION.md
Normal 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,
|
||||
});
|
||||
```
|
||||
@@ -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
24
.github/workflows/server-typecheck.yml
vendored
Normal 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
cd server && bun ts
|
||||
314
.opencode/plans/checkout-session-completed-v2.md
Normal file
314
.opencode/plans/checkout-session-completed-v2.md
Normal 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
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
52
apps/checkout/.claude/skills/d3k/SKILL.md
Normal file
52
apps/checkout/.claude/skills/d3k/SKILL.md
Normal 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
24
apps/checkout/.gitignore
vendored
Normal 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
3
apps/checkout/README.md
Normal 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
50
apps/checkout/biome.json
Normal 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
1205
apps/checkout/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
24
apps/checkout/components.json
Normal file
24
apps/checkout/components.json
Normal 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
13
apps/checkout/index.html
Normal 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>
|
||||
42
apps/checkout/package.json
Normal file
42
apps/checkout/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/checkout/public/vite.svg
Normal file
1
apps/checkout/public/vite.svg
Normal 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 |
17
apps/checkout/src/api/checkoutClient.ts
Normal file
17
apps/checkout/src/api/checkoutClient.ts
Normal 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" });
|
||||
9
apps/checkout/src/assets/autumn.svg
Normal file
9
apps/checkout/src/assets/autumn.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 937 KiB |
1
apps/checkout/src/assets/react.svg
Normal file
1
apps/checkout/src/assets/react.svg
Normal 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 |
140
apps/checkout/src/components/bg/background-beams.tsx
Normal file
140
apps/checkout/src/components/bg/background-beams.tsx
Normal 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";
|
||||
87
apps/checkout/src/components/checkout/CheckoutContent.tsx
Normal file
87
apps/checkout/src/components/checkout/CheckoutContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
133
apps/checkout/src/components/checkout/confirm/ConfirmSection.tsx
Normal file
133
apps/checkout/src/components/checkout/confirm/ConfirmSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
123
apps/checkout/src/components/checkout/plan/PlanGroupSection.tsx
Normal file
123
apps/checkout/src/components/checkout/plan/PlanGroupSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/checkout/src/components/checkout/plan/PlanSection.tsx
Normal file
30
apps/checkout/src/components/checkout/plan/PlanSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
apps/checkout/src/components/checkout/plan/PlanSelectionCard.tsx
Normal file
310
apps/checkout/src/components/checkout/plan/PlanSelectionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
114
apps/checkout/src/components/checkout/shared/QuantityInput.tsx
Normal file
114
apps/checkout/src/components/checkout/shared/QuantityInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
64
apps/checkout/src/components/motion/CrossfadeContainer.tsx
Normal file
64
apps/checkout/src/components/motion/CrossfadeContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/checkout/src/components/theme-provider.tsx
Normal file
73
apps/checkout/src/components/theme-provider.tsx
Normal 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;
|
||||
};
|
||||
51
apps/checkout/src/components/ui/button.tsx
Normal file
51
apps/checkout/src/components/ui/button.tsx
Normal 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 }
|
||||
107
apps/checkout/src/components/ui/card.tsx
Normal file
107
apps/checkout/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
23
apps/checkout/src/components/ui/separator.tsx
Normal file
23
apps/checkout/src/components/ui/separator.tsx
Normal 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 }
|
||||
26
apps/checkout/src/components/ui/skeleton.tsx
Normal file
26
apps/checkout/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
28
apps/checkout/src/contexts/CheckoutContext.tsx
Normal file
28
apps/checkout/src/contexts/CheckoutContext.tsx
Normal 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;
|
||||
}
|
||||
45
apps/checkout/src/hooks/useCheckout.ts
Normal file
45
apps/checkout/src/hooks/useCheckout.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
136
apps/checkout/src/hooks/useCheckoutState.ts
Normal file
136
apps/checkout/src/hooks/useCheckoutState.ts
Normal 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>;
|
||||
40
apps/checkout/src/hooks/useDevThemeToggle.ts
Normal file
40
apps/checkout/src/hooks/useDevThemeToggle.ts
Normal 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
194
apps/checkout/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
92
apps/checkout/src/lib/animations.ts
Normal file
92
apps/checkout/src/lib/animations.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
6
apps/checkout/src/lib/utils.ts
Normal file
6
apps/checkout/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
56
apps/checkout/src/main.tsx
Normal file
56
apps/checkout/src/main.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/checkout/src/pages/CheckoutPage.tsx
Normal file
19
apps/checkout/src/pages/CheckoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
apps/checkout/src/utils/buildHeaderDescription.ts
Normal file
189
apps/checkout/src/utils/buildHeaderDescription.ts
Normal 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.`;
|
||||
}
|
||||
14
apps/checkout/src/utils/formatUtils.ts
Normal file
14
apps/checkout/src/utils/formatUtils.ts
Normal 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)}`;
|
||||
}
|
||||
15
apps/checkout/src/utils/trialUtils.ts
Normal file
15
apps/checkout/src/utils/trialUtils.ts
Normal 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}`;
|
||||
}
|
||||
32
apps/checkout/tsconfig.app.json
Normal file
32
apps/checkout/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
13
apps/checkout/tsconfig.json
Normal file
13
apps/checkout/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/checkout/tsconfig.node.json
Normal file
26
apps/checkout/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
24
apps/checkout/vite.config.ts
Normal file
24
apps/checkout/vite.config.ts
Normal 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: [".."],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
|
||||
12
package.json
12
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
14
scripts/testGroups/attach.sh
Executable 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'
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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": [
|
||||
|
||||
76
server/src/external/autumn/autumnCli.ts
vendored
76
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// // });
|
||||
// }
|
||||
};
|
||||
|
||||
16
server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts
vendored
Normal file
16
server/src/external/stripe/checkoutSessions/operations/getExpandedStripeCheckoutSession.ts
vendored
Normal 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);
|
||||
};
|
||||
51
server/src/external/stripe/checkoutSessions/operations/getStripeCheckoutSession.ts
vendored
Normal file
51
server/src/external/stripe/checkoutSessions/operations/getStripeCheckoutSession.ts
vendored
Normal 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>;
|
||||
};
|
||||
57
server/src/external/stripe/checkoutSessions/utils/convertStripeCheckoutSession.ts
vendored
Normal file
57
server/src/external/stripe/checkoutSessions/utils/convertStripeCheckoutSession.ts
vendored
Normal 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;
|
||||
};
|
||||
68
server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts
vendored
Normal file
68
server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts
vendored
Normal 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;
|
||||
}
|
||||
17
server/src/external/stripe/checkoutSessions/utils/index.ts
vendored
Normal file
17
server/src/external/stripe/checkoutSessions/utils/index.ts
vendored
Normal 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,
|
||||
},
|
||||
};
|
||||
25
server/src/external/stripe/common/utils/stripeItemToFeatureOptionsQuantity.ts
vendored
Normal file
25
server/src/external/stripe/common/utils/stripeItemToFeatureOptionsQuantity.ts
vendored
Normal 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();
|
||||
};
|
||||
@@ -127,6 +127,8 @@ export const createStripePrepaid = async ({
|
||||
};
|
||||
}
|
||||
|
||||
console.log("priceAmountData", priceAmountData);
|
||||
|
||||
stripePrice = await stripeCli.prices.create({
|
||||
...productData,
|
||||
currency: orgToCurrency({ org }),
|
||||
|
||||
82
server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts
vendored
Normal file
82
server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts
vendored
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
45
server/src/external/stripe/prices/operations/getStripePrice.ts
vendored
Normal file
45
server/src/external/stripe/prices/operations/getStripePrice.ts
vendored
Normal 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;
|
||||
}
|
||||
15
server/src/external/stripe/stripeEnsureUtils.ts
vendored
15
server/src/external/stripe/stripeEnsureUtils.ts
vendored
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
7
server/src/external/stripe/subscriptions/subscriptionItems/index.ts
vendored
Normal file
7
server/src/external/stripe/subscriptions/subscriptionItems/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { findSubscriptionItemByAutumnPrice } from "@/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice";
|
||||
|
||||
export const stripeSubscriptionItemUtils = {
|
||||
find: {
|
||||
byAutumnPrice: findSubscriptionItemByAutumnPrice,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts
vendored
Normal file
64
server/src/external/stripe/webhookHandlers/common/expireAndActivateWithTracking.ts
vendored
Normal 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 };
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user