From 980f2d958a24c5519f39819485dc866ed2df0ef8 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 8 Jul 2025 18:52:27 +0100 Subject: [PATCH] fix: usage_limits field returned in get customer and check --- server/src/external/resend/resendUtils.ts | 14 ++ .../src/internal/api/entitled/checkRouter.ts | 10 +- .../entitled/checkUtils/getV2CheckResponse.ts | 49 +++--- .../cusProducts/cusEnts/cusEntUtils.ts | 5 +- .../balancesToFeatureResponse.ts | 12 +- .../cusFeatureResponseUtils/getCusBalances.ts | 15 +- .../customers/cusUtils/getCustomerDetails.ts | 3 +- .../src/internal/emails/sendMigrationEmail.ts | 3 +- .../createEntityForCusProduct.ts | 2 +- server/src/utils/auth.ts | 1 - .../tests/advanced/usageLimit/usageLimit1.ts | 107 +++++++++---- .../tests/advanced/usageLimit/usageLimit2.ts | 145 ++++++++++++++++++ shared/enums/ErrCode.ts | 2 +- vite/src/views/auth/SignIn.tsx | 4 +- 14 files changed, 295 insertions(+), 77 deletions(-) create mode 100644 server/tests/advanced/usageLimit/usageLimit2.ts diff --git a/server/src/external/resend/resendUtils.ts b/server/src/external/resend/resendUtils.ts index 58073b680..76c6e8d5d 100644 --- a/server/src/external/resend/resendUtils.ts +++ b/server/src/external/resend/resendUtils.ts @@ -41,11 +41,25 @@ export const sendTextEmail = async ({ if (error) { logger.error(`Error sending email`, { error, + data: { + from, + fromEmail, + to, + subject, + body, + }, }); } } catch (error) { logger.error(`Error sending email`, { error, + data: { + from, + fromEmail, + to, + subject, + body, + }, }); throw error; } diff --git a/server/src/internal/api/entitled/checkRouter.ts b/server/src/internal/api/entitled/checkRouter.ts index 30967eb8f..32ef8abb3 100644 --- a/server/src/internal/api/entitled/checkRouter.ts +++ b/server/src/internal/api/entitled/checkRouter.ts @@ -5,21 +5,16 @@ import { type Feature, FeatureType, type FullCustomerEntitlement, - type Organization, } from "@autumn/shared"; -import { - cusEntsContainFeature, - getFeatureBalance, - getUnlimitedAndUsageAllowed, -} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { getFeatureBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; import { handleEventSent } from "../events/eventRouter.js"; import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import { SuccessCode } from "@autumn/shared"; + import { handleProductCheck } from "./handlers/handleProductCheck.js"; import { getBooleanEntitledResult } from "./checkUtils.js"; import { getCheckPreview } from "./getCheckPreview.js"; @@ -183,6 +178,7 @@ checkRouter.post("", async (req: any, res: any) => { org, cusProducts, requiredBalance, + apiVersion, }); const { allowed, balance } = v2Response; diff --git a/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts index 5d99b5a1e..c47f8a6c2 100644 --- a/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts @@ -1,14 +1,10 @@ -import { - getFeatureBalance, - getUnlimitedAndUsageAllowed, -} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { cusEntMatchesFeature } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { getCusBalances } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.js"; import { balancesToFeatureResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.js"; import { CheckResponseSchema, Feature, - FeatureType, FullCusEntWithFullCusProduct, FullCusProduct, FullCustomer, @@ -16,7 +12,6 @@ import { SuccessCode, } from "@autumn/shared"; import { notNullish } from "@/utils/genUtils.js"; -import { freeTrialsAreSame } from "@/internal/products/free-trials/freeTrialUtils.js"; export const getFeatureToUse = ({ creditSystems, @@ -59,6 +54,7 @@ export const getV2CheckResponse = async ({ org, cusProducts, requiredBalance, + apiVersion, }: { fullCus: FullCustomer; cusEnts: FullCusEntWithFullCusProduct[]; @@ -67,6 +63,7 @@ export const getV2CheckResponse = async ({ org: Organization; cusProducts: FullCusProduct[]; requiredBalance?: number; + apiVersion: number; }) => { // 1. Get the feature to use const featureToUse = getFeatureToUse({ @@ -92,6 +89,7 @@ export const getV2CheckResponse = async ({ cusPrices, org, entity: fullCus.entity, + apiVersion, }); let cusFeatures = balancesToFeatureResponse({ @@ -99,37 +97,40 @@ export const getV2CheckResponse = async ({ balances, }); -// cusFeature.balance += overage_limit - const cusFeature = cusFeatures[featureToUse.id] || {}; let allowed = false; - let totalUsageLimit = 0; - console.log(featureCusEnts.length); - for (const ent of featureCusEnts) { - totalUsageLimit += ent.entitlement.usage_limit || 0; - } - console.log("Feature Balance", cusFeature.balance); - console.log("Total Usage Limit", totalUsageLimit); - console.log("Required Balance", requiredBalance); - console.log("Current Usage", cusFeature.usage); + let totalPaidUsageAllowance = featureCusEnts.reduce((acc, ce) => { + let ent = ce.entitlement; + if (notNullish(ent.usage_limit)) { + return acc + ent.usage_limit! - (ent.allowance || 0); + } + return acc; + }, 0); if ( (cusFeature && unlimited) || usageAllowed || - cusFeature.balance >= (requiredBalance || 1) + // cusFeature.balance >= (requiredBalance || 1) + cusFeature.balance + totalPaidUsageAllowance >= (requiredBalance || 1) ) { allowed = true; } - console.log("Included Usage", cusFeature.included_usage); + // let totalUsageLimit = 0; - if (totalUsageLimit > 0) { - if ((cusFeature.balance - (requiredBalance || 1)) < -(totalUsageLimit - (cusFeature.included_usage || 0))) { - allowed = false; - } - } + // for (const ent of featureCusEnts) { + // totalUsageLimit += ent.entitlement.usage_limit || 0; + // } + // if (totalUsageLimit > 0) { + // if ( + // cusFeature.balance - (requiredBalance || 1) < + // -(totalUsageLimit - (cusFeature.included_usage || 0)) + // ) { + // allowed = false; + // } + // } return CheckResponseSchema.parse({ customer_id: fullCus.id, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index 2856a045f..89e51211e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -351,7 +351,10 @@ export const getUnlimitedAndUsageAllowed = ({ ); const usageAllowed = cusEnts.some( - (ent) => ent.internal_feature_id === internalFeatureId && ent.usage_allowed, + (ent) => + ent.internal_feature_id === internalFeatureId && + ent.usage_allowed && + nullish(ent.entitlement.usage_limit), ); return { unlimited, usageAllowed }; diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts index a47c8ab91..3e66e2360 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts @@ -12,6 +12,7 @@ import { getCusFeatureType, isCreditSystem, } from "@/internal/features/featureUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; export const sumValues = ( entList: CusEntResponse[], @@ -46,6 +47,7 @@ export const featuresToObject = ({ entList: CusEntResponse[]; }) => { let featureObject: Record = {}; + for (let entRes of entList) { let feature = features.find((f) => f.id == entRes.feature_id)!; let featureType = getCusFeatureType({ feature }); @@ -57,7 +59,13 @@ export const featuresToObject = ({ if (featureObject[featureId]) { continue; } - + + let includedUsage = sumValues(relatedEnts, "included_usage"); + let usageLimit: number | undefined = sumValues(relatedEnts, "usage_limit"); + if (notNullish(usageLimit) && usageLimit === includedUsage) { + usageLimit = undefined; + } + featureObject[featureId] = { id: featureId, name: feature.name, @@ -66,7 +74,7 @@ export const featuresToObject = ({ balance: unlimited ? null : sumValues(relatedEnts, "balance"), usage: sumValues(relatedEnts, "usage"), included_usage: sumValues(relatedEnts, "included_usage"), - usage_limit: sumValues(relatedEnts, "usage_limit"), + usage_limit: usageLimit, next_reset_at: getEarliestNextResetAt(relatedEnts), interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts index bc50ab54a..f6b9075f2 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts @@ -63,6 +63,7 @@ export const getV1EntitlementsRes = ({ res.next_reset_at = isBoolean || unlimited ? undefined : cusEnt.next_reset_at; res.allowance = isBoolean || unlimited ? undefined : 0; + res.usage_limit = isBoolean || unlimited ? undefined : 0; } return res; @@ -74,6 +75,7 @@ export const getCusBalances = async ({ cusPrices, org, entity, + apiVersion, }: { cusEntsWithCusProduct: (FullCustomerEntitlement & { customer_product: FullCusProduct; @@ -81,6 +83,7 @@ export const getCusBalances = async ({ cusPrices: FullCustomerPrice[]; org: Organization; entity?: Entity; + apiVersion: number; }) => { const data: Record = {}; const features = cusEntsWithCusProduct.map( @@ -116,7 +119,7 @@ export const getCusBalances = async ({ }); // 1. Initialize balance object - if (!data[key] && org.api_version == APIVersion.v1) { + if (!data[key] && apiVersion == APIVersion.v1) { data[key] = getV1EntitlementsRes({ org, cusEnt, @@ -146,13 +149,13 @@ export const getCusBalances = async ({ used: isBoolean ? undefined : unlimited ? null : 0, unused: 0, overage_allowed: usageAllowed, - usage_limit: ent.usage_limit ?? null, }; if (org.config.api_version >= BREAK_API_VERSION) { data[key].next_reset_at = isBoolean || unlimited ? undefined : cusEnt.next_reset_at; data[key].allowance = isBoolean || unlimited ? undefined : 0; + data[key].usage_limit = isBoolean || unlimited ? undefined : 0; } } } @@ -179,7 +182,6 @@ export const getCusBalances = async ({ data[key].total += total; data[key].unused += unused || 0; - // data[key].overage_limit = ent.usage_limit; if (org.config.api_version >= BREAK_API_VERSION) { if ( @@ -197,6 +199,13 @@ export const getCusBalances = async ({ }); data[key].allowance += (resetBalance || 0) * count; + + let usageLimit = ent.usage_limit; + if (notNullish(usageLimit)) { + data[key].usage_limit += usageLimit; + } else { + data[key].usage_limit += ent.allowance || 0; + } } } diff --git a/server/src/internal/customers/cusUtils/getCustomerDetails.ts b/server/src/internal/customers/cusUtils/getCustomerDetails.ts index f526d09f2..96e9eea5f 100644 --- a/server/src/internal/customers/cusUtils/getCustomerDetails.ts +++ b/server/src/internal/customers/cusUtils/getCustomerDetails.ts @@ -15,9 +15,7 @@ import { Organization, FullCustomer, CusExpand, - RewardType, RewardResponse, - CouponDurationType, EntityResponseSchema, } from "@autumn/shared"; import { getCusInvoices } from "./cusUtils.js"; @@ -77,6 +75,7 @@ export const getCustomerDetails = async ({ cusEntsWithCusProduct: cusEnts, cusPrices: cusProductsToCusPrices({ cusProducts, inStatuses }), org, + apiVersion, }); let subIds = cusProducts.flatMap( diff --git a/server/src/internal/emails/sendMigrationEmail.ts b/server/src/internal/emails/sendMigrationEmail.ts index 5b5d8f329..e542d8905 100644 --- a/server/src/internal/emails/sendMigrationEmail.ts +++ b/server/src/internal/emails/sendMigrationEmail.ts @@ -27,7 +27,8 @@ export const sendMigrationEmail = safeResend({ console.log("Sending migration email"); await sendTextEmail({ - from: "John", + from: `Autumn`, + fromEmail: "hey", to: "johnyeocx@gmail.com", subject: `Migration Job Finished -- ${migrationJob.id}`, body: ` diff --git a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts index 33a156ed8..784c02cba 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/createEntityForCusProduct.ts @@ -134,7 +134,7 @@ export const createEntityForCusProduct = async ({ ) { throw new RecaseError({ message: `Cannot create ${inputEntities.length} entities for feature ${feature.name} as it would exceed the usage limit.`, - code: ErrCode.InvalidInputs, + code: ErrCode.FeatureLimitReached, }); } diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 5899a9fe7..645c3cd19 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -49,7 +49,6 @@ export const auth = betterAuth({ "http://localhost:3000", "https://app.useautumn.com", "https://*.useautumn.com", - process.env.CLIENT_URL!, ], emailAndPassword: { enabled: true, diff --git a/server/tests/advanced/usageLimit/usageLimit1.ts b/server/tests/advanced/usageLimit/usageLimit1.ts index 9ad68c678..66b9a1fee 100644 --- a/server/tests/advanced/usageLimit/usageLimit1.ts +++ b/server/tests/advanced/usageLimit/usageLimit1.ts @@ -2,7 +2,7 @@ import chalk from "chalk"; import Stripe from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +import { APIVersion, AppEnv, ErrCode, Organization } from "@autumn/shared"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { setupBefore } from "tests/before.js"; @@ -11,19 +11,22 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; +import { expect } from "chai"; + +const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 0, + usageLimit: 2, +}); export let pro = constructProduct({ - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - usageLimit: 2, - }), - ], + items: [userItem], type: "pro", }); -const testCase = "entity1"; +const testCase = "usageLimit1"; describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => { let customerId = testCase; @@ -34,29 +37,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => { let curUnix = new Date().getTime(); - const entities = [ - { - id: "1", - name: "Entity 1", - featureId: TestFeature.Users, - }, - { - id: "2", - name: "Entity 1", - featureId: TestFeature.Users, - }, - { - id: "3", - name: "Entity 1", - featureId: TestFeature.Users, - }, - { - id: "4", - name: "Entity 1", - featureId: TestFeature.Users, - }, - ]; - before(async function () { await setupBefore(this); const { autumnJs } = this; @@ -92,6 +72,29 @@ describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => { testClockId = testClockId1!; }); + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + { + id: "3", + name: "Entity 3", + feature_id: TestFeature.Users, + }, + { + id: "4", + name: "Entity 4", + feature_id: TestFeature.Users, + }, + ]; + it("should attach pro product", async function () { await runAttachTest({ autumn, @@ -103,4 +106,44 @@ describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => { env, }); }); + it("should create more entities than the limit and hit error", async function () { + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities); + }, + }); + }); + + it("should create entities one by one, then hit usage limit", async function () { + await autumn.entities.create(customerId, entities[0]); + await autumn.entities.create(customerId, entities[1]); + + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities[2]); + }, + }); + }); + + it("should have correct check and get customer value", async function () { + const check = await autumn.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + }); + const customer = await autumn.customers.get(customerId); + + console.log(check); + console.log(customer); + + expect(check.balance).to.equal(-2); + // @ts-ignore + expect(check.usage_limit).to.equal(userItem.usage_limit); + + // @ts-ignore + expect(customer.features[TestFeature.Users].usage_limit).to.equal( + userItem.usage_limit, + ); + }); }); diff --git a/server/tests/advanced/usageLimit/usageLimit2.ts b/server/tests/advanced/usageLimit/usageLimit2.ts new file mode 100644 index 000000000..f622f9343 --- /dev/null +++ b/server/tests/advanced/usageLimit/usageLimit2.ts @@ -0,0 +1,145 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, ErrCode, Organization } from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; +import { expect } from "chai"; + +const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 0, + usageLimit: 2, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "entity1"; + +describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const entities = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + { + id: "3", + name: "Entity 3", + feature_id: TestFeature.Users, + }, + { + id: "4", + name: "Entity 4", + feature_id: TestFeature.Users, + }, + ]; + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + it("should create more entities than the limit and hit error", async function () { + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities); + }, + }); + }); + + it("should create entities one by one, then hit usage limit", async function () { + await autumn.entities.create(customerId, entities[0]); + await autumn.entities.create(customerId, entities[1]); + + await expectAutumnError({ + errCode: ErrCode.FeatureLimitReached, + func: async () => { + await autumn.entities.create(customerId, entities[2]); + }, + }); + }); + + it("should have correct check and get customer value", async function () { + const check = await autumn.check({ + customer_id: customerId, + feature_id: TestFeature.Users, + }); + expect(check.balance).to.equal(-2); + // @ts-ignore + expect(check.usage_limit).to.equal(userItem.usage_limit); + + const customer = await autumn.customers.get(customerId); + // @ts-ignore + expect(customer.features[TestFeature.Users].usage_limit).to.equal( + userItem.usage_limit, + ); + }); +}); diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 35606f6a2..a3c1ddf2a 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -17,7 +17,6 @@ export const ErrCode = { // General InvalidInputs: "invalid_inputs", - LimitsReached: "limits_reached", InvalidRequest: "invalid_request", InvalidExpand: "invalid_expand", @@ -30,6 +29,7 @@ export const ErrCode = { InvalidFeature: "invalid_feature", DuplicateFeatureId: "duplicate_feature_id", InvalidEventName: "invalid_event_name", + FeatureLimitReached: "feature_limit_reached", // Internal InternalError: "internal_error", diff --git a/vite/src/views/auth/SignIn.tsx b/vite/src/views/auth/SignIn.tsx index b4aad934e..c171eaa60 100644 --- a/vite/src/views/auth/SignIn.tsx +++ b/vite/src/views/auth/SignIn.tsx @@ -119,7 +119,7 @@ export const SignIn = () => { Continue with Google - + */} {/* Divider */}