feat: 🎸 add tests and stuff
This commit is contained in:
@@ -13,6 +13,7 @@ import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBilling
|
|||||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
|
||||||
|
import { computeAttachPreviewBillingPlan } from "@/internal/billing/v2/utils/billingPlan/preview/computeAttachPreviewBillingPlan";
|
||||||
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
||||||
import {
|
import {
|
||||||
type CreateAutumnCheckoutResult,
|
type CreateAutumnCheckoutResult,
|
||||||
@@ -77,9 +78,14 @@ export async function updateSubscription({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (preview) {
|
if (preview) {
|
||||||
|
const previewBillingPlan = await computeAttachPreviewBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
billingContext,
|
billingContext,
|
||||||
billingPlan,
|
billingPlan: { ...billingPlan, preview: previewBillingPlan },
|
||||||
billingResult: undefined,
|
billingResult: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,5 +43,6 @@ export const billingPlanToAttachPreview = async ({
|
|||||||
redirect_to_checkout: willRedirectToCheckout,
|
redirect_to_checkout: willRedirectToCheckout,
|
||||||
checkout_type: checkoutType,
|
checkout_type: checkoutType,
|
||||||
tax: billingPlan.preview?.tax,
|
tax: billingPlan.preview?.tax,
|
||||||
|
invoice_credits: billingPlan.preview?.invoiceCredits,
|
||||||
} satisfies AttachPreviewResponse;
|
} satisfies AttachPreviewResponse;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import type {
|
import type {
|
||||||
AttachBillingContext,
|
|
||||||
AutumnBillingPlan,
|
AutumnBillingPlan,
|
||||||
|
BillingContext,
|
||||||
PreviewBillingPlan,
|
PreviewBillingPlan,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import { computeAttachInvoiceCreditPreview } from "./invoiceCredits/computeAttachInvoiceCreditPreview";
|
||||||
import { computeAttachTaxPreview } from "./tax/computeAttachTaxPreview";
|
import { computeAttachTaxPreview } from "./tax/computeAttachTaxPreview";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build-stage orchestrator for preview-only enrichments on the attach flow.
|
* Build-stage orchestrator for preview-only enrichments. Originally
|
||||||
|
* scoped to the attach flow (hence the name); now also drives previews
|
||||||
|
* for `updateSubscription` and `multiAttach`. The helpers it composes
|
||||||
|
* only read fields available on the parent `BillingContext`, so the
|
||||||
|
* widened parameter type is type-safe across all preview callers.
|
||||||
*
|
*
|
||||||
* Invoked from `attach.ts` ONLY when `preview: true`. Calls each individual
|
* Rename to `computePreviewBillingPlan` is a follow-up.
|
||||||
* enrichment helper (currently just tax) and assembles the
|
|
||||||
* `PreviewBillingPlan` bag that lives at `billingPlan.preview`.
|
|
||||||
*
|
*
|
||||||
* New preview enrichments (per-line tax breakdown, alt-currency previews,
|
* Invoked from action handlers ONLY when `preview: true`. Calls each
|
||||||
* next-cycle tax, etc.) slot in here as additional fields on
|
* individual enrichment helper and assembles the `PreviewBillingPlan`
|
||||||
* `PreviewBillingPlan` + their own helper module under `preview/`.
|
* bag that lives at `billingPlan.preview`. New preview enrichments
|
||||||
|
* (per-line tax breakdown, alt-currency previews, next-cycle tax, etc.)
|
||||||
|
* slot in here as additional fields on `PreviewBillingPlan` + their own
|
||||||
|
* helper module under `preview/`.
|
||||||
*/
|
*/
|
||||||
export const computeAttachPreviewBillingPlan = async ({
|
export const computeAttachPreviewBillingPlan = async ({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -23,14 +29,17 @@ export const computeAttachPreviewBillingPlan = async ({
|
|||||||
autumnBillingPlan,
|
autumnBillingPlan,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: AttachBillingContext;
|
billingContext: BillingContext;
|
||||||
autumnBillingPlan: AutumnBillingPlan;
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
}): Promise<PreviewBillingPlan> => {
|
}): Promise<PreviewBillingPlan> => {
|
||||||
const tax = await computeAttachTaxPreview({
|
// Tax involves a Stripe round-trip; invoice-credits is local. Run in
|
||||||
ctx,
|
// parallel so the credits read doesn't add to the wall-clock latency.
|
||||||
billingContext,
|
const [tax, invoiceCredits] = await Promise.all([
|
||||||
autumnBillingPlan,
|
computeAttachTaxPreview({ ctx, billingContext, autumnBillingPlan }),
|
||||||
});
|
Promise.resolve(
|
||||||
|
computeAttachInvoiceCreditPreview({ ctx, billingContext }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return { tax };
|
return { tax, invoiceCredits };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
BillingContext,
|
||||||
|
PreviewInvoiceCredits,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { orgToCurrency, stripeToAtmnAmount } from "@autumn/shared";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-stage helper that surfaces the Stripe customer's credit balance on
|
||||||
|
* the attach preview so sales/dashboard users can see how much credit will
|
||||||
|
* offset the next invoice.
|
||||||
|
*/
|
||||||
|
export const computeAttachInvoiceCreditPreview = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
}): PreviewInvoiceCredits | undefined => {
|
||||||
|
if (!billingContext.stripeCustomer) return undefined;
|
||||||
|
|
||||||
|
const stripeBalance = billingContext.stripeCustomer.balance ?? 0;
|
||||||
|
const currency = orgToCurrency({ org: ctx.org });
|
||||||
|
|
||||||
|
// Flip sign: Stripe stores credit as negative; we surface as positive.
|
||||||
|
return {
|
||||||
|
balance: stripeToAtmnAmount({ amount: -stripeBalance, currency }),
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AttachBillingContext,
|
|
||||||
AutumnBillingPlan,
|
AutumnBillingPlan,
|
||||||
|
BillingContext,
|
||||||
PreviewTax,
|
PreviewTax,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +56,7 @@ export const computeAttachTaxPreview = async ({
|
|||||||
autumnBillingPlan,
|
autumnBillingPlan,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: AttachBillingContext;
|
billingContext: BillingContext;
|
||||||
autumnBillingPlan: AutumnBillingPlan;
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
}): Promise<PreviewTax | undefined> => {
|
}): Promise<PreviewTax | undefined> => {
|
||||||
if (!ctx.org.config.automatic_tax) return undefined;
|
if (!ctx.org.config.automatic_tax) return undefined;
|
||||||
|
|||||||
@@ -28,5 +28,7 @@ export const billingPlanToUpdateSubscriptionPreview = async ({
|
|||||||
intent: billingPlanToUpdateSubscriptionPreviewIntent({
|
intent: billingPlanToUpdateSubscriptionPreviewIntent({
|
||||||
billingContext,
|
billingContext,
|
||||||
}),
|
}),
|
||||||
|
tax: billingPlan.preview?.tax,
|
||||||
|
invoice_credits: billingPlan.preview?.invoiceCredits,
|
||||||
} satisfies PreviewUpdateSubscriptionResponse;
|
} satisfies PreviewUpdateSubscriptionResponse;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
import type { BillingContext, BillingPlan } from "@autumn/shared";
|
import type { BillingContext, BillingPlan } from "@autumn/shared";
|
||||||
import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared";
|
import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview";
|
import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview";
|
||||||
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
|
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
|
||||||
import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges";
|
import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges";
|
||||||
import { logBillingPreview } from "./logs/logBillingPreview";
|
import { logBillingPreview } from "./logs/logBillingPreview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preview-layer adjustments (Stripe Tax, Stripe customer credit
|
||||||
|
* balance) to the immediate-period total. Mirrors what Stripe will actually
|
||||||
|
* invoice:
|
||||||
|
* - Tax is added on top of the discounted subtotal.
|
||||||
|
* - Customer credit is subtracted, capped at (subtotal + tax) so the total
|
||||||
|
* never goes negative. Leftover credit rolls to the next invoice in
|
||||||
|
* Stripe; we don't surface that here beyond the row tooltip on the FE.
|
||||||
|
*
|
||||||
|
* `next_cycle.total` is intentionally NOT adjusted — we don't compute
|
||||||
|
* next-cycle tax (would require a forward-dated Stripe Tax calculation),
|
||||||
|
* and the `subtotal`/`total` doc strings on `next_cycle` reflect that.
|
||||||
|
*
|
||||||
|
* If `billingPlan.preview` is undefined (non-attach flows that skip the
|
||||||
|
* enrichment step) the math is a no-op and `total` is unchanged.
|
||||||
|
*/
|
||||||
|
const applyPreviewAdjustmentsToTotal = ({
|
||||||
|
subtotal,
|
||||||
|
total,
|
||||||
|
billingPlan,
|
||||||
|
}: {
|
||||||
|
subtotal: number;
|
||||||
|
total: number;
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
}): number => {
|
||||||
|
const taxTotal = billingPlan.preview?.tax?.total ?? 0;
|
||||||
|
const creditBalance = billingPlan.preview?.invoiceCredits?.balance ?? 0;
|
||||||
|
|
||||||
|
const taxed = new Decimal(total).add(taxTotal);
|
||||||
|
const cappedCredit = Decimal.min(creditBalance, Decimal.max(taxed, 0));
|
||||||
|
return taxed.sub(cappedCredit).toDP(2).toNumber();
|
||||||
|
};
|
||||||
|
|
||||||
export const billingPlanToPreviewResponse = async ({
|
export const billingPlanToPreviewResponse = async ({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
@@ -20,8 +54,18 @@ export const billingPlanToPreviewResponse = async ({
|
|||||||
const autumnBillingPlan = billingPlan.autumn;
|
const autumnBillingPlan = billingPlan.autumn;
|
||||||
const allLineItems = autumnBillingPlan.lineItems ?? [];
|
const allLineItems = autumnBillingPlan.lineItems ?? [];
|
||||||
|
|
||||||
const { immediateLineItems, previewLineItems, subtotal, total } =
|
const {
|
||||||
billingPlanToImmediatePreview({ billingPlan });
|
immediateLineItems,
|
||||||
|
previewLineItems,
|
||||||
|
subtotal,
|
||||||
|
total: lineItemsTotal,
|
||||||
|
} = billingPlanToImmediatePreview({ billingPlan });
|
||||||
|
|
||||||
|
const total = applyPreviewAdjustmentsToTotal({
|
||||||
|
subtotal,
|
||||||
|
total: lineItemsTotal,
|
||||||
|
billingPlan,
|
||||||
|
});
|
||||||
|
|
||||||
const currency = orgToCurrency({ org: ctx.org });
|
const currency = orgToCurrency({ org: ctx.org });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Integration test for the new `invoice_credits` field on `previewAttach`.
|
||||||
|
*
|
||||||
|
* Architecture (per fetch–build–execute):
|
||||||
|
* Symptom surfaces in: server/src/internal/billing/v2/utils/billingPlan/billingPlanToAttachPreview.ts
|
||||||
|
* (formatter passes through `billingPlan.preview.invoiceCredits` to the response)
|
||||||
|
* Root cause lives in: server/src/internal/billing/v2/utils/billingPlan/preview/invoiceCredits/computeAttachInvoiceCreditPreview.ts
|
||||||
|
* (build-stage helper that reads stripeCustomer.balance off the
|
||||||
|
* billingContext)
|
||||||
|
* Fix layer: same — preview enrichment is genuinely owned at this layer,
|
||||||
|
* mirroring the existing tax helper. No upstream invariant lives higher.
|
||||||
|
*
|
||||||
|
* Contract: `invoice_credits` is ALWAYS present in the response when a
|
||||||
|
* Stripe customer is connected to this customer, regardless of balance
|
||||||
|
* value or checkout mode. The frontend decides whether to display the row.
|
||||||
|
*
|
||||||
|
* Sign convention under test: Stripe stores a customer credit as a NEGATIVE
|
||||||
|
* `balance`. The API surfaces it as a POSITIVE `balance` so the frontend
|
||||||
|
* can simply subtract it from the post-tax total.
|
||||||
|
*
|
||||||
|
* Cases:
|
||||||
|
* - Stripe balance = -2000 ($20 credit), card-on-file flow → present, balance=20.
|
||||||
|
* - Stripe balance = 0 → present, balance=0.
|
||||||
|
* - Stripe balance = -2000, stripe_checkout flow → present, balance=20.
|
||||||
|
* (Field still returned; FE just hides the row when redirecting to Checkout.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type { AttachPreviewResponse } from "@autumn/shared";
|
||||||
|
import { products } from "@tests/utils/fixtures/products.js";
|
||||||
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-attach-invoice-credits (Stripe credit on file): present, sign-flipped to positive, currency matches")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "invoice-credits-on";
|
||||||
|
const proProd = products.pro({ id: "pro", items: [] });
|
||||||
|
|
||||||
|
const { autumnV2_2 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.customer({
|
||||||
|
testClock: false,
|
||||||
|
paymentMethod: "success",
|
||||||
|
// Stripe stores credit as a negative balance. -2000 = $20 credit.
|
||||||
|
stripeCustomerOverrides: { balance: -2000 },
|
||||||
|
}),
|
||||||
|
s.products({ list: [proProd] }),
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV2_2.billing.previewAttach({
|
||||||
|
customer_id: customerId,
|
||||||
|
plan_id: `pro_${customerId}`,
|
||||||
|
})) as AttachPreviewResponse;
|
||||||
|
|
||||||
|
expect(preview.invoice_credits).toBeDefined();
|
||||||
|
// Sign-flipped: stripe -2000 → atmn +20 in major units.
|
||||||
|
expect(preview.invoice_credits?.balance).toBe(20);
|
||||||
|
expect(preview.invoice_credits?.currency).toBe(preview.currency);
|
||||||
|
// Total contract: subtotal stays pre-credit; total subtracts credit
|
||||||
|
// capped at subtotal+tax (no auto_tax here → tax=0). $20 plan,
|
||||||
|
// $20 credit on file → total = 0.
|
||||||
|
expect(preview.subtotal).toBe(20);
|
||||||
|
expect(preview.total).toBe(0);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-attach-invoice-credits (zero balance): present, balance=0, currency matches")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "invoice-credits-zero";
|
||||||
|
const proProd = products.pro({ id: "pro", items: [] });
|
||||||
|
|
||||||
|
const { autumnV2_2 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.customer({
|
||||||
|
testClock: false,
|
||||||
|
paymentMethod: "success",
|
||||||
|
// No balance override — Stripe defaults to 0.
|
||||||
|
}),
|
||||||
|
s.products({ list: [proProd] }),
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV2_2.billing.previewAttach({
|
||||||
|
customer_id: customerId,
|
||||||
|
plan_id: `pro_${customerId}`,
|
||||||
|
})) as AttachPreviewResponse;
|
||||||
|
|
||||||
|
expect(preview.invoice_credits).toBeDefined();
|
||||||
|
expect(preview.invoice_credits?.balance).toBe(0);
|
||||||
|
expect(preview.invoice_credits?.currency).toBe(preview.currency);
|
||||||
|
// No credit, no tax → total === subtotal.
|
||||||
|
expect(preview.subtotal).toBe(20);
|
||||||
|
expect(preview.total).toBe(20);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-attach-invoice-credits (stripe_checkout flow): still present, frontend chooses not to render the row")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "invoice-credits-stripe-checkout";
|
||||||
|
const proProd = products.pro({ id: "pro", items: [] });
|
||||||
|
|
||||||
|
const { autumnV2_2 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.customer({
|
||||||
|
testClock: false,
|
||||||
|
// NO paymentMethod — forces stripe_checkout flow on attach.
|
||||||
|
stripeCustomerOverrides: { balance: -2000 },
|
||||||
|
}),
|
||||||
|
s.products({ list: [proProd] }),
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV2_2.billing.previewAttach({
|
||||||
|
customer_id: customerId,
|
||||||
|
plan_id: `pro_${customerId}`,
|
||||||
|
})) as AttachPreviewResponse;
|
||||||
|
|
||||||
|
expect(preview.checkout_type).toBe("stripe_checkout");
|
||||||
|
// Field is still returned — Stripe Checkout will apply the balance
|
||||||
|
// itself, so the FE hides the row to avoid confusing display. But
|
||||||
|
// the numeric `total` stays accurate per the API contract: $20
|
||||||
|
// plan minus $20 credit = $0.
|
||||||
|
expect(preview.invoice_credits).toBeDefined();
|
||||||
|
expect(preview.invoice_credits?.balance).toBe(20);
|
||||||
|
expect(preview.invoice_credits?.currency).toBe(preview.currency);
|
||||||
|
expect(preview.subtotal).toBe(20);
|
||||||
|
expect(preview.total).toBe(0);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the new `tax` and `invoice_credits` fields on
|
||||||
|
* `previewUpdateSubscription`. Mirrors the attach preview suite — same
|
||||||
|
* helpers fire for both flows now via the shared
|
||||||
|
* `computeAttachPreviewBillingPlan` orchestrator.
|
||||||
|
*
|
||||||
|
* Architecture (per fetch–build–execute):
|
||||||
|
* Symptom surfaces in: server/src/internal/billing/v2/utils/billingPlan/toUpdateSubscriptionPreview/billingPlanToUpdateSubscriptionPreview.ts
|
||||||
|
* Root cause lives in: server/src/internal/billing/v2/utils/billingPlan/preview/computeAttachPreviewBillingPlan.ts
|
||||||
|
* (orchestrator widened to BillingContext, runs for update-sub too)
|
||||||
|
* Fix layer: same — preview enrichment is genuinely owned at this layer.
|
||||||
|
*
|
||||||
|
* NEW-FEATURE assertions: each case validates every new field, not just
|
||||||
|
* one. Locking the contract on first introduction.
|
||||||
|
*
|
||||||
|
* Two flow shapes (prepaid quantity vs. base-price custom plan) because
|
||||||
|
* platform.create sub-orgs (needed for AU tax registrations) don't carry
|
||||||
|
* the v2 TestFeature catalog. Credits cases use prepaid+Messages on the
|
||||||
|
* default org; tax cases use a feature-less monthly price item on a
|
||||||
|
* sub-org that has Stripe Tax registered for AU.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import type {
|
||||||
|
ApiCustomerV3,
|
||||||
|
PreviewUpdateSubscriptionResponse,
|
||||||
|
} 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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const auAddress = {
|
||||||
|
country: "AU",
|
||||||
|
line1: "1 Test St",
|
||||||
|
city: "Sydney",
|
||||||
|
postal_code: "2000",
|
||||||
|
state: "NSW",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Credits flow: prepaid Messages, default org.
|
||||||
|
const billingUnits = 12;
|
||||||
|
const pricePerUnit = 8;
|
||||||
|
const baseUnits = 10;
|
||||||
|
const targetUnits = 20;
|
||||||
|
const expectedSubtotal = (targetUnits - baseUnits) * pricePerUnit; // $80
|
||||||
|
|
||||||
|
const buildPrepaidProduct = () =>
|
||||||
|
products.base({
|
||||||
|
id: "prepaid",
|
||||||
|
items: [
|
||||||
|
items.prepaid({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
billingUnits,
|
||||||
|
price: pricePerUnit,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-update-subscription-credits (Stripe credit on file): credits present, sign-flipped, total subtracts capped credit")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "us-credits-on";
|
||||||
|
const product = buildPrepaidProduct();
|
||||||
|
|
||||||
|
const { ctx, autumnV1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||||
|
s.products({ list: [product] }),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
s.attach({
|
||||||
|
productId: product.id,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
quantity: baseUnits * billingUnits,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set credit balance AFTER initial attach so Stripe doesn't consume
|
||||||
|
// it on the first invoice. We want the credit on file at the moment
|
||||||
|
// the previewUpdate runs.
|
||||||
|
const customer =
|
||||||
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||||
|
const stripeCustomerId = customer.stripe_id;
|
||||||
|
expect(stripeCustomerId).toBeDefined();
|
||||||
|
await ctx.stripeCli.customers.update(stripeCustomerId!, {
|
||||||
|
balance: -2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV1.subscriptions.previewUpdate({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: product.id,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
quantity: targetUnits * billingUnits,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})) as PreviewUpdateSubscriptionResponse;
|
||||||
|
|
||||||
|
expect(preview.subtotal).toBe(expectedSubtotal);
|
||||||
|
expect(preview.invoice_credits).toBeDefined();
|
||||||
|
expect(preview.invoice_credits?.balance).toBe(20);
|
||||||
|
expect(preview.invoice_credits?.currency).toBe(preview.currency);
|
||||||
|
expect(preview.tax).toBeUndefined();
|
||||||
|
// Contract: total = subtotal + tax(0) - cappedCredit. Cap at
|
||||||
|
// (subtotal + tax) so the result never goes negative.
|
||||||
|
const expectedCappedCredit = Math.min(20, expectedSubtotal);
|
||||||
|
expect(preview.total).toBe(expectedSubtotal - expectedCappedCredit);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-update-subscription-credits (zero balance): credits present with balance=0, total === subtotal")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "us-credits-zero";
|
||||||
|
const product = buildPrepaidProduct();
|
||||||
|
|
||||||
|
const { autumnV1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||||
|
s.products({ list: [product] }),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
s.attach({
|
||||||
|
productId: product.id,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
quantity: baseUnits * billingUnits,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV1.subscriptions.previewUpdate({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: product.id,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
quantity: targetUnits * billingUnits,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})) as PreviewUpdateSubscriptionResponse;
|
||||||
|
|
||||||
|
expect(preview.subtotal).toBe(expectedSubtotal);
|
||||||
|
expect(preview.invoice_credits).toBeDefined();
|
||||||
|
expect(preview.invoice_credits?.balance).toBe(0);
|
||||||
|
expect(preview.invoice_credits?.currency).toBe(preview.currency);
|
||||||
|
expect(preview.tax).toBeUndefined();
|
||||||
|
expect(preview.total).toBe(expectedSubtotal);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tax flow: pro plan ($20/mo) with custom-plan upgrade to $50/mo via
|
||||||
|
// `items` override on previewUpdate. Sub-org with AU tax registration.
|
||||||
|
// Default org doesn't have AU tax registered, so we need the sub-org.
|
||||||
|
// Pro/premium fixtures with empty items don't need TestFeature catalog.
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-update-subscription-tax (auto_tax on, AU customer): tax.status=complete, total = subtotal + tax")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "us-tax-on";
|
||||||
|
const proProd = products.pro({ id: "pro", items: [] });
|
||||||
|
|
||||||
|
const { autumnV1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
s.platform.create({
|
||||||
|
configOverrides: { automatic_tax: true },
|
||||||
|
taxRegistrations: ["AU"],
|
||||||
|
}),
|
||||||
|
s.customer({
|
||||||
|
testClock: false,
|
||||||
|
paymentMethod: "success",
|
||||||
|
stripeCustomerOverrides: { address: auAddress },
|
||||||
|
}),
|
||||||
|
s.products({ list: [proProd] }),
|
||||||
|
],
|
||||||
|
actions: [s.billing.attach({ productId: "pro" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom-plan update: bump base price from $20 → $50 to force a
|
||||||
|
// positive prorated immediate charge that Stripe Tax can compute on.
|
||||||
|
const preview = (await autumnV1.subscriptions.previewUpdate({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: `pro_${customerId}`,
|
||||||
|
items: [items.monthlyPrice({ price: 50 })],
|
||||||
|
})) as PreviewUpdateSubscriptionResponse;
|
||||||
|
|
||||||
|
expect(preview.subtotal).toBeGreaterThan(0);
|
||||||
|
expect(preview.tax).toBeDefined();
|
||||||
|
expect(preview.tax?.status).toBe("complete");
|
||||||
|
expect(preview.tax?.currency).toBe(preview.currency);
|
||||||
|
expect(preview.tax?.total).toBeGreaterThan(0);
|
||||||
|
expect(preview.tax?.amount_exclusive).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
|
||||||
|
expect(preview.total).toBeCloseTo(
|
||||||
|
preview.subtotal + (preview.tax?.total ?? 0),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(preview.total).toBeGreaterThan(preview.subtotal);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.concurrent(
|
||||||
|
`${chalk.yellowBright("preview-update-subscription-tax (auto_tax off): tax field omitted, total === subtotal (no credit)")}`,
|
||||||
|
async () => {
|
||||||
|
const customerId = "us-tax-off";
|
||||||
|
const proProd = products.pro({ id: "pro", items: [] });
|
||||||
|
|
||||||
|
const { autumnV1 } = await initScenario({
|
||||||
|
customerId,
|
||||||
|
setup: [
|
||||||
|
// auto_tax defaults to false. Use a sub-org for parity with
|
||||||
|
// the auto_tax-on case (same code path through platform.create).
|
||||||
|
s.platform.create({ taxRegistrations: ["AU"] }),
|
||||||
|
s.customer({
|
||||||
|
testClock: false,
|
||||||
|
paymentMethod: "success",
|
||||||
|
stripeCustomerOverrides: { address: auAddress },
|
||||||
|
}),
|
||||||
|
s.products({ list: [proProd] }),
|
||||||
|
],
|
||||||
|
actions: [s.billing.attach({ productId: "pro" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = (await autumnV1.subscriptions.previewUpdate({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: `pro_${customerId}`,
|
||||||
|
items: [items.monthlyPrice({ price: 50 })],
|
||||||
|
})) as PreviewUpdateSubscriptionResponse;
|
||||||
|
|
||||||
|
expect(preview.subtotal).toBeGreaterThan(0);
|
||||||
|
expect(preview.tax).toBeUndefined();
|
||||||
|
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
|
||||||
|
expect(preview.total).toBe(preview.subtotal);
|
||||||
|
},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
@@ -80,12 +80,15 @@ test.concurrent(`${chalk.yellowBright(
|
|||||||
expect(preview.tax?.amount_exclusive).toBeGreaterThan(0);
|
expect(preview.tax?.amount_exclusive).toBeGreaterThan(0);
|
||||||
expect(preview.tax?.amount_inclusive).toBe(0);
|
expect(preview.tax?.amount_inclusive).toBe(0);
|
||||||
|
|
||||||
// Sanity: the existing autumn-side total stays separate from the
|
// New contract: `total` includes tax and the (capped) credit balance.
|
||||||
// stripe-side tax breakdown. They're computing different things —
|
// `subtotal` stays pre-tax, pre-credit. Customer here has no credit on
|
||||||
// the autumn `total` may include credits that Stripe doesn't tax.
|
// file, so credit term is 0 and total = subtotal + tax.
|
||||||
expect(preview.total).toBeGreaterThan(0);
|
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
|
||||||
// Don't assert preview.total === preview.tax.total — divergence is
|
expect(preview.total).toBeCloseTo(
|
||||||
// expected and documented on the schema.
|
preview.subtotal + (preview.tax?.total ?? 0),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(preview.total).toBeGreaterThan(preview.subtotal);
|
||||||
}, 300_000);
|
}, 300_000);
|
||||||
|
|
||||||
test.concurrent(`${chalk.yellowBright(
|
test.concurrent(`${chalk.yellowBright(
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ export function PreviewTotalsBlock({
|
|||||||
!willRedirectToStripeCheckout && creditBalance > 0
|
!willRedirectToStripeCheckout && creditBalance > 0
|
||||||
? Math.min(creditBalance, subtotalBeforeCredit)
|
? Math.min(creditBalance, subtotalBeforeCredit)
|
||||||
: 0;
|
: 0;
|
||||||
const showCreditRow = creditApplied > 0;
|
// Hide the row entirely when there's no credit on file. We also hide
|
||||||
|
// when redirecting to Stripe Checkout (Stripe applies the balance in
|
||||||
|
// their hosted form — showing it here would diverge) or when nothing
|
||||||
|
// would actually be applied (e.g. $0 plan with credit).
|
||||||
|
const showCreditRow =
|
||||||
|
creditBalance > 0 && !willRedirectToStripeCheckout && creditApplied > 0;
|
||||||
const creditRollover = creditBalance - creditApplied;
|
const creditRollover = creditBalance - creditApplied;
|
||||||
|
|
||||||
const { currency } = previewData;
|
const { currency } = previewData;
|
||||||
@@ -94,7 +99,7 @@ export function PreviewTotalsBlock({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-border/60 pt-2 mt-1">
|
<div className="flex items-center justify-between border-t border-border pt-2 mt-1">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
Total Due Now
|
Total Due Now
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user