Merge branch 'dev' into feat/attach-auto-topups-manually
This commit is contained in:
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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 \
|
||||
.
|
||||
|
||||
35
.github/workflows/validate-schema.yml
vendored
35
.github/workflows/validate-schema.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -146,4 +146,4 @@ AGENTS.md
|
||||
|
||||
server/.turbo
|
||||
.context
|
||||
.opencode
|
||||
.opencode/
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
12
bun.lock
12
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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
74
server/src/external/axiom/aplUtils.ts
vendored
Normal 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
20
server/src/external/axiom/initAxiom.ts
vendored
Normal 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;
|
||||
@@ -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!);
|
||||
|
||||
236
server/src/external/stripe/previewStripeResourceIds.ts
vendored
Normal file
236
server/src/external/stripe/previewStripeResourceIds.ts
vendored
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
1
server/src/external/tinybird/initTinybird.ts
vendored
1
server/src/external/tinybird/initTinybird.ts
vendored
@@ -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 */
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()];
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function attach({
|
||||
const billingContext = await setupAttachBillingContext({
|
||||
ctx,
|
||||
params,
|
||||
preview,
|
||||
contextOverride,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({
|
||||
const billingContext = await setupCreateScheduleBillingContext({
|
||||
ctx,
|
||||
params,
|
||||
preview: true,
|
||||
});
|
||||
|
||||
await handleCreateScheduleErrors({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function multiAttach({
|
||||
const billingContext = await setupMultiAttachBillingContext({
|
||||
ctx,
|
||||
params,
|
||||
preview,
|
||||
});
|
||||
|
||||
// 2. Errors
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const handleVersionProductV2 = async ({
|
||||
newItems: newProductV2.items,
|
||||
features,
|
||||
product: newProduct,
|
||||
logger: console,
|
||||
logger: ctx.logger,
|
||||
isCustom: false,
|
||||
newVersion: true,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
143
server/src/internal/workbench/handlers/handleListRequestLogs.ts
Normal file
143
server/src/internal/workbench/handlers/handleListRequestLogs.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
7
server/src/internal/workbench/workbenchRouter.ts
Normal file
7
server/src/internal/workbench/workbenchRouter.ts
Normal 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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
207
server/tests/unit/shared/getPriceStripeReuseLevel.test.ts
Normal file
207
server/tests/unit/shared/getPriceStripeReuseLevel.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, '') }}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface BillingContext {
|
||||
userMetadata?: Record<string, string>;
|
||||
|
||||
skipBillingChanges?: boolean;
|
||||
dryRunStripe?: boolean;
|
||||
|
||||
checkoutMode?: CheckoutMode;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
vite/src/components/ui/resizable.tsx
Normal file
50
vite/src/components/ui/resizable.tsx
Normal 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 }
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user