diff --git a/.opencode/opencode.json b/.opencode/opencode.json index f577902ce..21d2083d8 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -34,6 +34,22 @@ "type": "remote", "url": "https://mcp.incident.io/mcp", "oauth": {} + }, + "plain": { + "type": "remote", + "url": "https://mcp.plain.com/mcp", + "oauth": {} + }, + "autumn-internal": { + "type": "local", + "command": [ + "sh", + "-c", + "cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=prod --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\"" + ], + "env": { + "AUTUMN_REPO_ROOT": "/Users/johnyeocx/Autumn" + } } }, "plugin": [ diff --git a/AGENTS.md b/AGENTS.md index 047ac6fb5..a05b68482 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,42 @@ +# Autumn Shared Utils + +Before writing inline `.filter()`, `.find()`, `.some()`, boolean predicate, or `To` transform logic over Autumn objects (`Price`, `Entitlement`, `FullCusProduct`, `FullCustomer`, `Feature`, etc.), **check `autumn/shared/utils/` for an existing helper.** Reaching for `array.filter(... === id)` directly is almost always a sign the utility was missed. + +The package is organized resource-first, pattern-second. Within each `Utils/` folder: + +- `classify*/` — `is*` boolean predicates (`isPrepaidPrice`, `isCustomerProductPaidRecurring`) +- `convert*/` — `To` transforms (`cusProductToPrices`, `entToPrice`) +- `find*/` — `Array.find` lookups (`findFeatureById`, `findPriceByFeatureId`) +- `filter*/` — `Array.filter` collections (`filterCustomerProductsByFeatureId`) +- `enrich*` files — augment with joined data (`enrichEntitlementWithFeature`) + +**If the helper you need doesn't exist, ALWAYS ask the user before adding one.** Naming and folder placement are cross-cutting and non-trivial — wrong placement clutters `@autumn/shared` for every consumer. + +Full convention (folder tree, naming nuances, anti-patterns): see the `shared-utils` skill. + +# Installing External Skills + +Third-party skills installed via `bunx skills add ` land under each agent's local skill dir (`.claude/skills/`, `.cursor/skills/`, etc.). Those locations are NOT a source of truth — `bun ai sync` only reads from `ai/config/skills/**` and prunes anything else it manages, so a raw `bunx skills add` will not propagate to the other consumer repos (autumn, cloud). + +## Workflow + +1. Install via the CLI as usual: + ```sh + bunx skills add / + ``` +2. Move the installed skill folder(s) into `ai/config/skills/external//`. `external/` is core, so both `autumn` and `cloud` consume it. Use `cloud/external/` only if the skill references cloud-only code. +3. Delete the leftover copies from `.claude/skills/`, `.cursor/skills/`, `.agents/skills/`, `.opencode/skills/` — `bun ai sync` will recreate them as symlinks. +4. Run `bun ai sync` to symlink the skill into every agent dir across every repo that pulls the `ai/` submodule. + +## Notes + +- Skill folder names must be globally unique across `ai/config/skills/**` (sync flattens them). +- Keep upstream `SKILL.md` frontmatter intact — `name` and `description` drive when the agent loads it. Only edit if the description is not specific enough about WHEN to use the skill. +- If the skill ships a `references/` or `scripts/` subfolder, copy the whole directory tree, not just `SKILL.md`. +- Re-running `bunx skills add` upstream-updates: install fresh, diff against `ai/config/skills/external//`, then promote the changes. + # Scope Cache Refresh Changes Safely When changing cache-refresh behavior for API routes: diff --git a/ai b/ai index 26c998878..e80869a20 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 26c9988782d3d53604aeccf6204669eb3500bf7e +Subproject commit e80869a20baf7e6170da6ee48c52e19ed22b4f70 diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index d55230d9c..886b9e2b3 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -13,6 +13,7 @@ import { computeCustomPlan } from "@/internal/billing/v2/actions/updateSubscript import { finalizeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan"; import { computeUpdateQuantityPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; import { computeFieldUpdates } from "./computeFieldUpdates"; /** @@ -85,6 +86,18 @@ export const computeUpdateSubscriptionPlan = async ({ params, }); + // When skipBillingChanges is true, Stripe is never called, so the post-Stripe + // sub-id linkage in executeStripeSubscriptionAction never runs. + const existingSubscriptionId = + billingContext.customerProduct.subscription_ids?.[0]; + + if (billingContext.skipBillingChanges && existingSubscriptionId) { + addStripeSubscriptionIdToBillingPlan({ + autumnBillingPlan: plan, + stripeSubscriptionId: existingSubscriptionId, + }); + } + return plan; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index c75a7cf27..f4751f149 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -1,5 +1,6 @@ import type { BillingContext, StripeItemSpec } from "@autumn/shared"; import { + cp, filterCustomerProductsByActiveStatuses, filterCustomerProductsByProcessorType, filterCustomerProductsByStripeSubscriptionId, @@ -107,9 +108,23 @@ export const buildStripeSubscriptionItemsUpdate = ({ processorType: ProcessorType.Stripe, }); + // 2a. Exclude orphans: paid-recurring customer products that have no + // linked Stripe subscription. These represent broken state (e.g. a prior + // update unlinked the sub). Their recurring prices must not contribute to + // any Stripe sub action — otherwise a fresh attach on the same customer + // would bundle their items into a new sub and double-charge the customer. + const nonOrphanCustomerProducts = stripeManagedCustomerProducts.filter( + (customerProduct) => { + const isPaidRecurringOrphan = + cp(customerProduct).paid().recurring().valid && + !cp(customerProduct).hasSubscription().valid; + return !isPaidRecurringOrphan; + }, + ); + // 3. Filter customer products by active statuses const activeCustomerProducts = filterCustomerProductsByActiveStatuses({ - customerProducts: stripeManagedCustomerProducts, + customerProducts: nonOrphanCustomerProducts, }); // 4. Get recurring subscription item array (doesn't include one-off items) diff --git a/server/tests/integration/billing/attach/orphan-sub/attach-addon-with-orphaned-base.test.ts b/server/tests/integration/billing/attach/orphan-sub/attach-addon-with-orphaned-base.test.ts new file mode 100644 index 000000000..76292c216 --- /dev/null +++ b/server/tests/integration/billing/attach/orphan-sub/attach-addon-with-orphaned-base.test.ts @@ -0,0 +1,105 @@ +/** + * TDD test for: attaching a one-off add-on must not create a recurring Stripe + * subscription that bundles items from a pre-existing customer product whose + * subscription_ids have been cleared (orphaned). + * + * Repro of the May 4 incident on a Lingo-style customer (two duplicate + * pay_as_you_go_prod attaches each created a brand-new sub bundling Production + * base price + credits-prepaid one-off). + * + * Pre-fix: `buildStripeSubscriptionItemsUpdate` filters customer products by + * empty `subscription_ids` when no current sub is targeted. That bucket + * contains the orphaned base product (had a sub, link got cleared on a prior + * billing.update bug), so its recurring price leaks into the new sub created + * for the add-on attach. The customer ends up with a fresh recurring + * subscription bundling base + credits-prepaid items. + * + * Post-fix: only products *being inserted* by this attach should contribute + * recurring items to a brand-new sub. Pre-existing orphaned products must not + * be re-bundled. A one-off add-on attach against an orphaned base produces a + * one-off invoice only — no recurring sub is created for the add-on. + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { CusService } from "@/internal/customers/CusService"; + +test(`${chalk.yellowBright("attach addon with orphaned base: one-off add-on does not create a new recurring sub")}`, async () => { + const customerId = "attach-addon-orphaned-base"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const oneOffCreditsItem = items.oneOffMessages({ + billingUnits: 50, + price: 10, + }); + const credits = products.oneOffAddOn({ + id: "credits-pack", + items: [oneOffCreditsItem], + }); + + const { customerId: cid, autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, credits] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Snapshot stripe state right after the base attach + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: cid, + }); + const proCusProduct = fullCustomerBefore.customer_products.find( + (cp) => cp.product_id === pro.id, + ); + expect(proCusProduct).toBeDefined(); + expect(proCusProduct!.subscription_ids?.length ?? 0).toBeGreaterThan(0); + + const stripeCustomerId = fullCustomerBefore.processor?.id; + if (!stripeCustomerId) throw new Error("missing stripe customer id"); + + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + expect(subsBefore.data.length).toBe(1); + const originalProSubId = subsBefore.data[0].id; + + // Simulate the Apr 29 orphan: clear the sub link on the pro cusProduct. + // The Stripe sub still exists and is still active. + await CusProductService.update({ + ctx, + cusProductId: proCusProduct!.id, + updates: { subscription_ids: [] }, + }); + + // Now attach the one-off add-on. Pre-fix this incorrectly creates a fresh + // recurring sub bundling pro's base price + credits prepaid item. + await autumnV1.billing.attach({ + customer_id: cid, + product_id: credits.id, + feature_quantities: [ + { + feature_id: oneOffCreditsItem.feature_id, + quantity: oneOffCreditsItem.billing_units, + }, + ], + }); + + const subsAfter = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + + // The only Stripe sub should still be the original (orphaned) Pro sub — + // no extra sub created for the one-off add-on attach. + expect(subsAfter.data.length).toBe(1); + expect(subsAfter.data[0].id).toBe(originalProSubId); +}); diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts index 6f44aaa3c..77896f60a 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts @@ -5,8 +5,8 @@ * Focuses on complex multi-product scenarios with subscription schedules. */ -import { test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, CusProductStatus } from "@autumn/shared"; import { expectCustomerProducts, expectProductCanceling, @@ -16,6 +16,8 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { CusService } from "@/internal/customers/CusService"; // ═══════════════════════════════════════════════════════════════════════════════ // TEST 1: Cancel pro immediately after entity cancel/uncancel/cancel cycle @@ -111,3 +113,68 @@ test.concurrent(`${chalk.yellowBright("cancel immediately edge: cancel pro after // shouldBeCanceled: true, }); }); + +test(`${chalk.yellowBright("cancel orphaned base: cancel_immediately on a paid recurring orphan does not create a new sub")}`, async () => { + const customerId = "cancel-orphaned-base-immediately"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { customerId: cid, autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: cid, + }); + const proCusProduct = fullCustomerBefore.customer_products.find( + (cp) => cp.product_id === pro.id, + ); + expect(proCusProduct).toBeDefined(); + + const stripeCustomerId = fullCustomerBefore.processor?.id; + if (!stripeCustomerId) throw new Error("missing stripe customer id"); + + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + expect(subsBefore.data.length).toBe(1); + const originalProSubId = subsBefore.data[0].id; + + // Orphan the cusProduct — sub still exists in Stripe, link cleared in autumn + await CusProductService.update({ + ctx, + cusProductId: proCusProduct!.id, + updates: { subscription_ids: [] }, + }); + + // Cancel immediately on the orphan should not create a new Stripe sub. + await autumnV1.subscriptions.update({ + customer_id: cid, + product_id: pro.id, + cancel_action: "cancel_immediately" as const, + }); + + const subsAfter = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + expect(subsAfter.data.length).toBe(1); + expect(subsAfter.data[0].id).toBe(originalProSubId); + + const fullCustomerAfter = await CusService.getFull({ + ctx, + idOrInternalId: cid, + }); + const activePro = fullCustomerAfter.customer_products.find( + (cp) => + cp.product_id === pro.id && cp.status === CusProductStatus.Active, + ); + expect(activePro).toBeUndefined(); +}); diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts index 4fad852a2..39e0ea717 100644 --- a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts +++ b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts @@ -1,6 +1,69 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; +import { + CusProductStatus, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {}); test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {}); + +test(`${chalk.yellowBright("update no_billing_changes: customize preserves subscription_ids on new cusProduct")}`, async () => { + const customerId = "update-no-billing-preserves-sub"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { autumnV2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Capture original subscription_ids on the active cusProduct + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const cusProductBefore = fullCustomerBefore.customer_products.find( + (cp) => + cp.product_id === pro.id && cp.status === CusProductStatus.Active, + ); + expect(cusProductBefore).toBeDefined(); + const originalSubIds = cusProductBefore?.subscription_ids ?? []; + expect(originalSubIds.length).toBeGreaterThan(0); + + // Customize the plan with no_billing_changes: should NOT touch Stripe but + // the new (replacement) active cusProduct must still link to the live sub. + await autumnV2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + no_billing_changes: true, + customize: { + price: itemsV2.monthlyPrice({ amount: 50 }), + }, + }); + + const fullCustomerAfter = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const activeProRows = fullCustomerAfter.customer_products.filter( + (cp) => + cp.product_id === pro.id && cp.status === CusProductStatus.Active, + ); + expect(activeProRows.length).toBe(1); + const cusProductAfter = activeProRows[0]; + + expect(cusProductAfter.subscription_ids).toEqual(originalSubIds); +});