diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8364ebdd..8f963d32d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 \ . diff --git a/.github/workflows/validate-schema.yml b/.github/workflows/validate-schema.yml index 4a0455ba7..291f9e806 100644 --- a/.github/workflows/validate-schema.yml +++ b/.github/workflows/validate-schema.yml @@ -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 diff --git a/.gitignore b/.gitignore index a9c70a0c0..b85bd1dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,4 @@ AGENTS.md server/.turbo .context -.opencode +.opencode/ diff --git a/.opencode/opencode.json b/.opencode/opencode.json deleted file mode 100644 index 14dbb803e..000000000 --- a/.opencode/opencode.json +++ /dev/null @@ -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" - ] -} diff --git a/AGENTS.md b/AGENTS.md index a4bee656f..5bf9e2c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/bun.lock b/bun.lock index 1846c3ae7..ca77a463b 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/openapi/v2.1/contracts/balancesContract.ts b/packages/openapi/v2.1/contracts/balancesContract.ts index e2001e3ba..9c9dd96f0 100644 --- a/packages/openapi/v2.1/contracts/balancesContract.ts +++ b/packages/openapi/v2.1/contracts/balancesContract.ts @@ -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, + }, + ], }, ], }), diff --git a/server/package.json b/server/package.json index f3ec18c57..fbee53607 100644 --- a/server/package.json +++ b/server/package.json @@ -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:", diff --git a/server/src/external/axiom/aplUtils.ts b/server/src/external/axiom/aplUtils.ts new file mode 100644 index 000000000..faea744dd --- /dev/null +++ b/server/src/external/axiom/aplUtils.ts @@ -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}`; +}; diff --git a/server/src/external/axiom/initAxiom.ts b/server/src/external/axiom/initAxiom.ts new file mode 100644 index 000000000..d6445f156 --- /dev/null +++ b/server/src/external/axiom/initAxiom.ts @@ -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; diff --git a/server/src/external/stripe/createStripePrice/createStripePrice.ts b/server/src/external/stripe/createStripePrice/createStripePrice.ts index 260f4e29f..ac2e4bd33 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrice.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrice.ts @@ -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!); diff --git a/server/src/external/stripe/previewStripeResourceIds.ts b/server/src/external/stripe/previewStripeResourceIds.ts new file mode 100644 index 000000000..0b3fa7d6f --- /dev/null +++ b/server/src/external/stripe/previewStripeResourceIds.ts @@ -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; + 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; + + 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; + 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}`, + }); + } + } +}; diff --git a/server/src/external/tinybird/initTinybird.ts b/server/src/external/tinybird/initTinybird.ts index cb71e7f00..25468e99e 100644 --- a/server/src/external/tinybird/initTinybird.ts +++ b/server/src/external/tinybird/initTinybird.ts @@ -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 */ diff --git a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts index 2dd8ba9fe..f425abe26 100644 --- a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts +++ b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts @@ -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< diff --git a/server/src/external/tinybird/sendEvents/mapEvent.ts b/server/src/external/tinybird/sendEvents/mapEvent.ts index 1d7434506..ab35074c3 100644 --- a/server/src/external/tinybird/sendEvents/mapEvent.ts +++ b/server/src/external/tinybird/sendEvents/mapEvent.ts @@ -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, }; }; diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index 92ab5d5bb..2406ba3d0 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -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, }; }); diff --git a/server/src/internal/api/events/EventService.ts b/server/src/internal/api/events/EventService.ts index 3449072a3..f8eed02a6 100644 --- a/server/src/internal/api/events/EventService.ts +++ b/server/src/internal/api/events/EventService.ts @@ -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, diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts index 9e89754b7..f496dd967 100644 --- a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts +++ b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts @@ -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, }; }; diff --git a/server/src/internal/balances/events/initEvent.ts b/server/src/internal/balances/events/initEvent.ts index 6c70218ac..a102a80bc 100644 --- a/server/src/internal/balances/events/initEvent.ts +++ b/server/src/internal/balances/events/initEvent.ts @@ -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; diff --git a/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts index 2650db1b8..cf88337b2 100644 --- a/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts +++ b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts @@ -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); diff --git a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts index d2f5acfa6..8f1c0ce97 100644 --- a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts +++ b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts @@ -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, }; }; diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index e3a320043..74fc2bd91 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -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, }; }; diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index a93e5ecff..d0f3a84ac 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -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( diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 056e3b54a..9ac4e797a 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -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; } diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 810964696..95f010401 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -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( diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 3c20a5656..8cffcfbf1 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -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; } diff --git a/server/src/internal/balances/utils/deductionV2/index.ts b/server/src/internal/balances/utils/deductionV2/index.ts index e416db289..25cc39d27 100644 --- a/server/src/internal/balances/utils/deductionV2/index.ts +++ b/server/src/internal/balances/utils/deductionV2/index.ts @@ -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"; diff --git a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts new file mode 100644 index 000000000..1ff5c65c8 --- /dev/null +++ b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts @@ -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(); + const rolloverIdToFeatureId = new Map(); + + 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(); + + 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()]; +}; diff --git a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts index af65bf2f6..1c8fde046 100644 --- a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts @@ -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, - redis, - ), + fetchLegacyLockReceiptJson({ lockReceiptKey }), fetchAndClaimLockReceiptV2FromCandidates({ ctx, lockId, diff --git a/server/src/internal/balances/utils/lock/saveLockReceipt.ts b/server/src/internal/balances/utils/lock/saveLockReceipt.ts index c552c1e45..129304894 100644 --- a/server/src/internal/balances/utils/lock/saveLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/saveLockReceipt.ts @@ -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>, diff --git a/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts b/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts index 3082640e5..76be24c44 100644 --- a/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts +++ b/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts @@ -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); diff --git a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts index 311754541..5937807f9 100644 --- a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts +++ b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index ffb08ea8d..874ec8a31 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -41,6 +41,7 @@ export async function attach({ const billingContext = await setupAttachBillingContext({ ctx, params, + preview, contextOverride, }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 196515ced..89d8344b1 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -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 => { 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, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 746bccc85..d8cdde138 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -106,9 +106,11 @@ const setupImmediateMultiProductTrialContext = async ({ export const setupImmediateMultiProductBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => { 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, }; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index d219c1ca0..481cfbf9c 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({ const billingContext = await setupCreateScheduleBillingContext({ ctx, params, + preview: true, }); await handleCreateScheduleErrors({ diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index e71a1c5e5..3d38fea39 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -70,9 +70,11 @@ const setupCreateScheduleCheckoutMode = ({ export const setupCreateScheduleBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: CreateScheduleParamsV0; + preview?: boolean; }): Promise => { const normalizedPhases = normalizeCreateSchedulePhases({ phases: params.phases, @@ -99,6 +101,7 @@ export const setupCreateScheduleBillingContext = async ({ const billingContext = await setupImmediateMultiProductBillingContext({ ctx, params: immediateParams, + preview, }); validateCreateSchedulePhasePlans({ diff --git a/server/src/internal/billing/v2/actions/migrate/migrate.ts b/server/src/internal/billing/v2/actions/migrate/migrate.ts index 276bc365e..a4514c5a1 100644 --- a/server/src/internal/billing/v2/actions/migrate/migrate.ts +++ b/server/src/internal/billing/v2/actions/migrate/migrate.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts index ee74b5f79..1ef79e545 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts @@ -34,6 +34,7 @@ export async function multiAttach({ const billingContext = await setupMultiAttachBillingContext({ ctx, params, + preview, }); // 2. Errors diff --git a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts index 6f10040d0..664a8b7d7 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts @@ -11,11 +11,14 @@ import { setupImmediateMultiProductBillingContext } from "../../common/immediate export const setupMultiAttachBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => setupImmediateMultiProductBillingContext({ ctx, params, + preview, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 45d873610..2057cc9d7 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -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 => { const fullCustomer = await setupFullCustomerContext({ @@ -210,6 +212,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ actionSource: "updateSubscription", skipBillingChanges, + dryRunStripe: preview, checkoutMode, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 70c19d47f..7ea07a791 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -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({ - ctx, - action: CheckoutAction.UpdateSubscription, - params, - billingContext, - billingPlan, - }); + ctx, + action: CheckoutAction.UpdateSubscription, + params, + billingContext, + billingPlan, + }); return autumnCheckoutResult; } diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index c35dbe12e..b28802438 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts index 8441f920b..3f4f14b15 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts @@ -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 diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts index a95a6608c..3c250ab76 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts @@ -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, }, }; }); diff --git a/server/src/internal/product/actions/createProduct.ts b/server/src/internal/product/actions/createProduct.ts index 943749a49..2fe40c00b 100644 --- a/server/src/internal/product/actions/createProduct.ts +++ b/server/src/internal/product/actions/createProduct.ts @@ -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, diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index f6ead34c8..674ef69fb 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -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, diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 581f08c5b..a4ce06080 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -98,7 +98,7 @@ export const handleVersionProductV2 = async ({ newItems: newProductV2.items, features, product: newProduct, - logger: console, + logger: ctx.logger, isCustom: false, newVersion: true, }); diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index 6fbbe6aba..d831fdfa5 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -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[] = []; 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, diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 9f7c56aba..967ccedc7 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -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, diff --git a/server/src/internal/rewards/RewardRedemptionService.ts b/server/src/internal/rewards/RewardRedemptionService.ts deleted file mode 100644 index 1d9ca261c..000000000 --- a/server/src/internal/rewards/RewardRedemptionService.ts +++ /dev/null @@ -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})`, - ); - } -} diff --git a/server/src/internal/workbench/handlers/handleListRequestLogs.ts b/server/src/internal/workbench/handlers/handleListRequestLogs.ts new file mode 100644 index 000000000..002551dd9 --- /dev/null +++ b/server/src/internal/workbench/handlers/handleListRequestLogs.ts @@ -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; +} + +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, + 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, + 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; + 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, + }); + } + }, +}); diff --git a/server/src/internal/workbench/workbenchRouter.ts b/server/src/internal/workbench/workbenchRouter.ts new file mode 100644 index 000000000..c0e4e41bc --- /dev/null +++ b/server/src/internal/workbench/workbenchRouter.ts @@ -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(); + +workbenchRouter.post("/requests", ...handleListRequestLogs); diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index a792697d9..b86f99c71 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -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(); @@ -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) { diff --git a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts index 2d07507f9..f9fb23fd0 100644 --- a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts +++ b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts @@ -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, }); }; diff --git a/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts new file mode 100644 index 000000000..5fb282443 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts @@ -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(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 }], + }); +}); diff --git a/server/tests/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts new file mode 100644 index 000000000..3a5d0f43b --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -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(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); + }, +); diff --git a/server/tests/integration/billing/migrations/migrate-states.test.ts b/server/tests/integration/billing/migrations/migrate-states.test.ts index a4423fadb..975b54bf0 100644 --- a/server/tests/integration/billing/migrations/migrate-states.test.ts +++ b/server/tests/integration/billing/migrations/migrate-states.test.ts @@ -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(); +}); diff --git a/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts new file mode 100644 index 000000000..11a02035e --- /dev/null +++ b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts @@ -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, + }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts new file mode 100644 index 000000000..1d6c916cf --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts @@ -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(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(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(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(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(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(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(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(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(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(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(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts new file mode 100644 index 000000000..262382de7 --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts @@ -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> => { + const map = new Map>(); + for (const price of prices) { + const config = price.config as Record; + 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).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; + const afterConfig = afterMessages.config as Record; + 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; + const afterConfig = afterMessages.config as Record; + 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); +}); diff --git a/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts new file mode 100644 index 000000000..85c674dbc --- /dev/null +++ b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts @@ -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; + 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); + } +}; diff --git a/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts new file mode 100644 index 000000000..379e4c7a0 --- /dev/null +++ b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts @@ -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 ?? ""; + return `feature:${featureId ?? ""}|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; + 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; + const customerConfig = customerPrice.config as Record; + 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 }; diff --git a/server/tests/integration/crud/customers/customer-billing-controls.test.ts b/server/tests/integration/crud/customers/customer-billing-controls.test.ts index 30cd73cef..73435968d 100644 --- a/server/tests/integration/crud/customers/customer-billing-controls.test.ts +++ b/server/tests/integration/crud/customers/customer-billing-controls.test.ts @@ -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(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()); +}); diff --git a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts new file mode 100644 index 000000000..74b5d56ff --- /dev/null +++ b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts @@ -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 => ({ + 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, + }, + ]); + }); +}); diff --git a/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts new file mode 100644 index 000000000..f46eb29d8 --- /dev/null +++ b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/billing/init-stripe-resources-for-products.test.ts b/server/tests/unit/billing/init-stripe-resources-for-products.test.ts index 3da1717fb..b3040c31c 100644 --- a/server/tests/unit/billing/init-stripe-resources-for-products.test.ts +++ b/server/tests/unit/billing/init-stripe-resources-for-products.test.ts @@ -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: [], diff --git a/server/tests/unit/products/handle-new-product-items-versioning.test.ts b/server/tests/unit/products/handle-new-product-items-versioning.test.ts new file mode 100644 index 000000000..37a236b17 --- /dev/null +++ b/server/tests/unit/products/handle-new-product-items-versioning.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts b/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts new file mode 100644 index 000000000..e1c172e26 --- /dev/null +++ b/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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(); + }); +}); diff --git a/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts new file mode 100644 index 000000000..745543b75 --- /dev/null +++ b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts @@ -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 => ({ + 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 => ({ + 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"); + }); +}); diff --git a/server/tests/utils/fixtures/itemsV2.ts b/server/tests/utils/fixtures/itemsV2.ts index 696cf48df..493e7900c 100644 --- a/server/tests/utils/fixtures/itemsV2.ts +++ b/server/tests/utils/fixtures/itemsV2.ts @@ -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; diff --git a/server/tests/utils/productUtils.ts b/server/tests/utils/productUtils.ts index 914e9351c..cbbc4e28d 100644 --- a/server/tests/utils/productUtils.ts +++ b/server/tests/utils/productUtils.ts @@ -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 }), ); } diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index f2cb87e8f..41fb656f0 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -242,6 +242,7 @@ type ScenarioConfig = { stripeCustomerOverrides?: Partial; 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, }); } diff --git a/server/tinybird/datasources/events.datasource b/server/tinybird/datasources/events.datasource index abb7e2ba7..ecefb94ed 100644 --- a/server/tinybird/datasources/events.datasource +++ b/server/tinybird/datasources/events.datasource @@ -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" \ No newline at end of file +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 \ No newline at end of file diff --git a/server/tinybird/materializations/events_by_timestamp_mv.datasource b/server/tinybird/materializations/events_by_timestamp_mv.datasource index e7e5648aa..f11a642a6 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv.datasource +++ b/server/tinybird/materializations/events_by_timestamp_mv.datasource @@ -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)" diff --git a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe index 2ce837d0e..4d5a6fd2c 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe +++ b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe @@ -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 diff --git a/server/tinybird/pipes/list_events_paginated.pipe b/server/tinybird/pipes/list_events_paginated.pipe index 0c1dbd1ba..f6a88289b 100644 --- a/server/tinybird/pipes/list_events_paginated.pipe +++ b/server/tinybird/pipes/list_events_paginated.pipe @@ -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, '') }} diff --git a/shared/api/balances/track/changes/V2.0_TrackChange.ts b/shared/api/balances/track/changes/V2.0_TrackChange.ts index 2b104b454..f7fd924bd 100644 --- a/shared/api/balances/track/changes/V2.0_TrackChange.ts +++ b/shared/api/balances/track/changes/V2.0_TrackChange.ts @@ -49,8 +49,18 @@ export const V2_0_TrackChange = defineVersionChange({ } } + const { + customer_id, + entity_id, + event_name, + value, + }: z.infer = input; + return { - ...input, + customer_id, + entity_id, + event_name, + value, balance: transformedBalance, balances: transformedBalances, }; diff --git a/shared/api/balances/track/trackResponseV3.ts b/shared/api/balances/track/trackResponseV3.ts index 8850f54ca..b32c54a2d 100644 --- a/shared/api/balances/track/trackResponseV3.ts +++ b/shared/api/balances/track/trackResponseV3.ts @@ -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; + /** * 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; diff --git a/shared/api/events/list/eventsListResponse.ts b/shared/api/events/list/eventsListResponse.ts index b0fb071e2..a56174efc 100644 --- a/shared/api/events/list/eventsListResponse.ts +++ b/shared/api/events/list/eventsListResponse.ts @@ -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( diff --git a/shared/api/products/crud/createPlanParamsV1.ts b/shared/api/products/crud/createPlanParamsV1.ts index f97a578b4..258b40bf7 100644 --- a/shared/api/products/crud/createPlanParamsV1.ts +++ b/shared/api/products/crud/createPlanParamsV1.ts @@ -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 diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts index 57da6a97c..9dddd0f9c 100644 --- a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts +++ b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts @@ -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 }), }; } diff --git a/shared/api/products/productOpModels.ts b/shared/api/products/productOpModels.ts index faab01772..dd69394d2 100644 --- a/shared/api/products/productOpModels.ts +++ b/shared/api/products/productOpModels.ts @@ -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], diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index 511f9c4a7..2c13ed27f 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -85,6 +85,7 @@ export interface BillingContext { userMetadata?: Record; skipBillingChanges?: boolean; + dryRunStripe?: boolean; checkoutMode?: CheckoutMode; diff --git a/shared/models/eventModels/eventTable.ts b/shared/models/eventModels/eventTable.ts index a094f79f9..5f574a2c2 100644 --- a/shared/models/eventModels/eventTable.ts +++ b/shared/models/eventModels/eventTable.ts @@ -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>(), + deductions: jsonb().$type(), }, (table) => [ foreignKey({ diff --git a/shared/utils/index.ts b/shared/utils/index.ts index bcae47dc5..23d5d0423 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -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"; diff --git a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts index 66eae1c8a..06c569cdf 100644 --- a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts +++ b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts @@ -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, diff --git a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts new file mode 100644 index 000000000..de5923cc6 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts @@ -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>; + +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 }; +}; diff --git a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts new file mode 100644 index 000000000..667fc17a2 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts @@ -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"; +}; diff --git a/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts new file mode 100644 index 000000000..3e7ab54f9 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts @@ -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>; + 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; +}; diff --git a/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts b/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts new file mode 100644 index 000000000..8f5034573 --- /dev/null +++ b/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts @@ -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; diff --git a/vite/package.json b/vite/package.json index 527989bc1..98fdb7c35 100644 --- a/vite/package.json +++ b/vite/package.json @@ -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" }, diff --git a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx index 618cf0636..bbfd5325d 100644 --- a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx +++ b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx @@ -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 = {}; - 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, diff --git a/vite/src/components/forms/shared/SendInvoiceStage.tsx b/vite/src/components/forms/shared/SendInvoiceStage.tsx index 54545370d..c0e95abff 100644 --- a/vite/src/components/forms/shared/SendInvoiceStage.tsx +++ b/vite/src/components/forms/shared/SendInvoiceStage.tsx @@ -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 }), diff --git a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx index 3076347b5..9fc9ea4cc 100644 --- a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx +++ b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx @@ -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 ( -
- - {label} - - {value} -
- ); -}; - -/** - * 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 ( - - {children} - - - - - - - ); + return {children}; }; diff --git a/vite/src/components/ui/dropdown-menu.tsx b/vite/src/components/ui/dropdown-menu.tsx index 7be114540..a8eb8d251 100644 --- a/vite/src/components/ui/dropdown-menu.tsx +++ b/vite/src/components/ui/dropdown-menu.tsx @@ -99,13 +99,13 @@ const DropdownMenuSubContent = React.forwardRef< + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ )} + + ) +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/vite/src/components/v2/FeatureSelector.tsx b/vite/src/components/v2/FeatureSelector.tsx index 05b0e9301..500f850a4 100644 --- a/vite/src/components/v2/FeatureSelector.tsx +++ b/vite/src/components/v2/FeatureSelector.tsx @@ -55,7 +55,7 @@ export function FeatureSelector({ - +
{features.length === 0 ? (
diff --git a/vite/src/components/v2/selects/Select.tsx b/vite/src/components/v2/selects/Select.tsx index ce88c953e..c0789a676 100644 --- a/vite/src/components/v2/selects/Select.tsx +++ b/vite/src/components/v2/selects/Select.tsx @@ -93,7 +93,7 @@ function SelectContent({ side={side} alignItemWithTrigger={false} positionMethod="fixed" - className="isolate z-[200]" + className="isolate z-[160]" > ; +} + +interface ListResponse { + logs: RequestLogEntry[]; + unconfigured?: boolean; +} + +export const useCusRequestLogsQuery = ({ + customerId, + enabled, +}: { + customerId: string | undefined; + enabled: boolean; +}) => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + const filters = useWorkbenchStore((s) => s.filters); + + const { data, isLoading, isFetching, error, refetch } = + useQuery({ + queryKey: buildKey([ + "customer_request_logs", + customerId, + filters.method, + filters.status, + filters.search, + ]), + queryFn: async () => { + const { data } = await axiosInstance.post( + "/workbench/requests", + { + customer_id: customerId, + method: filters.method, + status: filters.status, + search: filters.search || undefined, + }, + ); + return data; + }, + enabled: enabled && !!customerId, + staleTime: 30_000, + refetchOnWindowFocus: true, + placeholderData: (prev) => prev, + }); + + return { + logs: data?.logs ?? [], + unconfigured: data?.unconfigured === true, + isLoading, + isFetching, + error, + refetch, + }; +}; + +export const filterMethods: { value: WorkbenchMethod; label: string }[] = [ + { value: "all", label: "All methods" }, + { value: "GET", label: "GET" }, + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "PATCH", label: "PATCH" }, + { value: "DELETE", label: "DELETE" }, +]; + +export const filterStatuses: { value: WorkbenchStatus; label: string }[] = [ + { value: "all", label: "All statuses" }, + { value: "2xx", label: "2xx" }, + { value: "4xx", label: "4xx" }, + { value: "5xx", label: "5xx" }, +]; diff --git a/vite/src/hooks/stores/useWorkbenchStore.ts b/vite/src/hooks/stores/useWorkbenchStore.ts new file mode 100644 index 000000000..ccf36302d --- /dev/null +++ b/vite/src/hooks/stores/useWorkbenchStore.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type WorkbenchMethod = + | "all" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; +export type WorkbenchStatus = "all" | "2xx" | "4xx" | "5xx"; + +export const WORKBENCH_MIN_HEIGHT = 200; +export const WORKBENCH_MAX_HEIGHT_RATIO = 0.92; +export const WORKBENCH_DEFAULT_HEIGHT = 360; + +interface WorkbenchFilters { + method: WorkbenchMethod; + status: WorkbenchStatus; + search: string; +} + +interface WorkbenchState { + isOpen: boolean; + height: number; + filters: WorkbenchFilters; + selectedLogId: string | null; + + open: () => void; + close: () => void; + toggle: () => void; + setHeight: (height: number) => void; + setFilters: (filters: Partial) => void; + setSelectedLogId: (id: string | null) => void; +} + +const DEFAULT_FILTERS: WorkbenchFilters = { + method: "all", + status: "all", + search: "", +}; + +export const useWorkbenchStore = create()( + persist( + (set) => ({ + isOpen: false, + height: WORKBENCH_DEFAULT_HEIGHT, + filters: DEFAULT_FILTERS, + selectedLogId: null, + + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((s) => ({ isOpen: !s.isOpen })), + setHeight: (height) => set({ height }), + setFilters: (filters) => + set((s) => ({ filters: { ...s.filters, ...filters } })), + setSelectedLogId: (id) => set({ selectedLogId: id }), + }), + { + name: "workbench-store", + partialize: (s) => ({ height: s.height }), + }, + ), +); diff --git a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx index 03e931d42..a86a12a2e 100644 --- a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx +++ b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx @@ -105,18 +105,18 @@ const AutoTopupRow = ({
Threshold: {autoTopup.threshold.toLocaleString()} Qty: {autoTopup.quantity.toLocaleString()} - {purchaseLimit && ( - - {hasExpandedLimit - ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` - : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} - - )} - {hasExpandedLimit && purchaseLimit.next_reset_at && ( - - Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} - - )} + {purchaseLimit && purchaseLimit.limit != null && purchaseLimit.interval != null && ( + + {hasExpandedLimit + ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` + : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} + + )} + {hasExpandedLimit && purchaseLimit.next_reset_at && ( + + Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} + + )}
); diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx index 4c9d18fec..7c59f993a 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx @@ -18,7 +18,7 @@ import { AttachFooterV3 } from "@/components/forms/attach-v2/components/AttachFo import { buildAttachPreviewTotals, getAttachPreviewLineItems, - getAttachScheduledStartDate, + isFutureStartDate, } from "@/components/forms/attach-v2/utils/buildAttachPreviewTotals"; import { GenerateCheckoutStageWithPreview, @@ -369,10 +369,7 @@ function SendInvoiceContent() { const { setSheet } = useSheetStore(); const itemId = useSheetStore((s) => s.itemId); const startDate = useStore(form.store, (state) => state.values.startDate); - const scheduledStartDate = getAttachScheduledStartDate({ - startDate, - previewData: previewQuery.data, - }); + const scheduledStartDate = isFutureStartDate(startDate) ? startDate : null; return ( +
); diff --git a/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx b/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx index 3975215d1..4d50884e2 100644 --- a/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx +++ b/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx @@ -31,6 +31,7 @@ const EXPAND_PARAMS = [ "entities", "referrals", "payment_method", + "billing_controls.auto_topups.purchase_limit", ].join(","); export function ShowCustomerObjectSheet({ diff --git a/vite/src/views/customers2/customer/workbench/Workbench.tsx b/vite/src/views/customers2/customer/workbench/Workbench.tsx new file mode 100644 index 000000000..4636c196e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/Workbench.tsx @@ -0,0 +1,153 @@ +import { + ArrowClockwiseIcon, + TerminalWindowIcon, + XIcon, +} from "@phosphor-icons/react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { useCusRequestLogsQuery } from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { cn } from "@/lib/utils"; +import { useCustomerContext } from "../CustomerContext"; +import { useWorkbenchEscape } from "./hooks/useWorkbenchEscape"; +import { useWorkbenchResize } from "./hooks/useWorkbenchResize"; +import { WorkbenchFilters } from "./WorkbenchFilters"; +import { WorkbenchLogDetail } from "./WorkbenchLogDetail"; +import { WorkbenchLogList } from "./WorkbenchLogList"; + +export const Workbench = () => { + const { customer } = useCustomerContext(); + const isOpen = useWorkbenchStore((s) => s.isOpen); + const height = useWorkbenchStore((s) => s.height); + const close = useWorkbenchStore((s) => s.close); + + const { handleProps } = useWorkbenchResize(); + useWorkbenchEscape(); + + const { refetch, isFetching } = useCusRequestLogsQuery({ + customerId: customer?.id, + enabled: isOpen, + }); + + return ( + { + if (!open) close(); + }} + modal={false} + dismissible={false} + autoFocus={false} + repositionInputs={false} + noBodyStyles + > + + + + Workbench + + + refetch()} + isFetching={isFetching} + onClose={close} + handleProps={handleProps} + /> + +
+ + + + + + + + + +
+
+
+
+ ); +}; + +type ResizeHandleProps = ReturnType["handleProps"]; + +const WorkbenchHeader = ({ + onRefresh, + isFetching, + onClose, + handleProps, +}: { + onRefresh: () => void; + isFetching: boolean; + onClose: () => void; + handleProps: ResizeHandleProps; +}) => ( +
+ + +
+
+ + Workbench +
+
+
+ Logs +
+
+ +
+ +
+ + } + title="Refresh" + className="cursor-pointer" + /> + } + title="Close" + className="cursor-pointer" + /> +
+
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx new file mode 100644 index 000000000..c1ac9b298 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx @@ -0,0 +1,52 @@ +import { TerminalWindowIcon } from "@phosphor-icons/react"; +import { useMatch } from "react-router"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { cn } from "@/lib/utils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { useSidebarContext } from "@/views/main-sidebar/SidebarContext"; + +export const WorkbenchButton = () => { + const { isAdmin } = useAdmin(); + const onCustomerView = useMatch("/customers/:customer_id"); + const onCustomerSubView = useMatch("/customers/:customer_id/*"); + const isMobile = useIsMobile(); + const { expanded } = useSidebarContext(); + + const toggle = useWorkbenchStore((s) => s.toggle); + const isOpen = useWorkbenchStore((s) => s.isOpen); + + if (!isAdmin || isMobile || (!onCustomerView && !onCustomerSubView)) { + return null; + } + + return ( + + ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx new file mode 100644 index 000000000..2e9b43741 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx @@ -0,0 +1,12 @@ +export const WorkbenchEmptyState = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+

{title}

+

{children}

+
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx new file mode 100644 index 000000000..5b9800c5e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx @@ -0,0 +1,43 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/v2/selects/Select"; + +interface FilterOption { + value: T; + label: string; +} + +export const WorkbenchFilterSelect = ({ + value, + options, + onChange, + placeholder, +}: { + value: T; + options: readonly FilterOption[]; + onChange: (next: T) => void; + placeholder: string; +}) => { + const label = options.find((o) => o.value === value)?.label ?? placeholder; + + return ( + + ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx new file mode 100644 index 000000000..a498ffdd4 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx @@ -0,0 +1,58 @@ +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; +import { + filterMethods, + filterStatuses, +} from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { WorkbenchFilterSelect } from "./WorkbenchFilterSelect"; + +const SEARCH_DEBOUNCE_MS = 300; + +export const WorkbenchFilters = () => { + const filters = useWorkbenchStore((s) => s.filters); + const setFilters = useWorkbenchStore((s) => s.setFilters); + + const [searchInput, setSearchInput] = useState(filters.search); + + useEffect(() => { + if (searchInput === filters.search) return; + const t = setTimeout( + () => setFilters({ search: searchInput }), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(t); + }, [searchInput, filters.search, setFilters]); + + return ( +
+
+ setSearchInput(e.target.value)} + placeholder="Search path or body" + className="!h-7 !text-xs !min-w-0 pl-2 pr-7 w-52" + /> + +
+ + setFilters({ method })} + placeholder="All methods" + /> + + setFilters({ status })} + placeholder="All statuses" + /> +
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx new file mode 100644 index 000000000..a182b04b5 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx @@ -0,0 +1,145 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import { useCallback, useMemo, useRef } from "react"; +import CopyButton from "@/components/general/CopyButton"; + +const AUTUMN_DARK_THEME = "autumn-workbench-dark"; +const AUTUMN_LIGHT_THEME = "autumn-workbench-light"; + +export const WorkbenchJsonViewer = ({ + data, + height = "400px", +}: { + data: unknown; + height?: string; +}) => { + const editorRef = useRef[0] | null>(null); + + const formatted = useMemo(() => { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }, [data]); + + const onMount: OnMount = useCallback((editor, monaco) => { + editorRef.current = editor; + + monaco.editor.defineTheme(AUTUMN_DARK_THEME, { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editor.foreground": "#e7e7e7", + "editorLineNumber.foreground": "#4a4a4a", + "editorLineNumber.activeForeground": "#8a8a8a", + "editor.lineHighlightBackground": "#ffffff08", + "editor.selectionBackground": "#3b82f640", + "editor.inactiveSelectionBackground": "#3b82f620", + "editorIndentGuide.background1": "#2a2a2a", + "editorIndentGuide.activeBackground1": "#3a3a3a", + "editorBracketMatch.background": "#3b82f620", + "editorBracketMatch.border": "#3b82f680", + "editorWidget.background": "#1a1a1a", + "editorWidget.border": "#2a2a2a", + "input.background": "#0f0f0f", + "input.border": "#2a2a2a", + "editor.findMatchBackground": "#fbbf2440", + "editor.findMatchHighlightBackground": "#fbbf2420", + "scrollbarSlider.background": "#ffffff14", + "scrollbarSlider.hoverBackground": "#ffffff20", + "scrollbarSlider.activeBackground": "#ffffff30", + }, + }); + + monaco.editor.defineTheme(AUTUMN_LIGHT_THEME, { + base: "vs", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editorLineNumber.foreground": "#c0c0c0", + "editor.lineHighlightBackground": "#00000005", + }, + }); + + const isDark = document.documentElement.classList.contains("dark"); + monaco.editor.setTheme(isDark ? AUTUMN_DARK_THEME : AUTUMN_LIGHT_THEME); + + let folded = false; + const foldChildren = () => { + if (folded) return; + editor.getAction("editor.foldLevel2")?.run(); + folded = true; + }; + + const sub = editor.onDidChangeModelDecorations(() => { + foldChildren(); + sub.dispose(); + }); + setTimeout(() => { + foldChildren(); + sub.dispose(); + }, 250); + }, []); + + return ( +
+
+ + +
+ +
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx new file mode 100644 index 000000000..bf3aea518 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx @@ -0,0 +1,174 @@ +import CopyButton from "@/components/general/CopyButton"; +import { + type RequestLogEntry, + useCusRequestLogsQuery, +} from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { cn } from "@/lib/utils"; +import { WorkbenchJsonViewer } from "./WorkbenchJsonViewer"; +import { + extractRes, + extractScope, + formatLogDateTime, + methodColorClass, + statusBadgeClass, + statusText, +} from "./workbenchUtils"; + +export const WorkbenchLogDetail = ({ + customerId, + isOpen, +}: { + customerId: string | undefined; + isOpen: boolean; +}) => { + const selectedLogId = useWorkbenchStore((s) => s.selectedLogId); + const { logs } = useCusRequestLogsQuery({ customerId, enabled: isOpen }); + + const log = logs.find((l) => l.id === selectedLogId) ?? null; + + if (!log) { + return ( +
+ Select a request to view details +
+ ); + } + + return ( +
+ + + + + +
+ ); +}; + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const ReqResSplit = ({ raw }: { raw: Record }) => { + const req = extractScope(raw, "req"); + const res = extractRes(raw); + const reqData = Object.keys(req).length > 0 ? req : {}; + const resData = + res && typeof res === "object" && Object.keys(res).length > 0 ? res : {}; + + return ( +
+
+ Request + +
+
+ Response + +
+
+ ); +}; + +const ContextViewer = ({ raw }: { raw: Record }) => { + const ctx = extractScope(raw, "context"); + const data = Object.keys(ctx).length > 0 ? ctx : {}; + return ( +
+ Context + +
+ ); +}; + +const DetailHeader = ({ log }: { log: RequestLogEntry }) => ( +
+
+ API request +
+
+ + {log.method ?? "—"} + + {log.path ?? "(unknown)"} +
+
+); + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + <> +
{label}
+
{children}
+ +); + +const DetailRows = ({ log }: { log: RequestLogEntry }) => ( +
+ + + {statusText(log.statusCode)} + + + + {log.reqId && ( + +
+ {log.reqId} + +
+
+ )} + + + {formatLogDateTime(log.time)} + + + {log.durationMs != null && ( + + {log.durationMs}ms + + )} + + {log.ip && ( + + {log.ip} + + )} + + {log.userAgent && ( + + {log.userAgent} + + )} + + {log.customerId && ( + + {log.customerId} + + )} +
+); + +const RawJsonViewer = ({ raw }: { raw: Record }) => ( +
+ Raw event + +
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx new file mode 100644 index 000000000..59ecb739e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx @@ -0,0 +1,86 @@ +import { useCusRequestLogsQuery } from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { WorkbenchEmptyState } from "./WorkbenchEmptyState"; +import { WorkbenchLogRow } from "./WorkbenchLogRow"; +import { groupLogsByDay } from "./workbenchUtils"; + +const LoadingSkeleton = () => ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+); + +export const WorkbenchLogList = ({ + customerId, + isOpen, +}: { + customerId: string | undefined; + isOpen: boolean; +}) => { + const selectedLogId = useWorkbenchStore((s) => s.selectedLogId); + const setSelectedLogId = useWorkbenchStore((s) => s.setSelectedLogId); + + const { logs, unconfigured, isLoading, isFetching, error } = + useCusRequestLogsQuery({ customerId, enabled: isOpen }); + + const hasData = logs.length > 0; + const groups = groupLogsByDay(logs); + + const renderContent = () => { + if (isLoading && !hasData) return ; + if (error) { + return ( + + Check the server logs or your Axiom configuration. + + ); + } + if (unconfigured) { + return ( + + Set AXIOM_ADMIN_TOKEN on the server + to enable the workbench. + + ); + } + if (!hasData) { + return ( + + No API requests for this customer in the last 7 days. + + ); + } + return groups.map((group) => ( +
+
+ {group.label} +
+ {group.entries.map((log) => ( + setSelectedLogId(log.id)} + /> + ))} +
+ )); + }; + + return ( +
+
+ {isFetching && ( +
+ )} +
+
{renderContent()}
+
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx new file mode 100644 index 000000000..9e4874eae --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx @@ -0,0 +1,50 @@ +import type { RequestLogEntry } from "@/hooks/queries/useCusRequestLogsQuery"; +import { cn } from "@/lib/utils"; +import { + formatLogTime, + methodColorClass, + statusBadgeClass, +} from "./workbenchUtils"; + +export const WorkbenchLogRow = ({ + log, + selected, + onSelect, +}: { + log: RequestLogEntry; + selected: boolean; + onSelect: () => void; +}) => ( + +); diff --git a/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts new file mode 100644 index 000000000..e3d405ecd --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; + +const shouldDeferEscape = (target: Element | null): boolean => { + if (target?.closest(".monaco-editor")) return true; + if (useSheetStore.getState().type !== null) return true; + return ( + !!document.querySelector('[role="dialog"][data-state="open"]') || + !!document.querySelector('[role="alertdialog"][data-state="open"]') || + !!document.querySelector("dialog[open]") || + !!document.querySelector("[data-inline-editor-open]") + ); +}; + +export const useWorkbenchEscape = () => { + const isOpen = useWorkbenchStore((s) => s.isOpen); + const close = useWorkbenchStore((s) => s.close); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.defaultPrevented) return; + if (shouldDeferEscape(e.target as Element | null)) return; + close(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isOpen, close]); +}; diff --git a/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts new file mode 100644 index 000000000..2431c9e85 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useRef } from "react"; +import { + useWorkbenchStore, + WORKBENCH_MAX_HEIGHT_RATIO, + WORKBENCH_MIN_HEIGHT, +} from "@/hooks/stores/useWorkbenchStore"; + +const clampHeight = (h: number) => + Math.max( + WORKBENCH_MIN_HEIGHT, + Math.min(window.innerHeight * WORKBENCH_MAX_HEIGHT_RATIO, h), + ); + +export const useWorkbenchResize = () => { + const setHeight = useWorkbenchStore((s) => s.setHeight); + const dragStartY = useRef(null); + const dragStartHeight = useRef(0); + + const handleProps = useMemo( + () => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault(); + dragStartY.current = e.clientY; + dragStartHeight.current = useWorkbenchStore.getState().height; + e.currentTarget.setPointerCapture(e.pointerId); + }, + onPointerMove: (e: React.PointerEvent) => { + if (dragStartY.current == null) return; + const delta = dragStartY.current - e.clientY; + setHeight(clampHeight(dragStartHeight.current + delta)); + }, + onPointerUp: (e: React.PointerEvent) => { + dragStartY.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }, + onPointerCancel: (e: React.PointerEvent) => { + dragStartY.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }, + }), + [setHeight], + ); + + useEffect(() => { + const onResize = () => + setHeight(clampHeight(useWorkbenchStore.getState().height)); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [setHeight]); + + return { handleProps }; +}; diff --git a/vite/src/views/customers2/customer/workbench/workbenchUtils.ts b/vite/src/views/customers2/customer/workbench/workbenchUtils.ts new file mode 100644 index 000000000..d11a8b9a3 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/workbenchUtils.ts @@ -0,0 +1,125 @@ +import type { RequestLogEntry } from "@/hooks/queries/useCusRequestLogsQuery"; + +export const statusBadgeClass = (status: number): string => { + if (status >= 200 && status < 300) { + return "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900"; + } + if (status >= 300 && status < 400) { + return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900"; + } + if (status >= 400 && status < 500) { + return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900"; + } + return "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900"; +}; + +export const statusText = (status: number): string => { + if (status === 200) return "200 OK"; + if (status === 201) return "201 Created"; + if (status === 204) return "204 No Content"; + if (status === 400) return "400 Bad Request"; + if (status === 401) return "401 Unauthorized"; + if (status === 403) return "403 Forbidden"; + if (status === 404) return "404 Not Found"; + if (status === 409) return "409 Conflict"; + if (status === 422) return "422 Unprocessable"; + if (status === 429) return "429 Rate Limited"; + if (status === 500) return "500 Server Error"; + return String(status); +}; + +export const methodColorClass = (method: string | null): string => { + switch (method) { + case "GET": + return "text-blue-600 dark:text-blue-400"; + case "POST": + return "text-emerald-600 dark:text-emerald-400"; + case "PUT": + return "text-amber-600 dark:text-amber-400"; + case "PATCH": + return "text-purple-600 dark:text-purple-400"; + case "DELETE": + return "text-red-600 dark:text-red-400"; + default: + return "text-t2"; + } +}; + +const dayBucketLabel = (date: Date, now: Date): string => { + const y = date.toDateString() === now.toDateString(); + if (y) return "Today"; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; + + return date.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +export const groupLogsByDay = ( + logs: RequestLogEntry[], +): { label: string; entries: RequestLogEntry[] }[] => { + const now = new Date(); + const groups = new Map(); + + for (const log of logs) { + const d = new Date(log.time); + const label = dayBucketLabel(d, now); + const existing = groups.get(label) ?? []; + existing.push(log); + groups.set(label, existing); + } + + return Array.from(groups.entries()).map(([label, entries]) => ({ + label, + entries, + })); +}; + +export const formatLogTime = (time: string): string => + new Date(time).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + +export const formatLogDateTime = (time: string): string => + new Date(time).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + +const isPlainObject = (v: unknown): v is Record => + v != null && typeof v === "object" && !Array.isArray(v); + +export const extractScope = ( + raw: Record, + prefix: string, +): Record => { + const base = prefix.replace(/\.$/, ""); + const direct = raw[base]; + if (isPlainObject(direct)) return direct; + + const dotted = `${base}.`; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + if (k.startsWith(dotted)) { + out[k.slice(dotted.length)] = v; + } + } + return out; +}; + +export const extractRes = (raw: Record): unknown => { + if (raw.res !== undefined) return raw.res ?? {}; + return extractScope(raw, "res"); +}; diff --git a/vite/src/views/main-sidebar/SidebarBottom.tsx b/vite/src/views/main-sidebar/SidebarBottom.tsx index 96bc97a86..408432453 100644 --- a/vite/src/views/main-sidebar/SidebarBottom.tsx +++ b/vite/src/views/main-sidebar/SidebarBottom.tsx @@ -2,6 +2,7 @@ import { BooksIcon, DiscordLogoIcon } from "@phosphor-icons/react"; import { useEnv } from "@/utils/envUtils"; +import { WorkbenchButton } from "@/views/customers2/customer/workbench/WorkbenchButton"; import { FeedbackDialog } from "./FeedbackDialog"; import { NavButton } from "./NavButton"; import { SidebarContact } from "./SidebarContact"; @@ -15,20 +16,7 @@ export default function SidebarBottom() { return (
- {/* } - title="Connect to Stripe" - env={env} - /> */} - {/* {env === AppEnv.Sandbox && ( - } - title="Onboarding" - env={env} - /> - )} */} + }