work-license-server 1.0.14 → 1.0.16

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,6 +32,41 @@ 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
72
  constructor({ dataDir = './data', adminLogin = '', adminPassword = '', publicOrigin = 'https://work.odant.org' } = {}) {
@@ -202,7 +237,29 @@ export class LicenseStore {
202
237
  _resignAllWithAuthority() {
203
238
  let changed = false;
204
239
  for (const [id, lic] of Object.entries(this.state.licenses || {})) {
205
- if (lic && lic.issuerPublicKey !== this.authorityKeys.publicKey) {
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) {
206
263
  this.state.licenses[id] = signCertificate(lic, this.authorityKeys);
207
264
  changed = true;
208
265
  }
@@ -214,6 +271,7 @@ export class LicenseStore {
214
271
  hubUrl: user.hubUrl || 'https://work.odant.org',
215
272
  accountId: user.userId,
216
273
  email: user.email,
274
+ licenseId: user.licenseId || '',
217
275
  tokenId: user.hubTokenId || '',
218
276
  secret: user.hubTokenSecret || '',
219
277
  issuedAt: user.createdAt || Date.now(),
@@ -242,11 +300,13 @@ export class LicenseStore {
242
300
  */
243
301
  issueOrUpdateLicense({
244
302
  licenseId,
303
+ serverName = '',
245
304
  hwid = '',
246
305
  nodeFingerprint = '',
247
306
  customer = {},
248
307
  ownerAccountId = '',
249
308
  ownerEmail = '',
309
+ hubUrl = '',
250
310
  partnerId = '',
251
311
  hardwareWarrantyYears = 1,
252
312
  partnerAddons = {},
@@ -272,13 +332,42 @@ export class LicenseStore {
272
332
  ? Boolean(baseActivated)
273
333
  : (existing?.baseActivated !== undefined ? Boolean(existing.baseActivated) : true);
274
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
+
275
359
  const record = {
276
360
  licenseId: id,
361
+ serverName: resolvedServerName,
277
362
  issuedAt,
278
363
  updatedAt: now,
279
364
  issuer: 'work.odant.org',
280
- ownerAccountId: String(ownerAccountId || existing?.ownerAccountId || ''),
281
- 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,
282
371
  baseActivated: isBaseActivated,
283
372
  baseSku: String(baseSku || existing?.baseSku || PAK_PRODUCT.sku),
284
373
  customer: {
@@ -290,6 +379,7 @@ export class LicenseStore {
290
379
  hardware: {
291
380
  hwid: hwid || existing?.hardware?.hwid || ('PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase()),
292
381
  nodeFingerprint: nodeFingerprint || existing?.hardware?.nodeFingerprint || '',
382
+ hwidBound,
293
383
  sku: String(baseSku || existing?.hardware?.sku || PAK_PRODUCT.sku),
294
384
  cpu: PAK_PRODUCT.hardware.cpu,
295
385
  ramGb: PAK_PRODUCT.hardware.ramGb,
@@ -542,10 +632,15 @@ export class LicenseStore {
542
632
 
543
633
  return {
544
634
  licenseId: lic.licenseId,
635
+ serverName: lic.serverName || 'Сервер WORK #1',
545
636
  issuedAt: lic.issuedAt,
546
637
  updatedAt: lic.updatedAt,
547
638
  ownerAccountId: lic.ownerAccountId || '',
548
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),
549
644
  baseActivated: lic.baseActivated !== false,
550
645
  baseSku: lic.baseSku || PAK_PRODUCT.sku,
551
646
  customer: lic.customer,
@@ -608,6 +703,8 @@ export class LicenseStore {
608
703
  */
609
704
  createOrder({
610
705
  licenseId = '',
706
+ serverName = '',
707
+ createNewServer = false,
611
708
  hwid = '',
612
709
  nodeFingerprint = '',
613
710
  includeBasePak = false,
@@ -633,10 +730,17 @@ export class LicenseStore {
633
730
  if (quote.totalAmountRub <= 0)
634
731
  throw new Error('Сумма заказа должна быть больше 0 ₽');
635
732
 
636
- let targetLic = this.findLicense(licenseId || hwid);
733
+ const forceNew = Boolean(createNewServer || String(licenseId).toUpperCase() === 'NEW');
734
+ let targetLic = forceNew ? null : this.findLicense(licenseId || hwid);
637
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;
638
740
  targetLic = this.issueOrUpdateLicense({
639
- licenseId: licenseId || undefined,
741
+ licenseId: (!forceNew && licenseId) ? licenseId : undefined,
742
+ serverName: serverName || `Сервер WORK #${existingCount + 1}`,
743
+ baseActivated: forceNew ? false : undefined,
640
744
  hwid,
641
745
  nodeFingerprint,
642
746
  customer,
@@ -727,11 +831,11 @@ export class LicenseStore {
727
831
  if (!ord)
728
832
  throw new Error(`Заказ ${orderId} не найден`);
729
833
  if (ord.status === 'paid') {
730
- 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]) {
731
835
  const resv = this.state.reservations[ord.reservationId];
732
836
  return {
733
837
  order: ord,
734
- reservation: resv,
838
+ reservation: this.getFranchiseSummary(resv.reservationId),
735
839
  regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
736
840
  alreadyProcessed: true,
737
841
  };
@@ -754,10 +858,60 @@ export class LicenseStore {
754
858
  resv.paymentId = ord.paymentId;
755
859
  if (!resv.convertedToPartnerId)
756
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);
757
873
  this._save();
758
874
  return {
759
875
  order: ord,
760
- 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),
761
915
  regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
762
916
  alreadyProcessed: false,
763
917
  };
@@ -902,7 +1056,7 @@ export class LicenseStore {
902
1056
  regionQuota = this.getRegionPartnerQuotaStatus(resv.regionCode);
903
1057
  }
904
1058
  this._save();
905
- return { order: ord, regionQuota };
1059
+ return { ok: true, orderId: ord.orderId, order: ord, regionQuota };
906
1060
  }
907
1061
 
908
1062
  /**
@@ -1041,9 +1195,218 @@ export class LicenseStore {
1041
1195
  return all;
1042
1196
  }
1043
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
+
1044
1405
  _formatUserResponse(user) {
1045
1406
  if (!user)
1046
1407
  return null;
1408
+ this._expireStaleReservations();
1409
+ const now = Date.now();
1047
1410
  const userEmail = String(user.email || '').toLowerCase();
1048
1411
  const userOrders = Object.values(this.state.orders || {})
1049
1412
  .filter(o => o.status !== 'canceled' && (
@@ -1052,12 +1415,8 @@ export class LicenseStore {
1052
1415
  || (userEmail && String(o.customer?.email || '').toLowerCase() === userEmail)
1053
1416
  ))
1054
1417
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1055
- const userReservations = Object.values(this.state.reservations || {})
1056
- .filter(r => r.status !== 'canceled' && (
1057
- (user.userId && r.ownerAccountId === user.userId)
1058
- || (userEmail && String(r.email || '').toLowerCase() === userEmail)
1059
- ))
1060
- .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1418
+ const userFranchises = this.listUserFranchises(user, now);
1419
+ const userServers = this.listUserServers(user, now);
1061
1420
  return {
1062
1421
  userId: user.userId,
1063
1422
  email: user.email,
@@ -1081,9 +1440,11 @@ export class LicenseStore {
1081
1440
  ofertaSignatureId: user.ofertaSignatureId || '',
1082
1441
  createdAt: user.createdAt,
1083
1442
  lastLoginAt: user.lastLoginAt,
1084
- license: this.getLicenseSummary(user.licenseId),
1443
+ license: this.getLicenseSummary(user.licenseId, now),
1444
+ servers: userServers,
1445
+ franchises: userFranchises,
1085
1446
  orders: userOrders,
1086
- reservations: userReservations,
1447
+ reservations: userFranchises,
1087
1448
  };
1088
1449
  }
1089
1450
 
@@ -1222,27 +1583,39 @@ export class LicenseStore {
1222
1583
  }
1223
1584
 
1224
1585
  /**
1225
- * Сгенерировать (или перевыпустить) токен владельца в личном кабинете (содержит URL сервера поддержки).
1586
+ * Сгенерировать (или перевыпустить) токен привязки сервера (или владельца) в личном кабинете (содержит URL сервера поддержки и ID сервера).
1226
1587
  */
1227
- generateUserHubToken(sessionKeyOrEmail = '', { hubUrl = 'https://work.odant.org' } = {}) {
1228
- const key = String(sessionKeyOrEmail || '').trim();
1229
- const user = Object.values(this.state.users).find(
1230
- u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1231
- );
1588
+ generateUserHubToken(sessionKeyOrEmail = '', { licenseId = '', hubUrl = 'https://work.odant.org' } = {}) {
1589
+ const user = this._findUser(sessionKeyOrEmail);
1232
1590
  if (!user)
1233
1591
  throw new Error('Аккаунт владельца не найден в личном кабинете');
1234
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
+
1235
1597
  const tokData = createOwnerAccessToken({
1236
- hubUrl: hubUrl || user.hubUrl || 'https://work.odant.org',
1598
+ hubUrl: resolvedHubUrl,
1237
1599
  accountId: user.userId,
1238
1600
  email: user.email,
1601
+ licenseId: targetLic ? targetLic.licenseId : (user.licenseId || ''),
1602
+ serverName: targetLic ? (targetLic.serverName || 'Сервер WORK') : '',
1239
1603
  issuedAt: Date.now(),
1240
1604
  }, this.authorityKeys);
1241
1605
 
1242
- user.hubToken = tokData.token;
1243
- user.hubTokenId = tokData.payload.tokenId;
1244
- user.hubTokenSecret = tokData.payload.secret;
1245
- 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
+ }
1246
1619
  this._save();
1247
1620
 
1248
1621
  return {
@@ -1253,13 +1626,15 @@ export class LicenseStore {
1253
1626
  email: user.email,
1254
1627
  hubUrl: tokData.payload.hubUrl,
1255
1628
  issuedAt: tokData.payload.issuedAt,
1256
- licenseId: user.licenseId,
1629
+ licenseId: targetLic ? targetLic.licenseId : user.licenseId,
1630
+ serverName: targetLic ? targetLic.serverName : '',
1631
+ user: this._formatUserResponse(user),
1257
1632
  };
1258
1633
  }
1259
1634
 
1260
1635
  /**
1261
- * Проверить токен владельца (owk1...), привязать локальную систему (HWID / nodeFingerprint) к аккаунту
1262
- * владельца и выдать подписанные файлы лицензий (базовую на 10 вечных мест и дополнительные временные)
1636
+ * Проверить токен владельца/сервера (owk1...), привязать локальную систему (HWID / nodeFingerprint) к конкретному
1637
+ * серверу из реестра владельца и выдать подписанные файлы лицензий (базовую на 10 вечных мест и дополнительные временные)
1263
1638
  * для сохранения в папке #system.
1264
1639
  */
1265
1640
  syncByOwnerToken({
@@ -1277,7 +1652,7 @@ export class LicenseStore {
1277
1652
  throw err;
1278
1653
  }
1279
1654
 
1280
- const { accountId, email, tokenId, secret, hubUrl } = parsed.payload;
1655
+ const { accountId, email, tokenId, secret, hubUrl, licenseId: tokLicenseId } = parsed.payload;
1281
1656
  const user = Object.values(this.state.users).find(
1282
1657
  u => u.userId === accountId || (email && u.email === email.toLowerCase())
1283
1658
  );
@@ -1286,15 +1661,35 @@ export class LicenseStore {
1286
1661
  err.statusCode = 404;
1287
1662
  throw err;
1288
1663
  }
1289
- 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) {
1290
1682
  const err = new Error('Токен владельца был отозван или перевыпущен в личном кабинете');
1291
1683
  err.statusCode = 401;
1292
1684
  throw err;
1293
1685
  }
1294
1686
 
1295
- // Обновляем привязку оборудования к лицензии владельца
1687
+ const targetLicenseId = targetLic ? targetLic.licenseId : user.licenseId;
1688
+
1689
+ // Обновляем привязку оборудования именно к этому серверу из реестра
1296
1690
  const summary = this.issueOrUpdateLicense({
1297
- licenseId: user.licenseId,
1691
+ licenseId: targetLicenseId,
1692
+ serverName: targetLic?.serverName || 'Сервер WORK #1',
1298
1693
  hwid: hwid || undefined,
1299
1694
  nodeFingerprint: nodeFingerprint || undefined,
1300
1695
  ownerAccountId: user.userId,
@@ -1405,10 +1800,271 @@ export class LicenseStore {
1405
1800
  });
1406
1801
  }
1407
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
+
1408
2063
  /**
1409
2064
  * Получить статус квоты и вакантных франшиз по региону (макс. число партнёров пропорционально населению).
1410
2065
  */
1411
2066
  getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null, options = {}) {
2067
+ this._expireStaleReservations();
1412
2068
  const info = calculateFranchiseFee(regionCode, customPopulationMillions);
1413
2069
  const ignoreResId = options?.ignoreReservationId || '';
1414
2070
  const existingInRegion = Object.values(this.state.partners).filter(
@@ -1417,6 +2073,7 @@ export class LicenseStore {
1417
2073
  const reservedInRegion = Object.values(this.state.reservations || {}).filter(
1418
2074
  r => r.regionCode === info.regionCode
1419
2075
  && r.status !== 'canceled'
2076
+ && r.status !== 'expired'
1420
2077
  && !r.convertedToPartnerId
1421
2078
  && r.reservationId !== ignoreResId
1422
2079
  );
@@ -1433,8 +2090,9 @@ export class LicenseStore {
1433
2090
  }
1434
2091
 
1435
2092
  /**
1436
- * Зарезервировать региональную франшизу с выставлением счёта ЮKassa на невозвратный залог 100 000 ₽.
1437
- * Полный паушальный взнос (500 000 ₽ × индекс региона) фиксируется в договоре резерва, полная оплата — в течение 1 года.
2093
+ * Зарезервировать региональную франшизу с выставлением счёта ЮKassa на невозвратный залог 100 000 ₽ (таймер 15 минут).
2094
+ * Пользователь может резервировать несколько франшиз в разных регионах, но не более 1 в одном регионе.
2095
+ * Полный паушальный взнос (500 000 ₽ × индекс региона) погашается по помесячному графику в течение 1 года (12 мес.).
1438
2096
  */
1439
2097
  reserveFranchiseSlot({
1440
2098
  regionCode = 'RU-SVE',
@@ -1444,11 +2102,46 @@ export class LicenseStore {
1444
2102
  email = '',
1445
2103
  phone = '',
1446
2104
  contactPerson = '',
2105
+ depositTtlMs = FRANCHISE_RESERVATION_TTL_MS,
1447
2106
  returnUrl = '',
1448
2107
  } = {}) {
2108
+ const now = Date.now();
2109
+ this._expireStaleReservations(now);
1449
2110
  const effCompanyName = String(companyName || email || 'Партнёр ODANT-WORK').trim();
2111
+ const cleanEmail = String(email || '').trim().toLowerCase();
2112
+ const cleanOwnerId = String(ownerAccountId || '').trim();
1450
2113
 
1451
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
+
1452
2145
  if (quotaStatus.vacantFranchises <= 0) {
1453
2146
  throw new Error(
1454
2147
  `В регионе «${quotaStatus.regionName}» нет вакантных франшиз `
@@ -1456,9 +2149,11 @@ export class LicenseStore {
1456
2149
  );
1457
2150
  }
1458
2151
 
1459
- const now = Date.now();
1460
2152
  const reservationId = 'RES-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase();
1461
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;
1462
2157
  const orderId = 'ORD-FR-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
1463
2158
  const invoiceNumber = 'СЧ-FR-' + new Date(now).toISOString().slice(0, 10).replace(/-/g, '') + '-' + orderId.slice(-4);
1464
2159
  const contractNumber = 'ДЛ-FR-' + new Date(now).getFullYear() + '-' + orderId.slice(-6);
@@ -1466,7 +2161,7 @@ export class LicenseStore {
1466
2161
  const reservation = {
1467
2162
  reservationId,
1468
2163
  orderId,
1469
- ownerAccountId: String(ownerAccountId || '').trim(),
2164
+ ownerAccountId: cleanOwnerId,
1470
2165
  regionCode: quotaStatus.regionCode,
1471
2166
  regionName: quotaStatus.regionName,
1472
2167
  district: quotaStatus.district,
@@ -1477,9 +2172,16 @@ export class LicenseStore {
1477
2172
  contactPerson: String(contactPerson || '').trim(),
1478
2173
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
1479
2174
  nonRefundableDepositRub: depositRub,
1480
- remainingFranchiseFeeRub: Math.max(0, quotaStatus.franchiseFeeRub - depositRub),
2175
+ paidInstallmentsRub: 0,
2176
+ totalPaidRub: 0,
2177
+ remainingFranchiseFeeRub: remainingRub,
1481
2178
  fullPaymentPeriodMonths: FRANCHISE_PROGRAM.fullPaymentPeriodMonths,
1482
2179
  fullPaymentDeadlineAt: now + 365 * 24 * 3600_000,
2180
+ depositDeadlineAt,
2181
+ paymentSchedule: buildFranchisePaymentSchedule({
2182
+ remainingRub,
2183
+ startMs: now,
2184
+ }),
1483
2185
  status: 'reserved_pending_deposit',
1484
2186
  depositPaid: false,
1485
2187
  createdAt: now,
@@ -1488,23 +2190,24 @@ export class LicenseStore {
1488
2190
 
1489
2191
  const order = {
1490
2192
  orderId,
1491
- ownerAccountId: String(ownerAccountId || '').trim(),
2193
+ ownerAccountId: cleanOwnerId,
1492
2194
  kind: 'franchise_reservation',
1493
2195
  reservationId,
1494
2196
  licenseId: reservationId,
1495
2197
  regionCode: quotaStatus.regionCode,
1496
2198
  regionName: quotaStatus.regionName,
1497
- tierName: `Резерв франшизы (${quotaStatus.regionName})`,
2199
+ tierName: `Начальный взнос за резерв франшизы (${quotaStatus.regionName})`,
1498
2200
  addonSeats: 1,
1499
2201
  years: 1,
1500
2202
  amountRub: depositRub,
1501
2203
  franchiseFeeRub: quotaStatus.franchiseFeeRub,
2204
+ depositDeadlineAt,
1502
2205
  invoiceNumber,
1503
2206
  contractNumber,
1504
2207
  invoiceUrl: `/api/orders/${encodeURIComponent(orderId)}/invoice`,
1505
2208
  items: [
1506
2209
  {
1507
- title: `Невозвратный залог за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, полная оплата в течение 1 года)`,
2210
+ title: `Начальный взнос (залог) за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, график погашения остатка — 12 ежемесячных платежей)`,
1508
2211
  quantity: 1,
1509
2212
  unitPriceRub: depositRub,
1510
2213
  totalRub: depositRub,
@@ -1519,7 +2222,7 @@ export class LicenseStore {
1519
2222
  status: 'pending',
1520
2223
  paymentId: '',
1521
2224
  confirmationUrl: '',
1522
- returnUrl: returnUrl || 'https://work.odant.org',
2225
+ returnUrl: returnUrl || 'https://work.odant.org/#addons',
1523
2226
  createdAt: now,
1524
2227
  paidAt: null,
1525
2228
  };
@@ -1529,7 +2232,7 @@ export class LicenseStore {
1529
2232
  this._save();
1530
2233
 
1531
2234
  return {
1532
- reservation,
2235
+ reservation: this.getFranchiseSummary(reservationId, now),
1533
2236
  order,
1534
2237
  regionQuota: this.getRegionPartnerQuotaStatus(quotaStatus.regionCode),
1535
2238
  };
@@ -1579,21 +2282,32 @@ export class LicenseStore {
1579
2282
  }
1580
2283
 
1581
2284
  /**
1582
- * Отменить неоплаченный заказ в Личном кабинете (и снять резерв региона, если это был резерв франшизы).
2285
+ * Отменить резерв франшизы (до оплаты залога — пользователем, либо в любой момент — администратором).
1583
2286
  */
1584
- cancelOrder(orderId) {
1585
- const order = this.state.orders[orderId];
1586
- if (!order)
1587
- throw new Error(`Заказ ${orderId} не найден`);
1588
- if (order.status === 'paid')
1589
- throw new Error('Оплаченный заказ отменить нельзя');
1590
- order.status = 'canceled';
1591
- order.canceledAt = Date.now();
1592
- if (order.reservationId && this.state.reservations[order.reservationId]) {
1593
- 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';
1594
2304
  }
1595
2305
  this._save();
1596
- return { ok: true, orderId };
2306
+ return {
2307
+ ok: true,
2308
+ reservationId,
2309
+ regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
2310
+ };
1597
2311
  }
1598
2312
 
1599
2313
  /**