@things-factory/saas 0.0.0-stage → 10.1.236

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 (83) hide show
  1. package/README.md +19 -2
  2. package/dist-client/bootstrap.d.ts +1 -0
  3. package/dist-client/bootstrap.js +5 -0
  4. package/dist-client/bootstrap.js.map +1 -0
  5. package/dist-client/components/saas-bill-editor.d.ts +59 -0
  6. package/dist-client/components/saas-bill-editor.js +211 -0
  7. package/dist-client/components/saas-bill-editor.js.map +1 -0
  8. package/dist-client/entity360/saas-account-360.d.ts +12 -0
  9. package/dist-client/entity360/saas-account-360.js +231 -0
  10. package/dist-client/entity360/saas-account-360.js.map +1 -0
  11. package/dist-client/index.d.ts +3 -0
  12. package/dist-client/index.js +4 -0
  13. package/dist-client/index.js.map +1 -0
  14. package/dist-client/pages/saas-account-list.d.ts +57 -0
  15. package/dist-client/pages/saas-account-list.js +175 -0
  16. package/dist-client/pages/saas-account-list.js.map +1 -0
  17. package/dist-client/pages/saas-account-page.d.ts +54 -0
  18. package/dist-client/pages/saas-account-page.js +352 -0
  19. package/dist-client/pages/saas-account-page.js.map +1 -0
  20. package/dist-client/pages/saas-cost-trend.d.ts +23 -0
  21. package/dist-client/pages/saas-cost-trend.js +219 -0
  22. package/dist-client/pages/saas-cost-trend.js.map +1 -0
  23. package/dist-client/route.d.ts +1 -0
  24. package/dist-client/route.js +16 -0
  25. package/dist-client/route.js.map +1 -0
  26. package/dist-client/tsconfig.tsbuildinfo +1 -0
  27. package/dist-client/words.d.ts +47 -0
  28. package/dist-client/words.js +39 -0
  29. package/dist-client/words.js.map +1 -0
  30. package/dist-server/index.d.ts +1 -0
  31. package/dist-server/index.js +9 -0
  32. package/dist-server/index.js.map +1 -0
  33. package/dist-server/role-tasks.d.ts +2 -0
  34. package/dist-server/role-tasks.js +16 -0
  35. package/dist-server/role-tasks.js.map +1 -0
  36. package/dist-server/service/checks.d.ts +32 -0
  37. package/dist-server/service/checks.js +83 -0
  38. package/dist-server/service/checks.js.map +1 -0
  39. package/dist-server/service/index.d.ts +20 -0
  40. package/dist-server/service/index.js +38 -0
  41. package/dist-server/service/index.js.map +1 -0
  42. package/dist-server/service/saas-access.d.ts +20 -0
  43. package/dist-server/service/saas-access.js +36 -0
  44. package/dist-server/service/saas-access.js.map +1 -0
  45. package/dist-server/service/saas-account.d.ts +43 -0
  46. package/dist-server/service/saas-account.js +166 -0
  47. package/dist-server/service/saas-account.js.map +1 -0
  48. package/dist-server/service/saas-bill.d.ts +41 -0
  49. package/dist-server/service/saas-bill.js +160 -0
  50. package/dist-server/service/saas-bill.js.map +1 -0
  51. package/dist-server/service/saas-mutation.d.ts +12 -0
  52. package/dist-server/service/saas-mutation.js +103 -0
  53. package/dist-server/service/saas-mutation.js.map +1 -0
  54. package/dist-server/service/saas-query.d.ts +69 -0
  55. package/dist-server/service/saas-query.js +215 -0
  56. package/dist-server/service/saas-query.js.map +1 -0
  57. package/dist-server/service/saas-rules.d.ts +138 -0
  58. package/dist-server/service/saas-rules.js +217 -0
  59. package/dist-server/service/saas-rules.js.map +1 -0
  60. package/dist-server/service/saas-seat.d.ts +25 -0
  61. package/dist-server/service/saas-seat.js +105 -0
  62. package/dist-server/service/saas-seat.js.map +1 -0
  63. package/dist-server/service/saas-service.d.ts +119 -0
  64. package/dist-server/service/saas-service.js +492 -0
  65. package/dist-server/service/saas-service.js.map +1 -0
  66. package/dist-server/service/saas-subscription.d.ts +44 -0
  67. package/dist-server/service/saas-subscription.js +174 -0
  68. package/dist-server/service/saas-subscription.js.map +1 -0
  69. package/dist-server/service/saas-trend-type.d.ts +9 -0
  70. package/dist-server/service/saas-trend-type.js +37 -0
  71. package/dist-server/service/saas-trend-type.js.map +1 -0
  72. package/dist-server/service/saas-type.d.ts +113 -0
  73. package/dist-server/service/saas-type.js +430 -0
  74. package/dist-server/service/saas-type.js.map +1 -0
  75. package/dist-server/tsconfig.tsbuildinfo +1 -0
  76. package/package.json +49 -4
  77. package/things-factory.config.js +13 -0
  78. package/translations/en.json +185 -0
  79. package/translations/es.json +185 -0
  80. package/translations/ja.json +185 -0
  81. package/translations/ko.json +185 -0
  82. package/translations/ms.json +185 -0
  83. package/translations/zh.json +185 -0
@@ -0,0 +1,119 @@
1
+ import { Transaction } from '@things-factory/accounting';
2
+ import { Patch } from '@things-factory/asset/dist-server/service/patches.js';
3
+ import { Domain } from '@things-factory/shell';
4
+ import { EntityManager } from 'typeorm';
5
+ import { SaasScope } from './saas-access';
6
+ import { SaasAccount } from './saas-account';
7
+ import { SaasBill } from './saas-bill';
8
+ import { SaasSeat } from './saas-seat';
9
+ import { SaasSubscription } from './saas-subscription';
10
+ import { SaasTrend } from './saas-trend-type';
11
+ export declare const SAAS_ACCOUNT_FACT = "saas-account";
12
+ export declare const SAAS_SUBSCRIPTION_FACT = "saas-subscription";
13
+ export declare function saveSaasAccounts(tx: EntityManager, domain: Domain, user: any, patches: Patch[]): Promise<SaasAccount[]>;
14
+ export declare function saveSaasSubscriptions(tx: EntityManager, domain: Domain, user: any, scope: SaasScope, accountId: string, patches: Patch[]): Promise<SaasSubscription[]>;
15
+ /** The subscriptions of an account, with the seats held today. */
16
+ export declare function saasSubscriptionsOf(tx: EntityManager, domain: Domain, scope: SaasScope, accountId: string, today: string): Promise<SaasSubscription[]>;
17
+ export declare function saveSaasSeats(tx: EntityManager, domain: Domain, user: any, scope: SaasScope, subscriptionId: string, patches: Patch[]): Promise<SaasSeat[]>;
18
+ export declare function saasSeatsOf(tx: EntityManager, domain: Domain, scope: SaasScope, subscriptionId: string): Promise<SaasSeat[]>;
19
+ export interface SaasBillFields {
20
+ id?: string | null;
21
+ month: string;
22
+ billedOn?: string | null;
23
+ description?: string | null;
24
+ lines: {
25
+ subscriptionId?: string | null;
26
+ label?: string | null;
27
+ amount: number;
28
+ }[];
29
+ }
30
+ export declare function saveSaasBill(tx: EntityManager, domain: Domain, user: any, scope: SaasScope, accountId: string, input: SaasBillFields): Promise<SaasBill>;
31
+ export declare function deleteSaasBill(tx: EntityManager, domain: Domain, scope: SaasScope, id: string): Promise<boolean>;
32
+ /** The bills of some accounts (all when none named) from a month on, with their lines and totals, latest first. */
33
+ export declare function billsOf(tx: EntityManager, domain: Domain, accountIds?: string[], from?: string, to?: string): Promise<SaasBill[]>;
34
+ export declare function saasBillsOf(tx: EntityManager, domain: Domain, scope: SaasScope, accountId: string): Promise<SaasBill[]>;
35
+ export interface SaasExpectedRow {
36
+ accountId: string;
37
+ accountName: string;
38
+ counterpartyId?: string | null;
39
+ paymentAccountId?: string | null;
40
+ month: string;
41
+ day: string | null;
42
+ amount: number;
43
+ currency: string;
44
+ /** A bill is written for the month: the amount is the bill's, not an estimate. */
45
+ billed: boolean;
46
+ }
47
+ /**
48
+ * What each account is expected to be billed, month by month from `from` to `to`: the bill when one is written for the
49
+ * month, else what its subscriptions say (usage at the average of its latest bills).
50
+ */
51
+ export declare function saasExpected(tx: EntityManager, domain: Domain, from: string, to: string): Promise<SaasExpectedRow[]>;
52
+ /**
53
+ * The bills of the months from `from` to `to` as series - all, an account, a subscription - and a department series: a
54
+ * line of a seat plan split by the seats held at the month's end by department, any other line its subscription's
55
+ * department. Plus what the latest month's bills are worth telling.
56
+ */
57
+ export declare function saasCostTrend(tx: EntityManager, domain: Domain, from: string, to: string): Promise<{
58
+ months: string[];
59
+ all: import("./saas-rules").TrendSeries[];
60
+ accounts: {
61
+ name: string;
62
+ key: string;
63
+ accountId?: string | null;
64
+ subscriptionId?: string | null;
65
+ currency: string;
66
+ amounts: number[];
67
+ total: number;
68
+ monthOverMonth: number | null;
69
+ yearOverYear: number | null;
70
+ }[];
71
+ subscriptions: {
72
+ name: string;
73
+ accountName: string;
74
+ key: string;
75
+ accountId?: string | null;
76
+ subscriptionId?: string | null;
77
+ currency: string;
78
+ amounts: number[];
79
+ total: number;
80
+ monthOverMonth: number | null;
81
+ yearOverYear: number | null;
82
+ }[];
83
+ departments: {
84
+ total: number;
85
+ key: string;
86
+ departmentId: string | null;
87
+ name: string | null;
88
+ currency: string;
89
+ amounts: number[];
90
+ }[];
91
+ alerts: {
92
+ accountName: string;
93
+ subscriptionName: string;
94
+ accountId: string;
95
+ month: string;
96
+ kind: import("./saas-rules").SaasAlertKind;
97
+ subscriptionId?: string;
98
+ amount: number;
99
+ limit: number;
100
+ }[];
101
+ }>;
102
+ /**
103
+ * What left the bank for an account's vendor (ADR-0177 decision 10, the user 2026-10-30: 「이테크 입금 이력이 있을텐데」)
104
+ * - the bank lines (accounting, confirmed) of money out to the account's counterparty, newest first. Only lines in the
105
+ * account's currency count toward its months.
106
+ */
107
+ export declare function bankPaymentsOf(tx: EntityManager, domain: Domain, counterpartyIds: string[], from?: string, to?: string): Promise<Transaction[]>;
108
+ /** The bank lines that paid an account's vendor, every one - for its 360. */
109
+ export declare function saasPaymentsOf(tx: EntityManager, domain: Domain, scope: SaasScope, accountId: string): Promise<any[] | Transaction[]>;
110
+ /**
111
+ * Each account's last twelve months before this one and the next three expected (`saasExpected`). A month is its bill
112
+ * when one is written; else, for a reader of the bank lines (`withBank`), what left the bank for its vendor that month
113
+ * (decision 10); else empty. One read for all the accounts of a list.
114
+ */
115
+ export declare function saasTrendsOf(tx: EntityManager, domain: Domain, accounts: SaasAccount[], today: string, withBank?: boolean): Promise<Map<string, SaasTrend>>;
116
+ /** What moved on an account and on each of its subscriptions, newest first. */
117
+ export declare function saasChangesOf(tx: EntityManager, domain: Domain, scope: SaasScope, accountId: string): Promise<(import("@things-factory/fact-change").FactChange & {
118
+ subject: string;
119
+ })[]>;
@@ -0,0 +1,492 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SAAS_SUBSCRIPTION_FACT = exports.SAAS_ACCOUNT_FACT = void 0;
4
+ exports.saveSaasAccounts = saveSaasAccounts;
5
+ exports.saveSaasSubscriptions = saveSaasSubscriptions;
6
+ exports.saasSubscriptionsOf = saasSubscriptionsOf;
7
+ exports.saveSaasSeats = saveSaasSeats;
8
+ exports.saasSeatsOf = saasSeatsOf;
9
+ exports.saveSaasBill = saveSaasBill;
10
+ exports.deleteSaasBill = deleteSaasBill;
11
+ exports.billsOf = billsOf;
12
+ exports.saasBillsOf = saasBillsOf;
13
+ exports.saasExpected = saasExpected;
14
+ exports.saasCostTrend = saasCostTrend;
15
+ exports.bankPaymentsOf = bankPaymentsOf;
16
+ exports.saasPaymentsOf = saasPaymentsOf;
17
+ exports.saasTrendsOf = saasTrendsOf;
18
+ exports.saasChangesOf = saasChangesOf;
19
+ const accounting_1 = require("@things-factory/accounting");
20
+ const asset_1 = require("@things-factory/asset");
21
+ /* The register grids' save, the asset module's - this module saves its grids the same way (one implementation). */
22
+ const patches_js_1 = require("@things-factory/asset/dist-server/service/patches.js");
23
+ const biz_base_1 = require("@things-factory/biz-base");
24
+ const fact_change_1 = require("@things-factory/fact-change");
25
+ const organization_1 = require("@things-factory/organization");
26
+ const shell_1 = require("@things-factory/shell");
27
+ const typeorm_1 = require("typeorm");
28
+ const saas_access_1 = require("./saas-access");
29
+ const saas_account_1 = require("./saas-account");
30
+ const saas_bill_1 = require("./saas-bill");
31
+ const saas_rules_1 = require("./saas-rules");
32
+ const saas_seat_1 = require("./saas-seat");
33
+ const saas_subscription_1 = require("./saas-subscription");
34
+ const currencyOf = (value, fallback) => {
35
+ const text = String(value ?? '').trim().toUpperCase();
36
+ if (!text)
37
+ return fallback;
38
+ if (!/^[A-Z]{3}$/.test(text))
39
+ throw new shell_1.Refusal('saas-currency-invalid', { value: text }, `'${text}' is not a currency code (KRW, USD)`);
40
+ return text;
41
+ };
42
+ const intOf = (value, code, min, max) => {
43
+ if (value === null || value === undefined || value === '')
44
+ return null;
45
+ const number = Number(value);
46
+ if (!Number.isInteger(number) || number < min || number > max)
47
+ throw new shell_1.Refusal(code, { min, max }, `Give a whole number from ${min} to ${max}`);
48
+ return number;
49
+ };
50
+ async function oneOf(tx, target, domain, id, code) {
51
+ if (!id)
52
+ return null;
53
+ const found = await tx.getRepository(target).findOne({ where: { domain: { id: domain.id }, id } });
54
+ if (!found)
55
+ throw new shell_1.Refusal(code, {}, 'What it points at was not found');
56
+ return found;
57
+ }
58
+ const field = (patch, name, old) => (patch[name] !== undefined ? patch[name] : old?.[name]);
59
+ /*
60
+ * What moved on an account or a subscription, field by field (ADR-0177 decision 11) - the fact-change module keeps it.
61
+ * A row's relations are told by their id; a new row has no history yet.
62
+ */
63
+ exports.SAAS_ACCOUNT_FACT = 'saas-account';
64
+ exports.SAAS_SUBSCRIPTION_FACT = 'saas-subscription';
65
+ 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'];
67
+ 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
+ async function recordChanges(tx, domain, user, factKind, old, row, fields) {
69
+ if (!old)
70
+ return;
71
+ const after = { ...old, ...idsOf(row) };
72
+ await (0, fact_change_1.recordFactChanges)(tx, domain, { factKind, factName: old.id, changes: (0, fact_change_1.fieldChanges)(old, after, fields) }, { user, source: 'saas' });
73
+ }
74
+ /* ----------------------------------------------------------------------------------------------------------------- */
75
+ /* 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) => {
78
+ const row = {};
79
+ const name = (0, patches_js_1.touched)(patch, 'name', old) ? (0, patches_js_1.textOf)(patch.name) : old.name;
80
+ if (!name)
81
+ throw new shell_1.Refusal('saas-account-name-required', {}, 'Name the service');
82
+ row.name = name;
83
+ for (const one of ['identifier', 'purpose', 'billingEmail', 'secretLocation', 'description'])
84
+ if ((0, patches_js_1.touched)(patch, one, old))
85
+ row[one] = (0, patches_js_1.textOf)(patch[one]);
86
+ if ((0, patches_js_1.touched)(patch, 'currency', old))
87
+ row.currency = currencyOf(patch.currency, 'KRW');
88
+ if ((0, patches_js_1.touched)(patch, 'billingDay', old))
89
+ row.billingDay = intOf(patch.billingDay, 'saas-billing-day-invalid', 1, 31);
90
+ if ((0, patches_js_1.touched)(patch, 'increaseAlertRate', old)) {
91
+ const rate = (0, patches_js_1.amountOf)(patch.increaseAlertRate, 'saas-alert-rate-invalid');
92
+ if (rate !== null && rate > 10)
93
+ throw new shell_1.Refusal('saas-alert-rate-invalid', {}, 'Give the rate as a part - 0.2 for 20%');
94
+ row.increaseAlertRate = rate;
95
+ }
96
+ if ((0, patches_js_1.touched)(patch, 'counterpartyId', old))
97
+ row.counterparty = await oneOf(tx, biz_base_1.Counterparty, domain, patch.counterpartyId, 'saas-counterparty-not-found');
98
+ if ((0, patches_js_1.touched)(patch, 'paymentAccountId', old))
99
+ row.paymentAccount = await oneOf(tx, accounting_1.BankAccount, domain, patch.paymentAccountId, 'saas-payment-account-not-found');
100
+ if ((0, patches_js_1.touched)(patch, 'parentId', old)) {
101
+ const parent = await oneOf(tx, saas_account_1.SaasAccount, domain, patch.parentId, 'saas-parent-not-found');
102
+ if (parent && (parent.id === old?.id || (await tx.getRepository(saas_account_1.SaasAccount).findOne({ where: { id: parent.id }, relations: ['parent'] }))?.parent))
103
+ throw new shell_1.Refusal('saas-parent-invalid', {}, 'An account is under a top account only, and not under itself');
104
+ if (parent && old && (await tx.getRepository(saas_subscription_1.SaasSubscription).count({ where: { account: { id: old.id } } })))
105
+ throw new shell_1.Refusal('saas-parent-has-subscriptions', {}, 'An account with subscriptions is billed itself - it cannot go under another');
106
+ row.parent = parent;
107
+ }
108
+ if ((0, patches_js_1.touched)(patch, 'adminIds', old)) {
109
+ const ids = [...new Set((patch.adminIds ?? []).filter(Boolean))];
110
+ const admins = ids.length ? await tx.getRepository(organization_1.Employee).find({ where: { id: (0, typeorm_1.In)(ids), domain: { id: domain.id } } }) : [];
111
+ if (admins.length !== ids.length)
112
+ throw new shell_1.Refusal('saas-admin-not-found', {}, 'An admin is no employee of this domain');
113
+ row.admins = admins;
114
+ }
115
+ if ((0, patches_js_1.touched)(patch, 'status', old))
116
+ row.status = patch.status ?? saas_rules_1.SaasAccountStatus.ACTIVE;
117
+ if ((0, patches_js_1.touched)(patch, 'closedOn', old))
118
+ row.closedOn = (0, patches_js_1.dayOf)(patch.closedOn, 'saas-day-invalid');
119
+ if ((row.status ?? old?.status) === saas_rules_1.SaasAccountStatus.CLOSED && !(row.closedOn !== undefined ? row.closedOn : old?.closedOn))
120
+ throw new shell_1.Refusal('saas-closed-on-required', {}, 'Give the day it was closed');
121
+ await recordChanges(tx, domain, user, exports.SAAS_ACCOUNT_FACT, old, row, ACCOUNT_TRACKED);
122
+ return row;
123
+ }, 'saas-account-not-found');
124
+ }
125
+ /* ----------------------------------------------------------------------------------------------------------------- */
126
+ /* Subscriptions (decision 3) - by keepers, and by the account's admins */
127
+ async function saveSaasSubscriptions(tx, domain, user, scope, accountId, patches) {
128
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
129
+ if (account.parentId && patches.some(one => one.cuFlag !== '-'))
130
+ throw new shell_1.Refusal('saas-subscription-on-sub-account', {}, 'An account under another is billed by its parent - subscribe there');
131
+ return (0, patches_js_1.applyPatches)(tx, saas_subscription_1.SaasSubscription, domain, user, patches, async (patch, old) => {
132
+ const row = old ? {} : { account };
133
+ const name = (0, patches_js_1.touched)(patch, 'name', old) ? (0, patches_js_1.textOf)(patch.name) : old.name;
134
+ if (!name)
135
+ throw new shell_1.Refusal('saas-subscription-name-required', {}, 'Name the product or plan');
136
+ row.name = name;
137
+ const billing = (field(patch, 'billing', old) ?? saas_rules_1.SaasBilling.FIXED);
138
+ if (!Object.values(saas_rules_1.SaasBilling).includes(billing))
139
+ throw new shell_1.Refusal('saas-billing-invalid', {}, 'Say how it is paid for');
140
+ row.billing = billing;
141
+ for (const one of ['quantity', 'unitPrice', 'amount', 'budget'])
142
+ if ((0, patches_js_1.touched)(patch, one, old))
143
+ row[one] = (0, patches_js_1.amountOf)(patch[one], 'saas-amount-invalid');
144
+ 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))
146
+ throw new shell_1.Refusal('saas-seat-price-required', {}, 'A seat plan takes its seats and the price of one');
147
+ if ((billing === saas_rules_1.SaasBilling.FIXED || billing === saas_rules_1.SaasBilling.COMMITMENT) && (value('amount') === null || value('amount') === undefined))
148
+ throw new shell_1.Refusal('saas-amount-required', {}, 'Give what one billing costs');
149
+ if ((0, patches_js_1.touched)(patch, 'everyMonths', old))
150
+ row.everyMonths = intOf(patch.everyMonths, 'saas-every-months-invalid', 1, 120) ?? 1;
151
+ row.startOn = (0, patches_js_1.touched)(patch, 'startOn', old) ? (0, patches_js_1.dayOf)(patch.startOn, 'saas-start-on-invalid', true) : old.startOn;
152
+ if ((0, patches_js_1.touched)(patch, 'renewalOn', old))
153
+ row.renewalOn = (0, patches_js_1.dayOf)(patch.renewalOn, 'saas-day-invalid');
154
+ if ((0, patches_js_1.touched)(patch, 'endedOn', old))
155
+ row.endedOn = (0, patches_js_1.dayOf)(patch.endedOn, 'saas-day-invalid');
156
+ if ((0, patches_js_1.touched)(patch, 'autoRenew', old))
157
+ row.autoRenew = patch.autoRenew !== false;
158
+ if ((0, patches_js_1.touched)(patch, 'noticeDays', old))
159
+ row.noticeDays = intOf(patch.noticeDays, 'saas-notice-days-invalid', 0, 365);
160
+ if ((0, patches_js_1.touched)(patch, 'description', old))
161
+ row.description = (0, patches_js_1.textOf)(patch.description);
162
+ if ((0, patches_js_1.touched)(patch, 'departmentId', old))
163
+ row.department = await oneOf(tx, organization_1.Department, domain, patch.departmentId, 'saas-department-not-found');
164
+ if ((0, patches_js_1.touched)(patch, 'assetId', old))
165
+ row.asset = await oneOf(tx, asset_1.Asset, domain, patch.assetId, 'saas-asset-not-found');
166
+ if (billing === saas_rules_1.SaasBilling.OWNED && !(row.asset !== undefined ? row.asset : old?.assetId))
167
+ throw new shell_1.Refusal('saas-owned-asset-required', {}, 'A license owned points at its asset in the register - its cost is there');
168
+ if (billing === saas_rules_1.SaasBilling.COMMITMENT && !value('renewalOn'))
169
+ throw new shell_1.Refusal('saas-term-end-required', {}, 'A commitment takes the day its term ends');
170
+ if ((0, patches_js_1.touched)(patch, 'status', old))
171
+ row.status = patch.status ?? saas_rules_1.SaasSubscriptionStatus.ACTIVE;
172
+ const status = row.status ?? old?.status ?? saas_rules_1.SaasSubscriptionStatus.ACTIVE;
173
+ if (status === saas_rules_1.SaasSubscriptionStatus.CANCELLED && !value('endedOn'))
174
+ throw new shell_1.Refusal('saas-ended-on-required', {}, 'Give the day it ended');
175
+ if (status === saas_rules_1.SaasSubscriptionStatus.CANCELLING && !value('renewalOn'))
176
+ throw new shell_1.Refusal('saas-renewal-on-required', {}, 'Not renewing needs the day it would renew');
177
+ if (value('endedOn') && value('endedOn') < row.startOn)
178
+ throw new shell_1.Refusal('saas-ended-before-start', {}, 'It ended before it started');
179
+ await recordChanges(tx, domain, user, exports.SAAS_SUBSCRIPTION_FACT, old, row, SUBSCRIPTION_TRACKED);
180
+ return row;
181
+ }, 'saas-subscription-not-found', { account: { id: account.id } });
182
+ }
183
+ /** The subscriptions of an account, with the seats held today. */
184
+ async function saasSubscriptionsOf(tx, domain, scope, accountId, today) {
185
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
186
+ const subs = await tx.getRepository(saas_subscription_1.SaasSubscription).find({ where: { domain: { id: domain.id }, account: { id: account.id } }, relations: ['department', 'asset'], order: { name: 'ASC' } });
187
+ const seats = subs.length ? await tx.getRepository(saas_seat_1.SaasSeat).find({ where: { domain: { id: domain.id }, subscription: { id: (0, typeorm_1.In)(subs.map(one => one.id)) } } }) : [];
188
+ for (const sub of subs)
189
+ sub.seatsHeld = seats.filter(seat => seat.subscriptionId === sub.id && (0, saas_rules_1.seatHeld)(seat, today)).length;
190
+ return subs;
191
+ }
192
+ /* ----------------------------------------------------------------------------------------------------------------- */
193
+ /* Seats (decision 6) */
194
+ async function subscriptionIn(tx, domain, scope, subscriptionId) {
195
+ const sub = subscriptionId ? await tx.getRepository(saas_subscription_1.SaasSubscription).findOne({ where: { domain: { id: domain.id }, id: subscriptionId } }) : null;
196
+ if (!sub || (scope !== 'all' && !scope.has(sub.accountId)))
197
+ throw new shell_1.Refusal('saas-subscription-not-found', {}, 'The subscription was not found - reload and try again');
198
+ return sub;
199
+ }
200
+ async function saveSaasSeats(tx, domain, user, scope, subscriptionId, patches) {
201
+ const subscription = await subscriptionIn(tx, domain, scope, subscriptionId);
202
+ return (0, patches_js_1.applyPatches)(tx, saas_seat_1.SaasSeat, domain, user, patches, async (patch, old) => {
203
+ const row = old ? {} : { subscription };
204
+ if ((0, patches_js_1.touched)(patch, 'employeeId', old))
205
+ row.employee = await oneOf(tx, organization_1.Employee, domain, patch.employeeId, 'saas-seat-employee-not-found');
206
+ for (const one of ['holder', 'description'])
207
+ if ((0, patches_js_1.touched)(patch, one, old))
208
+ row[one] = (0, patches_js_1.textOf)(patch[one]);
209
+ if (!(row.employee !== undefined ? row.employee : old?.employeeId) && !(row.holder !== undefined ? row.holder : old?.holder))
210
+ throw new shell_1.Refusal('saas-seat-holder-required', {}, 'Say who holds the seat - an employee, or a name');
211
+ if ((0, patches_js_1.touched)(patch, 'fromOn', old))
212
+ row.fromOn = (0, patches_js_1.dayOf)(patch.fromOn, 'saas-day-invalid');
213
+ if ((0, patches_js_1.touched)(patch, 'untilOn', old))
214
+ row.untilOn = (0, patches_js_1.dayOf)(patch.untilOn, 'saas-day-invalid');
215
+ const from = row.fromOn !== undefined ? row.fromOn : old?.fromOn;
216
+ const until = row.untilOn !== undefined ? row.untilOn : old?.untilOn;
217
+ if (from && until && until < from)
218
+ throw new shell_1.Refusal('saas-seat-until-before-from', {}, 'It was taken back before it was given');
219
+ return row;
220
+ }, 'saas-seat-not-found', { subscription: { id: subscription.id } });
221
+ }
222
+ async function saasSeatsOf(tx, domain, scope, subscriptionId) {
223
+ const sub = await subscriptionIn(tx, domain, scope, subscriptionId);
224
+ return tx.getRepository(saas_seat_1.SaasSeat).find({ where: { domain: { id: domain.id }, subscription: { id: sub.id } }, relations: ['employee'], order: { fromOn: 'DESC' } });
225
+ }
226
+ async function saveSaasBill(tx, domain, user, scope, accountId, input) {
227
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
228
+ if (account.parentId)
229
+ throw new shell_1.Refusal('saas-bill-on-sub-account', {}, 'An account under another is billed by its parent');
230
+ const month = String(input.month ?? '').trim();
231
+ if (!saas_rules_1.MONTH.test(month))
232
+ throw new shell_1.Refusal('saas-month-invalid', { value: month }, 'Write the month as YYYY-MM');
233
+ const bills = tx.getRepository(saas_bill_1.SaasBill);
234
+ const old = input.id ? await bills.findOne({ where: { domain: { id: domain.id }, id: input.id, account: { id: account.id } } }) : null;
235
+ if (input.id && !old)
236
+ throw new shell_1.Refusal('saas-bill-not-found', {}, 'The bill was not found - reload and try again');
237
+ const twin = await bills.findOne({ where: { domain: { id: domain.id }, account: { id: account.id }, month } });
238
+ if (twin && twin.id !== old?.id)
239
+ throw new shell_1.Refusal('saas-bill-month-taken', { month }, `The account already has a bill for ${month} - change that one`);
240
+ const lines = (input.lines ?? []).filter(line => line && (line.subscriptionId || (0, patches_js_1.textOf)(line.label) || Number(line.amount)));
241
+ if (!lines.length)
242
+ throw new shell_1.Refusal('saas-bill-lines-required', {}, 'A bill takes at least one line');
243
+ const subIds = [...new Set(lines.map(line => line.subscriptionId).filter(Boolean))];
244
+ if (subIds.length) {
245
+ const found = await tx.getRepository(saas_subscription_1.SaasSubscription).count({ where: { domain: { id: domain.id }, account: { id: account.id }, id: (0, typeorm_1.In)(subIds) } });
246
+ if (found !== subIds.length)
247
+ throw new shell_1.Refusal('saas-bill-subscription-not-found', {}, 'A line names a subscription not of this account');
248
+ }
249
+ for (const line of lines)
250
+ if (!Number.isFinite(Number(line.amount)))
251
+ throw new shell_1.Refusal('saas-amount-invalid', {}, 'Give the amount as a number');
252
+ const bill = await bills.save({
253
+ ...(old ?? { domain, account, creator: user, currency: account.currency }),
254
+ month,
255
+ billedOn: (0, patches_js_1.dayOf)(input.billedOn, 'saas-day-invalid'),
256
+ description: (0, patches_js_1.textOf)(input.description),
257
+ updater: user
258
+ });
259
+ await tx.getRepository(saas_bill_1.SaasBillLine).delete({ bill: { id: bill.id } });
260
+ await tx.getRepository(saas_bill_1.SaasBillLine).save(lines.map(line => ({ domain, bill: { id: bill.id }, subscription: line.subscriptionId ? { id: line.subscriptionId } : null, label: (0, patches_js_1.textOf)(line.label), amount: Number(line.amount) })));
261
+ return billsOf(tx, domain, [account.id]).then(all => all.find(one => one.id === bill.id));
262
+ }
263
+ async function deleteSaasBill(tx, domain, scope, id) {
264
+ const bill = await tx.getRepository(saas_bill_1.SaasBill).findOne({ where: { domain: { id: domain.id }, id } });
265
+ if (!bill || (scope !== 'all' && !scope.has(bill.accountId)))
266
+ throw new shell_1.Refusal('saas-bill-not-found', {}, 'The bill was not found - reload and try again');
267
+ await tx.getRepository(saas_bill_1.SaasBill).softDelete({ id });
268
+ return true;
269
+ }
270
+ /** The bills of some accounts (all when none named) from a month on, with their lines and totals, latest first. */
271
+ async function billsOf(tx, domain, accountIds, from, to) {
272
+ const query = tx
273
+ .getRepository(saas_bill_1.SaasBill)
274
+ .createQueryBuilder('bill')
275
+ .leftJoinAndSelect('bill.lines', 'line')
276
+ .leftJoinAndSelect('line.subscription', 'subscription')
277
+ .where('bill.domain = :domain', { domain: domain.id });
278
+ if (accountIds) {
279
+ if (!accountIds.length)
280
+ return [];
281
+ query.andWhere('bill.account IN (:...accounts)', { accounts: accountIds });
282
+ }
283
+ if (from)
284
+ query.andWhere('bill.month >= :from', { from });
285
+ if (to)
286
+ query.andWhere('bill.month <= :to', { to });
287
+ const bills = await query.orderBy('bill.month', 'DESC').getMany();
288
+ for (const bill of bills)
289
+ bill.total = (0, saas_rules_1.billTotal)(bill.lines ?? []);
290
+ return bills;
291
+ }
292
+ async function saasBillsOf(tx, domain, scope, accountId) {
293
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
294
+ return billsOf(tx, domain, [account.id]);
295
+ }
296
+ const factsOf = (bill) => ({
297
+ accountId: bill.accountId,
298
+ month: bill.month,
299
+ currency: bill.currency,
300
+ lines: (bill.lines ?? []).map(line => ({ subscriptionId: line.subscriptionId ?? null, amount: Number(line.amount) }))
301
+ });
302
+ /**
303
+ * What each account is expected to be billed, month by month from `from` to `to`: the bill when one is written for the
304
+ * month, else what its subscriptions say (usage at the average of its latest bills).
305
+ */
306
+ async function saasExpected(tx, domain, from, to) {
307
+ const accounts = await tx.getRepository(saas_account_1.SaasAccount).find({ where: { domain: { id: domain.id }, status: saas_rules_1.SaasAccountStatus.ACTIVE } });
308
+ const top = accounts.filter(one => !one.parentId);
309
+ if (!top.length)
310
+ return [];
311
+ const subs = await tx.getRepository(saas_subscription_1.SaasSubscription).find({ where: { domain: { id: domain.id }, account: { id: (0, typeorm_1.In)(top.map(one => one.id)) } } });
312
+ const bills = await billsOf(tx, domain, top.map(one => one.id), (0, saas_rules_1.shiftMonth)(from, -12), to);
313
+ const billed = new Map();
314
+ for (const bill of bills)
315
+ if (bill.month < from)
316
+ for (const line of bill.lines ?? [])
317
+ if (line.subscriptionId)
318
+ billed.set(line.subscriptionId, [...(billed.get(line.subscriptionId) ?? []), { month: bill.month, amount: Number(line.amount) }]);
319
+ const rows = [];
320
+ for (const account of top) {
321
+ const own = subs.filter(one => one.accountId === account.id);
322
+ for (const month of (0, saas_rules_1.monthsFrom)(from, to)) {
323
+ const [year, m] = month.split('-').map(Number);
324
+ const bill = bills.find(one => one.accountId === account.id && one.month === month);
325
+ const expected = (0, saas_rules_1.expectedOfMonth)(account, own, billed, year, m);
326
+ if (!bill && !expected)
327
+ continue;
328
+ rows.push({
329
+ accountId: account.id,
330
+ accountName: account.name,
331
+ counterpartyId: account.counterpartyId ?? null,
332
+ paymentAccountId: account.paymentAccountId ?? null,
333
+ month,
334
+ day: bill?.billedOn ?? expected?.day ?? null,
335
+ amount: bill ? bill.total : expected.amount,
336
+ currency: account.currency,
337
+ billed: !!bill
338
+ });
339
+ }
340
+ }
341
+ return rows;
342
+ }
343
+ /* ----------------------------------------------------------------------------------------------------------------- */
344
+ /* The cost trend (decision 9) */
345
+ /** Whose department each employee is in. */
346
+ async function departmentsOfEmployees(tx, domain, ids) {
347
+ if (!ids.length)
348
+ return new Map();
349
+ const employees = await tx.getRepository(organization_1.Employee).find({ where: { domain: { id: domain.id }, id: (0, typeorm_1.In)(ids) }, relations: ['department'] });
350
+ return new Map(employees.filter(one => one.department).map(one => [one.id, { id: one.department.id, name: one.department.name }]));
351
+ }
352
+ /**
353
+ * The bills of the months from `from` to `to` as series - all, an account, a subscription - and a department series: a
354
+ * line of a seat plan split by the seats held at the month's end by department, any other line its subscription's
355
+ * department. Plus what the latest month's bills are worth telling.
356
+ */
357
+ async function saasCostTrend(tx, domain, from, to) {
358
+ if (!saas_rules_1.MONTH.test(from) || !saas_rules_1.MONTH.test(to) || from > to)
359
+ throw new shell_1.Refusal('saas-month-invalid', {}, 'Give the months as YYYY-MM, the first before the last');
360
+ if ((0, saas_rules_1.monthsFrom)(from, to).length > 60)
361
+ throw new shell_1.Refusal('saas-trend-too-long', { max: 60 }, 'Up to 60 months at once');
362
+ const bills = await billsOf(tx, domain, undefined, (0, saas_rules_1.shiftMonth)(from, -1), to);
363
+ const facts = bills.map(factsOf);
364
+ const trend = (0, saas_rules_1.trendOf)(facts, from, to);
365
+ const accounts = await tx.getRepository(saas_account_1.SaasAccount).find({ where: { domain: { id: domain.id } }, withDeleted: true });
366
+ const subs = await tx.getRepository(saas_subscription_1.SaasSubscription).find({ where: { domain: { id: domain.id } }, relations: ['department'], withDeleted: true });
367
+ const seats = subs.length ? await tx.getRepository(saas_seat_1.SaasSeat).find({ where: { domain: { id: domain.id }, subscription: { id: (0, typeorm_1.In)(subs.map(one => one.id)) } } }) : [];
368
+ const departmentOf = await departmentsOfEmployees(tx, domain, [...new Set(seats.map(one => one.employeeId).filter(Boolean))]);
369
+ const months = trend.months;
370
+ const departments = new Map();
371
+ const addTo = (id, name, currency, at, amount) => {
372
+ const key = `${id ?? ''}|${currency}`;
373
+ let one = departments.get(key);
374
+ if (!one)
375
+ departments.set(key, (one = { key, departmentId: id, name, currency, amounts: months.map(() => 0) }));
376
+ one.amounts[at] += amount;
377
+ };
378
+ for (const bill of facts) {
379
+ const at = months.indexOf(bill.month);
380
+ if (at < 0)
381
+ continue;
382
+ const monthEnd = `${bill.month}-31`;
383
+ for (const line of bill.lines) {
384
+ const sub = subs.find(one => one.id === line.subscriptionId);
385
+ const held = sub?.billing === saas_rules_1.SaasBilling.SEAT ? seats.filter(seat => seat.subscriptionId === sub.id && (0, saas_rules_1.seatHeld)(seat, monthEnd) && (!seat.fromOn || seat.fromOn <= monthEnd)) : [];
386
+ if (held.length) {
387
+ for (const seat of held) {
388
+ const department = seat.employeeId ? departmentOf.get(seat.employeeId) : undefined;
389
+ addTo(department?.id ?? sub?.department?.id ?? null, department?.name ?? sub?.department?.name ?? null, bill.currency, at, line.amount / held.length);
390
+ }
391
+ }
392
+ else
393
+ addTo(sub?.department?.id ?? null, sub?.department?.name ?? null, bill.currency, at, line.amount);
394
+ }
395
+ }
396
+ const latest = facts.filter(one => one.month <= to).map(one => one.month).sort().pop();
397
+ const alerts = latest
398
+ ? facts
399
+ .filter(one => one.month === latest)
400
+ .flatMap(bill => {
401
+ const account = accounts.find(one => one.id === bill.accountId);
402
+ if (!account)
403
+ return [];
404
+ const previous = facts.find(one => one.accountId === bill.accountId && one.month === (0, saas_rules_1.shiftMonth)(bill.month, -1));
405
+ return (0, saas_rules_1.alertsOf)(account, subs.filter(one => one.accountId === account.id), bill, previous).map(alert => ({ ...alert, accountId: account.id, month: bill.month }));
406
+ })
407
+ : [];
408
+ const name = (id) => accounts.find(one => one.id === id)?.name ?? null;
409
+ const subName = (id) => subs.find(one => one.id === id)?.name ?? null;
410
+ return {
411
+ months,
412
+ all: trend.all,
413
+ accounts: trend.accounts.map(one => ({ ...one, name: name(one.accountId) })),
414
+ subscriptions: trend.subscriptions.map(one => ({ ...one, name: subName(one.subscriptionId), accountName: name(one.accountId) })),
415
+ departments: [...departments.values()].map(one => ({ ...one, total: one.amounts.reduce((sum, amount) => sum + amount, 0) })).sort((a, b) => b.total - a.total),
416
+ alerts: alerts.map(alert => ({ ...alert, accountName: name(alert.accountId), subscriptionName: subName(alert.subscriptionId) }))
417
+ };
418
+ }
419
+ /* ----------------------------------------------------------------------------------------------------------------- */
420
+ /* A row's months for the list and the 360 (decision 10) and what moved on it (decision 11) */
421
+ /**
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.
425
+ */
426
+ async function bankPaymentsOf(tx, domain, counterpartyIds, from, to) {
427
+ if (!counterpartyIds.length)
428
+ return [];
429
+ const query = tx
430
+ .getRepository(accounting_1.Transaction)
431
+ .createQueryBuilder('line')
432
+ .where('line.domain = :domain', { domain: domain.id })
433
+ .andWhere('line.category = :category', { category: accounting_1.TransactionCategory.CONFIRMED })
434
+ .andWhere('line.type = :type', { type: accounting_1.CashFlowType.EXPENSE })
435
+ .andWhere('line.counterparty IN (:...parties)', { parties: counterpartyIds });
436
+ if (from)
437
+ query.andWhere('line.transactionDate >= :from', { from });
438
+ if (to)
439
+ query.andWhere('line.transactionDate <= :to', { to });
440
+ return query.orderBy('line.transactionDate', 'DESC').addOrderBy('line.amount', 'DESC').getMany();
441
+ }
442
+ /** The bank lines that paid an account's vendor, every one - for its 360. */
443
+ async function saasPaymentsOf(tx, domain, scope, accountId) {
444
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
445
+ return account.counterpartyId ? bankPaymentsOf(tx, domain, [account.counterpartyId]) : [];
446
+ }
447
+ /**
448
+ * 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.
451
+ */
452
+ async function saasTrendsOf(tx, domain, accounts, today, withBank = false) {
453
+ const trends = new Map();
454
+ if (!accounts.length)
455
+ return trends;
456
+ const now = today.slice(0, 7);
457
+ const months = (0, saas_rules_1.monthsFrom)((0, saas_rules_1.shiftMonth)(now, -12), (0, saas_rules_1.shiftMonth)(now, -1));
458
+ const nextMonths = (0, saas_rules_1.monthsFrom)(now, (0, saas_rules_1.shiftMonth)(now, 2));
459
+ const bills = await billsOf(tx, domain, accounts.map(one => one.id), months[0], months[months.length - 1]);
460
+ const expected = await saasExpected(tx, domain, nextMonths[0], nextMonths[nextMonths.length - 1]);
461
+ 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`) : [];
463
+ for (const account of accounts) {
464
+ const own = bills.filter(one => one.accountId === account.id);
465
+ const lines = paid.filter(line => line.counterpartyId === account.counterpartyId && (line.currency || 'KRW') === account.currency);
466
+ const banked = (month) => {
467
+ const of = lines.filter(line => line.transactionDate?.slice(0, 7) === month);
468
+ return of.length ? of.reduce((sum, line) => sum + Number(line.amount), 0) : null;
469
+ };
470
+ const values = months.map(month => own.find(one => one.month === month)?.total ?? banked(month));
471
+ trends.set(account.id, {
472
+ months,
473
+ values,
474
+ sources: months.map((month, i) => (own.some(one => one.month === month) ? 'bill' : values[i] === null ? null : 'bank')),
475
+ nextMonths,
476
+ expected: nextMonths.map(month => expected.find(one => one.accountId === account.id && one.month === month)?.amount ?? null),
477
+ currency: account.currency
478
+ });
479
+ }
480
+ return trends;
481
+ }
482
+ /** What moved on an account and on each of its subscriptions, newest first. */
483
+ async function saasChangesOf(tx, domain, scope, accountId) {
484
+ const account = await (0, saas_access_1.accountIn)(tx, domain, scope, accountId);
485
+ const subs = await tx.getRepository(saas_subscription_1.SaasSubscription).find({ where: { domain: { id: domain.id }, account: { id: account.id } }, withDeleted: true });
486
+ const rows = [
487
+ ...(await (0, fact_change_1.readFactChanges)(tx, domain, exports.SAAS_ACCOUNT_FACT, account.id)).map(one => Object.assign(one, { subject: account.name })),
488
+ ...(await Promise.all(subs.map(async (sub) => (await (0, fact_change_1.readFactChanges)(tx, domain, exports.SAAS_SUBSCRIPTION_FACT, sub.id)).map(one => Object.assign(one, { subject: sub.name }))))).flat()
489
+ ];
490
+ return rows.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
491
+ }
492
+ //# sourceMappingURL=saas-service.js.map