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 { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
|
||||
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 {
|
||||
type CreateAutumnCheckoutResult,
|
||||
@@ -77,9 +78,14 @@ export async function updateSubscription({
|
||||
});
|
||||
|
||||
if (preview) {
|
||||
const previewBillingPlan = await computeAttachPreviewBillingPlan({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
return {
|
||||
billingContext,
|
||||
billingPlan,
|
||||
billingPlan: { ...billingPlan, preview: previewBillingPlan },
|
||||
billingResult: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,5 +43,6 @@ export const billingPlanToAttachPreview = async ({
|
||||
redirect_to_checkout: willRedirectToCheckout,
|
||||
checkout_type: checkoutType,
|
||||
tax: billingPlan.preview?.tax,
|
||||
invoice_credits: billingPlan.preview?.invoiceCredits,
|
||||
} satisfies AttachPreviewResponse;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import type {
|
||||
AttachBillingContext,
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
PreviewBillingPlan,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { computeAttachInvoiceCreditPreview } from "./invoiceCredits/computeAttachInvoiceCreditPreview";
|
||||
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
|
||||
* enrichment helper (currently just tax) and assembles the
|
||||
* `PreviewBillingPlan` bag that lives at `billingPlan.preview`.
|
||||
* Rename to `computePreviewBillingPlan` is a follow-up.
|
||||
*
|
||||
* 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/`.
|
||||
* Invoked from action handlers ONLY when `preview: true`. Calls each
|
||||
* individual enrichment helper and assembles the `PreviewBillingPlan`
|
||||
* 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 ({
|
||||
ctx,
|
||||
@@ -23,14 +29,17 @@ export const computeAttachPreviewBillingPlan = async ({
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: AttachBillingContext;
|
||||
billingContext: BillingContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}): Promise<PreviewBillingPlan> => {
|
||||
const tax = await computeAttachTaxPreview({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
// Tax involves a Stripe round-trip; invoice-credits is local. Run in
|
||||
// parallel so the credits read doesn't add to the wall-clock latency.
|
||||
const [tax, invoiceCredits] = await Promise.all([
|
||||
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 {
|
||||
AttachBillingContext,
|
||||
AutumnBillingPlan,
|
||||
BillingContext,
|
||||
PreviewTax,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
@@ -56,7 +56,7 @@ export const computeAttachTaxPreview = async ({
|
||||
autumnBillingPlan,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: AttachBillingContext;
|
||||
billingContext: BillingContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}): Promise<PreviewTax | undefined> => {
|
||||
if (!ctx.org.config.automatic_tax) return undefined;
|
||||
|
||||
@@ -28,5 +28,7 @@ export const billingPlanToUpdateSubscriptionPreview = async ({
|
||||
intent: billingPlanToUpdateSubscriptionPreviewIntent({
|
||||
billingContext,
|
||||
}),
|
||||
tax: billingPlan.preview?.tax,
|
||||
invoice_credits: billingPlan.preview?.invoiceCredits,
|
||||
} satisfies PreviewUpdateSubscriptionResponse;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import type { BillingContext, BillingPlan } from "@autumn/shared";
|
||||
import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview";
|
||||
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
|
||||
import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges";
|
||||
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 ({
|
||||
ctx,
|
||||
billingContext,
|
||||
@@ -20,8 +54,18 @@ export const billingPlanToPreviewResponse = async ({
|
||||
const autumnBillingPlan = billingPlan.autumn;
|
||||
const allLineItems = autumnBillingPlan.lineItems ?? [];
|
||||
|
||||
const { immediateLineItems, previewLineItems, subtotal, total } =
|
||||
billingPlanToImmediatePreview({ billingPlan });
|
||||
const {
|
||||
immediateLineItems,
|
||||
previewLineItems,
|
||||
subtotal,
|
||||
total: lineItemsTotal,
|
||||
} = billingPlanToImmediatePreview({ billingPlan });
|
||||
|
||||
const total = applyPreviewAdjustmentsToTotal({
|
||||
subtotal,
|
||||
total: lineItemsTotal,
|
||||
billingPlan,
|
||||
});
|
||||
|
||||
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_inclusive).toBe(0);
|
||||
|
||||
// Sanity: the existing autumn-side total stays separate from the
|
||||
// stripe-side tax breakdown. They're computing different things —
|
||||
// the autumn `total` may include credits that Stripe doesn't tax.
|
||||
expect(preview.total).toBeGreaterThan(0);
|
||||
// Don't assert preview.total === preview.tax.total — divergence is
|
||||
// expected and documented on the schema.
|
||||
// New contract: `total` includes tax and the (capped) credit balance.
|
||||
// `subtotal` stays pre-tax, pre-credit. Customer here has no credit on
|
||||
// file, so credit term is 0 and total = subtotal + tax.
|
||||
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(
|
||||
|
||||
@@ -47,7 +47,12 @@ export function PreviewTotalsBlock({
|
||||
!willRedirectToStripeCheckout && creditBalance > 0
|
||||
? Math.min(creditBalance, subtotalBeforeCredit)
|
||||
: 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 { currency } = previewData;
|
||||
@@ -94,7 +99,7 @@ export function PreviewTotalsBlock({
|
||||
</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">
|
||||
Total Due Now
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user