diff --git a/src/monetization/upsell.ts b/src/monetization/upsell.ts new file mode 100644 index 0000000..05d1f24 --- /dev/null +++ b/src/monetization/upsell.ts @@ -0,0 +1,127 @@ +/** + * Auto-Upsell Trigger System — Issue #3 + * Triggers contextual upgrade prompts when users reach 50% of their free credit limit. + * Idempotent: fires exactly once per threshold per user. + */ + +export interface DatabaseClient { + query(sql: string, params?: unknown[]): Promise<{ rows: Record[] }>; +} + +export interface UpsellResult { + triggered: boolean; + prompt?: string; + variant?: 'A' | 'B'; + triggerType?: string; +} + +/** A/B test prompt variants */ +const PROMPT_VARIANTS = { + A: "You've used 50% of your free calls. Upgrade now for unlimited access and priority support — plans start at $5/mo.", + B: "Halfway through your free tier! Power users save 40% with our Pro plan. Upgrade before your calls run out.", +} as const; + +/** + * Deterministic A/B variant selection based on user_id hash. + * Consistent: same user always sees the same variant. + */ +export function selectVariant(userId: string): 'A' | 'B' { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 2 === 0 ? 'A' : 'B'; +} + +/** + * Middleware-compatible function that checks if a user has crossed the + * 50% free-call threshold (5 out of 10) and triggers an upsell prompt. + * + * Idempotent: uses INSERT ... ON CONFLICT DO NOTHING to ensure the + * trigger fires exactly once per user per trigger_type. + * + * @param db - Database client + * @param userId - The user making the API call + * @param callCount - The user's current total call count (after this call) + * @returns UpsellResult indicating whether a prompt was triggered + */ +export async function checkUpsellTrigger( + db: DatabaseClient, + userId: string, + callCount: number, +): Promise { + const FREE_LIMIT = 10; + const THRESHOLD = Math.floor(FREE_LIMIT / 2); // 5 + + // Only trigger at exactly the threshold call + if (callCount !== THRESHOLD) { + return { triggered: false }; + } + + const triggerType = 'free_limit_50pct'; + + // Idempotent insert — ON CONFLICT prevents double-trigger + const result = await db.query( + `INSERT INTO upsell_triggers (user_id, trigger_type) + VALUES ($1, $2) + ON CONFLICT (user_id, trigger_type) DO NOTHING + RETURNING id`, + [userId, triggerType], + ); + + // If no row returned, the trigger already fired for this user + if (result.rows.length === 0) { + return { triggered: false }; + } + + const variant = selectVariant(userId); + + // Log to system_events + await db.query( + `INSERT INTO system_events (type, user_id, metadata) + VALUES ($1, $2, $3)`, + [ + 'upsell_trigger_fired', + userId, + JSON.stringify({ trigger_type: triggerType, variant, call_count: callCount }), + ], + ); + + return { + triggered: true, + prompt: PROMPT_VARIANTS[variant], + variant, + triggerType, + }; +} + +/** + * Express/Hono-style middleware factory. + * Attaches X-Upsell-Prompt header to responses when threshold is crossed. + * + * Usage: + * app.use(upsellMiddleware(db, getUserCallCount)); + */ +export function upsellMiddleware( + db: DatabaseClient, + getCallCount: (userId: string) => Promise, +) { + return async (req: { userId?: string }, res: { setHeader: (k: string, v: string) => void }, next: () => void) => { + const userId = req.userId; + if (!userId) { + next(); + return; + } + + const callCount = await getCallCount(userId); + const result = await checkUpsellTrigger(db, userId, callCount); + + if (result.triggered && result.prompt) { + res.setHeader('X-Upsell-Prompt', 'true'); + res.setHeader('X-Upsell-Text', result.prompt); + res.setHeader('X-Upsell-Variant', result.variant!); + } + + next(); + }; +} diff --git a/tests/upsell.test.ts b/tests/upsell.test.ts new file mode 100644 index 0000000..8f3ce63 --- /dev/null +++ b/tests/upsell.test.ts @@ -0,0 +1,103 @@ +import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { checkUpsellTrigger, selectVariant } from '../src/monetization/upsell.ts'; + +// ---- Mock Database ---- + +class MockDB { + triggers = new Map>(); + events: Array<{ type: string; user_id: string; metadata: string }> = []; + + async query(sql: string, params: unknown[] = []): Promise<{ rows: Record[] }> { + if (sql.includes('INSERT INTO upsell_triggers')) { + const userId = params[0] as string; + const triggerType = params[1] as string; + const key = `${userId}:${triggerType}`; + if (this.triggers.has(key)) { + return { rows: [] }; // ON CONFLICT DO NOTHING — no rows returned + } + this.triggers.set(key, new Set()); + return { rows: [{ id: crypto.randomUUID() }] }; + } + + if (sql.includes('INSERT INTO system_events')) { + this.events.push({ + type: params[0] as string, + user_id: params[1] as string, + metadata: params[2] as string, + }); + return { rows: [] }; + } + + return { rows: [] }; + } +} + +// ---- Tests ---- + +Deno.test('No trigger when call count is below threshold', async () => { + const db = new MockDB(); + const result = await checkUpsellTrigger(db, 'user_1', 3); + assertEquals(result.triggered, false); + assertEquals(db.triggers.size, 0); +}); + +Deno.test('No trigger when call count is above threshold', async () => { + const db = new MockDB(); + const result = await checkUpsellTrigger(db, 'user_1', 7); + assertEquals(result.triggered, false); +}); + +Deno.test('Trigger fires at exactly call #5 (50% of 10)', async () => { + const db = new MockDB(); + const result = await checkUpsellTrigger(db, 'user_1', 5); + assertEquals(result.triggered, true); + assertEquals(result.triggerType, 'free_limit_50pct'); + assertEquals(typeof result.prompt, 'string'); + assertEquals(result.prompt!.length > 0, true); +}); + +Deno.test('Idempotent: second trigger for same user does NOT fire', async () => { + const db = new MockDB(); + + // First trigger — should fire + const first = await checkUpsellTrigger(db, 'user_1', 5); + assertEquals(first.triggered, true); + + // Second trigger — same user, same threshold — should NOT fire + const second = await checkUpsellTrigger(db, 'user_1', 5); + assertEquals(second.triggered, false); +}); + +Deno.test('Different users can both trigger independently', async () => { + const db = new MockDB(); + + const r1 = await checkUpsellTrigger(db, 'user_A', 5); + const r2 = await checkUpsellTrigger(db, 'user_B', 5); + + assertEquals(r1.triggered, true); + assertEquals(r2.triggered, true); +}); + +Deno.test('System event is logged on trigger', async () => { + const db = new MockDB(); + await checkUpsellTrigger(db, 'user_1', 5); + + assertEquals(db.events.length, 1); + assertEquals(db.events[0].type, 'upsell_trigger_fired'); + assertEquals(db.events[0].user_id, 'user_1'); +}); + +Deno.test('A/B variant is deterministic per user', () => { + const v1 = selectVariant('user_abc'); + const v2 = selectVariant('user_abc'); + assertEquals(v1, v2); // Same user always gets same variant + + // Variant is always A or B + assertEquals(['A', 'B'].includes(selectVariant('user_xyz')), true); +}); + +Deno.test('No event logged when trigger does not fire', async () => { + const db = new MockDB(); + await checkUpsellTrigger(db, 'user_1', 3); + assertEquals(db.events.length, 0); +});