adding free product to referrals

This commit is contained in:
John Yeo
2025-04-11 09:33:28 +01:00
parent e1e0d60655
commit 7ade00bcb1
35 changed files with 932 additions and 155 deletions

View File

@@ -22,14 +22,14 @@ export class Autumn {
public headers: Record<string, string>;
public baseUrl: string;
constructor(apiKey?: string) {
constructor(apiKey?: string, baseUrl?: string) {
this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
// this.baseUrl = "https://api.useautumn.com/v1";
this.baseUrl = "http://localhost:8080/v1";
this.baseUrl = baseUrl || "http://localhost:8080/v1";
}
async get(path: string) {

View File

@@ -298,9 +298,12 @@ const handleInvoicePaidDiscount = async ({
expand: ["applies_to"],
});
// console.log("Total discount amounts", totalDiscountAmounts);
// console.log("Autumn reward", autumnReward);
// 1. New amount:
const autumnDiscountConfig = autumnReward.discount_config!;
const curAmount = autumnDiscountConfig.discount_value;
// const autumnDiscountConfig = autumnReward.discount_config!;
const curAmount = discount.coupon.amount_off;
const amountUsed = totalDiscountAmounts?.find(
(item) => item.discount === discount.id

View File

@@ -9,8 +9,10 @@ import RecaseError from "@/utils/errorUtils.js";
import {
AppEnv,
CusProductStatus,
CustomerEntitlement,
ErrCode,
FullCusProduct,
FullCustomerEntitlement,
Organization,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
@@ -18,6 +20,7 @@ import Stripe from "stripe";
import { subIsPrematurelyCanceled } from "../stripeSubUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
export const handleSubscriptionDeleted = async ({
sb,
@@ -38,6 +41,7 @@ export const handleSubscriptionDeleted = async ({
stripeSubId: subscription.id,
orgId: org.id,
env,
withCusEnts: true,
});
if (activeCusProducts.length === 0) {
@@ -91,8 +95,6 @@ export const handleSubscriptionDeleted = async ({
return;
}
// If there's scheduled_id, skip?
// Prematurely canceled
if (
cusProduct.scheduled_ids &&
cusProduct.scheduled_ids.length > 0 &&
@@ -116,7 +118,7 @@ export const handleSubscriptionDeleted = async ({
return;
}
// 1. Expire current product -> Probably going to be a problem...?
// 1. Expire current product
await CusProductService.update({
sb,
cusProductId: cusProduct.id,
@@ -126,6 +128,30 @@ export const handleSubscriptionDeleted = async ({
},
});
try {
// 2. TODO: Clear entities
let internalFeatureIds = new Set(
cusProduct.customer_entitlements.map(
(ce: FullCustomerEntitlement) => ce.entitlement.internal_feature_id!
)
);
await EntityService.deleteByInternalFeatureId({
sb,
internalCustomerId: cusProduct.customer.internal_id,
internalFeatureIds: Array.from(internalFeatureIds),
orgId: org.id,
env,
});
logger.info(
` ✅ deleted ${internalFeatureIds.size} entities for customer ${cusProduct.customer.id}`
);
} catch (error) {
logger.error("Failed to delete entities on sub deleted");
logger.error(error);
}
if (cusProduct.product.is_add_on) {
return;
}

View File

@@ -191,4 +191,30 @@ export class EntityService {
throw error;
}
}
static async deleteByInternalFeatureId({
sb,
internalFeatureIds,
internalCustomerId,
orgId,
env,
}: {
sb: SupabaseClient;
internalFeatureIds: string[];
internalCustomerId: string;
orgId: string;
env: string;
}) {
const { error } = await sb
.from("entities")
.delete()
.in("internal_feature_id", internalFeatureIds)
.eq("org_id", orgId)
.eq("env", env)
.eq("internal_customer_id", internalCustomerId);
if (error) {
throw error;
}
}
}

View File

@@ -28,6 +28,7 @@ rewardRouter.post("", async (req: any, res: any) => {
reward: rewardData,
orgId,
env,
internalId: rewardBody.internal_id,
});
if (getRewardCat(newReward) === RewardCategory.Discount) {
@@ -136,13 +137,12 @@ rewardRouter.delete("/:id", async (req: any, res: any) => {
}
});
rewardRouter.post("/:id", async (req: any, res: any) => {
rewardRouter.post("/:internalId", async (req: any, res: any) => {
try {
const { id } = req.params;
const { internalId } = req.params;
const { orgId, env } = req;
const couponBody = req.body;
const rewardBody = req.body;
console.log("Reward body", couponBody);
const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({
org,
@@ -151,27 +151,29 @@ rewardRouter.post("/:id", async (req: any, res: any) => {
const prices = await PriceService.getPricesFromIds({
sb: req.sb,
priceIds: couponBody.price_ids,
priceIds: rewardBody.price_ids,
});
// 1. Delete old prices from stripe
await stripeCli.coupons.del(id);
await stripeCli.coupons.del(internalId);
// 2. Create new coupon
await createStripeCoupon({
reward: couponBody,
stripeCli,
org,
prices,
});
let rewardCat = getRewardCat(rewardBody);
if (rewardCat !== RewardCategory.Discount) {
await createStripeCoupon({
reward: rewardBody,
stripeCli,
org,
prices,
});
}
// 3. Update coupon in db
const updatedCoupon = await RewardService.update({
sb: req.sb,
internalId: id,
internalId,
env,
orgId,
update: couponBody,
update: rewardBody,
});
res.status(200).json(updatedCoupon);

View File

@@ -0,0 +1,22 @@
import { SupabaseClient } from "@supabase/supabase-js";
export class CusReadService {
static async getInInternalIds({
sb,
internalIds,
}: {
sb: SupabaseClient;
internalIds: string[];
}) {
const { data, error } = await sb
.from("customers")
.select("*")
.in("internal_id", internalIds);
if (error) {
throw error;
}
return data;
}
}

View File

@@ -26,7 +26,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
import { CusService } from "../CusService.js";
import { getExistingCusProducts } from "./handleExistingProduct.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { searchCusProducts } from "@/internal/customers/products/cusProductUtils.js";
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
import { initCusEntitlement } from "./initCusEnt.js";
@@ -328,7 +328,10 @@ export const createFullCusProduct = async ({
status: CusProductStatus.Active,
});
if (isOneOff(prices) && notNullish(existingCusProduct)) {
if (
(isOneOff(prices) || (isFreeProduct(prices) && product.is_add_on)) &&
notNullish(existingCusProduct)
) {
await updateOneTimeCusProduct({
sb,
attachParams,

View File

@@ -5,6 +5,7 @@ import { InvoiceService } from "./invoices/InvoiceService.js";
import { FeatureService } from "../features/FeatureService.js";
import {
CusProductStatus,
ErrCode,
FullCustomerEntitlement,
FullCustomerPrice,
} from "@autumn/shared";
@@ -18,6 +19,8 @@ import { getCusEntMasterBalance } from "./entitlements/cusEntUtils.js";
import { getLatestProducts } from "../products/productUtils.js";
import { getProductVersionCounts } from "../products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { RewardRedemptionService } from "../rewards/RewardRedemptionService.js";
import { CusReadService } from "./CusReadService.js";
export const cusRouter = Router();
@@ -65,6 +68,7 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
try {
// Get customer invoices
const [org, features, coupons, products, events, customer] =
await Promise.all([
OrgService.getFromReq(req),
@@ -209,6 +213,69 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
}
});
cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
try {
const { sb, org, env } = req;
const { customer_id } = req.params;
const orgId = req.orgId;
let internalCustomer = await CusService.getByIdOrInternalId({
sb,
orgId,
env,
idOrInternalId: customer_id,
isFull: true,
});
// Get all redemptions for this customer
let [referred, redeemed] = await Promise.all([
RewardRedemptionService.getByReferrer({
sb,
internalCustomerId: internalCustomer.internal_id,
withCustomer: true,
limit: 100,
}),
RewardRedemptionService.getByCustomer({
sb,
internalCustomerId: internalCustomer.internal_id,
withReferralCode: true,
limit: 100,
}),
]);
let redeemedCustomerIds = redeemed.map(
(redemption: any) => redemption.referral_code.internal_customer_id
);
let redeemedCustomers = await CusReadService.getInInternalIds({
sb,
internalIds: redeemedCustomerIds,
});
for (const redemption of redeemed) {
redemption.referral_code.customer = redeemedCustomers.find(
(customer: any) =>
customer.internal_id === redemption.referral_code.internal_customer_id
);
}
if (!internalCustomer) {
throw new RecaseError({
message: "Customer not found",
code: ErrCode.CustomerNotFound,
});
}
res.status(200).send({
referred,
redeemed,
});
} catch (error) {
console.error("Error getting customer referrals", error);
res.status(500).send({ error: "Error getting customer referrals" });
}
});
cusRouter.get(
"/:customer_id/product/:product_id",
async (req: any, res: any) => {

View File

@@ -245,16 +245,24 @@ export class CusProductService {
orgId,
env,
inStatuses,
withCusEnts = false,
}: {
sb: SupabaseClient;
stripeSubId: string;
orgId: string;
env: AppEnv;
inStatuses?: string[];
withCusEnts?: boolean;
}) {
const query = sb
.from("customer_products")
.select("*, product:products(*), customer:customers!inner(*)")
.select(
`*, product:products(*), customer:customers!inner(*)${
withCusEnts
? ", customer_entitlements:customer_entitlements!inner(*, entitlement:entitlements!inner(*, feature:features!inner(*)))"
: ""
}` as "*"
)
.or(
`processor->>'subscription_id'.eq.'${stripeSubId}', subscription_ids.cs.{${stripeSubId}}`
)

View File

@@ -24,6 +24,7 @@ export class RewardRedemptionService {
withRewardProgram = false,
internalRewardProgramId,
triggerWhen,
limit,
}: {
sb: any;
internalCustomerId: string;
@@ -32,6 +33,7 @@ export class RewardRedemptionService {
withRewardProgram?: boolean;
internalRewardProgramId?: string;
triggerWhen?: RewardTriggerEvent;
limit?: number;
}) {
let query = sb
.from("reward_redemptions")
@@ -56,6 +58,10 @@ export class RewardRedemptionService {
query = query.eq("triggered", triggered);
}
if (notNullish(limit)) {
query = query.limit(limit);
}
const { data, error } = await query;
if (error) {
@@ -65,6 +71,35 @@ export class RewardRedemptionService {
return data;
}
static async getByReferrer({
sb,
internalCustomerId,
withCustomer = false,
limit = 100,
}: {
sb: any;
internalCustomerId: string;
withCustomer?: boolean;
limit?: number;
}) {
const { data, error } = await sb
.from("reward_redemptions")
.select(
`
*, referral_code:referral_codes!inner(*)
${withCustomer ? ", customer:customers!inner(*)" : ""}
`
)
.eq("referral_code.internal_customer_id", internalCustomerId)
.limit(limit);
if (error) {
throw error;
}
return data;
}
static async getByCodeAndCustomer({
sb,
orgId,

View File

@@ -1,10 +1,24 @@
import { AppEnv, ReferralCode, Reward, RewardRedemption } from "@autumn/shared";
import {
AppEnv,
CusProductStatus,
Customer,
FullRewardProgram,
ReferralCode,
Reward,
RewardProgram,
RewardReceivedBy,
RewardRedemption,
} from "@autumn/shared";
import { CusService } from "../customers/CusService.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import Stripe from "stripe";
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import { CusProductService } from "../customers/products/CusProductService.js";
import { ProductService } from "../products/ProductService.js";
import { createFullCusProduct } from "../customers/add-product/createFullCusProduct.js";
import { InsertCusProductParams } from "../customers/products/AttachParams.js";
export const generateReferralCode = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -87,3 +101,111 @@ export const triggerRedemption = async ({
return updatedRedemption;
};
export const triggerFreeProduct = async ({
sb,
referralCode,
redeemer,
redemption,
rewardProgram,
org,
env,
logger,
}: {
sb: any;
referralCode: ReferralCode;
redeemer: Customer;
redemption: RewardRedemption;
rewardProgram: FullRewardProgram;
org: any;
env: AppEnv;
logger: any;
}) => {
logger.info(`Triggering free product reward`);
let { product_ids, received_by } = rewardProgram;
let addToRedeemer = received_by === RewardReceivedBy.All;
let addToReferrer =
received_by === RewardReceivedBy.Referrer ||
received_by === RewardReceivedBy.All;
let productId = rewardProgram.reward.free_product_id!;
let fullProduct = await ProductService.getFullProduct({
sb,
productId,
orgId: org.id,
env,
});
let referrer = await CusService.getByInternalId({
sb,
internalId: referralCode.internal_customer_id,
});
logger.info(`Referrer: ${referrer.name} (${referrer.id})`);
let [redeemerCusProducts, referrerCusProducts] = await Promise.all([
CusService.getFullCusProducts({
sb,
internalCustomerId: redeemer.internal_id,
logger,
withProduct: true,
withPrices: true,
inStatuses: [CusProductStatus.Active],
}),
CusService.getFullCusProducts({
sb,
internalCustomerId: referrer.internal_id,
logger,
withProduct: true,
withPrices: true,
inStatuses: [CusProductStatus.Active],
}),
]);
let attachParams: InsertCusProductParams = {
org,
product: fullProduct,
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
optionsList: [],
entities: [],
freeTrial: null,
customer: referrer,
cusProducts: referrerCusProducts,
};
if (addToRedeemer) {
let redeemerAttachParams = structuredClone({
...attachParams,
customer: redeemer,
cusProducts: redeemerCusProducts,
});
await createFullCusProduct({
sb,
attachParams: redeemerAttachParams,
});
logger.info(`✅ Added ${fullProduct.name} to redeemer`);
}
if (addToReferrer) {
await createFullCusProduct({
sb,
attachParams: {
...attachParams,
customer: referrer,
cusProducts: referrerCusProducts,
},
});
logger.info(`✅ Added ${fullProduct.name} to referrer`);
}
await RewardRedemptionService.update({
sb,
id: redemption.id,
updates: {
triggered: true,
applied: true,
},
});
};

View File

@@ -1,20 +1,43 @@
import RecaseError from "@/utils/errorUtils.js";
import { generateId, nullish } from "@/utils/genUtils.js";
import {
Reward,
CreateReward,
RewardType,
RewardCategory,
ErrCode,
DiscountConfigSchema,
} from "@autumn/shared";
export const constructReward = ({
internalId,
reward,
orgId,
env,
}: {
internalId?: string;
reward: CreateReward;
orgId: string;
env: string;
}) => {
if (!reward.id || !reward.name) {
throw new RecaseError({
message: "Reward ID and name are required",
code: ErrCode.InvalidReward,
});
}
if (reward.type === RewardType.FreeProduct && !reward.free_product_id) {
throw new RecaseError({
message: "Select a free product",
code: ErrCode.InvalidReward,
});
}
if (getRewardCat(reward as Reward) === RewardCategory.Discount) {
DiscountConfigSchema.parse(reward.discount_config);
}
let promoCodes = reward.promo_codes.filter((promoCode) => {
return promoCode.code.length > 0;
});
@@ -35,7 +58,7 @@ export const constructReward = ({
let newReward = {
...reward,
...configData,
internal_id: generateId("rew"),
internal_id: internalId || generateId("rew"),
created_at: Date.now(),
org_id: orgId,
env,

View File

@@ -1,7 +1,8 @@
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import { RewardTriggerEvent } from "@autumn/shared";
import { triggerRedemption } from "./referralUtils.js";
import { RewardCategory, RewardTriggerEvent } from "@autumn/shared";
import { triggerFreeProduct, triggerRedemption } from "./referralUtils.js";
import { RewardProgramService } from "../rewards/RewardProgramService.js";
import { getRewardCat } from "./rewardUtils.js";
export const runTriggerCheckoutReward = async ({
sb,
payload,
@@ -63,15 +64,29 @@ export const runTriggerCheckoutReward = async ({
return;
}
await triggerRedemption({
sb,
referralCode,
org,
env,
logger,
reward,
redemption,
});
let rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
sb,
referralCode,
redeemer: customer,
rewardProgram: reward_program,
org,
env,
logger,
redemption,
});
} else {
await triggerRedemption({
sb,
referralCode,
org,
env,
logger,
reward,
redemption,
});
}
}
} catch (error) {
logger.error("Failed to trigger checkout reward");

View File

@@ -6,15 +6,16 @@ MOCHA_CMD="npx mocha --parallel --timeout 10000000 --ignore tests/00_setup.ts"
if [ "$1" == "basic-parallel" ]; then
MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \
tests/basic/*.ts \
tests/attach/**/*.ts \
tests/basic/entities/*.ts \
tests/basic/referrals/*.ts \
# tests/attach/**/*.ts \
# tests/basic/referrals/*.ts \
elif [ "$1" == "advanced-parallel" ]; then
MOCHA_PARALLEL=true \
$MOCHA_SETUP \
&& $MOCHA_CMD 'tests/advanced/usage/*.ts'\
&& $MOCHA_CMD 'tests/advanced/coupons/*.ts'\
# && $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\
# && $MOCHA_CMD 'tests/advanced/usage/*.ts'\
elif [ "$1" == "alex-parallel" ]; then

View File

@@ -5,7 +5,7 @@ import { getPriceForOverage } from "@/internal/prices/priceUtils.js";
import { Customer } from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addDays, addHours, addMonths, format } from "date-fns";
import { addHours, addMonths } from "date-fns";
import Stripe from "stripe";
import { AutumnCli } from "tests/cli/AutumnCli.js";
import { features, products, rewards } from "tests/global.js";
@@ -29,7 +29,7 @@ describe(
let customer: Customer;
let testClockId: string;
let couponAmount = rewards.rolloverAll.discount_value;
let couponAmount = rewards.rolloverAll.discount_config.discount_value;
before(async function () {
const { testClockId: testClockId1, customer: customer1 } =

View File

@@ -12,7 +12,6 @@ import { compareMainProduct } from "tests/utils/compare.js";
import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js";
import {
advanceClockForInvoice,
advanceTestClock,
completeCheckoutForm,
getDiscount,
} from "tests/utils/stripeUtils.js";
@@ -27,7 +26,7 @@ describe(
let customer: Customer;
let testClockId: string;
let couponAmount = rewards.rolloverUsage.discount_value;
let couponAmount = rewards.rolloverUsage.discount_config.discount_value;
before(async function () {
const { testClockId: testClockId1, customer: customer1 } =

View File

@@ -0,0 +1,149 @@
import { features, products, referralPrograms } from "../../global.js";
import { assert } from "chai";
import chalk from "chalk";
import AutumnError, { Autumn } from "@/external/autumn/autumnCli.js";
import { setupBefore } from "tests/before.js";
import {
Customer,
ErrCode,
ReferralCode,
RewardRedemption,
} from "@autumn/shared";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays, addHours, addMonths } from "date-fns";
import { Stripe } from "stripe";
import { initCustomer } from "tests/utils/init.js";
import {
checkFeatureHasCorrectBalance,
compareMainProduct,
compareProductEntitlements,
} from "tests/utils/compare.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals3: Testing free product referrals"
)}`, () => {
let mainCustomerId = "main-referral-3";
let redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
let autumn: Autumn;
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
let redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
before(async function () {
await setupBefore(this);
autumn = this.autumn;
stripeCli = this.stripeCli;
const { testClockId: testClockId1, customer } =
await initCustomerWithTestClock({
customerId: mainCustomerId,
sb: this.sb,
org: this.org,
env: this.env,
fingerprint: "main-referral-3",
});
testClockId = testClockId1;
mainCustomer = customer;
await autumn.attach({
customerId: mainCustomerId,
productId: products.proWithTrial.id,
});
let batchCreate = [];
for (let redeemer of redeemers) {
batchCreate.push(
initCustomer({
customerId: redeemer,
sb: this.sb,
org: this.org,
env: this.env,
attachPm: true,
})
);
}
await Promise.all(batchCreate);
});
it("should create code once", async function () {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async function () {
for (let redeemer of redeemers) {
let redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
redemptions.push(redemption);
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
}
// Try redeem for redeemer1 again
try {
let redemption1 = await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
it("should be triggered (and applied) when redeemers check out", async function () {
for (let i = 0; i < redeemers.length; i++) {
let redeemer = redeemers[i];
await autumn.attach({
customerId: redeemer,
productId: products.pro.id,
});
await timeout(3000);
// Get redemption object
let redemption = await autumn.redemptions.get({
redemptionId: redemptions[i].id,
});
// Check if redemption is triggered
let count = i + 1;
if (count > referralPrograms.freeProduct.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
// 1. Check that main customer has free add on
compareProductEntitlements({
customerId: mainCustomerId,
product: products.freeAddOn,
features,
quantity: count,
});
compareProductEntitlements({
customerId: redeemer,
product: products.freeAddOn,
features,
});
}
}
});
});

View File

@@ -9,7 +9,9 @@ import {
DiscountType,
EntInterval,
Feature,
RewardReceivedBy,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import { FeatureType } from "@autumn/shared";
import {
@@ -300,6 +302,20 @@ export const products = {
],
freeTrial: null,
}),
freeAddOn: initProduct({
id: "freeAddOn",
entitlements: {
metered1: initEntitlement({
feature: features.metered1,
allowance: 100,
interval: EntInterval.Lifetime,
}),
},
prices: [],
freeTrial: null,
isAddOn: true,
}),
};
export const oneTimeProducts = {
@@ -775,12 +791,14 @@ export const entityProducts = {
export const rewards = {
rolloverAll: initReward({
id: "rolloverAll",
type: RewardType.FixedDiscount,
discountValue: 1000,
rollover: true,
applyToAll: true,
}),
rolloverUsage: initReward({
id: "rolloverUsage",
type: RewardType.FixedDiscount,
discountValue: 1000,
rollover: true,
onlyUsagePrices: true,
@@ -788,12 +806,17 @@ export const rewards = {
}),
monthOff: initReward({
id: "monthOff",
discountType: DiscountType.Percentage,
type: RewardType.PercentageDiscount,
discountValue: 100,
applyToAll: true,
durationType: CouponDurationType.Months,
durationValue: 1,
}),
freeProduct: initReward({
id: "freeProduct",
type: RewardType.FreeProduct,
freeProductId: products.freeAddOn.id,
}),
};
export const referralPrograms = {
@@ -807,7 +830,13 @@ export const referralPrograms = {
id: "immediate",
internalRewardId: rewards.monthOff.id,
when: RewardTriggerEvent.CustomerCreation,
// productIds: [products.pro.id, products.proWithTrial.id],
}),
freeProduct: initRewardProgram({
id: "freeProduct",
internalRewardId: rewards.freeProduct.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.All,
productIds: [products.pro.id, products.proWithTrial.id],
}),
};

View File

@@ -180,10 +180,12 @@ export const compareProductEntitlements = ({
customerId,
product,
features,
quantity = 1,
}: {
customerId: string;
product: any;
features: Record<string, Feature>;
quantity?: number;
}) => {
for (const entitlement of Object.values(
product.entitlements
@@ -196,7 +198,7 @@ export const compareProductEntitlements = ({
customerId,
feature,
entitlement,
expectedBalance: entitlement.allowance || 0,
expectedBalance: (entitlement.allowance || 0) * quantity,
});
}
};

View File

@@ -14,6 +14,7 @@ import {
FreeTrial,
Organization,
PriceType,
RewardReceivedBy,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
@@ -339,6 +340,7 @@ export const initReward = ({
onlyUsagePrices = false,
productIds,
applyToAll = false,
freeProductId,
}: {
id: string;
type?: RewardType;
@@ -349,19 +351,36 @@ export const initReward = ({
onlyUsagePrices?: boolean;
productIds?: string[];
applyToAll?: boolean;
freeProductId?: string;
}): any => {
return {
id,
name: keyToTitle(id),
type,
discount_value: discountValue,
duration_type: durationType,
duration_value: durationValue,
should_rollover: rollover,
apply_to_all: applyToAll,
only_usage_prices: onlyUsagePrices,
product_ids: productIds,
};
if (
type == RewardType.PercentageDiscount ||
type == RewardType.FixedDiscount
) {
return {
id,
name: keyToTitle(id),
type,
only_usage_prices: onlyUsagePrices,
product_ids: productIds,
discount_config: {
discount_value: discountValue,
duration_type: durationType,
duration_value: durationValue,
should_rollover: rollover,
apply_to_all: applyToAll,
},
};
} else if (type == RewardType.FreeProduct) {
return {
id,
name: keyToTitle(id),
type,
free_product_id: freeProductId,
};
}
};
export const initRewardProgram = ({
@@ -370,12 +389,14 @@ export const initRewardProgram = ({
productIds = [],
internalRewardId,
maxRedemptions = 2,
receivedBy = RewardReceivedBy.Referrer,
}: {
id: string;
productIds?: string[];
internalRewardId: string;
when?: RewardTriggerEvent;
maxRedemptions?: number;
receivedBy?: RewardReceivedBy;
}): any => {
return {
id,
@@ -383,5 +404,6 @@ export const initRewardProgram = ({
product_ids: productIds,
internal_reward_id: internalRewardId,
max_redemptions: maxRedemptions,
received_by: receivedBy,
};
};

View File

@@ -9,6 +9,7 @@ import {
PriceType,
Reward,
RewardProgram,
RewardType,
} from "@autumn/shared";
import axios from "axios";
@@ -310,34 +311,10 @@ export const setupOrg = async ({
// Insert coupons
let insertCoupons = [];
for (const reward of Object.values(rewards)) {
const createCoupon = async () => {
const createReward = async () => {
let priceIds = [];
if (reward.only_usage_prices) {
let filteredProducts = allProducts.filter((product: FullProduct) => {
if (reward.product_ids) {
return reward.product_ids.includes(product.id);
} else return true;
});
priceIds = filteredProducts.flatMap((product: FullProduct) =>
product.prices
.filter((price: Price) => price.config!.type === PriceType.Usage)
.map((price) => {
return price.id;
})
);
} else if (reward.product_ids) {
priceIds = allProducts
.filter((product: FullProduct) =>
reward.product_ids.includes(product.id)
)
.flatMap((product: FullProduct) =>
product.prices.map((price) => price.id)
);
}
const newReward: CreateReward & { id: string } = {
let rewardData: any = {
id: reward.id,
name: reward.name,
promo_codes: [
@@ -346,8 +323,57 @@ export const setupOrg = async ({
},
],
type: reward.type,
discount_config: reward.discount_config,
free_product_id: reward.free_product_id,
};
if (reward.type === RewardType.FreeProduct) {
rewardData.free_product_id = reward.free_product_id;
} else {
if (reward.only_usage_prices) {
let filteredProducts = allProducts.filter((product: FullProduct) => {
if (reward.product_ids) {
return reward.product_ids.includes(product.id);
} else return true;
});
priceIds = filteredProducts.flatMap((product: FullProduct) =>
product.prices
.filter((price: Price) => price.config!.type === PriceType.Usage)
.map((price) => {
return price.id;
})
);
} else if (reward.product_ids) {
priceIds = allProducts
.filter((product: FullProduct) =>
reward.product_ids.includes(product.id)
)
.flatMap((product: FullProduct) =>
product.prices.map((price) => price.id)
);
}
rewardData.discount_config = {
discount_value: reward.discount_config.discount_value,
duration_type: reward.discount_config.duration_type,
duration_value: reward.discount_config.duration_value,
should_rollover: reward.discount_config.should_rollover,
apply_to_all: reward.discount_config.apply_to_all,
price_ids: priceIds,
};
}
const newReward: CreateReward & { internal_id: string } = {
internal_id: reward.id,
id: reward.id,
name: reward.name,
promo_codes: [
{
code: reward.id,
},
],
type: reward.type,
discount_config: rewardData.discount_config,
free_product_id: rewardData.free_product_id,
};
let rewardRes = await autumn.rewards.create(newReward);
@@ -356,7 +382,7 @@ export const setupOrg = async ({
rewardRes,
};
};
insertCoupons.push(createCoupon());
insertCoupons.push(createReward());
}
await Promise.all(insertCoupons);

View File

@@ -104,7 +104,8 @@ export const ErrCode = {
// Pay for invoice
PayInvoiceFailed: "invoice_payment_failed",
// COUPONS
// Rewards
InvalidReward: "invalid_reward",
PromoCodeAlreadyExistsInStripe: "promo_code_already_exists_in_stripe",
// Entity

View File

@@ -28,7 +28,7 @@ const PromoCodeSchema = z.object({
code: z.string(),
});
const DiscountConfigSchema = z.object({
export const DiscountConfigSchema = z.object({
discount_value: z.number(),
duration_type: z.nativeEnum(CouponDurationType),
duration_value: z.number(),

View File

@@ -8,6 +8,12 @@ export enum RewardTriggerEvent {
Checkout = "checkout",
}
export enum RewardReceivedBy {
Referrer = "referrer",
All = "all",
// Redeemer = "redeemer",
}
export const RewardProgram = z.object({
internal_id: z.string(),
id: z.string(),
@@ -24,6 +30,8 @@ export const RewardProgram = z.object({
org_id: z.string(),
env: z.string(),
created_at: z.number(),
received_by: z.nativeEnum(RewardReceivedBy),
});
export const CreateRewardProgram = z.object({
@@ -33,6 +41,8 @@ export const CreateRewardProgram = z.object({
exclude_trial: z.boolean().optional(),
internal_reward_id: z.string(),
max_redemptions: z.number().optional(),
received_by: z.nativeEnum(RewardReceivedBy),
});
export type RewardProgram = z.infer<typeof RewardProgram>;

View File

@@ -47,7 +47,10 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-sm bg-stone-700 shadow-md px-4 py-2 text-xs text-stone-100 max-w-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
`z-50 overflow-hidden rounded-sm bg-stone-700 shadow-md px-4 py-2 text-xs text-stone-100 max-w-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2
bg-white/50 backdrop-blur-sm shadow-sm border-1 text-t2
`,
className
)}
{...props}

View File

@@ -3,6 +3,7 @@ import {
BillingInterval,
BillWhen,
EntitlementWithFeature,
Product,
} from "@autumn/shared";
import { FixedPriceConfig, Price, UsagePriceConfig } from "@autumn/shared";
@@ -62,3 +63,9 @@ export const pricesOnlyOneOff = (prices: Price[]) => {
return price.config?.interval == BillingInterval.OneOff;
});
};
export const isFreeProduct = (prices: Price[]) => {
return prices.every((price) => {
return price.config?.interval == BillingInterval.OneOff;
});
};

View File

@@ -1,3 +1,8 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCustomerContext } from "./CustomerContext";
import { getStripeCusLink } from "@/utils/linkUtils";
import { Product } from "@autumn/shared";
@@ -5,11 +10,12 @@ import { faStripe } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ArrowUpRightFromSquare, Check } from "lucide-react";
import { Copy } from "lucide-react";
import React from "react";
import { useState } from "react";
import { Link } from "react-router";
export const CustomerDetails = () => {
const { customer, products, env, discount } = useCustomerContext();
const { customer, products, env, discount, referrals } = useCustomerContext();
const [idCopied, setIdCopied] = useState(false);
const [idHover, setIdHover] = useState(false);
@@ -116,22 +122,97 @@ export const CustomerDetails = () => {
</>
)}
<ReferralDetails />
{customer.processor?.id && (
<Link
className="!cursor-pointer hover:underline"
to={getStripeCusLink(customer.processor?.id, env)}
target="_blank"
>
<div className="flex justify-center items-center w-fit gap-2">
<FontAwesomeIcon
icon={faStripe}
className="!h-5 text-[#675DFF]"
/>
<ArrowUpRightFromSquare size={10} className="text-[#675DFF]" />
</div>
</Link>
<React.Fragment>
<Link
className="!cursor-pointer hover:underline"
to={getStripeCusLink(customer.processor?.id, env)}
target="_blank"
>
<div className="flex justify-center items-center w-fit gap-2">
<FontAwesomeIcon
icon={faStripe}
className="!h-5 text-[#675DFF]"
/>
<ArrowUpRightFromSquare size={10} className="text-[#675DFF]" />
</div>
</Link>
<p></p>
</React.Fragment>
)}
</div>
</div>
);
};
export const ReferralDetails = () => {
let { referrals } = useCustomerContext();
if (!referrals) return null;
return (
<>
{referrals.referred.length > 0 && (
<>
<p className="text-t3 text-xs font-medium">Referrals</p>
<Tooltip>
<TooltipTrigger className="flex items-center justify-start gap-1">
<p>
{referrals.referred.length}{" "}
<span className="text-t3">
({referrals.referred.filter((r: any) => r.triggered).length}{" "}
activated)
</span>
</p>
</TooltipTrigger>
<TooltipContent
className="px-2"
align="start"
side="bottom"
sideOffset={5}
>
<div
// key={referral.id}
className="flex grid grid-cols-2 gap-1 font-mono"
>
{referrals.referred.map((referral: any) => (
<>
<p className="font-medium max-w-[140px] truncate">
{referral.customer.name}
</p>
<p className="text-t2 max-w-[100px] truncate">
({referral.customer.id})
</p>
</>
))}
</div>
</TooltipContent>
</Tooltip>
</>
)}
{referrals.redeemed.length > 0 && (
<>
<p className="text-t3 text-xs font-medium">Redeemed</p>
<Tooltip>
<TooltipTrigger className="flex items-center justify-start gap-1">
<p>{referrals.redeemed[0].referral_code.code}</p>
</TooltipTrigger>
<TooltipContent
className="px-2 font-mono flex flex-col gap-1"
align="start"
side="bottom"
sideOffset={5}
>
<p className="font-medium">Referred by: </p>
<p>
{referrals.redeemed[0].referral_code?.customer.name} (
{referrals.redeemed[0].referral_code?.customer.id})
</p>
</TooltipContent>
</Tooltip>
</>
)}
</>
);
};

View File

@@ -45,6 +45,16 @@ export default function CustomerView({ env }: { env: AppEnv }) {
env,
});
const {
data: referrals,
isLoading: referralsLoading,
error: referralsError,
mutate: referralsMutate,
} = useAxiosSWR({
url: `/customers/${customer_id}/referrals`,
env,
});
const [addCouponOpen, setAddCouponOpen] = useState(false);
const [showExpired, setShowExpired] = useState(false);
@@ -84,6 +94,7 @@ export default function CustomerView({ env }: { env: AppEnv }) {
env,
cusMutate,
setAddCouponOpen,
referrals,
}}
>
<div className="flex flex-col gap-1">
@@ -108,7 +119,8 @@ export default function CustomerView({ env }: { env: AppEnv }) {
<h2 className="flex text-lg text-t1 font-medium gap-2 w-full justify-start">
{customer.name && (
<span className="min-w-0 max-w-[50%] truncate">
<AdminHover texts={[
<AdminHover
texts={[
{
key: "Internal ID",
value: customer.internal_id,

View File

@@ -91,36 +91,47 @@ export const CustomerEntitlementsList = ({
// return productA.product.name.localeCompare(productB.product.name);
// });
const sortedEntitlements = filteredEntitlements
const sortedEntitlements = filteredEntitlements;
const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => {
setSelectedCusEntitlement(cusEnt);
};
const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => {
let entitlement = cusEnt.entitlement;
let featureEntities = entities.filter((e: any) => e.feature_id === entitlement.feature.id);
let hoverTexts = [{
key: "Cus Ent ID",
value: cusEnt.id,
}]
let featureEntities = entities.filter(
(e: any) => e.feature_id === entitlement.feature.id
);
let hoverTexts = [
{
key: "Cus Ent ID",
value: cusEnt.id,
},
];
if (featureEntities.length > 0) {
hoverTexts.push({
key: "Entities",
value: featureEntities.map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted": ""}`).join("\n"),
})
value: featureEntities
.map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`)
.join("\n"),
});
} else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) {
let mappedEntities = Object.keys(cusEnt.entities).map((e: any) => {
let entity = entities.find((ee: any) => ee.id === e);
let balance = cusEnt.entities![e].balance;
return `${entity.id} (${entity.name}): ${balance}`;
}).join("\n");
let mappedEntities = Object.keys(cusEnt.entities)
.map((e: any) => {
let entity = entities.find((ee: any) => ee.id === e);
if (!entity) {
return `${e}: Deleted`;
}
let balance = cusEnt.entities![e].balance;
return `${entity.id} (${entity.name}): ${balance}`;
})
.join("\n");
hoverTexts.push({
key: "Entities",
value: mappedEntities,
})
});
}
return hoverTexts;

View File

@@ -70,7 +70,7 @@ function ProductsView({ env }: { env: AppEnv }) {
onClick={() => setShowRewards((prev) => !prev)}
>
<Ticket size={12} className="mr-2" />
Coupons
Rewards
</ToggleDisplayButton>
</div>
<ProductsTable products={data?.products} />
@@ -79,9 +79,9 @@ function ProductsView({ env }: { env: AppEnv }) {
<React.Fragment>
<div className="flex flex-col gap-4 h-fit mt-6">
<div>
<h2 className="text-lg font-medium">Coupons</h2>
<h2 className="text-lg font-medium">Rewards</h2>
<p className="text-sm text-t2">
Create a coupon to give users credits or a discount on one or
Create a reward to give users a product or a discount on one or
more products.{" "}
{/* <span className="text-t3">(eg, 10% off all products).</span> */}
</p>

View File

@@ -25,6 +25,7 @@ import {
DiscountType,
RewardProgram,
RewardTriggerEvent,
RewardReceivedBy,
} from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext";
@@ -44,6 +45,7 @@ const defaultRewardProgram: CreateRewardProgram = {
exclude_trial: false,
internal_reward_id: "",
max_redemptions: 0,
received_by: RewardReceivedBy.Referrer,
};
function CreateRewardProgramModal() {

View File

@@ -7,7 +7,12 @@ import {
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Reward, RewardProgram, RewardTriggerEvent } from "@autumn/shared";
import {
Reward,
RewardProgram,
RewardTriggerEvent,
RewardReceivedBy,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useState } from "react";
@@ -50,7 +55,7 @@ export const RewardProgramConfig = ({
/>
</div>
<div className="w-6/12">
<FieldLabel>Coupon</FieldLabel>
<FieldLabel>Reward</FieldLabel>
<Select
value={rewardProgram.internal_reward_id}
onValueChange={(value) =>
@@ -58,7 +63,7 @@ export const RewardProgramConfig = ({
}
>
<SelectTrigger>
<SelectValue placeholder="Select a coupon" />
<SelectValue placeholder="Select a reward" />
</SelectTrigger>
<SelectContent>
{rewards.map((reward: Reward) => (
@@ -109,6 +114,33 @@ export const RewardProgramConfig = ({
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FieldLabel>Received by</FieldLabel>
<Select
value={rewardProgram.received_by}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
received_by: value as RewardReceivedBy,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Who should receive the reward" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardReceivedBy).map((receivedBy) => (
<SelectItem key={receivedBy} value={receivedBy}>
{receivedBy === RewardReceivedBy.All
? "Referrer & Redeemer"
: keyToTitle(receivedBy)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{rewardProgram.when === RewardTriggerEvent.Checkout && (
<div className="w-full">

View File

@@ -67,12 +67,12 @@ function CreateReward() {
className="w-full"
startIcon={<PlusIcon size={15} />}
>
Create Coupon
Create Reward
</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Coupon</DialogTitle>
<DialogTitle>Create Reward</DialogTitle>
</DialogHeader>
<RewardConfig reward={reward as any} setReward={setReward as any} />
<DialogFooter>

View File

@@ -10,11 +10,13 @@ import {
CouponDurationType,
RewardType,
Product,
FullProduct,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { DiscountConfig } from "./DiscountConfig";
import { notNullish } from "@/utils/genUtils";
import { defaultDiscountConfig } from "./defaultRewardModels";
import { isFreeProduct } from "@/utils/product/priceUtils";
export const RewardConfig = ({
reward,
@@ -103,23 +105,44 @@ export const RewardConfig = ({
</div>
{reward.type === RewardType.FreeProduct ? (
<div>
<FieldLabel>Product</FieldLabel>
<FieldLabel description="Select a free add-on product to give away">
Product
</FieldLabel>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
setReward({ ...reward, free_product_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select a product" />
</SelectTrigger>
<SelectContent>
{products.map((product: Product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
{(() => {
const freeAddOns = products
.filter((product: FullProduct) => product.is_add_on)
.filter((product: FullProduct) =>
isFreeProduct(product.prices)
);
let empty = freeAddOns.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{freeAddOns.map((product: Product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</>
);
})()}
</Select>
</div>
) : notNullish(reward.type) ? (

View File

@@ -14,12 +14,13 @@ import {
CouponDurationType,
DiscountType,
RewardType,
Product,
} from "@autumn/shared";
import UpdateReward from "./UpdateReward";
import { useState } from "react";
import { RewardRowToolbar } from "./RewardRowToolbar";
export const RewardsTable = () => {
const { rewards, org } = useProductsContext();
const { rewards, org, products } = useProductsContext();
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
const [open, setOpen] = useState(false);
@@ -47,8 +48,8 @@ export const RewardsTable = () => {
<TableRow>
<TableHead className="">Name</TableHead>
<TableHead>Promo Codes</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Type</TableHead>
<TableHead>Reward</TableHead>
<TableHead className="min-w-0 w-28">Created At</TableHead>
<TableHead className="min-w-0 w-10"></TableHead>
@@ -71,17 +72,31 @@ export const RewardsTable = () => {
.join(", ")}
</TableCell>
<TableCell className="min-w-32">
<div className="flex items-center gap-1">
<p>{reward.discount_config?.discount_value} </p>
<p className="text-t3">
{reward.type == RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
</div>
{keyToTitle(reward.type)}
</TableCell>
<TableCell className="">
{reward.discount_config?.duration_type ==
<div className="flex items-center gap-1">
{reward.type == RewardType.FreeProduct ? (
<p>
{
products.find(
(product: Product) =>
product.id === reward.free_product_id
)?.name
}
</p>
) : (
<>
<p>{reward.discount_config?.discount_value} </p>
<p className="text-t3">
{reward.type == RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
</>
)}
</div>
{/* {reward.discount_config?.duration_type ==
CouponDurationType.Months
? `${reward.discount_config?.duration_value} months`
: reward.discount_config?.duration_type ==
@@ -91,7 +106,7 @@ export const RewardsTable = () => {
: reward.discount_config?.duration_type ==
CouponDurationType.Forever
? "Forever"
: "One-off"}
: "One-off"} */}
</TableCell>
<TableCell className="">
{formatUnixToDateTime(reward.created_at).date}