From 3932a45f09e0ead9550c537b770d59e41096af74 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 25 Jun 2025 12:20:28 +0100 Subject: [PATCH] fix: reward program errors, default paid product expired --- localtunnel-start.sh | 9 +- pnpm-lock.yaml | 9 ++ server/package.json | 1 + server/shell/g2.sh | 2 +- server/src/external/resend/loopsUtils.ts | 29 +++++ .../createStripeArrearProrated.ts | 4 +- .../createStripePrice/createStripePrepaid.ts | 10 +- .../handleCusProductDeleted.ts | 24 +--- server/src/index.ts | 3 +- server/src/internal/admin/withAdminAuth.ts | 2 - .../api/components/componentRouter.ts | 49 ++++--- .../api/rewards/rewardProgramRouter.ts | 42 ++++++ .../addProductFlow/handleOneOffFunction.ts | 4 +- .../attachParams/convertToParams.ts | 12 +- .../customers/cusProducts/cusProductUtils.ts | 120 ++++++++++-------- .../customers/cusUtils/createNewCustomer.ts | 3 +- .../internal/customers/expire/expireRouter.ts | 34 ++--- .../handlers/handleCusProductExpired.ts | 70 +++------- .../internal/invoices/invoiceFormatUtils.ts | 9 +- server/src/internal/orgs/orgRouter.ts | 2 +- .../internal/products/pricecn/pricecnUtils.ts | 11 +- .../product-items/validateProductItems.ts | 25 ++++ server/src/utils/auth.ts | 2 + server/src/utils/models/Request.ts | 2 + .../advanced/multiFeature/multiFeature1.ts | 4 +- .../advanced/multiFeature/multiFeature2.ts | 7 +- server/tests/attach/basic/basic7.ts | 5 +- .../attach/updateQuantity/updateQuantity1.ts | 2 +- .../components/attach-preview/DueToday.tsx | 4 +- .../reward-programs/RewardProgramConfig.tsx | 2 +- .../views/products/rewards/DiscountConfig.tsx | 18 +-- 31 files changed, 306 insertions(+), 214 deletions(-) create mode 100644 server/src/external/resend/loopsUtils.ts diff --git a/localtunnel-start.sh b/localtunnel-start.sh index b9372bfd1..50ffa2497 100755 --- a/localtunnel-start.sh +++ b/localtunnel-start.sh @@ -1,19 +1,24 @@ #!/bin/sh +echo "STARTING LOCALTUNNEL SCRIPT" + # Read LOCALTUNNEL_RESERVED_KEY from .env file if [ -f "/app/server/.env" ]; then export $(cat /app/server/.env | grep LOCALTUNNEL_RESERVED_KEY) fi +echo "LOCALTUNNEL_RESERVED_KEY: ${LOCALTUNNEL_RESERVED_KEY}" + + # Set default subdomain if env var not found if [ -z "$LOCALTUNNEL_RESERVED_KEY" ]; then LOCALTUNNEL_RESERVED_KEY="autumn-dev" fi -echo "LOCALTUNNEL_RESERVED_KEY: ${LOCALTUNNEL_RESERVED_KEY}" echo "Installing localtunnel..." npm install -g localtunnel -echo "Starting localtunnel..." + +echo "Server is ready! Starting localtunnel..." lt --port 8080 --local-host server --subdomain ${LOCALTUNNEL_RESERVED_KEY} --print-requests \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec08668b3..b40f07147 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + loops: + specifier: ^5.0.1 + version: 5.0.1 mime-detect: specifier: ^1.3.0 version: 1.3.0 @@ -5333,6 +5336,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loops@5.0.1: + resolution: {integrity: sha512-xM1c9mnlr8Hr4cHW944TQoK6ApynjinUWOgYZd9/B0/3lwTThq24BQ7+XLjgbFAP5kJzqDTRDQi3t+Diy51Udw==} + engines: {node: '>=18'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -12352,6 +12359,8 @@ snapshots: long@5.3.2: {} + loops@5.0.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 diff --git a/server/package.json b/server/package.json index 243ae889e..cc0691392 100644 --- a/server/package.json +++ b/server/package.json @@ -80,6 +80,7 @@ "ioredis": "^5.5.0", "ksuid": "^3.0.0", "lodash-es": "^4.17.21", + "loops": "^5.0.1", "mime-detect": "^1.3.0", "nodemon": "^3.1.7", "openai": "^4.85.2", diff --git a/server/shell/g2.sh b/server/shell/g2.sh index cdf6bf9ec..37e98c511 100755 --- a/server/shell/g2.sh +++ b/server/shell/g2.sh @@ -9,6 +9,6 @@ MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \ 'tests/attach/migrations/*.ts' \ 'tests/attach/newVersion/*.ts' \ 'tests/attach/others/*.ts' \ -'tests/attach/updateEnts/*.ts' \ +'tests/attach/updateEnts/*.ts' diff --git a/server/src/external/resend/loopsUtils.ts b/server/src/external/resend/loopsUtils.ts new file mode 100644 index 000000000..52d0abf55 --- /dev/null +++ b/server/src/external/resend/loopsUtils.ts @@ -0,0 +1,29 @@ +import { LoopsClient } from "loops"; +import { logger } from "../logtail/logtailUtils.js"; +import { User } from "better-auth"; + +const createLoopsCli = () => { + return new LoopsClient(process.env.LOOPS_API_KEY || ""); +}; + +export const createLoopsContact = async (user: User) => { + if (!process.env.LOOPS_API_KEY) return; + + try { + let email = user.email; + let firstName = user.name?.split(" ")[0] || ""; + let lastName = user.name?.split(" ")[1] || ""; + const loops = createLoopsCli(); + + const resp = await loops.createContact(email, { + firstName, + lastName, + }); + + return resp; + } catch (error) { + logger.error("Error creating loops contact", { error }); + } +}; + +export { createLoopsCli }; diff --git a/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts b/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts index fda9cb082..1c33b65ef 100644 --- a/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts +++ b/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts @@ -17,6 +17,7 @@ import { billingIntervalToStripe } from "../stripePriceUtils.js"; import { priceToInArrearTiers } from "./createStripeInArrear.js"; import { PriceService } from "@/internal/products/prices/PriceService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { Decimal } from "decimal.js"; export interface StripeMeteredPriceParams { db: DrizzleCli; @@ -123,7 +124,8 @@ export const arrearProratedToStripeTiers = ( } for (let i = 0; i < usageConfig.usage_tiers.length; i++) { const tier = usageConfig.usage_tiers[i]; - const amount = tier.amount * 100; + // const amount = tier.amount * 100; + const amount = new Decimal(tier.amount).mul(100).toNumber(); const upTo = tier.to == -1 || tier.to == TierInfinite ? "inf" diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index e57d63c43..510e70184 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -33,16 +33,9 @@ export const prepaidToStripeTiers = ( const tiers: any[] = []; - // if (numFree > 0) { - // tiers.push({ - // unit_amount_decimal: 0, - // up_to: numFree, - // }); - // } - for (let i = 0; i < usageConfig.usage_tiers.length; i++) { const tier = usageConfig.usage_tiers[i]; - const amount = tier.amount * 100; + const amount = new Decimal(tier.amount).mul(100).toNumber(); const upTo = tier.to == -1 || tier.to == TierInfinite ? "inf" @@ -108,7 +101,6 @@ export const createStripePrepaid = async ({ stripePrice = await stripeCli.prices.create({ ...productData, - // unit_amount_decimal: (amount * 100).toString(), unit_amount_decimal: unitAmountDecimalStr, currency: org.default_currency!, }); diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts index cc44e97be..8145f9bf3 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts @@ -40,13 +40,8 @@ export const handleCusProductDeleted = async ({ prematurelyCanceled: boolean; }) => { const { org, env } = req; - // const customerId = cusProduct.customer!.id; - // const orgId = org.id; - // const lockKey = `attach_${customerId}_${orgId}_${env}`; - const { scheduled_ids } = cusProduct; - - const customer = await CusService.getFull({ + const fullCus = await CusService.getFull({ db, idOrInternalId: cusProduct.internal_customer_id, orgId: org.id, @@ -56,7 +51,7 @@ export const handleCusProductDeleted = async ({ const paymentMethod = await getCusPaymentMethod({ stripeCli, - stripeId: customer.processor?.id, + stripeId: fullCus.processor?.id, }); const isV4Usage = cusProduct.api_version === APIVersion.v1_4; @@ -68,7 +63,7 @@ export const handleCusProductDeleted = async ({ if (usagePrices.length > 0) { logger.info( - `sub.deleted, submitting usage for ${customer.id}, ${cusProduct.product.name}`, + `sub.deleted, submitting usage for ${fullCus.id}, ${cusProduct.product.name}`, ); await createUsageInvoice({ @@ -78,7 +73,7 @@ export const handleCusProductDeleted = async ({ stripeCli, paymentMethod, cusProduct, - fullCus: customer, + fullCus, }), cusProduct, stripeSubs: [subscription], @@ -138,12 +133,8 @@ export const handleCusProductDeleted = async ({ const activatedFuture = await activateFutureProduct({ req, - db, cusProduct, subscription, - org, - env, - logger, }); if (activatedFuture) { @@ -163,13 +154,10 @@ export const handleCusProductDeleted = async ({ }); await activateDefaultProduct({ - db, + req, productGroup: cusProduct.product.group, - customer: cusProduct.customer!, - org, - env, + fullCus, curCusProduct: curMainProduct || undefined, - logger, }); await cancelCusProductSubscriptions({ diff --git a/server/src/index.ts b/server/src/index.ts index 81310ce95..f66637c2d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,7 +25,6 @@ import { client, db } from "./db/initDrizzle.js"; import { toNodeHandler } from "better-auth/node"; import { auth } from "./utils/auth.js"; import { checkEnvVars } from "./utils/initUtils.js"; -import { initLogger } from "./errors/logger.js"; const tracer = trace.getTracer("express"); @@ -93,7 +92,6 @@ const init = async () => { env: req.headers["app_env"] || undefined, method: req.method, url: req.originalUrl, - body: req.body, timestamp: req.timestamp, }; @@ -114,6 +112,7 @@ const init = async () => { req: reqContext, }, }); + req.logger = req.logtail; const endSpan = () => { try { diff --git a/server/src/internal/admin/withAdminAuth.ts b/server/src/internal/admin/withAdminAuth.ts index 7430180fa..b10935137 100644 --- a/server/src/internal/admin/withAdminAuth.ts +++ b/server/src/internal/admin/withAdminAuth.ts @@ -24,8 +24,6 @@ export const withAdminAuth = async (req: any, res: any, next: NextFunction) => { }); } - console.log("Admin auth passed"); - next(); } catch (error: any) { logger.error(`Admin req failed: ${error.message}`); diff --git a/server/src/internal/api/components/componentRouter.ts b/server/src/internal/api/components/componentRouter.ts index f53e72f7c..c4f9b6cac 100644 --- a/server/src/internal/api/components/componentRouter.ts +++ b/server/src/internal/api/components/componentRouter.ts @@ -39,6 +39,11 @@ componentRouter.get("/pricing_table", async (req: any, res) => })(), ]); + // Sort by add ons + products.sort((a, b) => { + return a.is_add_on ? 1 : -1; + }); + // 1. Sort products by price products.sort((a, b) => { let isUpgradeA = isProductUpgrade({ @@ -69,29 +74,31 @@ componentRouter.get("/pricing_table", async (req: any, res) => } let pricecnProds = await Promise.all( - products.map(async (p) => { - let prod = getProductResponse({ product: p, features }); - let curMainProduct, curScheduledProduct; + products + // .filter((p) => !p.is_add_on) + .map(async (p) => { + let prod = getProductResponse({ product: p, features }); + let curMainProduct, curScheduledProduct; - if (cusProducts) { - let res = getExistingCusProducts({ - product: p, - cusProducts: cusProducts, + if (cusProducts) { + let res = getExistingCusProducts({ + product: p, + cusProducts: cusProducts, + }); + + curMainProduct = res.curMainProduct; + curScheduledProduct = res.curScheduledProduct; + } + + return toPricecnProduct({ + org, + product: prod as ProductV2, + fullProduct: p, + features, + curMainProduct, + curScheduledProduct, }); - - curMainProduct = res.curMainProduct; - curScheduledProduct = res.curScheduledProduct; - } - - return toPricecnProduct({ - org, - product: prod as ProductV2, - fullProduct: p, - features, - curMainProduct, - curScheduledProduct, - }); - }), + }), ); res.status(200).json({ diff --git a/server/src/internal/api/rewards/rewardProgramRouter.ts b/server/src/internal/api/rewards/rewardProgramRouter.ts index 036d2bf5e..70db20ddc 100644 --- a/server/src/internal/api/rewards/rewardProgramRouter.ts +++ b/server/src/internal/api/rewards/rewardProgramRouter.ts @@ -1,4 +1,5 @@ import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; +import { RewardService } from "@/internal/rewards/RewardService.js"; import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { nullish } from "@/utils/genUtils.js"; @@ -19,12 +20,53 @@ rewardProgramRouter.post("", (req, res) => action: "create reward trigger", handler: async (req: any, res: any) => { const { orgId, env, db } = req; + const body = req.body; + + if (!body.internal_reward_id) { + throw new RecaseError({ + message: "Please select a reward to link this program to", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if (!body.id) { + throw new RecaseError({ + message: "Please give this program an ID", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + let existingProgram = await RewardProgramService.get({ + db, + id: body.id, + orgId, + env, + }); + + if (existingProgram) { + throw new RecaseError({ + message: `Program with ID ${body.id} already exists`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + const rewardProgram = constructRewardProgram({ rewardProgramData: CreateRewardProgram.parse(req.body), orgId, env, }); + // Fetch reward ID + // let reward = await RewardService.get({ + // db, + // id: rewardProgram.internal_reward_id, + // orgId, + // env, + // }); + if ( rewardProgram.when == RewardTriggerEvent.Checkout && (nullish(rewardProgram.product_ids) || diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index df23ab6f1..fa23436f8 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -36,6 +36,7 @@ export const handleOneOffFunction = async ({ customer, products, prices, + entitlements, optionsList, reward, } = attachParams; @@ -69,6 +70,7 @@ export const handleOneOffFunction = async ({ org, price, product: product!, + ents: entitlements, quantity: options?.quantity, withProductPrefix: true, }); @@ -76,7 +78,7 @@ export const handleOneOffFunction = async ({ invoiceItems.push({ description, price_data: { - unit_amount: amount * 100, + unit_amount: new Decimal(amount).mul(100).round().toNumber(), currency: org.default_currency, product: price.config?.stripe_product_id || product?.processor?.id!, }, diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts index d14994780..7b1ea7dc2 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts @@ -92,16 +92,24 @@ export const newCusToAttachParams = ({ stripeCli, }: { req: ExtendedRequest; - newCus: Customer; + newCus: FullCustomer; products: FullProduct[]; stripeCli: Stripe; }) => { + if (!newCus.customer_products) { + newCus.customer_products = []; + } + + if (!newCus.entities) { + newCus.entities = []; + } + const attachParams: AttachParams = { stripeCli, paymentMethod: null, req, org: req.org, - customer: newCusToFullCus({ newCus }), + customer: newCus, products, prices: products.flatMap((p) => p.prices), entitlements: products.flatMap((p) => p.entitlements), diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index 83bb5d710..d940e137f 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -8,6 +8,7 @@ import { Entity, FixedPriceConfig, FullCusProduct, + FullCustomer, Organization, PriceType, Subscription, @@ -35,6 +36,11 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han import { DrizzleCli } from "@/db/initDrizzle.js"; import { getExistingCusProducts } from "./cusProductUtils/getExistingCusProducts.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; +import { cusProductToPrices } from "./cusProductUtils/convertCusProduct.js"; +import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; +import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js"; +import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; +import { newCusToAttachParams } from "../attach/attachUtils/attachParams/convertToParams.js"; export const isActiveStatus = (status: CusProductStatus) => { return ( @@ -116,22 +122,17 @@ export const cancelCusProductSubscriptions = async ({ }; export const activateDefaultProduct = async ({ - db, + req, productGroup, - customer, - org, - env, + fullCus, curCusProduct, - logger, }: { - db: DrizzleCli; + req: ExtendedRequest; productGroup: string; - customer: Customer; - org: Organization; - env: AppEnv; + fullCus: FullCustomer; curCusProduct?: FullCusProduct; - logger: any; }) => { + const { db, org, env, logger } = req; // 1. Expire current product const defaultProducts = await ProductService.listDefault({ db, @@ -145,49 +146,67 @@ export const activateDefaultProduct = async ({ return false; } - if ( - curCusProduct && - curCusProduct.internal_product_id == defaultProd.internal_id - ) { - // console.log(" ❌ default product is already active"); + if (curCusProduct?.internal_product_id == defaultProd.internal_id) { return false; } - await createFullCusProduct({ - db, - attachParams: { + const stripeCli = createStripeCli({ org, env }); + + let defaultIsFree = isFreeProduct(defaultProd.prices); + + if (!defaultIsFree) { + await initStripeCusAndProducts({ + db, org, - customer, - product: defaultProd, - prices: defaultProd.prices, - entitlements: defaultProd.entitlements, - freeTrial: defaultProd.free_trial || null, - optionsList: [], - entities: [], - features: [], - replaceables: [], - }, - scenario: AttachScenario.New, - logger, + env, + customer: fullCus, + products: [defaultProd], + logger, + }); + } + + await handleAddProduct({ + req, + attachParams: newCusToAttachParams({ + req, + newCus: fullCus, + products: [defaultProd], + stripeCli, + }), }); + // await createFullCusProduct({ + // db, + // attachParams: { + // org, + // customer, + // product: defaultProd, + // prices: defaultProd.prices, + // entitlements: defaultProd.entitlements, + // freeTrial: defaultProd.free_trial || null, + // optionsList: [], + // entities: [], + // features: [], + // replaceables: [], + // }, + // scenario: AttachScenario.New, + // logger, + // }); + // console.log(` ✅ activated default product: ${defaultProd.group}`); return true; }; export const expireAndActivate = async ({ - db, - env, + req, cusProduct, - org, - logger, + fullCus, }: { - db: DrizzleCli; - env: AppEnv; + req: ExtendedRequest; cusProduct: FullCusProduct; - org: Organization; - logger: any; + fullCus: FullCustomer; }) => { + const { db, org, env, logger } = req; // 1. Expire current product await CusProductService.update({ db, @@ -195,33 +214,32 @@ export const expireAndActivate = async ({ updates: { status: CusProductStatus.Expired, ended_at: Date.now() }, }); + // Check if it's one time product + let prices = cusProductToPrices({ cusProduct }); + let product = cusProduct.product; + const isOneOffOrAddOn = product.is_add_on || isOneOff(prices); + + if (isOneOffOrAddOn) { + return; + } + await activateDefaultProduct({ - db, + req, productGroup: cusProduct.product.group, - customer: cusProduct.customer!, - org, - env, - logger, + fullCus, }); }; export const activateFutureProduct = async ({ req, - db, cusProduct, subscription, - org, - env, - logger = console, }: { req: ExtendedRequest; - db: DrizzleCli; cusProduct: FullCusProduct; subscription: Stripe.Subscription; - org: Organization; - env: AppEnv; - logger: any; }) => { + const { db, org, env, logger } = req; const stripeCli = createStripeCli({ org, env, diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index a9273d840..46a83e5c1 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -13,6 +13,7 @@ import { ErrCode, BillingInterval, AttachScenario, + FullCustomer, } from "@autumn/shared"; import { AppEnv, Customer } from "@autumn/shared"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; @@ -132,7 +133,7 @@ export const createNewCustomer = async ({ req, attachParams: newCusToAttachParams({ req, - newCus: newCustomer, + newCus: newCustomer as FullCustomer, products: nonFreeProds, stripeCli, }), diff --git a/server/src/internal/customers/expire/expireRouter.ts b/server/src/internal/customers/expire/expireRouter.ts index 7cf490117..22342eee6 100644 --- a/server/src/internal/customers/expire/expireRouter.ts +++ b/server/src/internal/customers/expire/expireRouter.ts @@ -20,28 +20,25 @@ expireRouter.post("", async (req, res) => let expireImmediately = cancel_immediately || false; let prorate = true; - let [customer, org] = await Promise.all([ - CusService.getFull({ - db, - orgId, - idOrInternalId: customer_id, - env, - withEntities: true, - entityId: entity_id, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - allowNotFound: false, - }), - OrgService.getFromReq(req), - ]); + let fullCus = await CusService.getFull({ + db, + orgId, + idOrInternalId: customer_id, + env, + withEntities: true, + entityId: entity_id, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + allowNotFound: false, + }); - if (entity_id && !customer.entity) { + if (entity_id && !fullCus.entity) { throw new RecaseError({ code: ErrCode.EntityNotFound, message: `Entity ${entity_id} not found for customer ${customer_id}`, }); } - let cusProducts = customer.customer_products; + let cusProducts = fullCus.customer_products; let cusProductsToExpire = cusProducts.filter( (cusProduct: FullCusProduct) => @@ -59,13 +56,8 @@ expireRouter.post("", async (req, res) => for (const cusProduct of cusProductsToExpire) { await expireCusProduct({ req, - db, cusProduct, - cusProducts, - org, - env, - logger, - customer, + fullCus, expireImmediately, prorate, }); diff --git a/server/src/internal/customers/handlers/handleCusProductExpired.ts b/server/src/internal/customers/handlers/handleCusProductExpired.ts index 936064a70..ea0abba5f 100644 --- a/server/src/internal/customers/handlers/handleCusProductExpired.ts +++ b/server/src/internal/customers/handlers/handleCusProductExpired.ts @@ -19,8 +19,10 @@ import { Organization, AppEnv, Customer, + FullCustomer, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; +import { CusService } from "../CusService.js"; export const removeScheduledProduct = async ({ req, @@ -70,34 +72,25 @@ export const removeScheduledProduct = async ({ export const expireCusProduct = async ({ req, - db, cusProduct, // cus product to expire - cusProducts, // other cus products - org, - env, - logger, - customer, + fullCus, expireImmediately = true, prorate, }: { req: ExtendedRequest; - db: DrizzleCli; cusProduct: FullCusProduct; - cusProducts: FullCusProduct[]; - org: Organization; - env: AppEnv; - logger: any; - customer: Customer; + fullCus: FullCustomer; expireImmediately: boolean; prorate: boolean; }) => { + const { db, org, env, logger } = req; logger.info("--------------------------------"); logger.info( `🔔 Expiring cutomer product (${ expireImmediately ? "immediately" : "end of cycle" })`, ); - logger.info(`Customer: ${customer.id} (${env}), Org: ${org.id}`); + logger.info(`Customer: ${fullCus.id} (${env}), Org: ${org.id}`); logger.info( `Product: ${cusProduct.product.name}, Status: ${cusProduct.status}`, ); @@ -109,7 +102,7 @@ export const expireCusProduct = async ({ req, db, cusProduct, - cusProducts, + cusProducts: fullCus.customer_products, org, env, logger, @@ -120,14 +113,9 @@ export const expireCusProduct = async ({ // 1. If main product, can't expire if there's scheduled product let isMain = !cusProduct.product.is_add_on; if (isMain) { - let cusProducts = await CusProductService.list({ - db, - internalCustomerId: customer.internal_id, - }); - let { curScheduledProduct: futureProduct } = getExistingCusProducts({ product: cusProduct.product, - cusProducts, + cusProducts: fullCus.customer_products, internalEntityId: cusProduct.internal_entity_id, }); @@ -181,16 +169,11 @@ export const expireCusProduct = async ({ return; } - // For regular products - // 1. Cancel stripe subscriptions - logger.info(`Expiring current product: ${cusProduct.product.name}`); await expireAndActivate({ - db, - env, + req, cusProduct, - org, - logger, + fullCus, }); logger.info(`Cancelling stripe subscriptions`); @@ -202,26 +185,14 @@ export const expireCusProduct = async ({ prorate, }); - // if (!cancelled) { - // await expireAndActivate({ - // db, - // env, - // cusProduct, - // org, - // logger, - // }); - // } // else will be handled by webhook - return; }; export const handleCusProductExpired = async (req: any, res: any) => { try { - const { db, logtail: logger } = req; + const { db } = req; - const org = await OrgService.getFromReq(req); const customerProductId = req.params.customer_product_id; - let cusProduct = await CusProductService.get({ db, id: customerProductId, @@ -238,26 +209,17 @@ export const handleCusProductExpired = async (req: any, res: any) => { }); } - const cusProducts = await CusProductService.list({ + const fullCus = await CusService.getFull({ db, - internalCustomerId: cusProduct.customer!.internal_id, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Scheduled, - ], - withCustomer: true, + idOrInternalId: cusProduct.customer!.id!, + orgId: req.orgId, + env: req.env, }); await expireCusProduct({ req, - db, cusProduct, - cusProducts, - org, - env: req.env, - logger: req.logtail, - customer: cusProduct.customer!, + fullCus, expireImmediately: true, prorate: true, }); diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 3f7003dab..ca2ad6972 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -180,17 +180,21 @@ export const newPriceToInvoiceDescription = ({ product, quantity, withProductPrefix = true, + ents, }: { org: Organization; price: Price; product: FullProduct; quantity?: number; withProductPrefix?: boolean; + ents?: EntitlementWithFeature[]; }) => { - const ents = product.entitlements; - const billingType = getBillingType(price.config); + if (!ents) { + ents = product.entitlements; + } + let description = ""; if ( billingType == BillingType.FixedCycle || @@ -209,7 +213,6 @@ export const newPriceToInvoiceDescription = ({ } if (billingType == BillingType.UsageInAdvance) { - const ent = getPriceEntitlement(price, ents); description = formatPrepaidPrice({ price, ents, quantity: quantity! }); } diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 60833370e..329fc6bea 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -5,7 +5,7 @@ import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { encryptData } from "@/utils/encryptUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; -import { createClerkCli } from "@/external/clerkUtils.js"; + import { checkKeyValid, createWebhookEndpoint, diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index 4a5a761e4..9dc1d6f43 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -361,7 +361,16 @@ export const toPricecnProduct = ({ return { id: product.id, name: product.name, - price: price, + is_add_on: product.is_add_on, + price: price + ? { + primary_text: price.primaryText, + secondary_text: price.secondaryText, + + // To deprecate + ...price, + } + : null, items: pricecnItems, scenario, button_text: buttonText, diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index af1cb5a45..b15f787d9 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -21,6 +21,7 @@ import { } from "./productItemUtils/getItemType.js"; import { itemToEntInterval } from "./itemIntervalUtils.js"; import { createFeaturesFromItems } from "./createFeaturesFromItems.js"; +import { Decimal } from "decimal.js"; const validateProductItem = ({ item, @@ -89,6 +90,30 @@ const validateProductItem = ({ } } + // 4. One off prices / fixed prices can have at most 2 decimal places + if ((isFeaturePriceItem(item) && !item.interval) || isPriceItem(item)) { + // One off price..., can't have more than 2 DP + if (item.price && item.price.toString().split(".")[1]?.length > 2) { + throw new RecaseError({ + message: `One off prices can have at most 2 decimal places`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if (item.tiers) { + item.tiers.forEach((tier) => { + if (tier.amount.toString().split(".")[1]?.length > 2) { + throw new RecaseError({ + message: `One off prices can have at most 2 decimal places`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + }); + } + } + // 4. If it's a feature item, it should have included usage as number or inf if (isFeaturePriceItem(item) || isFeatureItem(item)) { if ( diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 80f99e382..bf3aefc85 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -10,6 +10,7 @@ import sendOTPEmail from "@/internal/emails/sendOTPEmail.js"; import { sendOnboardingEmail } from "@/internal/emails/sendOnboardingEmail.js"; import { ADMIN_USER_IDs } from "./constants.js"; import { afterOrgCreated } from "./authUtils/afterOrgCreated.js"; +import { createLoopsContact } from "@/external/resend/loopsUtils.js"; export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -20,6 +21,7 @@ export const auth = betterAuth({ user: { create: { after: async (user) => { + await createLoopsContact(user); await sendOnboardingEmail({ name: user.name, email: user.email, diff --git a/server/src/utils/models/Request.ts b/server/src/utils/models/Request.ts index 316591137..a82a682d0 100644 --- a/server/src/utils/models/Request.ts +++ b/server/src/utils/models/Request.ts @@ -7,6 +7,7 @@ import type { import { DrizzleCli } from "@/db/initDrizzle.js"; import { PostHog } from "posthog-node"; +import { Logger } from "pino"; export interface ExtendedRequest extends ExpressRequest { orgId: string; @@ -15,6 +16,7 @@ export interface ExtendedRequest extends ExpressRequest { features: Feature[]; db: DrizzleCli; logtail: Logtail; + logger: Logger; id?: string; userId?: string; diff --git a/server/tests/advanced/multiFeature/multiFeature1.ts b/server/tests/advanced/multiFeature/multiFeature1.ts index b6c80921e..27b0427b2 100644 --- a/server/tests/advanced/multiFeature/multiFeature1.ts +++ b/server/tests/advanced/multiFeature/multiFeature1.ts @@ -4,6 +4,7 @@ import { features } from "tests/global.js"; import { setupBefore } from "tests/before.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { + APIVersion, AppEnv, BillingInterval, ProductItemFeatureType, @@ -106,6 +107,7 @@ describe(`${chalk.yellowBright( "multiFeature1: Testing prepaid + pay per use -> prepaid + pay per use", )}`, () => { let autumn: AutumnInt = new AutumnInt(); + let autumn2: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); let customerId = testCase; let prepaidQuantity = 10; @@ -228,7 +230,7 @@ describe(`${chalk.yellowBright( }); // Check invoice too - let { invoices } = await autumn.customers.get(customerId); + let { invoices } = await autumn2.customers.get(customerId); let invoice1Amount = (premium.items.prepaid.price ?? 0) * prepaidQuantity - diff --git a/server/tests/advanced/multiFeature/multiFeature2.ts b/server/tests/advanced/multiFeature/multiFeature2.ts index 66b67eecb..60f9ad4fe 100644 --- a/server/tests/advanced/multiFeature/multiFeature2.ts +++ b/server/tests/advanced/multiFeature/multiFeature2.ts @@ -4,6 +4,7 @@ import { features } from "tests/global.js"; import { setupBefore } from "tests/before.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { + APIVersion, AppEnv, BillingInterval, EntInterval, @@ -101,6 +102,7 @@ describe(`${chalk.yellowBright( "multiFeature2: Testing lifetime + pay per use -> pay per use", )}`, () => { let autumn: AutumnInt = new AutumnInt(); + let autumn2: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); let customerId = testCase; let totalUsage = 0; @@ -191,7 +193,7 @@ describe(`${chalk.yellowBright( feature_id: features.metered1.id, }); - await timeout(5000); + await timeout(4000); await autumn.attach({ customer_id: customerId, @@ -213,8 +215,9 @@ describe(`${chalk.yellowBright( ); // Check invoice too - let res = await autumn.customers.get(customerId); + let res = await autumn2.customers.get(customerId); let invoices = res.invoices; + let invoice0Amount = value * (pro.items.payPerUse.price ?? 0); expect(invoices![0].total).to.equal( invoice0Amount, diff --git a/server/tests/attach/basic/basic7.ts b/server/tests/attach/basic/basic7.ts index 85849ee75..98d40cef0 100644 --- a/server/tests/attach/basic/basic7.ts +++ b/server/tests/attach/basic/basic7.ts @@ -51,7 +51,7 @@ describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer) await autumn.cancel({ customer_id: customerId, product_id: products.proWithTrial.id, - expire_immediately: true, + cancel_immediately: true, }); await timeout(5000); // for webhook to be processed }); @@ -67,11 +67,10 @@ describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer) compareMainProduct({ sent: products.proWithTrial, cusRes: customer, - status: CusProductStatus.Trialing, }); const invoices = customer.invoices; - expect(invoices.length).to.equal(1, "Invoice length should be 1"); + expect(invoices.length).to.equal(2, "Invoice length should be 1"); expect(invoices[0].amount).to.equal( products.proWithTrial.prices[0].amount, "should have paid full amount (trial already used once)", diff --git a/server/tests/attach/updateQuantity/updateQuantity1.ts b/server/tests/attach/updateQuantity/updateQuantity1.ts index 3dc744937..77cf81d90 100644 --- a/server/tests/attach/updateQuantity/updateQuantity1.ts +++ b/server/tests/attach/updateQuantity/updateQuantity1.ts @@ -150,7 +150,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl stripeCli, testClockId, advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 40, + waitForSeconds: 30, }); await runAttachTest({ diff --git a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx index 8864eb460..040530a47 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/DueToday.tsx @@ -7,6 +7,7 @@ import { formatAmount } from "@/utils/product/productItemUtils"; import { AttachBranch } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { Input } from "@/components/ui/input"; +import { notNullish } from "@/utils/genUtils"; export const DueToday = () => { const { attachState, product, org } = useProductContext(); @@ -83,11 +84,12 @@ export const DueToday = () => {
) => { const newOptions = [...options]; newOptions[index].quantity = parseInt(e.target.value) * billing_units; + setOptions(newOptions); }} className="w-12 h-7" diff --git a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/reward-programs/RewardProgramConfig.tsx index cc1efec5c..a2ef1a4ac 100644 --- a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/reward-programs/RewardProgramConfig.tsx @@ -196,7 +196,7 @@ const ProductSelector = ({ variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50" + className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 data-[state=open]:border-focus data-[state=open]:shadow-focus" > {rewardProgram.product_ids?.length === 0 ? ( "Select Products" diff --git a/vite/src/views/products/rewards/DiscountConfig.tsx b/vite/src/views/products/rewards/DiscountConfig.tsx index d3399aca7..4d305b252 100644 --- a/vite/src/views/products/rewards/DiscountConfig.tsx +++ b/vite/src/views/products/rewards/DiscountConfig.tsx @@ -167,16 +167,6 @@ const ProductPriceSelector = ({ return

No products available

; } - const getPriceText = (priceId: string) => { - let product: any = null; - product = products.find((p: any) => - p.prices?.find((p: any) => p.id === priceId), - ); - const price = product?.prices?.find((p: any) => p.id === priceId); - - return `${product?.name} - ${price?.name}`; - }; - return ( @@ -195,9 +185,9 @@ const ProductPriceSelector = ({ {config.price_ids?.map((priceId) => (
-

+

{formatProductItemText({ item: products .find((p: any) => @@ -255,8 +245,8 @@ const ProductPriceSelector = ({ }) .map((item: any) => ( handlePriceToggle(item.price_id)} className="cursor-pointer overflow-x-hidden max-w-[380px]" >