@agenticmail/enterprise 0.5.300 → 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.
- package/dist/chunk-QKQWDX6M.js +1519 -0
- package/dist/chunk-VLVRDLYO.js +48 -0
- package/dist/chunk-VMVOJFCX.js +4338 -0
- package/dist/cli-agent-4GZGC6XO.js +1778 -0
- package/dist/cli-recover-WS27YEB7.js +487 -0
- package/dist/cli-serve-HVULKNQH.js +143 -0
- package/dist/cli-verify-CVYMUGKX.js +149 -0
- package/dist/cli.js +5 -5
- package/dist/dashboard/pages/organizations.js +166 -13
- package/dist/factory-XEBV2VGZ.js +9 -0
- package/dist/index.js +3 -3
- package/dist/postgres-NZBDKOQR.js +816 -0
- package/dist/server-3HZEV5X2.js +15 -0
- package/dist/setup-JUB67BUU.js +20 -0
- package/dist/sqlite-INPN4DQN.js +545 -0
- package/package.json +1 -1
- package/src/admin/routes.ts +94 -5
- package/src/dashboard/pages/organizations.js +166 -13
- package/src/db/postgres.ts +17 -0
- package/src/db/sqlite.ts +8 -0
package/src/admin/routes.ts
CHANGED
|
@@ -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 {
|
|
@@ -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);
|
|
@@ -209,8 +223,8 @@ export function OrganizationsPage() {
|
|
|
209
223
|
org.description && h('div', { style: { fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12, lineHeight: 1.5 } }, org.description),
|
|
210
224
|
h('div', { style: { display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' } },
|
|
211
225
|
h('span', null, I.agents(), ' ', (org.agent_count || 0), ' agent', (org.agent_count || 0) !== 1 ? 's' : ''),
|
|
212
|
-
org.
|
|
213
|
-
org.
|
|
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)
|
|
214
228
|
),
|
|
215
229
|
h('div', { style: { display: 'flex', gap: 6, marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10 }, onClick: function(e) { e.stopPropagation(); } },
|
|
216
230
|
h('button', { className: 'btn btn-ghost btn-sm', onClick: function() { openEdit(org); } }, I.edit(), ' Edit'),
|
|
@@ -229,24 +243,36 @@ export function OrganizationsPage() {
|
|
|
229
243
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14, padding: 4 } },
|
|
230
244
|
h('div', null,
|
|
231
245
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Name *'),
|
|
232
|
-
h('input', { className: 'input', value: fname, onInput: function(e) { setFname(e.target.value); if (!slugManual) setFslug(slugify(e.target.value)); }, placeholder: '
|
|
246
|
+
h('input', { className: 'input', value: fname, onInput: function(e) { setFname(e.target.value); if (!slugManual) setFslug(slugify(e.target.value)); }, placeholder: 'AgenticMail' })
|
|
233
247
|
),
|
|
234
248
|
h('div', null,
|
|
235
249
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Slug *'),
|
|
236
|
-
h('input', { className: 'input', value: fslug, onInput: function(e) { setFslug(e.target.value); setSlugManual(true); }, placeholder: '
|
|
250
|
+
h('input', { className: 'input', value: fslug, onInput: function(e) { setFslug(e.target.value); setSlugManual(true); }, placeholder: 'agenticmail', style: { fontFamily: 'var(--font-mono, monospace)' } })
|
|
237
251
|
),
|
|
238
252
|
h('div', null,
|
|
239
253
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Contact Name'),
|
|
240
|
-
h('input', { className: 'input', value: fcontact, onInput: function(e) { setFcontact(e.target.value); }, placeholder: '
|
|
254
|
+
h('input', { className: 'input', value: fcontact, onInput: function(e) { setFcontact(e.target.value); }, placeholder: 'Ope Olatunji' })
|
|
241
255
|
),
|
|
242
256
|
h('div', null,
|
|
243
257
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Contact Email'),
|
|
244
|
-
h('input', { className: 'input', type: 'email', value: femail, onInput: function(e) { setFemail(e.target.value); }, placeholder: '
|
|
258
|
+
h('input', { className: 'input', type: 'email', value: femail, onInput: function(e) { setFemail(e.target.value); }, placeholder: 'ope@agenticmail.io' })
|
|
245
259
|
),
|
|
246
260
|
h('div', null,
|
|
247
261
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Description'),
|
|
248
262
|
h('textarea', { className: 'input', value: fdesc, onInput: function(e) { setFdesc(e.target.value); }, placeholder: 'Brief description...', rows: 3, style: { resize: 'vertical' } })
|
|
249
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
|
+
),
|
|
250
276
|
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 } },
|
|
251
277
|
h('button', { className: 'btn btn-secondary', onClick: function() { setShowCreate(false); } }, 'Cancel'),
|
|
252
278
|
h('button', { className: 'btn btn-primary', disabled: !fname || !fslug || acting === 'create', onClick: doCreate }, acting === 'create' ? 'Creating...' : 'Create')
|
|
@@ -277,6 +303,18 @@ export function OrganizationsPage() {
|
|
|
277
303
|
h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Description'),
|
|
278
304
|
h('textarea', { className: 'input', value: fdesc, onInput: function(e) { setFdesc(e.target.value); }, rows: 3, style: { resize: 'vertical' } })
|
|
279
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
|
+
),
|
|
280
318
|
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 } },
|
|
281
319
|
h('button', { className: 'btn btn-secondary', onClick: function() { setEditOrg(null); } }, 'Cancel'),
|
|
282
320
|
h('button', { className: 'btn btn-primary', disabled: !fname || acting === 'edit', onClick: doEdit }, acting === 'edit' ? 'Saving...' : 'Save Changes')
|
|
@@ -285,10 +323,10 @@ export function OrganizationsPage() {
|
|
|
285
323
|
),
|
|
286
324
|
|
|
287
325
|
// Detail Modal
|
|
288
|
-
detailOrg && h(Modal, { title: detailOrg.name || 'Organization Detail', onClose: function() { setDetailOrg(null); },
|
|
326
|
+
detailOrg && h(Modal, { title: detailOrg.name || 'Organization Detail', onClose: function() { setDetailOrg(null); }, width: 700 },
|
|
289
327
|
h('div', { style: { padding: 4 } },
|
|
290
328
|
// Org info
|
|
291
|
-
h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom:
|
|
329
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 16, marginBottom: 16 } },
|
|
292
330
|
h('div', null,
|
|
293
331
|
h('div', { style: { fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', fontWeight: 600, marginBottom: 4 } }, 'Slug'),
|
|
294
332
|
h('div', { style: { fontFamily: 'var(--font-mono, monospace)', fontSize: 13 } }, detailOrg.slug)
|
|
@@ -303,10 +341,29 @@ export function OrganizationsPage() {
|
|
|
303
341
|
detailOrg.contact_email && h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, detailOrg.contact_email)
|
|
304
342
|
)
|
|
305
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
|
+
|
|
306
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),
|
|
307
352
|
|
|
308
|
-
//
|
|
309
|
-
h('div', { style: {
|
|
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'),
|
|
310
367
|
detailAgents.length > 0
|
|
311
368
|
? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 } },
|
|
312
369
|
detailAgents.map(function(a) {
|
|
@@ -339,6 +396,102 @@ export function OrganizationsPage() {
|
|
|
339
396
|
),
|
|
340
397
|
h('button', { className: 'btn btn-primary btn-sm', disabled: !assignAgentId || acting === 'assign', onClick: doAssignAgent }, acting === 'assign' ? 'Assigning...' : 'Assign')
|
|
341
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
|
|
342
495
|
)
|
|
343
496
|
)
|
|
344
497
|
);
|
package/src/db/postgres.ts
CHANGED
|
@@ -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,
|