This commit is contained in:
John Yeo
2026-01-29 09:44:34 +00:00
parent 033d6133c9
commit baa3b00295
69 changed files with 2847 additions and 511 deletions

View File

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

10
.vscode/settings.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ export async function updateSubscription({
stripe: stripeBillingPlan,
};
if (!preview) {
if (preview) {
return {
billingContext,
billingPlan,

View File

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

View File

@@ -25,7 +25,7 @@ export const handleAttachV2 = createRoute({
const { billingContext, billingResult } = await billingActions.attach({
ctx,
params: body,
preview: true,
preview: false,
});
if (!billingResult) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ export interface StripeBillingPlanResult {
deferred?: boolean;
stripeInvoice?: Stripe.Invoice;
stripeSubscription?: Stripe.Subscription;
stripeCheckoutSession?: Stripe.Checkout.Session;
requiredAction?: {
code: PaymentFailureCode;
reason: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
// Just an entry file for search

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -10,6 +10,7 @@ export type SheetType =
| "new-feature"
| "select-feature"
| "attach-product"
| "attach-product-v2"
| "subscription-detail"
| "subscription-update"
| "subscription-update-v2"

View File

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

View File

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

View File

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