From 0b6d767f8b733753f810f031e2da1e68829a917b Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 16 Jan 2026 14:37:41 +0000 Subject: [PATCH] fix: update subscription next cycle end floored --- .../skills/write-test/references/ENTITIES.md | 58 ++ .../write-test/references/EXPECTATIONS.md | 39 +- .../skills/write-test/references/GOTCHAS.md | 466 ++++++------- scripts/testGroups/g1.sh | 1 + scripts/testGroups/g4.sh | 26 +- scripts/testGroups/g5.sh | 34 +- scripts/testGroups/g6.sh | 25 - scripts/testGroups/{g7.sh => legacy/alex.sh} | 0 .../legacy/update-paid-allocated.sh | 10 + scripts/testGroups/update-subscription.sh | 7 +- server/src/external/redis/redisUtils.ts | 24 + .../deduction/executePostgresDeduction.ts | 175 ++--- .../utils/deduction/executeRedisDeduction.ts | 10 +- .../executeStripeSubscriptionOperation.ts | 15 +- .../v2/setup/setupFeatureQuantitiesContext.ts | 11 - .../computeUpdateQuantityDetails.ts | 5 +- .../errors/handleFeatureQuantityErrors.ts | 57 +- .../errors/handleUpdateSubscriptionErrors.ts | 7 +- .../getBillingCycleAnchorForDirection.ts | 26 + .../billingPlanToNextCyclePreview.ts | 16 + .../applyExistingUsages.ts | 48 ++ .../cusProductToExistingUsages.ts | 9 +- .../mergeEntitiesWithExistingUsages.ts | 27 +- .../initCustomerEntitlementEntities.ts | 4 +- .../initFullCustomerProduct.ts | 1 + .../customerProductToArrearLineItems.ts | 66 ++ .../lineItems/customerProductToLineItems.ts | 21 +- .../lineItems/getLineItemBillingPeriod.ts | 63 ++ .../customers/add-product/initCusEnt.ts | 5 +- .../cusEnts/cusEntUtils/findCusEntUtils.ts | 14 - .../getApiBalance/getApiBalance.ts | 14 +- .../handleCreateEntity/autoCreateEntity.ts | 3 +- .../createEntityForCusProduct.ts | 142 ++-- .../handleCreateEntity/getInputEntities.ts | 2 +- .../handleCreateEntity/handleCreateEntity2.ts | 19 +- .../handleDeleteEntity/handleDeleteEntity.ts | 22 +- server/src/utils/scriptUtils/constructItem.ts | 9 +- .../tests/balances/check/basic/check2.test.ts | 2 +- .../track-entity-balances6.test.ts} | 14 +- .../track-paid-allocated7.test.ts | 115 ++++ server/tests/contUse/entities/entity1.test.ts | 186 ----- server/tests/contUse/entities/entity2.test.ts | 173 ----- server/tests/contUse/entities/entity3.test.ts | 160 ----- server/tests/contUse/entities/entity4.test.ts | 216 ------ server/tests/contUse/entities/entity5.test.ts | 155 ----- server/tests/contUse/roles/role1.test.ts | 231 ------- server/tests/contUse/roles/role3.test.ts | 236 ------- .../update-per-entity-product.test.ts | 489 +++++++++++++ .../update-feature-quantity-errors.test.ts | 41 +- .../free-trial/update-paid-trials.test.ts | 2 +- .../free-trial/update-remove-trial.test.ts | 12 +- .../update-trial-edge-cases.test.ts | 98 +++ .../update-action-required-basic.test.ts | 2 +- .../update-action-required-scenarios.test.ts | 79 --- .../invoice/update-invoice-deferred.test.ts | 6 +- .../multi-entity-from-free.test.ts | 10 +- .../schedules-free-to-paid.test.ts | 154 ----- .../multi-product/schedules-from-paid.test.ts | 14 +- .../preview-total/preview-proration.test.ts | 80 --- .../multi-feature-quantity.test.ts | 482 ++++++------- .../quantity-while-cancelling.test.ts | 47 +- .../quantity-with-cancellation.test.ts | 104 --- .../utils/expectCustomerProductTrialing.ts | 6 +- .../billing/utils/stripeSubscriptionUtils.ts | 8 - .../crud/customers/create-customer1.test.ts | 0 .../crud/customers/create-customer2.test.ts | 0 .../crud/customers/list-customers.test.ts | 0 .../create-entity/create-entity-paid.test.ts | 641 ++++++++++++++++++ .../create-entity/create-entity-race.test.ts | 70 ++ .../create-entity/utils/expectEntityUtils.ts | 167 +++++ .../crud/plan/create-plan-basic.test.ts | 0 .../crud/plan/create-plan-boolean.test.ts | 0 .../crud/plan/create-plan-complex.test.ts | 0 .../plan/create-plan-cross-version.test.ts | 0 .../crud/plan/create-plan-flags.test.ts | 0 .../crud/plan/create-plan-metered.test.ts | 0 .../crud/plan/create-plan-pricing.test.ts | 0 .../crud/plan/create-plan-tiered.test.ts | 0 .../plan/create-plan-usage-pricing.test.ts | 0 .../crud/plan/create-plan-validation.test.ts | 0 .../crud/plan/get-plan/get-plan1.test.ts | 0 .../crud/plan/update-plan.test.ts | 0 .../apply-existing-usages1.test.ts | 4 + .../apply-existing-usages2.test.ts | 18 +- .../apply-existing-usages3.test.ts | 25 +- .../apply-existing-usages4.test.ts | 169 ++++- .../cus-product-to-existing-usages.test.ts | 2 +- .../get-cycle-end/get-cycle-end-floor.test.ts | 238 +++++++ .../get-cycle-start-floor.test.ts | 164 +++++ ...compute-update-subscription-intent.spec.ts | 21 - server/tests/utils/fixtures/items.ts | 15 + .../billingUtils/cycleUtils/getCycleEnd.ts | 24 +- .../billingUtils/cycleUtils/getCycleStart.ts | 20 +- .../cycleUtils/getLineItemBillingPeriod.ts | 33 - shared/utils/billingUtils/index.ts | 2 - .../cusProductToArrearLineItems.ts | 66 -- .../lineItemBuilders/usagePriceToLineItem.ts | 10 +- .../balanceUtils/cusEntsToPrepaidQuantity.ts | 15 +- .../balanceUtils/cusEntsToPurchasedBalance.ts | 5 +- .../balanceUtils/cusEntsToUsage.ts | 6 +- .../utils/cusEntUtils/classifyCusEntUtils.ts | 7 + .../findCustomerEntitlementByFeature.ts | 71 ++ shared/utils/cusEntUtils/index.ts | 1 + .../productUtils/entUtils/classifyEntUtils.ts | 11 + .../utils/productUtils/entUtils/entUtils.ts | 12 - shared/utils/productUtils/entUtils/index.ts | 1 - .../components/sheets/BalanceEditSheet.tsx | 2 + .../hooks/useFeatureUsageBalance.ts | 6 +- 108 files changed, 3583 insertions(+), 2846 deletions(-) delete mode 100755 scripts/testGroups/g6.sh rename scripts/testGroups/{g7.sh => legacy/alex.sh} (100%) create mode 100755 scripts/testGroups/legacy/update-paid-allocated.sh create mode 100644 server/src/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts create mode 100644 server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts rename server/tests/{contUse/roles/role2.test.ts => balances/track/entity-balances/track-entity-balances6.test.ts} (95%) create mode 100644 server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts delete mode 100644 server/tests/contUse/entities/entity1.test.ts delete mode 100644 server/tests/contUse/entities/entity2.test.ts delete mode 100644 server/tests/contUse/entities/entity3.test.ts delete mode 100644 server/tests/contUse/entities/entity4.test.ts delete mode 100644 server/tests/contUse/entities/entity5.test.ts delete mode 100644 server/tests/contUse/roles/role1.test.ts delete mode 100644 server/tests/contUse/roles/role3.test.ts create mode 100644 server/tests/integration/billing/update-subscription/custom-plan/update-per-entity-product.test.ts delete mode 100644 server/tests/integration/billing/update-subscription/preview-total/preview-proration.test.ts delete mode 100644 server/tests/integration/billing/update-subscription/update-quantity/quantity-with-cancellation.test.ts rename server/tests/{ => integration}/crud/customers/create-customer1.test.ts (100%) rename server/tests/{ => integration}/crud/customers/create-customer2.test.ts (100%) rename server/tests/{ => integration}/crud/customers/list-customers.test.ts (100%) create mode 100644 server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts create mode 100644 server/tests/integration/crud/entities/create-entity/create-entity-race.test.ts create mode 100644 server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts rename server/tests/{ => integration}/crud/plan/create-plan-basic.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-boolean.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-complex.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-cross-version.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-flags.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-metered.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-pricing.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-tiered.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-usage-pricing.test.ts (100%) rename server/tests/{ => integration}/crud/plan/create-plan-validation.test.ts (100%) rename server/tests/{ => integration}/crud/plan/get-plan/get-plan1.test.ts (100%) rename server/tests/{ => integration}/crud/plan/update-plan.test.ts (100%) create mode 100644 server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-floor.test.ts create mode 100644 server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-floor.test.ts delete mode 100644 shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts delete mode 100644 shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts create mode 100644 shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementByFeature.ts delete mode 100644 shared/utils/productUtils/entUtils/entUtils.ts diff --git a/.claude/skills/write-test/references/ENTITIES.md b/.claude/skills/write-test/references/ENTITIES.md index de57f2ba9..d5d880247 100644 --- a/.claude/skills/write-test/references/ENTITIES.md +++ b/.claude/skills/write-test/references/ENTITIES.md @@ -4,6 +4,64 @@ Entities enable per-entity product attachments (workspaces, projects, teams, sea ## Core Concepts +### Two Entity Patterns (Important!) + +There are **two distinct ways** to use entities in Autumn: + +#### 1. Entity Products (Attaching products TO entities) +- Each entity gets its own product attachment +- Entities have independent subscriptions +- Tracking on entity deducts from entity's own balance first +- See: `server/tests/balances/track/entity-products/` + +```typescript +// Attach product TO an entity +await autumnV1.attach({ + customer_id: customerId, + entity_id: entity.id, // Product attached to this entity + product_id: pro.id, +}); +``` + +#### 2. Per-Entity Features (Products with entity-scoped balances) +- Customer has ONE product with features that track usage PER entity +- Each entity gets its own balance allocation for that feature +- Uses `entity_feature_id` in product item configuration +- See: `server/tests/balances/track/entity-balances/` + +```typescript +// Product item with per-entity balance +const perEntityMessages = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, + entityFeatureId: TestFeature.Users, // Each User entity gets 1000 messages +}); + +// Attach product to CUSTOMER (not entity) +await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, // No entity_id - attached at customer level +}); + +// Create entities - each automatically gets their per-entity balance +await autumnV1.entities.create(customerId, [ + { id: "user1", name: "User 1", feature_id: TestFeature.Users }, + { id: "user2", name: "User 2", feature_id: TestFeature.Users }, +]); + +// Track usage for specific entity +await autumnV1.track({ + customer_id: customerId, + entity_id: "user1", + feature_id: TestFeature.Messages, + value: 100, // Deducts from user1's 1000 balance +}); +``` + +**Key Difference:** +- **Entity Products**: `attach({ entity_id })` - product belongs to entity +- **Per-Entity Features**: `entity_feature_id` in item config - balance distributed to entities + ### What are Entities? Entities are sub-units of a customer that can have their own: diff --git a/.claude/skills/write-test/references/EXPECTATIONS.md b/.claude/skills/write-test/references/EXPECTATIONS.md index ad7e0c81a..a27552495 100644 --- a/.claude/skills/write-test/references/EXPECTATIONS.md +++ b/.claude/skills/write-test/references/EXPECTATIONS.md @@ -5,7 +5,7 @@ ```typescript import { expectCustomerFeatureCorrect, expectCustomerFeatureExists } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; @@ -91,6 +91,43 @@ await expectProductActive({ }); ``` +### `expectProductCanceling` + +Verify product is in canceling state (scheduled for removal at end of billing cycle). This is the state a product enters after a downgrade - it remains active until the billing cycle ends. + +**Important:** Canceling is NOT a status value. The product has `status: "active"` with `canceled_at` set. + +```typescript +// Works with both customers and entities +const entity = await autumnV1.entities.get(customerId, entityId); +await expectProductCanceling({ + customer: entity, // Pass entity data here + productId: premium.id, +}); +``` + +### `expectProductScheduled` + +Verify product is scheduled (waiting to become active at end of billing cycle). + +```typescript +await expectProductScheduled({ + customer, + productId: pro.id, +}); +``` + +### `expectProductNotPresent` + +Verify product does not exist for customer/entity. + +```typescript +await expectProductNotPresent({ + customer, + productId: pro.id, +}); +``` + ### `expectProductTrialing` Verify product is in trial state. diff --git a/.claude/skills/write-test/references/GOTCHAS.md b/.claude/skills/write-test/references/GOTCHAS.md index 3dbb133b8..91605f54a 100644 --- a/.claude/skills/write-test/references/GOTCHAS.md +++ b/.claude/skills/write-test/references/GOTCHAS.md @@ -1,344 +1,280 @@ -# Common Gotchas and Debugging +# Test Gotchas -## Test Context (`ctx`) +Quick reference for common mistakes. Each gotcha follows the format: +- **Wrong** / **Right** examples +- Brief explanation if needed -All tests have access to `ctx` which contains: -```typescript -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +--- -ctx.org // Test organization -ctx.db // Database connection -ctx.features // Organization features -ctx.stripeCli // Stripe client -ctx.env // Environment (sandbox) -``` - -## Autumn Client Initialization - -### Default (Secret Key) -```typescript -const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); -``` - -### Public Key (Limited Access) -```typescript -const autumnPublic = new AutumnInt({ - version: ApiVersion.V1_2, - secretKey: ctx.org.test_pkey!, -}); -``` - -**Public key restrictions:** -- Can only access: `GET /products`, `POST /entitled`, `POST /check`, `POST /attach`, `GET /customers/:id` -- Cannot send events (`send_event: true` is silently ignored) - -### With Custom Config -```typescript -const autumn = new AutumnInt({ - version: ApiVersion.V1_2, - orgConfig: { include_past_due: true }, -}); -``` - -## Payment Method Required for Paid Features - -**If your product has ANY price, you MUST attach a payment method:** - -```typescript -// CORRECT - Product with prices -s.customer({ paymentMethod: "success" }) - -// OR with legacy init: -await initCustomerV3({ - ctx, - customerId, - attachPm: "success", // Required! -}); - -// WRONG - Missing payment method for paid product -s.customer({}) // Will fail on billing -``` - -Use `paymentMethod: "success"` when product has: -- Overage pricing (arrear items) -- Per-seat pricing -- Usage-based billing -- Any base price - -## Test Clock Issues - -### `Date.now()` Doesn't Change +## Setup & Initialization +### Payment Method Required for Paid Features ```typescript // WRONG -expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14)); +s.customer({}) -// CORRECT - Use advancedTo from initScenario -expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14)); +// RIGHT +s.customer({ paymentMethod: "success" }) ``` +Required for: overage pricing, per-seat, usage-based billing, any base price. -### Test Clock Must Be Enabled - -```typescript -// testClock defaults to true, but if disabled: -s.customer({ testClock: false }) - -// Then s.advanceTestClock will throw -``` - -## Product ID Issues - -### Always Use `product.id`, Not Strings - +### Product IDs - Use Variables, Not Strings ```typescript // WRONG s.attach({ productId: "pro" }) -await autumnV1.attach({ product_id: `pro_${customerId}` }); -// CORRECT +// RIGHT s.attach({ productId: pro.id }) -await autumnV1.attach({ product_id: pro.id }); ``` +Products are prefixed by `initScenario`. Always use `product.id`. -Products are mutated by `initScenario` to include the prefix. Using `product.id` ensures correct prefixed ID. - -## Error Testing - -### Use `expectAutumnError`, Not try-catch - -```typescript -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; -import { ErrCode } from "@autumn/shared"; - -// CORRECT -await expectAutumnError({ - errCode: ErrCode.CustomerNotFound, - func: async () => { - await autumn.customers.get("invalid-id"); - }, -}); - -// WRONG - Don't use try-catch -let errorThrown = false; -try { - await autumn.customers.get("invalid-id"); -} catch (error) { - errorThrown = true; -} -expect(errorThrown).toBe(true); -``` - -**Common Error Codes:** -- `ErrCode.CustomerNotFound` -- `ErrCode.ProductNotFound` -- `ErrCode.FeatureNotFound` -- `ErrCode.InsufficientBalance` -- `ErrCode.DuplicateIdempotencyKey` -- `ErrCode.InvalidRequest` - -## Lifetime/One-Off Interval Values - -### Constructing Features - -Use `interval: null` for lifetime features: - -```typescript -const lifetimeMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, // null = lifetime/one-off -}); -``` - -### In API Responses - -Lifetime breakdowns use `"one_off"`, NOT `null`: - -```typescript -// API response structure: -{ - "reset": { - "interval": "one_off", // NOT null! - "resets_at": null - } -} - -// CORRECT - Use ResetInterval enum -import { ResetInterval } from "@autumn/shared"; - -const lifetimeBreakdown = res.balance?.breakdown?.find( - b => b.reset?.interval === ResetInterval.OneOff -); - -// WRONG - null won't match -const wrong = res.balance?.breakdown?.find( - b => b.reset?.interval === null // Won't find lifetime! -); -``` - -## Prepaid Gotchas - -### Quantity Required - -```typescript -// WRONG - Missing options -s.attach({ productId: pro.id }) - -// CORRECT -s.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], -}) -``` - -### Quantity Rounds UP to Billing Units - -```typescript -// With billingUnits: 100: -// quantity: 50 → rounds to 100 credits -// quantity: 150 → rounds to 200 credits - -// To get exactly 50, use billingUnits: 1 or billingUnits: 50 -``` - -### Quantity Goes to `purchased_balance`, Not `granted_balance` - -```typescript -// With includedUsage: 0, billingUnits: 100, quantity: 50: -// - Rounds to 100 -// - granted_balance: 0 (from includedUsage) -// - purchased_balance: 100 (from quantity) -// - current_balance: 100 (total) -``` - -## Multiple Products - -### Unique IDs Required - +### Multiple Products Need Unique IDs ```typescript // WRONG - Same default ID const prod1 = constructProduct({ type: "free", items: [...] }); const prod2 = constructProduct({ type: "free", items: [...] }); -// CORRECT +// RIGHT const prod1 = constructProduct({ type: "free", id: "prod1", items: [...] }); -const prod2 = constructProduct({ type: "free", id: "prod2", items: [...] }); +const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, items: [...] }); ``` +Without `isAddOn: true`, second product **replaces** the first. -### Second Product Needs `isAddOn: true` - -Without it, second product **replaces** the first: - +### Product Fixtures with Built-in Base Price +`products.pro`, `products.proWithTrial`, etc. already include a base price. Only `products.base` has no base price. ```typescript -// WRONG - prod2 replaces prod1 -const prod1 = constructProduct({ type: "free", id: "prod1", ... }); -const prod2 = constructProduct({ type: "free", id: "prod2", ... }); +// WRONG - Double base price (pro already has $20/mo) +const priceItem = items.monthlyPrice({ price: 20 }); +const pro = products.pro({ + id: "pro", + items: [messagesItem, priceItem], // Now has TWO base prices! +}); -// CORRECT -const prod1 = constructProduct({ type: "free", id: "prod1", ... }); -const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, ... }); +// RIGHT - Use products.base when you need a reference to the price item +const priceItem = items.monthlyPrice({ price: 20 }); +const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], +}); +// Now priceItem.price! can be used in assertions + +// ALSO RIGHT - Use pro/proWithTrial if you don't need the price reference +const pro = products.pro({ id: "pro", items: [messagesItem] }); +const proTrial = products.proWithTrial({ id: "pro", items: [messagesItem], trialDays: 14 }); ``` -## Product States After Downgrade +--- -When downgrading from Product A to Product B: -- **Product A**: enters "canceling" state (`status: "active"` but `canceled_at` is set) -- **Product B**: enters "scheduled" state (`status: "scheduled"`) - -After billing cycle ends: -- **Product A**: removed/expired -- **Product B**: becomes "active" +## API & Types +### Error Testing - Use `expectAutumnError` ```typescript -// After downgrade: -await expectProductCanceling({ customer, productId: premium.id }); -await expectProductScheduled({ customer, productId: pro.id }); +// WRONG +try { await autumn.customers.get("invalid"); } catch { errorThrown = true; } -// After billing cycle: -await expectProductNotPresent({ customer, productId: premium.id }); -await expectProductActive({ customer, productId: pro.id }); +// RIGHT +await expectAutumnError({ + errCode: ErrCode.CustomerNotFound, + func: () => autumn.customers.get("invalid"), +}); ``` -## Sync Timing Issues +### Entities Use `customer:` Not `entity:` +```typescript +// WRONG +expectCustomerFeatureCorrect({ entity: entityData, ... }); + +// RIGHT +expectCustomerFeatureCorrect({ customer: entityData, ... }); +``` +`expectCustomerFeatureCorrect` accepts both `ApiCustomerV3` and `ApiEntityV0` via the `customer` param. + +### Lifetime Interval: `null` vs `"one_off"` +```typescript +// Constructing: use null +constructFeatureItem({ interval: null }); + +// In API responses: use ResetInterval.OneOff +breakdown.find(b => b.reset?.interval === ResetInterval.OneOff); +// NOT: b.reset?.interval === null (won't match!) +``` + +--- + +## Timing & Sync + +### Test Clock: `Date.now()` Doesn't Change +```typescript +// WRONG +expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14)); + +// RIGHT +expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14)); +``` ### Wait After Track Before Attach - -`track` updates Redis immediately but syncs to Postgres asynchronously. `attach` rebuilds cache from Postgres. - ```typescript // WRONG - Cache gets stale data await autumnV1.track({ ... }); await autumnV1.attach({ ... }); -// CORRECT +// RIGHT await autumnV1.track({ ... }); await new Promise(r => setTimeout(r, 2000)); await autumnV1.attach({ ... }); ``` +`track` syncs to Postgres async; `attach` rebuilds from Postgres. -Not an issue if you attach all products in setup before tracking. - -### Cache vs Database Verification - +### Cache vs Database ```typescript -// Cached (immediate) const cached = await autumnV1.customers.get(customerId); - -// Database (skip cache) await new Promise(r => setTimeout(r, 2000)); const fromDb = await autumnV1.customers.get(customerId, { skip_cache: "true" }); ``` -## Invoice Count Mismatches +--- -### Allocated Features Create Invoices on Track +## Prepaid Features +### Quantity Required on Attach ```typescript -// Product: 3 included seats @ $10/seat overage -await autumnV1.track({ value: 5 }); // 2 over included -// Invoice count = attach (1) + track overage (1) = 2 +// WRONG +s.attach({ productId: pro.id }) + +// RIGHT +s.attach({ productId: pro.id, options: [{ feature_id: TestFeature.Messages, quantity: 200 }] }) ``` -### Consumable Features Don't Charge on Update - +### Quantity Rounds UP to Billing Units ```typescript -// Overage billed at cycle end, not on update -expect(preview.total).toBe(0); // Even with overage +// billingUnits: 100 +// quantity: 50 → 100 credits +// quantity: 150 → 200 credits ``` -### Prepaid Refund/Charge Logic +### Quantity Goes to `purchased_balance` +```typescript +// includedUsage: 0, billingUnits: 100, quantity: 50 +// granted_balance: 0, purchased_balance: 100, current_balance: 100 +``` +### Consumable + Prepaid on Same Feature: Balances Sum +When a product has both consumable and prepaid items for the same feature, they create **separate breakdown entries** that sum together: +```typescript +// consumableItem with includedUsage: 50 +// prepaidItem with quantity: 100 +// Results in TWO breakdown entries for the same feature: +// - Consumable: granted_balance: 50, purchased_balance: 0 +// - Prepaid: granted_balance: 0, purchased_balance: 100 +// Aggregated totals: +// - granted_balance: 50 +// - purchased_balance: 100 +// - current_balance: 150 (sum of both) +// - included_usage: 150 (NOT just prepaid quantity!) + +// WRONG +expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 100, // Only prepaid quantity + balance: 100, +}); + +// RIGHT +expectCustomerFeatureCorrect({ + customer: entity, + featureId: TestFeature.Messages, + includedUsage: 150, // 50 (consumable) + 100 (prepaid) + balance: 150, +}); +``` + +--- + +## Billing & Invoices + +### Consumable Overage: Not Charged on Update +```typescript +expect(preview.total).toBe(0); // Even with existing overage +``` +Overage billed at cycle end, not on subscription update. + +### Allocated Features: Invoice on Track +```typescript +// 3 included seats, track 5 → 2 overage +// Invoice count = attach (1) + overage (1) = 2 +``` + +### Prepaid Charge = Diff, Not Total ```typescript // Old: 2 packs @ $10 = $20 // New: 5 packs @ $10 = $50 -// preview.total = $50 - $20 = $30 (NOT $50!) +// preview.total = $30 (NOT $50) ``` -## Free-to-Free Tests - -Skip `expectSubToBeCorrect` for free products (no Stripe subscription exists): - +### Free-to-Free: Skip Subscription Check ```typescript -// Free-to-free: No subscription check +// Free products have no Stripe subscription expectCustomerFeatureCorrect({ ... }); // Don't call expectSubToBeCorrect - -// Free-to-paid or paid-to-paid: Check subscription -await expectSubToBeCorrect({ db: ctx.db, customerId, org: ctx.org, env: ctx.env }); ``` -## Server Logs Not Visible +--- -Console logs in server code don't appear in test output. Ask the user to check server logs directly. - -## Decimal.js for Balance Calculations +## Product States +### Canceling/Downgrading is NOT a Status ```typescript -import { Decimal } from "decimal.js"; +// WRONG - Checking status for canceling products +expect(product.status).toBe("canceling"); // "canceling" is not a valid status! -// WRONG - Floating point error +// RIGHT - Use expectProductCanceling helper +await expectProductCanceling({ customer, productId: premium.id }); +``` +A product that is canceling or downgrading has `status: "active"` with `canceled_at` set. The "canceling" state is a derived state, not a status value. + +### After Downgrade (A → B) +- **Product A**: "canceling" (`status: "active"`, `canceled_at` set) → use `expectProductCanceling` +- **Product B**: "scheduled" (`status: "scheduled"`) → use `expectProductScheduled` + +After billing cycle: +- **Product A**: removed +- **Product B**: "active" + +### expectProductCanceling Works with Entities +```typescript +// For entities, pass the entity data to the customer param +const entity1Data = await autumnV1.entities.get(customerId, entities[0].id); +await expectProductCanceling({ + customer: entity1Data, + productId: premium.id, +}); +``` + +--- + +## Misc + +### Server Logs Not Visible in Tests +Console logs in server code don't appear in test output. Check server logs directly. + +### Decimal.js for Balance Math +```typescript +// WRONG expect(balance).toBe(100 - 23.47); -// CORRECT +// RIGHT expect(balance).toBe(new Decimal(100).sub(23.47).toNumber()); ``` + +--- + +## Quick Reference + +| Context | Import | +|---------|--------| +| Test context | `import ctx from "@tests/utils/testInitUtils/createTestContext.js"` | +| Error testing | `import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"` | +| Error codes | `import { ErrCode } from "@autumn/shared"` | +| Reset intervals | `import { ResetInterval } from "@autumn/shared"` | +| Decimal math | `import { Decimal } from "decimal.js"` | diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 148654c92..011ca8994 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -31,6 +31,7 @@ BUN_PARALLEL_COMPACT \ 'server/tests/balances/check/prepaid' \ 'server/tests/balances/check/send-event' \ 'server/tests/balances/check/loose' \ + 'server/tests/balances/set-usage' \ --max=6 diff --git a/scripts/testGroups/g4.sh b/scripts/testGroups/g4.sh index 561bd8d25..0aeac69f0 100755 --- a/scripts/testGroups/g4.sh +++ b/scripts/testGroups/g4.sh @@ -4,10 +4,24 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ - 'server/tests/contUse/roles' \ - 'server/tests/contUse/update' \ - 'server/tests/contUse/entities' \ - 'server/tests/balances/set-usage' \ - --max=6 + 'server/tests/merged/downgrade' \ + 'server/tests/merged/separate' \ + 'server/tests/merged/add' \ + 'server/tests/merged/group' \ + 'server/tests/merged/prepaid' \ + 'server/tests/merged/upgrade' \ + 'server/tests/merged/addOn' \ + 'server/tests/merged/trial' \ + 'server/tests/core/cancel' \ + --max=6 \ + + + - # 'server/tests/contUse/track' \ + + +# deprecated tests(?) +# 'server/tests/core/multiAttach' \ +# 'server/tests/core/multiAttach/multiInvoice' \ +# 'server/tests/core/multiAttach/multiUpgrade' \ +# 'sever/tests/core/multiAttach/multiReward' diff --git a/scripts/testGroups/g5.sh b/scripts/testGroups/g5.sh index 0aeac69f0..402b3a99e 100755 --- a/scripts/testGroups/g5.sh +++ b/scripts/testGroups/g5.sh @@ -3,25 +3,23 @@ # Source shared configuration source "$(dirname "$0")/config.sh" + + BUN_PARALLEL_COMPACT \ - 'server/tests/merged/downgrade' \ - 'server/tests/merged/separate' \ - 'server/tests/merged/add' \ - 'server/tests/merged/group' \ - 'server/tests/merged/prepaid' \ - 'server/tests/merged/upgrade' \ - 'server/tests/merged/addOn' \ - 'server/tests/merged/trial' \ - 'server/tests/core/cancel' \ - --max=6 \ - - - + 'server/tests/advanced/coupons' \ + 'server/tests/advanced/misc' \ + 'server/tests/attach/updateQuantity' \ + 'server/tests/attach/multiProduct' \ + 'server/tests/advanced/multiFeature' \ + 'server/tests/advanced/referrals' \ + 'server/tests/advanced/rollovers' \ + 'server/tests/advanced/customInterval' \ + 'server/tests/advanced/usageLimit' \ + --max=6 +# BUN_PARALLEL_COMPACT \ +# 'server/tests/advanced/usage' +# # 'server/tests/crud/plan' -# deprecated tests(?) -# 'server/tests/core/multiAttach' \ -# 'server/tests/core/multiAttach/multiInvoice' \ -# 'server/tests/core/multiAttach/multiUpgrade' \ -# 'sever/tests/core/multiAttach/multiReward' +# # 'server/tests/advanced/referrals/paid' \ \ No newline at end of file diff --git a/scripts/testGroups/g6.sh b/scripts/testGroups/g6.sh deleted file mode 100755 index 402b3a99e..000000000 --- a/scripts/testGroups/g6.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Source shared configuration -source "$(dirname "$0")/config.sh" - - - -BUN_PARALLEL_COMPACT \ - 'server/tests/advanced/coupons' \ - 'server/tests/advanced/misc' \ - 'server/tests/attach/updateQuantity' \ - 'server/tests/attach/multiProduct' \ - 'server/tests/advanced/multiFeature' \ - 'server/tests/advanced/referrals' \ - 'server/tests/advanced/rollovers' \ - 'server/tests/advanced/customInterval' \ - 'server/tests/advanced/usageLimit' \ - --max=6 - - -# BUN_PARALLEL_COMPACT \ -# 'server/tests/advanced/usage' -# # 'server/tests/crud/plan' - -# # 'server/tests/advanced/referrals/paid' \ \ No newline at end of file diff --git a/scripts/testGroups/g7.sh b/scripts/testGroups/legacy/alex.sh similarity index 100% rename from scripts/testGroups/g7.sh rename to scripts/testGroups/legacy/alex.sh diff --git a/scripts/testGroups/legacy/update-paid-allocated.sh b/scripts/testGroups/legacy/update-paid-allocated.sh new file mode 100755 index 000000000..6ad0af49c --- /dev/null +++ b/scripts/testGroups/legacy/update-paid-allocated.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +BUN_PARALLEL_COMPACT \ + 'server/tests/contUse/update' \ + --max=6 + + # 'server/tests/contUse/track' \ diff --git a/scripts/testGroups/update-subscription.sh b/scripts/testGroups/update-subscription.sh index 2800f947b..68e6edb25 100755 --- a/scripts/testGroups/update-subscription.sh +++ b/scripts/testGroups/update-subscription.sh @@ -6,13 +6,12 @@ source "$(dirname "$0")/config.sh" # Exit immediately if a command exits with a non-zero status set -e -# bun test:integration update-subscription/custom-plan -# bun test:integration update-subscription/discounts -# bun test:integration update-subscription/errors +bun test:integration update-subscription/custom-plan +bun test:integration update-subscription/discounts +bun test:integration update-subscription/errors bun test:integration update-subscription/free-trial bun test:integration update-subscription/invoice bun test:integration update-subscription/multi-product -bun test:integration update-subscription/preview-total bun test:integration update-subscription/update-quantity bun test:integration update-subscription/version-update diff --git a/server/src/external/redis/redisUtils.ts b/server/src/external/redis/redisUtils.ts index f92788a61..8f4a47be3 100644 --- a/server/src/external/redis/redisUtils.ts +++ b/server/src/external/redis/redisUtils.ts @@ -69,3 +69,27 @@ export const acquireLock = async ({ return true; } }; + +/** + * Execute a function with a distributed lock. Acquires lock, runs the function, then releases the lock. + * Ensures lock is always released even if the function throws an error. + */ +export const withLock = async ({ + lockKey, + ttlMs = 10000, + errorMessage = DEFAULT_ERROR_MESSAGE, + fn, +}: { + lockKey: string; + ttlMs?: number; + errorMessage?: string; + fn: () => Promise; +}): Promise => { + await acquireLock({ lockKey, ttlMs, errorMessage }); + + try { + return await fn(); + } finally { + await clearLock({ lockKey }); + } +}; diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index d617dff97..bb90101a5 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -1,10 +1,10 @@ import { ACTIVE_STATUSES, - CusProductStatus, type FullCustomer, InternalError, } from "@autumn/shared"; import { sql } from "drizzle-orm"; +import { withLock } from "@/external/redis/redisUtils.js"; import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js"; import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; @@ -74,30 +74,33 @@ export const executePostgresDeduction = async ({ deductions, }); - let allUpdates: Record = {}; + const executeDeduction = async (): Promise< + Record + > => { + let allUpdates: Record = {}; - // Need to deduct from customer entitlement... - for (const deduction of deductions) { - const { feature, deduction: toDeduct, targetBalance } = deduction; + // Need to deduct from customer entitlement... + for (const deduction of deductions) { + const { feature, deduction: toDeduct, targetBalance } = deduction; - const { - customerEntitlementDeductions, - rolloverIds, - customerEntitlements, - unlimitedFeatureIds, - } = prepareFeatureDeduction({ - ctx, - fullCustomer, - deduction, - options, - }); + const { + customerEntitlementDeductions, + rolloverIds, + customerEntitlements, + unlimitedFeatureIds, + } = prepareFeatureDeduction({ + ctx, + fullCustomer, + deduction, + options, + }); - if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) - continue; + if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) + continue; - // Call the stored function to deduct from entitlements with credit costs - const result = await db.execute( - sql`SELECT * FROM deduct_from_cus_ents( + // Call the stored function to deduct from entitlements with credit costs + const result = await db.execute( + sql`SELECT * FROM deduct_from_cus_ents( ${JSON.stringify({ sorted_entitlements: customerEntitlementDeductions, amount_to_deduct: toDeduct ?? null, @@ -111,82 +114,94 @@ export const executePostgresDeduction = async ({ feature_id: feature.id, })}::jsonb )`, - ); + ); - // Parse the JSONB result - const resultJson = result[0]?.deduct_from_cus_ents as { - updates: Record; - remaining: number; - }; + // Parse the JSONB result + const resultJson = result[0]?.deduct_from_cus_ents as { + updates: Record; + remaining: number; + }; - if (!resultJson) { - throw new InternalError({ - message: "Failed to deduct from entitlements", + if (!resultJson) { + throw new InternalError({ + message: "Failed to deduct from entitlements", + }); + } + + const { updates } = resultJson; + logDeductionUpdates({ + ctx, + fullCustomer, + updates, + source: "executePostgresDeduction", }); - } + allUpdates = { ...allUpdates, ...updates }; - const { updates } = resultJson; - logDeductionUpdates({ - ctx, - fullCustomer, - updates, - source: "executePostgresDeduction", - }); - allUpdates = { ...allUpdates, ...updates }; + try { + for (const cusEntId of Object.keys(updates)) { + const update = updates[cusEntId]; + const cusEnt = customerEntitlements.find((ce) => ce.id === cusEntId); - try { - for (const cusEntId of Object.keys(updates)) { - const update = updates[cusEntId]; - const cusEnt = customerEntitlements.find((ce) => ce.id === cusEntId); + if (!cusEnt) continue; - if (!cusEnt) continue; + await handlePaidAllocatedCusEnt({ + ctx, + cusEnt, + fullCus: fullCustomer, + updates, + }); - await handlePaidAllocatedCusEnt({ + applyDeductionUpdateToFullCustomer({ + fullCus: fullCustomer, + cusEntId, + update, + }); + } + } catch (error) { + if (error instanceof Error && !error?.message?.includes("declined")) { + ctx.logger.error( + `[deductFromCusEnts] Attempting rollback due to error: ${error}`, + ); + } + await rollbackDeduction({ ctx, - cusEnt, - fullCus: fullCustomer, + oldFullCus, updates, }); + throw error; + } - applyDeductionUpdateToFullCustomer({ - fullCus: fullCustomer, - cusEntId, - update, - }); - } - } catch (error) { - if (error instanceof Error && !error?.message?.includes("declined")) { - ctx.logger.error( - `[deductFromCusEnts] Attempting rollback due to error: ${error}`, - ); - } - await rollbackDeduction({ + handleThresholdReached({ ctx, oldFullCus, - updates, + newFullCus: fullCustomer, + feature: deduction.feature, + }).catch((error) => { + ctx.logger.error( + `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, + ); }); - throw error; } - handleThresholdReached({ - ctx, - oldFullCus, - newFullCus: fullCustomer, - feature: deduction.feature, - }).catch((error) => { - ctx.logger.error( - `[executeRedisDeduction] Failed to handle threshold reached: ${error}`, - ); - }); - } + if (refreshCache) { + await deleteCachedApiCustomer({ + customerId, + ctx, + source: "executePostgresDeduction", + }); + } - if (refreshCache) { - await deleteCachedApiCustomer({ - customerId, - ctx, - source: "executePostgresDeduction", - }); - } + return allUpdates; + }; + + const allUpdates = resolvedOptions.paidAllocated + ? await withLock({ + lockKey: `lock:deduction:${org.id}:${env}:${customerId}`, + ttlMs: 10000, + errorMessage: `Deduction for paid feature ${deductions[0]?.feature?.name} already in progress for customer ${customerId}.`, + fn: executeDeduction, + }) + : await executeDeduction(); return { oldFullCus, diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index c2a55be25..389bd3aaf 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -123,11 +123,11 @@ export const executeRedisDeduction = async ({ const resultJson = JSON.parse(result) as LuaDeductionResult; - // if (resultJson.logs && resultJson.logs.length > 0) { - // ctx.logger.debug( - // `[executeRedisDeduction] Logs: ${resultJson.logs.join("\n")}`, - // ); - // } + if (resultJson.logs && resultJson.logs.length > 0) { + ctx.logger.debug( + `[executeRedisDeduction] Logs: ${resultJson.logs.join("\n")}`, + ); + } if (resultJson.error) { throw new RedisDeductionError({ diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts index d00a0d9c2..1391d3584 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -1,4 +1,4 @@ -import { InternalError } from "@autumn/shared"; +import { InternalError, nullish } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; @@ -15,6 +15,7 @@ export const executeStripeSubscriptionOperation = async ({ }) => { const { org, env } = ctx; const stripeClient = createStripeCli({ org, env }); + const { paymentMethod } = billingContext; const invoiceModeParams = billingContext.invoiceMode ? { @@ -23,6 +24,12 @@ export const executeStripeSubscriptionOperation = async ({ } : {}; + // default incomplete used so that payment failure / 3ds errors are clearly handled + const createPaymentBehavior = + nullish(paymentMethod) || paymentMethod?.type === "custom" + ? "default_incomplete" + : "allow_incomplete"; + switch (subscriptionAction.type) { case "update": { let stripeSubscription = billingContext.stripeSubscription; @@ -52,7 +59,11 @@ export const executeStripeSubscriptionOperation = async ({ return await stripeClient.subscriptions.create({ ...subscriptionAction.params, ...invoiceModeParams, - payment_behavior: "allow_incomplete", + + billing_mode: { type: "flexible" }, + + payment_behavior: createPaymentBehavior, + expand: ["latest_invoice"], }); case "cancel": diff --git a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts index 295767e43..4d8eee01f 100644 --- a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts +++ b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts @@ -3,7 +3,6 @@ import { type FeatureOptions, type FullCusProduct, type FullProduct, - formatPrice, isPrepaidPrice, priceToFeature, type UpdateSubscriptionV0Params, @@ -28,16 +27,6 @@ export const setupFeatureQuantitiesContext = ({ }): FeatureOptions[] => { const options: FeatureOptions[] = []; - console.log( - "Full product prices:", - fullProduct.prices.map((price) => formatPrice({ price })), - ); - - console.log( - "Current customer product options:", - currentCustomerProduct?.options, - ); - for (const price of fullProduct.prices) { if (!isPrepaidPrice(price)) continue; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts index 651497c23..015cc4389 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts @@ -10,9 +10,9 @@ import { type LineItem, RecaseError, } from "@autumn/shared"; -import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; +import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod"; import { calculateUpdateQuantityDifferences } from "./calculateUpdateQuantityDifferences"; import { calculateUpdateQuantityEntitlementChange } from "./calculateUpdateQuantityEntitlementChange"; import { computeUpdateQuantityLineItems } from "./computeUpdateQuantityLineItems"; @@ -106,9 +106,8 @@ export const computeUpdateQuantityDetails = ({ } const billingPeriod = getLineItemBillingPeriod({ - anchorMs: billingCycleAnchorMs, + billingContext: updateSubscriptionContext, price: customerPrice.price, - nowMs: currentEpochMs, }); if (!billingPeriod) { diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts index 74de3bd51..d7b59b09f 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleFeatureQuantityErrors.ts @@ -2,29 +2,78 @@ import { cusProductToPrices, ErrCode, isPrepaidPrice, + priceToFeature, RecaseError, + type UpdateSubscriptionV0Params, type UsagePriceConfig, } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan"; +const checkInputFeatureQuantitiesAreValid = ({ + ctx, + params, + autumnBillingPlan, + billingContext, +}: { + ctx: AutumnContext; + params: UpdateSubscriptionV0Params; + autumnBillingPlan: AutumnBillingPlan; + billingContext: UpdateSubscriptionBillingContext; +}) => { + const targetCustomerProduct = + autumnBillingPlan.insertCustomerProducts?.[0] ?? + billingContext.customerProduct; + + const prepaidPrices = cusProductToPrices({ + cusProduct: targetCustomerProduct, + }).filter(isPrepaidPrice); + + for (const option of params.options ?? []) { + const targetPrepaidPrice = prepaidPrices.find((p) => { + const priceFeature = priceToFeature({ + price: p, + features: ctx.features, + errorOnNotFound: false, + }); + return priceFeature?.id === option.feature_id; + }); + + if (!targetPrepaidPrice) { + throw new RecaseError({ + message: `Invalid feature quantity passed in (feature ID: ${option.feature_id}). This feature has no prepaid price on the updated product.`, + }); + } + } +}; + export const handleFeatureQuantityErrors = ({ - // biome-ignore lint/correctness/noUnusedFunctionParameters: consistent signature with other error handlers + ctx, billingContext, autumnBillingPlan, + params, }: { + ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; autumnBillingPlan: AutumnBillingPlan; + params: UpdateSubscriptionV0Params; }) => { + // 1. Check if param feature IDs are valid + checkInputFeatureQuantitiesAreValid({ + ctx, + autumnBillingPlan, + billingContext, + params, + }); + const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; if (!newCustomerProduct) return; const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct }); const prepaidPrices = newPrices.filter(isPrepaidPrice); - if (prepaidPrices.length === 0) { - return; - } + if (prepaidPrices.length === 0) return; const options = newCustomerProduct.options || []; const missingFeatures: string[] = []; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index cdfcac7b7..692d3ef7f 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -39,7 +39,12 @@ export const handleUpdateSubscriptionErrors = async ({ handleProductTypeTransitionErrors({ billingContext, autumnBillingPlan }); // 3. Feature quantity errors (prepaid prices must have options) - handleFeatureQuantityErrors({ billingContext, autumnBillingPlan }); + handleFeatureQuantityErrors({ + ctx, + billingContext, + autumnBillingPlan, + params, + }); // 4. Custom plan errors handleCustomPlanErrors({ ctx, billingContext, autumnBillingPlan, params }); diff --git a/server/src/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection.ts b/server/src/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection.ts new file mode 100644 index 000000000..21b17729e --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection.ts @@ -0,0 +1,26 @@ +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { getCurrentBillingCycleAnchorMs } from "@/internal/billing/v2/utils/billingContext/getCurrentBillingCycleAnchorMs"; + +/** + * Returns the billing cycle anchor to use for a particular direction. + * - "charge": uses the (possibly updated) billingCycleAnchorMs from the context + * - "refund": uses the original billing cycle anchor before update + */ +export const getBillingCycleAnchorForDirection = ({ + billingContext, + direction, +}: { + billingContext: BillingContext; + direction: "charge" | "refund"; +}) => { + const originalBillingCycleAnchorMs = getCurrentBillingCycleAnchorMs({ + billingContext, + }); + + const anchorMs = + direction === "refund" + ? originalBillingCycleAnchorMs + : billingContext.billingCycleAnchorMs; + + return anchorMs; +}; diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts index 657df5372..898140589 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToNextCyclePreview.ts @@ -2,6 +2,7 @@ import { type BillingPreviewResponse, cp, cusProductsToPrices, + formatMs, getCycleEnd, getSmallestInterval, sumValues, @@ -50,13 +51,28 @@ export const billingPlanToNextCyclePreview = ({ if (!smallestInterval) return undefined; + ctx.logger.debug( + `[billingPlanToNextCyclePreview] Billing cycle anchor: ${formatMs(billingCycleAnchorMs)}`, + ); + ctx.logger.debug( + `[billingPlanToNextCyclePreview] Smallest interval: ${smallestInterval.interval}`, + ); + ctx.logger.debug( + `[billingPlanToNextCyclePreview] Current epoch ms: ${formatMs(billingContext.currentEpochMs)}`, + ); + const nextCycleStart = getCycleEnd({ anchor: billingCycleAnchorMs, interval: smallestInterval.interval, intervalCount: smallestInterval.intervalCount, now: billingContext.currentEpochMs, + floor: billingCycleAnchorMs, }); + ctx.logger.debug( + `[billingPlanToNextCyclePreview] Next cycle start: ${formatMs(nextCycleStart)}`, + ); + const autumnLineItems = customerProducts.flatMap((customerProduct) => customerProductToLineItems({ ctx, diff --git a/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts index 84d049b2b..3b4bc0a7c 100644 --- a/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages.ts @@ -4,14 +4,51 @@ import { type ExistingUsages, type FullCusProduct, } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { deductFromCusEntsTypescript } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages"; +const logExistingUsages = ({ + ctx, + existingUsages, +}: { + ctx: AutumnContext; + existingUsages: ExistingUsages; +}) => { + const existinUsagesLogs = Object.entries(existingUsages).map( + ([internalFeatureId, existingUsage]) => { + const entityUsages = Object.entries(existingUsage.entityUsages).map( + ([entityId, entityUsage]) => ({ + entityId, + entityUsage, + }), + ); + return { + featureId: ctx.features.find((f) => f.internal_id === internalFeatureId) + ?.id, + usage: existingUsage.usage, + entityUsages: entityUsages.length > 0 ? entityUsages : undefined, + }; + }, + ); + ctx.logger.debug(`[applyExistingUsages] existing usages:`, existinUsagesLogs); + + addToExtraLogs({ + ctx, + extras: { + existingUsages: existinUsagesLogs, + }, + }); +}; + export const applyExistingUsages = ({ + ctx, customerProduct, existingUsages = {}, entities, }: { + ctx: AutumnContext; customerProduct: FullCusProduct; existingUsages?: ExistingUsages; entities: Entity[]; @@ -22,6 +59,11 @@ export const applyExistingUsages = ({ existingUsages, }); + logExistingUsages({ + ctx, + existingUsages: mergedExistingUsages, + }); + for (const [internalFeatureId, existingUsage] of Object.entries< ExistingUsages[string] >(mergedExistingUsages)) { @@ -55,6 +97,12 @@ export const applyExistingUsages = ({ original.balance = newCusEnt.balance; original.entities = newCusEnt.entities; original.adjustment = newCusEnt.adjustment; + + ctx.logger.debug(`Deduction for feature ${newCusEnt.feature_id}:`, { + balance: newCusEnt.balance, + entities: newCusEnt.entities, + adjustment: newCusEnt.adjustment, + }); } } } diff --git a/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts index d8e0c847b..5bff5a7ef 100644 --- a/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages.ts @@ -46,9 +46,14 @@ export const cusProductToExistingUsages = ({ // 1. If it's entity scoped if (isEntityScopedCusEnt(cusEnt)) { - // const entityUsages = cusEnt.entities; + const entityAllowance = cusEnt.entitlement.allowance ?? 0; for (const [entityId, entityBalance] of Object.entries(cusEnt.entities)) { - currentExistingUsage.entityUsages![entityId] = entityBalance.balance; + // Usage = startingBalance + adjustment - currentBalance + const entityUsage = new Decimal(entityAllowance) + .add(entityBalance.adjustment ?? 0) + .sub(entityBalance.balance) + .toNumber(); + currentExistingUsage.entityUsages![entityId] = entityUsage; } continue; } diff --git a/server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts b/server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts index 51c8b6c6a..445acfcc9 100644 --- a/server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts +++ b/server/src/internal/billing/v2/utils/handleExistingUsages/mergeEntitiesWithExistingUsages.ts @@ -7,19 +7,30 @@ export const mergeEntitiesWithExistingUsages = ({ entities: Entity[]; existingUsages: ExistingUsages; }): ExistingUsages => { - const internalFeatureIdToUsage = new Map(); + // Count entities per feature (for continuous-use features like seats) + const internalFeatureIdToEntityCount = new Map(); for (const entity of entities) { - internalFeatureIdToUsage.set( + internalFeatureIdToEntityCount.set( entity.internal_feature_id, - (internalFeatureIdToUsage.get(entity.internal_feature_id) || 0) + 1, + (internalFeatureIdToEntityCount.get(entity.internal_feature_id) || 0) + 1, ); } - for (const [internalFeatureId, usage] of internalFeatureIdToUsage.entries()) { - existingUsages[internalFeatureId] = { - usage, - entityUsages: {}, - }; + // Add entity counts as top-level usage for features that track entities + // Entity count takes priority over existing usage data + for (const [ + internalFeatureId, + entityCount, + ] of internalFeatureIdToEntityCount.entries()) { + if (!existingUsages[internalFeatureId]) { + existingUsages[internalFeatureId] = { + usage: entityCount, + entityUsages: {}, + }; + } else { + // Entity count takes priority - override existing usage + existingUsages[internalFeatureId].usage = entityCount; + } } return existingUsages; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts index e218a41c9..e9d89328a 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementEntities.ts @@ -2,7 +2,7 @@ import { type EntitlementWithFeature, type Entity, type EntityBalance, - entitlementFeatureMatchesEntityFeature, + entitlementHasEntityFeature, isEntityScopedEntitlement, } from "@autumn/shared"; @@ -20,7 +20,7 @@ export const initCustomerEntitlementEntities = ({ const entities: Record = {}; for (const entity of customerEntities) { - const featureMatches = entitlementFeatureMatchesEntityFeature({ + const featureMatches = entitlementHasEntityFeature({ entitlement, entity, }); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts index aab193140..9f690fcfb 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts @@ -63,6 +63,7 @@ export const initFullCustomerProduct = ({ }; applyExistingUsages({ + ctx, customerProduct: newFullCustomerProduct, existingUsages: initContext.existingUsages, entities: fullCustomer.entities, diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts new file mode 100644 index 000000000..ce0a3cd5e --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts @@ -0,0 +1,66 @@ +import { + cusPriceToCusEntWithCusProduct, + type FullCusProduct, + isConsumablePrice, + type LineItem, + type LineItemContext, + orgToCurrency, + usagePriceToLineItem, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "../../billingContext"; +import { getLineItemBillingPeriod } from "./getLineItemBillingPeriod"; + +export const customerProductToArrearLineItems = ({ + ctx, + customerProduct, + billingContext, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; + billingContext: BillingContext; +}) => { + let lineItems: LineItem[] = []; + + for (const cusPrice of customerProduct.customer_prices) { + const price = cusPrice.price; + + if (!isConsumablePrice(price)) continue; + + // Calculate billing period + const billingPeriod = getLineItemBillingPeriod({ + billingContext, + price, + }); + + const cusEnt = cusPriceToCusEntWithCusProduct({ + cusProduct: customerProduct, + cusPrice, + cusEnts: customerProduct.customer_entitlements, + }); + + if (!cusEnt) { + throw new Error( + `[customerProductToArrearLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`, + ); + } + + const context: LineItemContext = { + price, + product: customerProduct.product, + feature: cusEnt.entitlement.feature, + + billingPeriod, + direction: "charge", + billingTiming: "in_arrear", + now: billingContext.currentEpochMs, + currency: orgToCurrency({ org: ctx.org }), + }; + + lineItems.push(usagePriceToLineItem({ cusEnt, context })); + } + + lineItems = lineItems.filter((item) => item.amount !== 0); + + return lineItems; +}; diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts index ec7cd062d..c76bdb2a9 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts @@ -7,7 +7,6 @@ import { cusPriceToCusEnt, type FullCusProduct, fixedPriceToLineItem, - getLineItemBillingPeriod, isConsumablePrice, isFixedPrice, isOneOffPrice, @@ -17,8 +16,9 @@ import { usagePriceToLineItem, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getBillingCycleAnchorForDirection } from "@/internal/billing/v2/utils/billingContext/getBillingCycleAnchorForDirection"; import type { BillingContext } from "../../billingContext"; -import { getCurrentBillingCycleAnchorMs } from "../billingContext/getCurrentBillingCycleAnchorMs"; +import { getLineItemBillingPeriod } from "./getLineItemBillingPeriod"; export type LineItemDirection = "charge" | "refund"; @@ -47,15 +47,11 @@ export const customerProductToLineItems = ({ }): LineItem[] => { const { billingCycleAnchorMs, currentEpochMs } = billingContext; - const originalBillingCycleAnchorMs = getCurrentBillingCycleAnchorMs({ + const anchorMs = getBillingCycleAnchorForDirection({ billingContext, + direction, }); - const anchorMs = - direction === "refund" - ? originalBillingCycleAnchorMs - : billingCycleAnchorMs; - let lineItems: LineItem[] = []; let filteredCustomerPrices = customerProduct.customer_prices; @@ -69,10 +65,15 @@ export const customerProductToLineItems = ({ const price = cusPrice.price; // Calculate billing period + + const billingContextForPeriod = { + ...billingContext, + billingCycleAnchorMs: anchorMs, + }; + const billingPeriod = getLineItemBillingPeriod({ - anchorMs, + billingContext: billingContextForPeriod, price, - nowMs: currentEpochMs, }); // Build line item context diff --git a/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts b/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts new file mode 100644 index 000000000..787b88221 --- /dev/null +++ b/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts @@ -0,0 +1,63 @@ +import { + type BillingPeriod, + getCycleEnd, + getCycleStart, + isOneOffPrice, + type Price, + secondsToMs, +} from "@autumn/shared"; +import type { BillingContext } from "../../billingContext"; + +/** + * Calculates the billing period (start and end) for a line item based on the billing context. + * + * Uses floor constraints to ensure: + * - Period start is not before the Stripe subscription creation date + * - Period end is not before the billing cycle anchor (e.g., trial end date) + * + * @param billingContext - The billing context containing anchors and timestamps + * @param price - The price to calculate the billing period for + * @returns BillingPeriod with start/end timestamps, or undefined for one-off prices + */ +export const getLineItemBillingPeriod = ({ + billingContext, + price, +}: { + billingContext: BillingContext; + price: Price; +}): BillingPeriod | undefined => { + if (isOneOffPrice(price)) return undefined; + + const { billingCycleAnchorMs, currentEpochMs, stripeSubscription } = + billingContext; + + const { interval, interval_count: intervalCount } = price.config; + + // Floor for start: subscription creation date (can't bill before subscription existed) + const startFloor = stripeSubscription?.created + ? secondsToMs(stripeSubscription.created) + : undefined; + + // Floor for end: billing cycle anchor (can't end billing period before anchor, e.g., trial end) + // Only apply when anchor is a specific timestamp, not "now" + const endFloor = + billingCycleAnchorMs === "now" ? undefined : billingCycleAnchorMs; + + const start = getCycleStart({ + anchor: billingCycleAnchorMs, + interval, + intervalCount, + now: currentEpochMs, + floor: startFloor, + }); + + const end = getCycleEnd({ + anchor: billingCycleAnchorMs, + interval, + intervalCount, + now: currentEpochMs, + floor: endFloor, + }); + + return { start, end }; +}; diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index 374790790..ce0a711fd 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -6,7 +6,7 @@ import { type EntitlementWithFeature, type Entity, type EntityBalance, - entitlementFeatureMatchesEntityFeature, + entitlementHasEntityFeature, type FeatureOptions, FeatureType, type FreeTrial, @@ -39,8 +39,7 @@ export const initCusEntEntities = ({ : null; for (const entity of entities) { - if (!entitlementFeatureMatchesEntityFeature({ entitlement, entity })) - continue; + if (!entitlementHasEntityFeature({ entitlement, entity })) continue; if (existingCusEnt?.entities?.[entity.id]) { continue; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts index 9f30e5ff8..c46170533 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts @@ -32,20 +32,6 @@ export const cusEntMatchesEntity = ({ return cusProductMatch && entityFeatureIdMatch; }; -export const findMainCusEntForFeature = ({ - cusEnts, - feature, -}: { - cusEnts: FullCustomerEntitlement[]; - feature: Feature; -}) => { - const mainCusEnt = cusEnts.find( - (e: any) => e.entitlement.feature.internal_id === feature.internal_id, - ); - - return mainCusEnt; -}; - export const findLinkedCusEnts = ({ cusEnts, feature, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 7d549c255..33a2b4bc0 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -24,6 +24,7 @@ import { type Feature, FeatureType, getCusEntBalance, + nullish, sumValues, } from "@autumn/shared"; import { Decimal } from "decimal.js"; @@ -48,6 +49,8 @@ const cusEntsToBreakdown = ({ breakdown: ApiBalanceBreakdown; prepaidQuantity: number; }[] => { + const entityId = fullCus.entity?.id; + const keyToCusEnts: Record = {}; for (const cusEnt of cusEnts) { const key = cusEntToKey({ cusEnt }); @@ -75,7 +78,11 @@ const cusEntsToBreakdown = ({ includeBreakdown: false, }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); + const prepaidQuantity = cusEntsToPrepaidQuantity({ + cusEnts, + sumAcrossEntities: nullish(entityId), + }); + const planId = cusEntsToPlanId({ cusEnts }); // Get expires_at from the first cusEnt (since key is cusEnt.id, there's only one) @@ -250,7 +257,10 @@ export const getApiBalance = ({ if (error) throw error; // Return in latest format - version transformation happens at Customer level - const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); + const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ + cusEnts, + sumAcrossEntities: nullish(entityId), + }); const breakdownLegacyData = breakdown.map((item) => ({ key: item.key, prepaid_quantity: item.prepaidQuantity, diff --git a/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts b/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts index 7a5177d27..ba64c745d 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts @@ -8,7 +8,6 @@ import { import { EntityService } from "@/internal/api/entities/EntityService.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import type { ExtendedRequest } from "../../../../utils/models/Request.js"; import { CusService } from "../../../customers/CusService.js"; import { constructEntity } from "../../entityUtils/entityUtils.js"; import { createEntityForCusProduct } from "./createEntityForCusProduct.js"; @@ -66,7 +65,7 @@ export const autoCreateEntity = async ({ for (const cusProduct of fullCus.customer_products) { await createEntityForCusProduct({ - req: ctx as unknown as ExtendedRequest, + ctx, customer: fullCus, cusProduct, inputEntities: [inputEntity], diff --git a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts index 4b4394bf9..5cf580074 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts @@ -1,23 +1,25 @@ import { + addCusProductToCusEnt, type CreateEntityParams, + cusEntToCusPrice, ErrCode, + type FullCusEntWithFullCusProduct, type FullCusProduct, type FullCustomer, type FullCustomerEntitlement, + findCustomerEntitlementByFeature, + findFeatureById, type Replaceable, } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { - findLinkedCusEnts, - findMainCusEntForFeature, -} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; -import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { acquireLock, clearLock } from "@/external/redis/redisUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { adjustAllowance } from "@/internal/balances/utils/paidAllocatedFeature/adjustAllowance.js"; import { getReps } from "@/internal/balances/utils/paidAllocatedFeature/createPaidAllocatedInvoice/handleProratedUpgrade.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { findLinkedCusEnts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; export const updateLinkedCusEnt = async ({ db, @@ -65,18 +67,16 @@ export const updateLinkedCusEnt = async ({ }; export const createEntityForCusProduct = async ({ - req, + ctx, customer, cusProduct, inputEntities, - logger, fromAutoCreate = false, }: { - req: ExtendedRequest; + ctx: AutumnContext; customer: FullCustomer; cusProduct: FullCusProduct; inputEntities: CreateEntityParams[]; - logger: any; fromAutoCreate?: boolean; }) => { const featureToEntities = inputEntities.reduce( @@ -87,22 +87,35 @@ export const createEntityForCusProduct = async ({ {} as Record, ); - const { db, env, org, features } = req; + const { db, env, org, features, logger } = ctx; const cusEnts = cusProduct.customer_entitlements; const cusPrices = cusProduct.customer_prices; for (const featureId in featureToEntities) { const inputEntities = featureToEntities[featureId]!; - const feature = features.find((f: any) => f.id === featureId)!; + const feature = findFeatureById({ + features, + featureId, + errorOnNotFound: true, + }); - const mainCusEnt = findMainCusEntForFeature({ + const mainCusEnt = findCustomerEntitlementByFeature({ cusEnts, feature, }); + let mainCusEntWithCusProduct: FullCusEntWithFullCusProduct | undefined; + if (mainCusEnt) { - const cusPrice = getRelatedCusPrice(mainCusEnt, cusPrices); + mainCusEntWithCusProduct = addCusProductToCusEnt({ + cusEnt: mainCusEnt, + cusProduct, + }); + + const cusPrice = cusEntToCusPrice({ + cusEnt: mainCusEntWithCusProduct, + }); if (fromAutoCreate && cusPrice) { throw new RecaseError({ @@ -114,50 +127,63 @@ export const createEntityForCusProduct = async ({ // 1. If main cus ent: let deletedReplaceables: Replaceable[] = []; - if (mainCusEnt) { - const originalBalance = mainCusEnt.balance || 0; - const newBalance = originalBalance - inputEntities.length; - - const repsLength = getReps({ - cusEnt: mainCusEnt as any, - prevBalance: originalBalance, - newBalance, - }).length; - const innerNewBalance = newBalance + repsLength; - - // Check if new balance would exceed usage limit - if ( - notNullish(mainCusEnt.entitlement.usage_limit) && - innerNewBalance < -mainCusEnt.entitlement.usage_limit! - ) { - throw new RecaseError({ - message: `Cannot create ${inputEntities.length} entities for feature ${feature.name} as it would exceed the usage limit.`, - code: ErrCode.FeatureLimitReached, - }); - } - - const { deletedReplaceables: deletedReplaceables_, invoice } = - await adjustAllowance({ - db, - env, - org, - cusPrices, - customer, - affectedFeature: feature, - cusEnt: { ...mainCusEnt, customer_product: cusProduct }, - originalBalance, - newBalance, - logger, - errorIfIncomplete: true, - }); - - deletedReplaceables = deletedReplaceables_ || []; - - await CusEntService.decrement({ - db, - id: mainCusEnt.id, - amount: inputEntities.length - deletedReplaceables.length, + if (mainCusEntWithCusProduct) { + // Acquire lock to prevent race conditions on seat charging + const lockKey = `lock:create-entity:${org.id}:${env}:${customer.id}`; + await acquireLock({ + lockKey, + ttlMs: 10000, + errorMessage: + "Entity creation already in progress for this customer, try again in a few seconds", }); + + try { + const originalBalance = mainCusEntWithCusProduct.balance || 0; + const newBalance = originalBalance - inputEntities.length; + + const repsLength = getReps({ + cusEnt: mainCusEntWithCusProduct, + prevBalance: originalBalance, + newBalance, + }).length; + const innerNewBalance = newBalance + repsLength; + + // Check if new balance would exceed usage limit + if ( + notNullish(mainCusEntWithCusProduct.entitlement.usage_limit) && + innerNewBalance < -mainCusEntWithCusProduct.entitlement.usage_limit! + ) { + throw new RecaseError({ + message: `Cannot create ${inputEntities.length} entities for feature ${feature.name} as it would exceed the usage limit.`, + code: ErrCode.FeatureLimitReached, + }); + } + + const { deletedReplaceables: deletedReplaceables_ } = + await adjustAllowance({ + db, + env, + org, + cusPrices, + customer, + affectedFeature: feature, + cusEnt: mainCusEntWithCusProduct, + originalBalance, + newBalance, + logger, + errorIfIncomplete: true, + }); + + deletedReplaceables = deletedReplaceables_ || []; + + await CusEntService.decrement({ + db, + id: mainCusEntWithCusProduct.id, + amount: inputEntities.length - deletedReplaceables.length, + }); + } finally { + await clearLock({ lockKey }); + } } const entityToReplacement: Record = {}; diff --git a/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts b/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts index 8a58c4804..9cd3e05f5 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts @@ -33,7 +33,7 @@ export const validateAndGetInputEntities = async ({ }); // 2. Get input entities - let inputEntities: any[] = []; + let inputEntities: CreateEntityParams[] = []; if (Array.isArray(createEntityData)) { inputEntities = createEntityData; } else { diff --git a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts index b113625a9..d9246dfed 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts @@ -5,12 +5,12 @@ import { CreateEntityQuerySchema, type CustomerData, type Entity, + findFeatureById, notNullish, } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "../../../../honoMiddlewares/routeHandler.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import type { ExtendedRequest } from "../../../../utils/models/Request.js"; import { EntityService } from "../../../api/entities/EntityService.js"; import { getApiEntity } from "../../entityUtils/apiEntityUtils/getApiEntity.js"; import { constructEntity } from "../../entityUtils/entityUtils.js"; @@ -49,18 +49,21 @@ export const createEntities = async ({ for (const cusProduct of cusProducts) { await createEntityForCusProduct({ - req: ctx as unknown as ExtendedRequest, + ctx, customer: fullCus, cusProduct, inputEntities, - logger, }); } - let data = inputEntities.map((e: any) => + let data = inputEntities.map((e) => constructEntity({ inputEntity: e, - feature: features.find((f: any) => f.id === e.feature_id)!, + feature: findFeatureById({ + features, + featureId: e.feature_id, + errorOnNotFound: true, + }), internalCustomerId: fullCus.internal_id, orgId: org.id, env, @@ -68,10 +71,12 @@ export const createEntities = async ({ ); const newEntities: Entity[] = []; - if (existingEntities.some((e: Entity) => e.id === null)) { + + const noIdEntity = existingEntities.find((e) => e.id === null); + if (noIdEntity) { const updatedEntity = await EntityService.update({ db, - internalId: existingEntities.find((e: any) => e.id === null)!.internal_id, + internalId: noIdEntity.internal_id, update: { id: inputEntities[0].id, name: inputEntities[0].name, diff --git a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts index 43de488a4..242ce55d7 100644 --- a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts +++ b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts @@ -1,13 +1,15 @@ -import { type EntityBalance, EntityNotFoundError } from "@autumn/shared"; +import { + type EntityBalance, + EntityNotFoundError, + findCustomerEntitlementByFeature, + findFeatureById, +} from "@autumn/shared"; import { adjustAllowance } from "@/internal/balances/utils/paidAllocatedFeature/adjustAllowance.js"; import { createRoute } from "../../../../honoMiddlewares/routeHandler.js"; import { EntityService } from "../../../api/entities/EntityService.js"; import { CusService } from "../../../customers/CusService.js"; import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { - findLinkedCusEnts, - findMainCusEntForFeature, -} from "../../../customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; +import { findLinkedCusEnts } from "../../../customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { deleteEntityFromCusEnt, replaceEntityInCusEnt, @@ -32,18 +34,22 @@ export const handleDeleteEntity = createRoute({ const existingEntities = fullCus.entities; const cusProducts = fullCus.customer_products; - const entity = existingEntities.find((e: any) => e.id === entity_id); + const entity = existingEntities.find((e) => e.id === entity_id); if (!entity) { throw new EntityNotFoundError({ entityId: entity_id }); } - const feature = features.find((f: any) => f.id === entity?.feature_id); + const feature = findFeatureById({ + features, + featureId: entity.feature_id, + errorOnNotFound: true, + }); for (const cusProduct of cusProducts) { const cusEnts = cusProduct.customer_entitlements; - const mainCusEnt = findMainCusEntForFeature({ + const mainCusEnt = findCustomerEntitlementByFeature({ cusEnts, feature: feature!, }); diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index b773abe1e..aa1d590da 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -10,9 +10,9 @@ import { UsageModel, } from "@autumn/shared"; -/* -** Only required for more complex features, consider using items in the test fixtures instead -*/ +/* + ** Only required for more complex features, consider using items in the test fixtures instead + */ export const constructFeatureItem = ({ featureId, includedUsage = 150, @@ -80,6 +80,7 @@ export const constructPrepaidItem = ({ usageLimit, intervalCount = 1, resetUsageWhenEnabled, + entityFeatureId, }: { featureId: string; price?: number; @@ -92,6 +93,7 @@ export const constructPrepaidItem = ({ usageLimit?: number; intervalCount?: number; resetUsageWhenEnabled?: boolean; + entityFeatureId?: string; }) => { const item: ProductItem = { feature_id: featureId, @@ -110,6 +112,7 @@ export const constructPrepaidItem = ({ }, usage_limit: usageLimit, reset_usage_when_enabled: resetUsageWhenEnabled, + entity_feature_id: entityFeatureId, }; return item; diff --git a/server/tests/balances/check/basic/check2.test.ts b/server/tests/balances/check/basic/check2.test.ts index 32bed03ac..abb3942ab 100644 --- a/server/tests/balances/check/basic/check2.test.ts +++ b/server/tests/balances/check/basic/check2.test.ts @@ -85,7 +85,7 @@ describe(`${chalk.yellowBright("check2: test /check on boolean feature")}`, () = granted_balance: 0, max_purchase: null, overage_allowed: false, - plan_id: "check2_free", + plan_id: freeProd.id, purchased_balance: 0, reset: null, usage: 0, diff --git a/server/tests/contUse/roles/role2.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts similarity index 95% rename from server/tests/contUse/roles/role2.test.ts rename to server/tests/balances/track/entity-balances/track-entity-balances6.test.ts index 7719ffaa9..7cadc217d 100644 --- a/server/tests/contUse/roles/role2.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances6.test.ts @@ -20,11 +20,11 @@ import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js" import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const user = TestFeature.Users; -const admin = TestFeature.Admin; const userMessages = constructArrearItem({ featureId: TestFeature.Messages, price: 0.5, + includedUsage: 1000, entityFeatureId: user, }) as LimitedItem; @@ -33,9 +33,9 @@ export const pro = constructProduct({ type: "pro", }); -const testCase = "role2"; +const testCase = "track-entity-balances6"; -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing overages for per entity`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: per-entity overage billing`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); let testClockId: string; @@ -59,8 +59,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing overages for per en testClockId = res.testClockId!; }); - const user1 = "user1"; - const user2 = "user2"; + const user1 = `${testCase}-user1`; + const user2 = `${testCase}-user2`; const firstEntities: CreateEntityParams[] = [ { @@ -93,6 +93,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing overages for per en expect(customer.features[TestFeature.Messages].included_usage).toBe( userMessages.included_usage * firstEntities.length, ); + + await timeout(4000); }); const user1Usage = 125000; @@ -112,8 +114,6 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing overages for per en entity_id: user2, }); - await timeout(4000); - const includedUsage = userMessages.included_usage; const { balance: userBalance } = await autumn.check({ diff --git a/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts new file mode 100644 index 000000000..cf255e920 --- /dev/null +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated7.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Tests distributed lock preventing race conditions on paid-allocated features. + * + * The lock ensures that concurrent track requests for paid-allocated features + * (which may trigger billing operations) are serialized to prevent race conditions. + */ + +test( + `${chalk.yellowBright("paid-allocated-lock: concurrent track requests are serialized by distributed lock")}`, + async () => { + const allocatedUsersItem = items.allocatedUsers({ includedUsage: 0 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [allocatedUsersItem, priceItem], + }); + + const uniqueId = `paid-alloc-lock-${Date.now()}`; + const { customerId, autumnV2 } = await initScenario({ + customerId: uniqueId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify initial balance + const customerBefore = (await autumnV2.customers.get( + customerId, + )) as ApiCustomer; + expect(customerBefore.balances[TestFeature.Users].current_balance).toBe(0); + + // 1. Send concurrent requests - lock should serialize them + console.log("🚀 Starting 5 concurrent track calls (2 users each)..."); + + const promises = Array(5) + .fill(null) + .map(() => + autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 2, + }), + ); + + const results = await Promise.allSettled(promises); + const successCount = results.filter((r) => r.status === "fulfilled").length; + const rejectedCount = results.filter((r) => r.status === "rejected").length; + + console.log( + `✅ Successful: ${successCount}, ❌ Rejected (lock): ${rejectedCount}`, + ); + + // At least one should succeed + expect(successCount).toEqual(1); + + // 2. Verify balance is mathematically correct + await timeout(2000); + const customerAfter = (await autumnV2.customers.get( + customerId, + )) as ApiCustomer; + const balance = customerAfter.balances[TestFeature.Users]; + + const expectedUsage = successCount * 2; + expect(balance.usage).toBe(expectedUsage); + expect(balance.granted_balance).toBe(0); + + expect(customerAfter.invoices?.length).toBe(2); + + // Balance equation: granted + purchased - usage = current + const expectedCurrentBalance = + balance.granted_balance + balance.purchased_balance - balance.usage; + expect(balance.current_balance).toBe(expectedCurrentBalance); + + // 3. Sequential track after concurrent burst should work + console.log("📊 Executing sequential track after burst..."); + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 1, + }); + + const customerFinal = (await autumnV2.customers.get( + customerId, + )) as ApiCustomer; + expect(customerFinal.balances[TestFeature.Users].usage).toBe( + expectedUsage + 1, + ); + + // 4. Verify DB consistency + await new Promise((r) => setTimeout(r, 3000)); + + const dbCustomer = (await autumnV2.customers.get(customerId, { + skip_cache: "true", + })) as ApiCustomer; + + expect(dbCustomer.balances[TestFeature.Users].usage).toBe( + expectedUsage + 1, + ); + + await timeout(2000); + expect(dbCustomer.invoices?.length).toBe(3); + }, + { timeout: 60000 }, +); diff --git a/server/tests/contUse/entities/entity1.test.ts b/server/tests/contUse/entities/entity1.test.ts deleted file mode 100644 index e4ced6b5b..000000000 --- a/server/tests/contUse/entities/entity1.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "entity1"; - -// Pro is $20 / month, Seat is $50 / user - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create / delete entities`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - test("should create entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += 1; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - usage: [ - { - featureId: TestFeature.Users, - value: 1, - }, - ], - }); - }); - - const entities = [ - { - id: "2", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test2", - feature_id: TestFeature.Users, - }, - ]; - - test("should create 2 entities and have correct invoice", async () => { - // await advanceTestClock({ - // stripeCli: ctx.stripeCli, - // testClockId, - // advanceTo: addWeeks(new Date(), 2).getTime(), - // waitForSeconds: 30, - // }); - - await autumn.entities.create(customerId, entities); - await timeout(3000); - - usage += entities.length; - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - itemQuantity: usage, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices!; - expect(invoices.length).toBe(2); - expect(invoices[0].total).toBe(userItem.price! * entities.length); - }); - - test("should delete 1 entity and have no new invoice", async () => { - await autumn.entities.delete(customerId, entities[0].id); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices!; - expect(invoices.length).toBe(2); - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - numReplaceables: 1, - itemQuantity: usage - 1, - }); - }); - - const newEntities = [ - { - id: "4", - name: "test3", - feature_id: TestFeature.Users, - }, - { - id: "5", - name: "test4", - feature_id: TestFeature.Users, - }, - ]; - - test("should create 2 entities and have correct invoice (only pay for 1)", async () => { - await autumn.entities.create(customerId, newEntities); - await timeout(3000); - usage += 1; - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices!; - - expect(invoices.length).toBe(3); - expect(invoices[0].total).toBe(userItem.price!); - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - itemQuantity: usage, - }); - }); -}); diff --git a/server/tests/contUse/entities/entity2.test.ts b/server/tests/contUse/entities/entity2.test.ts deleted file mode 100644 index ac0cef128..000000000 --- a/server/tests/contUse/entities/entity2.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { - calcProrationAndExpectInvoice, - expectSubQuantityCorrect, -} from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, -}); - -const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "entity2"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing entities, prorate now`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let curUnix = Date.now(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - test("should create entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += 1; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - const newEntities = [ - { - id: "2", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test2", - feature_id: TestFeature.Users, - }, - ]; - - test("should create 2 entities and have correct invoice", async () => { - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 30, - }); - - await autumn.entities.create(customerId, newEntities); - usage += newEntities.length; - - const { stripeSubs } = await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - itemQuantity: usage, - }); - - await timeout(5000); - - await calcProrationAndExpectInvoice({ - autumn, - stripeSubs, - customerId, - quantity: newEntities.length, - unitPrice: userItem.price!, - curUnix, - numInvoices: 2, - }); - }); - - test("should delete 1 entity and have correct invoice amount", async () => { - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 30, - }); - - await timeout(5000); - - await autumn.entities.delete(customerId, newEntities[0].id); - usage -= 1; - - const { stripeSubs } = await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - }); - - await calcProrationAndExpectInvoice({ - autumn, - stripeSubs, - customerId, - quantity: -1, - unitPrice: userItem.price!, - curUnix, - numInvoices: 3, - }); - }); -}); diff --git a/server/tests/contUse/entities/entity3.test.ts b/server/tests/contUse/entities/entity3.test.ts deleted file mode 100644 index 81493050f..000000000 --- a/server/tests/contUse/entities/entity3.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; -import chalk from "chalk"; -import { addHours, addMonths, addWeeks } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "entity3"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing replaceables deleted at end of cycle`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let curUnix = new Date().getTime(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - test("should create three entities, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += firstEntities.length; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - test("should delete 2 entities and have no new invoice", async () => { - curUnix = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 30, - }); - - await autumn.entities.delete(customerId, firstEntities[0].id); - await autumn.entities.delete(customerId, firstEntities[1].id); - - const numReplaceables = 2; - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - numReplaceables, - itemQuantity: usage - numReplaceables, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices!; - expect(invoices.length).toBe(1); - }); - - test("should advance clock to next cycle and have correct invoice", async () => { - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addHours( - addMonths(new Date(), 1), - hoursToFinalizeInvoice, - ).getTime(), - }); - - usage -= 2; // 2 entities deleted - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - - const basePrice = getBasePrice({ product: pro }); - expect(invoices.length).toBe(2); - expect(invoices[0].total).toBe(basePrice); // 0 entities - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - itemQuantity: usage, - numReplaceables: 0, - }); - }); -}); diff --git a/server/tests/contUse/entities/entity4.test.ts b/server/tests/contUse/entities/entity4.test.ts deleted file mode 100644 index a7dbed4f4..000000000 --- a/server/tests/contUse/entities/entity4.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Handling per entity features! - -import { beforeAll, describe, expect, test } from "bun:test"; -import { - CusExpand, - LegacyVersion, - type LimitedItem, - OnDecrease, - OnIncrease, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { useEntityBalanceAndExpect } from "@tests/utils/expectUtils/expectContUse/expectEntityUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { - constructArrearProratedItem, - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -const perEntityItem = constructFeatureItem({ - featureId: TestFeature.Messages, - entityFeatureId: TestFeature.Users, - includedUsage: 500, -}) as LimitedItem; - -export const pro = constructProduct({ - items: [userItem, perEntityItem], - type: "pro", -}); - -const testCase = "entity4"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing per entity features`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - test("should create one entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += firstEntities.length; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - test("should create 3 entities and have correct message balance", async () => { - const newEntities = [ - { - id: "2", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - await autumn.entities.create(customerId, newEntities); - usage += newEntities.length; - - const customer = await autumn.customers.get(customerId, { - expand: [CusExpand.Entities], - }); - - const res = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance).toBe((perEntityItem.included_usage as number) * usage); - - // @ts-expect-error - for (const entity of customer.entities) { - const entRes = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: entity.id ?? "", - }); - - expect(entRes.balance).toBe(perEntityItem.included_usage); - } - }); - - // 1. Use from main balance... - test("should use from top level balance", async () => { - const deduction = 600; - const perEntityIncluded = perEntityItem.included_usage as number; - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: deduction, - }); - await timeout(5000); - - const { balance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(balance).toBe(perEntityIncluded * usage - deduction); - }); - - test("should use from entity balance", async () => { - await useEntityBalanceAndExpect({ - autumn, - customerId, - featureId: TestFeature.Messages, - entityId: "2", - }); - - await useEntityBalanceAndExpect({ - autumn, - customerId, - featureId: TestFeature.Messages, - entityId: "3", - }); - }); - - // Delete one entity and create a new one and master balance should be same - const deletedEntityId = "2"; - const newEntity = { - id: "4", - name: "test", - feature_id: TestFeature.Users, - }; - test("should delete one entity and create a new one", async () => { - const { balance: masterBalanceBefore } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - const { balance: entityBalanceBefore } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: deletedEntityId, - }); - - await autumn.entities.delete(customerId, deletedEntityId); - await autumn.entities.create(customerId, [newEntity]); - - const { balance: masterBalanceAfter } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(new Decimal(masterBalanceAfter ?? 0).toDP(5).toNumber()).toBe( - new Decimal(masterBalanceBefore ?? 0).toDP(5).toNumber(), - ); - - const { balance: entityBalanceAfter } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: newEntity.id, - }); - - expect(entityBalanceAfter).toBe(entityBalanceBefore); - }); -}); diff --git a/server/tests/contUse/entities/entity5.test.ts b/server/tests/contUse/entities/entity5.test.ts deleted file mode 100644 index 6285fc46a..000000000 --- a/server/tests/contUse/entities/entity5.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// test payment failures - -import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "entity5"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create entity payment fail`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - ]; - - test("should create one entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += firstEntities.length; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - test("should attach failed payment method", async () => { - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - await attachFailedPaymentMethod({ - stripeCli: ctx.stripeCli, - customer: fullCus, - }); - }); - - test("should try to create entities and fail", async () => { - await expectAutumnError({ - errMessage: "card was declined.", - func: async () => { - await autumn.entities.create(customerId, [ - { - id: "2", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test", - feature_id: TestFeature.Users, - }, - ]); - }, - }); - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - numReplaceables: 0, - }); - }); - - test("should track usage for users and fail", async () => { - await expectAutumnError({ - errMessage: "card was declined.", - func: async () => { - return await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: 2, - }); - }, - }); - - await expectSubQuantityCorrect({ - stripeCli: ctx.stripeCli, - productId: pro.id, - db: ctx.db, - org: ctx.org, - env: ctx.env, - customerId, - usage, - numReplaceables: 0, - }); - }); -}); diff --git a/server/tests/contUse/roles/role1.test.ts b/server/tests/contUse/roles/role1.test.ts deleted file mode 100644 index 5226110ac..000000000 --- a/server/tests/contUse/roles/role1.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Handling per entity features! - -import { beforeAll, describe, expect, test } from "bun:test"; -import { - LegacyVersion, - type LimitedItem, - type ProductItem, -} from "@autumn/shared"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const user = TestFeature.Users; -const admin = TestFeature.Admin; - -const userMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: user, -}) as LimitedItem; - -const adminMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 500, - entityFeatureId: admin, -}) as LimitedItem; - -const adminRights = constructFeatureItem({ - featureId: TestFeature.AdminRights, - entityFeatureId: admin, - isBoolean: true, -}) as ProductItem; - -export const pro = constructProduct({ - items: [userMessages, adminMessages, adminRights], - type: "pro", -}); - -const testCase = "role1"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing roles`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - const curUnix = new Date().getTime(); - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = testClockId1!; - }); - - const userId = "user1"; - const adminId = "admin1"; - const firstEntities = [ - { - id: userId, - name: "test", - feature_id: user, - }, - { - id: adminId, - name: "test", - feature_id: admin, - }, - ]; - - test("should create initial entities, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - }); - }); - - test("should have correct check result for admin rights", async () => { - const { allowed } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.AdminRights, - entity_id: adminId, - }); - - const entity = await autumn.entities.get(customerId, adminId); - - expect(allowed).toBe(true); - expect(entity.features[TestFeature.AdminRights]).toBeDefined(); - - const { allowed: userAllowed } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.AdminRights, - entity_id: userId, - }); - const userEntity = await autumn.entities.get(customerId, userId); - - expect(userAllowed).toBe(false); - expect(userEntity.features[TestFeature.AdminRights]).toBeUndefined(); - }); - - test("should have correct total balance", async () => { - const { balance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - const totalIncluded = - userMessages.included_usage + adminMessages.included_usage; - - expect(balance).toBe(totalIncluded); - }); - - test("should have correct per entity balance", async () => { - const { balance: userBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: userId, - }); - - const userEntity = await autumn.entities.get(customerId, userId); - - expect(userBalance).toBe(userMessages.included_usage); - expect(userEntity.features[TestFeature.Messages].included_usage).toBe( - userMessages.included_usage, - ); - - const { balance: adminBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: adminId, - }); - - const adminEntity = await autumn.entities.get(customerId, adminId); - - expect(adminBalance).toBe(adminMessages.included_usage); - expect(adminEntity.features[TestFeature.Messages].included_usage).toBe( - adminMessages.included_usage, - ); - }); - - const userUsage = Math.random() * 50; - const expectedUserBalance = new Decimal(userMessages.included_usage) - .minus(userUsage) - .toNumber(); - test("should have correct user usage", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: userUsage, - entity_id: userId, - }); - await timeout(2000); - - const { balance: userBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: userId, - }); - - const { balance: adminBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: adminId, - }); - - expect(new Decimal(adminBalance ?? 0).toDP(5).toNumber()).toBe( - new Decimal(adminMessages.included_usage).toDP(5).toNumber(), - ); - expect(new Decimal(userBalance ?? 0).toDP(5).toNumber()).toBe( - new Decimal(expectedUserBalance).toDP(5).toNumber(), - ); - }); - - const adminUsage = Math.random() * 50; - const expectedAdminBalance = new Decimal(adminMessages.included_usage) - .minus(adminUsage) - .toNumber(); - test("Should have correct admin usage", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: adminUsage, - entity_id: adminId, - }); - await timeout(2000); - - const { balance: adminBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: adminId, - }); - - const { balance: userBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: userId, - }); - - expect(new Decimal(adminBalance ?? 0).toDP(5).toNumber()).toBe( - new Decimal(expectedAdminBalance).toDP(5).toNumber(), - ); - expect(new Decimal(userBalance ?? 0).toDP(5).toNumber()).toBe( - new Decimal(expectedUserBalance).toDP(5).toNumber(), - ); - }); -}); diff --git a/server/tests/contUse/roles/role3.test.ts b/server/tests/contUse/roles/role3.test.ts deleted file mode 100644 index 13ffaa960..000000000 --- a/server/tests/contUse/roles/role3.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type CreateEntityParams, - LegacyVersion, - type LimitedItem, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { addMonths } from "date-fns"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const user = TestFeature.Users; -const admin = TestFeature.Admin; - -const userMessages = constructArrearItem({ - featureId: TestFeature.Messages, - price: 0.5, - entityFeatureId: user, -}) as LimitedItem; - -const adminMessages = constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 0, - price: 0.1, - entityFeatureId: admin, -}) as LimitedItem; - -export const pro = constructProduct({ - items: [userMessages, adminMessages], - type: "pro", -}); - -const testCase = "role3"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing overages for per entity, diff roles`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let testClockId: string; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); - - testClockId = res.testClockId!; - }); - - const user1 = "user1"; - const user2 = "user2"; - const admin1 = "admin1"; - const admin2 = "admin2"; - const firstEntities: CreateEntityParams[] = [ - { - id: user1, - name: "test", - feature_id: user, - }, - { - id: user2, - name: "test", - feature_id: user, - }, - { - id: admin1, - name: "test", - feature_id: admin, - }, - { - id: admin2, - name: "test", - feature_id: admin, - }, - ]; - - test("should create initial entities, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - entities: firstEntities, - }); - }); - - const user1Usage = 125000; - const user2Usage = 150000; - - // total: 275000, included: 10000, overage: 255000 - test("should track correct usage for seat messages", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: user1Usage, - entity_id: user1, - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: user2Usage, - entity_id: user2, - }); - - await timeout(4000); - - const includedUsage = userMessages.included_usage; - - const { balance: userBalance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: user1, - }); - - expect(userBalance).toBe(includedUsage - user1Usage); - - const { balance: user2Balance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: user2, - }); - - expect(user2Balance).toBe(includedUsage - user2Usage); - - const { balance: admin1Balance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: admin1, - }); - - expect(admin1Balance).toBe(adminMessages.included_usage); - - const { balance: admin2Balance } = await autumn.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: admin2, - }); - - expect(admin2Balance).toBe(adminMessages.included_usage); - }); - - const admin1Usage = 130000; - const admin2Usage = 140000; - // total: 270000, included: 0, overage: 270000 - test("should track correct usage for admin messages", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: admin1Usage, - entity_id: admin1, - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: admin2Usage, - entity_id: admin2, - }); - - await timeout(4000); - }); - - test("should have correct invoice next cycle", async () => { - await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId, - advanceTo: addMonths(new Date(), 1).getTime(), - // addHours( - // addMonths(new Date(), 1), - // hoursToFinalizeInvoice - // ).getTime(), - waitForSeconds: 30, - }); - - return; - - const includedUsage = userMessages.included_usage; - const user1Overage = user1Usage - includedUsage; - const user2Overage = user2Usage - includedUsage; - const totalUserUsage = user1Overage + user2Overage + includedUsage; - - const admin1Overage = admin1Usage - adminMessages.included_usage; - const admin2Overage = admin2Usage - adminMessages.included_usage; - const totalAdminUsage = - admin1Overage + admin2Overage + adminMessages.included_usage; - - const expectedInvoiceTotal = await getExpectedInvoiceTotal({ - customerId, - productId: pro.id, - usage: [ - { - featureId: TestFeature.Messages, - entityFeatureId: user, - value: totalUserUsage, - }, - { - featureId: TestFeature.Messages, - entityFeatureId: admin, - value: totalAdminUsage, - }, - ], - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, - expectExpired: true, - }); - - const customer = await autumn.customers.get(customerId); - expect(customer.invoices[0].total).toBe(expectedInvoiceTotal); - }); -}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-per-entity-product.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-per-entity-product.test.ts new file mode 100644 index 000000000..c8d99f276 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-per-entity-product.test.ts @@ -0,0 +1,489 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PER-ENTITY PRODUCT UPDATE TESTS +// Tests for updating products that have per-entity feature balances (entity_feature_id) +// ═══════════════════════════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Update free product to add per-entity feature +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("per-entity: add per-entity feature to free product")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1, entities } = await initScenario({ + customerId: "pe-add-feature", + setup: [ + s.customer({}), + s.products({ list: [free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Update to add per-entity messages feature + const perEntityMessages = items.monthlyMessages({ + includedUsage: 500, + entityFeatureId: TestFeature.Users, + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [perEntityMessages], + }); + + // Verify customer balance is sum of all entity balances + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: perEntityMessages.included_usage * 2, // 500 * 2 entities + balance: perEntityMessages.included_usage * 2, + usage: 0, + }); + + // Verify each entity has its own balance + for (const entity of entities) { + const entityData = await autumnV1.entities.get( + customerId, + entity.id, + ); + expectCustomerFeatureCorrect({ + customer: entityData, + featureId: TestFeature.Messages, + balance: perEntityMessages.included_usage, + includedUsage: perEntityMessages.included_usage, + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Update free product to consumable per-entity, track into overage +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("per-entity: add consumable per-entity feature and track overage")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "pe-cons-overage", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Update to consumable per-entity messages ($0.10/message overage) + const consumablePerEntity = items.consumableMessages({ + includedUsage: 1000, + entityFeatureId: TestFeature.Users, + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [consumablePerEntity], + }); + + // Verify initial entity balances + for (const entity of entities) { + const entityData = await autumnV1.entities.get( + customerId, + entity.id, + ); + expectCustomerFeatureCorrect({ + customer: entityData, + featureId: TestFeature.Messages, + balance: consumablePerEntity.included_usage, + includedUsage: consumablePerEntity.included_usage, + }); + } + + // Track each entity into overage + const entity1Usage = 1200; // 200 over + const entity2Usage = 1500; // 500 over + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: entity1Usage, + }, + { timeout: 2000 }, + ); + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: entity2Usage, + }, + { timeout: 2000 }, + ); + + // Verify entity balances are negative (in overage) + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: consumablePerEntity.included_usage - entity1Usage, // -200 + }); + + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: consumablePerEntity.included_usage - entity2Usage, // -500 + }); + + // Verify customer total balance + const customer = await autumnV1.customers.get(customerId); + const expectedBalance = + consumablePerEntity.included_usage * 2 - entity1Usage - entity2Usage; + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: expectedBalance, // 2000 - 2700 = -700 + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Update per-entity feature with increased included usage +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("per-entity: increase included usage preserves entity usage")}`, async () => { + // Start with per-entity feature + const perEntityMessages = items.monthlyMessages({ + includedUsage: 500, + entityFeatureId: TestFeature.Users, + }); + const free = products.base({ items: [perEntityMessages] }); + + const { customerId, autumnV1, entities } = await initScenario({ + customerId: "pe-increase-inc", + setup: [ + s.customer({}), + s.products({ list: [free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: free.id, timeout: 2000 })], + }); + + // Track some usage on each entity + const entity1Usage = 200; + const entity2Usage = 300; + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: entity1Usage, + }, + { timeout: 2000 }, + ); + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: entity2Usage, + }, + { timeout: 2000 }, + ); + + // Verify usage before update + const entity1Before = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expect(entity1Before.features?.[TestFeature.Messages]?.balance).toBe( + 500 - entity1Usage, + ); + + // Update to increase included usage + const updatedPerEntityMessages = items.monthlyMessages({ + includedUsage: 1000, // Increased from 500 + entityFeatureId: TestFeature.Users, + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [updatedPerEntityMessages], + }); + + // Verify entity usage is preserved, balance reflects new included usage + const entity1After = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1After, + featureId: TestFeature.Messages, + includedUsage: updatedPerEntityMessages.included_usage, + balance: updatedPerEntityMessages.included_usage - entity1Usage, // 1000 - 200 = 800 + usage: entity1Usage, + }); + + const entity2After = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2After, + featureId: TestFeature.Messages, + includedUsage: updatedPerEntityMessages.included_usage, + balance: updatedPerEntityMessages.included_usage - entity2Usage, // 1000 - 300 = 700 + usage: entity2Usage, + }); + + // Verify customer total + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: updatedPerEntityMessages.included_usage * 2, + balance: + updatedPerEntityMessages.included_usage * 2 - entity1Usage - entity2Usage, + usage: entity1Usage + entity2Usage, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Update billing model (consumable to prepaid) for per-entity features +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("per-entity: change from consumable to prepaid billing")}`, async () => { + // Start with consumable per-entity feature + const consumablePerEntity = items.consumableMessages({ + includedUsage: 500, + entityFeatureId: TestFeature.Users, + }); + const free = products.base({ items: [consumablePerEntity] }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "pe-cons-to-prep", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Track some usage on entity 1 + const entity1Usage = 200; + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: entity1Usage, + }, + { timeout: 2000 }, + ); + + // Update to prepaid per-entity ($10 per 100 units) + const prepaidPerEntity = items.prepaidMessages({ + includedUsage: 500, + price: 10, + billingUnits: 100, + entityFeatureId: TestFeature.Users, + }); + + const updateParams = { + customer_id: customerId, + product_id: free.id, + items: [prepaidPerEntity], + options: [{ feature_id: TestFeature.Messages, quantity: 500 }], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should charge for prepaid units: 500 units / 100 billing_units * $10 = $50 + // (options.quantity is per-customer, not per-entity) + expect(preview.total).toEqual(50); + + await autumnV1.subscriptions.update(updateParams); + + // Verify entity balances - usage should be preserved + // Each entity gets: included (500) + purchased share (500/2 = 250) + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + // included (500) + purchased (500) - usage (200) = 550 + balance: 500 + 500 - entity1Usage, + usage: entity1Usage, + }); + + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + // included (500) + purchased (500) - usage (0) = 1000 + balance: 500 + 500, + usage: 0, + }); + + // Verify customer total + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + // (500 + 250) * 2 entities - 200 usage = 1300 + balance: (500 + 500) * 2 - entity1Usage, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: Update from free to pay-per-use per-entity features +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("per-entity: upgrade free to pay-per-use per-entity")}`, async () => { + // Start with free per-entity feature + const freePerEntity = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const free = products.base({ items: [freePerEntity] }); + + const { customerId, autumnV1, ctx, entities } = await initScenario({ + customerId: "pe-free-to-ppu", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: free.id })], + }); + + // Track some usage before upgrade + const entity1Usage = 50; + const entity2Usage = 75; + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: entity1Usage, + }, + { timeout: 2000 }, + ); + + await autumnV1.track( + { + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: entity2Usage, + }, + { timeout: 2000 }, + ); + + // Upgrade to pay-per-use per-entity ($0.10/message overage) + const payPerUsePerEntity = items.consumableMessages({ + includedUsage: 100, // Same included usage + entityFeatureId: TestFeature.Users, + }); + + const updateParams = { + customer_id: customerId, + product_id: free.id, + items: [payPerUsePerEntity], + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // No immediate charge - consumable bills in arrears + expect(preview.total).toEqual(0); + + await autumnV1.subscriptions.update(updateParams); + + // Verify entity balances preserved usage + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + includedUsage: payPerUsePerEntity.included_usage, + balance: payPerUsePerEntity.included_usage - entity1Usage, + usage: entity1Usage, + }); + + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + includedUsage: payPerUsePerEntity.included_usage, + balance: payPerUsePerEntity.included_usage - entity2Usage, + usage: entity2Usage, + }); + + // Verify customer total + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: payPerUsePerEntity.included_usage * 2, + balance: + payPerUsePerEntity.included_usage * 2 - entity1Usage - entity2Usage, + usage: entity1Usage + entity2Usage, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts index d057f7d8a..fc83211ee 100644 --- a/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/update-feature-quantity-errors.test.ts @@ -126,7 +126,7 @@ test.concurrent(`${chalk.yellowBright("error: negative quantity for prepaid feat options: [{ feature_id: TestFeature.Messages, quantity: 5 }], }), ], - }); + }); // Try to update with negative quantity const updateParams = { @@ -145,3 +145,42 @@ test.concurrent(`${chalk.yellowBright("error: negative quantity for prepaid feat }, }); }); + +// 4. Update quantity for non-existent feature → error +test.concurrent(`${chalk.yellowBright("error: update quantity for non-existent feature")}`, async () => { + const product = products.base({ + id: "multi_feature", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: 10, + price: 5, + }), + ], + }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "err-nonexistent-feature", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: 10 * 10 }], + }), + ], + }); + + // Try to update a feature that doesn't exist in the subscription + await expectAutumnError({ + func: async () => { + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Users, quantity: 10 * 10 }], + }); + }, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts index b036c807d..67281a236 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts @@ -522,7 +522,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")} // Invoice count: 1 ($0 trial) + 1 ($20 trial end) + 1 (refund ~-$18) = 3 expectCustomerInvoiceCorrect({ customer, - count: 2, + count: 3, latestTotal: proratedRefund, latestInvoiceProductId: proTrial.id, }); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-remove-trial.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-remove-trial.test.ts index 2c1f8ab69..f715c4dbb 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-remove-trial.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-remove-trial.test.ts @@ -2,10 +2,10 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { getStripeSubscription } from "@tests/integration/billing/utils/discounts/discountTestUtils"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -17,7 +17,7 @@ test.concurrent(`${chalk.yellowBright("subscription-update: remove trial with no trialDays: 14, }); - const { customerId, autumnV1 } = await initScenario({ + const { customerId, autumnV1, ctx } = await initScenario({ customerId: "sub-update-remove-trial", setup: [ s.customer({ testClock: true, paymentMethod: "success" }), @@ -26,15 +26,11 @@ test.concurrent(`${chalk.yellowBright("subscription-update: remove trial with no actions: [ s.attach({ productId: proTrial.id }), s.removePaymentMethod(), - s.attachPaymentMethod({ type: "success" }), // Re-attach but subscription default_payment_method stays null + s.attachPaymentMethod({ type: "success" }), ], }); - // 1. Verify stripe subscription.default_payment_method is null - const { subscription: stripeSub } = await getStripeSubscription({ - customerId, - }); - expect(stripeSub.default_payment_method).toBeNull(); + await timeout(2000); // New items for the update const newMessagesItem = items.monthlyMessages({ includedUsage: 200 }); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts index eb641d87a..b2fd2a7fd 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-trial-edge-cases.test.ts @@ -14,6 +14,7 @@ import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { addDays } from "date-fns"; /** * Update Trial Edge Cases Tests @@ -236,3 +237,100 @@ test.concurrent(`${chalk.yellowBright("trial-edge-cases: start with users, add t }, }); }); + +// 2. Start with trial -> extend trial with longer duration +test.concurrent(`${chalk.yellowBright("trial-edge-cases: extend trial with longer duration")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + + const pro = products.base({ + items: [messagesItem, priceItem], + trialDays: 20, + }); + + const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ + customerId: "trial-edge-extend", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Step 1: Verify initial state - trialing for 20 days + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerBefore, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(20), + }); + + // No invoice yet (trial) + expectCustomerInvoiceCorrect({ + customer: customerBefore, + count: 1, + latestTotal: 0, + }); + + // Step 2: Update to extend trial to 200 days + const extendTrialParams = { + customer_id: customerId, + product_id: pro.id, + free_trial: { + length: 200, + duration: FreeTrialDuration.Day, + card_required: true, + unique_fingerprint: false, + }, + }; + + const extendTrialPreview = + await autumnV1.subscriptions.previewUpdate(extendTrialParams); + + // Should be $0 since still in trial + expect(extendTrialPreview.total).toEqual(0); + + // next_cycle should show when the extended trial ends (in 200 days) + expectPreviewNextCycleCorrect({ + preview: extendTrialPreview, + startsAt: advancedTo + ms.days(200), + total: priceItem.price!, + }); + + await autumnV1.subscriptions.update(extendTrialParams, { timeout: 2000 }); + + const customerAfterExtend = + await autumnV1.customers.get(customerId); + + // Product should now be trialing with 200 day trial + await expectProductTrialing({ + customer: customerAfterExtend, + productId: pro.id, + trialEndsAt: addDays(advancedTo, 200).getTime(), + toleranceMs: ms.hours(2), + }); + + // Features should still work + expectCustomerFeatureCorrect({ + customer: customerAfterExtend, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + }); + + // Still no invoice (still in trial) + expectCustomerInvoiceCorrect({ + customer: customerAfterExtend, + count: 2, + latestTotal: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/invoice/update-action-required-basic.test.ts b/server/tests/integration/billing/update-subscription/invoice/update-action-required-basic.test.ts index 95699bb30..cd5a12291 100644 --- a/server/tests/integration/billing/update-subscription/invoice/update-action-required-basic.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice/update-action-required-basic.test.ts @@ -346,7 +346,7 @@ test.concurrent(`${chalk.yellowBright("subscription-create: 3ds authentication r }); // Complete 3DS authentication - await completeInvoiceCheckout({ + await completeInvoiceConfirmation({ url: result.payment_url!, }); diff --git a/server/tests/integration/billing/update-subscription/invoice/update-action-required-scenarios.test.ts b/server/tests/integration/billing/update-subscription/invoice/update-action-required-scenarios.test.ts index e539f1a1e..588037c28 100644 --- a/server/tests/integration/billing/update-subscription/invoice/update-action-required-scenarios.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice/update-action-required-scenarios.test.ts @@ -248,85 +248,6 @@ test.concurrent(`${chalk.yellowBright("action-required-scenario: free → paid") }); }); -// ═══════════════════════════════════════════════════════════════════════════════ -// CASE 4: TRIAL REMOVAL - ACTION REQUIRED -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("action-required-scenario: remove trial")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice({ price: 20 }); - const proTrial = products.base({ - id: "pro-trial", - items: [messagesItem, priceItem], - trialDays: 7, - }); - - const { customerId, autumnV1 } = await initScenario({ - customerId: "action-scn-trial", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - ], - actions: [ - s.attach({ productId: proTrial.id }), - s.attachPaymentMethod({ type: "authenticate" }), // Switch to 3DS card - ], - }); - - // Remove trial by passing free_trial: null - const updateParams = { - customer_id: customerId, - product_id: proTrial.id, - free_trial: null, - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should charge full price ($20) since trial is being removed - expect(preview.total).toBe(20); - - const result = await autumnV1.subscriptions.update(updateParams); - - expect(result.required_action).toBeDefined(); - expect(result.required_action?.code).toBe("3ds_required"); - expect(result.payment_url).toBeDefined(); - - const customer = await autumnV1.customers.get(customerId); - - // Before authentication - balance should be 100 (trial) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 100, - }); - - await completeInvoiceConfirmation({ - url: result.payment_url!, - }); - - const customerAfterAuth = - await autumnV1.customers.get(customerId); - - expectProductActive({ - customer: customerAfterAuth, - productId: proTrial.id, - }); - - // After authentication - balance should still be 100 (now paid, no longer trial) - expectCustomerFeatureCorrect({ - customer: customerAfterAuth, - featureId: TestFeature.Messages, - balance: 100, - }); - - await expectCustomerInvoiceCorrect({ - customer: customerAfterAuth, - count: 2, // Initial trial attach ($0) + update - latestTotal: preview.total, - latestStatus: "paid", - }); -}); - // ═══════════════════════════════════════════════════════════════════════════════ // CASE 5: INCREASE QUANTITY - ACTION REQUIRED // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/server/tests/integration/billing/update-subscription/invoice/update-invoice-deferred.test.ts b/server/tests/integration/billing/update-subscription/invoice/update-invoice-deferred.test.ts index cb22a498d..d7a0b7af1 100644 --- a/server/tests/integration/billing/update-subscription/invoice/update-invoice-deferred.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice/update-invoice-deferred.test.ts @@ -74,14 +74,14 @@ test.concurrent(`${chalk.yellowBright("invoice-deferred: increase price (finaliz expect(clonedResult).toMatchObject({ payment_url: expect.any(String), - invoice: { + invoice: expect.objectContaining({ status: "open", stripe_id: expect.any(String), total: preview.total, hosted_invoice_url: expect.any(String), - }, - required_action: undefined, + }), }); + expect(clonedResult.required_action).toBeUndefined(); const stripeInvoice = await ctx.stripeCli.invoices.retrieve( result.invoice!.stripe_id, diff --git a/server/tests/integration/billing/update-subscription/multi-product/multi-entity-from-free.test.ts b/server/tests/integration/billing/update-subscription/multi-product/multi-entity-from-free.test.ts index 1ef7c5c55..cd5bab0dc 100644 --- a/server/tests/integration/billing/update-subscription/multi-product/multi-entity-from-free.test.ts +++ b/server/tests/integration/billing/update-subscription/multi-product/multi-entity-from-free.test.ts @@ -253,8 +253,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-free-to-paid: base + consuma expectCustomerFeatureCorrect({ customer: entity2Data, featureId: TestFeature.Messages, - includedUsage: 100, // prepaid quantity - balance: 100, + includedUsage: 150, // prepaid quantity + balance: 150, usage: 0, }); @@ -398,9 +398,6 @@ test.concurrent(`${chalk.yellowBright("multi-entity-free-to-paid: annual mid-cyc items: [messagesItem, annualPriceItem], }); - // Annual price should be charged in full (no proration for annual) - console.log("Preview total (annual mid-cycle):", preview.total); - await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[1].id, @@ -480,9 +477,6 @@ test.concurrent(`${chalk.yellowBright("multi-entity-free-to-paid: monthly mid-cy items: [messagesItem, priceItem], }); - // Mid-cycle: ~15/30 days remaining = ~50% proration = ~$10 - console.log("Preview total (mid-cycle):", preview.total); - await autumnV1.subscriptions.update({ customer_id: customerId, entity_id: entities[1].id, diff --git a/server/tests/integration/billing/update-subscription/multi-product/schedules-free-to-paid.test.ts b/server/tests/integration/billing/update-subscription/multi-product/schedules-free-to-paid.test.ts index 71365d8a8..938dde122 100644 --- a/server/tests/integration/billing/update-subscription/multi-product/schedules-free-to-paid.test.ts +++ b/server/tests/integration/billing/update-subscription/multi-product/schedules-free-to-paid.test.ts @@ -6,7 +6,6 @@ import { expectProductActive, expectProductCanceling, expectProductNotPresent, - expectProductScheduled, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; @@ -200,16 +199,6 @@ test.concurrent(`${chalk.yellowBright("schedules-f2p: downgrade entity 1, upgrad entity_id: entities[0].id, }); - // Verify entity 1 has scheduled downgrade - const entity1AfterDowngrade = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - console.log( - "Entity 1 products after downgrade:", - entity1AfterDowngrade.products, - ); - // Now upgrade entity 2 from free to paid const priceItem = items.monthlyPrice(); @@ -302,146 +291,3 @@ test.concurrent(`${chalk.yellowBright("schedules-f2p: downgrade entity 1, upgrad subCount: 1, }); }); - -// 3. Cancel entity 1 (pro→free scheduled), upgrade entity 2 (free→paid) -// This tests that when pro is canceled, the free default is scheduled as replacement -test.concurrent(`${chalk.yellowBright("schedules-f2p: cancel to default, upgrade entity 2")}`, async () => { - const customerId = "sched-cancel-default-upgrade"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - // Free is the default product - const free = constructProduct({ - id: "free", - items: [messagesItem], - type: "free", - isDefault: true, - }); - - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - - const { autumnV1, ctx, entities, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", withDefault: true }), - s.products({ list: [free, pro] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ productId: "pro", entityIndex: 0 }), - s.attach({ productId: "free", entityIndex: 1 }), - s.cancel({ productId: "pro", entityIndex: 0 }), // Cancel entity 1's pro → free scheduled - ], - }); - - // Verify entity 1 has pro canceled and free scheduled - const entity1AfterCancel = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectProductCanceling({ - customer: entity1AfterCancel, - productId: pro.id, - }); - await expectProductScheduled({ - customer: entity1AfterCancel, - productId: free.id, - }); - - // Now upgrade entity 2 from free to paid - const priceItem = items.monthlyPrice(); - - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[1].id, - product_id: free.id, - items: [messagesItem, priceItem], - }); - - expect(preview.total).toEqual(20); - - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entities[1].id, - product_id: free.id, - items: [messagesItem, priceItem], - }); - - // Verify entity 2 upgraded successfully - const entity2Data = await autumnV1.entities.get(customerId, entities[1].id); - - expectCustomerFeatureCorrect({ - customer: entity2Data, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage, - usage: 0, - }); - - // Verify invoices - const customer = await autumnV1.customers.get(customerId); - - expectCustomerInvoiceCorrect({ - customer, - count: 2, // entity1 pro attach + entity2 upgrade - latestTotal: 20, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - subCount: 1, - }); - - // Advance to next billing cycle - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - // Verify state after cycle - const entity1AfterCycle = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - const entity2AfterCycle = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - - // Entity 1: pro should be gone, free should be active - await expectProductNotPresent({ - customer: entity1AfterCycle, - productId: pro.id, - }); - await expectProductActive({ - customer: entity1AfterCycle, - productId: free.id, - }); - - // Entity 2: should still have paid product with features - await expectProductActive({ - customer: entity2AfterCycle, - productId: free.id, - }); - expectCustomerFeatureCorrect({ - customer: entity2AfterCycle, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage, - usage: 0, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - subCount: 1, - }); -}); diff --git a/server/tests/integration/billing/update-subscription/multi-product/schedules-from-paid.test.ts b/server/tests/integration/billing/update-subscription/multi-product/schedules-from-paid.test.ts index 8622a028f..10c4fee24 100644 --- a/server/tests/integration/billing/update-subscription/multi-product/schedules-from-paid.test.ts +++ b/server/tests/integration/billing/update-subscription/multi-product/schedules-from-paid.test.ts @@ -157,10 +157,6 @@ test.concurrent(`${chalk.yellowBright("schedules-p2p: downgrade entity 1, update customerId, entities[0].id, ); - console.log( - "Entity 1 products after downgrade:", - entity1AfterDowngrade.products, - ); // Entity 2 updates Premium items to different price const newPriceItem = items.monthlyPrice({ price: 60 }); // $60/mo @@ -501,10 +497,6 @@ test.concurrent(`${chalk.yellowBright("schedules-p2p: downgrade + update to free customerId, entities[0].id, ); - console.log( - "Entity 1 products after downgrade:", - entity1AfterDowngrade.products, - ); // Entity 2 updates Premium to be free (no price, only feature item) await autumnV1.subscriptions.update({ @@ -716,7 +708,7 @@ test.concurrent(`${chalk.yellowBright("schedules-p2p: downgrade entity 2 from pr customerId, entities[1].id, ); - await expectProductActive({ + await expectProductCanceling({ customer: entity2AfterDowngrade, productId: premium.id, }); @@ -736,7 +728,7 @@ test.concurrent(`${chalk.yellowBright("schedules-p2p: downgrade entity 2 from pr customerId, entities[1].id, ); - await expectProductActive({ + await expectProductCanceling({ customer: entity2AfterUpdate, productId: premium.id, }); @@ -869,7 +861,7 @@ test.concurrent(`${chalk.yellowBright("schedules-p2p: 3 premium entities - downg const entity2Data = await autumnV1.entities.get(customerId, entities[1].id); const entity3Data = await autumnV1.entities.get(customerId, entities[2].id); - await expectProductActive({ + await expectProductCanceling({ customer: entity1Data, productId: premium.id, }); diff --git a/server/tests/integration/billing/update-subscription/preview-total/preview-proration.test.ts b/server/tests/integration/billing/update-subscription/preview-total/preview-proration.test.ts deleted file mode 100644 index 6619edb3a..000000000 --- a/server/tests/integration/billing/update-subscription/preview-total/preview-proration.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, test } from "bun:test"; -import { applyProration, type Price, priceToLineAmount } from "@autumn/shared"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -/** - * Preview Total Tests - * - * These tests verify that preview calculations are correct by comparing - * against manually calculated expected values using shared math utilities. - */ - -// 1. Mid-cycle upgrade: free to paid with proration -test.concurrent(`${chalk.yellowBright("preview-total: mid-cycle free to paid proration")}`, async () => { - const customerId = "preview-midcycle-f2p"; - - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const free = products.base({ id: "free", items: [messagesItem] }); - - const { autumnV1, advancedTo } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [free] }), - ], - actions: [ - s.attach({ productId: "free" }), - s.advanceTestClock({ days: 15 }), // Mid-cycle - ], - }); - - // Get subscription to find billing period - const customer = await autumnV1.customers.get(customerId); - const subscription = customer.products?.[0]; - - if ( - !subscription?.current_period_start || - !subscription?.current_period_end - ) { - throw new Error("Missing billing period on subscription"); - } - - const billingPeriod = { - start: subscription.current_period_start, - end: subscription.current_period_end, - }; - - // Add a $20/mo price - const priceItem = items.monthlyPrice(); - - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: free.id, - items: [messagesItem, priceItem], - }); - - // Calculate expected amount manually - const baseAmount = priceToLineAmount({ - price: priceItem.price as unknown as Price, - multiplier: 1, - }); - - const expectedAmount = Math.round( - applyProration({ - now: advancedTo!, - billingPeriod, - amount: baseAmount, - }), - ); - - console.log("Base amount:", baseAmount); - console.log("Billing period:", billingPeriod); - console.log("Advanced to:", advancedTo); - console.log("Expected (prorated):", expectedAmount); - console.log("Preview total:", preview.total); - - expect(preview.total).toBe(expectedAmount); -}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/multi-feature-quantity.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/multi-feature-quantity.test.ts index 1eb8ec264..3c8537984 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/multi-feature-quantity.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/multi-feature-quantity.test.ts @@ -18,298 +18,240 @@ import chalk from "chalk"; * which would break if features were sent in a different order. */ -test.concurrent( - `${chalk.yellowBright("update-quantity: features in reverse order")}`, - async () => { - const customerId = "multi-feat-reverse-order"; +test.concurrent(`${chalk.yellowBright("update-quantity: features in reverse order")}`, async () => { + const customerId = "multi-feat-reverse-order"; - const product = products.base({ - id: "multi_feature", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits: 10, - price: 5, - }), - items.prepaid({ - featureId: TestFeature.Words, - billingUnits: 100, - }), - items.prepaid({ - featureId: TestFeature.Users, - billingUnits: 1, - price: 2, - }), - ], - }); + const product = products.base({ + id: "multi_feature", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: 10, + price: 5, + }), + items.prepaid({ + featureId: TestFeature.Words, + billingUnits: 100, + }), + items.prepaid({ + featureId: TestFeature.Users, + billingUnits: 1, + price: 2, + }), + ], + }); - // Attach with features in order: Messages, Words, Users - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * 10 }, // 100 messages - { feature_id: TestFeature.Words, quantity: 5 * 100 }, // 500 words - { feature_id: TestFeature.Users, quantity: 20 * 1 }, // 20 users - ], - }), - ], - }); + // Attach with features in order: Messages, Words, Users + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * 10 }, // 100 messages + { feature_id: TestFeature.Words, quantity: 5 * 100 }, // 500 words + { feature_id: TestFeature.Users, quantity: 20 * 1 }, // 20 users + ], + }), + ], + }); - // Update with features in REVERSE order: Users, Words, Messages - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Users, quantity: 50 * 1 }, // 50 users (increased) - { feature_id: TestFeature.Words, quantity: 10 * 100 }, // 1000 words (increased) - { feature_id: TestFeature.Messages, quantity: 5 * 10 }, // 50 messages (decreased) - ], - }); + // Update with features in REVERSE order: Users, Words, Messages + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Users, quantity: 50 * 1 }, // 50 users (increased) + { feature_id: TestFeature.Words, quantity: 10 * 100 }, // 1000 words (increased) + { feature_id: TestFeature.Messages, quantity: 5 * 10 }, // 50 messages (decreased) + ], + }); - const customer = await autumnV1.customers.get(customerId); + const customer = await autumnV1.customers.get(customerId); - // Each feature should be updated correctly despite reordering - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(50); - expect(customer.features?.[TestFeature.Words]?.balance).toBe(1000); - expect(customer.features?.[TestFeature.Users]?.balance).toBe(50); - }, -); + // Each feature should be updated correctly despite reordering + expect(customer.features?.[TestFeature.Messages]?.balance).toBe(50); + expect(customer.features?.[TestFeature.Words]?.balance).toBe(1000); + expect(customer.features?.[TestFeature.Users]?.balance).toBe(50); +}); -test.concurrent( - `${chalk.yellowBright("update-quantity: features in random order")}`, - async () => { - const customerId = "multi-feat-random-order"; +test.concurrent(`${chalk.yellowBright("update-quantity: features in random order")}`, async () => { + const customerId = "multi-feat-random-order"; - const product = products.base({ - id: "multi_feature", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits: 10, - price: 5, - }), - items.prepaid({ - featureId: TestFeature.Words, - billingUnits: 100, - }), - items.prepaid({ - featureId: TestFeature.Users, - billingUnits: 1, - price: 2, - }), - ], - }); + const product = products.base({ + id: "multi_feature", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: 10, + price: 5, + }), + items.prepaid({ + featureId: TestFeature.Words, + billingUnits: 100, + }), + items.prepaid({ + featureId: TestFeature.Users, + billingUnits: 1, + price: 2, + }), + ], + }); - // Attach with features in order: Messages, Words, Users - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * 10 }, - { feature_id: TestFeature.Words, quantity: 5 * 100 }, - { feature_id: TestFeature.Users, quantity: 20 * 1 }, - ], - }), - ], - }); + // Attach with features in order: Messages, Words, Users + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * 10 }, + { feature_id: TestFeature.Words, quantity: 5 * 100 }, + { feature_id: TestFeature.Users, quantity: 20 * 1 }, + ], + }), + ], + }); - // Update with features in random order: Words, Users, Messages - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words - { feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users - { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages - ], - }); + // Update with features in random order: Words, Users, Messages + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words + { feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users + { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages + ], + }); - const customer = await autumnV1.customers.get(customerId); + const customer = await autumnV1.customers.get(customerId); - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150); - expect(customer.features?.[TestFeature.Words]?.balance).toBe(2000); - expect(customer.features?.[TestFeature.Users]?.balance).toBe(100); - }, -); + expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150); + expect(customer.features?.[TestFeature.Words]?.balance).toBe(2000); + expect(customer.features?.[TestFeature.Users]?.balance).toBe(100); +}); -test.concurrent( - `${chalk.yellowBright("update-quantity: partial update (one feature changed)")}`, - async () => { - const customerId = "multi-feat-partial-update"; +test.concurrent(`${chalk.yellowBright("update-quantity: partial update (one feature changed)")}`, async () => { + const customerId = "multi-feat-partial-update"; - const product = products.base({ - id: "multi_feature", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits: 10, - price: 5, - }), - items.prepaid({ - featureId: TestFeature.Words, - billingUnits: 100, - }), - items.prepaid({ - featureId: TestFeature.Users, - billingUnits: 1, - price: 2, - }), - ], - }); + const product = products.base({ + id: "multi_feature", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: 10, + price: 5, + }), + items.prepaid({ + featureId: TestFeature.Words, + billingUnits: 100, + }), + items.prepaid({ + featureId: TestFeature.Users, + billingUnits: 1, + price: 2, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages - { feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words - { feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // 150 messages + { feature_id: TestFeature.Words, quantity: 20 * 100 }, // 2000 words + { feature_id: TestFeature.Users, quantity: 100 * 1 }, // 100 users + ], + }), + ], + }); - // Only update Words, keep others the same - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Words, quantity: 30 * 100 }, // 3000 words - only updating this - { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // Keep at 150 - { feature_id: TestFeature.Users, quantity: 100 * 1 }, // Keep at 100 - ], - }); + // Only update Words, keep others the same + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Words, quantity: 30 * 100 }, // 3000 words - only updating this + { feature_id: TestFeature.Messages, quantity: 15 * 10 }, // Keep at 150 + { feature_id: TestFeature.Users, quantity: 100 * 1 }, // Keep at 100 + ], + }); - const customer = await autumnV1.customers.get(customerId); + const customer = await autumnV1.customers.get(customerId); - // Words should be updated - expect(customer.features?.[TestFeature.Words]?.balance).toBe(3000); + // Words should be updated + expect(customer.features?.[TestFeature.Words]?.balance).toBe(3000); - // Others should remain the same - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150); - expect(customer.features?.[TestFeature.Users]?.balance).toBe(100); - }, -); + // Others should remain the same + expect(customer.features?.[TestFeature.Messages]?.balance).toBe(150); + expect(customer.features?.[TestFeature.Users]?.balance).toBe(100); +}); -test.concurrent( - `${chalk.yellowBright("update-quantity: error on non-existent feature")}`, - async () => { - const customerId = "multi-feat-nonexistent"; +test.concurrent(`${chalk.yellowBright("update-quantity: duplicate feature_ids uses last value")}`, async () => { + const customerId = "multi-feat-duplicate-ids"; - const product = products.base({ - id: "multi_feature", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits: 10, - price: 5, - }), - ], - }); + const product = products.base({ + id: "two_feature", + items: [ + items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: 10, + price: 5, + }), + items.prepaid({ + featureId: TestFeature.Words, + billingUnits: 100, + }), + ], + }); - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * 10 }, - ], - }), - ], - }); + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 10 * 10 }, + { feature_id: TestFeature.Words, quantity: 5 * 100 }, + ], + }), + ], + }); - // Try to update a feature that doesn't exist in the subscription - const invalidUpdate = autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: "non_existent_feature", quantity: 100 }, - ], - }); + // Send the same feature twice (edge case - should use last value or error) + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: 20 * 10 }, // First value: 200 + { feature_id: TestFeature.Messages, quantity: 30 * 10 }, // Second value (duplicate): 300 + { feature_id: TestFeature.Words, quantity: 10 * 100 }, + ], + }); - // Should throw an error - await expect(invalidUpdate).rejects.toThrow(); - }, -); + const customer = await autumnV1.customers.get(customerId); -test.concurrent( - `${chalk.yellowBright("update-quantity: duplicate feature_ids uses last value")}`, - async () => { - const customerId = "multi-feat-duplicate-ids"; + // The last value should win (or this should error - either is acceptable) + const messagesBalance = customer.features?.[TestFeature.Messages]?.balance; - const product = products.base({ - id: "two_feature", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits: 10, - price: 5, - }), - items.prepaid({ - featureId: TestFeature.Words, - billingUnits: 100, - }), - ], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * 10 }, - { feature_id: TestFeature.Words, quantity: 5 * 100 }, - ], - }), - ], - }); - - // Send the same feature twice (edge case - should use last value or error) - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 20 * 10 }, // First value: 200 - { feature_id: TestFeature.Messages, quantity: 30 * 10 }, // Second value (duplicate): 300 - { feature_id: TestFeature.Words, quantity: 10 * 100 }, - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - // The last value should win (or this should error - either is acceptable) - const messagesBalance = customer.features?.[TestFeature.Messages]?.balance; - - // Should be either 200 (first) or 300 (second), not some weird value from index mismatch - expect([200, 300]).toContain(messagesBalance ?? 0); - }, -); + // Should be either 200 (first) or 300 (second), not some weird value from index mismatch + expect([200, 300]).toContain(messagesBalance ?? 0); +}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/quantity-while-cancelling.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/quantity-while-cancelling.test.ts index ea73caa5c..340568e49 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/quantity-while-cancelling.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/quantity-while-cancelling.test.ts @@ -51,14 +51,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: active canceli price: pricePerUnit, }); - // Free is the default product - const free = constructProduct({ - id: "free", - items: [prepaidItem], - type: "free", - isDefault: true, - }); - const prepaid = constructRawProduct({ id: "prepaid", items: [prepaidItem], @@ -68,7 +60,7 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: active canceli customerId, setup: [ s.customer({ paymentMethod: "success" }), - s.products({ list: [free, prepaid] }), + s.products({ list: [prepaid] }), ], actions: [ s.attach({ @@ -88,10 +80,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: active canceli customer: customerAfterCancel, productId: prepaid.id, }); - await expectProductScheduled({ - customer: customerAfterCancel, - productId: free.id, - }); // Now update prepaid's quantity while it's canceling (10 → 20 units) await autumnV1.subscriptions.update({ @@ -112,12 +100,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: active canceli productId: prepaid.id, }); - // Scheduled free product should remain scheduled - await expectProductScheduled({ - customer: customerAfterUpdate, - productId: free.id, - }); - // Balance should be updated to 200 (20 units × 10 billing_units) expect(customerAfterUpdate.features?.[TestFeature.Messages]?.balance).toBe( 200, @@ -207,8 +189,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: scheduled prod productId: prepaid.id, }); - console.log("Products after downgrade:", customerAfterDowngrade.products); - // Now update the scheduled prepaid's quantity (5 → 15 units) await autumnV1.subscriptions.update({ customer_id: customerId, @@ -222,8 +202,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: scheduled prod const customerAfterUpdate = await autumnV1.customers.get(customerId); - console.log("Products after update:", customerAfterUpdate.products); - // Scheduled prepaid product should remain scheduled await expectProductScheduled({ customer: customerAfterUpdate, @@ -243,7 +221,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: scheduled prod org: ctx.org, env: ctx.env, subCount: 1, - shouldBeCanceled: true, }); }); @@ -274,14 +251,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: preserves usag price: pricePerUnit, }); - // Free is the default product - const free = constructProduct({ - id: "free", - items: [prepaidItem], - type: "free", - isDefault: true, - }); - const prepaid = constructRawProduct({ id: "prepaid", items: [prepaidItem], @@ -290,8 +259,8 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: preserves usag const { autumnV1, ctx } = await initScenario({ customerId, setup: [ - s.customer({ paymentMethod: "success", withDefault: true }), - s.products({ list: [free, prepaid] }), + s.customer({ paymentMethod: "success" }), + s.products({ list: [prepaid] }), ], actions: [ s.attach({ @@ -335,10 +304,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: preserves usag customer: customerAfterCancel, productId: prepaid.id, }); - await expectProductScheduled({ - customer: customerAfterCancel, - productId: free.id, - }); // Now update prepaid's quantity while it's canceling (10 → 20 units) await autumnV1.subscriptions.update({ @@ -359,12 +324,6 @@ test.concurrent(`${chalk.yellowBright("quantity-while-cancelling: preserves usag productId: prepaid.id, }); - // Scheduled free product should remain scheduled - await expectProductScheduled({ - customer: customerAfterUpdate, - productId: free.id, - }); - // Usage should be preserved expect(customerAfterUpdate.features?.[TestFeature.Messages]?.usage).toBe( messagesUsage, diff --git a/server/tests/integration/billing/update-subscription/update-quantity/quantity-with-cancellation.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/quantity-with-cancellation.test.ts deleted file mode 100644 index 28a069128..000000000 --- a/server/tests/integration/billing/update-subscription/update-quantity/quantity-with-cancellation.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; -import { CusService } from "@/internal/customers/CusService.js"; - -/** - * Subscription Update - Cancellation Integration Tests - * - * These tests verify that subscription updates correctly interact with - * the cancellation flow, including uncanceling subscriptions when a - * quantity update is performed on a canceled subscription. - */ - -const billingUnits = 12; - -test.concurrent( - `${chalk.yellowBright("update-quantity: uncancel when updating canceled subscription")}`, - async () => { - const customerId = "qty-cancel-uncancel"; - - const product = products.base({ - id: "prepaid", - items: [ - items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - }), - ], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 10 * billingUnits }, - ], - }), - s.cancel({ productId: product.id }), - ], - }); - - // Verify subscription is canceled - const customerBeforeUpdate = - await autumnV1.customers.get(customerId); - const subscriptionBefore = customerBeforeUpdate.products?.find( - (p) => p.id === product.id, - ); - - expect(subscriptionBefore).toBeDefined(); - expect(subscriptionBefore?.canceled_at).toBeDefined(); - expect(subscriptionBefore?.status).toBe("active"); - - // Update quantity - should uncancel - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: 20 * billingUnits }, - ], - }); - - const customerAfterUpdate = - await autumnV1.customers.get(customerId); - const subscriptionAfter = customerAfterUpdate.products?.find( - (p) => p.id === product.id, - ); - - // Should be uncanceled - expect(subscriptionAfter).toBeDefined(); - expect(subscriptionAfter?.canceled_at).toBeNull(); - expect(subscriptionAfter?.status).toBe("active"); - - // Balance should be updated - const feature = customerAfterUpdate.features?.[TestFeature.Messages]; - expect(feature?.balance).toBe(20 * billingUnits); - - // Verify internal canceled flag is false - const fullCustomer = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); - - const customerProduct = fullCustomer.customer_products.find( - (cp) => cp.product.id === product.id, - ); - - expect(customerProduct?.canceled).toBe(false); - expect(customerProduct?.canceled_at).toBeNull(); - expect(customerProduct?.ended_at).toBeNull(); - }, -); diff --git a/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts b/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts index 4e79850ce..f961c8d0c 100644 --- a/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts +++ b/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts @@ -19,12 +19,14 @@ export const expectProductTrialing = async ({ customer: providedCustomer, productId, trialEndsAt: expectedTrialEndsAt, + toleranceMs = TEN_MINUTES_MS, }: { customerId?: string; customer?: ApiCustomerV3 | ApiEntityV0; productId: string; /** Expected trial end timestamp (10 min tolerance) */ trialEndsAt?: number; + toleranceMs?: number; }) => { const customer = providedCustomer ? providedCustomer @@ -54,8 +56,8 @@ export const expectProductTrialing = async ({ // Verify trial_ends_at matches expected timestamp (with tolerance) if (expectedTrialEndsAt !== undefined) { expect( - Math.abs(trialEndsAt! - expectedTrialEndsAt) < TEN_MINUTES_MS, - `Product ${productId} current_period_end (${trialEndsAt}) should be within 10 min of ${expectedTrialEndsAt}`, + Math.abs(trialEndsAt! - expectedTrialEndsAt) < toleranceMs, + `Product ${productId} current_period_end (${formatMs(trialEndsAt)}) should be within 10 min of ${formatMs(expectedTrialEndsAt)}`, ).toBe(true); } diff --git a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts index fdaa6d147..a8d196557 100644 --- a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts +++ b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts @@ -89,20 +89,12 @@ export const calculateProratedRefund = async ({ }): Promise => { const { billingPeriod } = await getStripeSubscription({ customerId }); - console.log("calculateProratedRefund inputs:", { - nowMs, - billingPeriod, - amount, - }); - const prorated = applyProration({ now: nowMs, billingPeriod, amount, }); - console.log("applyProration result:", prorated); - return -Number(prorated.toFixed(2)); }; diff --git a/server/tests/crud/customers/create-customer1.test.ts b/server/tests/integration/crud/customers/create-customer1.test.ts similarity index 100% rename from server/tests/crud/customers/create-customer1.test.ts rename to server/tests/integration/crud/customers/create-customer1.test.ts diff --git a/server/tests/crud/customers/create-customer2.test.ts b/server/tests/integration/crud/customers/create-customer2.test.ts similarity index 100% rename from server/tests/crud/customers/create-customer2.test.ts rename to server/tests/integration/crud/customers/create-customer2.test.ts diff --git a/server/tests/crud/customers/list-customers.test.ts b/server/tests/integration/crud/customers/list-customers.test.ts similarity index 100% rename from server/tests/crud/customers/list-customers.test.ts rename to server/tests/integration/crud/customers/list-customers.test.ts diff --git a/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts b/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts new file mode 100644 index 000000000..f4dba26de --- /dev/null +++ b/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts @@ -0,0 +1,641 @@ +import { expect, test } from "bun:test"; +import { + CusExpand, + type LimitedItem, + OnDecrease, + OnIncrease, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; +import chalk from "chalk"; +import { addHours, addMonths, addWeeks } from "date-fns"; +import { Decimal } from "decimal.js"; +import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { timeout } from "@/utils/genUtils.js"; +import { + constructArrearProratedItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { + calcProrationAndExpectInvoice, + expectSubQuantityCorrect, + useEntityBalanceAndExpect, +} from "./utils/expectEntityUtils.js"; + +/** + * Tests for creating/deleting paid entities (seats) with billing + * Converted from: server/tests/contUse/entities/entity1.test.ts + * + * Pro product is $20/month base + $50/user seat + * Tests verify: + * - Creating entities generates correct invoices + * - Deleting entities creates replaceables (credit for deleted seats) + * - Creating new entities uses replaceables before charging + */ +test.concurrent(`${chalk.yellowBright("create-entity-paid: entity1 - create/delete entities with billing")}`, async () => { + // Custom user item with $50/user, 1 included, bill immediately on increase + const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + const pro = products.pro({ items: [userItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-entity-paid-1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + let usage = 0; + + // Step 1: Create first entity, then attach pro + const firstEntities = [ + { id: "1", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, firstEntities); + usage += 1; + + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Step 2: Create 2 more entities and verify invoice + const entities = [ + { id: "2", name: "test", feature_id: TestFeature.Users }, + { id: "3", name: "test2", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, entities); + await timeout(3000); + usage += entities.length; + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + itemQuantity: usage, + }); + + let customer = await autumnV1.customers.get(customerId); + let invoices = customer.invoices!; + expect(invoices.length).toBe(2); + expect(invoices[0].total).toBe(userItem.price! * entities.length); + + // Step 3: Delete 1 entity - should create replaceable, no new invoice + await autumnV1.entities.delete(customerId, entities[0].id); + + customer = await autumnV1.customers.get(customerId); + invoices = customer.invoices!; + expect(invoices.length).toBe(2); + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + numReplaceables: 1, + itemQuantity: usage - 1, + }); + + // Step 4: Create 2 new entities - should only pay for 1 (other uses replaceable) + const newEntities = [ + { id: "4", name: "test3", feature_id: TestFeature.Users }, + { id: "5", name: "test4", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, newEntities); + await timeout(3000); + usage += 1; // Only 1 because 1 uses the replaceable + + customer = await autumnV1.customers.get(customerId); + invoices = customer.invoices!; + + expect(invoices.length).toBe(3); + expect(invoices[0].total).toBe(userItem.price!); + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + itemQuantity: usage, + }); +}); + +/** + * Tests for entities with prorate immediately on increase/decrease + * Converted from: server/tests/contUse/entities/entity2.test.ts + * + * Pro product is $20/month base + $50/user seat with prorate immediately + * Tests verify: + * - Creating entities generates prorated invoices + * - Deleting entities generates prorated credit invoices + */ +test.concurrent(`${chalk.yellowBright("create-entity-paid: entity2 - prorate immediately on increase/decrease")}`, async () => { + // Custom user item with $50/user, 1 included, prorate immediately on both increase/decrease + const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + + const pro = products.pro({ items: [userItem] }); + + const { customerId, autumnV1, testClockId } = await initScenario({ + customerId: "create-entity-paid-2", + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + let usage = 0; + let curUnix = Date.now(); + + // Step 1: Create first entity, then attach pro + const firstEntities = [ + { id: "1", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, firstEntities); + usage += 1; + + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Step 2: Advance 2 weeks, create 2 entities and verify prorated invoice + const newEntities = [ + { id: "2", name: "test", feature_id: TestFeature.Users }, + { id: "3", name: "test2", feature_id: TestFeature.Users }, + ]; + + curUnix = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 30, + }); + + await autumnV1.entities.create(customerId, newEntities); + usage += newEntities.length; + + const { stripeSubs } = await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + itemQuantity: usage, + }); + + await timeout(5000); + + await calcProrationAndExpectInvoice({ + autumn: autumnV1, + stripeSubs, + customerId, + quantity: newEntities.length, + unitPrice: userItem.price!, + curUnix, + numInvoices: 2, + }); + + // Step 3: Advance 1 week, delete 1 entity and verify prorated credit invoice + curUnix = await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addWeeks(curUnix, 1).getTime(), + waitForSeconds: 30, + }); + + await timeout(5000); + + await autumnV1.entities.delete(customerId, newEntities[0].id); + usage -= 1; + + const { stripeSubs: stripeSubs2 } = await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + }); + + await calcProrationAndExpectInvoice({ + autumn: autumnV1, + stripeSubs: stripeSubs2, + customerId, + quantity: -1, + unitPrice: userItem.price!, + curUnix, + numInvoices: 3, + }); +}); + +/** + * Tests for replaceables being deleted at end of billing cycle + * Converted from: server/tests/contUse/entities/entity3.test.ts + * + * Pro product is $20/month base + $50/user seat + * Tests verify: + * - Deleting entities mid-cycle creates replaceables + * - At cycle renewal, replaceables are cleared and subscription is correct + */ +test.concurrent(`${chalk.yellowBright("create-entity-paid: entity3 - replaceables deleted at end of cycle")}`, async () => { + // Custom user item with $50/user, 1 included, bill immediately on increase, no change on decrease + const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + const pro = products.pro({ items: [userItem] }); + + const { customerId, autumnV1, testClockId } = await initScenario({ + customerId: "create-entity-paid-3", + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + let usage = 0; + + // Step 1: Create three entities, then attach pro + const firstEntities = [ + { id: "1", name: "test", feature_id: TestFeature.Users }, + { id: "2", name: "test", feature_id: TestFeature.Users }, + { id: "3", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Step 2: Advance 2 weeks, delete 2 entities - should have replaceables, no new invoice + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 30, + }); + + await autumnV1.entities.delete(customerId, firstEntities[0].id); + await autumnV1.entities.delete(customerId, firstEntities[1].id); + + const numReplaceables = 2; + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + numReplaceables, + itemQuantity: usage - numReplaceables, + }); + + let customer = await autumnV1.customers.get(customerId); + let invoices = customer.invoices!; + expect(invoices.length).toBe(1); + + // Step 3: Advance to next cycle - replaceables should be cleared + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + advanceTo: addHours( + addMonths(new Date(), 1), + hoursToFinalizeInvoice, + ).getTime(), + }); + + usage -= 2; // 2 entities deleted + + customer = await autumnV1.customers.get(customerId); + invoices = customer.invoices!; + + const basePrice = getBasePrice({ product: pro }); + expect(invoices.length).toBe(2); + expect(invoices[0].total).toBe(basePrice); // Only base price, 0 extra entities beyond included + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + itemQuantity: usage, + numReplaceables: 0, + }); +}); + +/** + * Tests for per-entity features (e.g., messages per user) + * Converted from: server/tests/contUse/entities/entity4.test.ts + * + * Pro product has: + * - $50/user seat with 1 included + * - 500 messages per entity (per user) + * + * Tests verify: + * - Per-entity balances are tracked correctly + * - Using balance at top level vs entity level + * - Deleting and creating entities maintains correct balances + */ +test.concurrent(`${chalk.yellowBright("create-entity-paid: entity4 - per entity features")}`, async () => { + // User item with $50/user, 1 included + const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + // Per-entity messages: 500 messages per user entity + const perEntityItem = constructFeatureItem({ + featureId: TestFeature.Messages, + entityFeatureId: TestFeature.Users, + includedUsage: 500, + }) as LimitedItem; + + const pro = products.pro({ items: [userItem, perEntityItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-entity-paid-4", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + let usage = 0; + + // Step 1: Create one entity, then attach pro + const firstEntities = [ + { id: "1", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Step 2: Create 2 more entities and verify message balance + const newEntities = [ + { id: "2", name: "test", feature_id: TestFeature.Users }, + { id: "3", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, newEntities); + usage += newEntities.length; + + const customer = await autumnV1.customers.get(customerId, { + expand: [CusExpand.Entities], + }); + + const res = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + expect(res.balance).toBe((perEntityItem.included_usage as number) * usage); + + // Verify each entity has correct balance + // @ts-expect-error - entities may not be typed + for (const entity of customer.entities) { + const entRes = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entity.id ?? "", + }); + expect(entRes.balance).toBe(perEntityItem.included_usage); + } + + // Step 3: Use from top level balance + const deduction = 600; + const perEntityIncluded = perEntityItem.included_usage as number; + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: deduction, + }); + await timeout(5000); + + const { balance } = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + expect(balance).toBe(perEntityIncluded * usage - deduction); + + // Step 4: Use from entity balances + await useEntityBalanceAndExpect({ + autumn: autumnV1, + customerId, + featureId: TestFeature.Messages, + entityId: "2", + }); + + await useEntityBalanceAndExpect({ + autumn: autumnV1, + customerId, + featureId: TestFeature.Messages, + entityId: "3", + }); + + // Step 5: Delete one entity and create a new one - master balance should remain same + const deletedEntityId = "2"; + const newEntity = { + id: "4", + name: "test", + feature_id: TestFeature.Users, + }; + + const { balance: masterBalanceBefore } = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const { balance: entityBalanceBefore } = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: deletedEntityId, + }); + + await autumnV1.entities.delete(customerId, deletedEntityId); + await autumnV1.entities.create(customerId, [newEntity]); + + const { balance: masterBalanceAfter } = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + expect(new Decimal(masterBalanceAfter ?? 0).toDP(5).toNumber()).toBe( + new Decimal(masterBalanceBefore ?? 0).toDP(5).toNumber(), + ); + + const { balance: entityBalanceAfter } = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: newEntity.id, + }); + + expect(entityBalanceAfter).toBe(entityBalanceBefore); +}); + +/** + * Tests for payment failures when creating entities + * Converted from: server/tests/contUse/entities/entity5.test.ts + * + * Tests verify: + * - Creating entities fails gracefully when payment fails + * - Tracking usage fails gracefully when payment fails + * - Subscription state remains unchanged after failure + */ +test.concurrent(`${chalk.yellowBright("create-entity-paid: entity5 - payment failure handling")}`, async () => { + // User item with $50/user, 1 included + const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + const pro = products.pro({ items: [userItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-entity-paid-5", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + let usage = 0; + + // Step 1: Create one entity, then attach pro + const firstEntities = [ + { id: "1", name: "test", feature_id: TestFeature.Users }, + ]; + await autumnV1.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + // Step 2: Attach a failing payment method + const fullCus = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + await attachFailedPaymentMethod({ + stripeCli: ctx.stripeCli, + customer: fullCus, + }); + + // Step 3: Try to create entities - should fail + await expectAutumnError({ + errMessage: "card was declined.", + func: async () => { + await autumnV1.entities.create(customerId, [ + { id: "2", name: "test", feature_id: TestFeature.Users }, + { id: "3", name: "test", feature_id: TestFeature.Users }, + ]); + }, + }); + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + numReplaceables: 0, + }); + + // Step 4: Try to track usage - should fail + await expectAutumnError({ + errMessage: "card was declined.", + func: async () => { + return await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 2, + }); + }, + }); + + await expectSubQuantityCorrect({ + stripeCli: ctx.stripeCli, + productId: pro.id, + db: ctx.db, + org: ctx.org, + env: ctx.env, + customerId, + usage, + numReplaceables: 0, + }); +}); diff --git a/server/tests/integration/crud/entities/create-entity/create-entity-race.test.ts b/server/tests/integration/crud/entities/create-entity/create-entity-race.test.ts new file mode 100644 index 000000000..4afb41dea --- /dev/null +++ b/server/tests/integration/crud/entities/create-entity/create-entity-race.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +test.concurrent(`${chalk.yellowBright("create-entity-race: mainCusEnt path should be protected by lock")}`, async () => { + // Product with allocated users (seats) - this creates a mainCusEnt with balance + const usersItem = items.allocatedUsers({ includedUsage: 5 }); + const pro = products.pro({ items: [usersItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-entity-race-1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Try to create two entities concurrently for the Users feature + // Since there's a mainCusEnt (allocated users with balance), the lock should be acquired + const [result1, result2] = await Promise.allSettled([ + autumnV1.entities.create(customerId, [ + { id: "seat-1", name: "Seat 1", feature_id: TestFeature.Users }, + ]), + autumnV1.entities.create(customerId, [ + { id: "seat-2", name: "Seat 2", feature_id: TestFeature.Users }, + ]), + ]); + + // One should succeed, one should fail with 429 (lock conflict) + const successes = [result1, result2].filter((r) => r.status === "fulfilled"); + const failures = [result1, result2].filter((r) => r.status === "rejected"); + + expect(successes.length).toBe(1); + expect(failures.length).toBe(1); + + const failedResult = failures[0] as PromiseRejectedResult; + // AutumnInt returns code: "rate_limit_exceeded" for 429 errors + expect(failedResult.reason.code).toBe("rate_limit_exceeded"); +}); + +test.concurrent(`${chalk.yellowBright("create-entity-race: non-mainCusEnt path allows concurrent creation")}`, async () => { + // Product with messages only - no Users entitlement + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initScenario({ + customerId: "create-entity-race-2", + setup: [s.customer({}), s.products({ list: [free] })], + actions: [s.attach({ productId: free.id })], + }); + + // Create entities for Users feature (no entitlement exists for this feature in the product) + // Both should succeed since there's no mainCusEnt for Users + const [result1, result2] = await Promise.allSettled([ + autumnV1.entities.create(customerId, [ + { id: "user-1", name: "User 1", feature_id: TestFeature.Users }, + ]), + autumnV1.entities.create(customerId, [ + { id: "user-2", name: "User 2", feature_id: TestFeature.Users }, + ]), + ]); + + // Both should succeed + expect(result1.status).toBe("fulfilled"); + expect(result2.status).toBe("fulfilled"); +}); diff --git a/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts b/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts new file mode 100644 index 000000000..199ae7595 --- /dev/null +++ b/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts @@ -0,0 +1,167 @@ +import { expect } from "bun:test"; +import { + type AppEnv, + cusProductToPrices, + type Organization, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; +import { findContUsePrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; +import { notNullish, timeout } from "@/utils/genUtils.js"; + +export const expectSubQuantityCorrect = async ({ + stripeCli, + productId, + usage, + db, + org, + env, + customerId, + itemQuantity, + numReplaceables = 0, +}: { + stripeCli: Stripe; + productId: string; + usage: number; + db: DrizzleCli; + org: Organization; + env: AppEnv; + customerId: string; + itemQuantity?: number; + numReplaceables?: number; +}) => { + const fullCus = await CusService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: customerId, + }); + + const cusProduct = fullCus.customer_products.find( + (cp) => cp.product_id === productId, + ); + + const stripeSubs = await getStripeSubs({ + stripeCli, + subIds: cusProduct?.subscription_ids, + }); + + const subItems = stripeSubs.flatMap((sub) => sub.items.data); + const prices = cusProductToPrices({ cusProduct: cusProduct! }); + + const contPrice = findContUsePrice({ prices }); + + const subItem = findStripeItemForPrice({ + price: contPrice!, + stripeItems: subItems, + }); + + expect(subItem).toBeDefined(); + + expect(subItem!.quantity).toBe( + notNullish(itemQuantity) ? itemQuantity : usage, + ); + + // Check num replaceables correct + const cusEnts = cusProduct?.customer_entitlements; + const cusEnt = cusEnts?.find((ent) => ent.feature_id === TestFeature.Users); + + expect(cusEnt).toBeDefined(); + expect(cusEnt?.replaceables.length).toBe(numReplaceables); + + const expectedBalance = cusEnt!.entitlement.allowance! - usage; + expect(cusEnt!.balance).toBe(expectedBalance); + + return { + fullCus, + cusProduct, + stripeSubs, + }; +}; + +export const calcProrationAndExpectInvoice = async ({ + autumn, + stripeSubs, + customerId, + quantity, + unitPrice, + curUnix, + numInvoices, +}: { + autumn: AutumnInt; + stripeSubs: Stripe.Subscription[]; + customerId: string; + quantity: number; + unitPrice: number; + curUnix: number; + numInvoices: number; +}) => { + const customer = await autumn.customers.get(customerId); + const invoices = customer.invoices; + + const sub = stripeSubs[0]; + const amount = quantity * unitPrice; + const { start, end } = subToPeriodStartEnd({ sub }); + let proratedAmount = calculateProrationAmount({ + amount, + periodStart: start * 1000, + periodEnd: end * 1000, + now: curUnix, + allowNegative: true, + }); + + proratedAmount = Number(proratedAmount.toFixed(2)); + + expect(invoices.length).toBe(numInvoices); + expect(invoices[0].total).toBe(proratedAmount); +}; + +export const useEntityBalanceAndExpect = async ({ + autumn, + customerId, + featureId, + entityId, +}: { + autumn: AutumnInt; + customerId: string; + featureId: string; + entityId: string; +}) => { + const deduction = new Decimal(Math.random() * 400) + .toDecimalPlaces(5) + .toNumber(); + + const balanceBefore = await autumn.check({ + customer_id: customerId, + feature_id: featureId, + entity_id: entityId, + }); + + await autumn.track({ + customer_id: customerId, + feature_id: featureId, + value: deduction, + entity_id: entityId, + }); + await timeout(3000); + + const balanceAfter = await autumn.check({ + customer_id: customerId, + feature_id: featureId, + entity_id: entityId, + }); + + const expectedBalance = new Decimal(balanceBefore.balance!) + .sub(deduction) + .toNumber(); + + expect(balanceAfter.balance).toBe(expectedBalance); +}; diff --git a/server/tests/crud/plan/create-plan-basic.test.ts b/server/tests/integration/crud/plan/create-plan-basic.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-basic.test.ts rename to server/tests/integration/crud/plan/create-plan-basic.test.ts diff --git a/server/tests/crud/plan/create-plan-boolean.test.ts b/server/tests/integration/crud/plan/create-plan-boolean.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-boolean.test.ts rename to server/tests/integration/crud/plan/create-plan-boolean.test.ts diff --git a/server/tests/crud/plan/create-plan-complex.test.ts b/server/tests/integration/crud/plan/create-plan-complex.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-complex.test.ts rename to server/tests/integration/crud/plan/create-plan-complex.test.ts diff --git a/server/tests/crud/plan/create-plan-cross-version.test.ts b/server/tests/integration/crud/plan/create-plan-cross-version.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-cross-version.test.ts rename to server/tests/integration/crud/plan/create-plan-cross-version.test.ts diff --git a/server/tests/crud/plan/create-plan-flags.test.ts b/server/tests/integration/crud/plan/create-plan-flags.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-flags.test.ts rename to server/tests/integration/crud/plan/create-plan-flags.test.ts diff --git a/server/tests/crud/plan/create-plan-metered.test.ts b/server/tests/integration/crud/plan/create-plan-metered.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-metered.test.ts rename to server/tests/integration/crud/plan/create-plan-metered.test.ts diff --git a/server/tests/crud/plan/create-plan-pricing.test.ts b/server/tests/integration/crud/plan/create-plan-pricing.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-pricing.test.ts rename to server/tests/integration/crud/plan/create-plan-pricing.test.ts diff --git a/server/tests/crud/plan/create-plan-tiered.test.ts b/server/tests/integration/crud/plan/create-plan-tiered.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-tiered.test.ts rename to server/tests/integration/crud/plan/create-plan-tiered.test.ts diff --git a/server/tests/crud/plan/create-plan-usage-pricing.test.ts b/server/tests/integration/crud/plan/create-plan-usage-pricing.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-usage-pricing.test.ts rename to server/tests/integration/crud/plan/create-plan-usage-pricing.test.ts diff --git a/server/tests/crud/plan/create-plan-validation.test.ts b/server/tests/integration/crud/plan/create-plan-validation.test.ts similarity index 100% rename from server/tests/crud/plan/create-plan-validation.test.ts rename to server/tests/integration/crud/plan/create-plan-validation.test.ts diff --git a/server/tests/crud/plan/get-plan/get-plan1.test.ts b/server/tests/integration/crud/plan/get-plan/get-plan1.test.ts similarity index 100% rename from server/tests/crud/plan/get-plan/get-plan1.test.ts rename to server/tests/integration/crud/plan/get-plan/get-plan1.test.ts diff --git a/server/tests/crud/plan/update-plan.test.ts b/server/tests/integration/crud/plan/update-plan.test.ts similarity index 100% rename from server/tests/crud/plan/update-plan.test.ts rename to server/tests/integration/crud/plan/update-plan.test.ts diff --git a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts index 8905667bf..6b867152f 100644 --- a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts +++ b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages"; +const ctx = contexts.create({}); + describe(chalk.yellowBright("applyExistingUsages"), () => { describe("basic usage deduction", () => { test("deducts existing usage from new entitlement balance", () => { @@ -29,6 +32,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => { // Act applyExistingUsages({ + ctx, customerProduct: cusProduct, existingUsages, entities: [], diff --git a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts index 91399ad73..3f1f5f064 100644 --- a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts +++ b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import { entities } from "@tests/utils/fixtures/db/entities"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages"; +const ctx = contexts.create({}); + describe( chalk.yellowBright("applyExistingUsages (testing entities flow)"), () => { @@ -67,6 +70,7 @@ describe( // Act applyExistingUsages({ + ctx, customerProduct: cusProduct, existingUsages, entities: entityList, @@ -118,7 +122,12 @@ describe( }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: entityList }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: entityList, + }); // Assert: Entity count (2) takes priority, balance = 10 - 2 = 8 const updatedCusEntA = customerProduct.customer_entitlements.find( @@ -173,7 +182,12 @@ describe( const existingUsages: ExistingUsages = {}; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: entityList }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: entityList, + }); // Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1 const updatedCusEnts = customerProduct.customer_entitlements.filter( diff --git a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts index afe750c2e..a5c9fd3aa 100644 --- a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts +++ b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import { EntInterval, type ExistingUsages } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages"; +const ctx = contexts.create({}); + describe( chalk.yellowBright("applyExistingUsages (testing deduction order)"), () => { @@ -44,6 +47,7 @@ describe( // Act applyExistingUsages({ + ctx, customerProduct: cusProduct, existingUsages, entities: [], @@ -96,7 +100,12 @@ describe( }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: [] }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: [], + }); // Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining const updatedPrepaid = customerProduct.customer_entitlements.find( @@ -149,7 +158,12 @@ describe( }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: [] }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: [], + }); const updatedPrepaid = customerProduct.customer_entitlements.find( (ce) => ce.usage_allowed === false, @@ -217,7 +231,12 @@ describe( }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: [] }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: [], + }); // Find each cusEnt by their unique characteristics const findCusEnt = (usageAllowed: boolean, interval: EntInterval) => diff --git a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts index 406d06bb5..642fc60cf 100644 --- a/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts +++ b/server/tests/unit/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages"; +const ctx = contexts.create({}); + describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { describe("entity usages deduction on entity-scoped cusEnt", () => { test("each entity's balance is reduced by its respective usage", () => { @@ -43,6 +46,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { // Act applyExistingUsages({ + ctx, customerProduct: cusProduct, existingUsages, entities: [], @@ -88,6 +92,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { // Act applyExistingUsages({ + ctx, customerProduct: cusProduct, existingUsages, entities: [], @@ -134,7 +139,12 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: [] }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: [], + }); // Assert: Deduction should flow through entities const updatedCusEnt = customerProduct.customer_entitlements[0]; @@ -185,7 +195,12 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }; // Act - applyExistingUsages({ customerProduct, existingUsages, entities: [] }); + applyExistingUsages({ + ctx, + customerProduct, + existingUsages, + entities: [], + }); // Assert: Entity balances should go negative const updatedCusEnt = customerProduct.customer_entitlements[0]; @@ -194,4 +209,154 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20 }); }); + + describe("per-entity to per-entity preservation", () => { + test("entity usages are preserved when updating from entity-scoped to entity-scoped cusEnt", () => { + const internalFeatureId = "internal_feature_a"; + const entityFeatureId = "entity_feature_id"; + + // Entity-scoped cusEnt with 3 entities, each with balance 100 + const entityScopedCusEnt = customerEntitlements.create({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 0, // Top-level balance not used for entity-scoped + entityFeatureId, + entities: { + entity1: { id: "entity1", balance: 100, adjustment: 0 }, + entity2: { id: "entity2", balance: 100, adjustment: 0 }, + entity3: { id: "entity3", balance: 100, adjustment: 0 }, + }, + }); + + const cusProduct = customerProducts.create({ + customerEntitlements: [entityScopedCusEnt], + }); + + // Existing entity usages: entity1 used 30, entity2 used 50, entity3 used 10 + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, + entityUsages: { + entity1: 30, + entity2: 50, + entity3: 10, + }, + }, + }; + + // Act + applyExistingUsages({ + ctx, + customerProduct: cusProduct, + existingUsages, + entities: [], + }); + + // Assert: Each entity's balance should be reduced by its usage + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.entities).not.toBeNull(); + expect(updatedCusEnt.entities?.entity1.balance).toBe(70); // 100 - 30 + expect(updatedCusEnt.entities?.entity2.balance).toBe(50); // 100 - 50 + expect(updatedCusEnt.entities?.entity3.balance).toBe(90); // 100 - 10 + }); + + test("entity usages are preserved even when some entities have no prior usage", () => { + const internalFeatureId = "internal_feature_a"; + const entityFeatureId = "entity_feature_id"; + + // Entity-scoped cusEnt with 3 entities + const entityScopedCusEnt = customerEntitlements.create({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 50, + balance: 0, + entityFeatureId, + entities: { + entity1: { id: "entity1", balance: 50, adjustment: 0 }, + entity2: { id: "entity2", balance: 50, adjustment: 0 }, + entity3: { id: "entity3", balance: 50, adjustment: 0 }, + }, + }); + + const cusProduct = customerProducts.create({ + customerEntitlements: [entityScopedCusEnt], + }); + + // Only entity1 has existing usage, entity2/entity3 have none + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, + entityUsages: { + entity1: 25, + // entity2 and entity3 have no entry - should remain at full balance + }, + }, + }; + + // Act + applyExistingUsages({ + ctx, + customerProduct: cusProduct, + existingUsages, + entities: [], + }); + + // Assert + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.entities).not.toBeNull(); + expect(updatedCusEnt.entities?.entity1.balance).toBe(25); // 50 - 25 + expect(updatedCusEnt.entities?.entity2.balance).toBe(50); // Unchanged + expect(updatedCusEnt.entities?.entity3.balance).toBe(50); // Unchanged + }); + }); + + describe("entity to non-entity conversion", () => { + test("entity usages are NOT summed when applied to non-entity-scoped cusEnt (current behavior)", () => { + const internalFeatureId = "internal_feature_a"; + + // Non-entity-scoped cusEnt (no entityFeatureId) + const nonEntityScopedCusEnt = customerEntitlements.create({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 300, + balance: 300, + // No entityFeatureId - not entity-scoped + }); + + const cusProduct = customerProducts.create({ + customerEntitlements: [nonEntityScopedCusEnt], + }); + + // Entity usages from previous entity-scoped product + // entity1: 30, entity2: 50, entity3: 10 = total 90 + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, // No top-level usage + entityUsages: { + entity1: 30, + entity2: 50, + entity3: 10, + }, + }, + }; + + // Act + applyExistingUsages({ + ctx, + customerProduct: cusProduct, + existingUsages, + entities: [], + }); + + // Assert: Entity usages should NOT be applied (cusEnt is non-entity-scoped) + // This is the CURRENT intentional behavior - entity usages don't convert to top-level + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.balance).toBe(300); // Unchanged - entity usages ignored + expect(updatedCusEnt.entities).toBeNull(); + }); + }); }); diff --git a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts index 82c1d5491..4791f7f10 100644 --- a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts +++ b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts @@ -86,7 +86,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { expect(existingUsages[internalFeatureId].usage).toBe(30); // Entity usages reflect current balances (not usage) expect(existingUsages[internalFeatureId].entityUsages).toEqual({ - entity1: 40, + entity1: 10, entity2: 25, }); }); diff --git a/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-floor.test.ts b/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-floor.test.ts new file mode 100644 index 000000000..5d0fdb606 --- /dev/null +++ b/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-floor.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { BillingInterval, getCycleEnd } from "@autumn/shared"; +import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils"; + +/** + * Test suite for getCycleEnd floor parameter + * + * The floor parameter sets a minimum allowed result. If the calculated cycle end + * is before the floor, the floor is returned instead. + * + * Use case: Long trial periods. When the billing anchor (trial end) is far in the + * future, getCycleEnd normally finds the next cycle boundary after `now`, which + * "wraps backwards" from the anchor. With floor, we can ensure billing doesn't + * start before the trial ends. + * + * Example: Trial ends 4 Aug (anchor), now is 16 Jan, monthly interval. + * Without floor: returns 4 Feb (next monthly boundary after now). + * With floor=4 Aug: returns 4 Aug (billing can't start before trial ends). + */ +describe("get-cycle-end-floor: floor parameter constrains minimum result", () => { + describe("Monthly interval with floor - long trial scenario", () => { + test("trial ends far in future: anchor 4 Aug, now 16 Jan, floor 4 Aug -> returns 4 Aug", () => { + // Trial ends 4 Aug (anchor), now is 16 Jan + // Without floor: cycle end would be 4 Feb (wraps backwards from anchor) + // With floor: returns 4 Aug (trial end date) + const anchor = toUnix({ year: 2026, month: 8, day: 4 }); + const now = toUnix({ year: 2026, month: 1, day: 16 }); + const floor = anchor; // Can't bill before trial ends + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + const { year, month, day } = fromUnix(result); + expect(year).toBe(2026); + expect(month).toBe(8); + expect(day).toBe(4); + }); + + test("200-day trial: anchor 4 Aug, now 16 Jan, floor 4 Aug -> returns anchor", () => { + // Simulating the exact bug scenario: 200-day trial + const anchor = toUnix({ + year: 2026, + month: 8, + day: 4, + hour: 13, + minute: 45, + }); + const now = toUnix({ + year: 2026, + month: 1, + day: 16, + hour: 13, + minute: 45, + }); + const floor = anchor; + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + }); + + test("floor not triggered when cycle end is after floor", () => { + // Anchor is 2 Jan, now is 15 Jan, floor is 1 Jan + // Cycle end is 2 Feb, which is after floor, so floor not used + const anchor = toUnix({ year: 2025, month: 1, day: 2 }); + const now = toUnix({ year: 2025, month: 1, day: 15 }); + const floor = toUnix({ year: 2025, month: 1, day: 1 }); + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + const { year, month, day } = fromUnix(result); + expect(year).toBe(2025); + expect(month).toBe(2); + expect(day).toBe(2); + }); + + test("floor equals calculated end: returns that value", () => { + // Edge case: floor is exactly the calculated cycle end + const anchor = toUnix({ year: 2025, month: 1, day: 15 }); + const now = toUnix({ year: 2025, month: 1, day: 20 }); + const floor = toUnix({ year: 2025, month: 2, day: 15 }); + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + }); + + test("anchor in near future (< 1 interval): without floor wraps correctly, with floor constrains", () => { + // Anchor is 28 Feb, now is 15 Jan + // Without floor: returns 28 Jan (next boundary) + // With floor of 28 Feb: returns 28 Feb + const anchor = toUnix({ year: 2025, month: 2, day: 28 }); + const now = toUnix({ year: 2025, month: 1, day: 15 }); + const floor = anchor; + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + const { month, day } = fromUnix(result); + expect(month).toBe(2); + expect(day).toBe(28); + }); + }); + + describe("Quarterly interval (intervalCount=3) with floor", () => { + test("long trial with quarterly billing: floor constrains result", () => { + // Trial ends 15 Jul (anchor, quarterly), now is 1 Feb + // Without floor: cycle end would be 15 Apr + // With floor: returns 15 Jul + const anchor = toUnix({ year: 2025, month: 7, day: 15 }); + const now = toUnix({ year: 2025, month: 2, day: 1 }); + const floor = anchor; + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 3, + now, + floor, + }); + + expect(result).toBe(floor); + const { month, day } = fromUnix(result); + expect(month).toBe(7); + expect(day).toBe(15); + }); + }); + + describe("Annual interval with floor", () => { + test("long trial with annual billing: floor constrains result", () => { + // Trial ends 15 Dec 2025 (anchor), now is 1 Mar 2025 + // Without floor: cycle end would be 15 Dec 2024 (past!) + // Wait, that's not right. Let me recalculate... + // Actually for annual: anchor 15 Dec 2025, now 1 Mar 2025 + // Difference is negative (-9 months), cyclesPassed = floor(-9/12) = -1 + // Next cycle end = anchor + (-1+1)*12 = 15 Dec 2025 + // So floor wouldn't be triggered here. Let's use a different scenario. + + // Anchor is 15 Dec 2026, now is 1 Mar 2025 + // This is a very long trial (21 months) + const anchor = toUnix({ year: 2026, month: 12, day: 15 }); + const now = toUnix({ year: 2025, month: 3, day: 1 }); + const floor = anchor; + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Year, + intervalCount: 1, + now, + floor, + }); + + // Without floor: would be 15 Dec 2025 + // With floor: returns 15 Dec 2026 + expect(result).toBe(floor); + const { year, month, day } = fromUnix(result); + expect(year).toBe(2026); + expect(month).toBe(12); + expect(day).toBe(15); + }); + }); + + describe("Weekly interval with floor", () => { + test("trial ends in 3 weeks: floor constrains result", () => { + // Anchor is 28 Jan (3 weeks from now), now is 7 Jan + // Without floor: cycle end would be 14 Jan (next weekly boundary) + // With floor: returns 28 Jan + const anchor = toUnix({ year: 2025, month: 1, day: 28 }); + const now = toUnix({ year: 2025, month: 1, day: 7 }); + const floor = anchor; + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Week, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + const { month, day } = fromUnix(result); + expect(month).toBe(1); + expect(day).toBe(28); + }); + }); + + describe("No floor (undefined) maintains original behavior", () => { + test("without floor: returns calculated cycle end even if anchor is far future", () => { + // Same scenario as first test but without floor + const anchor = toUnix({ year: 2026, month: 8, day: 4 }); + const now = toUnix({ year: 2026, month: 1, day: 16 }); + + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + // floor not provided + }); + + // Without floor, returns 4 Feb (next monthly boundary after now) + const { year, month, day } = fromUnix(result); + expect(year).toBe(2026); + expect(month).toBe(2); + expect(day).toBe(4); + }); + }); +}); diff --git a/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-floor.test.ts b/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-floor.test.ts new file mode 100644 index 000000000..8e1a808d2 --- /dev/null +++ b/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-floor.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { BillingInterval, getCycleStart } from "@autumn/shared"; +import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils"; + +/** + * Test suite for getCycleStart floor parameter + * + * The floor parameter sets a minimum allowed result. If the calculated cycle start + * is before the floor, the floor is returned instead. + * + * Use case: Subscription starts mid-cycle. Without floor, getCycleStart would return + * a date before the subscription existed. + * + * Example: Subscription starts 1 Jan, billing anchor is 15 Jan, now is 5 Jan. + * Without floor: returns 15 Dec (previous cycle boundary). + * With floor=1 Jan: returns 1 Jan (subscription start). + */ +describe("get-cycle-start-floor: floor parameter constrains minimum result", () => { + describe("Monthly interval with floor", () => { + test("subscription starts mid-cycle: anchor 15 Jan, now 5 Jan, floor 1 Jan -> returns 1 Jan", () => { + // Subscription created 1 Jan, anchor is 15 Jan, now is 5 Jan + // Without floor: cycle start would be 15 Dec (before subscription existed!) + // With floor: returns 1 Jan (subscription start) + const anchor = toUnix({ year: 2025, month: 1, day: 15 }); + const now = toUnix({ year: 2025, month: 1, day: 5 }); + const floor = toUnix({ year: 2025, month: 1, day: 1 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + const { year, month, day } = fromUnix(result); + expect(year).toBe(2025); + expect(month).toBe(1); + expect(day).toBe(1); + }); + + test("floor not triggered when cycle start is after floor", () => { + // Anchor is 2 Jan, now is 15 Feb, floor is 1 Jan + // Cycle start is 2 Feb, which is after floor, so floor not used + const anchor = toUnix({ year: 2025, month: 1, day: 2 }); + const now = toUnix({ year: 2025, month: 2, day: 15 }); + const floor = toUnix({ year: 2025, month: 1, day: 1 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + const { year, month, day } = fromUnix(result); + expect(year).toBe(2025); + expect(month).toBe(2); + expect(day).toBe(2); + }); + + test("floor equals calculated start: returns that value", () => { + // Edge case: floor is exactly the calculated cycle start + const anchor = toUnix({ year: 2025, month: 1, day: 15 }); + const now = toUnix({ year: 2025, month: 2, day: 20 }); + const floor = toUnix({ year: 2025, month: 2, day: 15 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + }); + + test("anchor far in future with floor: returns floor when cycle start would be before subscription", () => { + // Anchor is 15 Aug (7 months in future), now is 10 Jan + // Without floor: cycle start would be 15 Dec (previous year) + // With floor of 1 Jan: returns 1 Jan + const anchor = toUnix({ year: 2025, month: 8, day: 15 }); + const now = toUnix({ year: 2025, month: 1, day: 10 }); + const floor = toUnix({ year: 2025, month: 1, day: 1 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + }); + }); + + describe("Quarterly interval (intervalCount=3) with floor", () => { + test("subscription starts mid-quarter: floor constrains result", () => { + // Anchor is 15 Apr (quarterly), now is 20 Feb, subscription started 1 Feb + // Without floor: cycle start would be 15 Jan + // With floor: returns 1 Feb + const anchor = toUnix({ year: 2025, month: 4, day: 15 }); + const now = toUnix({ year: 2025, month: 2, day: 20 }); + const floor = toUnix({ year: 2025, month: 2, day: 1 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 3, + now, + floor, + }); + + expect(result).toBe(floor); + }); + }); + + describe("Annual interval with floor", () => { + test("subscription starts mid-year: floor constrains result", () => { + // Anchor is 15 Dec 2025, now is 1 Mar 2025, subscription started 1 Feb 2025 + // Without floor: cycle start would be 15 Dec 2024 + // With floor: returns 1 Feb 2025 + const anchor = toUnix({ year: 2025, month: 12, day: 15 }); + const now = toUnix({ year: 2025, month: 3, day: 1 }); + const floor = toUnix({ year: 2025, month: 2, day: 1 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Year, + intervalCount: 1, + now, + floor, + }); + + expect(result).toBe(floor); + }); + }); + + describe("No floor (undefined) maintains original behavior", () => { + test("without floor: returns calculated cycle start even if in the past", () => { + // Same scenario as first test but without floor + const anchor = toUnix({ year: 2025, month: 1, day: 15 }); + const now = toUnix({ year: 2025, month: 1, day: 5 }); + + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + // floor not provided + }); + + // Without floor, returns 15 Dec (previous cycle boundary) + const { year, month, day } = fromUnix(result); + expect(year).toBe(2024); + expect(month).toBe(12); + expect(day).toBe(15); + }); + }); +}); diff --git a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts index 767545067..6de741683 100644 --- a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts +++ b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts @@ -107,26 +107,5 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan); }); - - test("returns UpdatePlan with no options or items", () => { - const params: UpdateSubscriptionV0Params = { - ...baseParams, - }; - - const result = computeUpdateSubscriptionIntent(params); - - expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan); - }); - - test("returns UpdatePlan when options is empty array", () => { - const params: UpdateSubscriptionV0Params = { - ...baseParams, - options: [], - }; - - const result = computeUpdateSubscriptionIntent(params); - - expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan); - }); }); }); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index ef293ca6e..64b147bd5 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -43,15 +43,19 @@ const adminRights = () => /** * Monthly messages - resets each billing cycle * @param includedUsage - Free usage allowance (default: 100) + * @param entityFeatureId - Entity feature ID for per-entity balances */ const monthlyMessages = ({ includedUsage = 100, + entityFeatureId, }: { includedUsage?: number; + entityFeatureId?: string; } = {}): LimitedItem => constructFeatureItem({ featureId: TestFeature.Messages, includedUsage, + entityFeatureId, }) as LimitedItem; /** @@ -134,12 +138,14 @@ const prepaid = ({ billingUnits = 100, includedUsage = 0, config, + entityFeatureId, }: { featureId: string; price?: number; billingUnits?: number; includedUsage?: number; config?: ProductItemConfig; + entityFeatureId?: string; }): LimitedItem => constructPrepaidItem({ featureId, @@ -147,22 +153,26 @@ const prepaid = ({ billingUnits, includedUsage, config, + entityFeatureId, }) as LimitedItem; /** * Prepaid messages - purchase units upfront ($10/unit) * @param includedUsage - Free units before purchase required (default: 0), billing units are 100 + * @param entityFeatureId - Entity feature ID for per-entity balances */ const prepaidMessages = ({ includedUsage = 0, billingUnits = 100, price = 10, config, + entityFeatureId, }: { includedUsage?: number; billingUnits?: number; price?: number; config?: ProductItemConfig; + entityFeatureId?: string; } = {}): LimitedItem => prepaid({ featureId: TestFeature.Messages, @@ -170,6 +180,7 @@ const prepaidMessages = ({ billingUnits, includedUsage, config, + entityFeatureId, }); /** @@ -224,17 +235,21 @@ const oneOffMessages = ({ /** * Consumable messages - pay-per-use overage ($0.10/unit) * @param includedUsage - Free units before overage kicks in (default: 0) + * @param entityFeatureId - Entity feature ID for per-entity balances */ const consumableMessages = ({ includedUsage = 0, + entityFeatureId, }: { includedUsage?: number; + entityFeatureId?: string; } = {}): LimitedItem => constructArrearItem({ featureId: TestFeature.Messages, includedUsage, price: 0.1, billingUnits: 1, + entityFeatureId, }) as LimitedItem; // ═══════════════════════════════════════════════════════════════════ diff --git a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts index 0ddf245b9..4c57655de 100644 --- a/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts +++ b/shared/utils/billingUtils/cycleUtils/getCycleEnd.ts @@ -14,6 +14,12 @@ import { getCycleIntervalFunctions } from "./getCycleIntervalFunctions.js"; * @param interval - BillingInterval or EntInterval * @param intervalCount - Number of intervals per cycle (default: 1) * @param now - Current time (defaults to Date.now()) + * @param floor - Minimum allowed result (unix ms). If the calculated cycle end is before + * this value, returns the floor instead. Use this when billing cannot start before a + * certain date (e.g., trial end date for long trials). + * Example: Trial ends 4 Aug (anchor), now is 16 Jan, monthly interval. + * Without floor: returns 4 Feb (next monthly boundary after now). + * With floor=4 Aug: returns 4 Aug (billing can't start before trial ends). * @returns Unix timestamp of the next cycle end */ export const getCycleEnd = ({ @@ -21,20 +27,19 @@ export const getCycleEnd = ({ interval, intervalCount = 1, now, + floor, }: { anchor: number | "now"; interval: BillingInterval | EntInterval; intervalCount?: number; now: number; // milliseconds since epoch + floor?: number; }): number => { // EDGE CASE: anchor might be slightly before now due to network latency. const anchorDate = anchor === "now" ? new UTCDate(now) : new UTCDate(anchor); const nowDate = new UTCDate(now); - // For now, only handle monthly intervals - // TODO: Add support for other intervals - const intervalFunctions = getCycleIntervalFunctions({ interval }); if (!intervalFunctions) { @@ -61,11 +66,14 @@ export const getCycleEnd = ({ * (28 Feb will see cycles passes as -1, so next cycle will be anchorDate + (-1 + 1) months) */ - // const TOLERANCE_MS = 30 * 1000; // 30 seconds buffer for network latency - - /* TO CHECK: To we need a tolerance buffer? If so how much (seconds, milliseconds, etc.?) */ const candidate = add(anchorDate, cyclesPassed * intervalCount); - if (candidate.getTime() > now) return candidate.getTime(); + const result = + candidate.getTime() > now ? candidate.getTime() : nextCycleEnd.getTime(); - return nextCycleEnd.getTime(); + // If floor is provided and result is before floor, return floor + if (floor !== undefined && result < floor) { + return floor; + } + + return result; }; diff --git a/shared/utils/billingUtils/cycleUtils/getCycleStart.ts b/shared/utils/billingUtils/cycleUtils/getCycleStart.ts index 361f0e137..fbfbec7c4 100644 --- a/shared/utils/billingUtils/cycleUtils/getCycleStart.ts +++ b/shared/utils/billingUtils/cycleUtils/getCycleStart.ts @@ -14,7 +14,12 @@ import { getCycleIntervalFunctions } from "./getCycleIntervalFunctions.js"; * @param interval - BillingInterval or EntInterval * @param intervalCount - Number of intervals per cycle (default: 1) * @param now - Current time (defaults to Date.now()) - * @param floor - Whether to floor the cycle start to some unix + * @param floor - Minimum allowed result (unix ms). If the calculated cycle start is before + * this value, returns the floor instead. Use this when the subscription/entity didn't + * exist before a certain date (e.g., subscription creation date). + * Example: Subscription starts 1 Jan, anchor is 15 Jan, now is 5 Jan. + * Without floor: returns 15 Dec (previous cycle boundary). + * With floor=1 Jan: returns 1 Jan (subscription start). * @returns Unix timestamp of the current cycle start */ export const getCycleStart = ({ @@ -28,7 +33,7 @@ export const getCycleStart = ({ interval: BillingInterval | EntInterval; intervalCount?: number; now: number; // milliseconds since epoch; - floor?: number | undefined; + floor?: number; }): number => { const anchorDate = anchor === "now" ? new UTCDate(now) : new UTCDate(anchor); const nowDate = new UTCDate(now); @@ -62,7 +67,12 @@ export const getCycleStart = ({ finalCycleStart = add(anchorDate, (cyclesPassed - 1) * intervalCount); } - return floor - ? Math.floor(finalCycleStart.getTime()) - : finalCycleStart.getTime(); + const result = finalCycleStart.getTime(); + + // If floor is provided and result is before floor, return floor + if (floor !== undefined && result < floor) { + return floor; + } + + return result; }; diff --git a/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts b/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts deleted file mode 100644 index 2d5ed5ba5..000000000 --- a/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { BillingPeriod } from "../../../models/billingModels/invoicingModels/lineItemContext"; -import type { Price } from "../../../models/productModels/priceModels/priceModels"; -import { isOneOffPrice } from "../../productUtils/priceUtils/classifyPriceUtils"; -import { getCycleEnd } from "./getCycleEnd"; -import { getCycleStart } from "./getCycleStart"; - -export const getLineItemBillingPeriod = ({ - anchorMs, - price, - nowMs, -}: { - anchorMs: number | "now"; - price: Price; - nowMs: number; -}): BillingPeriod | undefined => { - if (isOneOffPrice(price)) return undefined; - - const { interval, interval_count: intervalCount } = price.config; - const start = getCycleStart({ - anchor: anchorMs, - interval, - intervalCount, - now: nowMs, - }); - const end = getCycleEnd({ - anchor: anchorMs, - interval, - intervalCount, - now: nowMs, - }); - - return { start, end }; -}; diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index c683772af..ac9437e8c 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -1,12 +1,10 @@ export * from "./cycleUtils/getCycleEnd.js"; export * from "./cycleUtils/getCycleStart.js"; -export * from "./cycleUtils/getLineItemBillingPeriod.js"; // Interval utils export * from "./intervalUtils/addDuration.js"; export * from "./intervalUtils/intervalArithmetic.js"; // Invoicing utils -export * from "./invoicingUtils/cusProductToArrearLineItems.js"; export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js"; export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js"; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts deleted file mode 100644 index c64b2b41f..000000000 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem"; -import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext"; -import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels"; -import type { Organization } from "../../../models/orgModels/orgTable"; -import { cusPriceToCusEntWithCusProduct } from "../../cusPriceUtils/convertCusPriceUtils"; -import { orgToCurrency } from "../../orgUtils/convertOrgUtils"; -import { isConsumablePrice } from "../../productUtils/priceUtils/classifyPriceUtils"; -import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod"; -import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem"; - -export const cusProductToArrearLineItems = ({ - cusProduct, - billingCycleAnchorMs, - nowMs, - org, -}: { - cusProduct: FullCusProduct; - billingCycleAnchorMs: number | "now"; - nowMs: number; - org: Organization; -}) => { - let lineItems: LineItem[] = []; - - for (const cusPrice of cusProduct.customer_prices) { - const price = cusPrice.price; - - if (!isConsumablePrice(price)) continue; - - // Calculate billing period - const billingPeriod = getLineItemBillingPeriod({ - anchorMs: billingCycleAnchorMs, - price, - nowMs, - }); - - const cusEnt = cusPriceToCusEntWithCusProduct({ - cusProduct, - cusPrice, - cusEnts: cusProduct.customer_entitlements, - }); - - if (!cusEnt) { - throw new Error( - `[cusProductToConsumableLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`, - ); - } - - const context: LineItemContext = { - price, - product: cusProduct.product, - feature: cusEnt.entitlement.feature, - - billingPeriod, - direction: "charge", - billingTiming: "in_arrear", - now: nowMs, - currency: orgToCurrency({ org }), - }; - - lineItems.push(usagePriceToLineItem({ cusEnt, context })); - } - - lineItems = lineItems.filter((item) => item.amount !== 0); - - return lineItems; -}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 6069425b2..cfd62bdd7 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -45,7 +45,10 @@ export const usagePriceToLineItem = ({ // 1. Get overage let overage = 0; if (isPrepaidPrice(cusPrice.price)) { - overage = cusEntsToPrepaidQuantity({ cusEnts: [cusEnt] }); + overage = cusEntsToPrepaidQuantity({ + cusEnts: [cusEnt], + sumAcrossEntities: false, + }); } else { overage = cusEntToInvoiceOverage({ cusEnt }); } @@ -53,7 +56,10 @@ export const usagePriceToLineItem = ({ // 2. Get usage let usage = 0; if (isPrepaidPrice(cusPrice.price)) { - usage = cusEntsToPrepaidQuantity({ cusEnts: [cusEnt] }); + usage = cusEntsToPrepaidQuantity({ + cusEnts: [cusEnt], + sumAcrossEntities: false, + }); } else { usage = cusEntToInvoiceUsage({ cusEnt }); } diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts index a9c1200fe..660271472 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts @@ -2,6 +2,7 @@ import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntTo import { Decimal } from "decimal.js"; import { type FullCusEntWithFullCusProduct, + isEntityScopedCusEnt, isPrepaidPrice, sumValues, } from "../../.."; @@ -9,8 +10,10 @@ import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProd export const cusEntToPrepaidQuantity = ({ cusEnt, + sumAcrossEntities = false, }: { cusEnt: FullCusEntWithFullCusProduct; + sumAcrossEntities?: boolean; }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); @@ -31,15 +34,25 @@ export const cusEntToPrepaidQuantity = ({ .mul(cusPrice.price.config.billing_units ?? 1) .toNumber(); + if (sumAcrossEntities && isEntityScopedCusEnt(cusEnt)) { + return new Decimal(quantityWithUnits) + .mul(Object.values(cusEnt.entities).length) + .toNumber(); + } + return quantityWithUnits; }; export const cusEntsToPrepaidQuantity = ({ cusEnts, + sumAcrossEntities = false, }: { cusEnts: FullCusEntWithFullCusProduct[]; + sumAcrossEntities?: boolean; }) => { return sumValues( - cusEnts.map((cusEnt) => cusEntToPrepaidQuantity({ cusEnt })), + cusEnts.map((cusEnt) => + cusEntToPrepaidQuantity({ cusEnt, sumAcrossEntities }), + ), ); }; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts index e73dfd019..d0308a3d0 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts @@ -45,7 +45,10 @@ export const cusEntToPurchasedBalance = ({ if (billingType === BillingType.UsageInAdvance) { // Purchased balance is how much was prepaid - const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt }); + const prepaidQuantity = cusEntToPrepaidQuantity({ + cusEnt, + sumAcrossEntities: nullish(entityId), + }); const mainOverage = getCusEntMainOverage({ cusEnt, entityId }); diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts index dbb70fdfa..ecab40902 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts @@ -1,5 +1,6 @@ import { Decimal } from "decimal.js"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { nullish } from "../../utils"; import { cusEntsToBalance } from "./cusEntsToBalance"; import { cusEntsToPrepaidQuantity } from "./cusEntsToPrepaidQuantity"; import { cusEntsToGrantedBalance } from "./grantedBalanceUtils/cusEntsToGrantedBalance"; @@ -13,7 +14,10 @@ export const cusEntsToUsage = ({ }) => { const grantedBalance = cusEntsToGrantedBalance({ cusEnts, entityId }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); + const prepaidQuantity = cusEntsToPrepaidQuantity({ + cusEnts, + sumAcrossEntities: nullish(entityId), + }); const balance = cusEntsToBalance({ cusEnts, entityId }); diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index ca8c9777d..11422fea2 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -51,6 +51,13 @@ export const isFreeCustomerEntitlement = ( return nullish(cusPrice); }; +export const isPaidCustomerEntitlement = ( + customerEntitlement: FullCusEntWithFullCusProduct, +) => { + const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); + return notNullish(cusPrice); +}; + export const isAllocatedCustomerEntitlement = ( customerEntitlement: FullCusEntWithFullCusProduct, ) => { diff --git a/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementByFeature.ts b/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementByFeature.ts new file mode 100644 index 000000000..8e7277556 --- /dev/null +++ b/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementByFeature.ts @@ -0,0 +1,71 @@ +import { InternalError } from "@autumn/shared"; +import type { Feature } from "@models/featureModels/featureModels"; +import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; + +export function findCustomerEntitlementByFeature< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>(params: { + cusEnts: T[]; + internalFeatureId?: string; + featureId?: string; + feature?: Feature; + errorOnNotFound: true; +}): T; + +export function findCustomerEntitlementByFeature< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>(params: { + cusEnts: T[]; + internalFeatureId?: string; + featureId?: string; + feature?: Feature; + errorOnNotFound?: false; +}): T | undefined; + +export function findCustomerEntitlementByFeature< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>({ + cusEnts, + internalFeatureId, + featureId, + feature, + errorOnNotFound = false, +}: { + cusEnts: T[]; + internalFeatureId?: string; + featureId?: string; + feature?: Feature; + errorOnNotFound?: boolean; +}): T | undefined { + const cusEnt = cusEnts.find((ce) => { + if (internalFeatureId && ce.internal_feature_id === internalFeatureId) { + return true; + } + if ( + featureId && + (ce.entitlement.feature_id === featureId || + ce.feature_id === featureId) + ) { + return true; + } + if ( + feature && + (ce.entitlement.feature.id === feature.id || + ce.entitlement.feature.internal_id === feature.internal_id) + ) { + return true; + } + return false; + }); + + if (!cusEnt && errorOnNotFound) { + const identifier = + internalFeatureId || featureId || feature?.id || feature?.internal_id; + throw new InternalError({ + message: `[findCustomerEntitlementByFeature] Customer entitlement not found for feature: ${identifier}`, + }); + } + + return cusEnt; +} diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index a19971562..a05f210f5 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -36,6 +36,7 @@ export * from "./filterCusEntUtils.js"; // Find utils export * from "./findCustomerEntitlement/findCustomerEntitlementById.js"; +export * from "./findCustomerEntitlement/findCustomerEntitlementByFeature.js"; export * from "./findCustomerEntitlement/findPrepaidCustomerEntitlement.js"; // Other utils export * from "./getRolloverFields.js"; diff --git a/shared/utils/productUtils/entUtils/classifyEntUtils.ts b/shared/utils/productUtils/entUtils/classifyEntUtils.ts index bf84f0158..1e21007ce 100644 --- a/shared/utils/productUtils/entUtils/classifyEntUtils.ts +++ b/shared/utils/productUtils/entUtils/classifyEntUtils.ts @@ -1,3 +1,4 @@ +import type { Entity } from "../../../models/cusModels/entityModels/entityModels"; import { FeatureType } from "../../../models/featureModels/featureEnums"; import { AllowanceType, @@ -37,3 +38,13 @@ export const isLifetimeEntitlement = ({ }) => { return entitlement.interval === EntInterval.Lifetime; }; + +export const entitlementHasEntityFeature = ({ + entitlement, + entity, +}: { + entitlement: EntitlementWithFeature; + entity: Entity; +}) => { + return entitlement.entity_feature_id === entity.feature_id; +}; diff --git a/shared/utils/productUtils/entUtils/entUtils.ts b/shared/utils/productUtils/entUtils/entUtils.ts deleted file mode 100644 index 87760d416..000000000 --- a/shared/utils/productUtils/entUtils/entUtils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Entity } from "../../../models/cusModels/entityModels/entityModels"; -import type { EntitlementWithFeature } from "../../../models/productModels/entModels/entModels"; - -export const entitlementFeatureMatchesEntityFeature = ({ - entitlement, - entity, -}: { - entitlement: EntitlementWithFeature; - entity: Entity; -}) => { - return entitlement.feature.internal_id === entity.internal_feature_id; -}; diff --git a/shared/utils/productUtils/entUtils/index.ts b/shared/utils/productUtils/entUtils/index.ts index 100820a57..aec013c52 100644 --- a/shared/utils/productUtils/entUtils/index.ts +++ b/shared/utils/productUtils/entUtils/index.ts @@ -1,4 +1,3 @@ export * from "./classifyEntUtils.js"; export * from "./enrichEntitlement.js"; -export * from "./entUtils.js"; export * from "./formatEntUtils.js"; diff --git a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx index aeee68477..ee2516eac 100644 --- a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx @@ -6,6 +6,7 @@ import { type FullCustomerEntitlement, type FullCustomerPrice, isUnlimitedCusEnt, + nullish, numberWithCommas, } from "@autumn/shared"; import { useEffect, useMemo, useState } from "react"; @@ -55,6 +56,7 @@ export function BalanceEditSheet() { if (!selectedCusEnt) return 0; return cusEntsToPrepaidQuantity({ cusEnts: [selectedCusEnt], + sumAcrossEntities: nullish(entityId), }); }, [selectedCusEnt]); diff --git a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts index 8d0c29deb..3415f2795 100644 --- a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts +++ b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts @@ -6,6 +6,7 @@ import { cusEntsToPrepaidQuantity, cusProductsToCusEnts, type FullCusProduct, + nullish, } from "@autumn/shared"; export interface FeatureUsageBalanceParams { @@ -54,7 +55,10 @@ export function useFeatureUsageBalance({ withRollovers: true, }); - const prepaidAllowance = cusEntsToPrepaidQuantity({ cusEnts }); + const prepaidAllowance = cusEntsToPrepaidQuantity({ + cusEnts, + sumAcrossEntities: nullish(entityId), + }); const balance = cusEntsToBalance({ cusEnts,