fix: orphaned cusProduct
This commit is contained in:
@@ -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": [
|
||||
|
||||
37
AGENTS.md
37
AGENTS.md
@@ -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
2
ai
Submodule ai updated: 26c9988782...e80869a20b
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user