From cf31454c630ac5d640e3fa9d0d005a47c5bc5c35 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 23 Jul 2025 13:47:56 +0100 Subject: [PATCH] feat: caching customer to speed up check and get customer --- server/src/cron.ts | 15 +++ server/src/external/stripe/stripeWebhooks.ts | 80 +++++++++++++++- .../handleCheckoutCompleted.ts | 4 +- server/src/internal/api/apiRouter.ts | 2 +- .../internal/api/entities/getEntityUtils.ts | 6 ++ .../api/entities/handlers/handleGetEntity.ts | 3 +- .../api/entitled/checkUtils/getCheckData.ts | 5 +- .../internal/customers/attach/handleAttach.ts | 1 - .../customers/cusCache/cusCacheUtils.ts | 2 +- .../customers/cusCache/getCusWithCache.ts | 27 ++++-- .../customers/cusCache/updateCachedCus.ts | 40 +++++++- .../customers/cusProducts/cusProductUtils.ts | 6 +- server/src/internal/customers/cusRouter.ts | 10 +- .../internal/customers/cusUtils/cusUtils.ts | 7 ++ .../customers/cusUtils/getOrCreateCustomer.ts | 45 ++++++--- .../customers/handlers/cusDeleteHandlers.ts | 7 -- .../customers/handlers/handleGetCustomer.ts | 2 + .../customers/handlers/handlePostCustomer.ts | 1 + .../handlers/handleUpdateBalances.ts | 7 -- .../handlers/handleUpdateCustomer.ts | 7 -- .../handlers/handleUpdateEntitlement.ts | 1 - .../handleCreateEntity/handleCreateEntity.ts | 2 + .../migrationSteps/migrateCustomer.ts | 11 ++- .../src/middleware/refreshCacheMiddleware.ts | 92 ++++++++++++++++--- server/tests/advanced/referrals/referrals2.ts | 21 +---- server/tests/advanced/referrals/referrals4.ts | 5 +- server/tests/attach/basic/basic5.ts | 10 +- server/tests/attach/downgrade/downgrade6.ts | 73 +++------------ server/tests/attach/downgrade/downgrade7.ts | 21 +++-- server/tests/attach/migrations/migration1.ts | 7 +- .../attach/migrations/runMigrationTest.ts | 3 +- .../attach/multiProduct/multiProduct3.ts | 4 +- server/tests/attach/prepaid/prepaid1.ts | 9 +- server/tests/contUse/entities/entity1.ts | 2 + vite/src/services/customers/CusService.tsx | 13 +-- .../entitlements/UpdateCusEntitlement.tsx | 17 ++-- 36 files changed, 380 insertions(+), 188 deletions(-) diff --git a/server/src/cron.ts b/server/src/cron.ts index fb15e40e6..b4493d0cd 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -25,6 +25,8 @@ import { UTCDate } from "@date-fns/utc"; import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.js"; +import { CusService } from "./internal/customers/CusService.js"; +import { refreshCusCache } from "./internal/customers/cusCache/updateCachedCus.js"; dotenv.config(); @@ -208,6 +210,19 @@ const resetCustomerEntitlement = async ({ format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") )}` ); + + let customer = await CusService.getByInternalId({ + db, + internalId: cusEnt.internal_customer_id, + }); + + if (customer) { + await refreshCusCache({ + customerId: customer.id!, + orgId: customer.org_id, + env: customer.env, + }); + } } catch (error: any) { console.log( `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 033b0dc85..ce0fd8503 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -3,7 +3,7 @@ import stripe, { Stripe } from "stripe"; import chalk from "chalk"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { AuthType, LoggerAction, Organization } from "@autumn/shared"; +import { AppEnv, AuthType, LoggerAction, Organization } from "@autumn/shared"; import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js"; @@ -18,6 +18,9 @@ import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubS import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { createStripeCli } from "./utils.js"; +import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; export const stripeWebhookRouter: Router = express.Router(); @@ -238,7 +241,82 @@ stripeWebhookRouter.post( return; } + try { + await handleStripeWebhookRefresh({ + eventType: event.type, + data: event.data, + db, + org, + env, + logger, + }); + } catch (error) { + logger.error(`Stripe webhook, error refreshing cache!`, { error }); + } + // DO NOT DELETE -- RESPONSIBLE FOR SENDING SUCCESSFUL RESPONSE TO STRIPE... response.status(200).send(); } ); + +const coreEvents = [ + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.paid", + "invoice.created", + "invoice.finalized", + "subscription_schedule.canceled", + "checkout.session.completed", +]; + +export const handleStripeWebhookRefresh = async ({ + eventType, + data, + db, + org, + env, + logger, +}: { + eventType: string; + data: any; + db: DrizzleCli; + org: Organization; + env: AppEnv; + logger: any; +}) => { + if (coreEvents.includes(eventType)) { + let stripeCusId = data.object.customer; + if (!stripeCusId) { + logger.warn( + `stripe webhook cache refresh, object doesn't contain customer id`, + { + data: { + eventType, + object: data.object, + }, + } + ); + return; + } + + let cus = await CusService.getByStripeId({ + db, + stripeId: stripeCusId, + }); + + if (!cus) { + logger.warn( + `Searched for customer by stripe id, but not found: ${stripeCusId}` + ); + return; + } + + // logger.info(`Deleting cache for customer ${cus.id}`); + await deleteCusCache({ + customerId: cus.id!, + orgId: org.id, + env, + }); + } +}; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index b9f07d684..77b5c44c0 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -70,7 +70,7 @@ export const handleCheckoutSessionCompleted = async ({ console.log( "Handling checkout.completed: autumn metadata:", - checkoutSession.metadata?.autumn_metadata_id, + checkoutSession.metadata?.autumn_metadata_id ); const checkoutSub = @@ -139,7 +139,7 @@ export const handleCheckoutSessionCompleted = async ({ attachParams, invoiceId, logger, - }), + }) ); } diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 443a7b403..2223a2a50 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -25,7 +25,7 @@ import { internalFeatureRouter } from "../features/internalFeatureRouter.js"; import { analyticsRouter } from "../analytics/analyticsRouter.js"; import { handleConnectStripe } from "../orgs/handlers/handleConnectStripe.js"; import { handleDeleteStripe } from "../orgs/handlers/handleDeleteStripe.js"; -import { refreshCusCache } from "../customers/cusCache/updateCachedCus.js"; + import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js"; const apiRouter: Router = Router(); diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index 86c659c91..c2f7539f5 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -35,6 +35,8 @@ export const getEntityResponse = async ({ withAutumnId = false, apiVersion, features, + logger, + skipCache = false, }: { db: DrizzleCli; entityIds: string[]; @@ -46,6 +48,8 @@ export const getEntityResponse = async ({ withAutumnId?: boolean; apiVersion: number; features: Feature[]; + logger: any; + skipCache?: boolean; }) => { // let customer = await CusService.getFull({ // db, @@ -63,6 +67,8 @@ export const getEntityResponse = async ({ env, expand, entityId, + logger, + skipCache, }); let entities = customer.entities.filter((e: Entity) => diff --git a/server/src/internal/api/entities/handlers/handleGetEntity.ts b/server/src/internal/api/entities/handlers/handleGetEntity.ts index dd8dd8f79..52ee6993f 100644 --- a/server/src/internal/api/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/api/entities/handlers/handleGetEntity.ts @@ -37,6 +37,7 @@ export const handleGetEntity = async (req: any, res: any) => entityId, apiVersion, features, + logger, }); // const end = performance.now(); // logger.info(`getEntityResponse took ${(end - start).toFixed(2)}ms`); @@ -53,7 +54,7 @@ export const handleGetEntity = async (req: any, res: any) => logger, }) : undefined, - }), + }) ); }, }); diff --git a/server/src/internal/api/entitled/checkUtils/getCheckData.ts b/server/src/internal/api/entitled/checkUtils/getCheckData.ts index eda3a8cd6..add274e7c 100644 --- a/server/src/internal/api/entitled/checkUtils/getCheckData.ts +++ b/server/src/internal/api/entitled/checkUtils/getCheckData.ts @@ -22,7 +22,7 @@ const getFeatureAndCreditSystems = ({ const { features } = req; const feature: Feature | undefined = features.find( - (feature: Feature) => feature.id === featureId, + (feature: Feature) => feature.id === featureId ); const creditSystems = getCreditSystemsFromFeature({ @@ -59,6 +59,7 @@ export const getCheckData = async ({ req }: { req: any }) => { inStatuses, entityId: entity_id, entityData: req.body.entity_data, + withCache: true, }); const duration = Date.now() - startTime; @@ -82,7 +83,7 @@ export const getCheckData = async ({ req }: { req: any }) => { cusEnt, entity: customer.entity!, features: allFeatures, - }), + }) ); } diff --git a/server/src/internal/customers/attach/handleAttach.ts b/server/src/internal/customers/attach/handleAttach.ts index 9982f59ea..64f6dcf6d 100644 --- a/server/src/internal/customers/attach/handleAttach.ts +++ b/server/src/internal/customers/attach/handleAttach.ts @@ -9,7 +9,6 @@ import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; import { checkStripeConnections, createStripePrices } from "./attachRouter.js"; import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; -import { refreshCusCache } from "../cusCache/updateCachedCus.js"; export const handleAttach = async (req: any, res: any) => routeHandler({ diff --git a/server/src/internal/customers/cusCache/cusCacheUtils.ts b/server/src/internal/customers/cusCache/cusCacheUtils.ts index c0fc22e7a..6ed1137ec 100644 --- a/server/src/internal/customers/cusCache/cusCacheUtils.ts +++ b/server/src/internal/customers/cusCache/cusCacheUtils.ts @@ -10,7 +10,7 @@ export const buildBaseCusCacheKey = ({ env: string; }) => { if (entityId) { - return `customer:${idOrInternalId}_${orgId}_${env}:${entityId}`; + return `customer:${idOrInternalId}_${orgId}_${env}:entity_${entityId}`; } else { return `customer:${idOrInternalId}_${orgId}_${env}`; } diff --git a/server/src/internal/customers/cusCache/getCusWithCache.ts b/server/src/internal/customers/cusCache/getCusWithCache.ts index 33ef70027..a7762be78 100644 --- a/server/src/internal/customers/cusCache/getCusWithCache.ts +++ b/server/src/internal/customers/cusCache/getCusWithCache.ts @@ -1,9 +1,10 @@ import { AppEnv, CusExpand, EntityExpand, FullCustomer } from "@autumn/shared"; -import { ACTIVE_STATUSES } from "../cusProducts/CusProductService.js"; +import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; import { db } from "@/db/initDrizzle.js"; import { CusService } from "../CusService.js"; import { buildBaseCusCacheKey } from "./cusCacheUtils.js"; import { initUpstash } from "./upstashUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; export const getCusWithCache = async ({ idOrInternalId, @@ -14,6 +15,7 @@ export const getCusWithCache = async ({ allowNotFound = true, skipCache = false, skipGet = false, + logger, }: { idOrInternalId: string; orgId: string; @@ -25,33 +27,38 @@ export const getCusWithCache = async ({ allowNotFound?: boolean; skipCache?: boolean; skipGet?: boolean; + logger: any; }): Promise => { - const statuses = ACTIVE_STATUSES; + const statuses = RELEVANT_STATUSES; const withEntities = true; const withSubs = true; const upstash = await initUpstash(); if (!upstash) skipCache = true; - const baseKey = buildBaseCusCacheKey({ + let cacheKey = buildBaseCusCacheKey({ idOrInternalId, orgId, env, entityId, }); - const cacheKey = `${baseKey}:${expand.join(",")}`; + if (expand.length > 0) { + cacheKey = `${cacheKey}:expand_${expand.join(",")}`; + } + if (!skipCache && !skipGet) { try { const cached = await upstash!.get(cacheKey); if (cached) { - console.log(`Cache hit: ${cacheKey}`); + logger.info(`Cache hit: ${cacheKey}`); + logger.info("Cached:", cached); return cached as FullCustomer; } else { - console.log(`Cache miss: ${cacheKey}`); + // logger.info(`Cache miss: ${cacheKey}`); } } catch (error) { - console.error(error); + logger.error(`Failed to get cache: ${cacheKey}`, { error }); } } @@ -70,12 +77,12 @@ export const getCusWithCache = async ({ if (entityId && !customer.entity) skipCache = true; - if (!skipCache) { + if (!skipCache && notNullish(customer)) { try { await upstash!.set(cacheKey, customer); - await upstash!.expire(cacheKey, 1000); // Expire after 60 seconds + await upstash!.expire(cacheKey, 300); // Expire after 5 minutes... } catch (error) { - console.error(error); + logger.error(`Failed to set cache: ${cacheKey}`, { error }); } } diff --git a/server/src/internal/customers/cusCache/updateCachedCus.ts b/server/src/internal/customers/cusCache/updateCachedCus.ts index 11170eca9..d2cd9059b 100644 --- a/server/src/internal/customers/cusCache/updateCachedCus.ts +++ b/server/src/internal/customers/cusCache/updateCachedCus.ts @@ -24,20 +24,56 @@ export const refreshCusCache = async ({ env, }); - const list = await upstash.keys(`${baseKey}:*`); + const list = await upstash.keys(`${baseKey}*`); for (const key of list) { const keyName = key; let params = keyName.split(":"); - let expand = params ? params[params.length - 1].split(",") : []; + let expandParam = params.find((p) => p.startsWith("expand_")); + let expand = expandParam + ? expandParam.replace("expand_", "").split(",") + : []; + + let entityIdParam = params.find((p) => p.startsWith("entity_")); + let entityId = entityIdParam + ? entityIdParam.replace("entity_", "") + : undefined; await getCusWithCache({ idOrInternalId: customerId, orgId, env, expand: expand as CusExpand[], + entityId, skipGet: true, + logger: console, }); console.log(`updated cache key: ${keyName}`); } }; + +export const deleteCusCache = async ({ + customerId, + orgId, + env, +}: { + customerId: string; + orgId: string; + env: AppEnv; +}) => { + const upstash = await initUpstash(); + if (!upstash) return; + + const baseKey = buildBaseCusCacheKey({ + idOrInternalId: customerId, + orgId, + env, + }); + + const list = await upstash.keys(`${baseKey}*`); + + for (const key of list) { + console.log("Deleting cache for key:", key); + await upstash.del(key); + } +}; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index d1c0f0b94..68dcd1edf 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -505,9 +505,11 @@ export const isTrialing = (cusProduct: FullCusProduct) => { export const getMainCusProduct = async ({ db, internalCustomerId, + productGroup, }: { db: DrizzleCli; internalCustomerId: string; + productGroup?: string; }) => { let cusProducts = await CusProductService.list({ db, @@ -520,7 +522,9 @@ export const getMainCusProduct = async ({ }); let mainCusProduct = cusProducts.find( - (cusProduct: FullCusProduct) => !cusProduct.product.is_add_on + (cusProduct: FullCusProduct) => + !cusProduct.product.is_add_on && + (productGroup ? cusProduct.product.group === productGroup : true) ); return mainCusProduct; diff --git a/server/src/internal/customers/cusRouter.ts b/server/src/internal/customers/cusRouter.ts index c3f67892a..d12e05c37 100644 --- a/server/src/internal/customers/cusRouter.ts +++ b/server/src/internal/customers/cusRouter.ts @@ -58,16 +58,16 @@ cusRouter.post("/:customer_id", handleUpdateCustomer); // Update customer entitlement directly cusRouter.post( - "/customer_entitlements/:customer_entitlement_id", + "/:customer_id/entitlements/:customer_entitlement_id", handleUpdateEntitlement ); cusRouter.post("/:customer_id/balances", handleUpdateBalances); -cusRouter.post( - "/customer_products/:customer_product_id", - handleCusProductExpired -); +// cusRouter.post( +// "/customer_products/:customer_product_id", +// handleCusProductExpired +// ); cusRouter.get("/:customer_id/billing_portal", async (req: any, res: any) => { try { diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index 120b0bede..32d85c36d 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -24,6 +24,7 @@ import { notNullish, nullish } from "@/utils/genUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import RecaseError from "@/utils/errorUtils.js"; import { processInvoice } from "@/internal/invoices/InvoiceService.js"; +import { refreshCusCache } from "../cusCache/updateCachedCus.js"; export const updateCustomerDetails = async ({ db, @@ -54,6 +55,12 @@ export const updateCustomerDetails = async ({ update: updates, }); customer = { ...customer, ...updates }; + + await refreshCusCache({ + customerId: customer.id!, + orgId: customer.org_id, + env: customer.env, + }); } return customer; diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index 415e7db85..9062a3d63 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -16,6 +16,8 @@ import { import { ExtendedRequest } from "@/utils/models/Request.js"; import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; +import { refreshCusCache } from "../cusCache/updateCachedCus.js"; +import { getCusWithCache } from "../cusCache/getCusWithCache.js"; export const getOrCreateCustomer = async ({ req, @@ -33,6 +35,7 @@ export const getOrCreateCustomer = async ({ // Entity stuff entityId, entityData, + withCache = false, }: { req: ExtendedRequest; customerId: string; @@ -43,6 +46,7 @@ export const getOrCreateCustomer = async ({ expand?: CusExpand[]; entityId?: string; entityData?: EntityData; + withCache?: boolean; }): Promise => { let customer; @@ -53,18 +57,29 @@ export const getOrCreateCustomer = async ({ } if (!skipGet) { - customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - inStatuses, - withEntities, - entityId, - expand, - allowNotFound: true, - withSubs: true, - }); + if (withCache) { + customer = await getCusWithCache({ + idOrInternalId: customerId, + orgId: org.id, + env, + entityId, + expand: expand as CusExpand[], + logger, + }); + } else { + customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses, + withEntities, + entityId, + expand, + allowNotFound: true, + withSubs: true, + }); + } } if (!customer) { @@ -134,6 +149,12 @@ export const getOrCreateCustomer = async ({ customer.entities = [...(customer.entities || []), newEntity]; customer.entity = newEntity; + + await refreshCusCache({ + customerId: customer.id!, + orgId: customer.org_id, + env: customer.env, + }); } return customer as FullCustomer; diff --git a/server/src/internal/customers/handlers/cusDeleteHandlers.ts b/server/src/internal/customers/handlers/cusDeleteHandlers.ts index c74ed8a5c..eddfe7cfd 100644 --- a/server/src/internal/customers/handlers/cusDeleteHandlers.ts +++ b/server/src/internal/customers/handlers/cusDeleteHandlers.ts @@ -7,7 +7,6 @@ import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { AppEnv, ErrCode, Organization } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import { refreshCusCache } from "../cusCache/updateCachedCus.js"; export const deleteCusById = async ({ db, @@ -91,12 +90,6 @@ export const handleDeleteCustomer = async (req: any, res: any) => deleteInStripe: req.query.delete_in_stripe === "true", }); - await refreshCusCache({ - customerId: req.params.customer_id, - orgId: org.id, - env, - }); - res.status(200).json(data); }, }); diff --git a/server/src/internal/customers/handlers/handleGetCustomer.ts b/server/src/internal/customers/handlers/handleGetCustomer.ts index 21a5d1c9b..345f96cf4 100644 --- a/server/src/internal/customers/handlers/handleGetCustomer.ts +++ b/server/src/internal/customers/handlers/handleGetCustomer.ts @@ -42,7 +42,9 @@ export const handleGetCustomer = async (req: any, res: any) => env, expand: expandArray, allowNotFound: true, + logger, }); + // const customer = await CusService.getFull({ // db, // idOrInternalId: customerId, diff --git a/server/src/internal/customers/handlers/handlePostCustomer.ts b/server/src/internal/customers/handlers/handlePostCustomer.ts index 75cee4325..70fe58e96 100644 --- a/server/src/internal/customers/handlers/handlePostCustomer.ts +++ b/server/src/internal/customers/handlers/handlePostCustomer.ts @@ -49,6 +49,7 @@ export const handlePostCustomerRequest = async (req: any, res: any) => { entityId: data.entity_id, entityData: data.entity_data, + withCache: true, }); let cusDetails = await getCustomerDetails({ diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts index 353ce7ce8..278194824 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalances.ts @@ -19,7 +19,6 @@ import { } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { notNullish } from "@/utils/genUtils.js"; -import { refreshCusCache } from "../cusCache/updateCachedCus.js"; const getCusFeaturesAndOrg = async (req: any, customerId: string) => { // 1. Get customer @@ -265,12 +264,6 @@ export const handleUpdateBalances = async (req: any, res: any) => { } await Promise.all(batchDeduct); - await refreshCusCache({ - customerId: cusId, - orgId: org.id, - env, - }); - logger.info(" ✅ Successfully updated balances"); res.status(200).json({ success: true }); diff --git a/server/src/internal/customers/handlers/handleUpdateCustomer.ts b/server/src/internal/customers/handlers/handleUpdateCustomer.ts index fbe7f44dd..f8eff40a6 100644 --- a/server/src/internal/customers/handlers/handleUpdateCustomer.ts +++ b/server/src/internal/customers/handlers/handleUpdateCustomer.ts @@ -11,7 +11,6 @@ import { parseCusExpand } from "../cusUtils/cusUtils.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ExtendedResponse } from "@/utils/models/Request.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; -import { refreshCusCache } from "../cusCache/updateCachedCus.js"; export const handleUpdateCustomer = async (req: any, res: any) => routeHandler({ @@ -135,12 +134,6 @@ export const handleUpdateCustomer = async (req: any, res: any) => reqApiVersion: req.apiVersion, }); - await refreshCusCache({ - customerId, - orgId, - env, - }); - res.status(200).json(customerDetails); }, }); diff --git a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts b/server/src/internal/customers/handlers/handleUpdateEntitlement.ts index 099f44c2b..09806c851 100644 --- a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts +++ b/server/src/internal/customers/handlers/handleUpdateEntitlement.ts @@ -14,7 +14,6 @@ import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { CusProductService } from "../cusProducts/CusProductService.js"; -import { refreshCusCache } from "../cusCache/updateCachedCus.js"; const getCusOrgAndCusPrice = async ({ db, diff --git a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts index cdd72bc83..f3a2f92e2 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity.ts @@ -94,6 +94,8 @@ export const createEntities = async ({ withAutumnId, apiVersion: apiVersion!, features, + logger, + skipCache: true, }); return entities; diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts index dae6bd45f..4448b4668 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -15,6 +15,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; +import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; export const migrateCustomer = async ({ db, @@ -62,7 +63,7 @@ export const migrateCustomer = async ({ const cusProducts = fullCus.customer_products; const filteredCusProducts = cusProducts.filter( - (cp: FullCusProduct) => cp.product.internal_id == fromProduct.internal_id, + (cp: FullCusProduct) => cp.product.internal_id == fromProduct.internal_id ); for (const cusProduct of filteredCusProducts) { @@ -78,12 +79,18 @@ export const migrateCustomer = async ({ req, attachParams, }); + + await deleteCusCache({ + customerId, + orgId, + env, + }); } return true; } catch (error: any) { logger.error( - `Migration failed for customer ${customerId}, job id: ${migrationJob?.id}`, + `Migration failed for customer ${customerId}, job id: ${migrationJob?.id}` ); logger.error(error); diff --git a/server/src/middleware/refreshCacheMiddleware.ts b/server/src/middleware/refreshCacheMiddleware.ts index 254479fa9..6792fee75 100644 --- a/server/src/middleware/refreshCacheMiddleware.ts +++ b/server/src/middleware/refreshCacheMiddleware.ts @@ -1,47 +1,117 @@ -const urls = [ +import { + deleteCusCache, + refreshCusCache, +} from "@/internal/customers/cusCache/updateCachedCus.js"; + +const cusPrefixedUrls = [ { method: "POST", url: "/customers/:customer_id", + type: "delete", }, { method: "DELETE", url: "/customers/:customer_id", + type: "delete", }, { method: "POST", url: "/customers/:customer_id/balances", + type: "delete", }, { method: "POST", - url: "/customers/customer_entitlements/:customer_entitlement_id", + url: "/customers/:customer_id/entitlements/:customer_entitlement_id", + type: "delete", }, { method: "POST", url: "/customers/:customer_id/balances", - }, - { - method: "POST", - url: "/customers/:customer_id/coupons/:coupon_id", + type: "delete", }, { method: "POST", url: "/customers/:customer_id/entities", + type: "delete", }, { method: "POST", url: "/customers/:customer_id/transfer_product", + type: "delete", }, +]; + +const matchesCusPrefixedUrl = (url: string, method: string) => { + return cusPrefixedUrls.find((urlObj) => { + // Check if method matches + if (urlObj.method !== method) { + return false; + } + + const regexPattern = urlObj.url + .replace(/:[^/]+/g, "([^/]+)") // Replace :param with capturing group + .replace(/\//g, "\\/"); // Escape forward slashes + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(url); + }); +}; + +const coreUrls = [ { method: "POST", url: "/attach", + type: "delete", + }, + { + method: "POST", + url: "/cancel", + type: "delete", }, ]; + +const handleRefreshCache = async (req: any, res: any) => { + const { logger } = req; + const pathMatch = matchesCusPrefixedUrl( + req.originalUrl.replace("/v1", ""), + req.method + ); + + if (pathMatch) { + const customerId = req.params.customer_id || req.params.customerId; + logger.info( + `Clearing cache for customer ${customerId}, url: ${req.originalUrl}` + ); + await deleteCusCache({ + customerId, + orgId: req.org.id, + env: req.env, + }); + } + + const coreMatch = coreUrls.find( + (urlObj) => + urlObj.url === req.originalUrl.replace("/v1", "") && + urlObj.method === req.method + ); + + if (coreMatch && req.body.customer_id) { + logger.info(`Clearing cache for core url ${req.originalUrl}`); + await deleteCusCache({ + customerId: req.body.customer_id, + orgId: req.org.id, + env: req.env, + }); + } +}; + export const refreshCacheMiddleware = async (req: any, res: any, next: any) => { - res.on("finish", async () => { - console.log("URL:", req.originalUrl); - console.log("METHOD:", req.method); - console.log("--------------------------------"); - }); + // Replace res.send... + const originalSend = res.send; + res.send = async (body: any) => { + await handleRefreshCache(req, res); + await originalSend.call(res, body); + }; next(); }; diff --git a/server/tests/advanced/referrals/referrals2.ts b/server/tests/advanced/referrals/referrals2.ts index 035bd493c..605de7231 100644 --- a/server/tests/advanced/referrals/referrals2.ts +++ b/server/tests/advanced/referrals/referrals2.ts @@ -18,7 +18,7 @@ import { initCustomer } from "tests/utils/init.js"; // UNCOMMENT FROM HERE describe(`${chalk.yellowBright( - "referrals2: Testing referrals (immediate redemption)", + "referrals2: Testing referrals (immediate redemption)" )}`, () => { let mainCustomerId = "main-referral-2"; let redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"]; @@ -53,7 +53,7 @@ describe(`${chalk.yellowBright( org: this.org, env: this.env, attachPm: true, - }), + }) ); } @@ -94,21 +94,9 @@ describe(`${chalk.yellowBright( } } - // Try redeem for redeemer1 again - // try { - // let redemption1 = await autumn.referrals.redeem({ - // customerId: redeemers[0], - // code: referralCode.code, - // }); - // assert.fail("Should not be able to redeem again"); - // } catch (error) { - // assert.instanceOf(error, AutumnError); - // assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode); - // } - // Check stripe customer let stripeCus = (await stripeCli.customers.retrieve( - mainCustomer.processor?.id, + mainCustomer.processor?.id )) as Stripe.Customer; assert.notEqual(stripeCus.discount, null); @@ -123,11 +111,12 @@ describe(`${chalk.yellowBright( await timeout(3000); - curTime = addDays(addDays(curTime, 7), 1); + curTime = addDays(addDays(curTime, 7), 4); await advanceTestClock({ testClockId, advanceTo: curTime.getTime(), stripeCli, + waitForSeconds: 30, }); // 1. Get invoice diff --git a/server/tests/advanced/referrals/referrals4.ts b/server/tests/advanced/referrals/referrals4.ts index 51d31569e..de28a7d13 100644 --- a/server/tests/advanced/referrals/referrals4.ts +++ b/server/tests/advanced/referrals/referrals4.ts @@ -15,7 +15,7 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; // UNCOMMENT FROM HERE describe(`${chalk.yellowBright( - "referrals4: Testing free product referrals with trial", + "referrals4: Testing free product referrals with trial" )}`, () => { let mainCustomerId = "main-referral-4"; // let redeemers = ["referral4-r1", "referral4-r2"]; @@ -95,12 +95,13 @@ describe(`${chalk.yellowBright( it("should be triggered after trial ends", async function () { let advanceTo = addHours( addDays(new Date(), 7), - hoursToFinalizeInvoice, + hoursToFinalizeInvoice ).getTime(); await advanceTestClock({ stripeCli, testClockId, advanceTo, + waitForSeconds: 30, }); let redemption = await autumn.redemptions.get(redemptions[0].id); diff --git a/server/tests/attach/basic/basic5.ts b/server/tests/attach/basic/basic5.ts index 3c232d8e7..cee5a2c0b 100644 --- a/server/tests/attach/basic/basic5.ts +++ b/server/tests/attach/basic/basic5.ts @@ -15,7 +15,7 @@ import { timeout } from "@/utils/genUtils.js"; const testCase = "basic5"; describe(`${chalk.yellowBright( - "basic5: Testing cancel through Stripe at period end and now", + "basic5: Testing cancel through Stripe at period end and now" )}`, () => { const customerId = testCase; let stripeCli: Stripe; @@ -45,7 +45,7 @@ describe(`${chalk.yellowBright( const cusRes: any = await AutumnCli.getCustomer(customerId); const proProduct = cusRes.products.find( - (p: any) => p.id === products.pro.id, + (p: any) => p.id === products.pro.id ); for (const subId of proProduct.subscription_ids) { @@ -64,13 +64,13 @@ describe(`${chalk.yellowBright( }); const proProduct = cusRes.products.find( - (p: any) => p.id === products.pro.id, + (p: any) => p.id === products.pro.id ); expect(proProduct.canceled_at).to.not.equal(null); expect(proProduct.status).to.equal(CusProductStatus.Active); const freeProduct = cusRes.products.find( - (p: any) => p.id === products.free.id, + (p: any) => p.id === products.free.id ); expect(freeProduct).to.exist; expect(freeProduct.status).to.equal(CusProductStatus.Scheduled); @@ -79,7 +79,7 @@ describe(`${chalk.yellowBright( it("should cancel pro product (now)", async function () { const cusRes: any = await AutumnCli.getCustomer(customerId); const proProduct = cusRes.products.find( - (p: any) => p.id === products.pro.id, + (p: any) => p.id === products.pro.id ); for (const subId of proProduct.subscription_ids) { diff --git a/server/tests/attach/downgrade/downgrade6.ts b/server/tests/attach/downgrade/downgrade6.ts index 6a03fbfdf..9cb3e0790 100644 --- a/server/tests/attach/downgrade/downgrade6.ts +++ b/server/tests/attach/downgrade/downgrade6.ts @@ -6,7 +6,6 @@ import { compareMainProduct } from "tests/utils/compare.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { setupBefore } from "tests/before.js"; -import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; const testCase = "downgrade6"; describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { @@ -25,6 +24,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { org: this.org, env: this.env, autumn: this.autumnJs, + attachPm: "success", }); customer = customer_; @@ -39,12 +39,17 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { }); it("should expire premium", async function () { - const cusProduct = await getMainCusProduct({ - db: this.db, - internalCustomerId: customer.internal_id, - }); + // const cusProduct = await getMainCusProduct({ + // db: this.db, + // internalCustomerId: customer.internal_id, + // }); - await AutumnCli.expire(cusProduct!.id); + // await AutumnCli.expire(cusProduct!.id); + await autumn.cancel({ + customer_id: customerId, + product_id: products.premium.id, + cancel_immediately: true, + }); }); it("should have correct product and entitlements after expiration", async function () { @@ -55,60 +60,4 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { cusRes: res, }); }); - - // // 2. Get premium - // it("POST /attach -- attaching premium, then attach pro", async function () { - // this.timeout(30000); - // await AutumnCli.attach({ - // customerId: customerId, - // productId: products.premium.id, - // }); - - // await AutumnCli.attach({ - // customerId: customerId, - // productId: products.pro.id, - // }); - // }); - - // it("Expiring pro product (should re-attach premium)", async function () { - // this.timeout(30000); - - // // Expire pro product - // const customerProduct = await getCusProduct( - // this.sb, - // customer.internal_id, - // products.pro.id, - // ); - // await AutumnCli.expire(customerProduct.id); - // await timeout(5000); - // }); - - // it("GET /customers/:customer_id -- checking product and ents", async function () { - // this.timeout(30000); - // // Check that free is attached - // const res = await AutumnCli.getCustomer(customerId); - // compareMainProduct({ - // sent: products.premium, - // cusRes: res, - // }); - - // // Get stripe subscription (ensure canceled is null) - // const stripeCli = createStripeCli({ - // org: this.org, - // env: this.env, - // }); - - // const premiumCusProduct = await getCusProduct( - // this.sb, - // customer.internal_id, - // products.premium.id, - // ); - - // const stripeSub = await stripeCli.subscriptions.retrieve( - // premiumCusProduct.processor.subscription_id, - // ); - - // // Check that canceled is null - // assert.isNull(stripeSub.canceled_at); - // }); }); diff --git a/server/tests/attach/downgrade/downgrade7.ts b/server/tests/attach/downgrade/downgrade7.ts index 7975fc8ec..a64565819 100644 --- a/server/tests/attach/downgrade/downgrade7.ts +++ b/server/tests/attach/downgrade/downgrade7.ts @@ -8,12 +8,14 @@ import { setupBefore } from "tests/before.js"; import { findCusProductById } from "@/internal/customers/cusProducts/cusProductUtils/findCusProduct.js"; import { expect } from "chai"; import { getSubsFromCusId } from "tests/utils/expectUtils/expectSubUtils.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; const testCase = "downgrade7"; describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)}`, () => { let customerId = testCase; let testClockId: string; let customer: Customer; + let autumn: AutumnInt = new AutumnInt(); before(async function () { await setupBefore(this); @@ -46,14 +48,19 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)} }); it("should expire scheduled product (pro)", async function () { - const cusProduct = await findCusProductById({ - db: this.db, - internalCustomerId: customer.internal_id, - productId: products.pro.id, - }); + // const cusProduct = await findCusProductById({ + // db: this.db, + // internalCustomerId: customer.internal_id, + // productId: products.pro.id, + // }); - expect(cusProduct).to.exist; - await AutumnCli.expire(cusProduct!.id); + // expect(cusProduct).to.exist; + await autumn.cancel({ + customer_id: customerId, + product_id: products.pro.id, + cancel_immediately: true, + }); + // await AutumnCli.expire(cusProduct!.id); }); it("should have correct product and entitlements (premium)", async function () { diff --git a/server/tests/attach/migrations/migration1.ts b/server/tests/attach/migrations/migration1.ts index d1cd1a93d..2f0d8f498 100644 --- a/server/tests/attach/migrations/migration1.ts +++ b/server/tests/attach/migrations/migration1.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { AppEnv, Organization, ProductV2 } from "@autumn/shared"; +import { AppEnv, LimitedItem, Organization, ProductV2 } from "@autumn/shared"; import chalk from "chalk"; import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; @@ -21,12 +21,12 @@ import { runMigrationTest } from "./runMigrationTest.js"; let messagesItem = constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 500, -}); +}) as LimitedItem; let wordsItem = constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100, -}); +}) as LimitedItem; export let free = constructProduct({ items: [messagesItem, wordsItem], @@ -143,6 +143,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product` stripeCli, testClockId, advanceTo: addWeeks(Date.now(), 1).getTime(), + waitForSeconds: 30, }); let customer = await autumn.customers.get(customerId); diff --git a/server/tests/attach/migrations/runMigrationTest.ts b/server/tests/attach/migrations/runMigrationTest.ts index 7a047065b..48c26afd6 100644 --- a/server/tests/attach/migrations/runMigrationTest.ts +++ b/server/tests/attach/migrations/runMigrationTest.ts @@ -77,7 +77,7 @@ export const runMigrationTest = async ({ to_version: toProduct.version, }); - await timeout(5000); + await timeout(10000); const { subs: subsAfter } = await getSubsFromCusId({ stripeCli, @@ -91,6 +91,7 @@ export const runMigrationTest = async ({ expectSubsSame({ subsBefore, subsAfter }); const cusAfter = await autumn.customers.get(customerId); + expectFeaturesCorrect({ customer: cusAfter, product: toProduct, diff --git a/server/tests/attach/multiProduct/multiProduct3.ts b/server/tests/attach/multiProduct/multiProduct3.ts index f00b5205b..a36269aea 100644 --- a/server/tests/attach/multiProduct/multiProduct3.ts +++ b/server/tests/attach/multiProduct/multiProduct3.ts @@ -92,7 +92,7 @@ describe( expect(premiumGroup1!.scheduled_ids!.length).to.equal(1); expect(starterGroup2!.scheduled_ids!.length).to.equal(1); expect(premiumGroup1!.scheduled_ids![0]).to.equal( - starterGroup2!.scheduled_ids![0], + starterGroup2!.scheduled_ids![0] ); // 2. Check that there's no starter group 1 @@ -151,5 +151,5 @@ describe( expect(sub.cancel_at).to.equal(null); expect(sub.status).to.equal("active"); }); - }, + } ); diff --git a/server/tests/attach/prepaid/prepaid1.ts b/server/tests/attach/prepaid/prepaid1.ts index 29030a64a..43a0e3ecb 100644 --- a/server/tests/attach/prepaid/prepaid1.ts +++ b/server/tests/attach/prepaid/prepaid1.ts @@ -174,14 +174,14 @@ describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proratio testClockId, advanceTo: addHours( addMonths(new Date(), 1), - hoursToFinalizeInvoice, + hoursToFinalizeInvoice ).getTime(), - waitForSeconds: 30, + waitForSeconds: 40, }); const autumnCus = await autumn.customers.get(customerId); expect(autumnCus.features[TestFeature.Messages].balance).to.equal( - newQuantity, + newQuantity ); expect(autumnCus.invoices.length).to.equal(3); @@ -190,8 +190,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proratio const cusProduct = await getMainCusProduct({ db, internalCustomerId: customer.internal_id, + productGroup: testCase, }); - // console.log(cusProduct); + expect(cusProduct?.options[0].quantity).to.equal(newQuantity / 100); expect(cusProduct?.options[0].upcoming_quantity).to.not.exist; }); diff --git a/server/tests/contUse/entities/entity1.ts b/server/tests/contUse/entities/entity1.ts index 15c0533ef..2fee91611 100644 --- a/server/tests/contUse/entities/entity1.ts +++ b/server/tests/contUse/entities/entity1.ts @@ -136,6 +136,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create / delete ent }); await autumn.entities.create(customerId, entities); + await timeout(3000); + usage += entities.length; await expectSubQuantityCorrect({ diff --git a/vite/src/services/customers/CusService.tsx b/vite/src/services/customers/CusService.tsx index 979d0759c..f41216df2 100644 --- a/vite/src/services/customers/CusService.tsx +++ b/vite/src/services/customers/CusService.tsx @@ -36,23 +36,24 @@ export class CusService { static async updateCusEntitlement( axios: AxiosInstance, + customer_id: string, customer_entitlement_id: string, - data: any, + data: any ) { return await axios.post( - `/v1/customers/customer_entitlements/${customer_entitlement_id}`, - data, + `/v1/customers/${customer_id}/entitlements/${customer_entitlement_id}`, + data ); } static async updateCusProductStatus( axios: AxiosInstance, customer_product_id: string, - data: any, + data: any ) { return await axios.post( `/v1/customers/customer_products/${customer_product_id}`, - data, + data ); } @@ -66,7 +67,7 @@ export class CusService { coupon_id: string; }) { return await axios.post( - `/v1/customers/${customer_id}/coupons/${coupon_id}`, + `/v1/customers/${customer_id}/coupons/${coupon_id}` ); } } diff --git a/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx b/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx index 6edc3b63c..5ca935eda 100644 --- a/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx +++ b/vite/src/views/customers/customer/entitlements/UpdateCusEntitlement.tsx @@ -34,7 +34,7 @@ function UpdateCusEntitlement({ const [updateLoading, setUpdateLoading] = useState(false); - let cusEnt = selectedCusEntitlement; + const cusEnt = selectedCusEntitlement; const [updateFields, setUpdateFields] = useState({ balance: @@ -78,11 +78,16 @@ function UpdateCusEntitlement({ setUpdateLoading(true); try { - await CusService.updateCusEntitlement(axiosInstance, cusEnt.id, { - balance: balanceInt, - next_reset_at: updateFields.next_reset_at, - entity_id: entityId, - }); + await CusService.updateCusEntitlement( + axiosInstance, + customer.id || customer.internal_id, + cusEnt.id, + { + balance: balanceInt, + next_reset_at: updateFields.next_reset_at, + entity_id: entityId, + } + ); toast.success("Entitlement updated successfully"); await cusMutate(); setSelectedCusEntitlement(null);