work-license-server 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/landing.js +307 -268
- package/package.json +1 -1
- package/src/landing.js +155 -292
- package/src/server.js +238 -2
- package/src/store.js +109 -15
package/src/server.js
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* - GET /api/admin/overview — Сводка по лицензиям, заказам и тикетам.
|
|
18
18
|
*/
|
|
19
19
|
import http from 'node:http';
|
|
20
|
+
import tls from 'node:tls';
|
|
20
21
|
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
21
23
|
import crypto from 'node:crypto';
|
|
22
24
|
import { fileURLToPath } from 'node:url';
|
|
23
25
|
import {
|
|
@@ -116,7 +118,106 @@ function sendHtml(res, status, html) {
|
|
|
116
118
|
res.end(html);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
function loadSmtpConfig() {
|
|
122
|
+
if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
|
|
123
|
+
return {
|
|
124
|
+
host: process.env.SMTP_HOST,
|
|
125
|
+
port: Number(process.env.SMTP_PORT || 465),
|
|
126
|
+
user: process.env.SMTP_USER,
|
|
127
|
+
pass: process.env.SMTP_PASS,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const candidates = [
|
|
131
|
+
path.resolve(process.cwd(), '#system/mail.json'),
|
|
132
|
+
path.resolve(process.cwd(), '../#system/mail.json'),
|
|
133
|
+
fileURLToPath(new URL('../../#system/mail.json', import.meta.url)),
|
|
134
|
+
];
|
|
135
|
+
for (const p of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
if (fs.existsSync(p)) {
|
|
138
|
+
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
139
|
+
if (j?.host && j?.auth?.user && j?.auth?.pass) {
|
|
140
|
+
return {
|
|
141
|
+
host: j.host,
|
|
142
|
+
port: Number(j.port || 465),
|
|
143
|
+
user: j.auth.user,
|
|
144
|
+
pass: j.auth.pass,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
host: 'smtp.yandex.ru',
|
|
153
|
+
port: 465,
|
|
154
|
+
user: 'proman1968.perepelkin@yandex.ru',
|
|
155
|
+
pass: 'zifeomdqijldyece',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Отправка реального письма с 6-значным кодом подтверждения по SMTPS (TLS 465, без внешних зависимостей).
|
|
161
|
+
*/
|
|
162
|
+
async function sendOtpEmail({ to, code }) {
|
|
163
|
+
const cfg = loadSmtpConfig();
|
|
164
|
+
const subjectB64 = Buffer.from(`Код подтверждения ODANT-WORK: ${code}`, 'utf8').toString('base64');
|
|
165
|
+
const htmlBody = [
|
|
166
|
+
'<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px;">',
|
|
167
|
+
'<h2 style="margin:0 0 12px;color:#0f172a;">Вход в Личный кабинет ODANT-WORK</h2>',
|
|
168
|
+
'<p style="color:#475569;font-size:15px;">Ваш одноразовый код подтверждения:</p>',
|
|
169
|
+
`<div style="font-size:36px;font-weight:800;letter-spacing:8px;text-align:center;padding:16px;background:#f8fafc;border-radius:10px;color:#0284c7;">${code}</div>`,
|
|
170
|
+
'<p style="color:#64748b;font-size:13px;margin-top:16px;">Код действителен 10 минут. Если вы не запрашивали вход на work.odant.org, просто проигнорируйте это письмо.</p>',
|
|
171
|
+
'</div>',
|
|
172
|
+
].join('\r\n');
|
|
173
|
+
const mimeMsg = [
|
|
174
|
+
`From: "ODANT-WORK" <${cfg.user}>`,
|
|
175
|
+
`To: <${to}>`,
|
|
176
|
+
`Subject: =?UTF-8?B?${subjectB64}?=`,
|
|
177
|
+
'MIME-Version: 1.0',
|
|
178
|
+
'Content-Type: text/html; charset=utf-8',
|
|
179
|
+
'Content-Transfer-Encoding: base64',
|
|
180
|
+
'',
|
|
181
|
+
Buffer.from(htmlBody, 'utf8').toString('base64'),
|
|
182
|
+
'.',
|
|
183
|
+
'',
|
|
184
|
+
].join('\r\n');
|
|
185
|
+
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const sock = tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host, timeout: 12000 });
|
|
188
|
+
let step = 0;
|
|
189
|
+
let buf = '';
|
|
190
|
+
const sendCmd = (line) => sock.write(line + '\r\n');
|
|
191
|
+
sock.on('timeout', () => { sock.destroy(); reject(new Error('Таймаут соединения с почтовым сервером')); });
|
|
192
|
+
sock.on('error', (err) => reject(err));
|
|
193
|
+
sock.on('data', (chunk) => {
|
|
194
|
+
buf += chunk.toString('utf8');
|
|
195
|
+
const lines = buf.split('\r\n');
|
|
196
|
+
buf = lines.pop() || '';
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
if (!/^\d{3} /.test(line)) continue;
|
|
199
|
+
const status = Number(line.slice(0, 3));
|
|
200
|
+
if (status >= 400) {
|
|
201
|
+
sock.destroy();
|
|
202
|
+
reject(new Error('Ошибка SMTP: ' + line));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (step === 0) { step = 1; sendCmd('EHLO work.odant.org'); }
|
|
206
|
+
else if (step === 1) { step = 2; sendCmd('AUTH LOGIN'); }
|
|
207
|
+
else if (step === 2) { step = 3; sendCmd(Buffer.from(cfg.user, 'utf8').toString('base64')); }
|
|
208
|
+
else if (step === 3) { step = 4; sendCmd(Buffer.from(cfg.pass, 'utf8').toString('base64')); }
|
|
209
|
+
else if (step === 4) { step = 5; sendCmd(`MAIL FROM:<${cfg.user}>`); }
|
|
210
|
+
else if (step === 5) { step = 6; sendCmd(`RCPT TO:<${to}>`); }
|
|
211
|
+
else if (step === 6) { step = 7; sendCmd('DATA'); }
|
|
212
|
+
else if (step === 7) { step = 8; sock.write(mimeMsg); }
|
|
213
|
+
else if (step === 8) { step = 9; sendCmd('QUIT'); sock.end(); resolve({ ok: true }); }
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
119
219
|
export function createLicenseServerApp(options = {}) {
|
|
220
|
+
const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
|
|
120
221
|
const store = options.store || new LicenseStore({
|
|
121
222
|
dataDir: options.dataDir || DEFAULT_DATA_DIR,
|
|
122
223
|
adminLogin: options.adminLogin,
|
|
@@ -247,7 +348,57 @@ export function createLicenseServerApp(options = {}) {
|
|
|
247
348
|
return sendJson(res, 200, PUBLIC_OFERTA);
|
|
248
349
|
}
|
|
249
350
|
|
|
250
|
-
// 2.0
|
|
351
|
+
// 2.0 Отправка одноразового 6-значного кода подтверждения на Email и проверка кода
|
|
352
|
+
if (req.method === 'POST' && pathname === '/api/auth/send-code') {
|
|
353
|
+
const body = await readJsonBody(req);
|
|
354
|
+
const cleanEmail = String(body.email || '').trim().toLowerCase();
|
|
355
|
+
if (!cleanEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail))
|
|
356
|
+
return sendJson(res, 400, { error: 'Укажите корректный email адрес' });
|
|
357
|
+
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
|
|
358
|
+
pendingOtpCodes.set(cleanEmail, {
|
|
359
|
+
code,
|
|
360
|
+
expiresAt: Date.now() + 10 * 60_000,
|
|
361
|
+
attempts: 0,
|
|
362
|
+
});
|
|
363
|
+
if (!options.skipEmailSend) {
|
|
364
|
+
await sendOtpEmail({ to: cleanEmail, code });
|
|
365
|
+
}
|
|
366
|
+
return sendJson(res, 200, {
|
|
367
|
+
ok: true,
|
|
368
|
+
email: cleanEmail,
|
|
369
|
+
message: `На адрес ${cleanEmail} отправлено письмо с 6-значным кодом подтверждения.`,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (req.method === 'POST' && pathname === '/api/auth/verify-code') {
|
|
374
|
+
const body = await readJsonBody(req);
|
|
375
|
+
const cleanEmail = String(body.email || '').trim().toLowerCase();
|
|
376
|
+
const code = String(body.code || '').trim();
|
|
377
|
+
const entry = pendingOtpCodes.get(cleanEmail);
|
|
378
|
+
if (!entry || Date.now() > entry.expiresAt) {
|
|
379
|
+
pendingOtpCodes.delete(cleanEmail);
|
|
380
|
+
return sendJson(res, 400, { error: 'Срок действия кода истёк или код не запрашивался. Запросите новый код.' });
|
|
381
|
+
}
|
|
382
|
+
entry.attempts += 1;
|
|
383
|
+
if (entry.attempts > 5) {
|
|
384
|
+
pendingOtpCodes.delete(cleanEmail);
|
|
385
|
+
return sendJson(res, 400, { error: 'Превышено число попыток ввода кода. Запросите новый код.' });
|
|
386
|
+
}
|
|
387
|
+
const a = Buffer.from(entry.code);
|
|
388
|
+
const b = Buffer.from(code);
|
|
389
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
390
|
+
return sendJson(res, 400, { error: 'Неверный код подтверждения. Проверьте письмо и попробуйте снова.' });
|
|
391
|
+
}
|
|
392
|
+
pendingOtpCodes.delete(cleanEmail);
|
|
393
|
+
const user = store.registerOrLoginUser({
|
|
394
|
+
email: cleanEmail,
|
|
395
|
+
ofertaAccepted: true,
|
|
396
|
+
hubUrl: body.hubUrl || reqOrigin,
|
|
397
|
+
});
|
|
398
|
+
return sendJson(res, 200, user);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 2.0.0 Прямая регистрация по API (для серверных интеграций и автотестов)
|
|
251
402
|
if (req.method === 'POST' && (pathname === '/api/auth/login' || pathname === '/api/auth/register')) {
|
|
252
403
|
const body = await readJsonBody(req);
|
|
253
404
|
const user = store.registerOrLoginUser({
|
|
@@ -327,7 +478,8 @@ export function createLicenseServerApp(options = {}) {
|
|
|
327
478
|
const user = ensureCustomerOferta(req, body);
|
|
328
479
|
const reserved = store.reserveFranchiseSlot({
|
|
329
480
|
...body,
|
|
330
|
-
|
|
481
|
+
ownerAccountId: user?.userId || body.ownerAccountId || '',
|
|
482
|
+
companyName: body.companyName || user?.companyName || user?.email || '',
|
|
331
483
|
inn: body.inn || user?.inn || '',
|
|
332
484
|
email: body.email || user?.email || '',
|
|
333
485
|
});
|
|
@@ -340,6 +492,90 @@ export function createLicenseServerApp(options = {}) {
|
|
|
340
492
|
});
|
|
341
493
|
}
|
|
342
494
|
|
|
495
|
+
// 2.6.1 Добавление позиции («Купить» или «Зарезервировать») как неоплаченного заказа в Личный кабинет
|
|
496
|
+
if (req.method === 'POST' && pathname === '/api/cabinet/orders/draft') {
|
|
497
|
+
const body = await readJsonBody(req);
|
|
498
|
+
const user = ensureCustomerOferta(req, body);
|
|
499
|
+
let order;
|
|
500
|
+
let reservation = null;
|
|
501
|
+
if (body.kind === 'franchise_reservation' || body.regionCode) {
|
|
502
|
+
const reserved = store.reserveFranchiseSlot({
|
|
503
|
+
regionCode: body.regionCode,
|
|
504
|
+
ownerAccountId: user?.userId || '',
|
|
505
|
+
companyName: body.companyName || user?.companyName || user?.email || 'Партнёр ODANT-WORK',
|
|
506
|
+
inn: body.inn || user?.inn || '',
|
|
507
|
+
email: body.email || user?.email || '',
|
|
508
|
+
});
|
|
509
|
+
order = reserved.order;
|
|
510
|
+
reservation = reserved.reservation;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
order = store.createOrder({
|
|
514
|
+
...body,
|
|
515
|
+
licenseId: body.licenseId || user?.licenseId || '',
|
|
516
|
+
ownerAccountId: user?.userId || '',
|
|
517
|
+
customer: {
|
|
518
|
+
name: body.customer?.name || user?.companyName || user?.email || '',
|
|
519
|
+
inn: body.customer?.inn || user?.inn || '',
|
|
520
|
+
email: body.customer?.email || user?.email || '',
|
|
521
|
+
phone: body.customer?.phone || user?.phone || '',
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const updatedUser = store.findUserBySessionKey(user?.sessionKey);
|
|
526
|
+
return sendJson(res, 200, {
|
|
527
|
+
ok: true,
|
|
528
|
+
order,
|
|
529
|
+
reservation,
|
|
530
|
+
user: updatedUser,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 2.6.2 Оплата существующего заказа из Личного кабинета (через ЮKassa или по счёту-договору для юрлиц)
|
|
535
|
+
if (req.method === 'POST' && pathname === '/api/cabinet/orders/pay') {
|
|
536
|
+
const body = await readJsonBody(req);
|
|
537
|
+
const user = ensureCustomerOferta(req, body);
|
|
538
|
+
const order = store.prepareOrderPayment(body.orderId, {
|
|
539
|
+
paymentMethod: body.paymentMethod || 'yookassa',
|
|
540
|
+
companyName: body.companyName || user?.companyName || '',
|
|
541
|
+
inn: body.inn || user?.inn || '',
|
|
542
|
+
});
|
|
543
|
+
const updatedUser = store.findUserBySessionKey(user?.sessionKey);
|
|
544
|
+
if (order.paymentMethod === 'invoice') {
|
|
545
|
+
return sendJson(res, 200, {
|
|
546
|
+
ok: true,
|
|
547
|
+
orderId: order.orderId,
|
|
548
|
+
paymentMethod: 'invoice',
|
|
549
|
+
invoiceNumber: order.invoiceNumber,
|
|
550
|
+
contractNumber: order.contractNumber,
|
|
551
|
+
invoiceUrl: `${reqOrigin}${order.invoiceUrl}`,
|
|
552
|
+
confirmationUrl: `${reqOrigin}${order.invoiceUrl}`,
|
|
553
|
+
order,
|
|
554
|
+
user: updatedUser,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
|
|
558
|
+
return sendJson(res, 200, {
|
|
559
|
+
ok: true,
|
|
560
|
+
...payment,
|
|
561
|
+
order,
|
|
562
|
+
user: updatedUser,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 2.6.3 Отмена (удаление) неоплаченного заказа из Личного кабинета
|
|
567
|
+
if (req.method === 'POST' && pathname === '/api/cabinet/orders/cancel') {
|
|
568
|
+
const body = await readJsonBody(req);
|
|
569
|
+
const user = ensureCustomerOferta(req, body);
|
|
570
|
+
store.cancelOrder(body.orderId);
|
|
571
|
+
const updatedUser = store.findUserBySessionKey(user?.sessionKey);
|
|
572
|
+
return sendJson(res, 200, {
|
|
573
|
+
ok: true,
|
|
574
|
+
orderId: body.orderId,
|
|
575
|
+
user: updatedUser,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
343
579
|
// 2.7 Собственный ИИ-агент лицензионного сервера ODANT-WORK
|
|
344
580
|
if (req.method === 'POST' && pathname === '/api/agent/chat') {
|
|
345
581
|
const body = await readJsonBody(req);
|
package/src/store.js
CHANGED
|
@@ -939,9 +939,24 @@ export class LicenseStore {
|
|
|
939
939
|
_formatUserResponse(user) {
|
|
940
940
|
if (!user)
|
|
941
941
|
return null;
|
|
942
|
+
const userEmail = String(user.email || '').toLowerCase();
|
|
943
|
+
const userOrders = Object.values(this.state.orders || {})
|
|
944
|
+
.filter(o => o.status !== 'canceled' && (
|
|
945
|
+
(user.userId && o.ownerAccountId === user.userId)
|
|
946
|
+
|| (user.licenseId && o.licenseId === user.licenseId)
|
|
947
|
+
|| (userEmail && String(o.customer?.email || '').toLowerCase() === userEmail)
|
|
948
|
+
))
|
|
949
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
950
|
+
const userReservations = Object.values(this.state.reservations || {})
|
|
951
|
+
.filter(r => r.status !== 'canceled' && (
|
|
952
|
+
(user.userId && r.ownerAccountId === user.userId)
|
|
953
|
+
|| (userEmail && String(r.email || '').toLowerCase() === userEmail)
|
|
954
|
+
))
|
|
955
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
942
956
|
return {
|
|
943
957
|
userId: user.userId,
|
|
944
958
|
email: user.email,
|
|
959
|
+
authProvider: user.authProvider || 'email',
|
|
945
960
|
name: user.name,
|
|
946
961
|
position: user.position || '',
|
|
947
962
|
companyName: user.companyName,
|
|
@@ -960,28 +975,37 @@ export class LicenseStore {
|
|
|
960
975
|
createdAt: user.createdAt,
|
|
961
976
|
lastLoginAt: user.lastLoginAt,
|
|
962
977
|
license: this.getLicenseSummary(user.licenseId),
|
|
978
|
+
orders: userOrders,
|
|
979
|
+
reservations: userReservations,
|
|
963
980
|
};
|
|
964
981
|
}
|
|
965
982
|
|
|
966
983
|
/**
|
|
967
|
-
* Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK
|
|
968
|
-
*
|
|
969
|
-
*
|
|
984
|
+
* Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK
|
|
985
|
+
* (по email, через OAuth или через мессенджеры Telegram / MAX / VK).
|
|
986
|
+
* Акцепт договора-оферты фиксируется по факту регистрации (ofertaAccepted = true по умолчанию).
|
|
970
987
|
*/
|
|
971
988
|
registerOrLoginUser({
|
|
972
989
|
email = '',
|
|
990
|
+
provider = 'email',
|
|
991
|
+
providerAccount = '',
|
|
973
992
|
name = '',
|
|
974
993
|
position = '',
|
|
975
994
|
companyName = '',
|
|
976
995
|
inn = '',
|
|
977
996
|
phone = '',
|
|
978
997
|
licenseId = '',
|
|
979
|
-
ofertaAccepted =
|
|
998
|
+
ofertaAccepted = true,
|
|
980
999
|
hubUrl = 'https://work.odant.org',
|
|
981
1000
|
} = {}) {
|
|
982
|
-
const
|
|
1001
|
+
const cleanProvider = String(provider || 'email').trim().toLowerCase();
|
|
1002
|
+
let cleanEmail = String(email || '').trim().toLowerCase();
|
|
1003
|
+
if (!cleanEmail && providerAccount) {
|
|
1004
|
+
const safeAcc = String(providerAccount).trim().toLowerCase().replace(/[^a-z0-9._+-]/g, '') || 'user';
|
|
1005
|
+
cleanEmail = safeAcc.includes('@') ? safeAcc : `${safeAcc}@${cleanProvider}.id.odant.org`;
|
|
1006
|
+
}
|
|
983
1007
|
if (!cleanEmail || !cleanEmail.includes('@'))
|
|
984
|
-
throw new Error('Укажите корректный
|
|
1008
|
+
throw new Error('Укажите корректный email или войдите через мессенджер / OAuth');
|
|
985
1009
|
|
|
986
1010
|
let user = this.state.users[cleanEmail];
|
|
987
1011
|
const now = Date.now();
|
|
@@ -995,7 +1019,7 @@ export class LicenseStore {
|
|
|
995
1019
|
ownerEmail: cleanEmail,
|
|
996
1020
|
baseActivated: false,
|
|
997
1021
|
customer: {
|
|
998
|
-
name: companyName || name ||
|
|
1022
|
+
name: companyName || name || cleanEmail,
|
|
999
1023
|
inn: inn || '',
|
|
1000
1024
|
email: cleanEmail,
|
|
1001
1025
|
phone: phone || '',
|
|
@@ -1013,11 +1037,12 @@ export class LicenseStore {
|
|
|
1013
1037
|
user = {
|
|
1014
1038
|
userId,
|
|
1015
1039
|
email: cleanEmail,
|
|
1040
|
+
authProvider: cleanProvider,
|
|
1016
1041
|
name: String(name || companyName || cleanEmail.split('@')[0]).trim(),
|
|
1017
1042
|
position: String(position || '').trim(),
|
|
1018
|
-
companyName: String(companyName ||
|
|
1019
|
-
inn: String(inn ||
|
|
1020
|
-
phone: String(phone ||
|
|
1043
|
+
companyName: String(companyName || '').trim(),
|
|
1044
|
+
inn: String(inn || '').trim(),
|
|
1045
|
+
phone: String(phone || '').trim(),
|
|
1021
1046
|
licenseId: lic.licenseId,
|
|
1022
1047
|
sessionKey: 'wks_' + crypto.randomBytes(16).toString('hex'),
|
|
1023
1048
|
hubToken: tokData.token,
|
|
@@ -1036,6 +1061,7 @@ export class LicenseStore {
|
|
|
1036
1061
|
}
|
|
1037
1062
|
else {
|
|
1038
1063
|
user.lastLoginAt = now;
|
|
1064
|
+
if (cleanProvider && cleanProvider !== 'email') user.authProvider = cleanProvider;
|
|
1039
1065
|
if (name) user.name = String(name).trim();
|
|
1040
1066
|
if (position) user.position = String(position).trim();
|
|
1041
1067
|
if (companyName) user.companyName = String(companyName).trim();
|
|
@@ -1058,11 +1084,11 @@ export class LicenseStore {
|
|
|
1058
1084
|
}
|
|
1059
1085
|
}
|
|
1060
1086
|
|
|
1061
|
-
if (ofertaAccepted && !user.ofertaAccepted) {
|
|
1087
|
+
if (ofertaAccepted !== false && !user.ofertaAccepted) {
|
|
1062
1088
|
user.ofertaAccepted = true;
|
|
1063
1089
|
user.ofertaAcceptedAt = now;
|
|
1064
1090
|
user.ofertaVersion = PUBLIC_OFERTA.version;
|
|
1065
|
-
user.ofertaSignerName = String(name || user.name || user.companyName).trim();
|
|
1091
|
+
user.ofertaSignerName = String(name || user.name || user.companyName || user.email).trim();
|
|
1066
1092
|
user.ofertaSignatureId = 'PEP-' + crypto.randomBytes(4).toString('hex').toUpperCase();
|
|
1067
1093
|
}
|
|
1068
1094
|
|
|
@@ -1299,6 +1325,7 @@ export class LicenseStore {
|
|
|
1299
1325
|
*/
|
|
1300
1326
|
reserveFranchiseSlot({
|
|
1301
1327
|
regionCode = 'RU-SVE',
|
|
1328
|
+
ownerAccountId = '',
|
|
1302
1329
|
companyName = '',
|
|
1303
1330
|
inn = '',
|
|
1304
1331
|
email = '',
|
|
@@ -1306,8 +1333,7 @@ export class LicenseStore {
|
|
|
1306
1333
|
contactPerson = '',
|
|
1307
1334
|
returnUrl = '',
|
|
1308
1335
|
} = {}) {
|
|
1309
|
-
|
|
1310
|
-
throw new Error('Укажите наименование компании для резервирования региональной франшизы');
|
|
1336
|
+
const effCompanyName = String(companyName || email || 'Партнёр ODANT-WORK').trim();
|
|
1311
1337
|
|
|
1312
1338
|
const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode);
|
|
1313
1339
|
if (quotaStatus.vacantFranchises <= 0) {
|
|
@@ -1321,14 +1347,17 @@ export class LicenseStore {
|
|
|
1321
1347
|
const reservationId = 'RES-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase();
|
|
1322
1348
|
const depositRub = FRANCHISE_PROGRAM.reservationDepositRub;
|
|
1323
1349
|
const orderId = 'ORD-FR-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
|
|
1350
|
+
const invoiceNumber = 'СЧ-FR-' + new Date(now).toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
|
|
1351
|
+
const contractNumber = 'ДЛ-FR-' + new Date(now).getFullYear() + '-' + orderId.slice(-6);
|
|
1324
1352
|
|
|
1325
1353
|
const reservation = {
|
|
1326
1354
|
reservationId,
|
|
1327
1355
|
orderId,
|
|
1356
|
+
ownerAccountId: String(ownerAccountId || '').trim(),
|
|
1328
1357
|
regionCode: quotaStatus.regionCode,
|
|
1329
1358
|
regionName: quotaStatus.regionName,
|
|
1330
1359
|
district: quotaStatus.district,
|
|
1331
|
-
companyName:
|
|
1360
|
+
companyName: effCompanyName,
|
|
1332
1361
|
inn: String(inn || '').trim(),
|
|
1333
1362
|
email: String(email || 'franchise@odant.org').trim(),
|
|
1334
1363
|
phone: String(phone || '').trim(),
|
|
@@ -1346,6 +1375,7 @@ export class LicenseStore {
|
|
|
1346
1375
|
|
|
1347
1376
|
const order = {
|
|
1348
1377
|
orderId,
|
|
1378
|
+
ownerAccountId: String(ownerAccountId || '').trim(),
|
|
1349
1379
|
kind: 'franchise_reservation',
|
|
1350
1380
|
reservationId,
|
|
1351
1381
|
licenseId: reservationId,
|
|
@@ -1356,6 +1386,9 @@ export class LicenseStore {
|
|
|
1356
1386
|
years: 1,
|
|
1357
1387
|
amountRub: depositRub,
|
|
1358
1388
|
franchiseFeeRub: quotaStatus.franchiseFeeRub,
|
|
1389
|
+
invoiceNumber,
|
|
1390
|
+
contractNumber,
|
|
1391
|
+
invoiceUrl: `/api/orders/${encodeURIComponent(orderId)}/invoice`,
|
|
1359
1392
|
items: [
|
|
1360
1393
|
{
|
|
1361
1394
|
title: `Невозвратный залог за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, полная оплата в течение 1 года)`,
|
|
@@ -1389,6 +1422,67 @@ export class LicenseStore {
|
|
|
1389
1422
|
};
|
|
1390
1423
|
}
|
|
1391
1424
|
|
|
1425
|
+
/**
|
|
1426
|
+
* Обновить способ оплаты и реквизиты юрлица для существующего заказа из Личного кабинета.
|
|
1427
|
+
*/
|
|
1428
|
+
prepareOrderPayment(orderId, {
|
|
1429
|
+
paymentMethod = 'yookassa',
|
|
1430
|
+
companyName = '',
|
|
1431
|
+
inn = '',
|
|
1432
|
+
kpp = '',
|
|
1433
|
+
legalAddress = '',
|
|
1434
|
+
} = {}) {
|
|
1435
|
+
const order = this.state.orders[orderId];
|
|
1436
|
+
if (!order)
|
|
1437
|
+
throw new Error(`Заказ ${orderId} не найден`);
|
|
1438
|
+
order.paymentMethod = paymentMethod === 'invoice' ? 'invoice' : 'yookassa';
|
|
1439
|
+
order.customer = order.customer || {};
|
|
1440
|
+
if (companyName) order.customer.name = String(companyName).trim();
|
|
1441
|
+
if (inn) order.customer.inn = String(inn).trim();
|
|
1442
|
+
if (kpp) order.customer.kpp = String(kpp).trim();
|
|
1443
|
+
if (legalAddress) order.customer.legalAddress = String(legalAddress).trim();
|
|
1444
|
+
if (!order.invoiceNumber) {
|
|
1445
|
+
order.invoiceNumber = 'СЧ-' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
|
|
1446
|
+
}
|
|
1447
|
+
if (!order.contractNumber) {
|
|
1448
|
+
order.contractNumber = 'ДЛ-' + new Date().getFullYear() + '-' + orderId.slice(-6);
|
|
1449
|
+
}
|
|
1450
|
+
if (!order.invoiceUrl) {
|
|
1451
|
+
order.invoiceUrl = `/api/orders/${encodeURIComponent(orderId)}/invoice`;
|
|
1452
|
+
}
|
|
1453
|
+
if (order.reservationId && this.state.reservations[order.reservationId]) {
|
|
1454
|
+
if (companyName) this.state.reservations[order.reservationId].companyName = String(companyName).trim();
|
|
1455
|
+
if (inn) this.state.reservations[order.reservationId].inn = String(inn).trim();
|
|
1456
|
+
}
|
|
1457
|
+
if (order.ownerAccountId) {
|
|
1458
|
+
const u = Object.values(this.state.users).find(usr => usr.userId === order.ownerAccountId);
|
|
1459
|
+
if (u) {
|
|
1460
|
+
if (companyName) u.companyName = String(companyName).trim();
|
|
1461
|
+
if (inn) u.inn = String(inn).trim();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
this._save();
|
|
1465
|
+
return order;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Отменить неоплаченный заказ в Личном кабинете (и снять резерв региона, если это был резерв франшизы).
|
|
1470
|
+
*/
|
|
1471
|
+
cancelOrder(orderId) {
|
|
1472
|
+
const order = this.state.orders[orderId];
|
|
1473
|
+
if (!order)
|
|
1474
|
+
throw new Error(`Заказ ${orderId} не найден`);
|
|
1475
|
+
if (order.status === 'paid')
|
|
1476
|
+
throw new Error('Оплаченный заказ отменить нельзя');
|
|
1477
|
+
order.status = 'canceled';
|
|
1478
|
+
order.canceledAt = Date.now();
|
|
1479
|
+
if (order.reservationId && this.state.reservations[order.reservationId]) {
|
|
1480
|
+
this.state.reservations[order.reservationId].status = 'canceled';
|
|
1481
|
+
}
|
|
1482
|
+
this._save();
|
|
1483
|
+
return { ok: true, orderId };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1392
1486
|
/**
|
|
1393
1487
|
* Зарегистрировать регионального партнёра-франчайзи с контролем региональной квоты (по населению)
|
|
1394
1488
|
* и расчётом фиксированного паушального взноса (пропорционально экономике региона).
|