diff --git a/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts b/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts index 9b292e000..192c61d2c 100644 --- a/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCusDiscountDeleted.ts @@ -7,164 +7,163 @@ import { notNullish } from "@/utils/genUtils.js"; import { createStripeCli } from "../utils.js"; export async function handleCusDiscountDeleted({ - db, - org, - discount, - env, - logger, - res, + db, + org, + discount, + env, + logger, + res, }: { - db: DrizzleCli; - org: any; - discount: any; - env: any; - logger: any; - res: any; + db: DrizzleCli; + org: any; + discount: any; + env: any; + logger: any; + res: any; }) { - const customer = await CusService.getByStripeId({ - db, - stripeId: discount.customer, - }); + const customer = await CusService.getByStripeId({ + db, + stripeId: discount.customer, + }); - if (!customer) { - logger.warn(`discount.deleted: customer ${discount.customer} not found`); - return; - } + if (!customer) { + logger.warn(`discount.deleted: customer ${discount.customer} not found`); + return; + } - if (customer.env !== env || customer.org_id !== org.id) { - logger.info( - `discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`, - ); - return; - } + if (customer.env !== env || customer.org_id !== org.id) { + logger.info( + `discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}` + ); + return; + } - // Check if any redemptions available, and apply to customer if so - const redemptions = await RewardRedemptionService.getUnappliedRedemptions({ - db, - internalCustomerId: customer.internal_id, - }); + // Check if any redemptions available, and apply to customer if so + const redemptions = await RewardRedemptionService.getUnappliedRedemptions({ + db, + internalCustomerId: customer.internal_id, + }); - logger.info( - `discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`, - ); + logger.info( + `discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions` + ); - if (redemptions.length == 0) return; + if (redemptions.length == 0) return; - const paidProductRedemption = redemptions.find( - (r) => - r.reward_program.reward.id === - (typeof discount.coupon == "string" - ? discount.coupon - : discount.coupon.id), - ); + const paidProductRedemption = redemptions.find( + (r) => + r.reward_program.reward.id === + (typeof discount.coupon == "string" + ? discount.coupon + : discount.coupon.id) + ); - if (discount.subscription) { - logger.info( - `Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`, - ); + if (discount.subscription) { + logger.info( + `Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}` + ); - if (!paidProductRedemption) return; + if (!paidProductRedemption) return; - // Re-apply coupon and mark applied / redeemer applied to true - const stripeCli = createStripeCli({ - org, - env, - }); + // Re-apply coupon and mark applied / redeemer applied to true + const stripeCli = createStripeCli({ + org, + env, + }); - // Mark reward redemption as applied / redeemer applied to true + // Mark reward redemption as applied / redeemer applied to true + const sub = await stripeCli.subscriptions.retrieve(discount.subscription); - const sub = await stripeCli.subscriptions.retrieve(discount.subscription); + // can't really test because it modifies subscription affected by test clock... + try { + await stripeCli.subscriptions.update(discount.subscription, { + discounts: [ + ...(sub.discounts as string[]).map((d: string) => ({ + discount: d, + })), + { + coupon: paidProductRedemption.reward_program.reward.id as string, + }, + ], + }); + } catch (error: any) { + logger.error( + `Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}` + ); + throw error; + } - // can't really test because it modifies subscription affected by test clock... - try { - await stripeCli.subscriptions.update(discount.subscription, { - discounts: [ - ...(sub.discounts as string[]).map((d: string) => ({ - discount: d, - })), - { - coupon: paidProductRedemption.reward_program.reward.id as string, - }, - ], - }); - } catch (error: any) { - logger.error( - `Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}`, - ); - throw error; - } + // Mark reward redemption as applied / redeemer applied to true + const isReferrer = + paidProductRedemption.referral_code.internal_customer_id === + customer.internal_id; - // Mark reward redemption as applied / redeemer applied to true - const isReferrer = - paidProductRedemption.referral_code.internal_customer_id === - customer.internal_id; + await RewardRedemptionService.update({ + db, + id: paidProductRedemption.id, + updates: { + applied: isReferrer ? true : undefined, + redeemer_applied: isReferrer ? undefined : true, + }, + }); - await RewardRedemptionService.update({ - db, - id: paidProductRedemption.id, - updates: { - applied: isReferrer ? true : undefined, - redeemer_applied: isReferrer ? undefined : true, - }, - }); + return; + } - return; - } + const redemption = redemptions[0]; - const redemption = redemptions[0]; + // Apply redemption to customer + const stripeCli = createStripeCli({ + org, + env, + }); - // Apply redemption to customer - const stripeCli = createStripeCli({ - org, - env, - }); + const stripeCus = (await stripeCli.customers.retrieve( + discount.customer + )) as Stripe.Customer; - const stripeCus = (await stripeCli.customers.retrieve( - discount.customer, - )) as Stripe.Customer; + if (stripeCus && notNullish(stripeCus.discount)) { + logger.info( + `discount.deleted: stripe customer ${discount.customer} already has a discount` + ); + return; + } - if (stripeCus && notNullish(stripeCus.discount)) { - logger.info( - `discount.deleted: stripe customer ${discount.customer} already has a discount`, - ); - return; - } + const reward = await RewardService.get({ + db, + orgId: org.id, + env, + idOrInternalId: redemption.reward_program.internal_reward_id!, + }); - const reward = await RewardService.get({ - db, - orgId: org.id, - env, - idOrInternalId: redemption.reward_program.internal_reward_id!, - }); + if (!reward) { + logger.warn( + `discount.deleted: reward ${redemption.reward_program.internal_id} not found` + ); + return; + } - if (!reward) { - logger.warn( - `discount.deleted: reward ${redemption.reward_program.internal_id} not found`, - ); - return; - } + const legacyStripe = createStripeCli({ + org, + env, + legacyVersion: true, + }); - const legacyStripe = createStripeCli({ - org, - env, - legacyVersion: true, - }); + await legacyStripe.customers.update(discount.customer, { + // @ts-expect-error + coupon: reward.id, + }); - await legacyStripe.customers.update(discount.customer, { - // @ts-expect-error - coupon: reward.id, - }); + await RewardRedemptionService.update({ + db, + id: redemption.id, + updates: { + applied: true, + }, + }); - await RewardRedemptionService.update({ - db, - 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}`); + 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/rewards/rewardProgramRouter.ts b/server/src/internal/api/rewards/rewardProgramRouter.ts index 725b9ac99..8f183eeca 100644 --- a/server/src/internal/api/rewards/rewardProgramRouter.ts +++ b/server/src/internal/api/rewards/rewardProgramRouter.ts @@ -1,8 +1,92 @@ import express, { type Router } from "express"; -import { handleCreateRewardProgram, handleDeleteRewardProgram } from "./handlers/rewardPrograms/index.js"; +import { + handleCreateRewardProgram, + handleDeleteRewardProgram, +} from "./handlers/rewardPrograms/index.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { + CreateRewardProgram, + ErrCode, + nullish, + RewardTriggerEvent, +} from "@autumn/shared"; +import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; +import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js"; export const rewardProgramRouter: Router = express.Router(); rewardProgramRouter.post("", handleCreateRewardProgram); -rewardProgramRouter.delete("/:id", handleDeleteRewardProgram); \ No newline at end of file +rewardProgramRouter.delete("/:id", handleDeleteRewardProgram); + +rewardProgramRouter.put("/:id", (req, res) => + routeHandler({ + req, + res, + action: "update reward program", + handler: async (req: any, res: any) => { + const { orgId, env, db } = req; + const { id } = req.params; + const body = req.body; + + if (!body.internal_reward_id) { + throw new RecaseError({ + message: "Please select a reward to link this program to", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + // Ensure program exists + let existingProgram = await RewardProgramService.get({ + db, + idOrInternalId: id, + orgId, + env, + }); + + if (!existingProgram) { + throw new RecaseError({ + message: `Program with ID ${id} does not exist`, + code: ErrCode.InvalidRequest, + statusCode: 404, + }); + } + + const rewardProgram = constructRewardProgram({ + rewardProgramData: CreateRewardProgram.parse({ + ...body, + id: existingProgram.id, // ID cannot be changed + }), + orgId, + env, + }); + + // Update on existing redemptions? (should be none unless affecting stacked rewards...) + + if ( + rewardProgram.when == RewardTriggerEvent.Checkout && + (nullish(rewardProgram.product_ids) || + rewardProgram.product_ids!.length == 0) + ) { + throw new RecaseError({ + message: + "When `Redeem On` is set to `Checkout`, must specify at least one product", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + let updatedRewardProgram = await RewardProgramService.update({ + db, + idOrInternalId: id, + orgId, + env, + data: rewardProgram, + }); + + return res.status(200).json(updatedRewardProgram); + }, + }) +); diff --git a/server/src/internal/rewards/RewardProgramService.ts b/server/src/internal/rewards/RewardProgramService.ts index 67ed56410..4b5791435 100644 --- a/server/src/internal/rewards/RewardProgramService.ts +++ b/server/src/internal/rewards/RewardProgramService.ts @@ -1,4 +1,4 @@ -import { and, arrayContains, count, eq, inArray } from "drizzle-orm"; +import { and, arrayContains, count, eq, inArray, or } from "drizzle-orm"; import RecaseError from "@/utils/errorUtils.js"; import { ErrCode, @@ -14,22 +14,25 @@ import { referralCodes, rewardRedemptions } from "@autumn/shared"; export class RewardProgramService { static async get({ db, - id, + idOrInternalId, orgId, env, errorIfNotFound = false, }: { db: DrizzleCli; - id: string; + idOrInternalId: string; orgId: string; env: string; errorIfNotFound?: boolean; }) { let result = await db.query.rewardPrograms.findFirst({ where: and( - eq(rewardPrograms.id, id), + or( + eq(rewardPrograms.id, idOrInternalId), + eq(rewardPrograms.internal_id, idOrInternalId) + ), eq(rewardPrograms.org_id, orgId), - eq(rewardPrograms.env, env), + eq(rewardPrograms.env, env) ), }); @@ -79,7 +82,7 @@ export class RewardProgramService { eq(rewardPrograms.org_id, orgId), eq(rewardPrograms.env, env), eq(rewardPrograms.when, RewardTriggerEvent.Checkout), - arrayContains(rewardPrograms.product_ids, productIds), + arrayContains(rewardPrograms.product_ids, productIds) ), }); @@ -104,7 +107,7 @@ export class RewardProgramService { eq(referralCodes.internal_customer_id, internalCustomerId), eq(referralCodes.internal_reward_program_id, internalRewardProgramId), eq(referralCodes.org_id, orgId), - eq(referralCodes.env, env), + eq(referralCodes.env, env) ), }); @@ -139,12 +142,12 @@ export class RewardProgramService { static async delete({ db, - id, + idOrInternalId, orgId, env, }: { db: DrizzleCli; - id: string; + idOrInternalId: string; orgId: string; env: string; }) { @@ -152,10 +155,13 @@ export class RewardProgramService { .delete(rewardPrograms) .where( and( - eq(rewardPrograms.id, id), + or( + eq(rewardPrograms.id, idOrInternalId), + eq(rewardPrograms.internal_id, idOrInternalId) + ), eq(rewardPrograms.org_id, orgId), - eq(rewardPrograms.env, env), - ), + eq(rewardPrograms.env, env) + ) ) .returning(); @@ -187,7 +193,7 @@ export class RewardProgramService { where: and( eq(referralCodes.code, code), eq(referralCodes.org_id, orgId), - eq(referralCodes.env, env), + eq(referralCodes.env, env) ), with: withRewardProgram ? { @@ -247,10 +253,48 @@ export class RewardProgramService { .where( and( eq(rewardRedemptions.referral_code_id, referralCodeId), - eq(rewardRedemptions.triggered, true), - ), + eq(rewardRedemptions.triggered, true) + ) ); return result[0].count; } + + static async update({ + db, + idOrInternalId, + orgId, + env, + data, + }: { + db: DrizzleCli; + idOrInternalId: string; + orgId: string; + env: string; + data: RewardProgram; + }) { + let result = await db + .update(rewardPrograms) + .set(data as any) + .where( + and( + or( + eq(rewardPrograms.id, idOrInternalId), + eq(rewardPrograms.internal_id, idOrInternalId) + ), + eq(rewardPrograms.org_id, orgId), + eq(rewardPrograms.env, env) + ) + ) + .returning(); + + if (result.length === 0) { + throw new RecaseError({ + message: "Reward program not found", + code: ErrCode.RewardNotFound, + }); + } + + return result[0] as RewardProgram; + } } diff --git a/vite/src/services/products/RewardProgramService.tsx b/vite/src/services/products/RewardProgramService.tsx new file mode 100644 index 000000000..c06a56364 --- /dev/null +++ b/vite/src/services/products/RewardProgramService.tsx @@ -0,0 +1,37 @@ +import { RewardProgram, CreateRewardProgram } from "@autumn/shared"; + +import { AxiosInstance } from "axios"; + +export class RewardProgramService { + static async createReward({ + axiosInstance, + data, + }: { + axiosInstance: AxiosInstance; + data: CreateRewardProgram; + }) { + await axiosInstance.post("/v1/reward_programs", data); + } + + static async deleteReward({ + axiosInstance, + internalId, + }: { + axiosInstance: AxiosInstance; + internalId: string; + }) { + await axiosInstance.delete(`/v1/reward_programs/${internalId}`); + } + + static async updateReward({ + axiosInstance, + internalId, + data, + }: { + axiosInstance: AxiosInstance; + internalId: string; + data: RewardProgram; + }) { + await axiosInstance.put(`/v1/reward_programs/${internalId}`, data); + } +} diff --git a/vite/src/views/products/products/components/CreateProductDialog.tsx b/vite/src/views/products/products/components/CreateProductDialog.tsx index d52971ed7..ca64819db 100644 --- a/vite/src/views/products/products/components/CreateProductDialog.tsx +++ b/vite/src/views/products/products/components/CreateProductDialog.tsx @@ -43,6 +43,17 @@ function CreateProduct({ const navigate = useNavigate(); const handleCreateClicked = async () => { + const productName = product.name?.trim() || ""; + + if (!/^[a-zA-Z0-9 _-]+$/.test(productName)) { + toast.error( + !productName + ? "Product name is required" + : "Product name can only contain alphanumeric characters, dashes (-), and underscores (_)" + ); + return; + } + setLoading(true); try { const newProduct = await ProductService.createProduct( diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx index ce898447a..f05cc9c53 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramConfig.tsx @@ -1,257 +1,264 @@ import { - type Reward, - type RewardProgram, - RewardReceivedBy, - RewardTriggerEvent, + type Reward, + type RewardProgram, + RewardReceivedBy, + RewardTriggerEvent, } from "@autumn/shared"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { useState } from "react"; import FieldLabel from "@/components/general/modal-components/FieldLabel"; import { Button } from "@/components/ui/button"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; export const RewardProgramConfig = ({ - rewardProgram, - setRewardProgram, + rewardProgram, + setRewardProgram, + isUpdate, }: { - rewardProgram: RewardProgram; - setRewardProgram: (rewardProgram: RewardProgram) => void; + rewardProgram: RewardProgram; + setRewardProgram: (rewardProgram: RewardProgram) => void; + isUpdate?: boolean; }) => { - const { rewards } = useRewardsQuery(); + const { rewards } = useRewardsQuery(); - return ( -
-
-
- Program ID - - setRewardProgram({ ...rewardProgram, id: e.target.value }) - } - /> -
-
- Reward - -
-
-
-
- Redeem On - -
-
- Max Redemptions - - setRewardProgram({ - ...rewardProgram, - max_redemptions: parseInt(e.target.value), - }) - } - /> -
-
-
-
- Received by - -
-
-
- {rewardProgram.when === RewardTriggerEvent.Checkout && ( -
- Products - -
- )} -
-
- ); + return ( +
+
+
+ Program ID + + setRewardProgram({ ...rewardProgram, id: e.target.value }) + } + /> +
+
+ Reward + +
+
+
+
+ Redeem On + +
+
+ Max Redemptions + + setRewardProgram({ + ...rewardProgram, + max_redemptions: parseInt(e.target.value), + }) + } + /> +
+
+
+
+ Received by + +
+
+
+ {rewardProgram.when === RewardTriggerEvent.Checkout && ( +
+ Products + +
+ )} +
+
+ ); }; const ProductSelector = ({ - rewardProgram, - setRewardProgram, + rewardProgram, + setRewardProgram, }: { - rewardProgram: RewardProgram; - setRewardProgram: (rewardProgram: RewardProgram) => void; + rewardProgram: RewardProgram; + setRewardProgram: (rewardProgram: RewardProgram) => void; }) => { - const { products } = useProductsQuery(); - const [open, setOpen] = useState(false); + const { products } = useProductsQuery(); + const [open, setOpen] = useState(false); - // Handle selection/deselection of a product - const handleProductToggle = (productId: string) => { - let newProductIds = [...(rewardProgram.product_ids || [])]; - if (newProductIds.includes(productId)) { - newProductIds = newProductIds.filter((id) => id !== productId); - } else { - newProductIds = [...newProductIds, productId]; - } - setRewardProgram({ - ...rewardProgram, - product_ids: newProductIds, - }); - }; + // Handle selection/deselection of a product + const handleProductToggle = (productId: string) => { + let newProductIds = [...(rewardProgram.product_ids || [])]; + if (newProductIds.includes(productId)) { + newProductIds = newProductIds.filter((id) => id !== productId); + } else { + newProductIds = [...newProductIds, productId]; + } + setRewardProgram({ + ...rewardProgram, + product_ids: newProductIds, + }); + }; - if (!products || products.length === 0) { - return

No products available

; - } + 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"; - }; + 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}
- {rewardProgram.product_ids?.includes(product.id) && ( - - )} -
- ))} -
-
-
-
-
-
- ); + return ( + + + + + ))} + + )} + + + + + + + + + No products found. + + {products.map((product: any) => ( + handleProductToggle(product.id)} + className="cursor-pointer" + > +
{product.name}
+ {rewardProgram.product_ids?.includes(product.id) && ( + + )} +
+ ))} +
+
+
+
+
+
+ ); }; diff --git a/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx index aa0a0740d..1bab00956 100644 --- a/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx +++ b/vite/src/views/products/rewards/reward-programs/RewardProgramsTable.tsx @@ -8,6 +8,7 @@ import { Item, Row } from "@/components/general/TableGrid"; import { AdminHover } from "@/components/general/AdminHover"; import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar"; +import UpdateRewardProgram from "./UpdateRewardPrograms"; export const RewardProgramsTable = () => { const { rewardPrograms } = useRewardsQuery(); @@ -17,6 +18,12 @@ export const RewardProgramsTable = () => { return ( <> + {/* */} {rewardPrograms && rewardPrograms.length > 0 ? ( diff --git a/vite/src/views/products/rewards/reward-programs/UpdateRewardPrograms.tsx b/vite/src/views/products/rewards/reward-programs/UpdateRewardPrograms.tsx new file mode 100644 index 000000000..9108fd48a --- /dev/null +++ b/vite/src/views/products/rewards/reward-programs/UpdateRewardPrograms.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { toast } from "sonner"; +import { RewardProgram } from "@autumn/shared"; +import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; +import { WarningBox } from "@/components/general/modal-components/WarningBox"; +import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery"; +import { RewardProgramConfig } from "./RewardProgramConfig"; +import { RewardProgramService } from "@/services/products/RewardProgramService"; + +function UpdateRewardProgram({ + open, + setOpen, + selectedRewardProgram, + setSelectedRewardProgram, +}: { + open: boolean; + setOpen: (open: boolean) => void; + selectedRewardProgram: RewardProgram | null; + setSelectedRewardProgram: (reward: RewardProgram) => void; +}) { + const [updateLoading, setUpdateLoading] = useState(false); + const { refetch } = useRewardsQuery(); + + const env = useEnv(); + const axiosInstance = useAxiosInstance({ env }); + + const handleUpdate = async () => { + setUpdateLoading(true); + try { + await RewardProgramService.updateReward({ + axiosInstance, + internalId: selectedRewardProgram!.internal_id, + data: selectedRewardProgram!, + }); + toast.success("Reward updated successfully"); + await refetch(); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update reward program")); + } + setUpdateLoading(false); + }; + + return ( + + + Update Reward Program + {/* + Existing customers with this reward program will not be affected + */} + + {selectedRewardProgram && ( + + )} + + + + + + + ); +} + +export default UpdateRewardProgram;