neoagent 3.0.1-beta.7 → 3.0.1-beta.9
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/.env.example +19 -0
- package/README.md +1 -0
- package/docs/billing.md +203 -0
- package/docs/configuration.md +13 -0
- package/docs/index.md +1 -0
- package/lib/schema_migrations.js +84 -0
- package/package.json +2 -1
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +2 -1
- package/server/admin/billing.js +292 -0
- package/server/admin/index.html +43 -1
- package/server/db/database.js +1 -0
- package/server/http/routes.js +9 -0
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +110 -0
- package/server/routes/billing.js +122 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/services/ai/loop/agent_engine_core.js +1 -1
- package/server/services/ai/loop/completion_judge.js +2 -9
- package/server/services/ai/loop/conversation_loop.js +12 -26
- package/server/services/ai/loopPolicy.js +2 -39
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/systemPrompt.js +17 -0
- package/server/services/ai/tools.js +1 -11
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +120 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/manager.js +11 -0
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/tasks/runtime.js +52 -12
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/workspace/manager.js +23 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let _billingPlans = [];
|
|
4
|
+
let _billingSubsOffset = 0;
|
|
5
|
+
const BILLING_SUBS_LIMIT = 50;
|
|
6
|
+
|
|
7
|
+
async function loadBilling() {
|
|
8
|
+
// Check if billing is enabled by probing the plans endpoint.
|
|
9
|
+
try {
|
|
10
|
+
const r = await fetch('/admin/api/billing/plans');
|
|
11
|
+
if (!r.ok) {
|
|
12
|
+
document.getElementById('billing-plans-content').innerHTML =
|
|
13
|
+
'<div class="empty">Billing is not enabled on this server. Set <code>NEOAGENT_BILLING_ENABLED=1</code> to enable it.</div>';
|
|
14
|
+
document.getElementById('billing-subs-content').innerHTML = '';
|
|
15
|
+
document.getElementById('nav-billing').style.display = '';
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const data = await r.json();
|
|
19
|
+
_billingPlans = data.plans || [];
|
|
20
|
+
renderPlansTable(_billingPlans);
|
|
21
|
+
} catch {
|
|
22
|
+
document.getElementById('billing-plans-content').innerHTML = '<div class="empty">Failed to load billing data.</div>';
|
|
23
|
+
}
|
|
24
|
+
document.getElementById('nav-billing').style.display = '';
|
|
25
|
+
_billingSubsOffset = 0;
|
|
26
|
+
await loadBillingSubscriptions();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Show the billing nav item on load if billing is enabled.
|
|
30
|
+
(async function checkBillingEnabled() {
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch('/admin/api/billing/plans');
|
|
33
|
+
if (r.ok) document.getElementById('nav-billing').style.display = '';
|
|
34
|
+
} catch {}
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
function planRowHtml(plan) {
|
|
38
|
+
const id = escAttr(plan.id);
|
|
39
|
+
const intervalLabel = plan.interval ? `/ ${plan.interval}` : '';
|
|
40
|
+
const price = plan.price_cents === 0 ? 'Free' : `${(plan.price_cents / 100).toFixed(2)} ${(plan.currency || 'usd').toUpperCase()} ${intervalLabel}`;
|
|
41
|
+
const status = plan.is_active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--text-muted)">Inactive</span>';
|
|
42
|
+
return `
|
|
43
|
+
<tr data-plan-id="${id}">
|
|
44
|
+
<td><strong>${esc(plan.name)}</strong><br><small style="color:var(--text-muted)">${esc(plan.id)}</small></td>
|
|
45
|
+
<td>${esc(price)}</td>
|
|
46
|
+
<td>${plan.token_limit_4h != null ? fmtTokens(plan.token_limit_4h) : '<span style="color:var(--text-muted)">Default</span>'}</td>
|
|
47
|
+
<td>${plan.token_limit_weekly != null ? fmtTokens(plan.token_limit_weekly) : '<span style="color:var(--text-muted)">Default</span>'}</td>
|
|
48
|
+
<td><code style="font-size:11px">${esc(plan.stripe_price_id || '—')}</code></td>
|
|
49
|
+
<td>${status}</td>
|
|
50
|
+
<td>
|
|
51
|
+
<button class="btn btn-sm" onclick="billingEditPlan('${id}')">Edit</button>
|
|
52
|
+
<button class="btn btn-sm btn-danger" onclick="billingDeletePlan('${id}')">Delete</button>
|
|
53
|
+
</td>
|
|
54
|
+
</tr>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderPlansTable(plans) {
|
|
58
|
+
const el = document.getElementById('billing-plans-content');
|
|
59
|
+
if (!plans.length) {
|
|
60
|
+
el.innerHTML = '<div class="empty">No plans yet. Create one above.</div>';
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
el.innerHTML = `
|
|
64
|
+
<table class="data-table">
|
|
65
|
+
<thead><tr>
|
|
66
|
+
<th>Plan</th><th>Price</th><th>4h Tokens</th><th>Weekly Tokens</th><th>Stripe Price ID</th><th>Status</th><th>Actions</th>
|
|
67
|
+
</tr></thead>
|
|
68
|
+
<tbody>${plans.map(planRowHtml).join('')}</tbody>
|
|
69
|
+
</table>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function billingShowNewPlanForm() {
|
|
73
|
+
billingOpenPlanModal(null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function billingEditPlan(planId) {
|
|
77
|
+
const plan = _billingPlans.find((p) => p.id === planId);
|
|
78
|
+
billingOpenPlanModal(plan);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function billingOpenPlanModal(plan) {
|
|
82
|
+
const isNew = !plan;
|
|
83
|
+
const title = isNew ? 'New Plan' : `Edit Plan: ${plan.name}`;
|
|
84
|
+
const html = `
|
|
85
|
+
<div id="billing-plan-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000">
|
|
86
|
+
<div style="background:var(--bg-card);border-radius:12px;padding:28px;width:540px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
|
|
87
|
+
<h2 style="margin:0 0 20px">${esc(title)}</h2>
|
|
88
|
+
<form id="billing-plan-form" onsubmit="billingPlanFormSubmit(event)">
|
|
89
|
+
<input type="hidden" id="bp-id" value="${esc(plan?.id || '')}">
|
|
90
|
+
<label class="form-label">Plan ID <small>(set once, used as identifier)</small></label>
|
|
91
|
+
<input class="form-input" id="bp-slug" value="${esc(plan?.id || '')}" ${isNew ? '' : 'disabled'} placeholder="plan_pro" style="margin-bottom:12px">
|
|
92
|
+
<label class="form-label">Name</label>
|
|
93
|
+
<input class="form-input" id="bp-name" value="${esc(plan?.name || '')}" required placeholder="Pro" style="margin-bottom:12px">
|
|
94
|
+
<label class="form-label">Description</label>
|
|
95
|
+
<input class="form-input" id="bp-desc" value="${esc(plan?.description || '')}" placeholder="Optional description" style="margin-bottom:12px">
|
|
96
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
|
97
|
+
<div>
|
|
98
|
+
<label class="form-label">Price (cents)</label>
|
|
99
|
+
<input class="form-input" id="bp-price" type="number" min="0" value="${plan?.price_cents ?? 0}" required>
|
|
100
|
+
</div>
|
|
101
|
+
<div>
|
|
102
|
+
<label class="form-label">Currency</label>
|
|
103
|
+
<input class="form-input" id="bp-currency" value="${esc(plan?.currency || 'usd')}" placeholder="usd">
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
|
107
|
+
<div>
|
|
108
|
+
<label class="form-label">Billing interval</label>
|
|
109
|
+
<select class="form-input" id="bp-interval">
|
|
110
|
+
<option value="month" ${plan?.interval === 'month' ? 'selected' : ''}>Monthly</option>
|
|
111
|
+
<option value="year" ${plan?.interval === 'year' ? 'selected' : ''}>Yearly</option>
|
|
112
|
+
<option value="" ${!plan?.interval ? 'selected' : ''}>One-time / Free</option>
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
<div>
|
|
116
|
+
<label class="form-label">Sort order</label>
|
|
117
|
+
<input class="form-input" id="bp-sort" type="number" value="${plan?.sort_order ?? 0}">
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<label class="form-label">Stripe Price ID</label>
|
|
121
|
+
<input class="form-input" id="bp-stripe-price" value="${esc(plan?.stripe_price_id || '')}" placeholder="price_..." style="margin-bottom:12px">
|
|
122
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
|
123
|
+
<div>
|
|
124
|
+
<label class="form-label">4h token limit <small>(blank = default)</small></label>
|
|
125
|
+
<input class="form-input" id="bp-tok-4h" type="number" min="0" value="${plan?.token_limit_4h ?? ''}" placeholder="2500000">
|
|
126
|
+
</div>
|
|
127
|
+
<div>
|
|
128
|
+
<label class="form-label">Weekly token limit <small>(blank = default)</small></label>
|
|
129
|
+
<input class="form-input" id="bp-tok-weekly" type="number" min="0" value="${plan?.token_limit_weekly ?? ''}" placeholder="10000000">
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<label class="form-label">Allowed model IDs <small>(comma-separated; blank = all)</small></label>
|
|
133
|
+
<input class="form-input" id="bp-models" value="${esc((plan?.allowed_models || []).join(', '))}" placeholder="claude-opus-4-8, gpt-4o" style="margin-bottom:12px">
|
|
134
|
+
<label class="form-label">Features <small>(comma-separated, shown on pricing page)</small></label>
|
|
135
|
+
<input class="form-input" id="bp-features" value="${esc((plan?.features || []).join(', '))}" placeholder="Unlimited agents, Priority support" style="margin-bottom:16px">
|
|
136
|
+
<label style="display:flex;align-items:center;gap:8px;margin-bottom:20px;cursor:pointer">
|
|
137
|
+
<input type="checkbox" id="bp-active" ${!plan || plan.is_active ? 'checked' : ''}> Active
|
|
138
|
+
</label>
|
|
139
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
140
|
+
<button type="button" class="btn" onclick="billingClosePlanModal()">Cancel</button>
|
|
141
|
+
<button type="submit" class="btn btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
|
142
|
+
</div>
|
|
143
|
+
</form>
|
|
144
|
+
</div>
|
|
145
|
+
</div>`;
|
|
146
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function billingClosePlanModal() {
|
|
150
|
+
document.getElementById('billing-plan-modal')?.remove();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function billingPlanFormSubmit(e) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
const id = document.getElementById('bp-id').value;
|
|
156
|
+
const slug = document.getElementById('bp-slug').value.trim();
|
|
157
|
+
const isNew = !id;
|
|
158
|
+
|
|
159
|
+
const parseTokenLimit = (val) => {
|
|
160
|
+
const n = parseInt(val, 10);
|
|
161
|
+
return isNaN(n) || val === '' ? null : n;
|
|
162
|
+
};
|
|
163
|
+
const parseModels = (val) => val.split(',').map((s) => s.trim()).filter(Boolean);
|
|
164
|
+
const parseFeatures = (val) => val.split(',').map((s) => s.trim()).filter(Boolean);
|
|
165
|
+
|
|
166
|
+
const body = {
|
|
167
|
+
id: isNew ? slug : undefined,
|
|
168
|
+
name: document.getElementById('bp-name').value.trim(),
|
|
169
|
+
description: document.getElementById('bp-desc').value.trim(),
|
|
170
|
+
price_cents: parseInt(document.getElementById('bp-price').value, 10),
|
|
171
|
+
currency: document.getElementById('bp-currency').value.trim() || 'usd',
|
|
172
|
+
interval: document.getElementById('bp-interval').value || null,
|
|
173
|
+
stripe_price_id: document.getElementById('bp-stripe-price').value.trim() || null,
|
|
174
|
+
token_limit_4h: parseTokenLimit(document.getElementById('bp-tok-4h').value),
|
|
175
|
+
token_limit_weekly: parseTokenLimit(document.getElementById('bp-tok-weekly').value),
|
|
176
|
+
allowed_models: parseModels(document.getElementById('bp-models').value),
|
|
177
|
+
features: parseFeatures(document.getElementById('bp-features').value),
|
|
178
|
+
sort_order: parseInt(document.getElementById('bp-sort').value, 10) || 0,
|
|
179
|
+
is_active: document.getElementById('bp-active').checked,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const url = isNew ? '/admin/api/billing/plans' : `/admin/api/billing/plans/${encodeURIComponent(id)}`;
|
|
184
|
+
const method = isNew ? 'POST' : 'PUT';
|
|
185
|
+
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
186
|
+
const data = await r.json();
|
|
187
|
+
if (!r.ok) { alert(data.error || 'Failed to save plan.'); return; }
|
|
188
|
+
billingClosePlanModal();
|
|
189
|
+
await loadBilling();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
alert('Error: ' + err.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function billingDeletePlan(planId) {
|
|
196
|
+
if (!confirm(`Delete plan "${planId}"? It will be deactivated (not hard-deleted).`)) return;
|
|
197
|
+
try {
|
|
198
|
+
const r = await fetch(`/admin/api/billing/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
|
|
199
|
+
if (!r.ok) { const d = await r.json(); alert(d.error || 'Failed.'); return; }
|
|
200
|
+
await loadBilling();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
alert('Error: ' + err.message);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function loadBillingSubscriptions() {
|
|
207
|
+
const statusFilter = document.getElementById('billing-sub-status-filter')?.value || '';
|
|
208
|
+
const el = document.getElementById('billing-subs-content');
|
|
209
|
+
el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
210
|
+
try {
|
|
211
|
+
const params = new URLSearchParams({ limit: BILLING_SUBS_LIMIT, offset: _billingSubsOffset });
|
|
212
|
+
if (statusFilter) params.set('status', statusFilter);
|
|
213
|
+
const r = await fetch('/admin/api/billing/subscriptions?' + params);
|
|
214
|
+
if (!r.ok) { el.innerHTML = '<div class="empty">Failed to load subscriptions.</div>'; return; }
|
|
215
|
+
const data = await r.json();
|
|
216
|
+
renderSubsTable(data.subscriptions, data.total);
|
|
217
|
+
} catch {
|
|
218
|
+
el.innerHTML = '<div class="empty">Failed to load subscriptions.</div>';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderSubsTable(rows, total) {
|
|
223
|
+
const el = document.getElementById('billing-subs-content');
|
|
224
|
+
if (!rows.length) { el.innerHTML = '<div class="empty">No subscriptions found.</div>'; return; }
|
|
225
|
+
el.innerHTML = `
|
|
226
|
+
<table class="data-table">
|
|
227
|
+
<thead><tr>
|
|
228
|
+
<th>User</th><th>Plan</th><th>Status</th><th>Period end</th><th>Actions</th>
|
|
229
|
+
</tr></thead>
|
|
230
|
+
<tbody>${rows.map(subRowHtml).join('')}</tbody>
|
|
231
|
+
</table>`;
|
|
232
|
+
|
|
233
|
+
const pag = document.getElementById('billing-subs-pagination');
|
|
234
|
+
const hasPrev = _billingSubsOffset > 0;
|
|
235
|
+
const hasNext = _billingSubsOffset + BILLING_SUBS_LIMIT < total;
|
|
236
|
+
pag.innerHTML = `
|
|
237
|
+
<span style="color:var(--text-muted);font-size:13px">${total} total</span>
|
|
238
|
+
${hasPrev ? `<button class="btn btn-sm" onclick="_billingSubsOffset-=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">← Prev</button>` : ''}
|
|
239
|
+
${hasNext ? `<button class="btn btn-sm" onclick="_billingSubsOffset+=${BILLING_SUBS_LIMIT};loadBillingSubscriptions()">Next →</button>` : ''}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function subRowHtml(sub) {
|
|
243
|
+
const userLabel = esc(sub.display_name || sub.username || String(sub.user_id));
|
|
244
|
+
const email = sub.email ? `<br><small style="color:var(--text-muted)">${esc(sub.email)}</small>` : '';
|
|
245
|
+
const statusColor = { active: 'var(--success)', trialing: 'var(--warning)', past_due: 'var(--danger)', canceled: 'var(--text-muted)' }[sub.status] || 'var(--text-muted)';
|
|
246
|
+
const periodEnd = sub.current_period_end ? sub.current_period_end.slice(0, 10) : '—';
|
|
247
|
+
return `
|
|
248
|
+
<tr>
|
|
249
|
+
<td>${userLabel}${email}</td>
|
|
250
|
+
<td>${esc(sub.plan_name)}</td>
|
|
251
|
+
<td><span style="color:${statusColor}">${esc(sub.status)}</span></td>
|
|
252
|
+
<td>${periodEnd}</td>
|
|
253
|
+
<td><button class="btn btn-sm" onclick="billingOverrideSub(${sub.user_id})">Override</button></td>
|
|
254
|
+
</tr>`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function billingOverrideSub(userId) {
|
|
258
|
+
if (!_billingPlans.length) { alert('No plans available.'); return; }
|
|
259
|
+
const options = _billingPlans.filter((p) => p.is_active).map((p) => `${p.id} — ${p.name}`).join('\n');
|
|
260
|
+
const planId = prompt(`Enter plan ID to assign:\n\n${options}`);
|
|
261
|
+
if (!planId) return;
|
|
262
|
+
try {
|
|
263
|
+
const r = await fetch(`/admin/api/billing/users/${userId}/subscription`, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({ planId: planId.trim() }),
|
|
267
|
+
});
|
|
268
|
+
const data = await r.json();
|
|
269
|
+
if (!r.ok) { alert(data.error || 'Failed.'); return; }
|
|
270
|
+
await loadBillingSubscriptions();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
alert('Error: ' + err.message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function esc(str) {
|
|
279
|
+
return String(str ?? '')
|
|
280
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
281
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function escAttr(str) {
|
|
285
|
+
return esc(str);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fmtTokens(n) {
|
|
289
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
290
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
291
|
+
return String(n);
|
|
292
|
+
}
|
package/server/admin/index.html
CHANGED
|
@@ -871,7 +871,7 @@
|
|
|
871
871
|
|
|
872
872
|
<button class="nav-item" data-page="issues" onclick="showPage('issues',this)">
|
|
873
873
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
874
|
-
<path fill-rule="evenodd" d="
|
|
874
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
875
875
|
</svg>
|
|
876
876
|
Issues
|
|
877
877
|
<span class="nav-badge" id="issues-badge" hidden>0</span>
|
|
@@ -900,6 +900,14 @@
|
|
|
900
900
|
Users
|
|
901
901
|
</button>
|
|
902
902
|
|
|
903
|
+
<button class="nav-item" id="nav-billing" data-page="billing" onclick="showPage('billing',this)" style="display:none">
|
|
904
|
+
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
905
|
+
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z"/>
|
|
906
|
+
<path fill-rule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clip-rule="evenodd"/>
|
|
907
|
+
</svg>
|
|
908
|
+
Billing
|
|
909
|
+
</button>
|
|
910
|
+
|
|
903
911
|
<button class="nav-item" data-page="sql" onclick="showPage('sql',this)">
|
|
904
912
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
905
913
|
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm11 1H6v8l4-2 4 2V6z" clip-rule="evenodd"/>
|
|
@@ -1189,6 +1197,39 @@
|
|
|
1189
1197
|
</div>
|
|
1190
1198
|
</section>
|
|
1191
1199
|
|
|
1200
|
+
<section id="page-billing" class="page" aria-label="Billing">
|
|
1201
|
+
<div class="page-header">
|
|
1202
|
+
<div>
|
|
1203
|
+
<h1 class="page-title">Billing</h1>
|
|
1204
|
+
<p class="page-subtitle">Subscription plans and user billing management</p>
|
|
1205
|
+
</div>
|
|
1206
|
+
</div>
|
|
1207
|
+
<div class="content">
|
|
1208
|
+
<div class="card">
|
|
1209
|
+
<div class="card-title">Subscription Plans</div>
|
|
1210
|
+
<div style="margin-bottom:12px">
|
|
1211
|
+
<button class="btn btn-primary" onclick="billingShowNewPlanForm()">+ New Plan</button>
|
|
1212
|
+
</div>
|
|
1213
|
+
<div id="billing-plans-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1214
|
+
</div>
|
|
1215
|
+
<div class="card">
|
|
1216
|
+
<div class="card-title">User Subscriptions</div>
|
|
1217
|
+
<div style="margin-bottom:12px;display:flex;gap:8px;align-items:center">
|
|
1218
|
+
<select id="billing-sub-status-filter" onchange="loadBillingSubscriptions()" style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--input-bg);color:var(--text)">
|
|
1219
|
+
<option value="">All statuses</option>
|
|
1220
|
+
<option value="active">Active</option>
|
|
1221
|
+
<option value="trialing">Trialing</option>
|
|
1222
|
+
<option value="past_due">Past due</option>
|
|
1223
|
+
<option value="canceled">Canceled</option>
|
|
1224
|
+
</select>
|
|
1225
|
+
<button class="btn" onclick="loadBillingSubscriptions()">Refresh</button>
|
|
1226
|
+
</div>
|
|
1227
|
+
<div id="billing-subs-content"><div class="empty"><span class="spinner"></span></div></div>
|
|
1228
|
+
<div id="billing-subs-pagination" style="margin-top:12px;display:flex;gap:8px;align-items:center"></div>
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
</section>
|
|
1232
|
+
|
|
1192
1233
|
</main>
|
|
1193
1234
|
|
|
1194
1235
|
<script src="/admin/admin.js"></script>
|
|
@@ -1196,5 +1237,6 @@
|
|
|
1196
1237
|
<script src="/admin/users.js"></script>
|
|
1197
1238
|
<script src="/admin/sql.js"></script>
|
|
1198
1239
|
<script src="/admin/access.js"></script>
|
|
1240
|
+
<script src="/admin/billing.js"></script>
|
|
1199
1241
|
</body>
|
|
1200
1242
|
</html>
|
package/server/db/database.js
CHANGED
|
@@ -1328,6 +1328,7 @@ for (const col of [
|
|
|
1328
1328
|
"ALTER TABLE memory_facts ADD COLUMN invalidated_at TEXT",
|
|
1329
1329
|
"ALTER TABLE memory_facts ADD COLUMN status TEXT DEFAULT 'active'",
|
|
1330
1330
|
"ALTER TABLE memory_facts ADD COLUMN supersedes_fact_id TEXT",
|
|
1331
|
+
"ALTER TABLE users ADD COLUMN billing_override_plan_id TEXT",
|
|
1331
1332
|
]) {
|
|
1332
1333
|
try { db.exec(col); } catch { /* column already exists */ }
|
|
1333
1334
|
}
|
package/server/http/routes.js
CHANGED
|
@@ -48,6 +48,15 @@ function registerApiRoutes(app) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Billing routes are mounted conditionally — only when billing is enabled.
|
|
52
|
+
// The webhook must be mounted before express.json() consumes the raw body;
|
|
53
|
+
// billing_webhook.js applies express.raw() inline for its own route.
|
|
54
|
+
const { isBillingEnabled } = require('../services/billing/config');
|
|
55
|
+
if (isBillingEnabled()) {
|
|
56
|
+
app.use('/api/billing/webhook', require('../routes/billing_webhook'));
|
|
57
|
+
app.use('/api/billing', require('../routes/billing'));
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
setupTelnyxWebhook(app);
|
|
52
61
|
|
|
53
62
|
app.get('/api/health', requireAuth, (req, res) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isBillingEnabled } = require('../services/billing/config');
|
|
4
|
+
|
|
5
|
+
function requireBilling(req, res, next) {
|
|
6
|
+
if (!isBillingEnabled()) {
|
|
7
|
+
return res.status(404).json({ error: 'Billing is not enabled on this server.' });
|
|
8
|
+
}
|
|
9
|
+
next();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { requireBilling };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4d0927fbddb563e4c34344ab07ec61ec
|
|
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"77e2e94772b6eb43759e34ed1ad7da4674e19c
|
|
|
37
37
|
|
|
38
38
|
_flutter.loader.load({
|
|
39
39
|
serviceWorkerSettings: {
|
|
40
|
-
serviceWorkerVersion: "
|
|
40
|
+
serviceWorkerVersion: "3425829902" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
|
41
41
|
}
|
|
42
42
|
});
|
|
@@ -135597,7 +135597,7 @@ r===$&&A.b()
|
|
|
135597
135597
|
p.push(A.jV(q,A.jd(!1,new A.a_(B.uM,A.db(new A.cC(B.jA,new A.a89(r,q),q),q,q),q),!1,B.I,!0),q,q,0,0,0,q))}r=!1
|
|
135598
135598
|
if(!s.ay)if(!s.ch){r=s.e
|
|
135599
135599
|
r===$&&A.b()
|
|
135600
|
-
r=B.b.u("
|
|
135600
|
+
r=B.b.u("mqkodqpj-f9ba2c1").length!==0&&r.b}if(r){r=s.d
|
|
135601
135601
|
r===$&&A.b()
|
|
135602
135602
|
r=r.aP&&!r.ai?84:0
|
|
135603
135603
|
s=s.e
|
|
@@ -141319,7 +141319,7 @@ $S:0}
|
|
|
141319
141319
|
A.a_p.prototype={}
|
|
141320
141320
|
A.T7.prototype={
|
|
141321
141321
|
nd(a){var s=this
|
|
141322
|
-
if(B.b.u("
|
|
141322
|
+
if(B.b.u("mqkodqpj-f9ba2c1").length===0||s.a!=null)return
|
|
141323
141323
|
s.B_()
|
|
141324
141324
|
s.a=A.ot(B.S2,new A.bde(s))},
|
|
141325
141325
|
B_(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
|
|
@@ -141337,7 +141337,7 @@ if(!t.f.b(k)){s=1
|
|
|
141337
141337
|
break}i=J.a3(k,"buildId")
|
|
141338
141338
|
h=i==null?null:B.b.u(J.p(i))
|
|
141339
141339
|
j=h==null?"":h
|
|
141340
|
-
if(J.bj(j)===0||J.e(j,"
|
|
141340
|
+
if(J.bj(j)===0||J.e(j,"mqkodqpj-f9ba2c1")){s=1
|
|
141341
141341
|
break}n.b=!0
|
|
141342
141342
|
n.F()
|
|
141343
141343
|
p=2
|
|
@@ -141354,7 +141354,7 @@ case 2:return A.i(o.at(-1),r)}})
|
|
|
141354
141354
|
return A.k($async$B_,r)},
|
|
141355
141355
|
vI(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
|
|
141356
141356
|
var $async$vI=A.h(function(a2,a3){if(a2===1){o.push(a3)
|
|
141357
|
-
s=p}for(;;)switch(s){case 0:if(B.b.u("
|
|
141357
|
+
s=p}for(;;)switch(s){case 0:if(B.b.u("mqkodqpj-f9ba2c1").length===0||n.c){s=1
|
|
141358
141358
|
break}n.c=!0
|
|
141359
141359
|
n.F()
|
|
141360
141360
|
p=4
|
package/server/routes/admin.js
CHANGED
|
@@ -773,6 +773,116 @@ router.put('/api/users/:id/rate-limits', requireAdminAuth, express.json(), (req,
|
|
|
773
773
|
}
|
|
774
774
|
});
|
|
775
775
|
|
|
776
|
+
// --- Billing admin routes (only when billing is enabled) ---
|
|
777
|
+
|
|
778
|
+
(function registerBillingRoutes() {
|
|
779
|
+
const { isBillingEnabled } = require('../services/billing/config');
|
|
780
|
+
if (!isBillingEnabled()) return;
|
|
781
|
+
|
|
782
|
+
const billingPlans = require('../services/billing/plans');
|
|
783
|
+
const billingSubscriptions = require('../services/billing/subscriptions');
|
|
784
|
+
|
|
785
|
+
// Plans CRUD
|
|
786
|
+
router.get('/api/billing/plans', requireAdminAuth, (req, res) => {
|
|
787
|
+
try {
|
|
788
|
+
res.json({ plans: billingPlans.listPlans({ includeInactive: true }) });
|
|
789
|
+
} catch (err) {
|
|
790
|
+
res.status(500).json({ error: err.message });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
router.post('/api/billing/plans', requireAdminAuth, express.json(), (req, res) => {
|
|
795
|
+
try {
|
|
796
|
+
const plan = billingPlans.createPlan(req.body);
|
|
797
|
+
res.status(201).json({ plan });
|
|
798
|
+
} catch (err) {
|
|
799
|
+
res.status(400).json({ error: err.message });
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
router.put('/api/billing/plans/:id', requireAdminAuth, express.json(), (req, res) => {
|
|
804
|
+
try {
|
|
805
|
+
const plan = billingPlans.updatePlan(req.params.id, req.body);
|
|
806
|
+
if (!plan) return res.status(404).json({ error: 'Plan not found.' });
|
|
807
|
+
res.json({ plan });
|
|
808
|
+
} catch (err) {
|
|
809
|
+
res.status(400).json({ error: err.message });
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
router.delete('/api/billing/plans/:id', requireAdminAuth, (req, res) => {
|
|
814
|
+
try {
|
|
815
|
+
billingPlans.deletePlan(req.params.id);
|
|
816
|
+
res.json({ ok: true });
|
|
817
|
+
} catch (err) {
|
|
818
|
+
res.status(500).json({ error: err.message });
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// Subscription browser (all users)
|
|
823
|
+
router.get('/api/billing/subscriptions', requireAdminAuth, (req, res) => {
|
|
824
|
+
try {
|
|
825
|
+
const db = require('../db/database');
|
|
826
|
+
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
|
|
827
|
+
const offset = parseInt(req.query.offset || '0', 10);
|
|
828
|
+
const status = req.query.status || null;
|
|
829
|
+
|
|
830
|
+
const where = status ? "WHERE s.status = ?" : "";
|
|
831
|
+
const params = status ? [status, limit, offset] : [limit, offset];
|
|
832
|
+
|
|
833
|
+
const rows = db.prepare(`
|
|
834
|
+
SELECT s.*, u.username, u.email, u.display_name,
|
|
835
|
+
p.name AS plan_name, p.price_cents, p.currency
|
|
836
|
+
FROM user_subscriptions s
|
|
837
|
+
JOIN users u ON u.id = s.user_id
|
|
838
|
+
JOIN billing_plans p ON p.id = s.plan_id
|
|
839
|
+
${where}
|
|
840
|
+
ORDER BY s.updated_at DESC
|
|
841
|
+
LIMIT ? OFFSET ?
|
|
842
|
+
`).all(...params);
|
|
843
|
+
|
|
844
|
+
const total = db.prepare(
|
|
845
|
+
`SELECT COUNT(*) AS n FROM user_subscriptions s ${where}`,
|
|
846
|
+
).get(...(status ? [status] : [])).n;
|
|
847
|
+
|
|
848
|
+
res.json({ subscriptions: rows, total, limit, offset });
|
|
849
|
+
} catch (err) {
|
|
850
|
+
res.status(500).json({ error: err.message });
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Per-user subscription management
|
|
855
|
+
router.get('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
|
|
856
|
+
try {
|
|
857
|
+
const sub = billingSubscriptions.getActiveSubscription(parseInt(req.params.id, 10));
|
|
858
|
+
res.json({ subscription: sub });
|
|
859
|
+
} catch (err) {
|
|
860
|
+
res.status(500).json({ error: err.message });
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
router.post('/api/billing/users/:id/subscription', requireAdminAuth, express.json(), (req, res) => {
|
|
865
|
+
try {
|
|
866
|
+
const userId = parseInt(req.params.id, 10);
|
|
867
|
+
const { planId, status } = req.body;
|
|
868
|
+
if (!planId) return res.status(400).json({ error: 'planId is required.' });
|
|
869
|
+
const sub = billingSubscriptions.adminSetSubscription(userId, planId, status);
|
|
870
|
+
res.json({ subscription: sub });
|
|
871
|
+
} catch (err) {
|
|
872
|
+
res.status(err.statusCode || 500).json({ error: err.message });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
router.delete('/api/billing/users/:id/subscription', requireAdminAuth, (req, res) => {
|
|
877
|
+
try {
|
|
878
|
+
billingSubscriptions.adminCancelSubscription(parseInt(req.params.id, 10));
|
|
879
|
+
res.json({ ok: true });
|
|
880
|
+
} catch (err) {
|
|
881
|
+
res.status(500).json({ error: err.message });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
})();
|
|
885
|
+
|
|
776
886
|
// --- Static files ---
|
|
777
887
|
|
|
778
888
|
router.use(express.static(ADMIN_DIR));
|