Merge pull request #1884 from useautumn/fix/anchor-proration-none
fix: anchor proration none
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;
|
||||
-- ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;
|
||||
1
shared/drizzle/0012_low_moonstone.sql
Normal file
1
shared/drizzle/0012_low_moonstone.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null;--> statement-breakpoint
|
||||
@@ -7626,4 +7626,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7643
shared/drizzle/meta/0012_snapshot.json
Normal file
7643
shared/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1781085888296,
|
||||
"tag": "0011_easy_spot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781125150955,
|
||||
"tag": "0012_low_moonstone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user