diff --git a/platform/migrations/0028_launch_signup_credits.sql b/platform/migrations/0028_launch_signup_credits.sql new file mode 100644 index 00000000..a0803b2c --- /dev/null +++ b/platform/migrations/0028_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}' 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..5d488e9f 100644 --- a/platform/src/lib/billing.ts +++ b/platform/src/lib/billing.ts @@ -8,6 +8,9 @@ import type { CreditEnv } from './entitlements'; import { ApiError, now, sha256 } from './http'; import { paymentReceipt, refundReconciliationStatements } from './credit-continuity'; +// Refunds retain the standard balance reset, independent of temporary signup offers. +const REFUND_STARTER_CREDITS = 1000; + interface BillingAccountRow { plan: 'starter' | 'builder'; has_payment_history: number; @@ -324,7 +327,7 @@ export async function applyRefundedOrder(env: PaymentWebhookEnv, payload: Webhoo return; } - const starterCredits = Number(env.STARTER_ONBOARDING_CREDITS); + const starterCredits = REFUND_STARTER_CREDITS; const subscription = order.subscription; const eventAt = payload.timestamp.getTime(); const state: BillingState = { @@ -360,7 +363,7 @@ async function reconcileDeletedRefund( await env.DB.batch([ ...refundReconciliationStatements(env.DB, { orderId: payload.data.id, userId, customerId, - eventAt: payload.timestamp.getTime(), starterCredits: Number(env.STARTER_ONBOARDING_CREDITS), + eventAt: payload.timestamp.getTime(), starterCredits: REFUND_STARTER_CREDITS, }), refundEventStatement(env.DB, eventId, payload.data.id, await eventFingerprint(payload)), ]); 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-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/auth/wrangler.jsonc b/platform/test/auth/wrangler.jsonc index 1375975c..e741cd63 100644 --- a/platform/test/auth/wrangler.jsonc +++ b/platform/test/auth/wrangler.jsonc @@ -9,7 +9,7 @@ "POLAR_ENVIRONMENT": "sandbox", "POLAR_ACCESS_TOKEN": "test-only-polar-token", "POLAR_BUILDER_PRODUCT_ID": "builder-product", - "STARTER_ONBOARDING_CREDITS": "1000", + "STARTER_ONBOARDING_CREDITS": "5000", "BUILDER_MONTHLY_CREDITS": "10000", "POLAR_WEBHOOK_SECRET": "whsec_c2FuZGJveC1zaWduYXR1cmUtdGVzdC1rZXktMzI=", "APP_ORIGIN": "http://auth.test", diff --git a/platform/test/billing.integration.test.ts b/platform/test/billing.integration.test.ts index f9b8001e..2b27e5b0 100644 --- a/platform/test/billing.integration.test.ts +++ b/platform/test/billing.integration.test.ts @@ -23,7 +23,7 @@ const env = { DB: workerEnv.DB, POLAR_BUILDER_PRODUCT_ID: 'builder-product', BUILDER_MONTHLY_CREDITS: '10000', - STARTER_ONBOARDING_CREDITS: '1000', + STARTER_ONBOARDING_CREDITS: '5000', } satisfies PaymentWebhookEnv; describe('Polar payment queries on D1', () => { @@ -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..656ad6b7 100644 --- a/platform/test/credits.integration.test.ts +++ b/platform/test/credits.integration.test.ts @@ -8,6 +8,7 @@ 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 { creditBalance, entitlements, @@ -20,7 +21,7 @@ import { const env = { DB: workerEnv.DB, - STARTER_ONBOARDING_CREDITS: '1000', + STARTER_ONBOARDING_CREDITS: '5000', BUILDER_MONTHLY_CREDITS: '10000', STARTER_PROJECT_LIMIT: '3', STARTER_MONITOR_LIMIT: '1', @@ -46,7 +47,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 +57,11 @@ 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 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 +84,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 +96,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 +186,12 @@ 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); + // The launch migration preserves existing balances and only changes new signup grants. + await db.prepare("INSERT INTO user (id,name,email,createdAt,updatedAt) VALUES ('launch-user','Launch','launch@migration.test',0,0)").run(); + 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 creditBalance({ ...env, DB: db }, 'returning-launch')).toBe(5000); }); test('manual grants update the stored balance once even when retried concurrently', async () => { @@ -195,7 +204,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 +212,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 +228,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 +251,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 +266,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 +301,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 +309,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 +323,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 +345,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 +361,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 +378,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/e2e/dashboard-api.spec.ts b/platform/test/e2e/dashboard-api.spec.ts index b4deb1e2..ea23eaa6 100644 --- a/platform/test/e2e/dashboard-api.spec.ts +++ b/platform/test/e2e/dashboard-api.spec.ts @@ -914,6 +914,7 @@ test('signup welcome celebration still works after sharing confetti with checkou await expect(welcome).toHaveCount(0); await page.getByRole('button', { name: 'Skip for now' }).click(); await expect(welcome).toBeVisible(); + await expect(welcome).toContainText("Your $10 launch gift is ready. We've added 5,000 free credits to your account."); 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$/); diff --git a/platform/test/welcome-email.test.ts b/platform/test/welcome-email.test.ts index 9f5faa8a..f20b831b 100644 --- a/platform/test/welcome-email.test.ts +++ b/platform/test/welcome-email.test.ts @@ -44,7 +44,7 @@ describe('welcome email queueing', () => { return { env: { APP_ORIGIN: 'https://www.video2ctx.dev', - STARTER_ONBOARDING_CREDITS: '1000', + STARTER_ONBOARDING_CREDITS: '5000', WELCOME_EMAIL_FROM: 'himanshu@notify.video2ctx.dev', EMAIL_TASKS: { send }, } as unknown as Env, @@ -70,7 +70,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 () => { diff --git a/platform/worker-configuration.d.ts b/platform/worker-configuration.d.ts index e77f9d7c..b0f7a15f 100644 --- a/platform/worker-configuration.d.ts +++ b/platform/worker-configuration.d.ts @@ -24,7 +24,7 @@ interface __BaseEnv_Env { EMAIL_REPLY_TO: "support@video2ctx.dev"; AI_GATEWAY_ID: "all-things-youtube"; PUBLIC_SEARCH_INSTANCE: "youtube-public"; - STARTER_ONBOARDING_CREDITS: "1000"; + STARTER_ONBOARDING_CREDITS: "5000"; BUILDER_MONTHLY_CREDITS: "10000"; STARTER_PROJECT_LIMIT: "3"; STARTER_MONITOR_LIMIT: "1"; diff --git a/platform/wrangler.agent-postman.local.jsonc b/platform/wrangler.agent-postman.local.jsonc index a6dd9e4b..3aeeefd4 100644 --- a/platform/wrangler.agent-postman.local.jsonc +++ b/platform/wrangler.agent-postman.local.jsonc @@ -18,7 +18,7 @@ "EMAIL_REPLY_TO": "support@video2ctx.dev", "AI_GATEWAY_ID": "all-things-youtube", "PUBLIC_SEARCH_INSTANCE": "youtube-public", - "STARTER_ONBOARDING_CREDITS": "1000", + "STARTER_ONBOARDING_CREDITS": "5000", "BUILDER_MONTHLY_CREDITS": "10000", "STARTER_PROJECT_LIMIT": "3", "STARTER_MONITOR_LIMIT": "1", diff --git a/platform/wrangler.jsonc b/platform/wrangler.jsonc index 5ae3bb6f..8c4ddcaa 100644 --- a/platform/wrangler.jsonc +++ b/platform/wrangler.jsonc @@ -19,7 +19,7 @@ "EMAIL_REPLY_TO": "support@video2ctx.dev", "AI_GATEWAY_ID": "all-things-youtube", "PUBLIC_SEARCH_INSTANCE": "youtube-public", - "STARTER_ONBOARDING_CREDITS": "1000", + "STARTER_ONBOARDING_CREDITS": "5000", "BUILDER_MONTHLY_CREDITS": "10000", "STARTER_PROJECT_LIMIT": "3", "STARTER_MONITOR_LIMIT": "1", diff --git a/platform/wrangler.polar.local.jsonc b/platform/wrangler.polar.local.jsonc index c1a347d9..af8b6fba 100644 --- a/platform/wrangler.polar.local.jsonc +++ b/platform/wrangler.polar.local.jsonc @@ -12,7 +12,7 @@ "AUTH_BASE_URL": "http://localhost:3001", "POLAR_ENVIRONMENT": "sandbox", "POLAR_BUILDER_PRODUCT_ID": "", - "STARTER_ONBOARDING_CREDITS": "1000", + "STARTER_ONBOARDING_CREDITS": "5000", "BUILDER_MONTHLY_CREDITS": "10000", "STARTER_PROJECT_LIMIT": "3", "STARTER_MONITOR_LIMIT": "1", diff --git a/reference/engineering/CREDIT_ACCOUNTING.md b/reference/engineering/CREDIT_ACCOUNTING.md index 62db98d4..e1d16e66 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,18 @@ 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 `0028_launch_signup_credits.sql` changes the signup trigger from 1,000 +to 5,000 credits. It has no backfill or date check. Existing balances and returning +account carryovers remain unchanged. `STARTER_ONBOARDING_CREDITS` is also 5,000 +for the current Starter offer and welcome email. Refund resets remain 1,000. + +End the launch week manually with a new migration restoring 1,000 for future +signups, and reset the configuration and launch copy in the same PR. Keep existing +balances intact. The standard hero copy is "Get started for free" and "1,000 +credits · No card required". Restore the welcome description to "1,000 free credits +are in your account. Can't wait to see what you build." Remove the Product Hunt +navbar badge and mobile menu link, and restore the pricing, FAQ, demo, and closing +copy. There is no scheduled expiry or automatic copy change. ## Manual credits @@ -179,14 +186,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 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 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-nav.tsx b/web/app/_directions/craft-nav.tsx index 7514c9ed..ad1996c4 100644 --- a/web/app/_directions/craft-nav.tsx +++ b/web/app/_directions/craft-nav.tsx @@ -11,6 +11,11 @@ const LINKS = [ { label: 'Installation', href: '#agent-setup' }, /* Phones drop the GitHub icon from the bar, so it lives in the menu there. */ { label: 'GitHub', href: 'https://github.com/devhims/video2ctx', phonesOnly: true }, + { + label: 'Product Hunt launch', + href: 'https://www.producthunt.com/products/video2ctx?launch=video2ctx', + phonesOnly: true, + }, ]; export function CraftNav() { @@ -29,14 +34,31 @@ export function CraftNav() { return (
- - - +