neoagent 3.0.1-beta.7 → 3.0.1-beta.8

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.
Files changed (38) hide show
  1. package/.env.example +19 -0
  2. package/README.md +1 -0
  3. package/docs/billing.md +203 -0
  4. package/docs/configuration.md +13 -0
  5. package/docs/index.md +1 -0
  6. package/lib/schema_migrations.js +84 -0
  7. package/package.json +2 -1
  8. package/server/admin/admin.js +2 -1
  9. package/server/admin/billing.js +292 -0
  10. package/server/admin/index.html +37 -0
  11. package/server/db/database.js +1 -0
  12. package/server/http/routes.js +9 -0
  13. package/server/middleware/requireBilling.js +12 -0
  14. package/server/public/.last_build_id +1 -1
  15. package/server/public/flutter_bootstrap.js +1 -1
  16. package/server/public/main.dart.js +4 -4
  17. package/server/routes/admin.js +110 -0
  18. package/server/routes/billing.js +122 -0
  19. package/server/routes/billing_webhook.js +43 -0
  20. package/server/routes/memory.js +1 -4
  21. package/server/routes/settings.js +1 -2
  22. package/server/routes/skills.js +1 -1
  23. package/server/services/ai/loop/agent_engine_core.js +1 -1
  24. package/server/services/ai/loop/completion_judge.js +2 -9
  25. package/server/services/ai/loop/conversation_loop.js +12 -26
  26. package/server/services/ai/loopPolicy.js +2 -39
  27. package/server/services/ai/models.js +18 -0
  28. package/server/services/ai/systemPrompt.js +17 -0
  29. package/server/services/ai/tools.js +1 -11
  30. package/server/services/billing/billing_email.js +106 -0
  31. package/server/services/billing/config.js +17 -0
  32. package/server/services/billing/plans.js +120 -0
  33. package/server/services/billing/stripe_client.js +21 -0
  34. package/server/services/billing/subscriptions.js +462 -0
  35. package/server/services/billing/trial_guard.js +111 -0
  36. package/server/services/manager.js +11 -0
  37. package/server/services/tasks/runtime.js +52 -12
  38. 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(--muted)">Inactive</span>';
42
+ return `
43
+ <tr data-plan-id="${id}">
44
+ <td><strong>${esc(plan.name)}</strong><br><small style="color:var(--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(--muted)">Default</span>'}</td>
47
+ <td>${plan.token_limit_weekly != null ? fmtTokens(plan.token_limit_weekly) : '<span style="color:var(--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(--card-bg);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(--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(--muted)">${esc(sub.email)}</small>` : '';
245
+ const statusColor = { active: 'var(--success)', trialing: 'var(--warning, #f59e0b)', past_due: 'var(--error)', canceled: 'var(--muted)' }[sub.status] || 'var(--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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
281
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
+ }
@@ -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,34 @@
1189
1197
  </div>
1190
1198
  </section>
1191
1199
 
1200
+ <section id="page-billing" class="page" aria-label="Billing">
1201
+ <div class="page-header"><h1>Billing</h1></div>
1202
+ <div class="cards">
1203
+ <div class="card">
1204
+ <div class="card-title">Subscription Plans</div>
1205
+ <div style="margin-bottom:12px">
1206
+ <button class="btn btn-primary" onclick="billingShowNewPlanForm()">+ New Plan</button>
1207
+ </div>
1208
+ <div id="billing-plans-content"><div class="empty"><span class="spinner"></span></div></div>
1209
+ </div>
1210
+ <div class="card">
1211
+ <div class="card-title">User Subscriptions</div>
1212
+ <div style="margin-bottom:12px;display:flex;gap:8px;align-items:center">
1213
+ <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)">
1214
+ <option value="">All statuses</option>
1215
+ <option value="active">Active</option>
1216
+ <option value="trialing">Trialing</option>
1217
+ <option value="past_due">Past due</option>
1218
+ <option value="canceled">Canceled</option>
1219
+ </select>
1220
+ <button class="btn" onclick="loadBillingSubscriptions()">Refresh</button>
1221
+ </div>
1222
+ <div id="billing-subs-content"><div class="empty"><span class="spinner"></span></div></div>
1223
+ <div id="billing-subs-pagination" style="margin-top:12px;display:flex;gap:8px;align-items:center"></div>
1224
+ </div>
1225
+ </div>
1226
+ </section>
1227
+
1192
1228
  </main>
1193
1229
 
1194
1230
  <script src="/admin/admin.js"></script>
@@ -1196,5 +1232,6 @@
1196
1232
  <script src="/admin/users.js"></script>
1197
1233
  <script src="/admin/sql.js"></script>
1198
1234
  <script src="/admin/access.js"></script>
1235
+ <script src="/admin/billing.js"></script>
1199
1236
  </body>
1200
1237
  </html>
@@ -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
  }
@@ -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
- 60f1b48c2d24c4a137efc1a6529d413f
1
+ dc90ac758721f68d16ff899fbce82971
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"77e2e94772b6eb43759e34ed1ad7da4674e19c
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "2202305600" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "3960040413" /* 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("mqj7s6w1-467cdbb").length!==0&&r.b}if(r){r=s.d
135600
+ r=B.b.u("mqjy0ig5-61a79c0").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("mqj7s6w1-467cdbb").length===0||s.a!=null)return
141322
+ if(B.b.u("mqjy0ig5-61a79c0").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,"mqj7s6w1-467cdbb")){s=1
141340
+ if(J.bj(j)===0||J.e(j,"mqjy0ig5-61a79c0")){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("mqj7s6w1-467cdbb").length===0||n.c){s=1
141357
+ s=p}for(;;)switch(s){case 0:if(B.b.u("mqjy0ig5-61a79c0").length===0||n.c){s=1
141358
141358
  break}n.c=!0
141359
141359
  n.F()
141360
141360
  p=4
@@ -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));