Files
cfw-autumn/server/src/internal/customers/attach/attachRouter.ts
2025-06-03 08:07:41 +01:00

459 lines
13 KiB
TypeScript

import { Router } from "express";
import RecaseError from "@/utils/errorUtils.js";
import { APIVersion, BillingType, FullCusProduct } from "@autumn/shared";
import { ErrCode } from "@/errors/errCodes.js";
import {
createStripeCusIfNotExists,
getCusPaymentMethod,
} from "@/external/stripe/stripeCusUtils.js";
import { handleAddProduct } from "@/internal/customers/add-product/handleAddProduct.js";
import {
getBillingType,
getEntOptions,
getPriceEntitlement,
getProductForPrice,
priceIsOneOffAndTiered,
} from "@/internal/products/prices/priceUtils.js";
import {
checkStripeProductExists,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import {
notNullish,
notNullOrUndefined,
nullOrUndefined,
} from "@/utils/genUtils.js";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { handleExistingProduct } from "@/internal/customers/add-product/handleExistingProduct.js";
import { handleAddFreeProduct } from "@/internal/customers/add-product/handleAddFreeProduct.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { AttachBodySchema } from "./models/AttachBody.js";
import { processAttachBody } from "./attachUtils/processAttachBody.js";
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
import { handleAttach } from "./handleAttach.js";
export const attachRouter = Router();
export const handlePrepaidErrors = async ({
attachParams,
useCheckout = false,
}: {
attachParams: AttachParams;
useCheckout?: boolean;
}) => {
const { prices, entitlements, optionsList } = attachParams;
// 2. Check if options are valid
for (const price of prices) {
const billingType = getBillingType(price.config!);
if (billingType === BillingType.UsageInAdvance) {
// Get options for price
let priceEnt = getPriceEntitlement(price, entitlements);
let options = getEntOptions(optionsList, priceEnt);
// 1. If not checkout, quantity should be defined
if (!useCheckout && nullOrUndefined(options?.quantity)) {
throw new RecaseError({
message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
if (
nullOrUndefined(options?.quantity) &&
priceIsOneOffAndTiered(price, priceEnt)
) {
throw new RecaseError({
code: ErrCode.InvalidRequest,
message:
"Quantity is required for start of period price that is one off and tiered",
statusCode: 400,
});
}
// 3. Quantity cannot be negative
if (notNullish(options?.quantity) && options?.quantity! < 0) {
throw new RecaseError({
message: `Quantity cannot be negative`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
// 4. If there's only one price, quantity must be greater than 0
if (options?.quantity === 0 && prices.length === 1) {
throw new RecaseError({
message: `When there's only one price, quantity must be greater than 0`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
}
}
};
export const handlePublicAttachErrors = async ({
curCusProduct,
isPublic,
}: {
curCusProduct: FullCusProduct | null;
isPublic: boolean;
}) => {
if (!isPublic) {
return;
}
if (!curCusProduct) {
return;
}
// 1. If on paid plan, not allowed to switch product
const curProductFree = isFreeProduct(
curCusProduct?.customer_prices.map((cp: any) => cp.price) || [], // if no current product...
);
if (!curProductFree) {
throw new RecaseError({
message: "Public attach: not allowed to upgrade / downgrade (from paid)",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
};
export const checkStripeConnections = async ({
req,
attachParams,
}: {
req: any;
attachParams: AttachParams;
}) => {
const { org, customer, products } = attachParams;
const logger = req.logtail;
const env = customer.env;
if (!org.stripe_connected) {
throw new RecaseError({
message: "Please connect to Stripe to add products",
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
}
// 2. If invoice only and no email, save email
if (attachParams.invoiceOnly && !customer.email) {
customer.email = `${customer.id}@invoices.useautumn.com`;
await CusService.update({
db: req.db,
internalCusId: customer.internal_id,
update: {
email: customer.email,
},
});
}
const batchProductUpdates = [
createStripeCusIfNotExists({
db: req.db,
org,
env,
customer,
logger,
}),
];
for (const product of products) {
batchProductUpdates.push(
checkStripeProductExists({
db: req.db,
org,
env,
product,
logger,
}),
);
}
await Promise.all(batchProductUpdates);
};
export const createStripePrices = async ({
attachParams,
useCheckout,
req,
logger,
}: {
attachParams: AttachParams;
useCheckout: boolean;
req: any;
logger: any;
}) => {
const { prices, entitlements, products, org, internalEntityId } =
attachParams;
const stripeCli = createStripeCli({ org, env: attachParams.customer.env });
const batchPriceUpdates = [];
for (const price of prices) {
let product = getProductForPrice(price, products);
batchPriceUpdates.push(
createStripePriceIFNotExist({
db: req.db,
stripeCli,
price,
entitlements,
product: product!,
org,
logger,
internalEntityId: attachParams.internalEntityId,
useCheckout,
}),
);
}
await Promise.all(batchPriceUpdates);
};
export const customerHasPm = async ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
// SCENARIO 3: No payment method, checkout required
const paymentMethod = await getCusPaymentMethod({
stripeCli: createStripeCli({
org: attachParams.org,
env: attachParams.customer.env,
}),
stripeId: attachParams.customer.processor?.id,
});
return notNullOrUndefined(paymentMethod) ? true : false;
};
const handleAttachOld = async (req: any, res: any) =>
routeHandler({
action: "attach",
req,
res,
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
await handleAttachRaceCondition({ req, res });
const attachBody = AttachBodySchema.parse(req.body);
const logger = req.logtail;
// PUBLIC STUFF
let forceCheckout = req.isPublic || attachBody.force_checkout || false;
let isCustom = attachBody.is_custom || false;
if (req.isPublic) {
isCustom = false;
}
logger.info("--------------------------------");
let publicStr = req.isPublic ? "(Public) " : "";
logger.info(
`${publicStr}ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`,
);
const {
customer,
products,
optionsList,
prices,
entitlements,
freeTrial,
} = await processAttachBody({
req,
attachBody,
});
const apiVersion =
orgToVersion({
org: req.org,
reqApiVersion: req.apiVersion,
}) || APIVersion.v1;
const internalEntityId = attachBody.entity_id
? customer.entities.find(
(e) =>
e.id === attachBody.entity_id ||
e.internal_id === attachBody.entity_id,
)?.internal_id
: undefined;
const stripeCli = createStripeCli({ org: req.org, env: req.env });
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: customer.processor?.id,
});
const attachParams: AttachParams = {
stripeCli,
paymentMethod,
customer,
products,
optionsList,
prices,
entitlements,
freeTrial,
// From req
req,
org: req.org,
entities: customer.entities,
features: req.features,
internalEntityId,
cusProducts: customer.customer_products,
// Others
apiVersion,
successUrl: attachBody.success_url,
invoiceOnly: attachBody.invoice_only,
billingAnchor: attachBody.billing_cycle_anchor,
metadata: attachBody.metadata,
disableFreeTrial: attachBody.free_trial === false || false,
checkoutSessionParams: attachBody.checkout_session_params,
isCustom,
};
// attachParams.apiVersion =
// orgToVersion({
// org,
// reqApiVersion: req.apiVersion,
// }) || APIVersion.v1;
// attachParams.req = req;
// attachParams.successUrl = attachBody.success_url;
// attachParams.invoiceOnly = attachBody.invoice_only;
// attachParams.billingAnchor = attachBody.billing_cycle_anchor;
// attachParams.metadata = attachBody.metadata;
// attachParams.isCustom = isCustom || false;
// attachParams.disableFreeTrial = attachBody.free_trial === false || false;
// attachParams.checkoutSessionParams = checkout_session_params;
logger.info(
`Customer: ${chalk.yellow(
`${attachParams.customer.id} (${attachParams.customer.name})`,
)}, Products: ${chalk.yellow(
attachParams.products.map((p) => p.id).join(", "),
)}`,
);
// 3. Check for stripe connection
await checkStripeConnections({ req, attachParams });
let hasPm = await customerHasPm({ attachParams });
const useCheckout = !hasPm || forceCheckout;
await createStripePrices({
attachParams,
useCheckout,
req,
logger,
});
logger.info(
`Has PM: ${chalk.yellow(hasPm)}, Force Checkout: ${chalk.yellow(
forceCheckout,
)}`,
);
// logger.info(
// `Use Checkout: ${chalk.yellow(useCheckout)}, Is Custom: ${chalk.yellow(
// isCustom,
// )}, Invoice Only: ${chalk.yellow(invoiceOnly)}`,
// {
// details: { hasPm, forceCheckout, useCheckout, isCustom, invoiceOnly },
// },
// );
// -------------------- ERROR CHECKING --------------------
// 1. Check for normal errors (eg. options, different recurring intervals)
const { curCusProduct, done } = await handleExistingProduct({
req,
res,
attachParams,
useCheckout,
invoiceOnly: attachBody.invoice_only,
isCustom,
});
await handlePrepaidErrors({
attachParams,
useCheckout,
});
await handlePublicAttachErrors({
curCusProduct,
isPublic: req.isPublic || false,
});
if (done) return;
// // -------------------- ATTACH PRODUCT --------------------
// SCENARIO 1: Free product, no existing product
const newProductsFree = isFreeProduct(attachParams.prices);
const allAddOns = attachParams.products.every((p) => p.is_add_on);
if (
(!curCusProduct && newProductsFree) ||
(allAddOns && newProductsFree)
) {
logger.info("SCENARIO 1: FREE PRODUCT");
await handleAddFreeProduct({
req,
res,
attachParams,
});
return;
}
if (useCheckout && !newProductsFree && !attachParams.invoiceOnly) {
logger.info("SCENARIO 2: USING CHECKOUT");
await handleCreateCheckout({
req,
res,
attachParams,
});
return;
}
// // SCENARIO 4: Switching product
// if (curCusProduct) {
// logger.info("SCENARIO 3: SWITCHING PRODUCT");
// await handleChangeProduct({
// req,
// res,
// attachParams,
// curCusProduct,
// });
// return;
// }
// SCENARIO 5: No existing product, not free product
logger.info("SCENARIO 4: ADDING PRODUCT");
await handleAddProduct({
req,
res,
attachParams,
});
},
});
attachRouter.post("", handleAttach);
attachRouter.post("/preview", handleAttachPreview);
// attachRouter.post("", handleAttachOld);