Files
cfw-autumn/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts
2026-01-15 15:04:37 +00:00

412 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout.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";
import { timeout } from "@/utils/genUtils.js";
const billingUnits = 12;
/**
* Invoice Mode Tests
*
* These tests verify different invoice mode configurations:
* - Default: draft invoice with immediate entitlements
* - Draft invoice with immediate entitlements (explicit)
* - Finalized invoice with immediate entitlements
* - Entitlements after payment (via checkout)
*/
test.concurrent(
`${chalk.yellowBright("update-quantity: default invoice mode (draft, immediate entitlements)")}`,
async () => {
const customerId = "inv-mode-default";
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 },
],
}),
],
});
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 15 * billingUnits },
],
invoice: true,
});
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
expect(afterBalance).toBe(beforeBalance + 60);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const feature = customer.features?.[TestFeature.Messages];
expect(feature?.balance).toBe(180);
const draftInvoice = customer.invoices?.find(
(inv) => inv.status === "draft",
);
expect(draftInvoice).toBeDefined();
},
);
test.concurrent(
`${chalk.yellowBright("update-quantity: draft invoice with immediate entitlements (explicit)")}`,
async () => {
const customerId = "inv-mode-draft-explicit";
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 },
],
}),
],
});
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 15 * billingUnits },
],
invoice: true,
finalize_invoice: false,
enable_product_immediately: true,
});
// Entitlements should be updated immediately
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// +5 units × 12 billing_units = +60 messages
expect(afterBalance).toBe(beforeBalance + 60);
// Verify via API that balance is updated and invoice is draft
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const feature = customer.features?.[TestFeature.Messages];
expect(feature?.balance).toBe(180); // 15 units × 12 = 180
const draftInvoice = customer.invoices?.find(
(inv) => inv.status === "draft",
);
expect(draftInvoice).toBeDefined();
},
);
test.concurrent(
`${chalk.yellowBright("update-quantity: finalized invoice with immediate entitlements")}`,
async () => {
const customerId = "inv-mode-finalized";
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 },
],
}),
],
});
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 20 * billingUnits },
],
invoice: true,
finalize_invoice: true,
enable_product_immediately: true,
});
// Entitlements should be updated immediately
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// +10 units × 12 billing_units = +120 messages
expect(afterBalance).toBe(beforeBalance + 120);
// Verify via API that balance is updated and invoice is paid
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const feature = customer.features?.[TestFeature.Messages];
expect(feature?.balance).toBe(240); // 20 units × 12 = 240
const paidInvoice = customer.invoices?.find(
(inv) => inv.status === "paid",
);
expect(paidInvoice).toBeDefined();
},
);
test.concurrent(
`${chalk.yellowBright("update-quantity: entitlements after payment via checkout")}`,
async () => {
const customerId = "inv-mode-payment-required";
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 },
],
}),
],
});
const beforeUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerProduct = beforeUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const beforeEntitlement = customerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const beforeBalance = beforeEntitlement?.balance || 0;
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 25 * billingUnits },
],
invoice: true,
finalize_invoice: true,
enable_product_immediately: false,
});
// Entitlements should NOT be updated yet (waiting for payment)
const afterUpdate = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const afterCustomerProduct = afterUpdate.customer_products.find(
(cp) => cp.product.id === product.id,
);
const afterEntitlement = afterCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const afterBalance = afterEntitlement?.balance || 0;
// Balance should remain unchanged until payment
expect(afterBalance).toBe(beforeBalance);
// Verify via API that balance is NOT updated and invoice is open
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const feature = customer.features?.[TestFeature.Messages];
expect(feature?.balance).toBe(120); // Still 10 units × 12 = 120
const openInvoice = customer.invoices?.find(
(inv) => inv.status === "open",
);
expect(openInvoice).toBeDefined();
expect(openInvoice?.hosted_invoice_url).toBeDefined();
// Complete payment via checkout using Puppeteer
await completeInvoiceCheckout({
url: openInvoice!.hosted_invoice_url!,
});
// Wait for webhook processing
await timeout(10000);
// Entitlements should now be updated after payment
const afterPayment = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const paidCustomerProduct = afterPayment.customer_products.find(
(cp) => cp.product.id === product.id,
);
const paidEntitlement = paidCustomerProduct?.customer_entitlements.find(
(ent) => ent.entitlement.feature_id === TestFeature.Messages,
);
const paidBalance = paidEntitlement?.balance || 0;
// +15 units × 12 billing_units = +180 messages
expect(paidBalance).toBe(beforeBalance + 180);
// Verify via API that balance is now updated and invoice is paid
const customerAfterPayment =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const featureAfterPayment =
customerAfterPayment.features?.[TestFeature.Messages];
expect(featureAfterPayment?.balance).toBe(300); // 25 units × 12 = 300
// All invoices should now be paid
const unpaidInvoices = customerAfterPayment.invoices?.filter(
(inv) => inv.status !== "paid",
);
expect(unpaidInvoices?.length ?? 0).toBe(0);
},
);