diff --git a/apps/api/src/services/reward-engine.ts b/apps/api/src/services/reward-engine.ts index 7d272de..ad2b0b0 100644 --- a/apps/api/src/services/reward-engine.ts +++ b/apps/api/src/services/reward-engine.ts @@ -9,6 +9,7 @@ const { referral, } = schema; import { eq, and, desc } from "drizzle-orm"; +import { calculateRewardAmount } from "@refref/types"; import type { RewardRuleConfigV1Type, EventMetadataV1Type, @@ -194,31 +195,6 @@ export async function processEventForRewards(db: DbType, eventId: string) { } } -/** - * Calculate reward amount based on rule configuration and event metadata - */ -function calculateRewardAmount( - rewardConfig: RewardRuleConfigV1Type["reward"], - eventMetadata?: EventMetadataV1Type, -): number { - const baseAmount = rewardConfig.amount; - - if (rewardConfig.unit === "fixed") { - return baseAmount; - } - - if (rewardConfig.unit === "percent") { - // For percentage rewards, calculate based on order amount if available - if (eventMetadata?.orderAmount) { - return (eventMetadata.orderAmount * baseAmount) / 100; - } - // If orderAmount is not available for a percentage reward, the value is 0 - return 0; - } - - return baseAmount; -} - /** * Update event status within a transaction */ diff --git a/apps/api/test/unit/reward-calculation.test.ts b/apps/api/test/unit/reward-calculation.test.ts index 2a94cd9..6024286 100644 --- a/apps/api/test/unit/reward-calculation.test.ts +++ b/apps/api/test/unit/reward-calculation.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { createDb, schema } from "@refref/coredb"; +import { describe, it, expect } from "vitest"; +import { calculateRewardAmount } from "@refref/types"; import type { RewardRuleConfigV1Type, EventMetadataV1Type, @@ -111,6 +111,62 @@ describe("Reward Calculation Logic", () => { }); }); + // Reward rules created through the program setup flow stored the unit as + // "percentage" (the spelling used by rewardConfigSchema.valueType) rather + // than "percent". Those rules must still be paid out as percentages. + describe("Legacy 'percentage' unit", () => { + const legacyPercentConfig = ( + amount: number, + ): RewardRuleConfigV1Type["reward"] => + ({ + type: "cash", + unit: "percentage", + amount, + }) as unknown as RewardRuleConfigV1Type["reward"]; + + it("should calculate percentage of order amount", () => { + const eventMetadata: EventMetadataV1Type = { + schemaVersion: 1, + source: "api", + orderAmount: 500, + orderId: "order_789", + }; + + const result = calculateRewardAmount( + legacyPercentConfig(10), + eventMetadata, + ); + + // 10% of 500 = 50, not a flat 10 + expect(result).toBe(50); + }); + + it("should not pay the percentage value out as a flat amount", () => { + const eventMetadata: EventMetadataV1Type = { + schemaVersion: 1, + source: "api", + orderAmount: 500, + orderId: "order_789", + }; + + const result = calculateRewardAmount( + legacyPercentConfig(10), + eventMetadata, + ); + + expect(result).not.toBe(10); + }); + + it("should return 0 when orderAmount is missing", () => { + const result = calculateRewardAmount(legacyPercentConfig(10), { + schemaVersion: 1, + source: "api", + }); + + expect(result).toBe(0); + }); + }); + describe("Reward metadata creation", () => { it("should NOT include coupon codes (external systems handle this)", () => { const metadata: any = { @@ -125,26 +181,3 @@ describe("Reward Calculation Logic", () => { }); }); }); - -// Helper function extracted from reward-engine.ts for testing -function calculateRewardAmount( - rewardConfig: RewardRuleConfigV1Type["reward"], - eventMetadata?: EventMetadataV1Type, -): number { - const baseAmount = rewardConfig.amount; - - if (rewardConfig.unit === "fixed") { - return baseAmount; - } - - if (rewardConfig.unit === "percent") { - // For percentage rewards, calculate based on order amount if available - if (eventMetadata?.orderAmount) { - return (eventMetadata.orderAmount * baseAmount) / 100; - } - // If orderAmount is not available for a percentage reward, the value is 0 - return 0; - } - - return baseAmount; -} diff --git a/apps/webapp/src/app/(authenticated)/(core)/programs/[id]/_components/reward-step-config.tsx b/apps/webapp/src/app/(authenticated)/(core)/programs/[id]/_components/reward-step-config.tsx index 5932644..fb9a3c1 100644 --- a/apps/webapp/src/app/(authenticated)/(core)/programs/[id]/_components/reward-step-config.tsx +++ b/apps/webapp/src/app/(authenticated)/(core)/programs/[id]/_components/reward-step-config.tsx @@ -5,6 +5,7 @@ import { programTemplateRewardStepSchemaV2, ProgramTemplateRewardStepV2Type, RewardRuleConfigV1Type, + normalizeRewardUnit, } from "@refref/types"; import { toast } from "sonner"; import { StickySaveBarRelative } from "@/components/sticky-save-bar"; @@ -62,14 +63,18 @@ function convertRewardRulesToFormData( referrerReward: { enabled: !!referrerRule && (referrerRule.isActive ?? true), valueType: - referrerRule?.config.reward.unit === "percent" ? "percentage" : "fixed", + normalizeRewardUnit(referrerRule?.config.reward.unit) === "percent" + ? "percentage" + : "fixed", value: referrerRule?.config.reward.amount ?? 10, currency: validateCurrency(referrerRule?.config.reward.currency), }, refereeReward: { enabled: !!refereeRule && (refereeRule.isActive ?? true), valueType: - refereeRule?.config.reward.unit === "percent" ? "percentage" : "fixed", + normalizeRewardUnit(refereeRule?.config.reward.unit) === "percent" + ? "percentage" + : "fixed", value: refereeRule?.config.reward.amount ?? 20, currency: validateCurrency(refereeRule?.config.reward.currency), minPurchaseAmount: refereeRule?.config.reward.minPurchaseAmount ?? 50, diff --git a/apps/webapp/src/server/api/routers/program.ts b/apps/webapp/src/server/api/routers/program.ts index 9178588..cc390d9 100644 --- a/apps/webapp/src/server/api/routers/program.ts +++ b/apps/webapp/src/server/api/routers/program.ts @@ -23,6 +23,7 @@ import { defaultWidgetConfig, getAllProgramTemplates, getProgramTemplateById, + normalizeRewardUnit, } from "@refref/types"; import { TRPCError } from "@trpc/server"; import { @@ -279,7 +280,7 @@ export const programRouter = createTRPCRouter({ reward: { type: "cash" as const, amount: rewardConfig.referrer.value, - unit: rewardConfig.referrer.valueType as "fixed" | "percent", + unit: normalizeRewardUnit(rewardConfig.referrer.valueType), currency: rewardConfig.referrer.currency, }, }, @@ -304,7 +305,7 @@ export const programRouter = createTRPCRouter({ reward: { type: "discount" as const, amount: rewardConfig.referee.value, - unit: rewardConfig.referee.valueType as "fixed" | "percent", + unit: normalizeRewardUnit(rewardConfig.referee.valueType), currency: rewardConfig.referee.currency, minPurchaseAmount: rewardConfig.referee.minPurchaseAmount, validityDays: rewardConfig.referee.validityDays, diff --git a/apps/webapp/src/server/services/reward-engine.ts b/apps/webapp/src/server/services/reward-engine.ts index 3c77d38..c041fc9 100644 --- a/apps/webapp/src/server/services/reward-engine.ts +++ b/apps/webapp/src/server/services/reward-engine.ts @@ -9,6 +9,7 @@ const { referral, } = schema; import { eq, and, desc } from "drizzle-orm"; +import { calculateRewardAmount, normalizeRewardUnit } from "@refref/types"; import type { RewardRuleConfigV1Type, EventMetadataV1Type, @@ -154,7 +155,7 @@ export async function processEventForRewards(db: DbType, eventId: string) { rewardMetadata.validUntil = validUntil.toISOString(); // Add min purchase amount if percentage discount - if (ruleConfig.reward.unit === "percent") { + if (normalizeRewardUnit(ruleConfig.reward.unit) === "percent") { rewardMetadata.minPurchaseAmount = 50; // Default minimum } } @@ -209,31 +210,6 @@ export async function processEventForRewards(db: DbType, eventId: string) { } } -/** - * Calculate reward amount based on rule configuration and event metadata - */ -function calculateRewardAmount( - rewardConfig: RewardRuleConfigV1Type["reward"], - eventMetadata?: EventMetadataV1Type, -): number { - const baseAmount = rewardConfig.amount; - - if (rewardConfig.unit === "fixed") { - return baseAmount; - } - - if (rewardConfig.unit === "percent") { - // For percentage rewards, calculate based on order amount if available - if (eventMetadata?.orderAmount) { - return (eventMetadata.orderAmount * baseAmount) / 100; - } - // Default to the percentage value itself if no order amount - return baseAmount; - } - - return baseAmount; -} - /** * Generate a unique coupon code for discount rewards */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eccbce1..9c1a624 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,6 +13,7 @@ export * from "./widget-config"; export * from "./widget-css-defaults"; export * from "./event-config"; export * from "./reward-config"; +export * from "./reward-calculation"; export const referralLinkSchema = z.object({ code: z.string(), diff --git a/packages/types/src/reward-calculation.ts b/packages/types/src/reward-calculation.ts new file mode 100644 index 0000000..5593cb1 --- /dev/null +++ b/packages/types/src/reward-calculation.ts @@ -0,0 +1,32 @@ +import type { EventMetadataV1Type } from "./event-config"; +import { + normalizeRewardUnit, + type RewardRuleConfigV1Type, +} from "./reward-config"; + +/** + * Calculate the payout for a reward rule. + * + * Percentage rules are resolved against the order amount carried on the event. + * A percentage rule with no order amount to apply to has no meaningful payout, + * so it resolves to 0 — returning the raw percentage would pay "10%" out as a + * flat 10. + * + * Shared by the API and webapp reward engines so both apply the same money + * rules. + */ +export function calculateRewardAmount( + rewardConfig: RewardRuleConfigV1Type["reward"], + eventMetadata?: EventMetadataV1Type, +): number { + const baseAmount = rewardConfig.amount; + + if (normalizeRewardUnit(rewardConfig.unit) === "percent") { + if (eventMetadata?.orderAmount) { + return (eventMetadata.orderAmount * baseAmount) / 100; + } + return 0; + } + + return baseAmount; +} diff --git a/packages/types/src/reward-config.ts b/packages/types/src/reward-config.ts index 130669e..712b4f6 100644 --- a/packages/types/src/reward-config.ts +++ b/packages/types/src/reward-config.ts @@ -8,6 +8,32 @@ export type RewardTypeEnum = z.infer; export const participantTypeSchema = z.enum(["referrer", "referee"]); export type ParticipantTypeEnum = z.infer; +// Reward unit enum. This is the canonical spelling used by reward rules. +export const rewardUnitSchema = z.enum(["fixed", "percent"]); +export type RewardUnitType = z.infer; + +// The program setup UI and `rewardConfigSchema.valueType` spell the percentage +// unit "percentage", while reward rules store it as "percent". Rules written +// before the two were reconciled hold "percentage", which no consumer matched. +// Normalizing on read keeps those existing rules working. +const rewardUnitAliases: Record = { + percentage: "percent", +}; + +/** + * Coerce a stored or user-supplied reward unit to its canonical spelling. + * Returns undefined for absent or unrecognized units, which callers treat as + * a flat amount. + */ +export function normalizeRewardUnit( + unit: string | undefined | null, +): RewardUnitType | undefined { + if (unit == null) return undefined; + const aliased = rewardUnitAliases[unit] ?? unit; + const parsed = rewardUnitSchema.safeParse(aliased); + return parsed.success ? parsed.data : undefined; +} + // Reward Rule Config V1 (simplified - event triggers only) export const rewardRuleConfigV1Schema = z.object({ schemaVersion: z.literal(1), @@ -18,7 +44,17 @@ export const rewardRuleConfigV1Schema = z.object({ reward: z.object({ type: rewardTypeSchema, amount: z.number(), - unit: z.enum(["fixed", "percent"]).optional(), // for discount: percent, for cash: fixed + // for discount: percent, for cash: fixed. Accepts the legacy "percentage" + // spelling written by the program setup flow and stores it as "percent". + unit: z + .preprocess( + (value) => + typeof value === "string" + ? (rewardUnitAliases[value] ?? value) + : value, + rewardUnitSchema, + ) + .optional(), currency: z.string().optional(), // Currency code (USD, EUR, GBP, etc.) minPurchaseAmount: z.number().optional(), // Minimum purchase amount for discount validityDays: z.number().int().positive().optional(), // Discount validity period in days