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
91 changes: 81 additions & 10 deletions migrations/add_referral_system.sql
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
-- Migration: Referral system (Issue #2)
-- Idempotent
-- Idempotent: safe to re-run

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),
code TEXT UNIQUE NOT NULL DEFAULT upper(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8)),
owner_id TEXT NOT NULL,
uses INT DEFAULT 0,
credits_awarded INT DEFAULT 0,
Expand All @@ -18,30 +18,101 @@ CREATE TABLE IF NOT EXISTS referral_conversions (
UNIQUE(referral_code, new_user_id)
);

CREATE TABLE IF NOT EXISTS user_credit_balances (
user_id TEXT PRIMARY KEY,
free_credits INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS user_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS system_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_referral_codes_owner
ON referral_codes (owner_id);

CREATE INDEX IF NOT EXISTS idx_referral_conversions_user
ON referral_conversions (new_user_id);

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;
v_norm TEXT;
BEGIN
IF p_code IS NULL OR btrim(p_code) = '' OR p_new_user_id IS NULL OR btrim(p_new_user_id) = '' THEN
RETURN jsonb_build_object('status', 'invalid_input');
END IF;

v_norm := upper(btrim(p_code));

-- Idempotency check
IF EXISTS (SELECT 1 FROM referral_conversions WHERE referral_code = p_code AND new_user_id = p_new_user_id) THEN
IF EXISTS (
SELECT 1 FROM referral_conversions
WHERE referral_code = v_norm 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;
SELECT owner_id INTO v_owner_id FROM referral_codes WHERE code = v_norm;
IF NOT FOUND THEN
RETURN jsonb_build_object('status', 'invalid_code');
END IF;

IF v_owner_id = p_new_user_id THEN
RETURN jsonb_build_object('status', 'self_referral');
END IF;

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

-- Award credits
UPDATE referral_codes SET uses = uses + 1, credits_awarded = credits_awarded + v_credits WHERE code = p_code;
-- Award credits on code + owner balance
UPDATE referral_codes
SET uses = uses + 1,
credits_awarded = credits_awarded + v_credits
WHERE code = v_norm;

INSERT INTO user_credit_balances (user_id, free_credits, updated_at)
VALUES (v_owner_id, v_credits, NOW())
ON CONFLICT (user_id) DO UPDATE
SET free_credits = user_credit_balances.free_credits + EXCLUDED.free_credits,
updated_at = NOW();

-- 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());
VALUES (
'referral_conversion',
jsonb_build_object(
'code', v_norm,
'new_user', p_new_user_id,
'owner_id', v_owner_id,
'credits', v_credits
),
NOW()
);

-- Notify both users
INSERT INTO user_notifications (user_id, message)
VALUES
(v_owner_id, format('Referral success: +%s free credits (code %s).', v_credits, v_norm)),
(p_new_user_id, format('Welcome — you joined via referral %s.', v_norm));

RETURN jsonb_build_object('status', 'ok', 'credits_awarded', v_credits, 'owner_id', v_owner_id);
RETURN jsonb_build_object(
'status', 'ok',
'credits_awarded', v_credits,
'owner_id', v_owner_id
);
END;
$$ LANGUAGE plpgsql;
177 changes: 177 additions & 0 deletions src/growth/referral.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Referral reward loop — Issue #2
* When user B signs up with A's code and makes their first paid call,
* A receives 5 free credits once (idempotent per code+new_user).
*/

export const REFERRAL_CREDITS = 5;
export const EVENT_TYPE = 'referral_conversion';

export interface ReferralCode {
code: string;
ownerId: string;
uses: number;
creditsAwarded: number;
createdAt: string;
}

export interface ReferralConversion {
referralCode: string;
newUserId: string;
convertedAt: string;
}

export interface SystemEvent {
eventType: string;
payload: Record<string, unknown>;
createdAt: string;
}

export interface Notification {
userId: string;
message: string;
createdAt: string;
}

export type ProcessStatus =
| 'ok'
| 'already_processed'
| 'invalid_code'
| 'self_referral'
| 'invalid_input';

export interface ProcessReferralResult {
status: ProcessStatus;
creditsAwarded?: number;
ownerId?: string;
event?: SystemEvent;
notifications?: Notification[];
}

function nowIso(): string {
return new Date().toISOString();
}

function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}

/** In-memory mirror of referral_codes + referral_conversions + system_events. */
export class ReferralStore {
codes = new Map<string, ReferralCode>();
/** key = `${code}::${newUserId}` */
conversions = new Map<string, ReferralConversion>();
/** owner_id → free credit balance */
balances = new Map<string, number>();
events: SystemEvent[] = [];
notifications: Notification[] = [];

createCode(ownerId: string, code?: string): ReferralCode {
if (!ownerId?.trim()) throw new Error('ownerId required');
const raw = code?.trim() || crypto.randomUUID().replace(/-/g, '').slice(0, 8);
const normalized = normalizeCode(raw);
if (this.codes.has(normalized)) throw new Error('code already exists');
const row: ReferralCode = {
code: normalized,
ownerId: ownerId.trim(),
uses: 0,
creditsAwarded: 0,
createdAt: nowIso(),
};
this.codes.set(normalized, row);
if (!this.balances.has(row.ownerId)) this.balances.set(row.ownerId, 0);
return row;
}

getCode(code: string): ReferralCode | undefined {
return this.codes.get(normalizeCode(code));
}

getBalance(userId: string): number {
return this.balances.get(userId) ?? 0;
}

clear(): void {
this.codes.clear();
this.conversions.clear();
this.balances.clear();
this.events = [];
this.notifications = [];
}
}

const defaultStore = new ReferralStore();

/**
* process_referral(referral_code, new_user_id)
* Awards REFERRAL_CREDITS to the code owner on first paid conversion.
* Idempotent: same (code, new_user) pair cannot convert twice.
*/
export function processReferral(
referralCode: string,
newUserId: string,
store: ReferralStore = defaultStore,
): ProcessReferralResult {
if (!referralCode?.trim() || !newUserId?.trim()) {
return { status: 'invalid_input' };
}

const code = normalizeCode(referralCode);
const userId = newUserId.trim();
const row = store.codes.get(code);
if (!row) return { status: 'invalid_code' };

if (row.ownerId === userId) {
return { status: 'self_referral' };
}

const convKey = `${code}::${userId}`;
if (store.conversions.has(convKey)) {
return { status: 'already_processed', ownerId: row.ownerId };
}

const convertedAt = nowIso();
store.conversions.set(convKey, {
referralCode: code,
newUserId: userId,
convertedAt,
});

row.uses += 1;
row.creditsAwarded += REFERRAL_CREDITS;
store.balances.set(row.ownerId, store.getBalance(row.ownerId) + REFERRAL_CREDITS);

const event: SystemEvent = {
eventType: EVENT_TYPE,
payload: {
code,
new_user: userId,
owner_id: row.ownerId,
credits: REFERRAL_CREDITS,
},
createdAt: convertedAt,
};
store.events.push(event);

const notifications: Notification[] = [
{
userId: row.ownerId,
message: `Referral success: +${REFERRAL_CREDITS} free credits (code ${code}).`,
createdAt: convertedAt,
},
{
userId,
message: `Welcome — you joined via referral ${code}.`,
createdAt: convertedAt,
},
];
store.notifications.push(...notifications);

return {
status: 'ok',
creditsAwarded: REFERRAL_CREDITS,
ownerId: row.ownerId,
event,
notifications,
};
}
74 changes: 74 additions & 0 deletions tests/referral.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { assertEquals, assertExists } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import {
REFERRAL_CREDITS,
EVENT_TYPE,
ReferralStore,
processReferral,
} from '../src/growth/referral.ts';

Deno.test('happy path awards 5 credits and logs event + notifications', () => {
const store = new ReferralStore();
store.createCode('alice', 'ALICE01');
const r = processReferral('alice01', 'bob', store);
assertEquals(r.status, 'ok');
assertEquals(r.creditsAwarded, REFERRAL_CREDITS);
assertEquals(r.ownerId, 'alice');
assertEquals(store.getBalance('alice'), 5);
assertEquals(store.getCode('ALICE01')?.uses, 1);
assertEquals(store.getCode('ALICE01')?.creditsAwarded, 5);
assertEquals(store.events.length, 1);
assertEquals(store.events[0].eventType, EVENT_TYPE);
assertEquals(store.notifications.length, 2);
});

Deno.test('idempotent: duplicate conversion for same user is rejected', () => {
const store = new ReferralStore();
store.createCode('alice', 'ALICE02');
const first = processReferral('ALICE02', 'bob', store);
assertEquals(first.status, 'ok');
const second = processReferral('ALICE02', 'bob', store);
assertEquals(second.status, 'already_processed');
assertEquals(store.getBalance('alice'), 5);
assertEquals(store.getCode('ALICE02')?.uses, 1);
assertEquals(store.events.length, 1);
});

Deno.test('same code can convert different users', () => {
const store = new ReferralStore();
store.createCode('alice', 'SHARE01');
assertEquals(processReferral('SHARE01', 'bob', store).status, 'ok');
assertEquals(processReferral('SHARE01', 'carol', store).status, 'ok');
assertEquals(store.getBalance('alice'), 10);
assertEquals(store.getCode('SHARE01')?.uses, 2);
});

Deno.test('invalid code returns invalid_code', () => {
const store = new ReferralStore();
const r = processReferral('NOPE', 'bob', store);
assertEquals(r.status, 'invalid_code');
});

Deno.test('self-referral is blocked', () => {
const store = new ReferralStore();
store.createCode('alice', 'SELF01');
const r = processReferral('SELF01', 'alice', store);
assertEquals(r.status, 'self_referral');
assertEquals(store.getBalance('alice'), 0);
});

Deno.test('invalid inputs are safe no-ops', () => {
const store = new ReferralStore();
assertEquals(processReferral('', 'bob', store).status, 'invalid_input');
assertEquals(processReferral('X', '', store).status, 'invalid_input');
});

Deno.test('event payload includes code, new_user, credits', () => {
const store = new ReferralStore();
store.createCode('owner', 'EVT001');
const r = processReferral('EVT001', 'newbie', store);
assertEquals(r.status, 'ok');
assertExists(r.event);
assertEquals(r.event!.payload.code, 'EVT001');
assertEquals(r.event!.payload.new_user, 'newbie');
assertEquals(r.event!.payload.credits, 5);
});