Files
cfw-autumn/server/tests/integration/billing/attach/discounts/attach-discounts-basic.test.ts
Charlie Lamb 5d32be5a11 Reapply "feat: add discounts to attach v2"
This reverts commit ab68a1aa0c.
2026-02-11 09:04:36 -08:00

498 lines
15 KiB
TypeScript

/**
* Integration tests for attaching products with discounts param.
*
* Tests basic discount scenarios:
* - Percent-off and amount-off rewards on new subscriptions
* - Promotion code resolution
* - Multiple rewards stacking
* - Duplicate reward deduplication
* - Upgrade with discount
* - Preview accuracy with discounts
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectCustomerProducts } 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 ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import {
createAmountCoupon,
createPercentCoupon,
createPromotionCode,
getStripeSubscription,
} from "../../utils/discounts/discountTestUtils.js";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Free to Pro with percent-off reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off coupon in Stripe
* - Attach pro ($20/mo) with discount param
*
* Expected:
* - Pro active, free removed
* - Invoice = $20 * 0.8 = $16
*/
test.concurrent(`${chalk.yellowBright("attach-discount 1: free to pro with percent-off reward")}`, async () => {
const customerId = "att-disc-pct-off";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 16,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Free to Pro with amount-off reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create $5 off coupon in Stripe
* - Attach pro ($20/mo) with discount param
*
* Expected:
* - Pro active, free removed
* - Invoice = $20 - $5 = $15
*/
test.concurrent(`${chalk.yellowBright("attach-discount 2: free to pro with amount-off reward")}`, async () => {
const customerId = "att-disc-amt-off";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createAmountCoupon({ stripeCli, amountOffCents: 500 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 15,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Free to Pro with promotion code
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 25% coupon + promotion code in Stripe
* - Attach pro ($20/mo) with promotion_code param
*
* Expected:
* - Pro active
* - Invoice = $20 * 0.75 = $15
*/
test.concurrent(`${chalk.yellowBright("attach-discount 3: free to pro with promotion code")}`, async () => {
const customerId = "att-disc-promo";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
const promoCode = await createPromotionCode({
stripeCli,
coupon,
code: `SAVE25-${customerId}`,
});
// Use the human-readable code string (not the promo code ID)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ promotion_code: promoCode.code }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 15,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Multiple rewards stack on new subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off + $2 off coupons
* - Attach pro ($20/mo) with both discounts
*
* Expected:
* - Percent applied first: $20 * 0.8 = $16
* - Then amount: $16 - $2 = $14
* - Invoice = $14
*/
test.concurrent(`${chalk.yellowBright("attach-discount 4: multiple rewards stack on new subscription")}`, async () => {
const customerId = "att-disc-multi";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const pctCoupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
const amtCoupon = await createAmountCoupon({
stripeCli,
amountOffCents: 200,
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: pctCoupon.id }, { reward_id: amtCoupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// 20% off $20 = $16, then $2 off = $14
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 14,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Duplicate reward deduped
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 20% off coupon
* - Attach pro with same coupon passed twice in discounts array
*
* Expected:
* - Only one discount applied (deduped by coupon ID)
* - Invoice = $20 * 0.8 = $16 (not $20 * 0.8 * 0.8)
*/
test.concurrent(`${chalk.yellowBright("attach-discount 5: duplicate reward deduped")}`, async () => {
const customerId = "att-disc-dedup";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }, { reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// Only one 20% discount, not double
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 16,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Upgrade pro to premium with reward
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on pro ($20/mo)
* - Create 20% off coupon
* - Upgrade to premium ($50/mo) with discount
*
* Expected:
* - At start of cycle: refund -$20 (full pro), charge $50 (full premium)
* - Discount applies to charge: $50 * 0.8 = $40
* - Total: -$20 + $40 = $20
*/
test.concurrent(`${chalk.yellowBright("attach-discount 6: upgrade pro to premium with reward")}`, async () => {
const customerId = "att-disc-upgrade";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const { stripeCli } = await getStripeSubscription({ customerId });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
// Preview should include discount
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: coupon.id }],
});
// Refund -$20 + discounted charge ($50 * 0.8 = $40) = $20
expect(preview.total).toBe(20);
// Execute attach
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
// Invoices: pro ($20) + upgrade ($20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Preview includes discount and matches execution
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on free product
* - Create 25% off coupon
* - Preview attach pro ($20/mo) with discount
* - Execute attach with same discount
*
* Expected:
* - Preview total = $20 * 0.75 = $15
* - Invoice total matches preview
*/
test.concurrent(`${chalk.yellowBright("attach-discount 7: preview matches execution with discount")}`, async () => {
const customerId = "att-disc-preview";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({ stripeCli, percentOff: 25 });
// Preview
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
expect(preview.total).toBe(15);
// Execute
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
discounts: [{ reward_id: coupon.id }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Invoice total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
});