diff --git a/.opencode/plans/checkout-session-completed-v2.md b/.opencode/plans/checkout-session-completed-v2.md new file mode 100644 index 000000000..b2a805b71 --- /dev/null +++ b/.opencode/plans/checkout-session-completed-v2.md @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 45f4ca949..a0721f975 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 + } } diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index aa89755a4..66c47a0f4 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -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; } } diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts new file mode 100644 index 000000000..ece3bd8e0 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts @@ -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, + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/getOptionsFromCheckout.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/getOptionsFromCheckout.ts similarity index 95% rename from server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/getOptionsFromCheckout.ts rename to server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/getOptionsFromCheckout.ts index 3b9d06131..900b8adb9 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/getOptionsFromCheckout.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/getOptionsFromCheckout.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSessionCompletedLegacy.ts similarity index 92% rename from server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts rename to server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSessionCompletedLegacy.ts index 52d4418c1..17e8542b2 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSessionCompletedLegacy.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSub.ts similarity index 94% rename from server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts rename to server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSub.ts index 70d808178..ab36f2857 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleCheckoutSub.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleRemainingSets.ts similarity index 94% rename from server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts rename to server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleRemainingSets.ts index 78325e8cd..e36342d1e 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleRemainingSets.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleSetupCheckout.ts similarity index 91% rename from server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts rename to server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleSetupCheckout.ts index da5a0e976..22fe2ab2e 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/legacy/handleSetupCheckout.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext.ts new file mode 100644 index 000000000..dd66f7f2c --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext.ts @@ -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 => { + 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, + }; +}; diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index 82ccc1819..36df018f7 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -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); diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index bebf70322..97c47afae 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -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 { // 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, }; } diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 53bfc6db6..41bcdae08 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -68,7 +68,7 @@ export async function updateSubscription({ stripe: stripeBillingPlan, }; - if (!preview) { + if (preview) { return { billingContext, billingPlan, diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 7d9bda21e..7d828a2b5 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -50,11 +50,14 @@ export const buildAutumnLineItems = ({ // will be handled in finalizeUpdateSubscriptionPlan const allLineItems = [...deletedLineItems, ...newLineItems]; - logBuildAutumnLineItems({ - logger, - deletedLineItems, - newLineItems, - }); + const debugLogs = false; + if (debugLogs) { + logBuildAutumnLineItems({ + logger, + deletedLineItems, + newLineItems, + }); + } return allLineItems; }; diff --git a/server/src/internal/billing/v2/handlers/handleAttachV2.ts b/server/src/internal/billing/v2/handlers/handleAttachV2.ts index 22d1047a8..67056c349 100644 --- a/server/src/internal/billing/v2/handlers/handleAttachV2.ts +++ b/server/src/internal/billing/v2/handlers/handleAttachV2.ts @@ -25,7 +25,7 @@ export const handleAttachV2 = createRoute({ const { billingContext, billingResult } = await billingActions.attach({ ctx, params: body, - preview: true, + preview: false, }); if (!billingResult) { diff --git a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts index 206a6d815..43954cc25 100644 --- a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts @@ -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) { diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts new file mode 100644 index 000000000..10b424791 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts @@ -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 }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts index ed3a130e9..d05097214 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts @@ -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, }); } diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts index 390b637ae..c6fa39c91 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts @@ -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 diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index c075054eb..29b7cf8ae 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -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 => { 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, }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index ec80d7bf0..28c68f865 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -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; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts new file mode 100644 index 000000000..3b83e3742 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts @@ -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 => { + 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, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts index 62b67672b..a42030cd8 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -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"; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index b33a78985..fe5fd6cfd 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -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"; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index bb3454e8b..67ed19e69 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -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"; /** diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index dc675c4f3..fde37d335 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -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 => !!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, }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index e034b0e4a..7fb15a7ab 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -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 - logTransitionPoints({ - ctx, - customerProducts: normalizedCustomerProducts, - transitionPoints, - nowMs, - }); + if (debugLogs) { + logTransitionPoints({ + ctx, + customerProducts: normalizedCustomerProducts, + transitionPoints, + nowMs, + }); + } let startMs = nowMs; @@ -166,14 +170,16 @@ export const buildStripePhasesUpdate = ({ }; // Log phase details - logPhase({ - ctx, - phase, - customerProducts: activeCustomerProducts, - phaseIndex, - logPrefix: "[buildStripePhasesUpdate]", - showCustomerProducts: true, - }); + if (debugLogs) { + logPhase({ + ctx, + phase, + customerProducts: activeCustomerProducts, + phaseIndex, + logPrefix: "[buildStripePhasesUpdate]", + showCustomerProducts: true, + }); + } phases.push(phase); diff --git a/server/src/internal/billing/v2/types/autumnBillingPlan.ts b/server/src/internal/billing/v2/types/autumnBillingPlan.ts index fdcadf58e..bd16b6bef 100644 --- a/server/src/internal/billing/v2/types/autumnBillingPlan.ts +++ b/server/src/internal/billing/v2/types/autumnBillingPlan.ts @@ -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; }; diff --git a/server/src/internal/billing/v2/types/billingPlan.ts b/server/src/internal/billing/v2/types/billingPlan.ts index 45f1e5c58..92d6424ef 100644 --- a/server/src/internal/billing/v2/types/billingPlan.ts +++ b/server/src/internal/billing/v2/types/billingPlan.ts @@ -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, diff --git a/server/src/internal/billing/v2/types/billingResult.ts b/server/src/internal/billing/v2/types/billingResult.ts index f7f75d77e..ffe4f543b 100644 --- a/server/src/internal/billing/v2/types/billingResult.ts +++ b/server/src/internal/billing/v2/types/billingResult.ts @@ -5,6 +5,7 @@ export interface StripeBillingPlanResult { deferred?: boolean; stripeInvoice?: Stripe.Invoice; stripeSubscription?: Stripe.Subscription; + stripeCheckoutSession?: Stripe.Checkout.Session; requiredAction?: { code: PaymentFailureCode; reason: string; diff --git a/server/src/internal/billing/v2/types/index.ts b/server/src/internal/billing/v2/types/index.ts index eb4427131..926c5688f 100644 --- a/server/src/internal/billing/v2/types/index.ts +++ b/server/src/internal/billing/v2/types/index.ts @@ -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"; diff --git a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts index ef8ce464e..ee5c6fd03 100644 --- a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts +++ b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeBillingPlan.ts @@ -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; diff --git a/server/src/internal/billing/v2/types/stripeBillingPlan/stripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeCheckoutSessionAction.ts new file mode 100644 index 000000000..a10b02a9a --- /dev/null +++ b/server/src/internal/billing/v2/types/stripeBillingPlan/stripeCheckoutSessionAction.ts @@ -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(), +}); + +export type StripeCheckoutSessionAction = z.infer< + typeof StripeCheckoutSessionActionSchema +>; diff --git a/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts b/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts index e0fdd3b40..749d38250 100644 --- a/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts +++ b/server/src/internal/billing/v2/utils/billingResult/billingResultToResponse.ts @@ -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; }; diff --git a/server/src/internal/checkouts/handlers/handleGetCheckout.ts b/server/src/internal/checkouts/handlers/handleGetCheckout.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/internal/checkouts/index.ts b/server/src/internal/checkouts/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/internal/metadata/MetadataService.ts b/server/src/internal/metadata/MetadataService.ts index 72d70fc36..8679fc2f1 100644 --- a/server/src/internal/metadata/MetadataService.ts +++ b/server/src/internal/metadata/MetadataService.ts @@ -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; + }) { + const updatedMetadata = await db + .update(metadata) + .set(updates) + .where(eq(metadata.id, id)) + .returning(); + + return updatedMetadata[0] as Metadata | undefined; + } } diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts index 3b8cd17ac..9588cbe70 100644 --- a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -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, + }, + }); +}; diff --git a/server/tests/integration/billing/attach/attachTests.md b/server/tests/integration/billing/attach/attachTests.md index 50621b270..89f8470e7 100644 --- a/server/tests/integration/billing/attach/attachTests.md +++ b/server/tests/integration/billing/attach/attachTests.md @@ -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(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** diff --git a/server/tests/integration/billing/attach/checkout/autumn-checkout/autumn-checkout-basic.test.ts b/server/tests/integration/billing/attach/checkout/autumn-checkout/autumn-checkout-basic.test.ts new file mode 100644 index 000000000..c1015abfe --- /dev/null +++ b/server/tests/integration/billing/attach/checkout/autumn-checkout/autumn-checkout-basic.test.ts @@ -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(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 diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-basic.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-basic.test.ts new file mode 100644 index 000000000..5e87c4e11 --- /dev/null +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-basic.test.ts @@ -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(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(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(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(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, + }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts b/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts index 867ec4743..25ddbafa5 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-entities.test.ts @@ -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(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(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(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, diff --git a/server/tests/integration/billing/attach/new-plan/attach-free.test.ts b/server/tests/integration/billing/attach/new-plan/attach-free.test.ts index b279b0017..68a46c46f 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-free.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-free.test.ts @@ -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(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(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, diff --git a/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts b/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts index 1aa363914..da753f4b6 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-one-time.test.ts @@ -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, - options: [{ feature_id: TestFeature.Messages, quantity: 1 }], - }), - ], + 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(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(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, - options: [{ feature_id: TestFeature.Messages, quantity: 1 }], - }), - ], + 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(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(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 - options: [{ feature_id: TestFeature.Messages, quantity: 1 }], - }), - ], + 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, + }); }); diff --git a/server/tests/integration/billing/attach/new-plan/attach-paid.test.ts b/server/tests/integration/billing/attach/new-plan/attach-paid.test.ts index 862e38b9d..53ba3fa5a 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-paid.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-paid.test.ts @@ -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(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, - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - }), - ], + 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(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, diff --git a/server/tests/integration/billing/update-subscription/BILLING_GUIDE.md b/server/tests/integration/billing/update-subscription/BILLING_GUIDE.md deleted file mode 100644 index 858016b8c..000000000 --- a/server/tests/integration/billing/update-subscription/BILLING_GUIDE.md +++ /dev/null @@ -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 - diff --git a/server/tests/integration/billing/update-subscription/update-subscription.test.ts b/server/tests/integration/billing/update-subscription/update-subscription.test.ts new file mode 100644 index 000000000..bad55ec97 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/update-subscription.test.ts @@ -0,0 +1 @@ +// Just an entry file for search diff --git a/shared/api/billing/common/billingResponse.ts b/shared/api/billing/common/billingResponse.ts index 1e8575c04..8270ebd25 100644 --- a/shared/api/billing/common/billingResponse.ts +++ b/shared/api/billing/common/billingResponse.ts @@ -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(), }); diff --git a/shared/models/billingModels/cusProductActions.ts b/shared/models/billingModels/cusProductActions.ts deleted file mode 100644 index 201a388b0..000000000 --- a/shared/models/billingModels/cusProductActions.ts +++ /dev/null @@ -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), -}); diff --git a/shared/models/billingModels/newProductAction.ts b/shared/models/billingModels/newProductAction.ts deleted file mode 100644 index 1d365f889..000000000 --- a/shared/models/billingModels/newProductAction.ts +++ /dev/null @@ -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; -export type EnrichedNewProductAction = z.infer< - typeof EnrichedNewProductActionSchema ->; diff --git a/shared/models/billingModels/ongoingCusProductAction.ts b/shared/models/billingModels/ongoingCusProductAction.ts deleted file mode 100644 index 2b7ebfe6f..000000000 --- a/shared/models/billingModels/ongoingCusProductAction.ts +++ /dev/null @@ -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 ->; diff --git a/shared/models/billingModels/scheduledCusProductAction.ts b/shared/models/billingModels/scheduledCusProductAction.ts deleted file mode 100644 index b62a83b14..000000000 --- a/shared/models/billingModels/scheduledCusProductAction.ts +++ /dev/null @@ -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 ->; diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index cb02da3a0..5ba8d1ab0 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -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(), stripe_invoice_id: text("stripe_invoice_id"), + stripe_checkout_session_id: text("stripe_checkout_session_id"), }); export type Metadata = InferSelectModel; diff --git a/shared/utils/orgUtils/convertOrgUtils.ts b/shared/utils/orgUtils/convertOrgUtils.ts index 501cdbdcc..cfb25b20a 100644 --- a/shared/utils/orgUtils/convertOrgUtils.ts +++ b/shared/utils/orgUtils/convertOrgUtils.ts @@ -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"; + } +}; diff --git a/vite/src/components/forms/attach-v2/attachFormSchema.ts b/vite/src/components/forms/attach-v2/attachFormSchema.ts new file mode 100644 index 000000000..52376640a --- /dev/null +++ b/vite/src/components/forms/attach-v2/attachFormSchema.ts @@ -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().nullable(), + version: z.number().positive().optional(), +}); + +export type AttachForm = z.infer; diff --git a/vite/src/components/forms/attach-v2/components/AttachFooter.tsx b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx new file mode 100644 index 000000000..985003c31 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx @@ -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 ( + + + + + + + +
+ + +
+
+
+ +
+
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx new file mode 100644 index 000000000..a421048f3 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx @@ -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 ( +
+ Plan Configuration + {hasCustomizations && Custom} +
+ ); +} + +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 ( + } + withSeparator + > + {(product?.items?.length ?? 0) > 0 || deletedItems.length > 0 ? ( + <> +
+ +
+ +
+ {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 ( + + + + ); + })} + {deletedItems.map((item: ProductItem, index: number) => ( + + + + ))} + + + +
+
+ + ) : ( + + )} +
+ ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx new file mode 100644 index 000000000..282c137be --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachPreviewSection.tsx @@ -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 ( + + + + ); + } + + return ( + + ); +} diff --git a/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx b/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx new file mode 100644 index 000000000..5e83c69d0 --- /dev/null +++ b/vite/src/components/forms/attach-v2/components/AttachProductSelection.tsx @@ -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 ( +
+ + {(field) => ( + ({ + 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 ? ( + + Custom + + ) : undefined + } + /> + )} + +
+ ); +} diff --git a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx new file mode 100644 index 000000000..238728629 --- /dev/null +++ b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx @@ -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( + 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(); + 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 = {}; + 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( + () => ({ + 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 ( + + {children} + + ); +} + +export function useAttachFormContext(): AttachFormContextValue { + const context = useContext(AttachFormReactContext); + if (!context) { + throw new Error( + "useAttachFormContext must be used within AttachFormProvider", + ); + } + return context; +} diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts b/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts new file mode 100644 index 000000000..5f10cbab5 --- /dev/null +++ b/vite/src/components/forms/attach-v2/hooks/useAttachForm.ts @@ -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; +} = {}) { + return useAppForm({ + defaultValues: { + productId: initialProductId || "", + prepaidOptions: initialPrepaidOptions ?? {}, + items: null, + version: undefined, + } as AttachForm, + validators: { + onChange: AttachFormSchema, + onSubmit: AttachFormSchema, + }, + }); +} + +export type UseAttachForm = ReturnType; diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts new file mode 100644 index 000000000..ffcf3b56b --- /dev/null +++ b/vite/src/components/forms/attach-v2/hooks/useAttachMutation.ts @@ -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( + "/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, + }; +} diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts b/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts new file mode 100644 index 000000000..ab51d35f0 --- /dev/null +++ b/vite/src/components/forms/attach-v2/hooks/useAttachPreview.ts @@ -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; + 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( + "/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; diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts new file mode 100644 index 000000000..8b0355d60 --- /dev/null +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -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; + items: ProductItem[] | null; + version: number | undefined; +} + +function convertPrepaidOptionsToFeatureOptions({ + prepaidOptions, + product, +}: { + prepaidOptions: Record; + 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 }; +} diff --git a/vite/src/components/forms/attach-v2/index.ts b/vite/src/components/forms/attach-v2/index.ts new file mode 100644 index 000000000..12ec09ab6 --- /dev/null +++ b/vite/src/components/forms/attach-v2/index.ts @@ -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"; diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index f9e5d0809..3885d5e46 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -10,6 +10,7 @@ export type SheetType = | "new-feature" | "select-feature" | "attach-product" + | "attach-product-v2" | "subscription-detail" | "subscription-update" | "subscription-update-v2" diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx new file mode 100644 index 000000000..143613262 --- /dev/null +++ b/vite/src/views/customers2/components/sheets/AttachProductSheetV2.tsx @@ -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 ( + +
+ + + +
+ + + {entityId ? ( +
+ + Attaching plan to entity{" "} + + {fullEntity?.name || fullEntity?.id} + + +
+ ) : entities.length > 0 ? ( +
+ + Attaching plan to customer - all entities will get access + +
+ ) : null} +
+
+ + {hasProductSelected && ( + <> + + + + + )} + + {productWithFormItems && ( + + )} +
+
+ ); +} + +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 ( + 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} + > + + + ); +} diff --git a/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx b/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx index 4cf6cdeb8..8461ced71 100644 --- a/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx +++ b/vite/src/views/customers2/components/table/customer-products/AttachProductSheetTrigger.tsx @@ -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 (