fix: entity and customer.products.updated webhooks

This commit is contained in:
John Yeo
2025-09-12 16:17:59 -07:00
parent a5c9106183
commit 9ab33fb268
33 changed files with 432 additions and 262 deletions

View File

@@ -61,9 +61,36 @@ autumnWebhookRouter.post(
switch (type) {
case WebhookEventType.CustomerProductsUpdated:
console.log(`--------------------------------`);
// console.log(`Received customer.products.updated webhook`);
// console.log(JSON.stringify(data, null, 2));
console.log(
`Type: ${type}, Scenario: ${data?.scenario}, Product: ${data?.updated_product?.id}`
`Customer:`,
data?.customer.id,
`Products:`,
data?.customer.products.map((p: any) => ({
id: p.id,
entity_id: p.entity_id,
status: p.status,
quantity: p.quantity,
}))
);
if (data?.entity) {
console.log(
`Entity: ${data.entity.id}, Products:`,
data.entity.products.map((p: any) => ({
id: p.id,
status: p.status,
quantity: p.quantity,
}))
);
}
console.log(
`Update product ID: ${data?.updated_product?.id}, Scenario: ${data?.scenario}`
);
console.log(`--------------------------------`);
break;
case WebhookEventType.CustomerThresholdReached:
console.log(`Type: ${type}`);

View File

@@ -20,11 +20,12 @@ export const priceToOneOffAndTiered = ({
}) => {
const config = price.config as UsagePriceConfig;
let quantity = options?.quantity!;
let overage = quantity * config.billing_units! - relatedEnt.allowance!;
let overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
// let overage = quantity * config.billing_units! - relatedEnt.allowance!;
if (overage <= 0) {
return null;
}
// if (overage <= 0) {
// return null;
// }
const amount = getPriceForOverage(price, overage);
if (!config.stripe_product_id) {

View File

@@ -125,6 +125,7 @@ export const handleCheckoutSessionCompleted = async ({
const anchorToUnix = checkoutSub
? getEarliestPeriodEnd({ sub: checkoutSub! }) * 1000
: undefined;
if (attachParams.productsList) {
console.log("Inserting products list");
for (const productOptions of attachParams.productsList) {

View File

@@ -1,5 +1,6 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
formatPrice,
getBillingType,
getPriceEntitlement,
priceIsOneOffAndTiered,
@@ -32,9 +33,7 @@ export const getOptionsFromCheckoutSession = async ({
for (const price of prices) {
let config = price.config as UsagePriceConfig;
if (getBillingType(config) != BillingType.UsageInAdvance) {
continue;
}
if (getBillingType(config) != BillingType.UsageInAdvance) continue;
const lineItem = findStripeItemForPrice({
price,
@@ -47,7 +46,8 @@ export const getOptionsFromCheckoutSession = async ({
let relatedEnt = getPriceEntitlement(price, ents);
if (priceIsOneOffAndTiered(price, relatedEnt)) {
quantity = (lineItem.quantity || 0) + (relatedEnt.allowance || 0);
// quantity = lineItem.quantity || 0;
continue;
} else {
quantity = lineItem.quantity || 0;
}

View File

@@ -121,11 +121,10 @@ export const handleSchedulePhaseCompleted = async ({
scheduled_ids: [],
},
});
} catch (error) {
} catch (error: any) {
logger.error(
`schedule.phase.completed: failed to cancel schedule ${schedule.id}`
`schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`
);
logger.error({ error });
}
}
// const currentPhase = schedule.phases.find(

View File

@@ -102,9 +102,7 @@ export const handleSubCanceled = async ({
const { db, env, logtail: logger } = req;
if (!canceledFromPortal || updatedCusProducts.length == 0) {
return;
}
if (!canceledFromPortal || updatedCusProducts.length == 0) return;
await updateCusProductCanceled({
db,
@@ -194,6 +192,8 @@ export const handleSubCanceled = async ({
}
for (let cusProd of updatedCusProducts) {
console.log("Sending webhook for canceled product: ", cusProd.product.id);
try {
await addProductsUpdatedWebhookTask({
req,

View File

@@ -7,6 +7,7 @@ import { AttachScenario, FullCusProduct } from "@autumn/shared";
import Stripe from "stripe";
import { isMultiProductSub } from "@/internal/customers/attach/mergeUtils/mergeUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getSubScenarioFromCache } from "@/internal/customers/cusCache/subCacheUtils.js";
const isSubRenewed = ({
previousAttributes,
sub,
@@ -70,12 +71,18 @@ export const handleSubRenewed = async ({
if (!renewed || updatedCusProducts.length == 0) return;
const subScenario = await getSubScenarioFromCache({ subId: sub.id });
console.log(`Renewed: ${renewed}, subScenario: ${subScenario}`);
if (subScenario === AttachScenario.Renew) return;
const customer = updatedCusProducts[0].customer;
let cusProducts = await CusProductService.list({
db,
internalCustomerId: customer!.internal_id,
});
console.log(`handling sub.renewed!`);
if (isMultiProductSub({ sub, cusProducts }) || sub.schedule) return;
await CusProductService.updateByStripeSubId({

View File

@@ -2,9 +2,9 @@ import {
ActionType,
AppEnv,
AuthType,
CusProductStatus,
FullCusProduct,
FullProduct,
notNullish,
Organization,
} from "@autumn/shared";
@@ -23,7 +23,8 @@ import { ActionService } from "@/internal/analytics/ActionService.js";
import { constructAction } from "@/internal/analytics/actionUtils.js";
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { cusProductToPrices, cusProductToProduct } from "@autumn/shared";
import { cusProductToProduct } from "@autumn/shared";
import { getSingleEntityResponse } from "@/internal/api/entities/getEntityUtils.js";
interface ActionDetails {
request_id: string;
@@ -121,19 +122,8 @@ export const handleProductsUpdated = async ({
// Product:
let product = cusProduct.product;
// const prices = cusProductToPrices({ cusProduct });
// const ents = cusProductToEnts({ cusProduct });
// let freeTrial = cusProduct.free_trial;
let fullProduct: FullProduct = cusProductToProduct({ cusProduct });
// {
// ...product,
// prices,
// entitlements: ents,
// free_trial: freeTrial || null,
// };
let customer = await CusService.getFull({
db,
idOrInternalId: data.customerId || data.internalCustomerId,
@@ -165,9 +155,6 @@ export const handleProductsUpdated = async ({
features,
});
// 1. Log action to DB
// console.log(`handling products.updated for customer ${customer.id}`);
try {
if (req) {
let action = constructAction({
@@ -205,6 +192,28 @@ export const handleProductsUpdated = async ({
}
}
let entityRes = null;
if (notNullish(customer?.entity)) {
entityRes = await getSingleEntityResponse({
entityId: customer.entity!.id,
org,
env,
fullCus: customer,
entity: customer.entity!,
features,
});
}
// console.log(`Sending svix event for customer ${customer.id}`);
// console.log(
// "Products:",
// cusDetails.products.map((p) => ({
// id: p.id,
// status: p.status,
// quantity: p.quantity,
// }))
// );
// 2. Send Svix event
await sendSvixEvent({
org,
@@ -213,6 +222,7 @@ export const handleProductsUpdated = async ({
data: {
scenario,
customer: cusDetails,
entity: entityRes,
updated_product: productRes,
},
});

View File

@@ -43,11 +43,11 @@ export const runActionHandlerTask = async ({
});
break;
}
} catch (error) {
} catch (error: any) {
logger.error("Error processing action handler job:", {
jobName: job.name,
error,
payload,
// jobName: job.name,
// payload,
message: error.message,
});
} finally {
await releaseLock({ lockKey, useBackup });

View File

@@ -1,10 +1,5 @@
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusWithCache } from "@/internal/customers/cusCache/getCusWithCache.js";
import {
ACTIVE_STATUSES,
RELEVANT_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getCusFeaturesResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.js";
import { processFullCusProducts } from "@/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.js";
@@ -13,7 +8,6 @@ import { nullish } from "@/utils/genUtils.js";
import {
type AppEnv,
Feature,
CusProductStatus,
type Entity,
EntityExpand,
type EntityResponse,
@@ -32,6 +26,7 @@ export const getSingleEntityResponse = async ({
org,
env,
fullCus,
entity,
features,
withAutumnId = false,
}: {
@@ -39,13 +34,10 @@ export const getSingleEntityResponse = async ({
org: Organization;
env: AppEnv;
fullCus: FullCustomer;
entity: Entity;
features: Feature[];
withAutumnId?: boolean;
}) => {
let entity = fullCus.entities.find(
(e: Entity) => e.id == entityId || e.internal_id == entityId
);
const apiVersion = APIVersion.v1_2;
if (!entity) {
@@ -80,7 +72,7 @@ export const getSingleEntityResponse = async ({
let { main, addOns } = await processFullCusProducts({
fullCusProducts: entityCusProducts,
entities: fullCus.entities,
entity,
subs: entitySubs,
org,
apiVersion: APIVersion.v1_2,
@@ -136,7 +128,7 @@ export const getEntityResponse = async ({
logger: any;
skipCache?: boolean;
}) => {
let customer = await getCusWithCache({
let fullCus = await getCusWithCache({
db,
idOrInternalId: customerId,
org,
@@ -147,7 +139,7 @@ export const getEntityResponse = async ({
skipCache,
});
if (!customer) {
if (!fullCus) {
throw new RecaseError({
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
@@ -157,11 +149,18 @@ export const getEntityResponse = async ({
const entityResponses: EntityResponse[] = [];
for (const entityId of entityIds) {
const entity = fullCus.entities.find(
(e: Entity) => e.id == entityId || e.internal_id == entityId
);
if (!entity) continue;
let entityResponse = await getSingleEntityResponse({
entityId,
org,
env,
fullCus: customer,
fullCus,
entity,
features,
withAutumnId,
});
@@ -171,8 +170,8 @@ export const getEntityResponse = async ({
return {
entities: entityResponses,
customer,
fullEntities: customer.entities,
invoices: customer.invoices,
customer: fullCus,
fullEntities: fullCus.entities,
invoices: fullCus.invoices,
};
};

View File

@@ -399,6 +399,7 @@ export const createFullCusProduct = async ({
}
// 3. Deduct existing usages
let deductedCusEnts = addExistingUsagesToCusEnts({
cusEnts: cusEnts,
entitlements: entitlements,

View File

@@ -80,6 +80,7 @@ export const handlePaidProduct = async ({
let trialEndsAt = undefined;
// 1. If merge sub
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
trialEndsAt = isTrialing({
@@ -152,6 +153,7 @@ export const handlePaidProduct = async ({
billingCycleAnchorUnix = attachParams.billingAnchor;
}
// console.log("Item set: ", itemSet);
try {
sub = await createStripeSub2({
db: req.db,

View File

@@ -9,7 +9,7 @@ import {
paramsToCurSub,
} from "../attachUtils/convertAttachParams.js";
import { paramsToScheduleItems } from "../mergeUtils/paramsToScheduleItems.js";
import { AttachConfig, SuccessCode } from "@autumn/shared";
import { AttachConfig, AttachScenario, SuccessCode } from "@autumn/shared";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
cusProductToSchedule,
@@ -19,6 +19,8 @@ import { subToNewSchedule } from "../mergeUtils/subToNewSchedule.js";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { updateCurSchedule } from "../mergeUtils/updateCurSchedule.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { addSubIdToCache } from "../../cusCache/subCacheUtils.js";
export const handleRenewProduct = async ({
req,
@@ -33,9 +35,9 @@ export const handleRenewProduct = async ({
}) => {
const logger = req.logtail;
const { stripeCli, customer: fullCus } = attachParams;
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const product = attachParams.products[0];
const cusProducts = attachParams.customer.customer_products;
@@ -91,6 +93,11 @@ export const handleRenewProduct = async ({
}
if (curSubId) {
// Add sub id to upstash
await addSubIdToCache({
subId: curSubId,
scenario: AttachScenario.Renew,
});
await stripeCli.subscriptions.update(curSubId, {
cancel_at: null,
});
@@ -193,6 +200,24 @@ export const handleRenewProduct = async ({
}
}
if (curCusProduct) {
try {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: curCusProduct.internal_customer_id,
org: attachParams.org,
env: attachParams.customer.env,
customerId:
attachParams.customer.id || attachParams.customer.internal_id,
scenario: AttachScenario.Renew,
cusProduct: curCusProduct,
logger,
});
} catch (error) {
logger.error("RENEW FLOW: failed to add to webhook queue", { error });
}
}
if (curScheduledProduct) {
await CusProductService.delete({
db: req.db,

View File

@@ -26,6 +26,7 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
export const handleScheduleFunction2 = async ({
req,
@@ -138,6 +139,9 @@ export const handleScheduleFunction2 = async ({
logger.info(`SCHEDULE FLOW: no schedule, canceling sub ${curSub?.id}`);
await stripeCli.subscriptions.update(curSub!.id, {
cancel_at: expectedEnd!,
cancellation_details: {
comment: "autumn_downgrade",
},
});
}
@@ -150,13 +154,36 @@ export const handleScheduleFunction2 = async ({
nextResetAt: expectedEnd! * 1000,
disableFreeTrial: true,
isDowngrade: true,
scenario: newProductFree
? AttachScenario.Cancel
: AttachScenario.Downgrade,
sendWebhook: false,
// scenario: newProductFree
// ? AttachScenario.Cancel
// : AttachScenario.Downgrade,
logger,
});
}
if (curCusProduct) {
try {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: curCusProduct.internal_customer_id,
org: attachParams.org,
env: attachParams.customer.env,
customerId:
attachParams.customer.id || attachParams.customer.internal_id,
scenario: isFreeProduct(attachParams.prices)
? AttachScenario.Cancel
: AttachScenario.Downgrade,
cusProduct: curCusProduct,
logger,
});
} catch (error) {
logger.error("SCHEDULE FLOW: failed to add to webhook queue", { error });
}
}
let apiVersion = attachParams.apiVersion || APIVersion.v1;
if (res) {

View File

@@ -207,6 +207,7 @@ export const handleUpgradeFlow = async ({
carryExistingUsages: config.carryUsage,
carryOverTrial: config.carryTrial,
anchorToUnix: anchorToUnix,
scenario: AttachScenario.Upgrade,
logger,
});
}

View File

@@ -184,8 +184,6 @@ export const handleCheckout = (req: any, res: any) =>
if (checkoutRes.next_cycle) {
const nextCycle = checkoutRes.next_cycle;
console.log("Due at:", formatUnixToDate(nextCycle.starts_at!));
console.log("Total:", nextCycle.total);
}
res.status(200).json({

View File

@@ -0,0 +1,21 @@
import { initUpstash } from "./upstashUtils.js";
export const addSubIdToCache = async ({
subId,
scenario,
}: {
subId: string;
scenario: string;
}) => {
const upstash = await initUpstash();
if (!upstash) return;
await upstash.set(`sub:${subId}`, scenario, {
ex: 180, // 3 minutes
});
};
export const getSubScenarioFromCache = async ({ subId }: { subId: string }) => {
const upstash = await initUpstash();
if (!upstash) return null;
return (await upstash.get(`sub:${subId}`)) as string | null;
};

View File

@@ -119,132 +119,6 @@ export const getCusEntBalance = ({
};
};
// export const sortCusEntsForDeduction = (
// cusEnts: (FullCustomerEntitlement & {
// customer_product?: FullCusProduct;
// })[],
// reverseOrder: boolean = false
// ) => {
// let intervalOrder: Record<EntInterval, number> = {
// [EntInterval.Minute]: 0, // 1 minute
// [EntInterval.Hour]: 1, // 1 hour
// [EntInterval.Day]: 2, // 1 day
// [EntInterval.Week]: 3, // 1 week
// [EntInterval.Month]: 4, // 1 month
// [EntInterval.Quarter]: 5, // 3 months
// [EntInterval.Year]: 6, // 1 year
// [EntInterval.SemiAnnual]: 7, // 6 months
// [EntInterval.Lifetime]: 8, // 1 time
// };
// // console.log(
// // `Cus ents before (${reverseOrder ? "reversed" : "normal"})`,
// // cusEnts.map(
// // (ce) => `${ce.entitlement.feature_id} - ${ce.entitlement.interval}`
// // )
// // );
// cusEnts.sort((a, b) => {
// const aEnt = a.entitlement;
// const bEnt = b.entitlement;
// // 1. If boolean, go first
// if (aEnt.feature.type == FeatureType.Boolean) {
// return -1;
// }
// if (bEnt.feature.type == FeatureType.Boolean) {
// return 1;
// }
// // 1. If a is credit system and b is not, a should go last
// if (
// aEnt.feature.type == FeatureType.CreditSystem &&
// bEnt.feature.type != FeatureType.CreditSystem
// ) {
// return 1;
// }
// // 2. If a is not credit system and b is, a should go first
// if (
// aEnt.feature.type != FeatureType.CreditSystem &&
// bEnt.feature.type == FeatureType.CreditSystem
// ) {
// return -1;
// }
// // 2. Sort by unlimited (unlimited goes first)
// if (
// aEnt.allowance_type == AllowanceType.Unlimited &&
// bEnt.allowance_type != AllowanceType.Unlimited
// ) {
// return -1;
// }
// if (
// aEnt.allowance_type != AllowanceType.Unlimited &&
// bEnt.allowance_type == AllowanceType.Unlimited
// ) {
// return 1;
// }
// // If one has usage_allowed, it should go last
// if (!a.usage_allowed && b.usage_allowed) {
// return -1;
// }
// if (!b.usage_allowed && a.usage_allowed) {
// return 1;
// }
// // If one has a next_reset_at, it should go first
// let nextResetFirst = reverseOrder ? 1 : -1;
// if (a.next_reset_at && !b.next_reset_at) {
// return nextResetFirst;
// }
// // If b has a next_reset_at, it should go first
// if (!a.next_reset_at && b.next_reset_at) {
// return -nextResetFirst;
// }
// // 3. Sort by interval
// let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
// let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
// if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
// if (reverseOrder) {
// return bVal.sub(aVal).toNumber();
// // return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval];
// } else {
// return aVal.sub(bVal).toNumber();
// // return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval];
// }
// }
// // Check if a is main product
// let aIsAddOn = a.customer_product?.product?.is_add_on;
// let bIsAddOn = b.customer_product?.product?.is_add_on;
// if (aIsAddOn && !bIsAddOn) {
// return 1;
// }
// if (!aIsAddOn && bIsAddOn) {
// return -1;
// }
// // 4. Sort by created_at
// return a.created_at - b.created_at;
// });
// // console.log(
// // `Cus ents after (${reverseOrder ? "reversed" : "normal"})`,
// // cusEnts.map(
// // (ce) => `${ce.entitlement.feature_id} - ${ce.entitlement.interval}`
// // )
// // );
// };
// Get related cusPrice
export const getRelatedCusPrice = (
cusEnt: FullCustomerEntitlement,

View File

@@ -221,7 +221,7 @@ export const addExistingUsagesToCusEnts = ({
for (const cusEnt of fullCusEnts) {
let ent = cusEnt.entitlement;
// let cusEntKey = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`;
let fromEntities = existingUsages[key].fromEntities;
// if (cusEntKey !== key) continue;

View File

@@ -1,7 +1,6 @@
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { ErrCode, FullCustomerEntitlement } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { FullCustomerEntitlement } from "@autumn/shared";
export const getEntityBalance = ({
cusEnt,
@@ -13,14 +12,7 @@ export const getEntityBalance = ({
let entityBalance = cusEnt.entities?.[entityId!]?.balance;
let adjustment = cusEnt.entities?.[entityId!]?.adjustment || 0;
if (nullish(entityBalance)) {
return { balance: 0, adjustment: 0 };
// throw new RecaseError({
// message: `Entity balance not found for entityId: ${entityId}`,
// code: ErrCode.EntityBalanceNotFound,
// statusCode: StatusCodes.BAD_REQUEST,
// });
}
if (nullish(entityBalance)) return { balance: 0, adjustment: 0 };
return {
balance: entityBalance,
@@ -45,11 +37,11 @@ export const getSummedEntityBalances = ({
return {
balance: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.balance,
0,
0
),
adjustment: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.adjustment,
0,
0
),
unused: 0,
count: Object.values(cusEnt.entities!).length,

View File

@@ -15,11 +15,11 @@ import { notNullish, notNullOrUndefined } from "@/utils/genUtils.js";
import { BREAK_API_VERSION } from "@/utils/constants.js";
import {
getCusEntBalance,
getRelatedCusPrice,
getResetBalance,
getUnlimitedAndUsageAllowed,
} from "../../cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
export interface CusFeatureBalance {
feature_id: string;

View File

@@ -41,16 +41,16 @@ export const getCusProductResponse = async ({
cusProduct,
subs,
org,
entities = [],
apiVersion,
features,
entity,
}: {
cusProduct: FullCusProduct;
org: Organization;
subs?: Subscription[];
entities?: Entity[];
apiVersion: number;
features: Feature[];
entity?: Entity;
}) => {
// Process prices
@@ -184,11 +184,12 @@ export const getCusProductResponse = async ({
// stripe_subscription_ids: cusProduct.subscription_ids || [],
started_at: cusProduct.starts_at,
entity_id: cusProduct.internal_entity_id
? entities?.find(
(e: Entity) => e.internal_id == cusProduct.internal_entity_id
)?.id
: cusProduct.entity_id || undefined,
entity_id: entity?.id || cusProduct.entity_id || undefined,
// entity_id: cusProduct.internal_entity_id
// ? entities?.find(
// (e: Entity) => e.internal_id == cusProduct.internal_entity_id
// )?.id
// : cusProduct.entity_id || undefined,
...stripeSubData,
items: v2Product.items,

View File

@@ -48,14 +48,14 @@ export const processFullCusProducts = async ({
fullCusProducts,
subs,
org,
entities = [],
entity,
apiVersion,
features,
}: {
fullCusProducts: any;
subs: any;
org: Organization;
entities?: Entity[];
entity?: Entity;
apiVersion: number;
features: Feature[];
}) => {
@@ -67,7 +67,7 @@ export const processFullCusProducts = async ({
cusProduct,
subs,
org,
entities,
entity,
apiVersion,
features,
});

View File

@@ -82,7 +82,6 @@ export const getCustomerDetails = async ({
subs,
org,
apiVersion,
entities: customer.entities,
features,
});

View File

@@ -7,6 +7,40 @@ import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import Stripe from "stripe";
const createDefaultBillingPortalConfiguration = async (stripeCli: Stripe) => {
try {
const configuration = await stripeCli.billingPortal.configurations.create({
features: {
customer_update: {
allowed_updates: ["email", "address"],
enabled: true,
},
invoice_history: {
enabled: true,
},
payment_method_update: {
enabled: true,
},
subscription_cancel: {
enabled: true,
},
},
// business_profile: {
// privacy_policy_url: "https://example.com/privacy",
// terms_of_service_url: "https://example.com/terms",
// },
});
return configuration;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create billing portal configuration: ${error.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};
export const handleCreateBillingPortal = async (req: any, res: any) =>
routeHandler({
@@ -37,23 +71,19 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
const stripeCli = createStripeCli({ org, env: req.env });
// Determine the Stripe customer ID to use
let stripeCustomerId: string;
if (!customer.processor?.id) {
let newCus;
try {
newCus = await createStripeCusIfNotExists({
const newCus = await createStripeCusIfNotExists({
db: req.db,
org,
env: req.env,
customer,
logger: req.logtail,
});
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
} finally {
if (!newCus) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
@@ -62,26 +92,78 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
});
}
const portal = await stripeCli.billingPortal.sessions.create({
customer: newCus.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
res.status(200).json({
customer_id: customer.id,
url: portal.url,
stripeCustomerId = newCus.id;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
const portal = await stripeCli.billingPortal.sessions.create({
customer: customer.processor.id,
stripeCustomerId = customer.processor.id;
}
// Create billing portal session
let portal;
try {
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
} catch (error: any) {
console.log(`Code: ${error.code}, Message: ${error.message}`);
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
// Check if the error is due to missing default configuration
if (
error.message &&
error.message.includes("default configuration has not been created")
) {
try {
// Create a default billing portal configuration
req.logtail?.info(
`Creating default billing portal configuration for customer ${customer.id}`
);
const configuration =
await createDefaultBillingPortalConfiguration(stripeCli);
req.logtail?.info(
"Successfully created billing portal configuration",
{
configurationId: configuration.id,
orgId: org.id,
}
);
// Retry creating the portal session with the new configuration
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
configuration: configuration.id,
});
} catch (configError: any) {
req.logtail?.error(
"Failed to create billing portal configuration",
{
error: configError.message,
orgId: org.id,
}
);
throw new RecaseError({
message: `Failed to create billing portal configuration: ${configError.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
throw error;
}
}
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
},
});

View File

@@ -3,12 +3,13 @@ import { z } from "zod";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusService } from "../CusService.js";
import { ErrCode } from "@autumn/shared";
import { AttachScenario, ErrCode } from "@autumn/shared";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { nullish } from "@/utils/genUtils.js";
import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { deleteCusCache } from "../cusCache/updateCachedCus.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
const TransferProductSchema = z.object({
from_entity_id: z.string().nullish(),
to_entity_id: z.string(),
@@ -119,6 +120,21 @@ export const handleTransferProduct = async (req: any, res: any) =>
internal_entity_id: toEntity.internal_id,
},
});
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: customer.internal_id,
org: req.org,
env: req.env,
customerId: customer.id || customer.internal_id,
scenario: AttachScenario.New,
cusProduct: {
...cusProduct,
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
logger: req.logger,
});
}
await deleteCusCache({

View File

@@ -100,6 +100,6 @@ export const handleDecreaseAndTransfer = async ({
},
product
),
sendWebhook: false,
scenario: AttachScenario.New,
});
};

View File

@@ -13,10 +13,8 @@ import {
} from "@/trigger/updateBalanceTask.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
getCusEntBalance,
getUnlimitedAndUsageAllowed,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { notNullish } from "@/utils/genUtils.js";

View File

@@ -9,10 +9,7 @@ import { adjustAllowance } from "@/trigger/adjustAllowance.js";
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
getCusEntBalance,
getRelatedCusPrice,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { DrizzleCli } from "@/db/initDrizzle.js";

View File

@@ -70,16 +70,12 @@ export const sendSvixThresholdReachedEvent = async ({
});
if (fullCus.entity) {
const entities = await EntityService.list({
db,
internalCustomerId: fullCus.internal_id,
});
fullCus.entities = entities;
await getSingleEntityResponse({
org,
env,
features,
fullCus,
entity: fullCus.entity,
entityId: fullCus.entity.id,
});
}
@@ -240,15 +236,11 @@ export const handleThresholdReached = async ({
});
if (fullCus.entity) {
const entities = await EntityService.list({
db,
internalCustomerId: fullCus.internal_id,
});
fullCus.entities = entities;
await getSingleEntityResponse({
org,
env,
features,
entity: fullCus.entity,
fullCus,
entityId: fullCus.entity.id,
});

View File

@@ -0,0 +1,71 @@
import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
import { notNullish, nullish } from "../utils.js";
export const getSummedEntityBalances = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
if (nullish(cusEnt.entities)) {
return {
balance: 0,
adjustment: 0,
unused: 0,
count: 0,
};
}
return {
balance: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.balance,
0
),
adjustment: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.adjustment,
0
),
unused: 0,
count: Object.values(cusEnt.entities!).length,
};
};
export const getCusEntBalance = ({
cusEnt,
entityId,
}: {
cusEnt: FullCustomerEntitlement;
entityId?: string | null;
}) => {
let entitlement = cusEnt.entitlement;
let ent = cusEnt.entitlement;
let feature = ent.feature;
if (notNullish(entitlement.entity_feature_id)) {
if (nullish(entityId)) {
return getSummedEntityBalances({
cusEnt,
});
} else {
let entityBalance = cusEnt.entities?.[entityId!]?.balance;
let adjustment = cusEnt.entities?.[entityId!]?.adjustment || 0;
if (nullish(entityBalance)) {
return { balance: 0, adjustment: 0, unused: 0, count: 1 };
}
return {
balance: entityBalance,
adjustment,
unused: 0,
count: 1,
};
}
}
return {
balance: cusEnt.balance,
adjustment: cusEnt.adjustment,
unused: cusEnt.replaceables?.length || 0,
count: 1,
};
};

View File

@@ -9,6 +9,9 @@ export * from "./cusProductUtils/cusProductConstants.js";
export * from "./cusProductUtils/cusProductUtils.js";
export * from "./utils.js";
// Cus ent utils
export * from "./cusEntUtils/balanceUtils.js";
// Item utils
export * from "./productV2Utils/productItemUtils/mapToItem.js";
export * from "./productV2Utils/productItemUtils/productItemUtils.js";

View File

@@ -22,6 +22,7 @@ import { getBackendErr, notNullish } from "@/utils/genUtils";
import CopyButton from "@/components/general/CopyButton";
import { AlertCircle, Info, InfoIcon } from "lucide-react";
import { useCusQuery } from "../hooks/useCusQuery";
import { getCusEntBalance } from "@autumn/shared";
function UpdateCusEntitlement({
selectedCusEntitlement,
@@ -32,17 +33,40 @@ function UpdateCusEntitlement({
}) {
const { customer, refetch } = useCusQuery();
const { entityId } = useCustomerContext();
// const { customer, env, cusMutate, entityId } = useCustomerContext();
const cusEnt = selectedCusEntitlement;
console.log(
"Balance: ",
cusEnt
? getCusEntBalance({
cusEnt: cusEnt!,
entityId,
}).balance
: null
);
const [updateLoading, setUpdateLoading] = useState(false);
const axiosInstance = useAxiosInstance();
console.log(
`Cus ent: ${cusEnt?.entitlement.feature_id}, Balances: `,
cusEnt
? getCusEntBalance({
cusEnt: cusEnt!,
entityId,
})
: null
);
const [updateFields, setUpdateFields] = useState<any>({
balance:
entityId && notNullish(cusEnt?.entities?.[entityId]?.balance)
? cusEnt?.entities?.[entityId]?.balance
: cusEnt?.balance,
balance: cusEnt
? getCusEntBalance({
cusEnt,
entityId,
}).balance
: null,
next_reset_at: cusEnt?.next_reset_at,
});
@@ -55,10 +79,12 @@ function UpdateCusEntitlement({
useEffect(() => {
setUpdateFields({
balance:
entityId && notNullish(cusEnt?.entities?.[entityId]?.balance)
? cusEnt?.entities?.[entityId]?.balance
: cusEnt?.balance,
balance: cusEnt
? getCusEntBalance({
cusEnt: cusEnt!,
entityId,
}).balance
: null,
next_reset_at: cusEnt?.next_reset_at,
});
}, [selectedCusEntitlement]);