fix: redeem free product on customer creation

This commit is contained in:
John Yeo
2025-06-29 19:52:51 +01:00
parent 1f99f339db
commit 2229d91f90
10 changed files with 91 additions and 70 deletions

View File

@@ -10,7 +10,7 @@ $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'
# $MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -117,17 +117,6 @@ export const handleProductsUpdated = async ({
deletedCusProduct,
} = data;
if (!req) {
logger.warn("products.updated, no req object found, skipping", {
...data,
org: {
id: org.id,
slug: org.slug,
},
});
return;
}
// Product:
let product = cusProduct.product;
let prices = cusProduct.customer_prices.map((cp) => cp.price);
@@ -181,26 +170,32 @@ export const handleProductsUpdated = async ({
// 1. Log action to DB
try {
let action = constructAction({
org,
env,
customer,
entity: customer.entity,
type: ActionType.CustomerProductsUpdated,
req,
properties: {
product_id: product.id,
customer_product_id: cusProduct.id,
scenario,
if (req) {
let action = constructAction({
org,
env,
customer,
entity: customer.entity,
type: ActionType.CustomerProductsUpdated,
req,
properties: {
product_id: product.id,
customer_product_id: cusProduct.id,
scenario,
deleted_product_id: deletedCusProduct?.product.id,
scheduled_product_id: scheduledCusProduct?.product.id,
deleted_product_id: deletedCusProduct?.product.id,
scheduled_product_id: scheduledCusProduct?.product.id,
body: req.body,
},
});
body: req.body,
},
});
await ActionService.insert(db, action);
await ActionService.insert(db, action);
} else {
logger.warn(
"products.updated, no req object found, skipping action insert",
);
}
} catch (error: any) {
logger.error("Failed to log action to DB", {
message: error.message,

View File

@@ -1,6 +1,7 @@
import { CusService } from "@/internal/customers/CusService.js";
import {
generateReferralCode,
triggerFreeProduct,
triggerRedemption,
} from "@/internal/rewards/referralUtils.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
@@ -8,11 +9,15 @@ import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionServ
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ErrCode, RewardTriggerEvent } from "@autumn/shared";
import { ErrCode, RewardCategory, RewardTriggerEvent } from "@autumn/shared";
import express, { Router } from "express";
import { RewardRedemption } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
export const referralRouter: Router = express.Router();
@@ -215,15 +220,45 @@ referralRouter.post("/redeem", (req, res) =>
if (
referralCode.reward_program.when === RewardTriggerEvent.CustomerCreation
) {
redemption = await triggerRedemption({
db: req.db,
referralCode,
org,
const reward = await RewardService.get({
db,
orgId,
env,
logger,
reward: reward_program.reward,
redemption,
idOrInternalId: reward_program.internal_reward_id,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${reward_program.internal_reward_id} not found`,
statusCode: 404,
code: ErrCode.RewardNotFound,
});
}
let rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
req: parseReqForAction(req) as ExtendedRequest,
db,
referralCode,
redeemer: customer,
rewardProgram: reward_program,
org,
env,
logger,
redemption,
});
} else {
await triggerRedemption({
db,
referralCode,
org,
env,
logger,
reward,
redemption,
});
}
}
// Add coupon to customer?

View File

@@ -7,7 +7,6 @@ import {
FullCustomerEntitlement,
FullEntitlement,
getFeatureInvoiceDescription,
InsertReplaceable,
PreviewLineItem,
Price,
} from "@autumn/shared";
@@ -156,6 +155,7 @@ export const getContUseInvoiceItems = async ({
attachParams,
prevCusEnt,
});
const prevItem = curItems.find(
(item) => item.price_id === prevCusPrice?.price.id,
);

View File

@@ -27,6 +27,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free
import { Decimal } from "decimal.js";
import { intervalsAreSame } from "../attachUtils/getAttachConfig.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
const getNextCycleAt = ({
prices,
@@ -184,6 +185,8 @@ export const getUpgradeProductPreview = async ({
freeTrial: attachParams.freeTrial,
});
items = items.filter((item) => item.amount !== 0);
if (branch == AttachBranch.UpdatePrepaidQuantity) {
items = items.filter((item) => item.usage_model == UsageModel.Prepaid);
dueNextCycle!.line_items = dueNextCycle!.line_items.filter(

View File

@@ -30,7 +30,7 @@ import {
subIsPrematurelyCanceled,
} from "@/external/stripe/stripeSubUtils.js";
import { getRelatedCusEnt } from "./cusPrices/cusPriceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { BREAK_API_VERSION } from "@/utils/constants.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
@@ -553,5 +553,5 @@ export const getFeatureQuantity = ({
const option = options.find(
(o) => o.internal_feature_id == internalFeatureId,
);
return option?.quantity || 1;
return nullish(option?.quantity) ? 1 : option?.quantity!;
};

View File

@@ -157,6 +157,7 @@ export const priceToInvoiceDescription = ({
cusProduct,
internalFeatureId: ent.feature.internal_id,
});
description = formatPrepaidPrice({ price, ents, quantity });
}

View File

@@ -6,6 +6,7 @@ import {
FullRewardProgram,
ReferralCode,
Reward,
RewardCategory,
RewardReceivedBy,
RewardRedemption,
} from "@autumn/shared";
@@ -22,6 +23,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "../customers/cusProducts/CusProductService.js";
import RecaseError from "@/utils/errorUtils.js";
import { StatusCodes } from "http-status-codes";
import { getRewardCat } from "./rewardUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
export const generateReferralCode = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -114,6 +117,7 @@ export const triggerRedemption = async ({
};
export const triggerFreeProduct = async ({
req,
db,
referralCode,
redeemer,
@@ -123,6 +127,7 @@ export const triggerFreeProduct = async ({
env,
logger,
}: {
req?: ExtendedRequest;
db: DrizzleCli;
referralCode: ReferralCode;
redeemer: Customer;
@@ -177,6 +182,7 @@ export const triggerFreeProduct = async ({
]);
let attachParams: InsertCusProductParams = {
req,
org,
product: fullProduct,
prices: fullProduct.prices,

View File

@@ -1,4 +1,5 @@
import "dotenv/config";
import { db } from "@/db/initDrizzle.js";
import sendOTPEmail from "@/internal/emails/sendOTPEmail.js";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
@@ -48,7 +49,7 @@ export const auth = betterAuth({
"http://localhost:3000",
"https://app.useautumn.com",
"https://*.useautumn.com",
process.env.CLIENT_URL!,
// process.env.CLIENT_URL!,
],
emailAndPassword: {
enabled: true,

View File

@@ -18,8 +18,8 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addWeeks } from "date-fns";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
import { timeout } from "@/utils/genUtils.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { timeout } from "@/utils/genUtils.js";
const testCase = "updateQuantity1";
@@ -111,33 +111,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl
});
});
// const newOpts = [
// {
// feature_id: TestFeature.Users,
// quantity: 1,
// },
// ];
// it("should throw error if try to reduce seats to less than current usage", async function () {
// await autumn.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 2,
// });
// await timeout(1000);
// await expectAutumnError({
// errCode: AttachErrCode.InvalidOptions,
// func: async () => {
// await autumn.attach({
// customer_id: customerId,
// product_id: pro.id,
// options: newOpts,
// });
// },
// });
// });
const updatedOpts = [
{
feature_id: TestFeature.Users,
@@ -146,6 +119,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl
];
it("should update quantity to 4 users and have usage stay the same", async function () {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
await timeout(3000);
curUnix = await advanceTestClock({
stripeCli,
testClockId,