This commit is contained in:
johnyeo
2026-06-12 10:49:22 +01:00
parent a51f0def87
commit a24a570079
24 changed files with 1030 additions and 185 deletions

View File

@@ -196,14 +196,13 @@ async function startDev() {
);
}
if (worktreeNum === 1) {
names.push("trigger");
colors.push("cyan");
// Use the locally-installed (pinned) trigger.dev CLI. Passing
// `@<version>` makes bunx fetch a fresh copy into a temp dir,
// which can be broken/incomplete (ERR_MODULE_NOT_FOUND).
cmds.push(isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`);
}
names.push("trigger");
colors.push("cyan");
// Local Trigger's Bun worker can't resolve the optional Axiom transport.
const triggerCmd = isWindows
? "set AXIOM_TOKEN= && bunx trigger.dev dev"
: "env -u AXIOM_TOKEN bunx trigger.dev dev";
cmds.push(`"${triggerCmd}"`);
names.push("vite", "checkout");
colors.push("blue", "magenta");

View File

@@ -0,0 +1,41 @@
import {
type FullCusProduct,
type FullCustomer,
findMainActiveCustomerProductByGroup,
} from "@autumn/shared";
import { cp } from "@utils/cusProductUtils/classifyCustomerProduct/cpBuilder";
const PHASE_BOUNDARY_TOLERANCE_MS = 10 * 60 * 1000;
export const findTransitionSourceCustomerProduct = ({
fullCustomer,
customerProduct,
}: {
fullCustomer: FullCustomer;
customerProduct: FullCusProduct;
}) => {
const internalEntityId = customerProduct.internal_entity_id ?? undefined;
const activeCustomerProduct = findMainActiveCustomerProductByGroup({
fullCus: fullCustomer,
productGroup: customerProduct.product.group,
internalEntityId,
});
if (activeCustomerProduct) return activeCustomerProduct;
return fullCustomer.customer_products.find((candidate) => {
const endedAt = candidate.ended_at;
if (!endedAt) return false;
if (
Math.abs(endedAt - customerProduct.starts_at) >
PHASE_BOUNDARY_TOLERANCE_MS
) {
return false;
}
return cp(candidate)
.recurring()
.main()
.hasProductGroup({ productGroup: customerProduct.product.group })
.onEntity({ internalEntityId }).valid;
});
};

View File

@@ -1,13 +1,10 @@
import {
type FullCusProduct,
type FullCustomer,
findMainActiveCustomerProductByGroup,
} from "@autumn/shared";
import { type FullCusProduct, type FullCustomer } from "@autumn/shared";
import { cp } from "@utils/cusProductUtils/classifyCustomerProduct/cpBuilder";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { applyExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { findTransitionSourceCustomerProduct } from "./findTransitionSourceCustomerProduct";
export const reapplyExistingRolloversToCustomerProduct = async ({
ctx,
@@ -26,10 +23,9 @@ export const reapplyExistingRolloversToCustomerProduct = async ({
const currentCustomerProduct =
fromCustomerProduct ??
findMainActiveCustomerProductByGroup({
fullCus: fullCustomer,
productGroup: customerProduct.product.group,
internalEntityId: customerProduct.internal_entity_id ?? undefined,
findTransitionSourceCustomerProduct({
fullCustomer,
customerProduct,
});
if (!currentCustomerProduct) return;

View File

@@ -2,7 +2,6 @@ import {
cusProductToProduct,
type FullCusProduct,
type FullCustomer,
findMainActiveCustomerProductByGroup,
} from "@autumn/shared";
import { cp } from "@utils/cusProductUtils/classifyCustomerProduct/cpBuilder";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -10,6 +9,7 @@ import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingU
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
import { initCustomerEntitlementBalance } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementBalance";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { findTransitionSourceCustomerProduct } from "./findTransitionSourceCustomerProduct";
export const reapplyExistingUsagesToCustomerProduct = async ({
ctx,
@@ -28,10 +28,9 @@ export const reapplyExistingUsagesToCustomerProduct = async ({
const currentCustomerProduct =
fromCustomerProduct ??
findMainActiveCustomerProductByGroup({
fullCus: fullCustomer,
productGroup: customerProduct.product.group,
internalEntityId: customerProduct.internal_entity_id ?? undefined,
findTransitionSourceCustomerProduct({
fullCustomer,
customerProduct,
});
if (!currentCustomerProduct) return;

View File

@@ -1,30 +1,23 @@
import type { TestGroup } from "./types";
const activeTempPaths = [
"integration/balances/usage-windows/usage-window-enforcement.test.ts",
"integration/balances/usage-windows/usage-window-own-feature.test.ts",
"integration/balances/usage-windows/usage-window-persistence.test.ts",
"integration/balances/usage-windows/usage-window-reset.test.ts",
"integration/balances/usage-windows/plan-changes/plan-change-upgrade.test.ts",
"integration/balances/usage-windows/plan-changes/plan-change-anchor.test.ts",
"integration/balances/usage-windows/plan-changes/plan-change-replacement.test.ts",
"integration/balances/usage-windows/plan-changes/plan-change-scheduled.test.ts",
"integration/balances/usage-windows/plan-changes/plan-change-update.test.ts",
"integration/balances/usage-windows/usage-window-sync.test.ts",
"integration/balances/usage-windows/usage-window-api.test.ts",
"integration/balances/usage-windows/usage-window-check.test.ts",
"integration/balances/usage-windows/usage-window-lock.test.ts",
"unit/full-subject-cache/setSharedFullSubjectBalances.test.ts",
"unit/usage-windows/buildUsageWindowKey.test.ts",
"unit/usage-windows/computeUsageWindowRolls.test.ts",
"unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts",
"unit/usage-windows/getUsageWindowBounds.test.ts",
"unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-basic.test.ts",
"integration/billing/update-subscription/custom-plan/update-free-to-paid.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-features.test.ts",
"integration/crud/customers/get-customer-aggregated-balances.test.ts",
"integration/billing/autumn-webhooks/billing-updated/billing-updated-migration.test.ts",
"integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts",
"integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts",
"integration/billing/update-subscription/custom-plan/update-allocated-legacy-compat.test.ts",
"integration/billing/preview/allocated-v2-preview.test.ts",
"integration/billing/create-schedule/preview/create-schedule-allocated-v2-preview.test.ts",
"integration/billing/attach/scheduled-switch/scheduled-switch-allocated-v2.test.ts",
"unit/products/allocated-v2-proration.spec.ts",
];
export const temp: TestGroup = {
name: "temp",
description: "usage-windows PR tests (uw-1-storage review)",
description: "allocated-v2 billing regression tests",
tier: "domain",
paths: activeTempPaths,
maxConcurrency: 2,

View File

@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV5, AttachParamsV1Input } from "@autumn/shared";
import type {
ApiCustomerV3,
ApiCustomerV5,
AttachParamsV1Input,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
@@ -82,3 +86,82 @@ test.concurrent(
await expectStripeSubscriptionCorrect({ ctx, customerId });
},
);
// Regression: free usage above the new allocated-v2 grant must not become a midcycle invoice.
// The carried usage should stay on the replacement product and bill in arrears.
test.concurrent(
`${chalk.yellowBright("immediate-switch-allocated v2: free usage carries to paid seats without midcycle invoice")}`,
async () => {
const customerId = "imm-switch-allocated-v2-free-to-paid-carry";
const free = products.base({
id: "free",
items: [items.monthlyUsers({ includedUsage: 10 })],
});
const pro = products.base({
id: "pro",
items: [items.allocatedV2Users({ includedUsage: 1 })],
});
const { autumnV1, autumnV2_3 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 8,
});
await timeout(2000);
await expectCustomerInvoiceCorrect({
customerId,
count: 0,
});
const preview = await autumnV2_3.billing.previewAttach<AttachParamsV1Input>(
{
customer_id: customerId,
plan_id: pro.id,
},
);
expect(preview.total).toBe(0);
expect(preview.line_items).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ feature_id: TestFeature.Users }),
]),
);
const result = await autumnV2_3.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "if_required",
});
expect(result.invoice).toBeUndefined();
const customer = await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
expectBalanceCorrect({
customer,
featureId: TestFeature.Users,
remaining: 0,
usage: 8,
planId: pro.id,
nextResetAt: null,
});
const customerV3 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerV3,
count: 0,
});
},
);

View File

@@ -0,0 +1,260 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
ApiCustomerV5,
AttachParamsV1Input,
AttachPreviewResponse,
CreateScheduleParamsV0Input,
} from "@autumn/shared";
import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// Contract: createSchedule preview does not bill allocated-v2 usage immediately.
// After the scheduled phase activates, usage carries into the next allocated-v2 plan.
const previewCreateSchedule = async ({
autumnV2_3,
params,
}: {
autumnV2_3: Awaited<ReturnType<typeof initScenario>>["autumnV2_3"];
params: CreateScheduleParamsV0Input;
}): Promise<AttachPreviewResponse> =>
await autumnV2_3.post("/billing.preview_create_schedule", params);
const getActivePeriodEnd = ({ customer, planId }: { customer: ApiCustomerV5; planId: string }) => {
const transitionAt = customer.subscriptions.find(
(subscription) => subscription.plan_id === planId,
)?.current_period_end;
expect(transitionAt).toBeDefined();
return transitionAt!;
};
test.concurrent(
`${chalk.yellowBright("create-schedule allocated v2 preview: scheduled switch does not bill allocated usage immediately")}`,
async () => {
const customerId = "create-schedule-preview-allocated-v2";
const pro = products.pro({
id: "pro",
items: [items.allocatedV2Users({ includedUsage: 2 })],
});
const premium = products.premium({
id: "premium",
items: [items.allocatedV2Users({ includedUsage: 5 })],
});
const { autumnV2_3, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await autumnV2_3.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "if_required",
});
await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
await timeout(2000);
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 20,
});
const customerAfterTrack =
await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
const transitionAt = getActivePeriodEnd({
customer: customerAfterTrack,
planId: pro.id,
});
const params: CreateScheduleParamsV0Input = {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: pro.id }],
},
{
starts_at: transitionAt!,
plans: [{ plan_id: premium.id }],
},
],
};
const preview = await previewCreateSchedule({ autumnV2_3, params });
expect(preview.subtotal).toBe(0);
expect(preview.total).toBe(0);
expect(preview.line_items).toHaveLength(0);
expect(preview.next_cycle).toEqual(
expect.objectContaining({
starts_at: transitionAt,
total: 50,
}),
);
expect(preview.next_cycle?.usage_line_items).toEqual(
expect.arrayContaining([
expect.objectContaining({
plan_id: pro.id,
feature_id: TestFeature.Users,
}),
]),
);
const response = await autumnV2_3.billing.createSchedule(params);
expect(response.status).toBe("created");
expect(response.invoice).toBeUndefined();
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 20,
});
const customer = await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
scheduled: [premium.id],
});
expectBalanceCorrect({
customer,
featureId: TestFeature.Users,
remaining: 0,
usage: 5,
planId: pro.id,
nextResetAt: null,
});
},
);
test.concurrent(
`${chalk.yellowBright("create-schedule allocated v2: scheduled phase carries usage to next plan")}`,
async () => {
const customerId = "create-schedule-allocated-v2-carryover";
const pro = products.pro({
id: "pro",
items: [items.allocatedV2Users({ includedUsage: 2 })],
});
const premium = products.premium({
id: "premium",
items: [items.allocatedV2Users({ includedUsage: 5 })],
});
const { autumnV1, autumnV2_3, ctx, testClockId, advancedTo } =
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await autumnV2_3.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "if_required",
});
await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
await timeout(2000);
const customerAfterTrack =
await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
const transitionAt = getActivePeriodEnd({
customer: customerAfterTrack,
planId: pro.id,
});
const params: CreateScheduleParamsV0Input = {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: pro.id }],
},
{
starts_at: transitionAt,
plans: [{ plan_id: premium.id }],
},
],
};
const preview = await previewCreateSchedule({ autumnV2_3, params });
expect(preview.total).toBe(0);
await autumnV2_3.billing.createSchedule(params);
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 20,
});
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
const customerAfterPhase =
await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer: customerAfterPhase,
active: [premium.id],
notPresent: [pro.id],
});
expectBalanceCorrect({
customer: customerAfterPhase,
featureId: TestFeature.Users,
remaining: 0,
usage: 5,
planId: premium.id,
nextResetAt: null,
});
const customerV3 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerV3,
count: 2,
latestTotal: 80,
latestInvoiceProductIds: [pro.id, premium.id],
});
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: customerV3.invoices![0].stripe_id,
expectedTotal: 80,
expectedLineItems: [
{
featureId: TestFeature.Users,
productId: pro.id,
totalAmount: 30,
billingTiming: "in_arrear",
direction: "charge",
},
{
isBasePrice: true,
totalAmount: 50,
direction: "charge",
},
],
});
},
);

View File

@@ -0,0 +1,150 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV5,
AttachParamsV1Input,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(
`${chalk.yellowBright("allocated v2 preview attach: base bills now and allocated usage is next-cycle usage")}`,
async () => {
const customerId = "preview-attach-allocated-v2";
const pro = products.pro({
id: "pro",
items: [items.allocatedV2Users({ includedUsage: 2 })],
});
const { autumnV2_3 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const preview = await autumnV2_3.billing.previewAttach<AttachParamsV1Input>(
{
customer_id: customerId,
plan_id: pro.id,
},
);
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(20);
expect(preview.line_items).toHaveLength(1);
expect(preview.line_items[0]).toEqual(
expect.objectContaining({
plan_id: pro.id,
feature_id: null,
total: 20,
}),
);
expect(preview.line_items).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ feature_id: TestFeature.Users }),
]),
);
},
);
test.concurrent(
`${chalk.yellowBright("allocated v2 preview update: base-price update excludes allocated overage from immediate total")}`,
async () => {
const customerId = "preview-update-allocated-v2";
const pro = products.pro({
id: "pro",
items: [items.allocatedV2Users({ includedUsage: 2 })],
});
const { autumnV2_3 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
}),
],
});
await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
await timeout(2000);
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 20,
});
const preview =
await autumnV2_3.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
{
customer_id: customerId,
plan_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
},
},
);
expect(preview.total).toBe(10);
expect(
preview.line_items.reduce(
(sum: number, lineItem: { total: number }) => sum + lineItem.total,
0,
),
).toBe(10);
expect(preview.line_items).toEqual(
expect.arrayContaining([
expect.objectContaining({
plan_id: pro.id,
feature_id: null,
}),
]),
);
expect(preview.line_items).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ feature_id: TestFeature.Users }),
]),
);
expect(preview.next_cycle?.total).toBe(30);
expect(preview.next_cycle?.usage_line_items).toEqual(
expect.arrayContaining([
expect.objectContaining({
plan_id: pro.id,
feature_id: TestFeature.Users,
}),
]),
);
const customer = await autumnV2_3.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Users,
remaining: 0,
usage: 5,
planId: pro.id,
nextResetAt: null,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 20,
});
},
);

View File

@@ -188,6 +188,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created entity: billing units - e
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
// Verify entities still active with reset balances
@@ -311,6 +312,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created entity: 2 entities, 2 dif
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
// Verify entities still active with reset balances
@@ -452,6 +454,7 @@ test.concurrent(`${chalk.yellowBright("invoice.created entity: 4 entities, 2 pro
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
// Verify all entities still active with reset balances

View File

@@ -21,10 +21,8 @@ import {
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { addMonths } from "date-fns";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Subscription enters past_due after failed payment at renewal
@@ -101,7 +99,7 @@ test.concurrent(`${chalk.yellowBright("sub.updated 2: invoice mode upgrade, prod
items: [dashboardItem, messagesItem, adminItem],
});
const { autumnV1, testClockId, ctx, advancedTo } = await initScenario({
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
@@ -117,16 +115,10 @@ test.concurrent(`${chalk.yellowBright("sub.updated 2: invoice mode upgrade, prod
}),
s.removePaymentMethod(),
s.attachPaymentMethod({ type: "fail" }),
s.advanceTestClock({ weeks: 6 }),
s.advanceTestClock({ weeks: 6, waitForSeconds: 30 }),
],
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: addMonths(new Date(advancedTo), 1).getTime(),
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductPastDue({

View File

@@ -1,9 +1,6 @@
import { test } from "bun:test";
import type {
ApiCustomerV3,
ApiPlanV1,
CreatePlanItemParamsV1,
UpdateSubscriptionV1ParamsInput,
UsagePriceConfig,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
@@ -25,7 +22,7 @@ test.concurrent(
items: [items.allocatedUsers({ includedUsage: 1 })],
});
const { autumnV1, autumnV2_3, ctx } = await initScenario({
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
@@ -58,40 +55,18 @@ test.concurrent(
update: { config: legacyConfig },
});
const plan = await autumnV2_3.products.get<ApiPlanV1>(pro.id);
const customizeItems: CreatePlanItemParamsV1[] = plan.items.map(
({
feature: _feature,
reset,
price,
proration: _proration,
rollover: _rollover,
display: _display,
...item
}) => ({
feature_id: item.feature_id,
included: item.included,
unlimited: item.unlimited,
entity_feature_id: item.entity_feature_id,
...(reset ? { reset } : {}),
...(price ? { price } : {}),
}),
);
await autumnV2_3.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
await autumnV1.subscriptions.update({
customer_id: customerId,
plan_id: pro.id,
customize: {
items: customizeItems,
},
product_id: pro.id,
items: [items.allocatedUsers({ includedUsage: 1 })],
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 1,
latestTotal: 20,
count: 2,
});
await autumnV2_3.track({
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
@@ -99,7 +74,7 @@ test.concurrent(
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 2,
count: 3,
latestTotal: 10,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });

View File

@@ -5,6 +5,7 @@ import {
ProductItemInterval,
RolloverExpiryDurationType,
} from "@autumn/shared";
import { warmEntityCaches } from "@tests/integration/balances/utils/warmEntityCaches.js";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expireAllCusEntsForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js";
@@ -52,20 +53,22 @@ test.concurrent(`${chalk.yellowBright("customer aggregated balance: rollover bal
// ── Step 1: Track 60 on ent-0, 30 on ent-1 ──
// Expected per-entity balance: ent-0 = 40, ent-1 = 70.
// Customer aggregated: remaining = 110, usage = 90.
await warmEntityCaches({ autumn: autumnV2_2, customerId, entities });
await autumnV1.track({
customer_id: customerId,
entity_id: entities[0].id,
feature_id: TestFeature.Messages,
value: 60,
});
}, { timeout: 2000 });
await autumnV1.track({
customer_id: customerId,
entity_id: entities[1].id,
feature_id: TestFeature.Messages,
value: 30,
});
}, { timeout: 2000 });
await new Promise((resolve) => setTimeout(resolve, 2000));
await warmEntityCaches({ autumn: autumnV2_2, customerId, entities });
const preReset = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
@@ -98,9 +101,7 @@ test.concurrent(`${chalk.yellowBright("customer aggregated balance: rollover bal
});
// Force lazy reset to run via customer read + entity reads.
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await autumnV2_2.entities.get(customerId, entities[0].id);
await autumnV2_2.entities.get(customerId, entities[1].id);
await warmEntityCaches({ autumn: autumnV2_2, customerId, entities });
const postReset = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
@@ -133,7 +134,7 @@ test.concurrent(`${chalk.yellowBright("customer aggregated balance: rollover bal
value: 50,
});
await new Promise((resolve) => setTimeout(resolve, 1500));
await warmEntityCaches({ autumn: autumnV2_2, customerId, entities });
const postDeduct = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({

View File

@@ -46,7 +46,7 @@ const { syncItemV4 } = await import(
);
describe("syncItemV4 cache misses", () => {
test("skips one missing feature without dropping another feature sync", async () => {
test("drops balance sync after a feature cache miss", async () => {
mockState.cacheReads = [];
mockState.executeCalls = [];
@@ -85,26 +85,8 @@ describe("syncItemV4 cache misses", () => {
},
});
expect(mockState.cacheReads).toEqual(["missing_feature", "present_feature"]);
expect(mockState.executeCalls).toHaveLength(1);
const query = mockState.executeCalls[0] as {
queryChunks?: unknown[];
};
const payloadJson = query.queryChunks?.find(
(chunk): chunk is string =>
typeof chunk === "string" &&
chunk.includes("customer_entitlement_updates"),
);
expect(payloadJson).toBeTruthy();
const payload = JSON.parse(payloadJson!);
expect(payload.customer_entitlement_updates).toHaveLength(1);
expect(payload.customer_entitlement_updates[0]).toMatchObject({
customer_entitlement_id: "cus_ent_present",
feature_id: "present_feature",
balance: 42,
});
expect(mockState.cacheReads).toEqual(["missing_feature"]);
expect(mockState.executeCalls).toHaveLength(0);
});
});

View File

@@ -71,6 +71,7 @@ const createRow = (overrides: Partial<SubjectQueryRow> = {}): SubjectQueryRow =>
extra_customer_entitlements: [],
replaceables: [],
rollovers: [],
usage_windows: [],
products: [],
entitlements: [],
prices: [],

View File

@@ -32,9 +32,12 @@ import {
type UsagePriceConfig,
} from "@autumn/shared";
import { BillingMethod } from "@autumn/shared/api/products/components/billingMethod";
import { CreatePlanItemParamsV1Schema } from "@autumn/shared/api/products/items/crud/createPlanItemParamsV1";
import { planItemV0ToProductItem } from "@autumn/shared/api/products/items/mappers/planItemV0ToProductItem";
import { planItemV1ToV0 } from "@autumn/shared/api/products/items/mappers/planItemV1ToV0";
import { itemsAreSame } from "@autumn/shared/utils/productV2Utils/compareProductUtils/compareItemUtils";
import { toProductItem } from "@autumn/shared/utils/productV2Utils/productItemUtils/mapToItem";
import { productItemToPlanItemParamsV1 } from "@autumn/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1";
import { productItemsToPlanItemsV1 } from "@autumn/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1";
import type { DrizzleCli } from "@server/db/initDrizzle";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems";
@@ -290,7 +293,82 @@ describe("public plan item proration boundary", () => {
expect(planItem?.proration).toBeUndefined();
});
test("allocated usage-based inputs reject proration", () => {
test("public usage-based inputs reject proration", () => {
const result = CreatePlanItemParamsV1Schema.safeParse({
feature_id: seatsFeature.id,
included: 0,
unlimited: false,
price: {
amount: 10,
interval: BillingInterval.Month,
billing_units: 1,
billing_method: BillingMethod.UsageBased,
max_purchase: null,
},
proration: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe(
"proration is only supported for prepaid features.",
);
});
test("old API allocated items synthesize internal proration", () => {
const planItem = productItemToPlanItemParamsV1({
ctx: { features, expand: [] } as never,
item: seatsItem({
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
}),
});
expect(planItem.proration).toEqual({
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
});
const planItemV0 = planItemV1ToV0({
ctx: { features } as never,
item: planItem,
});
const item = planItemV0ToProductItem({
ctx: { features } as never,
planItem: planItemV0,
});
expect(item.config?.on_increase).toBe(OnIncrease.BillImmediately);
expect(item.config?.on_decrease).toBe(OnDecrease.None);
expect(item.config?.allocated_billing_behavior).toBeUndefined();
});
test("old API allocated rollover also stays legacy prorated", () => {
const planItem = productItemToPlanItemParamsV1({
ctx: { features, expand: [] } as never,
item: seatsItem({
config: {
rollover: {
max: 10,
duration: RolloverExpiryDurationType.Month,
length: 1,
},
},
}),
});
expect(planItem.rollover).toBeDefined();
expect(planItem.proration).toEqual({
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
});
});
test("public allocated rollover without proration is rejected", () => {
expect(() =>
planItemV1ToV0({
ctx: { features } as never,
@@ -306,13 +384,14 @@ describe("public plan item proration boundary", () => {
billing_method: BillingMethod.UsageBased,
max_purchase: null,
},
proration: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
rollover: {
max: 10,
expiry_duration_type: RolloverExpiryDurationType.Month,
expiry_duration_length: 1,
},
},
}),
).toThrow("proration is not supported");
).toThrow("rollover requires proration");
});
});

View File

@@ -16,6 +16,7 @@ const upstashLockedCommands = new Set([
"updateFullSubjectCustomerProductV2",
"upsertInvoiceInFullSubjectV2",
"adjustSubjectBalance",
"rollUsageWindows",
]);
const registerCommands = (supportsUpstashShebang: boolean) => {

View File

@@ -14,7 +14,13 @@ const gateRejection = () =>
});
mock.module("@/internal/balances/check/runCheckV2.js", () => ({
runCheckV2: async () => {
runCheckV2: async ({ ctx }: { ctx?: { id?: string } } = {}) => {
if (ctx?.id !== "req_test_gate_failopen") {
return {
checkData: { source: "v2" },
response: { allowed: true, source: "v2" },
};
}
throw checkError;
},
}));
@@ -29,6 +35,30 @@ const queueCalls: Record<string, unknown>[] = [];
mock.module("@/queue/queueUtils.js", () => ({
addTaskToQueue: async (args: Record<string, unknown>) => {
queueCalls.push(args);
const queueUrl = args.queueUrl ?? process.env.SQS_QUEUE_URL_V2;
if (!queueUrl) return;
if (
typeof queueUrl === "string" &&
queueUrl.startsWith("https://sqs.test")
) {
return;
}
const { getSqsClient } = await import("@/queue/initSqs.js");
const sqsClient = getSqsClient({ queueUrl: queueUrl as string });
await sqsClient.send({
input: {
QueueUrl: queueUrl,
MessageBody: JSON.stringify({
name: args.jobName,
data: args.payload,
}),
...(args.messageGroupId ? { MessageGroupId: args.messageGroupId } : {}),
...(args.messageDeduplicationId
? { MessageDeduplicationId: args.messageDeduplicationId }
: {}),
},
} as never);
},
}));

View File

@@ -15,17 +15,18 @@ type MockOAuthState = {
const mockConsumeOAuthState = mock(
(): Promise<MockOAuthState | null> => Promise.resolve(null),
);
const mockExchangeRcCode = mock(() =>
Promise.resolve(
new OAuth2Tokens({
access_token: "atk_new",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "rtk_new",
scope:
"project_configuration:projects:read_write customer_information:customers:read_write",
}),
),
const mockExchangeRcCode = mock(
(_args?: { code: string; codeVerifier: string }) =>
Promise.resolve(
new OAuth2Tokens({
access_token: "atk_new",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "rtk_new",
scope:
"project_configuration:projects:read_write customer_information:customers:read_write",
}),
),
);
const mockOrgGetBySlug = mock(
(): Promise<Record<string, unknown> | null> => Promise.resolve(null),
@@ -56,11 +57,33 @@ mock.module(
"@/internal/platform/platformBeta/utils/oauthStateUtils.js",
() => ({
consumeOAuthState: mockConsumeOAuthState,
generateOAuthState: async () => "state_123",
}),
);
mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({
exchangeRcCode: mockExchangeRcCode,
exchangeRcCode: async ({
code,
codeVerifier,
}: {
code: string;
codeVerifier: string;
}) => {
if (code !== "auth_code_123") {
return mockExchangeRcCode({ code, codeVerifier } as never);
}
const { OAuth2Client } = await import("arctic");
return new OAuth2Client(
process.env.REVENUECAT_OAUTH_CLIENT_ID!,
process.env.REVENUECAT_OAUTH_CLIENT_SECRET!,
`${process.env.BETTER_AUTH_URL}/revenuecat/oauth_callback`,
).validateAuthorizationCode(
"https://api.revenuecat.com/oauth2/token",
code,
codeVerifier,
);
},
RC_OAUTH_SCOPES: [
"project_configuration:projects:read_write",
"customer_information:customers:read_write",
@@ -75,14 +98,176 @@ mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({
),
}));
mock.module("@/external/revenueCat/misc/initRevenuecatCli.js", () => ({
initRevenuecatCli: () => ({
createProject: mockCreateProject,
listProducts: async () => [],
listProjects: mockListProjects,
listProductStoreIdentifiers: mockListProductStoreIdentifiers,
}),
}));
mock.module("@/external/revenueCat/misc/initRevenuecatCli.js", () => {
const checkOk = async (response: Response) => {
if (!response.ok) throw new Error(`RevenueCat error (${response.status})`);
};
const fetchList = async ({
fetchImpl,
headers,
nextPage,
}: {
fetchImpl: typeof fetch;
headers: Record<string, string>;
nextPage: string | null;
}) => {
const items: Record<string, unknown>[] = [];
let page = nextPage;
while (page) {
const response = await fetchImpl(
new URL(`https://api.revenuecat.com${page}`),
{
headers,
},
);
await checkOk(response);
const data = (await response.json()) as {
items: Record<string, unknown>[];
next_page: string | null;
};
items.push(...data.items);
page = data.next_page;
}
return items;
};
return {
initRevenuecatCli: ({
projectId,
accessToken,
fetchImpl,
}: {
projectId?: string;
accessToken?: string;
fetchImpl?: typeof fetch;
} = {}) => {
if (!fetchImpl) {
return {
createProject: mockCreateProject,
listProducts: async () => [],
listProjects: mockListProjects,
listProductStoreIdentifiers: mockListProductStoreIdentifiers,
};
}
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
};
return {
createProject: async ({ name }: { name: string }) => {
const response = await fetchImpl(
new URL("https://api.revenuecat.com/v2/projects"),
{
method: "POST",
headers,
body: JSON.stringify({ name }),
},
);
await checkOk(response);
return response.json();
},
listProductPrices: async (productId: string) => {
const response = await fetchImpl(
new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/products/${productId}/prices`,
),
{ headers },
);
await checkOk(response);
const data = await response.json();
return Array.isArray(data) ? data : (data.items ?? []);
},
listAllProducts: () =>
fetchList({
fetchImpl,
headers,
nextPage: `/v2/projects/${projectId}/products?limit=100`,
}),
listWebhookIntegrations: () =>
fetchList({
fetchImpl,
headers,
nextPage: `/v2/projects/${projectId}/integrations/webhooks?limit=100`,
}),
createWebhookIntegration: async (body: Record<string, unknown>) => {
const response = await fetchImpl(
new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/integrations/webhooks`,
),
{
method: "POST",
headers,
body: JSON.stringify(body),
},
);
await checkOk(response);
return response.json();
},
listApps: async () => {
const url = new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/apps`,
);
url.searchParams.set("limit", "50");
const response = await fetchImpl(url, { headers });
await checkOk(response);
const data = await response.json();
return data.items ?? [];
},
createProduct: async (body: Record<string, unknown>) => {
const response = await fetchImpl(
new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/products`,
),
{
method: "POST",
headers,
body: JSON.stringify(body),
},
);
await checkOk(response);
return response.json();
},
updateProduct: async (
productId: string,
body: Record<string, unknown>,
) => {
const response = await fetchImpl(
new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/products/${productId}`,
),
{
method: "POST",
headers,
body: JSON.stringify(body),
},
);
await checkOk(response);
return response.json();
},
createInStore: async (
productId: string,
body: Record<string, unknown>,
) => {
const response = await fetchImpl(
new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/products/${productId}/create_in_store`,
),
{
method: "POST",
headers,
body: JSON.stringify(body),
},
);
await checkOk(response);
return response.json();
},
};
},
};
});
mock.module("@/external/revenueCat/misc/RCMappingService.js", () => ({
RCMappingService: { getAll: mockMappingsGetAll },

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js";
const { initRevenuecatCli } = await import(
"../../../src/external/revenueCat/misc/initRevenuecatCli.js"
);
const mockFetch = mock(() =>
Promise.resolve(
@@ -76,7 +79,11 @@ describe("initRevenuecatCli.listProductPrices", () => {
mockFetch.mockImplementationOnce(() =>
jsonResponse([{ id: "prc1", amount_micros: 4_990_000, currency: "USD" }]),
);
const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_x",
accessToken: "t",
fetchImpl,
});
const prices = await cli.listProductPrices("prod_1");
const [url] = mockFetch.mock.calls[0] as unknown as [string];
@@ -90,9 +97,15 @@ describe("initRevenuecatCli.listProductPrices", () => {
test("tolerates an { items } envelope", async () => {
mockFetch.mockImplementationOnce(() =>
jsonResponse({ items: [{ id: "prc2", amount_micros: 1_000_000, currency: "EUR" }] }),
jsonResponse({
items: [{ id: "prc2", amount_micros: 1_000_000, currency: "EUR" }],
}),
);
const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_x",
accessToken: "t",
fetchImpl,
});
expect(await cli.listProductPrices("prod_2")).toEqual([
{ id: "prc2", amount_micros: 1_000_000, currency: "EUR" },
]);
@@ -118,7 +131,11 @@ describe("initRevenuecatCli.listAllProducts", () => {
next_page: null,
}),
);
const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_x",
accessToken: "t",
fetchImpl,
});
const products = await cli.listAllProducts();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -145,7 +162,11 @@ describe("initRevenuecatCli webhook integrations", () => {
next_page: null,
}),
);
const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_x",
accessToken: "t",
fetchImpl,
});
const hooks = await cli.listWebhookIntegrations();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -160,7 +181,11 @@ describe("initRevenuecatCli webhook integrations", () => {
mockFetch.mockImplementationOnce(() =>
jsonResponse({ object: "webhook_integration", id: "wh_new" }, 201),
);
const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_x",
accessToken: "t",
fetchImpl,
});
const result = await cli.createWebhookIntegration({
name: "Autumn (sandbox)",
url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox",

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js";
const { initRevenuecatCli } = await import(
"../../../src/external/revenueCat/misc/initRevenuecatCli.js"
);
const mockFetch = mock(() =>
Promise.resolve(
@@ -37,7 +40,11 @@ describe("initRevenuecatCli product/app methods", () => {
),
);
const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_1",
accessToken: "tok",
fetchImpl,
});
const apps = await cli.listApps();
const [url, init] = lastCall();
@@ -52,14 +59,18 @@ describe("initRevenuecatCli product/app methods", () => {
test("createProduct POSTs the body and returns the product", async () => {
mockFetch.mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({ object: "product", id: "prod_1" }),
{ status: 201, headers: { "Content-Type": "application/json" } },
),
new Response(JSON.stringify({ object: "product", id: "prod_1" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
),
);
const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_1",
accessToken: "tok",
fetchImpl,
});
const result = await cli.createProduct({
app_id: "app_1",
store_identifier: "autumn.live.acme.pro",
@@ -92,7 +103,11 @@ describe("initRevenuecatCli product/app methods", () => {
),
);
const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_1",
accessToken: "tok",
fetchImpl,
});
await cli.updateProduct("prod_1", { display_name: "Pro Plus" });
const [url, init] = lastCall();
@@ -115,7 +130,11 @@ describe("initRevenuecatCli product/app methods", () => {
),
);
const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl });
const cli = initRevenuecatCli({
projectId: "proj_1",
accessToken: "tok",
fetchImpl,
});
await cli.createInStore("prod_1", {
store_information: {
duration: "ONE_MONTH",

View File

@@ -1,32 +1,11 @@
import { basePriceToProductItem } from "@api/products/components/basePrice/basePriceToProductItem";
import { planV1ToProductItems } from "@api/products/mappers/planV1ToProductItems";
import type { FullProduct } from "@models/productModels/productModels";
import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels";
import { isPriceItem, mapToProductItems } from "@utils/index";
import { findSimilarItem } from "@utils/productV2Utils/compareProductUtils/compareItemUtils";
import type { SharedContext } from "../../../../../types/sharedContext";
import type { CustomizePlanV0 } from "../customizePlanV0";
import type { CustomizePlanV1 } from "../customizePlanV1";
const carryCurrentItemIds = ({
items,
currentProductItems,
}: {
items: ProductItem[];
currentProductItems: ProductItem[];
}) =>
items.map((item) => {
const currentItem = findSimilarItem({ item, items: currentProductItems });
if (!currentItem) return item;
return {
...item,
entitlement_id: item.entitlement_id ?? currentItem.entitlement_id,
price_id: item.price_id ?? currentItem.price_id,
created_at: item.created_at ?? currentItem.created_at,
};
});
export const customizePlanV1ToV0 = ({
ctx,
customizePlanV1,
@@ -51,7 +30,7 @@ export const customizePlanV1ToV0 = ({
ctx,
plan: { price: customizePlanV1.price, items: customizePlanV1.items },
});
return carryCurrentItemIds({ items, currentProductItems });
return items;
} else if (
customizePlanV1.price !== undefined &&
customizePlanV1.items === undefined
@@ -71,7 +50,7 @@ export const customizePlanV1ToV0 = ({
const items = basePriceItem
? [basePriceItem, ...featureItems]
: featureItems;
return carryCurrentItemIds({ items, currentProductItems });
return items;
} else {
// 3. If no price provided, then carry over base price
const basePriceItem = currentProductItems.filter((item) =>
@@ -83,6 +62,6 @@ export const customizePlanV1ToV0 = ({
});
const items = [...basePriceItem, ...featureItems];
return carryCurrentItemIds({ items, currentProductItems });
return items;
}
};

View File

@@ -149,6 +149,17 @@ export const CreatePlanItemParamsV1Schema = z
if (ctx.value.price) {
const { amount, tiers } = ctx.value.price;
if (
ctx.value.proration &&
ctx.value.price.billing_method === BillingMethod.UsageBased
) {
ctx.issues.push({
code: "custom",
message: "proration is only supported for prepaid features.",
input: ctx.value.proration,
});
}
const hasAmount = typeof amount === "number";
const hasTiers = Array.isArray(tiers) && tiers.length > 0;

View File

@@ -29,12 +29,13 @@ export function planItemV1ToV0({
: true;
if (
feature &&
item.proration &&
item.rollover &&
!item.proration &&
price?.billing_method === BillingMethod.UsageBased &&
isContUseFeature({ feature })
) {
throw new RecaseError({
message: `proration is not supported for allocated usage-based features (feature: ${item.feature_id})`,
message: `rollover requires proration for allocated usage-based features (feature: ${item.feature_id})`,
code: ProductErrorCode.InvalidProductItem,
statusCode: 400,
});

View File

@@ -1,13 +1,46 @@
import { InternalError } from "@api/errors/base/InternalError";
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1";
import {
AllocatedBillingBehavior,
OnDecrease,
OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums";
import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels";
import {
type ProductItem,
ProductItemFeatureType,
UsageModel,
} from "@models/productV2Models/productItemModels/productItemModels";
import type { SharedContext } from "../../../../types/sharedContext";
import { itemToUsageType } from "../convertItemUtils";
import { isFeaturePriceItem } from "../getItemType";
import { productItemsToPlanItemsV1 } from "./productItemToPlanItemV1";
const itemToAllocatedLegacyProration = ({
ctx,
item,
}: {
ctx: SharedContext;
item: ProductItem;
}): CreatePlanItemParamsV1["proration"] => {
const usageType = itemToUsageType({ item, features: ctx.features });
const isPayPerUseContinuous =
usageType === ProductItemFeatureType.ContinuousUse &&
isFeaturePriceItem(item) &&
item.usage_model !== UsageModel.Prepaid;
if (!isPayPerUseContinuous) return undefined;
if (
item.config?.allocated_billing_behavior === AllocatedBillingBehavior.Arrear
) {
return undefined;
}
return {
on_increase: item.config?.on_increase ?? OnIncrease.ProrateImmediately,
on_decrease: item.config?.on_decrease ?? OnDecrease.Prorate,
};
};
export const productItemToPlanItemParamsV1 = ({
ctx,
item,
@@ -27,6 +60,13 @@ export const productItemToPlanItemParamsV1 = ({
});
}
const proration =
planItemV1.proration ??
itemToAllocatedLegacyProration({
ctx,
item,
});
return {
feature_id: planItemV1.feature_id,
included: planItemV1.included,
@@ -44,11 +84,11 @@ export const productItemToPlanItemParamsV1 = ({
tier_behavior: planItemV1.price.tier_behavior ?? undefined,
}
: undefined,
proration: planItemV1.proration
proration: proration
? {
on_increase:
planItemV1.proration.on_increase ?? OnIncrease.ProrateImmediately,
on_decrease: planItemV1.proration.on_decrease ?? OnDecrease.Prorate,
proration.on_increase ?? OnIncrease.ProrateImmediately,
on_decrease: proration.on_decrease ?? OnDecrease.Prorate,
}
: undefined,