diff --git a/src/growth/referral.ts b/src/growth/referral.ts new file mode 100644 index 0000000..c9775ab --- /dev/null +++ b/src/growth/referral.ts @@ -0,0 +1,76 @@ +export interface ReferralCode { + id: string; + code: string; + ownerId: string; + uses: number; + creditsAwarded: number; + createdAt: Date; +} + +export interface ReferralResult { + status: 'ok' | 'already_processed' | 'invalid_code'; + creditsAwarded?: number; + ownerId?: string; +} + +export class ReferralEngine { + private codes: Map = new Map(); + private conversions: Set = new Set(); + private events: Array<{ type: string; payload: Record; createdAt: Date }> = []; + + createCode(ownerId: string, customCode?: string): ReferralCode { + const code = customCode || Math.random().toString(36).substring(2, 10).toUpperCase(); + const entry: ReferralCode = { + id: `ref_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`, + code, + ownerId, + uses: 0, + creditsAwarded: 0, + createdAt: new Date(), + }; + this.codes.set(code, entry); + return entry; + } + + processReferral(code: string, newUserId: string): ReferralResult { + const conversionKey = `${code}:${newUserId}`; + if (this.conversions.has(conversionKey)) { + return { status: 'already_processed' }; + } + + const ref = this.codes.get(code); + if (!ref) { + return { status: 'invalid_code' }; + } + + const credits = 5; + this.conversions.add(conversionKey); + ref.uses += 1; + ref.creditsAwarded += credits; + + this.events.push({ + type: 'referral_conversion', + payload: { + code, + new_user: newUserId, + credits, + owner_id: ref.ownerId, + }, + createdAt: new Date(), + }); + + return { + status: 'ok', + creditsAwarded: credits, + ownerId: ref.ownerId, + }; + } + + getEvents() { + return this.events; + } + + getCode(code: string): ReferralCode | undefined { + return this.codes.get(code); + } +} diff --git a/tests/referral.test.ts b/tests/referral.test.ts new file mode 100644 index 0000000..dd53383 --- /dev/null +++ b/tests/referral.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ReferralEngine } from '../src/growth/referral'; + +describe('Referral Engine (#2)', () => { + it('awards 5 credits to referrer on successful conversion', () => { + const engine = new ReferralEngine(); + const codeObj = engine.createCode('user_A', 'REF123'); + + const res = engine.processReferral('REF123', 'user_B'); + assert.equal(res.status, 'ok'); + assert.equal(res.creditsAwarded, 5); + assert.equal(res.ownerId, 'user_A'); + + const updatedCode = engine.getCode('REF123'); + assert.equal(updatedCode?.uses, 1); + assert.equal(updatedCode?.creditsAwarded, 5); + + const events = engine.getEvents(); + assert.equal(events.length, 1); + assert.equal(events[0].type, 'referral_conversion'); + assert.equal(events[0].payload.credits, 5); + }); + + it('prevents duplicate referrals from the same user (idempotency)', () => { + const engine = new ReferralEngine(); + engine.createCode('user_A', 'REF123'); + + const res1 = engine.processReferral('REF123', 'user_B'); + assert.equal(res1.status, 'ok'); + + const res2 = engine.processReferral('REF123', 'user_B'); + assert.equal(res2.status, 'already_processed'); + + const updatedCode = engine.getCode('REF123'); + assert.equal(updatedCode?.uses, 1); + assert.equal(updatedCode?.creditsAwarded, 5); + }); + + it('returns invalid_code for non-existent referral code', () => { + const engine = new ReferralEngine(); + const res = engine.processReferral('NON_EXISTENT', 'user_B'); + assert.equal(res.status, 'invalid_code'); + }); +});