Merge pull request #1884 from useautumn/fix/anchor-proration-none

fix: anchor proration none
This commit is contained in:
John Yeo
2026-06-11 09:01:28 +01:00
committed by GitHub
14 changed files with 7767 additions and 8 deletions

View File

@@ -2,6 +2,7 @@ import { generateKsuid } from "@autumn/ksuid";
import type { BillingContext } from "@autumn/shared";
import {
customerProductToEntity,
cusPriceToCusEnt,
type DbInvoiceLineItem,
type FullCusProduct,
type InvoiceLineItemDiscount,
@@ -15,12 +16,14 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const chargeRowToRefundLineItem = ({
chargeRow,
creditAmount,
effectiveNow,
customerProduct,
billingContext,
ctx,
}: {
chargeRow: DbInvoiceLineItem;
creditAmount: number;
effectiveNow: number;
customerProduct: FullCusProduct;
billingContext: BillingContext;
ctx: AutumnContext;
@@ -50,6 +53,12 @@ export const chargeRowToRefundLineItem = ({
);
const price =
matchingCusPrice?.price ?? customerProduct.customer_prices[0]?.price;
const matchingCusEnt = matchingCusPrice
? cusPriceToCusEnt({
cusPrice: matchingCusPrice,
cusEnts: customerProduct.customer_entitlements,
})
: undefined;
if (!price) {
throw new Error(
@@ -60,17 +69,18 @@ export const chargeRowToRefundLineItem = ({
const context: LineItemContext = {
price,
product: customerProduct.product,
feature: undefined,
feature: matchingCusEnt?.entitlement.feature,
currency: orgToCurrency({ org: ctx.org }),
billingPeriod: { start: periodStart, end: periodEnd },
effectivePeriod: { start: billingContext.currentEpochMs, end: periodEnd },
effectivePeriod: { start: effectiveNow, end: periodEnd },
direction: "refund",
now: billingContext.currentEpochMs,
now: effectiveNow,
billingTiming: "in_advance",
discountable: false,
entity,
customerProduct,
customerPrice: matchingCusPrice,
customerEntitlement: matchingCusEnt,
};
const description = chargeRow.description

View File

@@ -5,6 +5,7 @@ import {
type LineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { augmentBillingContextForAnchorResetRefund } from "./augmentBillingContextForAnchorResetRefund";
import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem";
import {
computeAlreadyRefundedForCharge,
@@ -81,6 +82,20 @@ export const invoiceCreditFromStoredLineItems = ({
);
for (const chargeRow of usableRows) {
const periodStart = chargeRow.effective_period_start;
const periodEnd = chargeRow.effective_period_end;
if (periodStart == null || periodEnd == null) continue;
const action = augmentBillingContextForAnchorResetRefund({
currentEpochMs: now,
billingPeriod: { start: periodStart, end: periodEnd },
anchorResetRefund: billingContext.anchorResetRefund,
});
if (action.type === "skip") continue;
const effectiveNow =
action.type === "use_snapped_now" ? action.snappedNow : now;
const attributedAmount = splitMultiEntityAmount(chargeRow);
const alreadyRefunded = computeAlreadyRefundedForCharge({
@@ -95,7 +110,7 @@ export const invoiceCreditFromStoredLineItems = ({
const creditAmount = computeProratedCredit({
chargeRow: adjustedChargeRow,
now,
now: effectiveNow,
alreadyRefunded,
});
@@ -105,6 +120,7 @@ export const invoiceCreditFromStoredLineItems = ({
chargeRowToRefundLineItem({
chargeRow,
creditAmount,
effectiveNow,
customerProduct,
billingContext,
ctx,

View File

@@ -40,6 +40,13 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
const aggregatedRolloverGrant = new Decimal(aggregatedRolloverBalance)
.add(aggregatedRolloverUsage)
.toNumber();
const aggregatedNextResetAt = aggregatedFeatureBalance.next_reset_at ?? null;
const nextResetAt =
apiBalance.next_reset_at === null
? aggregatedNextResetAt
: aggregatedNextResetAt === null
? apiBalance.next_reset_at
: Math.min(apiBalance.next_reset_at, aggregatedNextResetAt);
// Aggregate rows do not retain the full per-entity/per-product breakdown, so
// the top-level summary is merged from the coarse aggregate values only.
@@ -77,6 +84,7 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
apiBalance.overage_allowed ||
aggregatedFeatureBalance.usage_allowed ||
false,
next_reset_at: nextResetAt,
breakdown: apiBalance.breakdown ?? [],
};
};

View File

@@ -201,6 +201,7 @@ export const getEntityAggregateFragments = ({
SUM(ce.balance::numeric) AS balance,
SUM(COALESCE(ce.adjustment, 0)::numeric) AS adjustment,
SUM(COALESCE(ce.additional_balance, 0)::numeric) AS additional_balance,
MIN(ce.next_reset_at) AS next_reset_at,
BOOL_OR(ce.unlimited) AS unlimited,
BOOL_OR(ce.usage_allowed) AS usage_allowed
FROM entity_level_cus_ents ce
@@ -219,6 +220,7 @@ export const getEntityAggregateFragments = ({
eat.balance,
eat.adjustment,
eat.additional_balance,
eat.next_reset_at,
COALESCE(erf.rollover_balance, 0) AS rollover_balance,
COALESCE(erf.rollover_usage, 0) AS rollover_usage,
eat.unlimited,

View File

@@ -27,5 +27,7 @@ export const coreAttach: TestGroup = {
"billing/attach/discounts/attach-discounts-basic.test.ts",
"billing/attach/new-billing-subscription/new-billing-subscription.test.ts",
"billing/attach/params/custom-plan/custom-plan-features.test.ts",
"billing/attach/params/billing-cycle-anchor/anchor-reset-refund/anchor-reset-with-carry-over.test.ts",
"billing/attach/params/start-date/starts-at-backdate.test.ts",
],
};

View File

@@ -10,6 +10,7 @@ export const coreBillingOthers: TestGroup = {
"billing/multi-attach/checkout/multi-attach-customize.test.ts",
"billing/multi-attach/multi-attach-errors.test.ts",
"billing/multi-attach/multi-attach-trial.test.ts",
"billing/create-schedule/phases/create-schedule-phases.test.ts",
// Setup payment
"billing/setup-payment",

View File

@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV5, AttachParamsV1Input } from "@autumn/shared";
import type {
ApiCustomerV5,
AttachParamsV1Input,
AttachPreviewResponse,
} from "@autumn/shared";
import { EntInterval, ProductItemInterval } from "@autumn/shared";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
@@ -316,6 +320,68 @@ test.concurrent(`${chalk.yellowBright("anchor-reset-carry-over 4: monthly messag
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
test.concurrent(
`${chalk.yellowBright("anchor-reset-carry-over 4b: monthly -> monthly stored charge (no refund)")}`,
async () => {
const customerId = "anchor-carry-m2m-stored";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ toNextInvoice: true }),
s.advanceTestClock({ days: 14 }),
],
});
const preview = (await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
billing_cycle_anchor: "now",
proration_behavior: "none",
carry_over_balances: { enabled: true },
plan_schedule: "immediate",
})) as AttachPreviewResponse;
expect(preview.total).toBe(50);
expect(preview.line_items.every((item) => item.total >= 0)).toBe(true);
const result = await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
billing_cycle_anchor: "now",
proration_behavior: "none",
carry_over_balances: { enabled: true },
redirect_mode: "if_required",
plan_schedule: "immediate",
});
expect(result.invoice).toBeDefined();
expect(result.invoice?.total).toBe(50);
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
},
300_000,
);
test.concurrent(`${chalk.yellowBright("anchor-reset-carry-over 5: annual messages only (no refund - 0 full years remaining)")}`, async () => {
const customerId = "anchor-no-partial-a2a-yearly-ent";
const annualMessages = constructFeatureItem({

View File

@@ -18,6 +18,7 @@ describe("fullSubject aggregate balance", () => {
balance: 180,
adjustment: 10,
additional_balance: 0,
next_reset_at: 1234567890,
rollover_balance: 0,
rollover_usage: 0,
unlimited: false,
@@ -76,5 +77,6 @@ describe("fullSubject aggregate balance", () => {
expect(merged.granted).toBe(310);
expect(merged.remaining).toBe(200);
expect(merged.usage).toBe(110);
expect(merged.next_reset_at).toBe(1234567890);
});
});

View File

@@ -1 +1 @@
ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;
-- ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null;--> statement-breakpoint

View File

@@ -7626,4 +7626,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1781085888296,
"tag": "0011_easy_spot",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781125150955,
"tag": "0012_low_moonstone",
"breakpoints": true
}
]
}
}

View File

@@ -20,6 +20,7 @@ export const AggregatedFeatureBalanceSchema = z.object({
balance: z.number(),
adjustment: z.number(),
additional_balance: z.number(),
next_reset_at: z.number().nullable(),
rollover_balance: z.number().default(0),
rollover_usage: z.number().default(0),
unlimited: z.boolean(),