Files
cfw-autumn/server/tests/integration/billing/sync/sync-basic.test.ts
2026-04-04 18:48:06 +01:00

657 lines
22 KiB
TypeScript

/**
* Sync Basic Tests
*
* Tests the Stripe → Autumn sync flow: fetching proposals from Stripe
* subscriptions and executing sync to create Autumn customer products.
*
* Test A: Customer has no product. Create a Stripe sub using Pro's real
* Stripe price ID, then sync it → Pro should be active.
*
* Test B: Customer starts on Free. Create a Stripe sub for Pro, sync
* with expire_previous → Free should be expired, Pro active.
*
* Test C: Pro + add-on in a single Stripe subscription → sync both.
*
* Test D: Two separate Stripe subscriptions for the same customer.
*
* Test E: Sync after advancing test clock by 2 weeks (reset anchoring).
*
* Test F: Sync a trialing Stripe subscription — customer product should be
* Trialing with trial_ends_at matching Stripe's trial_end.
*
* Test G: Stripe subscription with an inline (ad-hoc) price that doesn't
* exist in Autumn — proposals should still list it with price metadata.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import type Stripe from "stripe";
import type {
SyncProposal,
SyncProposalsResponse,
} from "@/internal/billing/v2/actions/sync/syncProposals";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
import {
createStripeSubscriptionFromProduct,
createStripeSubscriptionFromProducts,
getAllStripePriceIds,
} from "./utils/syncTestUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST A: Sync Pro subscription onto a customer with no existing product
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: sync Pro onto empty customer")}`, async () => {
const customerId = "sync-basic-empty";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// 1. Create a Stripe subscription using the real Stripe price ID
const stripeSubscription = await createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: pro.id,
});
expect(stripeSubscription.id).toBeDefined();
expect(stripeSubscription.status).toBe("active");
// 2. Fetch sync proposals — should find the subscription and auto-match
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
expect(proposalsResponse.proposals.length).toBeGreaterThanOrEqual(1);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
expect(matchedProposal!.stripe_subscription_status).toBe("active");
// Verify at least one item was auto-matched to our product
const matchedItem = matchedProposal!.items.find(
(item) => item.matched_plan_id === pro.id,
);
expect(matchedItem).toBeDefined();
// 3. Execute sync
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
},
],
});
expect(syncResult.results).toBeDefined();
expect(syncResult.results[0].success).toBe(true);
// 4. Verify customer now has the Pro product active
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// 5. Clean up: cancel the Stripe subscription we created directly
await ctx.stripeCli.subscriptions.cancel(stripeSubscription.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST B: Sync Pro subscription onto a customer that starts with Free
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: sync Pro onto customer with Free, expire previous")}`, async () => {
const customerId = "sync-basic-free-to-pro";
const messagesItem = items.monthlyMessages({ includedUsage: 50 });
const free = products.base({
id: "free",
items: [messagesItem],
group: "main",
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
group: "main",
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: free.id })],
});
// Verify customer starts with Free active
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBefore,
productId: free.id,
});
// 1. Create a Stripe subscription using Pro's real Stripe price ID
const stripeSubscription = await createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: pro.id,
});
expect(stripeSubscription.status).toBe("active");
// 2. Fetch sync proposals
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
// 3. Execute sync with expire_previous: true
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
expire_previous: true,
},
],
});
expect(syncResult.results[0].success).toBe(true);
// 4. Verify Pro is active, Free is expired (no longer in active products)
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfter,
productId: pro.id,
});
// Free should no longer be in the active products list
const freeProduct = (customerAfter.products ?? []).find(
(p: { id?: string }) => p.id === free.id,
);
expect(freeProduct === undefined || freeProduct.status === "expired").toBe(
true,
);
// Verify messages feature now reflects Pro's allowance
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST C: Pro + add-on in a single Stripe subscription
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: pro + add-on in single Stripe subscription")}`, async () => {
const customerId = "sync-basic-pro-addon";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const usersItem = items.monthlyUsers({ includedUsage: 5 });
const addon = products.recurringAddOn({
id: "addon",
items: [usersItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [],
});
// 1. Create a single Stripe subscription with items from both products
const stripeSubscription = await createStripeSubscriptionFromProducts({
ctx,
customerId,
productIds: [pro.id, addon.id],
});
expect(stripeSubscription.id).toBeDefined();
expect(stripeSubscription.status).toBe("active");
expect(stripeSubscription.items.data.length).toBeGreaterThanOrEqual(2);
// 2. Fetch sync proposals
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
expect(matchedProposal!.items.length).toBeGreaterThanOrEqual(2);
// 3. Execute sync — map to both Pro and Add-on
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
},
{
stripe_subscription_id: stripeSubscription.id,
plan_id: addon.id,
},
],
});
expect(syncResult.results).toBeDefined();
expect(syncResult.results.length).toBe(2);
// 4. Verify both products are active
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductActive({ customer, productId: addon.id });
// 5. Clean up
await ctx.stripeCli.subscriptions.cancel(stripeSubscription.id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST D: Two separate Stripe subscriptions for the same customer
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: two separate Stripe subscriptions")}`, async () => {
const customerId = "sync-basic-two-subs";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
group: "main",
});
const usersItem = items.monthlyUsers({ includedUsage: 10 });
const premium = products.premium({
id: "premium",
items: [usersItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
// 1. Create two separate Stripe subscriptions
const [stripeSub1, stripeSub2] = await Promise.all([
createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: pro.id,
}),
createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: premium.id,
}),
]);
expect(stripeSub1.id).toBeDefined();
expect(stripeSub2.id).toBeDefined();
expect(stripeSub1.id).not.toBe(stripeSub2.id);
// 2. Fetch sync proposals — should see both subscriptions
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const proposal1 = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSub1.id,
);
const proposal2 = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSub2.id,
);
expect(proposal1).toBeDefined();
expect(proposal2).toBeDefined();
// 3. Execute sync — each subscription maps to its own plan
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSub1.id,
plan_id: pro.id,
},
{
stripe_subscription_id: stripeSub2.id,
plan_id: premium.id,
},
],
});
expect(syncResult.results).toBeDefined();
expect(syncResult.results.length).toBe(2);
// 4. Verify both products are active
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductActive({ customer, productId: premium.id });
// 5. Clean up
await Promise.all([
ctx.stripeCli.subscriptions.cancel(stripeSub1.id),
ctx.stripeCli.subscriptions.cancel(stripeSub2.id),
]);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST E: Sync after advancing test clock by 2 weeks (reset anchoring)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: sync after advancing test clock 2 weeks")}`, async () => {
const customerId = "sync-basic-clock-advance";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
// 1. Create a Stripe subscription (automatically on the test clock)
const stripeSubscription = await createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: pro.id,
});
expect(stripeSubscription.status).toBe("active");
// 2. Advance test clock by 2 weeks
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfWeeks: 2,
waitForSeconds: 15,
});
// 3. Fetch sync proposals — subscription should still be active
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
expect(matchedProposal!.stripe_subscription_status).toBe("active");
// 4. Execute sync
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
},
],
});
expect(syncResult.results).toBeDefined();
expect(syncResult.results[0].success).toBe(true);
// 5. Verify customer has Pro active with correct features
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST F: Sync a trialing Stripe subscription (trial_ends_at alignment)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: sync trialing Stripe subscription")}`, async () => {
const customerId = "sync-basic-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
// 1. Get the Stripe customer ID and price IDs for manual sub creation with trial
const [fullCustomer, fullProduct] = await Promise.all([
CusService.getFull({ ctx, idOrInternalId: customerId }),
ProductService.getFull({
db: ctx.db,
idOrInternalId: pro.id,
orgId: ctx.org.id,
env: ctx.env,
}),
]);
const stripeCustomerId = fullCustomer.processor!.id!;
const stripePriceIds = getAllStripePriceIds({ fullProduct });
// 2. Create a Stripe subscription with a 14-day trial
const trialDays = 14;
const trialParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomerId,
items: stripePriceIds.map((priceId) => ({ price: priceId })),
trial_period_days: trialDays,
};
const stripeSubscription =
await ctx.stripeCli.subscriptions.create(trialParams);
expect(stripeSubscription.status).toBe("trialing");
expect(stripeSubscription.trial_end).toBeDefined();
const trialEndMs = stripeSubscription.trial_end! * 1000;
// 3. Advance test clock by 3 days (subscription should still be trialing)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 3,
waitForSeconds: 15,
});
// 4. Fetch sync proposals
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
expect(matchedProposal!.stripe_subscription_status).toBe("trialing");
// 5. Execute sync
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
},
],
});
expect(syncResult.results[0].success).toBe(true);
// 6. Verify customer product is trialing with correct trial_ends_at
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: trialEndMs,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST G: Stripe subscription with an ad-hoc inline price (no Autumn match)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("sync-basic: Stripe sub with inline ad-hoc price")}`, async () => {
const customerId = "sync-basic-adhoc-price";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// 1. Get the Stripe customer ID
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor!.id!;
// 2. Create a Stripe subscription with an inline price (no matching Autumn product)
const adHocProduct = await ctx.stripeCli.products.create({
name: "Ad-Hoc Service",
});
const adHocParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomerId,
items: [
{
price_data: {
currency: "usd",
unit_amount: 4999,
recurring: { interval: "month" },
product: adHocProduct.id,
},
},
],
};
const stripeSubscription =
await ctx.stripeCli.subscriptions.create(adHocParams);
expect(stripeSubscription.status).toBe("active");
// 3. Fetch sync proposals — should list the subscription with price metadata
const proposalsResponse: SyncProposalsResponse = await autumnV1.post(
"/billing.sync_proposals",
{ customer_id: customerId },
);
const matchedProposal = proposalsResponse.proposals.find(
(p: SyncProposal) => p.stripe_subscription_id === stripeSubscription.id,
);
expect(matchedProposal).toBeDefined();
expect(matchedProposal!.items.length).toBe(1);
const item = matchedProposal!.items[0];
// Price metadata should be populated even without an Autumn match
expect(item.unit_amount).toBe(4999);
expect(item.currency).toBe("usd");
expect(item.billing_scheme).toBe("per_unit");
expect(item.stripe_product_name).toBe("Ad-Hoc Service");
// No Autumn match expected
expect(item.matched_plan_id).toBeNull();
expect(item.match_method).toBeNull();
// 4. Sync it to Pro anyway (user manually picks a plan)
const syncResult = await autumnV1.post("/billing.sync", {
customer_id: customerId,
mappings: [
{
stripe_subscription_id: stripeSubscription.id,
plan_id: pro.id,
},
],
});
expect(syncResult.results[0].success).toBe(true);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
// 5. Clean up
await ctx.stripeCli.subscriptions.cancel(stripeSubscription.id);
});