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
20 changes: 4 additions & 16 deletions migrations/add_upsell_triggers.sql
Original file line number Diff line number Diff line change
@@ -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);
62 changes: 62 additions & 0 deletions src/monetization/upsell.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, UpsellTrigger> = 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`);
}
}
38 changes: 38 additions & 0 deletions tests/upsell.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});