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
37 changes: 29 additions & 8 deletions migrations/add_upsell_triggers.sql
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
-- 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(),
user_id TEXT NOT NULL,
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;
171 changes: 171 additions & 0 deletions src/monetization/upsell.ts
Original file line number Diff line number Diff line change
@@ -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<UpsellVariant, string> = {
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<string, string>;
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<string, UpsellTriggerRecord>();

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<string, string>,
result: UpsellCheckResult,
): Record<string, string> {
if (!result.triggered) return headers;
return { ...headers, ...result.header };
}

export { defaultStore as upsellStore };
74 changes: 74 additions & 0 deletions tests/upsell.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});