|
| 1 | +/** |
| 2 | + * Integration tests for duplicate discount handling across billing cycles. |
| 3 | + * |
| 4 | + * Tests that when a discount code already applied to a subscription is passed |
| 5 | + * again on a subsequent billing cycle, it is silently ignored (not applied twice) |
| 6 | + * and the attach still succeeds. |
| 7 | + */ |
| 8 | + |
| 9 | +import { expect, test } from "bun:test"; |
| 10 | +import type { ApiCustomerV3 } from "@autumn/shared"; |
| 11 | +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; |
| 12 | +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; |
| 13 | +import { items } from "@tests/utils/fixtures/items.js"; |
| 14 | +import { products } from "@tests/utils/fixtures/products.js"; |
| 15 | +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; |
| 16 | +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; |
| 17 | +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; |
| 18 | +import chalk from "chalk"; |
| 19 | +import { addHours, addMonths } from "date-fns"; |
| 20 | +import { createStripeCli } from "@/external/connect/createStripeCli.js"; |
| 21 | +import { |
| 22 | + createPercentCoupon, |
| 23 | + getStripeSubscription, |
| 24 | +} from "../../utils/discounts/discountTestUtils.js"; |
| 25 | + |
| 26 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 27 | +// TEST 1: billing.attach - duplicate discount on next cycle is silently ignored |
| 28 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 29 | + |
| 30 | +/** |
| 31 | + * Scenario: |
| 32 | + * - Customer on free (no subscription) |
| 33 | + * - Cycle 1: Attach pro WITH a 20% forever coupon → subscription created with coupon |
| 34 | + * - Advance to next invoice (cycle 2) |
| 35 | + * - Cycle 2: Upgrade to premium WITH same coupon → attach succeeds, duplicate ignored |
| 36 | + * |
| 37 | + * Expected: |
| 38 | + * - No error — attach succeeds |
| 39 | + * - premium is active, coupon not double-applied |
| 40 | + */ |
| 41 | +test.concurrent(`${chalk.yellowBright("attach-discount-double-use 1: billing.attach - duplicate discount on next cycle silently ignored")}`, async () => { |
| 42 | + const customerId = "att-disc-double-use-attach"; |
| 43 | + |
| 44 | + const free = products.base({ |
| 45 | + id: "free", |
| 46 | + items: [items.monthlyMessages({ includedUsage: 100 })], |
| 47 | + }); |
| 48 | + |
| 49 | + const pro = products.pro({ |
| 50 | + id: "pro", |
| 51 | + items: [items.monthlyMessages({ includedUsage: 500 })], |
| 52 | + }); |
| 53 | + |
| 54 | + const premium = products.premium({ |
| 55 | + id: "premium", |
| 56 | + items: [items.monthlyMessages({ includedUsage: 1000 })], |
| 57 | + }); |
| 58 | + |
| 59 | + const { autumnV1, advancedTo, testClockId } = await initScenario({ |
| 60 | + customerId, |
| 61 | + setup: [ |
| 62 | + s.customer({ paymentMethod: "success" }), |
| 63 | + s.products({ list: [free, pro, premium] }), |
| 64 | + ], |
| 65 | + actions: [s.billing.attach({ productId: free.id })], |
| 66 | + }); |
| 67 | + |
| 68 | + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); |
| 69 | + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); |
| 70 | + |
| 71 | + // Cycle 1: Attach pro with discount |
| 72 | + await autumnV1.billing.attach({ |
| 73 | + customer_id: customerId, |
| 74 | + product_id: pro.id, |
| 75 | + discounts: [{ reward_id: coupon.id }], |
| 76 | + }); |
| 77 | + |
| 78 | + // Advance to next invoice (start of cycle 2) |
| 79 | + await advanceTestClock({ |
| 80 | + stripeCli: ctx.stripeCli, |
| 81 | + testClockId: testClockId!, |
| 82 | + advanceTo: addHours( |
| 83 | + addMonths(new Date(advancedTo), 1), |
| 84 | + hoursToFinalizeInvoice, |
| 85 | + ).getTime(), |
| 86 | + waitForSeconds: 30, |
| 87 | + }); |
| 88 | + |
| 89 | + // Cycle 2: Upgrade with same discount — should succeed, duplicate silently ignored |
| 90 | + await autumnV1.billing.attach({ |
| 91 | + customer_id: customerId, |
| 92 | + product_id: premium.id, |
| 93 | + discounts: [{ reward_id: coupon.id }], |
| 94 | + }); |
| 95 | + |
| 96 | + const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId); |
| 97 | + await expectCustomerProducts({ |
| 98 | + customer, |
| 99 | + active: [premium.id], |
| 100 | + notPresent: [pro.id], |
| 101 | + }); |
| 102 | + |
| 103 | + // Coupon must appear exactly once — not double-applied |
| 104 | + const { subscription } = await getStripeSubscription({ customerId }); |
| 105 | + const couponDiscounts = subscription.discounts.filter((d) => { |
| 106 | + if (typeof d === "string") return false; |
| 107 | + const c = d.source?.coupon; |
| 108 | + return typeof c !== "string" && c?.id === coupon.id; |
| 109 | + }); |
| 110 | + expect(couponDiscounts).toHaveLength(1); |
| 111 | +}); |
| 112 | + |
| 113 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 114 | +// TEST 2: billing.multiAttach - duplicate discount on next cycle is silently ignored |
| 115 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 116 | + |
| 117 | +/** |
| 118 | + * Scenario: |
| 119 | + * - Customer on free (no subscription) |
| 120 | + * - Cycle 1: multiAttach [pro] WITH a 20% forever coupon → subscription created with coupon |
| 121 | + * - Advance to next invoice (cycle 2) |
| 122 | + * - Cycle 2: multiAttach [addon] WITH same coupon → attach succeeds, duplicate ignored |
| 123 | + * |
| 124 | + * Expected: |
| 125 | + * - No error — attach succeeds |
| 126 | + * - addon is active |
| 127 | + */ |
| 128 | +test.concurrent(`${chalk.yellowBright("attach-discount-double-use 2: billing.multiAttach - duplicate discount on next cycle silently ignored")}`, async () => { |
| 129 | + const customerId = "att-disc-double-use-multi"; |
| 130 | + |
| 131 | + const free = products.base({ |
| 132 | + id: "free", |
| 133 | + items: [items.monthlyMessages({ includedUsage: 100 })], |
| 134 | + }); |
| 135 | + |
| 136 | + const pro = products.pro({ |
| 137 | + id: "pro", |
| 138 | + items: [items.monthlyMessages({ includedUsage: 500 })], |
| 139 | + }); |
| 140 | + |
| 141 | + const addon = products.recurringAddOn({ |
| 142 | + id: "addon", |
| 143 | + items: [items.monthlyWords({ includedUsage: 100 })], |
| 144 | + }); |
| 145 | + |
| 146 | + const { autumnV1, advancedTo, testClockId } = await initScenario({ |
| 147 | + customerId, |
| 148 | + setup: [ |
| 149 | + s.customer({ paymentMethod: "success" }), |
| 150 | + s.products({ list: [free, pro, addon] }), |
| 151 | + ], |
| 152 | + actions: [s.billing.attach({ productId: free.id })], |
| 153 | + }); |
| 154 | + |
| 155 | + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); |
| 156 | + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); |
| 157 | + |
| 158 | + // Cycle 1: multiAttach pro with discount |
| 159 | + await autumnV1.billing.multiAttach({ |
| 160 | + customer_id: customerId, |
| 161 | + plans: [{ plan_id: pro.id }], |
| 162 | + discounts: [{ reward_id: coupon.id }], |
| 163 | + }); |
| 164 | + |
| 165 | + // Advance to next invoice (start of cycle 2) |
| 166 | + await advanceTestClock({ |
| 167 | + stripeCli: ctx.stripeCli, |
| 168 | + testClockId: testClockId!, |
| 169 | + advanceTo: addHours( |
| 170 | + addMonths(new Date(advancedTo), 1), |
| 171 | + hoursToFinalizeInvoice, |
| 172 | + ).getTime(), |
| 173 | + waitForSeconds: 30, |
| 174 | + }); |
| 175 | + |
| 176 | + // Cycle 2: multiAttach addon with same discount — should succeed, duplicate silently ignored |
| 177 | + await autumnV1.billing.multiAttach({ |
| 178 | + customer_id: customerId, |
| 179 | + plans: [{ plan_id: addon.id }], |
| 180 | + discounts: [{ reward_id: coupon.id }], |
| 181 | + }); |
| 182 | + |
| 183 | + const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId); |
| 184 | + await expectCustomerProducts({ |
| 185 | + customer, |
| 186 | + active: [pro.id, addon.id], |
| 187 | + notPresent: [free.id], |
| 188 | + }); |
| 189 | + |
| 190 | + // Coupon must appear exactly once on the pro subscription — not double-applied |
| 191 | + const { subscription } = await getStripeSubscription({ customerId }); |
| 192 | + const couponDiscounts = subscription.discounts.filter((d) => { |
| 193 | + if (typeof d === "string") return false; |
| 194 | + const c = d.source?.coupon; |
| 195 | + return typeof c !== "string" && c?.id === coupon.id; |
| 196 | + }); |
| 197 | + expect(couponDiscounts).toHaveLength(1); |
| 198 | +}); |
| 199 | + |
| 200 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 201 | +// TEST 3: same coupon on a different customer's subscription → allowed |
| 202 | +// ═══════════════════════════════════════════════════════════════════════════════ |
| 203 | + |
| 204 | +/** |
| 205 | + * Scenario: |
| 206 | + * - Customer 1 attaches pro WITH a 20% forever coupon → their subscription gets the coupon |
| 207 | + * - Customer 2 (different subscription) attaches pro with the SAME coupon → should succeed |
| 208 | + * |
| 209 | + * This confirms the check is per-subscription, not a global coupon blacklist. |
| 210 | + * The same coupon ID may be freely used across different customers' subscriptions. |
| 211 | + */ |
| 212 | +test.concurrent(`${chalk.yellowBright("attach-discount-double-use 3: same coupon on different customer subscription → allowed")}`, async () => { |
| 213 | + const customerId1 = "att-disc-double-use-c1"; |
| 214 | + const customerId2 = "att-disc-double-use-c2"; |
| 215 | + |
| 216 | + // Separate product objects per customer — initScenario mutates product.id in-place |
| 217 | + const free1 = products.base({ |
| 218 | + id: "free", |
| 219 | + items: [items.monthlyMessages({ includedUsage: 100 })], |
| 220 | + }); |
| 221 | + const pro1 = products.pro({ |
| 222 | + id: "pro", |
| 223 | + items: [items.monthlyMessages({ includedUsage: 500 })], |
| 224 | + }); |
| 225 | + |
| 226 | + const free2 = products.base({ |
| 227 | + id: "free", |
| 228 | + items: [items.monthlyMessages({ includedUsage: 100 })], |
| 229 | + }); |
| 230 | + const pro2 = products.pro({ |
| 231 | + id: "pro", |
| 232 | + items: [items.monthlyMessages({ includedUsage: 500 })], |
| 233 | + }); |
| 234 | + |
| 235 | + const { autumnV1 } = await initScenario({ |
| 236 | + customerId: customerId1, |
| 237 | + setup: [ |
| 238 | + s.customer({ paymentMethod: "success" }), |
| 239 | + s.products({ list: [free1, pro1] }), |
| 240 | + ], |
| 241 | + actions: [s.billing.attach({ productId: free1.id })], |
| 242 | + }); |
| 243 | + |
| 244 | + await initScenario({ |
| 245 | + customerId: customerId2, |
| 246 | + setup: [ |
| 247 | + s.customer({ paymentMethod: "success" }), |
| 248 | + s.products({ list: [free2, pro2] }), |
| 249 | + ], |
| 250 | + actions: [s.billing.attach({ productId: free2.id })], |
| 251 | + }); |
| 252 | + |
| 253 | + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); |
| 254 | + const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 }); |
| 255 | + |
| 256 | + // Customer 1: attach pro with coupon → their subscription gets the coupon |
| 257 | + await autumnV1.billing.attach({ |
| 258 | + customer_id: customerId1, |
| 259 | + product_id: pro1.id, |
| 260 | + discounts: [{ reward_id: coupon.id }], |
| 261 | + }); |
| 262 | + |
| 263 | + // Customer 2: attach pro with the SAME coupon → different subscription → must succeed |
| 264 | + await autumnV1.billing.attach({ |
| 265 | + customer_id: customerId2, |
| 266 | + product_id: pro2.id, |
| 267 | + discounts: [{ reward_id: coupon.id }], |
| 268 | + }); |
| 269 | + |
| 270 | + const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId2); |
| 271 | + await expectCustomerProducts({ |
| 272 | + customer: customer2, |
| 273 | + active: [pro2.id], |
| 274 | + notPresent: [free2.id], |
| 275 | + }); |
| 276 | +}); |
0 commit comments