diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index fb6071803..4dd9e35e9 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -1,79 +1,79 @@ -name: Benchmark Server PR +# name: Benchmark Server PR -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'server/**' - - 'pnpm-lock.yaml' - - 'package.json' +# on: +# pull_request: +# types: [opened, synchronize, reopened] +# paths: +# - 'server/**' +# - 'pnpm-lock.yaml' +# - 'package.json' -permissions: - contents: read - pull-requests: write +# permissions: +# contents: read +# pull-requests: write -jobs: - benchmark: - runs-on: ubuntu-latest +# jobs: +# benchmark: +# runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: latest +# - name: Setup pnpm +# uses: pnpm/action-setup@v2 +# with: +# version: latest - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' +# - name: Setup Node.js +# uses: actions/setup-node@v4 +# with: +# node-version: '20' +# cache: 'pnpm' - - name: Install dependencies - run: pnpm i --no-frozen-lockfile +# - name: Install dependencies +# run: pnpm i --no-frozen-lockfile - - name: Run benchmark - id: benchmark - working-directory: ./server - run: | - echo "BENCHMARK_OUTPUT<> $GITHUB_OUTPUT - FULL_OUTPUT=$(pnpm run benchmark 2>&1) - FILTERED_OUTPUT=$(echo "$FULL_OUTPUT" | grep -v "^> @.*benchmark" | grep -v "^> tsx benchmarks" | grep -v "Benchmark completed successfully") - echo "$FILTERED_OUTPUT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT +# - name: Run benchmark +# id: benchmark +# working-directory: ./server +# run: | +# echo "BENCHMARK_OUTPUT<> $GITHUB_OUTPUT +# FULL_OUTPUT=$(pnpm run benchmark 2>&1) +# FILTERED_OUTPUT=$(echo "$FULL_OUTPUT" | grep -v "^> @.*benchmark" | grep -v "^> tsx benchmarks" | grep -v "Benchmark completed successfully") +# echo "$FILTERED_OUTPUT" >> $GITHUB_OUTPUT +# echo "EOF" >> $GITHUB_OUTPUT - if echo "$FULL_OUTPUT" | grep -q "Benchmark completed successfully"; then - echo "BENCHMARK_STATUS=✅ Passed" >> $GITHUB_OUTPUT - else - echo "BENCHMARK_STATUS=❌ Failed" >> $GITHUB_OUTPUT - fi +# if echo "$FULL_OUTPUT" | grep -q "Benchmark completed successfully"; then +# echo "BENCHMARK_STATUS=✅ Passed" >> $GITHUB_OUTPUT +# else +# echo "BENCHMARK_STATUS=❌ Failed" >> $GITHUB_OUTPUT +# fi - - name: Comment PR - uses: actions/github-script@v7 - with: - script: | - const output = `${{ steps.benchmark.outputs.BENCHMARK_OUTPUT }}`; - const status = `${{ steps.benchmark.outputs.BENCHMARK_STATUS }}`; - const body = `## 📊 Benchmark Results +# - name: Comment PR +# uses: actions/github-script@v7 +# with: +# script: | +# const output = `${{ steps.benchmark.outputs.BENCHMARK_OUTPUT }}`; +# const status = `${{ steps.benchmark.outputs.BENCHMARK_STATUS }}`; +# const body = `## 📊 Benchmark Results - **Benchmark CI:** ${status} +# **Benchmark CI:** ${status} -
- Click to view benchmark results +#
+# Click to view benchmark results - \`\`\`javascript - ${output} - \`\`\` +# \`\`\`javascript +# ${output} +# \`\`\` -
+#
- *Benchmark run for commit ${{ github.sha }}*`; +# *Benchmark run for commit ${{ github.sha }}*`; - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); \ No newline at end of file +# github.rest.issues.createComment({ +# issue_number: context.issue.number, +# owner: context.repo.owner, +# repo: context.repo.repo, +# body: body +# }); \ No newline at end of file diff --git a/docker-compose.unix.yml b/docker-compose.unix.yml index 90e1d31a1..0ece11b63 100644 --- a/docker-compose.unix.yml +++ b/docker-compose.unix.yml @@ -91,7 +91,6 @@ services: # Run localtunnel localtunnel: - image: node:20-alpine build: dockerfile: docker/dev.dockerfile context: . diff --git a/localtunnel-start.sh b/localtunnel-start.sh index 50ffa2497..db9415083 100755 --- a/localtunnel-start.sh +++ b/localtunnel-start.sh @@ -1,21 +1,15 @@ #!/bin/sh -echo "STARTING LOCALTUNNEL SCRIPT" - # Read LOCALTUNNEL_RESERVED_KEY from .env file if [ -f "/app/server/.env" ]; then - export $(cat /app/server/.env | grep LOCALTUNNEL_RESERVED_KEY) + export $(cat /app/server/.env | grep LOCALTUNNEL_RESERVED_KEY | grep -v '^#') fi -echo "LOCALTUNNEL_RESERVED_KEY: ${LOCALTUNNEL_RESERVED_KEY}" - - # Set default subdomain if env var not found if [ -z "$LOCALTUNNEL_RESERVED_KEY" ]; then LOCALTUNNEL_RESERVED_KEY="autumn-dev" fi - echo "Installing localtunnel..." npm install -g localtunnel diff --git a/server/shell/g2.sh b/server/shell/g2.sh index 37e98c511..b1fd4d05b 100755 --- a/server/shell/g2.sh +++ b/server/shell/g2.sh @@ -4,11 +4,11 @@ source "$(dirname "$0")/config.sh" MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \ -'tests/attach/upgradeOld/*.ts' \ -'tests/attach/entities/*.ts' \ -'tests/attach/migrations/*.ts' \ -'tests/attach/newVersion/*.ts' \ -'tests/attach/others/*.ts' \ -'tests/attach/updateEnts/*.ts' +'tests/attach/others/*.ts' +# 'tests/attach/migrations/*.ts' \ +# 'tests/attach/newVersion/*.ts' \ +# 'tests/attach/upgradeOld/*.ts' \ +# 'tests/attach/entities/*.ts' \ +# 'tests/attach/updateEnts/*.ts' diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 42229fdcb..d2165bbed 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -5,7 +5,7 @@ import { cusRouter } from "./cusRouter.js"; import { productRouter } from "../products/productRouter.js"; import { featureApiRouter } from "./features/featureApiRouter.js"; -import { entitledRouter } from "./entitled/entitledRouter.js"; +import { checkRouter } from "./entitled/checkRouter.js"; import { attachRouter } from "../customers/attach/attachRouter.js"; import { pricingMiddleware } from "@/middleware/pricingMiddleware.js"; import { usageRouter } from "./events/usageRouter.js"; @@ -46,8 +46,8 @@ apiRouter.use("/redemptions", redemptionRouter); // Cus Product apiRouter.use("/attach", attachRouter); apiRouter.use("/cancel", expireRouter); -apiRouter.use("/entitled", entitledRouter); -apiRouter.use("/check", entitledRouter); +apiRouter.use("/entitled", checkRouter); +apiRouter.use("/check", checkRouter); apiRouter.use("/events", eventsRouter); apiRouter.use("/track", eventsRouter); diff --git a/server/src/internal/api/components/componentRouter.ts b/server/src/internal/api/components/componentRouter.ts index c4f9b6cac..980d8a474 100644 --- a/server/src/internal/api/components/componentRouter.ts +++ b/server/src/internal/api/components/componentRouter.ts @@ -97,6 +97,7 @@ componentRouter.get("/pricing_table", async (req: any, res) => features, curMainProduct, curScheduledProduct, + otherProducts: products.filter((other) => other.id != p.id), }); }), ); diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index cb3ad709e..918b81a8a 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -1,9 +1,7 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { - getCusFeaturesResponse, - getCusProductsResponse, -} from "@/internal/customers/cusUtils/cusResponseUtils.js"; +import { getCusProductsResponse } from "@/internal/customers/cusUtils/cusResponseUtils.js"; +import { getCusFeaturesResponse } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.js"; import RecaseError from "@/utils/errorUtils.js"; import { nullish } from "@/utils/genUtils.js"; diff --git a/server/src/internal/api/entitled/entitledRouter.ts b/server/src/internal/api/entitled/checkRouter.ts similarity index 61% rename from server/src/internal/api/entitled/entitledRouter.ts rename to server/src/internal/api/entitled/checkRouter.ts index d604ca190..075301c0d 100644 --- a/server/src/internal/api/entitled/entitledRouter.ts +++ b/server/src/internal/api/entitled/checkRouter.ts @@ -2,9 +2,7 @@ import { ErrCode } from "@/errors/errCodes.js"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { APIVersion, - CusProductStatus, type Feature, - FeatureSchema, FeatureType, type FullCustomerEntitlement, type Organization, @@ -18,21 +16,19 @@ import { import { Router } from "express"; import { StatusCodes } from "http-status-codes"; - import { handleEventSent } from "../events/eventRouter.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; -import { notNullish, nullish } from "@/utils/genUtils.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; +import { notNullish } from "@/utils/genUtils.js"; import { SuccessCode } from "@autumn/shared"; import { handleProductCheck } from "./handlers/handleProductCheck.js"; import { getBooleanEntitledResult } from "./checkUtils.js"; -import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; import { getCheckPreview } from "./getCheckPreview.js"; import { orgToVersion } from "@/utils/versionUtils.js"; -import { createStripeCli } from "@/external/stripe/utils.js"; +import { getCheckData } from "./checkUtils/getCheckData.js"; +import { getV1CheckResponse } from "./checkUtils/getV1CheckResponse.js"; +import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js"; -export const entitledRouter: Router = Router(); +export const checkRouter: Router = Router(); const getRequiredAndActualBalance = ({ cusEnts, @@ -165,122 +161,7 @@ const getMeteredEntitledResult = ({ }; }; -// Main functions -const getFeatureAndCreditSystems = async ({ - req, - featureId, -}: { - req: any; - featureId: string; -}) => { - const features = await FeatureService.getFromReq(req); - - const feature: Feature | undefined = features.find( - (feature) => feature.id === featureId, - ); - - const creditSystems: Feature[] = features.filter((feature) => { - return ( - feature.type == FeatureType.CreditSystem && - feature.config.schema.some( - (schema: any) => schema.metered_feature_id === featureId, - ) - ); - }); - - return { feature, creditSystems, allFeatures: features }; -}; - -// FETCH FUNCTION -const getCusEntsAndFeatures = async ({ - req, - logger, -}: { - req: any; - logger: any; -}) => { - let { customer_id, feature_id, customer_data, entity_id } = req.body; - - let { env, db } = req; - - // 1. Get org and features - const startTime = Date.now(); - - // Fetch org, feature, and customer in parallel - const [org, featureRes] = await Promise.all([ - OrgService.getFromReq(req), - getFeatureAndCreditSystems({ - req, - featureId: feature_id, - }), - ]); - - logger.info(`running /check for org: ${org.slug}, feature: ${feature_id}`); - const { feature, creditSystems, allFeatures } = featureRes; - - const customer = await getOrCreateCustomer({ - req, - customerId: customer_id, - customerData: customer_data, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - entityId: entity_id, - entityData: req.body.entity_data, - }); - - const duration = Date.now() - startTime; - // console.log(`/check: fetched org, features & customer in ${duration}ms`); - logger.info(`/check: fetched org, features & customer in ${duration}ms`); - - if (!feature) { - throw new RecaseError({ - message: `feature with id ${feature_id} not found`, - code: ErrCode.FeatureNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - let cusProducts = customer.customer_products; - - if (!org.config.include_past_due) { - cusProducts = cusProducts.filter( - (cusProduct) => cusProduct.status !== CusProductStatus.PastDue, - ); - } - - // For logging purposes... - let cusEnts = cusProducts.flatMap((cusProduct) => { - return cusProduct.customer_entitlements.map((cusEnt) => { - return { - ...cusEnt, - customer_product: cusProduct, - }; - }); - }); - - if (customer.entity) { - cusEnts = cusEnts.filter((cusEnt) => { - return ( - // notNullish(cusEnt.entities) || - nullish(cusEnt.customer_product.internal_entity_id) || - cusEnt.customer_product.internal_entity_id === - customer.entity!.internal_id - ); - }); - } - - return { - fullCus: customer, - cusEnts, - feature, - creditSystems, - org, - cusProducts, - allFeatures, - entity: customer.entity, - }; -}; - -entitledRouter.post("", async (req: any, res: any) => { +checkRouter.post("", async (req: any, res: any) => { try { let { customer_id, @@ -346,7 +227,6 @@ entitledRouter.post("", async (req: any, res: any) => { quantity = floatQuantity; } - logger.info(`/check: getting cusEnts and features`); const { fullCus, cusEnts, @@ -355,18 +235,13 @@ entitledRouter.post("", async (req: any, res: any) => { org, cusProducts, allFeatures, - } = await getCusEntsAndFeatures({ - req, - logger: req.logtail, - }); + } = await getCheckData({ req }); let apiVersion = orgToVersion({ org, reqApiVersion: req.apiVersion, }); - // logEntitled({ req, customer_id, cusEnts: cusEnts! }); - // 2. If boolean, return true if (feature.type === FeatureType.Boolean) { return await getBooleanEntitledResult({ @@ -382,15 +257,30 @@ entitledRouter.post("", async (req: any, res: any) => { }); } - const { allowed, balances } = getMeteredEntitledResult({ + const v1Response = getV1CheckResponse({ originalFeature: feature, creditSystems, - cusEnts: cusEnts! as FullCustomerEntitlement[], + cusEnts: cusEnts!, quantity, entityId: entity_id, org, }); + const v2Response = await getV2CheckResponse({ + fullCus, + cusEnts, + feature, + creditSystems, + org, + cusProducts, + requiredBalance, + }); + + const { allowed, balance } = v2Response; + const featureToUse = allFeatures.find( + (f: Feature) => f.id === v2Response.feature_id, + ); + if (allowed && req.isPublic !== true) { if (send_event) { await handleEventSent({ @@ -424,29 +314,13 @@ entitledRouter.post("", async (req: any, res: any) => { } } - let features = [feature, ...creditSystems]; - let balanceObj: any, featureToUse: any; - try { - balanceObj = balances.length > 0 ? balances[0] : null; - - featureToUse = - notNullish(balanceObj) && balanceObj.feature_id !== feature.id - ? features.find((f) => f.id === balanceObj.feature_id) - : creditSystems.length > 0 - ? creditSystems[0] - : feature; - } catch (error) { - logger.error(`/check: failed to get balance & feature to use`, error); - } - - // 3. If with preview, get preview let preview = undefined; if (req.body.with_preview) { try { preview = await getCheckPreview({ db, allowed, - balance: balanceObj?.balance, + balance: balance || undefined, feature: featureToUse!, cusProducts, allFeatures, @@ -459,21 +333,12 @@ entitledRouter.post("", async (req: any, res: any) => { if (apiVersion >= APIVersion.v1_1) { res.status(200).json({ - customer_id, - entity_id, - feature_id: featureToUse?.id, - required_balance: balanceObj?.required, - code: SuccessCode.FeatureFound, - - allowed, - unlimited: balanceObj?.unlimited || false, - balance: balanceObj?.unlimited ? null : balanceObj?.balance, + ...v2Response, preview, }); } else { res.status(200).json({ - allowed, - balances, + ...v1Response, preview, }); } @@ -483,3 +348,20 @@ entitledRouter.post("", async (req: any, res: any) => { handleRequestError({ req, error, res, action: "Failed to GET entitled" }); } }); + +// let features = [feature, ...creditSystems]; +// let balanceObj: any, featureToUse: any; +// try { +// balanceObj = balances.length > 0 ? balances[0] : null; + +// featureToUse = +// notNullish(balanceObj) && balanceObj.feature_id !== feature.id +// ? features.find((f) => f.id === balanceObj.feature_id) +// : creditSystems.length > 0 +// ? creditSystems[0] +// : feature; +// } catch (error) { +// logger.error(`/check: failed to get balance & feature to use`, error); +// } + +// 3. If with preview, get preview diff --git a/server/src/internal/api/entitled/checkUtils.ts b/server/src/internal/api/entitled/checkUtils.ts index 810024ec7..3453a9e39 100644 --- a/server/src/internal/api/entitled/checkUtils.ts +++ b/server/src/internal/api/entitled/checkUtils.ts @@ -124,8 +124,6 @@ export const getOptions = ({ let actualPrice = itemToPriceOrTiers({ item: i, }); - console.log("Price data:", priceData); - console.log("Actual price:", actualPrice); if (freeTrial) { priceData = { diff --git a/server/src/internal/api/entitled/checkUtils/getCheckData.ts b/server/src/internal/api/entitled/checkUtils/getCheckData.ts new file mode 100644 index 000000000..eda3a8cd6 --- /dev/null +++ b/server/src/internal/api/entitled/checkUtils/getCheckData.ts @@ -0,0 +1,108 @@ +import { cusEntMatchesEntity } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; +import { cusProductsToCusEnts } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; +import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { + CusProductStatus, + ErrCode, + Feature, + FeatureType, +} from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; + +// Main functions +const getFeatureAndCreditSystems = ({ + req, + featureId, +}: { + req: any; + featureId: string; +}) => { + const { features } = req; + + const feature: Feature | undefined = features.find( + (feature: Feature) => feature.id === featureId, + ); + + const creditSystems = getCreditSystemsFromFeature({ + featureId, + features, + }); + + return { feature, creditSystems, allFeatures: features }; +}; + +export const getCheckData = async ({ req }: { req: any }) => { + let { customer_id, feature_id, customer_data, entity_id } = req.body; + + const { org, logger } = req; + + const { feature, creditSystems, allFeatures } = getFeatureAndCreditSystems({ + req, + featureId: feature_id, + }); + + // 1. Get org and features + const startTime = Date.now(); + + logger.info(`running /check for org: ${org.slug}, feature: ${feature_id}`); + + let inStatuses = org.config.include_past_due + ? [CusProductStatus.Active, CusProductStatus.PastDue] + : [CusProductStatus.Active]; + + const customer = await getOrCreateCustomer({ + req, + customerId: customer_id, + customerData: customer_data, + inStatuses, + entityId: entity_id, + entityData: req.body.entity_data, + }); + + const duration = Date.now() - startTime; + logger.info(`/check: fetched org, features & customer in ${duration}ms`); + + if (!feature) { + throw new RecaseError({ + message: `feature with id ${feature_id} not found`, + code: ErrCode.FeatureNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + + let cusProducts = customer.customer_products; + + let cusEnts = cusProductsToCusEnts({ cusProducts }); + + if (customer.entity) { + cusEnts = cusEnts.filter((cusEnt) => + cusEntMatchesEntity({ + cusEnt, + entity: customer.entity!, + features: allFeatures, + }), + ); + } + + return { + fullCus: customer, + cusEnts, + feature, + creditSystems, + org, + cusProducts, + allFeatures, + entity: customer.entity, + }; +}; + +// const creditSystems: Feature[] = features.filter((feature: Feature) => { +// return ( +// feature.type == FeatureType.CreditSystem && +// feature.config.schema.some( +// (schema: any) => schema.metered_feature_id === featureId, +// ) +// ); +// }); diff --git a/server/src/internal/api/entitled/checkUtils/getV1CheckResponse.ts b/server/src/internal/api/entitled/checkUtils/getV1CheckResponse.ts new file mode 100644 index 000000000..b109a5051 --- /dev/null +++ b/server/src/internal/api/entitled/checkUtils/getV1CheckResponse.ts @@ -0,0 +1,143 @@ +import { + getFeatureBalance, + cusEntsContainFeature, + getUnlimitedAndUsageAllowed, +} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; +import { + FullCustomerEntitlement, + Feature, + FeatureType, + Organization, +} from "@autumn/shared"; + +const getRequiredAndActualBalance = ({ + cusEnts, + feature, + originalFeatureId, + required, + entityId, +}: { + cusEnts: FullCustomerEntitlement[]; + feature: Feature; + originalFeatureId: string; + required: number; + entityId: string; +}) => { + let requiredBalance = required; + if ( + feature.type === FeatureType.CreditSystem && + feature.id !== originalFeatureId + ) { + requiredBalance = featureToCreditSystem({ + featureId: originalFeatureId, + creditSystem: feature, + amount: required, + }); + } + + const actualBalance = getFeatureBalance({ + cusEnts, + internalFeatureId: feature.internal_id!, + entityId, + }); + + return { + required: requiredBalance, + actual: actualBalance, + entityId, + }; +}; + +export const getV1CheckResponse = ({ + originalFeature, + creditSystems, + cusEnts, + quantity, + entityId, + org, +}: { + originalFeature: Feature; + creditSystems: Feature[]; + cusEnts: FullCustomerEntitlement[]; + quantity: number; + entityId: string; + org: Organization; +}) => { + // If no entitlements -> return false + if (!cusEnts || cusEnts.length === 0) { + return { + allowed: false, + balances: [], + }; + } + + let allowed = false; + const balances = []; + + for (const feature of [originalFeature, ...creditSystems]) { + // 1. Skip if feature not among cusEnt + + if (!cusEntsContainFeature({ cusEnts, feature })) { + continue; + } + + // 2. Handle unlimited / usage allowed features + let { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({ + cusEnts, + internalFeatureId: feature.internal_id!, + }); + + if (unlimited || usageAllowed) { + balances.push({ + feature_id: feature.id, + unlimited, + usage_allowed: usageAllowed, + required: null, + balance: unlimited + ? null + : getFeatureBalance({ + cusEnts, + internalFeatureId: feature.internal_id!, + entityId, + }), + }); + allowed = true; + // continue; + break; + } + + // 3. Get required and actual balance + const { required, actual } = getRequiredAndActualBalance({ + cusEnts, + feature, + originalFeatureId: originalFeature.id, + required: quantity, + entityId, + }); + + let newBalance: any = { + feature_id: feature.id, + required, + balance: actual, + }; + + if (entityId) { + newBalance.entity_id = entityId; + } + + balances.push(newBalance); + + // allowed = allowed && actual! >= required; + allowed = actual! >= required; + + if (allowed) { + break; + } + } + + return { + allowed, + balances, + }; +}; diff --git a/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts new file mode 100644 index 000000000..218abf8d5 --- /dev/null +++ b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts @@ -0,0 +1,83 @@ +import { + getFeatureBalance, + getUnlimitedAndUsageAllowed, +} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { cusEntMatchesFeature } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; +import { getCusBalances } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.js"; +import { balancesToFeatureResponse } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.js"; +import { + CheckResponseSchema, + Feature, + FullCusEntWithFullCusProduct, + FullCusProduct, + FullCustomer, + Organization, + SuccessCode, +} from "@autumn/shared"; +import { notNullish } from "@/utils/genUtils.js"; + +export const getV2CheckResponse = async ({ + fullCus, + cusEnts, + feature, + creditSystems, + org, + cusProducts, + requiredBalance, +}: { + fullCus: FullCustomer; + cusEnts: FullCusEntWithFullCusProduct[]; + feature: Feature; + creditSystems: Feature[]; + org: Organization; + cusProducts: FullCusProduct[]; + requiredBalance?: number; +}) => { + // 1. Get the feature to use + const featureToUse = creditSystems.length > 0 ? creditSystems[0] : feature; + + const featureCusEnts = cusEnts.filter((cusEnt) => { + return cusEntMatchesFeature({ cusEnt, feature: featureToUse }); + }); + + const { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({ + cusEnts: featureCusEnts, + internalFeatureId: featureToUse.internal_id!, + }); + + const cusPrices = cusProducts.flatMap( + (cusProduct) => cusProduct.customer_prices, + ); + const balances = await getCusBalances({ + cusEntsWithCusProduct: featureCusEnts, + cusPrices, + org, + entity: fullCus.entity, + }); + + let cusFeatures = balancesToFeatureResponse({ + cusEnts: featureCusEnts, + balances, + }); + + const cusFeature = cusFeatures[featureToUse.id] || {}; + + let allowed = false; + if ( + (cusFeature && unlimited) || + usageAllowed || + cusFeature.balance >= (requiredBalance || 1) + ) { + allowed = true; + } + + return CheckResponseSchema.parse({ + customer_id: fullCus.id, + feature_id: featureToUse.id, + entity_id: fullCus.entity?.id, + required_balance: notNullish(requiredBalance) ? requiredBalance : 1, + code: SuccessCode.FeatureFound, + allowed, + ...cusFeature, + }); +}; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index 229ec3d1b..3e6590499 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -3,7 +3,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { AttachBody } from "../models/AttachBody.js"; import { AttachParams } from "../../cusProducts/AttachParams.js"; import { notNullish } from "@/utils/genUtils.js"; -import { AttachBranch, AttachErrCode } from "@autumn/shared"; +import { AttachBranch, AttachErrCode, BillingInterval } from "@autumn/shared"; import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; @@ -17,10 +17,11 @@ import { cusProductToProduct, } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; import { FeatureOptions, FullCusProduct } from "@autumn/shared"; -import { productsAreSame } from "@/internal/products/compareProductUtils.js"; +import { productsAreSame } from "@/internal/products/productUtils/compareProductUtils.js"; import { isTrialing } from "../../cusProducts/cusProductUtils.js"; import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { attachParamToCusProducts } from "./convertAttachParams.js"; +import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; const checkMultiProductErrors = async ({ attachParams, @@ -78,11 +79,14 @@ const checkMultiProductErrors = async ({ const getOptionsToUpdate = ({ oldOptionsList, newOptionsList, + curSameProduct, }: { oldOptionsList: FeatureOptions[]; newOptionsList: FeatureOptions[]; + curSameProduct: FullCusProduct; }) => { let optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[] = []; + const prices = cusProductToPrices({ cusProduct: curSameProduct }); for (const newOptions of newOptionsList) { let internalFeatureId = newOptions.internal_feature_id; @@ -90,6 +94,15 @@ const getOptionsToUpdate = ({ (o) => o.internal_feature_id === internalFeatureId, ); + let price = findPrepaidPrice({ + prices, + internalFeatureId: internalFeatureId!, + }); + + if (price?.config.interval == BillingInterval.OneOff) { + continue; + } + if (existingOptions && existingOptions.quantity !== newOptions.quantity) { optionsToUpdate.push({ new: newOptions, @@ -164,6 +177,7 @@ const getSameProductBranch = async ({ let optionsToUpdate = getOptionsToUpdate({ oldOptionsList: curSameProduct.options, newOptionsList: attachParams.optionsList, + curSameProduct, }); // 1. If prepaid quantity changed @@ -202,7 +216,7 @@ const getSameProductBranch = async ({ } if (fromPreview) { - if (hasPrepaidPrice({ prices: attachParams.prices })) { + if (hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })) { return AttachBranch.UpdatePrepaidQuantity; } } diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts index ab17f595d..48179baca 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpdateEntsPreview.ts @@ -1,4 +1,4 @@ -import { productsAreSame } from "@/internal/products/compareProductUtils.js"; +import { productsAreSame } from "@/internal/products/productUtils/compareProductUtils.js"; import { attachParamsToProduct, attachParamToCusProducts, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index 7bbdc53ee..07e8513fd 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -108,14 +108,6 @@ export const getCusEntBalance = ({ }; } - // let unusedCount = - // (entities && - // entities.filter( - // (entity) => - // entity.internal_feature_id == feature.internal_id && entity.deleted, - // ).length) || - // 0; - return { balance: cusEnt.balance, adjustment: cusEnt.adjustment, @@ -378,6 +370,16 @@ export const getFeatureBalance = ({ entityId?: string; }) => { let balance = 0; + + const { unlimited } = getUnlimitedAndUsageAllowed({ + cusEnts, + internalFeatureId, + }); + + if (unlimited) { + return null; + } + for (const cusEnt of cusEnts) { if (cusEnt.internal_feature_id !== internalFeatureId) { continue; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts index e1a2f8b37..b84ce2657 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts @@ -1,5 +1,52 @@ import { notNullish } from "@/utils/genUtils.js"; -import { Entity, Feature, FullCustomerEntitlement } from "@autumn/shared"; +import { + Entity, + EntityWithFeature, + Feature, + FullCusEntWithFullCusProduct, + FullCustomerEntitlement, +} from "@autumn/shared"; + +export const cusEntMatchesEntity = ({ + cusEnt, + entity, + features, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entity?: Entity; + features?: Feature[]; +}) => { + if (!entity) return true; + + let cusProductMatch = true; + + if (notNullish(cusEnt.customer_product?.internal_entity_id)) { + cusProductMatch = + cusEnt.customer_product.internal_entity_id === entity.internal_id; + } + + let entityFeatureIdMatch = true; + // let feature = features?.find( + // (f) => f.id == cusEnt.entitlement.entity_feature_id, + // ); + + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + entityFeatureIdMatch = + cusEnt.entitlement.entity_feature_id == entity.feature_id; + } + + return cusProductMatch && entityFeatureIdMatch; +}; + +export const cusEntMatchesFeature = ({ + cusEnt, + feature, +}: { + cusEnt: FullCustomerEntitlement; + feature: Feature; +}) => { + return cusEnt.entitlement.feature.internal_id === feature.internal_id; +}; export const findMainCusEntForFeature = ({ cusEnts, @@ -27,39 +74,24 @@ export const findLinkedCusEnts = ({ ); }; -export const cusEntEntityMatch = ({ - cusEnt, - entity, -}: { - cusEnt: FullCustomerEntitlement; - entity?: Entity; -}) => { - let entityFeatureId = cusEnt.entitlement.entity_feature_id; - let compareEntity = notNullish(entityFeatureId) && notNullish(entity); - - let entityMatch = compareEntity - ? entityFeatureId === entity!.feature_id - : true; - - return entityMatch; -}; - export const findCusEnt = ({ feature, cusEnts, - entity, onlyUsageAllowed = false, + entity, + features, }: { feature: Feature; cusEnts: FullCustomerEntitlement[]; - entity?: Entity; onlyUsageAllowed?: boolean; + entity?: Entity; + features?: Feature[]; }) => { return cusEnts.find((ce: any) => { let featureMatch = ce.entitlement.feature.internal_id === feature.internal_id; - let entityMatch = cusEntEntityMatch({ cusEnt: ce, entity }); + let entityMatch = cusEntMatchesEntity({ cusEnt: ce, entity, features }); let usageMatch = onlyUsageAllowed ? ce.usage_allowed : true; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.ts new file mode 100644 index 000000000..49c81dda7 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.ts @@ -0,0 +1,126 @@ +import { + CusEntResponse, + CusEntResponseSchema, + CusEntResponseV2, + Feature, + FeatureType, + FullCustomerEntitlement, +} from "@autumn/shared"; +import { CusFeatureBalance } from "./getCusBalances.js"; + +export const sumValues = ( + entList: CusEntResponse[], + key: keyof CusEntResponse, +) => { + return entList.reduce((acc, curr) => { + if (curr[key]) { + return acc + Number(curr[key]); + } + + return acc; + }, 0); +}; + +export const getEarliestNextResetAt = (entList: CusEntResponse[]) => { + let earliest = entList.reduce((acc, curr) => { + if (curr.next_reset_at && curr.next_reset_at < acc) { + return curr.next_reset_at; + } + + return acc; + }, Infinity); + + return earliest == Infinity ? null : earliest; +}; + +export const featuresToObject = ({ + features, + entList, +}: { + features: Feature[]; + entList: CusEntResponse[]; +}) => { + let featureObject: Record = {}; + for (let entRes of entList) { + let feature = features.find((f) => f.id == entRes.feature_id)!; + if (feature.type == FeatureType.Boolean) { + featureObject[feature.id] = { + id: feature.id, + name: feature.name, + }; + continue; + } else if (entRes.unlimited) { + featureObject[feature.id] = { + id: feature.id, + name: feature.name, + unlimited: true, + }; + continue; + } + + let featureId = feature.id; + let unlimited = entRes.unlimited; + let relatedEnts = entList.filter((e) => e.feature_id == featureId); + + if (featureObject[featureId]) { + continue; + } + + featureObject[featureId] = { + id: featureId, + name: feature.name, + unlimited, + balance: unlimited ? null : sumValues(relatedEnts, "balance"), + usage: sumValues(relatedEnts, "usage"), + included_usage: sumValues(relatedEnts, "included_usage"), + + next_reset_at: getEarliestNextResetAt(relatedEnts), + interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", + breakdown: + relatedEnts.length > 1 + ? relatedEnts.map((e) => ({ + interval: e.interval!, + balance: e.balance, + usage: e.usage, + included_usage: e.included_usage, + next_reset_at: e.next_reset_at, + })) + : undefined, + }; + } + + return featureObject; +}; + +export const balancesToFeatureResponse = ({ + cusEnts, + balances, +}: { + cusEnts: FullCustomerEntitlement[]; + balances: CusFeatureBalance[]; +}) => { + let features = cusEnts.map((cusEnt) => cusEnt.entitlement.feature); + + let entList: any = balances.map((b) => { + let isBoolean = + features.find((f: Feature) => f.id == b.feature_id)?.type == + FeatureType.Boolean; + + if (b.unlimited || isBoolean) { + return b; + } + + return CusEntResponseSchema.parse({ + ...b, + usage: b.used, + included_usage: b.allowance, + }); + }); + + entList = featuresToObject({ + features, + entList, + }); + + return entList; +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/getCusBalances.ts b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts similarity index 94% rename from server/src/internal/customers/cusProducts/cusEnts/getCusBalances.ts rename to server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts index 85537cf2c..8da776ebe 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/getCusBalances.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.ts @@ -1,5 +1,6 @@ import { APIVersion, + EntInterval, EntitlementWithFeature, Entity, FeatureType, @@ -18,7 +19,20 @@ import { getRelatedCusPrice, getResetBalance, getUnlimitedAndUsageAllowed, -} from "./cusEntUtils.js"; +} from "../cusEntUtils.js"; + +export interface CusFeatureBalance { + feature_id: string; + unlimited?: boolean; + interval?: EntInterval; + balance?: number | null; + total?: number | null; + adjustment?: number | null; + used?: number | null; + unused?: number | null; + next_reset_at?: number | null; + allowance?: number | null; +} export const getV1EntitlementsRes = ({ org, @@ -57,7 +71,6 @@ export const getV1EntitlementsRes = ({ export const getCusBalances = async ({ cusEntsWithCusProduct, cusPrices, - // entities, org, entity, }: { @@ -65,7 +78,6 @@ export const getCusBalances = async ({ customer_product: FullCusProduct; })[]; cusPrices: FullCustomerPrice[]; - // entities: Entity[]; org: Organization; entity?: Entity; }) => { @@ -231,5 +243,5 @@ export const getCusBalances = async ({ }); } - return balances; + return balances as CusFeatureBalance[]; }; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts new file mode 100644 index 000000000..34f25e3b1 --- /dev/null +++ b/server/src/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusFeaturesResponse.ts @@ -0,0 +1,31 @@ +import { balancesToFeatureResponse } from "./balancesToFeatureResponse.js"; +import { FullCusProduct, Organization, Entity } from "@autumn/shared"; +import { + cusProductsToCusEnts, + cusProductsToCusPrices, +} from "../../cusProductUtils/convertCusProduct.js"; +import { getCusBalances } from "./getCusBalances.js"; + +export const getCusFeaturesResponse = async ({ + cusProducts, + org, + entity, +}: { + cusProducts: FullCusProduct[]; + org: Organization; + entity?: Entity; +}) => { + let cusEnts = cusProductsToCusEnts({ cusProducts }) as any; + + const balances = await getCusBalances({ + cusEntsWithCusProduct: cusEnts, + cusPrices: cusProductsToCusPrices({ cusProducts }), + org, + entity, + }); + + return balancesToFeatureResponse({ + cusEnts, + balances, + }); +}; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index b7004f94b..56c3b603b 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -1,6 +1,7 @@ import { BillingType, CusProductStatus, + FullCusEntWithFullCusProduct, FullCusProduct, FullCustomerEntitlement, FullCustomerPrice, @@ -79,7 +80,7 @@ export const cusProductsToCusEnts = ({ sortCusEntsForDeduction(cusEnts, reverseOrder); - return cusEnts; + return cusEnts as FullCusEntWithFullCusProduct[]; }; export const cusProductToPrices = ({ diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils.ts b/server/src/internal/customers/cusUtils/cusResponseUtils.ts index 6097f4659..6e501fb9b 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils.ts @@ -1,7 +1,6 @@ import { processFullCusProducts } from "./cusUtils.js"; import { CusEntResponseSchema, - CusProductResponse, Entity, Feature, FeatureType, @@ -12,15 +11,6 @@ import { } from "@autumn/shared"; import Stripe from "stripe"; -import { getCusBalances } from "../cusProducts/cusEnts/getCusBalances.js"; -import { featuresToObject } from "./getCustomerDetails.js"; -import { - cusProductsToCusEnts, - cusProductsToCusPrices, -} from "../cusProducts/cusProductUtils/convertCusProduct.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { cusEntEntityMatch } from "../cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; - export const getCusProductsResponse = async ({ cusProducts, entities, @@ -46,53 +36,3 @@ export const getCusProductsResponse = async ({ return products; }; - -export const getCusFeaturesResponse = async ({ - cusProducts, - org, - entity, -}: { - cusProducts: FullCusProduct[]; - org: Organization; - entity?: Entity; -}) => { - let cusEnts = cusProductsToCusEnts({ cusProducts }) as any; - - // cusEnts = cusEnts.filter((ce: FullCustomerEntitlement) => { - // // return cusEntEntityMatch({ cusEnt: ce, entity }); - // }); - - const balances = await getCusBalances({ - cusEntsWithCusProduct: cusEnts, - cusPrices: cusProductsToCusPrices({ cusProducts }), - org, - entity, - }); - - let features = cusEnts.map( - (cusEnt: FullCustomerEntitlement) => cusEnt.entitlement.feature, - ); - - let entList: any = balances.map((b) => { - let isBoolean = - features.find((f: Feature) => f.id == b.feature_id)?.type == - FeatureType.Boolean; - - if (b.unlimited || isBoolean) { - return b; - } - - return CusEntResponseSchema.parse({ - ...b, - usage: b.used, - included_usage: b.allowance, - }); - }); - - entList = featuresToObject({ - features, - entList, - }); - - return entList; -}; diff --git a/server/src/internal/customers/cusUtils/getCustomerDetails.ts b/server/src/internal/customers/cusUtils/getCustomerDetails.ts index e1a433010..e8cc95462 100644 --- a/server/src/internal/customers/cusUtils/getCustomerDetails.ts +++ b/server/src/internal/customers/cusUtils/getCustomerDetails.ts @@ -1,7 +1,7 @@ import Stripe from "stripe"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { getCusBalances } from "@/internal/customers/cusProducts/cusEnts/getCusBalances.js"; +import { getCusBalances } from "@/internal/customers/cusProducts/cusEnts/cusFeatureUtils/getCusBalances.js"; import { BREAK_API_VERSION } from "@/utils/constants.js"; import { @@ -33,90 +33,7 @@ import { cusProductsToCusPrices, } from "../cusProducts/cusProductUtils/convertCusProduct.js"; import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; - -export const sumValues = ( - entList: CusEntResponse[], - key: keyof CusEntResponse, -) => { - return entList.reduce((acc, curr) => { - if (curr[key]) { - return acc + Number(curr[key]); - } - - return acc; - }, 0); -}; - -export const getEarliestNextResetAt = (entList: CusEntResponse[]) => { - let earliest = entList.reduce((acc, curr) => { - if (curr.next_reset_at && curr.next_reset_at < acc) { - return curr.next_reset_at; - } - - return acc; - }, Infinity); - - return earliest == Infinity ? null : earliest; -}; - -export const featuresToObject = ({ - features, - entList, -}: { - features: Feature[]; - entList: CusEntResponse[]; -}) => { - let featureObject: Record = {}; - for (let entRes of entList) { - let feature = features.find((f) => f.id == entRes.feature_id)!; - if (feature.type == FeatureType.Boolean) { - featureObject[feature.id] = { - id: feature.id, - name: feature.name, - }; - continue; - } else if (entRes.unlimited) { - featureObject[feature.id] = { - id: feature.id, - name: feature.name, - unlimited: true, - }; - continue; - } - - let featureId = feature.id; - let unlimited = entRes.unlimited; - let relatedEnts = entList.filter((e) => e.feature_id == featureId); - - if (featureObject[featureId]) { - continue; - } - - featureObject[featureId] = { - id: featureId, - name: feature.name, - unlimited, - balance: unlimited ? null : sumValues(relatedEnts, "balance"), - usage: sumValues(relatedEnts, "usage"), - included_usage: sumValues(relatedEnts, "included_usage"), - - next_reset_at: getEarliestNextResetAt(relatedEnts), - interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", - breakdown: - relatedEnts.length > 1 - ? relatedEnts.map((e) => ({ - interval: e.interval!, - balance: e.balance, - usage: e.usage, - included_usage: e.included_usage, - next_reset_at: e.next_reset_at, - })) - : undefined, - }; - } - - return featureObject; -}; +import { featuresToObject } from "../cusProducts/cusEnts/cusFeatureUtils/balancesToFeatureResponse.js"; export const getCustomerDetails = async ({ db, diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 6d837da3c..3ad6d5bbb 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -14,10 +14,6 @@ import { Organization, } from "@autumn/shared"; -import { createEntities } from "@/internal/entities/handlers/handleCreateEntity/handleCreateEntity.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { StatusCodes } from "http-status-codes"; -import { DrizzleCli } from "@/db/initDrizzle.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; diff --git a/server/src/internal/entities/entityUtils/entityUtils.ts b/server/src/internal/entities/entityUtils/entityUtils.ts index 940230437..166d54306 100644 --- a/server/src/internal/entities/entityUtils/entityUtils.ts +++ b/server/src/internal/entities/entityUtils/entityUtils.ts @@ -1,5 +1,10 @@ -import { generateId } from "@/utils/genUtils.js"; -import { AppEnv, Entity } from "@autumn/shared"; +import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; +import { + AppEnv, + Entity, + FeatureType, + FullCusEntWithFullCusProduct, +} from "@autumn/shared"; export const constructEntity = ({ inputEntity, diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 25d92d225..df9bf7e99 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -81,6 +81,8 @@ export const getProration = ({ }) => { if (!proration && !anchorToUnix) return undefined; + if (interval == BillingInterval.OneOff) return undefined; + if (proration) { return proration; } diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index d7f8a0f9a..a8c88e0a3 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -173,6 +173,7 @@ export class ProductService { inIds, returnAll = false, version, + excludeEnts = false, }: { db: DrizzleCli; orgId: string; @@ -180,6 +181,7 @@ export class ProductService { inIds?: string[]; returnAll?: boolean; version?: number; + excludeEnts?: boolean; }) { let data = (await db.query.products.findMany({ where: and( @@ -188,13 +190,16 @@ export class ProductService { inIds ? inArray(products.id, inIds) : undefined, version ? eq(products.version, version) : undefined, ), + with: { - entitlements: { - with: { - feature: true, - }, - where: eq(entitlements.is_custom, false), - }, + entitlements: excludeEnts + ? undefined + : { + with: { + feature: true, + }, + where: eq(entitlements.is_custom, false), + }, prices: { where: eq(prices.is_custom, false) }, free_trials: { where: eq(freeTrials.is_custom, false) }, }, diff --git a/server/src/internal/api/products/handlers/handleCopyProduct.ts b/server/src/internal/products/handlers/handleCopyProduct.ts similarity index 97% rename from server/src/internal/api/products/handlers/handleCopyProduct.ts rename to server/src/internal/products/handlers/handleCopyProduct.ts index 0a549df7c..43fc88b84 100644 --- a/server/src/internal/api/products/handlers/handleCopyProduct.ts +++ b/server/src/internal/products/handlers/handleCopyProduct.ts @@ -3,7 +3,7 @@ import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { CreateFeatureSchema, ErrCode } from "@autumn/shared"; -import { initNewFeature } from "../../features/featureApiRouter.js"; +import { initNewFeature } from "../../api/features/featureApiRouter.js"; import { copyProduct } from "@/internal/products/productUtils.js"; export const handleCopyProduct = async (req: any, res: any) => @@ -106,6 +106,7 @@ export const handleCopyProduct = async (req: any, res: any) => toOrgId: orgId, toId, toName, + fromEnv, toEnv: toEnv, toFeatures, fromFeatures, diff --git a/server/src/internal/api/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts similarity index 78% rename from server/src/internal/api/products/handlers/handleCreateProduct.ts rename to server/src/internal/products/handlers/handleCreateProduct.ts index e07731d34..8bc598a2f 100644 --- a/server/src/internal/api/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -10,6 +10,7 @@ import { CreateProductSchema, ErrCode, FreeTrial, + Price, ProductResponseSchema, } from "@autumn/shared"; import { @@ -22,12 +23,14 @@ import { import { ProductService } from "@/internal/products/ProductService.js"; import { constructProduct } from "@/internal/products/productUtils.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; +import { detectBaseVariant } from "../productUtils/detectProductVariant.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { JobName } from "@/queue/JobName.js"; const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { let { free_trial, items } = req.body; - let { orgId, env, db } = req; + let { orgId, env, db, features } = req; let productData = CreateProductSchema.parse(req.body); @@ -37,18 +40,15 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { productData.name = keyToTitle(productData.id); } - const [features, existingProduct] = await Promise.all([ - FeatureService.getFromReq(req), - ProductService.get({ - db, - id: productData.id, - orgId, - env, - }), - ]); + const existing = await ProductService.get({ + db, + orgId, + env, + id: productData.id, + }); // 1. If existing product, throw error - if (existingProduct) { + if (existing) { throw new RecaseError({ message: `Product ${productData.id} already exists`, code: ErrCode.ProductAlreadyExists, @@ -89,6 +89,7 @@ const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { productData, }; }; + export const handleCreateProduct = async (req: Request, res: any) => routeHandler({ req, @@ -96,22 +97,23 @@ export const handleCreateProduct = async (req: Request, res: any) => action: "POST /products", handler: async (req, res) => { let { items } = req.body; - let { logtail: logger, orgId, env, db } = req; + let { logtail: logger, org, features, env, db } = req; - let { features, freeTrial, productData } = await validateCreateProduct({ + let { freeTrial, productData } = await validateCreateProduct({ req, }); let newProduct = constructProduct({ productData, - orgId, + orgId: org.id, env, }); let product = await ProductService.insert({ db, product: newProduct }); + let prices: Price[] = []; if (notNullish(items)) { - await handleNewProductItems({ + const res = await handleNewProductItems({ db, product, features, @@ -122,6 +124,7 @@ export const handleCreateProduct = async (req: Request, res: any) => isCustom: false, newVersion: false, }); + prices = res.prices; } if (notNullish(freeTrial)) { @@ -134,6 +137,17 @@ export const handleCreateProduct = async (req: Request, res: any) => }); } + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: { + ...product, + prices, + entitlements: [], + }, + }, + }); + res.status(200).json( ProductResponseSchema.parse({ ...product, diff --git a/server/src/internal/api/products/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts similarity index 100% rename from server/src/internal/api/products/handleDeleteProduct.ts rename to server/src/internal/products/handlers/handleDeleteProduct.ts diff --git a/server/src/internal/api/products/handleGetProduct.ts b/server/src/internal/products/handlers/handleGetProduct.ts similarity index 100% rename from server/src/internal/api/products/handleGetProduct.ts rename to server/src/internal/products/handlers/handleGetProduct.ts diff --git a/server/src/internal/api/products/handlers/handleListProducts.ts b/server/src/internal/products/handlers/handleListProducts.ts similarity index 100% rename from server/src/internal/api/products/handlers/handleListProducts.ts rename to server/src/internal/products/handlers/handleListProducts.ts diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 160bc2abe..776f0c42e 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -13,6 +13,8 @@ import { routeHandler } from "@/utils/routerUtils.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { JobName } from "@/queue/JobName.js"; export const handleUpdateProductV2 = async (req: any, res: any) => routeHandler({ @@ -90,7 +92,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const { items, free_trial } = req.body; - await handleNewProductItems({ + const { prices, entitlements } = await handleNewProductItems({ db, curPrices: fullProduct.prices, curEnts: fullProduct.entitlements, @@ -111,6 +113,18 @@ export const handleUpdateProductV2 = async (req: any, res: any) => }); } + logger.info("Adding task to queue to detect base variant"); + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: { + ...fullProduct, + prices: prices.length > 0 ? prices : fullProduct.prices, + entitlements, + }, + }, + }); + res.status(200).send({ message: "Product updated" }); return; }, diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index c76e638dc..703bcfd3b 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -15,6 +15,8 @@ import { handleNewProductItems } from "@/internal/products/product-items/product import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; import { PriceService } from "@/internal/products/prices/PriceService.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { JobName } from "@/queue/JobName.js"; export const handleVersionProductV2 = async ({ req, @@ -108,5 +110,12 @@ export const handleVersionProductV2 = async ({ }); } + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: newProduct, + }, + }); + res.status(200).send(newProduct); }; diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index 642982af3..c5a20002f 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -1,6 +1,6 @@ import RecaseError from "@/utils/errorUtils.js"; -import { nullish } from "@/utils/genUtils.js"; +import { notNullish, nullish } from "@/utils/genUtils.js"; import { getFeatureName } from "@/internal/features/utils/displayUtils.js"; import { @@ -20,6 +20,7 @@ import { isPriceItem } from "../product-items/productItemUtils/getItemType.js"; import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js"; import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { isProductUpgrade } from "../productUtils.js"; +import { getFirstInterval } from "../prices/priceUtils/priceIntervalUtils.js"; export const sortProductItems = (items: ProductItem[], features: Feature[]) => { items.sort((a, b) => { @@ -277,9 +278,10 @@ export const getAttachScenario = ({ } let curFullProduct = cusProductToProduct({ cusProduct: curMainProduct }); + let isUpgrade = isProductUpgrade({ - prices1: fullProduct.prices, - prices2: curFullProduct.prices, + prices1: curFullProduct.prices, + prices2: fullProduct.prices, }); return isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade; @@ -289,6 +291,7 @@ export const toPricecnProduct = ({ org, product, fullProduct, + otherProducts, features, curMainProduct, curScheduledProduct, @@ -296,6 +299,7 @@ export const toPricecnProduct = ({ org: Organization; product: ProductV2; fullProduct: FullProduct; + otherProducts: FullProduct[]; features: Feature[]; curMainProduct?: FullCusProduct | null; curScheduledProduct?: FullCusProduct | null; @@ -364,9 +368,30 @@ export const toPricecnProduct = ({ }); let freeTrial = fullProduct.free_trial; + + let baseVariant = null; + if (fullProduct.base_variant_id) { + baseVariant = otherProducts.find( + (p) => p.id == fullProduct.base_variant_id, + ); + } + + let name = product.name; + if (baseVariant) { + name = `${baseVariant.name}`; + } + + let intervalGroup = null; + if ( + baseVariant || + otherProducts.some((p) => p.base_variant_id == product.id) + ) { + intervalGroup = getFirstInterval({ prices: fullProduct.prices }); + } + return { id: product.id, - name: product.name, + name, is_add_on: product.is_add_on, price: price ? { @@ -387,6 +412,8 @@ export const toPricecnProduct = ({ } : null, + interval_group: intervalGroup, + // To deprecate buttonText, }; diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts index 1d60c03a5..ded935665 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts @@ -1,5 +1,6 @@ import { APIVersion, + BillingInterval, BillingType, FullCusProduct, OnDecrease, @@ -38,10 +39,18 @@ export const isFixedPrice = ({ price }: { price: Price }) => { ); }; -export const hasPrepaidPrice = ({ prices }: { prices: Price[] }) => { - return prices.some( - (price) => getBillingType(price.config) == BillingType.UsageInAdvance, - ); +export const hasPrepaidPrice = ({ + prices, + excludeOneOff, +}: { + prices: Price[]; + excludeOneOff?: boolean; +}) => { + return prices.some((price) => { + let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance; + let isOneOff = price.config.interval == BillingInterval.OneOff; + return isUsage && (excludeOneOff ? !isOneOff : true); + }); }; export const isV4Usage = ({ diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 03ea53c10..65ec4c5e4 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -97,7 +97,7 @@ export const getItemFeatureType = ({ export const constructFeatureItem = ({ feature_id, included_usage, - interval, + interval = EntInterval.Month, entitlement_id, entity_feature_id, }: { diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 1daf80a35..cc085fe59 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -10,11 +10,11 @@ import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice import { createStripeCli } from "@/external/stripe/utils.js"; import { handleUpdateProductV2 } from "./handlers/handleUpdateProduct/handleUpdateProduct.js"; -import { handleDeleteProduct } from "../api/products/handleDeleteProduct.js"; -import { handleGetProduct } from "../api/products/handleGetProduct.js"; -import { handleCopyProduct } from "../api/products/handlers/handleCopyProduct.js"; -import { handleCreateProduct } from "../api/products/handlers/handleCreateProduct.js"; -import { handleListProducts } from "../api/products/handlers/handleListProducts.js"; +import { handleDeleteProduct } from "./handlers/handleDeleteProduct.js"; +import { handleGetProduct } from "./handlers/handleGetProduct.js"; +import { handleCopyProduct } from "./handlers/handleCopyProduct.js"; +import { handleCreateProduct } from "./handlers/handleCreateProduct.js"; +import { handleListProducts } from "./handlers/handleListProducts.js"; export const productRouter: Router = Router(); diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 6a24ad5d8..4ad9b37ac 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -102,6 +102,7 @@ export const constructProduct = ({ processor, internal_id: generateId("prod"), created_at: Date.now(), + base_variant_id: null, }; return newProduct; @@ -296,6 +297,7 @@ export const copyProduct = async ({ toOrgId, toId, toName, + fromEnv, toEnv, toFeatures, fromFeatures, @@ -303,6 +305,7 @@ export const copyProduct = async ({ db: DrizzleCli; product: FullProduct; toOrgId: string; + fromEnv: AppEnv; toEnv: AppEnv; toId: string; toName: string; @@ -317,6 +320,7 @@ export const copyProduct = async ({ org_id: toOrgId, env: toEnv, processor: null, + base_variant_id: fromEnv == toEnv ? null : product.base_variant_id, }; const newEntitlements: Entitlement[] = []; diff --git a/server/src/internal/products/compareProductUtils.ts b/server/src/internal/products/productUtils/compareProductUtils.ts similarity index 92% rename from server/src/internal/products/compareProductUtils.ts rename to server/src/internal/products/productUtils/compareProductUtils.ts index 04068c41e..3c4943358 100644 --- a/server/src/internal/products/compareProductUtils.ts +++ b/server/src/internal/products/productUtils/compareProductUtils.ts @@ -5,17 +5,17 @@ import { ProductItem, ProductV2, } from "@autumn/shared"; -import { mapToProductItems } from "./productV2Utils.js"; +import { mapToProductItems } from "../productV2Utils.js"; import { findSimilarItem, itemsAreSame, -} from "./product-items/compareItemUtils.js"; +} from "../product-items/compareItemUtils.js"; import RecaseError from "@/utils/errorUtils.js"; -import { freeTrialsAreSame } from "./free-trials/freeTrialUtils.js"; +import { freeTrialsAreSame } from "../free-trials/freeTrialUtils.js"; import { isFeaturePriceItem, isPriceItem, -} from "./product-items/productItemUtils/getItemType.js"; +} from "../product-items/productItemUtils/getItemType.js"; export const productsAreSame = ({ newProductV1, diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts new file mode 100644 index 000000000..928df239c --- /dev/null +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -0,0 +1,112 @@ +import { BillingInterval, FullProduct } from "@autumn/shared"; +import { generateObject } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { nullish } from "@/utils/genUtils.js"; +import { Logger } from "pino"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { ProductService } from "../ProductService.js"; + +const prompt = `Detect whether a given product (called "product_to_detect") is an interval variant of a base product from the list of existing products (called "existing_products"). + +An interval variant simply means a product is a quarterly, semi-annual, or annual version of a base monthly product. + +If the given product is a variant of another product (which we'll call the base variant), please return the id of the base variant in the base_variant_id field. + +To determine if a product is an interval variant, please follow these guidelines: + +1. Look at the name of the product. If it contains a word like "annual", "yearly", etc. and the name resembles another product, it's a variant. +- Example of this: "Pro (Annual)" is a variant of "Pro". + +2. + + + +4. If the current product is not a variant of any existing product, return null. +`; + +export const detectBaseVariant = async ({ + db, + curProduct, + logger, +}: { + db: DrizzleCli; + curProduct: FullProduct; + logger: Logger; +}) => { + let existingProducts = (await ProductService.listFull({ + db, + orgId: curProduct.org_id, + env: curProduct.env, + excludeEnts: true, + })) as FullProduct[]; + + // if (product.base_variant_id == baseVariantId) { + let curPrices = curProduct.prices; + let intervals = curPrices.map((price) => price.config.interval); + + // 1. Return null if add on + if (curProduct.is_add_on) return null; + + // // 2. Return null if only one off or monthly price + const oneOffOrMonthly = [BillingInterval.OneOff, BillingInterval.Month]; + if (intervals.every((i: BillingInterval) => oneOffOrMonthly.includes(i))) + return null; + + const filteredExistingProducts = existingProducts.filter( + (p) => + p.id != curProduct.id && + nullish(p.base_variant_id) && + !p.is_add_on && + p.prices.length > 0 && + p.prices.every((price) => price.config.interval == BillingInterval.Month), + ); + + if (filteredExistingProducts.length == 0) return null; + + const variables = ` + + ${JSON.stringify({ + id: curProduct.id, + name: curProduct.name, + prices: curPrices, + })} + + + + ${filteredExistingProducts + .map((p) => + JSON.stringify({ + id: p.id, + name: p.name, + prices: p.prices, + }), + ) + .join("\n")} + +`; + + let { object } = await generateObject({ + model: anthropic("claude-3-5-haiku-latest"), + schema: z.object({ base_variant_id: z.string().nullable() }), + prompt: `${prompt}\n\n${variables}`, + }); + + let baseVariantId = object.base_variant_id; + + logger.info( + `llm response for base variant of ${curProduct.id}: ${baseVariantId}`, + ); + + if (baseVariantId) { + await ProductService.updateByInternalId({ + db, + internalId: curProduct.internal_id, + update: { + base_variant_id: baseVariantId, + }, + }); + } + + return baseVariantId; +}; diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 9c71e8acf..9eafe1ef7 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -6,6 +6,7 @@ export enum JobName { TriggerCheckoutReward = "trigger-checkout-reward", GenerateFeatureDisplay = "generate-feature-display", + DetectBaseVariant = "detect-base-variant", HandleProductsUpdated = "handle-products-updated", HandleCustomerCreated = "handle-customer-created", diff --git a/server/src/queue/workersInit.ts b/server/src/queue/workersInit.ts index 172c8dff7..2d2db82ee 100644 --- a/server/src/queue/workersInit.ts +++ b/server/src/queue/workersInit.ts @@ -13,6 +13,8 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { acquireLock, getRedisConnection, releaseLock } from "./lockUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { logger } from "@/external/logtail/logtailUtils.js"; +import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js"; +import { Logger } from "pino"; const NUM_WORKERS = 10; @@ -47,6 +49,15 @@ const initWorker = ({ }, }); + if (job.name == JobName.DetectBaseVariant) { + await detectBaseVariant({ + db, + curProduct: job.data.curProduct, + logger: logtail as Logger, + }); + return; + } + if (job.name == JobName.GenerateFeatureDisplay) { await runSaveFeatureDisplayTask({ db, diff --git a/server/src/utils/errorUtils.ts b/server/src/utils/errorUtils.ts index 89b69851c..557488256 100644 --- a/server/src/utils/errorUtils.ts +++ b/server/src/utils/errorUtils.ts @@ -132,6 +132,7 @@ export const handleRequestError = ({ res.status(error.statusCode).json({ message: error.message, code: error.code, + env: req.env, }); return; } diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 9aa197c5c..5c83bdd0e 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -80,6 +80,7 @@ export const constructProduct = ({ trial = false, excludeBase = false, isDefault = true, + isAddOn = false, }: { id?: string; items: ProductItem[]; @@ -89,6 +90,7 @@ export const constructProduct = ({ trial?: boolean; excludeBase?: boolean; isDefault?: boolean; + isAddOn?: boolean; }) => { let price = 0; if (type == "pro") { @@ -133,7 +135,7 @@ export const constructProduct = ({ ? `${keyToTitle(type)} (${interval})` : keyToTitle(type), items, - is_add_on: false, + is_add_on: isAddOn, is_default: type == "free" && isDefault, version: 1, group: "", diff --git a/server/tests/contUse/entities/entity4.ts b/server/tests/contUse/entities/entity4.ts index f12fcc47a..ca9214052 100644 --- a/server/tests/contUse/entities/entity4.ts +++ b/server/tests/contUse/entities/entity4.ts @@ -165,6 +165,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing per entity features } }); + return; + // 1. Use from main balance... it("should use from top level balance", async function () { let deduction = 600; diff --git a/server/tests/contUse/track/track4.ts b/server/tests/contUse/track/track4.ts index 8a5cece07..e2ddbfe35 100644 --- a/server/tests/contUse/track/track4.ts +++ b/server/tests/contUse/track/track4.ts @@ -104,7 +104,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing set usage for cont stripeCli, testClockId, advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 5, + waitForSeconds: 15, }); await autumn.usage({ @@ -149,7 +149,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing set usage for cont stripeCli, testClockId, advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 5, + waitForSeconds: 15, }); await autumn.usage({ diff --git a/server/tests/contUse/update/updateContUse4.ts b/server/tests/contUse/update/updateContUse4.ts index ebf4f14d8..775da3461 100644 --- a/server/tests/contUse/update/updateContUse4.ts +++ b/server/tests/contUse/update/updateContUse4.ts @@ -135,7 +135,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse incl stripeCli, testClockId, advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 5, + waitForSeconds: 15, }); let customItems = replaceItems({ @@ -197,7 +197,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse incl stripeCli, testClockId, advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 5, + waitForSeconds: 15, }); let customItems = replaceItems({ diff --git a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts index 13e739f0c..2e27d9fb0 100644 --- a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts +++ b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts @@ -11,9 +11,7 @@ export const CusEntResponseSchema = z.object({ next_reset_at: z.number().nullish(), }); -export const CusEntResponseV2Schema = z.object({ - id: z.string(), - name: z.string().nullish(), +export const CoreCusFeatureResponseSchema = z.object({ interval: z.nativeEnum(EntInterval).or(z.literal("multiple")).nullish(), unlimited: z.boolean().nullish(), balance: z.number().nullish(), @@ -34,5 +32,24 @@ export const CusEntResponseV2Schema = z.object({ .nullish(), }); +export const CusEntResponseV2Schema = z + .object({ + id: z.string(), + name: z.string().nullish(), + }) + .extend(CoreCusFeatureResponseSchema.shape); + +export const CheckResponseSchema = z + .object({ + allowed: z.boolean(), + customer_id: z.string(), + feature_id: z.string(), + entity_id: z.string().nullish(), + required_balance: z.number(), + code: z.string(), + }) + .extend(CoreCusFeatureResponseSchema.shape); + export type CusEntResponse = z.infer; export type CusEntResponseV2 = z.infer; +export type CheckResponse = z.infer; diff --git a/shared/models/cusModels/entityModels/entityModels.ts b/shared/models/cusModels/entityModels/entityModels.ts index 732d332da..5c1156de1 100644 --- a/shared/models/cusModels/entityModels/entityModels.ts +++ b/shared/models/cusModels/entityModels/entityModels.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { Feature } from "../../featureModels/featureModels.js"; export const EntitySchema = z.object({ id: z.string(), @@ -25,5 +26,8 @@ export const EntityDataSchema = z.object({ }); export type Entity = z.infer; +export type EntityWithFeature = Entity & { + feature: Feature; +}; export type CreateEntity = z.infer; export type EntityData = z.infer; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts index edd0a96e2..df0a07ea9 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntTable.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntTable.ts @@ -6,6 +6,7 @@ import { unique, text, jsonb, + index, } from "drizzle-orm/pg-core"; import { features } from "../../featureModels/featureTable.js"; @@ -56,6 +57,7 @@ export const customerEntitlements = pgTable( }) .onUpdate("cascade") .onDelete("cascade"), + index("idx_customer_entitlements_product_id").on(table.customer_product_id), ], ); diff --git a/shared/models/cusProductModels/cusEntModels/replaceableTable.ts b/shared/models/cusProductModels/cusEntModels/replaceableTable.ts index fd5db1f47..08960105f 100644 --- a/shared/models/cusProductModels/cusEntModels/replaceableTable.ts +++ b/shared/models/cusProductModels/cusEntModels/replaceableTable.ts @@ -4,6 +4,7 @@ import { foreignKey, text, bigint, + index, } from "drizzle-orm/pg-core"; import { collatePgColumn } from "../../../db/utils.js"; @@ -25,6 +26,7 @@ export const replaceables = pgTable( foreignColumns: [customerEntitlements.id], name: "replaceables_cus_ent_id_fkey", }).onDelete("cascade"), + index("idx_replaceables_cus_ent_id").on(table.cus_ent_id), ], ).enableRLS(); diff --git a/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts b/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts index d3db38d7c..55c7fb056 100644 --- a/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts +++ b/shared/models/cusProductModels/cusPriceModels/cusPriceTable.ts @@ -1,4 +1,4 @@ -import { foreignKey, jsonb, text } from "drizzle-orm/pg-core"; +import { foreignKey, index, jsonb, text } from "drizzle-orm/pg-core"; import { numeric, pgTable } from "drizzle-orm/pg-core"; import { customers } from "../../cusModels/cusTable.js"; @@ -31,5 +31,6 @@ export const customerPrices = pgTable( foreignColumns: [prices.id], name: "customer_prices_price_id_fkey", }), + index("idx_customer_prices_product_id").on(table.customer_product_id), ], ); diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 403914484..272a4ef2a 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -5,6 +5,7 @@ import { foreignKey, unique, text, + index, } from "drizzle-orm/pg-core"; import { features } from "../../featureModels/featureTable.js"; @@ -47,6 +48,7 @@ export const entitlements = pgTable( .onUpdate("cascade") .onDelete("cascade"), unique("entitlements_id_key").on(table.id), + index("idx_entitlements_internal_product_id").on(table.internal_product_id), ], ); diff --git a/shared/models/productModels/priceModels/priceTable.ts b/shared/models/productModels/priceModels/priceTable.ts index 4faadb7f9..c1cd48965 100644 --- a/shared/models/productModels/priceModels/priceTable.ts +++ b/shared/models/productModels/priceModels/priceTable.ts @@ -6,6 +6,7 @@ import { numeric, jsonb, text, + index, } from "drizzle-orm/pg-core"; import { entitlements } from "../entModels/entTable.js"; @@ -45,6 +46,7 @@ export const prices = pgTable( .onUpdate("cascade") .onDelete("cascade"), unique("prices_id_key").on(table.id), + index("idx_prices_internal_product_id").on(table.internal_product_id), ], ); diff --git a/shared/models/productModels/productModels.ts b/shared/models/productModels/productModels.ts index b665d1923..0a12fd078 100644 --- a/shared/models/productModels/productModels.ts +++ b/shared/models/productModels/productModels.ts @@ -3,6 +3,7 @@ import { PriceSchema } from "./priceModels/priceModels.js"; import { EntitlementSchema } from "./entModels/entModels.js"; import { FeatureSchema } from "../featureModels/featureModels.js"; import { FreeTrialSchema } from "./freeTrialModels/freeTrialModels.js"; +import { AppEnv } from "../genModels/genEnums.js"; export const ProductSchema = z.object({ id: z.string(), @@ -12,7 +13,7 @@ export const ProductSchema = z.object({ version: z.number(), group: z.string(), - env: z.string(), + env: z.nativeEnum(AppEnv), internal_id: z.string(), org_id: z.string(), created_at: z.number(), @@ -23,6 +24,7 @@ export const ProductSchema = z.object({ id: z.string(), }) .nullish(), + base_variant_id: z.string().nullable(), }); export const CreateProductSchema = z.object({ diff --git a/shared/models/productModels/productTable.ts b/shared/models/productModels/productTable.ts index f844d891d..6cc49c1c6 100644 --- a/shared/models/productModels/productTable.ts +++ b/shared/models/productModels/productTable.ts @@ -32,6 +32,7 @@ export const products = pgTable( processor: jsonb() .$type() .default(sql`null`), + base_variant_id: text("base_variant_id"), }, (table) => [ foreignKey({