diff --git a/migrations/add_upsell_triggers.sql b/migrations/add_upsell_triggers.sql index 433d22d..e606a80 100644 --- a/migrations/add_upsell_triggers.sql +++ b/migrations/add_upsell_triggers.sql @@ -1,26 +1,14 @@ --- Migration: Upsell triggers (Issue #3) +-- Migration: Add upsell triggers (Issue #3) -- Idempotent CREATE TABLE IF NOT EXISTS upsell_triggers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id TEXT NOT NULL, - trigger_type TEXT NOT NULL DEFAULT 'free_limit_50pct', + trigger_type TEXT NOT NULL DEFAULT '50_percent_free_limit', shown_at TIMESTAMPTZ DEFAULT NOW(), converted BOOLEAN DEFAULT FALSE, + prompt_variant TEXT DEFAULT 'VARIANT_A', UNIQUE(user_id, trigger_type) ); -CREATE OR REPLACE FUNCTION check_upsell_trigger(p_user_id TEXT, p_call_count INT) -RETURNS JSONB AS $$ -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; - - RETURN jsonb_build_object('upsell', true, 'prompt', 'You have used 50% of your free calls. Upgrade for unlimited access.'); - END IF; - RETURN jsonb_build_object('upsell', false); -END; -$$ LANGUAGE plpgsql; +CREATE INDEX IF NOT EXISTS idx_upsell_triggers_user ON upsell_triggers(user_id); diff --git a/src/monetization/upsell.ts b/src/monetization/upsell.ts new file mode 100644 index 0000000..748716c --- /dev/null +++ b/src/monetization/upsell.ts @@ -0,0 +1,62 @@ +export interface UpsellTrigger { + userId: string; + triggerType: string; + shownAt: Date; + converted: boolean; + promptVariant: string; +} + +export interface UpsellEvaluation { + shouldPrompt: boolean; + promptText?: string; + promptVariant?: string; + headerKey: string; + headerValue: string; +} + +export const UPSELL_PROMPT_VARIANTS: Record = { + VARIANT_A: "You've used 50% of your free credits! Upgrade to Pro today for 20% off high-volume batches.", + VARIANT_B: "Enjoying the speed? Unlock unlimited parallel agent calls with our Pro Tier.", +}; + +export class UpsellEngine { + private triggers: Map = new Map(); + + evaluateFreeUsage(userId: string, currentCallCount: number, freeLimit: number = 10): UpsellEvaluation { + const threshold = Math.floor(freeLimit * 0.5); // 5th call out of 10 + + if (currentCallCount >= threshold) { + const triggerKey = `${userId}:50_percent_free_limit`; + if (!this.triggers.has(triggerKey)) { + const variant = currentCallCount % 2 === 0 ? 'VARIANT_A' : 'VARIANT_B'; + const promptText = UPSELL_PROMPT_VARIANTS[variant]; + + this.triggers.set(triggerKey, { + userId, + triggerType: '50_percent_free_limit', + shownAt: new Date(), + converted: false, + promptVariant: variant, + }); + + return { + shouldPrompt: true, + promptText, + promptVariant: variant, + headerKey: 'X-Upsell-Prompt', + headerValue: 'true', + }; + } + } + + return { + shouldPrompt: false, + headerKey: 'X-Upsell-Prompt', + headerValue: 'false', + }; + } + + getTrigger(userId: string): UpsellTrigger | undefined { + return this.triggers.get(`${userId}:50_percent_free_limit`); + } +} diff --git a/tests/upsell.test.ts b/tests/upsell.test.ts new file mode 100644 index 0000000..854e3b1 --- /dev/null +++ b/tests/upsell.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { UpsellEngine } from '../src/monetization/upsell'; + +describe('Upsell Trigger Engine (#3)', () => { + it('triggers upsell prompt when user reaches 5th free call (50% threshold)', () => { + const engine = new UpsellEngine(); + + // Calls 1-4 should not trigger + for (let i = 1; i <= 4; i++) { + const evalResult = engine.evaluateFreeUsage('user_123', i, 10); + assert.equal(evalResult.shouldPrompt, false); + assert.equal(evalResult.headerValue, 'false'); + } + + // Call 5 should trigger + const triggerResult = engine.evaluateFreeUsage('user_123', 5, 10); + assert.equal(triggerResult.shouldPrompt, true); + assert.equal(triggerResult.headerKey, 'X-Upsell-Prompt'); + assert.equal(triggerResult.headerValue, 'true'); + assert.ok(triggerResult.promptText); + + const record = engine.getTrigger('user_123'); + assert.ok(record); + assert.equal(record.userId, 'user_123'); + }); + + it('ensures trigger fires exactly once per threshold (idempotent)', () => { + const engine = new UpsellEngine(); + + const first = engine.evaluateFreeUsage('user_456', 5, 10); + assert.equal(first.shouldPrompt, true); + + const second = engine.evaluateFreeUsage('user_456', 6, 10); + assert.equal(second.shouldPrompt, false); + assert.equal(second.headerValue, 'false'); + }); +});