fix: connect stripe deleted clerk functions, allow reward to be passed into attach body

This commit is contained in:
John Yeo
2025-06-17 17:19:00 +01:00
parent 04fe714488
commit 5945a4b2e4
32 changed files with 642 additions and 300 deletions

View File

@@ -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"]
}

View File

@@ -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

13
pnpm-lock.yaml generated
View File

@@ -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

4
run.sh
View File

@@ -1,7 +1,11 @@
if [[ $1 == *"docker-compose"* ]]; then
if [[ $2 == "down" ]]; then
docker compose -f "$1" down $3
else
if [[ $3 == *"--build"* ]]; then
docker compose -f "$1" up --build
else
docker compose -f "$1" up $3
fi
fi
fi

View File

@@ -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",

View File

@@ -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",
);
}
}

View File

@@ -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;

View File

@@ -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 ({

View File

@@ -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

View File

@@ -109,6 +109,7 @@ export const handleInvoicePaidDiscount = async ({
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`,
);
if (expandedInvoice.subscription) {
await deleteCouponFromCus({
stripeCli,
stripeSubId: expandedInvoice.subscription as string,
@@ -116,6 +117,8 @@ export const handleInvoicePaidDiscount = async ({
discountId: discount.id,
logger,
});
}
continue;
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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");

View File

@@ -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;

View File

@@ -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(

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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,

View File

@@ -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) {
if (!reward) {
throw new RecaseError({
message:
"When providing product_ids, can't have multiple free trial products",
code: ErrCode.InvalidRequest,
message: `Reward ${idOrCode} not found`,
code: ErrCode.RewardNotFound,
statusCode: 404,
});
}
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;
};
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 });
const stripeCoupon = await stripeCli.coupons.retrieve(reward.id);
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,
reward,
stripeCoupon,
};
};
@@ -233,10 +63,22 @@ export const processAttachBody = async ({
});
const stripeCli = createStripeCli({ org, env });
let stripeCusData = await getStripeCusData({
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,

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;
},

View File

@@ -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,

View File

@@ -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",
});

View File

@@ -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,

View File

@@ -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);
}
});
});

View File

@@ -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({

View File

@@ -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({

View File

@@ -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"

View File

@@ -13,5 +13,6 @@
"rootDir": "."
},
"include": ["./**/*"],
"types": ["node"],
"exclude": ["node_modules", "dist"]
}