From eadb2ffe85c43cf6fc741c9823cd28eedb4a844c Mon Sep 17 00:00:00 2001 From: Chia Date: Wed, 5 Aug 2026 22:01:29 +1200 Subject: feat: harden prepaid billing and commercial operations --- internal/adminui/assets/app.js | 23 ++++++++++++++++++----- internal/adminui/assets/index.html | 18 +++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) (limited to 'internal/adminui/assets') diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js index 1a965fc..8d29e23 100644 --- a/internal/adminui/assets/app.js +++ b/internal/adminui/assets/app.js @@ -1,7 +1,7 @@ const state = { token: '', csrf: '', actor: {}, permissions: new Set(), overview: {}, tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [], - usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], sessions: [], + usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], refunds: [], disputes: [], invoices: [], sessions: [], mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {} }; const $ = (selector) => document.querySelector(selector); @@ -102,9 +102,12 @@ async function loadAll(knownSession = null) { state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [], state.actor.id ? api('/auth/mfa') : {totp_enabled:false,passkeys:[]}, state.actor.id ? api('/auth/sessions') : [], - state.actor.tenant_id && state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : [] + state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : [], + state.overview.billing_enabled ? permitted('billing.read','/billing/refunds') : [], + state.overview.billing_enabled ? permitted('billing.read','/billing/disputes') : [], + state.overview.billing_enabled ? permitted('billing.read','/billing/invoices') : [] ]); - [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders] = results; + [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders,state.refunds,state.disputes,state.invoices] = results; renderAll(); setConnected(true); return true; } catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; } } @@ -137,11 +140,14 @@ function renderProjects() { $('#projects-body').innerHTML = state.projects.map(i function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects,'id','name'); } function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `${esc(item.name)}${esc(item.key_prefix)}${shortID(item.project_id)}${(item.scopes||[]).map(scope=>`${esc(scope)}`).join('')}${esc(item.status)}${item.status==='active'&&can('keys.write')?``:''}`).join('') || emptyRow(6); } function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `${esc(item.name)}${esc(item.protocol)}${esc(item.base_url)}${integer(item.route_count)}${item.enabled?'enabled':'disabled'}${can('platform.write')?``:''}`).join('') || emptyRow(6); } -function renderModels() { $('#models-body').innerHTML = state.models.map(item => `${esc(item.public_id)}${esc(item.owned_by||'—')}in ${money(item.input_price_micros_per_million)}/1M · out ${money(item.output_price_micros_per_million)}/1M
${(item.routes||[]).map(route=>`${esc(route.provider_name||route.provider_id).slice(0,24)} → ${esc(route.upstream_model)} p${route.priority} / w${route.weight}`).join('')}
${item.enabled?'enabled':'disabled'}${can('platform.write')?``:''}`).join('') || emptyRow(5); } +function renderModels() { $('#models-body').innerHTML = state.models.map(item => `${esc(item.public_id)}${esc(item.display_name||'')} · ${integer(item.context_window)} ctx · ${esc((item.input_modalities||[]).join('+'))} → ${esc((item.output_modalities||[]).join('+'))}${esc(item.owned_by||'—')}v${item.price_version||1} ${esc(item.price_currency||'usd')} · in ${money(item.input_price_micros_per_million,item.price_currency)}/1M · out ${money(item.output_price_micros_per_million,item.price_currency)}/1M
${(item.routes||[]).map(route=>`${esc(route.provider_name||route.provider_id).slice(0,24)} → ${esc(route.upstream_model)} p${route.priority} / w${route.weight}`).join('')}
${esc(item.lifecycle||'active')}${can('platform.write')?``:''}`).join('') || emptyRow(5); } function renderBilling() { $('#billing-currency').textContent=(state.overview.billing_currency||'').toUpperCase(); $('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`${esc(item.tenant_name)}
${shortID(item.tenant_id)}${money(item.balance_micros,item.currency)}${money(item.reserved_micros,item.currency)}${money(item.available_micros,item.currency)}${date(item.updated_at)}`).join('')||emptyRow(5); $('#billing-ledger-body').innerHTML=state.ledger.map(item=>`${date(item.created_at)}${shortID(item.tenant_id)}${esc(item.kind)}${money(item.amount_micros,item.currency)}${money(item.balance_after_micros,item.currency)}${shortID(item.source_id)}`).join('')||emptyRow(6); + const orders=state.orders||[];$('#billing-orders-body').innerHTML=orders.map(item=>`${date(item.created_at)}${money(item.amount_micros,item.currency)}${esc(item.status)}${esc(item.reconciliation_status||'unknown')}${item.invoice_url?`Invoice`:''} ${item.invoice_pdf_url?`PDF`:''} ${item.receipt_url?`Receipt`:''}${['failed','expired'].includes(item.status)?``:''}${can('billing.adjust')&&item.status==='pending'&&item.reconciliation_status==='missing'?` `:''}${can('billing.adjust')&&item.status==='paid'&&item.reconciliation_status==='missing'&&!item.stripe_payment_intent_id?` `:''}${can('billing.adjust')&&['paid','partially_refunded'].includes(item.status)?` `:''}`).join('')||emptyRow(6); + $('#refunds-body').innerHTML=(state.refunds||[]).map(item=>`${date(item.created_at)}${shortID(item.topup_order_id)}${money(item.amount_micros,item.currency)}${esc(item.status)}${esc(item.last_error||'—')}`).join('')||emptyRow(5); + $('#disputes-body').innerHTML=(state.disputes||[]).map(item=>`${date(item.updated_at)}${money(item.amount_micros,item.currency)}${esc(item.status)}${esc(item.reason)}${date(item.due_by)}`).join('')||emptyRow(5); } function renderSecurity() { const totp=Boolean(state.mfa?.totp_enabled); @@ -182,6 +188,13 @@ document.addEventListener('click',async(event)=>{ const session=event.target.closest('[data-revoke-session]');if(session&&confirm('Sign out this device?')){try{const result=await api(`/auth/sessions/${session.dataset.revokeSession}/revoke`,{method:'POST',body:'{}'});if(result.current){state.csrf='';state.actor={};setConnected(false);}else{await loadAll();toast('Device signed out');}}catch(error){toast(error.message,true);}} const passkey=event.target.closest('[data-delete-passkey]');if(passkey&&confirm('Delete this passkey?')){const password=$('#passkey-form [name=current_password]').value;if(!password){toast('Enter your current password in the Passkey panel',true);}else{try{await api(`/auth/passkeys/${passkey.dataset.deletePasskey}/delete`,{method:'POST',body:JSON.stringify({current_password:password})});await loadAll();toast('Passkey deleted');}catch(error){toast(error.message,true);}}} const save=event.target.closest('.save-limit');if(save){const row=save.closest('[data-limit-project]');try{await api(`/limits/${row.dataset.limitProject}`,{method:'POST',body:JSON.stringify({requests_per_minute:Number(row.querySelector('.limit-rpm').value),tokens_per_minute:Number(row.querySelector('.limit-tpm').value),concurrent_requests:Number(row.querySelector('.limit-concurrency').value),monthly_spend_micros:decimalToScaled(row.querySelector('.limit-spend').value,6)})});await loadAll();toast('Project limits updated');}catch(error){toast(error.message,true);}} + if(event.target.id==='billing-portal'){try{const result=await api('/billing/portal-sessions',{method:'POST',body:'{}'});window.location.assign(result.url);}catch(error){toast(error.message,true);}} + if(event.target.id==='billing-export'){try{const headers={};if(state.token)headers.Authorization=`Bearer ${state.token}`;const response=await fetch('./api/billing/export.csv',{credentials:'same-origin',headers});if(!response.ok){const payload=await response.json().catch(()=>({}));throw new Error(payload?.error?.message||`Export failed (${response.status})`);}const blob=await response.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`aigw-ledger-${new Date().toISOString().slice(0,10)}.csv`;document.body.appendChild(link);link.click();link.remove();URL.revokeObjectURL(url);toast('Financial export downloaded');}catch(error){toast(error.message,true);}} + if(event.target.id==='billing-reconcile'){try{const result=await api('/billing/reconcile',{method:'POST',body:'{}'});toast(`Reconciliation ${result.status}: ${result.mismatch_count} mismatches`,result.mismatch_count>0);await loadAll();}catch(error){toast(error.message,true);}} + const retryOrder=event.target.closest('[data-retry-order]');if(retryOrder){try{const result=await api(`/billing/orders/${retryOrder.dataset.retryOrder}/retry`,{method:'POST',body:'{}'});window.location.assign(result.url);}catch(error){toast(error.message,true);}} + const resolveOrder=event.target.closest('[data-resolve-order]');if(resolveOrder){const reason=prompt('Resolution reason for this missing Stripe Session');if(reason){try{await api(`/billing/orders/${resolveOrder.dataset.resolveOrder}/resolve-missing?tenant_id=${encodeURIComponent(resolveOrder.dataset.orderTenant)}`,{method:'POST',body:JSON.stringify({reason})});await loadAll();toast('Missing top-up resolved');}catch(error){toast(error.message,true);}}} + const reverseOrder=event.target.closest('[data-reverse-order]');if(reverseOrder){const reason=prompt('Reason for reversing this unverified local credit');if(reason&&confirm('Post an equal negative ledger entry for this credit?')){try{await api(`/billing/orders/${reverseOrder.dataset.reverseOrder}/reverse-missing-credit?tenant_id=${encodeURIComponent(reverseOrder.dataset.orderTenant)}`,{method:'POST',body:JSON.stringify({reason})});await loadAll();toast('Unverified credit reversed');}catch(error){toast(error.message,true);}}} + const refundOrder=event.target.closest('[data-refund-order]');if(refundOrder){const amount=prompt('Refund amount in account currency');if(amount){try{const digits=currencyDigits(state.overview.billing_currency||'usd');await api(`/billing/orders/${refundOrder.dataset.refundOrder}/refund?tenant_id=${encodeURIComponent(state.orders.find(item=>item.id===refundOrder.dataset.refundOrder)?.tenant_id||'')}`,{method:'POST',body:JSON.stringify({amount_minor:decimalToScaled(amount,digits),reason:'requested_by_customer'})});await loadAll();toast('Refund queued');}catch(error){toast(error.message,true);}}} }); $('#key-tenant').addEventListener('change',renderKeyProjects); @@ -200,7 +213,7 @@ $('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault( $('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}}); $('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}}); $('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}}); -$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}}); +$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;for(const field of ['capabilities','input_modalities','output_modalities','regions','aliases','allowed_tenant_ids','allowed_key_ids'])data[field]=String(data[field]||'').split(',').map(value=>value.trim()).filter(Boolean);data.context_window=Number(data.context_window||0);data.max_output_tokens=Number(data.max_output_tokens||0);data.price_currency=state.overview.billing_currency||'usd';data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}}); $('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}}); $('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}}); $('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Invitation sent');}catch(error){toast(error.message,true);}}); diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html index 95ab153..04b4e87 100644 --- a/internal/adminui/assets/index.html +++ b/internal/adminui/assets/index.html @@ -46,14 +46,14 @@
ACCOUNT RECOVERY

Choose a new password

- +
TEAM ACCESS

Accept invitation

- + @@ -124,18 +124,18 @@
UPSTREAMS

Providers

- +
NameProtocolBase URLRoutesStatus
ROUTING

Models & routes

-
+
Public IDOwnerRoutesStatus
-
REVENUE

Balances & ledger

+
REVENUE

Balances & ledger

@@ -143,6 +143,10 @@
TenantBalanceReservedAvailableUpdated
AUDIT

Recent ledger entries

TimeTenantKindAmountBalance afterReference
+
PAYMENTS

Top-ups, invoices & refunds

+
CreatedAmountStatusReconciliationDocumentsAction
+
CreatedOrderAmountStatusFailure
+
UpdatedAmountStatusReasonDue
@@ -160,9 +164,9 @@
SECURITY

Account

Devices

DeviceIPMethodLast seen

Passkeys

NameCreatedLast used
-- cgit v1.2.3