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
127 changes: 127 additions & 0 deletions src/monetization/upsell.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] }>;
}

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<UpsellResult> {
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<number>,
) {
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();
};
}
103 changes: 103 additions & 0 deletions tests/upsell.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>();
events: Array<{ type: string; user_id: string; metadata: string }> = [];

async query(sql: string, params: unknown[] = []): Promise<{ rows: Record<string, unknown>[] }> {
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);
});