459 lines
13 KiB
TypeScript
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);
|