adding free product to referrals
This commit is contained in:
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -22,14 +22,14 @@ export class Autumn {
|
||||
public headers: Record<string, string>;
|
||||
public baseUrl: string;
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
constructor(apiKey?: string, baseUrl?: string) {
|
||||
this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
|
||||
this.headers = {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// this.baseUrl = "https://api.useautumn.com/v1";
|
||||
this.baseUrl = "http://localhost:8080/v1";
|
||||
this.baseUrl = baseUrl || "http://localhost:8080/v1";
|
||||
}
|
||||
|
||||
async get(path: string) {
|
||||
|
||||
@@ -298,9 +298,12 @@ const handleInvoicePaidDiscount = async ({
|
||||
expand: ["applies_to"],
|
||||
});
|
||||
|
||||
// console.log("Total discount amounts", totalDiscountAmounts);
|
||||
// console.log("Autumn reward", autumnReward);
|
||||
|
||||
// 1. New amount:
|
||||
const autumnDiscountConfig = autumnReward.discount_config!;
|
||||
const curAmount = autumnDiscountConfig.discount_value;
|
||||
// const autumnDiscountConfig = autumnReward.discount_config!;
|
||||
const curAmount = discount.coupon.amount_off;
|
||||
|
||||
const amountUsed = totalDiscountAmounts?.find(
|
||||
(item) => item.discount === discount.id
|
||||
|
||||
@@ -9,8 +9,10 @@ import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
CustomerEntitlement,
|
||||
ErrCode,
|
||||
FullCusProduct,
|
||||
FullCustomerEntitlement,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
@@ -18,6 +20,7 @@ import Stripe from "stripe";
|
||||
|
||||
import { subIsPrematurelyCanceled } from "../stripeSubUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import { EntityService } from "@/internal/api/entities/EntityService.js";
|
||||
|
||||
export const handleSubscriptionDeleted = async ({
|
||||
sb,
|
||||
@@ -38,6 +41,7 @@ export const handleSubscriptionDeleted = async ({
|
||||
stripeSubId: subscription.id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
withCusEnts: true,
|
||||
});
|
||||
|
||||
if (activeCusProducts.length === 0) {
|
||||
@@ -91,8 +95,6 @@ export const handleSubscriptionDeleted = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's scheduled_id, skip?
|
||||
// Prematurely canceled
|
||||
if (
|
||||
cusProduct.scheduled_ids &&
|
||||
cusProduct.scheduled_ids.length > 0 &&
|
||||
@@ -116,7 +118,7 @@ export const handleSubscriptionDeleted = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Expire current product -> Probably going to be a problem...?
|
||||
// 1. Expire current product
|
||||
await CusProductService.update({
|
||||
sb,
|
||||
cusProductId: cusProduct.id,
|
||||
@@ -126,6 +128,30 @@ export const handleSubscriptionDeleted = async ({
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. TODO: Clear entities
|
||||
let internalFeatureIds = new Set(
|
||||
cusProduct.customer_entitlements.map(
|
||||
(ce: FullCustomerEntitlement) => ce.entitlement.internal_feature_id!
|
||||
)
|
||||
);
|
||||
|
||||
await EntityService.deleteByInternalFeatureId({
|
||||
sb,
|
||||
internalCustomerId: cusProduct.customer.internal_id,
|
||||
internalFeatureIds: Array.from(internalFeatureIds),
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
` ✅ deleted ${internalFeatureIds.size} entities for customer ${cusProduct.customer.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete entities on sub deleted");
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
if (cusProduct.product.is_add_on) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,4 +191,30 @@ export class EntityService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteByInternalFeatureId({
|
||||
sb,
|
||||
internalFeatureIds,
|
||||
internalCustomerId,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
sb: SupabaseClient;
|
||||
internalFeatureIds: string[];
|
||||
internalCustomerId: string;
|
||||
orgId: string;
|
||||
env: string;
|
||||
}) {
|
||||
const { error } = await sb
|
||||
.from("entities")
|
||||
.delete()
|
||||
.in("internal_feature_id", internalFeatureIds)
|
||||
.eq("org_id", orgId)
|
||||
.eq("env", env)
|
||||
.eq("internal_customer_id", internalCustomerId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ rewardRouter.post("", async (req: any, res: any) => {
|
||||
reward: rewardData,
|
||||
orgId,
|
||||
env,
|
||||
internalId: rewardBody.internal_id,
|
||||
});
|
||||
|
||||
if (getRewardCat(newReward) === RewardCategory.Discount) {
|
||||
@@ -136,13 +137,12 @@ rewardRouter.delete("/:id", async (req: any, res: any) => {
|
||||
}
|
||||
});
|
||||
|
||||
rewardRouter.post("/:id", async (req: any, res: any) => {
|
||||
rewardRouter.post("/:internalId", async (req: any, res: any) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { internalId } = req.params;
|
||||
const { orgId, env } = req;
|
||||
const couponBody = req.body;
|
||||
const rewardBody = req.body;
|
||||
|
||||
console.log("Reward body", couponBody);
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
@@ -151,27 +151,29 @@ rewardRouter.post("/:id", async (req: any, res: any) => {
|
||||
|
||||
const prices = await PriceService.getPricesFromIds({
|
||||
sb: req.sb,
|
||||
priceIds: couponBody.price_ids,
|
||||
priceIds: rewardBody.price_ids,
|
||||
});
|
||||
|
||||
// 1. Delete old prices from stripe
|
||||
await stripeCli.coupons.del(id);
|
||||
await stripeCli.coupons.del(internalId);
|
||||
|
||||
// 2. Create new coupon
|
||||
await createStripeCoupon({
|
||||
reward: couponBody,
|
||||
stripeCli,
|
||||
org,
|
||||
prices,
|
||||
});
|
||||
let rewardCat = getRewardCat(rewardBody);
|
||||
if (rewardCat !== RewardCategory.Discount) {
|
||||
await createStripeCoupon({
|
||||
reward: rewardBody,
|
||||
stripeCli,
|
||||
org,
|
||||
prices,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update coupon in db
|
||||
const updatedCoupon = await RewardService.update({
|
||||
sb: req.sb,
|
||||
internalId: id,
|
||||
internalId,
|
||||
env,
|
||||
orgId,
|
||||
update: couponBody,
|
||||
update: rewardBody,
|
||||
});
|
||||
|
||||
res.status(200).json(updatedCoupon);
|
||||
|
||||
22
server/src/internal/customers/CusReadService.ts
Normal file
22
server/src/internal/customers/CusReadService.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export class CusReadService {
|
||||
static async getInInternalIds({
|
||||
sb,
|
||||
internalIds,
|
||||
}: {
|
||||
sb: SupabaseClient;
|
||||
internalIds: string[];
|
||||
}) {
|
||||
const { data, error } = await sb
|
||||
.from("customers")
|
||||
.select("*")
|
||||
.in("internal_id", internalIds);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free
|
||||
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
|
||||
import { CusService } from "../CusService.js";
|
||||
import { getExistingCusProducts } from "./handleExistingProduct.js";
|
||||
import { isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { searchCusProducts } from "@/internal/customers/products/cusProductUtils.js";
|
||||
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
|
||||
import { initCusEntitlement } from "./initCusEnt.js";
|
||||
@@ -328,7 +328,10 @@ export const createFullCusProduct = async ({
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
|
||||
if (isOneOff(prices) && notNullish(existingCusProduct)) {
|
||||
if (
|
||||
(isOneOff(prices) || (isFreeProduct(prices) && product.is_add_on)) &&
|
||||
notNullish(existingCusProduct)
|
||||
) {
|
||||
await updateOneTimeCusProduct({
|
||||
sb,
|
||||
attachParams,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InvoiceService } from "./invoices/InvoiceService.js";
|
||||
import { FeatureService } from "../features/FeatureService.js";
|
||||
import {
|
||||
CusProductStatus,
|
||||
ErrCode,
|
||||
FullCustomerEntitlement,
|
||||
FullCustomerPrice,
|
||||
} from "@autumn/shared";
|
||||
@@ -18,6 +19,8 @@ import { getCusEntMasterBalance } from "./entitlements/cusEntUtils.js";
|
||||
import { getLatestProducts } from "../products/productUtils.js";
|
||||
import { getProductVersionCounts } from "../products/productUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { RewardRedemptionService } from "../rewards/RewardRedemptionService.js";
|
||||
import { CusReadService } from "./CusReadService.js";
|
||||
|
||||
export const cusRouter = Router();
|
||||
|
||||
@@ -65,6 +68,7 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
|
||||
|
||||
try {
|
||||
// Get customer invoices
|
||||
|
||||
const [org, features, coupons, products, events, customer] =
|
||||
await Promise.all([
|
||||
OrgService.getFromReq(req),
|
||||
@@ -209,6 +213,69 @@ cusRouter.get("/:customer_id/data", async (req: any, res: any) => {
|
||||
}
|
||||
});
|
||||
|
||||
cusRouter.get("/:customer_id/referrals", async (req: any, res: any) => {
|
||||
try {
|
||||
const { sb, org, env } = req;
|
||||
const { customer_id } = req.params;
|
||||
const orgId = req.orgId;
|
||||
|
||||
let internalCustomer = await CusService.getByIdOrInternalId({
|
||||
sb,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: customer_id,
|
||||
isFull: true,
|
||||
});
|
||||
|
||||
// Get all redemptions for this customer
|
||||
let [referred, redeemed] = await Promise.all([
|
||||
RewardRedemptionService.getByReferrer({
|
||||
sb,
|
||||
internalCustomerId: internalCustomer.internal_id,
|
||||
withCustomer: true,
|
||||
limit: 100,
|
||||
}),
|
||||
RewardRedemptionService.getByCustomer({
|
||||
sb,
|
||||
internalCustomerId: internalCustomer.internal_id,
|
||||
withReferralCode: true,
|
||||
limit: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
let redeemedCustomerIds = redeemed.map(
|
||||
(redemption: any) => redemption.referral_code.internal_customer_id
|
||||
);
|
||||
|
||||
let redeemedCustomers = await CusReadService.getInInternalIds({
|
||||
sb,
|
||||
internalIds: redeemedCustomerIds,
|
||||
});
|
||||
|
||||
for (const redemption of redeemed) {
|
||||
redemption.referral_code.customer = redeemedCustomers.find(
|
||||
(customer: any) =>
|
||||
customer.internal_id === redemption.referral_code.internal_customer_id
|
||||
);
|
||||
}
|
||||
|
||||
if (!internalCustomer) {
|
||||
throw new RecaseError({
|
||||
message: "Customer not found",
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).send({
|
||||
referred,
|
||||
redeemed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting customer referrals", error);
|
||||
res.status(500).send({ error: "Error getting customer referrals" });
|
||||
}
|
||||
});
|
||||
|
||||
cusRouter.get(
|
||||
"/:customer_id/product/:product_id",
|
||||
async (req: any, res: any) => {
|
||||
|
||||
@@ -245,16 +245,24 @@ export class CusProductService {
|
||||
orgId,
|
||||
env,
|
||||
inStatuses,
|
||||
withCusEnts = false,
|
||||
}: {
|
||||
sb: SupabaseClient;
|
||||
stripeSubId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
inStatuses?: string[];
|
||||
withCusEnts?: boolean;
|
||||
}) {
|
||||
const query = sb
|
||||
.from("customer_products")
|
||||
.select("*, product:products(*), customer:customers!inner(*)")
|
||||
.select(
|
||||
`*, product:products(*), customer:customers!inner(*)${
|
||||
withCusEnts
|
||||
? ", customer_entitlements:customer_entitlements!inner(*, entitlement:entitlements!inner(*, feature:features!inner(*)))"
|
||||
: ""
|
||||
}` as "*"
|
||||
)
|
||||
.or(
|
||||
`processor->>'subscription_id'.eq.'${stripeSubId}', subscription_ids.cs.{${stripeSubId}}`
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ export class RewardRedemptionService {
|
||||
withRewardProgram = false,
|
||||
internalRewardProgramId,
|
||||
triggerWhen,
|
||||
limit,
|
||||
}: {
|
||||
sb: any;
|
||||
internalCustomerId: string;
|
||||
@@ -32,6 +33,7 @@ export class RewardRedemptionService {
|
||||
withRewardProgram?: boolean;
|
||||
internalRewardProgramId?: string;
|
||||
triggerWhen?: RewardTriggerEvent;
|
||||
limit?: number;
|
||||
}) {
|
||||
let query = sb
|
||||
.from("reward_redemptions")
|
||||
@@ -56,6 +58,10 @@ export class RewardRedemptionService {
|
||||
query = query.eq("triggered", triggered);
|
||||
}
|
||||
|
||||
if (notNullish(limit)) {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
@@ -65,6 +71,35 @@ export class RewardRedemptionService {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getByReferrer({
|
||||
sb,
|
||||
internalCustomerId,
|
||||
withCustomer = false,
|
||||
limit = 100,
|
||||
}: {
|
||||
sb: any;
|
||||
internalCustomerId: string;
|
||||
withCustomer?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { data, error } = await sb
|
||||
.from("reward_redemptions")
|
||||
.select(
|
||||
`
|
||||
*, referral_code:referral_codes!inner(*)
|
||||
${withCustomer ? ", customer:customers!inner(*)" : ""}
|
||||
`
|
||||
)
|
||||
.eq("referral_code.internal_customer_id", internalCustomerId)
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getByCodeAndCustomer({
|
||||
sb,
|
||||
orgId,
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { AppEnv, ReferralCode, Reward, RewardRedemption } from "@autumn/shared";
|
||||
import {
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
Customer,
|
||||
FullRewardProgram,
|
||||
ReferralCode,
|
||||
Reward,
|
||||
RewardProgram,
|
||||
RewardReceivedBy,
|
||||
RewardRedemption,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { CusService } from "../customers/CusService.js";
|
||||
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import Stripe from "stripe";
|
||||
import { RewardRedemptionService } from "./RewardRedemptionService.js";
|
||||
import { CusProductService } from "../customers/products/CusProductService.js";
|
||||
import { ProductService } from "../products/ProductService.js";
|
||||
import { createFullCusProduct } from "../customers/add-product/createFullCusProduct.js";
|
||||
import { InsertCusProductParams } from "../customers/products/AttachParams.js";
|
||||
|
||||
export const generateReferralCode = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
@@ -87,3 +101,111 @@ export const triggerRedemption = async ({
|
||||
|
||||
return updatedRedemption;
|
||||
};
|
||||
|
||||
export const triggerFreeProduct = async ({
|
||||
sb,
|
||||
referralCode,
|
||||
redeemer,
|
||||
redemption,
|
||||
rewardProgram,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
sb: any;
|
||||
referralCode: ReferralCode;
|
||||
redeemer: Customer;
|
||||
redemption: RewardRedemption;
|
||||
rewardProgram: FullRewardProgram;
|
||||
org: any;
|
||||
env: AppEnv;
|
||||
logger: any;
|
||||
}) => {
|
||||
logger.info(`Triggering free product reward`);
|
||||
let { product_ids, received_by } = rewardProgram;
|
||||
|
||||
let addToRedeemer = received_by === RewardReceivedBy.All;
|
||||
let addToReferrer =
|
||||
received_by === RewardReceivedBy.Referrer ||
|
||||
received_by === RewardReceivedBy.All;
|
||||
|
||||
let productId = rewardProgram.reward.free_product_id!;
|
||||
let fullProduct = await ProductService.getFullProduct({
|
||||
sb,
|
||||
productId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
let referrer = await CusService.getByInternalId({
|
||||
sb,
|
||||
internalId: referralCode.internal_customer_id,
|
||||
});
|
||||
logger.info(`Referrer: ${referrer.name} (${referrer.id})`);
|
||||
|
||||
let [redeemerCusProducts, referrerCusProducts] = await Promise.all([
|
||||
CusService.getFullCusProducts({
|
||||
sb,
|
||||
internalCustomerId: redeemer.internal_id,
|
||||
logger,
|
||||
withProduct: true,
|
||||
withPrices: true,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
}),
|
||||
CusService.getFullCusProducts({
|
||||
sb,
|
||||
internalCustomerId: referrer.internal_id,
|
||||
logger,
|
||||
withProduct: true,
|
||||
withPrices: true,
|
||||
inStatuses: [CusProductStatus.Active],
|
||||
}),
|
||||
]);
|
||||
|
||||
let attachParams: InsertCusProductParams = {
|
||||
org,
|
||||
product: fullProduct,
|
||||
prices: fullProduct.prices,
|
||||
entitlements: fullProduct.entitlements,
|
||||
optionsList: [],
|
||||
entities: [],
|
||||
freeTrial: null,
|
||||
customer: referrer,
|
||||
cusProducts: referrerCusProducts,
|
||||
};
|
||||
|
||||
if (addToRedeemer) {
|
||||
let redeemerAttachParams = structuredClone({
|
||||
...attachParams,
|
||||
customer: redeemer,
|
||||
cusProducts: redeemerCusProducts,
|
||||
});
|
||||
|
||||
await createFullCusProduct({
|
||||
sb,
|
||||
attachParams: redeemerAttachParams,
|
||||
});
|
||||
logger.info(`✅ Added ${fullProduct.name} to redeemer`);
|
||||
}
|
||||
|
||||
if (addToReferrer) {
|
||||
await createFullCusProduct({
|
||||
sb,
|
||||
attachParams: {
|
||||
...attachParams,
|
||||
customer: referrer,
|
||||
cusProducts: referrerCusProducts,
|
||||
},
|
||||
});
|
||||
logger.info(`✅ Added ${fullProduct.name} to referrer`);
|
||||
}
|
||||
|
||||
await RewardRedemptionService.update({
|
||||
sb,
|
||||
id: redemption.id,
|
||||
updates: {
|
||||
triggered: true,
|
||||
applied: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId, nullish } from "@/utils/genUtils.js";
|
||||
import {
|
||||
Reward,
|
||||
CreateReward,
|
||||
RewardType,
|
||||
RewardCategory,
|
||||
ErrCode,
|
||||
DiscountConfigSchema,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const constructReward = ({
|
||||
internalId,
|
||||
reward,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
internalId?: string;
|
||||
reward: CreateReward;
|
||||
orgId: string;
|
||||
env: string;
|
||||
}) => {
|
||||
if (!reward.id || !reward.name) {
|
||||
throw new RecaseError({
|
||||
message: "Reward ID and name are required",
|
||||
code: ErrCode.InvalidReward,
|
||||
});
|
||||
}
|
||||
|
||||
if (reward.type === RewardType.FreeProduct && !reward.free_product_id) {
|
||||
throw new RecaseError({
|
||||
message: "Select a free product",
|
||||
code: ErrCode.InvalidReward,
|
||||
});
|
||||
}
|
||||
|
||||
if (getRewardCat(reward as Reward) === RewardCategory.Discount) {
|
||||
DiscountConfigSchema.parse(reward.discount_config);
|
||||
}
|
||||
|
||||
let promoCodes = reward.promo_codes.filter((promoCode) => {
|
||||
return promoCode.code.length > 0;
|
||||
});
|
||||
@@ -35,7 +58,7 @@ export const constructReward = ({
|
||||
let newReward = {
|
||||
...reward,
|
||||
...configData,
|
||||
internal_id: generateId("rew"),
|
||||
internal_id: internalId || generateId("rew"),
|
||||
created_at: Date.now(),
|
||||
org_id: orgId,
|
||||
env,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { RewardRedemptionService } from "./RewardRedemptionService.js";
|
||||
import { RewardTriggerEvent } from "@autumn/shared";
|
||||
import { triggerRedemption } from "./referralUtils.js";
|
||||
import { RewardCategory, RewardTriggerEvent } from "@autumn/shared";
|
||||
import { triggerFreeProduct, triggerRedemption } from "./referralUtils.js";
|
||||
import { RewardProgramService } from "../rewards/RewardProgramService.js";
|
||||
import { getRewardCat } from "./rewardUtils.js";
|
||||
export const runTriggerCheckoutReward = async ({
|
||||
sb,
|
||||
payload,
|
||||
@@ -63,15 +64,29 @@ export const runTriggerCheckoutReward = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
await triggerRedemption({
|
||||
sb,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
let rewardCat = getRewardCat(reward);
|
||||
if (rewardCat === RewardCategory.FreeProduct) {
|
||||
await triggerFreeProduct({
|
||||
sb,
|
||||
referralCode,
|
||||
redeemer: customer,
|
||||
rewardProgram: reward_program,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
redemption,
|
||||
});
|
||||
} else {
|
||||
await triggerRedemption({
|
||||
sb,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to trigger checkout reward");
|
||||
|
||||
@@ -6,15 +6,16 @@ MOCHA_CMD="npx mocha --parallel --timeout 10000000 --ignore tests/00_setup.ts"
|
||||
if [ "$1" == "basic-parallel" ]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \
|
||||
tests/basic/*.ts \
|
||||
tests/attach/**/*.ts \
|
||||
tests/basic/entities/*.ts \
|
||||
tests/basic/referrals/*.ts \
|
||||
# tests/attach/**/*.ts \
|
||||
# tests/basic/referrals/*.ts \
|
||||
|
||||
elif [ "$1" == "advanced-parallel" ]; then
|
||||
MOCHA_PARALLEL=true \
|
||||
$MOCHA_SETUP \
|
||||
&& $MOCHA_CMD 'tests/advanced/usage/*.ts'\
|
||||
&& $MOCHA_CMD 'tests/advanced/coupons/*.ts'\
|
||||
# && $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\
|
||||
# && $MOCHA_CMD 'tests/advanced/usage/*.ts'\
|
||||
|
||||
|
||||
elif [ "$1" == "alex-parallel" ]; then
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getPriceForOverage } from "@/internal/prices/priceUtils.js";
|
||||
import { Customer } from "@autumn/shared";
|
||||
import { expect } from "chai";
|
||||
import chalk from "chalk";
|
||||
import { addDays, addHours, addMonths, format } from "date-fns";
|
||||
import { addHours, addMonths } from "date-fns";
|
||||
import Stripe from "stripe";
|
||||
import { AutumnCli } from "tests/cli/AutumnCli.js";
|
||||
import { features, products, rewards } from "tests/global.js";
|
||||
@@ -29,7 +29,7 @@ describe(
|
||||
let customer: Customer;
|
||||
let testClockId: string;
|
||||
|
||||
let couponAmount = rewards.rolloverAll.discount_value;
|
||||
let couponAmount = rewards.rolloverAll.discount_config.discount_value;
|
||||
|
||||
before(async function () {
|
||||
const { testClockId: testClockId1, customer: customer1 } =
|
||||
|
||||
@@ -12,7 +12,6 @@ import { compareMainProduct } from "tests/utils/compare.js";
|
||||
import { getFixedPriceAmount, timeout } from "tests/utils/genUtils.js";
|
||||
import {
|
||||
advanceClockForInvoice,
|
||||
advanceTestClock,
|
||||
completeCheckoutForm,
|
||||
getDiscount,
|
||||
} from "tests/utils/stripeUtils.js";
|
||||
@@ -27,7 +26,7 @@ describe(
|
||||
let customer: Customer;
|
||||
let testClockId: string;
|
||||
|
||||
let couponAmount = rewards.rolloverUsage.discount_value;
|
||||
let couponAmount = rewards.rolloverUsage.discount_config.discount_value;
|
||||
|
||||
before(async function () {
|
||||
const { testClockId: testClockId1, customer: customer1 } =
|
||||
|
||||
149
server/tests/basic/referrals/referrals3.ts
Normal file
149
server/tests/basic/referrals/referrals3.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { features, products, referralPrograms } from "../../global.js";
|
||||
import { assert } from "chai";
|
||||
import chalk from "chalk";
|
||||
import AutumnError, { Autumn } from "@/external/autumn/autumnCli.js";
|
||||
import { setupBefore } from "tests/before.js";
|
||||
import {
|
||||
Customer,
|
||||
ErrCode,
|
||||
ReferralCode,
|
||||
RewardRedemption,
|
||||
} from "@autumn/shared";
|
||||
import { timeout } from "tests/utils/genUtils.js";
|
||||
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
|
||||
import { advanceTestClock } from "tests/utils/stripeUtils.js";
|
||||
import { addDays, addHours, addMonths } from "date-fns";
|
||||
import { Stripe } from "stripe";
|
||||
import { initCustomer } from "tests/utils/init.js";
|
||||
import {
|
||||
checkFeatureHasCorrectBalance,
|
||||
compareMainProduct,
|
||||
compareProductEntitlements,
|
||||
} from "tests/utils/compare.js";
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
describe(`${chalk.yellowBright(
|
||||
"referrals3: Testing free product referrals"
|
||||
)}`, () => {
|
||||
let mainCustomerId = "main-referral-3";
|
||||
let redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
|
||||
let autumn: Autumn;
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let referralCode: ReferralCode;
|
||||
|
||||
let redemptions: RewardRedemption[] = [];
|
||||
let mainCustomer: Customer;
|
||||
|
||||
before(async function () {
|
||||
await setupBefore(this);
|
||||
autumn = this.autumn;
|
||||
stripeCli = this.stripeCli;
|
||||
|
||||
const { testClockId: testClockId1, customer } =
|
||||
await initCustomerWithTestClock({
|
||||
customerId: mainCustomerId,
|
||||
sb: this.sb,
|
||||
org: this.org,
|
||||
env: this.env,
|
||||
fingerprint: "main-referral-3",
|
||||
});
|
||||
testClockId = testClockId1;
|
||||
mainCustomer = customer;
|
||||
|
||||
await autumn.attach({
|
||||
customerId: mainCustomerId,
|
||||
productId: products.proWithTrial.id,
|
||||
});
|
||||
|
||||
let batchCreate = [];
|
||||
for (let redeemer of redeemers) {
|
||||
batchCreate.push(
|
||||
initCustomer({
|
||||
customerId: redeemer,
|
||||
sb: this.sb,
|
||||
org: this.org,
|
||||
env: this.env,
|
||||
attachPm: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchCreate);
|
||||
});
|
||||
|
||||
it("should create code once", async function () {
|
||||
referralCode = await autumn.referrals.createCode({
|
||||
customerId: mainCustomerId,
|
||||
referralId: referralPrograms.freeProduct.id,
|
||||
});
|
||||
|
||||
assert.exists(referralCode.code);
|
||||
});
|
||||
|
||||
it("should create redemption for each redeemer and fail if redeemed again", async function () {
|
||||
for (let redeemer of redeemers) {
|
||||
let redemption: RewardRedemption = await autumn.referrals.redeem({
|
||||
customerId: redeemer,
|
||||
code: referralCode.code,
|
||||
});
|
||||
|
||||
redemptions.push(redemption);
|
||||
|
||||
assert.equal(redemption.triggered, false);
|
||||
assert.equal(redemption.applied, false);
|
||||
}
|
||||
|
||||
// Try redeem for redeemer1 again
|
||||
try {
|
||||
let redemption1 = await autumn.referrals.redeem({
|
||||
customerId: redeemers[0],
|
||||
code: referralCode.code,
|
||||
});
|
||||
assert.fail("Should not be able to redeem again");
|
||||
} catch (error) {
|
||||
assert.instanceOf(error, AutumnError);
|
||||
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
|
||||
}
|
||||
});
|
||||
|
||||
it("should be triggered (and applied) when redeemers check out", async function () {
|
||||
for (let i = 0; i < redeemers.length; i++) {
|
||||
let redeemer = redeemers[i];
|
||||
|
||||
await autumn.attach({
|
||||
customerId: redeemer,
|
||||
productId: products.pro.id,
|
||||
});
|
||||
|
||||
await timeout(3000);
|
||||
|
||||
// Get redemption object
|
||||
let redemption = await autumn.redemptions.get({
|
||||
redemptionId: redemptions[i].id,
|
||||
});
|
||||
|
||||
// Check if redemption is triggered
|
||||
let count = i + 1;
|
||||
|
||||
if (count > referralPrograms.freeProduct.max_redemptions) {
|
||||
assert.equal(redemption.triggered, false);
|
||||
assert.equal(redemption.applied, false);
|
||||
} else {
|
||||
// 1. Check that main customer has free add on
|
||||
compareProductEntitlements({
|
||||
customerId: mainCustomerId,
|
||||
product: products.freeAddOn,
|
||||
features,
|
||||
quantity: count,
|
||||
});
|
||||
|
||||
compareProductEntitlements({
|
||||
customerId: redeemer,
|
||||
product: products.freeAddOn,
|
||||
features,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
DiscountType,
|
||||
EntInterval,
|
||||
Feature,
|
||||
RewardReceivedBy,
|
||||
RewardTriggerEvent,
|
||||
RewardType,
|
||||
} from "@autumn/shared";
|
||||
import { FeatureType } from "@autumn/shared";
|
||||
import {
|
||||
@@ -300,6 +302,20 @@ export const products = {
|
||||
],
|
||||
freeTrial: null,
|
||||
}),
|
||||
|
||||
freeAddOn: initProduct({
|
||||
id: "freeAddOn",
|
||||
entitlements: {
|
||||
metered1: initEntitlement({
|
||||
feature: features.metered1,
|
||||
allowance: 100,
|
||||
interval: EntInterval.Lifetime,
|
||||
}),
|
||||
},
|
||||
prices: [],
|
||||
freeTrial: null,
|
||||
isAddOn: true,
|
||||
}),
|
||||
};
|
||||
|
||||
export const oneTimeProducts = {
|
||||
@@ -775,12 +791,14 @@ export const entityProducts = {
|
||||
export const rewards = {
|
||||
rolloverAll: initReward({
|
||||
id: "rolloverAll",
|
||||
type: RewardType.FixedDiscount,
|
||||
discountValue: 1000,
|
||||
rollover: true,
|
||||
applyToAll: true,
|
||||
}),
|
||||
rolloverUsage: initReward({
|
||||
id: "rolloverUsage",
|
||||
type: RewardType.FixedDiscount,
|
||||
discountValue: 1000,
|
||||
rollover: true,
|
||||
onlyUsagePrices: true,
|
||||
@@ -788,12 +806,17 @@ export const rewards = {
|
||||
}),
|
||||
monthOff: initReward({
|
||||
id: "monthOff",
|
||||
discountType: DiscountType.Percentage,
|
||||
type: RewardType.PercentageDiscount,
|
||||
discountValue: 100,
|
||||
applyToAll: true,
|
||||
durationType: CouponDurationType.Months,
|
||||
durationValue: 1,
|
||||
}),
|
||||
freeProduct: initReward({
|
||||
id: "freeProduct",
|
||||
type: RewardType.FreeProduct,
|
||||
freeProductId: products.freeAddOn.id,
|
||||
}),
|
||||
};
|
||||
|
||||
export const referralPrograms = {
|
||||
@@ -807,7 +830,13 @@ export const referralPrograms = {
|
||||
id: "immediate",
|
||||
internalRewardId: rewards.monthOff.id,
|
||||
when: RewardTriggerEvent.CustomerCreation,
|
||||
// productIds: [products.pro.id, products.proWithTrial.id],
|
||||
}),
|
||||
freeProduct: initRewardProgram({
|
||||
id: "freeProduct",
|
||||
internalRewardId: rewards.freeProduct.id,
|
||||
when: RewardTriggerEvent.Checkout,
|
||||
receivedBy: RewardReceivedBy.All,
|
||||
productIds: [products.pro.id, products.proWithTrial.id],
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -180,10 +180,12 @@ export const compareProductEntitlements = ({
|
||||
customerId,
|
||||
product,
|
||||
features,
|
||||
quantity = 1,
|
||||
}: {
|
||||
customerId: string;
|
||||
product: any;
|
||||
features: Record<string, Feature>;
|
||||
quantity?: number;
|
||||
}) => {
|
||||
for (const entitlement of Object.values(
|
||||
product.entitlements
|
||||
@@ -196,7 +198,7 @@ export const compareProductEntitlements = ({
|
||||
customerId,
|
||||
feature,
|
||||
entitlement,
|
||||
expectedBalance: entitlement.allowance || 0,
|
||||
expectedBalance: (entitlement.allowance || 0) * quantity,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FreeTrial,
|
||||
Organization,
|
||||
PriceType,
|
||||
RewardReceivedBy,
|
||||
RewardTriggerEvent,
|
||||
RewardType,
|
||||
} from "@autumn/shared";
|
||||
@@ -339,6 +340,7 @@ export const initReward = ({
|
||||
onlyUsagePrices = false,
|
||||
productIds,
|
||||
applyToAll = false,
|
||||
freeProductId,
|
||||
}: {
|
||||
id: string;
|
||||
type?: RewardType;
|
||||
@@ -349,19 +351,36 @@ export const initReward = ({
|
||||
onlyUsagePrices?: boolean;
|
||||
productIds?: string[];
|
||||
applyToAll?: boolean;
|
||||
freeProductId?: string;
|
||||
}): any => {
|
||||
return {
|
||||
id,
|
||||
name: keyToTitle(id),
|
||||
type,
|
||||
discount_value: discountValue,
|
||||
duration_type: durationType,
|
||||
duration_value: durationValue,
|
||||
should_rollover: rollover,
|
||||
apply_to_all: applyToAll,
|
||||
only_usage_prices: onlyUsagePrices,
|
||||
product_ids: productIds,
|
||||
};
|
||||
if (
|
||||
type == RewardType.PercentageDiscount ||
|
||||
type == RewardType.FixedDiscount
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: keyToTitle(id),
|
||||
type,
|
||||
|
||||
only_usage_prices: onlyUsagePrices,
|
||||
product_ids: productIds,
|
||||
|
||||
discount_config: {
|
||||
discount_value: discountValue,
|
||||
duration_type: durationType,
|
||||
duration_value: durationValue,
|
||||
should_rollover: rollover,
|
||||
apply_to_all: applyToAll,
|
||||
},
|
||||
};
|
||||
} else if (type == RewardType.FreeProduct) {
|
||||
return {
|
||||
id,
|
||||
name: keyToTitle(id),
|
||||
type,
|
||||
free_product_id: freeProductId,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const initRewardProgram = ({
|
||||
@@ -370,12 +389,14 @@ export const initRewardProgram = ({
|
||||
productIds = [],
|
||||
internalRewardId,
|
||||
maxRedemptions = 2,
|
||||
receivedBy = RewardReceivedBy.Referrer,
|
||||
}: {
|
||||
id: string;
|
||||
productIds?: string[];
|
||||
internalRewardId: string;
|
||||
when?: RewardTriggerEvent;
|
||||
maxRedemptions?: number;
|
||||
receivedBy?: RewardReceivedBy;
|
||||
}): any => {
|
||||
return {
|
||||
id,
|
||||
@@ -383,5 +404,6 @@ export const initRewardProgram = ({
|
||||
product_ids: productIds,
|
||||
internal_reward_id: internalRewardId,
|
||||
max_redemptions: maxRedemptions,
|
||||
received_by: receivedBy,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PriceType,
|
||||
Reward,
|
||||
RewardProgram,
|
||||
RewardType,
|
||||
} from "@autumn/shared";
|
||||
import axios from "axios";
|
||||
|
||||
@@ -310,34 +311,10 @@ export const setupOrg = async ({
|
||||
// Insert coupons
|
||||
let insertCoupons = [];
|
||||
for (const reward of Object.values(rewards)) {
|
||||
const createCoupon = async () => {
|
||||
const createReward = async () => {
|
||||
let priceIds = [];
|
||||
|
||||
if (reward.only_usage_prices) {
|
||||
let filteredProducts = allProducts.filter((product: FullProduct) => {
|
||||
if (reward.product_ids) {
|
||||
return reward.product_ids.includes(product.id);
|
||||
} else return true;
|
||||
});
|
||||
|
||||
priceIds = filteredProducts.flatMap((product: FullProduct) =>
|
||||
product.prices
|
||||
.filter((price: Price) => price.config!.type === PriceType.Usage)
|
||||
.map((price) => {
|
||||
return price.id;
|
||||
})
|
||||
);
|
||||
} else if (reward.product_ids) {
|
||||
priceIds = allProducts
|
||||
.filter((product: FullProduct) =>
|
||||
reward.product_ids.includes(product.id)
|
||||
)
|
||||
.flatMap((product: FullProduct) =>
|
||||
product.prices.map((price) => price.id)
|
||||
);
|
||||
}
|
||||
|
||||
const newReward: CreateReward & { id: string } = {
|
||||
let rewardData: any = {
|
||||
id: reward.id,
|
||||
name: reward.name,
|
||||
promo_codes: [
|
||||
@@ -346,8 +323,57 @@ export const setupOrg = async ({
|
||||
},
|
||||
],
|
||||
type: reward.type,
|
||||
discount_config: reward.discount_config,
|
||||
free_product_id: reward.free_product_id,
|
||||
};
|
||||
|
||||
if (reward.type === RewardType.FreeProduct) {
|
||||
rewardData.free_product_id = reward.free_product_id;
|
||||
} else {
|
||||
if (reward.only_usage_prices) {
|
||||
let filteredProducts = allProducts.filter((product: FullProduct) => {
|
||||
if (reward.product_ids) {
|
||||
return reward.product_ids.includes(product.id);
|
||||
} else return true;
|
||||
});
|
||||
|
||||
priceIds = filteredProducts.flatMap((product: FullProduct) =>
|
||||
product.prices
|
||||
.filter((price: Price) => price.config!.type === PriceType.Usage)
|
||||
.map((price) => {
|
||||
return price.id;
|
||||
})
|
||||
);
|
||||
} else if (reward.product_ids) {
|
||||
priceIds = allProducts
|
||||
.filter((product: FullProduct) =>
|
||||
reward.product_ids.includes(product.id)
|
||||
)
|
||||
.flatMap((product: FullProduct) =>
|
||||
product.prices.map((price) => price.id)
|
||||
);
|
||||
}
|
||||
|
||||
rewardData.discount_config = {
|
||||
discount_value: reward.discount_config.discount_value,
|
||||
duration_type: reward.discount_config.duration_type,
|
||||
duration_value: reward.discount_config.duration_value,
|
||||
should_rollover: reward.discount_config.should_rollover,
|
||||
apply_to_all: reward.discount_config.apply_to_all,
|
||||
price_ids: priceIds,
|
||||
};
|
||||
}
|
||||
|
||||
const newReward: CreateReward & { internal_id: string } = {
|
||||
internal_id: reward.id,
|
||||
id: reward.id,
|
||||
name: reward.name,
|
||||
promo_codes: [
|
||||
{
|
||||
code: reward.id,
|
||||
},
|
||||
],
|
||||
type: reward.type,
|
||||
discount_config: rewardData.discount_config,
|
||||
free_product_id: rewardData.free_product_id,
|
||||
};
|
||||
|
||||
let rewardRes = await autumn.rewards.create(newReward);
|
||||
@@ -356,7 +382,7 @@ export const setupOrg = async ({
|
||||
rewardRes,
|
||||
};
|
||||
};
|
||||
insertCoupons.push(createCoupon());
|
||||
insertCoupons.push(createReward());
|
||||
}
|
||||
|
||||
await Promise.all(insertCoupons);
|
||||
|
||||
@@ -104,7 +104,8 @@ export const ErrCode = {
|
||||
// Pay for invoice
|
||||
PayInvoiceFailed: "invoice_payment_failed",
|
||||
|
||||
// COUPONS
|
||||
// Rewards
|
||||
InvalidReward: "invalid_reward",
|
||||
PromoCodeAlreadyExistsInStripe: "promo_code_already_exists_in_stripe",
|
||||
|
||||
// Entity
|
||||
|
||||
@@ -28,7 +28,7 @@ const PromoCodeSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
const DiscountConfigSchema = z.object({
|
||||
export const DiscountConfigSchema = z.object({
|
||||
discount_value: z.number(),
|
||||
duration_type: z.nativeEnum(CouponDurationType),
|
||||
duration_value: z.number(),
|
||||
|
||||
@@ -8,6 +8,12 @@ export enum RewardTriggerEvent {
|
||||
Checkout = "checkout",
|
||||
}
|
||||
|
||||
export enum RewardReceivedBy {
|
||||
Referrer = "referrer",
|
||||
All = "all",
|
||||
// Redeemer = "redeemer",
|
||||
}
|
||||
|
||||
export const RewardProgram = z.object({
|
||||
internal_id: z.string(),
|
||||
id: z.string(),
|
||||
@@ -24,6 +30,8 @@ export const RewardProgram = z.object({
|
||||
org_id: z.string(),
|
||||
env: z.string(),
|
||||
created_at: z.number(),
|
||||
|
||||
received_by: z.nativeEnum(RewardReceivedBy),
|
||||
});
|
||||
|
||||
export const CreateRewardProgram = z.object({
|
||||
@@ -33,6 +41,8 @@ export const CreateRewardProgram = z.object({
|
||||
exclude_trial: z.boolean().optional(),
|
||||
internal_reward_id: z.string(),
|
||||
max_redemptions: z.number().optional(),
|
||||
|
||||
received_by: z.nativeEnum(RewardReceivedBy),
|
||||
});
|
||||
|
||||
export type RewardProgram = z.infer<typeof RewardProgram>;
|
||||
|
||||
@@ -47,7 +47,10 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-sm bg-stone-700 shadow-md px-4 py-2 text-xs text-stone-100 max-w-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
`z-50 overflow-hidden rounded-sm bg-stone-700 shadow-md px-4 py-2 text-xs text-stone-100 max-w-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2
|
||||
|
||||
bg-white/50 backdrop-blur-sm shadow-sm border-1 text-t2
|
||||
`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BillingInterval,
|
||||
BillWhen,
|
||||
EntitlementWithFeature,
|
||||
Product,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { FixedPriceConfig, Price, UsagePriceConfig } from "@autumn/shared";
|
||||
@@ -62,3 +63,9 @@ export const pricesOnlyOneOff = (prices: Price[]) => {
|
||||
return price.config?.interval == BillingInterval.OneOff;
|
||||
});
|
||||
};
|
||||
|
||||
export const isFreeProduct = (prices: Price[]) => {
|
||||
return prices.every((price) => {
|
||||
return price.config?.interval == BillingInterval.OneOff;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCustomerContext } from "./CustomerContext";
|
||||
import { getStripeCusLink } from "@/utils/linkUtils";
|
||||
import { Product } from "@autumn/shared";
|
||||
@@ -5,11 +10,12 @@ import { faStripe } from "@fortawesome/free-brands-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { ArrowUpRightFromSquare, Check } from "lucide-react";
|
||||
import { Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export const CustomerDetails = () => {
|
||||
const { customer, products, env, discount } = useCustomerContext();
|
||||
const { customer, products, env, discount, referrals } = useCustomerContext();
|
||||
const [idCopied, setIdCopied] = useState(false);
|
||||
const [idHover, setIdHover] = useState(false);
|
||||
|
||||
@@ -116,22 +122,97 @@ export const CustomerDetails = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReferralDetails />
|
||||
|
||||
{customer.processor?.id && (
|
||||
<Link
|
||||
className="!cursor-pointer hover:underline"
|
||||
to={getStripeCusLink(customer.processor?.id, env)}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="flex justify-center items-center w-fit gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faStripe}
|
||||
className="!h-5 text-[#675DFF]"
|
||||
/>
|
||||
<ArrowUpRightFromSquare size={10} className="text-[#675DFF]" />
|
||||
</div>
|
||||
</Link>
|
||||
<React.Fragment>
|
||||
<Link
|
||||
className="!cursor-pointer hover:underline"
|
||||
to={getStripeCusLink(customer.processor?.id, env)}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="flex justify-center items-center w-fit gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faStripe}
|
||||
className="!h-5 text-[#675DFF]"
|
||||
/>
|
||||
<ArrowUpRightFromSquare size={10} className="text-[#675DFF]" />
|
||||
</div>
|
||||
</Link>
|
||||
<p></p>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReferralDetails = () => {
|
||||
let { referrals } = useCustomerContext();
|
||||
if (!referrals) return null;
|
||||
return (
|
||||
<>
|
||||
{referrals.referred.length > 0 && (
|
||||
<>
|
||||
<p className="text-t3 text-xs font-medium">Referrals</p>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center justify-start gap-1">
|
||||
<p>
|
||||
{referrals.referred.length}{" "}
|
||||
<span className="text-t3">
|
||||
({referrals.referred.filter((r: any) => r.triggered).length}{" "}
|
||||
activated)
|
||||
</span>
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="px-2"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div
|
||||
// key={referral.id}
|
||||
className="flex grid grid-cols-2 gap-1 font-mono"
|
||||
>
|
||||
{referrals.referred.map((referral: any) => (
|
||||
<>
|
||||
<p className="font-medium max-w-[140px] truncate">
|
||||
{referral.customer.name}
|
||||
</p>
|
||||
<p className="text-t2 max-w-[100px] truncate">
|
||||
({referral.customer.id})
|
||||
</p>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{referrals.redeemed.length > 0 && (
|
||||
<>
|
||||
<p className="text-t3 text-xs font-medium">Redeemed</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center justify-start gap-1">
|
||||
<p>{referrals.redeemed[0].referral_code.code}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="px-2 font-mono flex flex-col gap-1"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
>
|
||||
<p className="font-medium">Referred by: </p>
|
||||
<p>
|
||||
{referrals.redeemed[0].referral_code?.customer.name} (
|
||||
{referrals.redeemed[0].referral_code?.customer.id})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,16 @@ export default function CustomerView({ env }: { env: AppEnv }) {
|
||||
env,
|
||||
});
|
||||
|
||||
const {
|
||||
data: referrals,
|
||||
isLoading: referralsLoading,
|
||||
error: referralsError,
|
||||
mutate: referralsMutate,
|
||||
} = useAxiosSWR({
|
||||
url: `/customers/${customer_id}/referrals`,
|
||||
env,
|
||||
});
|
||||
|
||||
const [addCouponOpen, setAddCouponOpen] = useState(false);
|
||||
|
||||
const [showExpired, setShowExpired] = useState(false);
|
||||
@@ -84,6 +94,7 @@ export default function CustomerView({ env }: { env: AppEnv }) {
|
||||
env,
|
||||
cusMutate,
|
||||
setAddCouponOpen,
|
||||
referrals,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -108,7 +119,8 @@ export default function CustomerView({ env }: { env: AppEnv }) {
|
||||
<h2 className="flex text-lg text-t1 font-medium gap-2 w-full justify-start">
|
||||
{customer.name && (
|
||||
<span className="min-w-0 max-w-[50%] truncate">
|
||||
<AdminHover texts={[
|
||||
<AdminHover
|
||||
texts={[
|
||||
{
|
||||
key: "Internal ID",
|
||||
value: customer.internal_id,
|
||||
|
||||
@@ -91,36 +91,47 @@ export const CustomerEntitlementsList = ({
|
||||
// return productA.product.name.localeCompare(productB.product.name);
|
||||
// });
|
||||
|
||||
const sortedEntitlements = filteredEntitlements
|
||||
|
||||
const sortedEntitlements = filteredEntitlements;
|
||||
|
||||
const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => {
|
||||
setSelectedCusEntitlement(cusEnt);
|
||||
};
|
||||
|
||||
const getAdminHoverTexts = (cusEnt: FullCustomerEntitlement) => {
|
||||
let entitlement = cusEnt.entitlement;
|
||||
let featureEntities = entities.filter((e: any) => e.feature_id === entitlement.feature.id);
|
||||
|
||||
let hoverTexts = [{
|
||||
key: "Cus Ent ID",
|
||||
value: cusEnt.id,
|
||||
}]
|
||||
let featureEntities = entities.filter(
|
||||
(e: any) => e.feature_id === entitlement.feature.id
|
||||
);
|
||||
|
||||
let hoverTexts = [
|
||||
{
|
||||
key: "Cus Ent ID",
|
||||
value: cusEnt.id,
|
||||
},
|
||||
];
|
||||
|
||||
if (featureEntities.length > 0) {
|
||||
hoverTexts.push({
|
||||
key: "Entities",
|
||||
value: featureEntities.map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted": ""}`).join("\n"),
|
||||
})
|
||||
value: featureEntities
|
||||
.map((e: any) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`)
|
||||
.join("\n"),
|
||||
});
|
||||
} else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) {
|
||||
let mappedEntities = Object.keys(cusEnt.entities).map((e: any) => {
|
||||
let entity = entities.find((ee: any) => ee.id === e);
|
||||
let balance = cusEnt.entities![e].balance;
|
||||
return `${entity.id} (${entity.name}): ${balance}`;
|
||||
}).join("\n");
|
||||
let mappedEntities = Object.keys(cusEnt.entities)
|
||||
.map((e: any) => {
|
||||
let entity = entities.find((ee: any) => ee.id === e);
|
||||
if (!entity) {
|
||||
return `${e}: Deleted`;
|
||||
}
|
||||
let balance = cusEnt.entities![e].balance;
|
||||
return `${entity.id} (${entity.name}): ${balance}`;
|
||||
})
|
||||
.join("\n");
|
||||
hoverTexts.push({
|
||||
key: "Entities",
|
||||
value: mappedEntities,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return hoverTexts;
|
||||
|
||||
@@ -70,7 +70,7 @@ function ProductsView({ env }: { env: AppEnv }) {
|
||||
onClick={() => setShowRewards((prev) => !prev)}
|
||||
>
|
||||
<Ticket size={12} className="mr-2" />
|
||||
Coupons
|
||||
Rewards
|
||||
</ToggleDisplayButton>
|
||||
</div>
|
||||
<ProductsTable products={data?.products} />
|
||||
@@ -79,9 +79,9 @@ function ProductsView({ env }: { env: AppEnv }) {
|
||||
<React.Fragment>
|
||||
<div className="flex flex-col gap-4 h-fit mt-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">Coupons</h2>
|
||||
<h2 className="text-lg font-medium">Rewards</h2>
|
||||
<p className="text-sm text-t2">
|
||||
Create a coupon to give users credits or a discount on one or
|
||||
Create a reward to give users a product or a discount on one or
|
||||
more products.{" "}
|
||||
{/* <span className="text-t3">(eg, 10% off all products).</span> */}
|
||||
</p>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DiscountType,
|
||||
RewardProgram,
|
||||
RewardTriggerEvent,
|
||||
RewardReceivedBy,
|
||||
} from "@autumn/shared";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useProductsContext } from "../ProductsContext";
|
||||
@@ -44,6 +45,7 @@ const defaultRewardProgram: CreateRewardProgram = {
|
||||
exclude_trial: false,
|
||||
internal_reward_id: "",
|
||||
max_redemptions: 0,
|
||||
received_by: RewardReceivedBy.Referrer,
|
||||
};
|
||||
|
||||
function CreateRewardProgramModal() {
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Reward, RewardProgram, RewardTriggerEvent } from "@autumn/shared";
|
||||
import {
|
||||
Reward,
|
||||
RewardProgram,
|
||||
RewardTriggerEvent,
|
||||
RewardReceivedBy,
|
||||
} from "@autumn/shared";
|
||||
import { useProductsContext } from "../ProductsContext";
|
||||
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
|
||||
import { useState } from "react";
|
||||
@@ -50,7 +55,7 @@ export const RewardProgramConfig = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="w-6/12">
|
||||
<FieldLabel>Coupon</FieldLabel>
|
||||
<FieldLabel>Reward</FieldLabel>
|
||||
<Select
|
||||
value={rewardProgram.internal_reward_id}
|
||||
onValueChange={(value) =>
|
||||
@@ -58,7 +63,7 @@ export const RewardProgramConfig = ({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a coupon" />
|
||||
<SelectValue placeholder="Select a reward" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rewards.map((reward: Reward) => (
|
||||
@@ -109,6 +114,33 @@ export const RewardProgramConfig = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-full">
|
||||
<FieldLabel>Received by</FieldLabel>
|
||||
<Select
|
||||
value={rewardProgram.received_by}
|
||||
onValueChange={(value) =>
|
||||
setRewardProgram({
|
||||
...rewardProgram,
|
||||
received_by: value as RewardReceivedBy,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Who should receive the reward" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(RewardReceivedBy).map((receivedBy) => (
|
||||
<SelectItem key={receivedBy} value={receivedBy}>
|
||||
{receivedBy === RewardReceivedBy.All
|
||||
? "Referrer & Redeemer"
|
||||
: keyToTitle(receivedBy)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{rewardProgram.when === RewardTriggerEvent.Checkout && (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -67,12 +67,12 @@ function CreateReward() {
|
||||
className="w-full"
|
||||
startIcon={<PlusIcon size={15} />}
|
||||
>
|
||||
Create Coupon
|
||||
Create Reward
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Coupon</DialogTitle>
|
||||
<DialogTitle>Create Reward</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RewardConfig reward={reward as any} setReward={setReward as any} />
|
||||
<DialogFooter>
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
CouponDurationType,
|
||||
RewardType,
|
||||
Product,
|
||||
FullProduct,
|
||||
} from "@autumn/shared";
|
||||
import { useProductsContext } from "../ProductsContext";
|
||||
import { DiscountConfig } from "./DiscountConfig";
|
||||
import { notNullish } from "@/utils/genUtils";
|
||||
import { defaultDiscountConfig } from "./defaultRewardModels";
|
||||
import { isFreeProduct } from "@/utils/product/priceUtils";
|
||||
|
||||
export const RewardConfig = ({
|
||||
reward,
|
||||
@@ -103,23 +105,44 @@ export const RewardConfig = ({
|
||||
</div>
|
||||
{reward.type === RewardType.FreeProduct ? (
|
||||
<div>
|
||||
<FieldLabel>Product</FieldLabel>
|
||||
<FieldLabel description="Select a free add-on product to give away">
|
||||
Product
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={reward.free_product_id || undefined}
|
||||
onValueChange={(value) =>
|
||||
setReward({ ...reward, free_product_id: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a product" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((product: Product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
{(() => {
|
||||
const freeAddOns = products
|
||||
.filter((product: FullProduct) => product.is_add_on)
|
||||
.filter((product: FullProduct) =>
|
||||
isFreeProduct(product.prices)
|
||||
);
|
||||
|
||||
let empty = freeAddOns.length === 0;
|
||||
return (
|
||||
<>
|
||||
<SelectTrigger disabled={empty}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
empty
|
||||
? "Create a free add-on product first"
|
||||
: "Select a product"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freeAddOns.map((product: Product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Select>
|
||||
</div>
|
||||
) : notNullish(reward.type) ? (
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
CouponDurationType,
|
||||
DiscountType,
|
||||
RewardType,
|
||||
Product,
|
||||
} from "@autumn/shared";
|
||||
import UpdateReward from "./UpdateReward";
|
||||
import { useState } from "react";
|
||||
import { RewardRowToolbar } from "./RewardRowToolbar";
|
||||
export const RewardsTable = () => {
|
||||
const { rewards, org } = useProductsContext();
|
||||
const { rewards, org, products } = useProductsContext();
|
||||
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -47,8 +48,8 @@ export const RewardsTable = () => {
|
||||
<TableRow>
|
||||
<TableHead className="">Name</TableHead>
|
||||
<TableHead>Promo Codes</TableHead>
|
||||
<TableHead>Discount</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Reward</TableHead>
|
||||
|
||||
<TableHead className="min-w-0 w-28">Created At</TableHead>
|
||||
<TableHead className="min-w-0 w-10"></TableHead>
|
||||
@@ -71,17 +72,31 @@ export const RewardsTable = () => {
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-32">
|
||||
<div className="flex items-center gap-1">
|
||||
<p>{reward.discount_config?.discount_value} </p>
|
||||
<p className="text-t3">
|
||||
{reward.type == RewardType.PercentageDiscount
|
||||
? "%"
|
||||
: org?.default_currency || "USD"}
|
||||
</p>
|
||||
</div>
|
||||
{keyToTitle(reward.type)}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{reward.discount_config?.duration_type ==
|
||||
<div className="flex items-center gap-1">
|
||||
{reward.type == RewardType.FreeProduct ? (
|
||||
<p>
|
||||
{
|
||||
products.find(
|
||||
(product: Product) =>
|
||||
product.id === reward.free_product_id
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>{reward.discount_config?.discount_value} </p>
|
||||
<p className="text-t3">
|
||||
{reward.type == RewardType.PercentageDiscount
|
||||
? "%"
|
||||
: org?.default_currency || "USD"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* {reward.discount_config?.duration_type ==
|
||||
CouponDurationType.Months
|
||||
? `${reward.discount_config?.duration_value} months`
|
||||
: reward.discount_config?.duration_type ==
|
||||
@@ -91,7 +106,7 @@ export const RewardsTable = () => {
|
||||
: reward.discount_config?.duration_type ==
|
||||
CouponDurationType.Forever
|
||||
? "Forever"
|
||||
: "One-off"}
|
||||
: "One-off"} */}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{formatUnixToDateTime(reward.created_at).date}
|
||||
|
||||
Reference in New Issue
Block a user