From 774ba56ecea2c0dda57f0691d245ff2c63a4a963 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sat, 7 Feb 2026 10:42:03 -0800 Subject: [PATCH] latest --- scripts/testGroups/all.sh | 2 +- scripts/testGroups/g2.sh | 37 ++-- .../internal/billing/attach/handleAttach.ts | 2 + .../buildAutumnLineItems.ts | 4 +- .../filterLineItemsForTrialTransition.ts | 44 ++-- .../addProductFlow/handlePaidProduct.ts | 9 +- .../updateQuantityFlow/updateQuantityFlow.ts | 13 +- .../upgradeFlow/handleUpgradeFlow.ts | 5 +- server/tests/_temp/seed-scenarios.ts | 18 +- server/tests/_temp/temp.test.ts | 72 ++----- .../attach-new-billing-subscription.test.ts | 100 +++++---- .../legacy/attach/trial/legacy-trial.test.ts | 44 ++-- .../upgrade/legacy-upgrade-merged.test.ts | 38 ++-- .../merged/upgrade/mergedUpgrade1.test.ts | 196 ------------------ .../merged/upgrade/mergedUpgrade2.test.ts | 143 ------------- .../merged/upgrade/mergedUpgrade3.test.ts | 152 -------------- .../merged/upgrade/mergedUpgrade4.test.ts | 146 ------------- server/tests/utils/stripeUtils.ts | 2 +- 18 files changed, 189 insertions(+), 838 deletions(-) delete mode 100644 server/tests/merged/upgrade/mergedUpgrade1.test.ts delete mode 100644 server/tests/merged/upgrade/mergedUpgrade2.test.ts delete mode 100644 server/tests/merged/upgrade/mergedUpgrade3.test.ts delete mode 100644 server/tests/merged/upgrade/mergedUpgrade4.test.ts diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index 85a3c351c..8d0035929 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -8,7 +8,7 @@ BUN_PARALLEL_V2 \ 'integration/billing/migrations' \ 'integration/billing/cron' \ 'integration/crud/customers' \ - # 'integration/billing/attach' \ + 'integration/billing/attach' \ # 'integration/billing/attach' \ diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index 02bae0585..7e0168143 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -4,23 +4,26 @@ source "$(dirname "$0")/config.sh" export TEST_FILE_CONCURRENCY=6 - # 'attach/migrations' \ BUN_PARALLEL_V2 \ - 'attach/basic' \ - 'attach/upgrade' \ - 'attach/downgrade' \ - 'attach/free' \ - 'attach/addOn' \ - 'attach/checkout' \ - 'attach/others' \ - 'attach/upgradeOld' \ - 'attach/response' \ - 'interval/upgrade' \ - 'interval/multiSub' \ - # 'billing/new-billing-subscription' \ - # 'billing/legacy/attach' \ - # 'server/tests/attach/entities' - # --max=6 + 'billing/legacy/attach' - # 'billing/invoice-action-required' \ \ No newline at end of file +# BUN_PARALLEL_V2 \ +# 'attach/basic' \ +# 'attach/upgrade' \ +# 'attach/downgrade' \ +# 'attach/free' \ +# 'attach/addOn' \ +# 'attach/checkout' \ +# 'attach/others' \ +# 'attach/upgradeOld' \ +# 'attach/response' \ +# 'interval/upgrade' \ +# 'interval/multiSub' \ +# 'server/tests/attach/entities' +# # 'billing/new-billing-subscription' \ +# # 'billing/legacy/attach' \ +# # --max=6 + + +# # 'billing/invoice-action-required' \ \ No newline at end of file diff --git a/server/src/internal/billing/attach/handleAttach.ts b/server/src/internal/billing/attach/handleAttach.ts index 2e234bf27..12b761205 100644 --- a/server/src/internal/billing/attach/handleAttach.ts +++ b/server/src/internal/billing/attach/handleAttach.ts @@ -111,6 +111,8 @@ export const handleAttach = createRoute({ const { products, customer } = attachParams; + console.log("ATTACH RESPONSE:", response); + const responseV1 = AttachResponseV1Schema.parse({ success: true, product_ids: products.map((p) => p.id), diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index fd80235c2..4d2fbf4f8 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -20,7 +20,7 @@ export const buildAutumnLineItems = ({ const { logger } = ctx; // For now, update subscription doesn't charge for existing usage. - const { lineItems: arrearLineItems, updateCustomerEntitlements } = + let { lineItems: arrearLineItems, updateCustomerEntitlements } = deletedCustomerProduct && includeArrearLineItems ? customerProductToArrearLineItems({ ctx, @@ -33,6 +33,8 @@ export const buildAutumnLineItems = ({ }) : { lineItems: [], updateCustomerEntitlements: [] }; + arrearLineItems = arrearLineItems.filter((lineItem) => lineItem.amount !== 0); + // Get line items for ongoing cus product const deletedLineItems = deletedCustomerProduct ? customerProductToLineItems({ diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts index 427c28ea6..47ee807ac 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition.ts @@ -32,33 +32,25 @@ export const filterLineItemsForTrialTransition = ({ return lineItems; } - return lineItems - .map((lineItem) => { - const { billingTiming, direction, price } = lineItem.context; - const isPositive = lineItem.amount > 0; - const isRecurringPrice = !isOneOffPrice(price); + return lineItems.filter((lineItem) => { + const { billingTiming, direction, price } = lineItem.context; + const isPositive = lineItem.amount > 0; + const isRecurringPrice = !isOneOffPrice(price); - // Starting trial (!isTrialing → willBeTrialing): - // Mark in_advance positive recurring items as deferred (will be charged after trial) - if (willBeTrialing) { - if (billingTiming === "in_advance" && isPositive && isRecurringPrice) { - return { ...lineItem, deferredForTrial: true }; - } - } + // Ending trial (isTrialing → !willBeTrialing): + // Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges) + if (isTrialing) { + if (direction === "refund") return false; + if (billingTiming === "in_arrear" && isPositive) return false; + } - return lineItem; - }) - .filter((lineItem) => { - const { billingTiming, direction } = lineItem.context; - const isPositive = lineItem.amount > 0; + // Starting trial (!isTrialing → willBeTrialing): + // Filter out in_advance positive items (no charge for upcoming trial period) + if (willBeTrialing) { + if (billingTiming === "in_advance" && isPositive && isRecurringPrice) + return false; + } - // Ending trial (isTrialing → !willBeTrialing): - // Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges) - if (isTrialing) { - if (direction === "refund") return false; - if (billingTiming === "in_arrear" && isPositive) return false; - } - - return true; - }); + return true; + }); }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 2f33493e4..92ec2663d 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -15,10 +15,7 @@ import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachPa import { billingActions } from "@/internal/billing/v2/actions/index.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { - attachToInvoiceResponse, - insertInvoiceFromAttach, -} from "@/internal/invoices/invoiceUtils.js"; +import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js"; import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; @@ -95,9 +92,7 @@ export const handlePaidProduct = async ({ checkout_url: billingResponse?.payment_url ?? undefined, invoice: attachParams.invoiceOnly - ? attachToInvoiceResponse({ - invoice: billingResult?.stripe?.stripeInvoice || undefined, - }) + ? billingResult?.stripe?.stripeInvoice : undefined, }); diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index c81fdddfb..81d846dd4 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -16,16 +16,17 @@ export const handleUpdateQuantityFunction = async ({ attachParams: AttachParams; body: AttachBodyV0; }) => { - const { billingResponse } = await billingActions.legacy.updateQuantity({ - ctx, - body, - attachParams, - }); + const { billingResponse, billingResult } = + await billingActions.legacy.updateQuantity({ + ctx, + body, + attachParams, + }); return AttachFunctionResponseSchema.parse({ code: SuccessCode.FeaturesUpdated, message: `Successfully updated quantity for features`, - invoice: billingResponse?.invoice, + invoice: billingResult?.stripe?.stripeInvoice, checkout_url: billingResponse?.payment_url, }); diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index 4cb229eb6..add30fb80 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -6,7 +6,6 @@ import { } from "@autumn/shared"; import { billingActions } from "@/internal/billing/v2/actions/index.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { attachToInvoiceResponse } from "@/internal/invoices/invoiceUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; export const handleUpgradeFlow = async ({ @@ -268,9 +267,7 @@ export const handleUpgradeFlow = async ({ checkout_url: billingResponse?.payment_url, invoice: attachParams.invoiceOnly - ? attachToInvoiceResponse({ - invoice: billingResult?.stripe?.stripeInvoice || undefined, - }) + ? billingResult?.stripe?.stripeInvoice : undefined, }); }; diff --git a/server/tests/_temp/seed-scenarios.ts b/server/tests/_temp/seed-scenarios.ts index 62cf4852c..f276eecb4 100644 --- a/server/tests/_temp/seed-scenarios.ts +++ b/server/tests/_temp/seed-scenarios.ts @@ -20,7 +20,7 @@ import { products } from "@tests/utils/fixtures/products.js"; import { createTestContext } from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -const SEED_PREFIX = "seed-cancel-test"; +const SEED_PREFIX = "seed"; async function seedScenarios() { // Clear and setup org first @@ -40,7 +40,7 @@ async function seedScenarios() { items.consumableWords({ includedUsage: 100 }), // 100 free words, then $0.05/word overage items.allocatedWorkflows({ includedUsage: 3 }), // 3 free workflows, $10/workflow overage items.prepaidMessages({ - includedUsage: 50, + includedUsage: 100, billingUnits: 100, price: 10, }), // 50 free messages, $10/100 pack @@ -83,12 +83,26 @@ async function seedScenarios() { ], }); + const growthProduct = products.growth({ + id: "growth", + items: [ + items.consumableWords({ includedUsage: 1000 }), + items.allocatedWorkflows({ includedUsage: 10 }), + items.prepaidMessages({ + includedUsage: 200, + billingUnits: 100, + price: 6, + }), // 200 free messages, $8/100 pack + ], + }); + const allProducts = [ proProduct, recurringAddOn, freeProduct, oneTimeProduct, premiumProduct, + growthProduct, ]; // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 0f238e207..2a80445fb 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,11 +1,4 @@ -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import { test } from "bun:test"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -13,60 +6,23 @@ import chalk from "chalk"; * Test: Attach free default product, then attach pro with invoice mode */ test.concurrent(`${chalk.yellowBright("invoice-mode: free default then pro with invoice checkout")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 50 }); - - // Free default product - const free = products.base({ - id: "free", - items: [messagesItem], - isDefault: true, - }); - - // Pro product with price - const proMessagesItem = items.monthlyMessages({ includedUsage: 200 }); - const pro = products.pro({ - id: "pro", - items: [proMessagesItem], - }); - - const customerId = "temp-invoice-free-then-pro"; - - // Setup customer with default attached const { autumnV1 } = await initScenario({ - customerId, + customerId: "test", setup: [ - s.customer({ withDefault: true }), - s.products({ list: [free, pro] }), + s.customer({ paymentMethod: "success" }), + // s.products({ list: [free, pro] }), ], - actions: [s.attachPaymentMethod({ type: "fail" })], + actions: [], }); - // Attach pro with invoice mode - const attachResult = await autumnV1.attach({ - customer_id: customerId, - product_id: pro.id, - // invoice: true, - }); - - console.log("attachResult", attachResult); - return; - - expect(attachResult.checkout_url).toBeDefined(); - - // Complete invoice checkout - await completeInvoiceCheckout({ - url: attachResult.checkout_url!, - }); - - // Verify pro is now active - const customerAfter = await autumnV1.customers.get(customerId); - expectProductActive({ - customer: customerAfter, - productId: pro.id, - }); - expectCustomerFeatureCorrect({ - customer: customerAfter, - featureId: TestFeature.Messages, - balance: 200, + await autumnV1.attach({ + customer_id: "test", + product_id: "pro_seed-cancel-test", + options: [ + { + feature_id: "messages", + quantity: 100, + }, + ], }); }); diff --git a/server/tests/integration/billing/legacy/attach/attach-new-billing-subscription.test.ts b/server/tests/integration/billing/legacy/attach/attach-new-billing-subscription.test.ts index d10a846de..26f0994dd 100644 --- a/server/tests/integration/billing/legacy/attach/attach-new-billing-subscription.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-new-billing-subscription.test.ts @@ -16,9 +16,13 @@ import { expect, test } from "bun:test"; import { type ApiCustomerV3, CusExpand } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductActive, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectSubCount } from "@tests/merged/mergeUtils/expectSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import ctx from "@tests/utils/testInitUtils/createTestContext"; @@ -69,13 +73,19 @@ test.concurrent(`${chalk.yellowBright("attach: paid add-on with new_billing_subs ], }); - // After first add-on attach: 2 subs + // After first add-on attach: 2 subs, both products active await expectSubCount({ ctx, customerId, count: 2 }); const customer1 = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customer1, productId: addOn.id }); - expect(customer1.invoices.length).toBe(2); - expect(customer1.invoices[0].total).toBe(10); + await expectCustomerProducts({ + customer: customer1, + active: [pro.id, addOn.id], + }); + expectCustomerInvoiceCorrect({ + customer: customer1, + count: 2, + latestTotal: 20, // recurringAddOn uses type: "pro" → $20/month + }); // Attach same add-on again → 3 subs await autumnV1.attach({ @@ -89,8 +99,11 @@ test.concurrent(`${chalk.yellowBright("attach: paid add-on with new_billing_subs const customer2 = await autumnV1.customers.get(customerId); const addOnProduct = customer2.products.find((p) => p.id === addOn.id); expect(addOnProduct?.quantity).toBe(2); - expect(customer2.invoices?.length).toBe(3); - expect(customer2.invoices?.[0].total).toBe(10); + expectCustomerInvoiceCorrect({ + customer: customer2, + count: 3, + latestTotal: 20, + }); }, 120000); // ═══════════════════════════════════════════════════════════════════════════════ @@ -143,7 +156,7 @@ test.concurrent(`${chalk.yellowBright("attach: entities with new_billing_subscri await expectSubCount({ ctx, customerId, count: 2 }); const entity1 = await autumnV1.entities.get(customerId, entities[0].id); - expectProductAttached({ customer: entity1, productId: premium.id }); + await expectProductActive({ customer: entity1, productId: premium.id }); // Attach premium to entity 2 → 3 subs await autumnV1.attach({ @@ -156,30 +169,25 @@ test.concurrent(`${chalk.yellowBright("attach: entities with new_billing_subscri await expectSubCount({ ctx, customerId, count: 3 }); const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - expectProductAttached({ customer: entity2, productId: premium.id }); + await expectProductActive({ customer: entity2, productId: premium.id }); - // Verify final state + // Verify final state: customer pro active, both entity premiums active const customer = await autumnV1.customers.get(customerId, { expand: [CusExpand.Invoices], }); - - const customerPro = customer.products.find((p) => p.id === pro.id); - expect(customerPro).toBeDefined(); - expect(customerPro?.status).toBe("active"); + await expectProductActive({ customer, productId: pro.id }); const entity1Final = await autumnV1.entities.get(customerId, entities[0].id); - const e1Premium = entity1Final.products?.find( - (p: { id?: string }) => p.id === premium.id, - ); - expect(e1Premium).toBeDefined(); - expect(e1Premium!.status).toBe("active"); + await expectProductActive({ + customer: entity1Final, + productId: premium.id, + }); const entity2Final = await autumnV1.entities.get(customerId, entities[1].id); - const e2Premium = entity2Final.products?.find( - (p: { id?: string }) => p.id === premium.id, - ); - expect(e2Premium).toBeDefined(); - expect(e2Premium!.status).toBe("active"); + await expectProductActive({ + customer: entity2Final, + productId: premium.id, + }); }, 120000); // ═══════════════════════════════════════════════════════════════════════════════ @@ -229,11 +237,15 @@ test.concurrent(`${chalk.yellowBright("attach: customer upgrade doesn't affect e // Verify initial: customer pro + entity premium = 2 subs await expectSubCount({ ctx, customerId, count: 2 }); - const customerBefore = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customerBefore, productId: pro.id }); + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ customer: customerBefore, productId: pro.id }); const entityBefore = await autumnV1.entities.get(customerId, entities[0].id); - expectProductAttached({ customer: entityBefore, productId: premium.id }); + await expectProductActive({ + customer: entityBefore, + productId: premium.id, + }); // Upgrade customer from pro → premium await autumnV1.attach({ @@ -241,29 +253,25 @@ test.concurrent(`${chalk.yellowBright("attach: customer upgrade doesn't affect e product_id: premium.id, }); + // Customer should have premium active, pro gone const customerAfter = await autumnV1.customers.get(customerId); - expectProductAttached({ customer: customerAfter, productId: premium.id }); - - // Pro should be gone from customer-level products - const proProduct = customerAfter.products.find( - (p) => p.id === pro.id && !p.entity_id, - ); - expect(proProduct).toBeUndefined(); + await expectCustomerProducts({ + customer: customerAfter, + active: [premium.id], + notPresent: [pro.id], + }); // Still 2 subs (customer premium + entity premium on separate sub) await expectSubCount({ ctx, customerId, count: 2 }); - // Entity should still have premium on its separate sub + // Entity should still have premium active on its separate sub const entityAfter = await autumnV1.entities.get(customerId, entities[0].id); - const entityProducts = entityAfter.products!; - expect(entityProducts.length).toBe(1); - const entityPremium = entityProducts.find( - (p: { id?: string }) => p.id === premium.id, - ); - expect(entityPremium).toBeDefined(); - expect(entityPremium!.status).toBe("active"); + await expectProductActive({ + customer: entityAfter, + productId: premium.id, + }); + expect(entityAfter.products!.length).toBe(1); - const invoices = customerAfter.invoices; - expect(invoices).toBeDefined(); - expect(invoices!.length).toBeGreaterThanOrEqual(1); -}, 120000); + expect(customerAfter.invoices).toBeDefined(); + expect(customerAfter.invoices!.length).toBeGreaterThanOrEqual(1); +}); diff --git a/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts b/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts index 639241cae..fb01b9980 100644 --- a/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts +++ b/server/tests/integration/billing/legacy/attach/trial/legacy-trial.test.ts @@ -17,6 +17,8 @@ import { AttachBranch, CusProductStatus, } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; @@ -111,11 +113,12 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 1: upgrade during trial (pro }); // Premium period_end ≈ curUnix + 7 days - const product = customer.products.find((p: any) => p.id === premium.id)!; - expect(product.current_period_end).toBeDefined(); - expect( - Math.abs(product.current_period_end! - addDays(curUnix, 7).getTime()), - ).toBeLessThanOrEqual(1000 * 60 * 30); // 30 min tolerance + await expectProductTrialing({ + customer: customer as any, + productId: premium.id, + trialEndsAt: addDays(curUnix, 7).getTime(), + toleranceMs: 1000 * 60 * 30, // 30 min tolerance + }); await expectSubToBeCorrect({ db: ctx.db, @@ -185,16 +188,10 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 2: upgrade after trial ends const curUnix = await advanceTestClock({ stripeCli: ctx.stripeCli, testClockId: testClockId!, - advanceTo: addDays(new Date(), 8).getTime(), + advanceTo: addDays(new Date(), 12).getTime(), + waitForSeconds: 30, }); - // Preview attach → should be Upgrade branch (trial ended, product is active) - const attachPreview = await autumnV1.attachPreview({ - customer_id: customerId, - product_id: premium.id, - }); - expect(attachPreview?.branch).toBe(AttachBranch.Upgrade); - // Get checkout total for comparison const checkoutRes = await autumnV1.checkout({ customer_id: customerId, @@ -217,14 +214,21 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 2: upgrade after trial ends }); // Premium period_end ≈ curUnix + 7 days - const product = customer.products.find((p: any) => p.id === premium.id)!; - expect(product.current_period_end).toBeDefined(); - expect( - Math.abs(product.current_period_end! - addDays(curUnix, 7).getTime()), - ).toBeLessThanOrEqual(1000 * 60 * 30); // 30 min tolerance + await expectProductTrialing({ + customer: customer as any, + productId: premium.id, + trialEndsAt: addDays(curUnix, 7).getTime(), + toleranceMs: 1000 * 60 * 30, // 30 min tolerance + }); + + await expectCustomerInvoiceCorrect({ + customer: customer as any, + count: 4, + latestTotal: 0, + }); // Invoice total should match checkout preview - expect(customer.invoices[0].total).toBe( + expect(customer.invoices?.[1]?.total).toBe( new Decimal(checkoutRes.total).toDP(2).toNumber(), ); @@ -235,4 +239,4 @@ test.concurrent(`${chalk.yellowBright("legacy-trial 2: upgrade after trial ends env: ctx.env, shouldBeTrialing: true, }); -}, 120000); +}); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts index c98f0f6ed..445662dee 100644 --- a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts +++ b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade-merged.test.ts @@ -14,8 +14,9 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV3, CusProductStatus } from "@autumn/shared"; +import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { TestFeature } from "@tests/setup/v2Features"; -import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; @@ -57,7 +58,7 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity i ], actions: [ s.attach({ productId: pro.id, entityIndex: 0 }), - s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }), + s.attach({ productId: pro.id, entityIndex: 1, timeout: 4000 }), s.track({ featureId: TestFeature.Words, value: entity1Val, @@ -70,7 +71,7 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity i entityIndex: 1, timeout: 3000, }), - s.advanceTestClock({ weeks: 2 }), + // s.advanceTestClock({ weeks: 2 }), ], }); @@ -81,6 +82,24 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity i entity_id: "ent-1", }); + const entity1Overage = calculateExpectedInvoiceAmount({ + items: pro.items, + usage: [{ featureId: TestFeature.Words, value: entity1Val }], + options: { onlyArrear: true }, + }); + + const entity1Base = + getBasePrice({ product: premium }) - getBasePrice({ product: pro }); + + const customerAfterAttach = + await autumnV1.customers.get(customerId); + + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 3, + latestTotal: entity1Base + entity1Overage, + }); + // Advance to next invoice to check usage billing await advanceToNextInvoice({ stripeCli: ctx.stripeCli, @@ -89,15 +108,10 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity i }); // Entity 2's usage on pro should show up on the invoice - const expectedUsageTotal = await getExpectedInvoiceTotal({ - customerId, - productId: pro.id, + const expectedUsageTotal = calculateExpectedInvoiceAmount({ + items: pro.items, usage: [{ featureId: TestFeature.Words, value: entity2Val }], - onlyIncludeUsage: true, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, + options: { includeFixed: false, onlyArrear: true }, }); const basePrice = @@ -106,7 +120,7 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity i const customer = await autumnV1.customers.get(customerId); const invoice = customer.invoices![0]; expect(invoice.total).toBe(basePrice + expectedUsageTotal); -}, 120000); +}); // ═══════════════════════════════════════════════════════════════════════════════ // TEST 2: Upgrade cancels scheduled downgrade (single entity) diff --git a/server/tests/merged/upgrade/mergedUpgrade1.test.ts b/server/tests/merged/upgrade/mergedUpgrade1.test.ts deleted file mode 100644 index 84650ac51..000000000 --- a/server/tests/merged/upgrade/mergedUpgrade1.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} 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 { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils"; -import { constructArrearItem } 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 premium = constructProduct({ - id: "premium", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", -}); -const premiumAnnual = constructProduct({ - id: "premiumAnnual", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "premium", - isAnnual: true, -}); - -const pro = constructProduct({ - id: "pro", - items: [constructArrearItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const ops = [ - { - entityId: "1", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, -]; - -const testCase = "mergedUpgrade1"; -describe(`${chalk.yellowBright("mergedUpgrade1: Testing merged subs, upgrade 1 & 2 to pro, add premium 2")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let testClockId: string; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, premium, premiumAnnual], - prefix: testCase, - customerId, - }); - - const res = await initCustomerV3({ - ctx, - customerId, - - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - testClockId = res.testClockId!; - - await autumn.entities.create(customerId, entities); - }); - - for (let index = 0; index < ops.length; index++) { - const op = ops[index]; - test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => { - try { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entityId: op.entityId, - }); - } catch (error) { - console.log( - `Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`, - ); - throw error; - } - }); - } - - const entity1Val = 100000; - const entity2Val = 300000; - - test("should advance test clock and upgrade entity 1 to premium, and have correct invoice", async () => { - await timeout(3000); - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: entity1Val, - entity_id: "1", - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Words, - value: entity2Val, - entity_id: "2", - }); - - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(Date.now(), 2).getTime(), - waitForSeconds: 30, - }); - - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - entityId: "1", - }); - }); - - test("should advance to next invoice and have correct invoice", async () => { - await advanceToNextInvoice({ - stripeCli, - testClockId, - withPause: true, - }); - - const expectedTotal = await getExpectedInvoiceTotal({ - org, - env, - customerId, - productId: pro.id, - stripeCli, - db, - onlyIncludeUsage: true, - usage: [ - { - featureId: TestFeature.Words, - value: entity2Val, - }, - ], - }); - - const customer = await autumn.customers.get(customerId); - const invoice = customer.invoices[0]; - const basePrice = - getBasePrice({ product: pro }) + getBasePrice({ product: premium }); - expect(invoice.total).toBe(basePrice + expectedTotal); - }); -}); diff --git a/server/tests/merged/upgrade/mergedUpgrade2.test.ts b/server/tests/merged/upgrade/mergedUpgrade2.test.ts deleted file mode 100644 index bd5ab43c5..000000000 --- a/server/tests/merged/upgrade/mergedUpgrade2.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.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"; - -// UNCOMMENT FROM HERE - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const free = constructProduct({ - id: "free", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "free", - isDefault: false, -}); - -const premium = constructProduct({ - id: "premium", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "premium", -}); -const growth = constructProduct({ - id: "growth", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "growth", -}); - -const ops = [ - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "1", - product: pro, - results: [ - { product: premium, status: CusProductStatus.Active }, - { product: pro, status: CusProductStatus.Scheduled }, - ], - }, - { - entityId: "1", - product: growth, - results: [{ product: growth, status: CusProductStatus.Active }], - }, -]; - -const testCase = "mergedUpgrade2"; -describe(`${chalk.yellowBright("mergedUpgrade2: Upgrading when there's a scheduled downgrade")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, free, premium, growth], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - - await autumn.entities.create(customerId, entities); - }); - - for (const op of ops) { - test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entities, - entityId: op.entityId, - }); - - for (const result of op.results) { - const entity = await autumn.entities.get(customerId, op.entityId); - expectProductAttached({ - customer: entity, - product: result.product, - status: result.status, - }); - } - }); - } -}); diff --git a/server/tests/merged/upgrade/mergedUpgrade3.test.ts b/server/tests/merged/upgrade/mergedUpgrade3.test.ts deleted file mode 100644 index 6b4448fbd..000000000 --- a/server/tests/merged/upgrade/mergedUpgrade3.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.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"; - -// UNCOMMENT FROM HERE - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const free = constructProduct({ - id: "free", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "free", - isDefault: false, -}); - -const premium = constructProduct({ - id: "premium", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "premium", -}); -const growth = constructProduct({ - id: "growth", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "growth", -}); - -const ops = [ - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, - { - entityId: "1", - product: pro, - results: [ - { product: premium, status: CusProductStatus.Active }, - { product: pro, status: CusProductStatus.Scheduled }, - ], - }, - { - entityId: "2", - product: pro, - results: [ - { product: premium, status: CusProductStatus.Active }, - { product: pro, status: CusProductStatus.Scheduled }, - ], - }, - { - entityId: "2", - product: growth, - results: [{ product: growth, status: CusProductStatus.Active }], - // waitForInvoice: 5000, - }, -]; - -const testCase = "mergedUpgrade3"; -describe(`${chalk.yellowBright("mergedUpgrade3: Upgrading when there's a scheduled downgrade")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, free, premium, growth], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - - await autumn.entities.create(customerId, entities); - }); - - for (const op of ops) { - test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entities, - entityId: op.entityId, - }); - - for (const result of op.results) { - const entity = await autumn.entities.get(customerId, op.entityId); - expectProductAttached({ - customer: entity, - product: result.product, - status: result.status, - }); - } - }); - } -}); diff --git a/server/tests/merged/upgrade/mergedUpgrade4.test.ts b/server/tests/merged/upgrade/mergedUpgrade4.test.ts deleted file mode 100644 index 787bc234d..000000000 --- a/server/tests/merged/upgrade/mergedUpgrade4.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - CusProductStatus, - LegacyVersion, - type Organization, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.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"; - -// OPERATIONS: -// Pro, Pro -// Free -// Premium - -const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "pro", -}); - -const free = constructProduct({ - id: "free", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "free", - isDefault: false, -}); - -const premium = constructProduct({ - id: "premium", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "premium", -}); -const growth = constructProduct({ - id: "growth", - items: [constructFeatureItem({ featureId: TestFeature.Words })], - type: "growth", -}); - -const ops = [ - { - entityId: "1", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "2", - product: pro, - results: [{ product: pro, status: CusProductStatus.Active }], - }, - { - entityId: "1", - product: free, - results: [ - { product: pro, status: CusProductStatus.Active }, - { product: free, status: CusProductStatus.Scheduled }, - ], - }, - { - entityId: "1", - product: premium, - results: [{ product: premium, status: CusProductStatus.Active }], - }, -]; - -const testCase = "mergedUpgrade4"; -describe(`${chalk.yellowBright("mergedUpgrade4: Upgrading when there's a cancel")}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - - let stripeCli: Stripe; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - - const entities = [ - { - id: "1", - name: "Entity 1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "Entity 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initProductsV0({ - ctx, - products: [pro, free, premium, growth], - prefix: testCase, - customerId, - }); - - await initCustomerV3({ - ctx, - customerId, - attachPm: "success", - withTestClock: true, - }); - - stripeCli = ctx.stripeCli; - db = ctx.db; - org = ctx.org; - env = ctx.env; - - await autumn.entities.create(customerId, entities); - }); - - for (const op of ops) { - test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: op.product, - stripeCli, - db, - org, - env, - entities, - entityId: op.entityId, - }); - - for (const result of op.results) { - const entity = await autumn.entities.get(customerId, op.entityId); - expectProductAttached({ - customer: entity, - product: result.product, - status: result.status, - }); - } - }); - } -}); diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index 6f4d0be84..ac8c0a35f 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -26,7 +26,7 @@ export const completeCheckoutForm = async ( _isLocal?: boolean, ) => { const browser = await puppeteer.launch({ - headless: false, + headless: true, executablePath: process.env.TESTS_CHROMIUM_PATH, args: ["--no-sandbox", "--disable-setuid-sandbox"], });