@things-factory/saas 10.1.236 → 10.1.239

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.
Files changed (57) hide show
  1. package/dist-client/components/saas-candidates.d.ts +33 -0
  2. package/dist-client/components/saas-candidates.js +157 -0
  3. package/dist-client/components/saas-candidates.js.map +1 -0
  4. package/dist-client/index.d.ts +1 -0
  5. package/dist-client/index.js +1 -0
  6. package/dist-client/index.js.map +1 -1
  7. package/dist-client/pages/saas-account-list.js +3 -0
  8. package/dist-client/pages/saas-account-list.js.map +1 -1
  9. package/dist-client/pages/saas-account-page.js +7 -2
  10. package/dist-client/pages/saas-account-page.js.map +1 -1
  11. package/dist-client/tsconfig.tsbuildinfo +1 -1
  12. package/dist-server/role-tasks.js +3 -1
  13. package/dist-server/role-tasks.js.map +1 -1
  14. package/dist-server/service/checks.d.ts +4 -1
  15. package/dist-server/service/checks.js +5 -2
  16. package/dist-server/service/checks.js.map +1 -1
  17. package/dist-server/service/index.d.ts +4 -1
  18. package/dist-server/service/index.js +5 -1
  19. package/dist-server/service/index.js.map +1 -1
  20. package/dist-server/service/saas-bill.d.ts +1 -0
  21. package/dist-server/service/saas-bill.js +5 -0
  22. package/dist-server/service/saas-bill.js.map +1 -1
  23. package/dist-server/service/saas-hidden-vendor.d.ts +19 -0
  24. package/dist-server/service/saas-hidden-vendor.js +53 -0
  25. package/dist-server/service/saas-hidden-vendor.js.map +1 -0
  26. package/dist-server/service/saas-mutation.d.ts +8 -1
  27. package/dist-server/service/saas-mutation.js +69 -0
  28. package/dist-server/service/saas-mutation.js.map +1 -1
  29. package/dist-server/service/saas-query.d.ts +2 -1
  30. package/dist-server/service/saas-query.js +16 -0
  31. package/dist-server/service/saas-query.js.map +1 -1
  32. package/dist-server/service/saas-rules.d.ts +41 -0
  33. package/dist-server/service/saas-rules.js +49 -1
  34. package/dist-server/service/saas-rules.js.map +1 -1
  35. package/dist-server/service/saas-seat.d.ts +1 -0
  36. package/dist-server/service/saas-seat.js +5 -0
  37. package/dist-server/service/saas-seat.js.map +1 -1
  38. package/dist-server/service/saas-service.d.ts +30 -7
  39. package/dist-server/service/saas-service.js +136 -17
  40. package/dist-server/service/saas-service.js.map +1 -1
  41. package/dist-server/service/saas-subscription.d.ts +2 -0
  42. package/dist-server/service/saas-subscription.js +10 -0
  43. package/dist-server/service/saas-subscription.js.map +1 -1
  44. package/dist-server/service/saas-sync.d.ts +69 -0
  45. package/dist-server/service/saas-sync.js +166 -0
  46. package/dist-server/service/saas-sync.js.map +1 -0
  47. package/dist-server/service/saas-type.d.ts +69 -0
  48. package/dist-server/service/saas-type.js +257 -1
  49. package/dist-server/service/saas-type.js.map +1 -1
  50. package/dist-server/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +4 -4
  52. package/translations/en.json +24 -0
  53. package/translations/es.json +24 -0
  54. package/translations/ja.json +24 -0
  55. package/translations/ko.json +24 -0
  56. package/translations/ms.json +24 -0
  57. package/translations/zh.json +24 -0
@@ -12,10 +12,13 @@ exports.billsOf = billsOf;
12
12
  exports.saasBillsOf = saasBillsOf;
13
13
  exports.saasExpected = saasExpected;
14
14
  exports.saasCostTrend = saasCostTrend;
15
- exports.bankPaymentsOf = bankPaymentsOf;
15
+ exports.vendorLinesOf = vendorLinesOf;
16
16
  exports.saasPaymentsOf = saasPaymentsOf;
17
17
  exports.saasTrendsOf = saasTrendsOf;
18
18
  exports.saasChangesOf = saasChangesOf;
19
+ exports.saasCandidatesOf = saasCandidatesOf;
20
+ exports.createSaasAccountsFromCandidates = createSaasAccountsFromCandidates;
21
+ exports.hideSaasCandidates = hideSaasCandidates;
19
22
  const accounting_1 = require("@things-factory/accounting");
20
23
  const asset_1 = require("@things-factory/asset");
21
24
  /* The register grids' save, the asset module's - this module saves its grids the same way (one implementation). */
@@ -28,6 +31,7 @@ const typeorm_1 = require("typeorm");
28
31
  const saas_access_1 = require("./saas-access");
29
32
  const saas_account_1 = require("./saas-account");
30
33
  const saas_bill_1 = require("./saas-bill");
34
+ const saas_hidden_vendor_1 = require("./saas-hidden-vendor");
31
35
  const saas_rules_1 = require("./saas-rules");
32
36
  const saas_seat_1 = require("./saas-seat");
33
37
  const saas_subscription_1 = require("./saas-subscription");
@@ -63,7 +67,7 @@ const field = (patch, name, old) => (patch[name] !== undefined ? patch[name] : o
63
67
  exports.SAAS_ACCOUNT_FACT = 'saas-account';
64
68
  exports.SAAS_SUBSCRIPTION_FACT = 'saas-subscription';
65
69
  const ACCOUNT_TRACKED = ['name', 'counterpartyId', 'identifier', 'parentId', 'paymentAccountId', 'currency', 'billingDay', 'increaseAlertRate', 'status', 'closedOn'];
66
- const SUBSCRIPTION_TRACKED = ['name', 'billing', 'quantity', 'unitPrice', 'amount', 'budget', 'everyMonths', 'startOn', 'renewalOn', 'autoRenew', 'noticeDays', 'departmentId', 'assetId', 'status', 'endedOn'];
70
+ const SUBSCRIPTION_TRACKED = ['name', 'billing', 'vendorKey', 'trial', 'quantity', 'unitPrice', 'amount', 'budget', 'everyMonths', 'startOn', 'renewalOn', 'autoRenew', 'noticeDays', 'departmentId', 'assetId', 'status', 'endedOn'];
67
71
  const idsOf = (row) => Object.fromEntries(Object.entries(row).map(([key, value]) => (value && typeof value === 'object' && 'id' in value ? [`${key}Id`, value.id] : value === null && ['counterparty', 'parent', 'paymentAccount', 'department', 'asset'].includes(key) ? [`${key}Id`, null] : [key, value])));
68
72
  async function recordChanges(tx, domain, user, factKind, old, row, fields) {
69
73
  if (!old)
@@ -73,8 +77,8 @@ async function recordChanges(tx, domain, user, factKind, old, row, fields) {
73
77
  }
74
78
  /* ----------------------------------------------------------------------------------------------------------------- */
75
79
  /* Accounts (decision 2) - kept by `saas` keepers only */
76
- function saveSaasAccounts(tx, domain, user, patches) {
77
- return (0, patches_js_1.applyPatches)(tx, saas_account_1.SaasAccount, domain, user, patches, async (patch, old) => {
80
+ async function saveSaasAccounts(tx, domain, user, patches) {
81
+ const saved = await (0, patches_js_1.applyPatches)(tx, saas_account_1.SaasAccount, domain, user, patches, async (patch, old) => {
78
82
  const row = {};
79
83
  const name = (0, patches_js_1.touched)(patch, 'name', old) ? (0, patches_js_1.textOf)(patch.name) : old.name;
80
84
  if (!name)
@@ -121,6 +125,11 @@ function saveSaasAccounts(tx, domain, user, patches) {
121
125
  await recordChanges(tx, domain, user, exports.SAAS_ACCOUNT_FACT, old, row, ACCOUNT_TRACKED);
122
126
  return row;
123
127
  }, 'saas-account-not-found');
128
+ /* The vendor's lines written with its name and not linked yet are the account's months (개정 1). */
129
+ const vendors = patches.filter(one => one.cuFlag !== '-' && one.counterpartyId).map(one => one.counterpartyId);
130
+ if (vendors.length)
131
+ await (0, accounting_1.linkLinesByName)(tx, domain, user, vendors);
132
+ return saved;
124
133
  }
125
134
  /* ----------------------------------------------------------------------------------------------------------------- */
126
135
  /* Subscriptions (decision 3) - by keepers, and by the account's admins */
@@ -141,8 +150,15 @@ async function saveSaasSubscriptions(tx, domain, user, scope, accountId, patches
141
150
  for (const one of ['quantity', 'unitPrice', 'amount', 'budget'])
142
151
  if ((0, patches_js_1.touched)(patch, one, old))
143
152
  row[one] = (0, patches_js_1.amountOf)(patch[one], 'saas-amount-invalid');
153
+ /* `vendorKey` and `trial` are a sync's (개정 2) - the GraphQL patch does not carry them. */
154
+ if ((0, patches_js_1.touched)(patch, 'vendorKey', old))
155
+ row.vendorKey = (0, patches_js_1.textOf)(patch.vendorKey);
156
+ if ((0, patches_js_1.touched)(patch, 'trial', old))
157
+ row.trial = patch.trial === true;
144
158
  const value = (name) => (row[name] !== undefined ? row[name] : old?.[name]);
145
- if (billing === saas_rules_1.SaasBilling.SEAT && !(Number(value('quantity')) > 0 && value('unitPrice') !== null && value('unitPrice') !== undefined))
159
+ /* A plan read from the vendor's tenant may wait for its price (개정 2) - it is expected at nothing until then. */
160
+ const priced = value('unitPrice') !== null && value('unitPrice') !== undefined;
161
+ if (billing === saas_rules_1.SaasBilling.SEAT && !(Number(value('quantity')) > 0 && (priced || value('vendorKey'))))
146
162
  throw new shell_1.Refusal('saas-seat-price-required', {}, 'A seat plan takes its seats and the price of one');
147
163
  if ((billing === saas_rules_1.SaasBilling.FIXED || billing === saas_rules_1.SaasBilling.COMMITMENT) && (value('amount') === null || value('amount') === undefined))
148
164
  throw new shell_1.Refusal('saas-amount-required', {}, 'Give what one billing costs');
@@ -203,7 +219,8 @@ async function saveSaasSeats(tx, domain, user, scope, subscriptionId, patches) {
203
219
  const row = old ? {} : { subscription };
204
220
  if ((0, patches_js_1.touched)(patch, 'employeeId', old))
205
221
  row.employee = await oneOf(tx, organization_1.Employee, domain, patch.employeeId, 'saas-seat-employee-not-found');
206
- for (const one of ['holder', 'description'])
222
+ /* `login` is a sync's (개정 2) - the GraphQL patch does not carry it. */
223
+ for (const one of ['holder', 'login', 'description'])
207
224
  if ((0, patches_js_1.touched)(patch, one, old))
208
225
  row[one] = (0, patches_js_1.textOf)(patch[one]);
209
226
  if (!(row.employee !== undefined ? row.employee : old?.employeeId) && !(row.holder !== undefined ? row.holder : old?.holder))
@@ -254,6 +271,8 @@ async function saveSaasBill(tx, domain, user, scope, accountId, input) {
254
271
  month,
255
272
  billedOn: (0, patches_js_1.dayOf)(input.billedOn, 'saas-day-invalid'),
256
273
  description: (0, patches_js_1.textOf)(input.description),
274
+ /* Whoever writes it owns it: a person's save keeps a later sync off it (a sync marks its own, 개정 3). */
275
+ synced: false,
257
276
  updater: user
258
277
  });
259
278
  await tx.getRepository(saas_bill_1.SaasBillLine).delete({ bill: { id: bill.id } });
@@ -419,18 +438,20 @@ async function saasCostTrend(tx, domain, from, to) {
419
438
  /* ----------------------------------------------------------------------------------------------------------------- */
420
439
  /* A row's months for the list and the 360 (decision 10) and what moved on it (decision 11) */
421
440
  /**
422
- * What left the bank for an account's vendor (ADR-0177 decision 10, the user 2026-10-30: 「이테크 입금 이력이 있을텐데」)
423
- * - the bank lines (accounting, confirmed) of money out to the account's counterparty, newest first. Only lines in the
424
- * account's currency count toward its months.
441
+ * What went out to an account's vendor (ADR-0177 decision 10, the user 2026-10-30: 「이테크 입금 이력이 있을텐데」;
442
+ * 개정 1) - the bank withdrawals and the card charges (accounting, CONFIRMED and CARD) to the account's counterparty,
443
+ * newest first. A line takes its counterparty when it lands (accounting `saveTransactions`), so a vendor's lines are
444
+ * its linked ones. Only lines in the account's currency count toward its months.
425
445
  */
426
- async function bankPaymentsOf(tx, domain, counterpartyIds, from, to) {
446
+ async function vendorLinesOf(tx, domain, counterpartyIds, from, to) {
427
447
  if (!counterpartyIds.length)
428
448
  return [];
429
449
  const query = tx
430
450
  .getRepository(accounting_1.Transaction)
431
451
  .createQueryBuilder('line')
452
+ .leftJoinAndSelect('line.bankAccount', 'bankAccount')
432
453
  .where('line.domain = :domain', { domain: domain.id })
433
- .andWhere('line.category = :category', { category: accounting_1.TransactionCategory.CONFIRMED })
454
+ .andWhere('line.category IN (:...categories)', { categories: [accounting_1.TransactionCategory.CONFIRMED, accounting_1.TransactionCategory.CARD] })
434
455
  .andWhere('line.type = :type', { type: accounting_1.CashFlowType.EXPENSE })
435
456
  .andWhere('line.counterparty IN (:...parties)', { parties: counterpartyIds });
436
457
  if (from)
@@ -439,15 +460,15 @@ async function bankPaymentsOf(tx, domain, counterpartyIds, from, to) {
439
460
  query.andWhere('line.transactionDate <= :to', { to });
440
461
  return query.orderBy('line.transactionDate', 'DESC').addOrderBy('line.amount', 'DESC').getMany();
441
462
  }
442
- /** The bank lines that paid an account's vendor, every one - for its 360. */
463
+ /** The bank and card lines that paid an account's vendor, every one - for its 360. */
443
464
  async function saasPaymentsOf(tx, domain, scope, accountId) {
444
465
  const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
445
- return account.counterpartyId ? bankPaymentsOf(tx, domain, [account.counterpartyId]) : [];
466
+ return account.counterpartyId ? vendorLinesOf(tx, domain, [account.counterpartyId]) : [];
446
467
  }
447
468
  /**
448
469
  * Each account's last twelve months before this one and the next three expected (`saasExpected`). A month is its bill
449
- * when one is written; else, for a reader of the bank lines (`withBank`), what left the bank for its vendor that month
450
- * (decision 10); else empty. One read for all the accounts of a list.
470
+ * when one is written; else, for a reader of the bank lines (`withBank`), what went out to its vendor that month from
471
+ * the bank or a card (decision 10, 개정 1); else empty. One read for all the accounts of a list.
451
472
  */
452
473
  async function saasTrendsOf(tx, domain, accounts, today, withBank = false) {
453
474
  const trends = new Map();
@@ -459,7 +480,7 @@ async function saasTrendsOf(tx, domain, accounts, today, withBank = false) {
459
480
  const bills = await billsOf(tx, domain, accounts.map(one => one.id), months[0], months[months.length - 1]);
460
481
  const expected = await saasExpected(tx, domain, nextMonths[0], nextMonths[nextMonths.length - 1]);
461
482
  const parties = [...new Set(accounts.map(one => one.counterpartyId).filter(Boolean))];
462
- const paid = withBank ? await bankPaymentsOf(tx, domain, parties, `${months[0]}-01`, `${months[months.length - 1]}-31`) : [];
483
+ const paid = withBank ? await vendorLinesOf(tx, domain, parties, `${months[0]}-01`, `${months[months.length - 1]}-31`) : [];
463
484
  for (const account of accounts) {
464
485
  const own = bills.filter(one => one.accountId === account.id);
465
486
  const lines = paid.filter(line => line.counterpartyId === account.counterpartyId && (line.currency || 'KRW') === account.currency);
@@ -471,7 +492,13 @@ async function saasTrendsOf(tx, domain, accounts, today, withBank = false) {
471
492
  trends.set(account.id, {
472
493
  months,
473
494
  values,
474
- sources: months.map((month, i) => (own.some(one => one.month === month) ? 'bill' : values[i] === null ? null : 'bank')),
495
+ sources: months.map((month, i) => own.some(one => one.month === month)
496
+ ? 'bill'
497
+ : values[i] === null
498
+ ? null
499
+ : lines.some(line => line.transactionDate?.slice(0, 7) === month && line.category === accounting_1.TransactionCategory.CONFIRMED)
500
+ ? 'bank'
501
+ : 'card'),
475
502
  nextMonths,
476
503
  expected: nextMonths.map(month => expected.find(one => one.accountId === account.id && one.month === month)?.amount ?? null),
477
504
  currency: account.currency
@@ -489,4 +516,96 @@ async function saasChangesOf(tx, domain, scope, accountId) {
489
516
  ];
490
517
  return rows.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
491
518
  }
519
+ /**
520
+ * Whom the bank and card lines of the last twelve months show paid month after month that is no account yet and no
521
+ * recurring item - what a keeper may make an account of with one press. A line on a subject outside profit and loss
522
+ * (a card bill, a tax, a transfer, a loan) pays no vendor, and a person (salary, expenses) is no vendor. With `hidden`,
523
+ * the ones a keeper hid instead.
524
+ */
525
+ async function saasCandidatesOf(tx, domain, today, hidden = false) {
526
+ const now = today.slice(0, 7);
527
+ const from = `${(0, saas_rules_1.shiftMonth)(now, -12)}-01`;
528
+ const lines = await tx
529
+ .getRepository(accounting_1.Transaction)
530
+ .createQueryBuilder('line')
531
+ .leftJoinAndSelect('line.account', 'subject')
532
+ .where('line.domain = :domain', { domain: domain.id })
533
+ .andWhere('line.category IN (:...categories)', { categories: [accounting_1.TransactionCategory.CONFIRMED, accounting_1.TransactionCategory.CARD] })
534
+ .andWhere('line.type = :type', { type: accounting_1.CashFlowType.EXPENSE })
535
+ .andWhere('line.transactionDate >= :from', { from })
536
+ .getMany();
537
+ const accounts = await tx.getRepository(saas_account_1.SaasAccount).find({ where: { domain: { id: domain.id } } });
538
+ const recurring = await tx.getRepository(accounting_1.RecurringCashItem).find({ where: { domain: { id: domain.id } } });
539
+ const hiddenKeys = new Set((await tx.getRepository(saas_hidden_vendor_1.SaasHiddenVendor).find({ where: { domain: { id: domain.id } } })).map(one => one.counterpartyId ?? `name:${one.nameKey}`));
540
+ const skip = new Set([...accounts.map(one => one.counterpartyId), ...recurring.map(one => one.counterpartyId), ...(hidden ? [] : hiddenKeys)].filter(Boolean));
541
+ const parties = await tx.getRepository(biz_base_1.Counterparty).find({ where: { domain: { id: domain.id } }, select: ['id', 'name', 'aliases', 'kind'] });
542
+ const named = (0, biz_base_1.partyNamed)(parties);
543
+ const people = new Set(parties.filter(one => one.kind === biz_base_1.CounterpartyKind.PERSON).map(one => one.id));
544
+ /* Whom a line paid: its counterparty, else the one its written name is (the rule a line is linked by), else the name. */
545
+ const vendorOf = (line) => {
546
+ const id = line.counterpartyId ?? named(line.counterpartyName)?.id;
547
+ if (id)
548
+ return people.has(id) ? null : { key: id, counterpartyId: id };
549
+ const key = (0, biz_base_1.nameKey)(line.counterpartyName);
550
+ return key && !(0, biz_base_1.looksLikePersonName)(line.counterpartyName) ? { key: `name:${key}`, counterpartyId: null } : null;
551
+ };
552
+ const found = (0, saas_rules_1.candidatesOf)(lines
553
+ .filter(line => line.account?.profitAndLoss !== false)
554
+ .flatMap(line => {
555
+ const vendor = vendorOf(line);
556
+ return vendor ? [{ ...vendor, name: line.counterpartyName, transactionDate: line.transactionDate, amount: Number(line.amount), currency: line.currency, bankAccountId: line.bankAccountId }] : [];
557
+ }), skip).filter(one => hiddenKeys.has(one.key) === hidden);
558
+ return found.map(one => ({ ...one, counterpartyName: parties.find(party => party.id === one.counterpartyId)?.name ?? one.name ?? '', hidden }));
559
+ }
560
+ /**
561
+ * Make an account of each candidate - named after the vendor, paid from where it was paid most, on the day it was paid
562
+ * most - with one usage subscription budgeted at the monthly average, so the months to come are expected at it until
563
+ * bills say otherwise. A vendor the lines only name is registered as a counterparty first; the lines written with its
564
+ * name are linked to it (accounting `linkLinesByName`). One that is no candidate any more is refused.
565
+ */
566
+ async function createSaasAccountsFromCandidates(tx, domain, user, keys, today) {
567
+ const candidates = await saasCandidatesOf(tx, domain, today);
568
+ const made = [];
569
+ for (const key of [...new Set(keys)]) {
570
+ const one = candidates.find(candidate => candidate.key === key);
571
+ if (!one)
572
+ throw new shell_1.Refusal('saas-candidate-not-found', {}, 'It is no candidate any more - reload and try again');
573
+ const counterpartyId = one.counterpartyId ?? (await (0, biz_base_1.saveCounterparties)(tx, domain, user, [{ cuFlag: '+', name: one.counterpartyName, kind: biz_base_1.CounterpartyKind.COMPANY }]))[0].id;
574
+ /* Its lines written with its name and not linked yet are its months. */
575
+ await (0, accounting_1.linkLinesByName)(tx, domain, user, [counterpartyId]);
576
+ const [account] = await saveSaasAccounts(tx, domain, user, [
577
+ { cuFlag: '+', name: one.counterpartyName, counterpartyId, paymentAccountId: one.paymentAccountId, currency: one.currency, billingDay: one.billingDay, status: saas_rules_1.SaasAccountStatus.ACTIVE }
578
+ ]);
579
+ await saveSaasSubscriptions(tx, domain, user, 'all', account.id, [
580
+ { cuFlag: '+', name: one.counterpartyName, billing: saas_rules_1.SaasBilling.USAGE, budget: one.average, startOn: one.firstDate, status: saas_rules_1.SaasSubscriptionStatus.ACTIVE }
581
+ ]);
582
+ made.push(account);
583
+ }
584
+ return made;
585
+ }
586
+ /** Hide candidates - by their keys - or show them again. */
587
+ async function hideSaasCandidates(tx, domain, user, keys, hide) {
588
+ const repo = tx.getRepository(saas_hidden_vendor_1.SaasHiddenVendor);
589
+ const wanted = [...new Set(keys)].filter(Boolean);
590
+ const names = wanted.filter(key => key.startsWith('name:')).map(key => key.slice(5));
591
+ const ids = wanted.filter(key => !key.startsWith('name:'));
592
+ const parties = ids.length ? await tx.getRepository(biz_base_1.Counterparty).find({ where: { domain: { id: domain.id }, id: (0, typeorm_1.In)(ids) } }) : [];
593
+ if (parties.length !== ids.length)
594
+ throw new shell_1.Refusal('saas-counterparty-not-found', {}, 'The vendor was not found - reload and try again');
595
+ const rows = await repo.find({ where: { domain: { id: domain.id } } });
596
+ const held = (row) => (row.counterpartyId ? ids.includes(row.counterpartyId) : names.includes(row.nameKey));
597
+ if (!hide) {
598
+ const gone = rows.filter(held);
599
+ if (gone.length)
600
+ await repo.remove(gone);
601
+ return true;
602
+ }
603
+ for (const party of parties)
604
+ if (!rows.some(row => row.counterpartyId === party.id))
605
+ await repo.save({ domain, counterparty: party, creator: user });
606
+ for (const key of names)
607
+ if (!rows.some(row => !row.counterpartyId && row.nameKey === key))
608
+ await repo.save({ domain, nameKey: key, creator: user });
609
+ return true;
610
+ }
492
611
  //# sourceMappingURL=saas-service.js.map