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
89 changes: 89 additions & 0 deletions src/growth/referral.ts
Original file line number Diff line number Diff line change
@@ -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<string, { ownerId: string; uses: number; creditsAwarded: number }>;
conversions: Set<string>;
systemEvents: Array<{ type: string; payload: Record<string, unknown>; 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
};
}
39 changes: 39 additions & 0 deletions tests/referral.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});