feat: adding upstash cache

This commit is contained in:
John Yeo
2025-07-23 09:47:59 +01:00
parent da58df4ec5
commit c377f7a9af
19 changed files with 308 additions and 95 deletions

View File

@@ -46,6 +46,7 @@
"@react-email/components": "^0.0.42",
"@sentry/node": "^9.30.0",
"@supabase/supabase-js": "^2.46.2",
"@upstash/redis": "^1.35.1",
"ai": "^4.3.10",
"autumn-js": "^0.0.77",
"axios": "^1.8.3",

View File

@@ -25,12 +25,15 @@ 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();
apiRouter.use(apiAuthMiddleware);
apiRouter.use(pricingMiddleware);
apiRouter.use(analyticsMiddleware);
apiRouter.use(refreshCacheMiddleware);
apiRouter.use("/customers", cusRouter);
apiRouter.use("/invoices", invoiceRouter);

View File

@@ -1,4 +1,5 @@
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusWithCache } from "@/internal/customers/cusCache/getCusWithCache.js";
import {
ACTIVE_STATUSES,
RELEVANT_STATUSES,
@@ -46,25 +47,32 @@ export const getEntityResponse = async ({
apiVersion: number;
features: Feature[];
}) => {
let customer = await CusService.getFull({
db,
// let customer = await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId: org.id,
// env,
// withEntities: true,
// withSubs: true,
// expand,
// entityId,
// });
let customer = await getCusWithCache({
idOrInternalId: customerId,
orgId: org.id,
env,
withEntities: true,
withSubs: true,
expand,
entityId,
});
let entities = customer.entities.filter((e: Entity) =>
entityIds.includes(e.id),
entityIds.includes(e.id)
);
let entityCusProducts = customer.customer_products.filter(
(p: FullCusProduct) =>
entities.some((e: Entity) => e.internal_id == p.internal_entity_id) ||
nullish(p.internal_entity_id),
nullish(p.internal_entity_id)
);
let subs = customer.subscriptions || [];
@@ -72,7 +80,7 @@ export const getEntityResponse = async ({
const entityResponses: EntityResponse[] = [];
for (const entityId of entityIds) {
let entity = customer.entities.find(
(e: Entity) => e.id == entityId || e.internal_id == entityId,
(e: Entity) => e.id == entityId || e.internal_id == entityId
);
if (!entity) {
throw new RecaseError({
@@ -84,8 +92,8 @@ export const getEntityResponse = async ({
let entitySubs = subs.filter((s: Subscription) =>
entityCusProducts.some((p: FullCusProduct) =>
p.subscription_ids?.includes(s.stripe_id || ""),
),
p.subscription_ids?.includes(s.stripe_id || "")
)
);
let { main, addOns } = await processFullCusProducts({

View File

@@ -25,44 +25,6 @@ import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
export const checkRouter: Router = Router();
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,
};
};
checkRouter.post("", async (req: any, res: any) => {
try {
let {
@@ -181,7 +143,7 @@ checkRouter.post("", async (req: any, res: any) => {
const { allowed, balance } = v2Response;
const featureToUse = allFeatures.find(
(f: Feature) => f.id === v2Response.feature_id,
(f: Feature) => f.id === v2Response.feature_id
);
if (allowed && req.isPublic !== true) {

View File

@@ -17,6 +17,7 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { getFullCusQuery } from "./getFullCusQuery.js";
import { trace } from "@opentelemetry/api";
import { withSpan } from "../analytics/tracer/spanUtils.js";
import { RELEVANT_STATUSES } from "./cusProducts/CusProductService.js";
const tracer = trace.getTracer("express");
@@ -26,11 +27,7 @@ export class CusService {
idOrInternalId,
orgId,
env,
inStatuses = [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
],
inStatuses = RELEVANT_STATUSES,
withEntities = false,
entityId,
expand,
@@ -72,7 +69,7 @@ export class CusService {
withEntities,
withTrialsUsed,
withSubs,
entityId,
entityId
);
let result = await db.execute(query);
@@ -123,10 +120,10 @@ export class CusService {
where: and(
or(
eq(customers.id, idOrInternalId),
eq(customers.internal_id, idOrInternalId),
eq(customers.internal_id, idOrInternalId)
),
eq(customers.org_id, orgId),
eq(customers.env, env),
eq(customers.env, env)
),
});
@@ -152,7 +149,7 @@ export class CusService {
where: and(
eq(customers.email, email),
eq(customers.org_id, orgId),
eq(customers.env, env),
eq(customers.env, env)
),
});
@@ -270,8 +267,8 @@ export class CusService {
and(
eq(customers.internal_id, internalId),
eq(customers.org_id, orgId),
eq(customers.env, env),
),
eq(customers.env, env)
)
)
.returning();

View File

@@ -9,6 +9,7 @@ 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({
@@ -20,8 +21,6 @@ export const handleAttach = async (req: any, res: any) =>
const attachBody = AttachBodySchema.parse(req.body);
const logger = req.logtail;
const { attachParams, customPrices, customEnts } = await getAttachParams({
req,
attachBody,

View File

@@ -0,0 +1,17 @@
export const buildBaseCusCacheKey = ({
idOrInternalId,
entityId,
orgId,
env,
}: {
idOrInternalId: string;
entityId?: string;
orgId: string;
env: string;
}) => {
if (entityId) {
return `customer:${idOrInternalId}_${orgId}_${env}:${entityId}`;
} else {
return `customer:${idOrInternalId}_${orgId}_${env}`;
}
};

View File

@@ -0,0 +1,83 @@
import { AppEnv, CusExpand, EntityExpand, FullCustomer } from "@autumn/shared";
import { ACTIVE_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";
export const getCusWithCache = async ({
idOrInternalId,
orgId,
env,
entityId,
expand = [],
allowNotFound = true,
skipCache = false,
skipGet = false,
}: {
idOrInternalId: string;
orgId: string;
env: AppEnv;
entityId?: string;
// Optional
expand?: (CusExpand | EntityExpand)[];
allowNotFound?: boolean;
skipCache?: boolean;
skipGet?: boolean;
}): Promise<FullCustomer> => {
const statuses = ACTIVE_STATUSES;
const withEntities = true;
const withSubs = true;
const upstash = await initUpstash();
if (!upstash) skipCache = true;
const baseKey = buildBaseCusCacheKey({
idOrInternalId,
orgId,
env,
entityId,
});
const cacheKey = `${baseKey}:${expand.join(",")}`;
if (!skipCache && !skipGet) {
try {
const cached = await upstash!.get(cacheKey);
if (cached) {
console.log(`Cache hit: ${cacheKey}`);
return cached as FullCustomer;
} else {
console.log(`Cache miss: ${cacheKey}`);
}
} catch (error) {
console.error(error);
}
}
const customer = await CusService.getFull({
db,
idOrInternalId,
orgId,
env,
inStatuses: statuses,
withEntities,
withSubs,
allowNotFound,
expand,
entityId,
});
if (entityId && !customer.entity) skipCache = true;
if (!skipCache) {
try {
await upstash!.set(cacheKey, customer);
await upstash!.expire(cacheKey, 1000); // Expire after 60 seconds
} catch (error) {
console.error(error);
}
}
return customer;
};

View File

@@ -0,0 +1,43 @@
import { CusExpand, FullCusEntWithFullCusProduct } from "@autumn/shared";
import { AppEnv } from "autumn-js";
import { buildBaseCusCacheKey } from "./cusCacheUtils.js";
import { getCusWithCache } from "./getCusWithCache.js";
import { initUpstash } from "./upstashUtils.js";
export const refreshCusCache = async ({
customerId,
entityId,
orgId,
env,
}: {
customerId: string;
entityId?: 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) {
const keyName = key;
let params = keyName.split(":");
let expand = params ? params[params.length - 1].split(",") : [];
await getCusWithCache({
idOrInternalId: customerId,
orgId,
env,
expand: expand as CusExpand[],
skipGet: true,
});
console.log(`updated cache key: ${keyName}`);
}
};

View File

@@ -0,0 +1,13 @@
import "dotenv/config";
import { Redis } from "@upstash/redis";
export const initUpstash = async () => {
if (!process.env.UPSTASH_TOKEN) {
return null;
}
return new Redis({
url: "https://divine-sheepdog-46319.upstash.io",
token: process.env.UPSTASH_TOKEN,
});
};

View File

@@ -7,6 +7,7 @@ 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,
@@ -55,7 +56,7 @@ export const deleteCusById = async ({
`Couldn't delete ${chalk.yellow("stripe customer")} ${
customer.processor.id
}`,
error?.message || error,
error?.message || error
);
}
}
@@ -90,6 +91,12 @@ 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);
},
});

View File

@@ -10,6 +10,7 @@ import { StatusCodes } from "http-status-codes";
import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { getCusWithCache } from "../cusCache/getCusWithCache.js";
export const handleGetCustomer = async (req: any, res: any) =>
routeHandler({
@@ -35,21 +36,23 @@ export const handleGetCustomer = async (req: any, res: any) =>
logger.info(`getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
const customer = await CusService.getFull({
db,
const customer = await getCusWithCache({
idOrInternalId: customerId,
orgId: org.id,
env: env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
],
withEntities: true,
env,
expand: expandArray,
allowNotFound: true,
withSubs: true,
});
// const customer = await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId: org.id,
// env: env,
// withEntities: true,
// expand: expandArray,
// allowNotFound: true,
// withSubs: true,
// });
logger.info(`get customer took ${Date.now() - startTime}ms`);
if (!customer) {

View File

@@ -19,6 +19,7 @@ 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
@@ -63,7 +64,7 @@ export const handleUpdateBalances = async (req: any, res: any) => {
const { customer, org } = await getCusFeaturesAndOrg(req, cusId);
const featuresToUpdate = features.filter((f: any) =>
balances.map((b: any) => b.feature_id).includes(f.id),
balances.map((b: any) => b.feature_id).includes(f.id)
);
if (featuresToUpdate.length === 0) {
@@ -82,13 +83,12 @@ export const handleUpdateBalances = async (req: any, res: any) => {
logger.info("--------------------------------");
logger.info(
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`,
`REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`
);
logger.info(
`Features to update: ${balances.map(
(b: any) =>
`${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`,
)}`,
(b: any) => `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`
)}`
);
// Get deductions for each feature
@@ -111,7 +111,7 @@ export const handleUpdateBalances = async (req: any, res: any) => {
}
const feature = featuresToUpdate.find(
(f: any) => f.id === balance.feature_id,
(f: any) => f.id === balance.feature_id
);
if (balance.unlimited === true) {
@@ -191,18 +191,17 @@ export const handleUpdateBalances = async (req: any, res: any) => {
? cusEnts.find(
(cusEnt) =>
cusEnt.internal_feature_id === feature!.internal_id! &&
cusEnt.entitlement.interval === interval,
cusEnt.entitlement.interval === interval
)
: cusEnts.find(
(cusEnt) =>
cusEnt.internal_feature_id === feature!.internal_id!,
(cusEnt) => cusEnt.internal_feature_id === feature!.internal_id!
);
if (!cusEnt) {
logger.warn(
`No active cus ent to set unlimited balance for feature: ${
feature!.id
}`,
}`
);
return;
}
@@ -265,6 +264,13 @@ export const handleUpdateBalances = async (req: any, res: any) => {
batchDeduct.push(performDeduction());
}
await Promise.all(batchDeduct);
await refreshCusCache({
customerId: cusId,
orgId: org.id,
env,
});
logger.info(" ✅ Successfully updated balances");
res.status(200).json({ success: true });

View File

@@ -11,6 +11,7 @@ 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({
@@ -97,7 +98,7 @@ export const handleUpdateCustomer = async (req: any, res: any) =>
const stripeCli = createStripeCli({ org, env: req.env });
await stripeCli.customers.update(
originalCustomer.processor.id,
stripeUpdate as any,
stripeUpdate as any
);
}
@@ -134,6 +135,12 @@ export const handleUpdateCustomer = async (req: any, res: any) =>
reqApiVersion: req.apiVersion,
});
await refreshCusCache({
customerId,
orgId,
env,
});
res.status(200).json(customerDetails);
},
});

View File

@@ -14,6 +14,7 @@ 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,

View File

@@ -56,7 +56,7 @@ export const createEntities = async ({
internalCustomerId: customer.internal_id,
orgId: org.id,
env,
}),
})
);
let newEntities: Entity[] = [];

View File

@@ -0,0 +1,47 @@
const urls = [
{
method: "POST",
url: "/customers/:customer_id",
},
{
method: "DELETE",
url: "/customers/:customer_id",
},
{
method: "POST",
url: "/customers/:customer_id/balances",
},
{
method: "POST",
url: "/customers/customer_entitlements/:customer_entitlement_id",
},
{
method: "POST",
url: "/customers/:customer_id/balances",
},
{
method: "POST",
url: "/customers/:customer_id/coupons/:coupon_id",
},
{
method: "POST",
url: "/customers/:customer_id/entities",
},
{
method: "POST",
url: "/customers/:customer_id/transfer_product",
},
{
method: "POST",
url: "/attach",
},
];
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("--------------------------------");
});
next();
};

View File

@@ -42,6 +42,7 @@ import {
getBillingType,
getEntOptions,
} from "@/internal/products/prices/priceUtils.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
// Decimal.set({ precision: 12 }); // 12 DP precision
@@ -67,7 +68,7 @@ const getFeatureDeductions = ({
features: Feature[];
}) => {
const meteredFeatures = features.filter(
(feature) => feature.type === FeatureType.Metered,
(feature) => feature.type === FeatureType.Metered
);
const featureDeductions = [];
for (const feature of features) {
@@ -86,7 +87,7 @@ const getFeatureDeductions = ({
let unlimitedExists = cusEnts.some(
(cusEnt) =>
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
cusEnt.entitlement.internal_feature_id == feature.internal_id,
cusEnt.entitlement.internal_feature_id == feature.internal_id
);
if (unlimitedExists || !deduction) {
@@ -142,7 +143,7 @@ export const logBalanceUpdate = ({
console.log(
` - Customer: ${customer.id} (${customer.env}) | Org: ${
org.slug
} | Features: ${features.map((f) => f.id).join(", ")}`,
} | Features: ${features.map((f) => f.id).join(", ")}`
);
console.log(" - Properties:", properties);
console.log(
@@ -152,7 +153,7 @@ export const logBalanceUpdate = ({
if (notNullish(cusEnt.entitlement.entity_feature_id)) {
console.log(
` - Entity feature ID found for feature: ${cusEnt.feature_id}`,
` - Entity feature ID found for feature: ${cusEnt.feature_id}`
);
if (notNullish(entityId)) {
@@ -179,7 +180,7 @@ export const logBalanceUpdate = ({
})`;
}),
"| Deductions:",
featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`),
featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`)
);
};
@@ -462,7 +463,7 @@ export const deductFromUsageBasedCusEnt = async ({
if (!usageBasedEnt) {
console.log(
` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found`,
` - Feature ${feature.id}, To deduct: ${toDeduct} -> no usage-based entitlement found`
);
return;
}
@@ -659,7 +660,7 @@ export const runUpdateBalanceTask = async ({
console.log("--------------------------------");
console.log(
`UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}`,
`UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}`
);
const cusEnts: any = await updateCustomerBalance({
@@ -673,6 +674,13 @@ export const runUpdateBalanceTask = async ({
entityId,
});
await refreshCusCache({
customerId,
orgId: org.id,
env,
entityId,
});
if (!cusEnts || cusEnts.length === 0) {
return;
}

View File

@@ -20,6 +20,7 @@ import {
} from "./updateBalanceTask.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { refreshCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
// 2. Get deductions for each feature
const getFeatureDeductions = ({
@@ -42,7 +43,7 @@ const getFeatureDeductions = ({
let unlimitedExists = cusEnts.some(
(cusEnt) =>
cusEnt.entitlement.allowance_type === AllowanceType.Unlimited &&
cusEnt.entitlement.internal_feature_id == feature.internal_id,
cusEnt.entitlement.internal_feature_id == feature.internal_id
);
if (unlimitedExists) {
@@ -129,7 +130,7 @@ const logUsageUpdate = ({
org.slug
} | Features: ${features.map((f) => f.id).join(", ")} | Set Usage: ${
setUsage ? "true" : "false"
}`,
}`
);
console.log(
@@ -153,7 +154,7 @@ const logUsageUpdate = ({
})`;
}),
"| Deductions:",
featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`),
featureDeductions.map((f: any) => `${f.feature.id}: ${f.deduction}`)
);
};
@@ -300,7 +301,7 @@ export const runUpdateUsageTask = async ({
console.log("--------------------------------");
console.log(
`HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}`,
`HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}`
);
const cusEnts: any = await updateUsage({
@@ -316,6 +317,13 @@ export const runUpdateUsageTask = async ({
entityId,
});
await refreshCusCache({
customerId,
entityId,
orgId: org.id,
env,
});
if (!cusEnts || cusEnts.length === 0) {
return;
}