neuroalpha 0.5.16 → 0.5.19

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/public/styles.css CHANGED
@@ -1,25 +1,36 @@
1
1
  /* Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software. */
2
2
  :root {
3
+ color-scheme: dark;
3
4
  --bg: #0d1016;
4
- --panel: #13171f;
5
- --line: #2a313e;
5
+ --panel: #151a24;
6
+ --line: #30394a;
7
+ --border: #30394a;
8
+ --bg1: #0d1016;
9
+ --bg2: #151a24;
6
10
  --text: #e6edf3;
7
- --muted: #98a2b3;
8
- --accent: #4f8cff;
11
+ --muted: #9da7b8;
12
+ --accent: #5895ff;
9
13
  --green: #3fb950;
10
14
  --red: #f85149;
11
15
  --yellow: #d29922;
12
16
  --violet: #a371f7;
13
17
  --best-bg: #13241a;
18
+ --blocked-bg: rgba(244, 114, 182, 0.14);
19
+ --blocked-fg: #f472b6;
20
+ --blocked-border: rgba(244, 114, 182, 0.48);
14
21
  --flash-bg: #2a1f45;
15
22
  --chat-w: 38%;
16
- --accent-bg: rgba(79, 140, 255, 0.14);
17
- --hover-bg: rgba(255, 255, 255, 0.05);
23
+ --accent-bg: rgba(88, 149, 255, 0.15);
24
+ --hover-bg: rgba(255, 255, 255, 0.06);
18
25
  }
19
26
  body.light {
27
+ color-scheme: light;
20
28
  --bg: #f6f8fa;
21
29
  --panel: #ffffff;
22
30
  --line: #d0d7de;
31
+ --border: #d0d7de;
32
+ --bg1: #f6f8fa;
33
+ --bg2: #ffffff;
23
34
  --text: #1f2328;
24
35
  --muted: #57606a;
25
36
  --accent: #2457d6;
@@ -28,6 +39,9 @@ body.light {
28
39
  --yellow: #9a6700;
29
40
  --violet: #8250df;
30
41
  --best-bg: #dcfce7;
42
+ --blocked-bg: #fce7f3;
43
+ --blocked-fg: #be185d;
44
+ --blocked-border: #f9a8d4;
31
45
  --flash-bg: #ede9fe;
32
46
  --accent-bg: rgba(36, 87, 214, 0.09);
33
47
  --hover-bg: #f0f3f6;
@@ -35,6 +49,8 @@ body.light {
35
49
  * { box-sizing: border-box; }
36
50
  html, body { height: 100%; }
37
51
  body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
52
+ a, a:visited { color: var(--accent); text-decoration: none; }
53
+ a:hover { color: var(--accent); text-decoration: underline; }
38
54
  .muted { color: var(--muted); }
39
55
  .small { font-size: 12px; }
40
56
  .grow { flex: 1; }
@@ -52,8 +68,11 @@ header { height: 48px; display: flex; justify-content: space-between; align-item
52
68
  .dot.bad { background: var(--red); }
53
69
 
54
70
  input, select, textarea { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; font: inherit; }
71
+ input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
72
+ input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.82; }
73
+ select option, select optgroup { background: var(--panel); color: var(--text); }
55
74
  textarea { resize: none; width: 100%; }
56
- button, a.hdr-btn {
75
+ button, a.hdr-btn, a.hdr-btn:visited, a.btn, a.btn:visited {
57
76
  display: inline-flex;
58
77
  align-items: center;
59
78
  justify-content: center;
@@ -71,10 +90,10 @@ button, a.hdr-btn {
71
90
  text-decoration: none;
72
91
  transition: background .15s, border-color .15s, color .15s;
73
92
  }
74
- button:hover:not(:disabled), a.hdr-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--hover-bg); }
93
+ button:hover:not(:disabled), a.hdr-btn:hover, a.btn:hover { border-color: var(--accent); color: var(--accent); background: var(--hover-bg); text-decoration: none; }
75
94
  button:disabled { opacity: .55; cursor: default; }
76
- button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
77
- button.primary:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: #fff; filter: brightness(1.08); }
95
+ button.primary, a.btn.primary, a.btn.primary:visited { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
96
+ button.primary:hover:not(:disabled), a.btn.primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; filter: brightness(1.08); text-decoration: none; }
78
97
  button.icon { padding: 4px 9px; color: var(--text); background: var(--panel); border: 1px solid var(--line); }
79
98
  button.icon-lg { width: 40px; height: 40px; padding: 0; font-size: 15px; flex: none; }
80
99
  .hdr-btn { padding: 4px 10px; font-size: 12.5px; }
@@ -284,10 +303,13 @@ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line
284
303
  th { color: var(--muted); font-weight: 500; }
285
304
  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
286
305
  tr.best td { background: var(--best-bg); }
306
+ tr.fc-blocked td { background: var(--blocked-bg); }
307
+ tr.fc-blocked td:first-child { box-shadow: inset 3px 0 0 var(--blocked-fg); }
287
308
  .tag { font-size: 11px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
288
309
  .tag.ok { border-color: var(--green); color: var(--green); }
289
310
  .tag.no { border-color: var(--red); color: var(--red); }
290
311
  .tag.mid { border-color: var(--yellow); color: var(--yellow); }
312
+ .tag.blocked { border-color: var(--blocked-border); color: var(--blocked-fg); background: var(--blocked-bg); font-weight: 500; }
291
313
  .stab-box { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 8px 0 10px; font-size: 13px; }
292
314
  .stab-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
293
315
  .stab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
@@ -570,7 +592,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
570
592
  background: var(--accent);
571
593
  border-radius: 4px;
572
594
  }
573
-
574
- /* Прогноз: две таблицы с общей сеткой колонок */
575
- .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
595
+
596
+ /* Прогноз: две таблицы с общей сеткой колонок */
597
+ .fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
576
598
 
@@ -0,0 +1,30 @@
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ // Задать email и пароль главного администратора (пользователь №1) или добавить/обновить второго (запасного) администратора (--add).
3
+ // ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@neuroalpha.odant.org
4
+ // ADMIN_PASS='…' node scripts/set-admin-password.mjs --add --email backup@neuroalpha.odant.org --name "Запасной админ"
5
+ // kubectl exec -it deploy/neuroalpha-central -- env ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@…
6
+ // Пароль берётся из переменной ADMIN_PASS (чтобы не попадал в историю команд как аргумент).
7
+ import { db } from '../server/db.js';
8
+ import { hashPassword, ensureApiKey, createOrUpdateAdminUser } from '../server/auth/index.js';
9
+
10
+ const i = process.argv.indexOf('--email');
11
+ const iName = process.argv.indexOf('--name');
12
+ const isAdd = process.argv.includes('--add') || process.argv.includes('--second');
13
+ const email = String(i >= 0 ? process.argv[i + 1] : '').trim().toLowerCase();
14
+ const name = iName >= 0 ? String(process.argv[iName + 1] || '').trim() : '';
15
+ const pass = String(process.env.ADMIN_PASS || '');
16
+ if (!email.includes('@') || pass.length < 10) {
17
+ console.error('Укажите --email и пароль не короче 10 символов в переменной ADMIN_PASS');
18
+ process.exit(1);
19
+ }
20
+ if (isAdd) {
21
+ const u = createOrUpdateAdminUser({ email, password: pass, name: name || 'Администратор' });
22
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
23
+ console.log(`✓ Запасной администратор (#${u.id}): ${u.email}. Прежние сессии этого аккаунта завершены.`);
24
+ process.exit(0);
25
+ }
26
+ db.prepare("UPDATE users SET email = ?, name = COALESCE(NULLIF(?, ''), name), pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = 1").run(email, name, hashPassword(pass));
27
+ ensureApiKey(1);
28
+ db.prepare('DELETE FROM sessions WHERE user_id = 1').run();
29
+ console.log(`✓ Главный администратор (#1): ${email}. Все прежние сессии администратора завершены.`);
30
+ process.exit(0);
@@ -6,13 +6,13 @@ import { ROOT, strategyDefaults } from '../config.js';
6
6
  import { activeModelInfo } from '../llm/client.js';
7
7
  import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
8
8
  import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
9
- import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
- import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
9
+ import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
10
+ import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
11
11
  import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
12
12
  import { PARAMS } from '../params-meta.js';
13
13
  import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
14
14
  import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
15
- import { forecastNow, TIER1_LIQUID_SYMBOLS } from '../model/forecast.js';
15
+ import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
16
16
  import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
17
17
  import { ensureDividends } from '../data/moex.js';
18
18
  import { live } from '../model/live.js';
@@ -25,7 +25,7 @@ import { isCrossOrigin } from '../security.js';
25
25
  import { IS_CENTRAL, IS_INSTANCE, CENTRAL_URL, clientIp, isHttpsRequest } from '../role.js';
26
26
  import { consumeSsoToken } from '../instance/agent.js';
27
27
  import { IS_DESKTOP } from '../role.js';
28
- import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
28
+ import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ensureUserCloudInstance, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
29
29
  import { activateDesktop, instanceInfo } from '../instance/agent.js';
30
30
  import { isActivated } from '../instance/identity.js';
31
31
 
@@ -74,7 +74,7 @@ export function createRouter() {
74
74
  res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
75
75
  };
76
76
  r.get('/auth/me', (req, res) => res.json({
77
- user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [] } : null,
77
+ user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
78
78
  downloads: IS_CENTRAL ? {
79
79
  windows: process.env.DESKTOP_DOWNLOAD_WINDOWS || '',
80
80
  linux: process.env.DESKTOP_DOWNLOAD_LINUX || '',
@@ -221,10 +221,11 @@ export function createRouter() {
221
221
  if (!sent.tg && !sent.max) throw new Error('Сначала привяжите Telegram или MAX по вашему коду привязки');
222
222
  res.json({ ok: true, ...sent });
223
223
  }));
224
- r.post('/auth/redeem-promo', wrap((req, res) => {
224
+ r.post('/auth/redeem-promo', wrap(async (req, res) => {
225
225
  if (!req.user) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
226
226
  const user = redeemPromoCode(req.user.id, req.body?.code);
227
- res.json({ user });
227
+ if (IS_CENTRAL) await ensureUserCloudInstance(req.user.id).catch(() => {});
228
+ res.json({ user: { ...user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } });
228
229
  }));
229
230
  r.post('/auth/accept-offer', wrap((req, res) => {
230
231
  if (!req.user) throw new Error('Войдите в аккаунт');
@@ -277,12 +278,25 @@ export function createRouter() {
277
278
  }));
278
279
  r.post('/billing/verify', wrap(async (req, res) => {
279
280
  if (!req.user) throw new Error('Войдите в аккаунт');
280
- if (req.body?.paymentId) return res.json(await verifyYookassaPayment(req.body.paymentId));
281
- return res.json(await verifyUserLatestPayment(req.user.id));
281
+ const out = req.body?.paymentId
282
+ ? await verifyYookassaPayment(req.body.paymentId)
283
+ : await verifyUserLatestPayment(req.user.id);
284
+ if (IS_CENTRAL && out?.status === 'succeeded') {
285
+ await ensureUserCloudInstance(req.user.id).catch(() => {});
286
+ }
287
+ res.json({
288
+ ...out,
289
+ user: out?.user ? { ...out.user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
290
+ });
282
291
  }));
283
292
  r.post('/billing/yookassa/webhook', wrap(async (req, res) => {
284
293
  const pid = req.body?.object?.id;
285
- if (pid) await verifyYookassaPayment(pid).catch(() => {});
294
+ if (pid) {
295
+ const out = await verifyYookassaPayment(pid).catch(() => null);
296
+ if (IS_CENTRAL && out?.status === 'succeeded' && out?.user?.id) {
297
+ await ensureUserCloudInstance(out.user.id).catch(() => {});
298
+ }
299
+ }
286
300
  res.json({ ok: true });
287
301
  }));
288
302
  r.post('/notify/max/webhook', wrap(async (req, res) => {
@@ -393,9 +407,15 @@ export function createRouter() {
393
407
  requireAdmin(req);
394
408
  res.json({ promos: createPromoCode(req.body || {}) });
395
409
  }));
396
- r.post('/admin/user-plan', wrap((req, res) => {
410
+ r.post('/admin/user-plan', wrap(async (req, res) => {
411
+ requireAdmin(req);
412
+ const user = setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30);
413
+ if (IS_CENTRAL) await ensureUserCloudInstance(req.body?.userId).catch(() => {});
414
+ res.json({ user, users: listAllUsers() });
415
+ }));
416
+ r.post('/admin/create-admin', wrap((req, res) => {
397
417
  requireAdmin(req);
398
- res.json({ user: setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30), users: listAllUsers() });
418
+ res.json({ user: createOrUpdateAdminUser(req.body || {}), users: listAllUsers() });
399
419
  }));
400
420
  r.post('/admin/billing', wrap((req, res) => {
401
421
  requireAdmin(req);
@@ -636,6 +656,7 @@ export function createRouter() {
636
656
  const vocabHit = vocabList.find((v) => v.symbol === sym);
637
657
  const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
638
658
  const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
659
+ const tierMult = symbolEdgeMult(sym);
639
660
 
640
661
  // 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
641
662
  const nowTs = Date.now();
@@ -727,8 +748,8 @@ export function createRouter() {
727
748
  yearChangePct,
728
749
  tier: isTier1 ? 1 : 2,
729
750
  tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
730
- buyThresholdPct: isTier1 ? Math.max(fc?.buyThresholdPct || 0.30, 0.30) : Math.max(fc?.buyThresholdPct || 0.30, 0.35),
731
- shortThresholdPct: isTier1 ? Math.max(fc?.shortThresholdPct || 0.31, 0.31) : Math.max(fc?.shortThresholdPct || 0.31, 0.36),
751
+ buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.413) * tierMult, 0.30), 3),
752
+ shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.516) * tierMult, 0.31), 3),
732
753
  shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
733
754
  specs: {
734
755
  lotSize: sp.lotSize,
@@ -748,6 +769,12 @@ export function createRouter() {
748
769
  signal: Boolean(fcRow.signal),
749
770
  shortSignal: Boolean(fcRow.shortSignal),
750
771
  shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
772
+ shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
773
+ buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
774
+ buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
775
+ shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
776
+ buyBlockedByNews: fcRow.buyBlockedByNews || null,
777
+ shortBlockedByNews: fcRow.shortBlockedByNews || null,
751
778
  selfPct: fcRow.selfPct ?? 0,
752
779
  influences: (fcRow.influences || []).map((inf) => {
753
780
  const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
@@ -281,7 +281,7 @@ export function effectivePlan(user) {
281
281
  if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.none;
282
282
  return PLANS.owner;
283
283
  }
284
- if (user.role === 'admin' || user.plan === 'owner') return PLANS[user.plan] || PLANS.owner;
284
+ if (user.id === 1 || user.role === 'admin' || user.plan === 'owner') return PLANS.owner;
285
285
  const p = PLANS[user.plan] || PLANS.none;
286
286
  if (p.id !== 'none' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.none;
287
287
  return p;
@@ -445,9 +445,36 @@ export function listAllUsers() {
445
445
 
446
446
  export function setUserPlan(userId, plan, days = 30) {
447
447
  if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
448
+ const uid = Number(userId);
449
+ if (!uid) throw new Error('Некорректный ID пользователя');
448
450
  const until = (plan === 'none' || plan === 'owner') ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
449
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(plan, until, Number(userId));
450
- return publicUser(getUserById(userId));
451
+ // Тариф «Хозяин» (owner) даёт права администратора (role = 'admin'); главный владелец (id = 1) всегда сохраняет роль admin
452
+ const role = (plan === 'owner' || uid === 1) ? 'admin' : 'user';
453
+ db.prepare('UPDATE users SET plan = ?, plan_until = ?, role = ? WHERE id = ?').run(plan, until, role, uid);
454
+ return publicUser(getUserById(uid));
455
+ }
456
+
457
+ export function createOrUpdateAdminUser({ email, password, name }) {
458
+ const cleanEmail = String(email || '').trim().toLowerCase();
459
+ if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email администратора');
460
+ const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
461
+ const cleanPass = String(password || '').trim();
462
+ if (existing) {
463
+ if (cleanPass && cleanPass.length < 6) throw new Error('Пароль должен быть не короче 6 символов');
464
+ const nextHash = cleanPass ? hashPassword(cleanPass) : existing.pass_hash;
465
+ const nextName = String(name || '').trim() || existing.name || 'Администратор';
466
+ db.prepare("UPDATE users SET name = ?, pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = ?")
467
+ .run(nextName, nextHash, existing.id);
468
+ ensureApiKey(existing.id);
469
+ return publicUser(getUserById(existing.id));
470
+ }
471
+ if (cleanPass.length < 6) throw new Error('Для нового администратора укажите пароль не короче 6 символов');
472
+ const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
473
+ const apiKey = generateApiKey();
474
+ const cleanName = String(name || '').trim() || 'Администратор';
475
+ const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, offer_accepted_at, created_at)
476
+ VALUES (?, ?, ?, 'admin', 'owner', 0, ?, ?, ?, ?)`).run(cleanEmail, cleanName, hashPassword(cleanPass), code, apiKey, Date.now(), Date.now());
477
+ return publicUser(getUserById(Number(r.lastInsertRowid)));
451
478
  }
452
479
 
453
480
  // ---------- промокоды и персональные VIP-токены ----------
@@ -480,7 +507,11 @@ export function redeemPromoCode(userId, rawCode) {
480
507
  const u = getUserById(userId);
481
508
  const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
482
509
  const nextUntil = baseTs + promo.days * 86400000;
483
- db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
510
+ if (u.id === 1 || u.role === 'admin') {
511
+ db.prepare('UPDATE users SET offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(Date.now(), u.id);
512
+ } else {
513
+ db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
514
+ }
484
515
  db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
485
516
  db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
486
517
  return publicUser(getUserById(userId));
@@ -1,133 +1,142 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import crypto from 'node:crypto';
3
- import { db, kvGet, kvSet } from '../db.js';
4
- import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';
5
-
6
- export function getBillingConfig() {
7
- const saved = kvGet('yookassaConfig') || {};
8
- const shopId = String(saved.shopId || process.env.YOOKASSA_SHOP_ID || '').trim();
9
- const secretKey = decryptSecret(saved.secretKey || '') || String(process.env.YOOKASSA_SECRET_KEY || '').trim();
10
- const sendReceipt = saved.sendReceipt !== undefined ? Boolean(saved.sendReceipt) : (process.env.YOOKASSA_RECEIPT === '1');
11
- const vatCode = Number(saved.vatCode || process.env.YOOKASSA_VAT_CODE || 1);
12
- return { shopId, secretKey, sendReceipt, vatCode, enabled: Boolean(shopId && secretKey) };
13
- }
14
-
15
- export function publicBillingConfig() {
16
- const c = getBillingConfig();
17
- return { shopId: c.shopId, hasSecret: Boolean(c.secretKey), sendReceipt: c.sendReceipt, vatCode: c.vatCode, enabled: c.enabled, plans: PLANS };
18
- }
19
-
20
- export function saveBillingConfig(input = {}) {
21
- const prev = kvGet('yookassaConfig') || {};
22
- const next = {
23
- shopId: String(input.shopId ?? prev.shopId ?? '').trim(),
24
- secretKey: input.clearSecret ? '' : (input.secretKey && String(input.secretKey).trim() ? encryptSecret(input.secretKey) : (prev.secretKey || '')),
25
- sendReceipt: input.sendReceipt !== undefined ? Boolean(input.sendReceipt) : Boolean(prev.sendReceipt),
26
- vatCode: Number(input.vatCode ?? prev.vatCode ?? 1) || 1,
27
- };
28
- kvSet('yookassaConfig', next);
29
- return publicBillingConfig();
30
- }
31
-
32
- export async function createYookassaPayment({ user, plan, months = 1, returnOrigin = 'http://localhost:3000' }) {
33
- if (!user) throw new Error('Войдите в аккаунт для оформления подписки');
34
- const planObj = PLANS[plan];
35
- if (!planObj || planObj.priceRub <= 0) throw new Error('Выберите платный тариф (Инвестор или Профи)');
36
- const m = Math.max(1, Math.min(12, Math.floor(Number(months) || 1)));
37
- const discount = m >= 12 ? 0.8 : m >= 3 ? 0.9 : 1;
38
- const amountRub = Math.round(planObj.priceRub * m * discount);
39
- const cfg = getBillingConfig();
40
- if (!cfg.enabled) throw new Error('Платёжный шлюз ЮKassa ещё не настроен администратором (укажите Shop ID и секретный ключ)');
41
-
42
- const description = `Подписка «${planObj.name}» на ${m} мес. (${user.email})`;
43
- const payload = {
44
- amount: { value: amountRub.toFixed(2), currency: 'RUB' },
45
- capture: true,
46
- confirmation: {
47
- type: 'redirect',
48
- return_url: `${returnOrigin.replace(/\/+$/, '')}/?payment_check=1`,
49
- },
50
- description,
51
- metadata: { userId: String(user.id), plan, months: String(m) },
52
- };
53
- if (cfg.sendReceipt && user.email) {
54
- payload.receipt = {
55
- customer: { email: user.email },
56
- items: [{
57
- description: description.slice(0, 128),
58
- quantity: '1.00',
59
- amount: { value: amountRub.toFixed(2), currency: 'RUB' },
60
- vat_code: cfg.vatCode,
61
- payment_mode: 'full_payment',
62
- payment_subject: 'service',
63
- }],
64
- };
65
- }
66
-
67
- const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
68
- const r = await fetch('https://api.yookassa.ru/v3/payments', {
69
- method: 'POST',
70
- headers: {
71
- Authorization: `Basic ${auth}`,
72
- 'Idempotence-Key': crypto.randomUUID(),
73
- 'Content-Type': 'application/json',
74
- },
75
- body: JSON.stringify(payload),
76
- signal: AbortSignal.timeout(20000),
77
- });
78
- const j = await r.json().catch(() => ({}));
79
- if (!r.ok || !j.id) throw new Error(j.description || `Ошибка ЮKassa: HTTP ${r.status}`);
80
-
81
- db.prepare(`INSERT OR REPLACE INTO payments (id, user_id, plan, months, amount_rub, status, created_at, raw)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(j.id, user.id, plan, m, amountRub, j.status || 'pending', Date.now(), JSON.stringify(j));
83
-
84
- return {
85
- paymentId: j.id,
86
- status: j.status,
87
- amountRub,
88
- confirmationUrl: j.confirmation?.confirmation_url || null,
89
- };
90
- }
91
-
92
- function applyPaidSubscription(payRow, remoteObj) {
93
- if (!payRow || payRow.status === 'succeeded') return;
94
- const u = getUserById(payRow.user_id);
95
- if (!u) return;
96
- const baseTs = u.plan === payRow.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
97
- const nextUntil = baseTs + (Number(payRow.months) || 1) * 30 * 86400000;
98
- db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(payRow.plan, nextUntil, u.id);
99
- db.prepare('UPDATE payments SET status = ?, paid_at = ?, raw = ? WHERE id = ?')
100
- .run('succeeded', Date.now(), JSON.stringify(remoteObj || {}), payRow.id);
101
- }
102
-
103
- export async function verifyYookassaPayment(paymentId) {
104
- const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(String(paymentId));
105
- if (!row) throw new Error('Платёж не найден');
106
- if (row.status === 'succeeded') return { status: 'succeeded', user: publicUser(getUserById(row.user_id)) };
107
- const cfg = getBillingConfig();
108
- if (!cfg.enabled) throw new Error('ЮKassa не настроена');
109
- const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
110
- const r = await fetch(`https://api.yookassa.ru/v3/payments/${encodeURIComponent(row.id)}`, {
111
- headers: { Authorization: `Basic ${auth}` },
112
- signal: AbortSignal.timeout(15000),
113
- });
114
- const j = await r.json().catch(() => ({}));
115
- if (!r.ok) throw new Error(j.description || `HTTP ${r.status}`);
116
- if (j.status === 'succeeded' && j.paid) {
117
- applyPaidSubscription(row, j);
118
- } else if (j.status && j.status !== row.status) {
119
- db.prepare('UPDATE payments SET status = ?, raw = ? WHERE id = ?').run(j.status, JSON.stringify(j), row.id);
120
- }
121
- return { status: j.status, user: publicUser(getUserById(row.user_id)) };
122
- }
123
-
124
- export async function verifyUserLatestPayment(userId) {
125
- const row = db.prepare("SELECT * FROM payments WHERE user_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(Number(userId));
126
- if (!row) return { status: 'none', user: publicUser(getUserById(userId)) };
127
- return verifyYookassaPayment(row.id);
128
- }
129
-
130
- export function listAllPayments(limit = 100) {
131
- return db.prepare(`SELECT p.id, p.user_id, u.email, p.plan, p.months, p.amount_rub, p.status, p.created_at, p.paid_at
132
- FROM payments p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.created_at DESC LIMIT ?`).all(limit);
133
- }
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import crypto from 'node:crypto';
3
+ import { db, kvGet, kvSet } from '../db.js';
4
+ import { PLANS, encryptSecret, decryptSecret, getUserById, publicUser } from '../auth/index.js';
5
+
6
+ export function getBillingConfig() {
7
+ const saved = kvGet('yookassaConfig') || {};
8
+ const shopId = String(saved.shopId || process.env.YOOKASSA_SHOP_ID || '').trim();
9
+ const secretKey = decryptSecret(saved.secretKey || '') || String(process.env.YOOKASSA_SECRET_KEY || '').trim();
10
+ const sendReceipt = saved.sendReceipt !== undefined ? Boolean(saved.sendReceipt) : (process.env.YOOKASSA_RECEIPT === '1');
11
+ const vatCode = Number(saved.vatCode || process.env.YOOKASSA_VAT_CODE || 1);
12
+ return { shopId, secretKey, sendReceipt, vatCode, enabled: Boolean(shopId && secretKey) };
13
+ }
14
+
15
+ export function publicBillingConfig() {
16
+ const c = getBillingConfig();
17
+ return { shopId: c.shopId, hasSecret: Boolean(c.secretKey), sendReceipt: c.sendReceipt, vatCode: c.vatCode, enabled: c.enabled, plans: PLANS };
18
+ }
19
+
20
+ export function saveBillingConfig(input = {}) {
21
+ const prev = kvGet('yookassaConfig') || {};
22
+ const next = {
23
+ shopId: String(input.shopId ?? prev.shopId ?? '').trim(),
24
+ secretKey: input.clearSecret ? '' : (input.secretKey && String(input.secretKey).trim() ? encryptSecret(input.secretKey) : (prev.secretKey || '')),
25
+ sendReceipt: input.sendReceipt !== undefined ? Boolean(input.sendReceipt) : Boolean(prev.sendReceipt),
26
+ vatCode: Number(input.vatCode ?? prev.vatCode ?? 1) || 1,
27
+ };
28
+ kvSet('yookassaConfig', next);
29
+ return publicBillingConfig();
30
+ }
31
+
32
+ export async function createYookassaPayment({ user, plan, months = 1, returnOrigin = 'http://localhost:3000' }) {
33
+ if (!user) throw new Error('Войдите в аккаунт для оформления подписки');
34
+ const planObj = PLANS[plan];
35
+ if (!planObj || planObj.priceRub <= 0) throw new Error('Выберите платный тариф (Инвестор или Профи)');
36
+ const m = Math.max(1, Math.min(12, Math.floor(Number(months) || 1)));
37
+ const discount = m >= 12 ? 0.8 : m >= 3 ? 0.9 : 1;
38
+ const amountRub = Math.round(planObj.priceRub * m * discount);
39
+ const cfg = getBillingConfig();
40
+ if (!cfg.enabled) throw new Error('Платёжный шлюз ЮKassa ещё не настроен администратором (укажите Shop ID и секретный ключ)');
41
+
42
+ const description = `Подписка «${planObj.name}» на ${m} мес. (${user.email})`;
43
+ const payload = {
44
+ amount: { value: amountRub.toFixed(2), currency: 'RUB' },
45
+ capture: true,
46
+ confirmation: {
47
+ type: 'redirect',
48
+ return_url: `${returnOrigin.replace(/\/+$/, '')}/landing?payment_check=1`,
49
+ },
50
+ description,
51
+ metadata: { userId: String(user.id), plan, months: String(m) },
52
+ };
53
+ if (cfg.sendReceipt && user.email) {
54
+ payload.receipt = {
55
+ customer: { email: user.email },
56
+ items: [{
57
+ description: description.slice(0, 128),
58
+ quantity: '1.00',
59
+ amount: { value: amountRub.toFixed(2), currency: 'RUB' },
60
+ vat_code: cfg.vatCode,
61
+ payment_mode: 'full_payment',
62
+ payment_subject: 'service',
63
+ }],
64
+ };
65
+ }
66
+
67
+ const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
68
+ const r = await fetch('https://api.yookassa.ru/v3/payments', {
69
+ method: 'POST',
70
+ headers: {
71
+ Authorization: `Basic ${auth}`,
72
+ 'Idempotence-Key': crypto.randomUUID(),
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ body: JSON.stringify(payload),
76
+ signal: AbortSignal.timeout(20000),
77
+ });
78
+ const j = await r.json().catch(() => ({}));
79
+ if (!r.ok || !j.id) throw new Error(j.description || `Ошибка ЮKassa: HTTP ${r.status}`);
80
+
81
+ db.prepare(`INSERT OR REPLACE INTO payments (id, user_id, plan, months, amount_rub, status, created_at, raw)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(j.id, user.id, plan, m, amountRub, j.status || 'pending', Date.now(), JSON.stringify(j));
83
+
84
+ return {
85
+ paymentId: j.id,
86
+ status: j.status,
87
+ amountRub,
88
+ confirmationUrl: j.confirmation?.confirmation_url || null,
89
+ };
90
+ }
91
+
92
+ function applyPaidSubscription(payRow, remoteObj) {
93
+ if (!payRow || payRow.status === 'succeeded') return;
94
+ const u = getUserById(payRow.user_id);
95
+ if (!u) return;
96
+ const baseTs = u.plan === payRow.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
97
+ const nextUntil = baseTs + (Number(payRow.months) || 1) * 30 * 86400000;
98
+ // Главный владелец и администраторы сохраняют полный тариф owner
99
+ if (u.id !== 1 && u.role !== 'admin') {
100
+ db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(payRow.plan, nextUntil, u.id);
101
+ }
102
+ db.prepare('UPDATE payments SET status = ?, paid_at = ?, raw = ? WHERE id = ?')
103
+ .run('succeeded', Date.now(), JSON.stringify(remoteObj || {}), payRow.id);
104
+ }
105
+
106
+ export async function verifyYookassaPayment(paymentId) {
107
+ const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(String(paymentId));
108
+ if (!row) throw new Error('Платёж не найден');
109
+ if (row.status === 'succeeded') return { status: 'succeeded', user: publicUser(getUserById(row.user_id)) };
110
+ const cfg = getBillingConfig();
111
+ if (!cfg.enabled) throw new Error('ЮKassa не настроена');
112
+ const auth = Buffer.from(`${cfg.shopId}:${cfg.secretKey}`).toString('base64');
113
+ const r = await fetch(`https://api.yookassa.ru/v3/payments/${encodeURIComponent(row.id)}`, {
114
+ headers: { Authorization: `Basic ${auth}` },
115
+ signal: AbortSignal.timeout(15000),
116
+ });
117
+ const j = await r.json().catch(() => ({}));
118
+ if (!r.ok) throw new Error(j.description || `HTTP ${r.status}`);
119
+ if (j.status === 'succeeded' && j.paid) {
120
+ applyPaidSubscription(row, j);
121
+ } else if (j.status && j.status !== row.status) {
122
+ db.prepare('UPDATE payments SET status = ?, raw = ? WHERE id = ?').run(j.status, JSON.stringify(j), row.id);
123
+ }
124
+ return { status: j.status, user: publicUser(getUserById(row.user_id)) };
125
+ }
126
+
127
+ export async function verifyUserLatestPayment(userId) {
128
+ const row = db.prepare("SELECT * FROM payments WHERE user_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1").get(Number(userId));
129
+ if (!row) return { status: 'none', user: publicUser(getUserById(userId)) };
130
+ return verifyYookassaPayment(row.id);
131
+ }
132
+
133
+ export function listAllPayments(limit = 100) {
134
+ return db.prepare(`SELECT p.id, p.user_id, u.email, p.plan, p.months, p.amount_rub, p.status, p.created_at, p.paid_at
135
+ FROM payments p LEFT JOIN users u ON u.id = p.user_id ORDER BY p.created_at DESC LIMIT ?`).all(limit);
136
+ }
137
+
138
+ export function listUserPayments(userId, limit = 20) {
139
+ if (!userId) return [];
140
+ return db.prepare(`SELECT id, plan, months, amount_rub, status, created_at, paid_at
141
+ FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(Number(userId), limit);
142
+ }