Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions platform/migrations/0028_launch_signup_credits.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 5 additions & 2 deletions platform/src/lib/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)),
]);
Expand Down
14 changes: 7 additions & 7 deletions platform/test/admin-access.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 });
});
});

Expand Down
2 changes: 1 addition & 1 deletion platform/test/admin-user-deletion.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
});
2 changes: 1 addition & 1 deletion platform/test/agent-memory-updater.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading