Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 1 addition & 25 deletions apps/api/src/services/reward-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
referral,
} = schema;
import { eq, and, desc } from "drizzle-orm";
import { calculateRewardAmount } from "@refref/types";
import type {
RewardRuleConfigV1Type,
EventMetadataV1Type,
Expand Down Expand Up @@ -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
*/
Expand Down
83 changes: 58 additions & 25 deletions apps/api/test/unit/reward-calculation.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
programTemplateRewardStepSchemaV2,
ProgramTemplateRewardStepV2Type,
RewardRuleConfigV1Type,
normalizeRewardUnit,
} from "@refref/types";
import { toast } from "sonner";
import { StickySaveBarRelative } from "@/components/sticky-save-bar";
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions apps/webapp/src/server/api/routers/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
defaultWidgetConfig,
getAllProgramTemplates,
getProgramTemplateById,
normalizeRewardUnit,
} from "@refref/types";
import { TRPCError } from "@trpc/server";
import {
Expand Down Expand Up @@ -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,
},
},
Expand All @@ -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,
Expand Down
28 changes: 2 additions & 26 deletions apps/webapp/src/server/services/reward-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
referral,
} = schema;
import { eq, and, desc } from "drizzle-orm";
import { calculateRewardAmount, normalizeRewardUnit } from "@refref/types";
import type {
RewardRuleConfigV1Type,
EventMetadataV1Type,
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
32 changes: 32 additions & 0 deletions packages/types/src/reward-calculation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 37 additions & 1 deletion packages/types/src/reward-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ export type RewardTypeEnum = z.infer<typeof rewardTypeSchema>;
export const participantTypeSchema = z.enum(["referrer", "referee"]);
export type ParticipantTypeEnum = z.infer<typeof participantTypeSchema>;

// Reward unit enum. This is the canonical spelling used by reward rules.
export const rewardUnitSchema = z.enum(["fixed", "percent"]);
export type RewardUnitType = z.infer<typeof rewardUnitSchema>;

// 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<string, RewardUnitType> = {
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),
Expand All @@ -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
Expand Down