From 7ade00bcb1a0ee9af40e8f1d336bb01b7edfcb8f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 11 Apr 2025 09:33:28 +0100 Subject: [PATCH] adding free product to referrals --- server/src/external/autumn/autumnCli.ts | 4 +- .../webhookHandlers/handleInvoicePaid.ts | 7 +- .../webhookHandlers/handleSubDeleted.ts | 32 +++- .../internal/api/entities/EntityService.ts | 26 +++ .../src/internal/api/rewards/rewardRouter.ts | 32 ++-- .../src/internal/customers/CusReadService.ts | 22 +++ .../add-product/createFullCusProduct.ts | 7 +- .../internal/customers/internalCusRouter.ts | 67 ++++++++ .../customers/products/CusProductService.ts | 10 +- .../rewards/RewardRedemptionService.ts | 35 ++++ server/src/internal/rewards/referralUtils.ts | 124 ++++++++++++++- server/src/internal/rewards/rewardUtils.ts | 25 ++- .../internal/rewards/triggerCheckoutReward.ts | 37 +++-- server/test.sh | 7 +- server/tests/advanced/coupons/coupon1.ts | 4 +- server/tests/advanced/coupons/coupon2.ts | 3 +- server/tests/basic/referrals/referrals3.ts | 149 ++++++++++++++++++ server/tests/global.ts | 33 +++- server/tests/utils/compare.ts | 4 +- server/tests/utils/init.ts | 46 ++++-- server/tests/utils/setup.ts | 84 ++++++---- shared/errors/errCode.ts | 3 +- shared/models/rewardModels/rewardModels.ts | 2 +- .../rewardModels/rewardProgramModels.ts | 10 ++ vite/src/components/ui/tooltip.tsx | 5 +- vite/src/utils/product/priceUtils.ts | 7 + .../customers/customer/CustomerDetails.tsx | 109 +++++++++++-- .../views/customers/customer/CustomerView.tsx | 14 +- .../entitlements/CustomerEntitlementsList.tsx | 43 +++-- vite/src/views/products/ProductsView.tsx | 6 +- .../reward-programs/CreateRewardProgram.tsx | 2 + .../reward-programs/RewardProgramConfig.tsx | 38 ++++- .../views/products/rewards/CreateReward.tsx | 4 +- .../views/products/rewards/RewardConfig.tsx | 45 ++++-- .../views/products/rewards/RewardsTable.tsx | 41 +++-- 35 files changed, 932 insertions(+), 155 deletions(-) create mode 100644 server/src/internal/customers/CusReadService.ts create mode 100644 server/tests/basic/referrals/referrals3.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index b98e3cb12..1f7cd00bb 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -22,14 +22,14 @@ export class Autumn { public headers: Record; 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) { diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts index a50ddf709..508587193 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts @@ -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 diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts index 49152011c..32b01006f 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts @@ -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; } diff --git a/server/src/internal/api/entities/EntityService.ts b/server/src/internal/api/entities/EntityService.ts index b2b464853..87f95ce9f 100644 --- a/server/src/internal/api/entities/EntityService.ts +++ b/server/src/internal/api/entities/EntityService.ts @@ -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; + } + } } diff --git a/server/src/internal/api/rewards/rewardRouter.ts b/server/src/internal/api/rewards/rewardRouter.ts index 255ae6e3e..658dfd741 100644 --- a/server/src/internal/api/rewards/rewardRouter.ts +++ b/server/src/internal/api/rewards/rewardRouter.ts @@ -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); diff --git a/server/src/internal/customers/CusReadService.ts b/server/src/internal/customers/CusReadService.ts new file mode 100644 index 000000000..6b45b3422 --- /dev/null +++ b/server/src/internal/customers/CusReadService.ts @@ -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; + } +} diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 09376bc58..d30f48680 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -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, diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 2c01e79eb..49806c1f6 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -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) => { diff --git a/server/src/internal/customers/products/CusProductService.ts b/server/src/internal/customers/products/CusProductService.ts index c46596776..d80fd7590 100644 --- a/server/src/internal/customers/products/CusProductService.ts +++ b/server/src/internal/customers/products/CusProductService.ts @@ -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}}` ) diff --git a/server/src/internal/rewards/RewardRedemptionService.ts b/server/src/internal/rewards/RewardRedemptionService.ts index a7d652e57..df955086e 100644 --- a/server/src/internal/rewards/RewardRedemptionService.ts +++ b/server/src/internal/rewards/RewardRedemptionService.ts @@ -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, diff --git a/server/src/internal/rewards/referralUtils.ts b/server/src/internal/rewards/referralUtils.ts index a68ca65e3..76b99dcf4 100644 --- a/server/src/internal/rewards/referralUtils.ts +++ b/server/src/internal/rewards/referralUtils.ts @@ -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, + }, + }); +}; diff --git a/server/src/internal/rewards/rewardUtils.ts b/server/src/internal/rewards/rewardUtils.ts index 6cf6cd556..a661acf76 100644 --- a/server/src/internal/rewards/rewardUtils.ts +++ b/server/src/internal/rewards/rewardUtils.ts @@ -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, diff --git a/server/src/internal/rewards/triggerCheckoutReward.ts b/server/src/internal/rewards/triggerCheckoutReward.ts index 1e095614a..f95566804 100644 --- a/server/src/internal/rewards/triggerCheckoutReward.ts +++ b/server/src/internal/rewards/triggerCheckoutReward.ts @@ -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"); diff --git a/server/test.sh b/server/test.sh index 686eee1d8..aea01d685 100755 --- a/server/test.sh +++ b/server/test.sh @@ -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 diff --git a/server/tests/advanced/coupons/coupon1.ts b/server/tests/advanced/coupons/coupon1.ts index 4914d6ddd..e56e8ff1a 100644 --- a/server/tests/advanced/coupons/coupon1.ts +++ b/server/tests/advanced/coupons/coupon1.ts @@ -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 } = diff --git a/server/tests/advanced/coupons/coupon2.ts b/server/tests/advanced/coupons/coupon2.ts index 8d9e90be6..72b7feeb1 100644 --- a/server/tests/advanced/coupons/coupon2.ts +++ b/server/tests/advanced/coupons/coupon2.ts @@ -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 } = diff --git a/server/tests/basic/referrals/referrals3.ts b/server/tests/basic/referrals/referrals3.ts new file mode 100644 index 000000000..56869602c --- /dev/null +++ b/server/tests/basic/referrals/referrals3.ts @@ -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, + }); + } + } + }); +}); diff --git a/server/tests/global.ts b/server/tests/global.ts index 8ce60319c..5386a6038 100644 --- a/server/tests/global.ts +++ b/server/tests/global.ts @@ -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], }), }; diff --git a/server/tests/utils/compare.ts b/server/tests/utils/compare.ts index 4283a016d..f498ffa78 100644 --- a/server/tests/utils/compare.ts +++ b/server/tests/utils/compare.ts @@ -180,10 +180,12 @@ export const compareProductEntitlements = ({ customerId, product, features, + quantity = 1, }: { customerId: string; product: any; features: Record; + 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, }); } }; diff --git a/server/tests/utils/init.ts b/server/tests/utils/init.ts index 35abed050..14c65e9ce 100644 --- a/server/tests/utils/init.ts +++ b/server/tests/utils/init.ts @@ -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, }; }; diff --git a/server/tests/utils/setup.ts b/server/tests/utils/setup.ts index 479c9fb97..58250fd94 100644 --- a/server/tests/utils/setup.ts +++ b/server/tests/utils/setup.ts @@ -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); diff --git a/shared/errors/errCode.ts b/shared/errors/errCode.ts index 0ec0c6225..3fc3bd95f 100644 --- a/shared/errors/errCode.ts +++ b/shared/errors/errCode.ts @@ -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 diff --git a/shared/models/rewardModels/rewardModels.ts b/shared/models/rewardModels/rewardModels.ts index 986057532..cb5beb125 100644 --- a/shared/models/rewardModels/rewardModels.ts +++ b/shared/models/rewardModels/rewardModels.ts @@ -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(), diff --git a/shared/models/rewardModels/rewardProgramModels.ts b/shared/models/rewardModels/rewardProgramModels.ts index ae680e209..a342c5816 100644 --- a/shared/models/rewardModels/rewardProgramModels.ts +++ b/shared/models/rewardModels/rewardProgramModels.ts @@ -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; diff --git a/vite/src/components/ui/tooltip.tsx b/vite/src/components/ui/tooltip.tsx index ebce30535..af4f79aa7 100644 --- a/vite/src/components/ui/tooltip.tsx +++ b/vite/src/components/ui/tooltip.tsx @@ -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} diff --git a/vite/src/utils/product/priceUtils.ts b/vite/src/utils/product/priceUtils.ts index ce387dd38..2c8342e42 100644 --- a/vite/src/utils/product/priceUtils.ts +++ b/vite/src/utils/product/priceUtils.ts @@ -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; + }); +}; diff --git a/vite/src/views/customers/customer/CustomerDetails.tsx b/vite/src/views/customers/customer/CustomerDetails.tsx index d3dcf05de..27c2e15de 100644 --- a/vite/src/views/customers/customer/CustomerDetails.tsx +++ b/vite/src/views/customers/customer/CustomerDetails.tsx @@ -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 = () => { )} + + {customer.processor?.id && ( - -
- - -
- + + +
+ + +
+ +

+
)} ); }; + +export const ReferralDetails = () => { + let { referrals } = useCustomerContext(); + if (!referrals) return null; + return ( + <> + {referrals.referred.length > 0 && ( + <> +

Referrals

+ + + +

+ {referrals.referred.length}{" "} + + ({referrals.referred.filter((r: any) => r.triggered).length}{" "} + activated) + +

+
+ +
+ {referrals.referred.map((referral: any) => ( + <> +

+ {referral.customer.name} +

+

+ ({referral.customer.id}) +

+ + ))} +
+
+
+ + )} + {referrals.redeemed.length > 0 && ( + <> +

Redeemed

+ + +

{referrals.redeemed[0].referral_code.code}

+
+ +

Referred by:

+

+ {referrals.redeemed[0].referral_code?.customer.name} ( + {referrals.redeemed[0].referral_code?.customer.id}) +

+
+
+ + )} + + ); +}; diff --git a/vite/src/views/customers/customer/CustomerView.tsx b/vite/src/views/customers/customer/CustomerView.tsx index 81d04a4e0..9b1bc50a7 100644 --- a/vite/src/views/customers/customer/CustomerView.tsx +++ b/vite/src/views/customers/customer/CustomerView.tsx @@ -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, }} >
@@ -108,7 +119,8 @@ export default function CustomerView({ env }: { env: AppEnv }) {

{customer.name && ( - { 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; diff --git a/vite/src/views/products/ProductsView.tsx b/vite/src/views/products/ProductsView.tsx index 49505cfcd..84d0a0644 100644 --- a/vite/src/views/products/ProductsView.tsx +++ b/vite/src/views/products/ProductsView.tsx @@ -70,7 +70,7 @@ function ProductsView({ env }: { env: AppEnv }) { onClick={() => setShowRewards((prev) => !prev)} > - Coupons + Rewards

@@ -79,9 +79,9 @@ function ProductsView({ env }: { env: AppEnv }) {
-

Coupons

+

Rewards

- 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.{" "} {/* (eg, 10% off all products). */}

diff --git a/vite/src/views/products/reward-programs/CreateRewardProgram.tsx b/vite/src/views/products/reward-programs/CreateRewardProgram.tsx index 65c472c85..64d95ba9f 100644 --- a/vite/src/views/products/reward-programs/CreateRewardProgram.tsx +++ b/vite/src/views/products/reward-programs/CreateRewardProgram.tsx @@ -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() { diff --git a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/reward-programs/RewardProgramConfig.tsx index 7bea6b44b..8764c0a7a 100644 --- a/vite/src/views/products/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/reward-programs/RewardProgramConfig.tsx @@ -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 = ({ />
- Coupon + Reward + setRewardProgram({ + ...rewardProgram, + received_by: value as RewardReceivedBy, + }) + } + > + + + + + {Object.values(RewardReceivedBy).map((receivedBy) => ( + + {receivedBy === RewardReceivedBy.All + ? "Referrer & Redeemer" + : keyToTitle(receivedBy)} + + ))} + + +
+
{rewardProgram.when === RewardTriggerEvent.Checkout && (
diff --git a/vite/src/views/products/rewards/CreateReward.tsx b/vite/src/views/products/rewards/CreateReward.tsx index 6cb74531f..df8e75cf4 100644 --- a/vite/src/views/products/rewards/CreateReward.tsx +++ b/vite/src/views/products/rewards/CreateReward.tsx @@ -67,12 +67,12 @@ function CreateReward() { className="w-full" startIcon={} > - Create Coupon + Create Reward - Create Coupon + Create Reward diff --git a/vite/src/views/products/rewards/RewardConfig.tsx b/vite/src/views/products/rewards/RewardConfig.tsx index 8394809aa..7dc3db8ba 100644 --- a/vite/src/views/products/rewards/RewardConfig.tsx +++ b/vite/src/views/products/rewards/RewardConfig.tsx @@ -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 = ({
{reward.type === RewardType.FreeProduct ? (
- Product + + Product +
) : notNullish(reward.type) ? ( diff --git a/vite/src/views/products/rewards/RewardsTable.tsx b/vite/src/views/products/rewards/RewardsTable.tsx index 9e01e6a3b..4ab369d4f 100644 --- a/vite/src/views/products/rewards/RewardsTable.tsx +++ b/vite/src/views/products/rewards/RewardsTable.tsx @@ -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(null); const [open, setOpen] = useState(false); @@ -47,8 +48,8 @@ export const RewardsTable = () => { Name Promo Codes - Discount - Duration + Type + Reward Created At @@ -71,17 +72,31 @@ export const RewardsTable = () => { .join(", ")} -
-

{reward.discount_config?.discount_value}

-

- {reward.type == RewardType.PercentageDiscount - ? "%" - : org?.default_currency || "USD"} -

-
+ {keyToTitle(reward.type)}
- {reward.discount_config?.duration_type == +
+ {reward.type == RewardType.FreeProduct ? ( +

+ { + products.find( + (product: Product) => + product.id === reward.free_product_id + )?.name + } +

+ ) : ( + <> +

{reward.discount_config?.discount_value}

+

+ {reward.type == RewardType.PercentageDiscount + ? "%" + : org?.default_currency || "USD"} +

+ + )} +
+ {/* {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"} */}
{formatUnixToDateTime(reward.created_at).date}