wip
This commit is contained in:
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
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -24,5 +24,13 @@
|
||||
"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,
|
||||
"**/.opencode": true,
|
||||
"**/.superset": true,
|
||||
"**/.vscode": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { handleCheckoutSessionCompletedLegacy } from "./legacy/handleCheckoutSessionCompletedLegacy.js";
|
||||
import { setupCheckoutSessionCompletedContext } from "./setupCheckoutSessionCompletedContext.js";
|
||||
|
||||
export const handleStripeCheckoutSessionCompleted = async ({
|
||||
ctx,
|
||||
event,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
event: Stripe.CheckoutSessionCompletedEvent;
|
||||
}) => {
|
||||
const checkoutContext = await setupCheckoutSessionCompletedContext({
|
||||
ctx,
|
||||
event,
|
||||
});
|
||||
|
||||
// V2 flow
|
||||
if (checkoutContext) {
|
||||
ctx.logger.info(
|
||||
"[checkout.session.completed] V2 checkout - not yet implemented",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy flow - pass original params unchanged
|
||||
const { db, org, env } = ctx;
|
||||
await handleCheckoutSessionCompletedLegacy({
|
||||
ctx,
|
||||
db,
|
||||
org,
|
||||
data: event.data.object,
|
||||
env,
|
||||
});
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getPriceEntitlement,
|
||||
priceIsOneOffAndTiered,
|
||||
} from "@/internal/products/prices/priceUtils.js";
|
||||
import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { findStripeItemForPrice } from "../../../stripeSubUtils/stripeSubItemUtils.js";
|
||||
|
||||
export const getOptionsFromCheckoutSession = async ({
|
||||
checkoutSession,
|
||||
@@ -18,13 +18,13 @@ import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtil
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { getEarliestPeriodEnd } from "../stripeSubUtils/convertSubUtils.js";
|
||||
import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js";
|
||||
import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js";
|
||||
import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js";
|
||||
import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js";
|
||||
import { getEarliestPeriodEnd } from "../../../stripeSubUtils/convertSubUtils.js";
|
||||
import { getOptionsFromCheckoutSession } from "./getOptionsFromCheckout.js";
|
||||
import { handleCheckoutSub } from "./handleCheckoutSub.js";
|
||||
import { handleRemainingSets } from "./handleRemainingSets.js";
|
||||
import { handleSetupCheckout } from "./handleSetupCheckout.js";
|
||||
|
||||
export const handleCheckoutSessionCompleted = async ({
|
||||
export const handleCheckoutSessionCompletedLegacy = async ({
|
||||
ctx,
|
||||
db,
|
||||
org,
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@/internal/products/prices/priceUtils/findPriceUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import { initSubscription } from "@/internal/subscriptions/utils/initSubscription.js";
|
||||
import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js";
|
||||
import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js";
|
||||
import { getEmptyPriceItem } from "../../../priceToStripeItem/priceToStripeItem.js";
|
||||
import { subToPeriodStartEnd } from "../../../stripeSubUtils/convertSubUtils.js";
|
||||
|
||||
export const handleCheckoutSub = async ({
|
||||
stripeCli,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiVersion, isUsagePrice, type Organization } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js";
|
||||
import { getEmptyPriceItem } from "../../../priceToStripeItem/priceToStripeItem.js";
|
||||
|
||||
export const handleRemainingSets = async ({
|
||||
stripeCli,
|
||||
@@ -5,8 +5,8 @@ import { handleOneOffFunction } from "@/internal/customers/attach/attachFunction
|
||||
import { getDefaultAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { isOneOff } from "@/internal/products/productUtils.js";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||
import { getCusPaymentMethod } from "../../stripeCusUtils.js";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
|
||||
import { getCusPaymentMethod } from "../../../stripeCusUtils.js";
|
||||
|
||||
export const handleSetupCheckout = async ({
|
||||
ctx,
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type Metadata, MetadataType } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
|
||||
export interface CheckoutSessionCompletedContext {
|
||||
stripeCheckoutSession: Stripe.Checkout.Session;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
stripeInvoice?: Stripe.Invoice;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
export const setupCheckoutSessionCompletedContext = async ({
|
||||
ctx,
|
||||
event,
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
event: Stripe.CheckoutSessionCompletedEvent;
|
||||
}): Promise<CheckoutSessionCompletedContext | null> => {
|
||||
const { db, stripeCli } = ctx;
|
||||
const checkoutSessionData = event.data.object;
|
||||
|
||||
// Get metadata from checkout session
|
||||
const metadata = await getMetadataFromCheckoutSession(
|
||||
checkoutSessionData,
|
||||
db,
|
||||
);
|
||||
|
||||
// Return null if no metadata or not V2 checkout session type
|
||||
if (!metadata || metadata.type !== MetadataType.CheckoutSessionV2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand checkout session to get subscription and invoice
|
||||
const stripeCheckoutSession = await stripeCli.checkout.sessions.retrieve(
|
||||
checkoutSessionData.id,
|
||||
{
|
||||
expand: ["subscription", "invoice"],
|
||||
},
|
||||
);
|
||||
|
||||
const stripeSubscription = stripeCheckoutSession.subscription as
|
||||
| Stripe.Subscription
|
||||
| undefined;
|
||||
const stripeInvoice = stripeCheckoutSession.invoice as
|
||||
| Stripe.Invoice
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
stripeCheckoutSession,
|
||||
stripeSubscription: stripeSubscription ?? undefined,
|
||||
stripeInvoice: stripeInvoice ?? undefined,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { handlePreviewAttach } from "@/internal/billing/v2/handlers/handlePreviewAttach.js";
|
||||
import { handleAttachPreview } from "@/internal/customers/attach/handleAttachPreview/handleAttachPreview.js";
|
||||
import { handleCancelV2 } from "@/internal/customers/cancel/handleCancelV2.js";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
@@ -26,3 +27,4 @@ billingRouter.post(
|
||||
|
||||
// V2 Attach
|
||||
billingRouter.post("/billing/attach", ...handleAttachV2);
|
||||
billingRouter.post("/billing/preview_attach", ...handlePreviewAttach);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AttachParamsV0, RecaseError } from "@autumn/shared";
|
||||
import type { AttachParamsV0 } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { computeAttachPlan } from "@/internal/billing/v2/actions/attach/compute/computeAttachPlan";
|
||||
import { handleAttachV2Errors } from "@/internal/billing/v2/actions/attach/errors/handleAttachV2Errors";
|
||||
@@ -15,6 +15,13 @@ import type {
|
||||
} from "@/internal/billing/v2/types";
|
||||
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
||||
|
||||
export interface AttachResult {
|
||||
billingContext: AttachBillingContext;
|
||||
billingPlan?: BillingPlan;
|
||||
billingResult?: BillingResult | null;
|
||||
checkoutUrl?: string;
|
||||
}
|
||||
|
||||
export async function attach({
|
||||
ctx,
|
||||
params,
|
||||
@@ -23,11 +30,7 @@ export async function attach({
|
||||
ctx: AutumnContext;
|
||||
params: AttachParamsV0;
|
||||
preview?: boolean;
|
||||
}): Promise<{
|
||||
billingContext: AttachBillingContext;
|
||||
billingPlan: BillingPlan;
|
||||
billingResult: BillingResult | null;
|
||||
}> {
|
||||
}): Promise<AttachResult> {
|
||||
// 1. Setup
|
||||
const billingContext = await setupAttachBillingContext({
|
||||
ctx,
|
||||
@@ -52,19 +55,12 @@ export async function attach({
|
||||
params,
|
||||
});
|
||||
|
||||
if (billingContext.checkoutMode !== null) {
|
||||
// 4. Handle checkout mode (redirect to Stripe checkout)
|
||||
throw new RecaseError({
|
||||
message: `Checkout flow not yet implemented for attach v2 (checkoutMode: ${billingContext.checkoutMode}). Please add a payment method to the customer first.`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Evaluate Stripe billing plan
|
||||
// 4. Evaluate Stripe billing plan (handles checkout mode internally)
|
||||
const stripeBillingPlan = await evaluateStripeBillingPlan({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
checkoutMode: billingContext.checkoutMode,
|
||||
});
|
||||
|
||||
logStripeBillingPlan({ ctx, stripeBillingPlan, billingContext });
|
||||
@@ -74,7 +70,7 @@ export async function attach({
|
||||
stripe: stripeBillingPlan,
|
||||
};
|
||||
|
||||
if (!preview) {
|
||||
if (preview) {
|
||||
return {
|
||||
billingContext,
|
||||
billingPlan,
|
||||
@@ -82,6 +78,11 @@ export async function attach({
|
||||
};
|
||||
}
|
||||
|
||||
if (billingContext.checkoutMode === "autumn_checkout") {
|
||||
// return autumn checkout URL
|
||||
// return await createAutumnCheckout();
|
||||
}
|
||||
|
||||
// 6. Execute billing plan
|
||||
const billingResult = await executeBillingPlan({
|
||||
ctx,
|
||||
@@ -95,5 +96,6 @@ export async function attach({
|
||||
billingContext,
|
||||
billingPlan,
|
||||
billingResult,
|
||||
checkoutUrl: billingResult.stripe.stripeCheckoutSession?.url ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function updateSubscription({
|
||||
stripe: stripeBillingPlan,
|
||||
};
|
||||
|
||||
if (!preview) {
|
||||
if (preview) {
|
||||
return {
|
||||
billingContext,
|
||||
billingPlan,
|
||||
|
||||
@@ -50,11 +50,14 @@ export const buildAutumnLineItems = ({
|
||||
// will be handled in finalizeUpdateSubscriptionPlan
|
||||
const allLineItems = [...deletedLineItems, ...newLineItems];
|
||||
|
||||
const debugLogs = false;
|
||||
if (debugLogs) {
|
||||
logBuildAutumnLineItems({
|
||||
logger,
|
||||
deletedLineItems,
|
||||
newLineItems,
|
||||
});
|
||||
}
|
||||
|
||||
return allLineItems;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export const handleAttachV2 = createRoute({
|
||||
const { billingContext, billingResult } = await billingActions.attach({
|
||||
ctx,
|
||||
params: body,
|
||||
preview: true,
|
||||
preview: false,
|
||||
});
|
||||
|
||||
if (!billingResult) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { UpdateSubscriptionV0ParamsSchema, InternalError } from "@autumn/shared";
|
||||
import {
|
||||
InternalError,
|
||||
UpdateSubscriptionV0ParamsSchema,
|
||||
} from "@autumn/shared";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
||||
import { billingResultToResponse } from "../utils/billingResult/billingResultToResponse";
|
||||
@@ -26,7 +29,7 @@ export const handleUpdateSubscription = createRoute({
|
||||
await billingActions.updateSubscription({
|
||||
ctx,
|
||||
params: body,
|
||||
preview: true,
|
||||
preview: false,
|
||||
});
|
||||
|
||||
if (!billingResult) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
msToSeconds,
|
||||
orgToReturnUrl,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { billingPlanToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs";
|
||||
import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
StripeCheckoutSessionAction,
|
||||
} from "@/internal/billing/v2/types";
|
||||
|
||||
export const buildStripeCheckoutSessionAction = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
finalCustomerProducts,
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
finalCustomerProducts: FullCusProduct[];
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}): StripeCheckoutSessionAction => {
|
||||
const { org, env } = ctx;
|
||||
const { trialContext, stripeCustomer } = billingContext;
|
||||
|
||||
// 1. Get subscription items filtered to largest interval (for Stripe Checkout)
|
||||
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
|
||||
ctx,
|
||||
billingContext,
|
||||
finalCustomerProducts,
|
||||
filterByLargestInterval: true,
|
||||
});
|
||||
|
||||
// 2. Get one-off items
|
||||
const oneOffItemSpecs = billingPlanToOneOffStripeItemSpecs({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
|
||||
// 3. Determine mode: "subscription" or "payment"
|
||||
const isOneOffOnly = subItemsUpdate.length === 0;
|
||||
const mode: "subscription" | "payment" = isOneOffOnly
|
||||
? "payment"
|
||||
: "subscription";
|
||||
|
||||
// 4. Build line_items from sub items and one-off items
|
||||
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [
|
||||
...subItemsUpdate
|
||||
.filter((item) => item.price && !item.deleted)
|
||||
.map((item) => ({
|
||||
price: item.price!,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
...oneOffItemSpecs.map((item) => ({
|
||||
price: item.stripePriceId,
|
||||
quantity: item.quantity ?? 1,
|
||||
})),
|
||||
];
|
||||
|
||||
// 5. Trial handling (only for subscription mode)
|
||||
const trialEnd =
|
||||
mode === "subscription" && trialContext?.trialEndsAt
|
||||
? msToSeconds(trialContext.trialEndsAt)
|
||||
: undefined;
|
||||
|
||||
// 6. Build subscription_data (only for subscription mode)
|
||||
const subscriptionData:
|
||||
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
||||
| undefined =
|
||||
mode === "subscription"
|
||||
? {
|
||||
trial_end: trialEnd,
|
||||
...(trialContext?.cardRequired && {
|
||||
trial_settings: {
|
||||
end_behavior: { missing_payment_method: "cancel" },
|
||||
},
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 7. Build params (only variable params - static params added in execute)
|
||||
const params: Stripe.Checkout.SessionCreateParams = {
|
||||
customer: stripeCustomer.id,
|
||||
mode,
|
||||
line_items: lineItems,
|
||||
subscription_data: subscriptionData,
|
||||
return_url: orgToReturnUrl({ org, env }),
|
||||
};
|
||||
|
||||
return { type: "create", params };
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
|
||||
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
|
||||
import { billingPlanToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs";
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
StripeSubscriptionAction,
|
||||
StripeSubscriptionScheduleAction,
|
||||
} from "@/internal/billing/v2/types";
|
||||
@@ -39,6 +39,11 @@ export const buildStripeSubscriptionAction = ({
|
||||
autumnBillingPlan,
|
||||
});
|
||||
|
||||
const addInvoiceItems = oneOffItemSpecs.map((item) => ({
|
||||
price: item.stripePriceId,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
// Case 1: No subscription and sub items update is empty -> no action
|
||||
if (!stripeSubscription && subItemsUpdate.length === 0) {
|
||||
return undefined;
|
||||
@@ -50,10 +55,7 @@ export const buildStripeSubscriptionAction = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
subItemsUpdate,
|
||||
addInvoiceItems: oneOffItemSpecs.map((item) => ({
|
||||
price: item.stripePriceId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
addInvoiceItems,
|
||||
subscriptionCancelAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
isCustomerProductOnStripeSubscriptionSchedule,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { buildStripePhasesUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate";
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types";
|
||||
import type {
|
||||
BillingContext,
|
||||
StripeSubscriptionScheduleAction,
|
||||
} from "@/internal/billing/v2/types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TYPES
|
||||
|
||||
@@ -2,13 +2,16 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction";
|
||||
import { shouldCreateManualStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice";
|
||||
import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer";
|
||||
import type { BillingContext } from "../../../types";
|
||||
import { buildStripeCheckoutSessionAction } from "../../../providers/stripe/actionBuilders/buildStripeCheckoutSessionAction";
|
||||
import { buildStripeInvoiceAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
|
||||
import { buildStripeInvoiceItemsAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceItemsAction";
|
||||
import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
|
||||
import type {
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
CheckoutMode,
|
||||
StripeBillingPlan,
|
||||
StripeCheckoutSessionAction,
|
||||
StripeInvoiceAction,
|
||||
StripeInvoiceItemsAction,
|
||||
} from "../../../types";
|
||||
@@ -18,10 +21,12 @@ export const evaluateStripeBillingPlan = async ({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
checkoutMode,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
checkoutMode?: CheckoutMode;
|
||||
}): Promise<StripeBillingPlan> => {
|
||||
await initStripeResourcesForBillingPlan({
|
||||
ctx,
|
||||
@@ -61,6 +66,17 @@ export const evaluateStripeBillingPlan = async ({
|
||||
stripeSubscriptionAction,
|
||||
});
|
||||
|
||||
// Build checkout session action if checkout mode is stripe_checkout
|
||||
let stripeCheckoutSessionAction: StripeCheckoutSessionAction | undefined;
|
||||
if (checkoutMode === "stripe_checkout") {
|
||||
stripeCheckoutSessionAction = buildStripeCheckoutSessionAction({
|
||||
ctx,
|
||||
billingContext,
|
||||
finalCustomerProducts: finalFullCustomer.customer_products,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
}
|
||||
|
||||
let stripeInvoiceAction: StripeInvoiceAction | undefined;
|
||||
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
|
||||
if (createManualInvoice && lineItems) {
|
||||
@@ -75,9 +91,14 @@ export const evaluateStripeBillingPlan = async ({
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptionAction: stripeSubscriptionAction,
|
||||
// If checkout session action is present, don't include subscription action
|
||||
// (checkout will create the subscription)
|
||||
subscriptionAction: stripeCheckoutSessionAction
|
||||
? undefined
|
||||
: stripeSubscriptionAction,
|
||||
invoiceAction: stripeInvoiceAction,
|
||||
invoiceItemsAction: stripeInvoiceItemsAction,
|
||||
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
||||
checkoutSessionAction: stripeCheckoutSessionAction,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
||||
import { executeStripeCheckoutSessionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction";
|
||||
import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction";
|
||||
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
|
||||
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
|
||||
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
|
||||
import type {
|
||||
BillingContext,
|
||||
BillingPlan,
|
||||
StripeBillingPlanResult,
|
||||
} from "@/internal/billing/v2/types";
|
||||
import { StripeBillingStage } from "@/internal/billing/v2/types";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types";
|
||||
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types";
|
||||
|
||||
export const executeStripeBillingPlan = async ({
|
||||
ctx,
|
||||
@@ -25,8 +28,19 @@ export const executeStripeBillingPlan = async ({
|
||||
invoiceAction: stripeInvoiceAction,
|
||||
invoiceItemsAction: stripeInvoiceItemsAction,
|
||||
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
||||
checkoutSessionAction: stripeCheckoutSessionAction,
|
||||
} = billingPlan.stripe;
|
||||
|
||||
// Execute checkout session FIRST if present (returns early with deferred result)
|
||||
if (stripeCheckoutSessionAction) {
|
||||
return executeStripeCheckoutSessionAction({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
checkoutSessionAction: stripeCheckoutSessionAction,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect results from each stage
|
||||
let invoiceResult: StripeBillingPlanResult | undefined;
|
||||
let subscriptionResult: StripeBillingPlanResult | undefined;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { addDays } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type {
|
||||
BillingContext,
|
||||
BillingPlan,
|
||||
StripeBillingPlanResult,
|
||||
StripeCheckoutSessionAction,
|
||||
} from "@/internal/billing/v2/types";
|
||||
import {
|
||||
insertMetadataFromBillingPlan,
|
||||
updateMetadataWithCheckoutSession,
|
||||
} from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||
import { orgToCurrency } from "@/internal/orgs/orgUtils";
|
||||
|
||||
export const executeStripeCheckoutSessionAction = async ({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
checkoutSessionAction,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
checkoutSessionAction: StripeCheckoutSessionAction;
|
||||
}): Promise<StripeBillingPlanResult> => {
|
||||
const { org, logger } = ctx;
|
||||
const { fullCustomer } = billingContext;
|
||||
|
||||
const stripeCli = createStripeCli({ org, env: fullCustomer.env });
|
||||
|
||||
// 1. Insert metadata FIRST (without checkout session ID)
|
||||
const metadata = await insertMetadataFromBillingPlan({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
resumeAfter: undefined,
|
||||
expiresAt: addDays(Date.now(), 10).getTime(),
|
||||
});
|
||||
|
||||
// 2. Build full checkout params (merge variable + static params)
|
||||
const fullParams: Stripe.Checkout.SessionCreateParams = {
|
||||
...checkoutSessionAction.params,
|
||||
|
||||
// Static params
|
||||
currency: orgToCurrency({ org }),
|
||||
allow_promotion_codes: true,
|
||||
saved_payment_method_options: { payment_method_save: "enabled" },
|
||||
invoice_creation:
|
||||
checkoutSessionAction.params.mode === "payment"
|
||||
? { enabled: true }
|
||||
: undefined,
|
||||
|
||||
// Link to metadata
|
||||
metadata: { autumn_metadata_id: metadata.id },
|
||||
};
|
||||
|
||||
// 3. Create checkout session with fallback for payment method types
|
||||
let stripeCheckoutSession: Stripe.Checkout.Session;
|
||||
try {
|
||||
stripeCheckoutSession =
|
||||
await stripeCli.checkout.sessions.create(fullParams);
|
||||
logger.info(
|
||||
`✅ Created checkout session for customer ${fullCustomer.id ?? fullCustomer.internal_id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : undefined;
|
||||
if (msg?.includes("No valid payment method types")) {
|
||||
stripeCheckoutSession = await stripeCli.checkout.sessions.create({
|
||||
...fullParams,
|
||||
payment_method_types: ["card"],
|
||||
});
|
||||
logger.info(
|
||||
"✅ Created fallback checkout session with card payment method",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update metadata with checkout session ID
|
||||
await updateMetadataWithCheckoutSession({
|
||||
ctx,
|
||||
metadataId: metadata.id,
|
||||
stripeCheckoutSessionId: stripeCheckoutSession.id,
|
||||
});
|
||||
|
||||
// 5. Return result with checkout session
|
||||
return {
|
||||
deferred: true,
|
||||
stripeCheckoutSession,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ms } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { shouldDeferBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan";
|
||||
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
||||
import { StripeBillingStage } from "@/internal/billing/v2/types";
|
||||
import type {
|
||||
BillingContext,
|
||||
BillingPlan,
|
||||
StripeBillingPlanResult,
|
||||
StripeInvoiceMetadata,
|
||||
} from "@/internal/billing/v2/types";
|
||||
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types";
|
||||
import { StripeBillingStage } from "@/internal/billing/v2/types";
|
||||
import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode";
|
||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
||||
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
|
||||
import { shouldDeferBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/shouldDeferBillingPlan";
|
||||
@@ -11,9 +10,12 @@ import { finalizeStripeInvoice } from "@/internal/billing/v2/providers/stripe/ut
|
||||
import { executeStripeSubscriptionOperation } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation";
|
||||
import { getLatestInvoiceFromSubscriptionAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getLatestInvoiceFromSubscriptionAction";
|
||||
import { getRequiredActionFromSubscriptionInvoice } from "@/internal/billing/v2/providers/stripe/utils/subscriptions/getRequiredActionFromSubscriptionInvoice";
|
||||
import type {
|
||||
BillingContext,
|
||||
BillingPlan,
|
||||
StripeBillingPlanResult,
|
||||
} from "@/internal/billing/v2/types";
|
||||
import { StripeBillingStage } from "@/internal/billing/v2/types";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types";
|
||||
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types";
|
||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
|
||||
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createStripeCli } from "@server/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import type Stripe from "stripe";
|
||||
import { logSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction";
|
||||
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types";
|
||||
import type {
|
||||
BillingContext,
|
||||
StripeSubscriptionScheduleAction,
|
||||
} from "@/internal/billing/v2/types";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
filterCustomerProductsByActiveStatuses,
|
||||
filterCustomerProductsByStripeSubscriptionId,
|
||||
getLargestInterval,
|
||||
} from "@autumn/shared";
|
||||
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
||||
@@ -134,15 +135,49 @@ const stripeItemSpecsToSubItemsUpdate = ({
|
||||
return subItemsUpdate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters stripe item specs to only include items from the largest billing interval.
|
||||
* Used for Stripe Checkout which doesn't support multi-interval subscriptions.
|
||||
*/
|
||||
const filterStripeItemSpecsByLargestInterval = ({
|
||||
stripeItemSpecs,
|
||||
}: {
|
||||
stripeItemSpecs: StripeItemSpec[];
|
||||
}): StripeItemSpec[] => {
|
||||
const prices = stripeItemSpecs
|
||||
.map((spec) => spec.autumnPrice)
|
||||
.filter((p): p is NonNullable<typeof p> => !!p);
|
||||
|
||||
if (prices.length === 0) return stripeItemSpecs;
|
||||
|
||||
const largestInterval = getLargestInterval({ prices, excludeOneOff: true });
|
||||
if (!largestInterval) return stripeItemSpecs;
|
||||
|
||||
return stripeItemSpecs.filter((spec) => {
|
||||
const price = spec.autumnPrice;
|
||||
if (!price) return false;
|
||||
|
||||
const priceInterval = price.config.interval;
|
||||
const priceIntervalCount = price.config.interval_count ?? 1;
|
||||
|
||||
return (
|
||||
priceInterval === largestInterval.interval &&
|
||||
priceIntervalCount === largestInterval.intervalCount
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const buildStripeSubscriptionItemsUpdate = ({
|
||||
ctx,
|
||||
billingContext,
|
||||
finalCustomerProducts,
|
||||
filterByLargestInterval = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
finalCustomerProducts: FullCusProduct[];
|
||||
}) => {
|
||||
filterByLargestInterval?: boolean;
|
||||
}): Stripe.SubscriptionUpdateParams.Item[] => {
|
||||
// 1. Filter customer products by stripe subscription id
|
||||
const relatedCustomerProducts = filterCustomerProductsByStripeSubscriptionId({
|
||||
customerProducts: finalCustomerProducts,
|
||||
@@ -155,15 +190,22 @@ export const buildStripeSubscriptionItemsUpdate = ({
|
||||
});
|
||||
|
||||
// 3. Get recurring subscription item array (doesn't include one off items)
|
||||
const recurringItems = customerProductsToRecurringStripeItemSpecs({
|
||||
let recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({
|
||||
ctx,
|
||||
billingContext,
|
||||
customerProducts: activeCustomerProducts,
|
||||
});
|
||||
|
||||
// 4. Diff it with the current subscription items
|
||||
// 4. Optionally filter by largest interval (for Stripe Checkout)
|
||||
if (filterByLargestInterval) {
|
||||
recurringStripeItemSpecs = filterStripeItemSpecsByLargestInterval({
|
||||
stripeItemSpecs: recurringStripeItemSpecs,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Diff it with the current subscription items
|
||||
return stripeItemSpecsToSubItemsUpdate({
|
||||
billingContext,
|
||||
stripeItemSpecs: recurringItems,
|
||||
stripeItemSpecs: recurringStripeItemSpecs,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import { buildTransitionPoints } from "./buildTransitionPoints";
|
||||
import { logTransitionPoints } from "./logBuildPhaseHelpers";
|
||||
|
||||
@@ -107,13 +107,17 @@ export const buildStripePhasesUpdate = ({
|
||||
trialEndsAt: normalizedTrialEndsAt,
|
||||
});
|
||||
|
||||
const debugLogs = false;
|
||||
|
||||
// Log customer products and transition points
|
||||
if (debugLogs) {
|
||||
logTransitionPoints({
|
||||
ctx,
|
||||
customerProducts: normalizedCustomerProducts,
|
||||
transitionPoints,
|
||||
nowMs,
|
||||
});
|
||||
}
|
||||
|
||||
let startMs = nowMs;
|
||||
|
||||
@@ -166,6 +170,7 @@ export const buildStripePhasesUpdate = ({
|
||||
};
|
||||
|
||||
// Log phase details
|
||||
if (debugLogs) {
|
||||
logPhase({
|
||||
ctx,
|
||||
phase,
|
||||
@@ -174,6 +179,7 @@ export const buildStripePhasesUpdate = ({
|
||||
logPrefix: "[buildStripePhasesUpdate]",
|
||||
showCustomerProducts: true,
|
||||
});
|
||||
}
|
||||
|
||||
phases.push(phase);
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
PriceSchema,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import type { BillingContext } from "@/internal/billing/v2/types";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types";
|
||||
import type { BillingContext, BillingPlan } from "@/internal/billing/v2/types";
|
||||
|
||||
export const UpdateCustomerEntitlementSchema = z.object({
|
||||
customerEntitlement: FullCustomerEntitlementSchema,
|
||||
@@ -85,5 +84,5 @@ export type DeferredAutumnBillingPlanData = {
|
||||
env: AppEnv;
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
resumeAfter: StripeBillingStage;
|
||||
resumeAfter?: StripeBillingStage;
|
||||
};
|
||||
|
||||
@@ -7,21 +7,19 @@ import {
|
||||
import {
|
||||
type StripeBillingPlan,
|
||||
StripeBillingPlanSchema,
|
||||
type StripeCheckoutSessionAction,
|
||||
type StripeInvoiceAction,
|
||||
StripeInvoiceActionSchema,
|
||||
type StripeInvoiceItemsAction,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
type StripeInvoiceMetadata,
|
||||
type StripeSubscriptionAction,
|
||||
StripeSubscriptionActionSchema,
|
||||
type StripeSubscriptionScheduleAction,
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
} from "./stripeBillingPlan/stripeBillingPlan";
|
||||
|
||||
export type {
|
||||
AutumnBillingPlan,
|
||||
DeferredAutumnBillingPlanData,
|
||||
StripeBillingPlan,
|
||||
StripeCheckoutSessionAction,
|
||||
StripeInvoiceAction,
|
||||
StripeInvoiceItemsAction,
|
||||
StripeInvoiceMetadata,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface StripeBillingPlanResult {
|
||||
deferred?: boolean;
|
||||
stripeInvoice?: Stripe.Invoice;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
stripeCheckoutSession?: Stripe.Checkout.Session;
|
||||
requiredAction?: {
|
||||
code: PaymentFailureCode;
|
||||
reason: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./billingResult";
|
||||
|
||||
// Stripe billing plan types
|
||||
export * from "./stripeBillingPlan/stripeBillingPlan";
|
||||
export * from "./stripeBillingPlan/stripeCheckoutSessionAction";
|
||||
export * from "./stripeBillingPlan/stripeInvoiceAction";
|
||||
export * from "./stripeBillingPlan/stripeInvoiceItemsAction";
|
||||
export * from "./stripeBillingPlan/stripeSubscriptionAction";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
type StripeCheckoutSessionAction,
|
||||
StripeCheckoutSessionActionSchema,
|
||||
} from "./stripeCheckoutSessionAction";
|
||||
import {
|
||||
type StripeInvoiceAction,
|
||||
StripeInvoiceActionSchema,
|
||||
@@ -17,10 +21,12 @@ import {
|
||||
} from "./stripeSubscriptionScheduleAction";
|
||||
|
||||
export {
|
||||
StripeCheckoutSessionActionSchema,
|
||||
StripeInvoiceActionSchema,
|
||||
StripeInvoiceItemsActionSchema,
|
||||
StripeSubscriptionActionSchema,
|
||||
StripeSubscriptionScheduleActionSchema,
|
||||
type StripeCheckoutSessionAction,
|
||||
type StripeInvoiceAction,
|
||||
type StripeInvoiceItemsAction,
|
||||
type StripeSubscriptionAction,
|
||||
@@ -32,6 +38,7 @@ export const StripeBillingPlanSchema = z.object({
|
||||
subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(),
|
||||
invoiceAction: StripeInvoiceActionSchema.optional(),
|
||||
invoiceItemsAction: StripeInvoiceItemsActionSchema.optional(),
|
||||
checkoutSessionAction: StripeCheckoutSessionActionSchema.optional(),
|
||||
});
|
||||
|
||||
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const StripeCheckoutSessionActionSchema = z.object({
|
||||
type: z.literal("create"),
|
||||
params: z.custom<Stripe.Checkout.SessionCreateParams>(),
|
||||
});
|
||||
|
||||
export type StripeCheckoutSessionAction = z.infer<
|
||||
typeof StripeCheckoutSessionActionSchema
|
||||
>;
|
||||
@@ -16,6 +16,14 @@ export const billingResultToResponse = ({
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const stripeInvoice = billingResult.stripe.stripeInvoice;
|
||||
const stripeCheckoutSession = billingResult.stripe.stripeCheckoutSession;
|
||||
|
||||
// Checkout session URL takes priority, then invoice hosted URL
|
||||
const paymentUrl = stripeCheckoutSession?.url
|
||||
? stripeCheckoutSession.url
|
||||
: stripeInvoice?.status === "open" && stripeInvoice.hosted_invoice_url
|
||||
? stripeInvoice.hosted_invoice_url
|
||||
: null;
|
||||
|
||||
return {
|
||||
customer_id: customerId,
|
||||
@@ -32,11 +40,8 @@ export const billingResultToResponse = ({
|
||||
hosted_invoice_url: stripeInvoice.hosted_invoice_url ?? null,
|
||||
}
|
||||
: undefined,
|
||||
payment_url:
|
||||
stripeInvoice?.status === "open" && stripeInvoice.hosted_invoice_url
|
||||
? stripeInvoice.hosted_invoice_url
|
||||
: null,
|
||||
|
||||
payment_url: paymentUrl,
|
||||
checkout_url: stripeCheckoutSession?.url ?? null,
|
||||
required_action: billingResult.stripe.requiredAction,
|
||||
} satisfies BillingResponse;
|
||||
};
|
||||
|
||||
0
server/src/internal/checkouts/index.ts
Normal file
0
server/src/internal/checkouts/index.ts
Normal file
@@ -57,4 +57,22 @@ export class MetadataService {
|
||||
static async delete({ db, id }: { db: DrizzleCli; id: string }) {
|
||||
await db.delete(metadata).where(eq(metadata.id, id));
|
||||
}
|
||||
|
||||
static async update({
|
||||
db,
|
||||
id,
|
||||
updates,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
id: string;
|
||||
updates: Partial<MetadataInsert>;
|
||||
}) {
|
||||
const updatedMetadata = await db
|
||||
.update(metadata)
|
||||
.set(updates)
|
||||
.where(eq(metadata.id, id))
|
||||
.returning();
|
||||
|
||||
return updatedMetadata[0] as Metadata | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { generateId } from "@/utils/genUtils";
|
||||
import { MetadataService } from "../MetadataService";
|
||||
|
||||
/**
|
||||
* Creates metadata from a billing plan and optionally links it to a Stripe invoice.
|
||||
* Creates metadata from a billing plan and optionally links it to a Stripe invoice or checkout session.
|
||||
*/
|
||||
export const insertMetadataFromBillingPlan = async ({
|
||||
ctx,
|
||||
billingPlan,
|
||||
billingContext,
|
||||
stripeInvoice,
|
||||
stripeCheckoutSession,
|
||||
expiresAt,
|
||||
resumeAfter,
|
||||
}: {
|
||||
@@ -27,12 +28,18 @@ export const insertMetadataFromBillingPlan = async ({
|
||||
billingPlan: BillingPlan;
|
||||
billingContext: BillingContext;
|
||||
stripeInvoice?: Stripe.Invoice;
|
||||
resumeAfter: StripeBillingStage;
|
||||
stripeCheckoutSession?: Stripe.Checkout.Session;
|
||||
resumeAfter?: StripeBillingStage;
|
||||
expiresAt: number;
|
||||
}) => {
|
||||
const id = generateId("meta");
|
||||
|
||||
const type = stripeInvoice ? MetadataType.DeferredInvoice : undefined;
|
||||
let type: MetadataType | undefined;
|
||||
if (stripeCheckoutSession) {
|
||||
type = MetadataType.CheckoutSessionV2;
|
||||
} else if (stripeInvoice) {
|
||||
type = MetadataType.DeferredInvoice;
|
||||
}
|
||||
|
||||
const data = {
|
||||
requestId: ctx.id,
|
||||
@@ -49,6 +56,7 @@ export const insertMetadataFromBillingPlan = async ({
|
||||
id,
|
||||
type,
|
||||
stripe_invoice_id: stripeInvoice?.id,
|
||||
stripe_checkout_session_id: stripeCheckoutSession?.id,
|
||||
data,
|
||||
created_at: Date.now(),
|
||||
expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(),
|
||||
@@ -73,3 +81,25 @@ export const insertMetadataFromBillingPlan = async ({
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates metadata with checkout session ID after checkout is created.
|
||||
*/
|
||||
export const updateMetadataWithCheckoutSession = async ({
|
||||
ctx,
|
||||
metadataId,
|
||||
stripeCheckoutSessionId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
metadataId: string;
|
||||
stripeCheckoutSessionId: string;
|
||||
}) => {
|
||||
return MetadataService.update({
|
||||
db: ctx.db,
|
||||
id: metadataId,
|
||||
updates: {
|
||||
stripe_checkout_session_id: stripeCheckoutSessionId,
|
||||
type: MetadataType.CheckoutSessionV2,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -63,18 +63,30 @@
|
||||
});
|
||||
```
|
||||
|
||||
11. **Always call attach preview before attach to verify `preview.total`**
|
||||
- The preview endpoint validates pricing before the actual attach
|
||||
11. **ALWAYS call `billing.previewAttach` before `billing.attach` and verify**
|
||||
- Call preview BEFORE every attach to verify pricing
|
||||
- Assert `preview.due_today.total` matches expected amount EXACTLY (not `toBeCloseTo`)
|
||||
- After attach, verify invoice total matches preview total
|
||||
```typescript
|
||||
const preview = await autumn.attachPreview({
|
||||
// 1. Preview first - verify expected charge
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: productId,
|
||||
product_id: pro.id,
|
||||
entity_id: entityId, // Optional for entity-level
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }], // If prepaid
|
||||
});
|
||||
expect(preview.total).toBe(expectedTotal);
|
||||
expect(preview.due_today.total).toBe(30); // EXACT match, not toBeCloseTo
|
||||
|
||||
// Then perform the actual attach
|
||||
await autumn.attach({ ... });
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({ ... });
|
||||
|
||||
// 3. Verify invoice matches preview
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 30, // Must match preview.due_today.total
|
||||
});
|
||||
```
|
||||
|
||||
12. **Add-on is defined at product level, NOT in attach params**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Autumn Checkout Basic Tests (Attach V2)
|
||||
*
|
||||
* Tests for Autumn Checkout flow when customer HAS a payment method
|
||||
* but redirect_mode is set to "always".
|
||||
*
|
||||
* When checkoutMode = "autumn_checkout", attach returns an autumn confirmation
|
||||
* page URL instead of charging directly, giving the customer a chance to
|
||||
* review before payment.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Has payment method + redirect_mode: "always" → autumn_checkout mode
|
||||
* - Returns confirmation page URL
|
||||
* - Product is attached after user confirms
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, AttachPreview } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: With payment method + redirect_mode: "always" → autumn_checkout
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer HAS a payment method
|
||||
* - Attach pro product with redirect_mode: "always"
|
||||
*
|
||||
* Expected Result:
|
||||
* - Returns autumn checkout/confirmation URL (not stripe checkout)
|
||||
* - Does NOT charge immediately
|
||||
* - Product attached after user confirms on autumn page
|
||||
*
|
||||
* NOTE: This test defines expected behavior. Implementation pending per ENG-1013.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("autumn-checkout: with PM + redirect_mode always")}`, async () => {
|
||||
const customerId = "autumn-checkout-redirect-always";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro-autumn-checkout",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }), // HAS payment method
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - should show $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attempt attach with redirect_mode: "always"
|
||||
// This should return a confirmation URL instead of charging directly
|
||||
const result = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "always",
|
||||
});
|
||||
|
||||
// Should return a checkout/confirmation URL (autumn hosted page)
|
||||
// Note: The exact URL format depends on implementation
|
||||
expect(result.checkout_url || result.payment_url).toBeDefined();
|
||||
|
||||
// At this point, product should NOT be attached yet (waiting for confirmation)
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const productBefore = customerBefore.products?.find((p) => p.id === pro.id);
|
||||
|
||||
// Product should either not exist or be in a pending state
|
||||
// (Implementation may vary - could be no product, or product with pending status)
|
||||
// For now, just verify we got a URL and didn't charge immediately
|
||||
|
||||
// Note: Full test would include completing the autumn checkout flow
|
||||
// and verifying the product is attached afterward. This is left as
|
||||
// future work pending the autumn checkout implementation.
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Future tests to implement once autumn checkout is built:
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// TEST 2: autumn-checkout: complete flow and verify product attached
|
||||
// TEST 3: autumn-checkout: cancel flow (user doesn't confirm)
|
||||
// TEST 4: autumn-checkout: with prepaid options
|
||||
// TEST 5: autumn-checkout: entity-level attach
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Stripe Checkout Basic Tests (Attach V2)
|
||||
*
|
||||
* Tests for Stripe Checkout flow when customer has NO payment method.
|
||||
* When checkoutMode = "stripe_checkout", attach returns a checkout_url
|
||||
* that the customer uses to complete payment.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - No payment method → triggers stripe_checkout mode
|
||||
* - Returns checkout_url instead of charging directly
|
||||
* - Product is attached after checkout completion
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, AttachPreview } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { timeout } from "@tests/utils/genUtils";
|
||||
import { completeCheckoutForm } from "@tests/utils/stripeUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: No product → pro (new customer, no payment method)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - New customer with NO payment method
|
||||
* - Attach pro product
|
||||
*
|
||||
* Expected Result:
|
||||
* - Returns checkout_url (Stripe Checkout session)
|
||||
* - After completing checkout: product is attached, invoice paid
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("stripe-checkout: no product → pro")}`, async () => {
|
||||
const customerId = "stripe-checkout-no-pm-pro";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro-checkout",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true }), // No payment method!
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
// 1. Preview attach - should show $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attempt attach - should return checkout_url (not charge directly)
|
||||
const result = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
// Verify checkout_url is returned
|
||||
expect(result.checkout_url).toBeDefined();
|
||||
expect(result.checkout_url).toContain("checkout.stripe.com");
|
||||
|
||||
// 3. Complete checkout form
|
||||
await completeCheckoutForm(result.checkout_url);
|
||||
await timeout(12000); // Wait for webhook processing
|
||||
|
||||
// 4. Verify product is now attached
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectProductActive({
|
||||
customer,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Verify messages feature
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice was paid (matches preview total)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Free → pro (upgrade via checkout)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer on free product, NO payment method
|
||||
* - Attach pro product (upgrade)
|
||||
*
|
||||
* Expected Result:
|
||||
* - Returns checkout_url
|
||||
* - After checkout: pro replaces free
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("stripe-checkout: free → pro")}`, async () => {
|
||||
const customerId = "stripe-checkout-free-to-pro";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 50 });
|
||||
const free = products.base({
|
||||
id: "free-checkout",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
||||
const pro = products.pro({
|
||||
id: "pro-checkout-upgrade",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true }), // No payment method!
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. First attach free product (no checkout needed - it's free)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
// Verify free is attached
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({
|
||||
customer,
|
||||
productId: free.id,
|
||||
});
|
||||
|
||||
// 2. Preview upgrade to pro - should show $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 3. Attempt attach pro - should return checkout_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
expect(result.checkout_url).toBeDefined();
|
||||
|
||||
// 4. Complete checkout
|
||||
await completeCheckoutForm(result.checkout_url);
|
||||
await timeout(12000);
|
||||
|
||||
// 5. Verify pro replaced free
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectProductActive({
|
||||
customer,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Verify messages feature from pro (200, not 50)
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 200,
|
||||
balance: 200,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: One-off via checkout (mode: "payment")
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer with NO payment method
|
||||
* - Attach one-off product
|
||||
*
|
||||
* Expected Result:
|
||||
* - Returns checkout_url with mode: "payment" (not subscription)
|
||||
* - Credits granted after checkout
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("stripe-checkout: one-off purchase")}`, async () => {
|
||||
const customerId = "stripe-checkout-one-off";
|
||||
|
||||
const oneOffMessagesItem = items.oneOffMessages({
|
||||
includedUsage: 0,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
});
|
||||
|
||||
const oneOff = products.oneOff({
|
||||
id: "one-off-checkout",
|
||||
items: [oneOffMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true }), // No payment method!
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - base ($10) + messages ($10) = $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attempt attach - should return checkout_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
|
||||
expect(result.checkout_url).toBeDefined();
|
||||
|
||||
// 3. Complete checkout
|
||||
await completeCheckoutForm(result.checkout_url);
|
||||
await timeout(12000);
|
||||
|
||||
// 4. Verify credits were granted
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectProductActive({
|
||||
customer,
|
||||
productId: oneOff.id,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import type { ApiCustomerV3, ApiEntityV0, AttachPreview } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
@@ -51,12 +51,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: create entity, attach pro to en
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 0, // Attach to first entity
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach to entity - $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach to entity
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Get entity and verify it has the product
|
||||
@@ -85,6 +95,13 @@ test.concurrent(`${chalk.yellowBright("new-plan: create entity, attach pro to en
|
||||
// Customer should not have products array with this product
|
||||
const customerProduct = customer.products?.find((p) => p.id === pro.id);
|
||||
expect(customerProduct).toBeUndefined();
|
||||
|
||||
// Verify invoice on customer matches preview total: $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -116,10 +133,35 @@ test.concurrent(`${chalk.yellowBright("new-plan: create 2 entities, attach pro t
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview and attach to entity 1 - $20
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((preview1 as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// 2. Preview and attach to entity 2 - $20
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
expect((preview2 as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
// Get both entities and verify independent balances
|
||||
@@ -188,6 +230,14 @@ test.concurrent(`${chalk.yellowBright("new-plan: create 2 entities, attach pro t
|
||||
balance: 100, // Unchanged
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify 2 invoices, each $20
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -219,7 +269,21 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to entity 1, advance
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview and attach to entity 1 - $20 (full price)
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((preview1 as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Advance 2 weeks
|
||||
@@ -229,7 +293,15 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to entity 1, advance
|
||||
numberOfWeeks: 2,
|
||||
});
|
||||
|
||||
// Attach pro to entity 2 mid-cycle
|
||||
// 2. Preview attach to entity 2 mid-cycle (prorated)
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
const entity2Total = (preview2 as AttachPreview).due_today.total;
|
||||
|
||||
// 3. Attach to entity 2 mid-cycle
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
@@ -259,14 +331,12 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to entity 1, advance
|
||||
// Get customer to check invoices
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Should have 2 invoices: one full price, one prorated
|
||||
// Should have 2 invoices: one full price ($20), one prorated
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: entity2Total, // Prorated amount matches preview
|
||||
});
|
||||
|
||||
// Entity 2's invoice should be prorated (roughly half of $20 = ~$10)
|
||||
// Note: exact amount depends on billing cycle alignment
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -296,12 +366,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro annual to entity")}`
|
||||
s.products({ list: [proAnnual] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: proAnnual.id,
|
||||
entityIndex: 0,
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach to entity - $200 (annual)
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: proAnnual.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(200);
|
||||
|
||||
// 2. Attach to entity
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: proAnnual.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Get entity and verify product
|
||||
@@ -324,7 +404,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro annual to entity")}`
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Get customer and verify invoice (annual = $200)
|
||||
// Get customer and verify invoice matches preview total: $200
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
@@ -362,10 +442,33 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to customer, then pr
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id }), // Customer-level
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Entity-level
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview and attach to customer - $20
|
||||
const previewCust = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((previewCust as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
// 2. Preview and attach to entity - $20
|
||||
const previewEnt = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((previewEnt as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Get customer and entity
|
||||
@@ -400,6 +503,13 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro to customer, then pr
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify 2 invoices, each $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -430,10 +540,33 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free to customer, then f
|
||||
s.products({ list: [free] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: free.id }), // Customer-level
|
||||
s.billing.attach({ productId: free.id, entityIndex: 0 }), // Entity-level
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview and attach to customer - $0 (free)
|
||||
const previewCust = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
expect((previewCust as AttachPreview).due_today.total).toBe(0);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
// 2. Preview and attach to entity - $0 (free)
|
||||
const previewEnt = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect((previewEnt as AttachPreview).due_today.total).toBe(0);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Get customer and entity
|
||||
@@ -469,7 +602,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free to customer, then f
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify no invoices (both free)
|
||||
// Verify no invoices (both free) - matches preview total of 0
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 0,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import type { ApiCustomerV3, AttachPreview } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
@@ -47,7 +47,20 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free product")}`, async
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({}), s.products({ list: [free] })],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify no charge for free product
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(0);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -67,7 +80,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free product")}`, async
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify no invoice created (free product)
|
||||
// Verify no invoice created (free product) - matches preview total of 0
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 0,
|
||||
@@ -103,7 +116,20 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free with multiple featu
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({}), s.products({ list: [free] })],
|
||||
actions: [s.billing.attach({ productId: free.id })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify no charge for free product
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(0);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -135,7 +161,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach free with multiple featu
|
||||
// Verify dashboard feature (boolean - just check it exists)
|
||||
expect(customer.features[TestFeature.Dashboard]).toBeDefined();
|
||||
|
||||
// Verify no invoice created (free product)
|
||||
// Verify no invoice created (free product) - matches preview total of 0
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 0,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import type { ApiCustomerV3, ApiEntityV0, AttachPreview } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import {
|
||||
@@ -59,12 +59,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time purchase")}`, a
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: oneOff.id,
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify base ($10) + prepaid ($10) = $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -83,11 +93,11 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time purchase")}`, a
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice: one-time charge ($10 base + $10 messages = $20)
|
||||
// Verify invoice matches preview total: $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20, // oneOff base ($10) + prepaid ($10)
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,15 +133,33 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time purchase twice"
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attach same product again
|
||||
// 1. Preview first attach - $20
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview1 as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. First attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
|
||||
// 3. Preview second attach - $20
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview2 as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 4. Second attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
@@ -148,7 +176,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time purchase twice"
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify two invoices created
|
||||
// Verify two invoices created, each matching preview total
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
@@ -193,15 +221,34 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro then one-time as mai
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, oneOff] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attach one-time without isAddOn - should replace pro
|
||||
// 1. Preview and attach pro first - $20
|
||||
const previewPro = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((previewPro as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
// 2. Preview one-time replacement (includes refund for pro)
|
||||
const previewOneOff = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
const oneOffTotal = (previewOneOff as AttachPreview).due_today.total;
|
||||
|
||||
// 3. Attach one-time without isAddOn - should replace pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
// Note: NOT setting is_add_on: true
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -217,6 +264,13 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro then one-time as mai
|
||||
customer,
|
||||
productId: oneOff.id,
|
||||
});
|
||||
|
||||
// Verify latest invoice matches preview
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2, // pro invoice + one-off invoice
|
||||
latestTotal: oneOffTotal,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -251,12 +305,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time with quantity=0
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: oneOff.id,
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - base ($10) + messages ($10) = $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -269,11 +333,11 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time with quantity=0
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice: only messages charged
|
||||
// Verify invoice matches preview total: $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20, // base ($10) + messages ($10)
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,10 +381,30 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time as add-on to pr
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, oneOffAddon] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attach one-time add-on (is_add_on defined at product level, not in attach params)
|
||||
// 1. Preview and attach pro - $20
|
||||
const previewPro = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((previewPro as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
// 2. Preview add-on - base ($10) + prepaid ($5) = $15
|
||||
const previewAddOn = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOffAddon.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((previewAddOn as AttachPreview).due_today.total).toBe(15);
|
||||
|
||||
// 3. Attach add-on
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOffAddon.id,
|
||||
@@ -346,6 +430,13 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time as add-on to pr
|
||||
balance: 150,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify two invoices: pro ($20) + add-on ($15)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 15,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -379,12 +470,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time with multiple f
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 2 }], // 2 packs = 200 messages
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - base ($10) + 2 packs ($20) = $30
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 2 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(30);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 2 }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -397,11 +498,11 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time with multiple f
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice
|
||||
// Verify invoice matches preview total: $30
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 30, // base ($10) + 2 packs ($20)
|
||||
latestTotal: 30,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,13 +540,24 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time to entity")}`,
|
||||
s.products({ list: [oneOff] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: oneOff.id,
|
||||
entityIndex: 0, // Attach to first entity
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach to entity - base ($10) + prepaid ($10) = $20
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach to entity
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Get entity to verify balance
|
||||
@@ -467,4 +579,11 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach one-time to entity")}`,
|
||||
|
||||
// Customer should not have messages feature (it's on the entity)
|
||||
expect(customer.features[TestFeature.Messages]).toBeUndefined();
|
||||
|
||||
// Verify invoice on customer matches preview total: $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
* - Allocated features track entity usage
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { type ApiCustomerV3, ErrCode } from "@autumn/shared";
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type AttachPreview,
|
||||
ErrCode,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
@@ -57,12 +61,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with mixed features"
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }], // 1 pack of 100
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify base ($20) + prepaid ($10) = $30
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(30);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -99,7 +113,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with mixed features"
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice: base ($20) + prepaid ($10) = $30
|
||||
// Verify invoice matches preview total: $30
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
@@ -137,7 +151,20 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with allocated, crea
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 5, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify base price ($20)
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
// Track 5 users (creates overage of 2)
|
||||
@@ -166,7 +193,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with allocated, crea
|
||||
usage: 5,
|
||||
});
|
||||
|
||||
// Verify invoices: initial ($20) + overage (2 users @ $10 = $20)
|
||||
// Verify invoices: initial ($20 matches preview) + overage (2 users @ $10 = $20)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
@@ -208,9 +235,9 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach base with prepaid messag
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attempt to attach without options - should fail
|
||||
// Attempt to attach without options - should fail (no preview needed for error case)
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InvalidRequest,
|
||||
errCode: ErrCode.InvalidOptions,
|
||||
func: async () => {
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
@@ -254,9 +281,9 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with prepaid message
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attempt to attach without options - should fail
|
||||
// Attempt to attach without options - should fail (no preview needed for error case)
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InvalidRequest,
|
||||
errCode: ErrCode.InvalidOptions,
|
||||
func: async () => {
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
@@ -298,12 +325,22 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with prepaid message
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// 1. Preview attach - verify only base price ($20), no prepaid
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
|
||||
// 2. Attach
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
@@ -322,7 +359,7 @@ test.concurrent(`${chalk.yellowBright("new-plan: attach pro with prepaid message
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoice: only base price ($20), no prepaid
|
||||
// Verify invoice matches preview total: $20
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# Subscription Update Billing Guide
|
||||
|
||||
## Proration & Charges
|
||||
|
||||
When updating a subscription via `subscriptions.update` (custom plan), charges/credits are calculated based on the billing model:
|
||||
|
||||
### Billing Models
|
||||
|
||||
| Model | On Update Behavior |
|
||||
|-------|-------------------|
|
||||
| **Base Price** | Prorated charge/credit for price difference |
|
||||
| **Consumable** | No immediate overage charge (billed in arrears at cycle end) |
|
||||
| **Allocated** | Prorated charge for current overage above new included amount |
|
||||
| **Prepaid** | Full refund of previous prepaid, full charge for new prepaid |
|
||||
|
||||
### Detailed Behavior
|
||||
|
||||
#### 1. Base Price Changes
|
||||
- **Increase**: Charge prorated difference for remaining cycle
|
||||
- **Decrease**: Credit prorated difference for remaining cycle
|
||||
- **Remove**: Credit full remaining prorated amount
|
||||
|
||||
```typescript
|
||||
// $20/mo -> $30/mo at start of cycle = charge $10
|
||||
expect(preview.total).toBe(10);
|
||||
|
||||
// $30/mo -> $20/mo at start of cycle = credit $10
|
||||
expect(preview.total).toBe(-10);
|
||||
|
||||
// Mid-cycle (15 days): $20/mo -> $30/mo = charge ~$5 (prorated)
|
||||
expect(preview.total).toBe(5);
|
||||
```
|
||||
|
||||
#### 2. Consumable Features
|
||||
- **Never** charge overage on update
|
||||
- Overage is billed at end of billing cycle
|
||||
- Even if usage exceeds new included amount, preview.total = 0 for the consumable portion
|
||||
|
||||
```typescript
|
||||
// 80 used, 50 included = 30 overage, but...
|
||||
expect(preview.total).toBe(0); // Consumable overage NOT charged on update
|
||||
```
|
||||
|
||||
#### 3. Allocated Features (Seat-Based)
|
||||
- Charge prorated amount for overage seats above new included amount
|
||||
- Based on current usage vs new included allowance
|
||||
|
||||
```typescript
|
||||
// Using 5 seats, decrease included from 5 to 3
|
||||
// Overage = 5 - 3 = 2 seats @ $10/seat = $20
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
// Using 2 seats, increase included from 2 to 5
|
||||
// No overage, no charge
|
||||
expect(preview.total).toBe(0);
|
||||
```
|
||||
|
||||
##### ⚠️ Important: Allocated Features Create Invoices on Track
|
||||
|
||||
For allocated features (seat-based / prorated billing), **tracking usage past the included boundary immediately creates a prorated invoice**. This is handled in `adjustAllowance.ts`.
|
||||
|
||||
This means:
|
||||
- When `track()` causes usage to exceed included seats, an invoice is created immediately
|
||||
- This is different from consumable features, which only bill at cycle end
|
||||
|
||||
```typescript
|
||||
// Example: Product with 3 included seats @ $10/seat overage
|
||||
// Customer tracks 5 seats (2 over included)
|
||||
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
value: 5, // 2 over the 3 included
|
||||
});
|
||||
|
||||
// This immediately creates an invoice for the 2 extra seats (prorated)
|
||||
// Invoice count is now: 1 (initial) + 1 (track overage) = 2
|
||||
|
||||
// Later, when updating subscription:
|
||||
await autumnV1.subscriptions.update(updateParams);
|
||||
|
||||
// Invoice count becomes: 1 (initial) + 1 (track overage) + 1 (update) = 3
|
||||
```
|
||||
|
||||
This affects invoice count expectations in tests:
|
||||
- Usage within included: No extra invoice from track
|
||||
- Usage exceeds included: +1 invoice from track
|
||||
|
||||
#### 4. Prepaid Features
|
||||
|
||||
**Prepaid features require `options` with `quantity`** when attaching or updating. The `quantity` is:
|
||||
- The **total units** you want (NOT multiplied by billing_units)
|
||||
- **NOT** inclusive of `included_usage` (included_usage is separate free balance)
|
||||
|
||||
Billing logic on update:
|
||||
1. **Refund** previous prepaid amount: `old_packs * old_price`
|
||||
2. **Charge** new prepaid amount: `new_packs * new_price`
|
||||
3. **preview.total** = new charge - old refund
|
||||
|
||||
```typescript
|
||||
// Setup: $10 per 100 units (1 pack = 100 units at $10)
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: 0,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
});
|
||||
|
||||
// Attach with 2 packs (200 units)
|
||||
await initScenario({
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: "pro",
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 200 }], // 2 packs
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Upgrade to 5 packs (500 units)
|
||||
const preview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 500 }], // 5 packs
|
||||
});
|
||||
|
||||
// preview.total = (5 - 2) * $10 = $30
|
||||
expect(preview.total).toBe(30);
|
||||
|
||||
// Downgrade to 3 packs (300 units)
|
||||
const preview2 = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 300 }], // 3 packs
|
||||
});
|
||||
|
||||
// preview.total = (3 - 5) * $10 = -$20 (credit)
|
||||
expect(preview2.total).toBe(-20);
|
||||
```
|
||||
|
||||
##### Prepaid with Price/Billing Unit Changes
|
||||
|
||||
When changing price or billing units via `items`, the calculation uses old and new pack costs:
|
||||
|
||||
```typescript
|
||||
// Old: 3 packs of 100 @ $10 = $30
|
||||
// New: 3 packs of 100 @ $15 = $45
|
||||
// preview.total = $45 - $30 = $15
|
||||
expect(preview.total).toBe(15);
|
||||
|
||||
// Old: 300 units / 100 = 3 packs @ $10 = $30
|
||||
// New: 300 units / 50 = 6 packs @ $10 = $60
|
||||
// preview.total = $60 - $30 = $30
|
||||
expect(preview.total).toBe(30);
|
||||
```
|
||||
|
||||
### Preview vs Invoice Matching
|
||||
|
||||
Always verify that `preview.total` matches the actual invoice:
|
||||
|
||||
```typescript
|
||||
const updateParams = {
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
items: [newItem, priceItem],
|
||||
};
|
||||
|
||||
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
|
||||
expect(preview.total).toBe(expectedAmount);
|
||||
|
||||
await autumnV1.subscriptions.update(updateParams);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: expectedInvoiceCount,
|
||||
latestTotal: preview.total,
|
||||
});
|
||||
```
|
||||
|
||||
### Invoice Count Guidelines
|
||||
|
||||
| Transition | Expected Count |
|
||||
|------------|---------------|
|
||||
| Free-to-Free | 0 |
|
||||
| Free-to-Paid | 1 |
|
||||
| Paid-to-Paid (upgrade/downgrade) | Initial (1) + Update (1) = 2 |
|
||||
| Paid-to-Paid (allocated to prepaid) | Initial (1) + Arrear Settlement (1) + Prepaid (1) = 3 |
|
||||
|
||||
#### Allocated Feature Invoice Counts
|
||||
|
||||
For allocated features, invoice count depends on whether usage exceeded included at any point:
|
||||
|
||||
| Scenario | Invoice Count |
|
||||
|----------|--------------|
|
||||
| Usage stays within included, then update | Initial (1) + Update (1) = 2 |
|
||||
| Usage exceeds included via track, then update | Initial (1) + Track Overage (1) + Update (1) = 3 |
|
||||
| Usage exceeds included via track, update increases included to cover usage | Initial (1) + Track Overage (1) + Update Credit (1) = 3 |
|
||||
|
||||
```typescript
|
||||
// Example: 3 included seats, track 5 seats (2 over), then increase to 10 included
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 3, // 1 (attach) + 1 (track overage) + 1 (update credit)
|
||||
latestTotal: preview.total,
|
||||
});
|
||||
```
|
||||
|
||||
### No-Charge Updates
|
||||
|
||||
These updates should have `preview.total = 0`:
|
||||
- Adding/removing boolean features (no price impact)
|
||||
- Changing included usage (no billing attached)
|
||||
- Changing feature intervals (month → week)
|
||||
- Updating consumable features (overage not charged on update)
|
||||
- Increasing allocated seats when within included amount
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Just an entry file for search
|
||||
@@ -29,6 +29,9 @@ export const BillingResponseSchema = z.object({
|
||||
|
||||
payment_url: z.string().nullable(),
|
||||
|
||||
// Checkout URL for Stripe Checkout session (when customer has no payment method)
|
||||
checkout_url: z.string().nullable(),
|
||||
|
||||
required_action: BillingResponseRequiredActionSchema.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
import {
|
||||
EnrichedNewProductActionSchema,
|
||||
type NewProductAction,
|
||||
NewProductActionSchema,
|
||||
} from "./newProductAction";
|
||||
import {
|
||||
type OngoingCusProductAction,
|
||||
OngoingCusProductActionSchema,
|
||||
} from "./ongoingCusProductAction";
|
||||
import {
|
||||
type ScheduledCusProductAction,
|
||||
ScheduledCusProductActionSchema,
|
||||
} from "./scheduledCusProductAction";
|
||||
|
||||
export interface CusProductActions {
|
||||
ongoingCusProductAction?: OngoingCusProductAction;
|
||||
scheduledCusProductAction?: ScheduledCusProductAction;
|
||||
newProductActions: NewProductAction[];
|
||||
}
|
||||
|
||||
export const CusProductActionsSchema = z.object({
|
||||
ongoingCusProductAction: OngoingCusProductActionSchema,
|
||||
scheduledCusProductAction: ScheduledCusProductActionSchema,
|
||||
newProductActions: z.array(NewProductActionSchema),
|
||||
});
|
||||
|
||||
export const EnrichedCusProductActionsSchema = CusProductActionsSchema.extend({
|
||||
ongoingCusProductAction: OngoingCusProductActionSchema,
|
||||
scheduledCusProductAction: ScheduledCusProductActionSchema,
|
||||
newProductActions: z.array(EnrichedNewProductActionSchema),
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import z from "zod/v4";
|
||||
import { FullProductSchema } from "../productModels/productModels";
|
||||
|
||||
export const NewProductActionSchema = z.object({
|
||||
timing: z.literal(["scheduled", "immediate"]),
|
||||
product: FullProductSchema,
|
||||
});
|
||||
|
||||
export const EnrichedNewProductActionSchema = NewProductActionSchema.extend({
|
||||
startsAt: z.number().default(Date.now()),
|
||||
});
|
||||
|
||||
export type NewProductAction = z.infer<typeof NewProductActionSchema>;
|
||||
export type EnrichedNewProductAction = z.infer<
|
||||
typeof EnrichedNewProductActionSchema
|
||||
>;
|
||||
@@ -1,18 +0,0 @@
|
||||
import z from "zod/v4";
|
||||
import { FullCusProductSchema } from "../cusProductModels/cusProductModels";
|
||||
|
||||
enum OngoingCusProductActionEnum {
|
||||
Expire = "expire",
|
||||
Cancel = "cancel",
|
||||
Uncancel = "uncancel",
|
||||
Update = "update",
|
||||
}
|
||||
|
||||
// What happens to the CURRENT active cus product
|
||||
export const OngoingCusProductActionSchema = z.object({
|
||||
action: z.enum(OngoingCusProductActionEnum),
|
||||
cusProduct: FullCusProductSchema,
|
||||
});
|
||||
export type OngoingCusProductAction = z.infer<
|
||||
typeof OngoingCusProductActionSchema
|
||||
>;
|
||||
@@ -1,12 +0,0 @@
|
||||
import z from "zod/v4";
|
||||
import { FullCusProductSchema } from "../cusProductModels/cusProductModels";
|
||||
|
||||
// What happens to any SCHEDULED cus product
|
||||
export const ScheduledCusProductActionSchema = z.object({
|
||||
action: z.literal("delete"),
|
||||
cusProduct: FullCusProductSchema,
|
||||
});
|
||||
|
||||
export type ScheduledCusProductAction = z.infer<
|
||||
typeof ScheduledCusProductActionSchema
|
||||
>;
|
||||
@@ -8,9 +8,8 @@ export enum MetadataType {
|
||||
CheckoutSessionCompleted = "checkout_session_completed",
|
||||
|
||||
DeferredInvoice = "deferred_invoice",
|
||||
|
||||
// InvoiceActionRequiredV2 = "invoice_action_required_v2",
|
||||
// InvoiceCheckoutV2 = "invoice_checkout_v2",
|
||||
CheckoutSessionV2 = "checkout_session_v2",
|
||||
CheckoutSessionCompletedV2 = "checkout_session_completed_v2",
|
||||
}
|
||||
|
||||
export const metadata = pgTable("metadata", {
|
||||
@@ -20,6 +19,7 @@ export const metadata = pgTable("metadata", {
|
||||
data: jsonb(),
|
||||
type: text("type").$type<MetadataType>(),
|
||||
stripe_invoice_id: text("stripe_invoice_id"),
|
||||
stripe_checkout_session_id: text("stripe_checkout_session_id"),
|
||||
});
|
||||
|
||||
export type Metadata = InferSelectModel<typeof metadata>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppEnv } from "../../index.js";
|
||||
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
|
||||
import type { Organization } from "../../models/orgModels/orgTable.js";
|
||||
|
||||
@@ -11,3 +12,17 @@ export const orgToInStatuses = ({ org }: { org: Organization }) => {
|
||||
export const orgToCurrency = ({ org }: { org: Organization }) => {
|
||||
return org.default_currency || "usd";
|
||||
};
|
||||
|
||||
export const orgToReturnUrl = ({
|
||||
org,
|
||||
env,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
if (env === AppEnv.Sandbox) {
|
||||
return org.stripe_config?.sandbox_success_url || "https://useautumn.com";
|
||||
} else {
|
||||
return org.stripe_config?.success_url || "https://useautumn.com";
|
||||
}
|
||||
};
|
||||
|
||||
11
vite/src/components/forms/attach-v2/attachFormSchema.ts
Normal file
11
vite/src/components/forms/attach-v2/attachFormSchema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ProductItem } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AttachFormSchema = z.object({
|
||||
productId: z.string(),
|
||||
prepaidOptions: z.record(z.string(), z.number().nonnegative()),
|
||||
items: z.custom<ProductItem[]>().nullable(),
|
||||
version: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type AttachForm = z.infer<typeof AttachFormSchema>;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { SheetFooter } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
const FOOTER_DELAY_MS = 350;
|
||||
|
||||
export function AttachFooter() {
|
||||
const {
|
||||
isPending,
|
||||
previewQuery,
|
||||
handleConfirm,
|
||||
handleInvoiceAttach,
|
||||
formValues,
|
||||
} = useAttachFormContext();
|
||||
|
||||
const hasProductSelected = !!formValues.productId;
|
||||
const isLoading = previewQuery.isLoading;
|
||||
const hasError = !!previewQuery.error;
|
||||
const isReady = hasProductSelected && !isLoading && !hasError;
|
||||
|
||||
const [showFooter, setShowFooter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
const timer = setTimeout(() => setShowFooter(true), FOOTER_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setShowFooter(false);
|
||||
}, [isReady]);
|
||||
|
||||
if (!showFooter) return null;
|
||||
|
||||
return (
|
||||
<SheetFooter className="flex flex-col grid-cols-1 mt-0">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col gap-2 w-full"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" className="w-full" disabled={isPending}>
|
||||
Send an Invoice
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-(--radix-popover-trigger-width) p-0">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleInvoiceAttach({ enableProductImmediately: true })
|
||||
}
|
||||
className="px-4 py-3 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<div className="font-medium">Enable plan immediately</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable the plan immediately and redirect to Stripe to finalize
|
||||
the invoice
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleInvoiceAttach({ enableProductImmediately: false })
|
||||
}
|
||||
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
|
||||
>
|
||||
<div className="font-medium">Enable plan after payment</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Generate an invoice link for the customer. The plan will be
|
||||
enabled after they pay the invoice
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleConfirm}
|
||||
isLoading={isPending}
|
||||
>
|
||||
Attach Product
|
||||
</Button>
|
||||
</motion.div>
|
||||
</SheetFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ProductItem } from "@autumn/shared";
|
||||
import { buildEditsForItem, UsageModel } from "@autumn/shared";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
import { LayoutGroup, motion } from "motion/react";
|
||||
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
|
||||
import { StatusBadge } from "@/components/forms/update-subscription-v2/components/StatusBadge";
|
||||
import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow";
|
||||
import { LAYOUT_TRANSITION } from "@/components/forms/update-subscription-v2/constants/animationConstants";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
function SectionTitle({ hasCustomizations }: { hasCustomizations: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Plan Configuration</span>
|
||||
{hasCustomizations && <StatusBadge variant="created">Custom</StatusBadge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachPlanSection() {
|
||||
const {
|
||||
form,
|
||||
formValues,
|
||||
originalItems,
|
||||
productWithFormItems: product,
|
||||
hasCustomizations,
|
||||
handleEditPlan,
|
||||
} = useAttachFormContext();
|
||||
|
||||
const { prepaidOptions } = formValues;
|
||||
|
||||
const { org } = useOrg();
|
||||
const currency = org?.default_currency ?? "USD";
|
||||
|
||||
const originalItemsMap = new Map(
|
||||
originalItems?.filter((i) => i.feature_id).map((i) => [i.feature_id, i]) ??
|
||||
[],
|
||||
);
|
||||
|
||||
const currentFeatureIds = new Set(
|
||||
product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [],
|
||||
);
|
||||
|
||||
const deletedItems =
|
||||
hasCustomizations && originalItems
|
||||
? originalItems.filter(
|
||||
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
|
||||
)
|
||||
: [];
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
return (
|
||||
<SheetSection
|
||||
title={<SectionTitle hasCustomizations={hasCustomizations} />}
|
||||
withSeparator
|
||||
>
|
||||
{(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? (
|
||||
<>
|
||||
<div className="flex gap-2 justify-between items-center mb-3">
|
||||
<PriceDisplay product={product} currency={currency} />
|
||||
</div>
|
||||
<LayoutGroup>
|
||||
<div className="space-y-2">
|
||||
{product?.items?.map((item: ProductItem, index: number) => {
|
||||
if (!item.feature_id) return null;
|
||||
|
||||
const featureId = item.feature_id;
|
||||
const isPrepaid = item.usage_model === UsageModel.Prepaid;
|
||||
const currentPrepaidQuantity = isPrepaid
|
||||
? (prepaidOptions[featureId] ?? 0)
|
||||
: undefined;
|
||||
|
||||
const originalItem = originalItemsMap.get(featureId);
|
||||
const isCreated =
|
||||
hasCustomizations &&
|
||||
!originalItem &&
|
||||
originalItems &&
|
||||
originalItems.length > 0;
|
||||
|
||||
const edits = hasCustomizations
|
||||
? buildEditsForItem({
|
||||
updatedItem: item,
|
||||
originalItem,
|
||||
updatedPrepaidQuantity: currentPrepaidQuantity,
|
||||
originalPrepaidQuantity: undefined,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={featureId || item.price_id || index}
|
||||
layout
|
||||
transition={LAYOUT_TRANSITION}
|
||||
>
|
||||
<SubscriptionItemRow
|
||||
item={item}
|
||||
edits={edits}
|
||||
prepaidQuantity={currentPrepaidQuantity}
|
||||
form={form}
|
||||
featureId={featureId}
|
||||
isCreated={isCreated}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
{deletedItems.map((item: ProductItem, index: number) => (
|
||||
<motion.div
|
||||
key={`deleted-${item.feature_id || index}`}
|
||||
layout
|
||||
transition={LAYOUT_TRANSITION}
|
||||
>
|
||||
<SubscriptionItemRow item={item} isDeleted />
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div layout transition={LAYOUT_TRANSITION}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditPlan}
|
||||
className="w-full"
|
||||
>
|
||||
<PencilSimpleIcon size={14} className="mr-1" />
|
||||
Edit Plan Items
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={handleEditPlan} className="w-full">
|
||||
<PencilSimpleIcon size={14} className="mr-1" />
|
||||
Edit Plan Items
|
||||
</Button>
|
||||
)}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AxiosError } from "axios";
|
||||
import { format } from "date-fns";
|
||||
import { PreviewErrorDisplay } from "@/components/forms/update-subscription-v2/components/PreviewErrorDisplay";
|
||||
import { LineItemsPreview } from "@/components/v2/LineItemsPreview";
|
||||
import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
export function AttachPreviewSection() {
|
||||
const { previewQuery, formValues } = useAttachFormContext();
|
||||
|
||||
const hasProductSelected = !!formValues.productId;
|
||||
|
||||
const { isLoading, data: previewData, error: queryError } = previewQuery;
|
||||
const error = queryError
|
||||
? getBackendErr(queryError as AxiosError, "Failed to load preview")
|
||||
: undefined;
|
||||
|
||||
const totals = [];
|
||||
|
||||
if (previewData) {
|
||||
totals.push({
|
||||
label: "Total Due Now",
|
||||
amount: previewData.total,
|
||||
variant: "primary" as const,
|
||||
});
|
||||
|
||||
if (previewData.next_cycle) {
|
||||
totals.push({
|
||||
label: "Next Cycle",
|
||||
amount: previewData.next_cycle.total,
|
||||
variant: "secondary" as const,
|
||||
badge: previewData.next_cycle.starts_at
|
||||
? format(new Date(previewData.next_cycle.starts_at), "MMM d, yyyy")
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasProductSelected) return null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SheetSection title="Pricing Preview" withSeparator>
|
||||
<PreviewErrorDisplay error={error} />
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LineItemsPreview
|
||||
title="Pricing Preview"
|
||||
isLoading={isLoading}
|
||||
lineItems={previewData?.line_items}
|
||||
currency={previewData?.currency}
|
||||
totals={totals}
|
||||
filterZeroAmounts
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isProductAlreadyEnabled } from "@autumn/shared";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
import { useAttachFormContext } from "../context/AttachFormProvider";
|
||||
|
||||
export function AttachProductSelection() {
|
||||
const { form, hasCustomizations } = useAttachFormContext();
|
||||
|
||||
const { products } = useProductsQuery();
|
||||
const availableProducts = products.filter((p) => !p.archived);
|
||||
const { customer } = useCusQuery();
|
||||
const { entityId } = useEntity();
|
||||
|
||||
const productId = form.state.values.productId;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form.AppField name="productId">
|
||||
{(field) => (
|
||||
<field.SelectField
|
||||
label=""
|
||||
options={availableProducts.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
disabledValue: isProductAlreadyEnabled({
|
||||
productId: p.id,
|
||||
customer,
|
||||
entityId: entityId ?? undefined,
|
||||
})
|
||||
? "Already Enabled"
|
||||
: undefined,
|
||||
}))}
|
||||
placeholder="Select Product"
|
||||
hideFieldInfo
|
||||
selectValueAfter={
|
||||
hasCustomizations && productId ? (
|
||||
<span className="text-xs bg-green-500/10 text-green-500 px-1 py-0 rounded-md">
|
||||
Custom
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
Feature,
|
||||
FrontendProduct,
|
||||
ProductItem,
|
||||
ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { productV2ToFrontendProduct, UsageModel } from "@autumn/shared";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import type { PrepaidItemWithFeature } from "@/hooks/stores/useProductStore";
|
||||
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
|
||||
import type { AttachForm } from "../attachFormSchema";
|
||||
import { type UseAttachForm, useAttachForm } from "../hooks/useAttachForm";
|
||||
import { useAttachMutation } from "../hooks/useAttachMutation";
|
||||
import {
|
||||
type UseAttachPreviewReturn,
|
||||
useAttachPreview,
|
||||
} from "../hooks/useAttachPreview";
|
||||
import { useAttachRequestBody } from "../hooks/useAttachRequestBody";
|
||||
|
||||
export interface AttachFormContext {
|
||||
customerId: string | undefined;
|
||||
entityId: string | undefined;
|
||||
}
|
||||
|
||||
interface AttachFormContextValue {
|
||||
formContext: AttachFormContext;
|
||||
form: UseAttachForm;
|
||||
formValues: AttachForm;
|
||||
features: Feature[];
|
||||
|
||||
product: ProductV2 | undefined;
|
||||
prepaidItems: PrepaidItemWithFeature[];
|
||||
originalItems: ProductItem[] | undefined;
|
||||
productWithFormItems: FrontendProduct | undefined;
|
||||
hasCustomizations: boolean;
|
||||
|
||||
previewQuery: UseAttachPreviewReturn;
|
||||
|
||||
showPlanEditor: boolean;
|
||||
handleEditPlan: () => void;
|
||||
handlePlanEditorSave: (items: ProductItem[]) => void;
|
||||
handlePlanEditorCancel: () => void;
|
||||
|
||||
isPending: boolean;
|
||||
handleConfirm: () => void;
|
||||
handleInvoiceAttach: (params: { enableProductImmediately: boolean }) => void;
|
||||
}
|
||||
|
||||
const AttachFormReactContext = createContext<AttachFormContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
interface AttachFormProviderProps {
|
||||
customerId: string | undefined;
|
||||
entityId: string | undefined;
|
||||
initialProductId?: string;
|
||||
onPlanEditorOpen?: () => void;
|
||||
onPlanEditorClose?: () => void;
|
||||
onInvoiceCreated?: (invoiceId: string) => void;
|
||||
onCheckoutRedirect?: (checkoutUrl: string) => void;
|
||||
onSuccess?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AttachFormProvider({
|
||||
customerId,
|
||||
entityId,
|
||||
initialProductId,
|
||||
onPlanEditorOpen,
|
||||
onPlanEditorClose,
|
||||
onInvoiceCreated,
|
||||
onCheckoutRedirect,
|
||||
onSuccess,
|
||||
children,
|
||||
}: AttachFormProviderProps) {
|
||||
const [showPlanEditor, setShowPlanEditor] = useState(false);
|
||||
|
||||
const form = useAttachForm({ initialProductId });
|
||||
|
||||
const { features } = useFeaturesQuery();
|
||||
const { products } = useProductsQuery();
|
||||
|
||||
const formValues = useStore(form.store, (state) => state.values);
|
||||
const { productId, prepaidOptions, items, version } = formValues;
|
||||
|
||||
const product = useMemo(
|
||||
() => products.find((p) => p.id === productId && !p.archived),
|
||||
[products, productId],
|
||||
);
|
||||
|
||||
const { prepaidItems } = usePrepaidItems({ product });
|
||||
|
||||
// Track product changes and initialize prepaid options
|
||||
const previousProductIdRef = useRef<string | undefined>();
|
||||
useEffect(() => {
|
||||
// Only trigger when productId actually changes (not on initial mount with same value)
|
||||
if (previousProductIdRef.current === productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProductChange =
|
||||
previousProductIdRef.current !== undefined &&
|
||||
previousProductIdRef.current !== productId;
|
||||
|
||||
previousProductIdRef.current = productId;
|
||||
|
||||
if (isProductChange) {
|
||||
// Reset items and version when product changes
|
||||
form.setFieldValue("items", null);
|
||||
form.setFieldValue("version", undefined);
|
||||
}
|
||||
|
||||
// Initialize prepaid options for the selected product
|
||||
if (product) {
|
||||
const initialPrepaidOptions: Record<string, number> = {};
|
||||
for (const item of product.items) {
|
||||
if (item.usage_model === UsageModel.Prepaid && item.feature_id) {
|
||||
initialPrepaidOptions[item.feature_id] = 0;
|
||||
}
|
||||
}
|
||||
form.setFieldValue("prepaidOptions", initialPrepaidOptions);
|
||||
}
|
||||
}, [productId, product, form]);
|
||||
|
||||
const originalItems = product?.items as ProductItem[] | undefined;
|
||||
|
||||
const hasCustomizations = items !== null && items.length > 0;
|
||||
|
||||
const productWithFormItems = useMemo((): FrontendProduct | undefined => {
|
||||
if (!product) return undefined;
|
||||
|
||||
const baseFrontendProduct = productV2ToFrontendProduct({
|
||||
product: product as ProductV2,
|
||||
});
|
||||
|
||||
if (items) {
|
||||
return {
|
||||
...baseFrontendProduct,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
return baseFrontendProduct;
|
||||
}, [product, items]);
|
||||
|
||||
const previewQuery = useAttachPreview({
|
||||
customerId,
|
||||
entityId,
|
||||
product,
|
||||
prepaidOptions,
|
||||
items,
|
||||
version,
|
||||
});
|
||||
|
||||
const { buildRequestBody } = useAttachRequestBody({
|
||||
customerId,
|
||||
entityId,
|
||||
product,
|
||||
prepaidOptions,
|
||||
items,
|
||||
version,
|
||||
});
|
||||
|
||||
const { handleConfirm, handleInvoiceAttach, isPending } = useAttachMutation({
|
||||
customerId,
|
||||
buildRequestBody,
|
||||
onInvoiceCreated,
|
||||
onCheckoutRedirect,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const handleEditPlan = useCallback(() => {
|
||||
if (!productWithFormItems) return;
|
||||
setShowPlanEditor(true);
|
||||
onPlanEditorOpen?.();
|
||||
}, [productWithFormItems, onPlanEditorOpen]);
|
||||
|
||||
const handlePlanEditorSave = useCallback(
|
||||
(newItems: ProductItem[]) => {
|
||||
form.setFieldValue("items", newItems);
|
||||
|
||||
const currentPrepaidOptions = form.store.state.values.prepaidOptions;
|
||||
const updatedPrepaidOptions = { ...currentPrepaidOptions };
|
||||
let hasNewPrepaidItems = false;
|
||||
|
||||
for (const item of newItems) {
|
||||
if (
|
||||
item.usage_model === "prepaid" &&
|
||||
item.feature_id &&
|
||||
updatedPrepaidOptions[item.feature_id] === undefined
|
||||
) {
|
||||
updatedPrepaidOptions[item.feature_id] = 0;
|
||||
hasNewPrepaidItems = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewPrepaidItems) {
|
||||
form.setFieldValue("prepaidOptions", updatedPrepaidOptions);
|
||||
}
|
||||
|
||||
setShowPlanEditor(false);
|
||||
onPlanEditorClose?.();
|
||||
},
|
||||
[form, onPlanEditorClose],
|
||||
);
|
||||
|
||||
const handlePlanEditorCancel = useCallback(() => {
|
||||
setShowPlanEditor(false);
|
||||
onPlanEditorClose?.();
|
||||
}, [onPlanEditorClose]);
|
||||
|
||||
const formContext = useMemo(
|
||||
(): AttachFormContext => ({
|
||||
customerId,
|
||||
entityId,
|
||||
}),
|
||||
[customerId, entityId],
|
||||
);
|
||||
|
||||
const value = useMemo<AttachFormContextValue>(
|
||||
() => ({
|
||||
formContext,
|
||||
form,
|
||||
formValues,
|
||||
features,
|
||||
product,
|
||||
prepaidItems,
|
||||
originalItems,
|
||||
productWithFormItems,
|
||||
hasCustomizations,
|
||||
previewQuery,
|
||||
showPlanEditor,
|
||||
handleEditPlan,
|
||||
handlePlanEditorSave,
|
||||
handlePlanEditorCancel,
|
||||
isPending,
|
||||
handleConfirm,
|
||||
handleInvoiceAttach,
|
||||
}),
|
||||
[
|
||||
formContext,
|
||||
form,
|
||||
formValues,
|
||||
features,
|
||||
product,
|
||||
prepaidItems,
|
||||
originalItems,
|
||||
productWithFormItems,
|
||||
hasCustomizations,
|
||||
previewQuery,
|
||||
showPlanEditor,
|
||||
handleEditPlan,
|
||||
handlePlanEditorSave,
|
||||
handlePlanEditorCancel,
|
||||
isPending,
|
||||
handleConfirm,
|
||||
handleInvoiceAttach,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachFormReactContext.Provider value={value}>
|
||||
{children}
|
||||
</AttachFormReactContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAttachFormContext(): AttachFormContextValue {
|
||||
const context = useContext(AttachFormReactContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAttachFormContext must be used within AttachFormProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
25
vite/src/components/forms/attach-v2/hooks/useAttachForm.ts
Normal file
25
vite/src/components/forms/attach-v2/hooks/useAttachForm.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAppForm } from "@/hooks/form/form";
|
||||
import { type AttachForm, AttachFormSchema } from "../attachFormSchema";
|
||||
|
||||
export function useAttachForm({
|
||||
initialProductId,
|
||||
initialPrepaidOptions,
|
||||
}: {
|
||||
initialProductId?: string;
|
||||
initialPrepaidOptions?: Record<string, number>;
|
||||
} = {}) {
|
||||
return useAppForm({
|
||||
defaultValues: {
|
||||
productId: initialProductId || "",
|
||||
prepaidOptions: initialPrepaidOptions ?? {},
|
||||
items: null,
|
||||
version: undefined,
|
||||
} as AttachForm,
|
||||
validators: {
|
||||
onChange: AttachFormSchema,
|
||||
onSubmit: AttachFormSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type UseAttachForm = ReturnType<typeof useAttachForm>;
|
||||
110
vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts
Normal file
110
vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { AttachParamsV0 } from "@autumn/shared";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
interface AttachResponse {
|
||||
checkout_url?: string;
|
||||
invoice?: {
|
||||
stripe_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useAttachMutation({
|
||||
customerId,
|
||||
buildRequestBody,
|
||||
onInvoiceCreated,
|
||||
onCheckoutRedirect,
|
||||
onSuccess,
|
||||
}: {
|
||||
customerId: string | undefined;
|
||||
buildRequestBody: (params?: {
|
||||
useInvoice?: boolean;
|
||||
enableProductImmediately?: boolean;
|
||||
}) => AttachParamsV0 | null;
|
||||
onInvoiceCreated?: (invoiceId: string) => void;
|
||||
onCheckoutRedirect?: (checkoutUrl: string) => void;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
useInvoice,
|
||||
enableProductImmediately,
|
||||
}: {
|
||||
useInvoice?: boolean;
|
||||
enableProductImmediately?: boolean;
|
||||
}) => {
|
||||
if (!customerId) {
|
||||
throw new Error("Customer ID is required");
|
||||
}
|
||||
|
||||
const requestBody = buildRequestBody({
|
||||
useInvoice,
|
||||
enableProductImmediately,
|
||||
});
|
||||
|
||||
if (!requestBody) {
|
||||
throw new Error("Failed to build request body");
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<AttachResponse>(
|
||||
"/v1/billing/attach",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
return { data: response.data, useInvoice };
|
||||
},
|
||||
onSuccess: ({ data, useInvoice }) => {
|
||||
if (data?.checkout_url) {
|
||||
onCheckoutRedirect?.(data.checkout_url);
|
||||
toast.success("Redirecting to checkout...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (useInvoice && data?.invoice) {
|
||||
onInvoiceCreated?.(data.invoice.stripe_id);
|
||||
toast.success("Invoice created successfully");
|
||||
} else {
|
||||
toast.success("Product attached successfully");
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
|
||||
if (customerId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
|
||||
"Failed to attach product",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
mutation.mutate({ useInvoice: false });
|
||||
};
|
||||
|
||||
const handleInvoiceAttach = ({
|
||||
enableProductImmediately,
|
||||
}: {
|
||||
enableProductImmediately: boolean;
|
||||
}) => {
|
||||
mutation.mutate({
|
||||
useInvoice: true,
|
||||
enableProductImmediately,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
mutation,
|
||||
handleConfirm,
|
||||
handleInvoiceAttach,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
BillingPreviewResponse,
|
||||
ProductItem,
|
||||
ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useAttachRequestBody } from "./useAttachRequestBody";
|
||||
|
||||
interface UseAttachPreviewParams {
|
||||
customerId: string | undefined;
|
||||
entityId: string | undefined;
|
||||
product: ProductV2 | undefined;
|
||||
prepaidOptions: Record<string, number>;
|
||||
items: ProductItem[] | null;
|
||||
version: number | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAttachPreview({
|
||||
customerId,
|
||||
entityId,
|
||||
product,
|
||||
prepaidOptions,
|
||||
items,
|
||||
version,
|
||||
enabled,
|
||||
}: UseAttachPreviewParams) {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
const { requestBody } = useAttachRequestBody({
|
||||
customerId,
|
||||
entityId,
|
||||
product,
|
||||
prepaidOptions,
|
||||
items,
|
||||
version,
|
||||
});
|
||||
|
||||
const shouldEnable =
|
||||
enabled !== undefined ? enabled : !!(customerId && product && requestBody);
|
||||
|
||||
const queryKeyDeps = useMemo(
|
||||
() => JSON.stringify(requestBody),
|
||||
[requestBody],
|
||||
);
|
||||
|
||||
const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQueryKey(queryKeyDeps);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [queryKeyDeps]);
|
||||
|
||||
const isDebouncing = queryKeyDeps !== debouncedQueryKey;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["attach-preview-v2", debouncedQueryKey],
|
||||
queryFn: async () => {
|
||||
if (!requestBody || !customerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<BillingPreviewResponse>(
|
||||
"/v1/billing/preview_attach",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
enabled: shouldEnable,
|
||||
staleTime: 0,
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as AxiosError)?.response?.status;
|
||||
if (status && status >= 400 && status < 500) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
isLoading: shouldEnable && (query.isLoading || isDebouncing),
|
||||
};
|
||||
}
|
||||
|
||||
export type UseAttachPreviewReturn = ReturnType<typeof useAttachPreview>;
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
type AttachParamsV0,
|
||||
type FeatureOptions,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
import Decimal from "decimal.js";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface UseAttachRequestBodyParams {
|
||||
customerId: string | undefined;
|
||||
entityId: string | undefined;
|
||||
product: ProductV2 | undefined;
|
||||
prepaidOptions: Record<string, number>;
|
||||
items: ProductItem[] | null;
|
||||
version: number | undefined;
|
||||
}
|
||||
|
||||
function convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions,
|
||||
product,
|
||||
}: {
|
||||
prepaidOptions: Record<string, number>;
|
||||
product: ProductV2 | undefined;
|
||||
}): FeatureOptions[] | undefined {
|
||||
if (!product || Object.keys(prepaidOptions).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const options: FeatureOptions[] = [];
|
||||
|
||||
for (const [featureId, quantity] of Object.entries(prepaidOptions)) {
|
||||
const prepaidItem = product.items.find(
|
||||
(item) =>
|
||||
item.feature_id === featureId &&
|
||||
item.usage_model === UsageModel.Prepaid,
|
||||
);
|
||||
|
||||
if (prepaidItem) {
|
||||
options.push({
|
||||
feature_id: featureId,
|
||||
quantity: new Decimal(quantity || 0)
|
||||
.mul(prepaidItem.billing_units || 1)
|
||||
.toNumber(),
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
feature_id: featureId,
|
||||
quantity: quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options.length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
export function useAttachRequestBody({
|
||||
customerId,
|
||||
entityId,
|
||||
product,
|
||||
prepaidOptions,
|
||||
items,
|
||||
version,
|
||||
}: UseAttachRequestBodyParams) {
|
||||
const requestBody = useMemo((): AttachParamsV0 | null => {
|
||||
if (!customerId || !product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = convertPrepaidOptionsToFeatureOptions({
|
||||
prepaidOptions,
|
||||
product,
|
||||
});
|
||||
|
||||
const body: AttachParamsV0 = {
|
||||
customer_id: customerId,
|
||||
product_id: product.id,
|
||||
};
|
||||
|
||||
if (entityId) {
|
||||
body.entity_id = entityId;
|
||||
}
|
||||
|
||||
if (options && options.length > 0) {
|
||||
body.options = options;
|
||||
}
|
||||
|
||||
if (items && items.length > 0) {
|
||||
body.items = items;
|
||||
}
|
||||
|
||||
if (version !== undefined) {
|
||||
body.version = version;
|
||||
}
|
||||
|
||||
return body;
|
||||
}, [customerId, entityId, product, prepaidOptions, items, version]);
|
||||
|
||||
const buildRequestBody = useMemo(
|
||||
() =>
|
||||
({
|
||||
useInvoice,
|
||||
enableProductImmediately,
|
||||
}: {
|
||||
useInvoice?: boolean;
|
||||
enableProductImmediately?: boolean;
|
||||
} = {}): AttachParamsV0 | null => {
|
||||
if (!requestBody) return null;
|
||||
|
||||
const body = { ...requestBody };
|
||||
|
||||
if (useInvoice) {
|
||||
body.invoice = true;
|
||||
body.enable_product_immediately = enableProductImmediately;
|
||||
body.finalize_invoice = false;
|
||||
}
|
||||
|
||||
return body;
|
||||
},
|
||||
[requestBody],
|
||||
);
|
||||
|
||||
return { requestBody, buildRequestBody };
|
||||
}
|
||||
15
vite/src/components/forms/attach-v2/index.ts
Normal file
15
vite/src/components/forms/attach-v2/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Components
|
||||
|
||||
// Types
|
||||
export * from "./attachFormSchema";
|
||||
export * from "./components/AttachFooter";
|
||||
export * from "./components/AttachPlanSection";
|
||||
export * from "./components/AttachPreviewSection";
|
||||
export * from "./components/AttachProductSelection";
|
||||
// Context & Provider
|
||||
export * from "./context/AttachFormProvider";
|
||||
// Hooks
|
||||
export * from "./hooks/useAttachForm";
|
||||
export * from "./hooks/useAttachMutation";
|
||||
export * from "./hooks/useAttachPreview";
|
||||
export * from "./hooks/useAttachRequestBody";
|
||||
@@ -10,6 +10,7 @@ export type SheetType =
|
||||
| "new-feature"
|
||||
| "select-feature"
|
||||
| "attach-product"
|
||||
| "attach-product-v2"
|
||||
| "subscription-detail"
|
||||
| "subscription-update"
|
||||
| "subscription-update-v2"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Entity, FullCustomer } from "@autumn/shared";
|
||||
import {
|
||||
AttachFooter,
|
||||
AttachFormProvider,
|
||||
AttachPlanSection,
|
||||
AttachPreviewSection,
|
||||
AttachProductSelection,
|
||||
useAttachFormContext,
|
||||
} from "@/components/forms/attach-v2";
|
||||
import { InlinePlanEditor } from "@/components/v2/inline-custom-plan-editor/InlinePlanEditor";
|
||||
import {
|
||||
LayoutGroup,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
} from "@/components/v2/sheets/SharedSheetComponents";
|
||||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||
import { useCustomerContext } from "@/views/customers2/customer/CustomerContext";
|
||||
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
|
||||
|
||||
function SheetContent() {
|
||||
const {
|
||||
formValues,
|
||||
productWithFormItems,
|
||||
showPlanEditor,
|
||||
handlePlanEditorSave,
|
||||
handlePlanEditorCancel,
|
||||
} = useAttachFormContext();
|
||||
|
||||
const hasProductSelected = !!formValues.productId;
|
||||
|
||||
const { entityId } = useEntity();
|
||||
const { customer } = useCusQuery();
|
||||
const entities = (customer as FullCustomer)?.entities || [];
|
||||
const fullEntity = entities.find(
|
||||
(e: Entity) => e.id === entityId || e.internal_id === entityId,
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<SheetHeader
|
||||
title="Attach Product"
|
||||
description="Select and configure a product to attach to this customer"
|
||||
/>
|
||||
|
||||
<SheetSection withSeparator={false} className="pb-0">
|
||||
<div className="space-y-2">
|
||||
<AttachProductSelection />
|
||||
|
||||
{entityId ? (
|
||||
<div className="pt-2">
|
||||
<InfoBox variant="info">
|
||||
Attaching plan to entity{" "}
|
||||
<span className="font-semibold">
|
||||
{fullEntity?.name || fullEntity?.id}
|
||||
</span>
|
||||
</InfoBox>
|
||||
</div>
|
||||
) : entities.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<InfoBox variant="info">
|
||||
Attaching plan to customer - all entities will get access
|
||||
</InfoBox>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{hasProductSelected && (
|
||||
<>
|
||||
<AttachPlanSection />
|
||||
<AttachPreviewSection />
|
||||
<AttachFooter />
|
||||
</>
|
||||
)}
|
||||
|
||||
{productWithFormItems && (
|
||||
<InlinePlanEditor
|
||||
product={productWithFormItems}
|
||||
onSave={handlePlanEditorSave}
|
||||
onCancel={handlePlanEditorCancel}
|
||||
isOpen={showPlanEditor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachProductSheetV2() {
|
||||
const itemId = useSheetStore((s) => s.itemId);
|
||||
const { closeSheet } = useSheetStore();
|
||||
const { customer } = useCusQuery();
|
||||
const { stripeAccount } = useOrgStripeQuery();
|
||||
const env = useEnv();
|
||||
const { setIsInlineEditorOpen } = useCustomerContext();
|
||||
const { entityId } = useEntity();
|
||||
|
||||
return (
|
||||
<AttachFormProvider
|
||||
customerId={customer?.id ?? customer?.internal_id ?? ""}
|
||||
entityId={entityId ?? undefined}
|
||||
initialProductId={itemId ?? undefined}
|
||||
onPlanEditorOpen={() => setIsInlineEditorOpen(true)}
|
||||
onPlanEditorClose={() => setIsInlineEditorOpen(false)}
|
||||
onInvoiceCreated={(invoiceId) => {
|
||||
const invoiceLink = getStripeInvoiceLink({
|
||||
stripeInvoice: invoiceId,
|
||||
env,
|
||||
accountId: stripeAccount?.id,
|
||||
});
|
||||
window.open(invoiceLink, "_blank");
|
||||
}}
|
||||
onCheckoutRedirect={(checkoutUrl) => {
|
||||
window.location.href = checkoutUrl;
|
||||
}}
|
||||
onSuccess={closeSheet}
|
||||
>
|
||||
<SheetContent />
|
||||
</AttachFormProvider>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function AttachProductSheetTrigger() {
|
||||
const feature = features.features.find((f) => f.id === entity?.feature_id);
|
||||
|
||||
const handleClick = () => {
|
||||
setSheet({ type: "attach-product" });
|
||||
setSheet({ type: "attach-product-v2" });
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SubscriptionCancelSheet } from "@/views/customers2/components/sheets/Su
|
||||
import { SubscriptionUncancelSheet } from "@/views/customers2/components/sheets/SubscriptionUncancelSheet";
|
||||
import { SubscriptionUpdateSheet2 } from "@/views/customers2/components/sheets/SubscriptionUpdateSheet2";
|
||||
import { AttachProductSheet } from "../components/sheets/AttachProductSheet";
|
||||
import { AttachProductSheetV2 } from "../components/sheets/AttachProductSheetV2";
|
||||
import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet";
|
||||
import { BalanceSelectionSheet } from "../components/sheets/BalanceSelectionSheet";
|
||||
import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet";
|
||||
@@ -31,6 +32,8 @@ export function CustomerSheets() {
|
||||
switch (sheetType) {
|
||||
case "attach-product":
|
||||
return <AttachProductSheet />;
|
||||
case "attach-product-v2":
|
||||
return <AttachProductSheetV2 />;
|
||||
case "subscription-detail":
|
||||
return <SubscriptionDetailSheet />;
|
||||
case "subscription-update":
|
||||
|
||||
Reference in New Issue
Block a user