work-license-server 1.0.13 → 1.0.15

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/src/store.js CHANGED
@@ -32,14 +32,53 @@ import {
32
32
  } from './crypto.js';
33
33
 
34
34
  const ONE_YEAR_MS = 365 * 24 * 3600_000;
35
+ export const FRANCHISE_RESERVATION_TTL_MS = 15 * 60 * 1000; // 15 минут на оплату начального взноса за резерв франшизы
36
+
37
+ /**
38
+ * Построить 12-месячный график погашения остатка паушального взноса за региональную франшизу (платёж раз в месяц в течение года).
39
+ */
40
+ export function buildFranchisePaymentSchedule({
41
+ remainingRub = 0,
42
+ startMs = Date.now(),
43
+ existingSchedule = [],
44
+ } = {}) {
45
+ const totalRub = Math.max(0, Math.round(Number(remainingRub || 0)));
46
+ const months = 12;
47
+ const baseMonthly = Math.floor(totalRub / months);
48
+ const schedule = [];
49
+ for (let m = 1; m <= months; m++) {
50
+ const prev = Array.isArray(existingSchedule)
51
+ ? existingSchedule.find(x => Number(x.monthNumber) === m)
52
+ : null;
53
+ const amountRub = (m < months) ? baseMonthly : Math.max(0, totalRub - baseMonthly * (months - 1));
54
+ const d = new Date(Number(startMs || Date.now()));
55
+ d.setMonth(d.getMonth() + m);
56
+ schedule.push({
57
+ monthNumber: m,
58
+ installmentId: prev?.installmentId || `INST-${String(m).padStart(2, '0')}`,
59
+ title: `Платёж №${m} (месяц ${m} из ${months})`,
60
+ dueDate: prev?.status === 'paid' && prev?.dueDate ? prev.dueDate : d.getTime(),
61
+ amountRub: prev?.status === 'paid' && prev?.amountRub ? prev.amountRub : amountRub,
62
+ status: prev?.status === 'paid' ? 'paid' : 'pending',
63
+ paidAt: prev?.paidAt || null,
64
+ orderId: prev?.orderId || '',
65
+ paymentId: prev?.paymentId || '',
66
+ });
67
+ }
68
+ return schedule;
69
+ }
35
70
 
36
71
  export class LicenseStore {
37
- constructor({ dataDir = './data', adminLogin = '', adminPassword = '' } = {}) {
72
+ constructor({ dataDir = './data', adminLogin = '', adminPassword = '', publicOrigin = 'https://work.odant.org' } = {}) {
38
73
  this.dataDir = dataDir;
39
74
  fs.mkdirSync(this.dataDir, { recursive: true });
40
75
  this.stateFile = path.join(this.dataDir, 'store.json');
41
76
  this.keyFile = path.join(this.dataDir, 'authority-key.json');
42
- this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile);
77
+ const isOfficialOrigin = !publicOrigin || String(publicOrigin).includes('work.odant.org') || String(publicOrigin).includes('localhost');
78
+ const isFakeTestDir = String(dataDir).includes('fake-hub-data');
79
+ this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile, {
80
+ useCanonicalFallback: isOfficialOrigin && !isFakeTestDir,
81
+ });
43
82
  this.adminLogin = String(adminLogin || process.env.WORK_ADMIN_LOGIN || 'admin').trim();
44
83
  this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
45
84
  this.adminSessions = new Map();
@@ -186,11 +225,68 @@ export class LicenseStore {
186
225
  this.state.certificates = raw.certificates || {};
187
226
  this.state.users = raw.users || {};
188
227
  this._ensureBootstrapAdmin();
228
+ this._resignAllWithAuthority();
189
229
  }
190
230
  }
191
231
  catch { /* начинаем с чистого состояния */ }
192
232
  }
193
233
 
234
+ /**
235
+ * Гарантировать, что все токены владельцев и сертификаты лицензий в store подписаны текущим каноническим ключом ЦС.
236
+ */
237
+ _resignAllWithAuthority() {
238
+ let changed = false;
239
+ for (const [id, lic] of Object.entries(this.state.licenses || {})) {
240
+ if (!lic) continue;
241
+ if (!lic.serverName) {
242
+ lic.serverName = 'Сервер WORK';
243
+ changed = true;
244
+ }
245
+ if (!lic.hubToken || !verifyOwnerAccessToken(lic.hubToken, this.authorityKeys.publicKey)) {
246
+ const tokData = createOwnerAccessToken({
247
+ hubUrl: lic.hubUrl || 'https://work.odant.org',
248
+ accountId: lic.ownerAccountId || ('USR-' + id),
249
+ email: lic.ownerEmail || lic.customer?.email || '',
250
+ licenseId: lic.licenseId || id,
251
+ serverName: lic.serverName,
252
+ tokenId: lic.hubTokenId || '',
253
+ secret: lic.hubTokenSecret || '',
254
+ issuedAt: lic.issuedAt || Date.now(),
255
+ }, this.authorityKeys);
256
+ lic.hubToken = tokData.token;
257
+ lic.hubTokenId = tokData.payload.tokenId;
258
+ lic.hubTokenSecret = tokData.payload.secret;
259
+ lic.hubUrl = tokData.payload.hubUrl;
260
+ changed = true;
261
+ }
262
+ if (lic.issuerPublicKey !== this.authorityKeys.publicKey) {
263
+ this.state.licenses[id] = signCertificate(lic, this.authorityKeys);
264
+ changed = true;
265
+ }
266
+ }
267
+ for (const user of Object.values(this.state.users || {})) {
268
+ if (!user) continue;
269
+ if (!user.hubToken || !verifyOwnerAccessToken(user.hubToken, this.authorityKeys.publicKey)) {
270
+ const tokData = createOwnerAccessToken({
271
+ hubUrl: user.hubUrl || 'https://work.odant.org',
272
+ accountId: user.userId,
273
+ email: user.email,
274
+ licenseId: user.licenseId || '',
275
+ tokenId: user.hubTokenId || '',
276
+ secret: user.hubTokenSecret || '',
277
+ issuedAt: user.createdAt || Date.now(),
278
+ }, this.authorityKeys);
279
+ user.hubToken = tokData.token;
280
+ user.hubTokenId = tokData.payload.tokenId;
281
+ user.hubTokenSecret = tokData.payload.secret;
282
+ changed = true;
283
+ }
284
+ }
285
+ if (changed) {
286
+ try { this._save(); } catch { /* ignore */ }
287
+ }
288
+ }
289
+
194
290
  _save() {
195
291
  const tmp = `${this.stateFile}.${process.pid}.tmp`;
196
292
  fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), 'utf8');
@@ -204,11 +300,13 @@ export class LicenseStore {
204
300
  */
205
301
  issueOrUpdateLicense({
206
302
  licenseId,
303
+ serverName = '',
207
304
  hwid = '',
208
305
  nodeFingerprint = '',
209
306
  customer = {},
210
307
  ownerAccountId = '',
211
308
  ownerEmail = '',
309
+ hubUrl = '',
212
310
  partnerId = '',
213
311
  hardwareWarrantyYears = 1,
214
312
  partnerAddons = {},
@@ -234,13 +332,42 @@ export class LicenseStore {
234
332
  ? Boolean(baseActivated)
235
333
  : (existing?.baseActivated !== undefined ? Boolean(existing.baseActivated) : true);
236
334
 
335
+ const resolvedOwnerAccountId = String(ownerAccountId || existing?.ownerAccountId || '');
336
+ const resolvedOwnerEmail = String(ownerEmail || customer.email || existing?.ownerEmail || existing?.customer?.email || '').toLowerCase();
337
+ const resolvedServerName = String(serverName || existing?.serverName || 'Сервер WORK #1').trim();
338
+ const resolvedHubUrl = String(hubUrl || existing?.hubUrl || 'https://work.odant.org').replace(/\/+$/, '');
339
+
340
+ let hubToken = existing?.hubToken || '';
341
+ let hubTokenId = existing?.hubTokenId || '';
342
+ let hubTokenSecret = existing?.hubTokenSecret || '';
343
+ if (!hubToken || !verifyOwnerAccessToken(hubToken, this.authorityKeys.publicKey)) {
344
+ const tokData = createOwnerAccessToken({
345
+ hubUrl: resolvedHubUrl,
346
+ accountId: resolvedOwnerAccountId || ('USR-' + id),
347
+ email: resolvedOwnerEmail,
348
+ licenseId: id,
349
+ serverName: resolvedServerName,
350
+ issuedAt: now,
351
+ }, this.authorityKeys);
352
+ hubToken = tokData.token;
353
+ hubTokenId = tokData.payload.tokenId;
354
+ hubTokenSecret = tokData.payload.secret;
355
+ }
356
+
357
+ const hwidBound = Boolean(hwid || nodeFingerprint || existing?.hardware?.hwidBound);
358
+
237
359
  const record = {
238
360
  licenseId: id,
361
+ serverName: resolvedServerName,
239
362
  issuedAt,
240
363
  updatedAt: now,
241
364
  issuer: 'work.odant.org',
242
- ownerAccountId: String(ownerAccountId || existing?.ownerAccountId || ''),
243
- ownerEmail: String(ownerEmail || customer.email || existing?.ownerEmail || existing?.customer?.email || '').toLowerCase(),
365
+ ownerAccountId: resolvedOwnerAccountId,
366
+ ownerEmail: resolvedOwnerEmail,
367
+ hubToken,
368
+ hubTokenId,
369
+ hubTokenSecret,
370
+ hubUrl: resolvedHubUrl,
244
371
  baseActivated: isBaseActivated,
245
372
  baseSku: String(baseSku || existing?.baseSku || PAK_PRODUCT.sku),
246
373
  customer: {
@@ -252,6 +379,7 @@ export class LicenseStore {
252
379
  hardware: {
253
380
  hwid: hwid || existing?.hardware?.hwid || ('PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase()),
254
381
  nodeFingerprint: nodeFingerprint || existing?.hardware?.nodeFingerprint || '',
382
+ hwidBound,
255
383
  sku: String(baseSku || existing?.hardware?.sku || PAK_PRODUCT.sku),
256
384
  cpu: PAK_PRODUCT.hardware.cpu,
257
385
  ramGb: PAK_PRODUCT.hardware.ramGb,
@@ -504,10 +632,15 @@ export class LicenseStore {
504
632
 
505
633
  return {
506
634
  licenseId: lic.licenseId,
635
+ serverName: lic.serverName || 'Сервер WORK #1',
507
636
  issuedAt: lic.issuedAt,
508
637
  updatedAt: lic.updatedAt,
509
638
  ownerAccountId: lic.ownerAccountId || '',
510
639
  ownerEmail: lic.ownerEmail || lic.customer?.email || '',
640
+ hubToken: lic.hubToken || '',
641
+ hubTokenId: lic.hubTokenId || '',
642
+ hubUrl: lic.hubUrl || 'https://work.odant.org',
643
+ hwidBound: Boolean(lic.hardware?.hwidBound),
511
644
  baseActivated: lic.baseActivated !== false,
512
645
  baseSku: lic.baseSku || PAK_PRODUCT.sku,
513
646
  customer: lic.customer,
@@ -570,6 +703,8 @@ export class LicenseStore {
570
703
  */
571
704
  createOrder({
572
705
  licenseId = '',
706
+ serverName = '',
707
+ createNewServer = false,
573
708
  hwid = '',
574
709
  nodeFingerprint = '',
575
710
  includeBasePak = false,
@@ -595,10 +730,17 @@ export class LicenseStore {
595
730
  if (quote.totalAmountRub <= 0)
596
731
  throw new Error('Сумма заказа должна быть больше 0 ₽');
597
732
 
598
- let targetLic = this.findLicense(licenseId || hwid);
733
+ const forceNew = Boolean(createNewServer || String(licenseId).toUpperCase() === 'NEW');
734
+ let targetLic = forceNew ? null : this.findLicense(licenseId || hwid);
599
735
  if (!targetLic) {
736
+ const existingCount = Object.values(this.state.licenses).filter(
737
+ l => (ownerAccountId && l.ownerAccountId === ownerAccountId)
738
+ || (customer.email && l.ownerEmail === String(customer.email).toLowerCase())
739
+ ).length;
600
740
  targetLic = this.issueOrUpdateLicense({
601
- licenseId: licenseId || undefined,
741
+ licenseId: (!forceNew && licenseId) ? licenseId : undefined,
742
+ serverName: serverName || `Сервер WORK #${existingCount + 1}`,
743
+ baseActivated: forceNew ? false : undefined,
602
744
  hwid,
603
745
  nodeFingerprint,
604
746
  customer,
@@ -689,11 +831,11 @@ export class LicenseStore {
689
831
  if (!ord)
690
832
  throw new Error(`Заказ ${orderId} не найден`);
691
833
  if (ord.status === 'paid') {
692
- if (ord.kind === 'franchise_reservation' && ord.reservationId && this.state.reservations[ord.reservationId]) {
834
+ if ((ord.kind === 'franchise_reservation' || ord.kind === 'franchise_installment') && ord.reservationId && this.state.reservations[ord.reservationId]) {
693
835
  const resv = this.state.reservations[ord.reservationId];
694
836
  return {
695
837
  order: ord,
696
- reservation: resv,
838
+ reservation: this.getFranchiseSummary(resv.reservationId),
697
839
  regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
698
840
  alreadyProcessed: true,
699
841
  };
@@ -716,10 +858,60 @@ export class LicenseStore {
716
858
  resv.paymentId = ord.paymentId;
717
859
  if (!resv.convertedToPartnerId)
718
860
  resv.status = 'deposit_paid';
861
+ // Фиксируем 12-месячный график погашения остатка ровно от даты оплаты начального взноса
862
+ const remainingAfterDeposit = Math.max(0, Number(resv.franchiseFeeRub || 0) - Number(resv.nonRefundableDepositRub || 100_000));
863
+ resv.paymentSchedule = buildFranchisePaymentSchedule({
864
+ remainingRub: remainingAfterDeposit,
865
+ startMs: ord.paidAt,
866
+ existingSchedule: resv.paymentSchedule || [],
867
+ });
868
+ resv.paidInstallmentsRub = (resv.paymentSchedule || [])
869
+ .filter(it => it.status === 'paid')
870
+ .reduce((s, it) => s + Number(it.amountRub || 0), 0);
871
+ resv.totalPaidRub = Number(resv.nonRefundableDepositRub || 100_000) + resv.paidInstallmentsRub;
872
+ resv.remainingFranchiseFeeRub = Math.max(0, Number(resv.franchiseFeeRub || 0) - resv.totalPaidRub);
719
873
  this._save();
720
874
  return {
721
875
  order: ord,
722
- reservation: resv,
876
+ reservation: this.getFranchiseSummary(resv.reservationId),
877
+ regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
878
+ alreadyProcessed: false,
879
+ };
880
+ }
881
+
882
+ if (ord.kind === 'franchise_installment' && ord.reservationId && this.state.reservations[ord.reservationId]) {
883
+ const resv = this.state.reservations[ord.reservationId];
884
+ if (!Array.isArray(resv.paymentSchedule) || !resv.paymentSchedule.length) {
885
+ const rem = Math.max(0, Number(resv.franchiseFeeRub || 0) - Number(resv.nonRefundableDepositRub || 100_000));
886
+ resv.paymentSchedule = buildFranchisePaymentSchedule({
887
+ remainingRub: rem,
888
+ startMs: resv.paidAt || resv.createdAt || ord.paidAt,
889
+ });
890
+ }
891
+ const targetMonths = Array.isArray(ord.installmentMonths) && ord.installmentMonths.length
892
+ ? new Set(ord.installmentMonths.map(Number))
893
+ : null;
894
+ for (const inst of resv.paymentSchedule) {
895
+ if (inst.status === 'paid') continue;
896
+ if (ord.earlyPayoff || (targetMonths && targetMonths.has(Number(inst.monthNumber)))) {
897
+ inst.status = 'paid';
898
+ inst.paidAt = ord.paidAt;
899
+ inst.orderId = ord.orderId;
900
+ inst.paymentId = ord.paymentId || '';
901
+ }
902
+ }
903
+ resv.paidInstallmentsRub = resv.paymentSchedule
904
+ .filter(it => it.status === 'paid')
905
+ .reduce((s, it) => s + Number(it.amountRub || 0), 0);
906
+ resv.totalPaidRub = (resv.depositPaid ? Number(resv.nonRefundableDepositRub || 100_000) : 0) + resv.paidInstallmentsRub;
907
+ resv.remainingFranchiseFeeRub = Math.max(0, Number(resv.franchiseFeeRub || 0) - resv.totalPaidRub);
908
+ if (resv.remainingFranchiseFeeRub <= 0 && !resv.convertedToPartnerId) {
909
+ resv.status = 'paid_in_full';
910
+ }
911
+ this._save();
912
+ return {
913
+ order: ord,
914
+ reservation: this.getFranchiseSummary(resv.reservationId),
723
915
  regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
724
916
  alreadyProcessed: false,
725
917
  };
@@ -864,7 +1056,7 @@ export class LicenseStore {
864
1056
  regionQuota = this.getRegionPartnerQuotaStatus(resv.regionCode);
865
1057
  }
866
1058
  this._save();
867
- return { order: ord, regionQuota };
1059
+ return { ok: true, orderId: ord.orderId, order: ord, regionQuota };
868
1060
  }
869
1061
 
870
1062
  /**
@@ -1003,9 +1195,218 @@ export class LicenseStore {
1003
1195
  return all;
1004
1196
  }
1005
1197
 
1198
+ _findUser(sessionKeyOrEmail = '') {
1199
+ const key = String(sessionKeyOrEmail || '').trim();
1200
+ if (!key)
1201
+ return null;
1202
+ return Object.values(this.state.users || {}).find(
1203
+ u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1204
+ ) || null;
1205
+ }
1206
+
1207
+ /**
1208
+ * 1.1 Реестр серверов (устройств) пользователя: каждый сервер со своим токеном (owk1...) и своими лицензиями.
1209
+ */
1210
+ listUserServers(userOrKey, now = Date.now()) {
1211
+ const user = typeof userOrKey === 'object' ? userOrKey : this._findUser(userOrKey);
1212
+ if (!user)
1213
+ return [];
1214
+ const userEmail = String(user.email || '').toLowerCase();
1215
+ const serverLics = Object.values(this.state.licenses || {})
1216
+ .filter(lic => lic && (
1217
+ (user.userId && lic.ownerAccountId === user.userId)
1218
+ || (user.licenseId && lic.licenseId === user.licenseId)
1219
+ || (userEmail && String(lic.ownerEmail || lic.customer?.email || '').toLowerCase() === userEmail)
1220
+ ))
1221
+ .sort((a, b) => (a.issuedAt || 0) - (b.issuedAt || 0));
1222
+
1223
+ return serverLics.map((lic, idx) => {
1224
+ const summary = this.getLicenseSummary(lic.licenseId, now);
1225
+ const serverOrders = Object.values(this.state.orders || {})
1226
+ .filter(o => o.status !== 'canceled'
1227
+ && o.licenseId === lic.licenseId
1228
+ && o.kind !== 'franchise_reservation'
1229
+ && o.kind !== 'franchise_installment'
1230
+ )
1231
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1232
+ return {
1233
+ ...summary,
1234
+ serverName: lic.serverName || `Сервер WORK #${idx + 1}`,
1235
+ hubToken: lic.hubToken || user.hubToken || '',
1236
+ hubTokenId: lic.hubTokenId || user.hubTokenId || '',
1237
+ orders: serverOrders,
1238
+ };
1239
+ });
1240
+ }
1241
+
1242
+ /**
1243
+ * Добавить новый лицензируемый сервер (устройство) WORK в Личном кабинете со своим токеном привязки.
1244
+ */
1245
+ createUserServer(sessionKeyOrEmail = '', {
1246
+ serverName = '',
1247
+ hwid = '',
1248
+ hubUrl = 'https://work.odant.org',
1249
+ } = {}) {
1250
+ const user = this._findUser(sessionKeyOrEmail);
1251
+ if (!user)
1252
+ throw new Error('Войдите в Личный кабинет, чтобы добавить сервер');
1253
+ const existingServers = this.listUserServers(user);
1254
+ const cleanName = String(serverName || '').trim() || `Сервер WORK #${existingServers.length + 1}`;
1255
+ const summary = this.issueOrUpdateLicense({
1256
+ serverName: cleanName,
1257
+ hwid: hwid || '',
1258
+ baseActivated: false,
1259
+ ownerAccountId: user.userId,
1260
+ ownerEmail: user.email,
1261
+ hubUrl: hubUrl || user.hubUrl || 'https://work.odant.org',
1262
+ customer: {
1263
+ name: user.companyName || user.name || user.email,
1264
+ inn: user.inn || '',
1265
+ email: user.email,
1266
+ phone: user.phone || '',
1267
+ },
1268
+ });
1269
+ if (!user.licenseId) {
1270
+ user.licenseId = summary.licenseId;
1271
+ }
1272
+ this._save();
1273
+ return {
1274
+ ok: true,
1275
+ server: this.getLicenseSummary(summary.licenseId),
1276
+ user: this._formatUserResponse(user),
1277
+ };
1278
+ }
1279
+
1280
+ /**
1281
+ * Переименовать сервер (устройство) в реестре серверов пользователя.
1282
+ */
1283
+ renameUserServer(sessionKeyOrEmail = '', { licenseId = '', serverName = '' } = {}) {
1284
+ const user = this._findUser(sessionKeyOrEmail);
1285
+ const lic = this.state.licenses[licenseId];
1286
+ if (!lic)
1287
+ throw new Error(`Сервер ${licenseId} не найден`);
1288
+ if (user && !user.isAdmin && lic.ownerAccountId && lic.ownerAccountId !== user.userId)
1289
+ throw new Error('Нет прав на изменение этого сервера');
1290
+ const cleanName = String(serverName || '').trim();
1291
+ if (!cleanName)
1292
+ throw new Error('Укажите название сервера');
1293
+ lic.serverName = cleanName;
1294
+ lic.updatedAt = Date.now();
1295
+ this.state.licenses[licenseId] = signCertificate(lic, this.authorityKeys);
1296
+ this._save();
1297
+ return {
1298
+ ok: true,
1299
+ server: this.getLicenseSummary(licenseId),
1300
+ user: user ? this._formatUserResponse(user) : null,
1301
+ };
1302
+ }
1303
+
1304
+ /**
1305
+ * Удалить неактивированный сервер из реестра серверов пользователя (если на нём нет оплаченных лицензий).
1306
+ */
1307
+ deleteUserServer(sessionKeyOrEmail = '', { licenseId = '' } = {}) {
1308
+ const user = this._findUser(sessionKeyOrEmail);
1309
+ const lic = this.state.licenses[licenseId];
1310
+ if (!lic)
1311
+ throw new Error(`Сервер ${licenseId} не найден`);
1312
+ if (user && !user.isAdmin && lic.ownerAccountId && lic.ownerAccountId !== user.userId)
1313
+ throw new Error('Нет прав на удаление этого сервера');
1314
+ const hasPaidAddons = Array.isArray(lic.addonSubscriptions) && lic.addonSubscriptions.some(s => s.status === 'active');
1315
+ if (lic.baseActivated || hasPaidAddons)
1316
+ throw new Error('Нельзя удалить сервер с активированной платной лицензией');
1317
+ // Отменяем неоплаченные заказы этого сервера
1318
+ for (const ord of Object.values(this.state.orders || {})) {
1319
+ if (ord.licenseId === licenseId && ord.status !== 'paid') {
1320
+ ord.status = 'canceled';
1321
+ ord.canceledAt = Date.now();
1322
+ }
1323
+ }
1324
+ delete this.state.licenses[licenseId];
1325
+ if (user && user.licenseId === licenseId) {
1326
+ const remaining = this.listUserServers(user);
1327
+ user.licenseId = remaining[0]?.licenseId || '';
1328
+ }
1329
+ this._save();
1330
+ return {
1331
+ ok: true,
1332
+ deletedLicenseId: licenseId,
1333
+ user: user ? this._formatUserResponse(user) : null,
1334
+ };
1335
+ }
1336
+
1337
+ /**
1338
+ * Сбросить привязку оборудования (HWID / nodeFingerprint) у сервера (действие администратора при замене железа).
1339
+ */
1340
+ resetServerHwid(licenseId = '') {
1341
+ const lic = this.state.licenses[licenseId];
1342
+ if (!lic)
1343
+ throw new Error(`Сервер ${licenseId} не найден`);
1344
+ lic.hardware = {
1345
+ ...lic.hardware,
1346
+ hwid: 'PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase(),
1347
+ nodeFingerprint: '',
1348
+ hwidBound: false,
1349
+ };
1350
+ lic.updatedAt = Date.now();
1351
+ this.state.licenses[licenseId] = signCertificate(lic, this.authorityKeys);
1352
+ this._save();
1353
+ return this.getLicenseSummary(licenseId);
1354
+ }
1355
+
1356
+ /**
1357
+ * Административное управление сервером и лицензией из /admin (активация/отзыв базовой лицензии, переименование, сброс HWID).
1358
+ */
1359
+ adminManageServer({
1360
+ licenseId = '',
1361
+ serverName = undefined,
1362
+ baseActivated = undefined,
1363
+ baseSku = '',
1364
+ resetHwid = false,
1365
+ addonSeats = 0,
1366
+ addonTier = 'business',
1367
+ addonYears = 1,
1368
+ } = {}) {
1369
+ const lic = this.state.licenses[licenseId];
1370
+ if (!lic)
1371
+ throw new Error(`Сервер ${licenseId} не найден`);
1372
+ if (serverName !== undefined && String(serverName).trim()) {
1373
+ lic.serverName = String(serverName).trim();
1374
+ }
1375
+ if (baseActivated !== undefined) {
1376
+ lic.baseActivated = Boolean(baseActivated);
1377
+ if (lic.baseActivated) {
1378
+ lic.baseSeats = PAK_PRODUCT.baseSeats;
1379
+ lic.perpetualBase = true;
1380
+ if (baseSku) lic.baseSku = baseSku;
1381
+ }
1382
+ }
1383
+ if (resetHwid) {
1384
+ lic.hardware = {
1385
+ ...lic.hardware,
1386
+ hwid: 'PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase(),
1387
+ nodeFingerprint: '',
1388
+ hwidBound: false,
1389
+ };
1390
+ }
1391
+ lic.updatedAt = Date.now();
1392
+ this.state.licenses[licenseId] = signCertificate(lic, this.authorityKeys);
1393
+ if (Number(addonSeats) > 0) {
1394
+ return this.grantAddonSubscription(licenseId, {
1395
+ tier: addonTier,
1396
+ seats: Number(addonSeats),
1397
+ years: Number(addonYears) || 1,
1398
+ orderId: 'ADM-' + Date.now().toString(36).toUpperCase(),
1399
+ });
1400
+ }
1401
+ this._save();
1402
+ return this.getLicenseSummary(licenseId);
1403
+ }
1404
+
1006
1405
  _formatUserResponse(user) {
1007
1406
  if (!user)
1008
1407
  return null;
1408
+ this._expireStaleReservations();
1409
+ const now = Date.now();
1009
1410
  const userEmail = String(user.email || '').toLowerCase();
1010
1411
  const userOrders = Object.values(this.state.orders || {})
1011
1412
  .filter(o => o.status !== 'canceled' && (
@@ -1014,12 +1415,8 @@ export class LicenseStore {
1014
1415
  || (userEmail && String(o.customer?.email || '').toLowerCase() === userEmail)
1015
1416
  ))
1016
1417
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1017
- const userReservations = Object.values(this.state.reservations || {})
1018
- .filter(r => r.status !== 'canceled' && (
1019
- (user.userId && r.ownerAccountId === user.userId)
1020
- || (userEmail && String(r.email || '').toLowerCase() === userEmail)
1021
- ))
1022
- .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1418
+ const userFranchises = this.listUserFranchises(user, now);
1419
+ const userServers = this.listUserServers(user, now);
1023
1420
  return {
1024
1421
  userId: user.userId,
1025
1422
  email: user.email,
@@ -1043,9 +1440,11 @@ export class LicenseStore {
1043
1440
  ofertaSignatureId: user.ofertaSignatureId || '',
1044
1441
  createdAt: user.createdAt,
1045
1442
  lastLoginAt: user.lastLoginAt,
1046
- license: this.getLicenseSummary(user.licenseId),
1443
+ license: this.getLicenseSummary(user.licenseId, now),
1444
+ servers: userServers,
1445
+ franchises: userFranchises,
1047
1446
  orders: userOrders,
1048
- reservations: userReservations,
1447
+ reservations: userFranchises,
1049
1448
  };
1050
1449
  }
1051
1450
 
@@ -1184,27 +1583,39 @@ export class LicenseStore {
1184
1583
  }
1185
1584
 
1186
1585
  /**
1187
- * Сгенерировать (или перевыпустить) токен владельца в личном кабинете (содержит URL сервера поддержки).
1586
+ * Сгенерировать (или перевыпустить) токен привязки сервера (или владельца) в личном кабинете (содержит URL сервера поддержки и ID сервера).
1188
1587
  */
1189
- generateUserHubToken(sessionKeyOrEmail = '', { hubUrl = 'https://work.odant.org' } = {}) {
1190
- const key = String(sessionKeyOrEmail || '').trim();
1191
- const user = Object.values(this.state.users).find(
1192
- u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1193
- );
1588
+ generateUserHubToken(sessionKeyOrEmail = '', { licenseId = '', hubUrl = 'https://work.odant.org' } = {}) {
1589
+ const user = this._findUser(sessionKeyOrEmail);
1194
1590
  if (!user)
1195
1591
  throw new Error('Аккаунт владельца не найден в личном кабинете');
1196
1592
 
1593
+ const targetLicId = String(licenseId || user.licenseId || '').trim();
1594
+ const targetLic = targetLicId ? this.state.licenses[targetLicId] : null;
1595
+ const resolvedHubUrl = String(hubUrl || targetLic?.hubUrl || user.hubUrl || 'https://work.odant.org').replace(/\/+$/, '');
1596
+
1197
1597
  const tokData = createOwnerAccessToken({
1198
- hubUrl: hubUrl || user.hubUrl || 'https://work.odant.org',
1598
+ hubUrl: resolvedHubUrl,
1199
1599
  accountId: user.userId,
1200
1600
  email: user.email,
1601
+ licenseId: targetLic ? targetLic.licenseId : (user.licenseId || ''),
1602
+ serverName: targetLic ? (targetLic.serverName || 'Сервер WORK') : '',
1201
1603
  issuedAt: Date.now(),
1202
1604
  }, this.authorityKeys);
1203
1605
 
1204
- user.hubToken = tokData.token;
1205
- user.hubTokenId = tokData.payload.tokenId;
1206
- user.hubTokenSecret = tokData.payload.secret;
1207
- user.hubUrl = tokData.payload.hubUrl;
1606
+ if (targetLic) {
1607
+ targetLic.hubToken = tokData.token;
1608
+ targetLic.hubTokenId = tokData.payload.tokenId;
1609
+ targetLic.hubTokenSecret = tokData.payload.secret;
1610
+ targetLic.hubUrl = tokData.payload.hubUrl;
1611
+ this.state.licenses[targetLic.licenseId] = signCertificate(targetLic, this.authorityKeys);
1612
+ }
1613
+ if (!licenseId || licenseId === user.licenseId) {
1614
+ user.hubToken = tokData.token;
1615
+ user.hubTokenId = tokData.payload.tokenId;
1616
+ user.hubTokenSecret = tokData.payload.secret;
1617
+ user.hubUrl = tokData.payload.hubUrl;
1618
+ }
1208
1619
  this._save();
1209
1620
 
1210
1621
  return {
@@ -1215,13 +1626,15 @@ export class LicenseStore {
1215
1626
  email: user.email,
1216
1627
  hubUrl: tokData.payload.hubUrl,
1217
1628
  issuedAt: tokData.payload.issuedAt,
1218
- licenseId: user.licenseId,
1629
+ licenseId: targetLic ? targetLic.licenseId : user.licenseId,
1630
+ serverName: targetLic ? targetLic.serverName : '',
1631
+ user: this._formatUserResponse(user),
1219
1632
  };
1220
1633
  }
1221
1634
 
1222
1635
  /**
1223
- * Проверить токен владельца (owk1...), привязать локальную систему (HWID / nodeFingerprint) к аккаунту
1224
- * владельца и выдать подписанные файлы лицензий (базовую на 10 вечных мест и дополнительные временные)
1636
+ * Проверить токен владельца/сервера (owk1...), привязать локальную систему (HWID / nodeFingerprint) к конкретному
1637
+ * серверу из реестра владельца и выдать подписанные файлы лицензий (базовую на 10 вечных мест и дополнительные временные)
1225
1638
  * для сохранения в папке #system.
1226
1639
  */
1227
1640
  syncByOwnerToken({
@@ -1239,7 +1652,7 @@ export class LicenseStore {
1239
1652
  throw err;
1240
1653
  }
1241
1654
 
1242
- const { accountId, email, tokenId, secret, hubUrl } = parsed.payload;
1655
+ const { accountId, email, tokenId, secret, hubUrl, licenseId: tokLicenseId } = parsed.payload;
1243
1656
  const user = Object.values(this.state.users).find(
1244
1657
  u => u.userId === accountId || (email && u.email === email.toLowerCase())
1245
1658
  );
@@ -1248,15 +1661,35 @@ export class LicenseStore {
1248
1661
  err.statusCode = 404;
1249
1662
  throw err;
1250
1663
  }
1251
- if (user.hubTokenId && (user.hubTokenId !== tokenId || user.hubTokenSecret !== secret)) {
1664
+
1665
+ // Определяем целевой сервер из реестра серверов владельца
1666
+ let targetLic = null;
1667
+ if (tokLicenseId && this.state.licenses[tokLicenseId]) {
1668
+ targetLic = this.state.licenses[tokLicenseId];
1669
+ }
1670
+ if (!targetLic) {
1671
+ targetLic = Object.values(this.state.licenses).find(
1672
+ l => l && l.hubTokenId === tokenId && (l.ownerAccountId === user.userId || l.ownerEmail === user.email)
1673
+ ) || null;
1674
+ }
1675
+ if (!targetLic && user.licenseId && this.state.licenses[user.licenseId]) {
1676
+ targetLic = this.state.licenses[user.licenseId];
1677
+ }
1678
+
1679
+ const matchesServerToken = Boolean(targetLic?.hubTokenId && targetLic.hubTokenId === tokenId && targetLic.hubTokenSecret === secret);
1680
+ const matchesUserToken = Boolean(user.hubTokenId && user.hubTokenId === tokenId && user.hubTokenSecret === secret);
1681
+ if (!matchesServerToken && !matchesUserToken) {
1252
1682
  const err = new Error('Токен владельца был отозван или перевыпущен в личном кабинете');
1253
1683
  err.statusCode = 401;
1254
1684
  throw err;
1255
1685
  }
1256
1686
 
1257
- // Обновляем привязку оборудования к лицензии владельца
1687
+ const targetLicenseId = targetLic ? targetLic.licenseId : user.licenseId;
1688
+
1689
+ // Обновляем привязку оборудования именно к этому серверу из реестра
1258
1690
  const summary = this.issueOrUpdateLicense({
1259
- licenseId: user.licenseId,
1691
+ licenseId: targetLicenseId,
1692
+ serverName: targetLic?.serverName || 'Сервер WORK #1',
1260
1693
  hwid: hwid || undefined,
1261
1694
  nodeFingerprint: nodeFingerprint || undefined,
1262
1695
  ownerAccountId: user.userId,
@@ -1367,10 +1800,271 @@ export class LicenseStore {
1367
1800
  });
1368
1801
  }
1369
1802
 
1803
+ /**
1804
+ * Автоматически отменить резервы франшиз, по которым не оплачен начальный взнос (100 000 ₽) в течение 15 минут.
1805
+ */
1806
+ _expireStaleReservations(now = Date.now()) {
1807
+ let changed = false;
1808
+ for (const resv of Object.values(this.state.reservations || {})) {
1809
+ if (!resv) continue;
1810
+ if (
1811
+ resv.status === 'reserved_pending_deposit'
1812
+ && !resv.depositPaid
1813
+ && resv.depositDeadlineAt
1814
+ && now > Number(resv.depositDeadlineAt)
1815
+ ) {
1816
+ resv.status = 'expired';
1817
+ resv.expiredAt = now;
1818
+ if (resv.orderId && this.state.orders[resv.orderId] && this.state.orders[resv.orderId].status !== 'paid') {
1819
+ this.state.orders[resv.orderId].status = 'canceled';
1820
+ this.state.orders[resv.orderId].canceledAt = now;
1821
+ }
1822
+ changed = true;
1823
+ }
1824
+ }
1825
+ if (changed) {
1826
+ try { this._save(); } catch { /* ignore */ }
1827
+ }
1828
+ return changed;
1829
+ }
1830
+
1831
+ /**
1832
+ * Получить развёрнутую карточку региональной франшизы с таймером 15 минут и 12-месячным графиком погашения.
1833
+ */
1834
+ getFranchiseSummary(reservationId, now = Date.now()) {
1835
+ this._expireStaleReservations(now);
1836
+ const resv = this.state.reservations[reservationId];
1837
+ if (!resv)
1838
+ return null;
1839
+
1840
+ const depositRub = Number(resv.nonRefundableDepositRub || FRANCHISE_PROGRAM.reservationDepositRub || 100_000);
1841
+ const totalFeeRub = Number(resv.franchiseFeeRub || 500_000);
1842
+ const remainingAfterDeposit = Math.max(0, totalFeeRub - depositRub);
1843
+
1844
+ if (!Array.isArray(resv.paymentSchedule) || resv.paymentSchedule.length !== 12) {
1845
+ resv.paymentSchedule = buildFranchisePaymentSchedule({
1846
+ remainingRub: remainingAfterDeposit,
1847
+ startMs: resv.paidAt || resv.createdAt || now,
1848
+ existingSchedule: resv.paymentSchedule || [],
1849
+ });
1850
+ }
1851
+
1852
+ let nextFound = false;
1853
+ let paidMonthsCount = 0;
1854
+ let overdueMonthsCount = 0;
1855
+ let paidInstallmentsRub = 0;
1856
+ let nextInstallment = null;
1857
+
1858
+ const schedule = resv.paymentSchedule.map(inst => {
1859
+ const isPaid = inst.status === 'paid';
1860
+ if (isPaid) {
1861
+ paidMonthsCount += 1;
1862
+ paidInstallmentsRub += Number(inst.amountRub || 0);
1863
+ return { ...inst, displayStatus: 'paid' };
1864
+ }
1865
+ const isOverdue = Boolean(resv.depositPaid && inst.dueDate && now > Number(inst.dueDate));
1866
+ if (isOverdue) overdueMonthsCount += 1;
1867
+ let displayStatus = isOverdue ? 'overdue' : 'scheduled';
1868
+ if (!nextFound) {
1869
+ nextFound = true;
1870
+ if (!isOverdue) displayStatus = 'next';
1871
+ nextInstallment = { ...inst, displayStatus };
1872
+ }
1873
+ return { ...inst, displayStatus };
1874
+ });
1875
+
1876
+ const totalPaidRub = (resv.depositPaid ? depositRub : 0) + paidInstallmentsRub;
1877
+ const remainingFranchiseFeeRub = Math.max(0, totalFeeRub - totalPaidRub);
1878
+ const depositDeadlineAt = Number(resv.depositDeadlineAt || ((resv.createdAt || now) + FRANCHISE_RESERVATION_TTL_MS));
1879
+ const depositRemainingSeconds = (!resv.depositPaid && resv.status === 'reserved_pending_deposit')
1880
+ ? Math.max(0, Math.floor((depositDeadlineAt - now) / 1000))
1881
+ : 0;
1882
+
1883
+ const relatedOrders = Object.values(this.state.orders || {})
1884
+ .filter(o => o.reservationId === resv.reservationId && o.status !== 'canceled')
1885
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1886
+
1887
+ return {
1888
+ ...resv,
1889
+ depositDeadlineAt,
1890
+ depositRemainingSeconds,
1891
+ paymentSchedule: schedule,
1892
+ paidMonthsCount,
1893
+ totalMonthsCount: 12,
1894
+ overdueMonthsCount,
1895
+ paidInstallmentsRub,
1896
+ totalPaidRub,
1897
+ remainingFranchiseFeeRub,
1898
+ nextInstallment,
1899
+ orders: relatedOrders,
1900
+ };
1901
+ }
1902
+
1903
+ /**
1904
+ * 1.2 Реестр франшиз пользователя (несколько франшиз в разных регионах, но не более 1 в одном регионе).
1905
+ */
1906
+ listUserFranchises(userOrKey, now = Date.now()) {
1907
+ this._expireStaleReservations(now);
1908
+ const user = typeof userOrKey === 'object' ? userOrKey : this._findUser(userOrKey);
1909
+ if (!user)
1910
+ return [];
1911
+ const userEmail = String(user.email || '').toLowerCase();
1912
+ return Object.values(this.state.reservations || {})
1913
+ .filter(r => r && r.status !== 'canceled' && r.status !== 'expired' && (
1914
+ (user.userId && r.ownerAccountId === user.userId)
1915
+ || (userEmail && String(r.email || '').toLowerCase() === userEmail)
1916
+ ))
1917
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
1918
+ .map(r => this.getFranchiseSummary(r.reservationId, now));
1919
+ }
1920
+
1921
+ /**
1922
+ * Список всех франшиз и резервов для Админ-панели (/admin).
1923
+ */
1924
+ listAllFranchises(now = Date.now()) {
1925
+ this._expireStaleReservations(now);
1926
+ return Object.values(this.state.reservations || {})
1927
+ .filter(Boolean)
1928
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
1929
+ .map(r => this.getFranchiseSummary(r.reservationId, now));
1930
+ }
1931
+
1932
+ /**
1933
+ * Создать заказ на оплату очередного ежемесячного платежа (раз в месяц) или досрочное погашение остатка франшизы.
1934
+ */
1935
+ createFranchiseInstallmentOrder(sessionKeyOrEmail = '', {
1936
+ reservationId = '',
1937
+ monthNumber = null,
1938
+ earlyPayoff = false,
1939
+ paymentMethod = 'yookassa',
1940
+ companyName = '',
1941
+ inn = '',
1942
+ returnUrl = '',
1943
+ } = {}) {
1944
+ this._expireStaleReservations();
1945
+ const user = this._findUser(sessionKeyOrEmail);
1946
+ const summary = this.getFranchiseSummary(reservationId);
1947
+ if (!summary)
1948
+ throw new Error(`Франшиза ${reservationId} не найдена`);
1949
+ if (summary.status === 'canceled' || summary.status === 'expired')
1950
+ throw new Error('Резерв франшизы истёк или был отменён');
1951
+ if (!summary.depositPaid)
1952
+ throw new Error('Сначала оплатите начальный взнос за резервирование франшизы (100 000 ₽)');
1953
+
1954
+ const unpaid = (summary.paymentSchedule || []).filter(it => it.status !== 'paid');
1955
+ if (!unpaid.length || summary.remainingFranchiseFeeRub <= 0)
1956
+ throw new Error('Все 12 платежей по данной франшизе уже полностью погашены');
1957
+
1958
+ let targetInstallments = [];
1959
+ if (earlyPayoff) {
1960
+ targetInstallments = unpaid;
1961
+ }
1962
+ else if (monthNumber != null) {
1963
+ const found = unpaid.find(it => Number(it.monthNumber) === Number(monthNumber));
1964
+ if (!found)
1965
+ throw new Error(`Платёж №${monthNumber} уже оплачен или не найден`);
1966
+ targetInstallments = [found];
1967
+ }
1968
+ else {
1969
+ targetInstallments = [unpaid[0]];
1970
+ }
1971
+
1972
+ const amountRub = targetInstallments.reduce((s, it) => s + Number(it.amountRub || 0), 0);
1973
+ const monthsList = targetInstallments.map(it => Number(it.monthNumber));
1974
+ const now = Date.now();
1975
+ const method = String(paymentMethod || 'yookassa').toLowerCase() === 'invoice' ? 'invoice' : 'yookassa';
1976
+ const orderId = 'ORD-FI-' + now.toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
1977
+ const invoiceNumber = 'СЧ-FI-' + new Date(now).toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
1978
+ const contractNumber = summary.contractNumber || ('ДЛ-FR-' + new Date(now).getFullYear() + '-' + summary.reservationId.slice(-6));
1979
+
1980
+ const title = earlyPayoff
1981
+ ? `Досрочное погашение остатка паушального взноса по франшизе ODANT-WORK (${summary.regionName}, платежи №${monthsList.join(', ')})`
1982
+ : `Ежемесячный платёж №${monthsList[0]}/12 по графику погашения франшизы ODANT-WORK (${summary.regionName})`;
1983
+
1984
+ const order = {
1985
+ orderId,
1986
+ ownerAccountId: summary.ownerAccountId || user?.userId || '',
1987
+ kind: 'franchise_installment',
1988
+ reservationId: summary.reservationId,
1989
+ licenseId: summary.reservationId,
1990
+ regionCode: summary.regionCode,
1991
+ regionName: summary.regionName,
1992
+ earlyPayoff: Boolean(earlyPayoff),
1993
+ installmentMonths: monthsList,
1994
+ tierName: earlyPayoff
1995
+ ? `Досрочное погашение франшизы (${summary.regionName})`
1996
+ : `Платёж №${monthsList[0]}/12 · Франшиза (${summary.regionName})`,
1997
+ addonSeats: 1,
1998
+ years: 1,
1999
+ amountRub,
2000
+ paymentMethod: method,
2001
+ invoiceNumber,
2002
+ contractNumber,
2003
+ invoiceUrl: `/api/orders/${encodeURIComponent(orderId)}/invoice`,
2004
+ items: [
2005
+ {
2006
+ title,
2007
+ quantity: 1,
2008
+ unitPriceRub: amountRub,
2009
+ totalRub: amountRub,
2010
+ },
2011
+ ],
2012
+ customer: {
2013
+ name: companyName || summary.companyName || user?.companyName || user?.email || 'Партнёр ODANT-WORK',
2014
+ inn: inn || summary.inn || user?.inn || '',
2015
+ email: summary.email || user?.email || 'franchise@odant.org',
2016
+ phone: summary.phone || user?.phone || '',
2017
+ },
2018
+ status: method === 'invoice' ? 'awaiting_invoice' : 'pending',
2019
+ paymentId: '',
2020
+ confirmationUrl: method === 'invoice' ? `/api/orders/${encodeURIComponent(orderId)}/invoice` : '',
2021
+ returnUrl: returnUrl || 'https://work.odant.org/#addons',
2022
+ createdAt: now,
2023
+ paidAt: null,
2024
+ };
2025
+
2026
+ this.state.orders[orderId] = order;
2027
+ this._save();
2028
+ return {
2029
+ ok: true,
2030
+ order,
2031
+ franchise: this.getFranchiseSummary(summary.reservationId),
2032
+ user: user ? this._formatUserResponse(user) : null,
2033
+ };
2034
+ }
2035
+
2036
+ /**
2037
+ * Зачесть оплату ежемесячного платежа (или полного остатка) по графику франшизы из Админ-панели (/admin).
2038
+ */
2039
+ adminConfirmFranchiseInstallment(reservationId, {
2040
+ monthNumber = null,
2041
+ earlyPayoff = false,
2042
+ paymentOrderNumber = '',
2043
+ } = {}) {
2044
+ const resv = this.state.reservations[reservationId];
2045
+ if (!resv)
2046
+ throw new Error(`Франшиза ${reservationId} не найдена`);
2047
+ if (!resv.depositPaid && !earlyPayoff && monthNumber == null) {
2048
+ if (resv.orderId && this.state.orders[resv.orderId]) {
2049
+ return this.confirmInvoicePayment(resv.orderId, { paymentOrderNumber });
2050
+ }
2051
+ }
2052
+ const created = this.createFranchiseInstallmentOrder(resv.ownerAccountId || resv.email, {
2053
+ reservationId,
2054
+ monthNumber,
2055
+ earlyPayoff,
2056
+ paymentMethod: 'invoice',
2057
+ });
2058
+ return this.confirmInvoicePayment(created.order.orderId, {
2059
+ paymentOrderNumber: paymentOrderNumber || `ADM-FI-${Date.now()}`,
2060
+ });
2061
+ }
2062
+
1370
2063
  /**
1371
2064
  * Получить статус квоты и вакантных франшиз по региону (макс. число партнёров пропорционально населению).
1372
2065
  */
1373
2066
  getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null, options = {}) {
2067
+ this._expireStaleReservations();
1374
2068
  const info = calculateFranchiseFee(regionCode, customPopulationMillions);
1375
2069
  const ignoreResId = options?.ignoreReservationId || '';
1376
2070
  const existingInRegion = Object.values(this.state.partners).filter(
@@ -1379,6 +2073,7 @@ export class LicenseStore {
1379
2073
  const reservedInRegion = Object.values(this.state.reservations || {}).filter(
1380
2074
  r => r.regionCode === info.regionCode
1381
2075
  && r.status !== 'canceled'
2076
+ && r.status !== 'expired'
1382
2077
  && !r.convertedToPartnerId
1383
2078
  && r.reservationId !== ignoreResId
1384
2079
  );
@@ -1395,8 +2090,9 @@ export class LicenseStore {
1395
2090
  }
1396
2091
 
1397
2092
  /**
1398
- * Зарезервировать региональную франшизу с выставлением счёта ЮKassa на невозвратный залог 100 000 ₽.
1399
- * Полный паушальный взнос (500 000 ₽ × индекс региона) фиксируется в договоре резерва, полная оплата — в течение 1 года.
2093
+ * Зарезервировать региональную франшизу с выставлением счёта ЮKassa на невозвратный залог 100 000 ₽ (таймер 15 минут).
2094
+ * Пользователь может резервировать несколько франшиз в разных регионах, но не более 1 в одном регионе.
2095
+ * Полный паушальный взнос (500 000 ₽ × индекс региона) погашается по помесячному графику в течение 1 года (12 мес.).
1400
2096
  */
1401
2097
  reserveFranchiseSlot({
1402
2098
  regionCode = 'RU-SVE',
@@ -1406,11 +2102,46 @@ export class LicenseStore {
1406
2102
  email = '',
1407
2103
  phone = '',
1408
2104
  contactPerson = '',
2105
+ depositTtlMs = FRANCHISE_RESERVATION_TTL_MS,
1409
2106
  returnUrl = '',
1410
2107
  } = {}) {
2108
+ const now = Date.now();
2109
+ this._expireStaleReservations(now);
1411
2110
  const effCompanyName = String(companyName || email || 'Партнёр ODANT-WORK').trim();
2111
+ const cleanEmail = String(email || '').trim().toLowerCase();
2112
+ const cleanOwnerId = String(ownerAccountId || '').trim();
1412
2113
 
1413
2114
  const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode);
2115
+
2116
+ // Проверка ограничения 1.2.1: более 1 франшизы в одном регионе одному пользователю резервировать нельзя
2117
+ if (cleanOwnerId || cleanEmail) {
2118
+ const duplicateResv = Object.values(this.state.reservations || {}).find(r =>
2119
+ r
2120
+ && r.regionCode === quotaStatus.regionCode
2121
+ && r.status !== 'canceled'
2122
+ && r.status !== 'expired'
2123
+ && (
2124
+ (cleanOwnerId && r.ownerAccountId === cleanOwnerId)
2125
+ || (cleanEmail && String(r.email || '').toLowerCase() === cleanEmail)
2126
+ )
2127
+ );
2128
+ const duplicatePartner = Object.values(this.state.partners || {}).find(p =>
2129
+ p
2130
+ && p.regionCode === quotaStatus.regionCode
2131
+ && p.status !== 'revoked'
2132
+ && (
2133
+ (cleanOwnerId && p.ownerAccountId === cleanOwnerId)
2134
+ || (cleanEmail && String(p.email || '').toLowerCase() === cleanEmail)
2135
+ )
2136
+ );
2137
+ if (duplicateResv || duplicatePartner) {
2138
+ throw new Error(
2139
+ `В регионе «${quotaStatus.regionName}» у вас уже есть зарезервированная или действующая франшиза `
2140
+ + `(нельзя резервировать более 1 франшизы в одном регионе). Вы можете выбрать любой другой регион.`
2141
+ );
2142
+ }
2143
+ }
2144
+
1414
2145
  if (quotaStatus.vacantFranchises <= 0) {
1415
2146
  throw new Error(
1416
2147
  `В регионе «${quotaStatus.regionName}» нет вакантных франшиз `
@@ -1418,9 +2149,11 @@ export class LicenseStore {
1418
2149
  );
1419
2150
  }
1420
2151
 
1421
- const now = Date.now();
1422
2152
  const reservationId = 'RES-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase();
1423
2153
  const depositRub = FRANCHISE_PROGRAM.reservationDepositRub;
2154
+ const remainingRub = Math.max(0, quotaStatus.franchiseFeeRub - depositRub);
2155
+ const ttlMs = Number(depositTtlMs) > 0 ? Number(depositTtlMs) : FRANCHISE_RESERVATION_TTL_MS;
2156
+ const depositDeadlineAt = now + ttlMs;
1424
2157
  const orderId = 'ORD-FR-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
1425
2158
  const invoiceNumber = 'СЧ-FR-' + new Date(now).toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
1426
2159
  const contractNumber = 'ДЛ-FR-' + new Date(now).getFullYear() + '-' + orderId.slice(-6);
@@ -1428,7 +2161,7 @@ export class LicenseStore {
1428
2161
  const reservation = {
1429
2162
  reservationId,
1430
2163
  orderId,
1431
- ownerAccountId: String(ownerAccountId || '').trim(),
2164
+ ownerAccountId: cleanOwnerId,
1432
2165
  regionCode: quotaStatus.regionCode,
1433
2166
  regionName: quotaStatus.regionName,
1434
2167
  district: quotaStatus.district,
@@ -1439,9 +2172,16 @@ export class LicenseStore {
1439
2172
  contactPerson: String(contactPerson || '').trim(),
1440
2173
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
1441
2174
  nonRefundableDepositRub: depositRub,
1442
- remainingFranchiseFeeRub: Math.max(0, quotaStatus.franchiseFeeRub - depositRub),
2175
+ paidInstallmentsRub: 0,
2176
+ totalPaidRub: 0,
2177
+ remainingFranchiseFeeRub: remainingRub,
1443
2178
  fullPaymentPeriodMonths: FRANCHISE_PROGRAM.fullPaymentPeriodMonths,
1444
2179
  fullPaymentDeadlineAt: now + 365 * 24 * 3600_000,
2180
+ depositDeadlineAt,
2181
+ paymentSchedule: buildFranchisePaymentSchedule({
2182
+ remainingRub,
2183
+ startMs: now,
2184
+ }),
1445
2185
  status: 'reserved_pending_deposit',
1446
2186
  depositPaid: false,
1447
2187
  createdAt: now,
@@ -1450,23 +2190,24 @@ export class LicenseStore {
1450
2190
 
1451
2191
  const order = {
1452
2192
  orderId,
1453
- ownerAccountId: String(ownerAccountId || '').trim(),
2193
+ ownerAccountId: cleanOwnerId,
1454
2194
  kind: 'franchise_reservation',
1455
2195
  reservationId,
1456
2196
  licenseId: reservationId,
1457
2197
  regionCode: quotaStatus.regionCode,
1458
2198
  regionName: quotaStatus.regionName,
1459
- tierName: `Резерв франшизы (${quotaStatus.regionName})`,
2199
+ tierName: `Начальный взнос за резерв франшизы (${quotaStatus.regionName})`,
1460
2200
  addonSeats: 1,
1461
2201
  years: 1,
1462
2202
  amountRub: depositRub,
1463
2203
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
2204
+ depositDeadlineAt,
1464
2205
  invoiceNumber,
1465
2206
  contractNumber,
1466
2207
  invoiceUrl: `/api/orders/${encodeURIComponent(orderId)}/invoice`,
1467
2208
  items: [
1468
2209
  {
1469
- title: `Невозвратный залог за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, полная оплата в течение 1 года)`,
2210
+ title: `Начальный взнос (залог) за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, график погашения остатка — 12 ежемесячных платежей)`,
1470
2211
  quantity: 1,
1471
2212
  unitPriceRub: depositRub,
1472
2213
  totalRub: depositRub,
@@ -1481,7 +2222,7 @@ export class LicenseStore {
1481
2222
  status: 'pending',
1482
2223
  paymentId: '',
1483
2224
  confirmationUrl: '',
1484
- returnUrl: returnUrl || 'https://work.odant.org',
2225
+ returnUrl: returnUrl || 'https://work.odant.org/#addons',
1485
2226
  createdAt: now,
1486
2227
  paidAt: null,
1487
2228
  };
@@ -1491,7 +2232,7 @@ export class LicenseStore {
1491
2232
  this._save();
1492
2233
 
1493
2234
  return {
1494
- reservation,
2235
+ reservation: this.getFranchiseSummary(reservationId, now),
1495
2236
  order,
1496
2237
  regionQuota: this.getRegionPartnerQuotaStatus(quotaStatus.regionCode),
1497
2238
  };
@@ -1541,21 +2282,32 @@ export class LicenseStore {
1541
2282
  }
1542
2283
 
1543
2284
  /**
1544
- * Отменить неоплаченный заказ в Личном кабинете (и снять резерв региона, если это был резерв франшизы).
2285
+ * Отменить резерв франшизы (до оплаты залога — пользователем, либо в любой момент — администратором).
1545
2286
  */
1546
- cancelOrder(orderId) {
1547
- const order = this.state.orders[orderId];
1548
- if (!order)
1549
- throw new Error(`Заказ ${orderId} не найден`);
1550
- if (order.status === 'paid')
1551
- throw new Error('Оплаченный заказ отменить нельзя');
1552
- order.status = 'canceled';
1553
- order.canceledAt = Date.now();
1554
- if (order.reservationId && this.state.reservations[order.reservationId]) {
1555
- this.state.reservations[order.reservationId].status = 'canceled';
2287
+ cancelFranchiseReservation(reservationId, { byAdmin = false } = {}) {
2288
+ const resv = this.state.reservations[reservationId];
2289
+ if (!resv)
2290
+ throw new Error(`Франшиза ${reservationId} не найдена`);
2291
+ if (resv.depositPaid && !byAdmin)
2292
+ throw new Error('Залог по франшизе уже оплачен. Для расторжения обратитесь к администратору.');
2293
+ const now = Date.now();
2294
+ resv.status = 'canceled';
2295
+ resv.canceledAt = now;
2296
+ for (const ord of Object.values(this.state.orders || {})) {
2297
+ if (ord.reservationId === reservationId && ord.status !== 'paid') {
2298
+ ord.status = 'canceled';
2299
+ ord.canceledAt = now;
2300
+ }
2301
+ }
2302
+ if (resv.convertedToPartnerId && this.state.partners[resv.convertedToPartnerId] && byAdmin) {
2303
+ this.state.partners[resv.convertedToPartnerId].status = 'revoked';
1556
2304
  }
1557
2305
  this._save();
1558
- return { ok: true, orderId };
2306
+ return {
2307
+ ok: true,
2308
+ reservationId,
2309
+ regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
2310
+ };
1559
2311
  }
1560
2312
 
1561
2313
  /**