From 08d39309a69cb2ff16a324345b590341e1f38435 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:40:14 +0100 Subject: [PATCH] =?UTF-8?q?feat(server):=20=F0=9F=8E=B8=20add=20scope=20fi?= =?UTF-8?q?eld=20to=20customer=20subscriptions=20and=20purchases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../getApiSubscription/getApiSubscription.ts | 1 + .../getApiSubscriptionV2.ts | 11 +- .../crud/customers/customer-scope.test.ts | 137 ++++++++++++++++++ .../customers/cusPlans/apiSubscriptionV1.ts | 8 + .../mappers/apiSubscriptionV1ToPurchaseV0.ts | 1 + 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 server/tests/integration/crud/customers/customer-scope.test.ts diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index 7f29e24c8..a84b24d2e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -134,6 +134,7 @@ export const getApiSubscription = async ({ quantity: cusProduct.quantity, current_period_start: stripeSubData?.current_period_start || null, current_period_end: stripeSubData?.current_period_end || null, + scope: cusProduct.internal_entity_id ? "entity" : "customer", } satisfies ApiSubscriptionV1); return { diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts index b50765d0e..fa2faa9e4 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts @@ -129,11 +129,12 @@ export const getApiSubscriptionV2 = async ({ trial_ends_at: isCustomerProductTrialing(customerProduct) ? (customerProduct.trial_ends_at ?? null) : null, - started_at: customerProduct.starts_at, - quantity: customerProduct.quantity, - current_period_start: subscriptionPeriod.current_period_start, - current_period_end: subscriptionPeriod.current_period_end, - } satisfies ApiSubscriptionV1), + started_at: customerProduct.starts_at, + quantity: customerProduct.quantity, + current_period_start: subscriptionPeriod.current_period_start, + current_period_end: subscriptionPeriod.current_period_end, + scope: customerProduct.internal_entity_id ? "entity" : "customer", + } satisfies ApiSubscriptionV1), legacyData: { subscription_id: subId || undefined, options: customerProduct.options, diff --git a/server/tests/integration/crud/customers/customer-scope.test.ts b/server/tests/integration/crud/customers/customer-scope.test.ts new file mode 100644 index 000000000..8ed9db1cc --- /dev/null +++ b/server/tests/integration/crud/customers/customer-scope.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ApiCustomerV5Schema, +} from "@shared/api/customers/apiCustomerV5"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CUSTOMER SCOPE — `scope` field on subscriptions and purchases +// +// Contract under test: +// New types/fields: +// - ApiSubscriptionV1.scope: "customer" | "entity" +// - ApiPurchaseV0.scope: "customer" | "entity" +// New behaviors: +// - Customer-level product (internal_entity_id === null) → "customer" +// - Entity-level product (internal_entity_id !== null) → "entity" +// Side effects: none (pure projection from existing FullCusProduct). +// +// Pre-impl red: scope field is undefined, schema parse rejects. +// Post-impl green: all assertions pass. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("customer scope: customer-level subscription has scope=customer, entity-level has scope=entity")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const creditsItem = items.monthlyCredits({ includedUsage: 200 }); + + const cusLevelProd = products.pro({ + id: "cus-lvl-scope", + items: [messagesItem], + }); + const entityProd = products.base({ + id: "ent-prod-scope", + items: [creditsItem], + }); + + const customerId = "customer-scope-test"; + + const { autumnV2_2, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [cusLevelProd, entityProd] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: cusLevelProd.id }), + s.attach({ + productId: entityProd.id, + entityIndex: 0, + }), + ], + }); + + const customer = await autumnV2_2.customers.get(customerId, { + keepInternalFields: true, + }); + ApiCustomerV5Schema.parse(customer); + + // ── Customer-level subscription ── + const cusSub = customer.subscriptions.find( + (s) => s.plan_id === cusLevelProd.id, + ); + expect(cusSub).toBeDefined(); + expect(cusSub!.scope).toBe("customer"); + + // ── Entity-level subscription ── + const entSub = customer.subscriptions.find( + (s) => s.plan_id === entityProd.id, + ); + expect(entSub).toBeDefined(); + expect(entSub!.scope).toBe("entity"); + }, +); + +test.concurrent( + `${chalk.yellowBright("customer scope: purchase (one-off) has scope=customer")}`, + async () => { + const oneOffItem = items.oneOffMessages({ includedUsage: 50 }); + const oneOffProd = products.oneOff({ + id: "one-off-scope", + items: [oneOffItem], + }); + + const customerId = "customer-scope-one-off"; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [s.billing.attach({ productId: oneOffProd.id })], + }); + + const customer = await autumnV2_2.customers.get(customerId, { + keepInternalFields: true, + }); + ApiCustomerV5Schema.parse(customer); + + expect(customer.purchases.length).toBe(1); + expect(customer.purchases[0].scope).toBe("customer"); + }, +); + +test.concurrent( + `${chalk.yellowBright("customer scope: cached and uncached reads match")}`, + async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "scope-cache", items: [messagesItem] }); + + const customerId = "customer-scope-cache"; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const cached = await autumnV2_2.customers.get(customerId); + expect(cached.subscriptions[0].scope).toBe("customer"); + + const uncached = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + expect(uncached.subscriptions[0].scope).toBe("customer"); + }, +); diff --git a/shared/api/customers/cusPlans/apiSubscriptionV1.ts b/shared/api/customers/cusPlans/apiSubscriptionV1.ts index 022fe6e2e..947c7623d 100644 --- a/shared/api/customers/cusPlans/apiSubscriptionV1.ts +++ b/shared/api/customers/cusPlans/apiSubscriptionV1.ts @@ -52,6 +52,10 @@ export const ApiSubscriptionV1Schema = z.object({ quantity: z.number().meta({ description: "Number of units of this subscription (for per-seat plans).", }), + scope: z.enum(["customer", "entity"]).optional().meta({ + description: + "Whether this subscription is attached at the customer level or entity level.", + }), }); export const ApiPurchaseV0Schema = z.object({ @@ -71,6 +75,10 @@ export const ApiPurchaseV0Schema = z.object({ quantity: z.number().meta({ description: "Number of units purchased.", }), + scope: z.enum(["customer", "entity"]).optional().meta({ + description: + "Whether this purchase is attached at the customer level or entity level.", + }), }); export type ApiSubscriptionV1 = z.infer; diff --git a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts index 113aa618f..0130e557f 100644 --- a/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts +++ b/shared/api/customers/cusPlans/mappers/apiSubscriptionV1ToPurchaseV0.ts @@ -13,5 +13,6 @@ export function apiSubscriptionV1ToPurchaseV0({ expires_at: input.expires_at, started_at: input.started_at, quantity: input.quantity, + scope: input.scope, }; }