Merge branch 'dev' into feat/attach-auto-topups-manually

This commit is contained in:
amianthus
2026-05-13 11:27:02 +01:00
committed by GitHub
121 changed files with 5929 additions and 514 deletions

View File

@@ -135,8 +135,8 @@ jobs:
--push \
--provenance=false \
--sbom=false \
--cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG_BRANCH} \
--cache-to type=inline \
--cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH} \
--cache-to type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH},mode=max \
--tag ${TAGS//,/ --tag } \
-f docker/Dockerfile \
.

View File

@@ -9,23 +9,52 @@ on:
- main
jobs:
validate-schema:
name: Validate Schema
changes:
name: Detect schema-relevant changes
runs-on: ubuntu-latest
outputs:
schema: ${{ steps.filter.outputs.schema }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Filter paths
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
schema:
- 'shared/**'
- 'server/**'
- 'scripts/migrations/**'
- '.github/workflows/validate-schema.yml'
validate-schema:
name: Validate Schema
needs: changes
runs-on: ubuntu-latest
steps:
- name: Skip (no schema-relevant changes)
if: needs.changes.outputs.schema != 'true'
run: echo "No schema-relevant changes detected; skipping validation."
- name: Checkout code
if: needs.changes.outputs.schema == 'true'
uses: actions/checkout@v4
- name: Set up Bun
if: needs.changes.outputs.schema == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Install dependencies
if: needs.changes.outputs.schema == 'true'
run: bun install
- name: Validate schema
if: needs.changes.outputs.schema == 'true'
env:
DATABASE_URL: ${{ secrets.DATABASE_READ_ONLY_URL }}
DB_MAX_CONNECTIONS: 1

2
.gitignore vendored
View File

@@ -146,4 +146,4 @@ AGENTS.md
server/.turbo
.context
.opencode
.opencode/

View File

@@ -1,58 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"axiom": {
"type": "remote",
"url": "https://mcp.axiom.co/mcp"
},
"linear": {
"type": "remote",
"url": "https://mcp.linear.app/mcp",
"oauth": {}
},
"trigger": {
"type": "local",
"command": [
"bunx",
"trigger.dev@latest",
"mcp"
]
},
"tinybird": {
"type": "remote",
"url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}"
},
"mintlify": {
"type": "remote",
"url": "https://mintlify.com/docs/mcp"
},
"planetscale": {
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
},
"incident-io": {
"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_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud"
}
}
},
"plugin": [
"opencode-supermemory@latest"
]
}

View File

@@ -126,11 +126,7 @@ 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:

View File

@@ -525,6 +525,7 @@
"react-dom": "^18.2.0",
"react-grab": "^0.1.29",
"react-hotkeys-hook": "^4.6.1",
"react-resizable-panels": "^4.11.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.6.2",
"recharts": "^3.3.0",
@@ -538,6 +539,7 @@
"tailwindcss": "^4.0.13",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^3.25.23",
"zustand": "^5.0.8",
},
@@ -4871,6 +4873,8 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.11.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw=="],
"react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="],
"react-router-dom": ["react-router-dom@7.14.2", "", { "dependencies": { "react-router": "7.14.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ=="],
@@ -5577,6 +5581,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
@@ -6421,6 +6427,12 @@
"@react-grab/cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="],
"@react-grab/cli/@antfu/ni": ["@antfu/ni@30.1.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.6.0", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15" }, "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" } }, "sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw=="],
"@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@react-grab/cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="],

View File

@@ -112,6 +112,13 @@ export const balancesTrackContract = oc
customer_id: "cus_123",
value: 1,
balance: API_BALANCE_V1_EXAMPLE,
deductions: [
{
balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2",
feature_id: "messages",
value: 1,
},
],
},
],
}),

View File

@@ -52,6 +52,7 @@
"@aws-sdk/client-s3": "^3.1017.0",
"@aws-sdk/client-scheduler": "^3.1004.0",
"@aws-sdk/client-sqs": "^3.958.0",
"@axiomhq/js": "^1.6.1",
"@axiomhq/pino": "^1.3.1",
"@better-auth/dash": "catalog:",
"@better-auth/oauth-provider": "catalog:",

74
server/src/external/axiom/aplUtils.ts vendored Normal file
View File

@@ -0,0 +1,74 @@
export const escapeApl = (value: string): string =>
value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
export type StatusBucket = "all" | "2xx" | "4xx" | "5xx";
export type HttpMethodFilter =
| "all"
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE";
const statusBucketClause = (bucket: StatusBucket): string | null => {
switch (bucket) {
case "2xx":
return "statusCode >= 200 and statusCode < 300";
case "4xx":
return "statusCode >= 400 and statusCode < 500";
case "5xx":
return "statusCode >= 500 and statusCode < 600";
default:
return null;
}
};
export const buildRequestLogsQuery = ({
orgSlug,
env,
customerId,
method,
statusBucket,
search,
limit = 200,
rangeDays = 7,
}: {
orgSlug: string;
env: string;
customerId: string;
method?: HttpMethodFilter;
statusBucket?: StatusBucket;
search?: string;
limit?: number;
rangeDays?: number;
}): string => {
const filters: string[] = [
`_time > ago(${rangeDays}d)`,
`isnotnull(statusCode)`,
`isnotnull(['req.url'])`,
`(['context.org_slug'] == '${escapeApl(orgSlug)}' or orgSlug == '${escapeApl(orgSlug)}')`,
`(['context.env'] == '${escapeApl(env)}' or env == '${escapeApl(env)}')`,
`(['req.customer_id'] == '${escapeApl(customerId)}' or customer_id == '${escapeApl(customerId)}')`,
];
if (method && method !== "all") {
filters.push(`['req.method'] == '${escapeApl(method)}'`);
}
const statusClause = statusBucketClause(statusBucket ?? "all");
if (statusClause) filters.push(statusClause);
if (search?.trim()) {
const needle = escapeApl(search.trim());
filters.push(
`(['req.url'] contains '${needle}' or msg contains '${needle}')`,
);
}
const wheres = filters.map((f) => `| where ${f}`).join("\n");
return `['express']
${wheres}
| order by _time desc
| limit ${limit}`;
};

20
server/src/external/axiom/initAxiom.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import { Axiom } from "@axiomhq/js";
const AXIOM_ADMIN_TOKEN = process.env.AXIOM_ADMIN_TOKEN;
const AXIOM_ORG_ID = process.env.AXIOM_ORG_ID;
export const axiomClient: Axiom | null = AXIOM_ADMIN_TOKEN
? new Axiom({
token: AXIOM_ADMIN_TOKEN,
orgId: AXIOM_ORG_ID,
})
: null;
export const getAxiomClient = (): Axiom => {
if (!axiomClient) {
throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing)");
}
return axiomClient;
};
export const isAxiomConfigured = (): boolean => axiomClient !== null;

View File

@@ -11,6 +11,7 @@ import { getBillingType } from "@server/internal/products/prices/priceUtils";
import Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { createStripePrepaidPriceV2 } from "@/external/stripe/createStripePrice/createStripePrepaidPriceV2.js";
import { assertNoPreviewStripeIdsOnProduct } from "@/external/stripe/previewStripeResourceIds.js";
import { getStripePrice } from "@/external/stripe/prices/operations/getStripePrice.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { billingIntervalToStripe } from "../stripePriceUtils.js";
@@ -140,6 +141,7 @@ export const createStripePriceIFNotExist = async ({
// Fetch latest price data...
const { org, logger, db, env } = ctx;
assertNoPreviewStripeIdsOnProduct({ product });
const stripeCli = createStripeCli({ org, env });
const billingType = getBillingType(price.config!);

View File

@@ -0,0 +1,236 @@
import {
type AutumnBillingPlan,
type BillingContext,
cusProductToProduct,
type FullCusProduct,
type FullProduct,
findCustomerProductById,
InternalError,
isPrepaidPrice,
isPreviewStripeId,
isUsagePrice,
PREVIEW_STRIPE_PRICE_ID_PREFIX,
PREVIEW_STRIPE_PRODUCT_ID_PREFIX,
type Price,
ProcessorType,
type Product,
type UsagePriceConfig,
} from "@autumn/shared";
import {
applyCustomerProductPatch,
getPatchCustomerProducts,
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
import { hashJson } from "@/utils/hash/hashJson";
const previewHash = ({ value }: { value: unknown }) =>
hashJson({ value }).slice(0, 24);
export const assertNotPreviewStripeId = ({
stripeId,
fieldName,
}: {
stripeId?: string | null;
fieldName: string;
}) => {
if (!isPreviewStripeId({ stripeId })) return;
throw new InternalError({
message: `Refusing to persist preview Stripe id in ${fieldName}`,
});
};
export const previewStripeProductIdForProduct = ({
product,
}: {
product: Product;
}) =>
`${PREVIEW_STRIPE_PRODUCT_ID_PREFIX}${previewHash({
value: {
env: product.env,
internalProductId: product.internal_id,
productId: product.id,
},
})}`;
const previewStripeProductIdForPrice = ({
price,
product,
internalEntityId,
}: {
price: Price;
product: Product;
internalEntityId?: string;
}) => {
const config = price.config as Partial<UsagePriceConfig>;
return `${PREVIEW_STRIPE_PRODUCT_ID_PREFIX}${previewHash({
value: {
env: product.env,
featureId: config.feature_id,
internalEntityId,
internalFeatureId: config.internal_feature_id,
internalProductId: product.internal_id,
productId: product.id,
},
})}`;
};
const previewStripePriceIdForPrice = ({
price,
product,
fieldName,
}: {
price: Price;
product: Product;
fieldName: string;
}) =>
`${PREVIEW_STRIPE_PRICE_ID_PREFIX}${previewHash({
value: {
config: price.config,
fieldName,
internalProductId: product.internal_id,
},
})}`;
export const applyPreviewStripeResourcesToProduct = ({
product,
internalEntityId,
}: {
product: FullProduct;
internalEntityId?: string;
}) => {
const productProcessorId =
product.processor?.id ?? previewStripeProductIdForProduct({ product });
product.processor = {
id: productProcessorId,
type: ProcessorType.Stripe,
};
for (const price of product.prices) {
const config = price.config as Partial<UsagePriceConfig>;
config.stripe_price_id ??= previewStripePriceIdForPrice({
price,
product,
fieldName: "stripe_price_id",
});
if (isUsagePrice({ price })) {
config.stripe_product_id ??= previewStripeProductIdForPrice({
price,
product,
internalEntityId,
});
}
if (isPrepaidPrice(price)) {
config.stripe_prepaid_price_v2_id ??= previewStripePriceIdForPrice({
price,
product,
fieldName: "stripe_prepaid_price_v2_id",
});
}
}
};
const applyPreviewStripeResourcesToCustomerProduct = ({
customerProduct,
internalEntityId,
}: {
customerProduct: FullCusProduct;
internalEntityId?: string;
}) => {
const product = cusProductToProduct({ cusProduct: customerProduct });
applyPreviewStripeResourcesToProduct({ product, internalEntityId });
customerProduct.product.processor = product.processor ?? null;
};
/** Stamp preview Stripe IDs onto every customer product touched by a dry-run billing plan. */
export const applyPreviewStripeResourcesToBillingPlan = ({
autumnBillingPlan,
billingContext,
}: {
autumnBillingPlan: AutumnBillingPlan;
billingContext: BillingContext;
}) => {
const { fullCustomer } = billingContext;
const internalEntityId = fullCustomer.entity?.internal_id;
const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan });
const patchedCustomerProductIds = new Set(
patchCustomerProducts.map(
(patchCustomerProduct) => patchCustomerProduct.customerProduct.id,
),
);
for (const customerProduct of autumnBillingPlan.insertCustomerProducts) {
applyPreviewStripeResourcesToCustomerProduct({
customerProduct,
internalEntityId,
});
}
for (const patchCustomerProduct of patchCustomerProducts) {
const matchingCustomerProduct =
findCustomerProductById({
fullCustomer,
customerProductId: patchCustomerProduct.customerProduct.id,
}) ?? patchCustomerProduct.customerProduct;
const patchedCustomerProduct = applyCustomerProductPatch({
customerProduct: matchingCustomerProduct,
patch: patchCustomerProduct,
});
applyPreviewStripeResourcesToCustomerProduct({
customerProduct: patchedCustomerProduct,
internalEntityId,
});
if (matchingCustomerProduct === patchCustomerProduct.customerProduct) {
continue;
}
applyPreviewStripeResourcesToCustomerProduct({
customerProduct: applyCustomerProductPatch({
customerProduct: patchCustomerProduct.customerProduct,
patch: patchCustomerProduct,
}),
internalEntityId,
});
}
for (const customerProduct of fullCustomer.customer_products) {
if (patchedCustomerProductIds.has(customerProduct.id)) continue;
applyPreviewStripeResourcesToCustomerProduct({
customerProduct,
internalEntityId,
});
}
};
export const assertNoPreviewStripeIdsOnProduct = ({
product,
}: {
product: FullProduct;
}) => {
assertNotPreviewStripeId({
stripeId: product.processor?.id,
fieldName: "product.processor.id",
});
for (const price of product.prices) {
const config = price.config as Partial<UsagePriceConfig>;
for (const fieldName of [
"stripe_price_id",
"stripe_product_id",
"stripe_empty_price_id",
"stripe_placeholder_price_id",
"stripe_prepaid_price_v2_id",
] as const) {
assertNotPreviewStripeId({
stripeId: config[fieldName],
fieldName: `price.config.${fieldName}`,
});
}
}
};

View File

@@ -40,6 +40,7 @@ const TinybirdEventSchema = z.object({
internal_entity_id: z.string().nullable(),
customer_id: z.string(),
properties: z.string().nullable(),
deductions: z.string().nullable(),
});
/** Pre-built pipe callers */

View File

@@ -13,6 +13,7 @@ export const listEventsPaginatedPipeResponseSchema = z.object({
properties: z.string().nullable(),
idempotency_key: z.string().nullable(),
entity_id: z.string().nullable(),
deductions: z.string().nullable(),
});
export type ListEventsPaginatedPipeRow = z.infer<

View File

@@ -17,6 +17,7 @@ export interface TinybirdEvent {
internal_entity_id: string | null;
customer_id: string;
properties: string | null;
deductions: string | null;
}
/** Convert EventInsert to Tinybird schema */
@@ -46,5 +47,6 @@ export const mapToTinybirdEvent = (event: EventInsert): TinybirdEvent => {
internal_entity_id: event.internal_entity_id ?? null,
customer_id: event.customer_id,
properties: event.properties ? JSON.stringify(event.properties) : null,
deductions: event.deductions ? JSON.stringify(event.deductions) : null,
};
};

View File

@@ -1,4 +1,4 @@
import type { ApiEventsListItem } from "@autumn/shared";
import type { ApiEventsListItem, TrackDeduction } from "@autumn/shared";
import { epochToDateTime } from "@autumn/shared/api/common/epochUtils";
import { getTinybirdPipes } from "@/external/tinybird/initTinybird.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
@@ -84,6 +84,17 @@ export const listEvents = async ({
}
}
let deductions: TrackDeduction[] | null = null;
if (row.deductions) {
try {
const parsed = JSON.parse(row.deductions);
if (Array.isArray(parsed)) deductions = parsed as TrackDeduction[];
} catch {
// Invalid JSON — leave null so the caller can distinguish missing
// vs explicit empty.
}
}
return {
id: row.id,
timestamp: new Date(row.timestamp).getTime(),
@@ -91,6 +102,7 @@ export const listEvents = async ({
customer_id: row.customer_id,
value: row.value ?? 0,
properties,
deductions,
};
});

View File

@@ -48,7 +48,7 @@ export class EventService {
env: string;
limit?: number;
}) {
if (process.env.NODE_ENV !== "development") return [];
if (process.env.NODE_ENV === "production") return [];
const results = await db
.select({
id: events.id,

View File

@@ -28,30 +28,18 @@ const intervalToEntInterval = ({
}
};
const getWindowEndsAt = ({
now,
windowConfig,
}: {
now: number;
windowConfig: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig;
}) => {
return addInterval({
from: now,
interval: intervalToEntInterval({ interval: windowConfig.interval }),
intervalCount: windowConfig.interval_count ?? 1,
});
};
export const normalizeWindowCounter = ({
now,
windowEndsAt,
count,
windowConfig,
from,
}: {
now: number;
windowEndsAt: number;
count: number;
windowConfig?: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig;
from?: number;
}) => {
if (!windowConfig) return undefined;
@@ -59,8 +47,16 @@ export const normalizeWindowCounter = ({
return { windowEndsAt, count };
}
const interval = intervalToEntInterval({ interval: windowConfig.interval });
const intervalCount = windowConfig.interval_count || 1;
let projected = from ?? now;
do {
projected = addInterval({ from: projected, interval, intervalCount });
} while (projected <= now);
return {
windowEndsAt: getWindowEndsAt({ now, windowConfig }),
windowEndsAt: projected,
count: 0,
};
};

View File

@@ -1,4 +1,4 @@
import type { EventInsert, TrackParams } from "@autumn/shared";
import type { EventInsert, TrackDeduction, TrackParams } from "@autumn/shared";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { generateId } from "../../../utils/genUtils.js";
@@ -35,6 +35,7 @@ export const initEvent = (params: {
internalEntityId?: string;
customerId: string;
entityId?: string;
deductions?: TrackDeduction[];
}) => {
const {
ctx,
@@ -43,6 +44,7 @@ export const initEvent = (params: {
internalEntityId,
customerId,
entityId,
deductions,
} = params;
const { org, env } = ctx;
@@ -69,6 +71,7 @@ export const initEvent = (params: {
properties: eventInfo.properties ?? {},
idempotency_key: eventInfo.idempotency_key ?? null,
set_usage: false,
deductions: deductions && deductions.length > 0 ? deductions : null,
} satisfies EventInsert;
return newEvent;

View File

@@ -52,7 +52,11 @@ export const buildFinalizeLockContext = async ({
source: "runFinalizeLock",
});
const lockValue = calculateLockValue({ items: receipt.items });
const calculatedLockValue = calculateLockValue({ items: receipt.items });
const lockValue =
receipt.items.length === 0
? (receipt.overrideLockValue ?? calculatedLockValue)
: calculatedLockValue;
const finalValue =
params.action === "release" ? 0 : (params.override_value ?? lockValue);

View File

@@ -9,6 +9,7 @@ import {
import {
deductionToTrackResponseV2,
executePostgresDeductionV2,
projectMutationLogsToTrackDeductionsV2,
} from "@/internal/balances/utils/deductionV2/index.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import { handlePostgresTrackError } from "../utils/handlePostgresTrackError.js";
@@ -45,7 +46,12 @@ export const runPostgresTrackV3 = async ({
});
}
const { fullSubject: updatedFullSubject, updates } = result;
const { fullSubject: updatedFullSubject, updates, mutationLogs } = result;
const deductions = projectMutationLogsToTrackDeductionsV2({
fullSubject: updatedFullSubject,
mutationLogs,
});
if (!body.skip_event && !body.idempotency_key) {
const eventInfo = buildEventInfo(body);
@@ -56,6 +62,7 @@ export const runPostgresTrackV3 = async ({
internalEntityId: updatedFullSubject.internalEntityId,
customerId: body.customer_id,
entityId: body.entity_id,
deductions,
});
globalEventBatchingManager.addEvent(event);
@@ -75,5 +82,6 @@ export const runPostgresTrackV3 = async ({
value: body.value ?? 1,
balance,
balances,
deductions,
};
};

View File

@@ -1,4 +1,9 @@
import type { FullSubject, TrackParams, TrackResponseV3 } from "@autumn/shared";
import type {
FullSubject,
TrackDeduction,
TrackParams,
TrackResponseV3,
} from "@autumn/shared";
import { tryCatch } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js";
@@ -9,6 +14,7 @@ import {
import {
deductionToTrackResponseV2,
executeRedisDeductionV2,
projectMutationLogsToTrackDeductionsV2,
} from "@/internal/balances/utils/deductionV2/index.js";
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
@@ -49,10 +55,12 @@ const queueEvent = ({
ctx,
body,
fullSubject,
deductions,
}: {
ctx: AutumnContext;
body: TrackParams;
fullSubject: FullSubject;
deductions: TrackDeduction[];
}): void => {
if (body.skip_event) return;
@@ -66,6 +74,7 @@ const queueEvent = ({
internalEntityId: fullSubject.internalEntityId,
customerId: body.customer_id,
entityId: body.entity_id,
deductions,
}),
);
};
@@ -114,6 +123,7 @@ export const runRedisTrackV3 = async ({
fullSubject: updatedFullSubject,
rolloverUpdates,
modifiedCusEntIdsByFeatureId,
mutationLogs,
} = result;
queueSyncItem({
@@ -124,7 +134,12 @@ export const runRedisTrackV3 = async ({
modifiedCusEntIdsByFeatureId,
});
queueEvent({ ctx, body, fullSubject });
const deductions = projectMutationLogsToTrackDeductionsV2({
fullSubject: updatedFullSubject,
mutationLogs,
});
queueEvent({ ctx, body, fullSubject, deductions });
const { balance, balances } = await deductionToTrackResponseV2({
ctx,
@@ -140,5 +155,6 @@ export const runRedisTrackV3 = async ({
value: body.value ?? 1,
balance,
balances,
deductions,
};
};

View File

@@ -118,8 +118,19 @@ export const executePostgresDeduction = async ({
options,
});
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0)
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) {
if (unlimitedFeatureIds.length > 0 && preparedLock?.enabled) {
await saveLockReceipt({
lock: preparedLock,
customerId: fullCustomer.id || customerId,
featureId: feature.id,
entityId,
items: [],
overrideLockValue: toDeduct,
});
}
continue;
}
// Call the stored function to deduct from entitlements with credit costs
const result = await db.execute(

View File

@@ -11,6 +11,7 @@ import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeatur
import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { fireTrackWebhooks } from "../../trackWebhooks/fireTrackWebhooks.js";
import { saveLockReceipt } from "../lock/saveLockReceipt.js";
import type { DeductionOptions } from "../types/deductionTypes.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
@@ -116,6 +117,17 @@ export const executeRedisDeduction = async ({
});
if (unlimitedFeatureIds.length > 0) {
if (preparedLock) {
await saveLockReceipt({
lock: preparedLock,
customerId,
featureId: feature.id,
entityId,
items: [],
overrideLockValue: toDeduct,
redisInstance,
});
}
continue;
}

View File

@@ -117,8 +117,20 @@ export const executePostgresDeductionV2 = async ({
options: resolvedOptions,
});
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0)
if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) {
if (unlimitedFeatureIds.length > 0 && preparedLock?.enabled) {
await saveLockReceiptV2({
lock: preparedLock,
customerId: fullSubject.customerId || customerId,
featureId: feature.id,
entityId,
items: [],
overrideLockValue: toDeduct,
redisInstance: ctx.redisV2,
});
}
continue;
}
const result = await db.execute(
sql`SELECT * FROM deduct_from_cus_ents(

View File

@@ -13,6 +13,7 @@ import {
} from "@/internal/balances/track/v3/trackIdempotencyKey.js";
import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js";
import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js";
import { saveLockReceiptV2 } from "@/internal/balances/utils/lockV2/saveLockReceiptV2.js";
import { buildDeductFromSubjectBalancesKeys } from "@/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.js";
import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
@@ -128,6 +129,17 @@ export const executeRedisDeductionV2 = async ({
});
if (unlimitedFeatureIds.length > 0) {
if (preparedLock?.enabled) {
await saveLockReceiptV2({
lock: preparedLock,
customerId,
featureId: feature.id,
entityId,
items: [],
overrideLockValue: toDeduct,
redisInstance: redisInstance ?? ctx.redisV2,
});
}
continue;
}

View File

@@ -8,4 +8,5 @@ export { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
export { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
export { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js";
export { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js";
export { projectMutationLogsToTrackDeductionsV2 } from "./projectMutationLogsToTrackDeductionsV2.js";
export { rollbackDeductionV2 } from "./rollbackDeductionV2.js";

View File

@@ -0,0 +1,80 @@
import {
type FullSubject,
fullSubjectToCustomerEntitlements,
type TrackDeduction,
} from "@autumn/shared";
import type { MutationLogItem } from "../types/mutationLogItem.js";
export const projectMutationLogsToTrackDeductionsV2 = ({
fullSubject,
mutationLogs,
}: {
fullSubject: FullSubject;
mutationLogs: MutationLogItem[];
}): TrackDeduction[] => {
if (mutationLogs.length === 0) return [];
const customerEntitlements = fullSubjectToCustomerEntitlements({
fullSubject,
});
const customerEntitlementIdToFeatureId = new Map<string, string>();
const rolloverIdToFeatureId = new Map<string, string>();
for (const customerEntitlement of customerEntitlements) {
const featureId = customerEntitlement.entitlement.feature.id;
customerEntitlementIdToFeatureId.set(customerEntitlement.id, featureId);
for (const rollover of customerEntitlement.rollovers ?? []) {
rolloverIdToFeatureId.set(rollover.id, featureId);
}
}
// cus_ent_* and rollover_* share the same `balance_id` namespace in the
// public shape, but their internal types are scoped separately — qualify
// with the type when aggregating so the namespaces can't collide.
const aggregated = new Map<string, TrackDeduction>();
for (const log of mutationLogs) {
if (log.balance_delta === 0) continue;
let balanceId: string;
let featureId: string | undefined;
let typeQualifier: string;
if (
log.target_type === "customer_entitlement" &&
log.customer_entitlement_id
) {
balanceId = log.customer_entitlement_id;
featureId = customerEntitlementIdToFeatureId.get(balanceId);
typeQualifier = "ce";
} else if (log.target_type === "rollover" && log.rollover_id) {
balanceId = log.rollover_id;
featureId = rolloverIdToFeatureId.get(balanceId);
typeQualifier = "ro";
} else {
continue;
}
if (!featureId) continue;
const key = `${typeQualifier}::${balanceId}`;
const existing = aggregated.get(key);
// Lua emits balance_delta as negative for deductions; flip so the public
// shape reads "amount consumed = positive".
const valueDelta = -log.balance_delta;
if (existing) {
existing.value += valueDelta;
continue;
}
aggregated.set(key, {
balance_id: balanceId,
feature_id: featureId,
value: valueDelta,
});
}
return [...aggregated.values()];
};

View File

@@ -14,6 +14,7 @@ export type LockReceipt = {
entity_id?: string | null;
expires_at?: number | null;
region?: string | null;
overrideLockValue?: number | null;
items: MutationLogItem[];
};
@@ -62,6 +63,26 @@ const fetchAndClaimLockReceiptV2FromCandidates = async ({
return { found: false as const };
};
const isRedisWrongTypeError = (error: unknown) =>
error instanceof Error &&
/WRONGTYPE|wrong Redis type|wrong kind of value/i.test(error.message);
const fetchLegacyLockReceiptJson = async ({
lockReceiptKey,
}: {
lockReceiptKey: string;
}) =>
tryRedisRead(async () => {
try {
return (await redis.call("JSON.GET", lockReceiptKey, "$")) as
| string
| null;
} catch (error) {
if (isRedisWrongTypeError(error)) return null;
throw error;
}
}, redis);
export const fetchLockReceipt = async ({
ctx,
lockId,
@@ -81,11 +102,7 @@ export const fetchLockReceipt = async ({
// During org Redis migrations, V2 checks both shared and dedicated Redis.
// V1 half stays a plain JSON.GET — V1 finalize still claims via Lua afterwards.
const [rawReceiptV1, v2Result] = await Promise.all([
tryRedisRead(
() =>
redis.call("JSON.GET", lockReceiptKey, "$") as Promise<string | null>,
redis,
),
fetchLegacyLockReceiptJson({ lockReceiptKey }),
fetchAndClaimLockReceiptV2FromCandidates({
ctx,
lockId,

View File

@@ -10,6 +10,7 @@ export const saveLockReceipt = async ({
featureId,
entityId,
items,
overrideLockValue,
redisInstance,
}: {
lock: {
@@ -24,6 +25,7 @@ export const saveLockReceipt = async ({
featureId: string;
entityId?: string;
items: MutationLogItem[];
overrideLockValue?: number;
redisInstance?: Redis;
}) => {
const targetRedis = redisInstance ?? redis;
@@ -53,6 +55,7 @@ export const saveLockReceipt = async ({
entity_id: entityId ?? null,
expires_at: lock.expires_at ?? null,
created_at: lock.created_at,
overrideLockValue: overrideLockValue ?? null,
items,
}),
) as Promise<"OK" | null>,

View File

@@ -48,7 +48,11 @@ export const buildFinalizeLockContextV2 = async ({
source: "runFinalizeLockV2",
});
const lockValue = calculateLockValue({ items: receipt.items });
const calculatedLockValue = calculateLockValue({ items: receipt.items });
const lockValue =
receipt.items.length === 0
? (receipt.overrideLockValue ?? calculatedLockValue)
: calculatedLockValue;
const finalValue =
params.action === "release" ? 0 : (params.override_value ?? lockValue);

View File

@@ -16,6 +16,7 @@ export const saveLockReceiptV2 = async ({
featureId,
entityId,
items,
overrideLockValue,
redisInstance,
}: {
lock: {
@@ -30,6 +31,7 @@ export const saveLockReceiptV2 = async ({
featureId: string;
entityId?: string;
items: MutationLogItem[];
overrideLockValue?: number;
redisInstance: Redis;
}) => {
const payload = JSON.stringify({
@@ -42,6 +44,7 @@ export const saveLockReceiptV2 = async ({
entity_id: entityId ?? null,
expires_at: lock.expires_at ?? null,
created_at: lock.created_at,
overrideLockValue: overrideLockValue ?? null,
items,
});

View File

@@ -41,6 +41,7 @@ export async function attach({
const billingContext = await setupAttachBillingContext({
ctx,
params,
preview,
contextOverride,
});

View File

@@ -39,10 +39,12 @@ import { setupAttachTrialContext } from "./setupAttachTrialContext";
export const setupAttachBillingContext = async ({
ctx,
params,
preview = false,
contextOverride = {},
}: {
ctx: AutumnContext;
params: AttachParamsV1;
preview?: boolean;
contextOverride?: BillingContextOverride;
}): Promise<AttachBillingContext> => {
const { fullCustomer: fullCustomerOverride } = contextOverride;
@@ -195,7 +197,8 @@ export const setupAttachBillingContext = async ({
});
const billingStartsAt =
params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined);
params.starts_at ??
(planTiming === "end_of_cycle" ? endOfCycleMs : undefined);
const hasFutureStartDate = isFutureStartDate(
params.starts_at,
currentEpochMs,
@@ -278,6 +281,7 @@ export const setupAttachBillingContext = async ({
externalId: params.subscription_id,
skipBillingChanges,
dryRunStripe: preview,
anchorResetRefund: setupAnchorResetRefund({
billingCycleAnchor: params.billing_cycle_anchor,

View File

@@ -106,9 +106,11 @@ const setupImmediateMultiProductTrialContext = async ({
export const setupImmediateMultiProductBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: MultiAttachParamsV0;
preview?: boolean;
}): Promise<MultiAttachBillingContext> => {
const fullCustomer = await setupFullCustomerContext({
ctx,
@@ -254,5 +256,6 @@ export const setupImmediateMultiProductBillingContext = async ({
successUrl:
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
checkoutSessionParams: params.checkout_session_params,
dryRunStripe: preview,
};
};

View File

@@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({
const billingContext = await setupCreateScheduleBillingContext({
ctx,
params,
preview: true,
});
await handleCreateScheduleErrors({

View File

@@ -70,9 +70,11 @@ const setupCreateScheduleCheckoutMode = ({
export const setupCreateScheduleBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: CreateScheduleParamsV0;
preview?: boolean;
}): Promise<CreateScheduleBillingContext> => {
const normalizedPhases = normalizeCreateSchedulePhases({
phases: params.phases,
@@ -99,6 +101,7 @@ export const setupCreateScheduleBillingContext = async ({
const billingContext = await setupImmediateMultiProductBillingContext({
ctx,
params: immediateParams,
preview,
});
validateCreateSchedulePhasePlans({

View File

@@ -2,13 +2,17 @@ import {
type AttachBillingContext,
type BillingPlan,
type BillingResult,
ErrCode,
type FullCusProduct,
type FullCustomer,
type FullProduct,
featureUtils,
nullish,
RecaseError,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { TransitionRules } from "@shared/api/billing/common/transitionRules";
import { StatusCodes } from "http-status-codes";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingActions } from "@/internal/billing/v2/actions";
@@ -34,6 +38,18 @@ export async function migrate({
noBillingChanges?: boolean;
};
}) {
const willTouchStripe = options.noBillingChanges !== true;
const isHalfCancelled =
currentCustomerProduct.canceled === true &&
nullish(currentCustomerProduct.ended_at);
if (willTouchStripe && isHalfCancelled) {
throw new RecaseError({
message: `[migrate] Refusing to migrate cusProduct ${currentCustomerProduct.id} (${currentCustomerProduct.product_id}): canceled=true but ended_at is null. Set noBillingChanges:true or repair ended_at before retrying.`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.CONFLICT,
});
}
// 1. Build update subscription params
const entity = fullCustomer.entities.find(
(e) => e.internal_id === currentCustomerProduct.internal_entity_id,

View File

@@ -34,6 +34,7 @@ export async function multiAttach({
const billingContext = await setupMultiAttachBillingContext({
ctx,
params,
preview,
});
// 2. Errors

View File

@@ -11,11 +11,14 @@ import { setupImmediateMultiProductBillingContext } from "../../common/immediate
export const setupMultiAttachBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: MultiAttachParamsV0;
preview?: boolean;
}): Promise<MultiAttachBillingContext> =>
setupImmediateMultiProductBillingContext({
ctx,
params,
preview,
});

View File

@@ -41,10 +41,12 @@ const FIELDS_WITH_BILLING_CHANGES = [
export const setupUpdateSubscriptionBillingContext = async ({
ctx,
params,
preview = false,
contextOverride = {},
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV1Params;
preview?: boolean;
contextOverride?: UpdateSubscriptionBillingContextOverride;
}): Promise<UpdateSubscriptionBillingContext> => {
const fullCustomer = await setupFullCustomerContext({
@@ -210,6 +212,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
actionSource: "updateSubscription",
skipBillingChanges,
dryRunStripe: preview,
checkoutMode,

View File

@@ -43,6 +43,7 @@ export async function updateSubscription({
const billingContext = await setupUpdateSubscriptionBillingContext({
ctx,
params,
preview,
contextOverride,
});
@@ -96,12 +97,12 @@ export async function updateSubscription({
) {
const autumnCheckoutResult =
await createAutumnCheckout<UpdateSubscriptionBillingContext>({
ctx,
action: CheckoutAction.UpdateSubscription,
params,
billingContext,
billingPlan,
});
ctx,
action: CheckoutAction.UpdateSubscription,
params,
billingContext,
billingPlan,
});
return autumnCheckoutResult;
}

View File

@@ -1,18 +1,61 @@
import {
type AutumnBillingPlan,
type BillingContext,
copyStripeResourcesToMatchingPrice,
cusProductToProduct,
type FullProduct,
isFixedPrice,
isFreeProduct,
isPrepaidPrice,
nullish,
type Price,
} from "@autumn/shared";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice";
import { applyPreviewStripeResourcesToBillingPlan } from "@/external/stripe/previewStripeResourceIds";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import {
applyCustomerProductPatch,
getPatchCustomerProducts,
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
import { PriceService } from "@/internal/products/prices/PriceService";
import { checkStripeProductExists } from "@/internal/products/productUtils";
const shouldInitializeStripePrice = ({ price }: { price: Price }) => {
if (!isFixedPrice(price)) return true;
return (price.config.amount ?? 0) > 0;
};
const applyStripeReuseWithinProduct = async ({
db,
product,
}: {
db: AutumnContext["db"];
product: FullProduct;
}) => {
for (const targetPrice of product.prices) {
const candidatePrices = product.prices.filter(
(price) => price.id !== targetPrice.id,
);
if (candidatePrices.length === 0) continue;
const { copiedFields } = copyStripeResourcesToMatchingPrice({
targetPrice,
candidatePrices,
targetEntitlements: product.entitlements,
candidateEntitlements: product.entitlements,
});
if (copiedFields.length === 0) continue;
await PriceService.update({
db,
id: targetPrice.id,
update: { config: targetPrice.config },
});
}
};
export const initStripeResourcesForBillingPlan = async ({
ctx,
autumnBillingPlan,
@@ -24,24 +67,32 @@ export const initStripeResourcesForBillingPlan = async ({
}) => {
const { db, org, env, logger } = ctx;
if (billingContext.dryRunStripe) {
applyPreviewStripeResourcesToBillingPlan({
autumnBillingPlan,
billingContext,
});
return;
}
const { fullCustomer } = billingContext;
const { insertCustomerProducts } = autumnBillingPlan;
const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan });
const newProducts = insertCustomerProducts.flatMap((cp) =>
cusProductToProduct({ cusProduct: cp }),
const newProducts = insertCustomerProducts.flatMap((customerProduct) =>
cusProductToProduct({ cusProduct: customerProduct }),
);
const patchProducts = getPatchCustomerProducts({ autumnBillingPlan }).map(
(patchCustomerProduct) =>
cusProductToProduct({
cusProduct: applyCustomerProductPatch({
customerProduct: patchCustomerProduct.customerProduct,
patch: patchCustomerProduct,
}),
const patchProducts = patchCustomerProducts.map((patchCustomerProduct) =>
cusProductToProduct({
cusProduct: applyCustomerProductPatch({
customerProduct: patchCustomerProduct.customerProduct,
patch: patchCustomerProduct,
}),
}),
);
const patchedCustomerProductIds = new Set(
getPatchCustomerProducts({ autumnBillingPlan }).map(
patchCustomerProducts.map(
(patchCustomerProduct) => patchCustomerProduct.customerProduct.id,
),
);
@@ -57,9 +108,10 @@ export const initStripeResourcesForBillingPlan = async ({
...product,
prices: product.prices.filter(
(price) =>
nullish(price.config.stripe_price_id) ||
(isPrepaidPrice(price) &&
nullish(price.config.stripe_prepaid_price_v2_id)),
shouldInitializeStripePrice({ price }) &&
(nullish(price.config.stripe_price_id) ||
(isPrepaidPrice(price) &&
nullish(price.config.stripe_prepaid_price_v2_id))),
),
}))
.filter(
@@ -67,10 +119,18 @@ export const initStripeResourcesForBillingPlan = async ({
);
const allProducts = [...newProducts, ...patchProducts, ...existingProducts];
const internalEntityId = fullCustomer.entity?.internal_id;
await Promise.all(
allProducts.map((product) =>
applyStripeReuseWithinProduct({ db, product }),
),
);
const batchProductUpdates = [];
for (const product of allProducts) {
if (product.processor?.id != null) continue;
if (isFreeProduct({ prices: product.prices })) continue;
batchProductUpdates.push(
checkStripeProductExists({
@@ -86,10 +146,10 @@ export const initStripeResourcesForBillingPlan = async ({
const batchPriceUpdates = [];
const internalEntityId = fullCustomer.entity?.internal_id;
for (const product of allProducts) {
for (const price of product.prices) {
if (!shouldInitializeStripePrice({ price })) continue;
batchPriceUpdates.push(
createStripePriceIFNotExist({
ctx,

View File

@@ -1,6 +1,7 @@
import {
type BillingContext,
cusPriceToCusEntWithCusProduct,
type FixedPriceConfig,
type FullCusProduct,
type FullCustomerPrice,
isAllocatedPrice,
@@ -38,6 +39,9 @@ export const cusPriceToStripeItemSpec = ({
// 1. Fixed / one-off price (no entitlement needed)
if (isFixedPrice(price)) {
const config = price.config as FixedPriceConfig;
if ((config.amount ?? 0) <= 0) return null;
spec = fixedPriceToStripeItemSpec({ cusPrice, cusProduct });
} else {
// Resolve cusEntWithCusProduct for usage-based prices

View File

@@ -5,6 +5,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos";
import { normalizeWindowCounter } from "@/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.js";
/**
* When `expand=billing_controls.auto_topups.purchase_limit` is requested,
@@ -17,12 +18,12 @@ import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos";
* - Returns `undefined` when there are no configured auto_topups.
* - For each configured auto_topup with a matching DB row, the `purchase_limit`
* object is rebuilt with `count` + `next_reset_at` from the row. If the
* stored window has elapsed, `count` is projected to 0 and `next_reset_at`
* is advanced from the stored boundary by the configured interval. If the
* config had no `purchase_limit`, `interval` / `interval_count` / `limit`
* are returned as `null`.
* - For configured auto_topups WITHOUT a matching DB row (no top-up has
* ever fired), the entry is passed through unchanged from config.
*
* Per design: no live window normalization — raw DB values are surfaced as-is.
*/
export const getCusAutoTopupPurchaseLimits = async ({
ctx,
@@ -60,6 +61,13 @@ export const getCusAutoTopupPurchaseLimits = async ({
}
const configuredLimit = config.purchase_limit;
const normalized = normalizeWindowCounter({
now: Date.now(),
windowEndsAt: row.purchase_window_ends_at,
count: row.purchase_count,
windowConfig: configuredLimit,
from: row.purchase_window_ends_at,
});
return {
...config,
@@ -67,8 +75,8 @@ export const getCusAutoTopupPurchaseLimits = async ({
interval: configuredLimit?.interval ?? null,
interval_count: configuredLimit?.interval_count ?? null,
limit: configuredLimit?.limit ?? null,
count: row.purchase_count,
next_reset_at: row.purchase_window_ends_at,
count: normalized?.count ?? row.purchase_count,
next_reset_at: normalized?.windowEndsAt ?? row.purchase_window_ends_at,
},
};
});

View File

@@ -105,10 +105,12 @@ export const createProduct = async ({
free_trial: newFreeTrial,
};
await initProductInStripe({
ctx,
product: newFullProduct,
});
if (data.create_in_stripe !== false) {
await initProductInStripe({
ctx,
product: newFullProduct,
});
}
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,

View File

@@ -131,10 +131,12 @@ export const handleCreatePlan = createRoute({
free_trial: newFreeTrial,
};
await initProductInStripe({
ctx,
product: newFullProduct,
});
if (v1_2Body.create_in_stripe !== false) {
await initProductInStripe({
ctx,
product: newFullProduct,
});
}
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,

View File

@@ -98,7 +98,7 @@ export const handleVersionProductV2 = async ({
newItems: newProductV2.items,
features,
product: newProduct,
logger: console,
logger: ctx.logger,
isCustom: false,
newVersion: true,
});

View File

@@ -1,5 +1,6 @@
import {
type AppEnv,
copyStripeResourcesToMatchingPrice,
type Entitlement,
type Feature,
isFeatureItem,
@@ -10,6 +11,7 @@ import {
type ProductItem,
} from "@autumn/shared";
import type { DrizzleCli } from "@server/db/initDrizzle.js";
import type { Logger } from "@server/external/logtail/logtailUtils.js";
import { FeatureService } from "@server/internal/features/FeatureService.js";
import { EntitlementService } from "@server/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@server/internal/products/prices/PriceService.js";
@@ -76,7 +78,7 @@ const updateDbPricesAndEnts = async ({
ids: deletedEntIds,
});
} else {
const updateOrDelete: any = [];
const updateOrDelete: Promise<unknown>[] = [];
for (const ent of deletedEnts) {
const hasCustomPrice = customPrices.some(
(price) => price.entitlement_id === ent.id,
@@ -106,8 +108,7 @@ const updateDbPricesAndEnts = async ({
}
};
const handleCustomProductItems = async ({
db,
const handleCustomProductItems = ({
newPrices,
newEnts,
updatedPrices,
@@ -116,7 +117,6 @@ const handleCustomProductItems = async ({
sameEnts,
features,
}: {
db: DrizzleCli;
newPrices: Price[];
newEnts: Entitlement[];
updatedPrices: Price[];
@@ -124,17 +124,36 @@ const handleCustomProductItems = async ({
samePrices: Price[];
sameEnts: Entitlement[];
features: Feature[];
}) => ({
prices: [...newPrices, ...updatedPrices, ...samePrices],
entitlements: [...newEnts, ...updatedEnts, ...sameEnts].map((ent) => ({
...ent,
feature: features.find((f) => f.id === ent.feature_id),
})),
customPrices: [...newPrices, ...updatedPrices],
customEnts: [...newEnts, ...updatedEnts],
features,
});
const carryForwardStripeResources = ({
targetPrices,
targetEntitlements,
candidatePrices,
candidateEntitlements,
}: {
targetPrices: Price[];
targetEntitlements: Entitlement[];
candidatePrices: Price[];
candidateEntitlements: Entitlement[];
}) => {
return {
prices: [...newPrices, ...updatedPrices, ...samePrices],
entitlements: [...newEnts, ...updatedEnts, ...sameEnts].map((ent) => ({
...ent,
feature: features.find((f) => f.id === ent.feature_id),
})),
customPrices: [...newPrices, ...updatedPrices],
customEnts: [...newEnts, ...updatedEnts],
features,
};
for (const targetPrice of targetPrices) {
copyStripeResourcesToMatchingPrice({
targetPrice,
candidatePrices,
targetEntitlements,
candidateEntitlements,
});
}
};
export const handleNewProductItems = async ({
@@ -155,7 +174,7 @@ export const handleNewProductItems = async ({
newItems: ProductItem[];
features: Feature[];
product: Product;
logger: any;
logger: Logger;
isCustom: boolean;
newVersion?: boolean;
saveToDb?: boolean;
@@ -249,6 +268,13 @@ export const handleNewProductItems = async ({
}
}
carryForwardStripeResources({
targetPrices: [...newPrices, ...updatedPrices],
targetEntitlements: [...newEnts, ...updatedEnts, ...sameEnts],
candidatePrices: curPrices,
candidateEntitlements: curEnts,
});
const printLogs = false;
if (printLogs) {
logPrices({ prices: newPrices, prefix: "New prices" });
@@ -272,7 +298,6 @@ export const handleNewProductItems = async ({
if ((isCustom || newVersion) && saveToDb) {
return handleCustomProductItems({
db,
newPrices,
newEnts,
updatedPrices,

View File

@@ -23,6 +23,7 @@ import {
import type { DrizzleCli } from "@server/db/initDrizzle.js";
import { createStripeCli } from "@server/external/connect/createStripeCli.js";
import { createStripePriceIFNotExist } from "@server/external/stripe/createStripePrice/createStripePrice.js";
import { assertNoPreviewStripeIdsOnProduct } from "@server/external/stripe/previewStripeResourceIds.js";
import { getBillingType } from "@server/internal/products/prices/priceUtils.js";
import RecaseError from "@server/utils/errorUtils.js";
import { generateId, notNullish } from "@server/utils/genUtils.js";
@@ -244,6 +245,7 @@ export const checkStripeProductExists = async ({
logger: any;
}) => {
let createNew = false;
assertNoPreviewStripeIdsOnProduct({ product });
const stripeCli = createStripeCli({
org,
env,

View File

@@ -1,249 +0,0 @@
import {
customers,
ErrCode,
type RewardRedemption,
type RewardTriggerEvent,
referralCodes,
rewardPrograms,
rewardRedemptions,
rewards,
} from "@autumn/shared";
import { and, eq, or, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
export class RewardRedemptionService {
static async getById({ db, id }: { db: DrizzleCli; id: string }) {
const data = await db.query.rewardRedemptions.findFirst({
where: eq(rewardRedemptions.id, id),
});
if (!data) {
throw new RecaseError({
code: ErrCode.RewardRedemptionNotFound,
message: `Reward redemption ${id} not found`,
statusCode: 404,
});
}
return data;
}
static async getByCustomer({
db,
internalCustomerId,
triggered,
withReferralCode = false,
withRewardProgram = false,
internalRewardProgramId,
triggerWhen,
limit,
}: {
db: DrizzleCli;
internalCustomerId: string;
triggered?: boolean;
withReferralCode?: boolean;
withRewardProgram?: boolean;
internalRewardProgramId?: string;
triggerWhen?: RewardTriggerEvent;
limit?: number;
}) {
const data = await db.query.rewardRedemptions.findMany({
where: and(
eq(rewardRedemptions.internal_customer_id, internalCustomerId),
internalRewardProgramId
? eq(
rewardRedemptions.internal_reward_program_id,
internalRewardProgramId,
)
: undefined,
triggered ? eq(rewardRedemptions.triggered, triggered) : undefined,
),
with: {
reward_program: {
with: {
reward: true,
},
},
referral_code: true,
},
limit: limit ?? 100,
});
return data as any;
}
static async getByReferrer({
db,
internalCustomerId,
withCustomer = false,
limit = 100,
withRewardProgram = false,
}: {
db: DrizzleCli;
internalCustomerId: string;
withCustomer?: boolean;
limit?: number;
withRewardProgram?: boolean;
}) {
let query = db
.select()
.from(rewardRedemptions)
.innerJoin(
referralCodes,
eq(rewardRedemptions.referral_code_id, referralCodes.id),
)
.innerJoin(
customers,
sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${customers.internal_id}`,
);
if (withRewardProgram) {
query = query.innerJoin(
rewardPrograms,
eq(
rewardRedemptions.internal_reward_program_id,
rewardPrograms.internal_id,
),
);
}
const data = await query
.where(
sql`${referralCodes.internal_customer_id} COLLATE "C" = ${internalCustomerId}`,
)
.limit(limit);
const processed = data.map((d) => ({
...d.reward_redemptions,
referral_code: d.referral_codes,
customer: d.customers,
reward_program: withRewardProgram
? (d as any).reward_programs
: undefined,
}));
return processed;
}
static async insert({
db,
rewardRedemption,
}: {
db: DrizzleCli;
rewardRedemption: RewardRedemption;
}) {
const data = await db
.insert(rewardRedemptions)
.values(rewardRedemption)
.returning();
if (data.length === 0) {
throw new RecaseError({
code: ErrCode.InsertRewardRedemptionFailed,
message: `Failed to insert reward redemption`,
statusCode: 500,
});
}
return data[0] as RewardRedemption;
}
static async update({
db,
id,
updates,
}: {
db: DrizzleCli;
id: string;
updates: any;
}) {
const data = await db
.update(rewardRedemptions)
.set(updates)
.where(eq(rewardRedemptions.id, id))
.returning();
if (data.length === 0) {
throw new RecaseError({
code: "REWARD_REDEMPTION_NOT_FOUND",
message: `Reward redemption ${id} not found`,
});
}
return data[0] as RewardRedemption;
}
static async getUnappliedRedemptions({
db,
internalCustomerId,
}: {
db: DrizzleCli;
internalCustomerId: string;
}) {
const data = await db
.select()
.from(rewardRedemptions)
.innerJoin(
referralCodes,
eq(rewardRedemptions.referral_code_id, referralCodes.id),
)
.innerJoin(
customers,
sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${customers.internal_id}`,
)
.innerJoin(
rewardPrograms,
eq(
rewardRedemptions.internal_reward_program_id,
rewardPrograms.internal_id,
),
)
.innerJoin(
rewards,
eq(rewardPrograms.internal_reward_id, rewards.internal_id),
)
.where(
or(
and(
sql`${referralCodes.internal_customer_id} COLLATE "C" = ${internalCustomerId}`,
eq(rewardRedemptions.triggered, true),
eq(rewardRedemptions.applied, false),
),
and(
sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${internalCustomerId}`,
eq(rewardRedemptions.triggered, true),
eq(rewardRedemptions.redeemer_applied, false),
),
),
);
if (data.length === 0) return [];
const processed = data.map((d) => ({
...d.reward_redemptions,
referral_code: d.referral_codes,
reward_program: {
...d.reward_programs,
reward: d.rewards,
},
}));
return processed;
}
static async _resetCustomerRedemptions({
db,
internalCustomerId,
}: {
db: DrizzleCli;
internalCustomerId: string | string[];
}) {
if (!Array.isArray(internalCustomerId))
internalCustomerId = [internalCustomerId];
return await db
.delete(rewardRedemptions)
.where(
sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ANY(${internalCustomerId})`,
);
}
}

View File

@@ -0,0 +1,143 @@
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import {
buildRequestLogsQuery,
type HttpMethodFilter,
type StatusBucket,
} from "@/external/axiom/aplUtils.js";
import {
getAxiomClient,
isAxiomConfigured,
} from "@/external/axiom/initAxiom.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusService } from "@/internal/customers/CusService.js";
const ListRequestLogsSchema = z.object({
customer_id: z.string().min(1),
method: z.enum(["all", "GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
status: z.enum(["all", "2xx", "4xx", "5xx"]).optional(),
search: z.string().optional(),
});
export interface RequestLogEntry {
id: string;
time: string;
statusCode: number;
durationMs: number | null;
method: string | null;
url: string | null;
path: string | null;
reqId: string | null;
ip: string | null;
userAgent: string | null;
customerId: string | null;
msg: string | null;
raw: Record<string, unknown>;
}
const extractPath = (url: string | null | undefined): string | null => {
if (!url) return null;
try {
return new URL(url).pathname;
} catch {
return url;
}
};
const pickString = (
d: Record<string, unknown>,
keys: string[],
): string | null => {
for (const k of keys) {
const v = d[k];
if (typeof v === "string" && v.length > 0) return v;
}
return null;
};
const pickNumber = (
d: Record<string, unknown>,
keys: string[],
): number | null => {
for (const k of keys) {
const v = d[k];
if (typeof v === "number") return v;
}
return null;
};
export const handleListRequestLogs = createRoute({
scopes: [Scopes.Superuser],
body: ListRequestLogsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { org, env } = ctx;
const { customer_id, method, status, search } = c.req.valid("json");
const customer = await CusService.getFull({
ctx,
idOrInternalId: customer_id,
});
if (!customer) {
throw new RecaseError({
message: "Customer not found",
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
if (!isAxiomConfigured()) {
return c.json({ logs: [], unconfigured: true });
}
const apl = buildRequestLogsQuery({
orgSlug: org.slug,
env,
customerId: customer.id ?? customer_id,
method: method as HttpMethodFilter | undefined,
statusBucket: status as StatusBucket | undefined,
search,
});
try {
const axiom = getAxiomClient();
const result = await axiom.query(apl);
const matches = result.matches ?? [];
const logs: RequestLogEntry[] = matches.map((entry, i) => {
const raw = (entry.data ?? {}) as Record<string, unknown>;
const url = pickString(raw, ["req.url", "url"]);
return {
id: pickString(raw, ["req.id", "reqId"]) ?? `${entry._time}-${i}`,
time: entry._time,
statusCode: pickNumber(raw, ["statusCode"]) ?? 0,
durationMs: pickNumber(raw, ["durationMs"]),
method: pickString(raw, ["req.method", "method"]),
url,
path: extractPath(url),
reqId: pickString(raw, ["req.id", "reqId"]),
ip: pickString(raw, ["req.ip_address"]),
userAgent: pickString(raw, ["req.user_agent"]),
customerId: pickString(raw, [
"req.customer_id",
"customer_id",
"cusId",
]),
msg: pickString(raw, ["msg", "message"]),
raw,
};
});
return c.json({ logs });
} catch (err) {
ctx.logger?.error("Axiom workbench query failed", { err });
throw new RecaseError({
message: "Failed to query request logs",
code: ErrCode.InternalError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
},
});

View File

@@ -0,0 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleListRequestLogs } from "./handlers/handleListRequestLogs.js";
export const workbenchRouter = new Hono<HonoEnv>();
workbenchRouter.post("/requests", ...handleListRequestLogs);

View File

@@ -20,6 +20,7 @@ import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRo
import { savedViewsRouter } from "../internal/misc/savedViews/savedViewsRouter";
import { internalOrgRouter } from "../internal/orgs/orgRouter";
import { internalProductRouter } from "../internal/products/internalProductRouter";
import { workbenchRouter } from "../internal/workbench/workbenchRouter";
export const internalRouter = new Hono<HonoEnv>();
@@ -45,6 +46,7 @@ internalRouter.route("/trmnl", internalTrmnlRouter);
internalRouter.route("/feedback", feedbackRouter);
internalRouter.route("/saved_views", savedViewsRouter);
internalRouter.route("/query", internalAnalyticsRouter);
internalRouter.route("/workbench", workbenchRouter);
// Autumn SDK handler (requires session auth)
if (process.env.AUTUMN_SECRET_KEY) {

View File

@@ -11,6 +11,7 @@ export const initProductsV0 = async ({
skipPrefixIds = [],
customerId,
customerIds,
createInStripe,
}: {
ctx: TestContext;
products: ProductV2[];
@@ -18,6 +19,7 @@ export const initProductsV0 = async ({
skipPrefixIds?: string[];
customerId?: string;
customerIds?: string[];
createInStripe?: boolean;
}) => {
// 1. Add prefix to products (except those in skipPrefixIds)
if (prefix) {
@@ -55,5 +57,6 @@ export const initProductsV0 = async ({
env: ctx.env,
autumn: autumn,
products,
createInStripe,
});
};

View File

@@ -0,0 +1,159 @@
/**
* TDD test for lock + finalize on unlimited balance features.
*
* Contract under test:
* New behaviors:
* - check with lock=true on unlimited feature → allowed=true, lock receipt saved
* - finalize confirm on unlimited lock → success, receipt deleted
* - finalize release on unlimited lock → success, receipt deleted
* Side effects:
* - Lock receipt is saved to Redis with empty items for unlimited features
* - Lock receipt is cleaned up after finalize
*
* Pre-fix red: check with lock on unlimited skips lock receipt save entirely,
* so finalize throws "Lock not found for ID: ...".
* Post-fix green: empty lock receipt is saved, finalize finds and deletes it.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV5 } from "@autumn/shared";
import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { timeout } from "@/utils/genUtils";
const makeUnlimitedProd = () =>
products.base({
id: "unlimited",
items: [items.unlimitedMessages()],
});
// ── Contract assertion 1: check with lock on unlimited → allowed, finalize confirm → success ──
test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimited feature, finalize confirm succeeds")}`, async () => {
const unlimitedProd = makeUnlimitedProd();
const customerId = "lock-unlimited-confirm";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [unlimitedProd] }),
],
actions: [s.attach({ productId: unlimitedProd.id })],
});
await deleteLock({ ctx, lockId: lockKey });
const checkResponse = await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 10,
lock: { enabled: true, lock_id: lockKey },
});
expect(checkResponse.allowed).toBe(true);
const finalizeResponse = await autumnV2_1.balances.finalize({
lock_id: lockKey,
action: "confirm",
});
expect(finalizeResponse.success).toBe(true);
await expectLockReceiptDeleted({ ctx, lockId: lockKey });
await expectCustomerEventsCorrect({
customerId,
events: [{ value: 10 }],
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(customer).toBeDefined();
});
// ── Contract assertion 2: check with lock on unlimited → allowed, finalize release → success ──
test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimited feature, finalize release succeeds")}`, async () => {
const unlimitedProd = makeUnlimitedProd();
const customerId = "lock-unlimited-release";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [unlimitedProd] }),
],
actions: [s.attach({ productId: unlimitedProd.id })],
});
await deleteLock({ ctx, lockId: lockKey });
const checkResponse = await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 5,
lock: { enabled: true, lock_id: lockKey },
});
expect(checkResponse.allowed).toBe(true);
const finalizeResponse = await autumnV2_1.balances.finalize({
lock_id: lockKey,
action: "release",
});
expect(finalizeResponse.success).toBe(true);
await timeout(4000);
await expectLockReceiptDeleted({ ctx, lockId: lockKey });
await expectCustomerEventsCorrect({
customerId,
events: [{ value: -5 }, { value: 5 }],
});
});
// ── Contract assertion 3: check with lock=0 on unlimited, finalize confirm → success ──
test.concurrent(`${chalk.yellowBright("lock-unlimited: lock=0 on unlimited feature, finalize confirm succeeds")}`, async () => {
const unlimitedProd = makeUnlimitedProd();
const customerId = "lock-unlimited-zero";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [unlimitedProd] }),
],
actions: [s.attach({ productId: unlimitedProd.id })],
});
await deleteLock({ ctx, lockId: lockKey });
const checkResponse = await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 0,
lock: { enabled: true, lock_id: lockKey },
});
expect(checkResponse.allowed).toBe(true);
const finalizeResponse = await autumnV2_1.balances.finalize({
lock_id: lockKey,
action: "confirm",
});
expect(finalizeResponse.success).toBe(true);
await expectLockReceiptDeleted({ ctx, lockId: lockKey });
await expectCustomerEventsCorrect({
customerId,
events: [{ value: 0 }],
});
});

View File

@@ -0,0 +1,391 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
ApiEventsListResponse,
TrackDeduction,
TrackResponseV3,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import { timeout } from "@tests/utils/genUtils.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
const findDeductionByFeature = (
deductions: TrackDeduction[] | undefined,
featureId: string,
): TrackDeduction | undefined =>
deductions?.find((deduction) => deduction.feature_id === featureId);
// ═══════════════════════════════════════════════════════════════════
// A: Track within a feature's own allowance — only the main balance
// is touched. Linked credit systems serve as overflow only and
// stay untouched while allowance remains.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-A: within-allowance track surfaces a single deduction against the main balance")}`,
async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 200,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-deductions-a",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 10,
});
expect(trackRes.deductions).toBeDefined();
expect(trackRes.deductions).toHaveLength(1);
const action1Deduction = findDeductionByFeature(
trackRes.deductions,
TestFeature.Action1,
);
expect(action1Deduction).toBeDefined();
expect(action1Deduction?.value).toBe(10);
expect(
findDeductionByFeature(trackRes.deductions, TestFeature.Credits),
).toBeUndefined();
},
);
// ═══════════════════════════════════════════════════════════════════
// B: event_name fans out to two features; each within its own
// allowance → two deductions, no credit-system deductions.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-B: event_name across two features surfaces a deduction per touched balance")}`,
async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 80,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 150,
});
const action3Item = items.free({
featureId: TestFeature.Action3,
includedUsage: 60,
});
const credits2Item = items.free({
featureId: TestFeature.Credits2,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem, action3Item, credits2Item],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-deductions-b",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
event_name: "action-event",
value: 5,
});
expect(trackRes.deductions).toBeDefined();
expect(trackRes.deductions).toHaveLength(2);
const featureIds = (trackRes.deductions ?? [])
.map((deduction) => deduction.feature_id)
.sort();
expect(featureIds).toEqual(
[TestFeature.Action1, TestFeature.Action3].sort(),
);
expect(
findDeductionByFeature(trackRes.deductions, TestFeature.Action1)?.value,
).toBe(5);
expect(
findDeductionByFeature(trackRes.deductions, TestFeature.Action3)?.value,
).toBe(5);
},
);
// ═══════════════════════════════════════════════════════════════════
// C: Single-feature track, no credit system.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-C: feature with no credit systems surfaces a single deduction")}`,
async () => {
const messagesItem = items.free({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-deductions-c",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 7,
});
expect(trackRes.deductions).toBeDefined();
expect(trackRes.deductions).toHaveLength(1);
expect(trackRes.deductions?.[0].feature_id).toBe(TestFeature.Messages);
expect(trackRes.deductions?.[0].value).toBe(7);
},
);
// ═══════════════════════════════════════════════════════════════════
// D: A negative-value track emits a deduction with a negative value
// (refund / restore).
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-D: negative track value yields a negative-value deduction")}`,
async () => {
const messagesItem = items.free({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-deductions-d",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [
s.attach({ productId: freeProd.id }),
s.track({ featureId: TestFeature.Messages, value: 10 }),
],
});
const refundRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: -4,
});
expect(refundRes.deductions).toBeDefined();
expect(refundRes.deductions).toHaveLength(1);
expect(refundRes.deductions?.[0].feature_id).toBe(TestFeature.Messages);
expect(refundRes.deductions?.[0].value).toBe(-4);
},
);
// ═══════════════════════════════════════════════════════════════════
// E: A linked credit-system feature exists in the org but the customer
// has no entitlement to it — no deduction emitted for that feature.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-E: missing entitlement on credit system is omitted from deductions")}`,
async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [action1Item],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-deductions-e",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 5,
});
expect(trackRes.deductions).toBeDefined();
expect(trackRes.deductions).toHaveLength(1);
expect(trackRes.deductions?.[0].feature_id).toBe(TestFeature.Action1);
expect(trackRes.deductions?.[0].value).toBe(5);
expect(
findDeductionByFeature(trackRes.deductions, TestFeature.Credits),
).toBeUndefined();
},
);
// ═══════════════════════════════════════════════════════════════════
// F: Overflow into a linked credit system. This is the load-bearing
// scenario for the feature — a single track event depletes BOTH
// the main balance AND the credit-system balance, and the response
// surfaces both via `deductions`.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-F: track that overflows the main balance surfaces credit-system deductions too")}`,
async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 200,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem],
});
const { customerId, autumnV2_2, autumnV1, ctx } = await initScenario({
customerId: "track-deductions-f",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const creditFeature = ctx.features.find(
(f) => f.id === TestFeature.Credits,
);
expect(creditFeature).toBeDefined();
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.Action1].balance).toBe(100);
expect(customerBefore.features[TestFeature.Credits].balance).toBe(200);
// Drain Action1 down to 0 with a first track so the next event
// has to spill into Credits.
await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 100,
});
const overflowAmount = 50;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAmount,
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: overflowAmount,
});
expect(trackRes.deductions).toBeDefined();
expect(trackRes.deductions).toHaveLength(1);
// Action1 is empty before the overflow event, so the whole 50
// flows through the credit system and `deductions` has exactly
// the Credits row.
const creditsDeduction = findDeductionByFeature(
trackRes.deductions,
TestFeature.Credits,
);
expect(creditsDeduction).toBeDefined();
expect(
new Decimal(creditsDeduction?.value ?? 0)
.minus(expectedCreditCost)
.abs()
.lessThan(1e-9),
).toBe(true);
},
);
// ═══════════════════════════════════════════════════════════════════
// G: End-to-end Tinybird round-trip. Track an event, wait for the
// batch flush + Tinybird ingest, then read it back via events.list
// and confirm the `deductions` field round-trips through the column.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-deductions-G: deductions round-trip via events.list (writes Tinybird, reads it back)")}`,
async () => {
const messagesItem = items.free({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, autumnV2_2 } = await initScenario({
customerId: "track-deductions-g",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 12,
});
// Wait for the EventBatchingManager flush (350ms window) plus Tinybird
// ingest propagation. Existing credit-system tests use ~2s for cache
// sync; ~3s gives Tinybird a bit more slack.
await timeout(3000);
const eventsList = (await autumnV1.events.list({
customer_id: customerId,
})) as ApiEventsListResponse;
expect(eventsList.list.length).toBeGreaterThan(0);
const trackedEvent = eventsList.list.find(
(event) =>
event.feature_id === TestFeature.Messages && event.value === 12,
);
expect(trackedEvent).toBeDefined();
expect(trackedEvent?.deductions).toBeDefined();
expect(trackedEvent?.deductions).not.toBeNull();
expect(trackedEvent?.deductions).toHaveLength(1);
expect(trackedEvent?.deductions?.[0].feature_id).toBe(TestFeature.Messages);
expect(trackedEvent?.deductions?.[0].value).toBe(12);
},
);

View File

@@ -9,7 +9,7 @@
* - Scheduled downgrade is preserved
*/
import { test } from "bun:test";
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import {
@@ -23,6 +23,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 { CusService } from "@/internal/customers/CusService";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
const waitForMigration = (ms = 20000) =>
new Promise((resolve) => setTimeout(resolve, ms));
@@ -217,3 +219,85 @@ test.concurrent(`${chalk.yellowBright("migrate-states-2: scheduled downgrade pre
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Half-cancelled state (canceled=true, ended_at=null) blocks migration
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Regression for the May 5 incident: 13 Micro customers got silently
* uncancelled when a v4 product migration ran against rows in the half-state
* (canceled=true but ended_at=null). The schedule rebuilder treated the row
* as "no future cancellation" and POSTed cancel_at:null to Stripe.
*
* Migration must hard-fail on the half-state instead of touching Stripe.
*/
test.concurrent(`${chalk.yellowBright("migrate-states-3: half-cancelled state hard-blocks migration")}`, async () => {
const customerId = "migrate-states-half-cancelled";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: "pro", timeout: 10000 }),
s.updateSubscription({
productId: "pro",
cancelAction: "cancel_end_of_cycle",
}),
],
});
const fullCusBefore = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const proCusProduct = fullCusBefore.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(proCusProduct).toBeDefined();
expect(proCusProduct!.subscription_ids?.length).toBeGreaterThan(0);
const subId = proCusProduct!.subscription_ids![0];
const subBefore = await ctx.stripeCli.subscriptions.retrieve(subId);
expect(subBefore.cancel_at).toBeTruthy();
const stripeCancelAtBefore = subBefore.cancel_at!;
// Inject the production half-state.
await CusProductService.update({
ctx,
cusProductId: proCusProduct!.id,
updates: { ended_at: null },
});
const monthlyPrice = items.monthlyPrice({ price: 20 });
const v2Items = [monthlyPrice, items.monthlyMessages({ includedUsage: 600 })];
await autumnV1.products.update(pro.id, { items: v2Items });
await autumnV1.migrate({
from_product_id: pro.id,
to_product_id: pro.id,
from_version: 1,
to_version: 2,
});
await waitForMigration();
const subAfter = await ctx.stripeCli.subscriptions.retrieve(subId);
expect(subAfter.cancel_at).toBe(stripeCancelAtBefore);
const fullCusAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const v1Active = fullCusAfter.customer_products.find(
(cp) => cp.product.version === 1 && cp.status === "active",
);
expect(v1Active).toBeDefined();
});

View File

@@ -0,0 +1,209 @@
/**
* Preview billing actions must not create Stripe resources.
*
* Contract under test:
* - A plan created with create_in_stripe=false stays Stripe-less after
* these preview endpoints run:
* * POST /billing.preview_attach
* * POST /billing.preview_create_schedule (multi-phase)
* * POST /billing.preview_update (with customize)
* - No is_custom prices with Stripe IDs are persisted by customize
* previews.
* - Item coverage: monthlyMessages (metered), prepaidUsers,
* consumableWords, allocatedWorkflows.
*
* Implementation surface:
* server/src/internal/billing/v2/providers/stripe/utils/common/
* initStripeResourcesForProducts.ts — early returns via
* applyPreviewStripeResourcesToBillingPlan when dryRunStripe=true.
*/
import { expect, test } from "bun:test";
import type {
AttachPreviewResponse,
CreateScheduleParamsV0Input,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import {
expectNoCustomStripePrices,
expectNoStripeResources,
} from "@tests/integration/billing/misc/utils/expectNoStripeResources";
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 { ProductService } from "@/internal/products/ProductService";
const buildItems = () => [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: previewAttach against a plan created with create_in_stripe=false
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"previewAttach on no-stripe plan: preview totals correct, no Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-attach";
const proPlan = products.pro({
id: "pro-no-stripe-attach",
items: buildItems(),
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan], createInStripe: false }),
],
actions: [],
});
// s.products mutates proPlan.id to include the `_${customerId}` prefix.
const preview = (await autumnV2_2.billing.previewAttach({
customer_id: customerId,
plan_id: proPlan.id,
})) as AttachPreviewResponse;
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(20);
expect(preview.currency.toLowerCase()).toBe("usd");
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: proPlan.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: preview_create_schedule with multiple phases on no-stripe plans
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"preview_create_schedule multi-phase on no-stripe plans: preview works, no Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-schedule";
const proPlan = products.pro({
id: "pro-no-stripe-schedule",
items: buildItems(),
});
const premiumPlan = products.premium({
id: "premium-no-stripe-schedule",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, advancedTo, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proPlan, premiumPlan], createInStripe: false }),
],
actions: [],
});
const params: CreateScheduleParamsV0Input = {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
},
},
],
},
{
starts_at: advancedTo + 30 * 24 * 60 * 60 * 1000,
plans: [{ plan_id: premiumPlan.id }],
},
],
};
const preview = (await autumnV1.post(
"/billing.preview_create_schedule",
params,
)) as AttachPreviewResponse;
expect(preview.total).toBe(35);
expect(preview.subtotal).toBe(35);
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: proPlan.id,
});
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: premiumPlan.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: previewUpdate with customize on an active no-stripe sub. The customer
// is already on a no-stripe plan attached via /billing.preview_attach +
// /billing.preview_update is impossible — instead we attach the no-stripe plan
// via attach which would normally create Stripe resources. To avoid that, we
// rely on the contract that any sub created via real attach on a no-stripe
// plan still preview-cleanly. We attach a stripe-backed plan A so the customer
// has an active sub, then previewUpdate with customize.items shapes drawn from
// every payable category; the assertion is that no is_custom prices got
// persisted with Stripe IDs by the preview.
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"previewUpdate customize on active sub: no is_custom prices with Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-update";
const stripeBackedPlan = products.pro({
id: "pro-with-stripe-update",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [stripeBackedPlan] }),
],
actions: [s.billing.attach({ productId: stripeBackedPlan.id })],
});
const fullBefore = await ProductService.getFull({
db: ctx.db,
idOrInternalId: stripeBackedPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
plan_id: stripeBackedPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
},
};
const preview = await autumnV2_2.subscriptions.previewUpdate(params);
expect(typeof preview.total).toBe("number");
await expectNoCustomStripePrices({
db: ctx.db,
internalProductId: fullBefore.internal_id,
});
});

View File

@@ -0,0 +1,607 @@
/**
* Custom plans reuse the base plan's Stripe resources where possible.
*
* When a customer attaches a plan with `customize.items` (or `customize.price`),
* a new `is_custom: true` price row is written for each affected price. The
* carry-forward path in handleNewProductItems.carryForwardStripeResources
* (which calls copyStripeResourcesToMatchingPrice) MUST populate the new row's
* `stripe_*_id` fields from the matching catalog price so we don't mint
* duplicate Stripe Price objects.
*
* Contract under test:
* - Adding an unrelated boolean entitlement (dashboard) keeps every existing
* price's Stripe IDs intact.
* - Same for the paid feature shapes prepaid / consumable / allocated.
* - Negative: swapping prepaid → consumable on the same feature does NOT
* reuse stripe_price_id (different price.config.type / billing_method).
* - Negative: changing the price amount on a prepaid item does NOT reuse
* stripe_price_id (config differs → pricesAreSame=false → reuse level
* drops below "full").
* - Negative: changing tier amounts on a tiered prepaid item does NOT reuse
* stripe_price_id.
*
* Implementation surface:
* server/src/internal/products/product-items/productItemUtils/
* handleNewProductItems.ts — calls carryForwardStripeResources before
* persisting new prices.
* shared/utils/productUtils/priceUtils/match/
* copyStripeResourcesToMatchingPrice.ts + getPriceStripeReuseLevel.ts —
* the actual matching + copy logic.
* shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts —
* boolean predicate used by the test helpers.
*/
import { test } from "bun:test";
import {
type AttachParamsV1Input,
BillingInterval,
BillingMethod,
OnDecrease,
OnIncrease,
RolloverExpiryDurationType,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import {
expectAllStripeIdsReused,
expectStripePriceIdNotReused,
loadCustomerAndCatalogPrices,
} from "@tests/integration/billing/misc/utils/findCatalogAndCustomPrices";
import { TestFeature } from "@tests/setup/v2Features";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: custom plan adds a boolean entitlement, base price reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: add boolean entitlement → base price Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-boolean";
const proPlan = products.pro({
id: "pro-reuse-boolean",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: custom plan keeps prepaid/consumable/allocated items → all reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: paid feature shapes unchanged → all Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-paid";
const proPlan = products.pro({
id: "pro-reuse-paid",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.prepaidUsers({ amount: 10, billingUnits: 1 }),
itemsV2.consumableWords({ amount: 0.05 }),
itemsV2.allocatedWorkflows({ amount: 10 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3 (negative): swap prepaid → consumable on same feature → no reuse
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid → consumable on same feature → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-prepaid-to-consumable";
const proPlan = products.pro({
id: "pro-reuse-prepaid-to-consumable",
items: [items.prepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.consumableMessages({ amount: 0.5 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs, catalogPrices, customerPrices } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({
pairs,
featureId: TestFeature.Messages,
catalogPrices,
customerPrices,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4 (negative): change prepaid price amount → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid amount change → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-prepaid-amount";
const proPlan = products.pro({
id: "pro-reuse-prepaid-amount",
items: [items.prepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.prepaidMessages({ amount: 25, billingUnits: 100 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5 (negative): change tier amounts on tiered prepaid → not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: tier amount change → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-tier";
const proPlan = products.pro({
id: "pro-reuse-tier",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.tieredPrepaidMessages({
tiers: [
{ to: 600, amount: 20 },
{ to: TierInfinite, amount: 10 },
],
}),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6 (negative): graduated → volume tier_behavior → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: graduated → volume tier_behavior → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-tier-behavior";
const proPlan = products.pro({
id: "pro-reuse-tier-behavior",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 0,
price: {
tiers: [
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7 (negative): add flat_amount to a tier → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: add flat_amount to tier → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-flat-amount";
const proPlan = products.pro({
id: "pro-reuse-flat-amount",
items: [items.volumePrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 0,
price: {
tiers: [
{ to: 500, amount: 10, flat_amount: 100 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8 (negative): change proration_config on allocated → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change proration_config on allocated → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-proration";
const proPlan = products.pro({
id: "pro-reuse-proration",
items: [items.allocatedWorkflows({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Workflows,
included: 0,
price: {
amount: 10,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Workflows });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 9 (negative): change billing_units → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change prepaid billing_units → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-billing-units";
const proPlan = products.pro({
id: "pro-reuse-billing-units",
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 50 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 10 (positive): change rollover config on ent (base price unaffected)
// Rollover lives on the entitlement. monthlyMessagesWithRollover has no price,
// so the only paid line on this plan is the $20 base — which has no paired ent
// and thus is unaffected by ent rollover diffs. Asserts the base price still
// reuses all Stripe IDs across the customize.
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change rollover config → base price Stripe IDs still reused")}`, async () => {
const customerId = "reuse-custom-rollover";
const proPlan = products.pro({
id: "pro-reuse-rollover",
items: [
items.monthlyMessagesWithRollover({
includedUsage: 200,
rolloverConfig: {
max: 100,
length: 0,
duration: RolloverExpiryDurationType.Forever,
},
}),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 200,
rollover: {
max: 500,
expiry_duration_type: RolloverExpiryDurationType.Forever,
expiry_duration_length: 0,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 11 (positive): prepaid + consumable pair on same feature → both reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid + consumable pair on same feature → both Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-pair";
const proPlan = products.pro({
id: "pro-reuse-pair",
items: [
items.prepaidMessages({ includedUsage: 0, billingUnits: 100 }),
items.consumableMessages({ includedUsage: 0, price: 0.5 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }),
itemsV2.consumableMessages({ amount: 0.5 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});

View File

@@ -0,0 +1,270 @@
/**
* Plan versioning reuses the previous version's Stripe resources where possible.
*
* When a plan with existing customers is updated with a new items list, the
* V1 update handler (handleUpdatePlanV1) auto-creates a new product version via
* handleVersionProductV2. That path calls handleNewProductItems with
* { curPrices: latestProduct.prices, newVersion: true }. The carry-forward
* inside handleNewProductItems must copy the previous version's
* `stripe_*_id` fields onto each new-version price whose config still matches.
*
* Contract under test:
* - Versioning a plan with the same paid items (just adding a boolean entitlement)
* keeps every paid price's Stripe IDs intact on the new version.
* - Same for paid feature shapes (prepaid / consumable / allocated).
* - Negative: versioning with a changed item (price amount, tier behavior,
* billing_units) does NOT reuse stripe_price_id for that item.
*
* Implementation surface:
* server/src/internal/products/handlers/handleVersionProduct.ts — versioning entry.
* server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts — triggers versioning when customers exist + items differ.
* server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts — calls carryForwardStripeResources.
*/
import { expect, test } from "bun:test";
import {
type ApiPlanItemV1,
type CreatePlanItemParamsV1,
BillingInterval,
BillingMethod,
type Price,
priceStripeObjectsMatch,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
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 { ProductService } from "@/internal/products/ProductService";
const collectStripeIdsByFeatureKey = (
prices: Price[],
): Map<string, Record<string, string | null>> => {
const map = new Map<string, Record<string, string | null>>();
for (const price of prices) {
const config = price.config as Record<string, unknown>;
const featureId = (config.feature_id as string | undefined) ?? "__fixed__";
const billWhen = (config.bill_when as string | undefined) ?? "__none__";
const key = `${featureId}|${billWhen}`;
map.set(key, {
stripe_product_id: (config.stripe_product_id as string | null) ?? null,
stripe_price_id: (config.stripe_price_id as string | null) ?? null,
stripe_empty_price_id:
(config.stripe_empty_price_id as string | null) ?? null,
stripe_meter_id: (config.stripe_meter_id as string | null) ?? null,
stripe_prepaid_price_v2_id:
(config.stripe_prepaid_price_v2_id as string | null) ?? null,
stripe_placeholder_price_id:
(config.stripe_placeholder_price_id as string | null) ?? null,
});
}
return map;
};
const findPriceForFeature = (
prices: Price[],
featureId: string,
): Price | undefined =>
prices.find(
(price) =>
(price.config as Record<string, unknown>).feature_id === featureId,
);
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: version a plan with same paid items + new boolean → all reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: add boolean entitlement → all paid Stripe IDs reused on new version")}`, async () => {
const customerId = "reuse-version-add-bool";
const proPlan = products.pro({
id: "pro-version-add-bool",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeIds = collectStripeIdsByFeatureKey(beforeProduct.prices);
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
items.dashboard(),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterIds = collectStripeIdsByFeatureKey(afterProduct.prices);
for (const [key, before] of beforeIds.entries()) {
const after = afterIds.get(key);
expect(after).toBeDefined();
if (!after) continue;
expect(before.stripe_price_id).not.toBeNull();
expect(after.stripe_product_id).toBe(before.stripe_product_id);
expect(after.stripe_price_id).toBe(before.stripe_price_id);
expect(after.stripe_empty_price_id).toBe(before.stripe_empty_price_id);
expect(after.stripe_meter_id).toBe(before.stripe_meter_id);
expect(after.stripe_prepaid_price_v2_id).toBe(
before.stripe_prepaid_price_v2_id,
);
expect(after.stripe_placeholder_price_id).toBe(
before.stripe_placeholder_price_id,
);
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2 (negative): versioning with prepaid amount change → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: prepaid amount change → stripe_price_id NOT reused on new version")}`, async () => {
const customerId = "reuse-version-amount-change";
const proPlan = products.pro({
id: "pro-version-amount-change",
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeMessages = findPriceForFeature(
beforeProduct.prices,
TestFeature.Messages,
);
expect(beforeMessages).toBeDefined();
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 25 }),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterMessages = findPriceForFeature(
afterProduct.prices,
TestFeature.Messages,
);
expect(afterMessages).toBeDefined();
if (!afterMessages || !beforeMessages) return;
const beforeConfig = beforeMessages.config as Record<string, unknown>;
const afterConfig = afterMessages.config as Record<string, unknown>;
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3 (negative): versioning with tier_behavior change → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: graduated → volume tier_behavior → stripe_price_id NOT reused on new version")}`, async () => {
const customerId = "reuse-version-tier-behavior";
const proPlan = products.pro({
id: "pro-version-tier-behavior",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeMessages = findPriceForFeature(
beforeProduct.prices,
TestFeature.Messages,
);
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.volumePrepaidMessages({ includedUsage: 0 }),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterMessages = findPriceForFeature(
afterProduct.prices,
TestFeature.Messages,
);
expect(afterMessages).toBeDefined();
if (!afterMessages || !beforeMessages) return;
const beforeConfig = beforeMessages.config as Record<string, unknown>;
const afterConfig = afterMessages.config as Record<string, unknown>;
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
});

View File

@@ -0,0 +1,70 @@
import { expect } from "bun:test";
import { type AppEnv, type Price, prices } from "@autumn/shared";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
import { ProductService } from "@/internal/products/ProductService";
const stripeIdFields = [
"stripe_price_id",
"stripe_product_id",
"stripe_empty_price_id",
"stripe_meter_id",
"stripe_prepaid_price_v2_id",
] as const;
const expectPriceHasNoStripeIds = (price: Price) => {
const config = price.config as Record<string, unknown>;
for (const field of stripeIdFields) {
expect(config[field] ?? null).toBeNull();
}
};
export const expectNoStripeResources = async ({
db,
orgId,
env,
productId,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
productId: string;
}) => {
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
});
expect(fullProduct.processor?.id ?? null).toBeNull();
for (const price of fullProduct.prices) {
expectPriceHasNoStripeIds(price);
}
const allPrices = (await db.query.prices.findMany({
where: eq(prices.internal_product_id, fullProduct.internal_id),
})) as Price[];
for (const price of allPrices) {
expectPriceHasNoStripeIds(price);
}
};
export const expectNoCustomStripePrices = async ({
db,
internalProductId,
}: {
db: DrizzleCli;
internalProductId: string;
}) => {
const customPrices = (await db.query.prices.findMany({
where: eq(prices.internal_product_id, internalProductId),
})) as Price[];
for (const price of customPrices) {
if (!price.is_custom) continue;
expectPriceHasNoStripeIds(price);
}
};

View File

@@ -0,0 +1,181 @@
import { expect } from "bun:test";
import {
type AppEnv,
type FullCusProduct,
type Price,
type UsagePriceConfig,
diffPriceStripeObjects,
isFixedPrice,
priceStripeObjectsMatch,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
const priceFeatureId = (price: Price): string | null => {
if (isFixedPrice(price)) return null;
const config = price.config as UsagePriceConfig;
return config.feature_id ?? null;
};
const priceMatchKey = (price: Price): string => {
if (isFixedPrice(price)) return "__fixed__";
const featureId = priceFeatureId(price);
const config = price.config as UsagePriceConfig;
const billWhen = config.bill_when ?? "<missing>";
return `feature:${featureId ?? "<missing>"}|bill_when:${billWhen}`;
};
/**
* Attach a customer's primary FullCusProduct (custom or otherwise) to the
* matching catalog plan's prices via feature_id (or "fixed" for base prices).
* Returns matched pairs and the catalog plan for further assertions.
*/
export const loadCustomerAndCatalogPrices = async ({
ctx,
customerId,
catalogProductId,
}: {
ctx: AutumnContext;
customerId: string;
catalogProductId: string;
}): Promise<{
catalogPrices: Price[];
customerPrices: Price[];
pairs: { catalog: Price; customer: Price }[];
cusProduct: FullCusProduct;
}> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProduct = fullCustomer.customer_products[0];
if (!cusProduct) {
throw new Error(`Customer ${customerId} has no customer_products`);
}
const fullCatalog = await ProductService.getFull({
db: ctx.db,
idOrInternalId: catalogProductId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerPrices = cusProduct.customer_prices.map(
(customerPrice) => customerPrice.price,
);
const pairs: { catalog: Price; customer: Price }[] = [];
for (const customerPrice of customerPrices) {
const key = priceMatchKey(customerPrice);
const catalogMatch = fullCatalog.prices.find(
(price) => priceMatchKey(price) === key,
);
if (!catalogMatch) continue;
pairs.push({ catalog: catalogMatch, customer: customerPrice });
}
return {
catalogPrices: fullCatalog.prices,
customerPrices,
pairs,
cusProduct,
};
};
const formatDiff = (catalog: Price, customer: Price): string => {
const diffs = diffPriceStripeObjects({
priceA: catalog,
priceB: customer,
});
return diffs
.map((diff) => `${diff.field}: catalog=${diff.a ?? "null"}, customer=${diff.b ?? "null"}`)
.join("\n ");
};
/**
* Assert every (catalog, customer) price pair shares all Stripe-object IDs.
* Each catalog price must also have a non-null stripe_price_id so the
* assertion is meaningful (verifies real reuse, not "both empty").
*/
export const expectAllStripeIdsReused = ({
pairs,
}: {
pairs: { catalog: Price; customer: Price }[];
}) => {
expect(pairs.length).toBeGreaterThan(0);
for (const { catalog, customer } of pairs) {
const catalogConfig = catalog.config as Record<string, unknown>;
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
const matches = priceStripeObjectsMatch({
priceA: catalog,
priceB: customer,
});
if (!matches) {
throw new Error(
`Expected stripe-object reuse for ${priceMatchKey(catalog)} but got diffs:\n ${formatDiff(catalog, customer)}`,
);
}
}
};
/**
* Assert that the customer price keyed by `featureId` (or fixed base when
* `featureId` is null) does NOT reuse stripe_price_id from the catalog.
* Both prices must have non-null stripe_price_id values for the assertion
* to be meaningful. Falls back to feature-id-only matching when the strict
* (feature + bill_when) pairing misses (e.g. prepaid → consumable swap).
*/
export const expectStripePriceIdNotReused = ({
pairs,
featureId,
catalogPrices,
customerPrices,
}: {
pairs: { catalog: Price; customer: Price }[];
featureId: string | null;
catalogPrices?: Price[];
customerPrices?: Price[];
}) => {
let catalogPrice: Price | undefined;
let customerPrice: Price | undefined;
if (featureId === null) {
const pair = pairs.find(({ catalog }) => isFixedPrice(catalog));
catalogPrice = pair?.catalog;
customerPrice = pair?.customer;
} else {
const pair = pairs.find(
({ catalog }) => priceFeatureId(catalog) === featureId,
);
if (pair) {
catalogPrice = pair.catalog;
customerPrice = pair.customer;
} else if (catalogPrices && customerPrices) {
catalogPrice = catalogPrices.find(
(price) => priceFeatureId(price) === featureId,
);
customerPrice = customerPrices.find(
(price) => priceFeatureId(price) === featureId,
);
}
}
expect(catalogPrice).toBeDefined();
expect(customerPrice).toBeDefined();
if (!catalogPrice || !customerPrice) return;
const catalogConfig = catalogPrice.config as Record<string, unknown>;
const customerConfig = customerPrice.config as Record<string, unknown>;
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
expect(customerConfig.stripe_price_id ?? null).not.toBeNull();
expect(customerConfig.stripe_price_id).not.toBe(
catalogConfig.stripe_price_id,
);
};
export { priceMatchKey };
// Re-export for callers
export type { DrizzleCli, AppEnv };

View File

@@ -1,8 +1,10 @@
import { expect, test } from "bun:test";
import {
addInterval,
type ApiCustomerV5,
type CustomerBillingControls,
CustomerExpand,
EntInterval,
PurchaseLimitInterval,
} from "@autumn/shared";
import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig";
@@ -13,7 +15,9 @@ import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos";
import { CusService } from "@/internal/customers/CusService.js";
import { generateId } from "@/utils/genUtils.js";
const AUTO_TOPUP_WAIT_MS = 20000;
@@ -648,3 +652,70 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.pu
});
expect(typeof phaseCPurchaseLimit?.next_reset_at).toBe("number");
});
test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.purchase_limit expand projects next_reset_at forward when stored window is stale")}`, async () => {
const customerId = "customer-billing-controls-13";
const { autumnV2_1, autumnV2_2, ctx, customer } = await initScenario({
customerId,
setup: [s.customer({ testClock: false })],
actions: [],
});
await autumnV2_2.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 50,
quantity: 100,
purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 4 },
}),
});
const staleWindowEndsAt = Date.now() - 6 * 24 * 60 * 60 * 1000;
const now = Date.now();
await autoTopupLimitRepo.insert({
ctx,
data: {
id: generateId("atlim"),
internal_customer_id: customer.internal_id,
customer_id: customerId,
feature_id: TestFeature.Messages,
purchase_window_ends_at: staleWindowEndsAt,
purchase_count: 3,
attempt_window_ends_at: now,
attempt_count: 0,
failed_attempt_window_ends_at: now,
failed_attempt_count: 0,
updated_at: now,
},
});
const expanded = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
expand: [CustomerExpand.AutoTopupsPurchaseLimit],
skip_cache: "true",
});
const purchaseLimit = expanded.billing_controls?.auto_topups?.[0]
?.purchase_limit as
| {
interval: PurchaseLimitInterval | null;
interval_count: number | null;
limit: number | null;
count: number;
next_reset_at: number;
}
| undefined;
expect(purchaseLimit).toMatchObject({
interval: PurchaseLimitInterval.Month,
interval_count: 1,
limit: 4,
count: 0,
});
expect(purchaseLimit?.next_reset_at).toBe(
addInterval({
from: staleWindowEndsAt,
interval: EntInterval.Month,
intervalCount: 1,
}),
);
expect(purchaseLimit?.next_reset_at).toBeGreaterThan(Date.now());
});

View File

@@ -0,0 +1,366 @@
import { describe, expect, test } from "bun:test";
import {
AppEnv,
type Feature,
type FullCustomerEntitlement,
type FullSubject,
SubjectType,
} from "@autumn/shared";
import { projectMutationLogsToTrackDeductionsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.js";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
const buildFeature = (id: string): Feature =>
({
id,
internal_id: `feat_${id}`,
org_id: "org_1",
env: AppEnv.Live,
name: id,
type: "metered",
config: null,
display: null,
created_at: 1,
archived: false,
event_names: [],
}) as Feature;
const buildCustomerEntitlement = ({
id,
feature,
rolloverIds = [],
}: {
id: string;
feature: Feature;
rolloverIds?: string[];
}): FullCustomerEntitlement =>
({
id,
internal_customer_id: "cus_int_1",
internal_entity_id: null,
internal_feature_id: feature.internal_id,
customer_id: "cus_1",
feature_id: feature.id,
customer_product_id: null,
entitlement_id: `ent_${id}`,
created_at: 1,
unlimited: false,
balance: 10,
additional_balance: 0,
usage_allowed: true,
next_reset_at: null,
adjustment: 0,
expires_at: null,
cache_version: 0,
entities: null,
external_id: null,
entitlement: {
id: `ent_${id}`,
internal_product_id: "prod_1",
internal_feature_id: feature.internal_id,
feature_id: feature.id,
allowance_type: "fixed",
allowance: 10,
interval: "month",
interval_count: 1,
usage_limit: null,
carry_from_previous: false,
created_at: 1,
entity_feature_id: null,
is_custom: false,
org_id: "org_1",
rollover: null,
feature,
},
replaceables: [],
rollovers: rolloverIds.map((rolloverId) => ({
id: rolloverId,
cus_ent_id: id,
balance: 5,
usage: 0,
expires_at: null,
entities: null,
})),
}) as unknown as FullCustomerEntitlement;
const buildFullSubject = ({
customerEntitlements,
}: {
customerEntitlements: FullCustomerEntitlement[];
}): FullSubject =>
({
subjectType: SubjectType.Customer,
customerId: "cus_1",
internalCustomerId: "cus_int_1",
entityId: undefined,
internalEntityId: undefined,
customer: {
id: "cus_1",
internal_id: "cus_int_1",
org_id: "org_1",
env: AppEnv.Live,
created_at: 1,
},
entity: undefined,
customer_products: [],
extra_customer_entitlements: customerEntitlements,
subscriptions: [],
invoices: [],
aggregated_customer_products: undefined,
aggregated_customer_entitlements: undefined,
}) as unknown as FullSubject;
const buildLog = (overrides: Partial<MutationLogItem>): MutationLogItem => ({
target_type: "customer_entitlement",
customer_entitlement_id: null,
rollover_id: null,
entity_id: null,
credit_cost: 0,
balance_delta: 0,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
...overrides,
});
describe("projectMutationLogsToTrackDeductionsV2", () => {
test("returns an empty array when there are no logs", () => {
const fullSubject = buildFullSubject({ customerEntitlements: [] });
expect(
projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [] }),
).toEqual([]);
});
test("flips balance_delta sign so consumption is positive", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: -4,
}),
],
});
expect(result).toEqual([
{
balance_id: "cus_ent_messages",
feature_id: "messages",
value: 4,
},
]);
});
test("emits negative value when a track refunds balance (positive balance_delta)", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: 3,
}),
],
});
expect(result).toHaveLength(1);
expect(result[0].value).toBe(-3);
});
test("aggregates multiple logs against the same balance into one mutation", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: -2,
}),
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: -5,
}),
],
});
expect(result).toHaveLength(1);
expect(result[0].value).toBe(7);
});
test("emits a separate mutation for each touched balance across credit-system features", () => {
const messages = buildFeature("messages");
const aiCredits = buildFeature("ai_credits");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature: messages }),
buildCustomerEntitlement({
id: "cus_ent_ai_credits",
feature: aiCredits,
}),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: -1,
}),
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_ai_credits",
balance_delta: -7,
}),
],
});
expect(result).toHaveLength(2);
expect(result).toContainEqual({
balance_id: "cus_ent_messages",
feature_id: "messages",
value: 1,
});
expect(result).toContainEqual({
balance_id: "cus_ent_ai_credits",
feature_id: "ai_credits",
value: 7,
});
});
test("surfaces rollover mutations with the parent feature_id", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({
id: "cus_ent_messages",
feature,
rolloverIds: ["roll_1"],
}),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "rollover",
rollover_id: "roll_1",
balance_delta: -2,
}),
],
});
expect(result).toEqual([
{
balance_id: "roll_1",
feature_id: "messages",
value: 2,
},
]);
});
test("filters out grant-only adjustments (balance_delta === 0)", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
balance_delta: 0,
adjustment_delta: 5,
}),
],
});
expect(result).toEqual([]);
});
test("skips logs whose balance row is not in the full subject", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_unknown",
balance_delta: -3,
}),
],
});
expect(result).toEqual([]);
});
test("collapses per-entity logs against the same balance into one mutation (entity scope not exposed)", () => {
const feature = buildFeature("messages");
const fullSubject = buildFullSubject({
customerEntitlements: [
buildCustomerEntitlement({ id: "cus_ent_messages", feature }),
],
});
const result = projectMutationLogsToTrackDeductionsV2({
fullSubject,
mutationLogs: [
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
entity_id: "entity_a",
balance_delta: -2,
}),
buildLog({
target_type: "customer_entitlement",
customer_entitlement_id: "cus_ent_messages",
entity_id: "entity_b",
balance_delta: -3,
}),
],
});
expect(result).toEqual([
{
balance_id: "cus_ent_messages",
feature_id: "messages",
value: 5,
},
]);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import type {
TrackDeduction,
TrackResponseV2,
TrackResponseV3,
} from "@autumn/shared";
import { V2_0_TrackChange } from "@autumn/shared/api/balances/track/changes/V2.0_TrackChange";
const buildDeductions = (): TrackDeduction[] => [
{
balance_id: "cus_ent_messages",
feature_id: "messages",
value: 4,
},
];
describe("V2_0_TrackChange deduction strip", () => {
test("does not leak the deductions field to V2.0 clients", () => {
const transform = new V2_0_TrackChange();
const input: TrackResponseV3 = {
customer_id: "cus_1",
entity_id: undefined,
event_name: undefined,
value: 4,
balance: null,
balances: undefined,
deductions: buildDeductions(),
};
const transformed = transform.transformResponse({
input,
}) as TrackResponseV2;
expect(transformed).not.toHaveProperty("deductions");
expect(transformed.customer_id).toBe("cus_1");
expect(transformed.value).toBe(4);
expect(transformed.balance).toBeNull();
});
});

View File

@@ -3,15 +3,18 @@ import {
type AutumnBillingPlan,
type BillingContext,
BillingInterval,
BillWhen,
type FullCusProduct,
type FullCustomerPrice,
type Price,
PriceType,
type UsagePriceConfig,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
const mockState = {
priceIds: [] as string[],
productCalls: 0,
};
mock.module("@/external/stripe/createStripePrice/createStripePrice", () => ({
@@ -21,12 +24,21 @@ mock.module("@/external/stripe/createStripePrice/createStripePrice", () => ({
}));
mock.module("@/internal/products/productUtils", () => ({
checkStripeProductExists: async () => undefined,
checkStripeProductExists: async () => {
mockState.productCalls++;
},
}));
import { initStripeResourcesForBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
const fixedPrice = ({ id }: { id: string }): Price => ({
const fixedPrice = ({
id,
amount = 10,
}: {
id: string;
amount?: number;
}): Price => ({
id,
internal_product_id: "prod_internal",
org_id: "org_1",
@@ -37,7 +49,7 @@ const fixedPrice = ({ id }: { id: string }): Price => ({
proration_config: null,
config: {
type: PriceType.Fixed,
amount: 10,
amount,
interval: BillingInterval.Month,
stripe_price_id: null,
stripe_product_id: null,
@@ -46,6 +58,30 @@ const fixedPrice = ({ id }: { id: string }): Price => ({
},
});
const prepaidPrice = ({ id }: { id: string }): Price => ({
id,
internal_product_id: "prod_internal",
org_id: "org_1",
created_at: 1,
tier_behavior: null,
is_custom: false,
entitlement_id: "ent_1",
proration_config: null,
config: {
type: PriceType.Usage,
bill_when: BillWhen.StartOfPeriod,
billing_units: 1,
internal_feature_id: "feature_internal",
feature_id: "messages",
usage_tiers: [{ amount: 10, to: -1 }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_price_id: null,
stripe_product_id: null,
stripe_prepaid_price_v2_id: null,
},
});
const customerPrice = ({
id,
price,
@@ -109,6 +145,174 @@ const customerProduct = ({
describe("initStripeResourcesForBillingPlan", () => {
beforeEach(() => {
mockState.priceIds = [];
mockState.productCalls = 0;
});
test("uses preview Stripe IDs without initializing Stripe resources during dry run", async () => {
const basePrice = fixedPrice({ id: "price_base" });
const messagesPrice = prepaidPrice({ id: "price_messages" });
const baseCustomerPrice = customerPrice({
id: "cus_price_base",
price: basePrice,
});
const messagesCustomerPrice = customerPrice({
id: "cus_price_messages",
price: messagesPrice,
});
const newCustomerProduct = customerProduct({
customerPrices: [baseCustomerPrice, messagesCustomerPrice],
});
await initStripeResourcesForBillingPlan({
ctx: {
db: {},
org: { id: "org_1" },
env: "sandbox",
logger: { debug: () => undefined },
} as unknown as AutumnContext,
billingContext: {
dryRunStripe: true,
fullCustomer: {
internal_id: "cus_internal",
customer_products: [],
},
} as unknown as BillingContext,
autumnBillingPlan: {
customerId: "cus_1",
insertCustomerProducts: [newCustomerProduct],
} as AutumnBillingPlan,
});
const messagesConfig = messagesPrice.config as UsagePriceConfig;
expect(mockState.productCalls).toBe(0);
expect(mockState.priceIds).toEqual([]);
expect(newCustomerProduct.product.processor?.id).toStartWith(
"prod_PREVIEW_",
);
expect(basePrice.config.stripe_price_id).toStartWith("price_PREVIEW_");
expect(messagesConfig.stripe_price_id).toStartWith("price_PREVIEW_");
expect(messagesConfig.stripe_product_id).toStartWith("prod_PREVIEW_");
expect(messagesConfig.stripe_prepaid_price_v2_id).toStartWith(
"price_PREVIEW_",
);
});
test("uses preview Stripe IDs for patched customer products during dry run", async () => {
const keptPrice = fixedPrice({ id: "price_kept" });
const insertedPrice = prepaidPrice({ id: "price_inserted" });
const originalCustomerProduct = customerProduct({
customerPrices: [
customerPrice({
id: "cus_price_kept",
price: keptPrice,
}),
],
});
const insertedCustomerPrice = customerPrice({
id: "cus_price_inserted",
price: insertedPrice,
});
await initStripeResourcesForBillingPlan({
ctx: {
db: {},
org: { id: "org_1" },
env: "sandbox",
logger: { debug: () => undefined },
} as unknown as AutumnContext,
billingContext: {
dryRunStripe: true,
fullCustomer: {
internal_id: "cus_internal",
customer_products: [originalCustomerProduct],
},
} as unknown as BillingContext,
autumnBillingPlan: {
customerId: "cus_1",
insertCustomerProducts: [],
patchCustomerProducts: [
{
customerProduct: originalCustomerProduct,
insertCustomerPrices: [insertedCustomerPrice],
insertCustomerEntitlements: [],
deleteCustomerPrices: [],
deleteCustomerEntitlements: [],
},
],
} as AutumnBillingPlan,
});
const insertedConfig = insertedPrice.config as UsagePriceConfig;
expect(mockState.productCalls).toBe(0);
expect(mockState.priceIds).toEqual([]);
expect(originalCustomerProduct.product.processor?.id).toStartWith(
"prod_PREVIEW_",
);
expect(keptPrice.config.stripe_price_id).toStartWith("price_PREVIEW_");
expect(insertedConfig.stripe_price_id).toStartWith("price_PREVIEW_");
expect(insertedConfig.stripe_product_id).toStartWith("prod_PREVIEW_");
expect(insertedConfig.stripe_prepaid_price_v2_id).toStartWith(
"price_PREVIEW_",
);
});
test("does not initialize Stripe resources for zero fixed prices", async () => {
const zeroPrice = fixedPrice({ id: "price_free", amount: 0 });
const freeCustomerProduct = customerProduct({
customerPrices: [
customerPrice({
id: "cus_price_free",
price: zeroPrice,
}),
],
});
await initStripeResourcesForBillingPlan({
ctx: {
db: {},
org: { id: "org_1" },
env: "sandbox",
logger: { debug: () => undefined },
} as unknown as AutumnContext,
billingContext: {
fullCustomer: {
internal_id: "cus_internal",
customer_products: [],
},
} as unknown as BillingContext,
autumnBillingPlan: {
customerId: "cus_1",
insertCustomerProducts: [freeCustomerProduct],
} as AutumnBillingPlan,
});
expect(mockState.productCalls).toBe(0);
expect(mockState.priceIds).toEqual([]);
expect(zeroPrice.config.stripe_price_id).toBeNull();
});
test("omits zero fixed prices from Stripe item specs", () => {
const zeroPrice = fixedPrice({ id: "price_free", amount: 0 });
const freeCustomerProduct = customerProduct({
customerPrices: [
customerPrice({
id: "cus_price_free",
price: zeroPrice,
}),
],
});
const stripeItemSpecs = customerProductToStripeItemSpecs({
ctx: {} as AutumnContext,
customerProduct: freeCustomerProduct,
});
expect(stripeItemSpecs).toEqual({
oneOffItems: [],
recurringItems: [],
});
});
test("does not initialize Stripe resources for prices removed by a patch", async () => {
@@ -138,7 +342,7 @@ describe("initStripeResourcesForBillingPlan", () => {
internal_id: "cus_internal",
customer_products: [originalCustomerProduct],
},
} as BillingContext,
} as unknown as BillingContext,
autumnBillingPlan: {
customerId: "cus_1",
insertCustomerProducts: [],

View File

@@ -0,0 +1,220 @@
import { describe, expect, test } from "bun:test";
import {
AllowanceType,
AppEnv,
BillingInterval,
BillWhen,
EntInterval,
type Entitlement,
type Feature,
FeatureType,
FeatureUsageType,
type FixedPriceConfig,
type Price,
PriceType,
type Product,
type ProductItem,
ProductItemInterval,
TierInfinite,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@server/db/initDrizzle";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems";
const orgId = "org_versioning";
const previousProductInternalId = "prod_internal_v1";
const newProductInternalId = "prod_internal_v2";
const now = 1_800_000_000_000;
const feature: Feature = {
internal_id: "feat_internal_ai_credits",
id: "ai_credits",
name: "AI Credits",
type: FeatureType.Metered,
config: { usage_type: FeatureUsageType.Single },
org_id: orgId,
env: AppEnv.Sandbox,
created_at: now,
archived: false,
event_names: [],
};
const previousEntitlement: Entitlement = {
id: "ent_v1",
org_id: orgId,
created_at: now,
is_custom: false,
internal_product_id: previousProductInternalId,
internal_feature_id: feature.internal_id,
feature_id: feature.id,
allowance: 100,
allowance_type: AllowanceType.Fixed,
interval: EntInterval.Month,
interval_count: 1,
carry_from_previous: false,
entity_feature_id: undefined,
usage_limit: null,
rollover: null,
};
const previousUsageConfig: UsagePriceConfig = {
type: PriceType.Usage,
bill_when: BillWhen.EndOfPeriod,
billing_units: 1,
should_prorate: false,
internal_feature_id: feature.internal_id,
feature_id: feature.id,
usage_tiers: [{ amount: 0.1, to: TierInfinite }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_product_id: "prod_ai_credits",
stripe_price_id: "price_ai_credits",
stripe_meter_id: "meter_ai_credits",
stripe_event_name: "ai_credits_used",
stripe_empty_price_id: "price_ai_credits_empty",
};
const previousUsagePrice: Price = {
id: "pr_v1",
org_id: orgId,
created_at: now,
internal_product_id: previousProductInternalId,
is_custom: false,
config: previousUsageConfig,
entitlement_id: previousEntitlement.id,
proration_config: null,
tier_behavior: null,
};
const previousFixedConfig: FixedPriceConfig = {
type: PriceType.Fixed,
amount: 500,
interval: BillingInterval.Month,
interval_count: 1,
stripe_product_id: null,
feature_id: null,
internal_feature_id: null,
stripe_price_id: "price_base_v1",
};
const previousFixedPrice: Price = {
id: "pr_fixed_v1",
org_id: orgId,
created_at: now,
internal_product_id: previousProductInternalId,
is_custom: false,
config: previousFixedConfig,
proration_config: null,
};
const newProduct: Product = {
id: "enterprise",
name: "Enterprise",
description: null,
is_add_on: false,
is_default: false,
version: 2,
group: "",
env: AppEnv.Sandbox,
internal_id: newProductInternalId,
org_id: orgId,
created_at: now,
processor: null,
base_variant_id: null,
archived: false,
config: { ignore_past_due: false },
};
const baseItem: ProductItem = {
price: 500,
interval: ProductItemInterval.Month,
interval_count: 1,
price_id: previousFixedPrice.id,
};
const aiCreditsItem: ProductItem = {
feature_id: feature.id,
included_usage: 100,
price: 0.1,
interval: ProductItemInterval.Month,
interval_count: 1,
usage_model: "pay_per_use" as ProductItem["usage_model"],
billing_units: 1,
reset_usage_when_enabled: true,
price_id: previousUsagePrice.id,
entitlement_id: previousEntitlement.id,
};
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
child: () => noopLogger,
} as never;
describe("handleNewProductItems versioning carries forward Stripe IDs", () => {
test("copies every Stripe resource field onto the new version when config matches", async () => {
const result = await handleNewProductItems({
db: {} as DrizzleCli,
curPrices: [previousFixedPrice, previousUsagePrice],
curEnts: [previousEntitlement],
newItems: [baseItem, aiCreditsItem],
features: [feature],
product: newProduct,
logger: noopLogger,
isCustom: false,
newVersion: true,
saveToDb: false,
});
const fixedNew = result.prices.find(
(price) => price.config.type === PriceType.Fixed,
);
const usageNew = result.prices.find(
(price) => price.config.type === PriceType.Usage,
);
expect(fixedNew).toBeDefined();
expect(usageNew).toBeDefined();
expect((fixedNew?.config as FixedPriceConfig).stripe_price_id).toBe(
"price_base_v1",
);
const usageConfig = usageNew?.config as UsagePriceConfig;
expect(usageConfig.stripe_product_id).toBe("prod_ai_credits");
expect(usageConfig.stripe_price_id).toBe("price_ai_credits");
expect(usageConfig.stripe_meter_id).toBe("meter_ai_credits");
expect(usageConfig.stripe_event_name).toBe("ai_credits_used");
expect(usageConfig.stripe_empty_price_id).toBe("price_ai_credits_empty");
});
test("falls back to stripe_product_id only when the usage tier changes", async () => {
const changedAiCreditsItem: ProductItem = {
...aiCreditsItem,
price: 0.2,
};
const result = await handleNewProductItems({
db: {} as DrizzleCli,
curPrices: [previousUsagePrice],
curEnts: [previousEntitlement],
newItems: [changedAiCreditsItem],
features: [feature],
product: newProduct,
logger: noopLogger,
isCustom: false,
newVersion: true,
saveToDb: false,
});
const usageNew = result.prices.find(
(price) => price.config.type === PriceType.Usage,
);
expect(usageNew).toBeDefined();
const usageConfig = usageNew?.config as UsagePriceConfig;
expect(usageConfig.stripe_product_id).toBe("prod_ai_credits");
expect(usageConfig.stripe_price_id).toBeUndefined();
expect(usageConfig.stripe_meter_id).toBe("meter_ai_credits");
expect(usageConfig.stripe_empty_price_id).toBeUndefined();
});
});

View File

@@ -0,0 +1,172 @@
import { describe, expect, test } from "bun:test";
import {
AllowanceType,
AppEnv,
BillingInterval,
BillWhen,
copyStripeResourcesToMatchingPrice,
EntInterval,
type Entitlement,
type Price,
PriceType,
TierInfinite,
type UsagePriceConfig,
} from "@autumn/shared";
const orgId = "org_copy";
const internalProductId = "prod_internal_copy";
const now = 1_800_000_000_000;
const baseConfig: UsagePriceConfig = {
type: PriceType.Usage,
bill_when: BillWhen.EndOfPeriod,
billing_units: 1,
should_prorate: false,
internal_feature_id: "feat_internal_ai_credits",
feature_id: "ai_credits",
usage_tiers: [{ amount: 0.1, to: TierInfinite }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_product_id: "prod_ai_credits",
stripe_price_id: "price_ai_credits",
stripe_meter_id: "meter_ai_credits",
stripe_event_name: "ai_credits_used",
};
const candidate = (overrides: Partial<Price> = {}): Price => ({
id: "pr_existing",
org_id: orgId,
created_at: now,
internal_product_id: internalProductId,
is_custom: false,
config: { ...baseConfig },
entitlement_id: "ent_existing",
proration_config: null,
tier_behavior: null,
...overrides,
});
const target = (overrides: Partial<Price> = {}): Price => ({
id: "pr_new",
org_id: orgId,
created_at: now,
internal_product_id: internalProductId,
is_custom: false,
config: {
...baseConfig,
stripe_product_id: undefined,
stripe_price_id: undefined,
stripe_meter_id: undefined,
stripe_event_name: undefined,
} as UsagePriceConfig,
entitlement_id: "ent_new",
proration_config: null,
tier_behavior: null,
...overrides,
});
const entitlement = (overrides: Partial<Entitlement> = {}): Entitlement => ({
id: "ent_existing",
org_id: orgId,
created_at: now,
is_custom: false,
internal_product_id: internalProductId,
internal_feature_id: baseConfig.internal_feature_id!,
feature_id: baseConfig.feature_id!,
allowance: 100,
allowance_type: AllowanceType.Fixed,
interval: EntInterval.Month,
interval_count: 1,
carry_from_previous: false,
entity_feature_id: undefined,
usage_limit: null,
rollover: null,
...overrides,
});
describe("copyStripeResourcesToMatchingPrice", () => {
test("prefers a full match over a stripeProductOnly match", () => {
const fullCandidate = candidate({ id: "pr_full" });
const productOnlyCandidate = candidate({
id: "pr_cheaper",
config: {
...baseConfig,
usage_tiers: [{ amount: 0.05, to: TierInfinite }],
stripe_product_id: "prod_other_credits",
stripe_price_id: "price_other_credits",
},
entitlement_id: "ent_existing_cheaper",
});
const newPrice = target();
const result = copyStripeResourcesToMatchingPrice({
targetPrice: newPrice,
candidatePrices: [productOnlyCandidate, fullCandidate],
targetEntitlements: [entitlement({ id: "ent_new" })],
candidateEntitlements: [
entitlement(),
entitlement({
id: "ent_existing_cheaper",
allowance: 200,
}),
],
});
const config = newPrice.config as UsagePriceConfig;
expect(result.copiedFields).toContain("stripe_product_id");
expect(config.stripe_product_id).toBe("prod_ai_credits");
expect(config.stripe_price_id).toBe("price_ai_credits");
expect(config.stripe_meter_id).toBe("meter_ai_credits");
expect(config.stripe_event_name).toBe("ai_credits_used");
});
test("copies only stripe_product_id from a stripeProductOnly match", () => {
const productOnlyCandidate = candidate({
id: "pr_cheaper",
config: {
...baseConfig,
usage_tiers: [{ amount: 0.05, to: TierInfinite }],
stripe_product_id: "prod_other_credits",
stripe_price_id: "price_other_credits",
stripe_meter_id: "meter_other_credits",
},
});
const newPrice = target();
const result = copyStripeResourcesToMatchingPrice({
targetPrice: newPrice,
candidatePrices: [productOnlyCandidate],
targetEntitlements: [entitlement({ id: "ent_new" })],
candidateEntitlements: [entitlement()],
});
const config = newPrice.config as UsagePriceConfig;
expect(result.copiedFields).toEqual(["stripe_product_id"]);
expect(config.stripe_product_id).toBe("prod_other_credits");
expect(config.stripe_price_id).toBeUndefined();
expect(config.stripe_meter_id).toBeUndefined();
});
test("returns no copied fields when nothing matches", () => {
const unrelatedCandidate = candidate({
id: "pr_unrelated",
config: {
...baseConfig,
feature_id: "other_feature",
internal_feature_id: "feat_internal_other",
},
});
const newPrice = target();
const result = copyStripeResourcesToMatchingPrice({
targetPrice: newPrice,
candidatePrices: [unrelatedCandidate],
targetEntitlements: [entitlement({ id: "ent_new" })],
candidateEntitlements: [entitlement()],
});
const config = newPrice.config as UsagePriceConfig;
expect(result.copiedFields).toEqual([]);
expect(config.stripe_product_id).toBeUndefined();
});
});

View File

@@ -0,0 +1,207 @@
import { describe, expect, test } from "bun:test";
import {
AllowanceType,
AppEnv,
BillingInterval,
BillWhen,
EntInterval,
type Entitlement,
type FixedPriceConfig,
getPriceStripeReuseLevel,
PREVIEW_STRIPE_PRICE_ID_PREFIX,
type Price,
PriceType,
TierInfinite,
type UsagePriceConfig,
} from "@autumn/shared";
const orgId = "org_match";
const internalProductId = "prod_internal_match";
const now = 1_800_000_000_000;
const usageConfig: UsagePriceConfig = {
type: PriceType.Usage,
bill_when: BillWhen.EndOfPeriod,
billing_units: 1,
should_prorate: false,
internal_feature_id: "feat_internal_ai_credits",
feature_id: "ai_credits",
usage_tiers: [{ amount: 0.1, to: TierInfinite }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_product_id: "prod_ai_credits",
stripe_price_id: "price_ai_credits",
stripe_meter_id: "meter_ai_credits",
stripe_event_name: "ai_credits_used",
};
const usagePrice = (overrides: Partial<Price> = {}): Price => ({
id: "pr_ai_credits",
org_id: orgId,
created_at: now,
internal_product_id: internalProductId,
is_custom: false,
config: { ...usageConfig },
entitlement_id: "ent_ai_credits",
proration_config: null,
tier_behavior: null,
...overrides,
});
const entitlement = (overrides: Partial<Entitlement> = {}): Entitlement => ({
id: "ent_ai_credits",
org_id: orgId,
created_at: now,
is_custom: false,
internal_product_id: internalProductId,
internal_feature_id: usageConfig.internal_feature_id!,
feature_id: usageConfig.feature_id!,
allowance: 100,
allowance_type: AllowanceType.Fixed,
interval: EntInterval.Month,
interval_count: 1,
carry_from_previous: false,
entity_feature_id: undefined,
usage_limit: null,
rollover: null,
...overrides,
});
describe("getPriceStripeReuseLevel", () => {
test("returns full when configs and paired entitlements match", () => {
const level = getPriceStripeReuseLevel({
newPrice: usagePrice({ id: "pr_new" }),
candidatePrice: usagePrice(),
newEntitlements: [entitlement()],
candidateEntitlements: [entitlement()],
});
expect(level).toBe("full");
});
test("returns stripeProductOnly when config differs but feature scope matches", () => {
const cheaper = usagePrice({
id: "pr_cheaper",
config: {
...usageConfig,
usage_tiers: [{ amount: 0.05, to: TierInfinite }],
},
});
const level = getPriceStripeReuseLevel({
newPrice: cheaper,
candidatePrice: usagePrice(),
newEntitlements: [entitlement()],
candidateEntitlements: [entitlement()],
});
expect(level).toBe("stripeProductOnly");
});
test("returns none when entity scope differs", () => {
const level = getPriceStripeReuseLevel({
newPrice: usagePrice({
id: "pr_new",
entitlement_id: "ent_per_seat",
}),
candidatePrice: usagePrice(),
newEntitlements: [
entitlement({ id: "ent_per_seat", entity_feature_id: "seat" }),
],
candidateEntitlements: [entitlement()],
});
expect(level).toBe("none");
});
test("returns none when candidate has preview-only Stripe IDs", () => {
const previewCandidate = usagePrice({
config: {
...usageConfig,
stripe_product_id: `${PREVIEW_STRIPE_PRICE_ID_PREFIX}ai_credits`,
},
});
const level = getPriceStripeReuseLevel({
newPrice: usagePrice({ id: "pr_new" }),
candidatePrice: previewCandidate,
newEntitlements: [entitlement()],
candidateEntitlements: [entitlement()],
});
expect(level).toBe("none");
});
test("returns full when both usage prices share configs and lack paired entitlements", () => {
const orphan = usagePrice({ id: "pr_orphan", entitlement_id: null });
const orphanNew = usagePrice({ id: "pr_orphan_new", entitlement_id: null });
const level = getPriceStripeReuseLevel({
newPrice: orphanNew,
candidatePrice: orphan,
newEntitlements: [],
candidateEntitlements: [],
});
expect(level).toBe("full");
});
test("returns stripeProductOnly when both usage prices lack entitlements but configs differ", () => {
const cheaper = usagePrice({
id: "pr_orphan_cheaper",
entitlement_id: null,
config: {
...usageConfig,
usage_tiers: [{ amount: 0.05, to: TierInfinite }],
},
});
const original = usagePrice({ id: "pr_orphan", entitlement_id: null });
const level = getPriceStripeReuseLevel({
newPrice: cheaper,
candidatePrice: original,
newEntitlements: [],
candidateEntitlements: [],
});
expect(level).toBe("stripeProductOnly");
});
test("returns full for matching fixed prices regardless of paired entitlements", () => {
const fixed: Price = {
id: "pr_base",
org_id: orgId,
created_at: now,
internal_product_id: internalProductId,
is_custom: false,
config: {
type: PriceType.Fixed,
amount: 500,
interval: BillingInterval.Month,
interval_count: 1,
stripe_product_id: null,
feature_id: null,
internal_feature_id: null,
stripe_price_id: "price_fixed",
} satisfies FixedPriceConfig,
proration_config: null,
};
const fixedTarget: Price = {
...fixed,
id: "pr_base_new",
config: {
...(fixed.config as FixedPriceConfig),
stripe_price_id: null,
},
};
const level = getPriceStripeReuseLevel({
newPrice: fixedTarget,
candidatePrice: fixed,
newEntitlements: [],
candidateEntitlements: [],
});
expect(level).toBe("full");
});
});

View File

@@ -122,6 +122,63 @@ const allocatedUsers = ({
},
});
const allocatedWorkflows = ({
amount = 10,
included = 0,
}: {
amount?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Workflows,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
const prepaidUsers = ({
amount = 10,
billingUnits = 1,
included = 0,
}: {
amount?: number;
billingUnits?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Users,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: billingUnits,
},
});
const consumableWords = ({
amount = 0.05,
included = 0,
}: {
amount?: number;
included?: number;
} = {}) => ({
feature_id: TestFeature.Words,
included,
price: {
amount,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
});
/**
* Tiered prepaid messages - tier `to` values INCLUDE the included amount.
* Default: included=100, tiers=[{to:600, amount:10}, {to:"inf", amount:5}]
@@ -184,9 +241,12 @@ export const itemsV2 = {
monthlyWords,
dashboard,
prepaidMessages,
prepaidUsers,
prepaidWords,
consumableMessages,
consumableWords,
allocatedUsers,
allocatedWorkflows,
tieredPrepaidMessages,
volumePrepaidMessages,
} as const;

View File

@@ -15,6 +15,7 @@ export const createProduct = async ({
autumn,
product,
prefix,
createInStripe,
}: {
db: DrizzleCli;
orgId: string;
@@ -22,6 +23,7 @@ export const createProduct = async ({
autumn: AutumnInt;
product: any;
prefix?: string;
createInStripe?: boolean;
}) => {
try {
const products = await ProductService.listFull({
@@ -60,6 +62,10 @@ export const createProduct = async ({
clone.name = `${clone.name} ${prefix}`;
}
if (createInStripe === false) {
clone.create_in_stripe = false;
}
try {
await autumn.products.create(clone);
} catch (error: any) {
@@ -83,6 +89,7 @@ export const createProducts = async ({
products,
prefix,
customerId,
createInStripe,
}: {
db: DrizzleCli;
orgId: string;
@@ -91,6 +98,7 @@ export const createProducts = async ({
products: any[];
prefix?: string;
customerId?: string;
createInStripe?: boolean;
}) => {
if (customerId) {
try {
@@ -101,7 +109,7 @@ export const createProducts = async ({
const batchCreate = [];
for (const product of products) {
batchCreate.push(
createProduct({ db, orgId, env, autumn, product, prefix }),
createProduct({ db, orgId, env, autumn, product, prefix, createInStripe }),
);
}

View File

@@ -242,6 +242,7 @@ type ScenarioConfig = {
stripeCustomerOverrides?: Partial<Stripe.CustomerCreateParams>;
products: ProductV2[];
productPrefix?: string;
productCreateInStripe?: boolean;
entityConfig?: EntityConfig;
customerIds?: string[];
cleanup: CleanupConfig;
@@ -326,16 +327,19 @@ const products = ({
list,
prefix,
customerIdsToDelete,
createInStripe,
}: {
list: ProductV2[];
prefix?: string;
customerIdsToDelete?: string[];
createInStripe?: boolean;
}): ConfigFn => {
return (config) => ({
...config,
products: list,
productPrefix: prefix,
customerIds: customerIdsToDelete,
productCreateInStripe: createInStripe,
});
};
@@ -1104,6 +1108,7 @@ export async function initScenario({
products: config.products,
prefix: productPrefix,
customerIds: allCustomerIds,
createInStripe: config.productCreateInStripe,
});
}

View File

@@ -16,8 +16,28 @@ SCHEMA >
`entity_id` Nullable(String) `json:$.entity_id`,
`internal_entity_id` Nullable(String) `json:$.internal_entity_id`,
`customer_id` String `json:$.customer_id`,
`properties` JSON `json:$.properties`
`properties` JSON `json:$.properties`,
`deductions` Nullable(String) `json:$.deductions` DEFAULT NULL
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
ENGINE_SORTING_KEY "org_id, env, customer_id, event_name, timestamp"
ENGINE_SORTING_KEY "org_id, env, customer_id, event_name, timestamp"
FORWARD_QUERY >
SELECT
id,
org_id,
org_slug,
internal_customer_id,
env,
created_at,
timestamp,
event_name,
idempotency_key,
value,
set_usage,
entity_id,
internal_entity_id,
customer_id,
properties,
defaultValueOfTypeName('Nullable(String)') AS deductions

View File

@@ -13,7 +13,8 @@ SCHEMA >
`value` Nullable(Decimal(38, 19)),
`properties` Nullable(String),
`idempotency_key` Nullable(String),
`entity_id` String DEFAULT ''
`entity_id` String DEFAULT '',
`deductions` Nullable(String)
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"

View File

@@ -14,7 +14,8 @@ SQL >
value,
properties,
idempotency_key,
coalesce(entity_id, '') as entity_id
coalesce(entity_id, '') as entity_id,
deductions
FROM events
ORDER BY timestamp DESC, id DESC

View File

@@ -18,7 +18,8 @@ SQL >
value,
properties,
idempotency_key,
entity_id
entity_id,
deductions
FROM events_by_timestamp_mv
WHERE
org_id = {{ String(org_id, '') }}

View File

@@ -49,8 +49,18 @@ export const V2_0_TrackChange = defineVersionChange({
}
}
const {
customer_id,
entity_id,
event_name,
value,
}: z.infer<typeof TrackResponseV3Schema> = input;
return {
...input,
customer_id,
entity_id,
event_name,
value,
balance: transformedBalance,
balances: transformedBalances,
};

View File

@@ -1,6 +1,22 @@
import { z } from "zod/v4";
import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js";
export const TrackDeductionSchema = z.object({
balance_id: z.string().meta({
description:
"ID of the underlying balance row that was deducted from (customer_entitlement or rollover).",
}),
feature_id: z.string().meta({
description: "The feature this balance belongs to.",
}),
value: z.number().meta({
description:
"Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).",
}),
});
export type TrackDeduction = z.infer<typeof TrackDeductionSchema>;
/**
* Track response V3 - uses ApiBalanceV1 (V2.1 format)
* This is the server's internal response format
@@ -32,6 +48,10 @@ export const TrackResponseV3Schema = z.object({
description:
"Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.",
}),
deductions: z.array(TrackDeductionSchema).optional().meta({
description:
"Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.",
}),
});
export type TrackResponseV3 = z.infer<typeof TrackResponseV3Schema>;

View File

@@ -1,5 +1,6 @@
import { createPagePaginatedResponseSchema } from "@api/common/pagePaginationSchemas";
import { z } from "zod/v4";
import { TrackDeductionSchema } from "../../balances/track/trackResponseV3";
export const EVENTS_LIST_EXAMPLE = {
list: [
@@ -10,6 +11,13 @@ export const EVENTS_LIST_EXAMPLE = {
customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx",
value: 30,
properties: {},
deductions: [
{
balance_id: "cus_ent_3DdSDtFBlvDbjyUuJeUIbQlyN12",
feature_id: "credits",
value: 30,
},
],
},
{
id: "evt_36xmHxxjAkqxufDf9yHAPNfRrLM",
@@ -18,6 +26,7 @@ export const EVENTS_LIST_EXAMPLE = {
customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx",
value: 49,
properties: {},
deductions: null,
},
],
total: 2,
@@ -39,6 +48,12 @@ export const ApiEventsListItemSchema = z.object({
properties: z
.record(z.string(), z.unknown())
.describe("Event properties (JSON)"),
deductions: z
.array(TrackDeductionSchema)
.nullable()
.describe(
"Per-balance breakdown of what this event deducted. Null for events ingested before deductions were tracked; an empty array means the event was accepted but no balance moved.",
),
});
export const ApiEventsListResponseSchema = createPagePaginatedResponseSchema(

View File

@@ -46,6 +46,10 @@ export const CreatePlanParamsV1Schema = z.object({
config: ProductConfigParamsSchema.optional().meta({
description: "Miscellaneous plan-level configuration flags.",
}),
create_in_stripe: z.boolean().default(true).meta({
internal: true,
}),
});
export const CreatePlanParamsV2Schema = z

View File

@@ -44,6 +44,11 @@ export function planParamsV1ToProductV2({
? params.config
: undefined;
const createInStripe =
"create_in_stripe" in params && params.create_in_stripe !== undefined
? params.create_in_stripe
: undefined;
return {
id: params.id, // fallback just for placeholders...
name: params.name,
@@ -63,5 +68,6 @@ export function planParamsV1ToProductV2({
: params.free_trial,
...(archived !== undefined && { archived }),
...(config !== undefined && { config }),
...(createInStripe !== undefined && { create_in_stripe: createInStripe }),
};
}

View File

@@ -99,6 +99,10 @@ export const CreateProductV2ParamsSchema = z
config: ProductConfigParamsSchema.optional().meta({
description: "Miscellaneous product-level configuration flags.",
}),
create_in_stripe: z.boolean().optional().meta({
internal: true,
}),
})
.meta({
examples: [CREATE_PRODUCT_EXAMPLE],

View File

@@ -85,6 +85,7 @@ export interface BillingContext {
userMetadata?: Record<string, string>;
skipBillingChanges?: boolean;
dryRunStripe?: boolean;
checkoutMode?: CheckoutMode;

View File

@@ -11,6 +11,7 @@ import {
timestamp,
unique,
} from "drizzle-orm/pg-core";
import type { TrackDeduction } from "../../api/balances/track/trackResponseV3.js";
import { customers } from "../cusModels/cusTable.js";
export const events = pgTable(
@@ -34,6 +35,7 @@ export const events = pgTable(
// Optional stuff...
customer_id: text("customer_id").notNull(),
properties: jsonb().$type<Record<string, any>>(),
deductions: jsonb().$type<TrackDeduction[]>(),
},
(table) => [
foreignKey({

View File

@@ -25,13 +25,12 @@ export * from "./cusProductUtils/index";
// Cus utils
export * from "./cusUtils/index";
export * from "./expandUtils";
export * from "./featureUtils";
// Feature utils
export * from "./featureUtils/apiFeatureToDbFeature";
export * from "./featureUtils/convertFeatureUtils";
export * from "./featureUtils/findFeatureUtils";
export * from "./featureUtils/index";
export * from "./featureUtils";
// INTERVAL UTILS
export * from "./intervalUtils/addBillingInterval";
@@ -48,17 +47,25 @@ export * from "./productUtils/entUtils/index";
export * from "./productUtils/freeTrialUtils";
export * from "./productUtils/index";
export * from "./productUtils/isProductUpgrade";
export * from "./productUtils/priceUtils/index";
export * from "./productUtils/priceUtils";
export * from "./productUtils/priceUtils/index";
// Price match utils
export * from "./productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice";
export * from "./productUtils/priceUtils/match/getPriceStripeReuseLevel";
export * from "./productUtils/priceUtils/match/priceStripeObjectsMatch";
export * from "./productV2Utils/mapToProductV2";
export * from "./productV2Utils/productItemUtils/classifyItemUtils";
export * from "./productV2Utils/productItemUtils/getItemType";
export * from "./productV2Utils/productItemUtils/matchPlanItem";
export * from "./productV2Utils/productItemUtils/sortPlanItems";
// Item utils
export * from "./productV2Utils/productItemUtils/mapToItem";
export * from "./productV2Utils/productItemUtils/matchPlanItem";
export * from "./productV2Utils/productItemUtils/productItemUtils";
export * from "./productV2Utils/productItemUtils/sortPlanItems";
export * from "./productV2Utils/productV2ToFrontendProduct";
export * from "./productV2Utils/productV2ToV1";
export * from "./productV3Utils/productItemUtils/productV3ItemUtils";
// Stripe resource utils
export * from "./stripeUtils/classifyStripeResource/isPreviewStripeId";
export * from "./utils";

View File

@@ -36,7 +36,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
ent1.allowance_type !== AllowanceType.Unlimited &&
ent1.allowance != ent2.allowance,
carryFromPrevious: ent1.carry_from_previous != ent2.carry_from_previous,
entityFeatureId: ent1.entity_feature_id !== ent2.entity_feature_id,
entityFeatureId: ent1.entity_feature_id != ent2.entity_feature_id,
usageLimit: ent1.usage_limit != ent2.usage_limit,
rollover: !rolloversAreSame({
rollover1: ent1.rollover,

View File

@@ -0,0 +1,107 @@
import { type Entitlement, nullish, type Price } from "@autumn/shared";
import { getPriceStripeReuseLevel } from "./getPriceStripeReuseLevel.js";
const stripeResourceFields = [
"stripe_product_id",
"stripe_price_id",
"stripe_empty_price_id",
"stripe_placeholder_price_id",
"stripe_prepaid_price_v2_id",
"stripe_meter_id",
"stripe_event_name",
] as const;
export type StripeResourceField = (typeof stripeResourceFields)[number];
type StripeResourceConfig = Partial<Record<StripeResourceField, string | null>>;
const copyField = ({
fromConfig,
toConfig,
field,
}: {
fromConfig: StripeResourceConfig;
toConfig: StripeResourceConfig;
field: StripeResourceField;
}) => {
if (!nullish(toConfig[field])) return false;
if (nullish(fromConfig[field])) return false;
toConfig[field] = fromConfig[field];
return true;
};
/**
* Copy Stripe resource IDs (and `stripe_event_name`) from the best-matching
* candidate price onto `targetPrice.config`, in place. Returns the fields that
* were copied.
*
* Two-pass search: a "full" match (identical config + paired entitlement) is
* preferred over a "stripeProductOnly" match (same feature/entity scope). For
* "full" matches every Stripe resource field is copied; for "stripeProductOnly"
* only `stripe_product_id` is copied so a fresh price is later minted under the
* existing per-feature Stripe product.
*/
export const copyStripeResourcesToMatchingPrice = ({
targetPrice,
candidatePrices,
targetEntitlements,
candidateEntitlements,
}: {
targetPrice: Price;
// Stripe IDs are copied FROM the best-matching candidate TO targetPrice.
candidatePrices: Price[];
targetEntitlements: Entitlement[];
candidateEntitlements: Entitlement[];
}): { copiedFields: StripeResourceField[] } => {
const levels = candidatePrices.map((candidatePrice) => ({
candidatePrice,
level: getPriceStripeReuseLevel({
newPrice: targetPrice,
candidatePrice,
newEntitlements: targetEntitlements,
candidateEntitlements,
}),
}));
const fullMatch = levels.find((entry) => entry.level === "full");
const productOnlyMatch =
fullMatch ?? levels.find((entry) => entry.level === "stripeProductOnly");
const productSource = productOnlyMatch?.candidatePrice;
const fullSource = fullMatch?.candidatePrice;
const targetConfig = targetPrice.config as StripeResourceConfig;
const copiedFields: StripeResourceField[] = [];
if (productSource) {
const fromConfig = productSource.config as StripeResourceConfig;
if (
copyField({
fromConfig,
toConfig: targetConfig,
field: "stripe_product_id",
})
) {
copiedFields.push("stripe_product_id");
}
}
if (fullSource) {
const fromConfig = fullSource.config as StripeResourceConfig;
for (const field of stripeResourceFields) {
if (field === "stripe_product_id") continue;
if (
copyField({
fromConfig,
toConfig: targetConfig,
field,
})
) {
copiedFields.push(field);
}
}
}
return { copiedFields };
};

View File

@@ -0,0 +1,111 @@
import {
type Entitlement,
type EntitlementWithFeature,
entsAreSame,
isPreviewStripeId,
type Price,
PriceType,
pricesAreSame,
priceToEnt,
type UsagePriceConfig,
} from "@autumn/shared";
export type PriceStripeReuseLevel = "full" | "stripeProductOnly" | "none";
const stripeResourceFields = [
"stripe_product_id",
"stripe_price_id",
"stripe_empty_price_id",
"stripe_placeholder_price_id",
"stripe_prepaid_price_v2_id",
"stripe_meter_id",
"stripe_event_name",
] as const;
const priceHasPreviewStripeId = ({ price }: { price: Price }) => {
const config = price.config as Partial<
Record<(typeof stripeResourceFields)[number], string | null>
>;
return stripeResourceFields.some((field) =>
isPreviewStripeId({ stripeId: config[field] }),
);
};
const findPairedEntitlement = ({
price,
entitlements,
}: {
price: Price;
entitlements: Entitlement[];
}): Entitlement | undefined =>
priceToEnt({
price,
entitlements: entitlements as EntitlementWithFeature[],
});
/**
* Classify how much of the Stripe resource set on `candidatePrice` can be
* carried forward onto `newPrice` when the two represent the same logical
* Autumn price across a plan-update or version transition.
*
* - "full" — config + paired entitlement are identical; reuse all stripe_*_id + stripe_event_name fields.
* - "stripeProductOnly" — same (feature_id, entity_feature_id) usage scope; reuse just stripe_product_id so a new price is created under the existing plan-feature Stripe product.
* - "none" — no reuse, or candidate is preview-only.
*/
export const getPriceStripeReuseLevel = ({
newPrice,
candidatePrice,
newEntitlements,
candidateEntitlements,
}: {
newPrice: Price;
candidatePrice: Price;
newEntitlements: Entitlement[];
candidateEntitlements: Entitlement[];
}): PriceStripeReuseLevel => {
if (priceHasPreviewStripeId({ price: candidatePrice })) return "none";
if (newPrice.config?.type !== candidatePrice.config?.type) return "none";
if (pricesAreSame(candidatePrice, newPrice, false)) {
if (newPrice.config?.type !== PriceType.Usage) return "full";
const newEnt = findPairedEntitlement({
price: newPrice,
entitlements: newEntitlements,
});
const candidateEnt = findPairedEntitlement({
price: candidatePrice,
entitlements: candidateEntitlements,
});
// Both null = same (null) entity scope; both have ents = compare them.
if (!newEnt && !candidateEnt) return "full";
if (!newEnt || !candidateEnt) return "none";
if (entsAreSame(candidateEnt, newEnt)) return "full";
}
if (newPrice.config?.type !== PriceType.Usage) return "none";
const newUsageConfig = newPrice.config as UsagePriceConfig;
const candidateUsageConfig = candidatePrice.config as UsagePriceConfig;
if (newUsageConfig.feature_id !== candidateUsageConfig.feature_id) {
return "none";
}
const newEnt = findPairedEntitlement({
price: newPrice,
entitlements: newEntitlements,
});
const candidateEnt = findPairedEntitlement({
price: candidatePrice,
entitlements: candidateEntitlements,
});
if (!newEnt && !candidateEnt) return "stripeProductOnly";
if (!newEnt || !candidateEnt) return "none";
if (newEnt.entity_feature_id !== candidateEnt.entity_feature_id) {
return "none";
}
return "stripeProductOnly";
};

View File

@@ -0,0 +1,69 @@
import type { Price } from "@autumn/shared";
const stripeResourceFields = [
"stripe_product_id",
"stripe_price_id",
"stripe_empty_price_id",
"stripe_placeholder_price_id",
"stripe_prepaid_price_v2_id",
"stripe_meter_id",
"stripe_event_name",
] as const;
export type PriceStripeObjectField = (typeof stripeResourceFields)[number];
const readField = (price: Price, field: PriceStripeObjectField): string | null => {
const config = price.config as Partial<Record<PriceStripeObjectField, string | null>>;
return config[field] ?? null;
};
/**
* True iff every Stripe-resource field that initStripeResourcesForBillingPlan
* cares about (`stripe_product_id`, `stripe_price_id`, `stripe_empty_price_id`,
* `stripe_placeholder_price_id`, `stripe_prepaid_price_v2_id`,
* `stripe_meter_id`, `stripe_event_name`) is identical between the two prices.
*
* Used by stripe-reuse coverage to assert that a versioned / custom price
* carried the original plan's Stripe resources forward instead of minting
* fresh ones.
*/
export const priceStripeObjectsMatch = ({
priceA,
priceB,
}: {
priceA: Price;
priceB: Price;
}): boolean => {
for (const field of stripeResourceFields) {
if (readField(priceA, field) !== readField(priceB, field)) return false;
}
return true;
};
/**
* Returns the list of Stripe-resource fields whose values differ between
* `priceA` and `priceB`. Useful for surfacing why a reuse assertion failed.
*/
export const diffPriceStripeObjects = ({
priceA,
priceB,
}: {
priceA: Price;
priceB: Price;
}): {
field: PriceStripeObjectField;
a: string | null;
b: string | null;
}[] => {
const diffs: {
field: PriceStripeObjectField;
a: string | null;
b: string | null;
}[] = [];
for (const field of stripeResourceFields) {
const a = readField(priceA, field);
const b = readField(priceB, field);
if (a !== b) diffs.push({ field, a, b });
}
return diffs;
};

View File

@@ -0,0 +1,6 @@
export const PREVIEW_STRIPE_PRICE_ID_PREFIX = "price_PREVIEW_";
export const PREVIEW_STRIPE_PRODUCT_ID_PREFIX = "prod_PREVIEW_";
export const isPreviewStripeId = ({ stripeId }: { stripeId?: string | null }) =>
stripeId?.startsWith(PREVIEW_STRIPE_PRICE_ID_PREFIX) === true ||
stripeId?.startsWith(PREVIEW_STRIPE_PRODUCT_ID_PREFIX) === true;

View File

@@ -65,6 +65,7 @@
"react-dom": "^18.2.0",
"react-grab": "^0.1.29",
"react-hotkeys-hook": "^4.6.1",
"react-resizable-panels": "^4.11.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.6.2",
"recharts": "^3.3.0",
@@ -78,6 +79,7 @@
"tailwindcss": "^4.0.13",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^3.25.23",
"zustand": "^5.0.8"
},

View File

@@ -16,7 +16,6 @@ import {
isFreeProductV2,
isOneOffProductV2,
productV2ToFrontendProduct,
UsageModel,
} from "@autumn/shared";
import { useStore } from "@tanstack/react-form";
import {
@@ -276,8 +275,7 @@ export function AttachFormProvider({
[fullCustomer?.customer_products],
);
const disableProration =
isFreeToPaidTransition && !hasActiveSubscription;
const disableProration = isFreeToPaidTransition && !hasActiveSubscription;
const { prepaidItems } = usePrepaidItems({ product: effectiveProduct });
@@ -324,19 +322,16 @@ export function AttachFormProvider({
resetGrantFree();
}
// Initialize prepaid options for the selected product
// Initialize prepaid options for the selected product.
// Values start as undefined (not 0) so that unset quantities are omitted
// from the request — the backend carries over existing prepaid balances
// when no option is provided for a feature.
if (product) {
const newInitialPrepaidOptions: Record<string, number> = {};
for (const item of product.items) {
if (item.usage_model === UsageModel.Prepaid && item.feature_id) {
newInitialPrepaidOptions[item.feature_id] = 0;
}
}
const currentPrepaidOptions = form.store.state.values.prepaidOptions;
const resolvedPrepaidOptions =
isProductChange || Object.keys(currentPrepaidOptions).length === 0
? newInitialPrepaidOptions
: { ...newInitialPrepaidOptions, ...currentPrepaidOptions };
? {}
: { ...currentPrepaidOptions };
form.setFieldValue("prepaidOptions", resolvedPrepaidOptions);
setInitialPrepaidOptions(
resolvedPrepaidOptions as Record<string, number>,

View File

@@ -363,8 +363,7 @@ export function SendInvoiceStageWithPreview({
scheduledStartDate?: number | null;
}) {
const previewData = previewQuery.data;
const effectiveScheduledStartDate =
scheduledStartDate ?? previewData?.next_cycle?.starts_at ?? null;
const effectiveScheduledStartDate = scheduledStartDate ?? null;
const totals = useMemo(
() => buildAttachPreviewTotals({ previewData, startDate: null }),

View File

@@ -1,10 +1,5 @@
import type { ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { AdminHover } from "@/components/general/AdminHover";
export type AdminPlanIds = {
stripe_price_id?: string | null;
@@ -12,22 +7,6 @@ export type AdminPlanIds = {
internal_product_id?: string | null;
};
const Row = ({ label, value }: { label: string; value: string | null | undefined }) => {
if (!value) return null;
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wide text-t4">
{label}
</span>
<code className="text-xs font-mono text-t1 break-all">{value}</code>
</div>
);
};
/**
* Wraps a child with an admin-only hover tooltip showing identifying IDs
* for the displayed plan/price. No-op for non-admin users.
*/
export const AdminPlanIdsTooltip = ({
children,
ids,
@@ -35,28 +14,22 @@ export const AdminPlanIdsTooltip = ({
children: ReactNode;
ids: AdminPlanIds;
}) => {
const { isAdmin } = useAdmin();
const texts = [
ids.stripe_price_id && {
key: "Stripe price id",
value: ids.stripe_price_id,
},
ids.stripe_product_id && {
key: "Stripe product id",
value: ids.stripe_product_id,
},
ids.internal_product_id && {
key: "Autumn internal id",
value: ids.internal_product_id,
},
].filter(Boolean) as { key: string; value: string }[];
const hasAnyId = Boolean(
ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id,
);
if (texts.length === 0) return <>{children}</>;
if (!isAdmin || !hasAnyId) {
return <>{children}</>;
}
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="flex flex-col gap-2 max-w-sm"
>
<Row label="Stripe price id" value={ids.stripe_price_id} />
<Row label="Stripe product id" value={ids.stripe_product_id} />
<Row label="Autumn internal id" value={ids.internal_product_id} />
</TooltipContent>
</Tooltip>
);
return <AdminHover texts={texts}>{children}</AdminHover>;
};

View File

@@ -99,13 +99,13 @@ const DropdownMenuSubContent = React.forwardRef<
<MenuPrimitive.Positioner
sideOffset={sideOffset}
positionMethod="fixed"
className="isolate z-[200]"
className="isolate z-[160]"
>
<MenuPrimitive.Popup
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"z-[200] min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 shadow-lg data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50 text-zinc-800 font-regular",
"z-[160] min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 shadow-lg data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50 text-zinc-800 font-regular",
className,
)}
{...props}
@@ -130,13 +130,13 @@ const DropdownMenuContent = React.forwardRef<
side={side}
align={align}
positionMethod="fixed"
className="isolate z-[200] outline-none"
className="isolate z-[160] outline-none"
>
<MenuPrimitive.Popup
ref={ref}
data-slot="dropdown-menu-content"
className={cn(
"z-[200] min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-zinc-950 shadow-md dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50",
"z-[160] min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-zinc-950 shadow-md dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}

View File

@@ -61,7 +61,7 @@ function PopoverContent({
side={side}
sideOffset={sideOffset}
positionMethod="fixed"
className="isolate z-[200]"
className="isolate z-[160]"
>
<PopoverPrimitive.Popup
data-slot="popover-content"

View File

@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -55,7 +55,7 @@ export function FeatureSelector({
<CaretDownIcon className="size-3 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[200]">
<DropdownMenuContent align="start" className="z-[160]">
<div className="max-h-60 overflow-y-auto">
{features.length === 0 ? (
<div className="py-4 text-center text-sm text-t4">

View File

@@ -93,7 +93,7 @@ function SelectContent({
side={side}
alignItemWithTrigger={false}
positionMethod="fixed"
className="isolate z-[200]"
className="isolate z-[160]"
>
<SelectPrimitive.Popup
data-slot="select-content"

Some files were not shown because too many files have changed in this diff Show More