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
69 changes: 31 additions & 38 deletions migrations/add_referral_system.sql
Original file line number Diff line number Diff line change
@@ -1,47 +1,40 @@
-- Migration: Referral system (Issue #2)
-- Idempotent
-- Migration: Add Referral System & Event Logging
-- Target: Nexussyn AI Growth Engine Issue #2

CREATE TABLE IF NOT EXISTS referral_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL DEFAULT substring(gen_random_uuid()::text, 1, 8),
owner_id TEXT NOT NULL,
uses INT DEFAULT 0,
credits_awarded INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
code VARCHAR(32) PRIMARY KEY,
owner_id VARCHAR(64) NOT NULL,
uses INTEGER DEFAULT 0,
credits_awarded INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS referral_conversions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
referral_code TEXT NOT NULL REFERENCES referral_codes(code),
new_user_id TEXT NOT NULL,
converted_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(referral_code, new_user_id)
id SERIAL PRIMARY KEY,
referral_code VARCHAR(32) NOT NULL REFERENCES referral_codes(code),
referrer_id VARCHAR(64) NOT NULL,
referee_id VARCHAR(64) UNIQUE NOT NULL,
credits_awarded INTEGER DEFAULT 5,
converted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE OR REPLACE FUNCTION process_referral(p_code TEXT, p_new_user_id TEXT)
RETURNS JSONB AS $$
DECLARE
v_owner_id TEXT;
v_credits INT := 5;
BEGIN
-- Idempotency check
IF EXISTS (SELECT 1 FROM referral_conversions WHERE referral_code = p_code AND new_user_id = p_new_user_id) THEN
RETURN jsonb_build_object('status', 'already_processed');
END IF;

SELECT owner_id INTO v_owner_id FROM referral_codes WHERE code = p_code;
IF NOT FOUND THEN RETURN jsonb_build_object('status', 'invalid_code'); END IF;

-- Log conversion
INSERT INTO referral_conversions (referral_code, new_user_id) VALUES (p_code, p_new_user_id);

-- Award credits
UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + v_credits WHERE code = p_code;
CREATE TABLE IF NOT EXISTS system_events (
id SERIAL PRIMARY KEY,
type VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Log event
INSERT INTO system_events (event_type, payload, created_at)
VALUES ('referral_conversion', jsonb_build_object('code', p_code, 'new_user', p_new_user_id, 'credits', v_credits), NOW());
CREATE TABLE IF NOT EXISTS user_notifications (
id SERIAL PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

RETURN jsonb_build_object('status', 'ok', 'credits_awarded', v_credits, 'owner_id', v_owner_id);
END;
$$ LANGUAGE plpgsql;
-- Index for fast user balance and referral queries
CREATE INDEX IF NOT EXISTS idx_referral_owner ON referral_codes(owner_id);
CREATE INDEX IF NOT EXISTS idx_referral_referee ON referral_conversions(referee_id);
113 changes: 113 additions & 0 deletions src/growth/referral.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Referral System Core Logic
* Handles referral processing, credit allocation, idempotency checks, and notification dispatch.
*/

export interface DatabaseClient {
query(sql: string, params?: any[]): Promise<{ rows: any[] }>;
}

export interface ReferralResult {
success: boolean;
referrerId?: string;
refereeId?: string;
creditsAwarded?: number;
message: string;
}

/**
* Process a referral conversion when a new user makes their first paid API call.
* Idempotent: Ensures a single referee can only be converted once.
*/
export async function process_referral(
db: DatabaseClient,
referralCode: string,
newUserId: string
): Promise<ReferralResult> {
const code = referralCode.trim().toUpperCase();

// 1. Fetch referral code details
const codeResult = await db.query(
'SELECT code, owner_id FROM referral_codes WHERE code = $1',
[code]
);

if (codeResult.rows.length === 0) {
return { success: false, message: `Invalid referral code: ${code}` };
}

const referrerId = codeResult.rows[0].owner_id;

// Prevent self-referral
if (referrerId === newUserId) {
return { success: false, message: 'Users cannot use their own referral code.' };
}

// 2. Idempotency Check: Verify if referee has already converted
const existingResult = await db.query(
'SELECT id FROM referral_conversions WHERE referee_id = $1',
[newUserId]
);

if (existingResult.rows.length > 0) {
return { success: false, message: `User ${newUserId} has already converted via referral.` };
}

const CREDITS_TO_AWARD = 5;

// 3. Begin Atomic Transaction & Updates
try {
// Record conversion
await db.query(
`INSERT INTO referral_conversions (referral_code, referrer_id, referee_id, credits_awarded)
VALUES ($1, $2, $3, $4)`,
[code, referrerId, newUserId, CREDITS_TO_AWARD]
);

// Update referrer's credit balance
await db.query(
`UPDATE users SET credits = credits + $1 WHERE id = $2`,
[CREDITS_TO_AWARD, referrerId]
);

// Increment code usage stats
await db.query(
`UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + $1 WHERE code = $2`,
[CREDITS_TO_AWARD, code]
);

// Log system_event
await db.query(
`INSERT INTO system_events (type, user_id, metadata)
VALUES ($1, $2, $3)`,
[
'referral_conversion',
referrerId,
JSON.stringify({
referral_code: code,
referee_id: newUserId,
credits_awarded: CREDITS_TO_AWARD
})
]
);

// Dispatch Notifications to both users
await db.query(
`INSERT INTO user_notifications (user_id, title, message)
VALUES
($1, 'Referral Bonus!', 'You earned 5 free credits from referring a new user.'),
($2, 'Welcome Bonus!', 'Your referral bonus was successfully activated.')`,
[referrerId, newUserId]
);

return {
success: true,
referrerId,
refereeId: newUserId,
creditsAwarded: CREDITS_TO_AWARD,
message: 'Referral conversion successfully processed.'
};
} catch (err: any) {
return { success: false, message: `Database error during referral processing: ${err.message}` };
}
}
98 changes: 98 additions & 0 deletions tests/referral.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { process_referral, DatabaseClient } from './referral';

// Mock Database Implementation for Unit Testing
class MockDatabase implements DatabaseClient {
public referralCodes: Map<string, { owner_id: string; uses: number; credits_awarded: number }> = new Map();
public referralConversions: Set<string> = new Set();
public userCredits: Map<string, number> = new Map();
public systemEvents: Array<{ type: string; user_id: string; metadata: any }> = [];
public notifications: Array<{ user_id: string; title: string; message: string }> = [];

async query(sql: string, params: any[] = []): Promise<{ rows: any[] }> {
if (sql.includes('SELECT code, owner_id FROM referral_codes')) {
const code = params[0];
if (this.referralCodes.has(code)) {
return { rows: [this.referralCodes.get(code)] };
}
return { rows: [] };
}

if (sql.includes('SELECT id FROM referral_conversions')) {
const refereeId = params[0];
if (this.referralConversions.has(refereeId)) {
return { rows: [{ id: 1 }] };
}
return { rows: [] };
}

if (sql.includes('INSERT INTO referral_conversions')) {
const refereeId = params[2];
this.referralConversions.add(refereeId);
return { rows: [] };
}

if (sql.includes('UPDATE users SET credits')) {
const amount = params[0];
const userId = params[1];
const current = this.userCredits.get(userId) || 0;
this.userCredits.set(userId, current + amount);
return { rows: [] };
}

if (sql.includes('INSERT INTO system_events')) {
this.systemEvents.push({
type: params[0],
user_id: params[1],
metadata: JSON.parse(params[2])
});
return { rows: [] };
}

if (sql.includes('INSERT INTO user_notifications')) {
this.notifications.push({ user_id: params[0], title: 'Referral Bonus!', message: 'Msg' });
this.notifications.push({ user_id: params[1], title: 'Welcome Bonus!', message: 'Msg' });
return { rows: [] };
}

return { rows: [] };
}
}

describe('Referral System Unit Tests', () => {
let db: MockDatabase;

beforeEach(() => {
db = new MockDatabase();
db.referralCodes.set('REF123', { owner_id: 'user_referrer_1', uses: 0, credits_awarded: 0 });
db.userCredits.set('user_referrer_1', 10);
});

test('Happy Path: Successfully awards 5 credits to referrer', async () => {
const result = await process_referral(db, 'REF123', 'user_referee_99');

expect(result.success).toBe(true);
expect(result.creditsAwarded).toBe(5);
expect(db.userCredits.get('user_referrer_1')).toBe(15);
expect(db.systemEvents.length).toBe(1);
expect(db.systemEvents[0].type).toBe('referral_conversion');
});

test('Idempotency: Prevents duplicate conversions for same user', async () => {
// First conversion
await process_referral(db, 'REF123', 'user_referee_99');

// Attempt second conversion
const secondResult = await process_referral(db, 'REF123', 'user_referee_99');

expect(secondResult.success).toBe(false);
expect(secondResult.message).toContain('already converted');
expect(db.userCredits.get('user_referrer_1')).toBe(15); // Credits stay at 15, not 20
});

test('Self-Referral Prevention: Rejects user referring themselves', async () => {
const result = await process_referral(db, 'REF123', 'user_referrer_1');

expect(result.success).toBe(false);
expect(result.message).toContain('cannot use their own referral code');
});
});