feat: caching customer to speed up check and get customer

This commit is contained in:
John Yeo
2025-07-23 13:47:56 +01:00
parent c377f7a9af
commit cf31454c63
36 changed files with 380 additions and 188 deletions

View File

@@ -25,6 +25,8 @@ import { UTCDate } from "@date-fns/utc";
import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js"; import { type DrizzleCli, initDrizzle } from "./db/initDrizzle.js";
import { CusPriceService } from "./internal/customers/cusProducts/cusPrices/CusPriceService.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(); dotenv.config();
@@ -208,6 +210,19 @@ const resetCustomerEntitlement = async ({
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss") 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) { } catch (error: any) {
console.log( console.log(
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}` `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`

View File

@@ -3,7 +3,7 @@ import stripe, { Stripe } from "stripe";
import chalk from "chalk"; import chalk from "chalk";
import { OrgService } from "@/internal/orgs/OrgService.js"; 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 { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js"; import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
@@ -18,6 +18,9 @@ import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubS
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
import { ExtendedRequest } from "@/utils/models/Request.js"; import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "./utils.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(); export const stripeWebhookRouter: Router = express.Router();
@@ -238,7 +241,82 @@ stripeWebhookRouter.post(
return; 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... // DO NOT DELETE -- RESPONSIBLE FOR SENDING SUCCESSFUL RESPONSE TO STRIPE...
response.status(200).send(); 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,
});
}
};

View File

@@ -70,7 +70,7 @@ export const handleCheckoutSessionCompleted = async ({
console.log( console.log(
"Handling checkout.completed: autumn metadata:", "Handling checkout.completed: autumn metadata:",
checkoutSession.metadata?.autumn_metadata_id, checkoutSession.metadata?.autumn_metadata_id
); );
const checkoutSub = const checkoutSub =
@@ -139,7 +139,7 @@ export const handleCheckoutSessionCompleted = async ({
attachParams, attachParams,
invoiceId, invoiceId,
logger, logger,
}), })
); );
} }

View File

@@ -25,7 +25,7 @@ import { internalFeatureRouter } from "../features/internalFeatureRouter.js";
import { analyticsRouter } from "../analytics/analyticsRouter.js"; import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { handleConnectStripe } from "../orgs/handlers/handleConnectStripe.js"; import { handleConnectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { handleDeleteStripe } from "../orgs/handlers/handleDeleteStripe.js"; import { handleDeleteStripe } from "../orgs/handlers/handleDeleteStripe.js";
import { refreshCusCache } from "../customers/cusCache/updateCachedCus.js";
import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js"; import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js";
const apiRouter: Router = Router(); const apiRouter: Router = Router();

View File

@@ -35,6 +35,8 @@ export const getEntityResponse = async ({
withAutumnId = false, withAutumnId = false,
apiVersion, apiVersion,
features, features,
logger,
skipCache = false,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
entityIds: string[]; entityIds: string[];
@@ -46,6 +48,8 @@ export const getEntityResponse = async ({
withAutumnId?: boolean; withAutumnId?: boolean;
apiVersion: number; apiVersion: number;
features: Feature[]; features: Feature[];
logger: any;
skipCache?: boolean;
}) => { }) => {
// let customer = await CusService.getFull({ // let customer = await CusService.getFull({
// db, // db,
@@ -63,6 +67,8 @@ export const getEntityResponse = async ({
env, env,
expand, expand,
entityId, entityId,
logger,
skipCache,
}); });
let entities = customer.entities.filter((e: Entity) => let entities = customer.entities.filter((e: Entity) =>

View File

@@ -37,6 +37,7 @@ export const handleGetEntity = async (req: any, res: any) =>
entityId, entityId,
apiVersion, apiVersion,
features, features,
logger,
}); });
// const end = performance.now(); // const end = performance.now();
// logger.info(`getEntityResponse took ${(end - start).toFixed(2)}ms`); // logger.info(`getEntityResponse took ${(end - start).toFixed(2)}ms`);
@@ -53,7 +54,7 @@ export const handleGetEntity = async (req: any, res: any) =>
logger, logger,
}) })
: undefined, : undefined,
}), })
); );
}, },
}); });

View File

@@ -22,7 +22,7 @@ const getFeatureAndCreditSystems = ({
const { features } = req; const { features } = req;
const feature: Feature | undefined = features.find( const feature: Feature | undefined = features.find(
(feature: Feature) => feature.id === featureId, (feature: Feature) => feature.id === featureId
); );
const creditSystems = getCreditSystemsFromFeature({ const creditSystems = getCreditSystemsFromFeature({
@@ -59,6 +59,7 @@ export const getCheckData = async ({ req }: { req: any }) => {
inStatuses, inStatuses,
entityId: entity_id, entityId: entity_id,
entityData: req.body.entity_data, entityData: req.body.entity_data,
withCache: true,
}); });
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
@@ -82,7 +83,7 @@ export const getCheckData = async ({ req }: { req: any }) => {
cusEnt, cusEnt,
entity: customer.entity!, entity: customer.entity!,
features: allFeatures, features: allFeatures,
}), })
); );
} }

View File

@@ -9,7 +9,6 @@ import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js";
import { checkStripeConnections, createStripePrices } from "./attachRouter.js"; import { checkStripeConnections, createStripePrices } from "./attachRouter.js";
import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; import { insertCustomItems } from "./attachUtils/insertCustomItems.js";
import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; import { runAttachFunction } from "./attachUtils/getAttachFunction.js";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
export const handleAttach = async (req: any, res: any) => export const handleAttach = async (req: any, res: any) =>
routeHandler({ routeHandler({

View File

@@ -10,7 +10,7 @@ export const buildBaseCusCacheKey = ({
env: string; env: string;
}) => { }) => {
if (entityId) { if (entityId) {
return `customer:${idOrInternalId}_${orgId}_${env}:${entityId}`; return `customer:${idOrInternalId}_${orgId}_${env}:entity_${entityId}`;
} else { } else {
return `customer:${idOrInternalId}_${orgId}_${env}`; return `customer:${idOrInternalId}_${orgId}_${env}`;
} }

View File

@@ -1,9 +1,10 @@
import { AppEnv, CusExpand, EntityExpand, FullCustomer } from "@autumn/shared"; 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 { db } from "@/db/initDrizzle.js";
import { CusService } from "../CusService.js"; import { CusService } from "../CusService.js";
import { buildBaseCusCacheKey } from "./cusCacheUtils.js"; import { buildBaseCusCacheKey } from "./cusCacheUtils.js";
import { initUpstash } from "./upstashUtils.js"; import { initUpstash } from "./upstashUtils.js";
import { notNullish } from "@/utils/genUtils.js";
export const getCusWithCache = async ({ export const getCusWithCache = async ({
idOrInternalId, idOrInternalId,
@@ -14,6 +15,7 @@ export const getCusWithCache = async ({
allowNotFound = true, allowNotFound = true,
skipCache = false, skipCache = false,
skipGet = false, skipGet = false,
logger,
}: { }: {
idOrInternalId: string; idOrInternalId: string;
orgId: string; orgId: string;
@@ -25,33 +27,38 @@ export const getCusWithCache = async ({
allowNotFound?: boolean; allowNotFound?: boolean;
skipCache?: boolean; skipCache?: boolean;
skipGet?: boolean; skipGet?: boolean;
logger: any;
}): Promise<FullCustomer> => { }): Promise<FullCustomer> => {
const statuses = ACTIVE_STATUSES; const statuses = RELEVANT_STATUSES;
const withEntities = true; const withEntities = true;
const withSubs = true; const withSubs = true;
const upstash = await initUpstash(); const upstash = await initUpstash();
if (!upstash) skipCache = true; if (!upstash) skipCache = true;
const baseKey = buildBaseCusCacheKey({ let cacheKey = buildBaseCusCacheKey({
idOrInternalId, idOrInternalId,
orgId, orgId,
env, env,
entityId, entityId,
}); });
const cacheKey = `${baseKey}:${expand.join(",")}`; if (expand.length > 0) {
cacheKey = `${cacheKey}:expand_${expand.join(",")}`;
}
if (!skipCache && !skipGet) { if (!skipCache && !skipGet) {
try { try {
const cached = await upstash!.get(cacheKey); const cached = await upstash!.get(cacheKey);
if (cached) { if (cached) {
console.log(`Cache hit: ${cacheKey}`); logger.info(`Cache hit: ${cacheKey}`);
logger.info("Cached:", cached);
return cached as FullCustomer; return cached as FullCustomer;
} else { } else {
console.log(`Cache miss: ${cacheKey}`); // logger.info(`Cache miss: ${cacheKey}`);
} }
} catch (error) { } 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 (entityId && !customer.entity) skipCache = true;
if (!skipCache) { if (!skipCache && notNullish(customer)) {
try { try {
await upstash!.set(cacheKey, customer); 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) { } catch (error) {
console.error(error); logger.error(`Failed to set cache: ${cacheKey}`, { error });
} }
} }

View File

@@ -24,20 +24,56 @@ export const refreshCusCache = async ({
env, env,
}); });
const list = await upstash.keys(`${baseKey}:*`); const list = await upstash.keys(`${baseKey}*`);
for (const key of list) { for (const key of list) {
const keyName = key; const keyName = key;
let params = keyName.split(":"); 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({ await getCusWithCache({
idOrInternalId: customerId, idOrInternalId: customerId,
orgId, orgId,
env, env,
expand: expand as CusExpand[], expand: expand as CusExpand[],
entityId,
skipGet: true, skipGet: true,
logger: console,
}); });
console.log(`updated cache key: ${keyName}`); 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);
}
};

View File

@@ -505,9 +505,11 @@ export const isTrialing = (cusProduct: FullCusProduct) => {
export const getMainCusProduct = async ({ export const getMainCusProduct = async ({
db, db,
internalCustomerId, internalCustomerId,
productGroup,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
internalCustomerId: string; internalCustomerId: string;
productGroup?: string;
}) => { }) => {
let cusProducts = await CusProductService.list({ let cusProducts = await CusProductService.list({
db, db,
@@ -520,7 +522,9 @@ export const getMainCusProduct = async ({
}); });
let mainCusProduct = cusProducts.find( 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; return mainCusProduct;

View File

@@ -58,16 +58,16 @@ cusRouter.post("/:customer_id", handleUpdateCustomer);
// Update customer entitlement directly // Update customer entitlement directly
cusRouter.post( cusRouter.post(
"/customer_entitlements/:customer_entitlement_id", "/:customer_id/entitlements/:customer_entitlement_id",
handleUpdateEntitlement handleUpdateEntitlement
); );
cusRouter.post("/:customer_id/balances", handleUpdateBalances); cusRouter.post("/:customer_id/balances", handleUpdateBalances);
cusRouter.post( // cusRouter.post(
"/customer_products/:customer_product_id", // "/customer_products/:customer_product_id",
handleCusProductExpired // handleCusProductExpired
); // );
cusRouter.get("/:customer_id/billing_portal", async (req: any, res: any) => { cusRouter.get("/:customer_id/billing_portal", async (req: any, res: any) => {
try { try {

View File

@@ -24,6 +24,7 @@ import { notNullish, nullish } from "@/utils/genUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js"; import { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
import { processInvoice } from "@/internal/invoices/InvoiceService.js"; import { processInvoice } from "@/internal/invoices/InvoiceService.js";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
export const updateCustomerDetails = async ({ export const updateCustomerDetails = async ({
db, db,
@@ -54,6 +55,12 @@ export const updateCustomerDetails = async ({
update: updates, update: updates,
}); });
customer = { ...customer, ...updates }; customer = { ...customer, ...updates };
await refreshCusCache({
customerId: customer.id!,
orgId: customer.org_id,
env: customer.env,
});
} }
return customer; return customer;

View File

@@ -16,6 +16,8 @@ import {
import { ExtendedRequest } from "@/utils/models/Request.js"; import { ExtendedRequest } from "@/utils/models/Request.js";
import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.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 ({ export const getOrCreateCustomer = async ({
req, req,
@@ -33,6 +35,7 @@ export const getOrCreateCustomer = async ({
// Entity stuff // Entity stuff
entityId, entityId,
entityData, entityData,
withCache = false,
}: { }: {
req: ExtendedRequest; req: ExtendedRequest;
customerId: string; customerId: string;
@@ -43,6 +46,7 @@ export const getOrCreateCustomer = async ({
expand?: CusExpand[]; expand?: CusExpand[];
entityId?: string; entityId?: string;
entityData?: EntityData; entityData?: EntityData;
withCache?: boolean;
}): Promise<FullCustomer> => { }): Promise<FullCustomer> => {
let customer; let customer;
@@ -53,18 +57,29 @@ export const getOrCreateCustomer = async ({
} }
if (!skipGet) { if (!skipGet) {
customer = await CusService.getFull({ if (withCache) {
db, customer = await getCusWithCache({
idOrInternalId: customerId, idOrInternalId: customerId,
orgId: org.id, orgId: org.id,
env, env,
inStatuses, entityId,
withEntities, expand: expand as CusExpand[],
entityId, logger,
expand, });
allowNotFound: true, } else {
withSubs: true, customer = await CusService.getFull({
}); db,
idOrInternalId: customerId,
orgId: org.id,
env,
inStatuses,
withEntities,
entityId,
expand,
allowNotFound: true,
withSubs: true,
});
}
} }
if (!customer) { if (!customer) {
@@ -134,6 +149,12 @@ export const getOrCreateCustomer = async ({
customer.entities = [...(customer.entities || []), newEntity]; customer.entities = [...(customer.entities || []), newEntity];
customer.entity = newEntity; customer.entity = newEntity;
await refreshCusCache({
customerId: customer.id!,
orgId: customer.org_id,
env: customer.env,
});
} }
return customer as FullCustomer; return customer as FullCustomer;

View File

@@ -7,7 +7,6 @@ import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js"; import { routeHandler } from "@/utils/routerUtils.js";
import { AppEnv, ErrCode, Organization } from "@autumn/shared"; import { AppEnv, ErrCode, Organization } from "@autumn/shared";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
export const deleteCusById = async ({ export const deleteCusById = async ({
db, db,
@@ -91,12 +90,6 @@ export const handleDeleteCustomer = async (req: any, res: any) =>
deleteInStripe: req.query.delete_in_stripe === "true", deleteInStripe: req.query.delete_in_stripe === "true",
}); });
await refreshCusCache({
customerId: req.params.customer_id,
orgId: org.id,
env,
});
res.status(200).json(data); res.status(200).json(data);
}, },
}); });

View File

@@ -42,7 +42,9 @@ export const handleGetCustomer = async (req: any, res: any) =>
env, env,
expand: expandArray, expand: expandArray,
allowNotFound: true, allowNotFound: true,
logger,
}); });
// const customer = await CusService.getFull({ // const customer = await CusService.getFull({
// db, // db,
// idOrInternalId: customerId, // idOrInternalId: customerId,

View File

@@ -49,6 +49,7 @@ export const handlePostCustomerRequest = async (req: any, res: any) => {
entityId: data.entity_id, entityId: data.entity_id,
entityData: data.entity_data, entityData: data.entity_data,
withCache: true,
}); });
let cusDetails = await getCustomerDetails({ let cusDetails = await getCustomerDetails({

View File

@@ -19,7 +19,6 @@ import {
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { notNullish } from "@/utils/genUtils.js"; import { notNullish } from "@/utils/genUtils.js";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
const getCusFeaturesAndOrg = async (req: any, customerId: string) => { const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
// 1. Get customer // 1. Get customer
@@ -265,12 +264,6 @@ export const handleUpdateBalances = async (req: any, res: any) => {
} }
await Promise.all(batchDeduct); await Promise.all(batchDeduct);
await refreshCusCache({
customerId: cusId,
orgId: org.id,
env,
});
logger.info(" ✅ Successfully updated balances"); logger.info(" ✅ Successfully updated balances");
res.status(200).json({ success: true }); res.status(200).json({ success: true });

View File

@@ -11,7 +11,6 @@ import { parseCusExpand } from "../cusUtils/cusUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js"; import { FeatureService } from "@/internal/features/FeatureService.js";
import { ExtendedResponse } from "@/utils/models/Request.js"; import { ExtendedResponse } from "@/utils/models/Request.js";
import { ExtendedRequest } 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) => export const handleUpdateCustomer = async (req: any, res: any) =>
routeHandler({ routeHandler({
@@ -135,12 +134,6 @@ export const handleUpdateCustomer = async (req: any, res: any) =>
reqApiVersion: req.apiVersion, reqApiVersion: req.apiVersion,
}); });
await refreshCusCache({
customerId,
orgId,
env,
});
res.status(200).json(customerDetails); res.status(200).json(customerDetails);
}, },
}); });

View File

@@ -14,7 +14,6 @@ import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js";
import { ExtendedRequest } from "@/utils/models/Request.js"; import { ExtendedRequest } from "@/utils/models/Request.js";
import { DrizzleCli } from "@/db/initDrizzle.js"; import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "../cusProducts/CusProductService.js"; import { CusProductService } from "../cusProducts/CusProductService.js";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
const getCusOrgAndCusPrice = async ({ const getCusOrgAndCusPrice = async ({
db, db,

View File

@@ -94,6 +94,8 @@ export const createEntities = async ({
withAutumnId, withAutumnId,
apiVersion: apiVersion!, apiVersion: apiVersion!,
features, features,
logger,
skipCache: true,
}); });
return entities; return entities;

View File

@@ -15,6 +15,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "@/external/stripe/utils.js"; import { createStripeCli } from "@/external/stripe/utils.js";
import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js";
import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
export const migrateCustomer = async ({ export const migrateCustomer = async ({
db, db,
@@ -62,7 +63,7 @@ export const migrateCustomer = async ({
const cusProducts = fullCus.customer_products; const cusProducts = fullCus.customer_products;
const filteredCusProducts = cusProducts.filter( 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) { for (const cusProduct of filteredCusProducts) {
@@ -78,12 +79,18 @@ export const migrateCustomer = async ({
req, req,
attachParams, attachParams,
}); });
await deleteCusCache({
customerId,
orgId,
env,
});
} }
return true; return true;
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Migration failed for customer ${customerId}, job id: ${migrationJob?.id}`, `Migration failed for customer ${customerId}, job id: ${migrationJob?.id}`
); );
logger.error(error); logger.error(error);

View File

@@ -1,47 +1,117 @@
const urls = [ import {
deleteCusCache,
refreshCusCache,
} from "@/internal/customers/cusCache/updateCachedCus.js";
const cusPrefixedUrls = [
{ {
method: "POST", method: "POST",
url: "/customers/:customer_id", url: "/customers/:customer_id",
type: "delete",
}, },
{ {
method: "DELETE", method: "DELETE",
url: "/customers/:customer_id", url: "/customers/:customer_id",
type: "delete",
}, },
{ {
method: "POST", method: "POST",
url: "/customers/:customer_id/balances", url: "/customers/:customer_id/balances",
type: "delete",
}, },
{ {
method: "POST", method: "POST",
url: "/customers/customer_entitlements/:customer_entitlement_id", url: "/customers/:customer_id/entitlements/:customer_entitlement_id",
type: "delete",
}, },
{ {
method: "POST", method: "POST",
url: "/customers/:customer_id/balances", url: "/customers/:customer_id/balances",
}, type: "delete",
{
method: "POST",
url: "/customers/:customer_id/coupons/:coupon_id",
}, },
{ {
method: "POST", method: "POST",
url: "/customers/:customer_id/entities", url: "/customers/:customer_id/entities",
type: "delete",
}, },
{ {
method: "POST", method: "POST",
url: "/customers/:customer_id/transfer_product", 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", method: "POST",
url: "/attach", 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) => { export const refreshCacheMiddleware = async (req: any, res: any, next: any) => {
res.on("finish", async () => { // Replace res.send...
console.log("URL:", req.originalUrl); const originalSend = res.send;
console.log("METHOD:", req.method); res.send = async (body: any) => {
console.log("--------------------------------"); await handleRefreshCache(req, res);
}); await originalSend.call(res, body);
};
next(); next();
}; };

View File

@@ -18,7 +18,7 @@ import { initCustomer } from "tests/utils/init.js";
// UNCOMMENT FROM HERE // UNCOMMENT FROM HERE
describe(`${chalk.yellowBright( describe(`${chalk.yellowBright(
"referrals2: Testing referrals (immediate redemption)", "referrals2: Testing referrals (immediate redemption)"
)}`, () => { )}`, () => {
let mainCustomerId = "main-referral-2"; let mainCustomerId = "main-referral-2";
let redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"]; let redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"];
@@ -53,7 +53,7 @@ describe(`${chalk.yellowBright(
org: this.org, org: this.org,
env: this.env, env: this.env,
attachPm: true, 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 // Check stripe customer
let stripeCus = (await stripeCli.customers.retrieve( let stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id, mainCustomer.processor?.id
)) as Stripe.Customer; )) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null); assert.notEqual(stripeCus.discount, null);
@@ -123,11 +111,12 @@ describe(`${chalk.yellowBright(
await timeout(3000); await timeout(3000);
curTime = addDays(addDays(curTime, 7), 1); curTime = addDays(addDays(curTime, 7), 4);
await advanceTestClock({ await advanceTestClock({
testClockId, testClockId,
advanceTo: curTime.getTime(), advanceTo: curTime.getTime(),
stripeCli, stripeCli,
waitForSeconds: 30,
}); });
// 1. Get invoice // 1. Get invoice

View File

@@ -15,7 +15,7 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js";
// UNCOMMENT FROM HERE // UNCOMMENT FROM HERE
describe(`${chalk.yellowBright( describe(`${chalk.yellowBright(
"referrals4: Testing free product referrals with trial", "referrals4: Testing free product referrals with trial"
)}`, () => { )}`, () => {
let mainCustomerId = "main-referral-4"; let mainCustomerId = "main-referral-4";
// let redeemers = ["referral4-r1", "referral4-r2"]; // let redeemers = ["referral4-r1", "referral4-r2"];
@@ -95,12 +95,13 @@ describe(`${chalk.yellowBright(
it("should be triggered after trial ends", async function () { it("should be triggered after trial ends", async function () {
let advanceTo = addHours( let advanceTo = addHours(
addDays(new Date(), 7), addDays(new Date(), 7),
hoursToFinalizeInvoice, hoursToFinalizeInvoice
).getTime(); ).getTime();
await advanceTestClock({ await advanceTestClock({
stripeCli, stripeCli,
testClockId, testClockId,
advanceTo, advanceTo,
waitForSeconds: 30,
}); });
let redemption = await autumn.redemptions.get(redemptions[0].id); let redemption = await autumn.redemptions.get(redemptions[0].id);

View File

@@ -15,7 +15,7 @@ import { timeout } from "@/utils/genUtils.js";
const testCase = "basic5"; const testCase = "basic5";
describe(`${chalk.yellowBright( 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; const customerId = testCase;
let stripeCli: Stripe; let stripeCli: Stripe;
@@ -45,7 +45,7 @@ describe(`${chalk.yellowBright(
const cusRes: any = await AutumnCli.getCustomer(customerId); const cusRes: any = await AutumnCli.getCustomer(customerId);
const proProduct = cusRes.products.find( 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) { for (const subId of proProduct.subscription_ids) {
@@ -64,13 +64,13 @@ describe(`${chalk.yellowBright(
}); });
const proProduct = cusRes.products.find( 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.canceled_at).to.not.equal(null);
expect(proProduct.status).to.equal(CusProductStatus.Active); expect(proProduct.status).to.equal(CusProductStatus.Active);
const freeProduct = cusRes.products.find( 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).to.exist;
expect(freeProduct.status).to.equal(CusProductStatus.Scheduled); expect(freeProduct.status).to.equal(CusProductStatus.Scheduled);
@@ -79,7 +79,7 @@ describe(`${chalk.yellowBright(
it("should cancel pro product (now)", async function () { it("should cancel pro product (now)", async function () {
const cusRes: any = await AutumnCli.getCustomer(customerId); const cusRes: any = await AutumnCli.getCustomer(customerId);
const proProduct = cusRes.products.find( 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) { for (const subId of proProduct.subscription_ids) {

View File

@@ -6,7 +6,6 @@ import { compareMainProduct } from "tests/utils/compare.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { setupBefore } from "tests/before.js"; import { setupBefore } from "tests/before.js";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
const testCase = "downgrade6"; const testCase = "downgrade6";
describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => {
@@ -25,6 +24,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => {
org: this.org, org: this.org,
env: this.env, env: this.env,
autumn: this.autumnJs, autumn: this.autumnJs,
attachPm: "success",
}); });
customer = customer_; customer = customer_;
@@ -39,12 +39,17 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => {
}); });
it("should expire premium", async function () { it("should expire premium", async function () {
const cusProduct = await getMainCusProduct({ // const cusProduct = await getMainCusProduct({
db: this.db, // db: this.db,
internalCustomerId: customer.internal_id, // 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 () { it("should have correct product and entitlements after expiration", async function () {
@@ -55,60 +60,4 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => {
cusRes: res, 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);
// });
}); });

View File

@@ -8,12 +8,14 @@ import { setupBefore } from "tests/before.js";
import { findCusProductById } from "@/internal/customers/cusProducts/cusProductUtils/findCusProduct.js"; import { findCusProductById } from "@/internal/customers/cusProducts/cusProductUtils/findCusProduct.js";
import { expect } from "chai"; import { expect } from "chai";
import { getSubsFromCusId } from "tests/utils/expectUtils/expectSubUtils.js"; import { getSubsFromCusId } from "tests/utils/expectUtils/expectSubUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const testCase = "downgrade7"; const testCase = "downgrade7";
describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)}`, () => { describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)}`, () => {
let customerId = testCase; let customerId = testCase;
let testClockId: string; let testClockId: string;
let customer: Customer; let customer: Customer;
let autumn: AutumnInt = new AutumnInt();
before(async function () { before(async function () {
await setupBefore(this); await setupBefore(this);
@@ -46,14 +48,19 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)}
}); });
it("should expire scheduled product (pro)", async function () { it("should expire scheduled product (pro)", async function () {
const cusProduct = await findCusProductById({ // const cusProduct = await findCusProductById({
db: this.db, // db: this.db,
internalCustomerId: customer.internal_id, // internalCustomerId: customer.internal_id,
productId: products.pro.id, // productId: products.pro.id,
}); // });
expect(cusProduct).to.exist; // expect(cusProduct).to.exist;
await AutumnCli.expire(cusProduct!.id); 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 () { it("should have correct product and entitlements (premium)", async function () {

View File

@@ -1,7 +1,7 @@
import { expect } from "chai"; import { expect } from "chai";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.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 chalk from "chalk";
import Stripe from "stripe"; import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js"; import { DrizzleCli } from "@/db/initDrizzle.js";
@@ -21,12 +21,12 @@ import { runMigrationTest } from "./runMigrationTest.js";
let messagesItem = constructFeatureItem({ let messagesItem = constructFeatureItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
includedUsage: 500, includedUsage: 500,
}); }) as LimitedItem;
let wordsItem = constructFeatureItem({ let wordsItem = constructFeatureItem({
featureId: TestFeature.Words, featureId: TestFeature.Words,
includedUsage: 100, includedUsage: 100,
}); }) as LimitedItem;
export let free = constructProduct({ export let free = constructProduct({
items: [messagesItem, wordsItem], items: [messagesItem, wordsItem],
@@ -143,6 +143,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`
stripeCli, stripeCli,
testClockId, testClockId,
advanceTo: addWeeks(Date.now(), 1).getTime(), advanceTo: addWeeks(Date.now(), 1).getTime(),
waitForSeconds: 30,
}); });
let customer = await autumn.customers.get(customerId); let customer = await autumn.customers.get(customerId);

View File

@@ -77,7 +77,7 @@ export const runMigrationTest = async ({
to_version: toProduct.version, to_version: toProduct.version,
}); });
await timeout(5000); await timeout(10000);
const { subs: subsAfter } = await getSubsFromCusId({ const { subs: subsAfter } = await getSubsFromCusId({
stripeCli, stripeCli,
@@ -91,6 +91,7 @@ export const runMigrationTest = async ({
expectSubsSame({ subsBefore, subsAfter }); expectSubsSame({ subsBefore, subsAfter });
const cusAfter = await autumn.customers.get(customerId); const cusAfter = await autumn.customers.get(customerId);
expectFeaturesCorrect({ expectFeaturesCorrect({
customer: cusAfter, customer: cusAfter,
product: toProduct, product: toProduct,

View File

@@ -92,7 +92,7 @@ describe(
expect(premiumGroup1!.scheduled_ids!.length).to.equal(1); expect(premiumGroup1!.scheduled_ids!.length).to.equal(1);
expect(starterGroup2!.scheduled_ids!.length).to.equal(1); expect(starterGroup2!.scheduled_ids!.length).to.equal(1);
expect(premiumGroup1!.scheduled_ids![0]).to.equal( expect(premiumGroup1!.scheduled_ids![0]).to.equal(
starterGroup2!.scheduled_ids![0], starterGroup2!.scheduled_ids![0]
); );
// 2. Check that there's no starter group 1 // 2. Check that there's no starter group 1
@@ -151,5 +151,5 @@ describe(
expect(sub.cancel_at).to.equal(null); expect(sub.cancel_at).to.equal(null);
expect(sub.status).to.equal("active"); expect(sub.status).to.equal("active");
}); });
}, }
); );

View File

@@ -174,14 +174,14 @@ describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proratio
testClockId, testClockId,
advanceTo: addHours( advanceTo: addHours(
addMonths(new Date(), 1), addMonths(new Date(), 1),
hoursToFinalizeInvoice, hoursToFinalizeInvoice
).getTime(), ).getTime(),
waitForSeconds: 30, waitForSeconds: 40,
}); });
const autumnCus = await autumn.customers.get(customerId); const autumnCus = await autumn.customers.get(customerId);
expect(autumnCus.features[TestFeature.Messages].balance).to.equal( expect(autumnCus.features[TestFeature.Messages].balance).to.equal(
newQuantity, newQuantity
); );
expect(autumnCus.invoices.length).to.equal(3); expect(autumnCus.invoices.length).to.equal(3);
@@ -190,8 +190,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proratio
const cusProduct = await getMainCusProduct({ const cusProduct = await getMainCusProduct({
db, db,
internalCustomerId: customer.internal_id, internalCustomerId: customer.internal_id,
productGroup: testCase,
}); });
// console.log(cusProduct);
expect(cusProduct?.options[0].quantity).to.equal(newQuantity / 100); expect(cusProduct?.options[0].quantity).to.equal(newQuantity / 100);
expect(cusProduct?.options[0].upcoming_quantity).to.not.exist; expect(cusProduct?.options[0].upcoming_quantity).to.not.exist;
}); });

View File

@@ -136,6 +136,8 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create / delete ent
}); });
await autumn.entities.create(customerId, entities); await autumn.entities.create(customerId, entities);
await timeout(3000);
usage += entities.length; usage += entities.length;
await expectSubQuantityCorrect({ await expectSubQuantityCorrect({

View File

@@ -36,23 +36,24 @@ export class CusService {
static async updateCusEntitlement( static async updateCusEntitlement(
axios: AxiosInstance, axios: AxiosInstance,
customer_id: string,
customer_entitlement_id: string, customer_entitlement_id: string,
data: any, data: any
) { ) {
return await axios.post( return await axios.post(
`/v1/customers/customer_entitlements/${customer_entitlement_id}`, `/v1/customers/${customer_id}/entitlements/${customer_entitlement_id}`,
data, data
); );
} }
static async updateCusProductStatus( static async updateCusProductStatus(
axios: AxiosInstance, axios: AxiosInstance,
customer_product_id: string, customer_product_id: string,
data: any, data: any
) { ) {
return await axios.post( return await axios.post(
`/v1/customers/customer_products/${customer_product_id}`, `/v1/customers/customer_products/${customer_product_id}`,
data, data
); );
} }
@@ -66,7 +67,7 @@ export class CusService {
coupon_id: string; coupon_id: string;
}) { }) {
return await axios.post( return await axios.post(
`/v1/customers/${customer_id}/coupons/${coupon_id}`, `/v1/customers/${customer_id}/coupons/${coupon_id}`
); );
} }
} }

View File

@@ -34,7 +34,7 @@ function UpdateCusEntitlement({
const [updateLoading, setUpdateLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false);
let cusEnt = selectedCusEntitlement; const cusEnt = selectedCusEntitlement;
const [updateFields, setUpdateFields] = useState<any>({ const [updateFields, setUpdateFields] = useState<any>({
balance: balance:
@@ -78,11 +78,16 @@ function UpdateCusEntitlement({
setUpdateLoading(true); setUpdateLoading(true);
try { try {
await CusService.updateCusEntitlement(axiosInstance, cusEnt.id, { await CusService.updateCusEntitlement(
balance: balanceInt, axiosInstance,
next_reset_at: updateFields.next_reset_at, customer.id || customer.internal_id,
entity_id: entityId, cusEnt.id,
}); {
balance: balanceInt,
next_reset_at: updateFields.next_reset_at,
entity_id: entityId,
}
);
toast.success("Entitlement updated successfully"); toast.success("Entitlement updated successfully");
await cusMutate(); await cusMutate();
setSelectedCusEntitlement(null); setSelectedCusEntitlement(null);