diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..b98de2f44 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "Autumn dev container", + "image": "mcr.microsoft.com/devcontainers/node:18", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "forwardPorts": [8080], + // "postCreateCommand": "pnpm install", + "postCreateCommand": "apt update && apt install -y zsh", + "settings": { + "terminal.integrated.shell.linux": "/bin/zsh" + }, + "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/commands.sh b/commands.sh index 23a7329de..db2950d37 100644 --- a/commands.sh +++ b/commands.sh @@ -7,10 +7,10 @@ docker compose -f docker-compose.db.yml up --build # Create DB tables pnpm -# docker volume rm main-repo_shared-node-modules main-repo_root-node-modules main-repo_vite-node-modules +# docker volume rm autumn-oss_shared-node-modules autumn-oss_root-node-modules autumn-oss_vite-node-modules # Dev docker compose -f docker-compose.dev.yml down -docker volume rm autumn-oss_shared-node-modules autumn-oss_root-node-modules autumn-oss_vite-node-modules +docker volume rm main-repo_shared-node-modules main-repo_root-node-modules main-repo_vite-node-modules docker compose -f docker-compose.dev.yml build --no-cache docker compose -f docker-compose.dev.yml up --build diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d49e5c55..d5e31e416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,8 @@ importers: specifier: ^4.3.10 version: 4.3.16(react@18.3.1)(zod@3.25.64) autumn-js: - specifier: ^0.0.64 - version: 0.0.64(@tanstack/react-query@5.80.7(react@18.3.1))(react@18.3.1) + specifier: ^0.0.65 + version: 0.0.65(@tanstack/react-query@5.80.7(react@18.3.1))(react@18.3.1) axios: specifier: ^1.8.3 version: 1.10.0 @@ -260,6 +260,9 @@ importers: specifier: ^3.25.23 version: 3.25.64 devDependencies: + '@types/node': + specifier: ^24.0.3 + version: 24.0.3 nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -2685,8 +2688,8 @@ packages: react: optional: true - autumn-js@0.0.64: - resolution: {integrity: sha512-Fa5lr9A0ywYNcbny/dQBRKSGaqnTuUvqtiBegHrr5Z3wCw9A/Z2LRH/f8AqHYQSFOXVkS763ngLlGLxfJpYgQQ==} + autumn-js@0.0.65: + resolution: {integrity: sha512-YKR5kNDdpYvxGDNEmgqtqrDODyJG3frApR2HC9z05hazg94rEDSoatfUh7iweJUAGSauhqhjWADtgMvRRbSLfA==} peerDependencies: '@tanstack/react-query': ^5.76.1 react: ^18.0.0 || ^19.0.0 @@ -7829,7 +7832,7 @@ snapshots: optionalDependencies: react: 18.3.1 - autumn-js@0.0.64(@tanstack/react-query@5.80.7(react@18.3.1))(react@18.3.1): + autumn-js@0.0.65(@tanstack/react-query@5.80.7(react@18.3.1))(react@18.3.1): dependencies: '@tanstack/react-query': 5.80.7(react@18.3.1) rou3: 0.6.3 diff --git a/run.sh b/run.sh index 1325c82d2..a0e4f16db 100755 --- a/run.sh +++ b/run.sh @@ -2,6 +2,10 @@ if [[ $1 == *"docker-compose"* ]]; then if [[ $2 == "down" ]]; then docker compose -f "$1" down $3 else - docker compose -f "$1" up $3 + if [[ $3 == *"--build"* ]]; then + docker compose -f "$1" up --build + else + docker compose -f "$1" up $3 + fi fi fi diff --git a/server/package.json b/server/package.json index 648a45c92..82b02b722 100644 --- a/server/package.json +++ b/server/package.json @@ -47,7 +47,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "ai": "^4.3.10", - "autumn-js": "^0.0.64", + "autumn-js": "^0.0.65", "axios": "^1.8.3", "better-auth": "^1.2.9", "body-parser": "^1.20.3", diff --git a/server/src/external/logtail/logtailUtils.ts b/server/src/external/logtail/logtailUtils.ts index aa2f2c727..f599d3973 100644 --- a/server/src/external/logtail/logtailUtils.ts +++ b/server/src/external/logtail/logtailUtils.ts @@ -7,16 +7,33 @@ import { Logtail } from "@logtail/node"; const pinoLogger = initLogger(); const createLogMethod = (pinoMethod: any, logtailMethod: any) => { + function rewriteAppPath(str: string) { + if (typeof str !== "string") return str; + return str.replace(/\/app\//g, "./"); + } + + function rewriteErrorStack(error: Error) { + if (error instanceof Error && typeof error.stack === "string") { + const newError = new Error(error.message); + newError.stack = rewriteAppPath(error.stack); + return newError; + } + + return error; + } + return (...args: any[]) => { let message = ""; let mergedObj = {}; // Helper function to convert Error objects to plain objects - const strings = args.filter((arg) => typeof arg === "string"); - const objects = args.filter( - (arg) => typeof arg !== "string" && arg !== null, - ); + const strings = args + .filter((arg) => typeof arg === "string") + .map(rewriteAppPath); + const objects = args + .filter((arg) => typeof arg !== "string" && arg !== null) + .map((obj) => (obj instanceof Error ? rewriteErrorStack(obj) : obj)); // Use last string as message, or use Error message if no strings provided if (strings.length > 0) { @@ -25,7 +42,9 @@ const createLogMethod = (pinoMethod: any, logtailMethod: any) => { // If no string message but we have an Error object, use its stack trace const errorObject = args.find((arg) => arg instanceof Error); if (errorObject) { - message = errorObject.stack || errorObject.message || "Error occurred"; + message = rewriteAppPath( + errorObject.stack || errorObject.message || "Error occurred", + ); } } diff --git a/server/src/external/stripe/stripeCouponUtils/stripeCouponUtils.ts b/server/src/external/stripe/stripeCouponUtils/stripeCouponUtils.ts index bf69d511b..63d9c9792 100644 --- a/server/src/external/stripe/stripeCouponUtils/stripeCouponUtils.ts +++ b/server/src/external/stripe/stripeCouponUtils/stripeCouponUtils.ts @@ -75,6 +75,10 @@ export const createStripeCoupon = async ({ }) => { let discountConfig = reward.discount_config; + try { + await stripeCli.coupons.del(reward.id); + } catch (error) {} + let stripeProdIds = prices.map((price) => { if (price.config!.type === PriceType.Fixed) { return price.product.processor?.id; diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index f52ced15a..daf8d02a0 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -41,7 +41,13 @@ export const createStripeCusIfNotExists = async ({ createNew = true; } else { try { - await stripeCli.customers.retrieve(customer.processor.id); + let stripeCus = await stripeCli.customers.retrieve( + customer.processor.id, + { + expand: ["test_clock", "invoice_settings.default_payment_method"], + }, + ); + return stripeCus as Stripe.Customer; } catch (error) { createNew = true; } @@ -70,9 +76,9 @@ export const createStripeCusIfNotExists = async ({ id: stripeCustomer.id, type: ProcessorType.Stripe, }; - } - return; + return stripeCustomer; + } }; export const createStripeCustomer = async ({ diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index 04ea14216..d0ec0b481 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -6,6 +6,7 @@ import { Organization, ErrCode, BillingInterval, + Reward, } from "@autumn/shared"; import Stripe from "stripe"; import { getCusPaymentMethod } from "../stripeCusUtils.js"; @@ -27,6 +28,7 @@ export const createStripeSub = async ({ anchorToUnix, itemSet, now, + reward, }: { db: DrizzleCli; stripeCli: Stripe; @@ -37,6 +39,7 @@ export const createStripeSub = async ({ anchorToUnix?: number; itemSet: ItemSet; now?: number; + reward?: Reward; }) => { let paymentMethod = await getCusPaymentMethod({ stripeCli, @@ -82,6 +85,8 @@ export const createStripeSub = async ({ billing_cycle_anchor: billingCycleAnchorUnix ? Math.floor(billingCycleAnchorUnix / 1000) : undefined, + + coupon: reward ? reward.id : undefined, }); // Store diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaidDiscount.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaidDiscount.ts index d86d0452b..455e3c4c8 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaidDiscount.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaidDiscount.ts @@ -109,13 +109,16 @@ export const handleInvoicePaidDiscount = async ({ `Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`, ); - await deleteCouponFromCus({ - stripeCli, - stripeSubId: expandedInvoice.subscription as string, - stripeCusId: expandedInvoice.customer as string, - discountId: discount.id, - logger, - }); + if (expandedInvoice.subscription) { + await deleteCouponFromCus({ + stripeCli, + stripeSubId: expandedInvoice.subscription as string, + stripeCusId: expandedInvoice.customer as string, + discountId: discount.id, + logger, + }); + } + continue; } diff --git a/server/src/index.ts b/server/src/index.ts index 569c8d88c..2575bb044 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -37,6 +37,7 @@ const init = async () => { "http://localhost:3000", "https://app.useautumn.com", "https://*.useautumn.com", + "https://localhost:8080", process.env.CLIENT_URL || "", ], credentials: true, diff --git a/server/src/internal/api/entitled/entitledRouter.ts b/server/src/internal/api/entitled/entitledRouter.ts index d613181fc..7a1527232 100644 --- a/server/src/internal/api/entitled/entitledRouter.ts +++ b/server/src/internal/api/entitled/entitledRouter.ts @@ -290,6 +290,8 @@ entitledRouter.post("", async (req: any, res: any) => { entity_id, } = req.body; + console.log("BODY:", req.body); + const { logtail: logger, db } = req; if (!customer_id) { diff --git a/server/src/internal/api/rewards/rewardRouter.ts b/server/src/internal/api/rewards/rewardRouter.ts index 15adf7711..630aec747 100644 --- a/server/src/internal/api/rewards/rewardRouter.ts +++ b/server/src/internal/api/rewards/rewardRouter.ts @@ -1,10 +1,6 @@ -import express, { Router } from "express"; -import { - CreateRewardSchema, - ErrCode, - RewardCategory, - RewardType, -} from "@autumn/shared"; +import express from "express"; +import { CreateRewardSchema, ErrCode, RewardCategory } from "@autumn/shared"; +import { Router } from "express"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index e4b10669b..df23ab6f1 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -37,6 +37,7 @@ export const handleOneOffFunction = async ({ products, prices, optionsList, + reward, } = attachParams; const { invoiceOnly } = config; @@ -89,6 +90,13 @@ export const handleOneOffFunction = async ({ customer: customer.processor.id!, auto_advance: false, currency: org.default_currency!, + discounts: reward + ? [ + { + coupon: reward.id, + }, + ] + : undefined, }); logger.info("2. Creating invoice items"); diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 4bc08cc28..2692268e2 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -44,6 +44,7 @@ export const handlePaidProduct = async ({ invoiceOnly, cusProducts, stripeCli, + reward, } = attachParams; if (attachParams.disableFreeTrial) { @@ -56,7 +57,6 @@ export const handlePaidProduct = async ({ }); let subscriptions: Stripe.Subscription[] = []; - let invoiceIds: string[] = []; // Only merge if no free trials let mergeCusProduct = undefined; @@ -71,7 +71,8 @@ export const handlePaidProduct = async ({ subIds: mergeCusProduct?.subscription_ids, }); - for (const itemSet of itemSets) { + for (let i = 0; i < itemSets.length; i++) { + const itemSet = itemSets[i]; if (itemSet.interval === BillingInterval.OneOff) { continue; } @@ -104,6 +105,7 @@ export const handlePaidProduct = async ({ invoiceOnly, itemSet, anchorToUnix: billingCycleAnchorUnix, + reward: i == 0 ? reward : undefined, }); let sub = subscription as Stripe.Subscription; diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index 72f98e562..911a83328 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -144,14 +144,6 @@ export const checkStripeConnections = async ({ 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`; @@ -165,13 +157,13 @@ export const checkStripeConnections = async ({ } const batchProductUpdates = [ - createStripeCusIfNotExists({ - db: req.db, - org, - env, - customer, - logger, - }), + // createStripeCusIfNotExists({ + // db: req.db, + // org, + // env, + // customer, + // logger, + // }), ]; for (const product of products) { batchProductUpdates.push( diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts new file mode 100644 index 000000000..78c5fbc98 --- /dev/null +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getCusAndProducts.ts @@ -0,0 +1,80 @@ +import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { isOneOff } from "@/internal/products/productUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { ErrCode, CusProductStatus } from "@autumn/shared"; +import { AttachBody } from "../../../models/AttachBody.js"; + +const getProductsForAttach = async ({ + req, + attachBody, +}: { + req: ExtendedRequest; + attachBody: AttachBody; +}) => { + const { product_id, product_ids, version } = attachBody; + + let products = await ProductService.listFull({ + db: req.db, + orgId: req.orgId, + env: req.env, + inIds: product_ids || [product_id!], + version, + }); + + if (notNullish(product_ids)) { + let freeTrialProds = products.filter((prod) => notNullish(prod.free_trial)); + if (freeTrialProds.length > 0) { + throw new RecaseError({ + message: + "When providing product_ids, can't have multiple free trial products", + code: ErrCode.InvalidRequest, + }); + } + + for (const prod of products) { + let otherProd = products.find( + (p) => p.group === prod.group && !p.is_add_on && p.id !== prod.id, + ); + + if (otherProd && !otherProd.is_add_on && !isOneOff(prod.prices)) { + throw new RecaseError({ + message: + "Can't attach multiple products from the same group that are not add-ons", + code: ErrCode.InvalidRequest, + }); + } + } + } + + return products; +}; + +export const getCustomerAndProducts = async ({ + req, + attachBody, +}: { + req: ExtendedRequest; + attachBody: AttachBody; +}) => { + const [customer, products] = await Promise.all([ + getOrCreateCustomer({ + req, + customerId: attachBody.customer_id, + customerData: attachBody.customer_data, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.Scheduled, + CusProductStatus.PastDue, + ], + withEntities: true, + entityId: attachBody.entity_id || undefined, + entityData: attachBody.entity_data, + }), + getProductsForAttach({ req, attachBody }), + ]); + + return { customer, products }; +}; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts new file mode 100644 index 000000000..ad9c9ea1d --- /dev/null +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts @@ -0,0 +1,138 @@ +import { + cusProductToPrices, + cusProductToEnts, +} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; +import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils.js"; +import { + getFreeTrialAfterFingerprint, + handleNewFreeTrial, +} from "@/internal/products/free-trials/freeTrialUtils.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { isMainProduct } from "@/internal/products/productUtils/classifyProduct.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { + FullCustomer, + FullProduct, + Price, + Entitlement, + CreateFreeTrial, +} from "@autumn/shared"; +import { AttachBody } from "../../../models/AttachBody.js"; +import { mapOptionsList } from "../../mapOptionsList.js"; + +export const getPricesAndEnts = async ({ + req, + attachBody, + customer, + products, +}: { + req: ExtendedRequest; + attachBody: AttachBody; + customer: FullCustomer; + products: FullProduct[]; +}) => { + const { options: optionsInput, is_custom, items, free_trial } = attachBody; + const { features, db, org, logtail: logger } = req; + + const { curMainProduct, curSameProduct } = getExistingCusProducts({ + product: products[0], + cusProducts: customer.customer_products, + internalEntityId: customer.entity?.internal_id, + }); + + // Not custom + if (!is_custom) { + let prices = products.flatMap((p: FullProduct) => p.prices); + let entitlements = products.flatMap((p: FullProduct) => p.entitlements); + + let freeTrial = null; + let freeTrialProduct = products.find((p) => notNullish(p.free_trial)); + if (freeTrialProduct) { + freeTrial = await getFreeTrialAfterFingerprint({ + db, + freeTrial: freeTrialProduct.free_trial, + fingerprint: customer.fingerprint, + internalCustomerId: customer.internal_id, + multipleAllowed: org.config.multiple_trials, + productId: freeTrialProduct.id, + }); + } + + const prodIsMain = isMainProduct({ product: products[0], prices }); + + return { + optionsList: mapOptionsList({ + optionsInput: optionsInput || [], + features, + prices, + curCusProduct: curMainProduct, + }), + prices, + entitlements, + freeTrial, + cusProducts: customer.customer_products, + }; + } + + const product = products[0]; + + let curPrices: Price[] = product!.prices; + let curEnts: Entitlement[] = product!.entitlements; + + if (curMainProduct?.product.id === product.id) { + curPrices = cusProductToPrices({ cusProduct: curMainProduct }); + curEnts = cusProductToEnts({ cusProduct: curMainProduct }); + } + + let { + prices, + entitlements: ents, + customPrices, + customEnts, + } = await handleNewProductItems({ + db, + curPrices, + curEnts, + newItems: attachBody.items || [], + features, + product, + logger, + isCustom: true, + }); + + const freeTrial = await handleNewFreeTrial({ + db, + curFreeTrial: product!.free_trial, + newFreeTrial: (free_trial as CreateFreeTrial) || null, + internalProductId: product!.internal_id, + isCustom: true, + }); + + const uniqueFreeTrial = await getFreeTrialAfterFingerprint({ + db, + freeTrial: freeTrial, + fingerprint: customer.fingerprint, + internalCustomerId: customer.internal_id, + multipleAllowed: org.config.multiple_trials, + productId: product.id, + }); + + return { + optionsList: mapOptionsList({ + optionsInput: optionsInput || [], + features, + prices, + curCusProduct: curMainProduct, + }), + prices, + entitlements: getEntsWithFeature({ + ents, + features, + }), + freeTrial: uniqueFreeTrial, + customPrices, + customEnts, + }; +}; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts index be5c586c0..98d4036d9 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts @@ -1,39 +1,61 @@ -import { listCusPaymentMethods } from "@/external/stripe/stripeCusUtils.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + createStripeCusIfNotExists, + listCusPaymentMethods, +} from "@/external/stripe/stripeCusUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { AppEnv, Customer, ErrCode, Organization } from "@autumn/shared"; import Stripe from "stripe"; export const getStripeCusData = async ({ stripeCli, stripeId, + db, + org, + env, + customer, + logger, }: { stripeCli: Stripe; stripeId?: string; + db: DrizzleCli; + org: Organization; + env: AppEnv; + customer: Customer; + logger: any; }) => { - if (!stripeId) { - return { stripeCus: undefined, paymentMethod: null, now: Date.now() }; + if (!org.stripe_connected) { + throw new RecaseError({ + message: "Please connect to Stripe to add products", + code: ErrCode.StripeConfigNotFound, + statusCode: 400, + }); } - let stripeCus = await stripeCli.customers.retrieve(stripeId, { - expand: ["test_clock", "invoice_settings.default_payment_method"], - }); + let stripeCus = (await createStripeCusIfNotExists({ + db, + org, + env, + customer, + logger, + })) as Stripe.Customer; - let stripeCusData = stripeCus as Stripe.Customer; - let testClock = - stripeCusData.test_clock as Stripe.TestHelpers.TestClock | null; + let testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null; // let now = testClock ? testClock.frozen_time * 1000 : Date.now(); let now = testClock ? testClock.frozen_time * 1000 : undefined; - let paymentMethod = stripeCusData.invoice_settings + let paymentMethod = stripeCus.invoice_settings ?.default_payment_method as Stripe.PaymentMethod | null; if (!paymentMethod) { let paymentMethods = await listCusPaymentMethods({ stripeCli, - stripeId, + stripeId: stripeCus.id, }); paymentMethod = paymentMethods.length ? paymentMethods[0] : null; } - return { stripeCus: stripeCusData, paymentMethod, now }; + return { stripeCus, paymentMethod, now }; }; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index 438775c41..334ad893a 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -22,6 +22,7 @@ export const getAttachParams = async ({ customPrices, customEnts, stripeVars, + reward, } = await processAttachBody({ req, attachBody, @@ -52,7 +53,7 @@ export const getAttachParams = async ({ entitlements, freeTrial, replaceables: [], - + reward, // From req req, org: req.org, diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts index cb0d0a4cc..651121f15 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts @@ -1,219 +1,49 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { AttachBody } from "../../models/AttachBody.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { - CreateFreeTrial, - CusProductStatus, - Entitlement, - ErrCode, - FullCustomer, - FullProduct, - Price, -} from "@autumn/shared"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { getOrCreateCustomer } from "../../../cusUtils/getOrCreateCustomer.js"; -import { getExistingCusProducts } from "../../../cusProducts/cusProductUtils/getExistingCusProducts.js"; -import { mapOptionsList } from "../mapOptionsList.js"; -import { - getFreeTrialAfterFingerprint, - handleNewFreeTrial, -} from "@/internal/products/free-trials/freeTrialUtils.js"; -import { - cusProductToEnts, - cusProductToPrices, -} from "../../../cusProducts/cusProductUtils/convertCusProduct.js"; -import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils.js"; -import { isMainProduct } from "@/internal/products/productUtils/classifyProduct.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { getStripeCusData } from "./attachParamsUtils/getStripeCusData.js"; -import { isOneOff } from "@/internal/products/productUtils.js"; +import { getPricesAndEnts } from "./attachParamsUtils/getPricesAndEnts.js"; +import { getCustomerAndProducts } from "./attachParamsUtils/getCusAndProducts.js"; +import { RewardService } from "@/internal/rewards/RewardService.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { ErrCode } from "@autumn/shared"; +import Stripe from "stripe"; -const getProductsForAttach = async ({ +export const getReward = async ({ req, attachBody, + stripeCli, }: { req: ExtendedRequest; attachBody: AttachBody; + stripeCli: Stripe; }) => { - const { product_id, product_ids, version } = attachBody; + const { reward: idOrCode } = attachBody; + if (!idOrCode) { + return undefined; + } - let products = await ProductService.listFull({ + // 1. Get reward by id or promo code + const reward = await RewardService.getByIdOrCode({ db: req.db, - orgId: req.orgId, + idOrCode, + orgId: req.org.id, env: req.env, - inIds: product_ids || [product_id!], - version, }); - if (notNullish(product_ids)) { - let freeTrialProds = products.filter((prod) => notNullish(prod.free_trial)); - if (freeTrialProds.length > 0) { - throw new RecaseError({ - message: - "When providing product_ids, can't have multiple free trial products", - code: ErrCode.InvalidRequest, - }); - } - - for (const prod of products) { - let otherProd = products.find( - (p) => p.group === prod.group && !p.is_add_on && p.id !== prod.id, - ); - - if (otherProd && !otherProd.is_add_on && !isOneOff(prod.prices)) { - throw new RecaseError({ - message: - "Can't attach multiple products from the same group that are not add-ons", - code: ErrCode.InvalidRequest, - }); - } - } + if (!reward) { + throw new RecaseError({ + message: `Reward ${idOrCode} not found`, + code: ErrCode.RewardNotFound, + statusCode: 404, + }); } - return products; -}; - -const getCustomerAndProducts = async ({ - req, - attachBody, -}: { - req: ExtendedRequest; - attachBody: AttachBody; -}) => { - const [customer, products] = await Promise.all([ - getOrCreateCustomer({ - req, - customerId: attachBody.customer_id, - customerData: attachBody.customer_data, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.Scheduled, - CusProductStatus.PastDue, - ], - withEntities: true, - entityId: attachBody.entity_id || undefined, - entityData: attachBody.entity_data, - }), - getProductsForAttach({ req, attachBody }), - ]); - - return { customer, products }; -}; - -const getPricesAndEnts = async ({ - req, - attachBody, - customer, - products, -}: { - req: ExtendedRequest; - attachBody: AttachBody; - customer: FullCustomer; - products: FullProduct[]; -}) => { - const { options: optionsInput, is_custom, items, free_trial } = attachBody; - const { features, db, org, logtail: logger } = req; - - const { curMainProduct, curSameProduct } = getExistingCusProducts({ - product: products[0], - cusProducts: customer.customer_products, - internalEntityId: customer.entity?.internal_id, - }); - - // Not custom - if (!is_custom) { - let prices = products.flatMap((p: FullProduct) => p.prices); - let entitlements = products.flatMap((p: FullProduct) => p.entitlements); - - let freeTrial = null; - let freeTrialProduct = products.find((p) => notNullish(p.free_trial)); - if (freeTrialProduct) { - freeTrial = await getFreeTrialAfterFingerprint({ - db, - freeTrial: freeTrialProduct.free_trial, - fingerprint: customer.fingerprint, - internalCustomerId: customer.internal_id, - multipleAllowed: org.config.multiple_trials, - productId: freeTrialProduct.id, - }); - } - - const prodIsMain = isMainProduct({ product: products[0], prices }); - - return { - optionsList: mapOptionsList({ - optionsInput: optionsInput || [], - features, - prices, - curCusProduct: curMainProduct, - }), - prices, - entitlements, - freeTrial, - cusProducts: customer.customer_products, - }; - } - - const product = products[0]; - - let curPrices: Price[] = product!.prices; - let curEnts: Entitlement[] = product!.entitlements; - - if (curMainProduct?.product.id === product.id) { - curPrices = cusProductToPrices({ cusProduct: curMainProduct }); - curEnts = cusProductToEnts({ cusProduct: curMainProduct }); - } - - let { - prices, - entitlements: ents, - customPrices, - customEnts, - } = await handleNewProductItems({ - db, - curPrices, - curEnts, - newItems: attachBody.items || [], - features, - product, - logger, - isCustom: true, - }); - - const freeTrial = await handleNewFreeTrial({ - db, - curFreeTrial: product!.free_trial, - newFreeTrial: (free_trial as CreateFreeTrial) || null, - internalProductId: product!.internal_id, - isCustom: true, - }); - - const uniqueFreeTrial = await getFreeTrialAfterFingerprint({ - db, - freeTrial: freeTrial, - fingerprint: customer.fingerprint, - internalCustomerId: customer.internal_id, - multipleAllowed: org.config.multiple_trials, - productId: product.id, - }); + const stripeCoupon = await stripeCli.coupons.retrieve(reward.id); return { - optionsList: mapOptionsList({ - optionsInput: optionsInput || [], - features, - prices, - curCusProduct: curMainProduct, - }), - prices, - entitlements: getEntsWithFeature({ - ents, - features, - }), - freeTrial: uniqueFreeTrial, - customPrices, - customEnts, + reward, + stripeCoupon, }; }; @@ -233,10 +63,22 @@ export const processAttachBody = async ({ }); const stripeCli = createStripeCli({ org, env }); - let stripeCusData = await getStripeCusData({ - stripeCli, - stripeId: customer.processor?.id, - }); + const [stripeCusData, rewardData] = await Promise.all([ + getStripeCusData({ + stripeCli, + stripeId: customer.processor?.id, + db: req.db, + org, + env, + customer, + logger: req.logtail, + }), + getReward({ + req, + attachBody, + stripeCli, + }), + ]); const { stripeCus, paymentMethod, now } = stripeCusData; @@ -257,6 +99,7 @@ export const processAttachBody = async ({ return { customer, products, + reward: rewardData?.reward, optionsList, prices, entitlements, diff --git a/server/src/internal/customers/attach/models/AttachBody.ts b/server/src/internal/customers/attach/models/AttachBody.ts index d542bfed2..21bda22d4 100644 --- a/server/src/internal/customers/attach/models/AttachBody.ts +++ b/server/src/internal/customers/attach/models/AttachBody.ts @@ -46,6 +46,7 @@ export const AttachBodySchema = z metadata: z.any().optional(), billing_cycle_anchor: z.number().optional(), checkout_session_params: z.any().optional(), + reward: z.string().optional(), }) .refine((data) => !(data.product_id && data.product_ids), { message: "Either product_id or product_ids should be provided, not both", diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 70fa98b8f..cce63ebf5 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -13,6 +13,7 @@ import { APIVersion, FullCustomer, AttachReplaceable, + Reward, } from "@autumn/shared"; import Stripe from "stripe"; @@ -25,6 +26,7 @@ export type AttachParams = { stripeCus?: Stripe.Customer; now?: number; paymentMethod: Stripe.PaymentMethod | null | undefined; + reward?: Reward; org: Organization; // customer: Customer; diff --git a/server/src/internal/mainRouter.ts b/server/src/internal/mainRouter.ts index b94c68f11..f5f753cfd 100644 --- a/server/src/internal/mainRouter.ts +++ b/server/src/internal/mainRouter.ts @@ -10,10 +10,10 @@ import { devRouter } from "./dev/devRouter.js"; import { cusRouter } from "./customers/internalCusRouter.js"; import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js"; import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js"; -import { Autumn } from "autumn-js"; -import { autumnHandler } from "autumn-js/express"; import { withAdminAuth } from "./admin/withAdminAuth.js"; import { adminRouter } from "./admin/adminRouter.js"; +import { autumnHandler } from "autumn-js/express"; +import { Autumn } from "autumn-js"; const mainRouter: Router = Router(); @@ -57,7 +57,10 @@ mainRouter.use( autumn: (req: any) => { let client = new Autumn({ url: "http://localhost:8080/v1", - headers: req.headers, + headers: { + cookie: req.headers.cookie, + "Content-Type": "application/json", + }, }); return client as any; }, diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomers.ts b/server/src/internal/migrations/migrationSteps/migrateCustomers.ts index 36e0460ba..bf822bf1f 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomers.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomers.ts @@ -134,22 +134,22 @@ export const migrateCustomers = async ({ // Get number of errors let migrationDetails: any = {}; - try { - let errors = await MigrationService.getErrors({ - db, - migrationJobId: migrationJob.id, - }); + // try { + // let errors = await MigrationService.getErrors({ + // db, + // migrationJobId: migrationJob.id, + // }); - migrationDetails.num_errors = errors!.length; - migrationDetails.failed_customers = errors!.map( - (e: any) => `${e.customer.id} - ${e.customer.name}`, - ); - } catch (error) { - migrationDetails.failed_to_get_errors = true; - migrationDetails.error = error; - logger.error("Failed to get migration errors"); - logger.error(error); - } + // migrationDetails.num_errors = errors!.length; + // migrationDetails.failed_customers = errors!.map( + // (e: any) => `${e.customer.id} - ${e.customer.name}`, + // ); + // } catch (error) { + // migrationDetails.failed_to_get_errors = true; + // migrationDetails.error = error; + // logger.error("Failed to get migration errors"); + // logger.error(error); + // } let curMigrationJob = await MigrationService.getJob({ db, diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index c50713ec6..60833370e 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -197,14 +197,6 @@ orgRouter.delete("/stripe", async (req: any, res) => { }, }); - const clerkCli = createClerkCli(); - await clerkCli.organizations.updateOrganization(req.orgId, { - publicMetadata: { - stripe_connected: false, - default_currency: undefined, - }, - }); - res.status(200).json({ message: "Stripe disconnected", }); diff --git a/server/src/internal/rewards/RewardService.ts b/server/src/internal/rewards/RewardService.ts index 028434922..bbe776be7 100644 --- a/server/src/internal/rewards/RewardService.ts +++ b/server/src/internal/rewards/RewardService.ts @@ -1,7 +1,7 @@ import { AppEnv, ErrCode, Reward, rewards } from "@autumn/shared"; import { DrizzleCli } from "@/db/initDrizzle.js"; import RecaseError from "@/utils/errorUtils.js"; -import { and, desc, eq, or } from "drizzle-orm"; +import { and, arrayContains, desc, eq, inArray, or, sql } from "drizzle-orm"; export class RewardService { static async get({ @@ -33,6 +33,38 @@ export class RewardService { return result as Reward; } + static async getByIdOrCode({ + db, + idOrCode, + orgId, + env, + }: { + db: DrizzleCli; + idOrCode: string; + orgId: string; + env: AppEnv; + }) { + let reward = await db.query.rewards.findFirst({ + where: and( + eq(rewards.org_id, orgId), + eq(rewards.env, env), + or( + eq(rewards.id, idOrCode), + sql`EXISTS ( + SELECT 1 FROM unnest("promo_codes") AS elem + WHERE elem->>'code' = ${idOrCode} + )`, + ), + ), + }); + + if (!reward) { + return null; + } + + return reward as Reward; + } + static async insert({ db, data, diff --git a/server/tests/advanced/coupons/coupon3.ts b/server/tests/advanced/coupons/coupon3.ts new file mode 100644 index 000000000..2f74f99ab --- /dev/null +++ b/server/tests/advanced/coupons/coupon3.ts @@ -0,0 +1,179 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { expect } from "chai"; + +import { + APIVersion, + AppEnv, + CouponDurationType, + CreateReward, + Organization, + RewardType, +} from "@autumn/shared"; + +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { + constructArrearItem, + constructFeatureItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { + addPrefixToProducts, + getBasePrice, +} from "tests/utils/testProductUtils/testProductUtils.js"; +import { createProducts, createReward } from "tests/utils/productUtils.js"; +import { expectAttachCorrect } from "tests/utils/expectUtils/expectAttach.js"; + +const pro = constructProduct({ + type: "pro", + items: [constructArrearItem({ featureId: TestFeature.Words })], +}); + +const oneOff = constructProduct({ + type: "one_off", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 500, + }), + ], +}); + +// Create reward input +const rewardId = "attach_coupon"; +const promoCode = "attach_coupon_code"; +const reward: CreateReward = { + id: rewardId, + name: "attach_coupon", + promo_codes: [{ code: promoCode }], + type: RewardType.FixedDiscount, + discount_config: { + discount_value: 5, + duration_type: CouponDurationType.Forever, + duration_value: 1, + should_rollover: true, + apply_to_all: true, + price_ids: [], + }, +}; + +const testCase = "coupon3"; +describe(chalk.yellow(`${testCase} - Testing attach coupon`), () => { + let customerId = testCase; + let stripeCli: Stripe; + let testClockId: string; + + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let org: Organization; + let env: AppEnv; + let db: DrizzleCli; + + let couponAmount = reward.discount_config!.discount_value; + + before(async function () { + await setupBefore(this); + + org = this.org; + env = this.env; + db = this.db; + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = await initCustomer({ + customerId, + org: this.org, + env: this.env, + db: this.db, + autumn: this.autumnJs, + attachPm: "success", + }); + + testClockId = testClockId1; + + addPrefixToProducts({ + products: [pro, oneOff], + prefix: testCase, + }); + + await createProducts({ + orgId: this.org.id, + env: this.env, + db: this.db, + autumn, + products: [pro, oneOff], + }); + + await createReward({ + orgId: org.id, + env, + db, + autumn, + reward, + productId: pro.id, + }); + }); + + // CYCLE 0 + it("should attach pro with reward ID", async () => { + const res = await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + reward: rewardId, + }); + + const customer = await autumn.customers.get(customerId); + expectAttachCorrect({ + customer, + product: pro, + }); + + const invoice = customer.invoices![0]; + let basePrice = getBasePrice({ product: pro }); + expect(invoice.total).to.equal(basePrice - couponAmount); + }); + + it("should attach one off with reward ID", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: oneOff.id, + reward: rewardId, + }); + + const customer = await autumn.customers.get(customerId); + expectAttachCorrect({ + customer, + product: oneOff, + }); + + const invoice = customer.invoices![0]; + let basePrice = getBasePrice({ product: oneOff }); + expect(invoice.total).to.equal(basePrice - couponAmount); + expect(invoice.product_ids).to.include(oneOff.id); + }); + + it("should attach one off with promo code", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: oneOff.id, + reward: promoCode, + }); + + const customer = await autumn.customers.get(customerId); + expectAttachCorrect({ + customer, + product: oneOff, + }); + + expect(customer.invoices!.length).to.equal(3); + let basePrice = getBasePrice({ product: oneOff }); + for (let i = 0; i < 2; i++) { + let invoice = customer.invoices![i]; + expect(invoice.total).to.equal(basePrice - couponAmount); + expect(invoice.product_ids).to.include(oneOff.id); + } + }); +}); diff --git a/server/tests/attach/migrations/migration4.ts b/server/tests/attach/migrations/migration4.ts index e937c5f71..084b8f33a 100644 --- a/server/tests/attach/migrations/migration4.ts +++ b/server/tests/attach/migrations/migration4.ts @@ -1,12 +1,6 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { - AppEnv, - BillingInterval, - Organization, - ProductItemInterval, - ProductV2, -} from "@autumn/shared"; +import { AppEnv, Organization } from "@autumn/shared"; import chalk from "chalk"; import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; @@ -16,13 +10,9 @@ import { addPrefixToProducts, runAttachTest } from "../utils.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { TestFeature } from "tests/setup/v2Features.js"; -import { replaceItems } from "../utils.js"; - import { defaultApiVersion } from "tests/constants.js"; import { runMigrationTest } from "./runMigrationTest.js"; import { timeout } from "@/utils/genUtils.js"; -import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { addDays } from "date-fns"; import { expect } from "chai"; let wordsItem = constructArrearItem({ diff --git a/shared/models/rewardModels/rewardModels/rewardModels.ts b/shared/models/rewardModels/rewardModels/rewardModels.ts index 646b5873f..1781fc753 100644 --- a/shared/models/rewardModels/rewardModels/rewardModels.ts +++ b/shared/models/rewardModels/rewardModels/rewardModels.ts @@ -11,7 +11,7 @@ export const DiscountConfigSchema = z.object({ duration_value: z.number(), should_rollover: z.boolean().optional(), apply_to_all: z.boolean().optional(), - price_ids: z.array(z.string()), + price_ids: z.array(z.string()).optional(), }); const RewardSchema = z.object({ diff --git a/shared/package.json b/shared/package.json index b8c1a856f..bf8780e0c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -28,6 +28,7 @@ "zod": "^3.25.23" }, "devDependencies": { + "@types/node": "^24.0.3", "nodemon": "^3.1.7", "tsx": "^4.19.4", "typescript": "^5.7.2" diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 25335aedc..41ff9b4be 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -13,5 +13,6 @@ "rootDir": "." }, "include": ["./**/*"], + "types": ["node"], "exclude": ["node_modules", "dist"] }