fix type errors and tests

This commit is contained in:
John Yeo
2026-03-13 11:48:31 +00:00
parent 624d5e78d8
commit 92456de04f
16 changed files with 934 additions and 1140 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Run TypeScript type check - name: Run TypeScript type check
run: | run: |
cd server && bun ts bun ts
build-and-push: build-and-push:
name: Build and Push Docker Image name: Build and Push Docker Image

View File

@@ -127,7 +127,13 @@ function getTierRangeText({
} }
const previousTier = tiers[index - 1]; const previousTier = tiers[index - 1];
if (!previousTier || typeof previousTier.to !== "number") return ""; if (
!previousTier ||
typeof previousTier.to !== "number" ||
typeof tier.to !== "number"
) {
return "";
}
return `for the next ${tier.to - previousTier.to}`; return `for the next ${tier.to - previousTier.to}`;
} }
@@ -232,8 +238,6 @@ export function PlanItemTierDetails({
return ( return (
<Accordion <Accordion
type="single"
collapsible
className={cn( className={cn(
"w-auto max-w-full", "w-auto max-w-full",
align === "right" && "items-end text-right", align === "right" && "items-end text-right",

View File

@@ -1,6 +1,6 @@
import { import {
type ApiPlanItemV1, type ApiPlanItemV1,
type BillingPreviewChange, type GetCheckoutResponse,
} from "@autumn/shared"; } from "@autumn/shared";
import { CheckIcon, WalletIcon } from "@phosphor-icons/react"; import { CheckIcon, WalletIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
@@ -43,7 +43,7 @@ function getFeatureName(planItem: ApiPlanItemV1): string {
} }
interface PlanSelectionCardProps { interface PlanSelectionCardProps {
change: BillingPreviewChange; change: GetCheckoutResponse["preview"]["incoming"][number];
} }
export function PlanSelectionCard({ change }: PlanSelectionCardProps) { export function PlanSelectionCard({ change }: PlanSelectionCardProps) {

View File

@@ -1,8 +1,8 @@
import { import {
type BillingPreviewChange,
type BillingResponse, type BillingResponse,
CheckoutAction, CheckoutAction,
type ConfirmCheckoutResponse, type ConfirmCheckoutResponse,
type GetCheckoutResponse,
CheckoutStatus, CheckoutStatus,
} from "@autumn/shared"; } from "@autumn/shared";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -17,12 +17,14 @@ import { buildHeaderDescription } from "@/utils/buildHeaderDescription";
const SUCCESS_REDIRECT_DELAY_MS = 2000; const SUCCESS_REDIRECT_DELAY_MS = 2000;
type CheckoutPreviewChange = GetCheckoutResponse["preview"]["incoming"][number];
function haveMatchingQuantities({ function haveMatchingQuantities({
incoming, incoming,
outgoing, outgoing,
}: { }: {
incoming: BillingPreviewChange; incoming: CheckoutPreviewChange;
outgoing: BillingPreviewChange; outgoing: CheckoutPreviewChange;
}) { }) {
if (incoming.feature_quantities.length !== outgoing.feature_quantities.length) { if (incoming.feature_quantities.length !== outgoing.feature_quantities.length) {
return false; return false;
@@ -43,7 +45,7 @@ function haveMatchingQuantities({
} }
function buildFeatureQuantities( function buildFeatureQuantities(
incoming: BillingPreviewChange[], incoming: CheckoutPreviewChange[],
quantities: Record<string, number>, quantities: Record<string, number>,
): { feature_id: string; quantity: number }[] { ): { feature_id: string; quantity: number }[] {
const featureQuantities: { feature_id: string; quantity: number }[] = []; const featureQuantities: { feature_id: string; quantity: number }[] = [];
@@ -123,18 +125,18 @@ export function useCheckoutState({
const isUpdateQuantityIntent = const isUpdateQuantityIntent =
preview?.object === "update_subscription_preview" && preview?.object === "update_subscription_preview" &&
preview.intent === "update_quantity"; preview.intent === "update_quantity";
const matchingOutgoingChange = incoming?.[0] const incomingChange = incoming?.[0];
? outgoing?.find((change) => change.plan_id === incoming[0].plan_id) const matchingOutgoingChange = incomingChange
? outgoing?.find((change) => change.plan_id === incomingChange.plan_id)
: undefined; : undefined;
const isUnchangedQuantityUpdate = const isUnchangedQuantityUpdate =
isUpdateQuantityIntent && isUpdateQuantityIntent && incomingChange && matchingOutgoingChange
Boolean(incoming?.[0]) && ? haveMatchingQuantities({
Boolean(matchingOutgoingChange) && incoming: incomingChange,
haveMatchingQuantities({ outgoing: matchingOutgoingChange,
incoming: incoming[0], })
outgoing: matchingOutgoingChange, : false;
}); const incomingPlan = incomingChange?.plan;
const incomingPlan = incoming?.[0]?.plan;
const freeTrial = incomingPlan?.free_trial; const freeTrial = incomingPlan?.free_trial;
const hasActiveTrial = !!freeTrial; const hasActiveTrial = !!freeTrial;

View File

@@ -1,15 +1,16 @@
import type { import type {
ApiFreeTrialV2, ApiFreeTrialV2,
AttachPreviewResponse,
BillingPreviewChange,
BillingPreviewResponse, BillingPreviewResponse,
CheckoutEntity, CheckoutEntity,
PreviewUpdateSubscriptionResponse, GetCheckoutResponse,
} from "@autumn/shared"; } from "@autumn/shared";
import { format } from "date-fns"; import { format } from "date-fns";
import { formatAmount } from "./formatUtils"; import { formatAmount } from "./formatUtils";
import { getCheckoutPreviewIntent } from "./getCheckoutPreviewIntent"; import { getCheckoutPreviewIntent } from "./getCheckoutPreviewIntent";
type CheckoutPreview = GetCheckoutResponse["preview"];
type CheckoutPreviewChange = CheckoutPreview["incoming"][number];
/** /**
* Builds a phrase describing applied discounts. * Builds a phrase describing applied discounts.
* Examples: "Discount code 20OFF applied for 20% off.", "Discount codes 20OFF (20% off) and SAVE10 ($10 off) applied." * Examples: "Discount code 20OFF applied for 20% off.", "Discount codes 20OFF (20% off) and SAVE10 ($10 off) applied."
@@ -130,9 +131,9 @@ export function buildHeaderDescription({
freeTrial, freeTrial,
hasActiveTrial, hasActiveTrial,
}: { }: {
preview?: AttachPreviewResponse | PreviewUpdateSubscriptionResponse; preview?: CheckoutPreview;
incoming?: BillingPreviewChange[]; incoming?: CheckoutPreviewChange[];
outgoing?: BillingPreviewChange[]; outgoing?: CheckoutPreviewChange[];
entity?: CheckoutEntity; entity?: CheckoutEntity;
freeTrial?: ApiFreeTrialV2 | null; freeTrial?: ApiFreeTrialV2 | null;
hasActiveTrial?: boolean; hasActiveTrial?: boolean;

View File

@@ -8,7 +8,7 @@ const res = await autumn.customers.getOrCreate({
customerId: "john", customerId: "john",
}); });
await autumn.entities.create({ const entity = await autumn.entities.create({
customerId: "john", customerId: "john",
entityId: "name", entityId: "name",
featureId: "user", featureId: "user",
@@ -23,4 +23,4 @@ await autumn.entities.create({
}, },
}); });
console.log(JSON.stringify(res, null, 2)); // console.log(JSON.stringify(entity.billingControls, null, 2));

View File

@@ -51,6 +51,10 @@ export function augmentCheckoutParams({
| AttachParamsV1["feature_quantities"] | AttachParamsV1["feature_quantities"]
| UpdateSubscriptionV1Params["feature_quantities"]; | UpdateSubscriptionV1Params["feature_quantities"];
}) => { }) => {
if (!body.feature_quantities) {
return originalFeatureQuantities;
}
return body.feature_quantities.map((featureQuantity) => { return body.feature_quantities.map((featureQuantity) => {
const originalFeatureQuantity = originalFeatureQuantities?.find( const originalFeatureQuantity = originalFeatureQuantities?.find(
(original) => original.feature_id === featureQuantity.feature_id, (original) => original.feature_id === featureQuantity.feature_id,

View File

@@ -6,29 +6,12 @@ export const temp: TestGroup = {
tier: "domain", tier: "domain",
paths: [ paths: [
// Cancel immediately with default tests // Cancel immediately with default tests
"integration/billing/attach/checkout/autumn-checkout/autumn-checkout-basic.test.ts",
"integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice.test.ts", "integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-basic.test.ts",
"integration/billing/attach/scheduled-switch/scheduled-switch-consumable.test.ts", "integration/billing/attach/immediate-switch/paid-features/immediate-switch-prepaid-no-options-advanced.test.ts",
"integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts", "integration/billing/attach/free-trial/override/trial-override-basic.test.ts",
"integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts", "integration/billing/attach/free-trial/override/trial-override-merge.test.ts",
"integration/billing/legacy/attach/upgrade/legacy-upgrade-usage.test.ts", "integration/billing/attach/free-trial/trial-upgrade.test.ts",
"integration/billing/migrations/migrate-trials.test.ts", "integration/billing/attach/free-trial/trial-entity-upgrade.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts",
"integration/billing/legacy/attach/new/legacy-new-merged.test.ts",
"integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts",
"integration/billing/migrations/migrate-free.test.ts",
"integration/billing/migrations/migrate-states.test.ts",
"integration/billing/update-subscription/cancel/uncancel/uncancel-combined.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-prepaid.test.ts",
"integration/billing/attach/invoice/attach-invoice-draft-deferred.test.ts",
"integration/billing/legacy/attach/invoice/payment-failure/legacy-attach-payment-failed.test.ts",
"integration/billing/setup-payment/setup-payment-with-customize.test.ts",
"integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts",
"integration/billing/update-subscription/free-trial/update-paid-trials.test.ts",
"integration/billing/legacy/attach/checkout/legacy-checkout-basic.test.ts",
"integration/billing/migrations/migrate-paid.test.ts",
"integration/billing/multi-attach/checkout/multi-attach-checkout-basic.test.ts",
"integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-features.test.ts",
], ],
}; };

View File

@@ -309,6 +309,7 @@ test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses
customer, customer,
productId: proTrial.id, productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14), trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(2),
}); });
// Verify feature reset aligns with fresh trial (14 days from now) // Verify feature reset aligns with fresh trial (14 days from now)
@@ -319,6 +320,7 @@ test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses
balance: 500, balance: 500,
usage: 0, usage: 0,
resetsAt: advancedTo + ms.days(14), // Fresh trial, reset at trial end resetsAt: advancedTo + ms.days(14), // Fresh trial, reset at trial end
toleranceMs: ms.hours(2),
}); });
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)

View File

@@ -330,12 +330,14 @@ test.concurrent(`${chalk.yellowBright("trial-override-merge 3: add-on with free_
customer, customer,
productId: proTrial.id, productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14), trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
}); });
await expectProductTrialing({ await expectProductTrialing({
customer, customer,
productId: addon.id, productId: addon.id,
trialEndsAt: advancedTo + ms.days(14), trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
}); });
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions) // Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)

View File

@@ -454,6 +454,7 @@ test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to pre
customer, customer,
productId: premiumTrial.id, productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14), trialEndsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
}); });
// Verify feature balance is premium's balance with resetsAt aligned to new trial end // Verify feature balance is premium's balance with resetsAt aligned to new trial end
@@ -464,6 +465,7 @@ test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to pre
balance: 1000, balance: 1000,
usage: 0, usage: 0,
resetsAt: advancedTo + ms.days(14), resetsAt: advancedTo + ms.days(14),
toleranceMs: ms.hours(1) + ms.minutes(10),
}); });
// Verify NO paid invoice generated - both are $0 trial invoices // Verify NO paid invoice generated - both are $0 trial invoices

View File

@@ -0,0 +1,479 @@
/**
* Immediate Switch Prepaid No-Options Tests (Attach V2)
*
* Advanced prepaid no-options scenarios, including price changes, usage,
* multi-feature behavior, and explicit overrides.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } 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.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 1: price increases")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-price-inc";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 15,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(40);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 2: price decreases")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-price-dec";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 15,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(20);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 3: with usage")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-with-usage";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
await new Promise((r) => setTimeout(r, 4000));
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await new Promise((r) => setTimeout(r, 2000));
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 150,
usage: 50,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(30);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 4: multiple prepaid partial options")}`, async () => {
const customerId = "imm-switch-prepaid-partial-opts";
const proMessagesPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const proWordsPrepaid = items.prepaid({
featureId: TestFeature.Words,
includedUsage: 0,
billingUnits: 100,
price: 5,
});
const pro = products.pro({
id: "pro",
items: [proMessagesPrepaid, proWordsPrepaid],
});
const premiumMessagesPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premiumWordsPrepaid = items.prepaid({
featureId: TestFeature.Words,
includedUsage: 0,
billingUnits: 100,
price: 5,
});
const premium = products.premium({
id: "premium",
items: [premiumMessagesPrepaid, premiumWordsPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 200 },
{ feature_id: TestFeature.Words, quantity: 500 },
],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
});
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Words,
balance: 500,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
});
expect(preview.total).toBe(40);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 300,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 500,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 5: all config changes")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-all-change";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 50,
billingUnits: 50,
price: 15,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(55);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options advanced 6: options explicitly set to 0")}`, async () => {
const customerId = "imm-switch-prepaid-opts-zero";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
});
expect(preview.total).toBe(10);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 0,
usage: 0,
});
});

View File

@@ -0,0 +1,384 @@
/**
* Immediate Switch Prepaid No-Options Tests (Attach V2)
*
* Basic carry-over scenarios for upgrades involving prepaid features where
* options are not passed on upgrade.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } 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.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 1: quantity carries over")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-carry";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(30);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 30,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 2: billing units 100 to 50")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-units-100-50";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 50,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(50);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 3: billing units 50 to 100")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-units-50-100";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 50,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(10);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 4: included usage increases")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-incl-inc";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(20);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("immediate-switch-prepaid-no-options basic 5: included usage decreases")}`, async () => {
const customerId = "imm-switch-prepaid-no-opts-incl-dec";
const proPrepaid = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(40);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});

View File

@@ -26,15 +26,23 @@ export const confirmAutumnCheckout = async ({
checkoutId, checkoutId,
customerId, customerId,
productId, productId,
featureQuantities,
}: { }: {
checkoutId: string; checkoutId: string;
customerId: string; customerId: string;
productId: string; productId: string;
featureQuantities?: Array<{ feature_id: string; quantity: number }>;
}): Promise<ConfirmCheckoutResponse> => { }): Promise<ConfirmCheckoutResponse> => {
const response = await fetch( const response = await fetch(
`${CHECKOUT_BASE_URL}/checkouts/${checkoutId}/confirm`, `${CHECKOUT_BASE_URL}/checkouts/${checkoutId}/confirm`,
{ {
method: "POST", method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(
featureQuantities ? { feature_quantities: featureQuantities } : {},
),
signal: AbortSignal.timeout(CHECKOUT_TIMEOUT_MS), signal: AbortSignal.timeout(CHECKOUT_TIMEOUT_MS),
}, },
); );
@@ -56,6 +64,7 @@ export const confirmAutumnCheckoutAndGetCustomer = async ({
checkoutId, checkoutId,
customerId, customerId,
productId, productId,
featureQuantities,
}: { }: {
autumnV1: { autumnV1: {
customers: { customers: {
@@ -65,11 +74,13 @@ export const confirmAutumnCheckoutAndGetCustomer = async ({
checkoutId: string; checkoutId: string;
customerId: string; customerId: string;
productId: string; productId: string;
featureQuantities?: Array<{ feature_id: string; quantity: number }>;
}) => { }) => {
const confirmData = await confirmAutumnCheckout({ const confirmData = await confirmAutumnCheckout({
checkoutId, checkoutId,
customerId, customerId,
productId, productId,
featureQuantities,
}); });
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId); const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);

View File

@@ -2,12 +2,14 @@ import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels"
import { z } from "zod/v4"; import { z } from "zod/v4";
export const ConfirmCheckoutParamsSchema = z.object({ export const ConfirmCheckoutParamsSchema = z.object({
feature_quantities: z.array( feature_quantities: z
FeatureOptionsSchema.pick({ .array(
feature_id: true, FeatureOptionsSchema.pick({
quantity: true, feature_id: true,
}), quantity: true,
), }),
)
.optional(),
}); });
export type ConfirmCheckoutParams = z.infer<typeof ConfirmCheckoutParamsSchema>; export type ConfirmCheckoutParams = z.infer<typeof ConfirmCheckoutParamsSchema>;