diff --git a/migrations/add_upsell_triggers.sql b/migrations/add_upsell_triggers.sql index 433d22d..05f3b14 100644 --- a/migrations/add_upsell_triggers.sql +++ b/migrations/add_upsell_triggers.sql @@ -1,5 +1,5 @@ -- Migration: Upsell triggers (Issue #3) --- Idempotent +-- Idempotent: safe to re-run CREATE TABLE IF NOT EXISTS upsell_triggers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -7,20 +7,41 @@ CREATE TABLE IF NOT EXISTS upsell_triggers ( trigger_type TEXT NOT NULL DEFAULT 'free_limit_50pct', shown_at TIMESTAMPTZ DEFAULT NOW(), converted BOOLEAN DEFAULT FALSE, + variant TEXT DEFAULT 'A', + prompt TEXT, UNIQUE(user_id, trigger_type) ); +CREATE INDEX IF NOT EXISTS idx_upsell_triggers_user + ON upsell_triggers (user_id); + CREATE OR REPLACE FUNCTION check_upsell_trigger(p_user_id TEXT, p_call_count INT) RETURNS JSONB AS $$ +DECLARE + v_existing UUID; + v_prompt TEXT := 'You have used 50% of your free calls. Upgrade for unlimited access.'; BEGIN - -- Fire at 5th call (50% of 10 free calls) - IF p_call_count = 5 THEN - INSERT INTO upsell_triggers (user_id, trigger_type) - VALUES (p_user_id, 'free_limit_50pct') - ON CONFLICT (user_id, trigger_type) DO NOTHING; + -- Fire exactly at 5th call (50% of 10 free calls) + IF p_call_count IS DISTINCT FROM 5 THEN + RETURN jsonb_build_object('upsell', false, 'reason', 'not_threshold'); + END IF; + + SELECT id INTO v_existing + FROM upsell_triggers + WHERE user_id = p_user_id AND trigger_type = 'free_limit_50pct'; - RETURN jsonb_build_object('upsell', true, 'prompt', 'You have used 50% of your free calls. Upgrade for unlimited access.'); + IF FOUND THEN + RETURN jsonb_build_object('upsell', false, 'reason', 'already_shown'); END IF; - RETURN jsonb_build_object('upsell', false); + + INSERT INTO upsell_triggers (user_id, trigger_type, prompt, variant) + VALUES (p_user_id, 'free_limit_50pct', v_prompt, 'A') + ON CONFLICT (user_id, trigger_type) DO NOTHING; + + RETURN jsonb_build_object( + 'upsell', true, + 'prompt', v_prompt, + 'header', 'X-Upsell-Prompt: true' + ); END; $$ LANGUAGE plpgsql; diff --git a/src/monetization/upsell.ts b/src/monetization/upsell.ts new file mode 100644 index 0000000..7f85379 --- /dev/null +++ b/src/monetization/upsell.ts @@ -0,0 +1,171 @@ +/** + * Auto-upsell trigger engine — Issue #3 + * Fires once when a user reaches 50% of the free credit limit (5th of 10 free calls). + * Sets `X-Upsell-Prompt: true` and returns an A/B prompt variant. + */ + +export const FREE_CALL_LIMIT = 10; +export const UPSELL_THRESHOLD = Math.floor(FREE_CALL_LIMIT / 2); // 5th call +export const TRIGGER_TYPE = 'free_limit_50pct'; + +export type UpsellVariant = 'A' | 'B' | 'C'; + +export const UPSELL_PROMPTS: Record = { + A: 'You have used 50% of your free calls. Upgrade now for unlimited x402 access.', + B: 'Halfway through your free tier — unlock Priority pricing and skip rate limits.', + C: '5 free calls used. Convert to paid and keep building without interruption.', +}; + +export interface UpsellTriggerRecord { + userId: string; + triggerType: string; + shownAt: string; + converted: boolean; + variant: UpsellVariant; + prompt: string; +} + +export interface UpsellCheckResult { + triggered: boolean; + alreadyShown: boolean; + header: Record; + prompt: string | null; + variant: UpsellVariant | null; + record: UpsellTriggerRecord | null; +} + +/** Deterministic A/B/C assignment from user id (stable across calls). */ +export function pickVariant(userId: string): UpsellVariant { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash * 31 + userId.charCodeAt(i)) >>> 0; + } + const keys = Object.keys(UPSELL_PROMPTS) as UpsellVariant[]; + return keys[hash % keys.length]; +} + +/** In-memory store mirroring UNIQUE(user_id, trigger_type). */ +export class UpsellStore { + private rows = new Map(); + + private key(userId: string, triggerType = TRIGGER_TYPE): string { + return `${userId}::${triggerType}`; + } + + get(userId: string, triggerType = TRIGGER_TYPE): UpsellTriggerRecord | undefined { + return this.rows.get(this.key(userId, triggerType)); + } + + /** Insert-once semantics (idempotent). Returns existing row if present. */ + insertOnce(record: UpsellTriggerRecord): { inserted: boolean; record: UpsellTriggerRecord } { + const k = this.key(record.userId, record.triggerType); + const existing = this.rows.get(k); + if (existing) return { inserted: false, record: existing }; + this.rows.set(k, record); + return { inserted: true, record }; + } + + markConverted(userId: string, triggerType = TRIGGER_TYPE): boolean { + const row = this.rows.get(this.key(userId, triggerType)); + if (!row) return false; + row.converted = true; + return true; + } + + clear(): void { + this.rows.clear(); + } +} + +const defaultStore = new UpsellStore(); + +/** + * Middleware-style check: at exactly the threshold call, insert trigger once + * and attach response headers. Subsequent crossings are no-ops. + */ +export function checkUpsellTrigger( + userId: string, + callCount: number, + store: UpsellStore = defaultStore, +): UpsellCheckResult { + if (!userId || typeof callCount !== 'number' || callCount < 0) { + return { + triggered: false, + alreadyShown: false, + header: {}, + prompt: null, + variant: null, + record: null, + }; + } + + const existing = store.get(userId); + if (existing) { + return { + triggered: false, + alreadyShown: true, + header: {}, + prompt: null, + variant: null, + record: existing, + }; + } + + if (callCount !== UPSELL_THRESHOLD) { + return { + triggered: false, + alreadyShown: false, + header: {}, + prompt: null, + variant: null, + record: null, + }; + } + + const variant = pickVariant(userId); + const prompt = UPSELL_PROMPTS[variant]; + const record: UpsellTriggerRecord = { + userId, + triggerType: TRIGGER_TYPE, + shownAt: new Date().toISOString(), + converted: false, + variant, + prompt, + }; + const { inserted, record: saved } = store.insertOnce(record); + + if (!inserted) { + return { + triggered: false, + alreadyShown: true, + header: {}, + prompt: null, + variant: null, + record: saved, + }; + } + + return { + triggered: true, + alreadyShown: false, + header: { + 'X-Upsell-Prompt': 'true', + 'X-Upsell-Variant': variant, + 'X-Upsell-Trigger': TRIGGER_TYPE, + }, + prompt, + variant, + record: saved, + }; +} + +/** Apply upsell headers onto a mutable header map / Headers-like object. */ +export function applyUpsellHeaders( + headers: Record, + result: UpsellCheckResult, +): Record { + if (!result.triggered) return headers; + return { ...headers, ...result.header }; +} + +export { defaultStore as upsellStore }; diff --git a/tests/upsell.test.ts b/tests/upsell.test.ts new file mode 100644 index 0000000..67fe40f --- /dev/null +++ b/tests/upsell.test.ts @@ -0,0 +1,74 @@ +import { assertEquals, assertExists } from 'https://deno.land/std@0.224.0/assert/mod.ts'; +import { + UPSELL_THRESHOLD, + UPSELL_PROMPTS, + UpsellStore, + checkUpsellTrigger, + applyUpsellHeaders, + pickVariant, +} from '../src/monetization/upsell.ts'; + +Deno.test('threshold is 5th call (50% of 10 free)', () => { + assertEquals(UPSELL_THRESHOLD, 5); +}); + +Deno.test('no trigger before threshold', () => { + const store = new UpsellStore(); + const r = checkUpsellTrigger('user-a', 4, store); + assertEquals(r.triggered, false); + assertEquals(r.header['X-Upsell-Prompt'], undefined); + assertEquals(store.get('user-a'), undefined); +}); + +Deno.test('triggers exactly once at threshold with header + prompt', () => { + const store = new UpsellStore(); + const r = checkUpsellTrigger('user-b', 5, store); + assertEquals(r.triggered, true); + assertEquals(r.header['X-Upsell-Prompt'], 'true'); + assertExists(r.prompt); + assertEquals(Object.values(UPSELL_PROMPTS).includes(r.prompt!), true); + assertExists(store.get('user-b')); +}); + +Deno.test('idempotent: second crossing does not re-fire', () => { + const store = new UpsellStore(); + const first = checkUpsellTrigger('user-c', 5, store); + assertEquals(first.triggered, true); + const second = checkUpsellTrigger('user-c', 5, store); + assertEquals(second.triggered, false); + assertEquals(second.alreadyShown, true); + assertEquals(second.header['X-Upsell-Prompt'], undefined); + // still only one row + assertEquals(store.get('user-c')?.userId, 'user-c'); +}); + +Deno.test('calls after threshold without prior trigger do not fire (must hit exactly 5)', () => { + const store = new UpsellStore(); + const r = checkUpsellTrigger('user-d', 6, store); + assertEquals(r.triggered, false); +}); + +Deno.test('A/B variants are stable per user', () => { + const v1 = pickVariant('stable-user-42'); + const v2 = pickVariant('stable-user-42'); + assertEquals(v1, v2); + assertEquals(Object.keys(UPSELL_PROMPTS).includes(v1), true); +}); + +Deno.test('applyUpsellHeaders merges only when triggered', () => { + const store = new UpsellStore(); + const hit = checkUpsellTrigger('user-e', 5, store); + const merged = applyUpsellHeaders({ 'Content-Type': 'application/json' }, hit); + assertEquals(merged['Content-Type'], 'application/json'); + assertEquals(merged['X-Upsell-Prompt'], 'true'); + + const miss = checkUpsellTrigger('user-f', 1, store); + const untouched = applyUpsellHeaders({ 'Content-Type': 'application/json' }, miss); + assertEquals(untouched['X-Upsell-Prompt'], undefined); +}); + +Deno.test('invalid inputs are safe no-ops', () => { + const store = new UpsellStore(); + assertEquals(checkUpsellTrigger('', 5, store).triggered, false); + assertEquals(checkUpsellTrigger('x', -1, store).triggered, false); +});