fix: billing cycle anchor in the future

This commit is contained in:
John Yeo
2026-04-28 12:23:41 +01:00
parent 45ddcf6fed
commit 30e25c5dd9
11 changed files with 245 additions and 52 deletions

View File

@@ -29,6 +29,11 @@
"planetscale": {
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
},
"incident-io": {
"type": "remote",
"url": "https://mcp.incident.io/mcp",
"oauth": {}
}
},
"plugin": [

View File

@@ -50,7 +50,7 @@ There is a legacy-compatibility exception in the adjust-balance flow:
Projects maintain state in `.context/<project>/` folders across sessions. Tasks are optional parallel workstreams within a project.
### Default: NOT interacting with a project
**Unless the user explicitly mentions a project or task by name, assume the current conversation is NOT associated with any project.** Session-start hooks may surface a list of active projects as reference material -- that alone is NOT a signal that the current work belongs to any of them.
**Unless the user explicitly mentions a project or task by name, assume the current conversation is NOT associated with any project.** Session-start hooks may surface a list of active projects as reference material that alone is NOT a signal that the current work belongs to any of them.
Do not:
- Read `.context/**` files proactively
@@ -77,7 +77,11 @@ Only update when the current work IS part of a project (see default-off rule abo
Do NOT update context during normal coding work. Work first, compact at breakpoints.
<<<<<<< HEAD
A STATUS.md entry should record changes to the project itself -- not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update).
=======
A STATUS.md entry should record changes to the project itself — not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update).
>>>>>>> a62195ba706f12fbd35f7ecf48b4701011115101
### Compaction quality
STATUS.md must be:

2
ai

Submodule ai updated: a04ded379f...18d68d3c2a

View File

@@ -115,10 +115,11 @@ export const setupAttachBillingContext = async ({
isTransitionFromFree &&
hasPaidRecurringSubscription);
const skipBillingFetching =
orgDisableStripeWrites({ ctx }) || params.no_billing_changes === true;
const skipBillingChanges =
orgDisableStripeWrites({ ctx }) ||
params.no_billing_changes === true ||
params.processor_subscription_id !== undefined;
skipBillingFetching || params.processor_subscription_id !== undefined;
const {
stripeSubscription,
@@ -135,7 +136,7 @@ export const setupAttachBillingContext = async ({
contextOverride,
params,
newBillingSubscription: shouldForceNewSubscription,
skipBillingChanges,
skipBillingFetching,
});
const featureQuantities = setupFeatureQuantitiesContext({

View File

@@ -79,13 +79,15 @@ export const setupUpdateSubscriptionBillingContext = async ({
),
);
const skipBillingChanges =
const skipBillingFetching =
orgDisableStripeWrites({ ctx }) ||
params.no_billing_changes === true ||
params.processor_subscription_id !== undefined ||
billingRelatedFields.length === 0 ||
isUpdatingFreeCustomerProduct;
const skipBillingChanges =
skipBillingFetching || params.processor_subscription_id !== undefined;
const {
stripeSubscription,
stripeSubscriptionSchedule,
@@ -99,7 +101,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
targetCustomerProduct: customerProduct,
contextOverride,
params,
skipBillingChanges,
skipBillingFetching,
product: fullProduct,
skipSubscriptionFetching: isUpdatingFreeCustomerProduct,
});

View File

@@ -24,7 +24,7 @@ export const setupStripeBillingContext = async ({
contextOverride = {},
params,
newBillingSubscription,
skipBillingChanges,
skipBillingFetching,
skipSubscriptionFetching,
}: {
ctx: AutumnContext;
@@ -34,14 +34,14 @@ export const setupStripeBillingContext = async ({
contextOverride?: BillingContextOverride;
params?: AttachParamsV1 | MultiAttachParamsV0 | UpdateSubscriptionV1Params;
newBillingSubscription?: boolean;
skipBillingChanges?: boolean;
skipBillingFetching?: boolean;
skipSubscriptionFetching?: boolean;
}) => {
const { stripeBillingContext } = contextOverride;
if (stripeBillingContext) return stripeBillingContext;
if (skipBillingChanges) {
if (skipBillingFetching) {
return {
stripeSubscription: undefined,
stripeSubscriptionSchedule: undefined,

View File

@@ -28,8 +28,12 @@ export const getLineItemBillingPeriod = ({
}): BillingPeriod | undefined => {
if (isOneOffPrice(price)) return undefined;
const { billingCycleAnchorMs, currentEpochMs, stripeSubscription } =
billingContext;
const {
billingCycleAnchorMs,
currentEpochMs,
stripeSubscription,
trialContext,
} = billingContext;
const { interval, interval_count: intervalCount } = price.config;
@@ -38,10 +42,14 @@ export const getLineItemBillingPeriod = ({
? secondsToMs(stripeSubscription.created)
: undefined;
// Floor for end: billing cycle anchor (can't end billing period before anchor, e.g., trial end)
// Only apply when anchor is a specific timestamp, not "now"
// Floor for end: trial end (can't end billing period before trial ends).
// Only used for long trials (>1 interval) where the natural cycle end would land
// before the trial end. For non-trial attaches against an existing subscription
// whose anchor is far in the future, do NOT floor at the anchor — natural cycle
// boundaries (e.g., monthly add-on on an annual sub) are correct.
const trialEndsAt = trialContext?.trialEndsAt;
const endFloor =
billingCycleAnchorMs === "now" ? undefined : billingCycleAnchorMs;
trialEndsAt && trialEndsAt > currentEpochMs ? trialEndsAt : undefined;
const start = getCycleStart({
anchor: billingCycleAnchorMs,

View File

@@ -95,3 +95,86 @@ test.concurrent(`${chalk.yellowBright("temp: pro annual prepaid credits with rol
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
test.concurrent(`${chalk.yellowBright("temp: pro prepaid messages rollover persists after price update")}`, async () => {
const customerId = "temp-pro-prepaid-msgs-price-update";
const rolloverConfig = {
max_percentage: 50,
length: 1,
duration: RolloverExpiryDurationType.Month,
};
const messagesItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 100,
billingUnits: 1,
price: 0.1,
rolloverConfig,
});
const updatedMessagesItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 100,
billingUnits: 1,
price: 0.2,
rolloverConfig,
});
const pro = products.pro({
id: "pro-prepaid-msgs-price-update",
items: [messagesItem],
});
const quantity = 1500;
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
s.advanceToNextInvoice(),
],
});
// After invoice: rollover = 50% of 1500 = 750
// New balance = 1500 + 750 = 2250
const expectedRollover = quantity / 2;
const expectedRemaining = quantity + expectedRollover;
const customerAfterInvoice =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerAfterInvoice,
featureId: TestFeature.Messages,
remaining: expectedRemaining,
usage: 0,
rollovers: [{ balance: expectedRollover }],
});
// Update subscription to change prepaid messages price
await autumnV2_2.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem],
});
const customerAfterUpdate =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerAfterUpdate,
featureId: TestFeature.Messages,
remaining: expectedRemaining,
usage: 0,
rollovers: [{ balance: expectedRollover }],
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -7,7 +7,13 @@
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import {
type ApiCustomerV3,
type ApiEntityV0,
type AttachParamsV0Input,
type AttachParamsV1,
BillingInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
@@ -15,13 +21,15 @@ import {
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { calculateProrationFromPeriod } from "@tests/integration/billing/utils/proration";
import { createCustomStripeSubscription } from "@tests/integration/billing/utils/stripe/createCustomStripeSubscription";
import { getStripeSubscription } from "@tests/integration/billing/utils/stripeSubscriptionUtils";
import { advanceTestClock } from "@tests/utils/stripeUtils";
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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addYears, subMinutes } from "date-fns";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Entity 1 pro annual, advance 3 weeks, attach pro monthly to entity 2
@@ -53,43 +61,54 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-entities-edge-cases 1: e
items: [proMonthlyMessages],
});
const { autumnV1, ctx, entities, advancedTo, testClockId } = await initScenario({
const { autumnV1, autumnV2_1, ctx, entities, advancedTo, testClockId } =
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proAnnual, proMonthly] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [],
});
// Manually create the annual Stripe subscription. With interval: "year" and
// no explicit billing_cycle_anchor, Stripe naturally anchors renewal one
// year out — mirrors the Mintlify production scenario where the customer
// already had a long-cycle annual sub before adding a second product.
const annualStripeSub = await createCustomStripeSubscription({
ctx,
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proAnnual, proMonthly] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: proAnnual.id, entityIndex: 0 })],
productId: proAnnual.id,
unitAmount: 20000,
interval: "year",
billingCycleAnchorMs: subMinutes(addYears(new Date(), 1), 100).getTime(),
});
// Set the annual sub's billing_cycle_anchor to exactly one year from its
// start date — this mirrors the Mintlify production scenario.
const {
stripeCli,
subscription: annualSub,
} = await getStripeSubscription({ customerId });
const annualStart = annualSub.start_date;
const oneYearFromStart = annualStart + 365 * 24 * 60 * 60;
await stripeCli.subscriptions.update(annualSub.id, {
trial_end: oneYearFromStart,
proration_behavior: "none",
// Link the manually-created sub to entity 1 via processor_subscription_id
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: proAnnual.id,
entity_id: entities[0].id,
processor_subscription_id: annualStripeSub.id,
});
// Now advance the clock 3 weeks into the annual cycle
await advanceTestClock({
stripeCli,
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfWeeks: 3,
});
const advancedToAfter = advancedTo + 3 * 7 * 24 * 60 * 60 * 1000;
// 1. Preview attach pro monthly to entity 2
const preview = await autumnV1.billing.previewAttach({
const params = {
customer_id: customerId,
product_id: proMonthly.id,
plan_id: proMonthly.id,
entity_id: entities[1].id,
});
} as AttachParamsV1;
const preview = await autumnV2_1.billing.previewAttach(params);
// 2. Attach pro monthly to entity 2
await autumnV1.billing.attach({
@@ -101,7 +120,10 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-entities-edge-cases 1: e
// 3. Compute the EXACT expected proration of $20 against the monthly sub's
// period via getStripeSubscription + calculateProrationFromPeriod
const { billingPeriod } = await getStripeSubscription({ customerId });
const { billingPeriod } = await getStripeSubscription({
customerId,
interval: BillingInterval.Month,
});
const expectedMonthlyCharge = calculateProrationFromPeriod({
billingPeriod,
advancedTo: advancedToAfter,

View File

@@ -1,5 +1,6 @@
import type Stripe from "stripe";
import { msToSeconds } from "@autumn/shared";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import type Stripe from "stripe";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
@@ -13,12 +14,16 @@ export const createCustomStripeSubscription = async ({
productId,
unitAmount = 2000,
interval = "month",
billingCycleAnchorMs,
prorationBehavior,
}: {
ctx: TestContext;
customerId: string;
productId: string;
unitAmount?: number;
interval?: Stripe.PriceCreateParams.Recurring.Interval;
billingCycleAnchorMs?: number;
prorationBehavior?: Stripe.SubscriptionCreateParams.ProrationBehavior;
}): Promise<Stripe.Subscription> => {
const [fullCustomer, fullProduct] = await Promise.all([
CusService.getFull({ ctx, idOrInternalId: customerId }),
@@ -45,5 +50,11 @@ export const createCustomStripeSubscription = async ({
},
},
],
...(billingCycleAnchorMs !== undefined && {
billing_cycle_anchor: msToSeconds(billingCycleAnchorMs),
}),
...(prorationBehavior !== undefined && {
proration_behavior: prorationBehavior,
}),
});
};

View File

@@ -1,15 +1,32 @@
import { applyProration } from "@autumn/shared";
import {
applyProration,
type BillingInterval,
getCycleEnd,
getCycleStart,
secondsToMs,
} from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
/**
* Get Stripe subscription for a customer.
*
* When `interval` is supplied, the returned billingPeriod is cycle-aligned
* to the subscription's `billing_cycle_anchor` for that interval (mirrors
* `getLineItemBillingPeriod` in production). This is the correct period to
* use when computing proration for a cross-interval add-on (e.g., a monthly
* item attached to an annual subscription, where Stripe's per-item
* `current_period_start/end` inherit the parent sub's annual period).
*/
export const getStripeSubscription = async ({
customerId,
interval,
intervalCount = 1,
}: {
customerId: string;
interval?: BillingInterval;
intervalCount?: number;
}) => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
@@ -34,11 +51,20 @@ export const getStripeSubscription = async ({
throw new Error("No subscriptions found");
}
// Find an active subscription (not canceled)
// Prefer a sub matching the requested interval, falling back to the first active.
const activeSubs = subscriptions.data.filter(
(sub) => sub.status === "active" || sub.status === "trialing",
);
const candidateSubs = activeSubs.length > 0 ? activeSubs : subscriptions.data;
const subscription =
subscriptions.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
) ?? subscriptions.data[0];
(interval &&
candidateSubs.find((sub) =>
sub.items.data.some(
(item) => item.price?.recurring?.interval === interval,
),
)) ||
candidateSubs[0];
// Get billing period from the first subscription item
// Stripe stores current_period_start/end on each item, not the subscription itself
@@ -62,14 +88,45 @@ export const getStripeSubscription = async ({
);
}
// When `interval` is supplied, align the billing period to that interval
// using the sub's billing_cycle_anchor — Stripe inherits the parent sub's
// period on every item regardless of price interval, so the raw
// current_period_* values are wrong for cross-interval add-ons.
let billingPeriod: { start: number; end: number };
if (interval) {
const anchorMs = secondsToMs(subscription.billing_cycle_anchor);
const subCreatedMs = subscription.created
? secondsToMs(subscription.created)
: undefined;
const nowMs = Date.now();
billingPeriod = {
start: getCycleStart({
anchor: anchorMs,
interval,
intervalCount,
now: nowMs,
floor: subCreatedMs,
}),
end: getCycleEnd({
anchor: anchorMs,
interval,
intervalCount,
now: nowMs,
}),
};
} else {
billingPeriod = {
start: periodStart * 1000,
end: periodEnd * 1000,
};
}
return {
stripeCli,
stripeCustomerId,
subscription,
billingPeriod: {
start: periodStart * 1000,
end: periodEnd * 1000,
},
billingPeriod,
};
};