neuroalpha 0.5.17 → 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/package.json +61 -60
- package/public/admin.html +603 -571
- package/public/app.js +96 -18
- package/public/landing.html +299 -73
- package/public/splat3d.html +671 -0
- package/public/styles.css +12 -3
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +41 -14
- package/server/auth/index.js +35 -4
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +81 -50
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +41 -19
- package/server/model/forecast.js +111 -17
- package/server/model/live.js +16 -18
- package/server/model/research.js +41 -15
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
package/public/styles.css
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
--yellow: #d29922;
|
|
16
16
|
--violet: #a371f7;
|
|
17
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);
|
|
18
21
|
--flash-bg: #2a1f45;
|
|
19
22
|
--chat-w: 38%;
|
|
20
23
|
--accent-bg: rgba(88, 149, 255, 0.15);
|
|
@@ -36,6 +39,9 @@ body.light {
|
|
|
36
39
|
--yellow: #9a6700;
|
|
37
40
|
--violet: #8250df;
|
|
38
41
|
--best-bg: #dcfce7;
|
|
42
|
+
--blocked-bg: #fce7f3;
|
|
43
|
+
--blocked-fg: #be185d;
|
|
44
|
+
--blocked-border: #f9a8d4;
|
|
39
45
|
--flash-bg: #ede9fe;
|
|
40
46
|
--accent-bg: rgba(36, 87, 214, 0.09);
|
|
41
47
|
--hover-bg: #f0f3f6;
|
|
@@ -297,10 +303,13 @@ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line
|
|
|
297
303
|
th { color: var(--muted); font-weight: 500; }
|
|
298
304
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
299
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); }
|
|
300
308
|
.tag { font-size: 11px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
|
|
301
309
|
.tag.ok { border-color: var(--green); color: var(--green); }
|
|
302
310
|
.tag.no { border-color: var(--red); color: var(--red); }
|
|
303
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; }
|
|
304
313
|
.stab-box { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 8px 0 10px; font-size: 13px; }
|
|
305
314
|
.stab-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
306
315
|
.stab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
@@ -583,7 +592,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
|
|
|
583
592
|
background: var(--accent);
|
|
584
593
|
border-radius: 4px;
|
|
585
594
|
}
|
|
586
|
-
|
|
587
|
-
/* Прогноз: две таблицы с общей сеткой колонок */
|
|
588
|
-
.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; }
|
|
589
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);
|
package/server/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
731
|
-
shortThresholdPct:
|
|
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);
|
package/server/auth/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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(/\/+$/, '')}
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|