latest
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const createRow = (overrides: Partial<SubjectQueryRow> = {}): SubjectQueryRow =>
|
||||
extra_customer_entitlements: [],
|
||||
replaceables: [],
|
||||
rollovers: [],
|
||||
usage_windows: [],
|
||||
products: [],
|
||||
entitlements: [],
|
||||
prices: [],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const upstashLockedCommands = new Set([
|
||||
"updateFullSubjectCustomerProductV2",
|
||||
"upsertInvoiceInFullSubjectV2",
|
||||
"adjustSubjectBalance",
|
||||
"rollUsageWindows",
|
||||
]);
|
||||
|
||||
const registerCommands = (supportsUpstashShebang: boolean) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user