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/assets/admin.js +603 -311
- package/assets/landing.css +150 -0
- package/assets/landing.js +504 -71
- package/package.json +1 -1
- package/src/admin.js +241 -165
- package/src/crypto.js +4 -0
- package/src/landing.js +32 -36
- package/src/server.js +120 -4
- package/src/store.js +771 -57
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
|
|
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:
|
|
281
|
-
ownerEmail:
|
|
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
|
-
|
|
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
|
|
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
|
|
1056
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
* Проверить токен
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1585
|
-
const
|
|
1586
|
-
if (!
|
|
1587
|
-
throw new Error(
|
|
1588
|
-
if (
|
|
1589
|
-
throw new Error('
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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 {
|
|
2306
|
+
return {
|
|
2307
|
+
ok: true,
|
|
2308
|
+
reservationId,
|
|
2309
|
+
regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
|
|
2310
|
+
};
|
|
1597
2311
|
}
|
|
1598
2312
|
|
|
1599
2313
|
/**
|