From a24a57007934deaf9bb521ce605a8a01c18beb7d Mon Sep 17 00:00:00 2001 From: johnyeo Date: Fri, 12 Jun 2026 10:49:22 +0100 Subject: [PATCH] latest --- scripts/dev.ts | 15 +- .../findTransitionSourceCustomerProduct.ts | 41 +++ ...applyExistingRolloversToCustomerProduct.ts | 14 +- .../reapplyExistingUsagesToCustomerProduct.ts | 9 +- server/tests/_groups/temp.ts | 33 +-- .../immediate-switch-allocated-v2.test.ts | 85 +++++- ...eate-schedule-allocated-v2-preview.test.ts | 260 ++++++++++++++++++ .../preview/allocated-v2-preview.test.ts | 150 ++++++++++ .../invoice-created-entity-consumable.test.ts | 3 + .../subscription-updated-past-due.test.ts | 12 +- .../update-allocated-legacy-compat.test.ts | 39 +-- .../get-customer-aggregated-balances.test.ts | 15 +- .../balances/syncItemV4-cache-miss.test.ts | 24 +- ...e-entity-and-customer-subject-rows.test.ts | 1 + .../products/allocated-v2-proration.spec.ts | 89 +++++- .../redis/register-redis-commands.spec.ts | 1 + ...ith-redis-fail-open-gate-rejection.test.ts | 32 ++- .../handleRevenueCatOAuthCallback.test.ts | 225 +++++++++++++-- .../unit/revenuecat/initRevenuecatCli.test.ts | 39 ++- .../initRevenuecatCliProducts.test.ts | 37 ++- .../mappers/customizePlanV1ToV0.ts | 27 +- .../items/crud/createPlanItemParamsV1.ts | 11 + .../products/items/mappers/planItemV1ToV0.ts | 5 +- .../productItemToPlanItemParamsV1.ts | 48 +++- 24 files changed, 1030 insertions(+), 185 deletions(-) create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct.ts create mode 100644 server/tests/integration/billing/create-schedule/preview/create-schedule-allocated-v2-preview.test.ts create mode 100644 server/tests/integration/billing/preview/allocated-v2-preview.test.ts diff --git a/scripts/dev.ts b/scripts/dev.ts index e6bac71f9..745986196 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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 - // `@` 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"); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct.ts new file mode 100644 index 000000000..bc547fb23 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct.ts @@ -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; + }); +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts index 8c9c8eed3..d38200321 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingRolloversToCustomerProduct.ts @@ -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; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts index 006632ab2..36f81096a 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/reapplyExistingUsagesToCustomerProduct.ts @@ -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; diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 8c8f1b4ba..f0af8aba6 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -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, diff --git a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-allocated-v2.test.ts b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-allocated-v2.test.ts index 2935a2a12..2f0b65156 100644 --- a/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-allocated-v2.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/paid-features/immediate-switch-allocated-v2.test.ts @@ -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( + { + 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({ + customer_id: customerId, + plan_id: pro.id, + redirect_mode: "if_required", + }); + expect(result.invoice).toBeUndefined(); + + const customer = await autumnV2_3.customers.get(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(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerV3, + count: 0, + }); + }, +); diff --git a/server/tests/integration/billing/create-schedule/preview/create-schedule-allocated-v2-preview.test.ts b/server/tests/integration/billing/create-schedule/preview/create-schedule-allocated-v2-preview.test.ts new file mode 100644 index 000000000..c592b487e --- /dev/null +++ b/server/tests/integration/billing/create-schedule/preview/create-schedule-allocated-v2-preview.test.ts @@ -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>["autumnV2_3"]; + params: CreateScheduleParamsV0Input; +}): Promise => + 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({ + 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(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(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({ + 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(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(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(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", + }, + ], + }); + }, +); diff --git a/server/tests/integration/billing/preview/allocated-v2-preview.test.ts b/server/tests/integration/billing/preview/allocated-v2-preview.test.ts new file mode 100644 index 000000000..1c5ebf02a --- /dev/null +++ b/server/tests/integration/billing/preview/allocated-v2-preview.test.ts @@ -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( + { + 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( + { + 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(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.Users, + remaining: 0, + usage: 5, + planId: pro.id, + nextResetAt: null, + }); + await expectCustomerInvoiceCorrect({ + customerId, + count: 1, + latestTotal: 20, + }); + }, +); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts index 9f7bc1163..9a0d53596 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts @@ -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 diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts index a45c359d2..51dca74a1 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts @@ -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(customerId); await expectProductPastDue({ diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-allocated-legacy-compat.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-allocated-legacy-compat.test.ts index 0fe2c1807..94f4ef7ec 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-allocated-legacy-compat.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-allocated-legacy-compat.test.ts @@ -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(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({ + 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(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(customerId), - count: 2, + count: 3, latestTotal: 10, }); await expectStripeSubscriptionCorrect({ ctx, customerId }); diff --git a/server/tests/integration/crud/customers/get-customer-aggregated-balances.test.ts b/server/tests/integration/crud/customers/get-customer-aggregated-balances.test.ts index 80251d527..d256fc1bb 100644 --- a/server/tests/integration/crud/customers/get-customer-aggregated-balances.test.ts +++ b/server/tests/integration/crud/customers/get-customer-aggregated-balances.test.ts @@ -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(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(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(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(customerId); expectBalanceCorrect({ diff --git a/server/tests/unit/balances/syncItemV4-cache-miss.test.ts b/server/tests/unit/balances/syncItemV4-cache-miss.test.ts index 5ac47ed64..2d38b3110 100644 --- a/server/tests/unit/balances/syncItemV4-cache-miss.test.ts +++ b/server/tests/unit/balances/syncItemV4-cache-miss.test.ts @@ -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); }); }); diff --git a/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts b/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts index 6d56d9eb9..904514e88 100644 --- a/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts +++ b/server/tests/unit/customers/merge-entity-and-customer-subject-rows.test.ts @@ -71,6 +71,7 @@ const createRow = (overrides: Partial = {}): SubjectQueryRow => extra_customer_entitlements: [], replaceables: [], rollovers: [], + usage_windows: [], products: [], entitlements: [], prices: [], diff --git a/server/tests/unit/products/allocated-v2-proration.spec.ts b/server/tests/unit/products/allocated-v2-proration.spec.ts index 9069b0c4d..bb7111e91 100644 --- a/server/tests/unit/products/allocated-v2-proration.spec.ts +++ b/server/tests/unit/products/allocated-v2-proration.spec.ts @@ -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"); }); }); diff --git a/server/tests/unit/redis/register-redis-commands.spec.ts b/server/tests/unit/redis/register-redis-commands.spec.ts index a555446f7..e4bf41c97 100644 --- a/server/tests/unit/redis/register-redis-commands.spec.ts +++ b/server/tests/unit/redis/register-redis-commands.spec.ts @@ -16,6 +16,7 @@ const upstashLockedCommands = new Set([ "updateFullSubjectCustomerProductV2", "upsertInvoiceInFullSubjectV2", "adjustSubjectBalance", + "rollUsageWindows", ]); const registerCommands = (supportsUpstashShebang: boolean) => { diff --git a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts index 7387093f3..96503771f 100644 --- a/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts +++ b/server/tests/unit/redis/with-redis-fail-open-gate-rejection.test.ts @@ -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[] = []; mock.module("@/queue/queueUtils.js", () => ({ addTaskToQueue: async (args: Record) => { 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); }, })); diff --git a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts index bba895e64..6b0676cd4 100644 --- a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts +++ b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts @@ -15,17 +15,18 @@ type MockOAuthState = { const mockConsumeOAuthState = mock( (): Promise => 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 | 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; + nextPage: string | null; + }) => { + const items: Record[] = []; + 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[]; + 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) => { + 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) => { + 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, + ) => { + 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, + ) => { + 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 }, diff --git a/server/tests/unit/revenuecat/initRevenuecatCli.test.ts b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts index 4ab076e8e..9fb7531a0 100644 --- a/server/tests/unit/revenuecat/initRevenuecatCli.test.ts +++ b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts @@ -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", diff --git a/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts index 30b734d55..28246894f 100644 --- a/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts +++ b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts @@ -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", diff --git a/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts b/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts index 19bff9d8d..5fdee5b52 100644 --- a/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts +++ b/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts @@ -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; } }; diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index e5a8872ae..86f310014 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -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; diff --git a/shared/api/products/items/mappers/planItemV1ToV0.ts b/shared/api/products/items/mappers/planItemV1ToV0.ts index acd3b22ff..c69984f28 100644 --- a/shared/api/products/items/mappers/planItemV1ToV0.ts +++ b/shared/api/products/items/mappers/planItemV1ToV0.ts @@ -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, }); diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts index 2678c24d5..e03cc29b7 100644 --- a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts @@ -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,