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
68 changes: 68 additions & 0 deletions src/monetization/upsell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export interface UpsellTriggerRecord {
user_id: string;
trigger_type: 'free_limit_50pct';
shown_at: string;
converted: boolean;
}

export interface UpsellResult {
shouldTrigger: boolean;
headers: Record<string, string>;
prompt: string | null;
trigger?: UpsellTriggerRecord;
}

const FREE_LIMIT = 10;
const THRESHOLD_CALL = 5;
const TRIGGER_TYPE = 'free_limit_50pct' as const;

const PROMPTS = [
'You have used 50% of your free calls. Upgrade now to keep growth automation running without interruption.',
'Half of your free calls are used. Upgrade to unlock more AI growth workflows before you hit the limit.',
'You are halfway through the free tier. Upgrade for uninterrupted execution and higher usage limits.',
];

export function buildUpsellPrompt(userId: string, callCount: number): string {
const index = Math.abs(hash(`${userId}:${callCount}`)) % PROMPTS.length;
return PROMPTS[index];
}

export function shouldTriggerUpsell(callCount: number, alreadyTriggered: boolean): boolean {
return callCount === THRESHOLD_CALL && !alreadyTriggered;
}

export function evaluateUpsellTrigger(input: {
userId: string;
callCount: number;
alreadyTriggered?: boolean;
now?: Date;
}): UpsellResult {
const alreadyTriggered = input.alreadyTriggered ?? false;
if (!shouldTriggerUpsell(input.callCount, alreadyTriggered)) {
return { shouldTrigger: false, headers: {}, prompt: null };
}

const prompt = buildUpsellPrompt(input.userId, input.callCount);
return {
shouldTrigger: true,
headers: {
'X-Upsell-Prompt': 'true',
'X-Upsell-Threshold': `${THRESHOLD_CALL}/${FREE_LIMIT}`,
},
prompt,
trigger: {
user_id: input.userId,
trigger_type: TRIGGER_TYPE,
shown_at: (input.now ?? new Date()).toISOString(),
converted: false,
},
};
}

function hash(value: string): number {
let total = 0;
for (let i = 0; i < value.length; i += 1) {
total = (total * 31 + value.charCodeAt(i)) | 0;
}
return total;
}
42 changes: 42 additions & 0 deletions tests/upsell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import {
buildUpsellPrompt,
evaluateUpsellTrigger,
shouldTriggerUpsell,
} from '../src/monetization/upsell.ts';

Deno.test('fires exactly on the 5th free call', () => {
assertEquals(shouldTriggerUpsell(4, false), false);
assertEquals(shouldTriggerUpsell(5, false), true);
assertEquals(shouldTriggerUpsell(6, false), false);
});

Deno.test('does not double-trigger after an existing trigger', () => {
assertEquals(shouldTriggerUpsell(5, true), false);
});

Deno.test('sets upsell headers and trigger record', () => {
const result = evaluateUpsellTrigger({
userId: 'user_123',
callCount: 5,
now: new Date('2026-07-08T00:00:00.000Z'),
});

assertEquals(result.shouldTrigger, true);
assertEquals(result.headers['X-Upsell-Prompt'], 'true');
assertEquals(result.headers['X-Upsell-Threshold'], '5/10');
assertEquals(result.trigger?.user_id, 'user_123');
assertEquals(result.trigger?.trigger_type, 'free_limit_50pct');
assertEquals(result.trigger?.converted, false);
});

Deno.test('returns no-op result when threshold is not crossed', () => {
const result = evaluateUpsellTrigger({ userId: 'user_123', callCount: 3 });
assertEquals(result.shouldTrigger, false);
assertEquals(result.headers, {});
assertEquals(result.prompt, null);
});

Deno.test('prompt variant is deterministic per user and count', () => {
assertEquals(buildUpsellPrompt('user_123', 5), buildUpsellPrompt('user_123', 5));
});