wrote tests for referral programs

This commit is contained in:
John Yeo
2025-04-09 14:26:36 +01:00
parent 44b459d452
commit bb435415bb
52 changed files with 974 additions and 358 deletions

View File

@@ -1,4 +1,4 @@
import { CreateCoupon } from "@autumn/shared"; import { CreateReward } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
import { AxiosInstance } from "axios"; import { AxiosInstance } from "axios";
@@ -9,7 +9,7 @@ export class CouponService {
data, data,
}: { }: {
axiosInstance: AxiosInstance; axiosInstance: AxiosInstance;
data: CreateCoupon; data: CreateReward;
}) { }) {
await axiosInstance.post("/v1/coupons", data); await axiosInstance.post("/v1/coupons", data);
} }

View File

@@ -12,7 +12,7 @@ import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Coupon, Customer } from "@autumn/shared"; import { Reward, Customer } from "@autumn/shared";
import { useCustomerContext } from "./CustomerContext"; import { useCustomerContext } from "./CustomerContext";
import { CusService } from "@/services/customers/CusService"; import { CusService } from "@/services/customers/CusService";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -76,7 +76,7 @@ export const CustomerToolbar = ({
}} }}
> >
<div className="flex text-sm items-center justify-between w-full gap-2"> <div className="flex text-sm items-center justify-between w-full gap-2">
<p className="text-t2">Add Coupon</p> <p className="text-t2">Add Reward</p>
<FontAwesomeIcon icon={faTicket} size="sm" /> <FontAwesomeIcon icon={faTicket} size="sm" />
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -2,7 +2,7 @@ import { SelectContent } from "@/components/ui/select";
import { SelectValue } from "@/components/ui/select"; import { SelectValue } from "@/components/ui/select";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { Coupon } from "@autumn/shared"; import { Reward } from "@autumn/shared";
import { Select, SelectItem } from "@/components/ui/select"; import { Select, SelectItem } from "@/components/ui/select";
import { import {
Dialog, Dialog,
@@ -22,28 +22,28 @@ const AddCouponDialogContent = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { coupons } = useCustomerContext(); const { coupons } = useCustomerContext();
const [couponSelected, setCouponSelected] = useState<Coupon | null>(null); const [couponSelected, setCouponSelected] = useState<Reward | null>(null);
const handleAddClicked = async () => {}; const handleAddClicked = async () => {};
return ( return (
<DialogContent> <DialogContent>
<DialogTitle>Add Coupon</DialogTitle> <DialogTitle>Add Reward</DialogTitle>
<div> <div>
<Select <Select
value={couponSelected?.internal_id} value={couponSelected?.internal_id}
onValueChange={(value) => { onValueChange={(value) => {
const coupon = coupons.find((c: Coupon) => c.internal_id === value); const coupon = coupons.find((c: Reward) => c.internal_id === value);
if (coupon) { if (coupon) {
setCouponSelected(coupon); setCouponSelected(coupon);
} }
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select Coupon" /> <SelectValue placeholder="Select Reward" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{coupons.map((coupon: Coupon) => ( {coupons.map((coupon: Reward) => (
<SelectItem key={coupon.internal_id} value={coupon.internal_id}> <SelectItem key={coupon.internal_id} value={coupon.internal_id}>
{coupon.name} {coupon.name}
</SelectItem> </SelectItem>
@@ -53,7 +53,7 @@ const AddCouponDialogContent = ({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="gradientPrimary" onClick={() => handleAddClicked()}> <Button variant="gradientPrimary" onClick={() => handleAddClicked()}>
Add Coupon Add Reward
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -17,7 +17,7 @@ import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTicketSimple } from "@fortawesome/pro-duotone-svg-icons"; import { faTicketSimple } from "@fortawesome/pro-duotone-svg-icons";
import { CouponsTable } from "./coupons/CouponsTable"; import { CouponsTable } from "./coupons/CouponsTable";
import CreateCoupon from "./coupons/CreateCoupon"; import CreateReward from "./coupons/CreateReward";
function ProductsView({ env }: { env: AppEnv }) { function ProductsView({ env }: { env: AppEnv }) {
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@@ -83,7 +83,7 @@ function ProductsView({ env }: { env: AppEnv }) {
</p> </p>
</div> </div>
<CouponsTable /> <CouponsTable />
<CreateCoupon /> <CreateReward />
</div> </div>
)} )}
</ProductsContext.Provider> </ProductsContext.Provider>

View File

@@ -8,7 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { import {
CouponDurationType, CouponDurationType,
CreateCoupon, CreateReward,
DiscountType, DiscountType,
Feature, Feature,
} from "@autumn/shared"; } from "@autumn/shared";
@@ -37,8 +37,8 @@ export const CouponConfig = ({
coupon, coupon,
setCoupon, setCoupon,
}: { }: {
coupon: CreateCoupon; coupon: CreateReward;
setCoupon: (coupon: CreateCoupon) => void; setCoupon: (coupon: CreateReward) => void;
}) => { }) => {
const { org } = useProductsContext(); const { org } = useProductsContext();
return ( return (
@@ -172,8 +172,8 @@ const ProductPriceSelector = ({
coupon, coupon,
setCoupon, setCoupon,
}: { }: {
coupon: CreateCoupon; coupon: CreateReward;
setCoupon: (coupon: CreateCoupon) => void; setCoupon: (coupon: CreateReward) => void;
}) => { }) => {
const { products, features } = useProductsContext(); const { products, features } = useProductsContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@@ -13,7 +13,7 @@ import toast from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Coupon, Feature } from "@autumn/shared"; import { Reward, Feature } from "@autumn/shared";
import { FeatureService } from "@/services/FeatureService"; import { FeatureService } from "@/services/FeatureService";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
@@ -24,7 +24,7 @@ export const CouponRowToolbar = ({
coupon, coupon,
}: { }: {
className?: string; className?: string;
coupon: Coupon; coupon: Reward;
}) => { }) => {
const { env, mutate } = useProductsContext(); const { env, mutate } = useProductsContext();
const axiosInstance = useAxiosInstance({ env }); const axiosInstance = useAxiosInstance({ env });

View File

@@ -20,7 +20,7 @@ import toast from "react-hot-toast";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { import {
CouponDurationType, CouponDurationType,
CreateCoupon as CreateCouponType, CreateReward as CreateCouponType,
DiscountType, DiscountType,
} from "@autumn/shared"; } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
@@ -41,7 +41,7 @@ const defaultCoupon: CreateCouponType = {
apply_to_all: true, apply_to_all: true,
}; };
function CreateCoupon() { function CreateReward() {
const { mutate, env } = useProductsContext(); const { mutate, env } = useProductsContext();
const axiosInstance = useAxiosInstance({ env: env }); const axiosInstance = useAxiosInstance({ env: env });
@@ -80,12 +80,12 @@ function CreateCoupon() {
className="w-full" className="w-full"
startIcon={<PlusIcon size={15} />} startIcon={<PlusIcon size={15} />}
> >
Create Coupon Create Reward
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-[500px]"> <DialogContent className="w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Coupon</DialogTitle> <DialogTitle>Create Reward</DialogTitle>
</DialogHeader> </DialogHeader>
{/* <CreditSystemConfig {/* <CreditSystemConfig
creditSystem={creditSystem} creditSystem={creditSystem}
@@ -106,4 +106,4 @@ function CreateCoupon() {
); );
} }
export default CreateCoupon; export default CreateReward;

View File

@@ -1,4 +1,4 @@
import { ErrCode } from "@autumn/shared"; import { CreateRewardTrigger, ErrCode } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
import chalk from "chalk"; import chalk from "chalk";
@@ -238,6 +238,56 @@ export class Autumn {
}, },
}; };
rewards = {
create: async (reward: any) => {
const data = await this.post(`/rewards`, reward);
return data;
},
};
referralPrograms = {
create: async (referralProgram: CreateRewardTrigger) => {
const data = await this.post(`/reward-triggers`, referralProgram);
return data;
},
};
referrals = {
createCode: async ({
customerId,
referralId,
}: {
customerId: string;
referralId: string;
}) => {
const data = await this.post(`/referrals/code`, {
customer_id: customerId,
referral_id: referralId,
});
return data;
},
redeem: async ({
customerId,
code,
}: {
customerId: string;
code: string;
}) => {
const data = await this.post(`/referrals/redeem`, {
customer_id: customerId,
code,
});
return data;
},
};
redemptions = {
get: async ({ redemptionId }: { redemptionId: string }) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data;
},
};
initStripe = async () => { initStripe = async () => {
await this.post(`/products/all/init_stripe`, {}); await this.post(`/products/all/init_stripe`, {});
}; };

View File

@@ -1,6 +1,6 @@
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
import { import {
Coupon, Reward,
CouponDurationType, CouponDurationType,
DiscountType, DiscountType,
ErrCode, ErrCode,
@@ -14,7 +14,7 @@ import {
import { logger } from "@trigger.dev/sdk/v3"; import { logger } from "@trigger.dev/sdk/v3";
import { Stripe } from "stripe"; import { Stripe } from "stripe";
const couponToStripeDuration = (coupon: Coupon) => { const couponToStripeDuration = (coupon: Reward) => {
if ( if (
coupon.duration_type === CouponDurationType.OneOff && coupon.duration_type === CouponDurationType.OneOff &&
coupon.should_rollover coupon.should_rollover
@@ -45,7 +45,7 @@ const couponToStripeValue = ({
coupon, coupon,
org, org,
}: { }: {
coupon: Coupon; coupon: Reward;
org: Organization; org: Organization;
}) => { }) => {
if (coupon.discount_type === DiscountType.Percentage) { if (coupon.discount_type === DiscountType.Percentage) {
@@ -66,7 +66,7 @@ export const createStripeCoupon = async ({
org, org,
prices, prices,
}: { }: {
coupon: Coupon; coupon: Reward;
stripeCli: Stripe; stripeCli: Stripe;
org: Organization; org: Organization;
prices: (Price & { product: Product })[]; prices: (Price & { product: Product })[];

View File

@@ -30,6 +30,7 @@ export const createWebhookEndpoint = async (
"invoice.created", "invoice.created",
"invoice.finalized", "invoice.finalized",
"subscription_schedule.canceled", "subscription_schedule.canceled",
"customer.discount.deleted",
], ],
}); });

View File

@@ -66,13 +66,35 @@ export const deactivateStripeMeters = async ({
}) => { }) => {
const stripeCli = createStripeCli({ org, env }); const stripeCli = createStripeCli({ org, env });
const stripeMeters = await stripeCli.billing.meters.list({ let allStripeMeters = [];
limit: 100, let hasMore = true;
status: "active", let startingAfter;
});
for (const meter of stripeMeters.data) { while (hasMore) {
await stripeCli.billing.meters.deactivate(meter.id); const response: any = await stripeCli.billing.meters.list({
limit: 100,
status: "active",
starting_after: startingAfter,
});
allStripeMeters.push(...response.data);
hasMore = response.has_more;
if (hasMore && response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
}
const batchSize = 10;
for (let i = 0; i < allStripeMeters.length; i += batchSize) {
const batch = allStripeMeters.slice(i, i + batchSize);
await Promise.all(
batch.map((meter) => stripeCli.billing.meters.deactivate(meter.id))
);
console.log(
`Deactivated ${i + batch.length}/${allStripeMeters.length} meters`
);
await new Promise((resolve) => setTimeout(resolve, 1000));
} }
}; };

View File

@@ -167,7 +167,9 @@ stripeWebhookRouter.post(
discount: event.data.object, discount: event.data.object,
env, env,
logger, logger,
res: response,
}); });
return;
break; break;
} }
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,5 @@
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
import stripe, { Stripe } from "stripe"; import { Stripe } from "stripe";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusProductService } from "@/internal/customers/products/CusProductService.js"; import { CusProductService } from "@/internal/customers/products/CusProductService.js";
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js"; import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
@@ -24,9 +24,6 @@ import {
attachToInsertParams, attachToInsertParams,
getPricesForProduct, getPricesForProduct,
} from "@/internal/products/productUtils.js"; } from "@/internal/products/productUtils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { CouponType, getCouponType } from "@/internal/rewards/rewardUtils.js";
import { Decimal } from "decimal.js";
import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js"; import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js";
import { createStripeSub } from "../stripeSubUtils/createStripeSub.js"; import { createStripeSub } from "../stripeSubUtils/createStripeSub.js";
import { getAlignedIntervalUnix } from "@/internal/prices/billingIntervalUtils.js"; import { getAlignedIntervalUnix } from "@/internal/prices/billingIntervalUtils.js";

View File

@@ -1,6 +1,79 @@
import { CusService } from "@/internal/customers/CusService.js"; import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { createStripeCli } from "../utils.js"; import { createStripeCli } from "../utils.js";
import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
import { notNullish, timeout } from "@/utils/genUtils.js";
export const handleDiscountCompleted = async ({
sb,
stripeCusId,
stripeCli,
logger,
}: {
sb: SupabaseClient;
stripeCusId: string;
stripeCli: Stripe;
logger: any;
}) => {
logger.info(`Checking discount completed`);
let customer = await CusService.getByStripeId({
sb,
stripeId: stripeCusId,
});
if (!customer) {
logger.warn(
`Checking discount completed: customer ${stripeCusId} 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(
`Checking discount completed: no redemptions available for customer ${customer.id}`
);
return;
}
let redemption = redemptions[0];
let reward = redemption.reward_trigger.reward;
let stripeCus = (await stripeCli.customers.retrieve(
stripeCusId
)) as Stripe.Customer;
if (stripeCus && notNullish(stripeCus.discount)) {
logger.info(
`Checking discount completed: stripe customer ${stripeCusId} already has a discount`
);
return;
}
await stripeCli.customers.update(stripeCusId, {
coupon: reward.internal_id,
});
await RewardRedemptionService.update({
sb,
id: redemption.id,
updates: {
applied: true,
},
});
logger.info(
`Checking discount completed: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
);
logger.info(`Redemption ID: ${redemption.id}`);
};
export async function handleCusDiscountDeleted({ export async function handleCusDiscountDeleted({
sb, sb,
@@ -8,12 +81,14 @@ export async function handleCusDiscountDeleted({
discount, discount,
env, env,
logger, logger,
res,
}: { }: {
sb: any; sb: any;
org: any; org: any;
discount: any; discount: any;
env: any; env: any;
logger: any; logger: any;
res: any;
}) { }) {
let customer = await CusService.getByStripeId({ let customer = await CusService.getByStripeId({
sb, sb,
@@ -52,6 +127,25 @@ export async function handleCusDiscountDeleted({
env, env,
}); });
let 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;
}
// Send response first...?
res.status(200).send("OK");
if (notNullish(stripeCus.test_clock)) {
// Time out for test clock to complete
await timeout(5000);
}
await stripeCli.customers.update(discount.customer, { await stripeCli.customers.update(discount.customer, {
coupon: reward.internal_id, coupon: reward.internal_id,
}); });

View File

@@ -260,6 +260,7 @@ const handleInvoicePaidDiscount = async ({
} }
const curCoupon = discount.coupon; const curCoupon = discount.coupon;
if (!curCoupon) { if (!curCoupon) {
continue; continue;
} }
@@ -308,8 +309,6 @@ const handleInvoicePaidDiscount = async ({
console.log(`Updating coupon amount from ${curAmount} to ${newAmount}`); console.log(`Updating coupon amount from ${curAmount} to ${newAmount}`);
// Create new coupon with that amount off
// console.log("Cur coupon applies to", curCoupon);
const newCoupon = await stripeCli.coupons.create({ const newCoupon = await stripeCli.coupons.create({
id: `${couponId}_${generateId("roll")}`, id: `${couponId}_${generateId("roll")}`,
name: discount.coupon.name as string, name: discount.coupon.name as string,

View File

@@ -3,17 +3,6 @@ import { AppEnv, CusProductStatus, Organization } from "@autumn/shared";
import Stripe from "stripe"; import Stripe from "stripe";
import { createStripeCli } from "../utils.js"; import { createStripeCli } from "../utils.js";
const handleSubPastDue = async ({
sb,
subscription,
}: {
sb: any;
subscription: any;
}) => {
// 1. Expire cus products
// Cancel subscription entirely
};
export const handleSubscriptionUpdated = async ({ export const handleSubscriptionUpdated = async ({
sb, sb,
org, org,

View File

@@ -16,7 +16,7 @@ import { entityRouter } from "./entities/entityRouter.js";
import { migrationRouter } from "./migrations/migrationRouter.js"; import { migrationRouter } from "./migrations/migrationRouter.js";
import rewardRouter from "./rewards/rewardRouter.js"; import rewardRouter from "./rewards/rewardRouter.js";
import { rewardTriggerRouter } from "./rewards/rewardTriggerRouter.js"; import { rewardTriggerRouter } from "./rewards/rewardTriggerRouter.js";
import { referralRouter } from "./rewards/referralRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
const apiRouter = Router(); const apiRouter = Router();
@@ -73,5 +73,6 @@ apiRouter.use("/migrations", migrationRouter);
// REWARDS // REWARDS
apiRouter.use("/reward-triggers", rewardTriggerRouter); apiRouter.use("/reward-triggers", rewardTriggerRouter);
apiRouter.use("/referrals", referralRouter); apiRouter.use("/referrals", referralRouter);
apiRouter.use("/redemptions", redemptionRouter);
export { apiRouter }; export { apiRouter };

View File

@@ -191,3 +191,27 @@ referralRouter.post("/redeem", (req, res) =>
}, },
}) })
); );
export const redemptionRouter = express.Router();
redemptionRouter.get("/:redemptionId", (req, res) =>
routeHandler({
req,
res,
action: "get redemption by id",
handler: async (req: any, res: any) => {
const { orgId, env, logtail: logger } = req;
const { redemptionId } = req.params;
let redemption = await RewardRedemptionService.getById({
sb: req.sb,
id: redemptionId,
});
// logger.info("Returning redemption");
// logger.info(redemption);
res.status(200).json(redemption);
},
})
);

View File

@@ -1,5 +1,5 @@
import express from "express"; import express from "express";
import { CouponDurationType, CreateCouponSchema } from "@autumn/shared"; import { CreateRewardSchema } from "@autumn/shared";
import { handleRequestError } from "@/utils/errorUtils.js"; import { handleRequestError } from "@/utils/errorUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js"; import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
@@ -18,7 +18,7 @@ rewardRouter.post("", async (req: any, res: any) => {
const { orgId, env } = req; const { orgId, env } = req;
const couponBody = req.body; const couponBody = req.body;
const couponData = CreateCouponSchema.parse(couponBody); const couponData = CreateRewardSchema.parse(couponBody);
const org = await OrgService.getFromReq(req); const org = await OrgService.getFromReq(req);
const newCoupon = initCoupon({ const newCoupon = initCoupon({
coupon: couponData, coupon: couponData,
@@ -74,12 +74,12 @@ rewardRouter.post("", async (req: any, res: any) => {
prices, prices,
}); });
console.log("✅ Coupon successfully created in Stripe"); console.log("✅ Reward successfully created in Stripe");
const insertedCoupon = await RewardService.insert({ const insertedCoupon = await RewardService.insert({
sb: req.sb, sb: req.sb,
data: newCoupon, data: newCoupon,
}); });
console.log("✅ Coupon successfully inserted into db"); console.log("✅ Reward successfully inserted into db");
res.status(200).json(insertedCoupon); res.status(200).json(insertedCoupon);
} catch (error) { } catch (error) {
@@ -117,7 +117,7 @@ rewardRouter.delete("/:id", async (req: any, res: any) => {
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: "Coupon deleted successfully", message: "Reward deleted successfully",
}); });
} catch (error) { } catch (error) {
handleRequestError({ handleRequestError({
@@ -135,7 +135,7 @@ rewardRouter.post("/:id", async (req: any, res: any) => {
const { orgId, env } = req; const { orgId, env } = req;
const couponBody = req.body; const couponBody = req.body;
console.log("Coupon body", couponBody); console.log("Reward body", couponBody);
const org = await OrgService.getFromReq(req); const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({ const stripeCli = createStripeCli({
org, org,

View File

@@ -26,6 +26,9 @@ rewardTriggerRouter.post("", (req, res) =>
} }
); );
console.log("✅ Successfully created reward trigger");
console.log(createdRewardTrigger);
return res.status(200).json(createdRewardTrigger); return res.status(200).json(createdRewardTrigger);
}, },
}) })

View File

@@ -281,14 +281,17 @@ export class CusService {
const { data, error } = await sb const { data, error } = await sb
.from("customers") .from("customers")
.select() .select()
.eq("processor->>id", stripeId) .eq("processor->>id", stripeId);
.single();
if (error) { if (error) {
throw error; throw error;
} }
return data; if (data.length === 0) {
return null;
}
return data[0];
} }
//search customers //search customers

View File

@@ -52,7 +52,8 @@ productRouter.get("/data", async (req: any, res) => {
live_pkey: org.live_pkey, live_pkey: org.live_pkey,
default_currency: org.default_currency, default_currency: org.default_currency,
}, },
coupons, // coupons,
rewards: coupons,
rewardTriggers, rewardTriggers,
}); });
} catch (error) { } catch (error) {

View File

@@ -81,7 +81,6 @@ const publicRouterMiddleware = async (req: any, res: any, next: any) => {
publicRouter.use(publicRouterMiddleware); publicRouter.use(publicRouterMiddleware);
publicRouter.get("/customers/:customer_id", async (req: any, res: any) => { publicRouter.get("/customers/:customer_id", async (req: any, res: any) => {
try { try {
const customerId = req.params.customer_id; const customerId = req.params.customer_id;
console.log("Getting customer (public)", customerId); console.log("Getting customer (public)", customerId);
@@ -103,7 +102,7 @@ publicRouter.get("/customers/:customer_id", async (req: any, res: any) => {
}); });
} }
const { main, addOns, balances, invoices } = await getCustomerDetails({ const cusData = await getCustomerDetails({
customer, customer,
sb: req.sb, sb: req.sb,
orgId: req.org.id, orgId: req.org.id,
@@ -112,13 +111,7 @@ publicRouter.get("/customers/:customer_id", async (req: any, res: any) => {
logger: req.logtail, logger: req.logtail,
}); });
res.status(200).json({ res.status(200).json(cusData);
customer: CustomerResponseSchema.parse(customer),
products: main,
add_ons: addOns,
entitlements: balances,
invoices,
});
} catch (error) { } catch (error) {
handleRequestError({ req, error, res, action: "get customer" }); handleRequestError({ req, error, res, action: "get customer" });
} }
@@ -130,63 +123,59 @@ publicRouter.get(
try { try {
const customerId = req.params.customerId; const customerId = req.params.customerId;
const customer = await CusService.getById({ const customer = await CusService.getById({
sb: req.sb, sb: req.sb,
id: customerId, id: customerId,
orgId: req.org.id, orgId: req.org.id,
env: req.env, env: req.env,
logger: req.logtail, logger: req.logtail,
});
if (!customer) {
return res.status(404).json({
message: `Customer ${customerId} not found`,
});
}
const cusProducts = await CusService.getFullCusProducts({
sb: req.sb,
internalCustomerId: customer.internal_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled],
withProduct: true,
withPrices: true,
});
if (!cusProducts || cusProducts.length === 0) {
return res.status(200).json({
main: [],
add_ons: [],
});
}
let main = [];
let addOns = [];
for (const cusProduct of cusProducts) {
let processed = processFullCusProduct({
cusProduct,
org: req.org,
subs: [],
}); });
if (processed.status == CusProductStatus.Trialing) { if (!customer) {
processed.status = CusProductStatus.Active; return res.status(404).json({
message: `Customer ${customerId} not found`,
});
} }
let isAddOn = cusProduct.product.is_add_on; const cusProducts = await CusService.getFullCusProducts({
if (isAddOn) { sb: req.sb,
addOns.push(processed); internalCustomerId: customer.internal_id,
} else { inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled],
main.push(processed); withProduct: true,
withPrices: true,
});
if (!cusProducts || cusProducts.length === 0) {
return res.status(200).json({
main: [],
add_ons: [],
});
} }
}
let main = [];
let addOns = [];
res.status(200).json({ for (const cusProduct of cusProducts) {
main, let processed = processFullCusProduct({
cusProduct,
org: req.org,
subs: [],
});
if (processed.status == CusProductStatus.Trialing) {
processed.status = CusProductStatus.Active;
}
let isAddOn = cusProduct.product.is_add_on;
if (isAddOn) {
addOns.push(processed);
} else {
main.push(processed);
}
}
res.status(200).json({
main,
add_ons: addOns, add_ons: addOns,
}); });
} catch (error) { } catch (error) {
@@ -205,17 +194,17 @@ publicRouter.get(
orgId: req.org.id, orgId: req.org.id,
env: req.env, env: req.env,
}); });
const features = await FeatureService.getFeatures({ const features = await FeatureService.getFeatures({
sb: req.sb, sb: req.sb,
orgId: req.org.id, orgId: req.org.id,
env: req.env, env: req.env,
}); });
const prices = product.prices; const prices = product.prices;
const options = getOptionsFromPrices(prices, features); const options = getOptionsFromPrices(prices, features);
res.status(200).json(options); res.status(200).json(options);
} catch (error) { } catch (error) {
handleRequestError({ req, error, res, action: "get product options" }); handleRequestError({ req, error, res, action: "get product options" });

View File

@@ -2,6 +2,20 @@ import { notNullish } from "@/utils/genUtils.js";
import { RewardRedemption, RewardTriggerEvent } from "@autumn/shared"; import { RewardRedemption, RewardTriggerEvent } from "@autumn/shared";
export class RewardRedemptionService { export class RewardRedemptionService {
static async getById({ sb, id }: { sb: any; id: string }) {
const { data, error } = await sb
.from("reward_redemptions")
.select("*")
.eq("id", id)
.single();
if (error) {
throw error;
}
return data;
}
static async getByCustomer({ static async getByCustomer({
sb, sb,
internalCustomerId, internalCustomerId,

View File

@@ -1,5 +1,5 @@
import { generateId } from "@/utils/genUtils.js"; import { generateId } from "@/utils/genUtils.js";
import { AppEnv, Coupon } from "@autumn/shared"; import { AppEnv, Reward } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
export class RewardService { export class RewardService {
@@ -8,7 +8,7 @@ export class RewardService {
data, data,
}: { }: {
sb: SupabaseClient; sb: SupabaseClient;
data: Coupon | Coupon[]; data: Reward | Reward[];
}) { }) {
const { data: insertedData, error } = await sb const { data: insertedData, error } = await sb
.from("rewards") .from("rewards")
@@ -103,7 +103,7 @@ export class RewardService {
internalId: string; internalId: string;
env: AppEnv; env: AppEnv;
orgId: string; orgId: string;
update: Partial<Coupon>; update: Partial<Reward>;
}) { }) {
const { data, error } = await sb const { data, error } = await sb
.from("rewards") .from("rewards")

View File

@@ -1,5 +1,5 @@
import { generateId } from "@/utils/genUtils.js"; import { generateId } from "@/utils/genUtils.js";
import { Coupon, CreateCoupon } from "@autumn/shared"; import { Reward, CreateReward } from "@autumn/shared";
export const initCoupon = ({ export const initCoupon = ({
coupon, coupon,
@@ -7,7 +7,7 @@ export const initCoupon = ({
env, env,
id, id,
}: { }: {
coupon: CreateCoupon; coupon: CreateReward;
orgId: string; orgId: string;
env: string; env: string;
id?: string; id?: string;
@@ -31,7 +31,7 @@ export enum CouponType {
Standard = "standard", Standard = "standard",
} }
export const getCouponType = (coupon: Coupon) => { export const getCouponType = (coupon: Reward) => {
if (!coupon) return null; if (!coupon) return null;
if (coupon.apply_to_all && coupon.should_rollover) { if (coupon.apply_to_all && coupon.should_rollover) {
return CouponType.AddInvoiceBalance; return CouponType.AddInvoiceBalance;

View File

@@ -43,3 +43,7 @@ export const notNullish = (value: any) => {
export const formatUnixToDateTime = (unixDate: number) => { export const formatUnixToDateTime = (unixDate: number) => {
return format(new Date(unixDate), "yyyy MMM dd HH:mm:ss"); return format(new Date(unixDate), "yyyy MMM dd HH:mm:ss");
}; };
export const timeout = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View File

@@ -5,16 +5,17 @@ MOCHA_CMD="npx mocha --parallel --timeout 10000000 --ignore tests/00_setup.ts"
# TEST PARALLEL # TEST PARALLEL
if [ "$1" == "basic-parallel" ]; then if [ "$1" == "basic-parallel" ]; then
MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \ MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \
tests/basic/*.ts \ tests/basic/referrals/*.ts \
tests/basic/entities/*.ts \ tests/attach/**/*.ts \
# tests/attach/**/*.ts \ # tests/basic/*.ts \
# tests/basic/entities/*.ts \
elif [ "$1" == "advanced-parallel" ]; then elif [ "$1" == "advanced-parallel" ]; then
MOCHA_PARALLEL=true \ MOCHA_PARALLEL=true \
$MOCHA_SETUP \ $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/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\
# && $MOCHA_CMD 'tests/advanced/coupons/*.ts' \ # && $MOCHA_CMD 'tests/advanced/usage/*.ts' \
elif [ "$1" == "alex-parallel" ]; then elif [ "$1" == "alex-parallel" ]; then

View File

@@ -7,9 +7,10 @@ import {
creditSystems, creditSystems,
advanceProducts, advanceProducts,
attachProducts, attachProducts,
coupons, rewards,
oneTimeProducts, oneTimeProducts,
entityProducts, entityProducts,
referralPrograms,
} from "./global.js"; } from "./global.js";
const ORG_SLUG = "unit-test-org"; const ORG_SLUG = "unit-test-org";
@@ -32,9 +33,10 @@ describe("Initialize org for tests", () => {
...oneTimeProducts, ...oneTimeProducts,
...entityProducts, ...entityProducts,
} as any, } as any,
coupons: { ...coupons } as any, rewards: { ...rewards } as any,
rewardTriggers: { ...referralPrograms } as any,
}); });
console.log("--------------------------------"); console.log("--------------------------------");
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js"; import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js"; import { createStripeCli } from "@/external/stripe/utils.js";
import { getOriginalCouponId } from "@/internal/coupons/couponUtils.js"; import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { getPriceForOverage } from "@/internal/prices/priceUtils.js"; import { getPriceForOverage } from "@/internal/prices/priceUtils.js";
import { Customer } from "@autumn/shared"; import { Customer } from "@autumn/shared";
import { expect } from "chai"; import { expect } from "chai";
@@ -8,7 +8,7 @@ import chalk from "chalk";
import { addDays, addHours, addMonths, format } from "date-fns"; import { addDays, addHours, addMonths, format } from "date-fns";
import Stripe from "stripe"; import Stripe from "stripe";
import { AutumnCli } from "tests/cli/AutumnCli.js"; import { AutumnCli } from "tests/cli/AutumnCli.js";
import { coupons, features, products } from "tests/global.js"; import { features, products, rewards } from "tests/global.js";
import { compareMainProduct } from "tests/utils/compare.js"; import { compareMainProduct } from "tests/utils/compare.js";
import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js"; import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js";
@@ -29,7 +29,7 @@ describe(
let customer: Customer; let customer: Customer;
let testClockId: string; let testClockId: string;
let couponAmount = coupons.rolloverAll.discount_value; let couponAmount = rewards.rolloverAll.discount_value;
before(async function () { before(async function () {
const { testClockId: testClockId1, customer: customer1 } = const { testClockId: testClockId1, customer: customer1 } =
@@ -65,7 +65,7 @@ describe(
await completeCheckoutForm( await completeCheckoutForm(
res.checkout_url, res.checkout_url,
undefined, undefined,
coupons.rolloverAll.id rewards.rolloverAll.id
); );
await timeout(20000); await timeout(20000);
@@ -88,7 +88,7 @@ describe(
try { try {
expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal(
coupons.rolloverAll.id rewards.rolloverAll.id
); );
// Expect amount to be original amount - pro price // Expect amount to be original amount - pro price
@@ -97,7 +97,7 @@ describe(
console.error("--------------------------------"); console.error("--------------------------------");
console.error( console.error(
"Expected stripe cus to have coupon", "Expected stripe cus to have coupon",
coupons.rolloverAll rewards.rolloverAll
); );
console.error("Actual stripe cus discount", cusDiscount); console.error("Actual stripe cus discount", cusDiscount);
throw error; throw error;
@@ -140,13 +140,13 @@ describe(
try { try {
expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal(
coupons.rolloverAll.id rewards.rolloverAll.id
); );
expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100); expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100);
} catch (error) { } catch (error) {
console.log("--------------------------------"); console.log("--------------------------------");
console.log("coupon1, cycle 1 failed"); console.log("coupon1, cycle 1 failed");
console.log("Expected stripe cus to have coupon", coupons.rolloverAll); console.log("Expected stripe cus to have coupon", rewards.rolloverAll);
console.log("Actual stripe cus discount", cusDiscount); console.log("Actual stripe cus discount", cusDiscount);
throw error; throw error;
} }

View File

@@ -1,13 +1,13 @@
import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js"; import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js"; import { createStripeCli } from "@/external/stripe/utils.js";
import { getOriginalCouponId } from "@/internal/coupons/couponUtils.js"; import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { getPriceForOverage } from "@/internal/prices/priceUtils.js"; import { getPriceForOverage } from "@/internal/prices/priceUtils.js";
import { Customer } from "@autumn/shared"; import { Customer } from "@autumn/shared";
import { expect } from "chai"; import { expect } from "chai";
import chalk from "chalk"; import chalk from "chalk";
import Stripe from "stripe"; import Stripe from "stripe";
import { AutumnCli } from "tests/cli/AutumnCli.js"; import { AutumnCli } from "tests/cli/AutumnCli.js";
import { coupons, features, products } from "tests/global.js"; import { features, products, rewards } from "tests/global.js";
import { compareMainProduct } from "tests/utils/compare.js"; import { compareMainProduct } from "tests/utils/compare.js";
import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js"; import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js";
import { import {
@@ -27,7 +27,7 @@ describe(
let customer: Customer; let customer: Customer;
let testClockId: string; let testClockId: string;
let couponAmount = coupons.rolloverAll.discount_value; let couponAmount = rewards.rolloverUsage.discount_value;
before(async function () { before(async function () {
const { testClockId: testClockId1, customer: customer1 } = const { testClockId: testClockId1, customer: customer1 } =
@@ -61,7 +61,7 @@ describe(
await completeCheckoutForm( await completeCheckoutForm(
res.checkout_url, res.checkout_url,
undefined, undefined,
coupons.rolloverUsage.id rewards.rolloverUsage.id
); );
await timeout(10000); await timeout(10000);
@@ -85,7 +85,7 @@ describe(
try { try {
expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal(
coupons.rolloverUsage.id rewards.rolloverUsage.id
); );
// Expect amount to be original amount - pro price // Expect amount to be original amount - pro price
@@ -94,7 +94,7 @@ describe(
logger.error("--------------------------------"); logger.error("--------------------------------");
logger.error( logger.error(
"Expected stripe cus to have coupon", "Expected stripe cus to have coupon",
coupons.rolloverUsage rewards.rolloverUsage
); );
logger.error("Actual stripe cus discount", cusDiscount); logger.error("Actual stripe cus discount", cusDiscount);
throw error; throw error;
@@ -139,7 +139,7 @@ describe(
try { try {
expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal( expect(getOriginalCouponId(cusDiscount.coupon?.id)).to.equal(
coupons.rolloverUsage.id rewards.rolloverUsage.id
); );
expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100); expect(cusDiscount.coupon?.amount_off).to.equal(couponAmount * 100);
} catch (error) { } catch (error) {
@@ -147,7 +147,7 @@ describe(
logger.error("coupon2, cycle 1 failed"); logger.error("coupon2, cycle 1 failed");
logger.error( logger.error(
"Expected stripe cus to have coupon", "Expected stripe cus to have coupon",
coupons.rolloverUsage rewards.rolloverUsage
); );
logger.error("Actual stripe cus discount", cusDiscount); logger.error("Actual stripe cus discount", cusDiscount);
throw error; throw error;

View File

@@ -14,7 +14,7 @@ import { sendGPUEvents } from "../../utils/advancedUsageUtils.js";
import chalk from "chalk"; import chalk from "chalk";
const PRECISION = 12; const PRECISION = 12;
describe(`${chalk.yellowBright( describe.skip(`${chalk.yellowBright(
"Testing group by -- regular metered1 feature" "Testing group by -- regular metered1 feature"
)}`, () => { )}`, () => {
let customerId = "group-by-basic-metered"; let customerId = "group-by-basic-metered";

View File

@@ -0,0 +1,194 @@
import { 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";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals1: Testing referrals (on checkout)"
)}`, () => {
let mainCustomerId = "main-referral-1";
let redeemers = ["referral1-r1", "referral1-r2", "referral1-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,
});
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.onCheckout.id,
});
assert.exists(referralCode.code);
// Get referral code again
let referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
});
assert.equal(referralCode2.code, 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.onCheckout.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
assert.equal(redemption.triggered, true);
assert.equal(redemption.applied, i == 0);
}
// Check stripe customer
let stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
}
});
let curTime = new Date();
it("customer should have discount for first purchase", async function () {
curTime = addHours(addDays(curTime, 7), 2);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// 1. Get invoice
let { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices.length, 2);
assert.equal(invoices[0].total, 0);
});
it("customer should have discount for second purchase", async function () {
// 2. Check that customer has another discount
let stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
// 2. Advance test clock to 1 month from start (trigger discount.deleted event)
curTime = addHours(addMonths(new Date(), 1), 2);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// 3. Advance test clock to 1 month + 7 days from start (trigger new invoice)
curTime = addDays(curTime, 7);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// // 3. Get invoice again
let { invoices: invoices2 } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices2.length, 3);
assert.equal(invoices2[0].total, 0);
});
});

View File

@@ -0,0 +1,171 @@
import { 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";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals2: Testing referrals (immediate redemption)"
)}`, () => {
let mainCustomerId = "main-referral-2";
let redeemers = ["referral2-r1", "referral2-r2", "referral2-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,
});
testClockId = testClockId1;
mainCustomer = customer;
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.immediate.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async function () {
for (let i = 0; i < redeemers.length; i++) {
let redeemer = redeemers[i];
let count = i + 1;
try {
let redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
redemptions.push(redemption);
if (count > referralPrograms.immediate.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
assert.fail("Should not be able to redeem again");
}
} catch (error) {
if (count > referralPrograms.immediate.max_redemptions) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.ReferralCodeMaxRedemptionsReached);
}
}
}
// 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);
// }
// Check stripe customer
let stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
});
let curTime = new Date();
it("customer should have discount for first purchase", async function () {
await autumn.attach({
customerId: mainCustomerId,
productId: products.proWithTrial.id,
});
await timeout(3000);
curTime = addHours(addDays(curTime, 7), 2);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// 1. Get invoice
let { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices.length, 2);
assert.equal(invoices[0].total, 0);
});
it("customer should have discount for second purchase", async function () {
// 2. Check that customer has another discount
let stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
// 2. Advance test clock to 1 month from start (trigger discount.deleted event)
curTime = addHours(addMonths(new Date(), 1), 2);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// 3. Advance test clock to 1 month + 7 days from start (trigger new invoice)
curTime = addDays(curTime, 7);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// // 3. Get invoice again
let { invoices: invoices2 } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices2.length, 3);
assert.equal(invoices2[0].total, 0);
});
});

View File

@@ -1,7 +1,7 @@
import { el } from "date-fns/locale"; import { el } from "date-fns/locale";
import { getAxiosInstance } from "../utils/setup.js"; import { getAxiosInstance } from "../utils/setup.js";
import RecaseError from "@/utils/errorUtils.js"; import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, CreateCoupon } from "@autumn/shared"; import { AppEnv, CreateReward } from "@autumn/shared";
const handleAxiosError = (error: any) => { const handleAxiosError = (error: any) => {
if (error.response.data) { if (error.response.data) {
// console.log(error.response.data); // console.log(error.response.data);
@@ -212,7 +212,7 @@ export class AutumnCli {
return data; return data;
} }
static async createCoupon(coupon: CreateCoupon) { static async createCoupon(coupon: CreateReward) {
const axiosInstance = getAxiosInstance(); const axiosInstance = getAxiosInstance();
const { data } = await axiosInstance.post(`/v1/coupons`, coupon); const { data } = await axiosInstance.post(`/v1/coupons`, coupon);
return data; return data;

View File

@@ -5,17 +5,21 @@ import {
AllowanceType, AllowanceType,
AppEnv, AppEnv,
BillingInterval, BillingInterval,
CouponDurationType,
DiscountType,
EntInterval, EntInterval,
Feature, Feature,
RewardTriggerEvent,
} from "@autumn/shared"; } from "@autumn/shared";
import { FeatureType } from "@autumn/shared"; import { FeatureType } from "@autumn/shared";
import { import {
initCoupon, initReward,
initEntitlement, initEntitlement,
initFeature, initFeature,
initFreeTrial, initFreeTrial,
initPrice, initPrice,
initProduct, initProduct,
initRewardTrigger,
} from "./utils/init.js"; } from "./utils/init.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
@@ -762,27 +766,49 @@ export const entityProducts = {
oneTier: true, oneTier: true,
billingUnits: 1, billingUnits: 1,
// Carry over usage // Carry over usage
}) }),
], ],
freeTrial: null, freeTrial: null,
}), }),
}; };
export const coupons = { export const rewards = {
rolloverAll: initCoupon({ rolloverAll: initReward({
id: "rolloverAll", id: "rolloverAll",
discountValue: 1000, discountValue: 1000,
rollover: true, rollover: true,
applyToAll: true, applyToAll: true,
}), }),
rolloverUsage: initCoupon({ rolloverUsage: initReward({
id: "rolloverUsage", id: "rolloverUsage",
discountValue: 1000, discountValue: 1000,
rollover: true, rollover: true,
onlyUsagePrices: true, onlyUsagePrices: true,
productIds: [products.proWithOverage.id], productIds: [products.proWithOverage.id],
}), }),
monthOff: initReward({
id: "monthOff",
discountType: DiscountType.Percentage,
discountValue: 100,
applyToAll: true,
durationType: CouponDurationType.Months,
durationValue: 1,
}),
};
export const referralPrograms = {
onCheckout: initRewardTrigger({
id: "onCheckout",
internalRewardId: rewards.monthOff.id,
when: RewardTriggerEvent.Checkout,
productIds: [products.pro.id, products.proWithTrial.id],
}),
immediate: initRewardTrigger({
id: "immediate",
internalRewardId: rewards.monthOff.id,
when: RewardTriggerEvent.Immediately,
// productIds: [products.pro.id, products.proWithTrial.id],
}),
}; };
const ORG_SLUG = "unit-test-org"; const ORG_SLUG = "unit-test-org";

View File

@@ -14,6 +14,7 @@ import {
FreeTrial, FreeTrial,
Organization, Organization,
PriceType, PriceType,
RewardTriggerEvent,
} from "@autumn/shared"; } from "@autumn/shared";
import { getAxiosInstance } from "./setup.js"; import { getAxiosInstance } from "./setup.js";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
@@ -326,8 +327,8 @@ export const initCustomer = async ({
} }
}; };
// Init Coupon // Init Reward
export const initCoupon = ({ export const initReward = ({
id, id,
discountType = DiscountType.Fixed, discountType = DiscountType.Fixed,
discountValue, discountValue,
@@ -361,3 +362,25 @@ export const initCoupon = ({
product_ids: productIds, product_ids: productIds,
}; };
}; };
export const initRewardTrigger = ({
id,
when = RewardTriggerEvent.Immediately,
productIds = [],
internalRewardId,
maxRedemptions = 2,
}: {
id: string;
productIds?: string[];
internalRewardId: string;
when?: RewardTriggerEvent;
maxRedemptions?: number;
}): any => {
return {
id,
when,
product_ids: productIds,
internal_reward_id: internalRewardId,
max_redemptions: maxRedemptions,
};
};

View File

@@ -1,12 +1,14 @@
import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { import {
AppEnv, AppEnv,
CreateCoupon, CreateReward,
Feature, Feature,
FeatureType, FeatureType,
FullProduct, FullProduct,
Price, Price,
PriceType, PriceType,
Reward,
RewardTrigger,
} from "@autumn/shared"; } from "@autumn/shared";
import axios from "axios"; import axios from "axios";
@@ -18,6 +20,8 @@ import {
} from "./stripeUtils.js"; } from "./stripeUtils.js";
import { AutumnCli } from "tests/cli/AutumnCli.js"; import { AutumnCli } from "tests/cli/AutumnCli.js";
import Stripe from "stripe"; import Stripe from "stripe";
import { Autumn } from "@/external/autumn/autumnCli.js";
import { deactivateStripeMeters } from "@/external/stripe/stripeProductUtils.js";
export const getAxiosInstance = ( export const getAxiosInstance = (
apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY! apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!
@@ -168,11 +172,15 @@ export const clearOrg = async ({
await deleteAllStripeTestClocks({ stripeCli }); await deleteAllStripeTestClocks({ stripeCli });
console.log(" ✅ Deleted Stripe test clocks"); console.log(" ✅ Deleted Stripe test clocks");
// Delete all stripe meters
await deactivateStripeMeters({ org, env });
console.log(" ✅ Deactivated Stripe meters");
// Batch delete coupons // Batch delete coupons
const batchDeleteCoupons = []; const batchDeleteCoupons = [];
const { data: coupons, error: couponError } = await sb const { data: coupons, error: couponError } = await sb
.from("coupons") .from("rewards")
.delete() .delete()
.eq("org_id", orgId) .eq("org_id", orgId)
.eq("env", env) .eq("env", env)
@@ -207,16 +215,19 @@ export const setupOrg = async ({
env, env,
features, features,
products, products,
coupons, rewards,
rewardTriggers,
}: { }: {
orgId: string; orgId: string;
env: AppEnv; env: AppEnv;
features: Record<string, Feature & { eventName: string }>; features: Record<string, Feature & { eventName: string }>;
products: Record<string, FullProduct | any>; products: Record<string, FullProduct | any>;
coupons: Record<string, any>; rewards: Record<string, any>;
rewardTriggers: Record<string, RewardTrigger>;
}) => { }) => {
const axiosInstance = getAxiosInstance(); const axiosInstance = getAxiosInstance();
const sb = createSupabaseClient(); const sb = createSupabaseClient();
const autumn = new Autumn(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!);
let insertFeatures = []; let insertFeatures = [];
for (const feature of Object.values(features)) { for (const feature of Object.values(features)) {
@@ -298,14 +309,14 @@ export const setupOrg = async ({
// Insert coupons // Insert coupons
let insertCoupons = []; let insertCoupons = [];
for (const coupon of Object.values(coupons)) { for (const reward of Object.values(rewards)) {
const createCoupon = async () => { const createCoupon = async () => {
let priceIds = []; let priceIds = [];
if (coupon.only_usage_prices) { if (reward.only_usage_prices) {
let filteredProducts = allProducts.filter((product: FullProduct) => { let filteredProducts = allProducts.filter((product: FullProduct) => {
if (coupon.product_ids) { if (reward.product_ids) {
return coupon.product_ids.includes(product.id); return reward.product_ids.includes(product.id);
} else return true; } else return true;
}); });
@@ -316,44 +327,56 @@ export const setupOrg = async ({
return price.id; return price.id;
}) })
); );
} else if (coupon.product_ids) { } else if (reward.product_ids) {
priceIds = allProducts priceIds = allProducts
.filter((product: FullProduct) => .filter((product: FullProduct) =>
coupon.product_ids.includes(product.id) reward.product_ids.includes(product.id)
) )
.flatMap((product: FullProduct) => .flatMap((product: FullProduct) =>
product.prices.map((price) => price.id) product.prices.map((price) => price.id)
); );
} }
const newCoupon: CreateCoupon & { id: string } = { const newReward: CreateReward & { id: string } = {
id: coupon.id, id: reward.id,
name: coupon.name, name: reward.name,
price_ids: priceIds, price_ids: priceIds,
promo_codes: [ promo_codes: [
{ {
code: coupon.id, code: reward.id,
}, },
], ],
discount_type: coupon.discount_type, discount_type: reward.discount_type,
discount_value: coupon.discount_value, discount_value: reward.discount_value,
duration_type: coupon.duration_type, duration_type: reward.duration_type,
duration_value: coupon.duration_value, duration_value: reward.duration_value,
should_rollover: coupon.should_rollover, should_rollover: reward.should_rollover,
apply_to_all: coupon.apply_to_all, apply_to_all: reward.apply_to_all,
}; };
let couponRes = await AutumnCli.createCoupon(newCoupon); let rewardRes = await autumn.rewards.create(newReward);
return { return {
id: coupon.id, id: reward.id,
couponRes, rewardRes,
}; };
}; };
insertCoupons.push(createCoupon()); insertCoupons.push(createCoupon());
} }
await Promise.all(insertCoupons); await Promise.all(insertCoupons);
console.log("✅ Inserted coupons"); console.log("✅ Inserted coupons");
// CREATE REWARD TRIGGERS
let insertRewardTriggers = [];
for (const rewardTrigger of Object.values(rewardTriggers)) {
const createRewardTrigger = async () => {
await autumn.referralPrograms.create(rewardTrigger);
};
insertRewardTriggers.push(createRewardTrigger());
}
await Promise.all(insertRewardTriggers);
console.log("✅ Inserted reward triggers");
// Initialize stripe products // Initialize stripe products
// How to check if mocha is in parallel mode? // How to check if mocha is in parallel mode?
if (process.env.MOCHA_PARALLEL) { if (process.env.MOCHA_PARALLEL) {

View File

@@ -11,7 +11,7 @@ export enum DiscountType {
Fixed = "fixed", Fixed = "fixed",
} }
const CouponSchema = z.object({ const RewardSchema = z.object({
internal_id: z.string(), internal_id: z.string(),
name: z.string().nullish(), name: z.string().nullish(),
price_ids: z.array(z.string()), price_ids: z.array(z.string()),
@@ -31,13 +31,12 @@ const CouponSchema = z.object({
created_at: z.number(), created_at: z.number(),
}); });
export const CreateCouponSchema = CouponSchema.omit({ export const CreateRewardSchema = RewardSchema.omit({
internal_id: true, internal_id: true,
org_id: true, org_id: true,
env: true, env: true,
created_at: true, created_at: true,
}); });
export type Coupon = z.infer<typeof CouponSchema>; export type CreateReward = z.infer<typeof CreateRewardSchema>;
export type CreateCoupon = z.infer<typeof CreateCouponSchema>; export type Reward = z.infer<typeof RewardSchema>;
export type Reward = z.infer<typeof CouponSchema>;

View File

@@ -1,20 +1,20 @@
import { Coupon, CreateCoupon } from "@autumn/shared"; import { Reward, CreateReward } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
import { AxiosInstance } from "axios"; import { AxiosInstance } from "axios";
export class CouponService { export class RewardService {
static async createCoupon({ static async createReward({
axiosInstance, axiosInstance,
data, data,
}: { }: {
axiosInstance: AxiosInstance; axiosInstance: AxiosInstance;
data: CreateCoupon; data: CreateReward;
}) { }) {
await axiosInstance.post("/v1/rewards", data); await axiosInstance.post("/v1/rewards", data);
} }
static async deleteCoupon({ static async deleteReward({
axiosInstance, axiosInstance,
internalId, internalId,
}: { }: {
@@ -24,14 +24,14 @@ export class CouponService {
await axiosInstance.delete(`/v1/rewards/${internalId}`); await axiosInstance.delete(`/v1/rewards/${internalId}`);
} }
static async updateCoupon({ static async updateReward({
axiosInstance, axiosInstance,
internalId, internalId,
data, data,
}: { }: {
axiosInstance: AxiosInstance; axiosInstance: AxiosInstance;
internalId: string; internalId: string;
data: Coupon; data: Reward;
}) { }) {
await axiosInstance.post(`/v1/rewards/${internalId}`, data); await axiosInstance.post(`/v1/rewards/${internalId}`, data);
} }

View File

@@ -99,7 +99,7 @@ export const CustomerToolbar = ({
}} }}
> >
<div className="flex text-sm items-center justify-between w-full gap-2"> <div className="flex text-sm items-center justify-between w-full gap-2">
<p className="text-t2">Add Coupon</p> <p className="text-t2">Add Reward</p>
<Ticket size={12} className="text-t3" /> <Ticket size={12} className="text-t3" />
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -2,7 +2,7 @@ import { DialogFooter } from "@/components/ui/dialog";
import { getOriginalCouponId } from "@/utils/product/couponUtils"; import { getOriginalCouponId } from "@/utils/product/couponUtils";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { Coupon, CreateCustomer, Customer } from "@autumn/shared"; import { Reward, CreateCustomer, Customer } from "@autumn/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogTitle } from "@/components/ui/dialog"; import { DialogTitle } from "@/components/ui/dialog";
@@ -29,7 +29,7 @@ const UpdateCustomerDialog = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { cusMutate } = useCustomerContext(); const { cusMutate } = useCustomerContext();
const [couponSelected, setCouponSelected] = useState<Coupon | null>(null); const [couponSelected, setCouponSelected] = useState<Reward | null>(null);
const [customer, setCustomer] = useState<CreateCustomer>(selectedCustomer); const [customer, setCustomer] = useState<CreateCustomer>(selectedCustomer);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const env = useEnv(); const env = useEnv();

View File

@@ -2,7 +2,7 @@ import { SelectContent } from "@/components/ui/select";
import { SelectValue } from "@/components/ui/select"; import { SelectValue } from "@/components/ui/select";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { Coupon } from "@autumn/shared"; import { Reward } from "@autumn/shared";
import { Select, SelectItem } from "@/components/ui/select"; import { Select, SelectItem } from "@/components/ui/select";
import { DialogContent, DialogTitle } from "@/components/ui/dialog"; import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useState } from "react"; import { useState } from "react";
@@ -22,7 +22,7 @@ const AddCouponDialogContent = ({
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
}) => { }) => {
const { cusMutate, customer, coupons, discount } = useCustomerContext(); const { cusMutate, customer, coupons, discount } = useCustomerContext();
const [couponSelected, setCouponSelected] = useState<Coupon | null>(null); const [couponSelected, setCouponSelected] = useState<Reward | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const env = useEnv(); const env = useEnv();
const axiosInstance = useAxiosInstance({ env }); const axiosInstance = useAxiosInstance({ env });
@@ -38,7 +38,7 @@ const AddCouponDialogContent = ({
setOpen(false); setOpen(false);
await cusMutate(); await cusMutate();
toast.success("Coupon added to customer"); toast.success("Reward added to customer");
} catch (error) { } catch (error) {
toast.error(getBackendErr(error, "Failed to create coupon")); toast.error(getBackendErr(error, "Failed to create coupon"));
} finally { } finally {
@@ -51,7 +51,7 @@ const AddCouponDialogContent = ({
const getExistingCoupon = () => { const getExistingCoupon = () => {
if (discount) { if (discount) {
return coupons.find( return coupons.find(
(c: Coupon) => c.internal_id === getOriginalCouponId(discount.coupon.id) (c: Reward) => c.internal_id === getOriginalCouponId(discount.coupon.id)
); );
} else { } else {
return null; return null;
@@ -60,10 +60,10 @@ const AddCouponDialogContent = ({
return ( return (
<DialogContent> <DialogContent>
<DialogTitle>Add Coupon</DialogTitle> <DialogTitle>Add Reward</DialogTitle>
{getExistingCoupon() && ( {getExistingCoupon() && (
<WarningBox> <WarningBox>
Coupon {getExistingCoupon()?.name} already applied. Adding a new one Reward {getExistingCoupon()?.name} already applied. Adding a new one
will replace the existing one. will replace the existing one.
</WarningBox> </WarningBox>
)} )}
@@ -71,20 +71,20 @@ const AddCouponDialogContent = ({
<Select <Select
value={couponSelected?.internal_id} value={couponSelected?.internal_id}
onValueChange={(value) => { onValueChange={(value) => {
const coupon = coupons.find((c: Coupon) => c.internal_id === value); const coupon = coupons.find((c: Reward) => c.internal_id === value);
if (coupon) { if (coupon) {
setCouponSelected(coupon); setCouponSelected(coupon);
} }
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select Coupon" /> <SelectValue placeholder="Select Reward" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* If empty */} {/* If empty */}
{coupons && coupons.length > 0 ? ( {coupons && coupons.length > 0 ? (
coupons.map((coupon: Coupon) => ( coupons.map((coupon: Reward) => (
<SelectItem key={coupon.internal_id} value={coupon.internal_id}> <SelectItem key={coupon.internal_id} value={coupon.internal_id}>
{coupon.name} {coupon.name}
</SelectItem> </SelectItem>
@@ -104,7 +104,7 @@ const AddCouponDialogContent = ({
disabled={!couponSelected} disabled={!couponSelected}
isLoading={loading} isLoading={loading}
> >
Add Coupon Add Reward
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -8,19 +8,19 @@ import { ProductsContext } from "./ProductsContext";
import { AppEnv } from "@autumn/shared"; import { AppEnv } from "@autumn/shared";
import CreateProduct from "./CreateProduct"; import CreateProduct from "./CreateProduct";
import { ProductsTable } from "./ProductsTable"; import { ProductsTable } from "./ProductsTable";
import { CouponsTable } from "./coupons/CouponsTable";
import CreateCoupon from "./coupons/CreateCoupon";
import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton";
import { Ticket } from "lucide-react"; import { Ticket } from "lucide-react";
import React from "react"; import React from "react";
import CreateRewardTrigger from "./reward-triggers/CreateRewardTriger";
import { RewardTriggersTable } from "./reward-triggers/RewardTriggersTable"; import { RewardTriggersTable } from "./reward-triggers/RewardTriggersTable";
import CreateRewardTriggerModal from "./reward-triggers/CreateRewardTriger"; import CreateRewardTriggerModal from "./reward-triggers/CreateRewardTriger";
import { RewardsTable } from "./rewards/RewardsTAble";
import CreateReward from "./rewards/CreateReward";
function ProductsView({ env }: { env: AppEnv }) { function ProductsView({ env }: { env: AppEnv }) {
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [showCoupons, setShowCoupons] = useState(false); const [showRewards, setShowRewards] = useState(false);
const { data, isLoading, mutate } = useAxiosSWR({ const { data, isLoading, mutate } = useAxiosSWR({
url: `/products/data`, url: `/products/data`,
env: env, env: env,
@@ -38,8 +38,8 @@ function ProductsView({ env }: { env: AppEnv }) {
setSelectedProduct(data.products[0]); setSelectedProduct(data.products[0]);
} }
if (data?.coupons.length > 0) { if (data?.rewards.length > 0) {
setShowCoupons(true); setShowRewards(true);
} }
}, [data]); }, [data]);
@@ -65,9 +65,9 @@ function ProductsView({ env }: { env: AppEnv }) {
</p> </p>
</div> </div>
<ToggleDisplayButton <ToggleDisplayButton
show={showCoupons} show={showRewards}
disabled={data?.coupons.length > 0} disabled={data?.rewards.length > 0}
onClick={() => setShowCoupons((prev) => !prev)} onClick={() => setShowRewards((prev) => !prev)}
> >
<Ticket size={12} className="mr-2" /> <Ticket size={12} className="mr-2" />
Coupons Coupons
@@ -75,7 +75,7 @@ function ProductsView({ env }: { env: AppEnv }) {
</div> </div>
<ProductsTable products={data?.products} /> <ProductsTable products={data?.products} />
<CreateProduct /> <CreateProduct />
{showCoupons && ( {showRewards && (
<React.Fragment> <React.Fragment>
<div className="flex flex-col gap-4 h-fit mt-6"> <div className="flex flex-col gap-4 h-fit mt-6">
<div> <div>
@@ -86,8 +86,8 @@ function ProductsView({ env }: { env: AppEnv }) {
{/* <span className="text-t3">(eg, 10% off all products).</span> */} {/* <span className="text-t3">(eg, 10% off all products).</span> */}
</p> </p>
</div> </div>
<CouponsTable /> <RewardsTable />
<CreateCoupon /> <CreateReward />
</div> </div>
<div className="flex flex-col gap-4 h-fit mt-6"> <div className="flex flex-col gap-4 h-fit mt-6">
<div> <div>

View File

@@ -19,9 +19,9 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner"; import { toast } from "sonner";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { import {
Coupon, Reward,
CouponDurationType, CouponDurationType,
CreateCoupon as CreateCouponType, CreateReward as CreateCouponType,
DiscountType, DiscountType,
RewardTrigger, RewardTrigger,
RewardTriggerEvent, RewardTriggerEvent,

View File

@@ -7,7 +7,7 @@ import {
SelectItem, SelectItem,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RewardTrigger, Coupon, RewardTriggerEvent } from "@autumn/shared"; import { RewardTrigger, Reward, RewardTriggerEvent } from "@autumn/shared";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useState } from "react"; import { useState } from "react";
@@ -50,7 +50,7 @@ export const RewardTriggerConfig = ({
/> />
</div> </div>
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel>Coupon</FieldLabel> <FieldLabel>Reward</FieldLabel>
<Select <Select
value={rewardTrigger.internal_reward_id} value={rewardTrigger.internal_reward_id}
onValueChange={(value) => onValueChange={(value) =>
@@ -61,7 +61,7 @@ export const RewardTriggerConfig = ({
<SelectValue placeholder="Select a coupon" /> <SelectValue placeholder="Select a coupon" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{coupons.map((coupon: Coupon) => ( {coupons.map((coupon: Reward) => (
<SelectItem key={coupon.name} value={coupon.internal_id}> <SelectItem key={coupon.name} value={coupon.internal_id}>
{coupon.name} {coupon.name}
</SelectItem> </SelectItem>

View File

@@ -9,7 +9,7 @@ import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { TableCell } from "@/components/ui/table"; import { TableCell } from "@/components/ui/table";
import { import {
Coupon, Reward,
CouponDurationType, CouponDurationType,
DiscountType, DiscountType,
RewardTrigger, RewardTrigger,

View File

@@ -1,7 +1,3 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { SelectContent } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { SelectItem } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -19,18 +15,18 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner"; import { toast } from "sonner";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { import {
Coupon, Reward,
CouponDurationType, CouponDurationType,
CreateCoupon as CreateCouponType, CreateReward as CreateRewardType,
DiscountType, DiscountType,
} from "@autumn/shared"; } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { RewardService } from "@/services/products/RewardService";
import { RewardConfig } from "./RewardConfig";
import { CouponConfig } from "./CouponConfig"; const defaultReward: CreateRewardType = {
import { CouponService } from "@/services/products/CouponService";
const defaultCoupon: CreateCouponType = {
name: "", name: "",
promo_codes: [{ code: "" }], promo_codes: [{ code: "" }],
price_ids: [], price_ids: [],
@@ -42,27 +38,27 @@ const defaultCoupon: CreateCouponType = {
apply_to_all: true, apply_to_all: true,
}; };
function CreateCoupon() { function CreateReward() {
const { mutate, env } = useProductsContext(); const { mutate, env } = useProductsContext();
const axiosInstance = useAxiosInstance({ env: env }); const axiosInstance = useAxiosInstance({ env: env });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coupon, setCoupon] = useState(defaultCoupon); const [reward, setReward] = useState(defaultReward);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setCoupon(defaultCoupon); setReward(defaultReward);
} }
}, [open]); }, [open]);
const handleCreate = async () => { const handleCreate = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
await CouponService.createCoupon({ await RewardService.createReward({
axiosInstance, axiosInstance,
data: coupon, data: reward,
}); });
await mutate(); await mutate();
@@ -81,18 +77,18 @@ function CreateCoupon() {
className="w-full" className="w-full"
startIcon={<PlusIcon size={15} />} startIcon={<PlusIcon size={15} />}
> >
Create Coupon Create Reward
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-[500px]"> <DialogContent className="w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Coupon</DialogTitle> <DialogTitle>Create Reward</DialogTitle>
</DialogHeader> </DialogHeader>
{/* <CreditSystemConfig {/* <CreditSystemConfig
creditSystem={creditSystem} creditSystem={creditSystem}
setCreditSystem={setCreditSystem} setCreditSystem={setCreditSystem}
/> */} /> */}
<CouponConfig coupon={coupon as any} setCoupon={setCoupon} /> <RewardConfig reward={reward as any} setReward={setReward} />
<DialogFooter> <DialogFooter>
<Button <Button
onClick={handleCreate} onClick={handleCreate}
@@ -107,4 +103,4 @@ function CreateCoupon() {
); );
} }
export default CreateCoupon; export default CreateReward;

View File

@@ -4,14 +4,8 @@ import { Input } from "@/components/ui/input";
import { useState } from "react"; import { useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select"; import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { import { Reward, CouponDurationType, DiscountType } from "@autumn/shared";
Coupon,
CouponDurationType,
CreateCoupon,
DiscountType,
} from "@autumn/shared";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { import {
Popover, Popover,
@@ -31,12 +25,12 @@ import { Check, ChevronsUpDown, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
export const CouponConfig = ({ export const RewardConfig = ({
coupon, reward,
setCoupon, setReward,
}: { }: {
coupon: Coupon; reward: Reward;
setCoupon: (coupon: Coupon) => void; setReward: (reward: Reward) => void;
}) => { }) => {
const { org } = useProductsContext(); const { org } = useProductsContext();
return ( return (
@@ -45,8 +39,8 @@ export const CouponConfig = ({
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel> <FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input <Input
value={coupon.name || ""} value={reward.name || ""}
onChange={(e) => setCoupon({ ...coupon, name: e.target.value })} onChange={(e) => setReward({ ...reward, name: e.target.value })}
/> />
</div> </div>
<div className="w-6/12"> <div className="w-6/12">
@@ -55,11 +49,11 @@ export const CouponConfig = ({
</FieldLabel> </FieldLabel>
<Input <Input
value={ value={
coupon.promo_codes.length > 0 ? coupon.promo_codes[0].code : "" reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
} }
onChange={(e) => onChange={(e) =>
setCoupon({ setReward({
...coupon, ...reward,
promo_codes: [{ code: e.target.value }], promo_codes: [{ code: e.target.value }],
}) })
} }
@@ -70,9 +64,9 @@ export const CouponConfig = ({
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel>Discount Type</FieldLabel> <FieldLabel>Discount Type</FieldLabel>
<Select <Select
value={coupon.discount_type} value={reward.discount_type}
onValueChange={(value) => onValueChange={(value) =>
setCoupon({ ...coupon, discount_type: value as DiscountType }) setReward({ ...reward, discount_type: value as DiscountType })
} }
> >
<SelectTrigger> <SelectTrigger>
@@ -90,13 +84,13 @@ export const CouponConfig = ({
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel>Amount</FieldLabel> <FieldLabel>Amount</FieldLabel>
<Input <Input
value={coupon.discount_value} value={reward.discount_value}
onChange={(e) => onChange={(e) =>
setCoupon({ ...coupon, discount_value: Number(e.target.value) }) setReward({ ...reward, discount_value: Number(e.target.value) })
} }
endContent={ endContent={
<p className="text-t3"> <p className="text-t3">
{coupon.discount_type === DiscountType.Percentage {reward.discount_type === DiscountType.Percentage
? "%" ? "%"
: org?.currency || "USD"} : org?.currency || "USD"}
</p> </p>
@@ -108,13 +102,13 @@ export const CouponConfig = ({
<div className="w-6/12"> <div className="w-6/12">
<FieldLabel>Duration</FieldLabel> <FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{coupon.duration_type === CouponDurationType.Months && ( {reward.duration_type === CouponDurationType.Months && (
<Input <Input
className="w-[60px] no-spinner" className="w-[60px] no-spinner"
value={coupon.duration_value} value={reward.duration_value}
onChange={(e) => { onChange={(e) => {
setCoupon({ setReward({
...coupon, ...reward,
duration_value: Number(e.target.value), duration_value: Number(e.target.value),
}); });
}} }}
@@ -122,10 +116,10 @@ export const CouponConfig = ({
/> />
)} )}
<Select <Select
value={coupon.duration_type} value={reward.duration_type}
onValueChange={(value) => onValueChange={(value) =>
setCoupon({ setReward({
...coupon, ...reward,
duration_type: value as CouponDurationType, duration_type: value as CouponDurationType,
}) })
} }
@@ -144,13 +138,13 @@ export const CouponConfig = ({
</div> </div>
</div> </div>
</div> </div>
{coupon.duration_type === CouponDurationType.OneOff && ( {reward.duration_type === CouponDurationType.OneOff && (
<div className="w-full ml-1 flex items-center gap-2"> <div className="w-full ml-1 flex items-center gap-2">
<Checkbox <Checkbox
checked={coupon.should_rollover} checked={reward.should_rollover}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setCoupon({ setReward({
...coupon, ...reward,
should_rollover: checked === true, should_rollover: checked === true,
}) })
} }
@@ -162,31 +156,31 @@ export const CouponConfig = ({
<div className="mt-4"> <div className="mt-4">
<p className="text-t2 mb-2">Products</p> <p className="text-t2 mb-2">Products</p>
<ProductPriceSelector coupon={coupon} setCoupon={setCoupon} /> <ProductPriceSelector reward={reward} setReward={setReward} />
</div> </div>
</div> </div>
); );
}; };
const ProductPriceSelector = ({ const ProductPriceSelector = ({
coupon, reward,
setCoupon, setReward,
}: { }: {
coupon: Coupon; reward: Reward;
setCoupon: (coupon: Coupon) => void; setReward: (reward: Reward) => void;
}) => { }) => {
const { products, features } = useProductsContext(); const { products, features } = useProductsContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Handle selection/deselection of a price // Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => { const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...coupon.price_ids]; let newPriceIds = [...reward.price_ids];
if (coupon.price_ids.includes(priceId)) { if (reward.price_ids.includes(priceId)) {
newPriceIds = coupon.price_ids.filter((id) => id !== priceId); newPriceIds = reward.price_ids.filter((id) => id !== priceId);
} else { } else {
newPriceIds = [...coupon.price_ids, priceId]; newPriceIds = [...reward.price_ids, priceId];
} }
setCoupon({ ...coupon, price_ids: newPriceIds }); setReward({ ...reward, price_ids: newPriceIds });
}; };
if (!products || products.length === 0) { if (!products || products.length === 0) {
@@ -212,13 +206,13 @@ const ProductPriceSelector = ({
aria-expanded={open} aria-expanded={open}
className="w-full justify-between min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50" className="w-full justify-between min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50"
> >
{coupon.apply_to_all ? ( {reward.apply_to_all ? (
"All Products" "All Products"
) : coupon.price_ids.length == 0 ? ( ) : reward.price_ids.length == 0 ? (
"Select Products" "Select Products"
) : ( ) : (
<> <>
{coupon.price_ids.map((priceId) => ( {reward.price_ids.map((priceId) => (
<div <div
key={priceId} key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit" className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
@@ -251,20 +245,20 @@ const ProductPriceSelector = ({
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
setCoupon({ setReward({
...coupon, ...reward,
apply_to_all: !coupon.apply_to_all, apply_to_all: !reward.apply_to_all,
}); });
}} }}
className="cursor-pointer" className="cursor-pointer"
> >
<p>Apply to all products</p> <p>Apply to all products</p>
{coupon.apply_to_all && ( {reward.apply_to_all && (
<Check size={12} className="text-t3" /> <Check size={12} className="text-t3" />
)} )}
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
{!coupon.apply_to_all && {!reward.apply_to_all &&
products.map((product: any) => ( products.map((product: any) => (
<CommandGroup key={product.id} heading={product.name}> <CommandGroup key={product.id} heading={product.name}>
{product.prices.length > 0 ? ( {product.prices.length > 0 ? (
@@ -276,7 +270,7 @@ const ProductPriceSelector = ({
className="cursor-pointer" className="cursor-pointer"
> >
<div className="flex items-center">{price.name}</div> <div className="flex items-center">{price.name}</div>
{coupon.price_ids.includes(price.id) && ( {reward.price_ids.includes(price.id) && (
<Check size={12} className="text-t3" /> <Check size={12} className="text-t3" />
)} )}
</CommandItem> </CommandItem>

View File

@@ -10,20 +10,14 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Coupon } from "@autumn/shared"; import { Reward } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { CouponService } from "@/services/products/CouponService"; import { RewardService } from "@/services/products/RewardService";
import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; import { ToolbarButton } from "@/components/general/table-components/ToolbarButton";
import { Delete } from "lucide-react"; import { Delete } from "lucide-react";
export const CouponRowToolbar = ({ export const RewardRowToolbar = ({ reward }: { reward: Reward }) => {
className,
coupon,
}: {
className?: string;
coupon: Coupon;
}) => {
const { env, mutate } = useProductsContext(); const { env, mutate } = useProductsContext();
const axiosInstance = useAxiosInstance({ env }); const axiosInstance = useAxiosInstance({ env });
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
@@ -33,9 +27,9 @@ export const CouponRowToolbar = ({
setDeleteLoading(true); setDeleteLoading(true);
try { try {
await CouponService.deleteCoupon({ await RewardService.deleteReward({
axiosInstance, axiosInstance,
internalId: coupon.internal_id, internalId: reward.internal_id,
}); });
await mutate(); await mutate();
} catch (error) { } catch (error) {

View File

@@ -8,14 +8,14 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { CouponRowToolbar } from "./CouponRowToolbar";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { Coupon, CouponDurationType, DiscountType } from "@autumn/shared"; import { Reward, CouponDurationType, DiscountType } from "@autumn/shared";
import UpdateCoupon from "./UpdateCoupon"; import UpdateReward from "./UpdateReward";
import { useState } from "react"; import { useState } from "react";
export const CouponsTable = () => { import { RewardRowToolbar } from "./RewardRowToolbar";
const { coupons, org } = useProductsContext(); export const RewardsTable = () => {
const [selectedCoupon, setSelectedCoupon] = useState<Coupon | null>(null); const { rewards, org } = useProductsContext();
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// const handleRowClick = (id: string) => { // const handleRowClick = (id: string) => {
@@ -31,11 +31,11 @@ export const CouponsTable = () => {
return ( return (
<> <>
<UpdateCoupon <UpdateReward
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
selectedCoupon={selectedCoupon} selectedReward={selectedReward}
setSelectedCoupon={setSelectedCoupon} setSelectedReward={setSelectedReward}
/> />
<Table> <Table>
<TableHeader className="rounded-full"> <TableHeader className="rounded-full">
@@ -50,48 +50,48 @@ export const CouponsTable = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{coupons.map((coupon: Coupon) => ( {rewards.map((reward: Reward) => (
<TableRow <TableRow
key={coupon.internal_id} key={reward.internal_id}
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
setSelectedCoupon(coupon); setSelectedReward(reward);
setOpen(true); setOpen(true);
}} }}
> >
<TableCell className="font-medium">{coupon.name}</TableCell> <TableCell className="font-medium">{reward.name}</TableCell>
<TableCell className="font-mono"> <TableCell className="font-mono">
{coupon.promo_codes {reward.promo_codes
.map((promoCode) => promoCode.code) .map((promoCode) => promoCode.code)
.join(", ")} .join(", ")}
</TableCell> </TableCell>
<TableCell className="min-w-32"> <TableCell className="min-w-32">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p>{coupon.discount_value} </p> <p>{reward.discount_value} </p>
<p className="text-t3"> <p className="text-t3">
{coupon.discount_type == DiscountType.Percentage {reward.discount_type == DiscountType.Percentage
? "%" ? "%"
: org?.default_currency || "USD"} : org?.default_currency || "USD"}
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell className=""> <TableCell className="">
{coupon.duration_type == CouponDurationType.Months {reward.duration_type == CouponDurationType.Months
? `${coupon.duration_value} months` ? `${reward.duration_value} months`
: coupon.duration_type == CouponDurationType.OneOff && : reward.duration_type == CouponDurationType.OneOff &&
coupon.should_rollover reward.should_rollover
? "One-off (rollover)" ? "One-off (rollover)"
: keyToTitle(coupon.duration_type)} : keyToTitle(reward.duration_type)}
</TableCell> </TableCell>
<TableCell className=""> <TableCell className="">
{formatUnixToDateTime(coupon.created_at).date} {formatUnixToDateTime(reward.created_at).date}
<span className="text-t3"> <span className="text-t3">
{" "} {" "}
{formatUnixToDateTime(coupon.created_at).time}{" "} {formatUnixToDateTime(reward.created_at).time}{" "}
</span> </span>
</TableCell> </TableCell>
<TableCell className=""> <TableCell className="">
<CouponRowToolbar coupon={coupon} /> <RewardRowToolbar reward={reward} />
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -9,27 +9,27 @@ import { Button } from "@/components/ui/button";
import { FeatureService } from "@/services/FeatureService"; import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner"; import { toast } from "sonner";
import { Coupon } from "@autumn/shared"; import { Reward } from "@autumn/shared";
import { useEnv } from "@/utils/envUtils"; import { useEnv } from "@/utils/envUtils";
import { useProductsContext } from "../ProductsContext"; import { useProductsContext } from "../ProductsContext";
import { CouponConfig } from "./CouponConfig"; import { RewardConfig } from "./RewardConfig";
import { CouponService } from "@/services/products/CouponService"; import { RewardService } from "@/services/products/RewardService";
import { getBackendErr } from "@/utils/genUtils"; import { getBackendErr } from "@/utils/genUtils";
import { WarningBox } from "@/components/general/modal-components/WarningBox"; import { WarningBox } from "@/components/general/modal-components/WarningBox";
function UpdateCoupon({ function UpdateReward({
open, open,
setOpen, setOpen,
selectedCoupon, selectedReward,
setSelectedCoupon, setSelectedReward,
}: { }: {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
selectedCoupon: Coupon | null; selectedReward: Reward | null;
setSelectedCoupon: (coupon: Coupon) => void; setSelectedReward: (reward: Reward) => void;
}) { }) {
const [updateLoading, setUpdateLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false);
const { coupons, mutate } = useProductsContext(); const { rewards, mutate } = useProductsContext();
const env = useEnv(); const env = useEnv();
const axiosInstance = useAxiosInstance({ env }); const axiosInstance = useAxiosInstance({ env });
@@ -37,12 +37,12 @@ function UpdateCoupon({
const handleUpdate = async () => { const handleUpdate = async () => {
setUpdateLoading(true); setUpdateLoading(true);
try { try {
await CouponService.updateCoupon({ await RewardService.updateReward({
axiosInstance, axiosInstance,
internalId: selectedCoupon!.internal_id, internalId: selectedReward!.internal_id,
data: selectedCoupon!, data: selectedReward!,
}); });
toast.success("Coupon updated successfully"); toast.success("Reward updated successfully");
await mutate(); await mutate();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
@@ -54,13 +54,13 @@ function UpdateCoupon({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
<DialogTitle>Update Coupon</DialogTitle> <DialogTitle>Update Reward</DialogTitle>
<WarningBox> <WarningBox>
Existing customers with this coupon will not be affected Existing customers with this coupon will not be affected
</WarningBox> </WarningBox>
{selectedCoupon && ( {selectedReward && (
<CouponConfig coupon={selectedCoupon} setCoupon={setSelectedCoupon} /> <RewardConfig reward={selectedReward} setReward={setSelectedReward} />
)} )}
<DialogFooter> <DialogFooter>
@@ -77,4 +77,4 @@ function UpdateCoupon({
); );
} }
export default UpdateCoupon; export default UpdateReward;