fix: billing cycle anchor in the future
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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
2
ai
Submodule ai updated: a04ded379f...18d68d3c2a
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user