From dfe35cf1e025fb9ceb8d7833500c3b488a9168fd Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 09:08:53 +0530 Subject: [PATCH 1/7] feat: offer $10 in signup credits for the product hunt launch --- .../migrations/0027_launch_signup_credits.sql | 28 ++++++++ platform/src/lib/billing.ts | 8 ++- platform/src/lib/entitlements.ts | 9 ++- platform/src/lib/notification-delivery.ts | 6 +- platform/src/lib/signup-credits.ts | 13 ++++ .../test/admin-access.integration.test.ts | 14 ++-- .../admin-user-deletion.integration.test.ts | 2 +- .../agent-memory-updater.integration.test.ts | 2 +- .../test/agent-runtime-do.integration.test.ts | 64 ++++++++--------- platform/test/auth-config.test.ts | 1 + platform/test/auth-worker.integration.test.ts | 10 +-- platform/test/billing.integration.test.ts | 28 ++++---- platform/test/credits.integration.test.ts | 72 +++++++++++-------- .../test/deletion-ledger.integration.test.ts | 23 +++--- platform/test/entitlements.test.ts | 2 +- platform/test/welcome-email.test.ts | 11 ++- reference/engineering/CREDIT_ACCOUNTING.md | 32 ++++++--- web/app/_directions/craft-demo.tsx | 6 +- web/app/_directions/craft-faq.tsx | 4 +- web/app/_directions/craft-pricing.tsx | 2 +- web/app/_directions/craft.tsx | 16 +++-- web/app/craft.css | 46 ++++++------ web/app/dashboard/WelcomeToast.tsx | 13 ++-- web/app/dashboard/settings/SettingsCards.tsx | 2 +- web/lib/welcome-toast.test.ts | 19 ++++- web/lib/welcome-toast.ts | 15 ++++ 26 files changed, 290 insertions(+), 158 deletions(-) create mode 100644 platform/migrations/0027_launch_signup_credits.sql create mode 100644 platform/src/lib/signup-credits.ts diff --git a/platform/migrations/0027_launch_signup_credits.sql b/platform/migrations/0027_launch_signup_credits.sql new file mode 100644 index 00000000..45e3c839 --- /dev/null +++ b/platform/migrations/0027_launch_signup_credits.sql @@ -0,0 +1,28 @@ +-- Product Hunt launch offer. Only accounts created after this migration receive it. +-- End the offer with a new migration restoring 1000; do not rewrite existing grants. +DROP TRIGGER credit_accounts_create_user; +CREATE TRIGGER credit_accounts_create_user +AFTER INSERT ON user +BEGIN + INSERT INTO credit_accounts (user_id, available_credits) VALUES (NEW.id, 0); + -- Claim the newest snapshot once, before reading its spendable balance. + -- A renamed successor retains this claim; another signup on the old email + -- receives zero rather than reusing an already transferred snapshot. + UPDATE account_credit_carryovers SET transferred_to_user_id=NEW.id + WHERE user_id=(SELECT user_id FROM deleted_accounts WHERE email=lower(trim(NEW.email)) + ORDER BY sequence DESC LIMIT 1) AND transferred_to_user_id IS NULL; + INSERT INTO credit_ledger + (id,user_id,operation_id,entry_type,credits,metadata_json,created_at) + SELECT 'onboarding:' || NEW.id,NEW.id, + CASE WHEN EXISTS (SELECT 1 FROM deleted_accounts WHERE email=lower(trim(NEW.email))) + THEN 'returning-account:v1' ELSE 'onboarding:v1' END, + CASE WHEN EXISTS (SELECT 1 FROM deleted_accounts WHERE email=lower(trim(NEW.email))) + THEN 'adjustment' ELSE 'grant' END, + CASE WHEN EXISTS (SELECT 1 FROM deleted_accounts WHERE email=lower(trim(NEW.email))) + THEN COALESCE((SELECT available_credits FROM account_credit_carryovers WHERE transferred_to_user_id=NEW.id),0) + ELSE 5000 END, + CASE WHEN EXISTS (SELECT 1 FROM deleted_accounts WHERE email=lower(trim(NEW.email))) + THEN '{"kind":"returning-account"}' + ELSE '{"plan":"starter","kind":"onboarding","allowance":5000,"offer":"product-hunt-launch-2026"}' END, + CAST(strftime('%s','now') AS INTEGER)*1000; +END; diff --git a/platform/src/lib/billing.ts b/platform/src/lib/billing.ts index 9e9500e3..6eae39c8 100644 --- a/platform/src/lib/billing.ts +++ b/platform/src/lib/billing.ts @@ -7,6 +7,7 @@ import type { WebhookSubscriptionUpdatedPayload } from '@polar-sh/sdk/models/com import type { CreditEnv } from './entitlements'; import { ApiError, now, sha256 } from './http'; import { paymentReceipt, refundReconciliationStatements } from './credit-continuity'; +import { signupAllowanceSql } from './signup-credits'; interface BillingAccountRow { plan: 'starter' | 'builder'; @@ -58,7 +59,7 @@ export async function getBillingSummary(env: CreditEnv, userId: string): Promise const row = await env.DB.prepare(` SELECT b.plan,b.status,b.has_payment_history,b.provider_customer_id, b.provider_subscription_id,b.cancel_at_period_end,b.current_period_start,b.current_period_end, - COALESCE(c.available_credits,0) AS credit_balance, + COALESCE(c.available_credits,0) AS credit_balance, ${signupAllowanceSql} AS onboarding_credits, EXISTS (SELECT 1 FROM billing_subscription_states WHERE user_id=u.id AND status IN ('incomplete','past_due','unpaid')) AS needs_attention, EXISTS (SELECT 1 FROM credit_ledger WHERE user_id=u.id @@ -69,7 +70,7 @@ export async function getBillingSummary(env: CreditEnv, userId: string): Promise FROM user u LEFT JOIN billing_accounts b ON b.user_id=u.id LEFT JOIN credit_accounts c ON c.user_id=u.id WHERE u.id=? `).bind(userId).first(); const plan = row?.plan === 'builder' ? 'builder' : 'starter'; const needsBillingAttention = row?.needs_attention === 1; @@ -77,7 +78,8 @@ export async function getBillingSummary(env: CreditEnv, userId: string): Promise plan, status: row?.status ?? 'inactive', creditBalance: row?.credit_balance ?? 0, - includedCredits: Number(plan === 'builder' ? env.BUILDER_MONTHLY_CREDITS : env.STARTER_ONBOARDING_CREDITS), + includedCredits: plan === 'builder' ? Number(env.BUILDER_MONTHLY_CREDITS) + : row?.onboarding_credits ?? Number(env.STARTER_ONBOARDING_CREDITS), cancelAtPeriodEnd: row?.cancel_at_period_end === 1, currentPeriodStart: row?.current_period_start ?? null, currentPeriodEnd: row?.current_period_end ?? null, diff --git a/platform/src/lib/entitlements.ts b/platform/src/lib/entitlements.ts index bad49c2d..74973273 100644 --- a/platform/src/lib/entitlements.ts +++ b/platform/src/lib/entitlements.ts @@ -1,5 +1,6 @@ import type { Plan } from '../types'; import { ApiError, now } from './http'; +import { signupAllowanceSql } from './signup-credits'; export interface Entitlements { plan: Plan; @@ -23,12 +24,14 @@ export interface CreditEnv { } export async function entitlements(env: CreditEnv, userId: string): Promise { - const row = await env.DB.prepare('SELECT plan FROM billing_accounts WHERE user_id = ?') - .bind(userId).first<{ plan: Plan }>(); + const row = await env.DB.prepare(`SELECT b.plan, ${signupAllowanceSql} AS onboarding_credits + FROM user u LEFT JOIN billing_accounts b ON b.user_id=u.id WHERE u.id=?`) + .bind(userId).first<{ plan: Plan; onboarding_credits: number | null }>(); const plan = row?.plan === 'builder' ? 'builder' : 'starter'; return { plan, - includedCredits: Number(plan === 'builder' ? env.BUILDER_MONTHLY_CREDITS : env.STARTER_ONBOARDING_CREDITS), + includedCredits: plan === 'builder' ? Number(env.BUILDER_MONTHLY_CREDITS) + : row?.onboarding_credits ?? Number(env.STARTER_ONBOARDING_CREDITS), creditGrant: plan === 'builder' ? 'billing-cycle' : 'onboarding', projectLimit: Number(plan === 'builder' ? env.BUILDER_PROJECT_LIMIT : env.STARTER_PROJECT_LIMIT), monitorLimit: Number(plan === 'builder' ? env.BUILDER_MONITOR_LIMIT : env.STARTER_MONITOR_LIMIT), diff --git a/platform/src/lib/notification-delivery.ts b/platform/src/lib/notification-delivery.ts index e1cc6f33..176f815e 100644 --- a/platform/src/lib/notification-delivery.ts +++ b/platform/src/lib/notification-delivery.ts @@ -54,10 +54,14 @@ interface NewAccount { export async function queueWelcomeEmail(env: Env, account: NewAccount): Promise { if (account.email.endsWith('@example.test')) return false; + const { signupGrant } = await import('./signup-credits'); + const credits = await signupGrant(env.DB, account.id); + if (credits === null) return false; + const { renderWelcomeEmail } = await import('./email-templates'); const content = await renderWelcomeEmail({ recipientName: account.name, - starterCredits: Number(env.STARTER_ONBOARDING_CREDITS), + starterCredits: credits, dashboardUrl: `${env.APP_ORIGIN}/dashboard`, agentUrl: `${env.APP_ORIGIN}/dashboard/sessions`, }); diff --git a/platform/src/lib/signup-credits.ts b/platform/src/lib/signup-credits.ts new file mode 100644 index 00000000..80db43fb --- /dev/null +++ b/platform/src/lib/signup-credits.ts @@ -0,0 +1,13 @@ +// Account reads use the allowance recorded at signup, so changing the offer +// never makes an older account appear to have received the new grant. +// The containing query must alias the user table as `u`. +export const signupAllowanceSql = `(SELECT CASE + WHEN operation_id='onboarding:v1' THEN COALESCE(json_extract(metadata_json,'$.allowance'),credits) + ELSE 0 END FROM credit_ledger WHERE id='onboarding:' || u.id AND user_id=u.id)`; + +/** Returning accounts carry their balance forward and receive no welcome grant. */ +export async function signupGrant(db: D1Database, userId: string): Promise { + return db.prepare(`SELECT credits FROM credit_ledger + WHERE user_id=? AND operation_id='onboarding:v1' AND entry_type='grant'`) + .bind(userId).first('credits'); +} diff --git a/platform/test/admin-access.integration.test.ts b/platform/test/admin-access.integration.test.ts index ab05346a..62986be4 100644 --- a/platform/test/admin-access.integration.test.ts +++ b/platform/test/admin-access.integration.test.ts @@ -30,15 +30,15 @@ describe('admin credit grants', () => { const before = await request(`/v1/admin/credits?user=${encodeURIComponent(customer.user.email.toUpperCase())}`, { headers: headers(admin.cookie) }); expect(before.status).toBe(200); expect(before.headers.get('cache-control')).toBe('no-store'); - expect(await before.json()).toMatchObject({ user: { ...customer.user, creditBalance: 1000 }, grants: [] }); + expect(await before.json()).toMatchObject({ user: { ...customer.user, creditBalance: 5000 }, grants: [] }); const response = await post({ ...input, adminUserId: customer.user.id }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ user: { id: customer.user.id, creditBalance: 11000 }, replayed: false, + expect(await response.json()).toMatchObject({ user: { id: customer.user.id, creditBalance: 15000 }, replayed: false, grant: { id: input.requestId, credits: 10000, reason: '', adminUserId: admin.user.id } }); const after = await request(`/v1/admin/credits?user=${customer.user.id}`, { headers: headers(admin.cookie) }); - expect(await after.json()).toMatchObject({ user: { creditBalance: 11000 }, grants: [{ id: input.requestId, reason: '', credits: 10000 }] }); + expect(await after.json()).toMatchObject({ user: { creditBalance: 15000 }, grants: [{ id: input.requestId, reason: '', credits: 10000 }] }); const balance = await env.DB.prepare('SELECT SUM(credits) AS balance FROM credit_ledger WHERE user_id=?').bind(customer.user.id).first<{ balance: number }>(); - expect(balance?.balance).toBe(11000); + expect(balance?.balance).toBe(15000); }); test('concurrent duplicate submissions grant once; changed payloads and cross-user reuse are rejected', async () => { @@ -51,8 +51,8 @@ describe('admin credit grants', () => { expect((await post({ ...input, reason: 'Different reason' })).status).toBe(409); const other = await session(); expect((await post({ ...input, userId: other.user.id, reason: 'Test allowance' })).status).toBe(409); - expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(customer.user.id).first()).toMatchObject({ available_credits: 11000 }); - expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(other.user.id).first()).toMatchObject({ available_credits: 1000 }); + expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(customer.user.id).first()).toMatchObject({ available_credits: 15000 }); + expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(other.user.id).first()).toMatchObject({ available_credits: 5000 }); }); test('validates credits and account existence and rejects untrusted origins and non-admin credentials', async () => { @@ -72,7 +72,7 @@ describe('admin credit grants', () => { } await env.DB.prepare("UPDATE user SET role='user' WHERE id=?").bind(admin.user.id).run(); expect((await post(input)).status).toBe(403); - expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(customer.user.id).first()).toMatchObject({ available_credits: 1000 }); + expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(customer.user.id).first()).toMatchObject({ available_credits: 5000 }); }); }); diff --git a/platform/test/admin-user-deletion.integration.test.ts b/platform/test/admin-user-deletion.integration.test.ts index 821da434..734837af 100644 --- a/platform/test/admin-user-deletion.integration.test.ts +++ b/platform/test/admin-user-deletion.integration.test.ts @@ -301,5 +301,5 @@ test('admin search returns each deleted signup separately alongside the active a expect(all.filter(u=>u.status==='deleted')).toHaveLength(26); expect(all.filter(u=>u.status==='deleted').every(u=>u.isAdmin===false)).toBe(true); const history=await (await request('/admin/users?q=history-00')).json(); - expect(history).toMatchObject({users:[{id:'history-00',status:'deleted',signedUpAt:123,deletedAt:expect.any(Number),creditBalance:1000,deletionSource:'admin',deletedByUserId:admin.user.id}],nextOffset:null}); + expect(history).toMatchObject({users:[{id:'history-00',status:'deleted',signedUpAt:123,deletedAt:expect.any(Number),creditBalance:5000,deletionSource:'admin',deletedByUserId:admin.user.id}],nextOffset:null}); }); diff --git a/platform/test/agent-memory-updater.integration.test.ts b/platform/test/agent-memory-updater.integration.test.ts index 26cada7d..97ad4bad 100644 --- a/platform/test/agent-memory-updater.integration.test.ts +++ b/platform/test/agent-memory-updater.integration.test.ts @@ -38,7 +38,7 @@ async function seed(name: string) { await reserveAgentCredits(env, userId, runId); instance.sql`INSERT INTO agent_runs (id,user_id,conversation_id,user_message_id,agent_message_id,turn_ordinal,message,status,phase, credits_remaining_at_admission,created_at,updated_at,research_deadline_at) - VALUES (${runId},${userId},${conversationId},${crypto.randomUUID()},${crypto.randomUUID()},${turn},${message},'running','executing',1000,0,0,0)`; + VALUES (${runId},${userId},${conversationId},${crypto.randomUUID()},${crypto.randomUUID()},${turn},${message},'running','executing',5000,0,0,0)`; instance.sql`INSERT INTO agent_routes VALUES (${runId},${JSON.stringify({ route: 'finalize', responseIntent: 'context_answer', reason: 'Stored context' })},0)`; internals(instance).sessionStore.beginRun(runId); return runId; diff --git a/platform/test/agent-runtime-do.integration.test.ts b/platform/test/agent-runtime-do.integration.test.ts index d0c28188..e2675125 100644 --- a/platform/test/agent-runtime-do.integration.test.ts +++ b/platform/test/agent-runtime-do.integration.test.ts @@ -34,7 +34,7 @@ async function seed(name: string, status = 'failed') { user_message_id,agent_message_id,turn_ordinal,message,status,phase, credits_remaining_at_admission,created_at,updated_at,research_deadline_at) VALUES (${runId},${userId},${conversationId},${crypto.randomUUID()},${crypto.randomUUID()}, - 1,'Private prompt',${status},'executing',1000,0,0,0)`; + 1,'Private prompt',${status},'executing',5000,0,0,0)`; instance.sql`INSERT INTO agent_tool_calls (run_id,tool_call_id,semantic_key,tool_name,operation,status,credits,created_at,updated_at) VALUES (${runId},'tool','meaning','get_video','video','completed',1,0,0)`; }); @@ -165,7 +165,7 @@ test('migrates stored agent message IDs without changing answers, history, or pa const saved = JSON.stringify({ runId, conversationId, userMessageId: row.user_message_id, assistantMessageId: agentMessageId, intent: 'inspect_video', answer: 'The saved answer.', confidence: 'medium', citations: [], artifacts: [], warnings: [], - billing: { creditsCharged: 1, creditsRemaining: 999 } }); + billing: { creditsCharged: 1, creditsRemaining: 4999 } }); instance.sql`UPDATE agent_runs SET result_json = ${saved}, billing_settled = 1 WHERE id = ${runId}`; instance.sql`INSERT INTO agent_tool_calls (run_id,tool_call_id,semantic_key,tool_name,operation,status,result_json,credits,created_at,updated_at) @@ -190,7 +190,7 @@ test('migrates stored agent message IDs without changing answers, history, or pa fiberId: 'migration-followup', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); const followup = await instance.startRun({ message: 'Explain that answer', conversationId, parentMessageId: agentMessageId }, - { userId, creditsRemaining: 999 }); + { userId, creditsRemaining: 4999 }); if ('rejected' in followup) throw Error(followup.message); expect(followup.agentMessageId).not.toBe(agentMessageId); expect(instance.sql`SELECT parent_message_id FROM agent_runs WHERE id = ${followup.runId}`[0]) @@ -209,7 +209,7 @@ test('restores provider metadata for follow-ups and validates historical citatio const saved = JSON.stringify({ runId, conversationId, userMessageId: parent.user_message_id, agentMessageId: parent.agent_message_id, intent: 'inspect_video', answer: 'A summary.', confidence: 'medium', citations: [], artifacts: [{ type: 'youtube_video_metadata', data: { id: 'abcdefghijk', viewCount: 999999 } }], - warnings: [], billing: { creditsCharged: 1, creditsRemaining: 999 } }); + warnings: [], billing: { creditsCharged: 1, creditsRemaining: 4999 } }); instance.sql`UPDATE agent_runs SET result_json = ${saved} WHERE id = ${runId}`; const packetId = `packet:${runId}:video`; const packet = JSON.stringify({ packetId, kind: 'youtube_video', @@ -221,7 +221,7 @@ test('restores provider metadata for follow-ups and validates historical citatio const fiber = vi.spyOn(instance, 'startFiber').mockResolvedValue({ fiberId: 'metadata-test', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true }); const receipt = await instance.startRun({ message: 'How many views did it have?', conversationId }, - { userId, creditsRemaining: 999 }); + { userId, creditsRemaining: 4999 }); if ('rejected' in receipt) throw Error(receipt.message); await reserveAgentCredits(env, userId, receipt.runId); const row = instance.sql`SELECT * FROM agent_runs WHERE id = ${receipt.runId}`[0]!; @@ -257,7 +257,7 @@ test('direct finalization restores cited frame evidence from ancestors and marks const saved = JSON.stringify({ runId, conversationId, userMessageId: parent.user_message_id, agentMessageId: parent.agent_message_id, intent: 'inspect_video', answer: 'The woman holds the microphone.', confidence: 'medium', citations: [citation], artifacts: [], warnings: [], - billing: { creditsCharged: 1, creditsRemaining: 999 } }); + billing: { creditsCharged: 1, creditsRemaining: 4999 } }); instance.sql`UPDATE agent_runs SET result_json = ${saved} WHERE id = ${runId}`; for (const id of ['frame-proof', 'uncited-proof']) { const packet = JSON.stringify({ packetId: id, kind: 'youtube_frames', @@ -271,7 +271,7 @@ test('direct finalization restores cited frame evidence from ancestors and marks status: 'running', createdAt: Date.now(), accepted: true }); try { const receipt = await instance.startRun({ message: 'Correct your earlier statement.', conversationId }, - { userId, creditsRemaining: 999 }); + { userId, creditsRemaining: 4999 }); if ('rejected' in receipt) throw Error(receipt.message); await reserveAgentCredits(env, userId, receipt.runId); const row = instance.sql`SELECT * FROM agent_runs WHERE id = ${receipt.runId}`[0]!; @@ -300,10 +300,10 @@ test('direct finalization restores cited frame evidence from ancestors and marks test('terminal run polling settles persisted evidence exactly once', async () => { const { runtime, userId, runId } = await seed('agent-settle-runtime'); expect(await runtime.getRun(runId)).toMatchObject({ status: 'failed', request: { message: 'Private prompt' } }); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); await runtime.getRun(runId); await runtime.reconcileRun(runId); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); }); test('progress restores the persisted phase and tool trace without inference or private diagnostics', async () => { @@ -344,7 +344,7 @@ test('pre-billing terminal runs are not charged retroactively', async () => { instance.sql`ALTER TABLE agent_runs DROP COLUMN billing_settled`; }); expect(await runtime.getRun(runId)).toMatchObject({ status: 'failed' }); - expect(await creditBalance(env, userId)).toBe(1000); + expect(await creditBalance(env, userId)).toBe(5000); }); test.each(['routing', 'executing', 'finalizing'])('migrates an active legacy %s run without restarting its phase clock', async phase => { @@ -370,7 +370,7 @@ test('recovers a D1 settlement committed before the SQLite checkpoint', async () const { runtime, userId, runId } = await seed('agent-cross-store-recovery'); await settleAgentCredits(env, userId, runId, 1, 0); await runtime.reconcileRun(runId); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); await runInDurableObject(runtime, async instance => { expect(instance.sql`SELECT billing_settled FROM agent_runs WHERE id = ${runId}`[0]) .toMatchObject({ billing_settled: 1 }); @@ -389,7 +389,7 @@ test('completed responses use the settled ledger balance, not their admission sn // Another account operation consumes credits between admission and settlement. await reserveAgentCredits(env, userId, 'another-run'); expect(await runtime.getRun(runId)).toMatchObject({ result: { billing: { - creditsCharged: 1, creditsRemaining: 977, + creditsCharged: 1, creditsRemaining: 4977, } } }); }); @@ -397,7 +397,7 @@ test('deadline watchdog settles abandoned runs without restarting inference', as const { runtime, userId, runId } = await seed('agent-watchdog-runtime', 'running'); await runtime.reconcileRun(runId); expect(await runtime.getRun(runId)).toMatchObject({ status: 'failed' }); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); }); test('account deletion clears runtime data and rejects delayed admissions', async () => { @@ -410,10 +410,10 @@ test('account deletion clears runtime data and rejects delayed admissions', asyn await runtime.deleteAccountData(); expect(await runtime.getRun(runId)).toBeNull(); expect(await runtime.getConversation(conversationId, userId)).toBeNull(); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); await runInDurableObject(runtime, async instance => { await expect(instance.startRun({ message: 'A delayed request', conversationId }, { - userId, creditsRemaining: 999, + userId, creditsRemaining: 4999, })).rejects.toThrow('deletion'); }); await runtime.deleteAccountData(); @@ -433,7 +433,7 @@ test('queued admissions retain their IDs and do not expire before classification const fiber = vi.spyOn(instance, 'startFiber').mockResolvedValue({ fiberId: identity.runId, name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); - const receipt = await instance.startRun(request, { userId, creditsRemaining: 1000 }, identity); + const receipt = await instance.startRun(request, { userId, creditsRemaining: 5000 }, identity); expect(receipt).toMatchObject({ request: { message: request.message }, runId: identity.runId, userMessageId: identity.userMessageId, agentMessageId: identity.agentMessageId, status: 'pending' }); expect(fiber).toHaveBeenCalledOnce(); fiber.mockRestore(); @@ -446,7 +446,7 @@ test('an interrupted pending admission retries startup without inserting a dupli const identity = { runId: crypto.randomUUID(), userMessageId: crypto.randomUUID(), agentMessageId: crypto.randomUUID(), admittedAt: Date.now() }; await runInDurableObject(runtime, async instance => { const fiber = vi.spyOn(instance, 'startFiber').mockRejectedValue(new Error('startup interrupted')); - const admission = { userId, creditsRemaining: 1000 }; + const admission = { userId, creditsRemaining: 5000 }; await expect(instance.startRun(request, admission, identity)).rejects.toThrow('startup interrupted'); await expect(instance.startRun(request, admission, identity)).rejects.toThrow('startup interrupted'); expect(fiber).toHaveBeenCalledTimes(2); @@ -464,7 +464,7 @@ test('keyless follow-ups create new runs while an active session still rejects o fiberId: 'stub', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); const request = { conversationId, message: 'Research five videos' }; - const admission = { userId, creditsRemaining: 1000 }; + const admission = { userId, creditsRemaining: 5000 }; const first = await instance.startRun(request, admission); if ('rejected' in first) throw new Error(first.message); expect(await instance.startRun(request, admission)).toMatchObject({ rejected: true, code: 'AGENT_CONVERSATION_BUSY' }); @@ -493,7 +493,7 @@ test('saves a ready answer and settles it idempotently', async () => { }); expect(await runtime.getRun(runId)).toMatchObject({ status: 'completed' }); await runtime.reconcileRun(runId); - expect(await creditBalance(env, userId)).toBe(999); + expect(await creditBalance(env, userId)).toBe(4999); }); @@ -582,8 +582,8 @@ test('persists an out-of-scope rejection and refunds the evidence credit reserva warnings: [{ code: 'OUT_OF_SCOPE', message: 'Bookings are outside YouTube video synthesis.' }] }); }); expect(await runtime.getRun(runId)).toMatchObject({ status: 'completed', route: { route: 'rejected' }, - result: { intent: 'rejected', citations: [], billing: { creditsCharged: 0, creditsRemaining: 1000 } } }); - expect(await creditBalance(env, userId)).toBe(1000); + result: { intent: 'rejected', citations: [], billing: { creditsCharged: 0, creditsRemaining: 5000 } } }); + expect(await creditBalance(env, userId)).toBe(5000); }); test('persists private transcript rejection details and retrieves only this run diagnostics', async () => { @@ -621,7 +621,7 @@ test('retry admission preserves the failed request, original display text, and s const fiber = vi.spyOn(instance, 'startFiber').mockResolvedValue({ fiberId: 'retry-test', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); - const admission = { userId, creditsRemaining: 999 }; + const admission = { userId, creditsRemaining: 4999 }; const receipt = await instance.startRun({ message: 'try again', conversationId }, admission); expect(receipt).not.toHaveProperty('rejected'); if ('rejected' in receipt) return; @@ -650,7 +650,7 @@ test('does not reinterpret a new task or a retry in another session as the faile }); for (const [message, session] of [['try again with a different video', conversationId], ['try again', crypto.randomUUID()]]) { const receipt = await instance.startRun({ message: message!, conversationId: session! }, - { userId, creditsRemaining: 999 }); + { userId, creditsRemaining: 4999 }); if ('rejected' in receipt) throw Error(receipt.message); expect(instance.sql`SELECT execution_message FROM agent_runs WHERE id = ${receipt.runId}`[0]) .toEqual({ execution_message: null }); @@ -666,7 +666,7 @@ test('legacy retry chains keep their original branch and completed retries remem // The failed request belongs to a valid completed branch. const result = JSON.stringify({ runId: crypto.randomUUID(), conversationId, userMessageId: crypto.randomUUID(), agentMessageId: parentId, intent: 'clarification', answer: 'Which comparison?', - confidence: 'low', citations: [], artifacts: [], warnings: [], billing: { creditsCharged: 0, creditsRemaining: 999 } }); + confidence: 'low', citations: [], artifacts: [], warnings: [], billing: { creditsCharged: 0, creditsRemaining: 4999 } }); instance.sql`INSERT INTO agent_runs (id,user_id,conversation_id,user_message_id,agent_message_id, turn_ordinal,message,status,phase,result_json,credits_remaining_at_admission,created_at,updated_at) VALUES ('parent',${userId},${conversationId},${crypto.randomUUID()},${parentId},0,'Earlier request','completed','completed',${result},999,0,0)`; @@ -679,7 +679,7 @@ test('legacy retry chains keep their original branch and completed retries remem fiberId: 'legacy-retry', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); const receipt = await instance.startRun({ message: 'retry', conversationId }, - { userId, creditsRemaining: 999 }); + { userId, creditsRemaining: 4999 }); if ('rejected' in receipt) throw Error(receipt.message); const row = instance.sql`SELECT * FROM agent_runs WHERE id = ${receipt.runId}`[0]!; expect(row).toMatchObject({ execution_message: 'Summarize https://youtu.be/abcdefghijk', parent_message_id: parentId }); @@ -1006,7 +1006,7 @@ test('restores display citations consistently without rewriting canonical answer agentMessageId: String(row.agent_message_id), intent: 'inspect_video', answer, confidence: 'medium', citations: ['frame:1', 'frame:2'].map(id => ({ id, sourceId: 'frames', provider: 'youtube', videoId: 'abcdefghijk', title: 'Saved video', excerpt: 'A person is visible.' })), - artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 999 } }; + artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 4999 } }; const literalUser = 'Explain the literal token [cite:example].'; instance.sql`UPDATE agent_runs SET result_json = ${JSON.stringify(result)}, message = ${literalUser} WHERE id = ${runId}`; const page = await instance.getConversation(conversationId, userId); @@ -1572,9 +1572,9 @@ test('a run stores the client time zone and older runs fall back to UTC', async fiberId: 'zone', name: 'agent-runtime-run', status: 'running', createdAt: Date.now(), accepted: true, }); const zoned = await instance.startRun({ message: 'Videos from this year', conversationId: crypto.randomUUID(), timeZone: 'Asia/Kolkata' }, - { userId, creditsRemaining: 1000 }); + { userId, creditsRemaining: 5000 }); const plain = await instance.startRun({ message: 'Videos from today', conversationId: crypto.randomUUID() }, - { userId, creditsRemaining: 1000 }); + { userId, creditsRemaining: 5000 }); if ('rejected' in zoned || 'rejected' in plain) throw new Error('run rejected'); const zone = (runId: string) => state.storage.sql.exec('SELECT time_zone FROM agent_runs WHERE id = ?', runId).toArray()[0]; expect(zone(zoned.runId)).toEqual({ time_zone: 'Asia/Kolkata' }); @@ -1979,7 +1979,7 @@ function insertFollowUps(instance: { sql: (strings: TemplateStringsArray, ...val const runId = crypto.randomUUID(), userMessageId = crypto.randomUUID(), agentMessageId = crypto.randomUUID(); const result = JSON.stringify({ runId, conversationId: options.conversationId, userMessageId, agentMessageId, intent: 'context_answer', answer: `Follow-up ${turn + 1}.`, confidence: 'medium', citations: options.citations, - artifacts: [], warnings: [], billing: { creditsCharged: 0, creditsRemaining: 999 } }); + artifacts: [], warnings: [], billing: { creditsCharged: 0, creditsRemaining: 4999 } }); instance.sql`INSERT INTO agent_runs (id,user_id,conversation_id,parent_message_id,user_message_id,agent_message_id, turn_ordinal,message,status,phase,result_json,credits_remaining_at_admission,created_at,updated_at,research_deadline_at) VALUES (${runId},${options.userId},${options.conversationId},${String(parent.agent_message_id)},${userMessageId},${agentMessageId}, @@ -2007,7 +2007,7 @@ test('preserves admitted cited lineage across bounded follow-ups, excludes uncit const parent = instance.sql`SELECT * FROM agent_runs WHERE id = ${runId}`[0]!; instance.sql`UPDATE agent_runs SET result_json = ${JSON.stringify({ runId, conversationId, userMessageId: parent.user_message_id, agentMessageId: parent.agent_message_id, intent: 'inspect_video', answer: 'A caption.', confidence: 'medium', - citations: [lineageCitation(cited)], artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 999 } })} WHERE id = ${runId}`; + citations: [lineageCitation(cited)], artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 4999 } })} WHERE id = ${runId}`; instance.sql`INSERT INTO agent_evidence_packets VALUES (${`packet:${runId}:cited`},${runId},'tool',${packet(`packet:${runId}:cited`, cited)},2000)`; instance.sql`INSERT INTO agent_evidence_packets VALUES (${`packet:${runId}:uncited`},${runId},'tool', ${packet(`packet:${runId}:uncited`, { ...cited, id: 'transcript:uncited', text: 'Uncited caption' })},2001)`; @@ -2046,7 +2046,7 @@ test('restores exactly delivered metadata snapshots and cited saved-session evid const parent = instance.sql`SELECT * FROM agent_runs WHERE id = ${runId}`[0]!; instance.sql`UPDATE agent_runs SET result_json = ${JSON.stringify({ runId, conversationId, userMessageId: parent.user_message_id, agentMessageId: parent.agent_message_id, intent: 'inspect_video', answer: 'A video.', confidence: 'medium', citations: [], - artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 999 } })} WHERE id = ${runId}`; + artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 4999 } })} WHERE id = ${runId}`; instance.sql`INSERT INTO agent_evidence_packets VALUES (${`packet:${runId}:video`},${runId},'tool',${JSON.stringify({ packetId: `packet:${runId}:video`, kind: 'youtube_video', sources: [{ id: 'video', provider: 'youtube', kind: 'video', videoId: lineageVideo, title: 'A video' }], excerpts: [], artifacts: [{ type: 'youtube_video_metadata', title: 'A video', data: { id: lineageVideo, viewCount: 404433 } }], @@ -2133,7 +2133,7 @@ test('conversation history carries only the inspection records its own turns pro const parent = instance.sql`SELECT * FROM agent_runs WHERE id = ${runId}`[0]!; instance.sql`UPDATE agent_runs SET result_json = ${JSON.stringify({ runId, conversationId, userMessageId: parent.user_message_id, agentMessageId: parent.agent_message_id, intent: 'inspect_video', answer: 'She holds a microphone.', confidence: 'medium', - citations: [], artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 999 } })} WHERE id = ${runId}`; + citations: [], artifacts: [], warnings: [], billing: { creditsCharged: 1, creditsRemaining: 4999 } })} WHERE id = ${runId}`; const frames = (packetId: string, basis: string) => JSON.stringify({ packetId, kind: 'youtube_frames', sources: [{ id: 'frames', provider: 'youtube', kind: 'frames', videoId: lineageVideo }], excerpts: [], warnings: [], usage: [], artifacts: [{ type: 'youtube_frame_retrieval', data: { requestedTimestampsMs: [200000], diff --git a/platform/test/auth-config.test.ts b/platform/test/auth-config.test.ts index efc89b11..6cd7a5fb 100644 --- a/platform/test/auth-config.test.ts +++ b/platform/test/auth-config.test.ts @@ -249,6 +249,7 @@ test('queues the sign-in link through the branded template', async () => { function welcomeAuthEnv() { return { + DB: { prepare: () => ({ bind: () => ({ first: async () => 5000 }) }) }, AUTH_BASE_URL: 'http://localhost:3000', APP_ORIGIN: 'http://localhost:3000', BETTER_AUTH_SECRET: 'test-secret-that-is-long-enough-for-tests', diff --git a/platform/test/auth-worker.integration.test.ts b/platform/test/auth-worker.integration.test.ts index 33655984..d117c499 100644 --- a/platform/test/auth-worker.integration.test.ts +++ b/platform/test/auth-worker.integration.test.ts @@ -268,7 +268,7 @@ describe('signed Polar webhook delivery', () => { expect(response.status).toBe(200); const billing = await request('/v1/billing', { headers: { cookie: browser.cookie } }); expect(await billing.json()).toMatchObject({ - plan: 'starter', creditBalance: 1000, canManageBilling: true, needsBillingAttention: true, + plan: 'starter', creditBalance: 5000, canManageBilling: true, needsBillingAttention: true, }); const invalid = JSON.parse(body); delete invalid.data.customer; @@ -290,7 +290,7 @@ describe('signed Polar webhook delivery', () => { expect(response.status).toBeLessThan(300); const billing = await request('/v1/billing', { headers: { cookie: browser.cookie } }); expect(await billing.json()).toMatchObject({ - plan: 'starter', status: 'inactive', creditBalance: 1000, canManageBilling: false, needsBillingAttention: false, + plan: 'starter', status: 'inactive', creditBalance: 5000, canManageBilling: false, needsBillingAttention: false, }); }); } @@ -316,7 +316,7 @@ describe('signed Polar webhook delivery', () => { } for (const browser of [payer, other]) { const summary = await request('/v1/billing', { headers: { cookie: browser.cookie } }); - expect(await summary.json()).toMatchObject({ plan: 'starter', creditBalance: 1000 }); + expect(await summary.json()).toMatchObject({ plan: 'starter', creditBalance: 5000 }); } const alerts = await request('/v1/notifications', { headers: { cookie: payer.cookie } }); expect(await alerts.json()).toMatchObject({ notifications: [] }); @@ -349,7 +349,7 @@ describe('signed Polar webhook delivery', () => { }); expect(oversized.status).toBe(413); const billing = await request('/v1/billing', { headers: { cookie: browser.cookie } }); - expect(await billing.json()).toMatchObject({ plan: 'starter', creditBalance: 1000, canManageBilling: false }); + expect(await billing.json()).toMatchObject({ plan: 'starter', creditBalance: 5000, canManageBilling: false }); }); test('returns a retryable failure with safe event diagnostics when a verified event cannot be applied', async () => { @@ -384,7 +384,7 @@ describe('signed Polar webhook delivery', () => { expect(response.status).toBe(400); } const billing = await request('/v1/billing', { headers: { cookie: browser.cookie } }); - expect(await billing.json()).toMatchObject({ plan: 'starter', creditBalance: 1000, canManageBilling: false }); + expect(await billing.json()).toMatchObject({ plan: 'starter', creditBalance: 5000, canManageBilling: false }); }); }); diff --git a/platform/test/billing.integration.test.ts b/platform/test/billing.integration.test.ts index f9b8001e..4d76650c 100644 --- a/platform/test/billing.integration.test.ts +++ b/platform/test/billing.integration.test.ts @@ -33,11 +33,11 @@ describe('Polar payment queries on D1', () => { await addCredits(userId, 20_000, 'admin:checkout-balance'); await syncCustomerState(env, activeCustomerState(userId)); await expect(getBillingSummary({ ...workerEnv, ...env }, userId)).resolves.toMatchObject({ - plan: 'builder', creditsReady: false, creditBalance: 21_000, + plan: 'builder', creditsReady: false, creditBalance: 25_000, }); await applyPaidOrder(env, paidOrder(userId, 'checkout-confirmed-order', new Date('2026-08-25T00:00:00Z'))); await expect(getBillingSummary({ ...workerEnv, ...env }, userId)).resolves.toMatchObject({ - plan: 'builder', creditsReady: true, creditBalance: 21_000, + plan: 'builder', creditsReady: true, creditBalance: 25_000, }); await env.DB.prepare("UPDATE credit_ledger SET created_at=? WHERE user_id=? AND operation_id GLOB 'polar:order:*'") .bind(new Date('2026-07-20T00:00:00Z').getTime(), userId).run(); @@ -163,7 +163,7 @@ describe('Polar payment queries on D1', () => { test.each(['paid', 'refund', 'paid and refund'])('preserves history when a newer customer snapshot arrives before %s', async (kind) => { const userId = `portal-out-of-order-${kind.replaceAll(' ', '-')}`; - await createUser(userId); + await createUser(userId, 1000); const customer = activeCustomerState(userId); customer.timestamp = new Date('2026-08-26T00:00:00Z'); customer.data.activeSubscriptions = []; @@ -187,7 +187,7 @@ describe('Polar payment queries on D1', () => { for (const kind of ['paid', 'refund'] as const) test.each(['valid', 'ownership mismatch', 'different customer'])( `repairs only history on an already acknowledged ${kind} replay: %s`, async (scenario) => { const userId = `portal-replay-${kind}-${scenario.replaceAll(' ', '-')}`; - await createUser(userId); + await createUser(userId, 1000); const customer = activeCustomerState(userId); customer.timestamp = new Date('2026-08-26T00:00:00Z'); customer.data.activeSubscriptions = []; @@ -221,12 +221,12 @@ describe('Polar payment queries on D1', () => { customer.data.activeSubscriptions = []; await syncCustomerState(env, customer); await expect(getBillingSummary({ ...workerEnv, ...env }, userId)).resolves.toMatchObject({ - plan: 'starter', canManageBilling: true, needsBillingAttention: true, creditBalance: 1000, + plan: 'starter', canManageBilling: true, needsBillingAttention: true, creditBalance: 5000, }); await syncSubscriptionState(env, subscriptionUpdate(userId, 'canceled', '2026-08-25T00:00:00Z')); await syncSubscriptionState(env, subscriptionUpdate(userId, status, '2026-08-24T12:00:00Z')); await expect(getBillingSummary({ ...workerEnv, ...env }, userId)).resolves.toMatchObject({ - plan: 'starter', canManageBilling: false, needsBillingAttention: false, creditBalance: 1000, + plan: 'starter', canManageBilling: false, needsBillingAttention: false, creditBalance: 5000, }); }); @@ -284,7 +284,7 @@ describe('Polar payment queries on D1', () => { await expect(account(userId)).resolves.toEqual(before); await expect(balance(userId)).resolves.toBe(beforeBalance); await expect(account(payer)).resolves.toBeNull(); - await expect(balance(payer)).resolves.toBe(1000); + await expect(balance(payer)).resolves.toBe(5000); await expect(operationCount(userId, `polar:order:${order.data.id}`)).resolves.toBe(0); await expect(operationCount(userId, `polar:refund:${order.data.id}`)).resolves.toBe(0); expect(logs).toHaveBeenCalledOnce(); @@ -314,12 +314,12 @@ describe('Polar payment queries on D1', () => { customer.data.activeSubscriptions[0]!.metadata = { app_user_id: userId }; await syncCustomerState(env, customer); await expect(account(userId)).resolves.toMatchObject({ plan: 'builder', status: 'active' }); - await expect(balance(userId)).resolves.toBe(1000); + await expect(balance(userId)).resolves.toBe(5000); const paid = paidOrder(userId, 'identity-correct-order', new Date('2026-08-25T00:00:00.000Z')); paid.data.metadata = { app_user_id: userId }; await applyPaidOrder(env, paid); await expect(balance(userId)).resolves.toBe(10_000); - await expect(balance(payer)).resolves.toBe(1000); + await expect(balance(payer)).resolves.toBe(5000); } finally { logs.mockRestore(); } }); @@ -330,7 +330,7 @@ describe('Polar payment queries on D1', () => { state.data.activeSubscriptions[0]!.metadata = { app_user_id: userId }; await syncCustomerState(env, state); await expect(account(userId)).resolves.toMatchObject({ plan: 'builder', status: 'active' }); - await expect(balance(userId)).resolves.toBe(1000); + await expect(balance(userId)).resolves.toBe(5000); }); test('uses the current configured 10,000-credit Builder allowance', async () => { @@ -347,7 +347,7 @@ describe('Polar payment queries on D1', () => { test('tops up Builder credits and makes Polar retries idempotent', async () => { const userId = 'payment-paid-user'; await createUser(userId); - await addCredits(userId, 6_500, 'test:opening-balance'); + await addCredits(userId, 2_500, 'test:opening-balance'); const payload = paidOrder(userId, 'paid-order'); payload.data.metadata = { app_user_id: userId }; @@ -367,7 +367,7 @@ describe('Polar payment queries on D1', () => { test('preserves balances already above the Builder allowance', async () => { const userId = 'payment-carry-user'; await createUser(userId); - await addCredits(userId, 24_000, 'test:opening-balance'); + await addCredits(userId, 20_000, 'test:opening-balance'); await applyPaidOrder(env, paidOrder(userId, 'carry-order')); @@ -410,11 +410,13 @@ function subscriptionUpdate(userId: string, status: WebhookSubscriptionUpdatedPa return { ...event, type: 'subscription.updated', timestamp: new Date(timestamp), data: { ...event.data, status } }; } -async function createUser(userId: string): Promise { +async function createUser(userId: string, openingCredits = 5000): Promise { const timestamp = Date.now(); await env.DB.prepare( 'INSERT INTO user (id,name,email,emailVerified,createdAt,updatedAt) VALUES (?,?,?,?,?,?)' ).bind(userId, userId, `${userId}@test.local`, 1, timestamp, timestamp).run(); + // Legacy replay scenarios start with the pre-launch allowance. + if (openingCredits !== 5000) await addCredits(userId, openingCredits - 5000, 'test:legacy-signup'); } async function addCredits(userId: string, credits: number, operationId: string): Promise { diff --git a/platform/test/credits.integration.test.ts b/platform/test/credits.integration.test.ts index ba01a342..361f81ef 100644 --- a/platform/test/credits.integration.test.ts +++ b/platform/test/credits.integration.test.ts @@ -8,6 +8,8 @@ import { afterEach, describe, expect, test } from 'vitest'; import { Hono } from 'hono'; import type { App } from '../src/types'; import { meterOperation } from '../src/lib/metering'; +import { getBillingSummary } from '../src/lib/billing'; +import { signupGrant } from '../src/lib/signup-credits'; import { creditBalance, entitlements, @@ -46,7 +48,7 @@ describe('credit queries on D1', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ saved: true }); expect(response.headers.get('X-Credits-Charged')).toBe('1'); - expect(response.headers.get('X-Credits-Remaining')).toBe('999'); + expect(response.headers.get('X-Credits-Remaining')).toBe('4999'); expect(tracked.calls()).toBe(2); expect(tracked.queries.join(' ')).not.toContain('billing_accounts'); expect(tracked.queries.join(' ')).not.toContain("'grant'"); @@ -56,9 +58,12 @@ describe('credit queries on D1', () => { test('signup grants credits before any API operation and repeated reads never regrant them', async () => { const id = 'signup-grant'; await createUser(id); - expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(id).first('available_credits')).toBe(1000); + expect(await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(id).first('available_credits')).toBe(5000); expect(await operationCount(id, 'onboarding:v1', 'grant')).toBe(1); - await addCredits(id, -1000, 'spend-signup-credits'); + await addCredits(id, -5000, 'spend-signup-credits'); + expect(await signupGrant(env.DB, id)).toBe(5000); + expect(await entitlements(env, id)).toMatchObject({ includedCredits: 5000 }); + expect(await getBillingSummary(env, id)).toMatchObject({ includedCredits: 5000, creditBalance: 0 }); const tracked = trackRoundTrips(env.DB); expect(await creditBalance({ ...env, DB: tracked.db }, id)).toBe(0); expect(tracked.calls()).toBe(1); @@ -81,7 +86,7 @@ describe('credit queries on D1', () => { await env.DB.prepare('DROP TRIGGER test_fail_signup_grant').run(); } await createUser(id); - expect(await creditBalance(env, id)).toBe(1000); + expect(await creditBalance(env, id)).toBe(5000); expect(await operationCount(id, 'onboarding:v1', 'grant')).toBe(1); }); @@ -93,7 +98,7 @@ describe('credit queries on D1', () => { settleCreditsAndReadBalance(env, id, 'operation', 10, 3, 0), settleCreditsAndReadBalance(env, id, 'operation', 10, 3, 0), ]); - expect(balances).toEqual([997, 997]); + expect(balances).toEqual([4997, 4997]); expect(await operationCount(id, 'operation', 'settle')).toBe(1); }); @@ -183,6 +188,17 @@ describe('credit queries on D1', () => { await applyD1Migrations(db, migrationEnv.TEST_MIGRATIONS); expect((await db.prepare('SELECT * FROM credit_ledger ORDER BY id').all()).results).toEqual(before.results); expect(await creditBalance({ ...env, DB: db }, 'empty')).toBe(0); + // Applying the launch migration must leave historical allowances and spent balances intact. + expect(await entitlements({ ...env, DB: db }, 'empty')).toMatchObject({ includedCredits: 1000 }); + expect(await getBillingSummary({ ...env, DB: db }, 'empty')).toMatchObject({ includedCredits: 1000, creditBalance: 0 }); + await db.prepare("INSERT INTO user (id,name,email,createdAt,updatedAt) VALUES ('launch-user','Launch','launch@migration.test',0,0)").run(); + expect(await signupGrant(db, 'launch-user')).toBe(5000); + expect(await creditBalance({ ...env, DB: db }, 'launch-user')).toBe(5000); + await db.prepare("DELETE FROM user WHERE id='launch-user'").run(); + await db.prepare("INSERT INTO user (id,name,email,createdAt,updatedAt) VALUES ('returning-launch','Launch','launch@migration.test',0,0)").run(); + expect(await signupGrant(db, 'returning-launch')).toBeNull(); + expect(await entitlements({ ...env, DB: db }, 'returning-launch')).toMatchObject({ includedCredits: 0 }); + expect(await creditBalance({ ...env, DB: db }, 'returning-launch')).toBe(5000); }); test('manual grants update the stored balance once even when retried concurrently', async () => { @@ -195,7 +211,7 @@ describe('credit queries on D1', () => { ON CONFLICT(user_id,operation_id,entry_type) DO NOTHING` ).bind(crypto.randomUUID(), id, Date.now()).run(); await Promise.all([grant(), grant()]); - expect(await creditBalance(env, id)).toBe(11000); + expect(await creditBalance(env, id)).toBe(15000); expect(await operationCount(id, 'manual-bonus', 'adjustment')).toBe(1); }); @@ -203,7 +219,7 @@ describe('credit queries on D1', () => { const id = 'data-parallel-balance'; await createUser(id); await setBuilderPlan(id); - await addCredits(id, -990, 'opening'); + await addCredits(id, -4990, 'opening'); const attempts = await Promise.allSettled([ reserveCredits(env, id, 'request-a', 7, {}), reserveCredits(env, id, 'request-b', 7, {}), @@ -219,16 +235,16 @@ describe('credit queries on D1', () => { const id = 'data-parallel-retry'; await createUser(id); await Promise.all([ - reserveCredits(env, id, 'request', 1000, {}), - reserveCredits(env, id, 'request', 1000, {}), + reserveCredits(env, id, 'request', 5000, {}), + reserveCredits(env, id, 'request', 5000, {}), ]); expect(await creditBalance(env, id)).toBe(0); expect(await operationCount(id, 'request', 'reserve')).toBe(1); await Promise.all([ - releaseCredits(env, id, 'request', 1000), - releaseCredits(env, id, 'request', 1000), + releaseCredits(env, id, 'request', 5000), + releaseCredits(env, id, 'request', 5000), ]); - expect(await creditBalance(env, id)).toBe(1000); + expect(await creditBalance(env, id)).toBe(5000); }); test('a failed balance update rolls back the ledger insertion', async () => { @@ -242,7 +258,7 @@ describe('credit queries on D1', () => { await expect(reserveCredits(env, id, 'failed-request', 7, {})).rejects.toThrow('test balance failure'); expect(await operationCount(id, 'failed-request', 'reserve')).toBe(0); expect((await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?') - .bind(id).first())?.available_credits).toBe(1000); + .bind(id).first())?.available_credits).toBe(5000); } finally { await env.DB.prepare('DROP TRIGGER test_reject_balance_update').run(); } @@ -257,7 +273,7 @@ describe('credit queries on D1', () => { VALUES ('rollback-entry',?,'rollback','adjustment',10000,0)`).bind(id), env.DB.prepare('INSERT INTO credit_accounts (user_id) VALUES (?)').bind('nonexistent-user'), ])).rejects.toThrow(); - expect(await creditBalance(env, id)).toBe(1000); + expect(await creditBalance(env, id)).toBe(5000); expect(await operationCount(id, 'rollback', 'adjustment')).toBe(0); }); @@ -292,7 +308,7 @@ describe('credit queries on D1', () => { ) INSERT INTO credit_ledger (id,user_id,operation_id,entry_type,credits,created_at) SELECT 'history-' || n, ?, 'history-' || n, 'adjustment', 1, 0 FROM entries`).bind(id).run(); const result = await env.DB.prepare('SELECT available_credits FROM credit_accounts WHERE user_id=?').bind(id).all(); - expect(result.results).toEqual([{ available_credits: 2000 }]); + expect(result.results).toEqual([{ available_credits: 6000 }]); expect(result.meta.rows_read).toBeLessThanOrEqual(3); }); @@ -300,9 +316,9 @@ describe('credit queries on D1', () => { const id = 'agent-settlement'; await createUser(id); await Promise.all([reserveAgentCredits(env, id, 'run'), reserveAgentCredits(env, id, 'run')]); - expect(await creditBalance(env, id)).toBe(1000 - AGENT_CREDIT_RESERVE); + expect(await creditBalance(env, id)).toBe(5000 - AGENT_CREDIT_RESERVE); await Promise.all([settleAgentCredits(env, id, 'run', 3, 42), settleAgentCredits(env, id, 'run', 3, 42)]); - expect(await creditBalance(env, id)).toBe(997); + expect(await creditBalance(env, id)).toBe(4997); expect(await reserveAgentCredits(env, id, 'run')).toBe(false); expect(await operationCount(id, 'agent:run', 'reserve')).toBe(1); expect(await operationCount(id, 'agent:run', 'settle')).toBe(1); @@ -314,14 +330,14 @@ describe('credit queries on D1', () => { await creditBalance(env, id); await settleAgentCredits(env, id, 'cancelled-run', 0, 0); expect(await reserveAgentCredits(env, id, 'cancelled-run')).toBe(false); - expect(await creditBalance(env, id)).toBe(1000); + expect(await creditBalance(env, id)).toBe(5000); }); test('parallel agent runs cannot overdraw an account', async () => { const id = 'agent-parallel-balance'; await createUser(id); await setBuilderPlan(id); - await addCredits(id, AGENT_CREDIT_RESERVE - 1000, 'test:agent-funds'); + await addCredits(id, AGENT_CREDIT_RESERVE - 5000, 'test:agent-funds'); const attempts = await Promise.allSettled([ reserveAgentCredits(env, id, 'run-a'), reserveAgentCredits(env, id, 'run-b'), ]); @@ -336,15 +352,15 @@ describe('credit queries on D1', () => { const userId = 'payment-starter-credit-user'; await createUser(userId); - await expect(creditBalance(env, userId)).resolves.toBe(1_000); - await expect(creditBalance(env, userId)).resolves.toBe(1_000); + await expect(creditBalance(env, userId)).resolves.toBe(5_000); + await expect(creditBalance(env, userId)).resolves.toBe(5_000); await expect(operationCount(userId, 'onboarding:v1', 'grant')).resolves.toBe(1); }); test('reads preserve adjustments instead of topping the account back up', async () => { const userId = 'payment-legacy-credit-user'; await createUser(userId); - await addCredits(userId, -904, 'test:remaining-balance'); + await addCredits(userId, -4904, 'test:remaining-balance'); await expect(creditBalance(env, userId)).resolves.toBe(96); }); @@ -352,7 +368,7 @@ describe('credit queries on D1', () => { test('does not grant Builder credits outside a paid-order webhook', async () => { const userId = 'payment-builder-credit-user'; await createUser(userId); - await addCredits(userId, -600, 'test:builder-balance'); + await addCredits(userId, -4600, 'test:builder-balance'); await setBuilderPlan(userId); await expect(entitlements(env, userId)).resolves.toMatchObject({ @@ -369,25 +385,25 @@ describe('credit queries on D1', () => { await reserveCredits(env, userId, 'operation:one', 200, { kind: 'test' }); await reserveCredits(env, userId, 'operation:one', 200, { kind: 'test' }); - await expect(creditBalance(env, userId)).resolves.toBe(800); + await expect(creditBalance(env, userId)).resolves.toBe(4800); await settleCredits(env, userId, 'operation:one', 200, 120, 500); await settleCredits(env, userId, 'operation:one', 200, 120, 500); - await expect(creditBalance(env, userId)).resolves.toBe(880); + await expect(creditBalance(env, userId)).resolves.toBe(4880); await reserveCredits(env, userId, 'operation:two', 100, { kind: 'test' }); await releaseCredits(env, userId, 'operation:two', 100); await releaseCredits(env, userId, 'operation:two', 100); - await expect(creditBalance(env, userId)).resolves.toBe(880); + await expect(creditBalance(env, userId)).resolves.toBe(4880); }); test('rejects a reservation larger than the available balance', async () => { const userId = 'payment-insufficient-credit-user'; await createUser(userId); - await expect(reserveCredits(env, userId, 'operation:too-large', 1_001, {})) + await expect(reserveCredits(env, userId, 'operation:too-large', 5_001, {})) .rejects.toMatchObject({ status: 402, code: 'INSUFFICIENT_CREDITS' }); - await expect(creditBalance(env, userId)).resolves.toBe(1_000); + await expect(creditBalance(env, userId)).resolves.toBe(5_000); }); }); diff --git a/platform/test/deletion-ledger.integration.test.ts b/platform/test/deletion-ledger.integration.test.ts index 35a5b9ed..3b321d0a 100644 --- a/platform/test/deletion-ledger.integration.test.ts +++ b/platform/test/deletion-ledger.integration.test.ts @@ -21,7 +21,7 @@ async function history(email: string) { test.each([340,0,-7,20000])('re-registration restores %i credits and never grants another onboarding allowance', async balance => { const email=`carry-${balance}@example.test`, first=`carry-first-${balance}`, second=`carry-second-${balance}`, third=`carry-third-${balance}`; - await create(first,email); await adjust(first,balance-1000); + await create(first,email); await adjust(first,balance-5000); await deleteAccountRecords(env.DB,{id:first,email}); const original=await history(email); expect(original).toHaveLength(1); @@ -47,7 +47,7 @@ test('outstanding reservations are not refunded into another signup and database const email='reserved-return@example.test'; await create('reserved-first',email); await env.DB.prepare(`INSERT INTO billing_accounts (user_id,provider_customer_id,plan,provider_updated_at,updated_at) VALUES ('reserved-first','polar-customer-reference','builder',0,0)`).run(); - await reserveCredits(credits,'reserved-first','in-flight',800,{}); + await reserveCredits(credits,'reserved-first','in-flight',4800,{}); await env.DB.prepare("DELETE FROM user WHERE id='reserved-first'").run(); expect((await history(email))[0]).toMatchObject({remaining_credits:200,polar_customer_id:'polar-customer-reference',plan:'builder',deletion_source:'database',deleted_by_user_id:null}); await create('reserved-second',email); @@ -56,7 +56,7 @@ test('outstanding reservations are not refunded into another signup and database }); test('ledger failure rolls back final deletion and a retry records one history row', async () => { - const id='failed-ledger-delete',email='failed-ledger-delete@example.test';await create(id,email);await adjust(id,-600); + const id='failed-ledger-delete',email='failed-ledger-delete@example.test';await create(id,email);await adjust(id,-4600); await env.DB.prepare(`CREATE TRIGGER test_fail_deletion_audit BEFORE INSERT ON deleted_accounts WHEN NEW.user_id='failed-ledger-delete' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`).run(); try { @@ -69,7 +69,7 @@ test('ledger failure rolls back final deletion and a retry records one history r }); test('returning signup is atomic, does not reuse deleted IDs, and cannot duplicate normalized emails concurrently', async () => { - const email='atomic-return@example.test';await create('atomic-first',email);await adjust('atomic-first',-1000); + const email='atomic-return@example.test';await create('atomic-first',email);await adjust('atomic-first',-5000); await deleteAccountRecords(env.DB,{id:'atomic-first',email}); await expect(create('atomic-first',email)).rejects.toThrow('Deleted user IDs cannot be reused'); await env.DB.prepare(`CREATE TRIGGER test_fail_returning_credit BEFORE INSERT ON credit_ledger @@ -88,12 +88,12 @@ test('returning signup is atomic, does not reuse deleted IDs, and cannot duplica test('real Better Auth signups get fresh identities and carry the last balance across repeated deletion', async () => { const email='auth-repeat-signup@example.test';const ids:string[]=[]; - for(const expected of [1000,350,200]) { + for(const expected of [5000,350,200]) { const response=await SELF.fetch('http://auth.test/__test/session',{method:'POST',body:JSON.stringify({email})}); expect(response.status).toBe(200); const {user}=await response.json() as {user:{id:string;email:string}};ids.push(user.id); expect(await creditBalance(credits,user.id)).toBe(expected); - if(expected===1000) await adjust(user.id,-650); else if(expected===350) await adjust(user.id,-150); + if(expected===5000) await adjust(user.id,-4650); else if(expected===350) await adjust(user.id,-150); await deleteAccountRecords(env.DB,user); } expect(new Set(ids).size).toBe(3); expect(await history(email)).toHaveLength(3); @@ -116,7 +116,7 @@ test('migration preserves existing balances and backfills no invented deletion h test('credit carryover uses the latest recorded deletion even when deletion timestamps disagree', async () => { - const email='ordered-history@example.test';await create('ordered-first',email);await adjust('ordered-first',-100); + const email='ordered-history@example.test';await create('ordered-first',email);await adjust('ordered-first',-4100); await deleteAccountRecords(env.DB,{id:'ordered-first',email}); // Simulate clock skew or a lower-precision timestamp from an older Worker. await env.DB.prepare("UPDATE deleted_accounts SET deleted_at=9999999999999 WHERE user_id='ordered-first'").run(); @@ -128,7 +128,7 @@ test('credit carryover uses the latest recorded deletion even when deletion time test('idempotent user inserts for an existing account do not fail or refill its balance', async () => { - const id='idempotent-existing-user',email='idempotent-existing@example.test';await create(id,email);await adjust(id,-700); + const id='idempotent-existing-user',email='idempotent-existing@example.test';await create(id,email);await adjust(id,-4700); await env.DB.prepare(`INSERT OR IGNORE INTO user (id,name,email,createdAt,updatedAt) VALUES (?,'Demo',?,0,0)`).bind(id,email).run(); expect(await creditBalance(credits,id)).toBe(300); }); @@ -161,7 +161,7 @@ test('review: an authenticated admin email change cannot transfer a deleted bala const response=await SELF.fetch('http://auth.test/__test/session',{method:'POST',body:JSON.stringify({email:'review-email-admin@example.test'})}); const admin=await response.json() as {user:{id:string};cookie:string}; await env.DB.prepare("UPDATE user SET role='admin' WHERE id=?").bind(admin.user.id).run(); - const email='review-original@example.test';await create('review-first',email);await adjust('review-first',-660); + const email='review-original@example.test';await create('review-first',email);await adjust('review-first',-4660); await deleteAccountRecords(env.DB,{id:'review-first',email});await create('review-second',email); const auth=createAuth(env,{waitUntil:()=>{}}); await auth.api.adminUpdateUser({body:{userId:'review-second',data:{email:'review-renamed@example.test'}},headers:new Headers({cookie:admin.cookie,origin:env.APP_ORIGIN})}); @@ -306,7 +306,7 @@ test('a refund for a different Polar customer never changes the transferred bala }); test('a failed successor signup rolls back its nonzero carryover claim', async () => { - const email='failed-transfer-claim@example.test';await create('failed-claim-first',email);await adjust('failed-claim-first',-660); + const email='failed-transfer-claim@example.test';await create('failed-claim-first',email);await adjust('failed-claim-first',-4660); await deleteAccountRecords(env.DB,{id:'failed-claim-first',email}); await env.DB.prepare(`CREATE TRIGGER test_failed_transfer_claim BEFORE INSERT ON credit_ledger WHEN NEW.user_id='failed-claim-second' BEGIN SELECT RAISE(ABORT,'opening balance unavailable'); END`).run(); @@ -422,6 +422,9 @@ test.each(['unclaimed','transferred'] as const)('real migration retains legacy p await db.prepare("INSERT INTO user (id,name,email,createdAt,updatedAt) VALUES (?,'Legacy',?,0,0)").bind(id,email).run(); await db.prepare("INSERT INTO billing_accounts (user_id,provider_customer_id,plan,status,provider_updated_at,updated_at) VALUES (?,?,'builder','active',?,?)") .bind(id,`customer-${id}`,newerAt,newerAt).run(); + // This shared migration database may already have the launch trigger from an earlier case. + // Model the legacy account's original 1,000-credit balance explicitly. + await db.prepare("UPDATE credit_ledger SET credits=1000,metadata_json='{\"allowance\":1000}' WHERE user_id=? AND operation_id='onboarding:v1'").bind(id).run(); for(const [orderId,amount,at] of [[`order-${id}`,9000,new Date('2026-10-14T10:01:00Z').getTime()],[`newer-${id}`,0,newerAt+60000]] as const) { await db.prepare("INSERT INTO credit_ledger (id,user_id,operation_id,entry_type,credits,metadata_json,created_at) VALUES (?,?,?,'grant',?,'{\"kind\":\"billing-cycle\"}',?)") .bind(orderId,id,`polar:order:${orderId}`,amount,at).run(); diff --git a/platform/test/entitlements.test.ts b/platform/test/entitlements.test.ts index 493a5689..d8bd0822 100644 --- a/platform/test/entitlements.test.ts +++ b/platform/test/entitlements.test.ts @@ -6,7 +6,7 @@ class CreditDatabase { prepare(sql: string) { this.queries.push(sql); return { bind: () => ({ first: async () => { - if (sql.includes('SELECT plan FROM billing_accounts')) return { plan: this.plan }; + if (sql.includes('AS onboarding_credits')) return { plan: this.plan, onboarding_credits: 1000 }; if (sql.includes('SELECT available_credits AS balance FROM credit_accounts')) return { balance: this.balance }; throw new Error('Unexpected credit query'); } }) }; diff --git a/platform/test/welcome-email.test.ts b/platform/test/welcome-email.test.ts index 9f5faa8a..116d2c10 100644 --- a/platform/test/welcome-email.test.ts +++ b/platform/test/welcome-email.test.ts @@ -40,10 +40,11 @@ describe('welcome email', () => { }); describe('welcome email queueing', () => { - function welcomeEnv(send = vi.fn(async () => undefined)) { + function welcomeEnv(send = vi.fn(async () => undefined), credits: number | null = 5000) { return { env: { APP_ORIGIN: 'https://www.video2ctx.dev', + DB: { prepare: () => ({ bind: () => ({ first: async () => credits }) }) }, STARTER_ONBOARDING_CREDITS: '1000', WELCOME_EMAIL_FROM: 'himanshu@notify.video2ctx.dev', EMAIL_TASKS: { send }, @@ -70,7 +71,7 @@ describe('welcome email queueing', () => { }); expect(message.unsubscribeUrl).toBeUndefined(); expect(message.html).toContain('https://www.video2ctx.dev/dashboard/sessions'); - expect(message.text).toContain('1,000 free credits'); + expect(message.text).toContain('5,000 free credits'); }); test('uses a neutral subject when the account has no name', async () => { @@ -81,6 +82,12 @@ describe('welcome email queueing', () => { expect((send.mock.calls[0] as unknown as [Record])[0].subject).toBe('Welcome to video2ctx'); }); + test('does not advertise a new gift to a returning account', async () => { + const { env, send } = welcomeEnv(undefined, null); + expect(await queueWelcomeEmail(env, { id: 'returning', email: 'returning@customer.test', name: 'Ari' })).toBe(false); + expect(send).not.toHaveBeenCalled(); + }); + test('skips test accounts', async () => { const { env, send } = welcomeEnv(); diff --git a/reference/engineering/CREDIT_ACCOUNTING.md b/reference/engineering/CREDIT_ACCOUNTING.md index 62db98d4..ed0d27a6 100644 --- a/reference/engineering/CREDIT_ACCOUNTING.md +++ b/reference/engineering/CREDIT_ACCOUNTING.md @@ -19,7 +19,7 @@ sequenceDiagram participant App as Platform Worker participant DB as D1 App->>DB: Create user at signup - Note over DB: Create balance row and grant 1,000 credits atomically + Note over DB: Create balance row and grant signup credits atomically DB-->>App: Account ready App->>DB: Insert reservation if stored balance covers cost Note over DB: Insert ledger entry and trigger balance deduction
Commit both or roll back both @@ -56,7 +56,7 @@ confirmed before changing shared state. Apply `platform/migrations/0018_signup_credit_grant.sql` before deploying the Worker that removes lazy grants. The standard `deploy:production` command runs -account migrations first. New user creation, the balance row, and the 1,000-credit +account migrations first. New user creation, the balance row, and the recorded signup grant commit together. A grant failure rolls back signup so it can retry; an account cannot be created successfully without its grant. @@ -67,11 +67,25 @@ they have spent their allowance. Existing adjustments count toward this one-time catch-up. The unique ledger operation prevents an old Worker from granting twice during rollout. Retain the trigger on Worker rollback. -The SQL trigger uses the current 1,000-credit signup policy. Future allowance -changes must update the trigger in a new migration and update -`STARTER_ONBOARDING_CREDITS` together; changing the environment variable alone -only affects entitlement display and refund policy. Builder top-ups, refund -resets, and per-operation credit prices are unchanged. +Migration `0027_launch_signup_credits.sql` changes new signup grants to 5,000 for +the Product Hunt launch offer. It changes only the signup trigger, with no +backfill or date check. Existing grants and balances remain unchanged. The +grant metadata records its allowance and `product-hunt-launch-2026` offer ID. +Account displays and welcome messages read the recorded allowance. Returning +accounts receive their carryover, without another gift or grant email. +`STARTER_ONBOARDING_CREDITS` stays at 1,000 for legacy fallback and refund policy. +Builder top-ups, refund resets, and per-operation credit prices are unchanged. + +### Ending the launch offer + +The offer ends through a separate PR. Add a new migration restoring 1,000 for +future signups, leaving existing grants intact. Restore the hero's standard copy: +"Get started for free" and "1,000 credits · No card required". Remove the Product +Hunt offer link and restore the standard pricing, FAQ, demo and closing copy. +The standard welcome toast remains in `web/lib/welcome-toast.ts` and is selected +from the account's recorded grant, so existing launch users retain accurate messages. +The advertised launch week is October 21 through 28, 2026. There is deliberately +no scheduled expiry, countdown, or automatic copy change. ## Manual credits @@ -179,14 +193,14 @@ cascades to both their account and ledger. ## Deleted-account credit continuity Migration `0026_deleted_accounts.sql` replaces the signup grant trigger. A new -email receives the one-time 1,000-credit grant. A returning email receives an +email receives the one-time signup grant, raised to 5,000 by migration 0027. A returning email receives an `adjustment` with operation ID `returning-account:v1`, equal to the newest recorded deletion's unclaimed carryover balance. Database insertion order, rather than wall-clock timestamps, determines the latest record using a monotonic sequence. Signup claims that snapshot for the new user ID in the same transaction. The claim survives admin email changes. A signup using an already claimed email snapshot starts at zero and cannot claim an older snapshot or another free grant. -Zero, negative and balances above 1,000 are preserved. Signup, the opening ledger entry and the projected account balance +Zero, negative and balances above the signup allowance are preserved. Signup, the opening ledger entry and the projected account balance remain one atomic D1 operation. Ordinary balance reads and reservations keep their existing round trips. Builder subscription state is not restored. diff --git a/web/app/_directions/craft-demo.tsx b/web/app/_directions/craft-demo.tsx index 4cd7b123..95c33a47 100644 --- a/web/app/_directions/craft-demo.tsx +++ b/web/app/_directions/craft-demo.tsx @@ -161,7 +161,7 @@ export function CraftDemo() { ) : limitReached ? (

- You have reached the free daily limit. Sign in to get 1,000 free + You have reached the free daily limit. Sign in to get 5,000 free credits.

Go to dashboard @@ -312,7 +312,7 @@ function CraftResult({ result }: { result: DemoResponse }) { transcript.segments.length, )} noun='segments' - offer='Get the full transcript with 1,000 free credits' + offer='Get the full transcript with 5,000 free credits' complete='Full transcript returned' signedIn={signedIn} /> @@ -404,7 +404,7 @@ function CraftResult({ result }: { result: DemoResponse }) { Math.min(visibleCommentCount, comments.comments.length), )} noun='comments' - offer='Get them all with 1,000 free credits' + offer='Get them all with 5,000 free credits' complete='All returned comments shown' signedIn={signedIn} // The offer waits until the sample runs out, so the panel diff --git a/web/app/_directions/craft-faq.tsx b/web/app/_directions/craft-faq.tsx index 49eb4dbe..91b2ab08 100644 --- a/web/app/_directions/craft-faq.tsx +++ b/web/app/_directions/craft-faq.tsx @@ -6,8 +6,8 @@ const FAQ = [ { - q: 'How far do 1,000 free credits go?', - a: 'Most reads cost 1 credit, and a fresh search or comments request costs 2. In our tests, a cited Video Agent answer about one video used 4 credits and a three-video comparison used 3, so the free grant covers a few hundred questions. No card is needed, and there is one grant per account.', + q: 'How far do the free launch credits go?', + a: 'The launch gift includes 5,000 credits. Most reads cost 1 credit, and a fresh search or comments request costs 2. In our tests, a cited Video Agent answer about one video used 4 credits and a three-video comparison used 3, but the cost varies with the question and the work needed. No card is needed, and there is one grant per account.', }, { q: 'Do I need an account?', diff --git a/web/app/_directions/craft-pricing.tsx b/web/app/_directions/craft-pricing.tsx index c32dbe9a..34063244 100644 --- a/web/app/_directions/craft-pricing.tsx +++ b/web/app/_directions/craft-pricing.tsx @@ -19,7 +19,7 @@ const PLANS: PricingPlan[] = [ name: 'Starter', price: 'Free', cadence: 'No card required', - credits: '1,000 credits once', + credits: '5,000 credits once during launch week', description: 'Explore complete video data in the dashboard and API before committing.', features: [ 'Dashboard and API access', diff --git a/web/app/_directions/craft.tsx b/web/app/_directions/craft.tsx index fb269ce2..07b9c675 100644 --- a/web/app/_directions/craft.tsx +++ b/web/app/_directions/craft.tsx @@ -21,7 +21,7 @@ import { CraftNav } from './craft-nav'; import { CraftPricing } from './craft-pricing'; import { Flashlight } from './flashlight'; import { FlickeringPixelText } from '../_components/flickering-pixel-text'; -import { ArrowRight } from '@phosphor-icons/react/ssr'; +import { ArrowRight, Gift } from '@phosphor-icons/react/ssr'; /* The three things video2ctx does, in the order the page shows them. */ const PILLARS = [ @@ -225,12 +225,14 @@ export function CraftDirection() {

Everything your agent needs to understand videos - Get started for free - - 1,000 credits · No card required - + + + Product Hunt launch + + No card required +

@@ -415,7 +417,7 @@ export function CraftDirection() {

Ask across videos, read what’s on screen, and catch new uploads.
- Start with 1,000 free credits. + Start with $10 in free credits during our Product Hunt launch week.

diff --git a/web/app/craft.css b/web/app/craft.css index c8153870..325a5e87 100644 --- a/web/app/craft.css +++ b/web/app/craft.css @@ -689,37 +689,47 @@ html:has(.craft) { text-wrap: balance; } -/* The free offer, set as quietly as the open-source note it replaced so it - * reads as a footnote to the claim. It is a link because "Get started" reads - * as an action, and a phrase that looks clickable should go somewhere. */ +/* Signup and Product Hunt are separate links within the hero offer. */ .craft-offer { width: fit-content; margin: var(--space-xs) auto 0; color: var(--ink-muted); display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; justify-content: center; + gap: 0.35em; text-decoration: none; transition: color var(--control) var(--out); font-family: var(--font-home-body); font-size: 0.8125rem; font-weight: 450; line-height: 1.35; - white-space: nowrap; + text-wrap: balance; } -/* Two phrases that only ever break between each other, so a narrow screen - * gets two clean lines instead of "1,000" stranded from "credits". */ -.craft-offer-terms { - display: inline-flex; - align-items: center; - gap: 0.35em; +.craft-offer > svg { + flex-shrink: 0; +} + +.craft-offer-details { + display: block; + margin-top: 0.35em; + color: var(--ink-muted); + font-family: var(--font-home-body); + font-size: 0.75rem; + font-weight: 450; + line-height: 1.5; } -.craft-offer-terms::before { - content: '·'; - margin-inline: 0.35em 0; +.craft-offer-details a { + color: inherit; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.craft-offer-details > span:last-child { + white-space: nowrap; } /* ------------------------------------------------------------------ form */ @@ -3477,14 +3487,6 @@ html:has(.craft) { display: none; } - .craft-offer { - flex-direction: column; - } - - .craft-offer-terms::before { - content: none; - } - .craft-run-sources, .craft-case .craft-run-sources { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/web/app/dashboard/WelcomeToast.tsx b/web/app/dashboard/WelcomeToast.tsx index 3c073fda..e2f204f7 100644 --- a/web/app/dashboard/WelcomeToast.tsx +++ b/web/app/dashboard/WelcomeToast.tsx @@ -2,29 +2,32 @@ import { useEffect, useRef } from 'react'; import { toast } from 'sonner'; -import { isWelcomeVisit, withoutWelcomeParam } from '../../lib/welcome-toast'; +import { isWelcomeVisit, welcomeMessage, withoutWelcomeParam } from '../../lib/welcome-toast'; import { burstConfetti, whenSettled } from './confetti'; +import { useAccountResource } from './DashboardDataProvider'; const TOAST_CLASS = 'welcome-toast'; /** Greets an account on the dashboard visit that follows its signup. */ export function WelcomeToast({ name }: { name?: string | null }) { + const { data: usage, ready, error } = useAccountResource('usage', null); const shown = useRef(false); useEffect(() => { if (shown.current || !isWelcomeVisit(window.location.search)) return; + if (!ready && !error) return; shown.current = true; window.history.replaceState(window.history.state, '', withoutWelcomeParam(`${window.location.pathname}${window.location.search}${window.location.hash}`)); - const firstName = name?.trim().split(/\s+/)[0]; - toast(firstName ? `Welcome to video2ctx, ${firstName}! 🎉` : 'Welcome to video2ctx! 🎉', { + const message = welcomeMessage(name, usage?.plan === 'starter' ? usage.includedCredits : null); + toast(message.title, { id: 'welcome', className: TOAST_CLASS, - description: "1,000 free credits are in your account. Can't wait to see what you build.", + description: message.description, }); // Burst from just inside the toast's top edge, centred, once it has slid into // place. Not cancelled on cleanup: React's development double-run would drop it. whenSettled(`.${TOAST_CLASS}`, (rect) => burstConfetti(rect.left + rect.width / 2, rect.top + 12)); - }, [name]); + }, [name, usage, ready, error]); return null; } diff --git a/web/app/dashboard/settings/SettingsCards.tsx b/web/app/dashboard/settings/SettingsCards.tsx index 26bbf21a..f1472229 100644 --- a/web/app/dashboard/settings/SettingsCards.tsx +++ b/web/app/dashboard/settings/SettingsCards.tsx @@ -104,7 +104,7 @@ export function BillingCard({ promise }: { promise: NonNullable + : `${billing?.includedCredits ? `Includes ${formatNumber(billing.includedCredits)} one-time signup credits. ` : ''}Upgrade to Builder for a 10,000 credit monthly allowance and higher workspace limits.`}

{billing?.plan === 'builder' && billing.currentPeriodEnd &&

{billing.cancelAtPeriodEnd ? `Builder remains active until ${formatBillingDate(billing.currentPeriodEnd)}.` : `Next renewal: ${formatBillingDate(billing.currentPeriodEnd)}.`}

} diff --git a/web/lib/welcome-toast.test.ts b/web/lib/welcome-toast.test.ts index 80aa25f0..4eecb527 100644 --- a/web/lib/welcome-toast.test.ts +++ b/web/lib/welcome-toast.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { isWelcomeVisit, newUserReturnPath, withoutWelcomeParam } from './welcome-toast.ts'; +import { isWelcomeVisit, newUserReturnPath, welcomeMessage, withoutWelcomeParam } from './welcome-toast.ts'; describe('welcome toast marker', () => { test('marks the return path for newly created accounts', () => { @@ -20,3 +20,20 @@ describe('welcome toast marker', () => { assert.equal(withoutWelcomeParam('/dashboard/sessions?q=chess&welcome=1#latest'), '/dashboard/sessions?q=chess#latest'); }); }); + + +describe('welcome credit message', () => { + test('uses the launch gift for a recorded 5,000-credit grant', () => { + assert.deepEqual(welcomeMessage('Ari Lee', 5000), { + title: 'Your $10 launch gift is ready!', + description: "We've added 5,000 free credits to your account.", + }); + }); + test('preserves the standard copy for older signups', () => { + assert.equal(welcomeMessage('Ari Lee', 1000).title, 'Welcome to video2ctx, Ari! 🎉'); + assert.equal(welcomeMessage('Ari Lee', 1000).description, "1,000 free credits are in your account. Can't wait to see what you build."); + }); + test('does not promise credits for a returning account or an unavailable allowance', () => { + for (const credits of [0, null]) assert.equal(welcomeMessage(null, credits).description, "Can't wait to see what you build."); + }); +}); diff --git a/web/lib/welcome-toast.ts b/web/lib/welcome-toast.ts index 8879f396..95af565a 100644 --- a/web/lib/welcome-toast.ts +++ b/web/lib/welcome-toast.ts @@ -3,6 +3,21 @@ const WELCOME_PARAM = 'welcome'; const BASE = 'https://video2ctx.local'; +/** Use the recorded signup allowance, never the current spendable balance. */ +export function welcomeMessage(name: string | null | undefined, signupCredits: number | null) { + if (signupCredits === 5000) return { + title: 'Your $10 launch gift is ready!', + description: "We've added 5,000 free credits to your account.", + }; + const firstName = name?.trim().split(/\s+/)[0]; + return { + title: firstName ? `Welcome to video2ctx, ${firstName}! 🎉` : 'Welcome to video2ctx! 🎉', + description: signupCredits && signupCredits > 0 + ? `${signupCredits.toLocaleString('en-US')} free credits are in your account. Can't wait to see what you build.` + : "Can't wait to see what you build.", + }; +} + /** The dashboard path a newly created account lands on after signup. */ export function newUserReturnPath(returnTo: string): string { const url = new URL(returnTo, BASE); From 056200dda2d1b90ec90178369bf78c3a0ef24e40 Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 09:10:58 +0530 Subject: [PATCH 2/7] test: verify standard and launch signup celebrations --- platform/test/e2e/dashboard-api.spec.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/platform/test/e2e/dashboard-api.spec.ts b/platform/test/e2e/dashboard-api.spec.ts index b46d82ae..906fcd33 100644 --- a/platform/test/e2e/dashboard-api.spec.ts +++ b/platform/test/e2e/dashboard-api.spec.ts @@ -905,12 +905,22 @@ for (const search of ['', '?checkout=cancelled']) test(`checkout confirmation do } finally { await account.clear(); } }); -test('signup welcome celebration still works after sharing confetti with checkout', async ({ page }) => { - await page.goto('/dashboard/settings?welcome=1'); - await expect(page.locator('[data-sonner-toast]').filter({ hasText: 'Welcome to video2ctx, Fixture!' })).toBeVisible(); - await expect(page.locator('body > [data-confetti]')).toHaveCount(1); - await expect(page.getByRole('dialog', { name: 'Welcome to Builder!' })).toHaveCount(0); - await expect(page).toHaveURL(/\/dashboard\/settings$/); +for (const grant of [1000, 5000]) test(`signup welcome celebration reflects the recorded ${grant}-credit grant`, async ({ page }) => { + const account = await accountScenario(page, { responses: { + '/v1/usage': { body: { plan: 'starter', creditBalance: 321, includedCredits: grant, creditGrant: 'onboarding' } }, + } }); + try { + await page.goto('/dashboard/settings?welcome=1'); + const title = grant === 5000 ? 'Your $10 launch gift is ready!' : 'Welcome to video2ctx, Fixture!'; + const toast = page.locator('[data-sonner-toast]').filter({ hasText: title }); + await expect(toast).toBeVisible(); + await expect(toast).toContainText(`${grant.toLocaleString('en-US')} free credits`); + await expect(page.locator('body > [data-confetti]')).toHaveCount(1); + await expect(page.getByRole('dialog', { name: 'Welcome to Builder!' })).toHaveCount(0); + await expect(page).toHaveURL(/\/dashboard\/settings$/); + await page.reload(); + await expect(page.locator('[data-sonner-toast]')).toHaveCount(0); + } finally { await account.clear(); } }); test('settings retries only the failed card and keeps mutations on a return visit', async ({ page }) => { From 30c4b02754d4072d6577c824586fc87fc3be491e Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 09:25:30 +0530 Subject: [PATCH 3/7] refactor: move product hunt launch link into the navbar --- reference/engineering/CREDIT_ACCOUNTING.md | 2 +- web/app/_directions/craft-nav.tsx | 32 ++++++++--- web/app/_directions/craft.tsx | 10 ++-- web/app/craft.css | 64 +++++++++++++--------- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/reference/engineering/CREDIT_ACCOUNTING.md b/reference/engineering/CREDIT_ACCOUNTING.md index ed0d27a6..fbea3480 100644 --- a/reference/engineering/CREDIT_ACCOUNTING.md +++ b/reference/engineering/CREDIT_ACCOUNTING.md @@ -81,7 +81,7 @@ Builder top-ups, refund resets, and per-operation credit prices are unchanged. The offer ends through a separate PR. Add a new migration restoring 1,000 for future signups, leaving existing grants intact. Restore the hero's standard copy: "Get started for free" and "1,000 credits · No card required". Remove the Product -Hunt offer link and restore the standard pricing, FAQ, demo and closing copy. +Hunt navbar badge and restore the standard pricing, FAQ, demo and closing copy. The standard welcome toast remains in `web/lib/welcome-toast.ts` and is selected from the account's recorded grant, so existing launch users retain accurate messages. The advertised launch week is October 21 through 28, 2026. There is deliberately diff --git a/web/app/_directions/craft-nav.tsx b/web/app/_directions/craft-nav.tsx index 7514c9ed..1ade66d6 100644 --- a/web/app/_directions/craft-nav.tsx +++ b/web/app/_directions/craft-nav.tsx @@ -29,14 +29,30 @@ export function CraftNav() { return (
- - - +
diff --git a/web/app/craft.css b/web/app/craft.css index 9ab98877..31816fcb 100644 --- a/web/app/craft.css +++ b/web/app/craft.css @@ -134,27 +134,18 @@ html:has(.craft) { } .craft-nav-product-hunt { - min-height: 2.25rem; - padding: 0 var(--space-xs); - border: 1px solid color-mix(in oklch, #ff6154 35%, var(--rule)); - border-radius: var(--radius-home-control); - background: color-mix(in oklch, #ff6154 8%, transparent); - color: #ff8177; display: inline-flex; - align-items: center; - gap: var(--space-xs); - font-size: 0.6875rem; - font-weight: 600; - text-decoration: none; - white-space: nowrap; + flex-shrink: 0; } -.craft-nav-product-hunt svg { - flex-shrink: 0; +.craft-nav-product-hunt img { + display: block; + width: auto; + height: 2.25rem; } -@media (max-width: 1000px) { - .craft-nav-product-hunt span { +@media (max-width: 720px) { + .craft-nav-product-hunt { display: none; } } From 66cfdfa3c80ace455fcc43a58e1b7d5fc6afeb85 Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 09:34:28 +0530 Subject: [PATCH 5/7] style: highlight the launch signup offer --- web/app/_directions/craft.tsx | 4 ++-- web/app/craft.css | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/web/app/_directions/craft.tsx b/web/app/_directions/craft.tsx index e115b5b0..a8b52824 100644 --- a/web/app/_directions/craft.tsx +++ b/web/app/_directions/craft.tsx @@ -225,9 +225,9 @@ export function CraftDirection() {

Everything your agent needs to understand videos - diff --git a/web/app/craft.css b/web/app/craft.css index 31816fcb..c05e95bc 100644 --- a/web/app/craft.css +++ b/web/app/craft.css @@ -703,8 +703,6 @@ html:has(.craft) { .craft-sub { max-width: 50ch; margin: var(--space-lg) 0 0; - /* Brighter than the soft ink so the offer line under it, which shares the - * paragraph, reads as the quieter of the two. */ color: color-mix(in oklch, var(--ink) 50%, var(--ink-soft)); font-size: clamp(0.95rem, 1.6vw, 1.0625rem); font-weight: 550; @@ -715,15 +713,27 @@ html:has(.craft) { /* Keep the signup offer and card reassurance together. */ .craft-offer { width: fit-content; - margin: var(--space-xs) auto 0; - color: var(--ink-muted); + max-width: 100%; + margin: var(--space-md) auto 0; + padding: 0.65rem 0.9rem; + border: 1px solid color-mix(in oklch, var(--accent) 38%, var(--rule)); + border-radius: var(--radius-home-control); + background: linear-gradient( + 110deg, + color-mix(in oklch, var(--accent) 14%, var(--paper)), + color-mix(in oklch, var(--accent) 5%, var(--paper)) + ); + box-shadow: + inset 0 1px 0 color-mix(in oklch, var(--accent) 12%, transparent), + 0 0 24px color-mix(in oklch, var(--accent) 9%, transparent); + color: var(--ink-soft); display: block; text-decoration: none; transition: color var(--control) var(--out); font-family: var(--font-home-body); font-size: 0.8125rem; - font-weight: 450; - line-height: 1.35; + font-weight: 500; + line-height: 1.5; text-wrap: balance; } @@ -731,6 +741,12 @@ html:has(.craft) { display: inline-block; margin-right: 0.35em; vertical-align: -0.2em; + color: color-mix(in oklch, var(--accent) 72%, var(--ink)); +} + +.craft-offer strong { + color: var(--ink); + font-weight: 650; } .craft-offer-terms { From 215d52ec3e4c52aff251e0214ff94d402609693e Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 09:37:32 +0530 Subject: [PATCH 6/7] style: soften the launch offer and animate its gift icon --- web/app/craft.css | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/web/app/craft.css b/web/app/craft.css index c05e95bc..b4bf1617 100644 --- a/web/app/craft.css +++ b/web/app/craft.css @@ -716,16 +716,16 @@ html:has(.craft) { max-width: 100%; margin: var(--space-md) auto 0; padding: 0.65rem 0.9rem; - border: 1px solid color-mix(in oklch, var(--accent) 38%, var(--rule)); + border: 1px solid color-mix(in oklch, var(--accent) 18%, var(--rule)); border-radius: var(--radius-home-control); background: linear-gradient( 110deg, - color-mix(in oklch, var(--accent) 14%, var(--paper)), - color-mix(in oklch, var(--accent) 5%, var(--paper)) + color-mix(in oklch, var(--accent) 7%, var(--paper)), + color-mix(in oklch, var(--accent) 2%, var(--paper)) ); box-shadow: - inset 0 1px 0 color-mix(in oklch, var(--accent) 12%, transparent), - 0 0 24px color-mix(in oklch, var(--accent) 9%, transparent); + inset 0 1px 0 color-mix(in oklch, var(--accent) 6%, transparent), + 0 0 18px color-mix(in oklch, var(--accent) 4%, transparent); color: var(--ink-soft); display: block; text-decoration: none; @@ -742,6 +742,32 @@ html:has(.craft) { margin-right: 0.35em; vertical-align: -0.2em; color: color-mix(in oklch, var(--accent) 72%, var(--ink)); + transform-origin: 50% 70%; +} + +@media (prefers-reduced-motion: no-preference) { + .craft-offer > svg { + animation: craft-gift-shake 6s var(--in-out) 1s infinite; + } +} + +/* A short lift and shake, followed by a quiet pause between repeats. */ +@keyframes craft-gift-shake { + 0%, 16%, 100% { + transform: translateY(0) rotate(0deg) scale(1); + } + 3% { + transform: translateY(-2px) rotate(-9deg) scale(1.12); + } + 6% { + transform: translateY(-2px) rotate(9deg) scale(1.16); + } + 9% { + transform: translateY(-1px) rotate(-6deg) scale(1.1); + } + 12% { + transform: translateY(1px) rotate(3deg) scale(0.98); + } } .craft-offer strong { From 0015daa0b4aa0cb764a3cb1c274f4c5911098402 Mon Sep 17 00:00:00 2001 From: Himanshu Gupta Date: Wed, 21 Oct 2026 10:27:45 +0530 Subject: [PATCH 7/7] docs: correct the launch migration reference --- reference/engineering/CREDIT_ACCOUNTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/engineering/CREDIT_ACCOUNTING.md b/reference/engineering/CREDIT_ACCOUNTING.md index 963c7b7e..e1d16e66 100644 --- a/reference/engineering/CREDIT_ACCOUNTING.md +++ b/reference/engineering/CREDIT_ACCOUNTING.md @@ -186,7 +186,7 @@ cascades to both their account and ledger. ## Deleted-account credit continuity Migration `0026_deleted_accounts.sql` replaces the signup grant trigger. A new -email receives the one-time signup grant, raised to 5,000 by migration 0027. A returning email receives an +email receives the one-time signup grant, raised to 5,000 by migration 0028. A returning email receives an `adjustment` with operation ID `returning-account:v1`, equal to the newest recorded deletion's unclaimed carryover balance. Database insertion order, rather than wall-clock timestamps, determines the latest record using a monotonic