diff --git a/src/growth/referral.ts b/src/growth/referral.ts new file mode 100644 index 0000000..93a565b --- /dev/null +++ b/src/growth/referral.ts @@ -0,0 +1,89 @@ +/** + * Referral Reward Loop — Issue #2 + * Awards credits to referrers upon successful referral conversions + */ + +export interface ReferralCode { + id: string; + code: string; + ownerId: string; + uses: number; + creditsAwarded: number; + createdAt: Date; +} + +export interface ReferralConversionResult { + status: 'ok' | 'already_processed' | 'invalid_code'; + creditsAwarded?: number; + ownerId?: string; +} + +export interface MockDatabase { + referralCodes: Map; + conversions: Set; + systemEvents: Array<{ type: string; payload: Record; timestamp: Date }>; +} + +export function createMockDatabase(): MockDatabase { + return { + referralCodes: new Map(), + conversions: new Set(), + systemEvents: [] + }; +} + +export function generateReferralCode(ownerId: string, customCode?: string, db?: MockDatabase): string { + const code = customCode || `ref_${ownerId.slice(0, 4)}_${Math.random().toString(36).substring(2, 6)}`; + if (db) { + db.referralCodes.set(code, { ownerId, uses: 0, creditsAwarded: 0 }); + } + return code; +} + +export function processReferral( + code: string, + newUserId: string, + db: MockDatabase, + creditsPerConversion = 5 +): ReferralConversionResult { + const conversionKey = `${code}:${newUserId}`; + + // 1. Check idempotency + if (db.conversions.has(conversionKey)) { + return { status: 'already_processed' }; + } + + // 2. Validate referral code + const referralEntry = db.referralCodes.get(code); + if (!referralEntry) { + return { status: 'invalid_code' }; + } + + // 3. Prevent self-referral + if (referralEntry.ownerId === newUserId) { + return { status: 'already_processed' }; + } + + // 4. Record conversion + db.conversions.add(conversionKey); + referralEntry.uses += 1; + referralEntry.creditsAwarded += creditsPerConversion; + + // 5. Emit system event + db.systemEvents.push({ + type: 'referral_conversion', + payload: { + code, + newUserId, + ownerId: referralEntry.ownerId, + creditsAwarded: creditsPerConversion + }, + timestamp: new Date() + }); + + return { + status: 'ok', + creditsAwarded: creditsPerConversion, + ownerId: referralEntry.ownerId + }; +} diff --git a/tests/referral.test.ts b/tests/referral.test.ts new file mode 100644 index 0000000..0a1bd8c --- /dev/null +++ b/tests/referral.test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { + createMockDatabase, + generateReferralCode, + processReferral +} from '../src/growth/referral.ts'; + +Deno.test('Referral: successful conversion awards 5 credits', () => { + const db = createMockDatabase(); + const code = generateReferralCode('user_alice', 'ALICE2026', db); + + const result = processReferral(code, 'user_bob', db); + assertEquals(result.status, 'ok'); + assertEquals(result.creditsAwarded, 5); + assertEquals(result.ownerId, 'user_alice'); + + const referralData = db.referralCodes.get(code); + assertEquals(referralData?.uses, 1); + assertEquals(referralData?.creditsAwarded, 5); + assertEquals(db.systemEvents.length, 1); + assertEquals(db.systemEvents[0].type, 'referral_conversion'); +}); + +Deno.test('Referral: prevents duplicate conversions (idempotency)', () => { + const db = createMockDatabase(); + const code = generateReferralCode('user_alice', 'ALICE2026', db); + + processReferral(code, 'user_bob', db); + const secondAttempt = processReferral(code, 'user_bob', db); + + assertEquals(secondAttempt.status, 'already_processed'); + assertEquals(db.referralCodes.get(code)?.uses, 1); +}); + +Deno.test('Referral: rejects invalid referral code', () => { + const db = createMockDatabase(); + const result = processReferral('NON_EXISTENT_CODE', 'user_bob', db); + assertEquals(result.status, 'invalid_code'); +});