From 44b459d4524871fccd7f1687160bdfb4dc17bc7d Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 9 Apr 2025 10:45:08 +0100 Subject: [PATCH] moving to qa referrals --- server/src/external/stripe/stripeWebhooks.ts | 11 + .../handleCusDiscountDeleted.ts | 71 +++++++ server/src/internal/api/apiRouter.ts | 6 +- .../src/internal/api/customers/cusRouter.ts | 35 +--- server/src/internal/api/customers/cusUtils.ts | 26 ++- .../internal/api/rewards/referralRouter.ts | 193 ++++++++++++++++++ .../api/rewards/rewardTriggerRouter.ts | 130 +----------- .../add-product/createFullCusProduct.ts | 28 ++- .../products/internalProductRouter.ts | 26 ++- .../rewards/RewardRedemptionService.ts | 153 ++++++++++++++ .../internal/rewards/RewardTriggerService.ts | 131 ++++++++++-- server/src/internal/rewards/referralUtils.ts | 83 ++++++++ .../internal/rewards/triggerCheckoutReward.ts | 92 +++++++++ server/src/queue/JobName.ts | 2 + server/src/queue/queue.ts | 11 + shared/errors/errCode.ts | 3 + shared/index.ts | 1 + .../referralModels/referralModels.ts | 9 +- shared/models/rewardModels/rewardModels.ts | 1 + .../rewardModels/rewardTriggerModels.ts | 22 +- .../views/products/coupons/CouponConfig.tsx | 12 +- .../products/product/ConfirmMigrateDialog.tsx | 78 +++++++ .../views/products/product/ManageProduct.tsx | 13 +- .../reward-triggers/CreateRewardTriger.tsx | 12 +- .../reward-triggers/RewardTriggerConfig.tsx | 185 ++++++++++++++--- .../RewardTriggerRowToolbar.tsx | 68 ++++++ .../reward-triggers/RewardTriggersTable.tsx | 102 ++++----- 27 files changed, 1200 insertions(+), 304 deletions(-) create mode 100644 server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts create mode 100644 server/src/internal/api/rewards/referralRouter.ts create mode 100644 server/src/internal/rewards/RewardRedemptionService.ts create mode 100644 server/src/internal/rewards/triggerCheckoutReward.ts create mode 100644 vite/src/views/products/product/ConfirmMigrateDialog.tsx create mode 100644 vite/src/views/products/reward-triggers/RewardTriggerRowToolbar.tsx diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index a069b4eb6..84d67a5df 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -18,6 +18,7 @@ import { createLogtail, createLogtailWithContext, } from "../logtail/logtailUtils.js"; +import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; export const stripeWebhookRouter = express.Router(); @@ -158,6 +159,16 @@ stripeWebhookRouter.post( logger, }); break; + + case "customer.discount.deleted": + await handleCusDiscountDeleted({ + sb: request.sb, + org, + discount: event.data.object, + env, + logger, + }); + break; } } catch (error) { handleRequestError({ diff --git a/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts b/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts new file mode 100644 index 000000000..f73b8511a --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts @@ -0,0 +1,71 @@ +import { CusService } from "@/internal/customers/CusService.js"; +import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; +import { createStripeCli } from "../utils.js"; + +export async function handleCusDiscountDeleted({ + sb, + org, + discount, + env, + logger, +}: { + sb: any; + org: any; + discount: any; + env: any; + logger: any; +}) { + let customer = await CusService.getByStripeId({ + sb, + stripeId: discount.customer, + }); + + if (customer.env !== env || customer.org_id !== org.id) { + logger.info(`discount.deleted: env or org mismatch, skipping`); + return; + } + + if (!customer) { + logger.warn(`discount.deleted: customer ${discount.customer} not found`); + return; + } + + // Check if any redemptions available, and apply to customer if so + let redemptions = await RewardRedemptionService.getUnappliedRedemptions({ + sb, + internalCustomerId: customer.internal_id, + }); + + if (redemptions.length == 0) { + // logger.info( + // `discount.deleted: no redemptions available for customer ${customer.id}` + // ); + return; + } + + let redemption = redemptions[0]; + let reward = redemption.reward_trigger.reward; + + // Apply redemption to customer + let stripeCli = createStripeCli({ + org, + env, + }); + + await stripeCli.customers.update(discount.customer, { + coupon: reward.internal_id, + }); + + await RewardRedemptionService.update({ + sb, + id: redemption.id, + updates: { + applied: true, + }, + }); + + logger.info( + `discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})` + ); + logger.info(`Redemption ID: ${redemption.id}`); +} diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 415eade68..3506133e5 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -15,10 +15,8 @@ import { invoiceRouter } from "./customers/invoiceRouter.js"; import { entityRouter } from "./entities/entityRouter.js"; import { migrationRouter } from "./migrations/migrationRouter.js"; import rewardRouter from "./rewards/rewardRouter.js"; -import { - referralRouter, - rewardTriggerRouter, -} from "./rewards/rewardTriggerRouter.js"; +import { rewardTriggerRouter } from "./rewards/rewardTriggerRouter.js"; +import { referralRouter } from "./rewards/referralRouter.js"; const apiRouter = Router(); diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index c98155f35..0ba1b9fba 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -1,32 +1,19 @@ -import { - AppEnv, - CreateCustomer, - CreateCustomerSchema, - CusProductStatus, - Customer, - CustomerResponseSchema, -} from "@autumn/shared"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; + +import { CreateCustomerSchema, CustomerResponseSchema } from "@autumn/shared"; import { ErrCode } from "@autumn/shared"; import { ErrorMessages } from "@/errors/errMessages.js"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; import { CusService } from "../../customers/CusService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { EventService } from "../events/EventService.js"; import { getCustomerDetails } from "./cusUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { getCusBalancesByEntitlement } from "@/internal/customers/entitlements/cusEntUtils.js"; -import { - fullCusProductToCusEnts, - fullCusProductToCusPrices, -} from "@/internal/customers/products/cusProductUtils.js"; import { deleteCusById } from "./handlers/cusDeleteHandlers.js"; import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js"; import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js"; import { handleCusProductExpired } from "./handlers/handleCusProductExpired.js"; import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js"; - import { handlePostCustomerRequest } from "./handlers/handleCreateCustomer.js"; import { notNullish } from "@/utils/genUtils.js"; import { entityRouter } from "../entities/entityRouter.js"; @@ -69,7 +56,6 @@ cusRouter.get("", async (req: any, res: any) => { cusRouter.post("", handlePostCustomerRequest); - // BY CUSTOMER ID cusRouter.get("/:customer_id", async (req: any, res: any) => { @@ -94,7 +80,8 @@ cusRouter.get("/:customer_id", async (req: any, res: any) => { return; } - const { main, addOns, balances, invoices } = await getCustomerDetails({ + // const { main, addOns, balances, invoices } = + let cusData = await getCustomerDetails({ customer, sb: req.sb, orgId: req.orgId, @@ -103,13 +90,7 @@ cusRouter.get("/:customer_id", async (req: any, res: any) => { logger: req.logtail, }); - res.status(200).json({ - customer: CustomerResponseSchema.parse(customer), - products: main, - add_ons: addOns, - entitlements: balances, - invoices, - }); + res.status(200).json(cusData); } catch (error) { handleRequestError({ req, error, res, action: "get customer" }); } @@ -131,8 +112,6 @@ cusRouter.delete("/:customer_id", async (req: any, res: any) => { } }); - - cusRouter.post("/:customer_id", async (req: any, res: any) => { try { const customerId = req.params.customer_id; @@ -311,8 +290,6 @@ cusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus); cusRouter.use("/:customer_id/entities", entityRouter); - - // cusRouter.put("", async (req: any, res: any) => { // try { // const { id, name, email, fingerprint, reset_at } = req.body; @@ -373,4 +350,4 @@ cusRouter.use("/:customer_id/entities", entityRouter); // } catch (error) { // handleRequestError({ req, error, res, action: "update customer" }); // } -// }); \ No newline at end of file +// }); diff --git a/server/src/internal/api/customers/cusUtils.ts b/server/src/internal/api/customers/cusUtils.ts index bccb80e8e..c62c4bb19 100644 --- a/server/src/internal/api/customers/cusUtils.ts +++ b/server/src/internal/api/customers/cusUtils.ts @@ -5,6 +5,7 @@ import { CusProductStatus, Customer, CustomerData, + CustomerResponseSchema, CustomerSchema, ErrCode, FullCusProduct, @@ -62,7 +63,7 @@ export const updateCustomerDetails = async ({ if (!customer.email && customerData?.email) { updates.email = customerData.email; } - + if (Object.keys(updates).length > 0) { logger.info(`Updating customer details`, { updates }); customer = await CusService.update({ @@ -73,8 +74,7 @@ export const updateCustomerDetails = async ({ } return customer; -} - +}; export const getOrCreateCustomer = async ({ sb, @@ -94,7 +94,7 @@ export const getOrCreateCustomer = async ({ skipGet?: boolean; }) => { let customer; - + if (!skipGet) { customer = await CusService.getByIdOrInternalId({ sb, @@ -119,7 +119,7 @@ export const getOrCreateCustomer = async ({ }, logger, }); - } + } customer = await updateCustomerDetails({ sb, @@ -342,11 +342,18 @@ export const getCustomerDetails = async ({ org, }); + // return { + // customer, + // main, + // addOns, + // balances, + // invoices: processedInvoices, + // }; return { - customer, - main, - addOns, - balances, + customer: CustomerResponseSchema.parse(customer), + products: main, + add_ons: addOns, + entitlements: balances, invoices: processedInvoices, }; }; @@ -399,7 +406,6 @@ export const getCusEntsInFeatures = async ({ } sortCusEntsForDeduction(cusEnts, reverseOrder); - if (!withPrices) { return { cusEnts, cusPrices: undefined }; diff --git a/server/src/internal/api/rewards/referralRouter.ts b/server/src/internal/api/rewards/referralRouter.ts new file mode 100644 index 000000000..fe81d778a --- /dev/null +++ b/server/src/internal/api/rewards/referralRouter.ts @@ -0,0 +1,193 @@ +import { CusService } from "@/internal/customers/CusService.js"; +import { + generateReferralCode, + triggerRedemption, +} from "@/internal/rewards/referralUtils.js"; +import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; +import { RewardTriggerService } from "@/internal/rewards/RewardTriggerService.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { generateId } from "@/utils/genUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import { ErrCode, RewardTriggerEvent } from "@autumn/shared"; +import express from "express"; +import { ReferralCode, RewardRedemption } from "@autumn/shared"; +import { OrgService } from "@/internal/orgs/OrgService.js"; + +export const referralRouter = express.Router(); + +// 1. Get referral code +referralRouter.post("/code", (req, res) => + routeHandler({ + req, + res, + action: "get referral code", + handler: async (req: any, res: any) => { + const { orgId, env, logtail: logger } = req; + const { referral_id: rewardTriggerId, customer_id: customerId } = + req.body; + + let rewardTrigger = await RewardTriggerService.getById({ + sb: req.sb, + id: rewardTriggerId, + orgId, + env, + errorIfNotFound: true, + }); + + let customer = await CusService.getById({ + sb: req.sb, + orgId, + env, + id: customerId, + logger, + }); + + if (!customer) { + throw new RecaseError({ + message: "Customer not found", + statusCode: 404, + code: ErrCode.CustomerNotFound, + }); + } + + // Get referral code by customer and reward trigger + let existingReferralCode = + await RewardTriggerService.getCodeByCustomerAndRewardTrigger({ + sb: req.sb, + orgId, + env, + internalCustomerId: customer.internal_id, + internalRewardTriggerId: rewardTrigger.internal_id, + }); + + if (existingReferralCode) { + return res.status(200).json(existingReferralCode); + } + + // Get random 8 letter code + const code = generateReferralCode(); + + let referralCode: ReferralCode = { + code, + org_id: orgId, + env, + internal_customer_id: customer.internal_id, + internal_reward_trigger_id: rewardTrigger.internal_id, + id: generateId("rc"), + created_at: Date.now(), + }; + + await RewardTriggerService.createReferralCode({ + sb: req.sb, + data: referralCode, + }); + + res.status(200).json(referralCode); + }, + }) +); + +referralRouter.post("/redeem", (req, res) => + routeHandler({ + req, + res, + action: "redeem referral code", + handler: async (req: any, res: any) => { + const { orgId, env, logtail: logger } = req; + // const { referral_id: rewardTriggerId } = req.params; + const { code, customer_id: customerId } = req.body; + + // 1. Get redeemed by customer, and referral code + let [customer, referralCode, org] = await Promise.all([ + CusService.getById({ + sb: req.sb, + orgId, + env, + id: customerId, + logger, + }), + RewardTriggerService.getReferralCode({ + sb: req.sb, + orgId, + env, + code, + withRewardTrigger: true, + }), + OrgService.getFromReq(req), + ]); + + if (!customer) { + throw new RecaseError({ + message: "Customer not found", + statusCode: 404, + code: ErrCode.CustomerNotFound, + }); + } + + // 2. Check that code has not reached max redemptions + let redemptionCount = await RewardTriggerService.getCodeRedemptionCount({ + sb: req.sb, + referralCodeId: referralCode.id, + }); + + if (redemptionCount >= referralCode.reward_trigger.max_redemptions) { + throw new RecaseError({ + message: "Referral code has reached max redemptions", + statusCode: 400, + code: ErrCode.ReferralCodeMaxRedemptionsReached, + }); + } + + // 3. Check that customer has not already redeemed a code in this referral program + let existingRedemptions = await RewardRedemptionService.getByCustomer({ + sb: req.sb, + internalCustomerId: customer.internal_id, + internalRewardTriggerId: referralCode.internal_reward_trigger_id, + }); + + if (existingRedemptions.length > 0) { + throw new RecaseError({ + message: `Customer ${customer.id} has already redeemed a code in this referral program`, + statusCode: 400, + code: ErrCode.CustomerAlreadyRedeemedReferralCode, + }); + } + + // 4. Insert redemption into db + let redemption: RewardRedemption = { + id: generateId("rr"), + referral_code_id: referralCode.id, + internal_customer_id: customer.internal_id, // redeemed by customer + internal_reward_trigger_id: referralCode.internal_reward_trigger_id, + created_at: Date.now(), + triggered: + referralCode.reward_trigger.when === RewardTriggerEvent.Immediately, + applied: false, + updated_at: Date.now(), + }; + + redemption = await RewardRedemptionService.insert({ + sb: req.sb, + rewardRedemption: redemption, + }); + + // 5. If reward trigger when is immediate: + let { reward_trigger } = referralCode; + + if (referralCode.reward_trigger.when === RewardTriggerEvent.Immediately) { + redemption = await triggerRedemption({ + sb: req.sb, + referralCode, + org, + env, + logger, + reward: reward_trigger.reward, + redemption, + }); + } + + // Add coupon to customer? + res.status(200).json(redemption); + }, + }) +); diff --git a/server/src/internal/api/rewards/rewardTriggerRouter.ts b/server/src/internal/api/rewards/rewardTriggerRouter.ts index 9aac172f3..2097823ac 100644 --- a/server/src/internal/api/rewards/rewardTriggerRouter.ts +++ b/server/src/internal/api/rewards/rewardTriggerRouter.ts @@ -1,12 +1,7 @@ -import { CusService } from "@/internal/customers/CusService.js"; -import { generateReferralCode } from "@/internal/rewards/referralUtils.js"; import { RewardTriggerService } from "@/internal/rewards/RewardTriggerService.js"; import { constructRewardTrigger } from "@/internal/rewards/rewardTriggerUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { generateId } from "@/utils/genUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { CreateRewardTrigger, ErrCode } from "@autumn/shared"; -import { ReferralCode } from "@shared/models/rewardModels/referralModels/referralModels.js"; +import { CreateRewardTrigger } from "@autumn/shared"; import express from "express"; export const rewardTriggerRouter = express.Router(); @@ -24,8 +19,6 @@ rewardTriggerRouter.post("", (req, res) => env, }); - console.log("rewardTrigger", rewardTrigger); - let createdRewardTrigger = await RewardTriggerService.createRewardTrigger( { sb: req.sb, @@ -38,130 +31,23 @@ rewardTriggerRouter.post("", (req, res) => }) ); -export const referralRouter = express.Router(); - -// 1. Get referral code -referralRouter.post("/code", (req, res) => +rewardTriggerRouter.delete("/:id", (req, res) => routeHandler({ req, res, - action: "get referral code", + action: "delete reward trigger", handler: async (req: any, res: any) => { - const { orgId, env, logtail: logger } = req; - const { referral_id: rewardTriggerId, customer_id: customerId } = - req.body; + const { orgId, env } = req; + const { id } = req.params; - let rewardTrigger = await RewardTriggerService.getById({ + let rewardTrigger = await RewardTriggerService.deleteById({ sb: req.sb, - id: rewardTriggerId, + id, orgId, env, }); - let customer = await CusService.getById({ - sb: req.sb, - orgId, - env, - id: customerId, - logger, - }); - - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - statusCode: 404, - code: ErrCode.CustomerNotFound, - }); - } - - // Get random 8 letter code - const code = generateReferralCode(); - - let referralCode: ReferralCode = { - code, - org_id: orgId, - env, - internal_customer_id: customer.internal_id, - internal_reward_trigger_id: rewardTrigger.internal_id, - id: generateId("rc"), - created_at: Date.now(), - }; - - await RewardTriggerService.createReferralCode({ - sb: req.sb, - data: referralCode, - }); - - res.status(200).json(referralCode); - }, - }) -); - -referralRouter.post("/redeem", (req, res) => - routeHandler({ - req, - res, - action: "redeem referral code", - handler: async (req: any, res: any) => { - const { orgId, env, logtail: logger } = req; - // const { referral_id: rewardTriggerId } = req.params; - const { code, customer_id: customerId } = req.body; - - // Redeemed by customer_id - - let [customer, referralCode] = await Promise.all([ - CusService.getById({ - sb: req.sb, - orgId, - env, - id: customerId, - logger, - }), - RewardTriggerService.getReferralCode({ - sb: req.sb, - orgId, - env, - code, - withRewardTrigger: true, - }), - ]); - - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - statusCode: 404, - code: ErrCode.CustomerNotFound, - }); - } - - console.log("Referral code:", referralCode); - - // let maxRedemptions = referralCode.reward_trigger.max_redemptions; - - let redemptionCount = await RewardTriggerService.getCodeRedemptionCount({ - sb: req.sb, - orgId, - env, - code, - }); - - if (redemptionCount >= referralCode.reward_trigger.max_redemptions) { - throw new RecaseError({ - message: "Referral code has reached max redemptions", - statusCode: 400, - code: ErrCode.ReferralCodeMaxRedemptionsReached, - }); - } - - // console.log("Redemption count:", redemptionCount); - - // Add coupon to customer? - - res.status(200).json({ - referralCode, - }); - - // 1. Check how many times code has been redeemed + return res.status(200).json(rewardTrigger); }, }) ); diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 14dac9a06..09376bc58 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -1,30 +1,24 @@ import { - AllowanceType, CusProductStatus, Price, ProcessorType, - EntInterval, CustomerEntitlement, CusProduct, FeatureOptions, FreeTrial, - BillingType, CollectionMethod, - Organization, - AppEnv, FullCusProduct, - FullCustomerEntitlement, LoggerAction, } from "@autumn/shared"; -import { generateId, notNullish, nullish } from "@/utils/genUtils.js"; -import { getNextEntitlementReset } from "@/utils/timeUtils.js"; -import { Customer, FeatureType } from "@autumn/shared"; -import { EntitlementWithFeature, FullProduct } from "@autumn/shared"; +import { generateId, notNullish } from "@/utils/genUtils.js"; + +import { Customer } from "@autumn/shared"; +import { FullProduct } from "@autumn/shared"; import { SupabaseClient } from "@supabase/supabase-js"; import { ErrCode } from "@/errors/errCodes.js"; import { StatusCodes } from "http-status-codes"; import RecaseError from "@/utils/errorUtils.js"; -import { getBillingType, getEntOptions } from "@/internal/prices/priceUtils.js"; +import { getEntOptions } from "@/internal/prices/priceUtils.js"; import { CustomerPrice } from "@autumn/shared"; import { CusProductService } from "../products/CusProductService.js"; import { InsertCusProductParams } from "../products/AttachParams.js"; @@ -37,6 +31,8 @@ import { searchCusProducts } from "@/internal/customers/products/cusProductUtils import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js"; import { initCusEntitlement } from "./initCusEnt.js"; import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { JobName } from "@/queue/JobName.js"; export const initCusPrice = ({ price, customer, @@ -419,6 +415,16 @@ export const createFullCusProduct = async ({ cusPrices, }); + await addTaskToQueue({ + jobName: JobName.TriggerCheckoutReward, + payload: { + customer, + product, + org, + env: customer.env, + }, + }); + // // Send webhook // await sendSvixEvent({ // org: customer.org, diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index e9615b77d..f6815bf00 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -14,6 +14,7 @@ import { getLatestProducts } from "./productUtils.js"; import { CusProductService } from "../customers/products/CusProductService.js"; import { CusProdReadService } from "../customers/products/CusProdReadService.js"; import { MigrationService } from "../migrations/MigrationService.js"; +import { RewardTriggerService } from "../rewards/RewardTriggerService.js"; export const productRouter = Router({ mergeParams: true }); @@ -26,17 +27,19 @@ productRouter.get("/data", async (req: any, res) => { orgId: req.orgId, }); - const [products, features, org, coupons] = await Promise.all([ - ProductService.getFullProducts({ - sb, - orgId: req.orgId, - env: req.env, - returnAll: true, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - RewardService.getAll({ sb, orgId: req.orgId, env: req.env }), - ]); + const [products, features, org, coupons, rewardTriggers] = + await Promise.all([ + ProductService.getFullProducts({ + sb, + orgId: req.orgId, + env: req.env, + returnAll: true, + }), + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + RewardService.getAll({ sb, orgId: req.orgId, env: req.env }), + RewardTriggerService.getAll({ sb, orgId: req.orgId, env: req.env }), + ]); res.status(200).json({ products: getLatestProducts(products), @@ -50,6 +53,7 @@ productRouter.get("/data", async (req: any, res) => { default_currency: org.default_currency, }, coupons, + rewardTriggers, }); } catch (error) { console.error("Failed to get products", error); diff --git a/server/src/internal/rewards/RewardRedemptionService.ts b/server/src/internal/rewards/RewardRedemptionService.ts new file mode 100644 index 000000000..6f200f1fe --- /dev/null +++ b/server/src/internal/rewards/RewardRedemptionService.ts @@ -0,0 +1,153 @@ +import { notNullish } from "@/utils/genUtils.js"; +import { RewardRedemption, RewardTriggerEvent } from "@autumn/shared"; + +export class RewardRedemptionService { + static async getByCustomer({ + sb, + internalCustomerId, + internalRewardTriggerId, + triggered, + withReferralCode = false, + withRewardTrigger, + triggerWhen, + }: { + sb: any; + internalCustomerId: string; + triggered?: boolean; + withReferralCode?: boolean; + withRewardTrigger?: boolean; + internalRewardTriggerId?: string; + triggerWhen?: RewardTriggerEvent; + }) { + let query = sb + .from("reward_redemptions") + .select( + ` + * + ${ + withRewardTrigger + ? ", reward_trigger:reward_triggers!inner(*, reward:rewards!inner(*))" + : "" + } + ${withReferralCode ? ", referral_code:referral_codes!inner(*)" : ""} + ` + ) + .eq("internal_customer_id", internalCustomerId); + + if (notNullish(internalRewardTriggerId)) { + query = query.eq("internal_reward_trigger_id", internalRewardTriggerId); + } + + if (notNullish(triggered)) { + query = query.eq("triggered", triggered); + } + + // if (notNullish(triggerWhen)) { + // query = query.eq("reward_trigger.when", triggerWhen); + // } + + const { data, error } = await query; + + if (error) { + throw error; + } + + return data; + } + + static async getByCodeAndCustomer({ + sb, + orgId, + env, + code, + internalCustomerId, + }: { + sb: any; + orgId: string; + env: string; + code: string; + internalCustomerId: string; + }) { + const { data, error } = await sb + .from("reward_redemptions") + .select("*") + .eq("code", code) + .eq("internal_customer_id", internalCustomerId); + + if (error) { + throw error; + } + + if (data.length === 0) { + return null; + } + + return data[0]; + } + + static async insert({ + sb, + rewardRedemption, + }: { + sb: any; + rewardRedemption: RewardRedemption; + }) { + const { data, error } = await sb + .from("reward_redemptions") + .insert(rewardRedemption) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + } + + static async update({ + sb, + id, + updates, + }: { + sb: any; + id: string; + updates: any; + }) { + const { data, error } = await sb + .from("reward_redemptions") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + } + + static async getUnappliedRedemptions({ + sb, + internalCustomerId, + }: { + sb: any; + internalCustomerId: string; + }) { + const { data, error } = await sb + .from("reward_redemptions") + .select( + "*, referral_code:referral_codes!inner(*), reward_trigger:reward_triggers!inner(*, reward:rewards!inner(*))" + ) + .eq("referral_code.internal_customer_id", internalCustomerId) + .eq("triggered", true) + .eq("applied", false); + + if (error) { + throw error; + } + + return data; + } +} diff --git a/server/src/internal/rewards/RewardTriggerService.ts b/server/src/internal/rewards/RewardTriggerService.ts index 5ae620178..97e3cbc76 100644 --- a/server/src/internal/rewards/RewardTriggerService.ts +++ b/server/src/internal/rewards/RewardTriggerService.ts @@ -1,25 +1,73 @@ -import { RewardTrigger } from "@autumn/shared"; +import RecaseError from "@/utils/errorUtils.js"; +import { ErrCode, RewardTrigger } from "@autumn/shared"; import { ReferralCode } from "@shared/models/rewardModels/referralModels/referralModels.js"; export class RewardTriggerService { + static async get({ sb, internalId }: { sb: any; internalId: string }) { + const { data, error } = await sb + .from("reward_triggers") + .select("*, reward:rewards!inner(*)") + .eq("internal_id", internalId); + + if (error) { + throw error; + } + + return data[0]; + } + static async getById({ sb, id, orgId, env, + errorIfNotFound = false, }: { sb: any; id: string; orgId: string; env: string; + errorIfNotFound?: boolean; }) { const { data, error } = await sb .from("reward_triggers") .select() .eq("id", id) .eq("org_id", orgId) - .eq("env", env) - .single(); + .eq("env", env); + + if (error) { + throw error; + } + + if (data.length === 0) { + if (errorIfNotFound) { + throw new RecaseError({ + message: "Referral not found", + code: ErrCode.ReferralNotFound, + }); + } + + return null; + } + + return data[0]; + } + + static async getAll({ + sb, + orgId, + env, + }: { + sb: any; + orgId: string; + env: string; + }) { + const { data, error } = await sb + .from("reward_triggers") + .select() + .eq("org_id", orgId) + .eq("env", env); if (error) { throw error; @@ -49,6 +97,34 @@ export class RewardTriggerService { return insertedData; } + static async deleteById({ + sb, + id, + orgId, + env, + }: { + sb: any; + id: string; + orgId: string; + env: string; + }) { + const { data, error } = await sb + .from("reward_triggers") + .delete() + .eq("id", id) + .eq("org_id", orgId) + .eq("env", env) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + } + + // REFERRAL CODE FUNCTIONS static async getReferralCode({ sb, orgId, @@ -65,7 +141,9 @@ export class RewardTriggerService { const { data, error } = await sb .from("referral_codes") .select( - withRewardTrigger ? "*, reward_trigger:reward_triggers!inner(*)" : "*" + withRewardTrigger + ? "*, reward_trigger:reward_triggers!inner(*, reward:rewards!inner(*))" + : "*" ) .eq("code", code) .eq("org_id", orgId) @@ -79,6 +157,38 @@ export class RewardTriggerService { return data; } + static async getCodeByCustomerAndRewardTrigger({ + sb, + orgId, + env, + internalCustomerId, + internalRewardTriggerId, + }: { + sb: any; + orgId: string; + env: string; + internalCustomerId: string; + internalRewardTriggerId: string; + }) { + const { data, error } = await sb + .from("referral_codes") + .select("*") + .eq("internal_customer_id", internalCustomerId) + .eq("internal_reward_trigger_id", internalRewardTriggerId) + .eq("org_id", orgId) + .eq("env", env); + + if (error) { + throw error; + } + + if (data.length === 0) { + return null; + } + + return data[0]; + } + static async createReferralCode({ sb, data, @@ -101,21 +211,16 @@ export class RewardTriggerService { static async getCodeRedemptionCount({ sb, - orgId, - env, - code, + referralCodeId, }: { sb: any; - orgId: string; - env: string; - code: string; + referralCodeId: string; }) { const { data, error, count } = await sb .from("reward_redemptions") .select("*, reward_trigger:reward_triggers!inner(*)", { count: "exact" }) - .eq("code", code) - .eq("reward_trigger.org_id", orgId) - .eq("reward_trigger.env", env); + .eq("referral_code_id", referralCodeId) + .eq("triggered", true); if (error) { throw error; diff --git a/server/src/internal/rewards/referralUtils.ts b/server/src/internal/rewards/referralUtils.ts index 7ed68edf8..dd2cd638c 100644 --- a/server/src/internal/rewards/referralUtils.ts +++ b/server/src/internal/rewards/referralUtils.ts @@ -1,3 +1,17 @@ +import { + AppEnv, + ReferralCode, + Reward, + RewardRedemption, + RewardTrigger, +} from "@autumn/shared"; +import { RewardTriggerService } from "./RewardTriggerService.js"; +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"; + export const generateReferralCode = () => { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const codeLength = 6; @@ -10,3 +24,72 @@ export const generateReferralCode = () => { return code; }; + +// Trigger reward +export const triggerRedemption = async ({ + sb, + referralCode, + org, + env, + logger, + reward, + redemption, +}: { + sb: any; + org: any; + env: AppEnv; + logger: any; + referralCode: ReferralCode; + reward: Reward; + redemption: RewardRedemption; +}) => { + logger.info( + `Triggering redemption ${redemption.id} for referral code ${referralCode.code}` + ); + + let applyToCustomer = await CusService.getByInternalId({ + sb, + internalId: referralCode.internal_customer_id, + }); + + let stripeCli = createStripeCli({ + org, + env, + }); + + await createStripeCusIfNotExists({ + sb, + customer: applyToCustomer, + org, + env, + logger, + }); + + let stripeCusId = applyToCustomer.processor.id; + let stripeCus = (await stripeCli.customers.retrieve( + stripeCusId + )) as Stripe.Customer; + + let applied = false; + if (!stripeCus.discount) { + await stripeCli.customers.update(stripeCusId, { + coupon: reward.internal_id, + }); + + applied = true; + logger.info(`Applied coupon to customer in Stripe`); + } + + let updatedRedemption = await RewardRedemptionService.update({ + sb, + id: redemption.id, + updates: { + applied, + triggered: true, + }, + }); + + logger.info(`Successfully triggered redemption, applied: ${applied}`); + + return updatedRedemption; +}; diff --git a/server/src/internal/rewards/triggerCheckoutReward.ts b/server/src/internal/rewards/triggerCheckoutReward.ts new file mode 100644 index 000000000..c3168fcf9 --- /dev/null +++ b/server/src/internal/rewards/triggerCheckoutReward.ts @@ -0,0 +1,92 @@ +import { createStripeCli } from "@/external/stripe/utils.js"; +import { RewardRedemptionService } from "./RewardRedemptionService.js"; +import { RewardTriggerService } from "./RewardTriggerService.js"; +import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { CusService } from "../customers/CusService.js"; +import Stripe from "stripe"; +import { RewardTriggerEvent } from "@autumn/shared"; +import { triggerRedemption } from "./referralUtils.js"; + +export const runTriggerCheckoutReward = async ({ + sb, + payload, + logger, +}: { + sb: any; + payload: any; + logger: any; +}) => { + try { + // Customer redeeming code, product they're buying + let { customer, product, org, env } = payload; + + // 1. Check if redemption exists + let redemptions = await RewardRedemptionService.getByCustomer({ + sb, + internalCustomerId: customer.internal_id, // customer that redeemed code + withRewardTrigger: true, + triggered: false, + withReferralCode: true, + triggerWhen: RewardTriggerEvent.Checkout, + }); + + for (let redemption of redemptions) { + if ( + !redemption || + redemption.reward_trigger.when !== RewardTriggerEvent.Checkout + ) { + return; + } + + let { reward_trigger, referral_code: referralCode } = redemption; + let { reward } = reward_trigger; + + logger.info(`--------------------------------`); + logger.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`); + logger.info( + `Redeemed by: ${customer.name} (${customer.id}) for referral program: ${reward_trigger.id}` + ); + logger.info(`Referral code: ${referralCode.code} (${referralCode.id})`); + + if (!reward_trigger.product_ids.includes(product.id)) { + logger.info( + `Product ${product.name} (${product.id}) not included in referral program, skipping` + ); + return; + } + + // Get redemption count + let redemptionCount = await RewardTriggerService.getCodeRedemptionCount({ + sb, + referralCodeId: referralCode.id, + }); + + if (redemptionCount >= reward_trigger.max_redemptions) { + logger.info( + `Max redemptions reached, not triggering latest redemption` + ); + return; + } + + await triggerRedemption({ + sb, + referralCode, + org, + env, + logger, + reward, + redemption, + }); + } + } catch (error) { + logger.error("Failed to trigger checkout reward"); + logger.error(error); + } + + // let { reward_trigger } = redemption; + // let { reward } = reward_trigger; + + // let customerToApplyDiscount = redemption.code.internal_customer_id; + + // console.log("Customer to apply discount", customerToApplyDiscount); +}; diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index fc2b14969..ef8707afe 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -3,4 +3,6 @@ export enum JobName { UpdateUsage = "update-usage", Migration = "migration", + + TriggerCheckoutReward = "trigger-checkout-reward", } diff --git a/server/src/queue/queue.ts b/server/src/queue/queue.ts index 78ac02690..5064abc2c 100644 --- a/server/src/queue/queue.ts +++ b/server/src/queue/queue.ts @@ -7,6 +7,7 @@ import { JobName } from "./JobName.js"; import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { SupabaseClient } from "@supabase/supabase-js"; import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; +import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js"; const NUM_WORKERS = 5; @@ -83,6 +84,16 @@ const initWorker = ({ return; } + if (job.name == JobName.TriggerCheckoutReward) { + await runTriggerCheckoutReward({ + payload: job.data, + sb, + logger: logtail, + }); + + return; + } + const { customerId } = job.data; while (!(await acquireLock({ customerId, timeout: 10000, useBackup }))) { diff --git a/shared/errors/errCode.ts b/shared/errors/errCode.ts index 24a0ea68b..32cc2c396 100644 --- a/shared/errors/errCode.ts +++ b/shared/errors/errCode.ts @@ -113,4 +113,7 @@ export const ErrCode = { // Referral codes ReferralCodeMaxRedemptionsReached: "referral_code_max_redemptions_reached", + ReferralNotFound: "referral_not_found", + CustomerAlreadyRedeemedReferralCode: + "customer_already_redeemed_referral_code", }; diff --git a/shared/index.ts b/shared/index.ts index be9896e48..a1e050080 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -14,6 +14,7 @@ export * from "./models/productModels/fixedPriceModels.js"; export * from "./models/productModels/freeTrialModels.js"; export * from "./models/rewardModels/rewardModels.js"; export * from "./models/rewardModels/rewardTriggerModels.js"; +export * from "./models/rewardModels/referralModels/referralModels.js"; // Gen Models export * from "./models/genModels.js"; diff --git a/shared/models/rewardModels/referralModels/referralModels.ts b/shared/models/rewardModels/referralModels/referralModels.ts index 640bc29e4..aee8844ca 100644 --- a/shared/models/rewardModels/referralModels/referralModels.ts +++ b/shared/models/rewardModels/referralModels/referralModels.ts @@ -16,17 +16,22 @@ let ReferralCodeSchema = z.object({ let RewardRedemptionSchema = z.object({ id: z.string(), - internal_reward_trigger_id: z.string().nullish(), created_at: z.number(), + updated_at: z.number(), // Customer who signed up / paid internal_customer_id: z.string(), // customer who redeemed the code... + internal_reward_trigger_id: z.string(), // reward trigger that was redeemed // Referral code used - code: z.string(), + // code: z.string(), + referral_code_id: z.string(), // Whether the reward was triggered triggered: z.boolean(), + + // Whether the (coupon) was applied + applied: z.boolean(), }); export type ReferralCode = z.infer; diff --git a/shared/models/rewardModels/rewardModels.ts b/shared/models/rewardModels/rewardModels.ts index 70e88d668..06d41df56 100644 --- a/shared/models/rewardModels/rewardModels.ts +++ b/shared/models/rewardModels/rewardModels.ts @@ -40,3 +40,4 @@ export const CreateCouponSchema = CouponSchema.omit({ export type Coupon = z.infer; export type CreateCoupon = z.infer; +export type Reward = z.infer; diff --git a/shared/models/rewardModels/rewardTriggerModels.ts b/shared/models/rewardModels/rewardTriggerModels.ts index a1ef03f4c..dbc10816d 100644 --- a/shared/models/rewardModels/rewardTriggerModels.ts +++ b/shared/models/rewardModels/rewardTriggerModels.ts @@ -1,7 +1,9 @@ import z from "zod"; +import { Reward } from "../rewardModels/rewardModels.js"; export enum RewardTriggerEvent { - SignUp = "sign_up", + // SignUp = "sign_up", + Immediately = "immediately", Checkout = "checkout", } @@ -9,10 +11,9 @@ export const RewardTrigger = z.object({ internal_id: z.string(), id: z.string(), - trigger: z.object({ - type: z.nativeEnum(RewardTriggerEvent), - product_id: z.string().optional(), - }), + when: z.nativeEnum(RewardTriggerEvent), + product_ids: z.array(z.string()).optional(), + exclude_trial: z.boolean().optional(), internal_reward_id: z.string(), @@ -26,13 +27,16 @@ export const RewardTrigger = z.object({ export const CreateRewardTrigger = z.object({ id: z.string(), - trigger: z.object({ - type: z.nativeEnum(RewardTriggerEvent), - product_id: z.string().optional(), - }), + when: z.nativeEnum(RewardTriggerEvent), + product_ids: z.array(z.string()).optional(), + exclude_trial: z.boolean().optional(), internal_reward_id: z.string(), max_redemptions: z.number().optional(), }); export type RewardTrigger = z.infer; export type CreateRewardTrigger = z.infer; + +export type FullRewardTrigger = RewardTrigger & { + reward: Reward; +}; diff --git a/vite/src/views/products/coupons/CouponConfig.tsx b/vite/src/views/products/coupons/CouponConfig.tsx index 0a6261a70..1c4ca0c58 100644 --- a/vite/src/views/products/coupons/CouponConfig.tsx +++ b/vite/src/views/products/coupons/CouponConfig.tsx @@ -54,7 +54,9 @@ export const CouponConfig = ({ Promotional Code 0 ? coupon.promo_codes[0].code : ""} + value={ + coupon.promo_codes.length > 0 ? coupon.promo_codes[0].code : "" + } onChange={(e) => setCoupon({ ...coupon, @@ -293,11 +295,3 @@ const ProductPriceSelector = ({ ); }; - -// productName: string; -// prices: { value: string; label: string }[]; -// } -// ][] -// ).map(([productId, productData]) => ( - -// ))} */} diff --git a/vite/src/views/products/product/ConfirmMigrateDialog.tsx b/vite/src/views/products/product/ConfirmMigrateDialog.tsx new file mode 100644 index 000000000..f414f7a8b --- /dev/null +++ b/vite/src/views/products/product/ConfirmMigrateDialog.tsx @@ -0,0 +1,78 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogTitle, + DialogHeader, + DialogContent, + DialogTrigger, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useProductContext } from "./ProductContext"; +import { toast } from "sonner"; +import { useState } from "react"; + +export default function ConfirmNewVersionDialog({ + open, + setOpen, + startMigration, +}: { + open: boolean; + setOpen: (open: boolean) => void; + startMigration: () => Promise; +}) { + const { product, version } = useProductContext(); + let [confirmText, setConfirmText] = useState(""); + let [isLoading, setIsLoading] = useState(false); + + const onClick = async () => { + if (confirmText !== product.id) { + toast.error("Confirmation text is incorrect"); + return; + } + + setIsLoading(true); + await startMigration(); + setIsLoading(false); + setOpen(false); + }; + + return ( + + + {/* */} + + + + Migrate customers? + +

+ Note: This will migrate all customers on {product.name} (version{" "} + {version}) to the latest version. +

+

+ Type {product.id} to continue. +

+ setConfirmText(e.target.value)} + type="text" + placeholder={product.id} + className="w-full text-black" + /> +
+
+ + + +
+
+ ); +} diff --git a/vite/src/views/products/product/ManageProduct.tsx b/vite/src/views/products/product/ManageProduct.tsx index 581736155..1f90b58e4 100644 --- a/vite/src/views/products/product/ManageProduct.tsx +++ b/vite/src/views/products/product/ManageProduct.tsx @@ -32,6 +32,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { pricesOnlyOneOff } from "@/utils/product/priceUtils"; +import ConfirmMigrateDialog from "./ConfirmMigrateDialog"; export const ManageProduct = ({ product, @@ -197,8 +198,9 @@ const CountAndMigrate = () => { let env = useEnv(); let axiosInstance = useAxiosInstance({ env }); let [loading, setLoading] = useState(false); + let [confirmMigrateOpen, setConfirmMigrateOpen] = useState(false); - const onMigrateClicked = async () => { + const migrateCustomers = async () => { setLoading(true); try { let { data } = await axiosInstance.post("/v1/migrations", { @@ -216,6 +218,10 @@ const CountAndMigrate = () => { setLoading(false); }; + const onMigrateClicked = () => { + setConfirmMigrateOpen(true); + }; + useEffect(() => { if (existingMigrations.length > 0) { // Run poll job on mutate @@ -305,6 +311,11 @@ const CountAndMigrate = () => { return (
+ diff --git a/vite/src/views/products/reward-triggers/CreateRewardTriger.tsx b/vite/src/views/products/reward-triggers/CreateRewardTriger.tsx index 277868bb8..a22ea11df 100644 --- a/vite/src/views/products/reward-triggers/CreateRewardTriger.tsx +++ b/vite/src/views/products/reward-triggers/CreateRewardTriger.tsx @@ -35,10 +35,14 @@ import { CreateRewardTrigger } from "@autumn/shared"; const defaultRewardTrigger: CreateRewardTrigger = { id: "", - trigger: { - type: RewardTriggerEvent.SignUp, - product_id: "", - }, + // trigger: { + // type: RewardTriggerEvent.SignUp, + // product_ids: [], + // exclude_trial: false, + // }, + when: RewardTriggerEvent.Immediately, + product_ids: [], + exclude_trial: false, internal_reward_id: "", max_redemptions: 0, }; diff --git a/vite/src/views/products/reward-triggers/RewardTriggerConfig.tsx b/vite/src/views/products/reward-triggers/RewardTriggerConfig.tsx index 2aeed6732..23d7945f5 100644 --- a/vite/src/views/products/reward-triggers/RewardTriggerConfig.tsx +++ b/vite/src/views/products/reward-triggers/RewardTriggerConfig.tsx @@ -9,6 +9,24 @@ import { } from "@/components/ui/select"; import { RewardTrigger, Coupon, RewardTriggerEvent } from "@autumn/shared"; import { useProductsContext } from "../ProductsContext"; +import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown, X } from "lucide-react"; export const RewardTriggerConfig = ({ rewardTrigger, @@ -18,6 +36,7 @@ export const RewardTriggerConfig = ({ setRewardTrigger: (rewardTrigger: RewardTrigger) => void; }) => { let { coupons } = useProductsContext(); + return (
@@ -30,19 +49,6 @@ export const RewardTriggerConfig = ({ } />
-
- Redeem On - -
-
-
Coupon
+
+
+
+ Redeem On + +
Max Redemptions setRewardTrigger({ ...rewardTrigger, @@ -74,24 +107,118 @@ export const RewardTriggerConfig = ({ }) } /> - {/* Add infinity */}
+
+ {rewardTrigger.when === RewardTriggerEvent.Checkout && ( +
+ Products + +
+ )} +
); }; -{ - /* - Promotional Code - - 0 ? coupon.promo_codes[0].code : ""} - onChange={(e) => - setCoupon({ - ...coupon, - promo_codes: [{ code: e.target.value }], - }) - } - /> */ -} +const ProductSelector = ({ + rewardTrigger, + setRewardTrigger, +}: { + rewardTrigger: RewardTrigger; + setRewardTrigger: (rewardTrigger: RewardTrigger) => void; +}) => { + const { products } = useProductsContext(); + const [open, setOpen] = useState(false); + + // Handle selection/deselection of a product + const handleProductToggle = (productId: string) => { + let newProductIds = [...(rewardTrigger.product_ids || [])]; + if (newProductIds.includes(productId)) { + newProductIds = newProductIds.filter((id) => id !== productId); + } else { + newProductIds = [...newProductIds, productId]; + } + setRewardTrigger({ + ...rewardTrigger, + product_ids: newProductIds, + }); + }; + + if (!products || products.length === 0) { + return

No products available

; + } + + const getProductText = (productId: string) => { + const product = products.find((p: any) => p.id === productId); + return product?.name || "Unknown Product"; + }; + + return ( + + + + + ))} + + )} + + + + + + + + + No products found. + + {products.map((product: any) => ( + handleProductToggle(product.id)} + className="cursor-pointer" + > +
{product.name}
+ {rewardTrigger.product_ids?.includes(product.id) && ( + + )} +
+ ))} +
+
+
+
+
+
+ ); +}; diff --git a/vite/src/views/products/reward-triggers/RewardTriggerRowToolbar.tsx b/vite/src/views/products/reward-triggers/RewardTriggerRowToolbar.tsx new file mode 100644 index 000000000..27f4e3390 --- /dev/null +++ b/vite/src/views/products/reward-triggers/RewardTriggerRowToolbar.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useProductsContext } from "../ProductsContext"; + +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { RewardTrigger } from "@autumn/shared"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { getBackendErr } from "@/utils/genUtils"; +import { toast } from "sonner"; +import SmallSpinner from "@/components/general/SmallSpinner"; +import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; +import { Delete } from "lucide-react"; + +export const RewardTriggerRowToolbar = ({ + rewardTrigger, +}: { + rewardTrigger: RewardTrigger; +}) => { + const { env, mutate } = useProductsContext(); + const axiosInstance = useAxiosInstance({ env }); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const handleDelete = async () => { + setDeleteLoading(true); + + try { + await axiosInstance.delete(`/v1/reward-triggers/${rewardTrigger.id}`); + + await mutate(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to delete reward trigger")); + } + + setDeleteLoading(false); + setDeleteOpen(false); + }; + return ( + + + + + + { + e.stopPropagation(); + e.preventDefault(); + await handleDelete(); + }} + > +
+ Delete + {deleteLoading ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/vite/src/views/products/reward-triggers/RewardTriggersTable.tsx b/vite/src/views/products/reward-triggers/RewardTriggersTable.tsx index 8a4e8f2f5..ca70e52cf 100644 --- a/vite/src/views/products/reward-triggers/RewardTriggersTable.tsx +++ b/vite/src/views/products/reward-triggers/RewardTriggersTable.tsx @@ -20,14 +20,15 @@ import { TableBody } from "@/components/ui/table"; import { Table, TableHead, TableRow, TableHeader } from "@/components/ui/table"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; +import { RewardTriggerRowToolbar } from "./RewardTriggerRowToolbar"; export const RewardTriggersTable = () => { - const { org } = useProductsContext(); + const { rewardTriggers } = useProductsContext(); const [selectedRewardTrigger, setSelectedRewardTrigger] = useState(null); const [open, setOpen] = useState(false); - let rewardTriggers: RewardTrigger[] = []; + // let rewardTriggers: RewardTrigger[] = []; // const handleRowClick = (id: string) => { // const creditSystem = creditSystems.find( // (creditSystem: Feature) => creditSystem.id === id @@ -50,60 +51,61 @@ export const RewardTriggersTable = () => { - Name - Promo Codes - Discount - Duration + ID + Redeem On + Max Redemptions + Products Created At - {rewardTriggers.map((rewardTrigger: RewardTrigger) => ( - { - setSelectedRewardTrigger(rewardTrigger); - setOpen(true); - }} - > - - {rewardTrigger.name} - - - {rewardTrigger.trigger.type} - - -
-

{rewardTrigger.max_redemptions}

-

- {rewardTrigger.unlimited_redemptions - ? "Unlimited" - : rewardTrigger.max_redemptions} -

-
-
- - {rewardTrigger.trigger.type == RewardTriggerEvent.SignUp - ? "Sign Up" - : rewardTrigger.trigger.type == RewardTriggerEvent.Checkout - ? "Checkout" - : keyToTitle(rewardTrigger.trigger.type)} - - - {formatUnixToDateTime(rewardTrigger.created_at).date} - - {" "} - {formatUnixToDateTime(rewardTrigger.created_at).time}{" "} - - - - {/* */} - -
- ))} + {rewardTriggers.map((rewardTrigger: RewardTrigger) => { + return ( + { + setSelectedRewardTrigger(rewardTrigger); + setOpen(true); + }} + > + + {rewardTrigger.id} + + + {rewardTrigger.when} + + +
+

+ {rewardTrigger.unlimited_redemptions + ? "Unlimited" + : rewardTrigger.max_redemptions} +

+
+
+ + {rewardTrigger.when == RewardTriggerEvent.Immediately + ? "Sign Up" + : rewardTrigger.when == RewardTriggerEvent.Checkout + ? "Checkout" + : keyToTitle(rewardTrigger.when)} + + + {formatUnixToDateTime(rewardTrigger.created_at).date} + + {" "} + {formatUnixToDateTime(rewardTrigger.created_at).time}{" "} + + + + + +
+ ); + })}