diff --git a/.gitignore b/.gitignore index 2959b0f20..09d5b91d5 100644 --- a/.gitignore +++ b/.gitignore @@ -86,7 +86,6 @@ next-env.d.ts shared/drizzle -run.sh commands.sh env.sh server/env.sh diff --git a/package.json b/package.json index 0f48be32e..5b4b75191 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "docs": "bun -F @autumn/docs dev", "docs:build": "bun -F @autumn/docs build", "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout", + "kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done", "atmn:build": "bun -F atmn build", "openapi:ts": "bun -F @autumn/openapi ts", "js:ts": "bun -F autumn-js ts", diff --git a/run.sh b/run.sh new file mode 100755 index 000000000..066ead246 --- /dev/null +++ b/run.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Root dispatcher: routes file paths under server/ to server/run.sh. +set -e + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +filename="$1" + +if [[ -z "$filename" ]]; then + echo "usage: $0 [args...]" >&2 + exit 1 +fi + +# Resolve to an absolute path so the prefix check works for relative inputs too. +if [[ "$filename" = /* ]]; then + resolved="$filename" +else + resolved="$(cd "$(dirname "$filename")" 2>/dev/null && pwd)/$(basename "$filename")" +fi + +if [[ "$resolved" == "$repo_root/server/"* ]]; then + exec "$repo_root/server/run.sh" "$resolved" "${@:2}" +fi + +echo "no router for: $resolved" >&2 +exit 1 diff --git a/server/run.sh b/server/run.sh index b1bce5813..fce55d7b6 100755 --- a/server/run.sh +++ b/server/run.sh @@ -1,12 +1,21 @@ #!/bin/bash # Run current file filename="$1" +line="$2" if [[ "$filename" == *"shell"* ]]; then "$filename" "${@:2}" elif [[ "$filename" == *".test.ts" ]]; then - # Test files: use bun test (preload configured in bunfig.toml) - NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename" + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + + if [[ -n "$line" && "$line" =~ ^[0-9]+$ ]]; then + # Targeted: run only the test enclosing the given line. + test_name="$(bun "$repo_root/scripts/testScripts/getDescribeAtCursor.ts" "$filename" "$line")" + NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename" -t "$test_name" + else + # Test files: use bun test (preload configured in bunfig.toml) + NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename" + fi elif [[ "$filename" == *".sh"* ]]; then "$filename" else diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts index 33621f116..d75b346ab 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts @@ -77,6 +77,7 @@ export const computeSyncFuturePhases = ({ fullCustomer, fullProduct: productContext.fullProduct, featureQuantities: productContext.featureQuantities, + entity: productContext.entity, startsAt: phaseContext.startsAt, endsAt: phaseContext.endsAt, currentEpochMs, diff --git a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts index 7e2424a24..bdb269d2f 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts @@ -35,7 +35,7 @@ export const initImmediateSyncCustomerProduct = ({ stripeSubscription: Stripe.Subscription; currentEpochMs: number; }): FullCusProduct => { - const { plan, fullProduct, featureQuantities } = productContext; + const { plan, fullProduct, featureQuantities, entity } = productContext; const trialEndsAt = getTrialEndsAtFromStripe({ stripeSubscription }); const { canceledAt, endedAt } = getCancelFieldsFromStripe({ @@ -49,6 +49,7 @@ export const initImmediateSyncCustomerProduct = ({ fullCustomer, fullProduct, featureQuantities, + entity, resetCycleAnchor: resetCycleAnchorMs, now: currentEpochMs, freeTrial: null, diff --git a/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts b/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts index 3846426bf..78f42edb9 100644 --- a/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts +++ b/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts @@ -72,8 +72,8 @@ export const logSyncContext = ({ const expire = pc.currentCustomerProduct ? ` expire=${pc.currentCustomerProduct.id}` : ""; - const entity = pc.plan.internal_entity_id - ? ` entity=${pc.plan.internal_entity_id}` + const entity = pc.entity + ? ` entity=${pc.entity.internal_id}` : ""; return `${pc.fullProduct.id}${customize}${expire}${entity}`; }) diff --git a/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts b/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts index 60293f6e6..698ff853c 100644 --- a/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts +++ b/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts @@ -1,4 +1,6 @@ import { + type Entity, + EntityNotFoundError, ErrCode, type FullCusProduct, type FullCustomer, @@ -41,6 +43,23 @@ const fetchStripeSchedule = async ({ return stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); }; +const resolvePlanEntity = ({ + plan, + fullCustomer, +}: { + plan: SyncPlanInstance; + fullCustomer: FullCustomer; +}): Entity | undefined => { + if (!plan.entity_id) return undefined; + const entity = fullCustomer.entities.find( + (e) => e.id === plan.entity_id || e.internal_id === plan.entity_id, + ); + if (!entity) { + throw new EntityNotFoundError({ entityId: plan.entity_id }); + } + return entity; +}; + const buildProductContext = async ({ ctx, fullCustomer, @@ -67,6 +86,8 @@ const buildProductContext = async ({ initializeUndefinedQuantities: true, }); + const entity = resolvePlanEntity({ plan, fullCustomer }); + let currentCustomerProduct: FullCusProduct | undefined; if ((isImmediate || plan.enable_plan_immediately) && plan.expire_previous) { const transition = setupAttachTransitionContext({ @@ -82,6 +103,7 @@ const buildProductContext = async ({ customPrices, customEntitlements, featureQuantities, + entity, currentCustomerProduct, accessStartsAt, }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index 45b066894..b2c7afe5c 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -26,6 +26,7 @@ export const initCustomerProduct = ({ freeTrial, trialEndsAt, now, + entity, } = initContext; const { subscriptionId, @@ -40,8 +41,9 @@ export const initCustomerProduct = ({ onTrialEnd, } = initOptions ?? {}; - const internalEntityId = fullCustomer.entity?.internal_id; - const entityId = fullCustomer.entity?.id; + const scopedEntity = entity ?? fullCustomer.entity; + const internalEntityId = scopedEntity?.internal_id; + const entityId = scopedEntity?.id; const startsAt = initOptions?.startsAt ?? now; const endedAt = initOptions?.endedAt; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index 01ffc2995..adcc97a8b 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -1,10 +1,12 @@ import { BillingVersion, CusProductStatus, + type Entity, type FeatureOptions, type FullCusProduct, type FullCustomer, type FullProduct, + truncateMsToSecondPrecision, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { initFullCustomerProduct } from "./initFullCustomerProduct"; @@ -21,6 +23,7 @@ export const initScheduledCustomerProduct = ({ fullCustomer, fullProduct, featureQuantities, + entity, startsAt, endsAt, currentEpochMs, @@ -33,6 +36,7 @@ export const initScheduledCustomerProduct = ({ fullCustomer: FullCustomer; fullProduct: FullProduct; featureQuantities: FeatureOptions[]; + entity?: Entity; startsAt: number; endsAt: number | null | undefined; currentEpochMs: number; @@ -45,20 +49,27 @@ export const initScheduledCustomerProduct = ({ subscriptionId?: string; subscriptionScheduleId?: string; }): FullCusProduct => { + const startsAtSecondsPrecision = truncateMsToSecondPrecision(startsAt); + const endsAtSecondsPrecision = + endsAt === null || endsAt === undefined + ? undefined + : truncateMsToSecondPrecision(endsAt); + return initFullCustomerProduct({ ctx, initContext: { fullCustomer, fullProduct, featureQuantities, - resetCycleAnchor: startsAt, + entity, + resetCycleAnchor: startsAtSecondsPrecision, freeTrial: null, now: currentEpochMs, billingVersion: BillingVersion.V2, }, initOptions: { - startsAt, - endedAt: endsAt ?? undefined, + startsAt: startsAtSecondsPrecision, + endedAt: endsAtSecondsPrecision, status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined, accessStartsAt, externalId, diff --git a/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts new file mode 100644 index 000000000..7e71486ef --- /dev/null +++ b/server/tests/integration/billing/create-schedule/create-schedule-customize.test.ts @@ -0,0 +1,575 @@ +import { expect, test } from "bun:test"; +import { + CusProductStatus, + customerEntitlements, + customerProducts, + ms, + schedulePhases, +} from "@autumn/shared"; +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 { advanceTestClock } from "@tests/utils/stripeUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { + getCustomerProductEntitlementBalances, + getCustomerProductPriceAmounts, + getRequiredScheduleId, +} from "./utils/createScheduleTestHelpers"; + +test.concurrent(`${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => { + const prepaidMessages = products.base({ + id: "prepaid", + items: [items.prepaidMessages()], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-options", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [prepaidMessages] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: prepaidMessages.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 400, + }, + ], + }, + ], + }, + ], + }); + + const insertedProducts = await ctx.db + .select({ + options: customerProducts.options, + }) + .from(customerProducts) + .where( + eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!), + ); + + expect(insertedProducts).toHaveLength(1); + expect(insertedProducts[0]!.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 4, + }), + ]); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => { + const base = products.base({ + id: "custom-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + + const insertedEntitlements = await ctx.db + .select({ + feature_id: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where( + eq( + customerEntitlements.customer_product_id, + response.phases[0]!.customer_product_ids[0]!, + ), + ); + + expect(insertedEntitlements).toEqual([ + { + feature_id: TestFeature.Words, + balance: 250, + }, + ]); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, async () => { + const base = products.base({ + id: "custom-future-base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize-future", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: base.id, + customize: { + items: [itemsV2.monthlyWords({ included: 250 })], + }, + }, + ], + }, + ], + }); + + const futurePhase = response.phases[1]; + expect(futurePhase).toBeDefined(); + + const futureCustomerProductId = futurePhase?.customer_product_ids[0]; + expect(futureCustomerProductId).toBeTruthy(); + + if (!futureCustomerProductId) { + throw new Error( + "Expected a scheduled customer product for the future phase", + ); + } + + const insertedEntitlements = await ctx.db + .select({ + feature_id: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where( + eq(customerEntitlements.customer_product_id, futureCustomerProductId), + ); + + expect(insertedEntitlements).toEqual([ + { + feature_id: TestFeature.Words, + balance: 250, + }, + ]); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, async () => { + const base = products.base({ + id: "create-schedule-customize-rollover", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-customize-rollover", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = advancedTo; + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + items: [ + itemsV2.monthlyWords({ included: 250 }), + itemsV2.dashboard(), + ], + }, + }, + ], + }, + ], + }); + + const futureCustomerProductId = response.phases[1]!.customer_product_ids[0]!; + + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([35]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 250 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + const activatedProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, futureCustomerProductId), + }); + + expect(activatedProduct?.status).toBe(CusProductStatus.Active); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([35]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 250 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, async () => { + const base = products.base({ + id: "create-schedule-custom-update", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx, testClockId, advancedTo } = + await initScenario({ + customerId: "create-schedule-custom-update", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = advancedTo; + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + items: [ + itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 200, + }, + ], + }, + ], + }, + ], + }); + + const initialFutureCustomerProductId = + initialResponse.phases[1]!.customer_product_ids[0]!; + + const updatedResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(15), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 55 }), + items: [ + itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: 500, + }, + ], + }, + ], + }, + ], + }); + + const updatedFutureCustomerProductId = + updatedResponse.phases[1]!.customer_product_ids[0]!; + + expect(updatedFutureCustomerProductId).not.toBe( + initialFutureCustomerProductId, + ); + expect( + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, initialFutureCustomerProductId), + }), + ).toBeUndefined(); + + const updatedFutureCustomerProduct = + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, updatedFutureCustomerProductId), + }); + + expect(updatedFutureCustomerProduct?.status).toBe(CusProductStatus.Scheduled); + expect(updatedFutureCustomerProduct?.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 5, + }), + ]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: updatedFutureCustomerProductId, + }), + ).toEqual([55]); + + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: now + ms.days(16), + waitForSeconds: 30, + }); + + const activatedFutureCustomerProduct = + await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, updatedFutureCustomerProductId), + }); + + expect(activatedFutureCustomerProduct?.status).toBe(CusProductStatus.Active); + expect(activatedFutureCustomerProduct?.options).toEqual([ + expect.objectContaining({ + feature_id: TestFeature.Messages, + quantity: 5, + }), + ]); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: updatedFutureCustomerProductId, + }), + ).toEqual([55]); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, async () => { + const base = products.base({ + id: "base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-update-with-customize", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const initialResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + ], + }); + + expect(initialResponse.phases).toHaveLength(1); + + const updatedResponse = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + items: [itemsV2.monthlyWords({ included: 200 })], + }, + }, + ], + }, + ], + }); + + expect(updatedResponse.phases).toHaveLength(2); + + const updatedDbPhases = await ctx.db + .select() + .from(schedulePhases) + .where( + eq( + schedulePhases.schedule_id, + getRequiredScheduleId(updatedResponse.schedule_id), + ), + ); + expect(updatedDbPhases).toHaveLength(2); + + const futureCustomerProductId = + updatedResponse.phases[1]!.customer_product_ids[0]!; + + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([50]); + + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]), + ); +}); + +test.concurrent(`${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, async () => { + const base = products.base({ + id: "bool-base", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice(), + ], + }); + + const { customerId, autumnV1, ctx } = await initScenario({ + customerId: "create-schedule-customize-boolean", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + items: [ + itemsV2.monthlyMessages({ included: 100 }), + itemsV2.dashboard(), + ], + }, + }, + ], + }, + ], + }); + + const customerProductId = response.phases[0]!.customer_product_ids[0]!; + + const entitlementBalances = await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }); + + expect(entitlementBalances).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Messages, balance: 100 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + + const customerProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, customerProductId), + }); + expect(customerProduct?.is_custom).toBe(true); +}); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts similarity index 72% rename from server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts rename to server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts index 195788b75..a485e156e 100644 --- a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts +++ b/server/tests/integration/billing/create-schedule/create-schedule-phases.test.ts @@ -1,30 +1,26 @@ import { expect, test } from "bun:test"; import { - type ApiCustomerV3, - type CreateScheduleParamsV0Input, - CheckoutAction, - CusProductStatus, - CustomerExpand, - customerEntitlements, - customerPrices, - customerProducts, - ms, - prices, - schedulePhases, - schedules, + type ApiCustomerV3, + type CreateScheduleParamsV0Input, + CheckoutAction, + CusProductStatus, + customerProducts, + ms, + schedulePhases, + schedules, } from "@autumn/shared"; import { - confirmAutumnCheckout, - fetchAutumnCheckout, + confirmAutumnCheckout, + fetchAutumnCheckout, } from "@tests/integration/billing/utils/checkout/autumnCheckoutUtils"; import { isAutumnCheckoutUrl } from "@tests/integration/billing/utils/isAutumnCheckoutUrl"; import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2"; import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; import { items } from "@tests/utils/fixtures/items"; -import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { advanceTestClock } from "@tests/utils/stripeUtils"; import { timeout } from "@tests/utils/genUtils.js"; @@ -33,89 +29,15 @@ import chalk from "chalk"; import { and, eq, inArray } from "drizzle-orm"; import { CusService } from "@/internal/customers/CusService"; import { - getFullCustomerSchedule, - hydrateCustomerWithSchedules, + getFullCustomerSchedule, + hydrateCustomerWithSchedules, } from "@/internal/customers/cusUtils/getFullCustomerSchedule"; import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer"; - -const getCustomerProductRows = async ({ - ctx, - customerId, - productIds, -}: { - ctx: Awaited>["ctx"]; - customerId: string; - productIds: string[]; -}) => - await ctx.db - .select({ - productId: customerProducts.product_id, - status: customerProducts.status, - }) - .from(customerProducts) - .where( - and( - eq(customerProducts.customer_id, customerId), - inArray(customerProducts.product_id, productIds), - ), - ); - -const getCustomerProductPriceAmounts = async ({ - ctx, - customerProductId, -}: { - ctx: Awaited>["ctx"]; - customerProductId: string; -}) => - ( - await ctx.db - .select({ config: prices.config }) - .from(customerPrices) - .innerJoin(prices, eq(customerPrices.price_id, prices.id)) - .where(eq(customerPrices.customer_product_id, customerProductId)) - ) - .map((row) => - row.config && "amount" in row.config ? row.config.amount : undefined, - ) - .filter((amount): amount is number => typeof amount === "number") - .sort((a, b) => a - b); - -const getCustomerProductEntitlementBalances = async ({ - ctx, - customerProductId, -}: { - ctx: Awaited>["ctx"]; - customerProductId: string; -}) => - await ctx.db - .select({ - feature_id: customerEntitlements.feature_id, - balance: customerEntitlements.balance, - }) - .from(customerEntitlements) - .where(eq(customerEntitlements.customer_product_id, customerProductId)); - -const getRequiredScheduleId = (scheduleId: string | null) => { - if (!scheduleId) { - throw new Error("Expected create_schedule response to include schedule_id"); - } - - return scheduleId; -}; - -const getCheckoutId = (paymentUrl: string | null | undefined) => { - if (!paymentUrl) { - throw new Error("Expected create_schedule response to include payment_url"); - } - - const checkoutId = paymentUrl.split("/c/")[1]; - - if (!checkoutId) { - throw new Error(`Expected Autumn checkout URL, received: ${paymentUrl}`); - } - - return checkoutId; -}; +import { + getCheckoutId, + getCustomerProductRows, + getRequiredScheduleId, +} from "./utils/createScheduleTestHelpers"; test.concurrent(`${chalk.yellowBright("create-schedule: bills the first phase immediately and stores later phases as scheduled")}`, async () => { const pro = products.pro({ @@ -330,416 +252,6 @@ test.concurrent(`${chalk.yellowBright("create-schedule: copies entity_id and rep expect(newCustomerProducts[0]!.status).toBe(CusProductStatus.Active); }); -test.concurrent(`${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => { - const prepaidMessages = products.base({ - id: "prepaid", - items: [items.prepaidMessages()], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-options", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [prepaidMessages] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: prepaidMessages.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 400, - }, - ], - }, - ], - }, - ], - }); - - const insertedProducts = await ctx.db - .select({ - options: customerProducts.options, - }) - .from(customerProducts) - .where( - eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!), - ); - - expect(insertedProducts).toHaveLength(1); - expect(insertedProducts[0]!.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 4, - }), - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => { - const base = products.base({ - id: "custom-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: base.id, - customize: { - items: [itemsV2.monthlyWords({ included: 250 })], - }, - }, - ], - }, - ], - }); - - const insertedEntitlements = await ctx.db - .select({ - feature_id: customerEntitlements.feature_id, - balance: customerEntitlements.balance, - }) - .from(customerEntitlements) - .where( - eq( - customerEntitlements.customer_product_id, - response.phases[0]!.customer_product_ids[0]!, - ), - ); - - expect(insertedEntitlements).toEqual([ - { - feature_id: TestFeature.Words, - balance: 250, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, async () => { - const base = products.base({ - id: "custom-future-base", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize-future", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = Date.now(); - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(30), - plans: [ - { - plan_id: base.id, - customize: { - items: [itemsV2.monthlyWords({ included: 250 })], - }, - }, - ], - }, - ], - }); - - const futurePhase = response.phases[1]; - expect(futurePhase).toBeDefined(); - - const futureCustomerProductId = futurePhase?.customer_product_ids[0]; - expect(futureCustomerProductId).toBeTruthy(); - - if (!futureCustomerProductId) { - throw new Error( - "Expected a scheduled customer product for the future phase", - ); - } - - const insertedEntitlements = await ctx.db - .select({ - feature_id: customerEntitlements.feature_id, - balance: customerEntitlements.balance, - }) - .from(customerEntitlements) - .where( - eq(customerEntitlements.customer_product_id, futureCustomerProductId), - ); - - expect(insertedEntitlements).toEqual([ - { - feature_id: TestFeature.Words, - balance: 250, - }, - ]); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, async () => { - const base = products.base({ - id: "create-schedule-customize-rollover", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-customize-rollover", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = advancedTo; - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 35 }), - items: [ - itemsV2.monthlyWords({ included: 250 }), - itemsV2.dashboard(), - ], - }, - }, - ], - }, - ], - }); - - const futureCustomerProductId = response.phases[1]!.customer_product_ids[0]!; - - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([35]); - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Words, balance: 250 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - const activatedProduct = await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, futureCustomerProductId), - }); - - expect(activatedProduct?.status).toBe(CusProductStatus.Active); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([35]); - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Words, balance: 250 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, async () => { - const base = products.base({ - id: "create-schedule-custom-update", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx, testClockId, advancedTo } = - await initScenario({ - customerId: "create-schedule-custom-update", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = advancedTo; - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 35 }), - items: [ - itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), - ], - }, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 200, - }, - ], - }, - ], - }, - ], - }); - - const initialFutureCustomerProductId = - initialResponse.phases[1]!.customer_product_ids[0]!; - - const updatedResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(15), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 55 }), - items: [ - itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }), - ], - }, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: 500, - }, - ], - }, - ], - }, - ], - }); - - const updatedFutureCustomerProductId = - updatedResponse.phases[1]!.customer_product_ids[0]!; - - expect(updatedFutureCustomerProductId).not.toBe( - initialFutureCustomerProductId, - ); - expect( - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, initialFutureCustomerProductId), - }), - ).toBeUndefined(); - - const updatedFutureCustomerProduct = - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, updatedFutureCustomerProductId), - }); - - expect(updatedFutureCustomerProduct?.status).toBe(CusProductStatus.Scheduled); - expect(updatedFutureCustomerProduct?.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 5, - }), - ]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: updatedFutureCustomerProductId, - }), - ).toEqual([55]); - - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - advanceTo: now + ms.days(16), - waitForSeconds: 30, - }); - - const activatedFutureCustomerProduct = - await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, updatedFutureCustomerProductId), - }); - - expect(activatedFutureCustomerProduct?.status).toBe(CusProductStatus.Active); - expect(activatedFutureCustomerProduct?.options).toEqual([ - expect.objectContaining({ - feature_id: TestFeature.Messages, - quantity: 5, - }), - ]); - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: updatedFutureCustomerProductId, - }), - ).toEqual([55]); -}); - test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, async () => { const pro = products.pro({ id: "deferred-pro", @@ -1237,42 +749,12 @@ test.concurrent(`${chalk.yellowBright("create-schedule: plans omitted from the n waitForSeconds: 30, }); - const productRows = await getCustomerProductRows({ - ctx, - customerId, - productIds: [nowBase.id, nowAddon.id, nextBase.id, nextAddon.id], - }); - - expect( - productRows - .filter((productRow) => productRow.status === CusProductStatus.Active) - .sort((a, b) => a.productId!.localeCompare(b.productId!)), - ).toEqual( - [ - { productId: nextAddon.id, status: CusProductStatus.Active }, - { productId: nextBase.id, status: CusProductStatus.Active }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - expect( - productRows.filter( - (productRow) => productRow.status === CusProductStatus.Scheduled, - ), - ).toHaveLength(0); - expect( - productRows - .filter((productRow) => productRow.status === CusProductStatus.Expired) - .sort((a, b) => a.productId!.localeCompare(b.productId!)), - ).toEqual( - [ - { productId: nowAddon.id, status: CusProductStatus.Expired }, - { productId: nowBase.id, status: CusProductStatus.Expired }, - ].sort((a, b) => a.productId.localeCompare(b.productId)), - ); - const customer = await autumnV1.customers.get(customerId); - expect(customer.products?.map((product) => product.id).sort()).toEqual( - [nextAddon.id, nextBase.id].sort(), - ); + await expectCustomerProducts({ + customer, + active: [nextAddon.id, nextBase.id], + notPresent: [nowAddon.id, nowBase.id], + }); }); test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, async () => { @@ -1308,7 +790,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a sched }); const now = advancedTo; - const initialResponse = await autumnV1.billing.createSchedule({ + await autumnV1.billing.createSchedule({ customer_id: customerId, phases: [ { @@ -1979,9 +1461,9 @@ test.concurrent(`${chalk.yellowBright("create-schedule: hydrates schedules on th expect(hydratedCustomer.schedule?.customer_id).toBe(customerId); expect(hydratedCustomer.schedule?.phases).toHaveLength(2); expect(hydratedCustomer.schedule?.phases[0]?.starts_at).toBe(now); -expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe( - now + ms.days(30), -); + expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe( + now + ms.days(30), + ); }); test.concurrent(`${chalk.yellowBright("create-schedule: adding a future phase to an existing single-phase schedule persists both phases")}`, async () => { @@ -2085,150 +1567,6 @@ test.concurrent(`${chalk.yellowBright("create-schedule: adding a future phase to expect(futureProducts[0]!.product_id).toBe(premium.id); }); -test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, async () => { - const base = products.base({ - id: "base", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-update-with-customize", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const now = Date.now(); - const initialResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - ], - }); - - expect(initialResponse.phases).toHaveLength(1); - - const updatedResponse = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: now, - plans: [{ plan_id: base.id }], - }, - { - starts_at: now + ms.days(30), - plans: [ - { - plan_id: base.id, - customize: { - price: itemsV2.monthlyPrice({ amount: 50 }), - items: [itemsV2.monthlyWords({ included: 200 })], - }, - }, - ], - }, - ], - }); - - expect(updatedResponse.phases).toHaveLength(2); - - const updatedDbPhases = await ctx.db - .select() - .from(schedulePhases) - .where( - eq( - schedulePhases.schedule_id, - getRequiredScheduleId(updatedResponse.schedule_id), - ), - ); - expect(updatedDbPhases).toHaveLength(2); - - const futureCustomerProductId = - updatedResponse.phases[1]!.customer_product_ids[0]!; - - expect( - await getCustomerProductPriceAmounts({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual([50]); - - expect( - await getCustomerProductEntitlementBalances({ - ctx, - customerProductId: futureCustomerProductId, - }), - ).toEqual( - expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]), - ); -}); - -test.concurrent(`${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, async () => { - const base = products.base({ - id: "bool-base", - items: [ - items.monthlyMessages({ includedUsage: 100 }), - items.monthlyPrice(), - ], - }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "create-schedule-customize-boolean", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [base] }), - ], - actions: [], - }); - - const response = await autumnV1.billing.createSchedule({ - customer_id: customerId, - phases: [ - { - starts_at: Date.now(), - plans: [ - { - plan_id: base.id, - customize: { - items: [ - itemsV2.monthlyMessages({ included: 100 }), - itemsV2.dashboard(), - ], - }, - }, - ], - }, - ], - }); - - const customerProductId = response.phases[0]!.customer_product_ids[0]!; - - const entitlementBalances = await getCustomerProductEntitlementBalances({ - ctx, - customerProductId, - }); - - expect(entitlementBalances).toEqual( - expect.arrayContaining([ - { feature_id: TestFeature.Messages, balance: 100 }, - { feature_id: TestFeature.Dashboard, balance: 0 }, - ]), - ); - - const customerProduct = await ctx.db.query.customerProducts.findFirst({ - where: eq(customerProducts.id, customerProductId), - }); - expect(customerProduct?.is_custom).toBe(true); -}); - test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entity-level schedules coexist independently")}`, async () => { const pro = products.pro({ id: "pro", @@ -2259,6 +1597,10 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entit starts_at: now, plans: [{ plan_id: pro.id }], }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: pro.id }], + }, ], }); @@ -2270,6 +1612,10 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entit starts_at: now, plans: [{ plan_id: addon.id }], }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: addon.id }], + }, ], }); diff --git a/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts new file mode 100644 index 000000000..9dbb2d9fc --- /dev/null +++ b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts @@ -0,0 +1,89 @@ +import { + customerEntitlements, + customerPrices, + customerProducts, + prices, +} from "@autumn/shared"; +import type { initScenario } from "@tests/utils/testInitUtils/initScenario"; +import { and, eq, inArray } from "drizzle-orm"; + +type Ctx = Awaited>["ctx"]; + +export const getCustomerProductRows = async ({ + ctx, + customerId, + productIds, +}: { + ctx: Ctx; + customerId: string; + productIds: string[]; +}) => + await ctx.db + .select({ + productId: customerProducts.product_id, + status: customerProducts.status, + }) + .from(customerProducts) + .where( + and( + eq(customerProducts.customer_id, customerId), + inArray(customerProducts.product_id, productIds), + ), + ); + +export const getCustomerProductPriceAmounts = async ({ + ctx, + customerProductId, +}: { + ctx: Ctx; + customerProductId: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerPrices) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where(eq(customerPrices.customer_product_id, customerProductId)) + ) + .map((row) => + row.config && "amount" in row.config ? row.config.amount : undefined, + ) + .filter((amount): amount is number => typeof amount === "number") + .sort((a, b) => a - b); + +export const getCustomerProductEntitlementBalances = async ({ + ctx, + customerProductId, +}: { + ctx: Ctx; + customerProductId: string; +}) => + await ctx.db + .select({ + feature_id: customerEntitlements.feature_id, + balance: customerEntitlements.balance, + }) + .from(customerEntitlements) + .where(eq(customerEntitlements.customer_product_id, customerProductId)); + +export const getRequiredScheduleId = (scheduleId: string | null) => { + if (!scheduleId) { + throw new Error("Expected create_schedule response to include schedule_id"); + } + + return scheduleId; +}; + +export const getCheckoutId = (paymentUrl: string | null | undefined) => { + if (!paymentUrl) { + throw new Error("Expected create_schedule response to include payment_url"); + } + + const checkoutId = paymentUrl.split("/c/")[1]; + + if (!checkoutId) { + throw new Error(`Expected Autumn checkout URL, received: ${paymentUrl}`); + } + + return checkoutId; +}; diff --git a/server/tests/integration/billing/utils/sync/expectSyncParams.ts b/server/tests/integration/billing/utils/sync/expectSyncParams.ts index 6f7f50236..b40121e12 100644 --- a/server/tests/integration/billing/utils/sync/expectSyncParams.ts +++ b/server/tests/integration/billing/utils/sync/expectSyncParams.ts @@ -4,7 +4,7 @@ import type { SyncParamsV1 } from "@autumn/shared"; export type ExpectedPlan = { plan_id: string; quantity?: number; - internal_entity_id?: string; + entity_id?: string; expire_previous?: boolean; customize?: | null @@ -43,8 +43,8 @@ const expectPlanCorrect = ({ if (expected.quantity !== undefined) { expect(plan.quantity ?? 1).toBe(expected.quantity); } - if (expected.internal_entity_id !== undefined) { - expect(plan.internal_entity_id).toBe(expected.internal_entity_id); + if (expected.entity_id !== undefined) { + expect(plan.entity_id).toBe(expected.entity_id); } if (expected.expire_previous !== undefined) { expect(plan.expire_previous).toBe(expected.expire_previous); diff --git a/shared/api/billing/sync/syncParamsV1.ts b/shared/api/billing/sync/syncParamsV1.ts index 687b0ac41..69bdffaf7 100644 --- a/shared/api/billing/sync/syncParamsV1.ts +++ b/shared/api/billing/sync/syncParamsV1.ts @@ -10,10 +10,9 @@ export const SyncPlanInstanceSchema = MultiPlanInstanceSchema.extend({ description: "Number of customer product instances to create from this plan entry. Defaults to 1. Used to express add-ons with quantity > 1.", }), - internal_entity_id: z.string().optional().meta({ + entity_id: z.string().optional().meta({ description: - "If set, the resulting customer product is bound to this entity.", - internal: true, + "If set, the resulting customer product is bound to this entity. Resolved against the customer's entities; pass the public entity id.", }), expire_previous: z.boolean().optional().meta({ description: diff --git a/shared/models/billingModels/context/syncBillingContext.ts b/shared/models/billingModels/context/syncBillingContext.ts index 22ff7a022..28b8ae974 100644 --- a/shared/models/billingModels/context/syncBillingContext.ts +++ b/shared/models/billingModels/context/syncBillingContext.ts @@ -6,6 +6,7 @@ import type { FullCusProduct, } from "../../cusProductModels/cusProductModels"; import type { FullCustomer } from "../../cusModels/fullCusModel"; +import type { Entity } from "../../cusModels/entityModels/entityModels"; import type { Entitlement } from "../../productModels/entModels/entModels"; import type { Price } from "../../productModels/priceModels/priceModels"; import type { FullProduct } from "../../productModels/productModels"; @@ -16,6 +17,8 @@ export interface SyncProductContext { customPrices: Price[]; customEntitlements: Entitlement[]; featureQuantities: FeatureOptions[]; + /** Resolved per-plan entity scope (from plan.entity_id), if any. */ + entity?: Entity; /** Existing active cusProduct in the same product group, if `expire_previous` was set. */ currentCustomerProduct?: FullCusProduct; accessStartsAt?: number; diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index 806252f7c..9f360a54d 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -5,6 +5,7 @@ import type { TrialOnEnd, } from "@models/productModels/freeTrialModels/freeTrialModels"; import type { ApiVersion } from "../../../api/versionUtils/ApiVersion"; +import type { Entity } from "../../cusModels/entityModels/entityModels"; import type { FullCustomer } from "../../cusModels/fullCusModel"; import type { CollectionMethod, @@ -31,6 +32,13 @@ export interface InitFullCustomerProductContext { fullProduct: FullProduct; featureQuantities: FeatureOptions[]; + /** + * Per-call override for the entity the resulting cusProduct should bind to. + * Wins over `fullCustomer.entity`. Used by flows (e.g. sync) where the + * entity is plan-specific rather than request-wide. + */ + entity?: Entity; + // For customer entitlements resetCycleAnchor: number | "now"; // Unix timestamp of the next // existingUsages?: ExistingUsages; diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index e6ed85e68..7d4ce4160 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -14,6 +14,7 @@ import { isFreeProduct, isOneOffProduct, } from "../../productUtils/classifyProduct/classifyProductUtils"; +import { ms } from "../../common"; import { notNullish, nullish } from "../../utils"; import { ACTIVE_STATUSES, RELEVANT_STATUSES } from ".."; import { cusProductToPrices } from "../convertCusProduct"; @@ -111,22 +112,16 @@ export const isCustomerProductExpired = (cp?: FullCusProduct) => { return cp.status === CusProductStatus.Expired; }; -/** - * Checks if a canceling customer product has reached its end time. - * Uses an optional tolerance to handle timing differences between Stripe and webhook arrival. - * - * @param toleranceMs - Tolerance in milliseconds (default: 10 minutes) - */ - export const hasCustomerProductEnded = ( cp: FullCusProduct, - params?: { nowMs?: number }, + params?: { nowMs?: number; toleranceMs?: number }, ) => { const nowMs = params?.nowMs ?? Date.now(); + const toleranceMs = params?.toleranceMs ?? ms.seconds(1); const hasEnded = // isCustomerProductCanceling(cp) && - notNullish(cp.ended_at) && nowMs >= cp.ended_at; + notNullish(cp.ended_at) && nowMs + toleranceMs >= cp.ended_at; return hasEnded; }; diff --git a/vite/src/hooks/stores/useSubscriptionStore.ts b/vite/src/hooks/stores/useSubscriptionStore.ts index 2dd1c89a7..df2b8ce7c 100644 --- a/vite/src/hooks/stores/useSubscriptionStore.ts +++ b/vite/src/hooks/stores/useSubscriptionStore.ts @@ -9,7 +9,6 @@ import { import { parseAsString, useQueryStates } from "nuqs"; import { useEffect, useMemo, useState } from "react"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { withEffectiveCustomerProductStatus } from "@/views/customers2/utils/effectiveCustomerProductStatus"; // Hook to sync entity_id between query params and store export const useEntity = () => { @@ -47,19 +46,14 @@ export const useEntity = () => { // Hook to get a customer product and its productV2 by customer product ID export const useSubscriptionById = ({ itemId }: { itemId: string | null }) => { - const { customer, testClockFrozenTimeMs } = useCusQuery(); + const { customer } = useCusQuery(); const cusProduct = useMemo(() => { if (!itemId || !customer?.customer_products) return null; - const customerProduct = customer.customer_products.find( + return customer.customer_products.find( (p: FullCusProduct) => p.id === itemId, - ); - if (!customerProduct) return null; - return withEffectiveCustomerProductStatus({ - customerProduct, - nowMs: testClockFrozenTimeMs, - }); - }, [itemId, customer?.customer_products, testClockFrozenTimeMs]); + ) ?? null; + }, [itemId, customer?.customer_products]); const productV2 = useMemo(() => { if (!cusProduct) return null; diff --git a/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx b/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx index 170efdfd3..da9fa082d 100644 --- a/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx +++ b/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx @@ -133,7 +133,7 @@ export function SyncPlanRow({ const availableProducts = products.filter((p) => !p.archived); const selectedProduct = products.find((p) => p.id === plan.plan_id); const hasCustomize = Boolean(plan.customize); - const hasEntityScope = Boolean(plan.internal_entity_id); + const hasEntityScope = Boolean(plan.entity_id); const [scopeOpen, setScopeOpen] = useState(hasEntityScope); @@ -279,9 +279,9 @@ export function SyncPlanRow({ {entities.length > 0 && scopeOpen && ( - onChange({ ...plan, internal_entity_id: entityId }) + onChange({ ...plan, entity_id: entityId }) } /> )} diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx index 430ff8c2e..dd96ae7ed 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageTable.tsx @@ -14,7 +14,6 @@ import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; -import { withEffectiveCustomerProductStatus } from "@/views/customers2/utils/effectiveCustomerProductStatus"; import { CustomerBalanceTable } from "../customer-balance/CustomerBalanceTable"; import { EmptyState } from "../EmptyState"; import { CustomerFeatureUsageColumns } from "./CustomerFeatureUsageColumns"; @@ -29,7 +28,7 @@ import { } from "./customerFeatureUsageUtils"; export function CustomerFeatureUsageTable() { - const { customer, features, isLoading, testClockFrozenTimeMs } = useCusQuery(); + const { customer, features, isLoading } = useCusQuery(); const { setSheet } = useSheetStore(); const { entityId } = useEntity(); @@ -44,13 +43,7 @@ export function CustomerFeatureUsageTable() { }, [customer?.entities, entityId]); const filteredCustomerProducts = useMemo(() => { - const customerProducts = (customer?.customer_products ?? []).map( - (customerProduct) => - withEffectiveCustomerProductStatus({ - customerProduct, - nowMs: testClockFrozenTimeMs, - }), - ); + const customerProducts = customer?.customer_products ?? []; if (!selectedEntity) { return customerProducts; @@ -62,7 +55,7 @@ export function CustomerFeatureUsageTable() { cp.internal_entity_id === selectedEntity.internal_id || cp.entity_id === selectedEntity.id, ); - }, [customer?.customer_products, selectedEntity, testClockFrozenTimeMs]); + }, [customer?.customer_products, selectedEntity]); const cusEnts = useMemo((): FullCusEntWithFullCusProduct[] => { const productEnts = flattenCustomerEntitlements({ diff --git a/vite/src/views/customers2/hooks/useCustomerProductsData.ts b/vite/src/views/customers2/hooks/useCustomerProductsData.ts index 44f8dfae8..62602e09c 100644 --- a/vite/src/views/customers2/hooks/useCustomerProductsData.ts +++ b/vite/src/views/customers2/hooks/useCustomerProductsData.ts @@ -4,7 +4,6 @@ import { useMemo } from "react"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { filterCustomerProductsByType } from "../components/table/customer-products/customerProductsTableFilters"; -import { withEffectiveCustomerProductStatus } from "../utils/effectiveCustomerProductStatus"; function filterBySelectedEntity({ products, @@ -37,26 +36,14 @@ export function useCustomerProductsData() { "customerProductsShowExpired", parseAsBoolean.withDefault(false), ); - const customerWithEffectiveStatuses = useMemo( - () => ({ - ...customer, - customer_products: customer.customer_products.map((customerProduct) => - withEffectiveCustomerProductStatus({ - customerProduct, - nowMs: testClockFrozenTimeMs, - }), - ), - }), - [customer, testClockFrozenTimeMs], - ); const { subscriptions, purchases } = useMemo( () => filterCustomerProductsByType({ - customer: customerWithEffectiveStatuses, + customer, showExpired: showExpired ?? false, }), - [customerWithEffectiveStatuses, showExpired], + [customer, showExpired], ); // Filter entity-level products by selected entity (if any) diff --git a/vite/src/views/customers2/utils/effectiveCustomerProductStatus.ts b/vite/src/views/customers2/utils/effectiveCustomerProductStatus.ts deleted file mode 100644 index 72fb40166..000000000 --- a/vite/src/views/customers2/utils/effectiveCustomerProductStatus.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - CusProductStatus, - type FullCusProduct, - hasCustomerProductEnded, -} from "@autumn/shared"; - -export function withEffectiveCustomerProductStatus({ - customerProduct, - nowMs, -}: { - customerProduct: FullCusProduct; - nowMs?: number; -}): FullCusProduct { - if (!hasCustomerProductEnded(customerProduct, { nowMs })) { - return customerProduct; - } - - return { ...customerProduct, status: CusProductStatus.Expired }; -}