diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 67b6a3efd..f577902ce 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -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": [ diff --git a/AGENTS.md b/AGENTS.md index 93d61d04b..047ac6fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ There is a legacy-compatibility exception in the adjust-balance flow: Projects maintain state in `.context//` 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: diff --git a/ai b/ai index a04ded379..18d68d3c2 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit a04ded379f43b3c8a23d7cc33eb125cce557500b +Subproject commit 18d68d3c2a8f1ca2773fd6f66362dc6e804eee81 diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 880839cf2..4b035f13d 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -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({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 5b4a6f750..9e293ce86 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts index c81b47fd2..15a6e8f15 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts b/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts index a2e71120a..591332aef 100644 --- a/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts +++ b/server/src/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod.ts @@ -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, diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 17654d84d..1c2d5f41f 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -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(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(customerId); + + expectBalanceCorrect({ + customer: customerAfterUpdate, + featureId: TestFeature.Messages, + remaining: expectedRemaining, + usage: 0, + rollovers: [{ balance: expectedRollover }], + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-edge-cases.test.ts b/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-edge-cases.test.ts index 9374ba896..d35ddafe7 100644 --- a/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-edge-cases.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-edge-cases.test.ts @@ -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({ + 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, diff --git a/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts b/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts index 0de748e34..dacc748b3 100644 --- a/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts +++ b/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts @@ -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 => { 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, + }), }); }; diff --git a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts index 45572f899..1d155efdc 100644 --- a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts +++ b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts @@ -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, }; };