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/assets/admin.js +602 -299
- package/assets/landing.js +541 -69
- package/package.json +1 -1
- package/src/admin.js +279 -182
- package/src/crypto.js +32 -3
- package/src/landing.js +38 -30
- package/src/server.js +121 -4
- package/src/store.js +810 -58
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
|
-
|
|
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:
|
|
243
|
-
ownerEmail:
|
|
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
|
-
|
|
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
|
|
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
|
|
1018
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
* Проверить токен
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1547
|
-
const
|
|
1548
|
-
if (!
|
|
1549
|
-
throw new Error(
|
|
1550
|
-
if (
|
|
1551
|
-
throw new Error('
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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 {
|
|
2306
|
+
return {
|
|
2307
|
+
ok: true,
|
|
2308
|
+
reservationId,
|
|
2309
|
+
regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
|
|
2310
|
+
};
|
|
1559
2311
|
}
|
|
1560
2312
|
|
|
1561
2313
|
/**
|