fix: orphaned cusProduct

This commit is contained in:
John Yeo
2026-05-04 21:58:56 +08:00
parent 6addb0e4af
commit 7effff4d7f
8 changed files with 321 additions and 5 deletions

View File

@@ -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": [

View File

@@ -1,5 +1,42 @@
<!-- Generated by ai-sync. Edit ai/rules/ instead. -->
# Autumn Shared Utils
Before writing inline `.filter()`, `.find()`, `.some()`, boolean predicate, or `<src>To<dst>` 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 `<resource>Utils/` folder:
- `classify*/``is*` boolean predicates (`isPrepaidPrice`, `isCustomerProductPaidRecurring`)
- `convert*/``<src>To<dst>` 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 <pkg>` 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 <owner>/<repo>
```
2. Move the installed skill folder(s) into `ai/config/skills/external/<skill-name>/`. `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/<name>/`, then promote the changes.
# Scope Cache Refresh Changes Safely
When changing cache-refresh behavior for API routes:

2
ai

Submodule ai updated: 26c9988782...e80869a20b

View File

@@ -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;
};

View File

@@ -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)

View File

@@ -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);
});

View File

@@ -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();
});

View File

@@ -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<UpdateSubscriptionV1ParamsInput>({
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);
});