@agenticmail/enterprise 0.5.299 → 0.5.301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2181,16 +2181,16 @@ export function createAdminRoutes(db: DatabaseAdapter) {
2181
2181
  const isPostgres = (db as any).pool;
2182
2182
  if (isPostgres) {
2183
2183
  await (db as any)._query(
2184
- `INSERT INTO client_organizations (id, name, slug, contact_name, contact_email, description) VALUES ($1, $2, $3, $4, $5, $6)`,
2185
- [id, body.name, body.slug, body.contact_name || null, body.contact_email || null, body.description || null]
2184
+ `INSERT INTO client_organizations (id, name, slug, contact_name, contact_email, description, billing_rate_per_agent, currency) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
2185
+ [id, body.name, body.slug, body.contact_name || null, body.contact_email || null, body.description || null, body.billing_rate_per_agent || 0, body.currency || 'USD']
2186
2186
  );
2187
2187
  const { rows } = await (db as any)._query(`SELECT * FROM client_organizations WHERE id = $1`, [id]);
2188
2188
  return c.json(rows[0], 201);
2189
2189
  } else {
2190
2190
  const engineDb = db.getEngineDB();
2191
2191
  await engineDb!.run(
2192
- `INSERT INTO client_organizations (id, name, slug, contact_name, contact_email, description) VALUES (?, ?, ?, ?, ?, ?)`,
2193
- [id, body.name, body.slug, body.contact_name || null, body.contact_email || null, body.description || null]
2192
+ `INSERT INTO client_organizations (id, name, slug, contact_name, contact_email, description, billing_rate_per_agent, currency) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
2193
+ [id, body.name, body.slug, body.contact_name || null, body.contact_email || null, body.description || null, body.billing_rate_per_agent || 0, body.currency || 'USD']
2194
2194
  );
2195
2195
  const row = await engineDb!.get(`SELECT * FROM client_organizations WHERE id = ?`, [id]);
2196
2196
  return c.json(row, 201);
@@ -2236,7 +2236,7 @@ export function createAdminRoutes(db: DatabaseAdapter) {
2236
2236
  const values: any[] = [];
2237
2237
  const isPostgres = (db as any).pool;
2238
2238
  let idx = 1;
2239
- for (const key of ['name', 'contact_name', 'contact_email', 'description']) {
2239
+ for (const key of ['name', 'contact_name', 'contact_email', 'description', 'billing_rate_per_agent', 'currency']) {
2240
2240
  if (body[key] !== undefined) {
2241
2241
  fields.push(isPostgres ? `${key} = $${idx++}` : `${key} = ?`);
2242
2242
  values.push(body[key]);
@@ -2393,6 +2393,95 @@ export function createAdminRoutes(db: DatabaseAdapter) {
2393
2393
  }
2394
2394
  });
2395
2395
 
2396
+ // ─── Organization Billing ────────────────────────────
2397
+
2398
+ api.get('/organizations/:id/billing', requireRole('admin'), async (c) => {
2399
+ const orgId = c.req.param('id');
2400
+ const months = parseInt(c.req.query('months') || '12');
2401
+ try {
2402
+ const isPostgres = (db as any).pool;
2403
+ let records: any[];
2404
+ if (isPostgres) {
2405
+ const { rows } = await (db as any)._query(
2406
+ `SELECT * FROM org_billing_records WHERE org_id = $1 ORDER BY month DESC LIMIT $2`,
2407
+ [orgId, months]
2408
+ );
2409
+ records = rows;
2410
+ } else {
2411
+ records = await db.getEngineDB()!.all(
2412
+ `SELECT * FROM org_billing_records WHERE org_id = ? ORDER BY month DESC LIMIT ?`,
2413
+ [orgId, months]
2414
+ );
2415
+ }
2416
+ return c.json({ records });
2417
+ } catch (e: any) {
2418
+ return c.json({ error: e.message }, 500);
2419
+ }
2420
+ });
2421
+
2422
+ api.put('/organizations/:id/billing', requireRole('admin'), async (c) => {
2423
+ const orgId = c.req.param('id');
2424
+ const { records } = await c.req.json();
2425
+ if (!Array.isArray(records)) return c.json({ error: 'records must be an array' }, 400);
2426
+ try {
2427
+ const isPostgres = (db as any).pool;
2428
+ const { randomUUID } = await import('crypto');
2429
+ for (const r of records) {
2430
+ if (!r.month) continue;
2431
+ if (isPostgres) {
2432
+ await (db as any)._query(
2433
+ `INSERT INTO org_billing_records (id, org_id, agent_id, month, revenue, token_cost, input_tokens, output_tokens, notes)
2434
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
2435
+ ON CONFLICT (org_id, agent_id, month) DO UPDATE SET
2436
+ revenue = EXCLUDED.revenue, token_cost = EXCLUDED.token_cost,
2437
+ input_tokens = EXCLUDED.input_tokens, output_tokens = EXCLUDED.output_tokens,
2438
+ notes = EXCLUDED.notes, updated_at = NOW()`,
2439
+ [randomUUID(), orgId, r.agentId || null, r.month, r.revenue || 0, r.tokenCost || 0, r.inputTokens || 0, r.outputTokens || 0, r.notes || null]
2440
+ );
2441
+ } else {
2442
+ const id = randomUUID();
2443
+ await db.getEngineDB()!.run(
2444
+ `INSERT OR REPLACE INTO org_billing_records (id, org_id, agent_id, month, revenue, token_cost, input_tokens, output_tokens, notes)
2445
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2446
+ [id, orgId, r.agentId || null, r.month, r.revenue || 0, r.tokenCost || 0, r.inputTokens || 0, r.outputTokens || 0, r.notes || null]
2447
+ );
2448
+ }
2449
+ }
2450
+ return c.json({ success: true });
2451
+ } catch (e: any) {
2452
+ return c.json({ error: e.message }, 500);
2453
+ }
2454
+ });
2455
+
2456
+ // ─── Organization Billing Summary ─────────────────────
2457
+
2458
+ api.get('/organizations/:id/billing-summary', requireRole('admin'), async (c) => {
2459
+ const orgId = c.req.param('id');
2460
+ try {
2461
+ const isPostgres = (db as any).pool;
2462
+ let rows: any[];
2463
+ if (isPostgres) {
2464
+ const result = await (db as any)._query(
2465
+ `SELECT month, SUM(revenue) as total_revenue, SUM(token_cost) as total_cost,
2466
+ SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens
2467
+ FROM org_billing_records WHERE org_id = $1
2468
+ GROUP BY month ORDER BY month ASC`, [orgId]
2469
+ );
2470
+ rows = result.rows;
2471
+ } else {
2472
+ rows = await db.getEngineDB()!.all(
2473
+ `SELECT month, SUM(revenue) as total_revenue, SUM(token_cost) as total_cost,
2474
+ SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens
2475
+ FROM org_billing_records WHERE org_id = ?
2476
+ GROUP BY month ORDER BY month ASC`, [orgId]
2477
+ );
2478
+ }
2479
+ return c.json({ summary: rows });
2480
+ } catch (e: any) {
2481
+ return c.json({ error: e.message }, 500);
2482
+ }
2483
+ });
2484
+
2396
2485
  /** Stop and optionally delete tunnel */
2397
2486
  api.post('/tunnel/stop', requireRole('admin'), async (c) => {
2398
2487
  try {
@@ -56,6 +56,7 @@ export const I = {
56
56
  chevronLeft: () => h('svg', S, h('polyline', { points: '15 18 9 12 15 6' })),
57
57
  chevronRight: () => h('svg', S, h('polyline', { points: '9 18 15 12 9 6' })),
58
58
  chevronDown: () => h('svg', S, h('polyline', { points: '6 9 12 15 18 9' })),
59
+ mail: () => h('svg', S, h('path', { d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z' }), h('polyline', { points: '22,6 12,13 2,6' })),
59
60
  building: () => h('svg', S, h('path', { d: 'M3 21h18M3 10h18M3 7l9-4 9 4M4 10v11M20 10v11M8 14v.01M12 14v.01M16 14v.01M8 18v.01M12 18v.01M16 18v.01' })),
60
61
  edit: () => h('svg', S, h('path', { d: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7' }), h('path', { d: 'M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z' })),
61
62
  };
@@ -41,8 +41,18 @@ export function OrganizationsPage() {
41
41
  var femail = _femail[0]; var setFemail = _femail[1];
42
42
  var _fdesc = useState('');
43
43
  var fdesc = _fdesc[0]; var setFdesc = _fdesc[1];
44
+ var _fbilling = useState('');
45
+ var fbilling = _fbilling[0]; var setFbilling = _fbilling[1];
46
+ var _fcurrency = useState('USD');
47
+ var fcurrency = _fcurrency[0]; var setFcurrency = _fcurrency[1];
44
48
  var _slugManual = useState(false);
45
49
  var slugManual = _slugManual[0]; var setSlugManual = _slugManual[1];
50
+ var _detailTab = useState('agents');
51
+ var detailTab = _detailTab[0]; var setDetailTab = _detailTab[1];
52
+ var _billingSummary = useState([]);
53
+ var billingSummary = _billingSummary[0]; var setBillingSummary = _billingSummary[1];
54
+ var _billingRecords = useState([]);
55
+ var billingRecords = _billingRecords[0]; var setBillingRecords = _billingRecords[1];
46
56
 
47
57
  var loadOrgs = useCallback(function() {
48
58
  setLoading(true);
@@ -62,29 +72,33 @@ export function OrganizationsPage() {
62
72
  };
63
73
 
64
74
  var openCreate = function() {
65
- setFname(''); setFslug(''); setFcontact(''); setFemail(''); setFdesc(''); setSlugManual(false);
75
+ setFname(''); setFslug(''); setFcontact(''); setFemail(''); setFdesc(''); setFbilling(''); setFcurrency('USD'); setSlugManual(false);
66
76
  setShowCreate(true);
67
77
  };
68
78
 
69
79
  var openEdit = function(org) {
70
80
  setFname(org.name || ''); setFslug(org.slug || ''); setFcontact(org.contact_name || ''); setFemail(org.contact_email || ''); setFdesc(org.description || '');
81
+ setFbilling(org.billing_rate_per_agent ? String(org.billing_rate_per_agent) : ''); setFcurrency(org.currency || 'USD');
71
82
  setEditOrg(org);
72
83
  };
73
84
 
74
85
  var openDetail = function(org) {
75
86
  setDetailOrg(org);
87
+ setDetailTab('agents');
76
88
  loadAllAgents();
77
89
  apiCall('/organizations/' + org.id).then(function(data) {
78
90
  setDetailAgents(data.agents || []);
79
91
  setDetailOrg(data);
80
92
  }).catch(function(err) { toast(err.message, 'error'); });
93
+ apiCall('/organizations/' + org.id + '/billing-summary').then(function(d) { setBillingSummary(d.summary || []); }).catch(function() {});
94
+ apiCall('/organizations/' + org.id + '/billing').then(function(d) { setBillingRecords(d.records || []); }).catch(function() {});
81
95
  };
82
96
 
83
97
  var doCreate = function() {
84
98
  setActing('create');
85
99
  apiCall('/organizations', {
86
100
  method: 'POST',
87
- body: JSON.stringify({ name: fname, slug: fslug, contact_name: fcontact, contact_email: femail, description: fdesc })
101
+ body: JSON.stringify({ name: fname, slug: fslug, contact_name: fcontact, contact_email: femail, description: fdesc, billing_rate_per_agent: fbilling ? parseFloat(fbilling) : 0, currency: fcurrency })
88
102
  }).then(function() {
89
103
  toast('Organization created', 'success');
90
104
  setShowCreate(false);
@@ -97,7 +111,7 @@ export function OrganizationsPage() {
97
111
  setActing('edit');
98
112
  apiCall('/organizations/' + editOrg.id, {
99
113
  method: 'PATCH',
100
- body: JSON.stringify({ name: fname, contact_name: fcontact, contact_email: femail, description: fdesc })
114
+ body: JSON.stringify({ name: fname, contact_name: fcontact, contact_email: femail, description: fdesc, billing_rate_per_agent: fbilling ? parseFloat(fbilling) : 0, currency: fcurrency })
101
115
  }).then(function() {
102
116
  toast('Organization updated', 'success');
103
117
  setEditOrg(null);
@@ -158,8 +172,9 @@ export function OrganizationsPage() {
158
172
  .finally(function() { setActing(''); });
159
173
  };
160
174
 
161
- var unassignedAgents = allAgents.filter(function(a) {
162
- return !a.client_org_id && detailAgents.every(function(da) { return da.id !== a.id; });
175
+ // Show agents not already in THIS org (includes unassigned AND agents from other orgs)
176
+ var assignableAgents = allAgents.filter(function(a) {
177
+ return detailAgents.every(function(da) { return da.id !== a.id; });
163
178
  });
164
179
 
165
180
  if (loading) return h('div', { style: { padding: 40, textAlign: 'center', color: 'var(--text-muted)' } }, 'Loading organizations...');
@@ -185,7 +200,11 @@ export function OrganizationsPage() {
185
200
  // Org cards
186
201
  orgs.length === 0
187
202
  ? h('div', { className: 'card', style: { textAlign: 'center', padding: 40 } },
188
- h('div', { style: { fontSize: 48, marginBottom: 12 } }, '🏢'),
203
+ h('div', { style: { width: 48, height: 48, margin: '0 auto 12px', borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
204
+ h('svg', { width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--text-muted)', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' },
205
+ h('path', { d: 'M3 21h18M3 10h18M3 7l9-4 9 4M4 10v11M20 10v11M8 14v.01M12 14v.01M16 14v.01M8 18v.01M12 18v.01M16 18v.01' })
206
+ )
207
+ ),
189
208
  h('div', { style: { fontSize: 15, fontWeight: 600, marginBottom: 4 } }, 'No organizations yet'),
190
209
  h('div', { style: { color: 'var(--text-muted)', fontSize: 13, marginBottom: 16 } }, 'Create your first client organization to start managing multi-tenant agent deployments.'),
191
210
  h('button', { className: 'btn btn-primary', onClick: openCreate }, I.plus(), ' Create Organization')
@@ -204,8 +223,8 @@ export function OrganizationsPage() {
204
223
  org.description && h('div', { style: { fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12, lineHeight: 1.5 } }, org.description),
205
224
  h('div', { style: { display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' } },
206
225
  h('span', null, I.agents(), ' ', (org.agent_count || 0), ' agent', (org.agent_count || 0) !== 1 ? 's' : ''),
207
- org.contact_email && h('span', null, ' ', org.contact_email),
208
- org.created_at && h('span', null, new Date(org.created_at).toLocaleDateString())
226
+ org.billing_rate_per_agent > 0 && h('span', { style: { fontWeight: 600, color: 'var(--success, #15803d)' } }, (org.currency || '$') + ' ', parseFloat(org.billing_rate_per_agent).toFixed(2), '/agent/mo'),
227
+ org.contact_email && h('span', null, I.mail(), ' ', org.contact_email)
209
228
  ),
210
229
  h('div', { style: { display: 'flex', gap: 6, marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10 }, onClick: function(e) { e.stopPropagation(); } },
211
230
  h('button', { className: 'btn btn-ghost btn-sm', onClick: function() { openEdit(org); } }, I.edit(), ' Edit'),
@@ -224,24 +243,36 @@ export function OrganizationsPage() {
224
243
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14, padding: 4 } },
225
244
  h('div', null,
226
245
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Name *'),
227
- h('input', { className: 'input', value: fname, onInput: function(e) { setFname(e.target.value); if (!slugManual) setFslug(slugify(e.target.value)); }, placeholder: 'Acme Corporation' })
246
+ h('input', { className: 'input', value: fname, onInput: function(e) { setFname(e.target.value); if (!slugManual) setFslug(slugify(e.target.value)); }, placeholder: 'AgenticMail' })
228
247
  ),
229
248
  h('div', null,
230
249
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Slug *'),
231
- h('input', { className: 'input', value: fslug, onInput: function(e) { setFslug(e.target.value); setSlugManual(true); }, placeholder: 'acme-corporation', style: { fontFamily: 'var(--font-mono, monospace)' } })
250
+ h('input', { className: 'input', value: fslug, onInput: function(e) { setFslug(e.target.value); setSlugManual(true); }, placeholder: 'agenticmail', style: { fontFamily: 'var(--font-mono, monospace)' } })
232
251
  ),
233
252
  h('div', null,
234
253
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Contact Name'),
235
- h('input', { className: 'input', value: fcontact, onInput: function(e) { setFcontact(e.target.value); }, placeholder: 'John Doe' })
254
+ h('input', { className: 'input', value: fcontact, onInput: function(e) { setFcontact(e.target.value); }, placeholder: 'Ope Olatunji' })
236
255
  ),
237
256
  h('div', null,
238
257
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Contact Email'),
239
- h('input', { className: 'input', type: 'email', value: femail, onInput: function(e) { setFemail(e.target.value); }, placeholder: 'john@acme.com' })
258
+ h('input', { className: 'input', type: 'email', value: femail, onInput: function(e) { setFemail(e.target.value); }, placeholder: 'ope@agenticmail.io' })
240
259
  ),
241
260
  h('div', null,
242
261
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Description'),
243
262
  h('textarea', { className: 'input', value: fdesc, onInput: function(e) { setFdesc(e.target.value); }, placeholder: 'Brief description...', rows: 3, style: { resize: 'vertical' } })
244
263
  ),
264
+ h('div', { style: { display: 'flex', gap: 12 } },
265
+ h('div', { style: { flex: 1 } },
266
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Billing Rate / Agent / Month'),
267
+ h('input', { className: 'input', type: 'number', step: '0.01', min: '0', value: fbilling, onInput: function(e) { setFbilling(e.target.value); }, placeholder: '0.00' })
268
+ ),
269
+ h('div', { style: { width: 100 } },
270
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Currency'),
271
+ h('select', { className: 'input', value: fcurrency, onChange: function(e) { setFcurrency(e.target.value); } },
272
+ h('option', { value: 'USD' }, 'USD'), h('option', { value: 'EUR' }, 'EUR'), h('option', { value: 'GBP' }, 'GBP'), h('option', { value: 'NGN' }, 'NGN'), h('option', { value: 'CAD' }, 'CAD'), h('option', { value: 'AUD' }, 'AUD')
273
+ )
274
+ )
275
+ ),
245
276
  h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 } },
246
277
  h('button', { className: 'btn btn-secondary', onClick: function() { setShowCreate(false); } }, 'Cancel'),
247
278
  h('button', { className: 'btn btn-primary', disabled: !fname || !fslug || acting === 'create', onClick: doCreate }, acting === 'create' ? 'Creating...' : 'Create')
@@ -272,6 +303,18 @@ export function OrganizationsPage() {
272
303
  h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Description'),
273
304
  h('textarea', { className: 'input', value: fdesc, onInput: function(e) { setFdesc(e.target.value); }, rows: 3, style: { resize: 'vertical' } })
274
305
  ),
306
+ h('div', { style: { display: 'flex', gap: 12 } },
307
+ h('div', { style: { flex: 1 } },
308
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Billing Rate / Agent / Month'),
309
+ h('input', { className: 'input', type: 'number', step: '0.01', min: '0', value: fbilling, onInput: function(e) { setFbilling(e.target.value); }, placeholder: '0.00' })
310
+ ),
311
+ h('div', { style: { width: 100 } },
312
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Currency'),
313
+ h('select', { className: 'input', value: fcurrency, onChange: function(e) { setFcurrency(e.target.value); } },
314
+ h('option', { value: 'USD' }, 'USD'), h('option', { value: 'EUR' }, 'EUR'), h('option', { value: 'GBP' }, 'GBP'), h('option', { value: 'NGN' }, 'NGN'), h('option', { value: 'CAD' }, 'CAD'), h('option', { value: 'AUD' }, 'AUD')
315
+ )
316
+ )
317
+ ),
275
318
  h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 } },
276
319
  h('button', { className: 'btn btn-secondary', onClick: function() { setEditOrg(null); } }, 'Cancel'),
277
320
  h('button', { className: 'btn btn-primary', disabled: !fname || acting === 'edit', onClick: doEdit }, acting === 'edit' ? 'Saving...' : 'Save Changes')
@@ -280,10 +323,10 @@ export function OrganizationsPage() {
280
323
  ),
281
324
 
282
325
  // Detail Modal
283
- detailOrg && h(Modal, { title: detailOrg.name || 'Organization Detail', onClose: function() { setDetailOrg(null); }, wide: true },
326
+ detailOrg && h(Modal, { title: detailOrg.name || 'Organization Detail', onClose: function() { setDetailOrg(null); }, width: 700 },
284
327
  h('div', { style: { padding: 4 } },
285
328
  // Org info
286
- h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 } },
329
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 16, marginBottom: 16 } },
287
330
  h('div', null,
288
331
  h('div', { style: { fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', fontWeight: 600, marginBottom: 4 } }, 'Slug'),
289
332
  h('div', { style: { fontFamily: 'var(--font-mono, monospace)', fontSize: 13 } }, detailOrg.slug)
@@ -298,10 +341,29 @@ export function OrganizationsPage() {
298
341
  detailOrg.contact_email && h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, detailOrg.contact_email)
299
342
  )
300
343
  ),
344
+ // Billing rate in header
345
+ detailOrg.billing_rate_per_agent > 0 && h('div', { style: { display: 'flex', alignItems: 'center', gap: 16, padding: '10px 14px', background: 'var(--success-soft, rgba(21,128,61,0.06))', borderRadius: 8, marginBottom: 16, fontSize: 13 } },
346
+ h('div', null, h('strong', null, 'Rate: '), (detailOrg.currency || 'USD') + ' ' + parseFloat(detailOrg.billing_rate_per_agent).toFixed(2) + '/agent/month'),
347
+ h('div', null, h('strong', null, 'Monthly Revenue: '), (detailOrg.currency || 'USD') + ' ' + (parseFloat(detailOrg.billing_rate_per_agent) * detailAgents.length).toFixed(2)),
348
+ h('div', null, h('strong', null, 'Agents: '), detailAgents.length)
349
+ ),
350
+
301
351
  detailOrg.description && h('div', { style: { fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16, padding: 12, background: 'var(--bg-tertiary)', borderRadius: 'var(--radius)' } }, detailOrg.description),
302
352
 
303
- // Linked agents
304
- h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 10 } }, 'Linked Agents (' + detailAgents.length + ')'),
353
+ // Tabs
354
+ h('div', { style: { display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', marginBottom: 16 } },
355
+ ['agents', 'billing'].map(function(t) {
356
+ return h('button', {
357
+ key: t, type: 'button',
358
+ style: { padding: '8px 16px', fontSize: 13, fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', color: detailTab === t ? 'var(--primary)' : 'var(--text-muted)', borderBottom: detailTab === t ? '2px solid var(--primary)' : '2px solid transparent', fontFamily: 'var(--font)' },
359
+ onClick: function() { setDetailTab(t); }
360
+ }, t === 'agents' ? 'Agents (' + detailAgents.length + ')' : 'Billing & Costs');
361
+ })
362
+ ),
363
+
364
+ // ── Agents Tab ────────────────────────────
365
+ detailTab === 'agents' && h(Fragment, null,
366
+ h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 10 } }, 'Linked Agents'),
305
367
  detailAgents.length > 0
306
368
  ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 } },
307
369
  detailAgents.map(function(a) {
@@ -322,13 +384,114 @@ export function OrganizationsPage() {
322
384
  h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 8 } }, 'Assign Agent'),
323
385
  h('div', { style: { display: 'flex', gap: 8 } },
324
386
  h('select', { className: 'input', value: assignAgentId, onChange: function(e) { setAssignAgentId(e.target.value); }, style: { flex: 1 } },
325
- h('option', { value: '' }, '— Select an unassigned agent —'),
326
- unassignedAgents.map(function(a) {
327
- return h('option', { key: a.id, value: a.id }, a.name + (a.role ? ' (' + a.role + ')' : ''));
387
+ h('option', { value: '' }, '— Select an agent to assign —'),
388
+ assignableAgents.map(function(a) {
389
+ var label = a.name + (a.role ? ' (' + a.role + ')' : '');
390
+ if (a.client_org_id) {
391
+ var fromOrg = orgs.find(function(o) { return o.id === a.client_org_id; });
392
+ label += fromOrg ? ' [from ' + fromOrg.name + ']' : ' [assigned elsewhere]';
393
+ }
394
+ return h('option', { key: a.id, value: a.id }, label);
328
395
  })
329
396
  ),
330
397
  h('button', { className: 'btn btn-primary btn-sm', disabled: !assignAgentId || acting === 'assign', onClick: doAssignAgent }, acting === 'assign' ? 'Assigning...' : 'Assign')
331
398
  )
399
+ ), // end agents tab
400
+
401
+ // ── Billing Tab ───────────────────────────
402
+ detailTab === 'billing' && h(Fragment, null,
403
+ // Revenue vs Cost chart
404
+ billingSummary.length > 0 && h('div', { style: { marginBottom: 20 } },
405
+ h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 12 } }, 'Revenue vs Cost'),
406
+ h('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 4, height: 160, padding: '0 8px', borderBottom: '1px solid var(--border)' } },
407
+ billingSummary.map(function(m, i) {
408
+ var rev = parseFloat(m.total_revenue) || 0;
409
+ var cost = parseFloat(m.total_cost) || 0;
410
+ var maxVal = Math.max.apply(null, billingSummary.map(function(s) { return Math.max(parseFloat(s.total_revenue) || 0, parseFloat(s.total_cost) || 0); })) || 1;
411
+ var revH = Math.max(4, (rev / maxVal) * 140);
412
+ var costH = Math.max(4, (cost / maxVal) * 140);
413
+ var profit = rev - cost;
414
+ return h('div', { key: i, style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 } },
415
+ h('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 2, height: 144 } },
416
+ h('div', { title: 'Revenue: ' + rev.toFixed(2), style: { width: 14, height: revH, background: 'var(--success, #15803d)', borderRadius: '3px 3px 0 0', minHeight: 4 } }),
417
+ h('div', { title: 'Cost: ' + cost.toFixed(2), style: { width: 14, height: costH, background: 'var(--danger, #dc2626)', borderRadius: '3px 3px 0 0', minHeight: 4, opacity: 0.7 } })
418
+ ),
419
+ h('div', { style: { fontSize: 9, color: 'var(--text-muted)', marginTop: 4, whiteSpace: 'nowrap' } }, m.month ? m.month.slice(5) : ''),
420
+ h('div', { style: { fontSize: 9, color: profit >= 0 ? 'var(--success, #15803d)' : 'var(--danger)', fontWeight: 600 } }, profit >= 0 ? '+' + profit.toFixed(0) : profit.toFixed(0))
421
+ );
422
+ })
423
+ ),
424
+ h('div', { style: { display: 'flex', gap: 16, marginTop: 8, fontSize: 11 } },
425
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 4 } }, h('span', { style: { width: 10, height: 10, borderRadius: 2, background: 'var(--success, #15803d)', display: 'inline-block' } }), 'Revenue'),
426
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 4 } }, h('span', { style: { width: 10, height: 10, borderRadius: 2, background: 'var(--danger)', opacity: 0.7, display: 'inline-block' } }), 'Token Cost'),
427
+ (function() {
428
+ var totRev = billingSummary.reduce(function(a, m) { return a + (parseFloat(m.total_revenue) || 0); }, 0);
429
+ var totCost = billingSummary.reduce(function(a, m) { return a + (parseFloat(m.total_cost) || 0); }, 0);
430
+ return h('span', { style: { marginLeft: 'auto', fontWeight: 600, color: (totRev - totCost) >= 0 ? 'var(--success, #15803d)' : 'var(--danger)' } },
431
+ 'Net: ' + (detailOrg.currency || 'USD') + ' ' + (totRev - totCost).toFixed(2)
432
+ );
433
+ })()
434
+ )
435
+ ),
436
+
437
+ billingSummary.length === 0 && h('div', { style: { padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--bg-tertiary)', borderRadius: 8, marginBottom: 16 } },
438
+ 'No billing data yet. Billing records are created as agents process tasks and accumulate token costs.'
439
+ ),
440
+
441
+ // Stats summary
442
+ (function() {
443
+ var totRev = billingSummary.reduce(function(a, m) { return a + (parseFloat(m.total_revenue) || 0); }, 0);
444
+ var totCost = billingSummary.reduce(function(a, m) { return a + (parseFloat(m.total_cost) || 0); }, 0);
445
+ var totIn = billingSummary.reduce(function(a, m) { return a + (parseInt(m.total_input_tokens) || 0); }, 0);
446
+ var totOut = billingSummary.reduce(function(a, m) { return a + (parseInt(m.total_output_tokens) || 0); }, 0);
447
+ var margin = totRev > 0 ? ((totRev - totCost) / totRev * 100) : 0;
448
+ return h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 16 } },
449
+ h('div', { style: { padding: 12, background: 'var(--bg-tertiary)', borderRadius: 8, textAlign: 'center' } },
450
+ h('div', { style: { fontSize: 18, fontWeight: 700, color: 'var(--success, #15803d)' } }, (detailOrg.currency || 'USD') + ' ' + totRev.toFixed(2)),
451
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Total Revenue')
452
+ ),
453
+ h('div', { style: { padding: 12, background: 'var(--bg-tertiary)', borderRadius: 8, textAlign: 'center' } },
454
+ h('div', { style: { fontSize: 18, fontWeight: 700, color: 'var(--danger)' } }, (detailOrg.currency || 'USD') + ' ' + totCost.toFixed(4)),
455
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Token Cost')
456
+ ),
457
+ h('div', { style: { padding: 12, background: 'var(--bg-tertiary)', borderRadius: 8, textAlign: 'center' } },
458
+ h('div', { style: { fontSize: 18, fontWeight: 700, color: margin >= 0 ? 'var(--success, #15803d)' : 'var(--danger)' } }, margin.toFixed(1) + '%'),
459
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Margin')
460
+ ),
461
+ h('div', { style: { padding: 12, background: 'var(--bg-tertiary)', borderRadius: 8, textAlign: 'center' } },
462
+ h('div', { style: { fontSize: 18, fontWeight: 700 } }, ((totIn + totOut) / 1000).toFixed(1) + 'K'),
463
+ h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Total Tokens')
464
+ )
465
+ );
466
+ })(),
467
+
468
+ // Per-agent breakdown table
469
+ billingRecords.length > 0 && h(Fragment, null,
470
+ h('div', { style: { fontSize: 14, fontWeight: 700, marginBottom: 8 } }, 'Records'),
471
+ h('div', { style: { overflowX: 'auto' } },
472
+ h('table', null,
473
+ h('thead', null, h('tr', null,
474
+ h('th', null, 'Month'), h('th', null, 'Agent'), h('th', { style: { textAlign: 'right' } }, 'Revenue'), h('th', { style: { textAlign: 'right' } }, 'Token Cost'), h('th', { style: { textAlign: 'right' } }, 'Profit'), h('th', { style: { textAlign: 'right' } }, 'Tokens')
475
+ )),
476
+ h('tbody', null,
477
+ billingRecords.map(function(r, i) {
478
+ var rev = parseFloat(r.revenue) || 0;
479
+ var cost = parseFloat(r.token_cost) || 0;
480
+ var agent = detailAgents.find(function(a) { return a.id === r.agent_id; });
481
+ return h('tr', { key: i },
482
+ h('td', { style: { fontFamily: 'var(--font-mono)', fontSize: 12 } }, r.month),
483
+ h('td', null, agent ? agent.name : (r.agent_id ? r.agent_id.slice(0, 8) : 'All')),
484
+ h('td', { style: { textAlign: 'right', color: 'var(--success, #15803d)' } }, rev.toFixed(2)),
485
+ h('td', { style: { textAlign: 'right', color: 'var(--danger)' } }, cost.toFixed(4)),
486
+ h('td', { style: { textAlign: 'right', fontWeight: 600, color: (rev - cost) >= 0 ? 'var(--success, #15803d)' : 'var(--danger)' } }, (rev - cost).toFixed(2)),
487
+ h('td', { style: { textAlign: 'right', fontSize: 12, color: 'var(--text-muted)' } }, ((parseInt(r.input_tokens) || 0) + (parseInt(r.output_tokens) || 0)).toLocaleString())
488
+ );
489
+ })
490
+ )
491
+ )
492
+ )
493
+ )
494
+ ) // end billing tab
332
495
  )
333
496
  )
334
497
  );
@@ -209,7 +209,24 @@ export class PostgresAdapter extends DatabaseAdapter {
209
209
  );
210
210
  `);
211
211
  await client.query(`
212
+ ALTER TABLE client_organizations ADD COLUMN IF NOT EXISTS billing_rate_per_agent NUMERIC(10,2) DEFAULT 0;
213
+ ALTER TABLE client_organizations ADD COLUMN IF NOT EXISTS currency TEXT DEFAULT 'USD';
212
214
  ALTER TABLE agents ADD COLUMN IF NOT EXISTS client_org_id TEXT REFERENCES client_organizations(id);
215
+
216
+ CREATE TABLE IF NOT EXISTS org_billing_records (
217
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
218
+ org_id TEXT NOT NULL REFERENCES client_organizations(id) ON DELETE CASCADE,
219
+ agent_id TEXT,
220
+ month TEXT NOT NULL,
221
+ revenue NUMERIC(10,2) DEFAULT 0,
222
+ token_cost NUMERIC(10,4) DEFAULT 0,
223
+ input_tokens BIGINT DEFAULT 0,
224
+ output_tokens BIGINT DEFAULT 0,
225
+ notes TEXT,
226
+ created_at TIMESTAMP DEFAULT NOW(),
227
+ updated_at TIMESTAMP DEFAULT NOW(),
228
+ UNIQUE(org_id, agent_id, month)
229
+ );
213
230
  `).catch(() => {});
214
231
  await client.query(`
215
232
  CREATE TABLE IF NOT EXISTS agent_knowledge_access (
package/src/db/sqlite.ts CHANGED
@@ -78,7 +78,15 @@ export class SqliteAdapter extends DatabaseAdapter {
78
78
  updated_at TEXT DEFAULT (datetime('now'))
79
79
  );
80
80
  `);
81
+ try { this.db.exec(`ALTER TABLE client_organizations ADD COLUMN billing_rate_per_agent REAL DEFAULT 0`); } catch { /* exists */ }
82
+ try { this.db.exec(`ALTER TABLE client_organizations ADD COLUMN currency TEXT DEFAULT 'USD'`); } catch { /* exists */ }
81
83
  try { this.db.exec(`ALTER TABLE agents ADD COLUMN client_org_id TEXT REFERENCES client_organizations(id)`); } catch { /* exists */ }
84
+ this.db.exec(`CREATE TABLE IF NOT EXISTS org_billing_records (
85
+ id TEXT PRIMARY KEY, org_id TEXT NOT NULL, agent_id TEXT, month TEXT NOT NULL,
86
+ revenue REAL DEFAULT 0, token_cost REAL DEFAULT 0, input_tokens INTEGER DEFAULT 0,
87
+ output_tokens INTEGER DEFAULT 0, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(org_id, agent_id, month)
89
+ )`);
82
90
  this.db.exec(`
83
91
  CREATE TABLE IF NOT EXISTS agent_knowledge_access (
84
92
  id TEXT PRIMARY KEY,