From 78a61d5ce147440cb50fd355672ae680a3948673 Mon Sep 17 00:00:00 2001 From: kingsmart-sketch Date: Sun, 27 Sep 2026 18:45:39 +0000 Subject: [PATCH] feat: add referral program with commission structure and fraud detection - Add ReferralTier, ReferralCode, Referral, ReferralCommission Prisma models - Add migration SQL (20260927180000_add_referral_program) - Implement ReferralService: code generation, referral tracking, commission calculation, payout, fraud detection (self-referral, duplicate, depth, velocity, locked-code guards), tier CRUD, affiliate dashboard - Add referral routes: GET my-code, GET dashboard, POST payout, GET code/:code (public), admin tier/code/fraud management endpoints - Hook commission processing into payment.service updateTipStatus on tip confirmation (non-blocking, errors logged) - Register referral routes in bootstrap() - Add POST /api/v1/referrals/payout to sensitive rate-limit class - Add comprehensive unit tests for all service methods --- .../migration.sql | 98 ++ prisma/schema.prisma | 126 +++ src/config/rate-limit.ts | 1 + src/domains/payments/payment.service.ts | 23 + .../__tests__/referral.service.test.ts | 857 ++++++++++++++++++ src/domains/referrals/referral.routes.ts | 437 +++++++++ src/domains/referrals/referral.service.ts | 620 +++++++++++++ src/domains/referrals/referral.types.ts | 104 +++ src/index.ts | 2 + 9 files changed, 2268 insertions(+) create mode 100644 prisma/migrations/20260927180000_add_referral_program/migration.sql create mode 100644 src/domains/referrals/__tests__/referral.service.test.ts create mode 100644 src/domains/referrals/referral.routes.ts create mode 100644 src/domains/referrals/referral.service.ts create mode 100644 src/domains/referrals/referral.types.ts diff --git a/prisma/migrations/20260927180000_add_referral_program/migration.sql b/prisma/migrations/20260927180000_add_referral_program/migration.sql new file mode 100644 index 0000000..e63922c --- /dev/null +++ b/prisma/migrations/20260927180000_add_referral_program/migration.sql @@ -0,0 +1,98 @@ +-- CreateTable: ReferralTier +CREATE TABLE "ReferralTier" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "commissionRate" DOUBLE PRECISION NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReferralTier_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: ReferralCode +CREATE TABLE "ReferralCode" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "tierId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lockedAt" TIMESTAMP(3), + "lockReason" TEXT, + "usageCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReferralCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: Referral +CREATE TABLE "Referral" ( + "id" TEXT NOT NULL, + "referralCodeId" TEXT NOT NULL, + "refereeId" TEXT NOT NULL, + "depth" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'pending', + "convertedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Referral_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: ReferralCommission +CREATE TABLE "ReferralCommission" ( + "id" TEXT NOT NULL, + "referralId" TEXT NOT NULL, + "tipId" TEXT NOT NULL, + "referrerId" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "commissionRate" DOUBLE PRECISION NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'XLM', + "status" TEXT NOT NULL DEFAULT 'pending', + "paidAt" TIMESTAMP(3), + "failReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReferralCommission_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: ReferralTier +CREATE UNIQUE INDEX "ReferralTier_name_key" ON "ReferralTier"("name"); +CREATE INDEX "ReferralTier_isDefault_idx" ON "ReferralTier"("isDefault"); + +-- CreateIndex: ReferralCode +CREATE UNIQUE INDEX "ReferralCode_userId_key" ON "ReferralCode"("userId"); +CREATE UNIQUE INDEX "ReferralCode_code_key" ON "ReferralCode"("code"); +CREATE INDEX "ReferralCode_userId_isActive_idx" ON "ReferralCode"("userId", "isActive"); +CREATE INDEX "ReferralCode_code_idx" ON "ReferralCode"("code"); +CREATE INDEX "idx_referralCode_user_createdAt" ON "ReferralCode"("userId", "createdAt" DESC); + +-- CreateIndex: Referral +CREATE UNIQUE INDEX "Referral_refereeId_key" ON "Referral"("refereeId"); +CREATE INDEX "Referral_referralCodeId_status_idx" ON "Referral"("referralCodeId", "status"); +CREATE INDEX "Referral_refereeId_idx" ON "Referral"("refereeId"); +CREATE INDEX "Referral_status_createdAt_idx" ON "Referral"("status", "createdAt"); +CREATE INDEX "idx_referral_code_createdAt" ON "Referral"("referralCodeId", "createdAt" DESC); + +-- CreateIndex: ReferralCommission +CREATE UNIQUE INDEX "ReferralCommission_tipId_key" ON "ReferralCommission"("tipId"); +CREATE INDEX "ReferralCommission_referrerId_status_idx" ON "ReferralCommission"("referrerId", "status"); +CREATE INDEX "ReferralCommission_referralId_status_idx" ON "ReferralCommission"("referralId", "status"); +CREATE INDEX "ReferralCommission_status_createdAt_idx" ON "ReferralCommission"("status", "createdAt"); +CREATE INDEX "idx_referralCommission_referrer_status_createdAt" ON "ReferralCommission"("referrerId", "status", "createdAt"); + +-- AddForeignKey: ReferralCode +ALTER TABLE "ReferralCode" ADD CONSTRAINT "ReferralCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ReferralCode" ADD CONSTRAINT "ReferralCode_tierId_fkey" FOREIGN KEY ("tierId") REFERENCES "ReferralTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: Referral +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_referralCodeId_fkey" FOREIGN KEY ("referralCodeId") REFERENCES "ReferralCode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: ReferralCommission +ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_tipId_fkey" FOREIGN KEY ("tipId") REFERENCES "Tip"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ReferralCommission" ADD CONSTRAINT "ReferralCommission_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3fdaac7..00e4ece 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,11 @@ model User { wallets Wallet[] tips Tip[] payments Payment[] + + // Referral program + referralCode ReferralCode? + referredAs Referral? @relation("RefereeUser") + commissionsEarned ReferralCommission[] @relation("ReferrerUser") } model Creator { @@ -96,6 +101,8 @@ model Tip { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + referralCommission ReferralCommission? + @@index([creatorId, status, createdAt]) @@index([fromUserId, createdAt]) @@index([status]) @@ -320,6 +327,125 @@ enum PayoutStatus { failed } +// ─── Referral Program ──────────────────────────────────────────────────────── + +// Commission tier configuration (admin-controlled). +// Admins can define named tiers with different commission rates. +// Each user is assigned a tier; default tier is applied if none specified. +model ReferralTier { + id String @id @default(cuid()) + name String @unique // e.g. "standard", "silver", "gold" + description String? + // Commission as a fraction (e.g. 0.05 = 5%) + commissionRate Float + isDefault Boolean @default(false) + + referralCodes ReferralCode[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isDefault]) +} + +// A unique referral code owned by a user. Each user may have at most one +// active code at a time. The code is a short, URL-safe alphanumeric string. +model ReferralCode { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + code String @unique + tierId String? + tier ReferralTier? @relation(fields: [tierId], references: [id], onDelete: SetNull) + + isActive Boolean @default(true) + // Soft fraud-lock: admin or system can deactivate without deleting history + lockedAt DateTime? + lockReason String? + + usageCount Int @default(0) + + referrals Referral[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, isActive]) + @@index([code]) + // Dashboard: referrer looks up their own code list newest-first. + @@index([userId, createdAt(sort: Desc)], map: "idx_referralCode_user_createdAt") +} + +// A single referral event: a new user who signed up via a referral code. +// One referee -> one referral record. +model Referral { + id String @id @default(cuid()) + referralCodeId String + referralCode ReferralCode @relation(fields: [referralCodeId], references: [id], onDelete: Cascade) + + // The user who was referred (referee) + refereeId String + referee User @relation("RefereeUser", fields: [refereeId], references: [id], onDelete: Cascade) + + // Depth starts at 1 for a direct referral. Used to enforce max-depth guard. + depth Int @default(1) + + // Lifecycle: pending → converted → rewarded | fraud + status String @default("pending") // pending, converted, rewarded, fraud + + // When the referee completed the qualifying action (e.g. first confirmed tip) + convertedAt DateTime? + + commissions ReferralCommission[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Prevent duplicate referral for the same referee + @@unique([refereeId]) + @@index([referralCodeId, status]) + @@index([refereeId]) + @@index([status, createdAt]) + // Referrer dashboard: all referrals for a given code, newest first. + @@index([referralCodeId, createdAt(sort: Desc)], map: "idx_referral_code_createdAt") +} + +// A commission earned by a referrer when their referee's tip is confirmed. +// Multiple commissions may exist per referral (one per qualifying tip). +model ReferralCommission { + id String @id @default(cuid()) + referralId String + referral Referral @relation(fields: [referralId], references: [id], onDelete: Cascade) + + // The tip that triggered this commission + tipId String + tip Tip @relation(fields: [tipId], references: [id], onDelete: Cascade) + + // Referrer (denormalized for query efficiency) + referrerId String + referrer User @relation("ReferrerUser", fields: [referrerId], references: [id], onDelete: Cascade) + + amount Float // Commission amount (computed from tip.amount * commissionRate) + commissionRate Float // Snapshot of the rate at the time of calculation + currency String @default("XLM") + + // pending → paid | failed + status String @default("pending") + paidAt DateTime? + failReason String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([tipId]) // One commission per tip (idempotent) + @@index([referrerId, status]) + @@index([referralId, status]) + @@index([status, createdAt]) + // Affiliate payout sweep: pending commissions for a referrer, oldest first. + @@index([referrerId, status, createdAt], map: "idx_referralCommission_referrer_status_createdAt") +} + model Payout { id String @id @default(cuid()) creatorId String diff --git a/src/config/rate-limit.ts b/src/config/rate-limit.ts index decaff9..1b3edef 100644 --- a/src/config/rate-limit.ts +++ b/src/config/rate-limit.ts @@ -76,6 +76,7 @@ export const RATE_LIMIT_RULES: RateLimitRule[] = [ { method: 'POST', url: '/api/v1/payments', class: 'sensitive' }, { method: 'POST', url: '/api/v1/payments/:id/refund', class: 'sensitive' }, { method: 'POST', url: '/api/v1/creators/payout', class: 'sensitive' }, + { method: 'POST', url: '/api/v1/referrals/payout', class: 'sensitive' }, // Resource intensive: Stellar Horizon round-trips, job enqueueing, cache // warming and EXPLAIN queries. diff --git a/src/domains/payments/payment.service.ts b/src/domains/payments/payment.service.ts index 3419f47..da816dd 100644 --- a/src/domains/payments/payment.service.ts +++ b/src/domains/payments/payment.service.ts @@ -723,6 +723,29 @@ export class PaymentService extends BaseService { throw new ConflictError('Tip update did not complete', { tipId }); } + // Process referral commission outside transaction (idempotent) + if (shouldDispatchWebhook && finalTip) { + try { + // Look up the sender's userId from the tip (fromUserId is the referee) + const tipRecord = await this.prisma.tip.findUnique({ + where: { id: finalTip.id }, + select: { fromUserId: true, amount: true }, + }); + if (tipRecord) { + const { ReferralService } = await import('../referrals/referral.service'); + const referralService = new ReferralService(this.prisma); + await referralService.processCommissionForTip({ + tipId: finalTip.id, + tipAmount: tipRecord.amount, + refereeUserId: tipRecord.fromUserId, + }); + } + } catch (e) { + // Commission failures must never block a confirmed tip + logger.error(`Failed to process referral commission for tip ${finalTip.id}:`, e); + } + } + // Dispatch webhooks outside transaction if (shouldDispatchWebhook) { try { diff --git a/src/domains/referrals/__tests__/referral.service.test.ts b/src/domains/referrals/__tests__/referral.service.test.ts new file mode 100644 index 0000000..9957f4d --- /dev/null +++ b/src/domains/referrals/__tests__/referral.service.test.ts @@ -0,0 +1,857 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ReferralService } from '../referral.service'; +import { ValidationError, NotFoundError, ConflictError } from '../../../utils/errors'; + +// ─── Minimal Prisma mock types ──────────────────────────────────────────────── + +type MockReferralTier = { + id: string; + name: string; + description: string | null; + commissionRate: number; + isDefault: boolean; + createdAt: Date; + updatedAt: Date; +}; + +type MockReferralCode = { + id: string; + userId: string; + code: string; + tierId: string | null; + tier?: MockReferralTier | null; + isActive: boolean; + lockedAt: Date | null; + lockReason: string | null; + usageCount: number; + createdAt: Date; + updatedAt: Date; +}; + +type MockReferral = { + id: string; + referralCodeId: string; + referralCode?: MockReferralCode & { tier?: MockReferralTier | null }; + refereeId: string; + depth: number; + status: string; + convertedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +type MockCommission = { + id: string; + referralId: string; + tipId: string; + referrerId: string; + amount: number; + commissionRate: number; + currency: string; + status: string; + paidAt: Date | null; + failReason: string | null; + createdAt: Date; + updatedAt: Date; +}; + +// ─── In-memory fake for Prisma ──────────────────────────────────────────────── + +function makeDate(): Date { + return new Date('2026-09-27T00:00:00Z'); +} + +function cuid(): string { + return Math.random().toString(36).slice(2, 12); +} + +class FakePrisma { + tiers: MockReferralTier[] = []; + codes: MockReferralCode[] = []; + referrals: MockReferral[] = []; + commissions: MockCommission[] = []; + + referralTier = { + findFirst: vi.fn(async (args?: { where?: { isDefault?: boolean } }) => { + if (args?.where?.isDefault) { + return this.tiers.find((t) => t.isDefault) ?? null; + } + return this.tiers[0] ?? null; + }), + findUnique: vi.fn(async (args: { where: { id?: string; name?: string } }) => { + const t = args.where.id + ? this.tiers.find((t) => t.id === args.where.id) + : this.tiers.find((t) => t.name === args.where.name); + return t ?? null; + }), + findMany: vi.fn(async () => [...this.tiers]), + create: vi.fn(async (args: { data: Partial }) => { + if (this.tiers.find((t) => t.name === args.data.name)) { + const err: Error & { code?: string } = new Error('Unique constraint'); + err.code = 'P2002'; + throw err; + } + const tier: MockReferralTier = { + id: cuid(), + name: args.data.name!, + description: args.data.description ?? null, + commissionRate: args.data.commissionRate!, + isDefault: args.data.isDefault ?? false, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + this.tiers.push(tier); + return tier; + }), + update: vi.fn(async (args: { where: { id: string }; data: Partial }) => { + const idx = this.tiers.findIndex((t) => t.id === args.where.id); + if (idx === -1) throw new Error('Not found'); + this.tiers[idx] = { ...this.tiers[idx], ...args.data, updatedAt: makeDate() }; + return this.tiers[idx]; + }), + updateMany: vi.fn(async (args: { where: { isDefault?: boolean; id?: { not?: string } }; data: Partial }) => { + this.tiers + .filter((t) => { + if (args.where.isDefault !== undefined && t.isDefault !== args.where.isDefault) return false; + if (args.where.id?.not !== undefined && t.id === args.where.id.not) return false; + return true; + }) + .forEach((t) => { + Object.assign(t, args.data, { updatedAt: makeDate() }); + }); + return { count: 1 }; + }), + delete: vi.fn(async (args: { where: { id: string } }) => { + const idx = this.tiers.findIndex((t) => t.id === args.where.id); + if (idx === -1) throw new Error('Not found'); + const [deleted] = this.tiers.splice(idx, 1); + return deleted; + }), + }; + + referralCode = { + findUnique: vi.fn(async (args: { where: { userId?: string; code?: string; id?: string } }) => { + const c = args.where.userId + ? this.codes.find((c) => c.userId === args.where.userId) + : args.where.code + ? this.codes.find((c) => c.code === args.where.code) + : this.codes.find((c) => c.id === args.where.id); + return c ?? null; + }), + create: vi.fn(async (args: { data: Partial }) => { + if ( + args.data.userId && this.codes.find((c) => c.userId === args.data.userId) || + args.data.code && this.codes.find((c) => c.code === args.data.code) + ) { + const err: Error & { code?: string } = new Error('Unique constraint'); + err.code = 'P2002'; + throw err; + } + const code: MockReferralCode = { + id: cuid(), + userId: args.data.userId!, + code: args.data.code!, + tierId: args.data.tierId ?? null, + isActive: true, + lockedAt: null, + lockReason: null, + usageCount: 0, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + this.codes.push(code); + return code; + }), + update: vi.fn(async (args: { where: { id: string }; data: Partial }) => { + const idx = this.codes.findIndex((c) => c.id === args.where.id); + if (idx === -1) throw new Error('Not found'); + this.codes[idx] = { ...this.codes[idx], ...args.data, updatedAt: makeDate() }; + return this.codes[idx]; + }), + }; + + referral = { + findUnique: vi.fn(async (args: { where: { refereeId?: string; id?: string }; include?: unknown }) => { + const r = args.where.refereeId + ? this.referrals.find((r) => r.refereeId === args.where.refereeId) + : this.referrals.find((r) => r.id === args.where.id); + if (!r) return null; + // Simulate includes + if (args.include) { + const code = this.codes.find((c) => c.id === r.referralCodeId) ?? null; + return { ...r, referralCode: code ? { ...code, tier: code.tierId ? this.tiers.find((t) => t.id === code.tierId) ?? null : null } : null }; + } + return r; + }), + count: vi.fn(async (args: { where: { referralCodeId?: string; createdAt?: { gte?: Date } } }) => { + return this.referrals.filter((r) => { + if (args.where.referralCodeId && r.referralCodeId !== args.where.referralCodeId) return false; + if (args.where.createdAt?.gte && r.createdAt < args.where.createdAt.gte) return false; + return true; + }).length; + }), + create: vi.fn(async (args: { data: Partial }) => { + if (args.data.refereeId && this.referrals.find((r) => r.refereeId === args.data.refereeId)) { + const err: Error & { code?: string } = new Error('Unique constraint'); + err.code = 'P2002'; + throw err; + } + const referral: MockReferral = { + id: cuid(), + referralCodeId: args.data.referralCodeId!, + refereeId: args.data.refereeId!, + depth: args.data.depth ?? 1, + status: args.data.status ?? 'pending', + convertedAt: null, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + this.referrals.push(referral); + return referral; + }), + update: vi.fn(async (args: { where: { id: string }; data: Partial }) => { + const idx = this.referrals.findIndex((r) => r.id === args.where.id); + if (idx === -1) throw new Error('Not found'); + this.referrals[idx] = { ...this.referrals[idx], ...args.data, updatedAt: makeDate() }; + return this.referrals[idx]; + }), + updateMany: vi.fn(async (args: { where: { id?: { in?: string[] } }; data: Partial }) => { + const ids = args.where.id?.in ?? []; + this.referrals.filter((r) => ids.includes(r.id)).forEach((r) => Object.assign(r, args.data)); + return { count: ids.length }; + }), + findMany: vi.fn(async (args?: { where?: { referralCodeId?: string }; take?: number; orderBy?: unknown }) => { + let result = [...this.referrals]; + if (args?.where?.referralCodeId) { + result = result.filter((r) => r.referralCodeId === args.where!.referralCodeId); + } + return result.slice(0, args?.take ?? result.length); + }), + }; + + referralCommission = { + findUnique: vi.fn(async (args: { where: { tipId?: string; id?: string } }) => { + const c = args.where.tipId + ? this.commissions.find((c) => c.tipId === args.where.tipId) + : this.commissions.find((c) => c.id === args.where.id); + return c ?? null; + }), + create: vi.fn(async (args: { data: Partial }) => { + const commission: MockCommission = { + id: cuid(), + referralId: args.data.referralId!, + tipId: args.data.tipId!, + referrerId: args.data.referrerId!, + amount: args.data.amount!, + commissionRate: args.data.commissionRate!, + currency: args.data.currency ?? 'XLM', + status: args.data.status ?? 'pending', + paidAt: null, + failReason: null, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + this.commissions.push(commission); + return commission; + }), + findMany: vi.fn(async (args?: { where?: { referrerId?: string; status?: string }; orderBy?: unknown; take?: number }) => { + let result = [...this.commissions]; + if (args?.where?.referrerId) result = result.filter((c) => c.referrerId === args.where!.referrerId); + if (args?.where?.status) result = result.filter((c) => c.status === args.where!.status); + return result.slice(0, args?.take ?? result.length); + }), + updateMany: vi.fn(async (args: { where: { id?: { in?: string[] }; referralId?: string; status?: string }; data: Partial }) => { + const ids = args.where.id?.in; + this.commissions + .filter((c) => { + if (ids && !ids.includes(c.id)) return false; + if (args.where.referralId && c.referralId !== args.where.referralId) return false; + if (args.where.status && c.status !== args.where.status) return false; + return true; + }) + .forEach((c) => Object.assign(c, args.data)); + return { count: 1 }; + }), + groupBy: vi.fn(async (args: { by: string[]; where?: { referrerId?: string; referralId?: { in?: string[] } }; _sum?: unknown; _count?: unknown }) => { + const commissions = this.commissions.filter((c) => { + if (args.where?.referrerId && c.referrerId !== args.where.referrerId) return false; + if (args.where?.referralId?.in && !args.where.referralId.in.includes(c.referralId)) return false; + return true; + }); + + if (args.by.includes('status')) { + const grouped: Record = {}; + for (const c of commissions) { + grouped[c.status] = (grouped[c.status] ?? 0) + c.amount; + } + return Object.entries(grouped).map(([status, amount]) => ({ + status, + _sum: { amount }, + _count: { id: commissions.filter((c) => c.status === status).length }, + })); + } + + if (args.by.includes('referralId')) { + const grouped: Record = {}; + for (const c of commissions) { + grouped[c.referralId] = (grouped[c.referralId] ?? 0) + c.amount; + } + return Object.entries(grouped).map(([referralId, amount]) => ({ + referralId, + _sum: { amount }, + })); + } + + return []; + }), + }; + + // $transaction: execute all operations in sequence + $transaction = vi.fn(async (operations: unknown) => { + if (typeof operations === 'function') { + return await operations(this); + } + if (Array.isArray(operations)) { + const results = []; + for (const op of operations) { + results.push(await op); + } + return results; + } + return null; + }); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function buildService(prisma: FakePrisma): ReferralService { + return new ReferralService(prisma as never); +} + +function seedCode( + prisma: FakePrisma, + opts: { userId: string; code?: string; isActive?: boolean; tierId?: string; lockedAt?: Date | null } +): MockReferralCode { + const c: MockReferralCode = { + id: cuid(), + userId: opts.userId, + code: opts.code ?? 'TESTCODE', + tierId: opts.tierId ?? null, + isActive: opts.isActive ?? true, + lockedAt: opts.lockedAt ?? null, + lockReason: null, + usageCount: 0, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + prisma.codes.push(c); + return c; +} + +function seedReferral( + prisma: FakePrisma, + opts: { referralCodeId: string; refereeId: string; status?: string; depth?: number } +): MockReferral { + const r: MockReferral = { + id: cuid(), + referralCodeId: opts.referralCodeId, + refereeId: opts.refereeId, + depth: opts.depth ?? 1, + status: opts.status ?? 'pending', + convertedAt: null, + createdAt: makeDate(), + updatedAt: makeDate(), + }; + prisma.referrals.push(r); + return r; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ReferralService.getOrCreateReferralCode', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('creates a code for a user who has none', async () => { + const result = await service.getOrCreateReferralCode('user-1'); + expect(result.userId).toBe('user-1'); + expect(result.code).toHaveLength(8); + expect(/^[A-Z0-9]+$/.test(result.code)).toBe(true); + expect(result.isActive).toBe(true); + expect(prisma.codes).toHaveLength(1); + }); + + it('returns the existing code on subsequent calls (idempotent)', async () => { + const first = await service.getOrCreateReferralCode('user-1'); + const second = await service.getOrCreateReferralCode('user-1'); + expect(second.id).toBe(first.id); + expect(prisma.codes).toHaveLength(1); + }); + + it('assigns the default tier if one exists', async () => { + const tier: MockReferralTier = { + id: 'tier-1', name: 'standard', description: null, + commissionRate: 0.05, isDefault: true, createdAt: makeDate(), updatedAt: makeDate(), + }; + prisma.tiers.push(tier); + const result = await service.getOrCreateReferralCode('user-2'); + expect(result.tierId).toBe('tier-1'); + }); + + it('codes contain no ambiguous chars (O/0/I/l)', async () => { + // Generate many codes and verify no ambiguous chars + for (let i = 0; i < 50; i++) { + const prismaLocal = new FakePrisma(); + const svc = buildService(prismaLocal); + const r = await svc.getOrCreateReferralCode(`user-${i}`); + expect(r.code).not.toMatch(/[OoIl0]/); + } + }); +}); + +describe('ReferralService.getReferralCodeByValue', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('returns a code that exists', async () => { + seedCode(prisma, { userId: 'user-1', code: 'ABCD1234' }); + const result = await service.getReferralCodeByValue('ABCD1234'); + expect(result.code).toBe('ABCD1234'); + }); + + it('throws NotFoundError for unknown codes', async () => { + await expect(service.getReferralCodeByValue('UNKNOWN1')).rejects.toThrow(NotFoundError); + }); +}); + +describe('ReferralService.registerReferral', () => { + let prisma: FakePrisma; + let service: ReferralService; + let code: MockReferralCode; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + code = seedCode(prisma, { userId: 'referrer-1', code: 'MYCODE12' }); + }); + + it('creates a referral record for a valid registration', async () => { + await service.registerReferral('MYCODE12', 'referee-1'); + expect(prisma.referrals).toHaveLength(1); + expect(prisma.referrals[0].refereeId).toBe('referee-1'); + expect(prisma.referrals[0].status).toBe('pending'); + expect(prisma.codes[0].usageCount).toBe(1); + }); + + it('silently skips unknown codes (does not block registration)', async () => { + await expect(service.registerReferral('BADCODE1', 'referee-1')).resolves.toBeUndefined(); + expect(prisma.referrals).toHaveLength(0); + }); + + it('rejects self-referral (user cannot refer themselves)', async () => { + await expect(service.registerReferral('MYCODE12', 'referrer-1')).rejects.toThrow(ValidationError); + expect(prisma.referrals).toHaveLength(0); + }); + + it('rejects a user who has already been referred', async () => { + await service.registerReferral('MYCODE12', 'referee-1'); + await expect(service.registerReferral('MYCODE12', 'referee-1')).rejects.toThrow(ConflictError); + expect(prisma.referrals).toHaveLength(1); + }); + + it('rejects locked codes', async () => { + code.isActive = false; + code.lockedAt = new Date(); + await expect(service.registerReferral('MYCODE12', 'referee-2')).rejects.toThrow(ValidationError); + }); + + it('rejects inactive codes', async () => { + code.isActive = false; + await expect(service.registerReferral('MYCODE12', 'referee-2')).rejects.toThrow(ValidationError); + }); + + it('blocks pyramid chains beyond depth 1 (skips without creating referral)', async () => { + // referrer-1 was itself referred by someone else (depth 1 already exists) + const parentCode = seedCode(prisma, { userId: 'grandparent-1', code: 'PARENT12' }); + seedReferral(prisma, { referralCodeId: parentCode.id, refereeId: 'referrer-1', depth: 1 }); + + // Now a new user tries to use referrer-1's code → depth would be 2 (exceeds MAX=1) + await service.registerReferral('MYCODE12', 'referee-deep'); + // Should silently skip (no referral created for this user) + expect(prisma.referrals.find((r) => r.refereeId === 'referee-deep')).toBeUndefined(); + }); + + it('allows depth=1 when referrer was not themselves referred', async () => { + // referrer-1 is a root referrer (depth 0 chain): should work + await service.registerReferral('MYCODE12', 'referee-shallow'); + expect(prisma.referrals.find((r) => r.refereeId === 'referee-shallow')).toBeDefined(); + }); + + it('locks the code and rejects after velocity limit is hit', async () => { + // Seed 20 recent referrals on this code to trigger velocity guard + const windowStart = new Date(Date.now() - 1000); // recent + for (let i = 0; i < 20; i++) { + prisma.referrals.push({ + id: cuid(), + referralCodeId: code.id, + refereeId: `old-user-${i}`, + depth: 1, + status: 'pending', + convertedAt: null, + createdAt: windowStart, + updatedAt: windowStart, + }); + } + await expect(service.registerReferral('MYCODE12', 'new-user-99')).rejects.toThrow(ValidationError); + // Code should be locked + expect(prisma.codes[0].isActive).toBe(false); + expect(prisma.codes[0].lockedAt).toBeDefined(); + }); +}); + +describe('ReferralService.processCommissionForTip', () => { + let prisma: FakePrisma; + let service: ReferralService; + let referralCode: MockReferralCode; + let referral: MockReferral; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + referralCode = seedCode(prisma, { userId: 'referrer-1', code: 'REFCODE1' }); + referral = seedReferral(prisma, { referralCodeId: referralCode.id, refereeId: 'referee-1' }); + }); + + it('creates a commission record for a referred user tip', async () => { + await service.processCommissionForTip({ + tipId: 'tip-1', + tipAmount: 100, + refereeUserId: 'referee-1', + }); + + expect(prisma.commissions).toHaveLength(1); + expect(prisma.commissions[0].tipId).toBe('tip-1'); + expect(prisma.commissions[0].referrerId).toBe('referrer-1'); + expect(prisma.commissions[0].commissionRate).toBe(0.05); // default rate + expect(prisma.commissions[0].amount).toBeCloseTo(5); // 5% of 100 + expect(prisma.commissions[0].status).toBe('pending'); + }); + + it('uses the tier commission rate when set', async () => { + const tier: MockReferralTier = { + id: 'tier-gold', name: 'gold', description: null, + commissionRate: 0.1, isDefault: false, createdAt: makeDate(), updatedAt: makeDate(), + }; + prisma.tiers.push(tier); + referralCode.tierId = tier.id; + referralCode.tier = tier; + + await service.processCommissionForTip({ + tipId: 'tip-gold', + tipAmount: 200, + refereeUserId: 'referee-1', + }); + + expect(prisma.commissions[0].commissionRate).toBe(0.1); + expect(prisma.commissions[0].amount).toBeCloseTo(20); // 10% of 200 + }); + + it('is idempotent: does not create duplicate commission for the same tip', async () => { + await service.processCommissionForTip({ tipId: 'tip-1', tipAmount: 100, refereeUserId: 'referee-1' }); + await service.processCommissionForTip({ tipId: 'tip-1', tipAmount: 100, refereeUserId: 'referee-1' }); + expect(prisma.commissions).toHaveLength(1); + }); + + it('does nothing when the user was not referred', async () => { + await service.processCommissionForTip({ tipId: 'tip-1', tipAmount: 100, refereeUserId: 'not-referred' }); + expect(prisma.commissions).toHaveLength(0); + }); + + it('skips commission for fraud-flagged referrals', async () => { + referral.status = 'fraud'; + await service.processCommissionForTip({ tipId: 'tip-1', tipAmount: 100, refereeUserId: 'referee-1' }); + expect(prisma.commissions).toHaveLength(0); + }); + + it('marks referral as converted on first commission', async () => { + expect(referral.status).toBe('pending'); + await service.processCommissionForTip({ tipId: 'tip-1', tipAmount: 100, refereeUserId: 'referee-1' }); + const updated = prisma.referrals.find((r) => r.id === referral.id); + expect(updated?.status).toBe('converted'); + expect(updated?.convertedAt).toBeDefined(); + }); + + it('does not re-convert an already-converted referral', async () => { + referral.status = 'converted'; + referral.convertedAt = new Date(); + await service.processCommissionForTip({ tipId: 'tip-2', tipAmount: 50, refereeUserId: 'referee-1' }); + // Commission still recorded, but referral status stays converted + expect(prisma.commissions).toHaveLength(1); + expect(prisma.referrals.find((r) => r.id === referral.id)?.status).toBe('converted'); + }); +}); + +describe('ReferralService.payoutCommissions', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + function seedCommission(prisma: FakePrisma, referralId: string, referrerId: string, amount: number, status = 'pending'): MockCommission { + const c: MockCommission = { + id: cuid(), referralId, tipId: cuid(), referrerId, + amount, commissionRate: 0.05, currency: 'XLM', + status, paidAt: null, failReason: null, + createdAt: makeDate(), updatedAt: makeDate(), + }; + prisma.commissions.push(c); + return c; + } + + it('marks all pending commissions as paid and returns totals', async () => { + seedCommission(prisma, 'ref-1', 'referrer-1', 5); + seedCommission(prisma, 'ref-2', 'referrer-1', 10); + + const result = await service.payoutCommissions('referrer-1'); + expect(result.count).toBe(2); + expect(result.totalAmount).toBeCloseTo(15); + prisma.commissions.forEach((c) => { + if (c.referrerId === 'referrer-1') { + expect(c.status).toBe('paid'); + } + }); + }); + + it('returns zero when there are no pending commissions', async () => { + const result = await service.payoutCommissions('referrer-1'); + expect(result).toEqual({ count: 0, totalAmount: 0 }); + }); + + it('does not pay another referrer\'s commissions', async () => { + seedCommission(prisma, 'ref-1', 'referrer-2', 50); + const result = await service.payoutCommissions('referrer-1'); + expect(result).toEqual({ count: 0, totalAmount: 0 }); + expect(prisma.commissions[0].status).toBe('pending'); + }); +}); + +describe('ReferralService.lockReferralCode', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('locks an active code', async () => { + const code = seedCode(prisma, { userId: 'user-1', code: 'LOCKME12' }); + const result = await service.lockReferralCode(code.id, 'Suspected abuse'); + expect(result.isActive).toBe(false); + expect(result.lockReason).toBe('Suspected abuse'); + expect(result.lockedAt).not.toBeNull(); + }); + + it('throws NotFoundError for unknown code id', async () => { + await expect(service.lockReferralCode('nonexistent', 'reason')).rejects.toThrow(NotFoundError); + }); +}); + +describe('ReferralService.flagReferralAsFraud', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('flags a referral as fraud and cancels pending commissions', async () => { + const code = seedCode(prisma, { userId: 'user-1', code: 'FRAUDME1' }); + const referral = seedReferral(prisma, { referralCodeId: code.id, refereeId: 'bad-user' }); + prisma.commissions.push({ + id: cuid(), referralId: referral.id, tipId: 'tip-1', + referrerId: 'user-1', amount: 10, commissionRate: 0.05, currency: 'XLM', + status: 'pending', paidAt: null, failReason: null, + createdAt: makeDate(), updatedAt: makeDate(), + }); + + await service.flagReferralAsFraud(referral.id, 'Bot accounts detected'); + + expect(prisma.referrals[0].status).toBe('fraud'); + expect(prisma.commissions[0].status).toBe('failed'); + expect(prisma.commissions[0].failReason).toBe('Bot accounts detected'); + }); + + it('throws NotFoundError for unknown referral id', async () => { + await expect(service.flagReferralAsFraud('nonexistent', 'reason')).rejects.toThrow(NotFoundError); + }); +}); + +describe('ReferralService tier CRUD', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('creates a tier', async () => { + const tier = await service.createTier({ name: 'silver', commissionRate: 0.07 }); + expect(tier.name).toBe('silver'); + expect(tier.commissionRate).toBe(0.07); + }); + + it('rejects commissionRate outside [0,1]', async () => { + await expect(service.createTier({ name: 'bad', commissionRate: 1.5 })).rejects.toThrow(ValidationError); + await expect(service.createTier({ name: 'neg', commissionRate: -0.1 })).rejects.toThrow(ValidationError); + }); + + it('rejects duplicate tier names', async () => { + await service.createTier({ name: 'silver', commissionRate: 0.07 }); + await expect(service.createTier({ name: 'silver', commissionRate: 0.08 })).rejects.toThrow(ConflictError); + }); + + it('unsets previous default when creating a new default', async () => { + const t1 = await service.createTier({ name: 'standard', commissionRate: 0.05, isDefault: true }); + expect(t1.isDefault).toBe(true); + const t2 = await service.createTier({ name: 'gold', commissionRate: 0.1, isDefault: true }); + expect(t2.isDefault).toBe(true); + // updateMany was called to unset the old default + expect(prisma.referralTier.updateMany).toHaveBeenCalled(); + }); + + it('lists all tiers', async () => { + await service.createTier({ name: 'a', commissionRate: 0.03 }); + await service.createTier({ name: 'b', commissionRate: 0.07 }); + const tiers = await service.listTiers(); + expect(tiers).toHaveLength(2); + }); + + it('updates a tier', async () => { + const t = await service.createTier({ name: 'orig', commissionRate: 0.05 }); + const updated = await service.updateTier(t.id, { commissionRate: 0.08 }); + expect(updated.commissionRate).toBe(0.08); + }); + + it('throws NotFoundError when updating unknown tier', async () => { + await expect(service.updateTier('nonexistent', { commissionRate: 0.05 })).rejects.toThrow(NotFoundError); + }); + + it('deletes a non-default tier', async () => { + await service.createTier({ name: 'default', commissionRate: 0.05, isDefault: true }); + const t2 = await service.createTier({ name: 'deletable', commissionRate: 0.03 }); + await expect(service.deleteTier(t2.id)).resolves.toBeUndefined(); + expect(prisma.tiers.find((t) => t.id === t2.id)).toBeUndefined(); + }); + + it('prevents deleting the default tier', async () => { + const t = await service.createTier({ name: 'def', commissionRate: 0.05, isDefault: true }); + await expect(service.deleteTier(t.id)).rejects.toThrow(ValidationError); + }); + + it('throws NotFoundError when deleting unknown tier', async () => { + await expect(service.deleteTier('nonexistent')).rejects.toThrow(NotFoundError); + }); +}); + +describe('ReferralService.assignTierToCode', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('assigns a tier to a code', async () => { + const code = seedCode(prisma, { userId: 'user-1', code: 'ASSIGN12' }); + const tier: MockReferralTier = { + id: 'tier-x', name: 'tier-x', description: null, + commissionRate: 0.08, isDefault: false, createdAt: makeDate(), updatedAt: makeDate(), + }; + prisma.tiers.push(tier); + + const result = await service.assignTierToCode(code.id, tier.id); + expect(result.tierId).toBe(tier.id); + }); + + it('throws NotFoundError if code not found', async () => { + const tier: MockReferralTier = { + id: 'tier-x', name: 'tier-x', description: null, + commissionRate: 0.08, isDefault: false, createdAt: makeDate(), updatedAt: makeDate(), + }; + prisma.tiers.push(tier); + await expect(service.assignTierToCode('bad-id', tier.id)).rejects.toThrow(NotFoundError); + }); + + it('throws NotFoundError if tier not found', async () => { + const code = seedCode(prisma, { userId: 'user-1', code: 'ASSIGN12' }); + await expect(service.assignTierToCode(code.id, 'bad-tier')).rejects.toThrow(NotFoundError); + }); +}); + +describe('ReferralService.getAffiliateDashboard', () => { + let prisma: FakePrisma; + let service: ReferralService; + + beforeEach(() => { + prisma = new FakePrisma(); + service = buildService(prisma); + }); + + it('returns empty dashboard when user has no referral code', async () => { + const dash = await service.getAffiliateDashboard('user-no-code'); + expect(dash.referralCode).toBeNull(); + expect(dash.stats.totalReferrals).toBe(0); + expect(dash.stats.totalCommissionsEarned).toBe(0); + expect(dash.recentReferrals).toHaveLength(0); + expect(dash.recentCommissions).toHaveLength(0); + }); + + it('aggregates stats correctly', async () => { + const code = seedCode(prisma, { userId: 'referrer-1', code: 'DASH1234' }); + seedReferral(prisma, { referralCodeId: code.id, refereeId: 'ref-a', status: 'converted' }); + seedReferral(prisma, { referralCodeId: code.id, refereeId: 'ref-b', status: 'pending' }); + seedReferral(prisma, { referralCodeId: code.id, refereeId: 'ref-c', status: 'fraud' }); + + const dash = await service.getAffiliateDashboard('referrer-1'); + + expect(dash.referralCode?.code).toBe('DASH1234'); + expect(dash.stats.totalReferrals).toBe(3); + expect(dash.stats.convertedReferrals).toBe(1); + expect(dash.stats.pendingReferrals).toBe(1); + expect(dash.stats.fraudReferrals).toBe(1); + expect(dash.recentReferrals).toHaveLength(3); + }); + + it('includes commission stats when commissions exist', async () => { + const code = seedCode(prisma, { userId: 'referrer-2', code: 'COMM1234' }); + const referral = seedReferral(prisma, { referralCodeId: code.id, refereeId: 'ref-d', status: 'converted' }); + prisma.commissions.push({ + id: cuid(), referralId: referral.id, tipId: 'tip-1', + referrerId: 'referrer-2', amount: 15, commissionRate: 0.05, currency: 'XLM', + status: 'pending', paidAt: null, failReason: null, + createdAt: makeDate(), updatedAt: makeDate(), + }); + + const dash = await service.getAffiliateDashboard('referrer-2'); + expect(dash.stats.pendingCommissions).toBeCloseTo(15); + expect(dash.stats.totalCommissionsEarned).toBeCloseTo(15); + expect(dash.recentCommissions).toHaveLength(1); + }); +}); diff --git a/src/domains/referrals/referral.routes.ts b/src/domains/referrals/referral.routes.ts new file mode 100644 index 0000000..b31e239 --- /dev/null +++ b/src/domains/referrals/referral.routes.ts @@ -0,0 +1,437 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { PrismaClient } from '@prisma/client'; +import { ReferralService } from './referral.service'; +import { formatSuccess, formatError } from '../../types/response'; +import { authMiddleware } from '../../middleware/auth'; +import { requireAdmin } from '../../middleware/rbac'; +import { AppError, ValidationError } from '../../utils/errors'; +import { CreateTierRequest, UpdateTierRequest } from './referral.types'; + +export const registerReferralRoutes = (app: FastifyInstance, prisma: PrismaClient): void => { + const referralService = new ReferralService(prisma); + + // ── Affiliate: Get or create your referral code ────────────────────────── + // GET /api/v1/referrals/my-code + app.get( + '/api/v1/referrals/my-code', + { + preHandler: authMiddleware, + schema: { + description: 'Get (or lazily create) the authenticated user\'s referral code', + response: { + 200: { description: 'Referral code' }, + 401: { description: 'Unauthorized' }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + try { + const user = request.user!; + const code = await referralService.getOrCreateReferralCode(user.userId); + reply.send(formatSuccess(code)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Affiliate: Dashboard ────────────────────────────────────────────────── + // GET /api/v1/referrals/dashboard + app.get( + '/api/v1/referrals/dashboard', + { + preHandler: authMiddleware, + schema: { + description: 'Get the affiliate dashboard for the authenticated user', + response: { + 200: { description: 'Dashboard stats, recent referrals and commissions' }, + 401: { description: 'Unauthorized' }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + try { + const user = request.user!; + const dashboard = await referralService.getAffiliateDashboard(user.userId); + reply.send(formatSuccess(dashboard)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Affiliate: Request commission payout ────────────────────────────────── + // POST /api/v1/referrals/payout + app.post( + '/api/v1/referrals/payout', + { + preHandler: authMiddleware, + schema: { + description: 'Mark all pending commissions as paid', + response: { + 200: { description: 'Payout result' }, + 401: { description: 'Unauthorized' }, + }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + try { + const user = request.user!; + const result = await referralService.payoutCommissions(user.userId); + reply.send(formatSuccess(result)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Public: Look up a referral code (used on the sign-up form) ──────────── + // GET /api/v1/referrals/code/:code + app.get<{ Params: { code: string } }>( + '/api/v1/referrals/code/:code', + { + schema: { + description: 'Validate a referral code (public; used during sign-up)', + params: { + type: 'object', + properties: { code: { type: 'string' } }, + required: ['code'], + }, + response: { + 200: { description: 'Referral code metadata' }, + 404: { description: 'Code not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { code: string } }>, + reply: FastifyReply + ) => { + try { + const { code } = request.params; + const record = await referralService.getReferralCodeByValue(code.toUpperCase()); + // Return limited info to the public + reply.send( + formatSuccess({ + code: record.code, + isActive: record.isActive, + }) + ); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: List all tiers ───────────────────────────────────────────────── + // GET /api/v1/admin/referrals/tiers + app.get( + '/api/v1/admin/referrals/tiers', + { + preHandler: requireAdmin, + schema: { + description: 'List all referral commission tiers (admin)', + response: { + 200: { description: 'Tier list' }, + 401: { description: 'Unauthorized' }, + 403: { description: 'Admin only' }, + }, + }, + }, + async (_request: FastifyRequest, reply: FastifyReply) => { + try { + const tiers = await referralService.listTiers(); + reply.send(formatSuccess(tiers)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Create tier ──────────────────────────────────────────────────── + // POST /api/v1/admin/referrals/tiers + app.post<{ Body: CreateTierRequest }>( + '/api/v1/admin/referrals/tiers', + { + preHandler: requireAdmin, + schema: { + description: 'Create a new referral commission tier (admin)', + body: { + type: 'object', + required: ['name', 'commissionRate'], + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + commissionRate: { type: 'number', minimum: 0, maximum: 1 }, + isDefault: { type: 'boolean' }, + }, + }, + response: { + 201: { description: 'Created tier' }, + 400: { description: 'Validation error' }, + 409: { description: 'Name conflict' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Body: CreateTierRequest }>, + reply: FastifyReply + ) => { + try { + const tier = await referralService.createTier(request.body); + reply.code(201).send(formatSuccess(tier)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Update tier ──────────────────────────────────────────────────── + // PATCH /api/v1/admin/referrals/tiers/:tierId + app.patch<{ Params: { tierId: string }; Body: UpdateTierRequest }>( + '/api/v1/admin/referrals/tiers/:tierId', + { + preHandler: requireAdmin, + schema: { + description: 'Update a referral commission tier (admin)', + params: { + type: 'object', + properties: { tierId: { type: 'string' } }, + required: ['tierId'], + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + commissionRate: { type: 'number', minimum: 0, maximum: 1 }, + isDefault: { type: 'boolean' }, + }, + }, + response: { + 200: { description: 'Updated tier' }, + 404: { description: 'Tier not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { tierId: string }; Body: UpdateTierRequest }>, + reply: FastifyReply + ) => { + try { + const { tierId } = request.params; + const tier = await referralService.updateTier(tierId, request.body); + reply.send(formatSuccess(tier)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Delete tier ──────────────────────────────────────────────────── + // DELETE /api/v1/admin/referrals/tiers/:tierId + app.delete<{ Params: { tierId: string } }>( + '/api/v1/admin/referrals/tiers/:tierId', + { + preHandler: requireAdmin, + schema: { + description: 'Delete a referral commission tier (admin)', + params: { + type: 'object', + properties: { tierId: { type: 'string' } }, + required: ['tierId'], + }, + response: { + 200: { description: 'Deleted' }, + 400: { description: 'Cannot delete default tier' }, + 404: { description: 'Tier not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { tierId: string } }>, + reply: FastifyReply + ) => { + try { + const { tierId } = request.params; + await referralService.deleteTier(tierId); + reply.send(formatSuccess({ deleted: true })); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Lock a referral code ─────────────────────────────────────────── + // POST /api/v1/admin/referrals/codes/:codeId/lock + app.post<{ Params: { codeId: string }; Body: { reason: string } }>( + '/api/v1/admin/referrals/codes/:codeId/lock', + { + preHandler: requireAdmin, + schema: { + description: 'Lock a referral code (admin fraud control)', + params: { + type: 'object', + properties: { codeId: { type: 'string' } }, + required: ['codeId'], + }, + body: { + type: 'object', + required: ['reason'], + properties: { + reason: { type: 'string' }, + }, + }, + response: { + 200: { description: 'Locked code' }, + 404: { description: 'Code not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { codeId: string }; Body: { reason: string } }>, + reply: FastifyReply + ) => { + try { + const { codeId } = request.params; + const { reason } = request.body; + if (!reason?.trim()) { + throw new ValidationError('Lock reason is required'); + } + const result = await referralService.lockReferralCode(codeId, reason); + reply.send(formatSuccess(result)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Flag a referral as fraud ────────────────────────────────────── + // POST /api/v1/admin/referrals/:referralId/flag-fraud + app.post<{ Params: { referralId: string }; Body: { reason: string } }>( + '/api/v1/admin/referrals/:referralId/flag-fraud', + { + preHandler: requireAdmin, + schema: { + description: 'Flag a referral as fraudulent (admin)', + params: { + type: 'object', + properties: { referralId: { type: 'string' } }, + required: ['referralId'], + }, + body: { + type: 'object', + required: ['reason'], + properties: { + reason: { type: 'string' }, + }, + }, + response: { + 200: { description: 'Fraud flagged' }, + 404: { description: 'Referral not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { referralId: string }; Body: { reason: string } }>, + reply: FastifyReply + ) => { + try { + const { referralId } = request.params; + const { reason } = request.body; + if (!reason?.trim()) { + throw new ValidationError('Fraud reason is required'); + } + await referralService.flagReferralAsFraud(referralId, reason); + reply.send(formatSuccess({ flagged: true })); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); + + // ── Admin: Assign tier to a referral code ───────────────────────────────── + // POST /api/v1/admin/referrals/codes/:codeId/tier + app.post<{ Params: { codeId: string }; Body: { tierId: string } }>( + '/api/v1/admin/referrals/codes/:codeId/tier', + { + preHandler: requireAdmin, + schema: { + description: 'Assign a commission tier to a referral code (admin)', + params: { + type: 'object', + properties: { codeId: { type: 'string' } }, + required: ['codeId'], + }, + body: { + type: 'object', + required: ['tierId'], + properties: { + tierId: { type: 'string' }, + }, + }, + response: { + 200: { description: 'Updated referral code' }, + 404: { description: 'Code or tier not found' }, + }, + }, + }, + async ( + request: FastifyRequest<{ Params: { codeId: string }; Body: { tierId: string } }>, + reply: FastifyReply + ) => { + try { + const { codeId } = request.params; + const { tierId } = request.body; + const result = await referralService.assignTierToCode(codeId, tierId); + reply.send(formatSuccess(result)); + } catch (error) { + if (error instanceof AppError) { + reply.code(error.statusCode).send(formatError(error.message, error.code)); + } else { + throw error; + } + } + } + ); +}; diff --git a/src/domains/referrals/referral.service.ts b/src/domains/referrals/referral.service.ts new file mode 100644 index 0000000..ae05d40 --- /dev/null +++ b/src/domains/referrals/referral.service.ts @@ -0,0 +1,620 @@ +import { PrismaClient } from '@prisma/client'; +import { randomBytes } from 'crypto'; +import { BaseService } from '../../services/base.service'; +import { + ReferralCodeResponse, + ReferralTierResponse, + AffiliateDashboardResponse, + ProcessCommissionRequest, + CreateTierRequest, + UpdateTierRequest, +} from './referral.types'; +import { + ValidationError, + NotFoundError, + ConflictError, +} from '../../utils/errors'; +import { logger } from '../../utils/logger'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Maximum referral chain depth to prevent pyramid-scheme patterns. */ +const MAX_REFERRAL_DEPTH = 1; + +/** Default commission rate applied when no tier is configured (5%). */ +const DEFAULT_COMMISSION_RATE = 0.05; + +/** A referral code is considered fraudulent if the same IP creates more than + * this many accounts within FRAUD_WINDOW_MS. (Structural detection; IP + * tracking uses the existing request context and is enforced in the route.) */ +const MAX_REFERRALS_PER_CODE_PER_DAY = 20; +const FRAUD_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Generate a human-friendly, URL-safe referral code. + * Format: 8 uppercase alphanumeric characters (no O/0/I/l to avoid confusion). + */ +function generateCode(): string { + const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + const bytes = randomBytes(8); + return Array.from(bytes) + .map((b) => alphabet[b % alphabet.length]) + .join(''); +} + +function formatCode(c: { id: string; code: string; userId: string; tierId: string | null; isActive: boolean; usageCount: number; lockedAt: Date | null; lockReason: string | null; createdAt: Date; updatedAt: Date }): ReferralCodeResponse { + return { + id: c.id, + code: c.code, + userId: c.userId, + tierId: c.tierId, + isActive: c.isActive, + usageCount: c.usageCount, + lockedAt: c.lockedAt?.toISOString() ?? null, + lockReason: c.lockReason, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + }; +} + +function formatTier(t: { id: string; name: string; description: string | null; commissionRate: number; isDefault: boolean; createdAt: Date; updatedAt: Date }): ReferralTierResponse { + return { + id: t.id, + name: t.name, + description: t.description, + commissionRate: t.commissionRate, + isDefault: t.isDefault, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + }; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class ReferralService extends BaseService { + constructor(private prisma: PrismaClient) { + super(); + } + + // ── Code Management ──────────────────────────────────────────────────────── + + /** + * Get (or lazily create) the caller's referral code. + * A user gets exactly one code; subsequent calls return the existing one. + */ + async getOrCreateReferralCode(userId: string): Promise { + return this.executeWithLogging('referral.getOrCreateCode', async () => { + const existing = await this.prisma.referralCode.findUnique({ + where: { userId }, + }); + + if (existing) { + return formatCode(existing); + } + + // Assign the default tier if one is configured + const defaultTier = await this.prisma.referralTier.findFirst({ + where: { isDefault: true }, + }); + + // Collision-resistant retry loop (extremely unlikely but safe) + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateCode(); + try { + const created = await this.prisma.referralCode.create({ + data: { + userId, + code, + tierId: defaultTier?.id ?? null, + }, + }); + return formatCode(created); + } catch (err: unknown) { + const isPrismaUniqueViolation = + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'P2002'; + if (!isPrismaUniqueViolation) throw err; + // code collision - try again + } + } + throw new Error('Failed to generate unique referral code after 5 attempts'); + }); + } + + /** + * Get a referral code by its string value (public lookup, used at sign-up). + */ + async getReferralCodeByValue(code: string): Promise { + return this.executeWithLogging('referral.getCodeByValue', async () => { + const record = await this.prisma.referralCode.findUnique({ + where: { code }, + }); + if (!record) throw new NotFoundError('Referral code'); + return formatCode(record); + }); + } + + // ── Referral Registration ────────────────────────────────────────────────── + + /** + * Record that a new user (`refereeId`) registered via `code`. + * + * Fraud guards: + * 1. A user cannot refer themselves. + * 2. A user can only be referred once. + * 3. Locked / inactive codes are rejected. + * 4. Max-depth check prevents pyramid chains. + * 5. Velocity check: if a code has been used more than + * MAX_REFERRALS_PER_CODE_PER_DAY times in the last 24 h the code is + * automatically locked and the registration is rejected. + */ + async registerReferral(code: string, refereeId: string): Promise { + return this.executeWithLogging('referral.register', async () => { + const referralCode = await this.prisma.referralCode.findUnique({ + where: { code }, + }); + + if (!referralCode) { + // Silently skip – unknown codes don't block registration + logger.warn(`Referral code not found: ${code}`); + return; + } + + // Guard: code must be active + if (!referralCode.isActive || referralCode.lockedAt) { + throw new ValidationError('Referral code is no longer active'); + } + + // Guard: self-referral + if (referralCode.userId === refereeId) { + throw new ValidationError('You cannot use your own referral code'); + } + + // Guard: already referred + const alreadyReferred = await this.prisma.referral.findUnique({ + where: { refereeId }, + }); + if (alreadyReferred) { + throw new ConflictError('User has already been referred'); + } + + // Guard: depth check (prevent multi-tier pyramids) + const referrerReferral = await this.prisma.referral.findUnique({ + where: { refereeId: referralCode.userId }, + }); + const depth = referrerReferral ? referrerReferral.depth + 1 : 1; + if (depth > MAX_REFERRAL_DEPTH) { + logger.warn(`Referral depth ${depth} exceeds max ${MAX_REFERRAL_DEPTH}`, { + refereeId, + referrerId: referralCode.userId, + }); + // Still allow the registration but don't create a referral record + // so no commission flows through multi-tier chains + return; + } + + // Fraud: velocity check + const windowStart = new Date(Date.now() - FRAUD_WINDOW_MS); + const recentCount = await this.prisma.referral.count({ + where: { + referralCodeId: referralCode.id, + createdAt: { gte: windowStart }, + }, + }); + + if (recentCount >= MAX_REFERRALS_PER_CODE_PER_DAY) { + // Lock the code and reject + await this.prisma.referralCode.update({ + where: { id: referralCode.id }, + data: { + lockedAt: new Date(), + lockReason: `Velocity fraud: ${recentCount} registrations in 24h`, + isActive: false, + }, + }); + logger.warn(`Referral code locked due to velocity abuse: ${code}`, { + recentCount, + referralCodeId: referralCode.id, + }); + throw new ValidationError('Referral code has been suspended due to suspicious activity'); + } + + // Create the referral + increment usage atomically + await this.prisma.$transaction([ + this.prisma.referral.create({ + data: { + referralCodeId: referralCode.id, + refereeId, + depth, + status: 'pending', + }, + }), + this.prisma.referralCode.update({ + where: { id: referralCode.id }, + data: { usageCount: { increment: 1 } }, + }), + ]); + + logger.info(`Referral registered`, { + refereeId, + referrerId: referralCode.userId, + code, + }); + }); + } + + // ── Commission Processing ────────────────────────────────────────────────── + + /** + * Called when a tip is confirmed. Looks up whether the tip sender (`refereeUserId`) + * was referred, and if so calculates and records a commission for their referrer. + * + * This is idempotent: the unique constraint on `tipId` prevents double-counting. + */ + async processCommissionForTip(req: ProcessCommissionRequest): Promise { + return this.executeWithLogging('referral.processCommission', async () => { + const { tipId, tipAmount, refereeUserId } = req; + + // Is there a referral for this user? + const referral = await this.prisma.referral.findUnique({ + where: { refereeId: refereeUserId }, + include: { referralCode: { include: { tier: true } } }, + }); + + if (!referral) return; + + // Only process for active referrals (not fraud-flagged) + if (referral.status === 'fraud') { + logger.warn(`Skipping commission for fraud-flagged referral`, { referralId: referral.id }); + return; + } + + // Idempotency: check if commission already exists for this tip + const existing = await this.prisma.referralCommission.findUnique({ + where: { tipId }, + }); + if (existing) return; + + // Determine commission rate from tier + const commissionRate = + referral.referralCode.tier?.commissionRate ?? DEFAULT_COMMISSION_RATE; + const commissionAmount = Math.round(tipAmount * commissionRate * 1e8) / 1e8; + + const referrerId = referral.referralCode.userId; + + await this.prisma.$transaction([ + // Record the commission + this.prisma.referralCommission.create({ + data: { + referralId: referral.id, + tipId, + referrerId, + amount: commissionAmount, + commissionRate, + status: 'pending', + }, + }), + // Mark referral as converted (first tip confirms conversion) + ...(referral.status === 'pending' + ? [ + this.prisma.referral.update({ + where: { id: referral.id }, + data: { status: 'converted', convertedAt: new Date() }, + }), + ] + : []), + ]); + + logger.info(`Commission recorded`, { + tipId, + referrerId, + refereeId: refereeUserId, + amount: commissionAmount, + rate: commissionRate, + }); + }); + } + + /** + * Mark pending commissions as paid (called by payout flow or admin action). + * Returns the total amount paid. + */ + async payoutCommissions(referrerId: string): Promise<{ count: number; totalAmount: number }> { + return this.executeWithLogging('referral.payoutCommissions', async () => { + const pending = await this.prisma.referralCommission.findMany({ + where: { referrerId, status: 'pending' }, + }); + + if (pending.length === 0) { + return { count: 0, totalAmount: 0 }; + } + + const ids = pending.map((c) => c.id); + const totalAmount = pending.reduce((sum, c) => sum + c.amount, 0); + + await this.prisma.referralCommission.updateMany({ + where: { id: { in: ids } }, + data: { status: 'paid', paidAt: new Date() }, + }); + + // Update referrals to "rewarded" state + const referralIds = [...new Set(pending.map((c) => c.referralId))]; + await this.prisma.referral.updateMany({ + where: { id: { in: referralIds } }, + data: { status: 'rewarded' }, + }); + + logger.info(`Commission payout completed`, { referrerId, count: ids.length, totalAmount }); + + return { count: ids.length, totalAmount }; + }); + } + + // ── Fraud Controls ──────────────────────────────────────────────────────── + + /** + * Admin: lock a referral code with a reason. + */ + async lockReferralCode(codeId: string, reason: string): Promise { + return this.executeWithLogging('referral.lockCode', async () => { + const code = await this.prisma.referralCode.findUnique({ where: { id: codeId } }); + if (!code) throw new NotFoundError('Referral code'); + + const updated = await this.prisma.referralCode.update({ + where: { id: codeId }, + data: { isActive: false, lockedAt: new Date(), lockReason: reason }, + }); + return formatCode(updated); + }); + } + + /** + * Admin: flag a referral as fraud. + */ + async flagReferralAsFraud(referralId: string, reason: string): Promise { + return this.executeWithLogging('referral.flagFraud', async () => { + const referral = await this.prisma.referral.findUnique({ where: { id: referralId } }); + if (!referral) throw new NotFoundError('Referral'); + + await this.prisma.$transaction([ + this.prisma.referral.update({ + where: { id: referralId }, + data: { status: 'fraud' }, + }), + // Cancel any pending commissions for this referral + this.prisma.referralCommission.updateMany({ + where: { referralId, status: 'pending' }, + data: { status: 'failed', failReason: reason }, + }), + ]); + + logger.warn(`Referral flagged as fraud`, { referralId, reason }); + }); + } + + // ── Dashboard ───────────────────────────────────────────────────────────── + + /** + * Aggregate affiliate dashboard for a user. + */ + async getAffiliateDashboard(userId: string): Promise { + return this.executeWithLogging('referral.dashboard', async () => { + const codeRecord = await this.prisma.referralCode.findUnique({ + where: { userId }, + }); + + if (!codeRecord) { + return { + referralCode: null, + stats: { + totalReferrals: 0, + convertedReferrals: 0, + pendingReferrals: 0, + fraudReferrals: 0, + totalCommissionsEarned: 0, + pendingCommissions: 0, + paidCommissions: 0, + }, + recentReferrals: [], + recentCommissions: [], + }; + } + + const [referrals, commissionStats, recentCommissions] = await Promise.all([ + this.prisma.referral.findMany({ + where: { referralCodeId: codeRecord.id }, + orderBy: { createdAt: 'desc' }, + take: 20, + }), + this.prisma.referralCommission.groupBy({ + by: ['status'], + where: { referrerId: userId }, + _sum: { amount: true }, + _count: { id: true }, + }), + this.prisma.referralCommission.findMany({ + where: { referrerId: userId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + ]); + + // Build per-referral commission totals for display + const referralIds = referrals.map((r) => r.id); + const perReferralCommissions = referralIds.length > 0 + ? await this.prisma.referralCommission.groupBy({ + by: ['referralId'], + where: { referralId: { in: referralIds } }, + _sum: { amount: true }, + }) + : []; + + const commissionByReferral = Object.fromEntries( + perReferralCommissions.map((c) => [c.referralId, c._sum.amount ?? 0]) + ); + + // Aggregate stats + let totalCommissionsEarned = 0; + let pendingCommissions = 0; + let paidCommissions = 0; + + for (const row of commissionStats) { + const sum = row._sum.amount ?? 0; + totalCommissionsEarned += sum; + if (row.status === 'pending') pendingCommissions += sum; + if (row.status === 'paid') paidCommissions += sum; + } + + const totalReferrals = referrals.length; + const convertedReferrals = referrals.filter((r) => r.status === 'converted' || r.status === 'rewarded').length; + const pendingReferrals = referrals.filter((r) => r.status === 'pending').length; + const fraudReferrals = referrals.filter((r) => r.status === 'fraud').length; + + return { + referralCode: formatCode(codeRecord), + stats: { + totalReferrals, + convertedReferrals, + pendingReferrals, + fraudReferrals, + totalCommissionsEarned: Math.round(totalCommissionsEarned * 1e8) / 1e8, + pendingCommissions: Math.round(pendingCommissions * 1e8) / 1e8, + paidCommissions: Math.round(paidCommissions * 1e8) / 1e8, + }, + recentReferrals: referrals.map((r) => ({ + id: r.id, + refereeId: r.refereeId, + status: r.status, + convertedAt: r.convertedAt?.toISOString() ?? null, + commissionsEarned: commissionByReferral[r.id] ?? 0, + createdAt: r.createdAt.toISOString(), + })), + recentCommissions: recentCommissions.map((c) => ({ + id: c.id, + referralId: c.referralId, + tipId: c.tipId, + referrerId: c.referrerId, + amount: c.amount, + commissionRate: c.commissionRate, + currency: c.currency, + status: c.status, + paidAt: c.paidAt?.toISOString() ?? null, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + })), + }; + }); + } + + // ── Admin: Tier Management ──────────────────────────────────────────────── + + async listTiers(): Promise { + return this.executeWithLogging('referral.listTiers', async () => { + const tiers = await this.prisma.referralTier.findMany({ + orderBy: { commissionRate: 'asc' }, + }); + return tiers.map(formatTier); + }); + } + + async createTier(data: CreateTierRequest): Promise { + return this.executeWithLogging('referral.createTier', async () => { + if (data.commissionRate < 0 || data.commissionRate > 1) { + throw new ValidationError('Commission rate must be between 0 and 1 (e.g. 0.05 for 5%)'); + } + + // If marking as default, unset any existing default first + if (data.isDefault) { + await this.prisma.referralTier.updateMany({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + } + + try { + const tier = await this.prisma.referralTier.create({ + data: { + name: data.name, + description: data.description ?? null, + commissionRate: data.commissionRate, + isDefault: data.isDefault ?? false, + }, + }); + return formatTier(tier); + } catch (err: unknown) { + const isPrismaUniqueViolation = + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'P2002'; + if (isPrismaUniqueViolation) { + throw new ConflictError(`Tier with name "${data.name}" already exists`); + } + throw err; + } + }); + } + + async updateTier(tierId: string, data: UpdateTierRequest): Promise { + return this.executeWithLogging('referral.updateTier', async () => { + const existing = await this.prisma.referralTier.findUnique({ where: { id: tierId } }); + if (!existing) throw new NotFoundError('Referral tier'); + + if (data.commissionRate !== undefined && (data.commissionRate < 0 || data.commissionRate > 1)) { + throw new ValidationError('Commission rate must be between 0 and 1 (e.g. 0.05 for 5%)'); + } + + if (data.isDefault) { + await this.prisma.referralTier.updateMany({ + where: { isDefault: true, id: { not: tierId } }, + data: { isDefault: false }, + }); + } + + const updated = await this.prisma.referralTier.update({ + where: { id: tierId }, + data: { + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description }), + ...(data.commissionRate !== undefined && { commissionRate: data.commissionRate }), + ...(data.isDefault !== undefined && { isDefault: data.isDefault }), + }, + }); + + return formatTier(updated); + }); + } + + async deleteTier(tierId: string): Promise { + return this.executeWithLogging('referral.deleteTier', async () => { + const existing = await this.prisma.referralTier.findUnique({ where: { id: tierId } }); + if (!existing) throw new NotFoundError('Referral tier'); + if (existing.isDefault) { + throw new ValidationError('Cannot delete the default tier; set another tier as default first'); + } + await this.prisma.referralTier.delete({ where: { id: tierId } }); + }); + } + + /** + * Assign a tier to a referral code. + */ + async assignTierToCode(codeId: string, tierId: string): Promise { + return this.executeWithLogging('referral.assignTier', async () => { + const [code, tier] = await Promise.all([ + this.prisma.referralCode.findUnique({ where: { id: codeId } }), + this.prisma.referralTier.findUnique({ where: { id: tierId } }), + ]); + if (!code) throw new NotFoundError('Referral code'); + if (!tier) throw new NotFoundError('Referral tier'); + + const updated = await this.prisma.referralCode.update({ + where: { id: codeId }, + data: { tierId }, + }); + return formatCode(updated); + }); + } +} diff --git a/src/domains/referrals/referral.types.ts b/src/domains/referrals/referral.types.ts new file mode 100644 index 0000000..ce2576e --- /dev/null +++ b/src/domains/referrals/referral.types.ts @@ -0,0 +1,104 @@ +// ─── Referral Domain Types ──────────────────────────────────────────────────── + +export interface ReferralTierResponse { + id: string; + name: string; + description: string | null; + commissionRate: number; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ReferralCodeResponse { + id: string; + code: string; + userId: string; + tierId: string | null; + isActive: boolean; + usageCount: number; + lockedAt: string | null; + lockReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ReferralResponse { + id: string; + referralCodeId: string; + refereeId: string; + depth: number; + status: string; + convertedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ReferralCommissionResponse { + id: string; + referralId: string; + tipId: string; + referrerId: string; + amount: number; + commissionRate: number; + currency: string; + status: string; + paidAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AffiliateDashboardResponse { + referralCode: ReferralCodeResponse | null; + stats: { + totalReferrals: number; + convertedReferrals: number; + pendingReferrals: number; + fraudReferrals: number; + totalCommissionsEarned: number; + pendingCommissions: number; + paidCommissions: number; + }; + recentReferrals: Array<{ + id: string; + refereeId: string; + status: string; + convertedAt: string | null; + commissionsEarned: number; + createdAt: string; + }>; + recentCommissions: ReferralCommissionResponse[]; +} + +export interface CreateReferralCodeRequest { + // No extra params needed; userId comes from auth context +} + +export interface RegisterWithReferralRequest { + code: string; + refereeId: string; +} + +export interface ProcessCommissionRequest { + tipId: string; + tipAmount: number; + refereeUserId: string; +} + +export interface CreateTierRequest { + name: string; + description?: string; + commissionRate: number; + isDefault?: boolean; +} + +export interface UpdateTierRequest { + name?: string; + description?: string; + commissionRate?: number; + isDefault?: boolean; +} + +export interface LockCodeRequest { + reason: string; +} diff --git a/src/index.ts b/src/index.ts index 9e4ccd9..dc133f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { registerWebhookRoutes } from './domains/webhooks/webhook.routes'; import { registerAnalyticsRoutes } from './domains/analytics/analytics.routes'; import { registerAdminRoutes } from './domains/admin/admin.routes'; import { registerNotificationRoutes } from './domains/notifications/notification.routes'; +import { registerReferralRoutes } from './domains/referrals/referral.routes'; import { registerMetricsRoute } from './routes/metrics.routes'; import { closeQueues } from './lib/queue'; import redisPool from './lib/redisPool'; @@ -240,6 +241,7 @@ const bootstrap = async (): Promise => { registerWebhookRoutes(app, prisma); registerAnalyticsRoutes(app, prisma); registerAdminRoutes(app, prisma); + registerReferralRoutes(app, prisma); registerMetricsRoute(app, prisma); registerQueryPerformanceRoutes(app); registerJobRoutes(app);