joopjs 2.0.0
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/CHANGELOG.md +678 -0
- package/README.md +583 -0
- package/dist/a11y.service-C-DQQfgO.d.mts +143 -0
- package/dist/a11y.service-CauEJrJe.d.ts +143 -0
- package/dist/adapters-B6slG6hQ.d.mts +84 -0
- package/dist/adapters-B6slG6hQ.d.ts +84 -0
- package/dist/aes.service-CkoupAww.d.mts +95 -0
- package/dist/aes.service-CkoupAww.d.ts +95 -0
- package/dist/ai/index.d.mts +99 -0
- package/dist/ai/index.d.ts +99 -0
- package/dist/ai/index.js +307 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/index.mjs +304 -0
- package/dist/ai/index.mjs.map +1 -0
- package/dist/analytics/index.d.mts +42 -0
- package/dist/analytics/index.d.ts +42 -0
- package/dist/analytics/index.js +139 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/index.mjs +136 -0
- package/dist/analytics/index.mjs.map +1 -0
- package/dist/angular/index.d.mts +148 -0
- package/dist/angular/index.d.ts +148 -0
- package/dist/angular/index.js +122 -0
- package/dist/angular/index.js.map +1 -0
- package/dist/angular/index.mjs +101 -0
- package/dist/angular/index.mjs.map +1 -0
- package/dist/api/index.d.mts +128 -0
- package/dist/api/index.d.ts +128 -0
- package/dist/api/index.js +1358 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/index.mjs +1332 -0
- package/dist/api/index.mjs.map +1 -0
- package/dist/auth/index.d.mts +105 -0
- package/dist/auth/index.d.ts +105 -0
- package/dist/auth/index.js +989 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/index.mjs +979 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth.service-DNVB-L4U.d.mts +16 -0
- package/dist/auth.service-PjUUSUIt.d.ts +16 -0
- package/dist/banking/index.d.mts +1530 -0
- package/dist/banking/index.d.ts +1530 -0
- package/dist/banking/index.js +4739 -0
- package/dist/banking/index.js.map +1 -0
- package/dist/banking/index.mjs +4661 -0
- package/dist/banking/index.mjs.map +1 -0
- package/dist/cache/index.d.mts +40 -0
- package/dist/cache/index.d.ts +40 -0
- package/dist/cache/index.js +174 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/index.mjs +172 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/client-profile.service-BuPeXVp5.d.mts +28 -0
- package/dist/client-profile.service-D5bRRYQp.d.ts +28 -0
- package/dist/config.models-Cqg04fAQ.d.mts +84 -0
- package/dist/config.models-Cqg04fAQ.d.ts +84 -0
- package/dist/config.service-CrCvI-JS.d.ts +31 -0
- package/dist/config.service-Cz4QQLlf.d.mts +31 -0
- package/dist/core/index.d.mts +4 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +631 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/index.mjs +619 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/crypto-utils-DriNhLdx.d.mts +30 -0
- package/dist/crypto-utils-DriNhLdx.d.ts +30 -0
- package/dist/data-storage.service-DT6xaTxE.d.ts +51 -0
- package/dist/data-storage.service-LvhGRCmw.d.mts +51 -0
- package/dist/deeplink/index.d.mts +39 -0
- package/dist/deeplink/index.d.ts +39 -0
- package/dist/deeplink/index.js +268 -0
- package/dist/deeplink/index.js.map +1 -0
- package/dist/deeplink/index.mjs +265 -0
- package/dist/deeplink/index.mjs.map +1 -0
- package/dist/deeplink.service-Ctd5u243.d.mts +35 -0
- package/dist/deeplink.service-uUuTnY9_.d.ts +35 -0
- package/dist/dev/index.d.mts +20 -0
- package/dist/dev/index.d.ts +20 -0
- package/dist/dev/index.js +51 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/index.mjs +49 -0
- package/dist/dev/index.mjs.map +1 -0
- package/dist/device/index.d.mts +108 -0
- package/dist/device/index.d.ts +108 -0
- package/dist/device/index.js +960 -0
- package/dist/device/index.js.map +1 -0
- package/dist/device/index.mjs +951 -0
- package/dist/device/index.mjs.map +1 -0
- package/dist/differential-privacy-BcAv1G80.d.mts +210 -0
- package/dist/differential-privacy-C8mAUjZr.d.ts +210 -0
- package/dist/encryption/index.d.mts +75 -0
- package/dist/encryption/index.d.ts +75 -0
- package/dist/encryption/index.js +605 -0
- package/dist/encryption/index.js.map +1 -0
- package/dist/encryption/index.mjs +598 -0
- package/dist/encryption/index.mjs.map +1 -0
- package/dist/form-validator-3tkmzr_o.d.mts +72 -0
- package/dist/form-validator-3tkmzr_o.d.ts +72 -0
- package/dist/forms/index.d.mts +59 -0
- package/dist/forms/index.d.ts +59 -0
- package/dist/forms/index.js +446 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/index.mjs +442 -0
- package/dist/forms/index.mjs.map +1 -0
- package/dist/i18n/index.d.mts +37 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/i18n/index.js +147 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/index.mjs +145 -0
- package/dist/i18n/index.mjs.map +1 -0
- package/dist/idempotency.service-_6LqhivP.d.mts +372 -0
- package/dist/idempotency.service-eOKoISRD.d.ts +372 -0
- package/dist/index-B_ksKpS1.d.mts +202 -0
- package/dist/index-CqDKWTUP.d.mts +28 -0
- package/dist/index-CqDKWTUP.d.ts +28 -0
- package/dist/index-DFqEoX_l.d.ts +202 -0
- package/dist/index-Dz0gOur2.d.mts +36 -0
- package/dist/index-Dz0gOur2.d.ts +36 -0
- package/dist/index.d.mts +1336 -0
- package/dist/index.d.ts +1336 -0
- package/dist/index.js +19464 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +19155 -0
- package/dist/index.mjs.map +1 -0
- package/dist/india/index.d.mts +75 -0
- package/dist/india/index.d.ts +75 -0
- package/dist/india/index.js +325 -0
- package/dist/india/index.js.map +1 -0
- package/dist/india/index.mjs +303 -0
- package/dist/india/index.mjs.map +1 -0
- package/dist/joop-Bx7Iwj5p.d.mts +155 -0
- package/dist/joop-CA3DMeOO.d.ts +155 -0
- package/dist/native-bridge/index.d.mts +27 -0
- package/dist/native-bridge/index.d.ts +27 -0
- package/dist/native-bridge/index.js +98 -0
- package/dist/native-bridge/index.js.map +1 -0
- package/dist/native-bridge/index.mjs +96 -0
- package/dist/native-bridge/index.mjs.map +1 -0
- package/dist/network/index.d.mts +85 -0
- package/dist/network/index.d.ts +85 -0
- package/dist/network/index.js +454 -0
- package/dist/network/index.js.map +1 -0
- package/dist/network/index.mjs +451 -0
- package/dist/network/index.mjs.map +1 -0
- package/dist/network-monitor-BIwPSXme.d.mts +179 -0
- package/dist/network-monitor-Bqp2hvZr.d.ts +179 -0
- package/dist/notification.service-Dm4fvfZf.d.mts +25 -0
- package/dist/notification.service-tEMKatWJ.d.ts +25 -0
- package/dist/observability/index.d.mts +179 -0
- package/dist/observability/index.d.ts +179 -0
- package/dist/observability/index.js +559 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/index.mjs +552 -0
- package/dist/observability/index.mjs.map +1 -0
- package/dist/oidc-client-DIJcClmB.d.mts +190 -0
- package/dist/oidc-client-DxhyE59t.d.ts +190 -0
- package/dist/platform/index.d.mts +73 -0
- package/dist/platform/index.d.ts +73 -0
- package/dist/platform/index.js +127 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/index.mjs +125 -0
- package/dist/platform/index.mjs.map +1 -0
- package/dist/pwa/index.d.mts +31 -0
- package/dist/pwa/index.d.ts +31 -0
- package/dist/pwa/index.js +247 -0
- package/dist/pwa/index.js.map +1 -0
- package/dist/pwa/index.mjs +244 -0
- package/dist/pwa/index.mjs.map +1 -0
- package/dist/react/index.d.mts +133 -0
- package/dist/react/index.d.ts +133 -0
- package/dist/react/index.js +632 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +630 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/router/index.d.mts +39 -0
- package/dist/router/index.d.ts +39 -0
- package/dist/router/index.js +168 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/index.mjs +166 -0
- package/dist/router/index.mjs.map +1 -0
- package/dist/security/index.d.mts +206 -0
- package/dist/security/index.d.ts +206 -0
- package/dist/security/index.js +1297 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/index.mjs +1285 -0
- package/dist/security/index.mjs.map +1 -0
- package/dist/session/index.d.mts +115 -0
- package/dist/session/index.d.ts +115 -0
- package/dist/session/index.js +297 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/index.mjs +292 -0
- package/dist/session/index.mjs.map +1 -0
- package/dist/state/index.d.mts +43 -0
- package/dist/state/index.d.ts +43 -0
- package/dist/state/index.js +156 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/index.mjs +152 -0
- package/dist/state/index.mjs.map +1 -0
- package/dist/statement-parser-BHQtXwCM.d.ts +260 -0
- package/dist/statement-parser-C2qNmb49.d.mts +260 -0
- package/dist/storage/index.d.mts +40 -0
- package/dist/storage/index.d.ts +40 -0
- package/dist/storage/index.js +256 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/index.mjs +252 -0
- package/dist/storage/index.mjs.map +1 -0
- package/dist/sync/index.d.mts +69 -0
- package/dist/sync/index.d.ts +69 -0
- package/dist/sync/index.js +330 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/index.mjs +323 -0
- package/dist/sync/index.mjs.map +1 -0
- package/dist/sync-engine-DCIMRG5s.d.ts +61 -0
- package/dist/sync-engine-DZqyKHkK.d.mts +61 -0
- package/dist/theme/index.d.mts +53 -0
- package/dist/theme/index.d.ts +53 -0
- package/dist/theme/index.js +169 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/index.mjs +167 -0
- package/dist/theme/index.mjs.map +1 -0
- package/dist/ui/index.d.mts +66 -0
- package/dist/ui/index.d.ts +66 -0
- package/dist/ui/index.js +811 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/index.mjs +803 -0
- package/dist/ui/index.mjs.map +1 -0
- package/dist/utilities/index.d.mts +199 -0
- package/dist/utilities/index.d.ts +199 -0
- package/dist/utilities/index.js +1991 -0
- package/dist/utilities/index.js.map +1 -0
- package/dist/utilities/index.mjs +1923 -0
- package/dist/utilities/index.mjs.map +1 -0
- package/dist/validation/index.d.mts +60 -0
- package/dist/validation/index.d.ts +60 -0
- package/dist/validation/index.js +460 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/index.mjs +455 -0
- package/dist/validation/index.mjs.map +1 -0
- package/dist/vue/index.d.mts +135 -0
- package/dist/vue/index.d.ts +135 -0
- package/dist/vue/index.js +621 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/vue/index.mjs +619 -0
- package/dist/vue/index.mjs.map +1 -0
- package/dist/watermark.service-Detur5tq.d.ts +235 -0
- package/dist/watermark.service-QNegMeQZ.d.mts +235 -0
- package/dist/workers/index.d.mts +42 -0
- package/dist/workers/index.d.ts +42 -0
- package/dist/workers/index.js +359 -0
- package/dist/workers/index.js.map +1 -0
- package/dist/workers/index.mjs +356 -0
- package/dist/workers/index.mjs.map +1 -0
- package/dist/workflow/index.d.mts +99 -0
- package/dist/workflow/index.d.ts +99 -0
- package/dist/workflow/index.js +282 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/index.mjs +279 -0
- package/dist/workflow/index.mjs.map +1 -0
- package/package.json +226 -0
|
@@ -0,0 +1,4661 @@
|
|
|
1
|
+
// src/banking/masking.utility.ts
|
|
2
|
+
function maskCard(cardNumber, showLast = 4) {
|
|
3
|
+
const digits = cardNumber.replace(/\D/g, "");
|
|
4
|
+
if (digits.length < showLast) return cardNumber;
|
|
5
|
+
const masked = "*".repeat(digits.length - showLast) + digits.slice(-showLast);
|
|
6
|
+
return masked.replace(/(.{4})/g, "$1 ").trim();
|
|
7
|
+
}
|
|
8
|
+
function formatCard(cardNumber) {
|
|
9
|
+
return cardNumber.replace(/\D/g, "").replace(/(.{4})/g, "$1 ").trim();
|
|
10
|
+
}
|
|
11
|
+
function maskIban(iban, showChars = 8) {
|
|
12
|
+
const clean = iban.replace(/\s/g, "");
|
|
13
|
+
if (clean.length <= showChars) return iban;
|
|
14
|
+
return clean.slice(0, 4) + "*".repeat(clean.length - showChars) + clean.slice(-4);
|
|
15
|
+
}
|
|
16
|
+
function maskAccountNumber(account, showLast = 4) {
|
|
17
|
+
const clean = account.replace(/\D/g, "");
|
|
18
|
+
if (clean.length <= showLast) return clean;
|
|
19
|
+
return "*".repeat(clean.length - showLast) + clean.slice(-showLast);
|
|
20
|
+
}
|
|
21
|
+
function maskEmail(email) {
|
|
22
|
+
const [user, domain] = email.split("@");
|
|
23
|
+
if (!domain || user.length < 3) return "***@" + (domain ?? "");
|
|
24
|
+
return user[0] + "*".repeat(user.length - 2) + user[user.length - 1] + "@" + domain;
|
|
25
|
+
}
|
|
26
|
+
function maskPhone(phone, showLast = 4) {
|
|
27
|
+
const digits = phone.replace(/\D/g, "");
|
|
28
|
+
if (digits.length <= showLast) return phone;
|
|
29
|
+
return "*".repeat(digits.length - showLast) + digits.slice(-showLast);
|
|
30
|
+
}
|
|
31
|
+
function maskName(name) {
|
|
32
|
+
const parts = name.trim().split(/\s+/);
|
|
33
|
+
return parts.map((p, i) => i === 0 ? p : p[0] + "*".repeat(p.length - 1)).join(" ");
|
|
34
|
+
}
|
|
35
|
+
var NRIC_SG_REGEX = /^[STFGM]\d{7}[A-Z]$/i;
|
|
36
|
+
var NRIC_WEIGHTS = [2, 7, 6, 5, 4, 3, 2];
|
|
37
|
+
var NRIC_SG_ST = ["J", "Z", "I", "H", "G", "F", "E", "D", "C", "B", "A"];
|
|
38
|
+
var NRIC_SG_FG = ["X", "W", "U", "T", "R", "Q", "P", "N", "M", "L", "K"];
|
|
39
|
+
var NRIC_SG_M = ["K", "L", "J", "N", "P", "Q", "R", "T", "U", "W", "X"];
|
|
40
|
+
function validateNric(nric) {
|
|
41
|
+
const clean = nric.trim().toUpperCase();
|
|
42
|
+
if (!NRIC_SG_REGEX.test(clean)) return false;
|
|
43
|
+
const prefix = clean[0];
|
|
44
|
+
const digits = clean.slice(1, 8).split("").map(Number);
|
|
45
|
+
const suffix = clean[8];
|
|
46
|
+
const sum = digits.reduce((acc, d, i) => acc + d * NRIC_WEIGHTS[i], 0);
|
|
47
|
+
const mod = (sum + (prefix === "G" || prefix === "T" ? 4 : prefix === "M" ? 3 : 0)) % 11;
|
|
48
|
+
let table;
|
|
49
|
+
if (prefix === "S" || prefix === "T") table = NRIC_SG_ST;
|
|
50
|
+
else if (prefix === "F" || prefix === "G") table = NRIC_SG_FG;
|
|
51
|
+
else table = NRIC_SG_M;
|
|
52
|
+
return table[mod] === suffix;
|
|
53
|
+
}
|
|
54
|
+
function maskNric(nric) {
|
|
55
|
+
const clean = nric.trim().toUpperCase();
|
|
56
|
+
if (clean.length < 5) return nric;
|
|
57
|
+
return clean[0] + "*".repeat(clean.length - 4) + clean.slice(-3);
|
|
58
|
+
}
|
|
59
|
+
var MYKAD_REGEX = /^\d{12}$|^\d{6}-\d{2}-\d{4}$/;
|
|
60
|
+
function validateMyKad(ic) {
|
|
61
|
+
return MYKAD_REGEX.test(ic.replace(/\s/g, ""));
|
|
62
|
+
}
|
|
63
|
+
function maskMyKad(ic) {
|
|
64
|
+
const clean = ic.replace(/[-\s]/g, "");
|
|
65
|
+
if (clean.length !== 12) return ic;
|
|
66
|
+
return clean.slice(0, 2) + "****" + clean.slice(6, 8) + "****";
|
|
67
|
+
}
|
|
68
|
+
function formatMyKad(ic) {
|
|
69
|
+
const clean = ic.replace(/\D/g, "");
|
|
70
|
+
if (clean.length !== 12) return ic;
|
|
71
|
+
return `${clean.slice(0, 6)}-${clean.slice(6, 8)}-${clean.slice(8)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/banking/custom-mask.utility.ts
|
|
75
|
+
function maskRange(value, firstVisible, lastVisible, maskChar = "*") {
|
|
76
|
+
const len = value.length;
|
|
77
|
+
const first = Math.max(0, Math.min(firstVisible, len));
|
|
78
|
+
const last = Math.max(0, Math.min(lastVisible, len - first));
|
|
79
|
+
const midLen = Math.max(0, len - first - last);
|
|
80
|
+
return value.slice(0, first) + maskChar.repeat(midLen) + value.slice(len - last || len);
|
|
81
|
+
}
|
|
82
|
+
function maskShowLast(value, n, maskChar = "*") {
|
|
83
|
+
return maskRange(value, 0, n, maskChar);
|
|
84
|
+
}
|
|
85
|
+
function maskShowFirst(value, n, maskChar = "*") {
|
|
86
|
+
return maskRange(value, n, 0, maskChar);
|
|
87
|
+
}
|
|
88
|
+
function maskPattern(value, pattern, maskChar = "*") {
|
|
89
|
+
let result = "";
|
|
90
|
+
let vi = 0;
|
|
91
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
92
|
+
const pc = pattern[i];
|
|
93
|
+
if (pc === "#") {
|
|
94
|
+
result += vi < value.length ? value[vi++] : "";
|
|
95
|
+
} else if (pc === "*" || pc === "X" || pc === "x") {
|
|
96
|
+
result += vi < value.length ? maskChar : "";
|
|
97
|
+
vi++;
|
|
98
|
+
} else {
|
|
99
|
+
result += pc;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
function maskKeepMiddle(value, visibleCount, maskChar = "*") {
|
|
105
|
+
const len = value.length;
|
|
106
|
+
const start = Math.max(0, Math.floor((len - visibleCount) / 2));
|
|
107
|
+
const end = start + visibleCount;
|
|
108
|
+
return maskChar.repeat(start) + value.slice(start, end) + maskChar.repeat(Math.max(0, len - end));
|
|
109
|
+
}
|
|
110
|
+
function maskInterleaved(value, maskChar = "*") {
|
|
111
|
+
return value.split("").map((c, i) => i % 2 === 0 ? c : maskChar).join("");
|
|
112
|
+
}
|
|
113
|
+
function maskKeepSeparators(value, lastVisible, separators = " -", maskChar = "*") {
|
|
114
|
+
const stripped = value.split("").filter((c) => !separators.includes(c)).join("");
|
|
115
|
+
const maskedStripped = maskShowLast(stripped, lastVisible, maskChar);
|
|
116
|
+
let mi = 0;
|
|
117
|
+
return value.split("").map((c) => separators.includes(c) ? c : maskedStripped[mi++] ?? "").join("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/banking/iban.utility.ts
|
|
121
|
+
var IBAN_LENGTHS = {
|
|
122
|
+
AL: 28,
|
|
123
|
+
AD: 24,
|
|
124
|
+
AE: 23,
|
|
125
|
+
AT: 20,
|
|
126
|
+
AZ: 28,
|
|
127
|
+
BA: 20,
|
|
128
|
+
BE: 16,
|
|
129
|
+
BG: 22,
|
|
130
|
+
BH: 22,
|
|
131
|
+
BR: 29,
|
|
132
|
+
BY: 28,
|
|
133
|
+
CH: 21,
|
|
134
|
+
CR: 22,
|
|
135
|
+
CY: 28,
|
|
136
|
+
CZ: 24,
|
|
137
|
+
DE: 22,
|
|
138
|
+
DK: 18,
|
|
139
|
+
DO: 28,
|
|
140
|
+
EE: 20,
|
|
141
|
+
EG: 29,
|
|
142
|
+
ES: 24,
|
|
143
|
+
FI: 18,
|
|
144
|
+
FO: 18,
|
|
145
|
+
FR: 27,
|
|
146
|
+
GB: 22,
|
|
147
|
+
GE: 22,
|
|
148
|
+
GI: 23,
|
|
149
|
+
GL: 18,
|
|
150
|
+
GR: 27,
|
|
151
|
+
GT: 28,
|
|
152
|
+
HR: 21,
|
|
153
|
+
HU: 28,
|
|
154
|
+
IE: 22,
|
|
155
|
+
IL: 23,
|
|
156
|
+
IQ: 23,
|
|
157
|
+
IS: 26,
|
|
158
|
+
IT: 27,
|
|
159
|
+
JO: 30,
|
|
160
|
+
KW: 30,
|
|
161
|
+
KZ: 20,
|
|
162
|
+
LB: 28,
|
|
163
|
+
LC: 32,
|
|
164
|
+
LI: 21,
|
|
165
|
+
LT: 20,
|
|
166
|
+
LU: 20,
|
|
167
|
+
LV: 21,
|
|
168
|
+
LY: 25,
|
|
169
|
+
MC: 27,
|
|
170
|
+
MD: 24,
|
|
171
|
+
ME: 22,
|
|
172
|
+
MK: 19,
|
|
173
|
+
MR: 27,
|
|
174
|
+
MT: 31,
|
|
175
|
+
MU: 30,
|
|
176
|
+
NL: 18,
|
|
177
|
+
NO: 15,
|
|
178
|
+
PK: 24,
|
|
179
|
+
PL: 28,
|
|
180
|
+
PS: 29,
|
|
181
|
+
PT: 25,
|
|
182
|
+
QA: 29,
|
|
183
|
+
RO: 24,
|
|
184
|
+
RS: 22,
|
|
185
|
+
SA: 24,
|
|
186
|
+
SC: 31,
|
|
187
|
+
SD: 18,
|
|
188
|
+
SE: 24,
|
|
189
|
+
SI: 19,
|
|
190
|
+
SK: 24,
|
|
191
|
+
SM: 27,
|
|
192
|
+
ST: 25,
|
|
193
|
+
SV: 28,
|
|
194
|
+
TL: 23,
|
|
195
|
+
TN: 24,
|
|
196
|
+
TR: 26,
|
|
197
|
+
UA: 29,
|
|
198
|
+
VA: 22,
|
|
199
|
+
VG: 24,
|
|
200
|
+
XK: 20
|
|
201
|
+
};
|
|
202
|
+
function validateIban(iban) {
|
|
203
|
+
const clean = iban.replace(/\s/g, "").toUpperCase();
|
|
204
|
+
const country = clean.slice(0, 2);
|
|
205
|
+
if (!IBAN_LENGTHS[country] || clean.length !== IBAN_LENGTHS[country]) return false;
|
|
206
|
+
const numeric = (clean.slice(4) + clean.slice(0, 4)).split("").map((c) => c.charCodeAt(0) >= 65 ? (c.charCodeAt(0) - 55).toString() : c).join("");
|
|
207
|
+
let remainder = 0;
|
|
208
|
+
for (const ch of numeric) remainder = (remainder * 10 + parseInt(ch)) % 97;
|
|
209
|
+
return remainder === 1;
|
|
210
|
+
}
|
|
211
|
+
function formatIban(iban) {
|
|
212
|
+
return iban.replace(/\s/g, "").toUpperCase().replace(/(.{4})/g, "$1 ").trim();
|
|
213
|
+
}
|
|
214
|
+
function getIbanCountry(iban) {
|
|
215
|
+
return iban.replace(/\s/g, "").toUpperCase().slice(0, 2);
|
|
216
|
+
}
|
|
217
|
+
function parseIban(iban) {
|
|
218
|
+
const clean = iban.replace(/\s/g, "").toUpperCase();
|
|
219
|
+
if (!validateIban(clean)) return null;
|
|
220
|
+
return { country: clean.slice(0, 2), checkDigits: clean.slice(2, 4), bban: clean.slice(4) };
|
|
221
|
+
}
|
|
222
|
+
function getSupportedIbanCountries() {
|
|
223
|
+
return Object.keys(IBAN_LENGTHS).sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/banking/currency.utility.ts
|
|
227
|
+
function formatCurrency(amount, options) {
|
|
228
|
+
const num = typeof amount === "string" ? parseFloat(amount.replace(/[,\s]/g, "")) : amount;
|
|
229
|
+
if (!isFinite(num)) return "\u2014";
|
|
230
|
+
return new Intl.NumberFormat(options.locale ?? "en-US", {
|
|
231
|
+
style: options.showSymbol === false ? "decimal" : "currency",
|
|
232
|
+
currency: options.showSymbol === false ? void 0 : options.currency,
|
|
233
|
+
minimumFractionDigits: options.minimumFractionDigits ?? 2,
|
|
234
|
+
maximumFractionDigits: options.maximumFractionDigits ?? 2
|
|
235
|
+
}).format(num);
|
|
236
|
+
}
|
|
237
|
+
function parseCurrencyString(value) {
|
|
238
|
+
const cleaned = value.replace(/[^0-9.\-]/g, "");
|
|
239
|
+
const num = parseFloat(cleaned);
|
|
240
|
+
return isNaN(num) ? 0 : num;
|
|
241
|
+
}
|
|
242
|
+
function convertCurrency(amount, rate, decimals = 2) {
|
|
243
|
+
return parseFloat((amount * rate).toFixed(decimals));
|
|
244
|
+
}
|
|
245
|
+
function getCurrencySymbol(currency, locale = "en-US") {
|
|
246
|
+
const formatted = new Intl.NumberFormat(locale, { style: "currency", currency }).format(0);
|
|
247
|
+
return formatted.replace(/[\d\s.,]/g, "").trim();
|
|
248
|
+
}
|
|
249
|
+
function formatAmount(amount, decimals = 2, locale = "en-US") {
|
|
250
|
+
return new Intl.NumberFormat(locale, {
|
|
251
|
+
minimumFractionDigits: decimals,
|
|
252
|
+
maximumFractionDigits: decimals
|
|
253
|
+
}).format(amount);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/banking/amount.utility.ts
|
|
257
|
+
function safeAdd(a, b, decimals = 2) {
|
|
258
|
+
const f = Math.pow(10, decimals);
|
|
259
|
+
return (Math.round(a * f) + Math.round(b * f)) / f;
|
|
260
|
+
}
|
|
261
|
+
function safeSubtract(a, b, decimals = 2) {
|
|
262
|
+
const f = Math.pow(10, decimals);
|
|
263
|
+
return (Math.round(a * f) - Math.round(b * f)) / f;
|
|
264
|
+
}
|
|
265
|
+
function safeMultiply(a, b, decimals = 2) {
|
|
266
|
+
return parseFloat((a * b).toFixed(decimals));
|
|
267
|
+
}
|
|
268
|
+
function safeDivide(a, b, decimals = 2) {
|
|
269
|
+
if (b === 0) throw new Error("Division by zero");
|
|
270
|
+
return parseFloat((a / b).toFixed(decimals));
|
|
271
|
+
}
|
|
272
|
+
function roundAmount(amount, decimals = 2) {
|
|
273
|
+
const f = Math.pow(10, decimals);
|
|
274
|
+
return Math.round(amount * f) / f;
|
|
275
|
+
}
|
|
276
|
+
function isValidAmount(value) {
|
|
277
|
+
if (typeof value === "string") {
|
|
278
|
+
const clean = value.replace(/[,\s]/g, "");
|
|
279
|
+
return /^-?\d+(\.\d+)?$/.test(clean) && isFinite(parseFloat(clean));
|
|
280
|
+
}
|
|
281
|
+
return typeof value === "number" && isFinite(value);
|
|
282
|
+
}
|
|
283
|
+
function clampAmount(amount, min, max) {
|
|
284
|
+
return Math.min(Math.max(amount, min), max);
|
|
285
|
+
}
|
|
286
|
+
function toMinorUnits(amount, decimalPlaces = 2) {
|
|
287
|
+
return Math.round(amount * Math.pow(10, decimalPlaces));
|
|
288
|
+
}
|
|
289
|
+
function fromMinorUnits(amount, decimalPlaces = 2) {
|
|
290
|
+
return amount / Math.pow(10, decimalPlaces);
|
|
291
|
+
}
|
|
292
|
+
function sumAmounts(...amounts) {
|
|
293
|
+
const f = 100;
|
|
294
|
+
return amounts.reduce((acc, a) => acc + Math.round(a * f), 0) / f;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/banking/transaction.utility.ts
|
|
298
|
+
function generateTransactionId(prefix = "TXN") {
|
|
299
|
+
const timestamp = Date.now();
|
|
300
|
+
const randomBytes = new Uint8Array(8);
|
|
301
|
+
crypto.getRandomValues(randomBytes);
|
|
302
|
+
const randomHex = Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
303
|
+
return { id: randomHex, timestamp, prefix, full: `${prefix}-${timestamp}-${randomHex}` };
|
|
304
|
+
}
|
|
305
|
+
function generateReferenceNumber(length = 12) {
|
|
306
|
+
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
307
|
+
const arr = new Uint8Array(length);
|
|
308
|
+
crypto.getRandomValues(arr);
|
|
309
|
+
return Array.from(arr).map((b) => chars[b % chars.length]).join("");
|
|
310
|
+
}
|
|
311
|
+
function parseTransactionId(id) {
|
|
312
|
+
const parts = id.split("-");
|
|
313
|
+
if (parts.length < 3) return null;
|
|
314
|
+
const timestamp = parseInt(parts[1]);
|
|
315
|
+
if (isNaN(timestamp)) return null;
|
|
316
|
+
return { prefix: parts[0], timestamp, random: parts.slice(2).join("-") };
|
|
317
|
+
}
|
|
318
|
+
function isValidTransactionId(id) {
|
|
319
|
+
return parseTransactionId(id) !== null;
|
|
320
|
+
}
|
|
321
|
+
function generateBatchId() {
|
|
322
|
+
const arr = new Uint8Array(12);
|
|
323
|
+
crypto.getRandomValues(arr);
|
|
324
|
+
return "BATCH-" + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/banking/receipt.service.ts
|
|
328
|
+
var JoopReceiptService = class {
|
|
329
|
+
build(data) {
|
|
330
|
+
return data;
|
|
331
|
+
}
|
|
332
|
+
toText(data) {
|
|
333
|
+
const div = "\u2500".repeat(40);
|
|
334
|
+
return [
|
|
335
|
+
div,
|
|
336
|
+
data.title.toUpperCase().padStart(Math.ceil((40 + data.title.length) / 2)),
|
|
337
|
+
div,
|
|
338
|
+
`Ref : ${data.referenceNumber}`,
|
|
339
|
+
`Date: ${data.timestamp}`,
|
|
340
|
+
div,
|
|
341
|
+
...data.lines.map((l) => l.label.padEnd(22) + l.value),
|
|
342
|
+
div,
|
|
343
|
+
...data.footer ? [data.footer] : []
|
|
344
|
+
].join("\n");
|
|
345
|
+
}
|
|
346
|
+
toJson(data) {
|
|
347
|
+
return JSON.stringify(data, null, 2);
|
|
348
|
+
}
|
|
349
|
+
toHtml(data) {
|
|
350
|
+
const rows = data.lines.map(
|
|
351
|
+
(l) => `<tr><td>${l.label}</td><td${l.bold ? ' style="font-weight:bold"' : ""}>${l.value}</td></tr>`
|
|
352
|
+
).join("");
|
|
353
|
+
return `<div class="joop-receipt">
|
|
354
|
+
<h2>${data.title}</h2>
|
|
355
|
+
<p>Ref: ${data.referenceNumber} | ${data.timestamp}</p>
|
|
356
|
+
<table>${rows}</table>${data.footer ? `
|
|
357
|
+
<p class="footer">${data.footer}</p>` : ""}
|
|
358
|
+
</div>`;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/banking/luhn.utility.ts
|
|
363
|
+
var CARD_PATTERNS = [
|
|
364
|
+
{ network: "amex", pattern: /^3[47]/, lengths: [15], cvvLength: 4 },
|
|
365
|
+
{ network: "diners", pattern: /^3(?:0[0-5]|[68])/, lengths: [14], cvvLength: 3 },
|
|
366
|
+
{ network: "discover", pattern: /^6(?:011|5[0-9]{2})/, lengths: [16], cvvLength: 3 },
|
|
367
|
+
{ network: "jcb", pattern: /^(?:2131|1800|35)/, lengths: [16], cvvLength: 3 },
|
|
368
|
+
{ network: "maestro", pattern: /^(?:5018|5020|5038|6304|6759|676[1-3])/, lengths: [12, 13, 14, 15, 16, 17, 18, 19], cvvLength: 3 },
|
|
369
|
+
{ network: "mastercard", pattern: /^5[1-5]|^2[2-7]/, lengths: [16], cvvLength: 3 },
|
|
370
|
+
{ network: "unionpay", pattern: /^62/, lengths: [16, 17, 18, 19], cvvLength: 3 },
|
|
371
|
+
{ network: "visa", pattern: /^4/, lengths: [13, 16, 19], cvvLength: 3 }
|
|
372
|
+
];
|
|
373
|
+
function validateLuhn(cardNumber) {
|
|
374
|
+
const digits = cardNumber.replace(/\D/g, "");
|
|
375
|
+
if (digits.length < 8) return false;
|
|
376
|
+
let sum = 0;
|
|
377
|
+
let double = false;
|
|
378
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
379
|
+
let d = parseInt(digits[i], 10);
|
|
380
|
+
if (double) {
|
|
381
|
+
d *= 2;
|
|
382
|
+
if (d > 9) d -= 9;
|
|
383
|
+
}
|
|
384
|
+
sum += d;
|
|
385
|
+
double = !double;
|
|
386
|
+
}
|
|
387
|
+
return sum % 10 === 0;
|
|
388
|
+
}
|
|
389
|
+
function luhnCheckDigit(partial) {
|
|
390
|
+
const digits = partial.replace(/\D/g, "");
|
|
391
|
+
let sum = 0;
|
|
392
|
+
let double = true;
|
|
393
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
394
|
+
let d = parseInt(digits[i], 10);
|
|
395
|
+
if (double) {
|
|
396
|
+
d *= 2;
|
|
397
|
+
if (d > 9) d -= 9;
|
|
398
|
+
}
|
|
399
|
+
sum += d;
|
|
400
|
+
double = !double;
|
|
401
|
+
}
|
|
402
|
+
return (10 - sum % 10) % 10;
|
|
403
|
+
}
|
|
404
|
+
function getCardNetwork(cardNumber) {
|
|
405
|
+
const digits = cardNumber.replace(/\D/g, "");
|
|
406
|
+
for (const card of CARD_PATTERNS) {
|
|
407
|
+
if (card.pattern.test(digits)) return card.network;
|
|
408
|
+
}
|
|
409
|
+
return "unknown";
|
|
410
|
+
}
|
|
411
|
+
function getCardInfo(cardNumber) {
|
|
412
|
+
const digits = cardNumber.replace(/\D/g, "");
|
|
413
|
+
const match = CARD_PATTERNS.find((c) => c.pattern.test(digits));
|
|
414
|
+
const network = match?.network ?? "unknown";
|
|
415
|
+
const lengths = match?.lengths ?? [13, 14, 15, 16, 19];
|
|
416
|
+
const cvvLength = match?.cvvLength ?? 3;
|
|
417
|
+
const valid = lengths.includes(digits.length) && validateLuhn(digits);
|
|
418
|
+
const formatted = formatCardNumber(digits);
|
|
419
|
+
return { network, valid, lengths, cvvLength, formatted };
|
|
420
|
+
}
|
|
421
|
+
function formatCardNumber(cardNumber) {
|
|
422
|
+
const digits = cardNumber.replace(/\D/g, "");
|
|
423
|
+
const network = getCardNetwork(digits);
|
|
424
|
+
if (network === "amex") {
|
|
425
|
+
return digits.replace(/^(\d{4})(\d{6})(\d{5})$/, "$1 $2 $3");
|
|
426
|
+
}
|
|
427
|
+
if (network === "diners") {
|
|
428
|
+
return digits.replace(/^(\d{4})(\d{6})(\d{4})$/, "$1 $2 $3");
|
|
429
|
+
}
|
|
430
|
+
return digits.replace(/(\d{4})(?=\d)/g, "$1 ");
|
|
431
|
+
}
|
|
432
|
+
function isCardExpired(month, year) {
|
|
433
|
+
const m = parseInt(String(month), 10);
|
|
434
|
+
let y = parseInt(String(year), 10);
|
|
435
|
+
if (y < 100) y += 2e3;
|
|
436
|
+
const now = /* @__PURE__ */ new Date();
|
|
437
|
+
const expiry = new Date(y, m - 1, 1);
|
|
438
|
+
expiry.setMonth(expiry.getMonth() + 1);
|
|
439
|
+
return expiry <= now;
|
|
440
|
+
}
|
|
441
|
+
function validateCvv(cvv, network = "unknown") {
|
|
442
|
+
const digits = cvv.replace(/\D/g, "");
|
|
443
|
+
const expectedLength = network === "amex" ? 4 : 3;
|
|
444
|
+
return digits.length === expectedLength;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/events/index.ts
|
|
448
|
+
var JoopSubject = class {
|
|
449
|
+
_listeners = [];
|
|
450
|
+
subscribe(listener) {
|
|
451
|
+
this._listeners.push(listener);
|
|
452
|
+
return () => {
|
|
453
|
+
this._listeners = this._listeners.filter((l) => l !== listener);
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
next(value) {
|
|
457
|
+
for (const listener of this._listeners) {
|
|
458
|
+
listener(value);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
asObservable() {
|
|
462
|
+
return new JoopObservable((listener) => this.subscribe(listener));
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
var JoopBehaviorSubject = class extends JoopSubject {
|
|
466
|
+
_value;
|
|
467
|
+
constructor(initialValue) {
|
|
468
|
+
super();
|
|
469
|
+
this._value = initialValue;
|
|
470
|
+
}
|
|
471
|
+
getValue() {
|
|
472
|
+
return this._value;
|
|
473
|
+
}
|
|
474
|
+
next(value) {
|
|
475
|
+
this._value = value;
|
|
476
|
+
super.next(value);
|
|
477
|
+
}
|
|
478
|
+
subscribe(listener) {
|
|
479
|
+
listener(this._value);
|
|
480
|
+
return super.subscribe(listener);
|
|
481
|
+
}
|
|
482
|
+
asObservable() {
|
|
483
|
+
return new JoopObservable((listener) => this.subscribe(listener));
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
var JoopObservable = class {
|
|
487
|
+
constructor(_subscribeFn) {
|
|
488
|
+
this._subscribeFn = _subscribeFn;
|
|
489
|
+
}
|
|
490
|
+
_subscribeFn;
|
|
491
|
+
subscribe(listener) {
|
|
492
|
+
return this._subscribeFn(listener);
|
|
493
|
+
}
|
|
494
|
+
/** Returns the current value without subscribing (only meaningful for BehaviorSubject-backed observables). */
|
|
495
|
+
getOnce() {
|
|
496
|
+
let result;
|
|
497
|
+
const unsub = this.subscribe((v) => {
|
|
498
|
+
result = v;
|
|
499
|
+
});
|
|
500
|
+
unsub();
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// src/banking/fraud-detection.ts
|
|
506
|
+
function _haversine(lat1, lon1, lat2, lon2) {
|
|
507
|
+
const R = 6371;
|
|
508
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
509
|
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
510
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
|
511
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
512
|
+
}
|
|
513
|
+
function _scoreToLevel(score) {
|
|
514
|
+
if (score <= 30) return "low";
|
|
515
|
+
if (score <= 60) return "medium";
|
|
516
|
+
if (score <= 80) return "high";
|
|
517
|
+
return "critical";
|
|
518
|
+
}
|
|
519
|
+
function _levelToRecommendation(level) {
|
|
520
|
+
switch (level) {
|
|
521
|
+
case "low":
|
|
522
|
+
return "allow";
|
|
523
|
+
case "medium":
|
|
524
|
+
return "review";
|
|
525
|
+
case "high":
|
|
526
|
+
return "challenge";
|
|
527
|
+
case "critical":
|
|
528
|
+
return "block";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
var JoopFraudDetection = class {
|
|
532
|
+
_cfg;
|
|
533
|
+
_history = [];
|
|
534
|
+
_alertHandlers = [];
|
|
535
|
+
// Expose a reactive subject so callers can subscribe to every evaluation result
|
|
536
|
+
result$;
|
|
537
|
+
constructor(config = {}) {
|
|
538
|
+
this._cfg = {
|
|
539
|
+
velocityWindowMs: config.velocityWindowMs ?? 36e5,
|
|
540
|
+
velocityCountThreshold: config.velocityCountThreshold ?? 10,
|
|
541
|
+
velocityAmountThreshold: config.velocityAmountThreshold ?? 5e3,
|
|
542
|
+
largeAmountThreshold: config.largeAmountThreshold ?? 1e4,
|
|
543
|
+
geoDistanceThresholdKm: config.geoDistanceThresholdKm ?? 500,
|
|
544
|
+
alertThreshold: config.alertThreshold ?? 70,
|
|
545
|
+
maxHistorySize: config.maxHistorySize ?? 1e3
|
|
546
|
+
};
|
|
547
|
+
this.result$ = new JoopBehaviorSubject(null);
|
|
548
|
+
}
|
|
549
|
+
// -------------------------------------------------------------------------
|
|
550
|
+
// Public API
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
evaluate(signal) {
|
|
553
|
+
const signals = [
|
|
554
|
+
this._velocityCount(signal),
|
|
555
|
+
this._velocityAmount(signal),
|
|
556
|
+
this._largeAmount(signal),
|
|
557
|
+
this._geoDistance(signal),
|
|
558
|
+
this._timeAnomaly(signal)
|
|
559
|
+
];
|
|
560
|
+
const raw = signals.reduce((sum, s) => sum + s.score, 0);
|
|
561
|
+
const score = Math.min(100, Math.round(raw));
|
|
562
|
+
const level = _scoreToLevel(score);
|
|
563
|
+
const result = {
|
|
564
|
+
transactionId: signal.transactionId,
|
|
565
|
+
score,
|
|
566
|
+
level,
|
|
567
|
+
signals,
|
|
568
|
+
recommendation: _levelToRecommendation(level),
|
|
569
|
+
timestamp: Date.now()
|
|
570
|
+
};
|
|
571
|
+
this.result$.next(result);
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
addSignal(signal) {
|
|
575
|
+
this._history.push(signal);
|
|
576
|
+
if (this._history.length > this._cfg.maxHistorySize) {
|
|
577
|
+
this._history.shift();
|
|
578
|
+
}
|
|
579
|
+
const result = this.evaluate(signal);
|
|
580
|
+
if (result.score >= this._cfg.alertThreshold) {
|
|
581
|
+
for (const handler of this._alertHandlers) {
|
|
582
|
+
try {
|
|
583
|
+
handler(result);
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
clearHistory(userId) {
|
|
590
|
+
if (userId === void 0) {
|
|
591
|
+
this._history = [];
|
|
592
|
+
} else {
|
|
593
|
+
this._history = this._history.filter((s) => s.userId !== userId);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
onAlert(handler) {
|
|
597
|
+
this._alertHandlers.push(handler);
|
|
598
|
+
return () => {
|
|
599
|
+
this._alertHandlers = this._alertHandlers.filter((h) => h !== handler);
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// -------------------------------------------------------------------------
|
|
603
|
+
// Signal evaluators
|
|
604
|
+
// -------------------------------------------------------------------------
|
|
605
|
+
/** Velocity: count of transactions by same user/device in window — max 30 pts */
|
|
606
|
+
_velocityCount(signal) {
|
|
607
|
+
const windowStart = signal.timestamp - this._cfg.velocityWindowMs;
|
|
608
|
+
const recent = this._history.filter(
|
|
609
|
+
(s) => s.timestamp >= windowStart && (signal.userId ? s.userId === signal.userId : s.deviceId === signal.deviceId)
|
|
610
|
+
);
|
|
611
|
+
const count = recent.length;
|
|
612
|
+
const threshold = this._cfg.velocityCountThreshold;
|
|
613
|
+
if (count === 0) {
|
|
614
|
+
return { name: "velocity_count", score: 0, triggered: false };
|
|
615
|
+
}
|
|
616
|
+
const ratio = Math.min(count / threshold, 1);
|
|
617
|
+
const score = Math.round(ratio * 30);
|
|
618
|
+
return {
|
|
619
|
+
name: "velocity_count",
|
|
620
|
+
score,
|
|
621
|
+
triggered: count >= threshold,
|
|
622
|
+
detail: `${count} transactions in window (threshold ${threshold})`
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** Velocity: total amount by same user/device in window — max 25 pts */
|
|
626
|
+
_velocityAmount(signal) {
|
|
627
|
+
const windowStart = signal.timestamp - this._cfg.velocityWindowMs;
|
|
628
|
+
const recent = this._history.filter(
|
|
629
|
+
(s) => s.timestamp >= windowStart && s.currency === signal.currency && (signal.userId ? s.userId === signal.userId : s.deviceId === signal.deviceId)
|
|
630
|
+
);
|
|
631
|
+
const total = recent.reduce((sum, s) => sum + s.amount, 0) + signal.amount;
|
|
632
|
+
const threshold = this._cfg.velocityAmountThreshold;
|
|
633
|
+
if (total <= 0) {
|
|
634
|
+
return { name: "velocity_amount", score: 0, triggered: false };
|
|
635
|
+
}
|
|
636
|
+
const ratio = Math.min(total / threshold, 1);
|
|
637
|
+
const score = Math.round(ratio * 25);
|
|
638
|
+
return {
|
|
639
|
+
name: "velocity_amount",
|
|
640
|
+
score,
|
|
641
|
+
triggered: total >= threshold,
|
|
642
|
+
detail: `${total.toFixed(2)} ${signal.currency} in window (threshold ${threshold})`
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Large single amount — max 20 pts */
|
|
646
|
+
_largeAmount(signal) {
|
|
647
|
+
const threshold = this._cfg.largeAmountThreshold;
|
|
648
|
+
if (signal.amount < threshold) {
|
|
649
|
+
return { name: "large_amount", score: 0, triggered: false };
|
|
650
|
+
}
|
|
651
|
+
const ratio = Math.min(signal.amount / (threshold * 3), 1);
|
|
652
|
+
const score = Math.round(10 + ratio * 10);
|
|
653
|
+
return {
|
|
654
|
+
name: "large_amount",
|
|
655
|
+
score,
|
|
656
|
+
triggered: true,
|
|
657
|
+
detail: `Amount ${signal.amount} ${signal.currency} exceeds threshold ${threshold}`
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** Geo distance from last transaction by same user — max 15 pts */
|
|
661
|
+
_geoDistance(signal) {
|
|
662
|
+
if (signal.latitude === void 0 || signal.longitude === void 0) {
|
|
663
|
+
return { name: "geo_distance", score: 0, triggered: false };
|
|
664
|
+
}
|
|
665
|
+
const previous = [...this._history].reverse().find(
|
|
666
|
+
(s) => s.latitude !== void 0 && s.longitude !== void 0 && (signal.userId ? s.userId === signal.userId : s.deviceId === signal.deviceId)
|
|
667
|
+
);
|
|
668
|
+
if (!previous || previous.latitude === void 0 || previous.longitude === void 0) {
|
|
669
|
+
return { name: "geo_distance", score: 0, triggered: false };
|
|
670
|
+
}
|
|
671
|
+
const km = _haversine(previous.latitude, previous.longitude, signal.latitude, signal.longitude);
|
|
672
|
+
const threshold = this._cfg.geoDistanceThresholdKm;
|
|
673
|
+
if (km < threshold) {
|
|
674
|
+
return { name: "geo_distance", score: 0, triggered: false };
|
|
675
|
+
}
|
|
676
|
+
const ratio = Math.min(km / (threshold * 4), 1);
|
|
677
|
+
const score = Math.round(ratio * 15);
|
|
678
|
+
return {
|
|
679
|
+
name: "geo_distance",
|
|
680
|
+
score,
|
|
681
|
+
triggered: true,
|
|
682
|
+
detail: `${km.toFixed(0)} km from last transaction (threshold ${threshold} km)`
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** Time anomaly: unusual hours 02:00–05:00 local time — max 10 pts */
|
|
686
|
+
_timeAnomaly(signal) {
|
|
687
|
+
const hour = new Date(signal.timestamp).getHours();
|
|
688
|
+
if (hour >= 2 && hour < 5) {
|
|
689
|
+
return {
|
|
690
|
+
name: "time_anomaly",
|
|
691
|
+
score: 10,
|
|
692
|
+
triggered: true,
|
|
693
|
+
detail: `Transaction at unusual hour ${hour}:xx (02\u201305 local)`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return { name: "time_anomaly", score: 0, triggered: false };
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/banking/open-banking.ts
|
|
701
|
+
var JoopOpenBankingClient = class {
|
|
702
|
+
_credentials;
|
|
703
|
+
_token = null;
|
|
704
|
+
/** Reactive authentication state — true once a valid token is held */
|
|
705
|
+
authenticated$;
|
|
706
|
+
constructor(credentials) {
|
|
707
|
+
this._credentials = credentials;
|
|
708
|
+
this.authenticated$ = new JoopBehaviorSubject(false);
|
|
709
|
+
}
|
|
710
|
+
// -------------------------------------------------------------------------
|
|
711
|
+
// Authentication
|
|
712
|
+
// -------------------------------------------------------------------------
|
|
713
|
+
async authenticate() {
|
|
714
|
+
const token = await this._fetchToken();
|
|
715
|
+
this._token = token;
|
|
716
|
+
this.authenticated$.next(true);
|
|
717
|
+
}
|
|
718
|
+
// -------------------------------------------------------------------------
|
|
719
|
+
// Accounts & balances
|
|
720
|
+
// -------------------------------------------------------------------------
|
|
721
|
+
async getAccounts(consentId) {
|
|
722
|
+
const raw = await this._request("GET", "/accounts", void 0, consentId);
|
|
723
|
+
return (raw.Data.Account ?? []).map((a) => ({
|
|
724
|
+
accountId: a.AccountId,
|
|
725
|
+
iban: a.Account?.[0]?.Identification,
|
|
726
|
+
currency: a.Currency,
|
|
727
|
+
accountType: a.AccountType ?? "Personal",
|
|
728
|
+
nickname: a.Nickname
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
// -------------------------------------------------------------------------
|
|
732
|
+
// Transactions
|
|
733
|
+
// -------------------------------------------------------------------------
|
|
734
|
+
async getTransactions(accountId, consentId, fromDate, toDate) {
|
|
735
|
+
const params = new URLSearchParams();
|
|
736
|
+
if (fromDate) params.set("fromBookingDateTime", fromDate);
|
|
737
|
+
if (toDate) params.set("toBookingDateTime", toDate);
|
|
738
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
739
|
+
const raw = await this._request(
|
|
740
|
+
"GET",
|
|
741
|
+
`/accounts/${accountId}/transactions${query}`,
|
|
742
|
+
void 0,
|
|
743
|
+
consentId
|
|
744
|
+
);
|
|
745
|
+
return (raw.Data.Transaction ?? []).map((t) => ({
|
|
746
|
+
transactionId: t.TransactionId,
|
|
747
|
+
accountId: t.AccountId,
|
|
748
|
+
amount: parseFloat(t.Amount.Amount),
|
|
749
|
+
currency: t.Amount.Currency,
|
|
750
|
+
creditDebitIndicator: t.CreditDebitIndicator,
|
|
751
|
+
status: t.Status,
|
|
752
|
+
bookingDateTime: t.BookingDateTime,
|
|
753
|
+
description: t.TransactionInformation,
|
|
754
|
+
merchantName: t.MerchantDetails?.MerchantName,
|
|
755
|
+
reference: t.Reference
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
// -------------------------------------------------------------------------
|
|
759
|
+
// Consents
|
|
760
|
+
// -------------------------------------------------------------------------
|
|
761
|
+
async createConsent(request) {
|
|
762
|
+
const body = {
|
|
763
|
+
Data: {
|
|
764
|
+
Permissions: request.scopes,
|
|
765
|
+
...request.expirationDateTime ? { ExpirationDateTime: request.expirationDateTime } : {}
|
|
766
|
+
},
|
|
767
|
+
Risk: {}
|
|
768
|
+
};
|
|
769
|
+
const raw = await this._request("POST", "/account-access-consents", body);
|
|
770
|
+
const consentId = raw.Data.ConsentId;
|
|
771
|
+
const authorisationUrl = `${this._credentials.baseUrl.replace(/\/open-banking.*$/, "")}/oauth2/authorize?response_type=code%20id_token&client_id=${encodeURIComponent(this._credentials.clientId)}&request=${encodeURIComponent(consentId)}`;
|
|
772
|
+
return {
|
|
773
|
+
consentId,
|
|
774
|
+
authorisationUrl,
|
|
775
|
+
status: raw.Data.Status
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
async getConsentStatus(consentId) {
|
|
779
|
+
const raw = await this._request(
|
|
780
|
+
"GET",
|
|
781
|
+
`/account-access-consents/${consentId}`
|
|
782
|
+
);
|
|
783
|
+
return raw.Data.Status;
|
|
784
|
+
}
|
|
785
|
+
async revokeConsent(consentId) {
|
|
786
|
+
await this._request("DELETE", `/account-access-consents/${consentId}`);
|
|
787
|
+
}
|
|
788
|
+
// -------------------------------------------------------------------------
|
|
789
|
+
// Payments
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
async initiatePayment(request) {
|
|
792
|
+
const consentBody = {
|
|
793
|
+
Data: {
|
|
794
|
+
Initiation: _buildInitiation(request)
|
|
795
|
+
},
|
|
796
|
+
Risk: { PaymentContextCode: "TransferToThirdParty" }
|
|
797
|
+
};
|
|
798
|
+
const consentRaw = await this._request(
|
|
799
|
+
"POST",
|
|
800
|
+
"/domestic-payment-consents",
|
|
801
|
+
consentBody
|
|
802
|
+
);
|
|
803
|
+
const consentId = consentRaw.Data.ConsentId;
|
|
804
|
+
const paymentBody = {
|
|
805
|
+
Data: {
|
|
806
|
+
ConsentId: consentId,
|
|
807
|
+
Initiation: _buildInitiation(request)
|
|
808
|
+
},
|
|
809
|
+
Risk: { PaymentContextCode: "TransferToThirdParty" }
|
|
810
|
+
};
|
|
811
|
+
const paymentRaw = await this._request(
|
|
812
|
+
"POST",
|
|
813
|
+
"/domestic-payments",
|
|
814
|
+
paymentBody
|
|
815
|
+
);
|
|
816
|
+
return {
|
|
817
|
+
paymentId: paymentRaw.Data.DomesticPaymentId,
|
|
818
|
+
status: paymentRaw.Data.Status,
|
|
819
|
+
consentId
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
async getPaymentStatus(paymentId) {
|
|
823
|
+
const raw = await this._request(
|
|
824
|
+
"GET",
|
|
825
|
+
`/domestic-payments/${paymentId}`
|
|
826
|
+
);
|
|
827
|
+
return raw.Data.Status;
|
|
828
|
+
}
|
|
829
|
+
async cancelPayment(paymentId) {
|
|
830
|
+
await this._request("DELETE", `/domestic-payments/${paymentId}`);
|
|
831
|
+
}
|
|
832
|
+
// -------------------------------------------------------------------------
|
|
833
|
+
// Private helpers
|
|
834
|
+
// -------------------------------------------------------------------------
|
|
835
|
+
async _getToken() {
|
|
836
|
+
if (this._token && Date.now() < this._token.expiresAt - 3e4) {
|
|
837
|
+
return this._token.accessToken;
|
|
838
|
+
}
|
|
839
|
+
const state = await this._fetchToken();
|
|
840
|
+
this._token = state;
|
|
841
|
+
this.authenticated$.next(true);
|
|
842
|
+
return state.accessToken;
|
|
843
|
+
}
|
|
844
|
+
async _fetchToken() {
|
|
845
|
+
const body = new URLSearchParams({
|
|
846
|
+
grant_type: "client_credentials",
|
|
847
|
+
client_id: this._credentials.clientId,
|
|
848
|
+
client_secret: this._credentials.clientSecret,
|
|
849
|
+
scope: "accounts payments"
|
|
850
|
+
});
|
|
851
|
+
const res = await fetch(this._credentials.tokenUrl, {
|
|
852
|
+
method: "POST",
|
|
853
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
854
|
+
body: body.toString()
|
|
855
|
+
});
|
|
856
|
+
if (!res.ok) {
|
|
857
|
+
throw new Error(`[JoopOpenBankingClient] Token request failed: ${res.status} ${res.statusText}`);
|
|
858
|
+
}
|
|
859
|
+
const json = await res.json();
|
|
860
|
+
return {
|
|
861
|
+
accessToken: json.access_token,
|
|
862
|
+
expiresAt: Date.now() + json.expires_in * 1e3
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async _request(method, path, body, consentId) {
|
|
866
|
+
const token = await this._getToken();
|
|
867
|
+
const url = `${this._credentials.baseUrl}${path}`;
|
|
868
|
+
const headers = {
|
|
869
|
+
"Authorization": `Bearer ${token}`,
|
|
870
|
+
"Content-Type": "application/json",
|
|
871
|
+
"x-fapi-interaction-id": _generateInteractionId(),
|
|
872
|
+
"x-fapi-financial-id": this._credentials.clientId
|
|
873
|
+
};
|
|
874
|
+
if (consentId) {
|
|
875
|
+
headers["x-ob-consent-id"] = consentId;
|
|
876
|
+
}
|
|
877
|
+
const res = await fetch(url, {
|
|
878
|
+
method,
|
|
879
|
+
headers,
|
|
880
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
881
|
+
});
|
|
882
|
+
if (!res.ok) {
|
|
883
|
+
const errorText = await res.text().catch(() => "");
|
|
884
|
+
throw new Error(
|
|
885
|
+
`[JoopOpenBankingClient] ${method} ${path} failed: ${res.status} ${res.statusText} \u2014 ${errorText}`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
if (res.status === 204) return void 0;
|
|
889
|
+
return res.json();
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
function _buildInitiation(req) {
|
|
893
|
+
return {
|
|
894
|
+
InstructionIdentification: req.reference,
|
|
895
|
+
EndToEndIdentification: req.reference,
|
|
896
|
+
InstructedAmount: {
|
|
897
|
+
Amount: req.amount.toFixed(2),
|
|
898
|
+
Currency: req.currency
|
|
899
|
+
},
|
|
900
|
+
CreditorAccount: {
|
|
901
|
+
SchemeName: "UK.OBIE.IBAN",
|
|
902
|
+
Identification: req.creditorIban,
|
|
903
|
+
Name: req.creditorName
|
|
904
|
+
},
|
|
905
|
+
...req.debtorIban ? {
|
|
906
|
+
DebtorAccount: {
|
|
907
|
+
SchemeName: "UK.OBIE.IBAN",
|
|
908
|
+
Identification: req.debtorIban
|
|
909
|
+
}
|
|
910
|
+
} : {},
|
|
911
|
+
...req.remittanceInfo ? { RemittanceInformation: { Unstructured: req.remittanceInfo } } : {}
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function _generateInteractionId() {
|
|
915
|
+
const bytes = new Uint8Array(16);
|
|
916
|
+
crypto.getRandomValues(bytes);
|
|
917
|
+
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
918
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/banking/payment-orchestrator.ts
|
|
922
|
+
var _TERMINAL_STEPS = /* @__PURE__ */ new Set(["authorised", "settled", "failed", "cancelled"]);
|
|
923
|
+
var JoopPaymentOrchestrator = class {
|
|
924
|
+
_cfg;
|
|
925
|
+
_sessions = /* @__PURE__ */ new Map();
|
|
926
|
+
constructor(config) {
|
|
927
|
+
this._cfg = {
|
|
928
|
+
gatewayUrl: config.gatewayUrl,
|
|
929
|
+
merchantId: config.merchantId,
|
|
930
|
+
apiKey: config.apiKey,
|
|
931
|
+
timeoutMs: config.timeoutMs ?? 3e4,
|
|
932
|
+
pollIntervalMs: config.pollIntervalMs ?? 2e3
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
// -------------------------------------------------------------------------
|
|
936
|
+
// Public API
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
938
|
+
async initiate(request) {
|
|
939
|
+
const sessionId = this._generateSessionId();
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
const maskedCard = `****${request.cardNumber.replace(/\s/g, "").slice(-4)}`;
|
|
942
|
+
const session = {
|
|
943
|
+
sessionId,
|
|
944
|
+
paymentId: "",
|
|
945
|
+
amount: request.amount,
|
|
946
|
+
currency: request.currency,
|
|
947
|
+
step: "initiating",
|
|
948
|
+
createdAt: now,
|
|
949
|
+
updatedAt: now
|
|
950
|
+
};
|
|
951
|
+
const subject = new JoopBehaviorSubject(session);
|
|
952
|
+
this._sessions.set(sessionId, subject);
|
|
953
|
+
try {
|
|
954
|
+
const raw = await this._fetch("POST", "/payments", {
|
|
955
|
+
amount: request.amount,
|
|
956
|
+
currency: request.currency,
|
|
957
|
+
cardNumber: maskedCard,
|
|
958
|
+
// send masked to gateway after capture
|
|
959
|
+
cardExpiry: request.cardExpiry,
|
|
960
|
+
cvv: request.cvv,
|
|
961
|
+
merchantId: request.merchantId,
|
|
962
|
+
reference: request.reference,
|
|
963
|
+
returnUrl: request.returnUrl,
|
|
964
|
+
notifyUrl: request.notifyUrl,
|
|
965
|
+
metadata: request.metadata
|
|
966
|
+
});
|
|
967
|
+
const updated = this._updateSession(subject, {
|
|
968
|
+
paymentId: raw.paymentId,
|
|
969
|
+
step: raw.requires3ds ? "awaiting_3ds" : "authorised",
|
|
970
|
+
threeDsUrl: raw.threeDsUrl,
|
|
971
|
+
threeDsData: raw.threeDsData,
|
|
972
|
+
errorCode: raw.errorCode,
|
|
973
|
+
errorMessage: raw.errorMessage
|
|
974
|
+
});
|
|
975
|
+
return updated;
|
|
976
|
+
} catch (err) {
|
|
977
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
978
|
+
return this._updateSession(subject, {
|
|
979
|
+
step: "failed",
|
|
980
|
+
errorMessage: errMsg
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async handle3ds(sessionId, result) {
|
|
985
|
+
const subject = this._requireSession(sessionId);
|
|
986
|
+
const current = subject.getValue();
|
|
987
|
+
if (result.status === "N") {
|
|
988
|
+
return this._updateSession(subject, {
|
|
989
|
+
step: "failed",
|
|
990
|
+
errorCode: "AUTH_FAILED_3DS",
|
|
991
|
+
errorMessage: "3DS authentication was declined by the card issuer."
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
this._updateSession(subject, { step: "processing_3ds" });
|
|
995
|
+
try {
|
|
996
|
+
const raw = await this._fetch(
|
|
997
|
+
"POST",
|
|
998
|
+
`/payments/${current.paymentId}/3ds`,
|
|
999
|
+
{
|
|
1000
|
+
transactionId: result.transactionId,
|
|
1001
|
+
status: result.status,
|
|
1002
|
+
eci: result.eci,
|
|
1003
|
+
cavv: result.cavv
|
|
1004
|
+
}
|
|
1005
|
+
);
|
|
1006
|
+
return this._updateSession(subject, {
|
|
1007
|
+
step: _mapGatewayStatus(raw.status),
|
|
1008
|
+
errorCode: raw.errorCode,
|
|
1009
|
+
errorMessage: raw.errorMessage
|
|
1010
|
+
});
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1013
|
+
return this._updateSession(subject, { step: "failed", errorMessage: errMsg });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async poll(sessionId) {
|
|
1017
|
+
const subject = this._requireSession(sessionId);
|
|
1018
|
+
const current = subject.getValue();
|
|
1019
|
+
if (_TERMINAL_STEPS.has(current.step)) {
|
|
1020
|
+
return current;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
const raw = await this._fetch(
|
|
1024
|
+
"GET",
|
|
1025
|
+
`/payments/${current.paymentId}`
|
|
1026
|
+
);
|
|
1027
|
+
return this._updateSession(subject, {
|
|
1028
|
+
step: _mapGatewayStatus(raw.status),
|
|
1029
|
+
errorCode: raw.errorCode,
|
|
1030
|
+
errorMessage: raw.errorMessage
|
|
1031
|
+
});
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1034
|
+
return this._updateSession(subject, { step: "failed", errorMessage: errMsg });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async confirm(sessionId) {
|
|
1038
|
+
const subject = this._requireSession(sessionId);
|
|
1039
|
+
const current = subject.getValue();
|
|
1040
|
+
if (_TERMINAL_STEPS.has(current.step)) {
|
|
1041
|
+
return current;
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
const raw = await this._fetch(
|
|
1045
|
+
"POST",
|
|
1046
|
+
`/payments/${current.paymentId}/confirm`
|
|
1047
|
+
);
|
|
1048
|
+
return this._updateSession(subject, {
|
|
1049
|
+
step: _mapGatewayStatus(raw.status),
|
|
1050
|
+
errorCode: raw.errorCode,
|
|
1051
|
+
errorMessage: raw.errorMessage
|
|
1052
|
+
});
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1055
|
+
return this._updateSession(subject, { step: "failed", errorMessage: errMsg });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
async cancel(sessionId) {
|
|
1059
|
+
const subject = this._requireSession(sessionId);
|
|
1060
|
+
const current = subject.getValue();
|
|
1061
|
+
if (_TERMINAL_STEPS.has(current.step)) {
|
|
1062
|
+
return current;
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
await this._fetch("DELETE", `/payments/${current.paymentId}`);
|
|
1066
|
+
return this._updateSession(subject, { step: "cancelled" });
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1069
|
+
return this._updateSession(subject, { step: "failed", errorMessage: errMsg });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
getSession(sessionId) {
|
|
1073
|
+
return this._sessions.get(sessionId)?.getValue() ?? null;
|
|
1074
|
+
}
|
|
1075
|
+
session$(sessionId) {
|
|
1076
|
+
const existing = this._sessions.get(sessionId);
|
|
1077
|
+
if (existing) return existing.asObservable();
|
|
1078
|
+
return new JoopObservable(() => () => {
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
destroy() {
|
|
1082
|
+
this._sessions.clear();
|
|
1083
|
+
}
|
|
1084
|
+
// -------------------------------------------------------------------------
|
|
1085
|
+
// Private helpers
|
|
1086
|
+
// -------------------------------------------------------------------------
|
|
1087
|
+
_requireSession(sessionId) {
|
|
1088
|
+
const subject = this._sessions.get(sessionId);
|
|
1089
|
+
if (!subject) throw new Error(`[JoopPaymentOrchestrator] Unknown sessionId: ${sessionId}`);
|
|
1090
|
+
return subject;
|
|
1091
|
+
}
|
|
1092
|
+
_updateSession(subject, patch) {
|
|
1093
|
+
const next = { ...subject.getValue(), ...patch, updatedAt: Date.now() };
|
|
1094
|
+
subject.next(next);
|
|
1095
|
+
return next;
|
|
1096
|
+
}
|
|
1097
|
+
_generateSessionId() {
|
|
1098
|
+
const bytes = new Uint8Array(18);
|
|
1099
|
+
crypto.getRandomValues(bytes);
|
|
1100
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1101
|
+
}
|
|
1102
|
+
async _fetch(method, path, body) {
|
|
1103
|
+
const url = `${this._cfg.gatewayUrl}${path}`;
|
|
1104
|
+
const controller = new AbortController();
|
|
1105
|
+
const timer = setTimeout(() => controller.abort(), this._cfg.timeoutMs);
|
|
1106
|
+
try {
|
|
1107
|
+
const res = await fetch(url, {
|
|
1108
|
+
method,
|
|
1109
|
+
headers: {
|
|
1110
|
+
"Authorization": `Bearer ${this._cfg.apiKey}`,
|
|
1111
|
+
"Content-Type": "application/json",
|
|
1112
|
+
"X-Merchant-Id": this._cfg.merchantId
|
|
1113
|
+
},
|
|
1114
|
+
signal: controller.signal,
|
|
1115
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
1116
|
+
});
|
|
1117
|
+
if (!res.ok) {
|
|
1118
|
+
const text = await res.text().catch(() => "");
|
|
1119
|
+
throw new Error(`[JoopPaymentOrchestrator] ${method} ${path}: ${res.status} \u2014 ${text}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (res.status === 204) return void 0;
|
|
1122
|
+
return res.json();
|
|
1123
|
+
} finally {
|
|
1124
|
+
clearTimeout(timer);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
function _mapGatewayStatus(status) {
|
|
1129
|
+
switch (status?.toLowerCase()) {
|
|
1130
|
+
case "authorised":
|
|
1131
|
+
case "authorized":
|
|
1132
|
+
return "authorised";
|
|
1133
|
+
case "settled":
|
|
1134
|
+
case "captured":
|
|
1135
|
+
return "settled";
|
|
1136
|
+
case "failed":
|
|
1137
|
+
case "declined":
|
|
1138
|
+
case "error":
|
|
1139
|
+
return "failed";
|
|
1140
|
+
case "cancelled":
|
|
1141
|
+
case "canceled":
|
|
1142
|
+
case "voided":
|
|
1143
|
+
return "cancelled";
|
|
1144
|
+
case "pending":
|
|
1145
|
+
case "processing":
|
|
1146
|
+
return "processing_3ds";
|
|
1147
|
+
default:
|
|
1148
|
+
return "processing_3ds";
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// src/banking/statement-parser.ts
|
|
1153
|
+
var JoopStatementParser = class _JoopStatementParser {
|
|
1154
|
+
// -------------------------------------------------------------------------
|
|
1155
|
+
// Auto-detect and dispatch
|
|
1156
|
+
// -------------------------------------------------------------------------
|
|
1157
|
+
parse(text, csvConfig) {
|
|
1158
|
+
const format = _JoopStatementParser.detectFormat(text);
|
|
1159
|
+
switch (format) {
|
|
1160
|
+
case "MT940":
|
|
1161
|
+
return this.parseMT940(text);
|
|
1162
|
+
case "OFX":
|
|
1163
|
+
return this.parseOFX(text);
|
|
1164
|
+
case "QIF":
|
|
1165
|
+
return this.parseQIF(text);
|
|
1166
|
+
case "CSV":
|
|
1167
|
+
return this.parseCsv(text, csvConfig);
|
|
1168
|
+
default:
|
|
1169
|
+
return {
|
|
1170
|
+
format: "unknown",
|
|
1171
|
+
transactions: [],
|
|
1172
|
+
parseErrors: ["Unable to detect statement format."]
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
static detectFormat(text) {
|
|
1177
|
+
const trimmed = text.trimStart();
|
|
1178
|
+
if (trimmed.startsWith(":20:") || trimmed.includes("\n:20:")) return "MT940";
|
|
1179
|
+
if (trimmed.includes("OFXHEADER:") || trimmed.includes("<OFX>")) return "OFX";
|
|
1180
|
+
if (/^[DTPMNLC!\^]/m.test(trimmed) && trimmed.includes("^")) return "QIF";
|
|
1181
|
+
const firstLines = trimmed.split("\n").slice(0, 5).join("\n");
|
|
1182
|
+
if (/[,;\t]/.test(firstLines)) return "CSV";
|
|
1183
|
+
return "unknown";
|
|
1184
|
+
}
|
|
1185
|
+
// -------------------------------------------------------------------------
|
|
1186
|
+
// MT940
|
|
1187
|
+
// -------------------------------------------------------------------------
|
|
1188
|
+
parseMT940(text) {
|
|
1189
|
+
const result = {
|
|
1190
|
+
format: "MT940",
|
|
1191
|
+
transactions: [],
|
|
1192
|
+
parseErrors: []
|
|
1193
|
+
};
|
|
1194
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1195
|
+
const fieldRegex = /:(\d{2}[A-Z]?):([\s\S]*?)(?=\n:\d{2}[A-Z]?:|$)/g;
|
|
1196
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1197
|
+
let m;
|
|
1198
|
+
while ((m = fieldRegex.exec(lines)) !== null) {
|
|
1199
|
+
const tag = m[1];
|
|
1200
|
+
const value = m[2].trim();
|
|
1201
|
+
const existing = fields.get(tag) ?? [];
|
|
1202
|
+
existing.push(value);
|
|
1203
|
+
fields.set(tag, existing);
|
|
1204
|
+
}
|
|
1205
|
+
const acct = fields.get("25")?.[0];
|
|
1206
|
+
if (acct) {
|
|
1207
|
+
result.accountId = acct.includes("/") ? acct.split("/")[1] : acct;
|
|
1208
|
+
}
|
|
1209
|
+
const ob = fields.get("60F")?.[0] ?? fields.get("60M")?.[0];
|
|
1210
|
+
if (ob) {
|
|
1211
|
+
const parsed = _parseMT940Balance(ob);
|
|
1212
|
+
if (parsed) {
|
|
1213
|
+
result.currency = parsed.currency;
|
|
1214
|
+
result.openingBalance = parsed.amount;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const cb = fields.get("62F")?.[0] ?? fields.get("62M")?.[0];
|
|
1218
|
+
if (cb) {
|
|
1219
|
+
const parsed = _parseMT940Balance(cb);
|
|
1220
|
+
if (parsed) result.closingBalance = parsed.amount;
|
|
1221
|
+
}
|
|
1222
|
+
const tag61 = fields.get("61") ?? [];
|
|
1223
|
+
const tag86 = fields.get("86") ?? [];
|
|
1224
|
+
tag61.forEach((line61, idx) => {
|
|
1225
|
+
const narrative = tag86[idx] ?? "";
|
|
1226
|
+
try {
|
|
1227
|
+
const txn = _parseMT940Transaction(line61, narrative, result.accountId, result.currency, idx);
|
|
1228
|
+
if (txn) result.transactions.push(txn);
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
result.parseErrors.push(`MT940 :61: index ${idx}: ${String(e)}`);
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
return result;
|
|
1234
|
+
}
|
|
1235
|
+
// -------------------------------------------------------------------------
|
|
1236
|
+
// OFX / QFX (SGML format)
|
|
1237
|
+
// -------------------------------------------------------------------------
|
|
1238
|
+
parseOFX(text) {
|
|
1239
|
+
const result = {
|
|
1240
|
+
format: "OFX",
|
|
1241
|
+
transactions: [],
|
|
1242
|
+
parseErrors: []
|
|
1243
|
+
};
|
|
1244
|
+
const tag = (name) => {
|
|
1245
|
+
const re = new RegExp(`<${name}>([^<\\n\\r]*)`, "i");
|
|
1246
|
+
return re.exec(text)?.[1]?.trim();
|
|
1247
|
+
};
|
|
1248
|
+
result.accountId = tag("ACCTID") ?? tag("BANKID");
|
|
1249
|
+
result.currency = tag("CURDEF") ?? tag("CURRENCY");
|
|
1250
|
+
const ledgerBal = tag("BALAMT");
|
|
1251
|
+
if (ledgerBal !== void 0) result.closingBalance = parseFloat(ledgerBal);
|
|
1252
|
+
const txnBlockRe = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi;
|
|
1253
|
+
let blockMatch;
|
|
1254
|
+
let idx = 0;
|
|
1255
|
+
while ((blockMatch = txnBlockRe.exec(text)) !== null) {
|
|
1256
|
+
const block = blockMatch[1];
|
|
1257
|
+
const bTag = (name) => {
|
|
1258
|
+
const re = new RegExp(`<${name}>([^<\\n\\r]*)`, "i");
|
|
1259
|
+
return re.exec(block)?.[1]?.trim();
|
|
1260
|
+
};
|
|
1261
|
+
try {
|
|
1262
|
+
const trnType = bTag("TRNTYPE") ?? "";
|
|
1263
|
+
const dtPosted = bTag("DTPOSTED") ?? "";
|
|
1264
|
+
const dtAvail = bTag("DTAVAIL");
|
|
1265
|
+
const amtStr = bTag("TRNAMT") ?? "0";
|
|
1266
|
+
const fitId = bTag("FITID") ?? String(idx);
|
|
1267
|
+
const name = bTag("NAME");
|
|
1268
|
+
const memo = bTag("MEMO");
|
|
1269
|
+
const refNum = bTag("REFNUM") ?? bTag("CHECKNUM");
|
|
1270
|
+
const rawAmount = parseFloat(amtStr);
|
|
1271
|
+
const amount = Math.abs(rawAmount);
|
|
1272
|
+
const type = _ofxTrnType(trnType, rawAmount);
|
|
1273
|
+
const date = _parseOFXDate(dtPosted);
|
|
1274
|
+
const valueDate = dtAvail ? _parseOFXDate(dtAvail) : void 0;
|
|
1275
|
+
const txn = {
|
|
1276
|
+
id: _stableId(result.accountId, date, amount, idx),
|
|
1277
|
+
date,
|
|
1278
|
+
valueDate,
|
|
1279
|
+
amount,
|
|
1280
|
+
type,
|
|
1281
|
+
currency: result.currency ?? "USD",
|
|
1282
|
+
description: memo ?? name ?? "",
|
|
1283
|
+
reference: refNum,
|
|
1284
|
+
counterpartyName: name,
|
|
1285
|
+
raw: block
|
|
1286
|
+
};
|
|
1287
|
+
result.transactions.push(txn);
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
result.parseErrors.push(`OFX STMTTRN index ${idx}: ${String(e)}`);
|
|
1290
|
+
}
|
|
1291
|
+
idx++;
|
|
1292
|
+
}
|
|
1293
|
+
return result;
|
|
1294
|
+
}
|
|
1295
|
+
// -------------------------------------------------------------------------
|
|
1296
|
+
// QIF (Quicken Interchange Format)
|
|
1297
|
+
// -------------------------------------------------------------------------
|
|
1298
|
+
parseQIF(text) {
|
|
1299
|
+
const result = {
|
|
1300
|
+
format: "QIF",
|
|
1301
|
+
transactions: [],
|
|
1302
|
+
parseErrors: []
|
|
1303
|
+
};
|
|
1304
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
1305
|
+
let current = {};
|
|
1306
|
+
let idx = 0;
|
|
1307
|
+
for (const line of lines) {
|
|
1308
|
+
if (line.startsWith("!")) continue;
|
|
1309
|
+
if (line === "^") {
|
|
1310
|
+
if (current.date !== void 0 && current.amount !== void 0) {
|
|
1311
|
+
try {
|
|
1312
|
+
const amount = Math.abs(current.amount);
|
|
1313
|
+
const type = current.amount < 0 ? "debit" : "credit";
|
|
1314
|
+
const txn = {
|
|
1315
|
+
id: _stableId(void 0, current.date, amount, idx),
|
|
1316
|
+
date: current.date,
|
|
1317
|
+
amount,
|
|
1318
|
+
type,
|
|
1319
|
+
currency: "USD",
|
|
1320
|
+
// QIF doesn't carry currency
|
|
1321
|
+
description: current.memo ?? current.payee ?? "",
|
|
1322
|
+
reference: current.ref,
|
|
1323
|
+
counterpartyName: current.payee,
|
|
1324
|
+
raw: void 0
|
|
1325
|
+
};
|
|
1326
|
+
result.transactions.push(txn);
|
|
1327
|
+
} catch (e) {
|
|
1328
|
+
result.parseErrors.push(`QIF record ${idx}: ${String(e)}`);
|
|
1329
|
+
}
|
|
1330
|
+
idx++;
|
|
1331
|
+
}
|
|
1332
|
+
current = {};
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
const code = line[0];
|
|
1336
|
+
const value = line.slice(1).trim();
|
|
1337
|
+
switch (code) {
|
|
1338
|
+
case "D":
|
|
1339
|
+
current.date = _parseQIFDate(value);
|
|
1340
|
+
break;
|
|
1341
|
+
case "T":
|
|
1342
|
+
current.amount = parseFloat(value.replace(/,/g, ""));
|
|
1343
|
+
break;
|
|
1344
|
+
case "P":
|
|
1345
|
+
current.payee = value;
|
|
1346
|
+
break;
|
|
1347
|
+
case "M":
|
|
1348
|
+
current.memo = value;
|
|
1349
|
+
break;
|
|
1350
|
+
case "N":
|
|
1351
|
+
current.ref = value;
|
|
1352
|
+
break;
|
|
1353
|
+
case "L":
|
|
1354
|
+
break;
|
|
1355
|
+
case "C":
|
|
1356
|
+
current.cleared = value;
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return result;
|
|
1361
|
+
}
|
|
1362
|
+
// -------------------------------------------------------------------------
|
|
1363
|
+
// CSV
|
|
1364
|
+
// -------------------------------------------------------------------------
|
|
1365
|
+
parseCsv(text, config = {}) {
|
|
1366
|
+
const result = {
|
|
1367
|
+
format: "CSV",
|
|
1368
|
+
transactions: [],
|
|
1369
|
+
parseErrors: []
|
|
1370
|
+
};
|
|
1371
|
+
const delimiter = config.delimiter ?? _detectDelimiter(text);
|
|
1372
|
+
const hasHeader = config.hasHeader ?? true;
|
|
1373
|
+
const skipRows = config.skipRows ?? 0;
|
|
1374
|
+
const rawRows = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").filter((l) => l.trim().length > 0);
|
|
1375
|
+
const allRows = rawRows.slice(skipRows);
|
|
1376
|
+
if (allRows.length === 0) {
|
|
1377
|
+
result.parseErrors.push("CSV: no data rows found.");
|
|
1378
|
+
return result;
|
|
1379
|
+
}
|
|
1380
|
+
const headerRow = hasHeader ? _splitCsvRow(allRows[0], delimiter) : void 0;
|
|
1381
|
+
const dataRows = hasHeader ? allRows.slice(1) : allRows;
|
|
1382
|
+
const resolve = (col) => {
|
|
1383
|
+
if (col === void 0) return -1;
|
|
1384
|
+
if (typeof col === "number") return col;
|
|
1385
|
+
return headerRow ? headerRow.findIndex((h) => h.toLowerCase() === col.toLowerCase()) : -1;
|
|
1386
|
+
};
|
|
1387
|
+
const dateIdx = resolve(config.dateColumn ?? _guessColumn(headerRow, ["date", "transaction date", "txn date"]));
|
|
1388
|
+
const amtIdx = resolve(config.amountColumn ?? _guessColumn(headerRow, ["amount", "value"]));
|
|
1389
|
+
const crIdx = resolve(config.creditColumn ?? _guessColumn(headerRow, ["credit", "credits"]));
|
|
1390
|
+
const drIdx = resolve(config.debitColumn ?? _guessColumn(headerRow, ["debit", "debits"]));
|
|
1391
|
+
const descIdx = resolve(config.descriptionColumn ?? _guessColumn(headerRow, ["description", "details", "narrative", "memo"]));
|
|
1392
|
+
const refIdx = resolve(config.referenceColumn ?? _guessColumn(headerRow, ["reference", "ref", "cheque no", "check no"]));
|
|
1393
|
+
const balIdx = resolve(config.balanceColumn ?? _guessColumn(headerRow, ["balance", "running balance"]));
|
|
1394
|
+
dataRows.forEach((row, idx) => {
|
|
1395
|
+
if (!row.trim()) return;
|
|
1396
|
+
const cols = _splitCsvRow(row, delimiter);
|
|
1397
|
+
try {
|
|
1398
|
+
const rawDate = dateIdx >= 0 ? cols[dateIdx] ?? "" : "";
|
|
1399
|
+
const rawDesc = descIdx >= 0 ? cols[descIdx] ?? "" : "";
|
|
1400
|
+
const rawRef = refIdx >= 0 ? cols[refIdx] ?? "" : void 0;
|
|
1401
|
+
const rawBal = balIdx >= 0 ? cols[balIdx] ?? "" : void 0;
|
|
1402
|
+
const date = _parseCsvDate(rawDate.trim(), config.dateFormat);
|
|
1403
|
+
let amount;
|
|
1404
|
+
let type;
|
|
1405
|
+
if (crIdx >= 0 || drIdx >= 0) {
|
|
1406
|
+
const crAmt = crIdx >= 0 ? _parseAmount(cols[crIdx] ?? "") : 0;
|
|
1407
|
+
const drAmt = drIdx >= 0 ? _parseAmount(cols[drIdx] ?? "") : 0;
|
|
1408
|
+
if (crAmt > 0) {
|
|
1409
|
+
amount = crAmt;
|
|
1410
|
+
type = "credit";
|
|
1411
|
+
} else if (drAmt > 0) {
|
|
1412
|
+
amount = drAmt;
|
|
1413
|
+
type = "debit";
|
|
1414
|
+
} else {
|
|
1415
|
+
amount = 0;
|
|
1416
|
+
type = "unknown";
|
|
1417
|
+
}
|
|
1418
|
+
} else if (amtIdx >= 0) {
|
|
1419
|
+
const raw = _parseAmount(cols[amtIdx] ?? "");
|
|
1420
|
+
amount = Math.abs(raw);
|
|
1421
|
+
type = raw < 0 ? "debit" : raw > 0 ? "credit" : "unknown";
|
|
1422
|
+
} else {
|
|
1423
|
+
result.parseErrors.push(`CSV row ${idx}: no amount column found.`);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const txn = {
|
|
1427
|
+
id: _stableId(void 0, date, amount, idx),
|
|
1428
|
+
date,
|
|
1429
|
+
amount,
|
|
1430
|
+
type,
|
|
1431
|
+
currency: "USD",
|
|
1432
|
+
description: rawDesc.replace(/^"|"$/g, ""),
|
|
1433
|
+
reference: rawRef?.replace(/^"|"$/g, ""),
|
|
1434
|
+
balance: rawBal ? _parseAmount(rawBal) : void 0,
|
|
1435
|
+
raw: row
|
|
1436
|
+
};
|
|
1437
|
+
result.transactions.push(txn);
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
result.parseErrors.push(`CSV row ${idx}: ${String(e)}`);
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
function _parseMT940Balance(value) {
|
|
1446
|
+
const m = /^[CD](\d{6})([A-Z]{3})([\d,]+)/.exec(value.replace(/\s/g, ""));
|
|
1447
|
+
if (!m) return null;
|
|
1448
|
+
const indicator = value[0] === "D" ? -1 : 1;
|
|
1449
|
+
return {
|
|
1450
|
+
currency: m[2],
|
|
1451
|
+
amount: indicator * parseFloat(m[3].replace(",", "."))
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
function _parseMT940Transaction(line61, narrative, accountId, currency, idx) {
|
|
1455
|
+
const re = /^(\d{6})(\d{4})?([CD])(R?)([A-Z]?)(\d[\d,]+)(.*)$/;
|
|
1456
|
+
const m = re.exec(line61.replace(/\s/g, ""));
|
|
1457
|
+
if (!m) return null;
|
|
1458
|
+
const yymmdd = m[1];
|
|
1459
|
+
const mmddOpt = m[2];
|
|
1460
|
+
const cdFlag = m[3];
|
|
1461
|
+
m[4];
|
|
1462
|
+
const amtStr = m[6];
|
|
1463
|
+
const rest = m[7] ?? "";
|
|
1464
|
+
const year = parseInt(yymmdd.slice(0, 2)) + 2e3;
|
|
1465
|
+
const month = yymmdd.slice(2, 4);
|
|
1466
|
+
const day = yymmdd.slice(4, 6);
|
|
1467
|
+
const date = `${year}-${month}-${day}`;
|
|
1468
|
+
let valueDate;
|
|
1469
|
+
if (mmddOpt) {
|
|
1470
|
+
const vmm = mmddOpt.slice(0, 2);
|
|
1471
|
+
const vdd = mmddOpt.slice(2, 4);
|
|
1472
|
+
valueDate = `${year}-${vmm}-${vdd}`;
|
|
1473
|
+
}
|
|
1474
|
+
const amount = parseFloat(amtStr.replace(",", "."));
|
|
1475
|
+
const type = cdFlag === "C" ? "credit" : "debit";
|
|
1476
|
+
const refMatch = /^([^/]{1,16})(?:\/\/(.+))?$/.exec(rest);
|
|
1477
|
+
const reference = refMatch?.[1]?.trim() || void 0;
|
|
1478
|
+
const description = narrative.replace(/\n/g, " ").trim() || reference || "";
|
|
1479
|
+
return {
|
|
1480
|
+
id: _stableId(accountId, date, amount, idx),
|
|
1481
|
+
date,
|
|
1482
|
+
valueDate,
|
|
1483
|
+
amount,
|
|
1484
|
+
type,
|
|
1485
|
+
currency: currency ?? "USD",
|
|
1486
|
+
description,
|
|
1487
|
+
reference,
|
|
1488
|
+
raw: line61 + (narrative ? "\n" + narrative : "")
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function _ofxTrnType(trnType, rawAmount) {
|
|
1492
|
+
switch (trnType.toUpperCase()) {
|
|
1493
|
+
case "CREDIT":
|
|
1494
|
+
return "credit";
|
|
1495
|
+
case "DEBIT":
|
|
1496
|
+
return "debit";
|
|
1497
|
+
case "FEE":
|
|
1498
|
+
return "fee";
|
|
1499
|
+
case "INT":
|
|
1500
|
+
return "interest";
|
|
1501
|
+
case "XFER":
|
|
1502
|
+
return "transfer";
|
|
1503
|
+
case "DEP":
|
|
1504
|
+
return "credit";
|
|
1505
|
+
case "PAYMENT":
|
|
1506
|
+
return "debit";
|
|
1507
|
+
case "ATM":
|
|
1508
|
+
case "POS":
|
|
1509
|
+
return rawAmount < 0 ? "debit" : "credit";
|
|
1510
|
+
default:
|
|
1511
|
+
return rawAmount < 0 ? "debit" : "credit";
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function _parseOFXDate(value) {
|
|
1515
|
+
const clean = value.slice(0, 8);
|
|
1516
|
+
if (clean.length < 8) return value;
|
|
1517
|
+
return `${clean.slice(0, 4)}-${clean.slice(4, 6)}-${clean.slice(6, 8)}`;
|
|
1518
|
+
}
|
|
1519
|
+
function _parseQIFDate(value) {
|
|
1520
|
+
const clean = value.replace(/'/g, "");
|
|
1521
|
+
const parts = clean.split(/[\-\.\/]/);
|
|
1522
|
+
if (parts.length < 3) return clean;
|
|
1523
|
+
let y = parseInt(parts[2]);
|
|
1524
|
+
const a = parseInt(parts[0]);
|
|
1525
|
+
const b = parseInt(parts[1]);
|
|
1526
|
+
if (y < 100) y += y < 30 ? 2e3 : 1900;
|
|
1527
|
+
if (a > 12) {
|
|
1528
|
+
return `${y}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
|
1529
|
+
}
|
|
1530
|
+
return `${y}-${String(a).padStart(2, "0")}-${String(b).padStart(2, "0")}`;
|
|
1531
|
+
}
|
|
1532
|
+
function _detectDelimiter(text) {
|
|
1533
|
+
const sample = text.slice(0, 2e3);
|
|
1534
|
+
const counts = { ",": 0, ";": 0, " ": 0, "|": 0 };
|
|
1535
|
+
for (const ch of sample) {
|
|
1536
|
+
if (ch in counts) counts[ch]++;
|
|
1537
|
+
}
|
|
1538
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
1539
|
+
}
|
|
1540
|
+
function _splitCsvRow(row, delimiter) {
|
|
1541
|
+
const result = [];
|
|
1542
|
+
let current = "";
|
|
1543
|
+
let inQuotes = false;
|
|
1544
|
+
for (let i = 0; i < row.length; i++) {
|
|
1545
|
+
const ch = row[i];
|
|
1546
|
+
if (ch === '"') {
|
|
1547
|
+
if (inQuotes && row[i + 1] === '"') {
|
|
1548
|
+
current += '"';
|
|
1549
|
+
i++;
|
|
1550
|
+
} else {
|
|
1551
|
+
inQuotes = !inQuotes;
|
|
1552
|
+
}
|
|
1553
|
+
} else if (ch === delimiter && !inQuotes) {
|
|
1554
|
+
result.push(current);
|
|
1555
|
+
current = "";
|
|
1556
|
+
} else {
|
|
1557
|
+
current += ch;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
result.push(current);
|
|
1561
|
+
return result;
|
|
1562
|
+
}
|
|
1563
|
+
function _guessColumn(header, candidates) {
|
|
1564
|
+
if (!header) return -1;
|
|
1565
|
+
for (const c of candidates) {
|
|
1566
|
+
const idx = header.findIndex((h) => h.toLowerCase().trim() === c.toLowerCase());
|
|
1567
|
+
if (idx >= 0) return idx;
|
|
1568
|
+
}
|
|
1569
|
+
return -1;
|
|
1570
|
+
}
|
|
1571
|
+
function _parseAmount(raw) {
|
|
1572
|
+
if (!raw || !raw.trim()) return 0;
|
|
1573
|
+
const negative = raw.includes("(") || raw.trimStart().startsWith("-");
|
|
1574
|
+
const clean = raw.replace(/[^0-9.,]/g, "");
|
|
1575
|
+
if (!clean) return 0;
|
|
1576
|
+
const hasEuropean = /\d{1,3}(\.\d{3})+,\d{2}$/.test(clean);
|
|
1577
|
+
const normalised = hasEuropean ? clean.replace(/\./g, "").replace(",", ".") : clean.replace(/,/g, "");
|
|
1578
|
+
const val = parseFloat(normalised);
|
|
1579
|
+
return negative ? -Math.abs(val) : val;
|
|
1580
|
+
}
|
|
1581
|
+
function _parseCsvDate(raw, fmt) {
|
|
1582
|
+
if (!raw) return "";
|
|
1583
|
+
const format = fmt ?? _detectDateFormat(raw);
|
|
1584
|
+
const clean = raw.replace(/[^\d]/g, "-").replace(/--+/g, "-");
|
|
1585
|
+
const parts = clean.split("-").filter(Boolean);
|
|
1586
|
+
if (parts.length < 3) return raw;
|
|
1587
|
+
let day, month, year;
|
|
1588
|
+
switch (format) {
|
|
1589
|
+
case "DD/MM/YYYY":
|
|
1590
|
+
[day, month, year] = parts;
|
|
1591
|
+
break;
|
|
1592
|
+
case "MM/DD/YYYY":
|
|
1593
|
+
[month, day, year] = parts;
|
|
1594
|
+
break;
|
|
1595
|
+
case "YYYY-MM-DD":
|
|
1596
|
+
default:
|
|
1597
|
+
[year, month, day] = parts;
|
|
1598
|
+
}
|
|
1599
|
+
const y = parseInt(year);
|
|
1600
|
+
const fullYear = y < 100 ? y < 30 ? 2e3 + y : 1900 + y : y;
|
|
1601
|
+
return `${fullYear}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
|
1602
|
+
}
|
|
1603
|
+
function _detectDateFormat(sample) {
|
|
1604
|
+
if (/^\d{4}[^\d]\d{2}[^\d]\d{2}$/.test(sample)) return "YYYY-MM-DD";
|
|
1605
|
+
if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(sample)) {
|
|
1606
|
+
const parts = sample.split("/");
|
|
1607
|
+
if (parseInt(parts[0]) > 12) return "DD/MM/YYYY";
|
|
1608
|
+
return "MM/DD/YYYY";
|
|
1609
|
+
}
|
|
1610
|
+
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(sample)) return "DD/MM/YYYY";
|
|
1611
|
+
return "YYYY-MM-DD";
|
|
1612
|
+
}
|
|
1613
|
+
function _stableId(accountId, date, amount, index) {
|
|
1614
|
+
const key = `${accountId ?? ""}|${date}|${amount.toFixed(2)}|${index}`;
|
|
1615
|
+
let hash = 5381;
|
|
1616
|
+
for (let i = 0; i < key.length; i++) {
|
|
1617
|
+
hash = (hash << 5) + hash ^ key.charCodeAt(i);
|
|
1618
|
+
hash = hash >>> 0;
|
|
1619
|
+
}
|
|
1620
|
+
return `TXN-${hash.toString(16).toUpperCase().padStart(8, "0")}-${index}`;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/banking/beneficiary.service.ts
|
|
1624
|
+
var JoopBeneficiaryService = class {
|
|
1625
|
+
_beneficiaries = /* @__PURE__ */ new Map();
|
|
1626
|
+
_list$ = new JoopBehaviorSubject([]);
|
|
1627
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────
|
|
1628
|
+
add(data) {
|
|
1629
|
+
const validation = this.validate(data);
|
|
1630
|
+
if (!validation.valid) throw new Error(`Invalid beneficiary: ${validation.errors.join(", ")}`);
|
|
1631
|
+
const b = { ...data, id: _uid(), createdAt: Date.now(), updatedAt: Date.now(), useCount: 0 };
|
|
1632
|
+
this._beneficiaries.set(b.id, b);
|
|
1633
|
+
this._emit();
|
|
1634
|
+
return { ...b };
|
|
1635
|
+
}
|
|
1636
|
+
update(id, patch) {
|
|
1637
|
+
const b = this._get(id);
|
|
1638
|
+
const updated = { ...b, ...patch, id, updatedAt: Date.now() };
|
|
1639
|
+
this._beneficiaries.set(id, updated);
|
|
1640
|
+
this._emit();
|
|
1641
|
+
return { ...updated };
|
|
1642
|
+
}
|
|
1643
|
+
remove(id) {
|
|
1644
|
+
this._beneficiaries.delete(id);
|
|
1645
|
+
this._emit();
|
|
1646
|
+
}
|
|
1647
|
+
get(id) {
|
|
1648
|
+
const b = this._beneficiaries.get(id);
|
|
1649
|
+
return b ? { ...b } : void 0;
|
|
1650
|
+
}
|
|
1651
|
+
getAll() {
|
|
1652
|
+
return Array.from(this._beneficiaries.values()).map((b) => ({ ...b }));
|
|
1653
|
+
}
|
|
1654
|
+
// ── Filtering ────────────────────────────────────────────────────────────
|
|
1655
|
+
getByType(type) {
|
|
1656
|
+
return this.getAll().filter((b) => b.type === type);
|
|
1657
|
+
}
|
|
1658
|
+
getByGroup(group) {
|
|
1659
|
+
return this.getAll().filter((b) => b.group === group);
|
|
1660
|
+
}
|
|
1661
|
+
getFavorites() {
|
|
1662
|
+
return this.getAll().filter((b) => b.isFavorite);
|
|
1663
|
+
}
|
|
1664
|
+
getRecent(limit = 5) {
|
|
1665
|
+
return this.getAll().filter((b) => b.lastUsedAt).sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)).slice(0, limit);
|
|
1666
|
+
}
|
|
1667
|
+
getFrequent(limit = 5) {
|
|
1668
|
+
return this.getAll().sort((a, b) => b.useCount - a.useCount).slice(0, limit);
|
|
1669
|
+
}
|
|
1670
|
+
search(query) {
|
|
1671
|
+
const q = query.toLowerCase();
|
|
1672
|
+
return this.getAll().filter(
|
|
1673
|
+
(b) => b.name.toLowerCase().includes(q) || b.nickname?.toLowerCase().includes(q) || b.accountNumber?.includes(q) || b.iban?.toLowerCase().includes(q) || b.upiId?.toLowerCase().includes(q) || b.mobile?.includes(q) || b.bankName?.toLowerCase().includes(q)
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
1677
|
+
toggleFavorite(id) {
|
|
1678
|
+
const b = this._get(id);
|
|
1679
|
+
return this.update(id, { isFavorite: !b.isFavorite });
|
|
1680
|
+
}
|
|
1681
|
+
verify(id) {
|
|
1682
|
+
return this.update(id, { isVerified: true });
|
|
1683
|
+
}
|
|
1684
|
+
markUsed(id) {
|
|
1685
|
+
const b = this._get(id);
|
|
1686
|
+
this.update(id, { lastUsedAt: Date.now(), useCount: b.useCount + 1 });
|
|
1687
|
+
}
|
|
1688
|
+
// ── Meta ──────────────────────────────────────────────────────────────────
|
|
1689
|
+
getGroups() {
|
|
1690
|
+
return [...new Set(this.getAll().map((b) => b.group).filter(Boolean))].sort();
|
|
1691
|
+
}
|
|
1692
|
+
count() {
|
|
1693
|
+
return this._beneficiaries.size;
|
|
1694
|
+
}
|
|
1695
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
1696
|
+
validate(b) {
|
|
1697
|
+
const errors = [];
|
|
1698
|
+
if (!b.name?.trim()) errors.push("name is required");
|
|
1699
|
+
if (!b.type) errors.push("type is required");
|
|
1700
|
+
if (b.type === "domestic" || b.type === "international") {
|
|
1701
|
+
if (!b.accountNumber && !b.iban) errors.push("accountNumber or iban required for bank transfers");
|
|
1702
|
+
}
|
|
1703
|
+
if (b.type === "upi") {
|
|
1704
|
+
if (!b.upiId) errors.push("upiId required for UPI beneficiaries");
|
|
1705
|
+
else if (!/^[\w.\-]+@[\w]+$/.test(b.upiId)) errors.push("upiId format invalid (expected user@bank)");
|
|
1706
|
+
}
|
|
1707
|
+
if (b.type === "mobile") {
|
|
1708
|
+
if (!b.mobile) errors.push("mobile required for mobile transfers");
|
|
1709
|
+
}
|
|
1710
|
+
if (b.iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{4,}$/.test(b.iban.replace(/\s/g, ""))) {
|
|
1711
|
+
errors.push("IBAN format invalid");
|
|
1712
|
+
}
|
|
1713
|
+
return { valid: errors.length === 0, errors };
|
|
1714
|
+
}
|
|
1715
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
1716
|
+
beneficiaries$() {
|
|
1717
|
+
return this._list$.asObservable();
|
|
1718
|
+
}
|
|
1719
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
1720
|
+
_get(id) {
|
|
1721
|
+
const b = this._beneficiaries.get(id);
|
|
1722
|
+
if (!b) throw new Error(`Beneficiary '${id}' not found`);
|
|
1723
|
+
return b;
|
|
1724
|
+
}
|
|
1725
|
+
_emit() {
|
|
1726
|
+
this._list$.next(this.getAll());
|
|
1727
|
+
}
|
|
1728
|
+
};
|
|
1729
|
+
function _uid() {
|
|
1730
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// src/banking/scheduled-payment.service.ts
|
|
1734
|
+
var MS = {
|
|
1735
|
+
once: null,
|
|
1736
|
+
daily: 864e5,
|
|
1737
|
+
weekly: 7 * 864e5,
|
|
1738
|
+
biweekly: 14 * 864e5,
|
|
1739
|
+
monthly: null,
|
|
1740
|
+
// computed via Date logic
|
|
1741
|
+
quarterly: null,
|
|
1742
|
+
annually: null
|
|
1743
|
+
};
|
|
1744
|
+
var JoopScheduledPaymentService = class {
|
|
1745
|
+
_payments = /* @__PURE__ */ new Map();
|
|
1746
|
+
_executions = [];
|
|
1747
|
+
_payments$ = new JoopBehaviorSubject([]);
|
|
1748
|
+
_due$ = new JoopSubject();
|
|
1749
|
+
// ── Schedule ──────────────────────────────────────────────────────────────
|
|
1750
|
+
schedule(data) {
|
|
1751
|
+
const p = {
|
|
1752
|
+
...data,
|
|
1753
|
+
id: _uid2(),
|
|
1754
|
+
nextExecutionDate: data.startDate,
|
|
1755
|
+
executionCount: 0,
|
|
1756
|
+
status: "active",
|
|
1757
|
+
createdAt: Date.now(),
|
|
1758
|
+
updatedAt: Date.now()
|
|
1759
|
+
};
|
|
1760
|
+
this._payments.set(p.id, p);
|
|
1761
|
+
this._emit();
|
|
1762
|
+
return { ...p };
|
|
1763
|
+
}
|
|
1764
|
+
update(id, patch) {
|
|
1765
|
+
const p = this._get(id);
|
|
1766
|
+
const updated = { ...p, ...patch, updatedAt: Date.now() };
|
|
1767
|
+
this._payments.set(id, updated);
|
|
1768
|
+
this._emit();
|
|
1769
|
+
return { ...updated };
|
|
1770
|
+
}
|
|
1771
|
+
cancel(id) {
|
|
1772
|
+
this.update(id, { status: "cancelled" });
|
|
1773
|
+
}
|
|
1774
|
+
pause(id) {
|
|
1775
|
+
this.update(id, { status: "paused" });
|
|
1776
|
+
}
|
|
1777
|
+
resume(id) {
|
|
1778
|
+
this.update(id, { status: "active" });
|
|
1779
|
+
}
|
|
1780
|
+
// ── Query ─────────────────────────────────────────────────────────────────
|
|
1781
|
+
get(id) {
|
|
1782
|
+
const p = this._payments.get(id);
|
|
1783
|
+
return p ? { ...p } : void 0;
|
|
1784
|
+
}
|
|
1785
|
+
getAll() {
|
|
1786
|
+
return Array.from(this._payments.values()).map((p) => ({ ...p }));
|
|
1787
|
+
}
|
|
1788
|
+
getActive() {
|
|
1789
|
+
return this.getAll().filter((p) => p.status === "active");
|
|
1790
|
+
}
|
|
1791
|
+
getDue(asOf = /* @__PURE__ */ new Date()) {
|
|
1792
|
+
return this.getActive().filter((p) => p.nextExecutionDate <= asOf.getTime());
|
|
1793
|
+
}
|
|
1794
|
+
getUpcoming(days = 30, asOf = /* @__PURE__ */ new Date()) {
|
|
1795
|
+
const cutoff = asOf.getTime() + days * 864e5;
|
|
1796
|
+
return this.getActive().filter((p) => p.nextExecutionDate <= cutoff);
|
|
1797
|
+
}
|
|
1798
|
+
// ── Execution ────────────────────────────────────────────────────────────
|
|
1799
|
+
recordExecution(paymentId, status, error) {
|
|
1800
|
+
const p = this._get(paymentId);
|
|
1801
|
+
const exec = {
|
|
1802
|
+
id: _uid2(),
|
|
1803
|
+
paymentId,
|
|
1804
|
+
executedAt: Date.now(),
|
|
1805
|
+
amount: p.amount,
|
|
1806
|
+
currency: p.currency,
|
|
1807
|
+
status,
|
|
1808
|
+
reference: `REF${Date.now().toString(36).toUpperCase()}`,
|
|
1809
|
+
error
|
|
1810
|
+
};
|
|
1811
|
+
this._executions.push(exec);
|
|
1812
|
+
p.executionCount++;
|
|
1813
|
+
p.lastExecutionDate = exec.executedAt;
|
|
1814
|
+
if (status === "failed") {
|
|
1815
|
+
p.status = "failed";
|
|
1816
|
+
} else if (p.frequency === "once" || p.maxExecutions && p.executionCount >= p.maxExecutions) {
|
|
1817
|
+
p.status = "completed";
|
|
1818
|
+
} else {
|
|
1819
|
+
p.nextExecutionDate = this.computeNextDate(p.nextExecutionDate, p.frequency);
|
|
1820
|
+
if (p.endDate && p.nextExecutionDate > p.endDate) p.status = "completed";
|
|
1821
|
+
}
|
|
1822
|
+
this._payments.set(paymentId, p);
|
|
1823
|
+
this._emit();
|
|
1824
|
+
return exec;
|
|
1825
|
+
}
|
|
1826
|
+
getExecutionHistory(paymentId) {
|
|
1827
|
+
return this._executions.filter((e) => e.paymentId === paymentId);
|
|
1828
|
+
}
|
|
1829
|
+
// ── Date computation ──────────────────────────────────────────────────────
|
|
1830
|
+
computeNextDate(current, frequency) {
|
|
1831
|
+
const ms = MS[frequency];
|
|
1832
|
+
if (ms !== null && ms !== void 0) return current + ms;
|
|
1833
|
+
const d = new Date(current);
|
|
1834
|
+
switch (frequency) {
|
|
1835
|
+
case "monthly":
|
|
1836
|
+
d.setMonth(d.getMonth() + 1);
|
|
1837
|
+
break;
|
|
1838
|
+
case "quarterly":
|
|
1839
|
+
d.setMonth(d.getMonth() + 3);
|
|
1840
|
+
break;
|
|
1841
|
+
case "annually":
|
|
1842
|
+
d.setFullYear(d.getFullYear() + 1);
|
|
1843
|
+
break;
|
|
1844
|
+
case "once":
|
|
1845
|
+
return current;
|
|
1846
|
+
}
|
|
1847
|
+
return d.getTime();
|
|
1848
|
+
}
|
|
1849
|
+
getSchedulePreview(startDate, frequency, count = 5, endDate) {
|
|
1850
|
+
const dates = [];
|
|
1851
|
+
let next = startDate;
|
|
1852
|
+
while (dates.length < count) {
|
|
1853
|
+
if (endDate && next > endDate) break;
|
|
1854
|
+
dates.push(next);
|
|
1855
|
+
if (frequency === "once") break;
|
|
1856
|
+
next = this.computeNextDate(next, frequency);
|
|
1857
|
+
}
|
|
1858
|
+
return dates;
|
|
1859
|
+
}
|
|
1860
|
+
// ── Observables ───────────────────────────────────────────────────────────
|
|
1861
|
+
payments$() {
|
|
1862
|
+
return this._payments$.asObservable();
|
|
1863
|
+
}
|
|
1864
|
+
due$() {
|
|
1865
|
+
return this._due$.asObservable();
|
|
1866
|
+
}
|
|
1867
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
1868
|
+
_get(id) {
|
|
1869
|
+
const p = this._payments.get(id);
|
|
1870
|
+
if (!p) throw new Error(`Scheduled payment '${id}' not found`);
|
|
1871
|
+
return p;
|
|
1872
|
+
}
|
|
1873
|
+
_emit() {
|
|
1874
|
+
this._payments$.next(this.getAll());
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
function _uid2() {
|
|
1878
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// src/banking/card-management.service.ts
|
|
1882
|
+
var DEFAULT_CONTROLS = {
|
|
1883
|
+
onlinePayments: true,
|
|
1884
|
+
internationalPayments: false,
|
|
1885
|
+
contactlessPayments: true,
|
|
1886
|
+
atmWithdrawals: true,
|
|
1887
|
+
ecommercePayments: true
|
|
1888
|
+
};
|
|
1889
|
+
var JoopCardManagementService = class {
|
|
1890
|
+
_cards = /* @__PURE__ */ new Map();
|
|
1891
|
+
_cards$ = new JoopBehaviorSubject([]);
|
|
1892
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────
|
|
1893
|
+
add(data) {
|
|
1894
|
+
const card = { ...data, id: _uid3(), createdAt: Date.now(), updatedAt: Date.now() };
|
|
1895
|
+
this._cards.set(card.id, card);
|
|
1896
|
+
this._emit();
|
|
1897
|
+
return { ...card };
|
|
1898
|
+
}
|
|
1899
|
+
get(id) {
|
|
1900
|
+
const c = this._cards.get(id);
|
|
1901
|
+
return c ? { ...c } : void 0;
|
|
1902
|
+
}
|
|
1903
|
+
getAll() {
|
|
1904
|
+
return Array.from(this._cards.values()).map((c) => ({ ...c }));
|
|
1905
|
+
}
|
|
1906
|
+
getByAccount(accountId) {
|
|
1907
|
+
return this.getAll().filter((c) => c.accountId === accountId);
|
|
1908
|
+
}
|
|
1909
|
+
getActive() {
|
|
1910
|
+
return this.getAll().filter((c) => c.status === "active");
|
|
1911
|
+
}
|
|
1912
|
+
remove(id) {
|
|
1913
|
+
this._cards.delete(id);
|
|
1914
|
+
this._emit();
|
|
1915
|
+
}
|
|
1916
|
+
// ── Card Controls ──────────────────────────────────────────────────────────
|
|
1917
|
+
freeze(id, reason) {
|
|
1918
|
+
const c = this._require(id);
|
|
1919
|
+
if (c.status === "blocked") throw new Error("Cannot freeze a blocked card");
|
|
1920
|
+
return this._update(id, { status: "frozen", frozenAt: Date.now(), frozenReason: reason });
|
|
1921
|
+
}
|
|
1922
|
+
unfreeze(id) {
|
|
1923
|
+
const c = this._require(id);
|
|
1924
|
+
if (c.status !== "frozen") throw new Error("Card is not frozen");
|
|
1925
|
+
return this._update(id, { status: "active", frozenAt: void 0, frozenReason: void 0 });
|
|
1926
|
+
}
|
|
1927
|
+
block(id) {
|
|
1928
|
+
return this._update(id, { status: "blocked", blockedAt: Date.now() });
|
|
1929
|
+
}
|
|
1930
|
+
// ── Limits & Controls ─────────────────────────────────────────────────────
|
|
1931
|
+
setSpendingLimits(id, limits) {
|
|
1932
|
+
const c = this._require(id);
|
|
1933
|
+
return this._update(id, { spendingLimits: { ...c.spendingLimits, ...limits } });
|
|
1934
|
+
}
|
|
1935
|
+
setControls(id, controls) {
|
|
1936
|
+
const c = this._require(id);
|
|
1937
|
+
return this._update(id, { controls: { ...c.controls, ...controls } });
|
|
1938
|
+
}
|
|
1939
|
+
// ── Virtual Card ──────────────────────────────────────────────────────────
|
|
1940
|
+
generateVirtual(accountId, holderName, options = {}) {
|
|
1941
|
+
const expiryMonths = options.expiryMonths ?? 12;
|
|
1942
|
+
const now = /* @__PURE__ */ new Date();
|
|
1943
|
+
const expiry = new Date(now.getFullYear(), now.getMonth() + expiryMonths, 1);
|
|
1944
|
+
const last4 = String(Math.floor(1e3 + Math.random() * 9e3));
|
|
1945
|
+
"4" + Array.from({ length: 11 }, () => Math.floor(Math.random() * 10)).join("");
|
|
1946
|
+
const cardNumber = `**** **** **** ${last4}`;
|
|
1947
|
+
return this.add({
|
|
1948
|
+
cardNumber,
|
|
1949
|
+
last4,
|
|
1950
|
+
network: "visa",
|
|
1951
|
+
type: "virtual",
|
|
1952
|
+
holderName: holderName.toUpperCase(),
|
|
1953
|
+
expiryMonth: expiry.getMonth() + 1,
|
|
1954
|
+
expiryYear: expiry.getFullYear(),
|
|
1955
|
+
status: "active",
|
|
1956
|
+
accountId,
|
|
1957
|
+
currency: options.currency ?? "USD",
|
|
1958
|
+
spendingLimits: {},
|
|
1959
|
+
controls: { ...DEFAULT_CONTROLS, internationalPayments: true }
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
// ── Validation ────────────────────────────────────────────────────────────
|
|
1963
|
+
isExpired(id) {
|
|
1964
|
+
const c = this._require(id);
|
|
1965
|
+
const now = /* @__PURE__ */ new Date();
|
|
1966
|
+
const expiry = new Date(c.expiryYear, c.expiryMonth - 1, 1);
|
|
1967
|
+
return expiry <= new Date(now.getFullYear(), now.getMonth(), 1);
|
|
1968
|
+
}
|
|
1969
|
+
canTransact(id, amount, type) {
|
|
1970
|
+
const c = this._require(id);
|
|
1971
|
+
if (c.status !== "active") return { allowed: false, reason: `Card is ${c.status}` };
|
|
1972
|
+
if (this.isExpired(id)) return { allowed: false, reason: "Card is expired" };
|
|
1973
|
+
const controlMap = {
|
|
1974
|
+
onlinePayments: "onlinePayments",
|
|
1975
|
+
internationalPayments: "internationalPayments",
|
|
1976
|
+
contactlessPayments: "contactlessPayments",
|
|
1977
|
+
atmWithdrawals: "atmWithdrawals",
|
|
1978
|
+
ecommerce: "ecommercePayments"
|
|
1979
|
+
};
|
|
1980
|
+
const controlKey = controlMap[type];
|
|
1981
|
+
if (controlKey && !c.controls[controlKey]) return { allowed: false, reason: `${type} is disabled on this card` };
|
|
1982
|
+
if (c.spendingLimits.perTransaction && amount > c.spendingLimits.perTransaction) {
|
|
1983
|
+
return { allowed: false, reason: `Amount exceeds per-transaction limit of ${c.spendingLimits.perTransaction}` };
|
|
1984
|
+
}
|
|
1985
|
+
return { allowed: true };
|
|
1986
|
+
}
|
|
1987
|
+
validatePin(pin) {
|
|
1988
|
+
return /^\d{4,6}$/.test(pin);
|
|
1989
|
+
}
|
|
1990
|
+
// ── Observables ───────────────────────────────────────────────────────────
|
|
1991
|
+
cards$() {
|
|
1992
|
+
return this._cards$.asObservable();
|
|
1993
|
+
}
|
|
1994
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
1995
|
+
_require(id) {
|
|
1996
|
+
const c = this._cards.get(id);
|
|
1997
|
+
if (!c) throw new Error(`Card '${id}' not found`);
|
|
1998
|
+
return c;
|
|
1999
|
+
}
|
|
2000
|
+
_update(id, patch) {
|
|
2001
|
+
const c = this._require(id);
|
|
2002
|
+
const updated = { ...c, ...patch, updatedAt: Date.now() };
|
|
2003
|
+
this._cards.set(id, updated);
|
|
2004
|
+
this._emit();
|
|
2005
|
+
return { ...updated };
|
|
2006
|
+
}
|
|
2007
|
+
_emit() {
|
|
2008
|
+
this._cards$.next(this.getAll());
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
function _uid3() {
|
|
2012
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// src/banking/statement-generator.service.ts
|
|
2016
|
+
var JoopStatementGeneratorService = class {
|
|
2017
|
+
generate(options) {
|
|
2018
|
+
const sorted = [...options.transactions].filter((t) => t.date >= options.fromDate && t.date <= options.toDate).sort((a, b) => a.date - b.date);
|
|
2019
|
+
let balance = options.openingBalance;
|
|
2020
|
+
let totalCredits = 0;
|
|
2021
|
+
let totalDebits = 0;
|
|
2022
|
+
const entries = sorted.map((tx) => {
|
|
2023
|
+
if (tx.type === "credit") {
|
|
2024
|
+
balance += tx.amount;
|
|
2025
|
+
totalCredits += tx.amount;
|
|
2026
|
+
} else {
|
|
2027
|
+
balance -= tx.amount;
|
|
2028
|
+
totalDebits += tx.amount;
|
|
2029
|
+
}
|
|
2030
|
+
return { ...tx, runningBalance: parseFloat(balance.toFixed(2)) };
|
|
2031
|
+
});
|
|
2032
|
+
return {
|
|
2033
|
+
accountId: options.accountId,
|
|
2034
|
+
accountName: options.accountName ?? "",
|
|
2035
|
+
accountNumber: options.accountNumber ?? "",
|
|
2036
|
+
currency: options.currency,
|
|
2037
|
+
fromDate: options.fromDate,
|
|
2038
|
+
toDate: options.toDate,
|
|
2039
|
+
openingBalance: options.openingBalance,
|
|
2040
|
+
closingBalance: parseFloat(balance.toFixed(2)),
|
|
2041
|
+
totalCredits: parseFloat(totalCredits.toFixed(2)),
|
|
2042
|
+
totalDebits: parseFloat(totalDebits.toFixed(2)),
|
|
2043
|
+
transactionCount: entries.length,
|
|
2044
|
+
entries,
|
|
2045
|
+
generatedAt: Date.now()
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
render(statement, format = "text") {
|
|
2049
|
+
switch (format) {
|
|
2050
|
+
case "csv":
|
|
2051
|
+
return this._renderCsv(statement);
|
|
2052
|
+
case "html":
|
|
2053
|
+
return this._renderHtml(statement);
|
|
2054
|
+
default:
|
|
2055
|
+
return this._renderText(statement);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
2059
|
+
getSummary(statement) {
|
|
2060
|
+
const summary = {};
|
|
2061
|
+
for (const entry of statement.entries) {
|
|
2062
|
+
const cat = entry.category ?? "Uncategorized";
|
|
2063
|
+
summary[cat] = (summary[cat] ?? 0) + entry.amount;
|
|
2064
|
+
}
|
|
2065
|
+
return summary;
|
|
2066
|
+
}
|
|
2067
|
+
getTopCategories(statement, limit = 5) {
|
|
2068
|
+
const summary = this.getSummary(statement);
|
|
2069
|
+
return Object.entries(summary).sort(([, a], [, b]) => b - a).slice(0, limit).map(([category, total]) => ({ category, total: parseFloat(total.toFixed(2)) }));
|
|
2070
|
+
}
|
|
2071
|
+
// ── Renderers ─────────────────────────────────────────────────────────────
|
|
2072
|
+
_renderText(s) {
|
|
2073
|
+
const lines = [];
|
|
2074
|
+
const sep = "\u2500".repeat(70);
|
|
2075
|
+
const fmt = (n) => `${s.currency} ${n.toFixed(2)}`;
|
|
2076
|
+
const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
2077
|
+
lines.push(sep);
|
|
2078
|
+
lines.push(`ACCOUNT STATEMENT`);
|
|
2079
|
+
lines.push(`Account: ${s.accountName} (${s.accountNumber})`);
|
|
2080
|
+
lines.push(`Period: ${fmtDate(s.fromDate)} to ${fmtDate(s.toDate)}`);
|
|
2081
|
+
lines.push(`Generated: ${new Date(s.generatedAt).toISOString()}`);
|
|
2082
|
+
lines.push(sep);
|
|
2083
|
+
lines.push(`Opening Balance: ${fmt(s.openingBalance)}`);
|
|
2084
|
+
lines.push("");
|
|
2085
|
+
lines.push(`${"Date".padEnd(12)}${"Description".padEnd(32)}${"Debit".padStart(12)}${"Credit".padStart(12)}${"Balance".padStart(14)}`);
|
|
2086
|
+
lines.push("\u2500".repeat(70));
|
|
2087
|
+
for (const e of s.entries) {
|
|
2088
|
+
const d = e.type === "debit" ? e.amount.toFixed(2) : "";
|
|
2089
|
+
const c = e.type === "credit" ? e.amount.toFixed(2) : "";
|
|
2090
|
+
lines.push(`${fmtDate(e.date).padEnd(12)}${e.description.slice(0, 30).padEnd(32)}${d.padStart(12)}${c.padStart(12)}${e.runningBalance.toFixed(2).padStart(14)}`);
|
|
2091
|
+
}
|
|
2092
|
+
lines.push("\u2500".repeat(70));
|
|
2093
|
+
lines.push(`${"".padEnd(44)}${"Total Debits:".padStart(12)} ${fmt(s.totalDebits)}`);
|
|
2094
|
+
lines.push(`${"".padEnd(44)}${"Total Credits:".padStart(12)} ${fmt(s.totalCredits)}`);
|
|
2095
|
+
lines.push(`${"".padEnd(44)}${"Closing Balance:".padStart(12)} ${fmt(s.closingBalance)}`);
|
|
2096
|
+
lines.push(sep);
|
|
2097
|
+
return lines.join("\n");
|
|
2098
|
+
}
|
|
2099
|
+
_renderCsv(s) {
|
|
2100
|
+
const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
2101
|
+
const rows = [
|
|
2102
|
+
`Account ID,${s.accountId}`,
|
|
2103
|
+
`Account Name,${s.accountName}`,
|
|
2104
|
+
`Account Number,${s.accountNumber}`,
|
|
2105
|
+
`Currency,${s.currency}`,
|
|
2106
|
+
`Period,${fmtDate(s.fromDate)} to ${fmtDate(s.toDate)}`,
|
|
2107
|
+
`Opening Balance,${s.openingBalance}`,
|
|
2108
|
+
`Closing Balance,${s.closingBalance}`,
|
|
2109
|
+
"",
|
|
2110
|
+
"Date,Description,Type,Amount,Currency,Reference,Category,Running Balance",
|
|
2111
|
+
...s.entries.map(
|
|
2112
|
+
(e) => [fmtDate(e.date), _csvEscape(e.description), e.type, e.amount, e.currency, e.reference ?? "", e.category ?? "", e.runningBalance].join(",")
|
|
2113
|
+
)
|
|
2114
|
+
];
|
|
2115
|
+
return rows.join("\n");
|
|
2116
|
+
}
|
|
2117
|
+
_renderHtml(s) {
|
|
2118
|
+
const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
2119
|
+
const fmt = (n) => `${s.currency} ${n.toFixed(2)}`;
|
|
2120
|
+
const rows = s.entries.map((e) => `
|
|
2121
|
+
<tr>
|
|
2122
|
+
<td>${fmtDate(e.date)}</td>
|
|
2123
|
+
<td>${_htmlEscape(e.description)}</td>
|
|
2124
|
+
<td>${e.type === "debit" ? fmt(e.amount) : ""}</td>
|
|
2125
|
+
<td>${e.type === "credit" ? fmt(e.amount) : ""}</td>
|
|
2126
|
+
<td>${fmt(e.runningBalance)}</td>
|
|
2127
|
+
<td>${e.category ?? ""}</td>
|
|
2128
|
+
</tr>`).join("");
|
|
2129
|
+
return `<!DOCTYPE html>
|
|
2130
|
+
<html lang="en">
|
|
2131
|
+
<head><meta charset="UTF-8"><title>Account Statement</title>
|
|
2132
|
+
<style>
|
|
2133
|
+
body { font-family: Arial, sans-serif; font-size: 13px; margin: 20px; }
|
|
2134
|
+
h2 { margin-bottom: 4px; }
|
|
2135
|
+
.meta { color: #555; margin-bottom: 16px; }
|
|
2136
|
+
table { width: 100%; border-collapse: collapse; }
|
|
2137
|
+
th, td { padding: 6px 10px; border: 1px solid #ddd; text-align: left; }
|
|
2138
|
+
th { background: #f5f5f5; font-weight: 600; }
|
|
2139
|
+
.summary { margin-top: 16px; font-weight: bold; }
|
|
2140
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
2141
|
+
</style>
|
|
2142
|
+
</head>
|
|
2143
|
+
<body>
|
|
2144
|
+
<h2>Account Statement</h2>
|
|
2145
|
+
<div class="meta">
|
|
2146
|
+
<div>Account: ${_htmlEscape(s.accountName)} (${_htmlEscape(s.accountNumber)})</div>
|
|
2147
|
+
<div>Period: ${fmtDate(s.fromDate)} to ${fmtDate(s.toDate)}</div>
|
|
2148
|
+
<div>Generated: ${new Date(s.generatedAt).toISOString()}</div>
|
|
2149
|
+
</div>
|
|
2150
|
+
<table>
|
|
2151
|
+
<thead>
|
|
2152
|
+
<tr><th>Date</th><th>Description</th><th>Debit</th><th>Credit</th><th>Balance</th><th>Category</th></tr>
|
|
2153
|
+
</thead>
|
|
2154
|
+
<tbody>
|
|
2155
|
+
<tr><td colspan="4"><strong>Opening Balance</strong></td><td><strong>${fmt(s.openingBalance)}</strong></td><td></td></tr>
|
|
2156
|
+
${rows}
|
|
2157
|
+
<tr class="summary">
|
|
2158
|
+
<td colspan="2">Totals</td>
|
|
2159
|
+
<td>${fmt(s.totalDebits)}</td>
|
|
2160
|
+
<td>${fmt(s.totalCredits)}</td>
|
|
2161
|
+
<td>${fmt(s.closingBalance)}</td>
|
|
2162
|
+
<td></td>
|
|
2163
|
+
</tr>
|
|
2164
|
+
</tbody>
|
|
2165
|
+
</table>
|
|
2166
|
+
</body>
|
|
2167
|
+
</html>`;
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
function _csvEscape(v) {
|
|
2171
|
+
if (v.includes(",") || v.includes('"') || v.includes("\n")) return `"${v.replace(/"/g, '""')}"`;
|
|
2172
|
+
return v;
|
|
2173
|
+
}
|
|
2174
|
+
function _htmlEscape(v) {
|
|
2175
|
+
return v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/banking/notification-center.service.ts
|
|
2179
|
+
var JoopNotificationCenterService = class {
|
|
2180
|
+
_notifications = [];
|
|
2181
|
+
_notifications$ = new JoopBehaviorSubject([]);
|
|
2182
|
+
_maxItems;
|
|
2183
|
+
constructor(maxItems = 500) {
|
|
2184
|
+
this._maxItems = maxItems;
|
|
2185
|
+
}
|
|
2186
|
+
// ── Add ───────────────────────────────────────────────────────────────────
|
|
2187
|
+
add(data) {
|
|
2188
|
+
const n = {
|
|
2189
|
+
...data,
|
|
2190
|
+
id: _uid4(),
|
|
2191
|
+
isRead: false,
|
|
2192
|
+
isDismissed: false,
|
|
2193
|
+
createdAt: Date.now()
|
|
2194
|
+
};
|
|
2195
|
+
this._notifications.unshift(n);
|
|
2196
|
+
if (this._notifications.length > this._maxItems) this._notifications.pop();
|
|
2197
|
+
this._emit();
|
|
2198
|
+
return { ...n };
|
|
2199
|
+
}
|
|
2200
|
+
// ── Read ──────────────────────────────────────────────────────────────────
|
|
2201
|
+
get(id) {
|
|
2202
|
+
return this._notifications.find((n) => n.id === id);
|
|
2203
|
+
}
|
|
2204
|
+
getAll(includeExpired = false) {
|
|
2205
|
+
const now = Date.now();
|
|
2206
|
+
return this._notifications.filter((n) => !n.isDismissed && (includeExpired || !n.expiresAt || n.expiresAt > now)).map((n) => ({ ...n }));
|
|
2207
|
+
}
|
|
2208
|
+
getUnread() {
|
|
2209
|
+
return this.getAll().filter((n) => !n.isRead);
|
|
2210
|
+
}
|
|
2211
|
+
getByType(type) {
|
|
2212
|
+
return this.getAll().filter((n) => n.type === type);
|
|
2213
|
+
}
|
|
2214
|
+
getByCategory(category) {
|
|
2215
|
+
return this.getAll().filter((n) => n.category === category);
|
|
2216
|
+
}
|
|
2217
|
+
getByPriority(priority) {
|
|
2218
|
+
return this.getAll().filter((n) => n.priority === priority);
|
|
2219
|
+
}
|
|
2220
|
+
// ── Mark read ─────────────────────────────────────────────────────────────
|
|
2221
|
+
markRead(id) {
|
|
2222
|
+
const n = this._notifications.find((n2) => n2.id === id);
|
|
2223
|
+
if (!n || n.isRead) return;
|
|
2224
|
+
n.isRead = true;
|
|
2225
|
+
n.readAt = Date.now();
|
|
2226
|
+
this._emit();
|
|
2227
|
+
}
|
|
2228
|
+
markAllRead() {
|
|
2229
|
+
const now = Date.now();
|
|
2230
|
+
let changed = false;
|
|
2231
|
+
for (const n of this._notifications) {
|
|
2232
|
+
if (!n.isRead) {
|
|
2233
|
+
n.isRead = true;
|
|
2234
|
+
n.readAt = now;
|
|
2235
|
+
changed = true;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
if (changed) this._emit();
|
|
2239
|
+
}
|
|
2240
|
+
// ── Dismiss ───────────────────────────────────────────────────────────────
|
|
2241
|
+
dismiss(id) {
|
|
2242
|
+
const n = this._notifications.find((n2) => n2.id === id);
|
|
2243
|
+
if (!n || n.isDismissed) return;
|
|
2244
|
+
n.isDismissed = true;
|
|
2245
|
+
this._emit();
|
|
2246
|
+
}
|
|
2247
|
+
dismissAll() {
|
|
2248
|
+
let changed = false;
|
|
2249
|
+
for (const n of this._notifications) {
|
|
2250
|
+
if (!n.isDismissed) {
|
|
2251
|
+
n.isDismissed = true;
|
|
2252
|
+
changed = true;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
if (changed) this._emit();
|
|
2256
|
+
}
|
|
2257
|
+
// ── Counts ────────────────────────────────────────────────────────────────
|
|
2258
|
+
unreadCount() {
|
|
2259
|
+
return this.getUnread().length;
|
|
2260
|
+
}
|
|
2261
|
+
getSummary() {
|
|
2262
|
+
const all = this.getAll();
|
|
2263
|
+
const byType = {};
|
|
2264
|
+
const byPriority = {};
|
|
2265
|
+
for (const n of all) {
|
|
2266
|
+
byType[n.type] = (byType[n.type] ?? 0) + 1;
|
|
2267
|
+
byPriority[n.priority] = (byPriority[n.priority] ?? 0) + 1;
|
|
2268
|
+
}
|
|
2269
|
+
return { total: all.length, unread: this.unreadCount(), byType, byPriority };
|
|
2270
|
+
}
|
|
2271
|
+
// ── Cleanup ───────────────────────────────────────────────────────────────
|
|
2272
|
+
purgeExpired() {
|
|
2273
|
+
const before = this._notifications.length;
|
|
2274
|
+
const now = Date.now();
|
|
2275
|
+
this._notifications = this._notifications.filter((n) => !n.expiresAt || n.expiresAt > now);
|
|
2276
|
+
const removed = before - this._notifications.length;
|
|
2277
|
+
if (removed > 0) this._emit();
|
|
2278
|
+
return removed;
|
|
2279
|
+
}
|
|
2280
|
+
clear() {
|
|
2281
|
+
this._notifications = [];
|
|
2282
|
+
this._emit();
|
|
2283
|
+
}
|
|
2284
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
2285
|
+
notifications$() {
|
|
2286
|
+
return this._notifications$.asObservable();
|
|
2287
|
+
}
|
|
2288
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
2289
|
+
_emit() {
|
|
2290
|
+
this._notifications$.next(this.getAll());
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
function _uid4() {
|
|
2294
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/banking/dispute.service.ts
|
|
2298
|
+
var JoopDisputeService = class {
|
|
2299
|
+
_disputes = /* @__PURE__ */ new Map();
|
|
2300
|
+
_disputes$ = new JoopBehaviorSubject([]);
|
|
2301
|
+
_counter = 1;
|
|
2302
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────
|
|
2303
|
+
create(data) {
|
|
2304
|
+
const dispute = {
|
|
2305
|
+
...data,
|
|
2306
|
+
id: _uid5(),
|
|
2307
|
+
referenceNumber: this._generateRef(),
|
|
2308
|
+
status: "open",
|
|
2309
|
+
evidence: [],
|
|
2310
|
+
createdAt: Date.now(),
|
|
2311
|
+
updatedAt: Date.now()
|
|
2312
|
+
};
|
|
2313
|
+
this._disputes.set(dispute.id, dispute);
|
|
2314
|
+
this._emit();
|
|
2315
|
+
return { ...dispute };
|
|
2316
|
+
}
|
|
2317
|
+
update(id, patch) {
|
|
2318
|
+
const d = this._require(id);
|
|
2319
|
+
Object.assign(d, patch, { updatedAt: Date.now() });
|
|
2320
|
+
this._emit();
|
|
2321
|
+
return { ...d };
|
|
2322
|
+
}
|
|
2323
|
+
get(id) {
|
|
2324
|
+
const d = this._disputes.get(id);
|
|
2325
|
+
return d ? { ...d, evidence: [...d.evidence] } : void 0;
|
|
2326
|
+
}
|
|
2327
|
+
getByRef(ref) {
|
|
2328
|
+
const d = Array.from(this._disputes.values()).find((d2) => d2.referenceNumber === ref);
|
|
2329
|
+
return d ? { ...d } : void 0;
|
|
2330
|
+
}
|
|
2331
|
+
getAll() {
|
|
2332
|
+
return Array.from(this._disputes.values()).map((d) => ({ ...d, evidence: [...d.evidence] }));
|
|
2333
|
+
}
|
|
2334
|
+
getByStatus(status) {
|
|
2335
|
+
return this.getAll().filter((d) => d.status === status);
|
|
2336
|
+
}
|
|
2337
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
2338
|
+
startReview(id, assignedTo) {
|
|
2339
|
+
return this._transition(id, "under-review", (d) => {
|
|
2340
|
+
if (assignedTo) d.assignedTo = assignedTo;
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
resolve(id, resolution) {
|
|
2344
|
+
return this._transition(id, "resolved", (d) => {
|
|
2345
|
+
d.resolution = resolution;
|
|
2346
|
+
d.resolvedAt = Date.now();
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
reject(id, reason) {
|
|
2350
|
+
return this._transition(id, "rejected", (d) => {
|
|
2351
|
+
d.rejectionReason = reason;
|
|
2352
|
+
d.resolvedAt = Date.now();
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
escalate(id, reason) {
|
|
2356
|
+
return this._transition(id, "escalated", (d) => {
|
|
2357
|
+
d.escalationReason = reason;
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
close(id) {
|
|
2361
|
+
return this._transition(id, "closed");
|
|
2362
|
+
}
|
|
2363
|
+
// ── Evidence ─────────────────────────────────────────────────────────────
|
|
2364
|
+
addEvidence(disputeId, evidence) {
|
|
2365
|
+
const d = this._require(disputeId);
|
|
2366
|
+
if (d.status === "resolved" || d.status === "rejected" || d.status === "closed") {
|
|
2367
|
+
throw new Error(`Cannot add evidence to a ${d.status} dispute`);
|
|
2368
|
+
}
|
|
2369
|
+
const e = { ...evidence, id: _uid5(), uploadedAt: Date.now() };
|
|
2370
|
+
d.evidence.push(e);
|
|
2371
|
+
d.updatedAt = Date.now();
|
|
2372
|
+
this._emit();
|
|
2373
|
+
return { ...e };
|
|
2374
|
+
}
|
|
2375
|
+
removeEvidence(disputeId, evidenceId) {
|
|
2376
|
+
const d = this._require(disputeId);
|
|
2377
|
+
const before = d.evidence.length;
|
|
2378
|
+
d.evidence = d.evidence.filter((e) => e.id !== evidenceId);
|
|
2379
|
+
if (d.evidence.length < before) {
|
|
2380
|
+
d.updatedAt = Date.now();
|
|
2381
|
+
this._emit();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
// ── Stats ────────────────────────────────────────────────────────────────
|
|
2385
|
+
getStats() {
|
|
2386
|
+
const all = this.getAll();
|
|
2387
|
+
return {
|
|
2388
|
+
total: all.length,
|
|
2389
|
+
open: all.filter((d) => d.status === "open").length,
|
|
2390
|
+
underReview: all.filter((d) => d.status === "under-review").length,
|
|
2391
|
+
resolved: all.filter((d) => d.status === "resolved").length,
|
|
2392
|
+
rejected: all.filter((d) => d.status === "rejected").length,
|
|
2393
|
+
escalated: all.filter((d) => d.status === "escalated").length,
|
|
2394
|
+
totalAmount: all.reduce((s, d) => s + d.amount, 0)
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
2398
|
+
disputes$() {
|
|
2399
|
+
return this._disputes$.asObservable();
|
|
2400
|
+
}
|
|
2401
|
+
// ── Internals ────────────────────────────────────────────────────────────
|
|
2402
|
+
_require(id) {
|
|
2403
|
+
const d = this._disputes.get(id);
|
|
2404
|
+
if (!d) throw new Error(`Dispute '${id}' not found`);
|
|
2405
|
+
return d;
|
|
2406
|
+
}
|
|
2407
|
+
_transition(id, status, mutate) {
|
|
2408
|
+
const d = this._require(id);
|
|
2409
|
+
d.status = status;
|
|
2410
|
+
d.updatedAt = Date.now();
|
|
2411
|
+
if (mutate) mutate(d);
|
|
2412
|
+
this._emit();
|
|
2413
|
+
return { ...d };
|
|
2414
|
+
}
|
|
2415
|
+
_generateRef() {
|
|
2416
|
+
const n = String(this._counter++).padStart(6, "0");
|
|
2417
|
+
return `DSP-${(/* @__PURE__ */ new Date()).getFullYear()}-${n}`;
|
|
2418
|
+
}
|
|
2419
|
+
_emit() {
|
|
2420
|
+
this._disputes$.next(this.getAll());
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
function _uid5() {
|
|
2424
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// src/banking/bill-payment.service.ts
|
|
2428
|
+
var JoopBillPaymentService = class {
|
|
2429
|
+
_billers = /* @__PURE__ */ new Map();
|
|
2430
|
+
_bills = /* @__PURE__ */ new Map();
|
|
2431
|
+
_payments = [];
|
|
2432
|
+
_payments$ = new JoopBehaviorSubject([]);
|
|
2433
|
+
// ── Billers ───────────────────────────────────────────────────────────────
|
|
2434
|
+
addBiller(data) {
|
|
2435
|
+
const biller = { ...data, id: _uid6(), isActive: true, createdAt: Date.now() };
|
|
2436
|
+
this._billers.set(biller.id, biller);
|
|
2437
|
+
return { ...biller };
|
|
2438
|
+
}
|
|
2439
|
+
updateBiller(id, patch) {
|
|
2440
|
+
const b = this._requireBiller(id);
|
|
2441
|
+
Object.assign(b, patch);
|
|
2442
|
+
return { ...b };
|
|
2443
|
+
}
|
|
2444
|
+
removeBiller(id) {
|
|
2445
|
+
this._billers.delete(id);
|
|
2446
|
+
}
|
|
2447
|
+
getBiller(id) {
|
|
2448
|
+
const b = this._billers.get(id);
|
|
2449
|
+
return b ? { ...b } : void 0;
|
|
2450
|
+
}
|
|
2451
|
+
getBillers(category) {
|
|
2452
|
+
const all = Array.from(this._billers.values()).filter((b) => b.isActive).map((b) => ({ ...b }));
|
|
2453
|
+
return category ? all.filter((b) => b.category === category) : all;
|
|
2454
|
+
}
|
|
2455
|
+
// ── Bills ─────────────────────────────────────────────────────────────────
|
|
2456
|
+
addBill(data) {
|
|
2457
|
+
const biller = this._billers.get(data.billerId);
|
|
2458
|
+
const bill = { ...data, id: _uid6(), billerName: biller?.name ?? data.billerName, status: "pending", createdAt: Date.now() };
|
|
2459
|
+
this._bills.set(bill.id, bill);
|
|
2460
|
+
return { ...bill };
|
|
2461
|
+
}
|
|
2462
|
+
getBills(billerId) {
|
|
2463
|
+
const all = Array.from(this._bills.values()).map((b) => ({ ...b }));
|
|
2464
|
+
return billerId ? all.filter((b) => b.billerId === billerId) : all;
|
|
2465
|
+
}
|
|
2466
|
+
getOverdueBills(asOf = Date.now()) {
|
|
2467
|
+
return this.getBills().filter((b) => b.status === "pending" && b.dueDate < asOf);
|
|
2468
|
+
}
|
|
2469
|
+
getUpcomingBills(days = 7, asOf = Date.now()) {
|
|
2470
|
+
const cutoff = asOf + days * 864e5;
|
|
2471
|
+
return this.getBills().filter((b) => b.status === "pending" && b.dueDate >= asOf && b.dueDate <= cutoff);
|
|
2472
|
+
}
|
|
2473
|
+
// ── Payments ─────────────────────────────────────────────────────────────
|
|
2474
|
+
pay(billerId, amount, options = {}) {
|
|
2475
|
+
const biller = this._requireBiller(billerId);
|
|
2476
|
+
const payment = {
|
|
2477
|
+
id: _uid6(),
|
|
2478
|
+
billerId,
|
|
2479
|
+
billId: options.billId,
|
|
2480
|
+
billerName: biller.name,
|
|
2481
|
+
amount,
|
|
2482
|
+
currency: biller.currency,
|
|
2483
|
+
status: "success",
|
|
2484
|
+
reference: options.reference ?? `PAY${Date.now().toString(36).toUpperCase()}`,
|
|
2485
|
+
fromAccount: options.fromAccount,
|
|
2486
|
+
paidAt: Date.now()
|
|
2487
|
+
};
|
|
2488
|
+
this._payments.push(payment);
|
|
2489
|
+
if (options.billId) {
|
|
2490
|
+
const bill = this._bills.get(options.billId);
|
|
2491
|
+
if (bill) bill.status = "paid";
|
|
2492
|
+
}
|
|
2493
|
+
this._payments$.next([...this._payments]);
|
|
2494
|
+
return { ...payment };
|
|
2495
|
+
}
|
|
2496
|
+
getPaymentHistory(billerId) {
|
|
2497
|
+
return billerId ? this._payments.filter((p) => p.billerId === billerId).map((p) => ({ ...p })) : this._payments.map((p) => ({ ...p }));
|
|
2498
|
+
}
|
|
2499
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
2500
|
+
getSummary() {
|
|
2501
|
+
const now = /* @__PURE__ */ new Date();
|
|
2502
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
2503
|
+
const yearStart = new Date(now.getFullYear(), 0, 1).getTime();
|
|
2504
|
+
const successful = this._payments.filter((p) => p.status === "success");
|
|
2505
|
+
return {
|
|
2506
|
+
totalPaidThisMonth: successful.filter((p) => p.paidAt >= monthStart).reduce((s, p) => s + p.amount, 0),
|
|
2507
|
+
totalPaidThisYear: successful.filter((p) => p.paidAt >= yearStart).reduce((s, p) => s + p.amount, 0),
|
|
2508
|
+
paymentCount: successful.length,
|
|
2509
|
+
billerCount: this._billers.size,
|
|
2510
|
+
currency: "MIXED"
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
2514
|
+
payments$() {
|
|
2515
|
+
return this._payments$.asObservable();
|
|
2516
|
+
}
|
|
2517
|
+
// ── Internals ────────────────────────────────────────────────────────────
|
|
2518
|
+
_requireBiller(id) {
|
|
2519
|
+
const b = this._billers.get(id);
|
|
2520
|
+
if (!b) throw new Error(`Biller '${id}' not found`);
|
|
2521
|
+
return b;
|
|
2522
|
+
}
|
|
2523
|
+
};
|
|
2524
|
+
function _uid6() {
|
|
2525
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// src/banking/payment-uri.service.ts
|
|
2529
|
+
var JoopPaymentUriService = class {
|
|
2530
|
+
// ── Parse ─────────────────────────────────────────────────────────────────
|
|
2531
|
+
parse(uri) {
|
|
2532
|
+
const raw = uri.trim();
|
|
2533
|
+
if (raw.toLowerCase().startsWith("upi://")) return this._parseUpi(raw);
|
|
2534
|
+
if (raw.toLowerCase().startsWith("bitcoin:")) return this._parseBitcoin(raw);
|
|
2535
|
+
if (raw.toLowerCase().startsWith("ethereum:")) return this._parseEthereum(raw);
|
|
2536
|
+
if (raw.toLowerCase().startsWith("payto:")) return this._parsePayTo(raw);
|
|
2537
|
+
if (raw.toLowerCase().startsWith("sepa:")) return this._parseSepa(raw);
|
|
2538
|
+
return { scheme: "generic", raw, isValid: false, errors: [`Unknown URI scheme: "${raw.split(":")[0]}"`] };
|
|
2539
|
+
}
|
|
2540
|
+
// ── Generate ─────────────────────────────────────────────────────────────
|
|
2541
|
+
generateUpi(params) {
|
|
2542
|
+
if (!params.pa) throw new Error("pa (payee VPA) is required");
|
|
2543
|
+
const q = new URLSearchParams();
|
|
2544
|
+
q.set("pa", params.pa);
|
|
2545
|
+
if (params.pn) q.set("pn", params.pn);
|
|
2546
|
+
if (params.am !== void 0) q.set("am", String(params.am));
|
|
2547
|
+
if (params.cu) q.set("cu", params.cu);
|
|
2548
|
+
if (params.tn) q.set("tn", params.tn);
|
|
2549
|
+
if (params.tr) q.set("tr", params.tr);
|
|
2550
|
+
if (params.mc) q.set("mc", params.mc);
|
|
2551
|
+
return `upi://pay?${q.toString()}`;
|
|
2552
|
+
}
|
|
2553
|
+
generateBitcoin(params) {
|
|
2554
|
+
if (!params.address) throw new Error("address is required");
|
|
2555
|
+
const q = new URLSearchParams();
|
|
2556
|
+
if (params.amount !== void 0) q.set("amount", String(params.amount));
|
|
2557
|
+
if (params.label) q.set("label", params.label);
|
|
2558
|
+
if (params.message) q.set("message", params.message);
|
|
2559
|
+
const qs = q.toString();
|
|
2560
|
+
return `bitcoin:${params.address}${qs ? `?${qs}` : ""}`;
|
|
2561
|
+
}
|
|
2562
|
+
generateEthereum(params) {
|
|
2563
|
+
if (!params.address) throw new Error("address is required");
|
|
2564
|
+
const q = new URLSearchParams();
|
|
2565
|
+
if (params.value !== void 0) q.set("value", String(params.value));
|
|
2566
|
+
if (params.gas !== void 0) q.set("gas", String(params.gas));
|
|
2567
|
+
if (params.data) q.set("data", params.data);
|
|
2568
|
+
const qs = q.toString();
|
|
2569
|
+
return `ethereum:${params.address}${qs ? `?${qs}` : ""}`;
|
|
2570
|
+
}
|
|
2571
|
+
generatePayTo(params) {
|
|
2572
|
+
const q = new URLSearchParams();
|
|
2573
|
+
if (params.amount !== void 0) q.set("amount", `${params.currency ?? "AUD"}:${params.amount}`);
|
|
2574
|
+
if (params.description) q.set("message", params.description);
|
|
2575
|
+
const qs = q.toString();
|
|
2576
|
+
return `payto://${params.payId}${qs ? `?${qs}` : ""}`;
|
|
2577
|
+
}
|
|
2578
|
+
generateSepa(params) {
|
|
2579
|
+
const fields = [
|
|
2580
|
+
"BCD",
|
|
2581
|
+
"002",
|
|
2582
|
+
"1",
|
|
2583
|
+
"SCT",
|
|
2584
|
+
params.bic ?? "",
|
|
2585
|
+
params.name ?? "",
|
|
2586
|
+
params.iban,
|
|
2587
|
+
params.amount ? `${params.currency ?? "EUR"}${params.amount.toFixed(2)}` : "",
|
|
2588
|
+
params.purpose ?? "",
|
|
2589
|
+
params.reference ?? ""
|
|
2590
|
+
].join("\n");
|
|
2591
|
+
return fields;
|
|
2592
|
+
}
|
|
2593
|
+
// ── Validate ─────────────────────────────────────────────────────────────
|
|
2594
|
+
isValid(uri) {
|
|
2595
|
+
return this.parse(uri).isValid;
|
|
2596
|
+
}
|
|
2597
|
+
getScheme(uri) {
|
|
2598
|
+
const parsed = this.parse(uri);
|
|
2599
|
+
return parsed.isValid ? parsed.scheme : null;
|
|
2600
|
+
}
|
|
2601
|
+
// ── Parsers ──────────────────────────────────────────────────────────────
|
|
2602
|
+
_parseUpi(raw) {
|
|
2603
|
+
const errors = [];
|
|
2604
|
+
try {
|
|
2605
|
+
const url = new URL(raw);
|
|
2606
|
+
if (url.pathname !== "pay" && url.host !== "pay") {
|
|
2607
|
+
const path = (url.pathname || url.host || "").replace(/^\//, "");
|
|
2608
|
+
if (path && path !== "pay") errors.push(`Unknown UPI action: ${path}`);
|
|
2609
|
+
}
|
|
2610
|
+
const pa = url.searchParams.get("pa");
|
|
2611
|
+
if (!pa) errors.push("pa (payee VPA) is required");
|
|
2612
|
+
else if (!/^[\w.\-+]+@[\w]+$/.test(pa)) errors.push("Invalid VPA format");
|
|
2613
|
+
const am = url.searchParams.get("am");
|
|
2614
|
+
if (am && isNaN(Number(am))) errors.push("am (amount) must be a number");
|
|
2615
|
+
const params = {
|
|
2616
|
+
pa: pa ?? "",
|
|
2617
|
+
pn: url.searchParams.get("pn") ?? void 0,
|
|
2618
|
+
am: am ? Number(am) : void 0,
|
|
2619
|
+
cu: url.searchParams.get("cu") ?? "INR",
|
|
2620
|
+
tn: url.searchParams.get("tn") ?? void 0,
|
|
2621
|
+
tr: url.searchParams.get("tr") ?? void 0,
|
|
2622
|
+
mc: url.searchParams.get("mc") ?? void 0
|
|
2623
|
+
};
|
|
2624
|
+
return { scheme: "upi", raw, isValid: errors.length === 0, errors, upi: params };
|
|
2625
|
+
} catch (e) {
|
|
2626
|
+
return { scheme: "upi", raw, isValid: false, errors: ["Invalid UPI URI"] };
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
_parseBitcoin(raw) {
|
|
2630
|
+
const errors = [];
|
|
2631
|
+
const [addressPart, queryPart] = raw.slice("bitcoin:".length).split("?");
|
|
2632
|
+
const address = addressPart.trim();
|
|
2633
|
+
if (!address) errors.push("Bitcoin address is required");
|
|
2634
|
+
else if (!/^(bc1|[13])[a-zA-Z0-9]{25,62}$/.test(address)) errors.push("Invalid Bitcoin address format");
|
|
2635
|
+
const q = queryPart ? new URLSearchParams(queryPart) : new URLSearchParams();
|
|
2636
|
+
const amount = q.get("amount") ? Number(q.get("amount")) : void 0;
|
|
2637
|
+
if (amount !== void 0 && isNaN(amount)) errors.push("amount must be a number");
|
|
2638
|
+
const params = { address, amount, label: q.get("label") ?? void 0, message: q.get("message") ?? void 0 };
|
|
2639
|
+
return { scheme: "bitcoin", raw, isValid: errors.length === 0, errors, bitcoin: params };
|
|
2640
|
+
}
|
|
2641
|
+
_parseEthereum(raw) {
|
|
2642
|
+
const errors = [];
|
|
2643
|
+
const [addressPart, queryPart] = raw.slice("ethereum:".length).split("?");
|
|
2644
|
+
const address = addressPart.trim();
|
|
2645
|
+
if (!address) errors.push("Ethereum address is required");
|
|
2646
|
+
else if (!/^0x[0-9a-fA-F]{40}$/.test(address)) errors.push("Invalid Ethereum address (expected 0x + 40 hex chars)");
|
|
2647
|
+
const q = queryPart ? new URLSearchParams(queryPart) : new URLSearchParams();
|
|
2648
|
+
const value = q.get("value") ? Number(q.get("value")) : void 0;
|
|
2649
|
+
const params = { address, value, gas: q.get("gas") ? Number(q.get("gas")) : void 0, data: q.get("data") ?? void 0 };
|
|
2650
|
+
return { scheme: "ethereum", raw, isValid: errors.length === 0, errors, ethereum: params };
|
|
2651
|
+
}
|
|
2652
|
+
_parsePayTo(raw) {
|
|
2653
|
+
const errors = [];
|
|
2654
|
+
try {
|
|
2655
|
+
const url = new URL(raw);
|
|
2656
|
+
const payId = url.hostname + (url.pathname || "");
|
|
2657
|
+
if (!payId) errors.push("PayID is required");
|
|
2658
|
+
const amountParam = url.searchParams.get("amount");
|
|
2659
|
+
let amount;
|
|
2660
|
+
let currency;
|
|
2661
|
+
if (amountParam) {
|
|
2662
|
+
const match = amountParam.match(/^([A-Z]{3}):?([\d.]+)$/);
|
|
2663
|
+
if (match) {
|
|
2664
|
+
currency = match[1];
|
|
2665
|
+
amount = Number(match[2]);
|
|
2666
|
+
} else errors.push("amount format: CCY:amount (e.g. AUD:50.00)");
|
|
2667
|
+
}
|
|
2668
|
+
const params = { payId, amount, currency, description: url.searchParams.get("message") ?? void 0 };
|
|
2669
|
+
return { scheme: "payto", raw, isValid: errors.length === 0, errors, payto: params };
|
|
2670
|
+
} catch {
|
|
2671
|
+
return { scheme: "payto", raw, isValid: false, errors: ["Invalid PayTo URI"] };
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
_parseSepa(raw) {
|
|
2675
|
+
const errors = [];
|
|
2676
|
+
const [ibanPart, queryPart] = raw.slice("sepa:".length).split("?");
|
|
2677
|
+
const iban = ibanPart.trim().replace(/\s/g, "");
|
|
2678
|
+
if (!iban) errors.push("IBAN is required");
|
|
2679
|
+
else if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) errors.push("Invalid IBAN format");
|
|
2680
|
+
const q = queryPart ? new URLSearchParams(queryPart) : new URLSearchParams();
|
|
2681
|
+
const amount = q.get("amount") ? Number(q.get("amount")) : void 0;
|
|
2682
|
+
const params = {
|
|
2683
|
+
iban,
|
|
2684
|
+
bic: q.get("bic") ?? void 0,
|
|
2685
|
+
name: q.get("name") ?? void 0,
|
|
2686
|
+
amount,
|
|
2687
|
+
currency: q.get("currency") ?? "EUR",
|
|
2688
|
+
reference: q.get("reference") ?? void 0,
|
|
2689
|
+
purpose: q.get("purpose") ?? void 0
|
|
2690
|
+
};
|
|
2691
|
+
return { scheme: "sepa", raw, isValid: errors.length === 0, errors, sepa: params };
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2695
|
+
// src/banking/mandate.service.ts
|
|
2696
|
+
var JoopMandateService = class {
|
|
2697
|
+
_mandates = /* @__PURE__ */ new Map();
|
|
2698
|
+
_executions = /* @__PURE__ */ new Map();
|
|
2699
|
+
_counter = 0;
|
|
2700
|
+
_change$ = new JoopSubject();
|
|
2701
|
+
// ── CRUD ─────────────────────────────────────────────────────────────────
|
|
2702
|
+
create(data) {
|
|
2703
|
+
if (data.maxAmount <= 0) throw new Error("maxAmount must be positive");
|
|
2704
|
+
const now = Date.now();
|
|
2705
|
+
const mandate = {
|
|
2706
|
+
id: _uid7(),
|
|
2707
|
+
ref: this._generateRef(),
|
|
2708
|
+
status: "pending",
|
|
2709
|
+
executionCount: 0,
|
|
2710
|
+
createdAt: now,
|
|
2711
|
+
updatedAt: now,
|
|
2712
|
+
nextExecutionAt: this._computeNextDate({ frequency: data.frequency, startDate: data.startDate }, data.startDate),
|
|
2713
|
+
...data
|
|
2714
|
+
};
|
|
2715
|
+
this._mandates.set(mandate.id, mandate);
|
|
2716
|
+
this._executions.set(mandate.id, []);
|
|
2717
|
+
return { ...mandate };
|
|
2718
|
+
}
|
|
2719
|
+
get(id) {
|
|
2720
|
+
const m = this._mandates.get(id);
|
|
2721
|
+
return m ? { ...m } : void 0;
|
|
2722
|
+
}
|
|
2723
|
+
getAll() {
|
|
2724
|
+
return Array.from(this._mandates.values()).map((m) => ({ ...m }));
|
|
2725
|
+
}
|
|
2726
|
+
// ── State transitions ─────────────────────────────────────────────────────
|
|
2727
|
+
activate(id) {
|
|
2728
|
+
return this._transition(id, "active");
|
|
2729
|
+
}
|
|
2730
|
+
cancel(id) {
|
|
2731
|
+
return this._transition(id, "cancelled");
|
|
2732
|
+
}
|
|
2733
|
+
pause(id) {
|
|
2734
|
+
return this._transition(id, "paused", (m) => {
|
|
2735
|
+
if (m.status !== "active") throw new Error("Only active mandates can be paused");
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
resume(id) {
|
|
2739
|
+
return this._transition(id, "active", (m) => {
|
|
2740
|
+
if (m.status !== "paused") throw new Error("Only paused mandates can be resumed");
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
fail(id) {
|
|
2744
|
+
return this._transition(id, "failed");
|
|
2745
|
+
}
|
|
2746
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
2747
|
+
getActive() {
|
|
2748
|
+
return this._byStatus("active");
|
|
2749
|
+
}
|
|
2750
|
+
getByAccount(accountId) {
|
|
2751
|
+
return this.getAll().filter((m) => m.accountId === accountId);
|
|
2752
|
+
}
|
|
2753
|
+
getByStatus(status) {
|
|
2754
|
+
return this._byStatus(status);
|
|
2755
|
+
}
|
|
2756
|
+
getDue(asOf = Date.now()) {
|
|
2757
|
+
return this.getActive().filter((m) => m.nextExecutionAt !== void 0 && m.nextExecutionAt <= asOf && !this.isExpired(m));
|
|
2758
|
+
}
|
|
2759
|
+
isExpired(mandate) {
|
|
2760
|
+
return mandate.endDate !== void 0 && Date.now() > mandate.endDate;
|
|
2761
|
+
}
|
|
2762
|
+
// ── Execution ─────────────────────────────────────────────────────────────
|
|
2763
|
+
recordExecution(mandateId, amount, status, failureReason) {
|
|
2764
|
+
const m = this._require(mandateId);
|
|
2765
|
+
if (amount > m.maxAmount) throw new Error(`Execution amount ${amount} exceeds mandate max ${m.maxAmount}`);
|
|
2766
|
+
const exec = { id: _uid7(), mandateId, amount, status, date: Date.now(), failureReason };
|
|
2767
|
+
this._executions.get(mandateId).push(exec);
|
|
2768
|
+
if (status === "success") {
|
|
2769
|
+
m.executionCount++;
|
|
2770
|
+
m.lastExecutedAt = exec.date;
|
|
2771
|
+
m.nextExecutionAt = this.computeNextDate(m, exec.date);
|
|
2772
|
+
}
|
|
2773
|
+
if (status === "failed" && !failureReason) exec.failureReason = "Unknown";
|
|
2774
|
+
m.updatedAt = Date.now();
|
|
2775
|
+
this._change$.next({ ...m });
|
|
2776
|
+
return { ...exec };
|
|
2777
|
+
}
|
|
2778
|
+
getExecutions(mandateId) {
|
|
2779
|
+
return [...this._executions.get(mandateId) ?? []];
|
|
2780
|
+
}
|
|
2781
|
+
// ── Date computation ──────────────────────────────────────────────────────
|
|
2782
|
+
computeNextDate(mandate, fromDate = Date.now()) {
|
|
2783
|
+
return this._computeNextDate(mandate, fromDate);
|
|
2784
|
+
}
|
|
2785
|
+
// ── Observable ────────────────────────────────────────────────────────────
|
|
2786
|
+
change$() {
|
|
2787
|
+
return this._change$.asObservable();
|
|
2788
|
+
}
|
|
2789
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
2790
|
+
_require(id) {
|
|
2791
|
+
const m = this._mandates.get(id);
|
|
2792
|
+
if (!m) throw new Error(`Mandate '${id}' not found`);
|
|
2793
|
+
return m;
|
|
2794
|
+
}
|
|
2795
|
+
_transition(id, status, validate) {
|
|
2796
|
+
const m = this._require(id);
|
|
2797
|
+
if (validate) validate(m);
|
|
2798
|
+
m.status = status;
|
|
2799
|
+
m.updatedAt = Date.now();
|
|
2800
|
+
this._change$.next({ ...m });
|
|
2801
|
+
return { ...m };
|
|
2802
|
+
}
|
|
2803
|
+
_byStatus(status) {
|
|
2804
|
+
return this.getAll().filter((m) => m.status === status);
|
|
2805
|
+
}
|
|
2806
|
+
_computeNextDate(mandate, from) {
|
|
2807
|
+
const d = new Date(from);
|
|
2808
|
+
switch (mandate.frequency) {
|
|
2809
|
+
case "daily":
|
|
2810
|
+
d.setDate(d.getDate() + 1);
|
|
2811
|
+
break;
|
|
2812
|
+
case "weekly":
|
|
2813
|
+
d.setDate(d.getDate() + 7);
|
|
2814
|
+
break;
|
|
2815
|
+
case "biweekly":
|
|
2816
|
+
d.setDate(d.getDate() + 14);
|
|
2817
|
+
break;
|
|
2818
|
+
case "monthly":
|
|
2819
|
+
d.setMonth(d.getMonth() + 1);
|
|
2820
|
+
break;
|
|
2821
|
+
case "quarterly":
|
|
2822
|
+
d.setMonth(d.getMonth() + 3);
|
|
2823
|
+
break;
|
|
2824
|
+
case "half-yearly":
|
|
2825
|
+
d.setMonth(d.getMonth() + 6);
|
|
2826
|
+
break;
|
|
2827
|
+
case "yearly":
|
|
2828
|
+
d.setFullYear(d.getFullYear() + 1);
|
|
2829
|
+
break;
|
|
2830
|
+
case "as-presented":
|
|
2831
|
+
return void 0;
|
|
2832
|
+
}
|
|
2833
|
+
return d.getTime();
|
|
2834
|
+
}
|
|
2835
|
+
_generateRef() {
|
|
2836
|
+
this._counter++;
|
|
2837
|
+
return `MND-${(/* @__PURE__ */ new Date()).getFullYear()}-${String(this._counter).padStart(6, "0")}`;
|
|
2838
|
+
}
|
|
2839
|
+
};
|
|
2840
|
+
function _uid7() {
|
|
2841
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// src/banking/split-payment.service.ts
|
|
2845
|
+
var JoopSplitPaymentService = class {
|
|
2846
|
+
_expenses = /* @__PURE__ */ new Map();
|
|
2847
|
+
_change$ = new JoopSubject();
|
|
2848
|
+
// ── Create ────────────────────────────────────────────────────────────────
|
|
2849
|
+
create(title, totalAmount, paidBy, participantNames, options = {}) {
|
|
2850
|
+
if (totalAmount <= 0) throw new Error("totalAmount must be positive");
|
|
2851
|
+
if (participantNames.length === 0) throw new Error("At least one participant required");
|
|
2852
|
+
const method = options.method ?? "equal";
|
|
2853
|
+
const shares = this._computeShares(totalAmount, participantNames.length, method, options);
|
|
2854
|
+
const participants = participantNames.map((name, i) => ({
|
|
2855
|
+
id: _uid8(),
|
|
2856
|
+
name,
|
|
2857
|
+
share: shares[i],
|
|
2858
|
+
paid: 0,
|
|
2859
|
+
status: "pending"
|
|
2860
|
+
}));
|
|
2861
|
+
const expense = {
|
|
2862
|
+
id: _uid8(),
|
|
2863
|
+
title,
|
|
2864
|
+
totalAmount,
|
|
2865
|
+
currency: options.currency ?? "USD",
|
|
2866
|
+
paidBy,
|
|
2867
|
+
method,
|
|
2868
|
+
participants,
|
|
2869
|
+
status: "open",
|
|
2870
|
+
notes: options.notes,
|
|
2871
|
+
createdAt: Date.now()
|
|
2872
|
+
};
|
|
2873
|
+
this._expenses.set(expense.id, expense);
|
|
2874
|
+
return this._snapshot(expense);
|
|
2875
|
+
}
|
|
2876
|
+
// ── Payment recording ─────────────────────────────────────────────────────
|
|
2877
|
+
recordPayment(expenseId, participantId, amount) {
|
|
2878
|
+
const e = this._require(expenseId);
|
|
2879
|
+
if (e.status === "settled" || e.status === "cancelled") throw new Error(`Expense is ${e.status}`);
|
|
2880
|
+
const p = e.participants.find((x) => x.id === participantId);
|
|
2881
|
+
if (!p) throw new Error(`Participant '${participantId}' not found`);
|
|
2882
|
+
if (amount <= 0) throw new Error("Payment amount must be positive");
|
|
2883
|
+
p.paid = _round(p.paid + amount);
|
|
2884
|
+
p.paid = Math.min(p.paid, p.share);
|
|
2885
|
+
p.status = p.paid >= p.share ? "paid" : "partial";
|
|
2886
|
+
this._updateStatus(e);
|
|
2887
|
+
this._change$.next(this._snapshot(e));
|
|
2888
|
+
return this._snapshot(e);
|
|
2889
|
+
}
|
|
2890
|
+
// ── Status ────────────────────────────────────────────────────────────────
|
|
2891
|
+
settle(expenseId) {
|
|
2892
|
+
const e = this._require(expenseId);
|
|
2893
|
+
e.status = "settled";
|
|
2894
|
+
e.settledAt = Date.now();
|
|
2895
|
+
this._change$.next(this._snapshot(e));
|
|
2896
|
+
}
|
|
2897
|
+
cancel(expenseId) {
|
|
2898
|
+
const e = this._require(expenseId);
|
|
2899
|
+
e.status = "cancelled";
|
|
2900
|
+
this._change$.next(this._snapshot(e));
|
|
2901
|
+
}
|
|
2902
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
2903
|
+
get(id) {
|
|
2904
|
+
const e = this._expenses.get(id);
|
|
2905
|
+
return e ? this._snapshot(e) : void 0;
|
|
2906
|
+
}
|
|
2907
|
+
getAll() {
|
|
2908
|
+
return Array.from(this._expenses.values()).map((e) => this._snapshot(e));
|
|
2909
|
+
}
|
|
2910
|
+
getOpen() {
|
|
2911
|
+
return this.getAll().filter((e) => e.status === "open" || e.status === "partial");
|
|
2912
|
+
}
|
|
2913
|
+
// ── Settlements ───────────────────────────────────────────────────────────
|
|
2914
|
+
getSettlements(expenseId) {
|
|
2915
|
+
const e = this._require(expenseId);
|
|
2916
|
+
return e.participants.filter((p) => p.paid < p.share && p.name !== e.paidBy).map((p) => ({ from: p.name, to: e.paidBy, amount: _round(p.share - p.paid) }));
|
|
2917
|
+
}
|
|
2918
|
+
getSimplifiedSettlements(expenseId) {
|
|
2919
|
+
return this.getSettlements(expenseId);
|
|
2920
|
+
}
|
|
2921
|
+
// ── Balance ───────────────────────────────────────────────────────────────
|
|
2922
|
+
getBalance(name) {
|
|
2923
|
+
let owes = 0;
|
|
2924
|
+
let owed = 0;
|
|
2925
|
+
for (const e of this.getOpen()) {
|
|
2926
|
+
const p = e.participants.find((x) => x.name === name);
|
|
2927
|
+
if (p && e.paidBy !== name) owes += Math.max(0, p.share - p.paid);
|
|
2928
|
+
if (e.paidBy === name) {
|
|
2929
|
+
for (const other of e.participants) {
|
|
2930
|
+
if (other.name !== name) owed += Math.max(0, other.share - other.paid);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
return { name, owes: _round(owes), owed: _round(owed), net: _round(owed - owes) };
|
|
2935
|
+
}
|
|
2936
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
2937
|
+
getSummary(expenseId) {
|
|
2938
|
+
const e = this._require(expenseId);
|
|
2939
|
+
const paid = e.participants.reduce((s, p) => s + p.paid, 0);
|
|
2940
|
+
return {
|
|
2941
|
+
total: e.totalAmount,
|
|
2942
|
+
paid: _round(paid),
|
|
2943
|
+
remaining: _round(e.totalAmount - paid),
|
|
2944
|
+
participantCount: e.participants.length,
|
|
2945
|
+
status: e.status
|
|
2946
|
+
};
|
|
2947
|
+
}
|
|
2948
|
+
change$() {
|
|
2949
|
+
return this._change$.asObservable();
|
|
2950
|
+
}
|
|
2951
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
2952
|
+
_computeShares(total, n, method, opts) {
|
|
2953
|
+
switch (method) {
|
|
2954
|
+
case "equal": {
|
|
2955
|
+
const each = _round(total / n);
|
|
2956
|
+
const arr = Array(n).fill(each);
|
|
2957
|
+
const diff = _round(total - arr.reduce((s, x) => s + x, 0));
|
|
2958
|
+
arr[0] = _round(arr[0] + diff);
|
|
2959
|
+
return arr;
|
|
2960
|
+
}
|
|
2961
|
+
case "exact": {
|
|
2962
|
+
const amounts = opts.amounts ?? [];
|
|
2963
|
+
if (amounts.length !== n) throw new Error("amounts length must match participant count");
|
|
2964
|
+
const sum = amounts.reduce((s, x) => s + x, 0);
|
|
2965
|
+
if (Math.abs(sum - total) > 0.01) throw new Error("Exact amounts must sum to totalAmount");
|
|
2966
|
+
return amounts.map((a) => _round(a));
|
|
2967
|
+
}
|
|
2968
|
+
case "percentage": {
|
|
2969
|
+
const pcts = opts.percentages ?? [];
|
|
2970
|
+
if (pcts.length !== n) throw new Error("percentages length must match participant count");
|
|
2971
|
+
const sum = pcts.reduce((s, x) => s + x, 0);
|
|
2972
|
+
if (Math.abs(sum - 100) > 0.01) throw new Error("Percentages must sum to 100");
|
|
2973
|
+
return pcts.map((p) => _round(p / 100 * total));
|
|
2974
|
+
}
|
|
2975
|
+
case "shares": {
|
|
2976
|
+
const sh = opts.shares ?? Array(n).fill(1);
|
|
2977
|
+
if (sh.length !== n) throw new Error("shares length must match participant count");
|
|
2978
|
+
const totalShares = sh.reduce((s, x) => s + x, 0);
|
|
2979
|
+
return sh.map((s) => _round(s / totalShares * total));
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
_updateStatus(e) {
|
|
2984
|
+
const allPaid = e.participants.every((p) => p.status === "paid");
|
|
2985
|
+
const nonePaid = e.participants.every((p) => p.paid === 0);
|
|
2986
|
+
if (allPaid) {
|
|
2987
|
+
e.status = "settled";
|
|
2988
|
+
e.settledAt = Date.now();
|
|
2989
|
+
} else if (nonePaid) e.status = "open";
|
|
2990
|
+
else e.status = "partial";
|
|
2991
|
+
}
|
|
2992
|
+
_require(id) {
|
|
2993
|
+
const e = this._expenses.get(id);
|
|
2994
|
+
if (!e) throw new Error(`Expense '${id}' not found`);
|
|
2995
|
+
return e;
|
|
2996
|
+
}
|
|
2997
|
+
_snapshot(e) {
|
|
2998
|
+
return { ...e, participants: e.participants.map((p) => ({ ...p })) };
|
|
2999
|
+
}
|
|
3000
|
+
};
|
|
3001
|
+
function _uid8() {
|
|
3002
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3003
|
+
}
|
|
3004
|
+
function _round(n) {
|
|
3005
|
+
return parseFloat(n.toFixed(2));
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
// src/banking/chequebook.service.ts
|
|
3009
|
+
var JoopChequebookService = class {
|
|
3010
|
+
_books = /* @__PURE__ */ new Map();
|
|
3011
|
+
_leafIndex = /* @__PURE__ */ new Map();
|
|
3012
|
+
// chequeNumber → leaf
|
|
3013
|
+
_change$ = new JoopSubject();
|
|
3014
|
+
// ── Chequebook management ─────────────────────────────────────────────────
|
|
3015
|
+
addChequebook(accountId, seriesStart, seriesEnd) {
|
|
3016
|
+
const leaves = this._generateLeaves(accountId, "pending-id", seriesStart, seriesEnd);
|
|
3017
|
+
const book = {
|
|
3018
|
+
id: _uid9(),
|
|
3019
|
+
accountId,
|
|
3020
|
+
seriesStart,
|
|
3021
|
+
seriesEnd,
|
|
3022
|
+
leaves: [],
|
|
3023
|
+
issuedAt: Date.now(),
|
|
3024
|
+
isActive: true
|
|
3025
|
+
};
|
|
3026
|
+
leaves.forEach((l) => {
|
|
3027
|
+
l.chequebookId = book.id;
|
|
3028
|
+
book.leaves.push(l);
|
|
3029
|
+
this._leafIndex.set(l.chequeNumber, l);
|
|
3030
|
+
});
|
|
3031
|
+
this._books.set(book.id, book);
|
|
3032
|
+
return this._snapshotBook(book);
|
|
3033
|
+
}
|
|
3034
|
+
getChequebook(id) {
|
|
3035
|
+
const b = this._books.get(id);
|
|
3036
|
+
return b ? this._snapshotBook(b) : void 0;
|
|
3037
|
+
}
|
|
3038
|
+
getByAccount(accountId) {
|
|
3039
|
+
return Array.from(this._books.values()).filter((b) => b.accountId === accountId).map((b) => this._snapshotBook(b));
|
|
3040
|
+
}
|
|
3041
|
+
deactivateChequebook(id) {
|
|
3042
|
+
const b = this._books.get(id);
|
|
3043
|
+
if (!b) throw new Error(`Chequebook '${id}' not found`);
|
|
3044
|
+
b.isActive = false;
|
|
3045
|
+
}
|
|
3046
|
+
// ── Leaf operations ───────────────────────────────────────────────────────
|
|
3047
|
+
issueCheque(chequeNumber, issuedTo, amount, options = {}) {
|
|
3048
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3049
|
+
if (leaf.status !== "available") throw new Error(`Cheque ${chequeNumber} is not available (status: ${leaf.status})`);
|
|
3050
|
+
if (amount <= 0) throw new Error("Amount must be positive");
|
|
3051
|
+
leaf.status = "issued";
|
|
3052
|
+
leaf.issuedTo = issuedTo;
|
|
3053
|
+
leaf.amount = amount;
|
|
3054
|
+
leaf.issuedAt = Date.now();
|
|
3055
|
+
leaf.expiresAt = options.expiresAt;
|
|
3056
|
+
leaf.notes = options.notes;
|
|
3057
|
+
this._change$.next({ ...leaf });
|
|
3058
|
+
return { ...leaf };
|
|
3059
|
+
}
|
|
3060
|
+
stopPayment(chequeNumber, reason) {
|
|
3061
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3062
|
+
if (leaf.status === "cleared") throw new Error("Cannot stop a cleared cheque");
|
|
3063
|
+
if (leaf.status === "stopped") throw new Error("Cheque is already stopped");
|
|
3064
|
+
leaf.status = "stopped";
|
|
3065
|
+
leaf.stoppedAt = Date.now();
|
|
3066
|
+
leaf.stopReason = reason;
|
|
3067
|
+
this._change$.next({ ...leaf });
|
|
3068
|
+
return { ...leaf };
|
|
3069
|
+
}
|
|
3070
|
+
markPresented(chequeNumber, presentedAt) {
|
|
3071
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3072
|
+
if (leaf.status !== "issued") throw new Error(`Cheque must be in 'issued' status to present (current: ${leaf.status})`);
|
|
3073
|
+
leaf.status = "presented";
|
|
3074
|
+
leaf.presentedAt = presentedAt ?? Date.now();
|
|
3075
|
+
this._change$.next({ ...leaf });
|
|
3076
|
+
return { ...leaf };
|
|
3077
|
+
}
|
|
3078
|
+
markCleared(chequeNumber, clearedAt) {
|
|
3079
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3080
|
+
if (leaf.status !== "presented") throw new Error(`Cheque must be in 'presented' status to clear (current: ${leaf.status})`);
|
|
3081
|
+
leaf.status = "cleared";
|
|
3082
|
+
leaf.clearedAt = clearedAt ?? Date.now();
|
|
3083
|
+
this._change$.next({ ...leaf });
|
|
3084
|
+
return { ...leaf };
|
|
3085
|
+
}
|
|
3086
|
+
markBounced(chequeNumber, reason = "Insufficient funds") {
|
|
3087
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3088
|
+
if (leaf.status !== "presented" && leaf.status !== "issued") throw new Error(`Cannot bounce cheque in status '${leaf.status}'`);
|
|
3089
|
+
leaf.status = "bounced";
|
|
3090
|
+
leaf.bounceReason = reason;
|
|
3091
|
+
this._change$.next({ ...leaf });
|
|
3092
|
+
return { ...leaf };
|
|
3093
|
+
}
|
|
3094
|
+
cancelCheque(chequeNumber) {
|
|
3095
|
+
const leaf = this._requireLeaf(chequeNumber);
|
|
3096
|
+
if (leaf.status === "cleared") throw new Error("Cannot cancel a cleared cheque");
|
|
3097
|
+
leaf.status = "cancelled";
|
|
3098
|
+
this._change$.next({ ...leaf });
|
|
3099
|
+
return { ...leaf };
|
|
3100
|
+
}
|
|
3101
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
3102
|
+
getLeaf(chequeNumber) {
|
|
3103
|
+
const l = this._leafIndex.get(chequeNumber);
|
|
3104
|
+
return l ? { ...l } : void 0;
|
|
3105
|
+
}
|
|
3106
|
+
getByStatus(status, accountId) {
|
|
3107
|
+
let leaves = Array.from(this._leafIndex.values()).filter((l) => l.status === status);
|
|
3108
|
+
if (accountId) leaves = leaves.filter((l) => l.accountId === accountId);
|
|
3109
|
+
return leaves.map((l) => ({ ...l }));
|
|
3110
|
+
}
|
|
3111
|
+
getAvailableLeaves(accountId) {
|
|
3112
|
+
return this.getByStatus("available", accountId);
|
|
3113
|
+
}
|
|
3114
|
+
isValid(chequeNumber) {
|
|
3115
|
+
const leaf = this._leafIndex.get(chequeNumber);
|
|
3116
|
+
if (!leaf) return false;
|
|
3117
|
+
if (!["available", "issued"].includes(leaf.status)) return false;
|
|
3118
|
+
if (leaf.expiresAt && Date.now() > leaf.expiresAt) return false;
|
|
3119
|
+
return true;
|
|
3120
|
+
}
|
|
3121
|
+
getStats(accountId) {
|
|
3122
|
+
let leaves = Array.from(this._leafIndex.values());
|
|
3123
|
+
if (accountId) leaves = leaves.filter((l) => l.accountId === accountId);
|
|
3124
|
+
const count = (s) => leaves.filter((l) => l.status === s).length;
|
|
3125
|
+
return {
|
|
3126
|
+
total: leaves.length,
|
|
3127
|
+
available: count("available"),
|
|
3128
|
+
issued: count("issued"),
|
|
3129
|
+
presented: count("presented"),
|
|
3130
|
+
cleared: count("cleared"),
|
|
3131
|
+
bounced: count("bounced"),
|
|
3132
|
+
stopped: count("stopped"),
|
|
3133
|
+
cancelled: count("cancelled"),
|
|
3134
|
+
expired: count("expired")
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
change$() {
|
|
3138
|
+
return this._change$.asObservable();
|
|
3139
|
+
}
|
|
3140
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
3141
|
+
_requireLeaf(chequeNumber) {
|
|
3142
|
+
const l = this._leafIndex.get(chequeNumber);
|
|
3143
|
+
if (!l) throw new Error(`Cheque '${chequeNumber}' not found`);
|
|
3144
|
+
return l;
|
|
3145
|
+
}
|
|
3146
|
+
_generateLeaves(accountId, chequebookId, start, end) {
|
|
3147
|
+
const startNum = parseInt(start, 10);
|
|
3148
|
+
const endNum = parseInt(end, 10);
|
|
3149
|
+
if (isNaN(startNum) || isNaN(endNum) || endNum < startNum) throw new Error("Invalid cheque series range");
|
|
3150
|
+
const padLen = start.length;
|
|
3151
|
+
const leaves = [];
|
|
3152
|
+
for (let n = startNum; n <= endNum; n++) {
|
|
3153
|
+
leaves.push({ chequeNumber: String(n).padStart(padLen, "0"), chequebookId, accountId, status: "available" });
|
|
3154
|
+
}
|
|
3155
|
+
return leaves;
|
|
3156
|
+
}
|
|
3157
|
+
_snapshotBook(b) {
|
|
3158
|
+
return { ...b, leaves: b.leaves.map((l) => ({ ...l })) };
|
|
3159
|
+
}
|
|
3160
|
+
};
|
|
3161
|
+
function _uid9() {
|
|
3162
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// src/banking/insurance.service.ts
|
|
3166
|
+
var FREQ_MONTHS = {
|
|
3167
|
+
monthly: 1,
|
|
3168
|
+
quarterly: 3,
|
|
3169
|
+
"half-yearly": 6,
|
|
3170
|
+
yearly: 12
|
|
3171
|
+
};
|
|
3172
|
+
var JoopInsuranceService = class {
|
|
3173
|
+
_policies = /* @__PURE__ */ new Map();
|
|
3174
|
+
_claims = /* @__PURE__ */ new Map();
|
|
3175
|
+
_payments = /* @__PURE__ */ new Map();
|
|
3176
|
+
// policyId → payments
|
|
3177
|
+
_claimCounter = 0;
|
|
3178
|
+
_change$ = new JoopSubject();
|
|
3179
|
+
// ── Policies ──────────────────────────────────────────────────────────────
|
|
3180
|
+
addPolicy(data) {
|
|
3181
|
+
if (data.coverageAmount <= 0) throw new Error("coverageAmount must be positive");
|
|
3182
|
+
if (data.premiumAmount <= 0) throw new Error("premiumAmount must be positive");
|
|
3183
|
+
if (data.endDate <= data.startDate) throw new Error("endDate must be after startDate");
|
|
3184
|
+
const now = Date.now();
|
|
3185
|
+
const policy = { ...data, id: _uid10(), createdAt: now, updatedAt: now };
|
|
3186
|
+
this._policies.set(policy.id, policy);
|
|
3187
|
+
this._payments.set(policy.id, []);
|
|
3188
|
+
this._change$.next({ ...policy });
|
|
3189
|
+
return { ...policy };
|
|
3190
|
+
}
|
|
3191
|
+
updatePolicy(id, patch) {
|
|
3192
|
+
const p = this._require(id);
|
|
3193
|
+
Object.assign(p, patch, { updatedAt: Date.now() });
|
|
3194
|
+
this._change$.next({ ...p });
|
|
3195
|
+
return { ...p };
|
|
3196
|
+
}
|
|
3197
|
+
cancelPolicy(id, reason) {
|
|
3198
|
+
const p = this._require(id);
|
|
3199
|
+
if (p.status === "cancelled") throw new Error("Policy is already cancelled");
|
|
3200
|
+
p.status = "cancelled";
|
|
3201
|
+
if (reason) p.notes = reason;
|
|
3202
|
+
p.updatedAt = Date.now();
|
|
3203
|
+
this._change$.next({ ...p });
|
|
3204
|
+
return { ...p };
|
|
3205
|
+
}
|
|
3206
|
+
renewPolicy(id, newEndDate) {
|
|
3207
|
+
const p = this._require(id);
|
|
3208
|
+
if (newEndDate <= p.endDate) throw new Error("newEndDate must be after current endDate");
|
|
3209
|
+
p.endDate = newEndDate;
|
|
3210
|
+
p.status = "active";
|
|
3211
|
+
p.updatedAt = Date.now();
|
|
3212
|
+
this._change$.next({ ...p });
|
|
3213
|
+
return { ...p };
|
|
3214
|
+
}
|
|
3215
|
+
getPolicy(id) {
|
|
3216
|
+
const p = this._policies.get(id);
|
|
3217
|
+
return p ? { ...p } : void 0;
|
|
3218
|
+
}
|
|
3219
|
+
getAll() {
|
|
3220
|
+
return Array.from(this._policies.values()).map((p) => ({ ...p }));
|
|
3221
|
+
}
|
|
3222
|
+
getActive() {
|
|
3223
|
+
return this.getAll().filter((p) => p.status === "active");
|
|
3224
|
+
}
|
|
3225
|
+
getByType(type) {
|
|
3226
|
+
return this.getAll().filter((p) => p.type === type);
|
|
3227
|
+
}
|
|
3228
|
+
getExpiring(withinDays, asOf = Date.now()) {
|
|
3229
|
+
const cutoff = asOf + withinDays * 864e5;
|
|
3230
|
+
return this.getActive().filter((p) => p.endDate <= cutoff && p.endDate > asOf);
|
|
3231
|
+
}
|
|
3232
|
+
// ── Claims ────────────────────────────────────────────────────────────────
|
|
3233
|
+
fileClaim(policyId, amount, description, documents = []) {
|
|
3234
|
+
const p = this._require(policyId);
|
|
3235
|
+
if (p.status !== "active") throw new Error(`Cannot file claim on a ${p.status} policy`);
|
|
3236
|
+
if (amount <= 0) throw new Error("Claim amount must be positive");
|
|
3237
|
+
if (amount > p.coverageAmount) throw new Error(`Claim amount exceeds coverage (${p.coverageAmount})`);
|
|
3238
|
+
this._claimCounter++;
|
|
3239
|
+
const claim = {
|
|
3240
|
+
id: _uid10(),
|
|
3241
|
+
claimNumber: `CLM-${(/* @__PURE__ */ new Date()).getFullYear()}-${String(this._claimCounter).padStart(6, "0")}`,
|
|
3242
|
+
policyId,
|
|
3243
|
+
amount,
|
|
3244
|
+
description,
|
|
3245
|
+
documents,
|
|
3246
|
+
status: "submitted",
|
|
3247
|
+
submittedAt: Date.now()
|
|
3248
|
+
};
|
|
3249
|
+
this._claims.set(claim.id, claim);
|
|
3250
|
+
return { ...claim };
|
|
3251
|
+
}
|
|
3252
|
+
updateClaim(id, status, options = {}) {
|
|
3253
|
+
const c = this._requireClaim(id);
|
|
3254
|
+
c.status = status;
|
|
3255
|
+
if (status === "paid" || status === "rejected" || status === "withdrawn") c.resolvedAt = Date.now();
|
|
3256
|
+
if (options.paidAmount !== void 0) c.paidAmount = options.paidAmount;
|
|
3257
|
+
if (options.rejectionReason) c.rejectionReason = options.rejectionReason;
|
|
3258
|
+
return { ...c };
|
|
3259
|
+
}
|
|
3260
|
+
getClaims(policyId) {
|
|
3261
|
+
const all = Array.from(this._claims.values()).map((c) => ({ ...c }));
|
|
3262
|
+
return policyId ? all.filter((c) => c.policyId === policyId) : all;
|
|
3263
|
+
}
|
|
3264
|
+
// ── Premium payments ──────────────────────────────────────────────────────
|
|
3265
|
+
recordPremiumPayment(policyId, amount, method) {
|
|
3266
|
+
this._require(policyId);
|
|
3267
|
+
const payment = { id: _uid10(), policyId, amount, date: Date.now(), method };
|
|
3268
|
+
this._payments.get(policyId).push(payment);
|
|
3269
|
+
const p = this._policies.get(policyId);
|
|
3270
|
+
const base = p.nextPremiumDate ?? Date.now();
|
|
3271
|
+
const d = new Date(base);
|
|
3272
|
+
d.setMonth(d.getMonth() + FREQ_MONTHS[p.premiumFrequency]);
|
|
3273
|
+
p.nextPremiumDate = d.getTime();
|
|
3274
|
+
p.updatedAt = Date.now();
|
|
3275
|
+
return { ...payment };
|
|
3276
|
+
}
|
|
3277
|
+
getPremiumHistory(policyId) {
|
|
3278
|
+
return [...this._payments.get(policyId) ?? []];
|
|
3279
|
+
}
|
|
3280
|
+
// ── Aggregates ────────────────────────────────────────────────────────────
|
|
3281
|
+
getTotalCoverage() {
|
|
3282
|
+
return this.getActive().reduce((s, p) => s + p.coverageAmount, 0);
|
|
3283
|
+
}
|
|
3284
|
+
getTotalAnnualPremium() {
|
|
3285
|
+
const YEARLY_FACTOR = {
|
|
3286
|
+
monthly: 12,
|
|
3287
|
+
quarterly: 4,
|
|
3288
|
+
"half-yearly": 2,
|
|
3289
|
+
yearly: 1
|
|
3290
|
+
};
|
|
3291
|
+
return this.getActive().reduce((s, p) => s + p.premiumAmount * YEARLY_FACTOR[p.premiumFrequency], 0);
|
|
3292
|
+
}
|
|
3293
|
+
change$() {
|
|
3294
|
+
return this._change$.asObservable();
|
|
3295
|
+
}
|
|
3296
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
3297
|
+
_require(id) {
|
|
3298
|
+
const p = this._policies.get(id);
|
|
3299
|
+
if (!p) throw new Error(`Policy '${id}' not found`);
|
|
3300
|
+
return p;
|
|
3301
|
+
}
|
|
3302
|
+
_requireClaim(id) {
|
|
3303
|
+
const c = this._claims.get(id);
|
|
3304
|
+
if (!c) throw new Error(`Claim '${id}' not found`);
|
|
3305
|
+
return c;
|
|
3306
|
+
}
|
|
3307
|
+
};
|
|
3308
|
+
function _uid10() {
|
|
3309
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// src/banking/remittance.service.ts
|
|
3313
|
+
var DEFAULT_FEES = {
|
|
3314
|
+
swift: 25,
|
|
3315
|
+
sepa: 0.5,
|
|
3316
|
+
ach: 0.25,
|
|
3317
|
+
wire: 20,
|
|
3318
|
+
crypto: 1,
|
|
3319
|
+
"mobile-money": 2,
|
|
3320
|
+
"cash-pickup": 5
|
|
3321
|
+
};
|
|
3322
|
+
var ARRIVAL_MS = {
|
|
3323
|
+
swift: 3 * 864e5,
|
|
3324
|
+
sepa: 864e5,
|
|
3325
|
+
ach: 2 * 864e5,
|
|
3326
|
+
wire: 864e5,
|
|
3327
|
+
crypto: 30 * 6e4,
|
|
3328
|
+
"mobile-money": 10 * 6e4,
|
|
3329
|
+
"cash-pickup": 2 * 6e4
|
|
3330
|
+
};
|
|
3331
|
+
var JoopRemittanceService = class {
|
|
3332
|
+
_remittances = /* @__PURE__ */ new Map();
|
|
3333
|
+
_rates = /* @__PURE__ */ new Map();
|
|
3334
|
+
// "USD:EUR" → rate
|
|
3335
|
+
_fees = /* @__PURE__ */ new Map();
|
|
3336
|
+
// channel → flat fee override
|
|
3337
|
+
_counter = 0;
|
|
3338
|
+
_change$ = new JoopSubject();
|
|
3339
|
+
// ── FX rates & fees ───────────────────────────────────────────────────────
|
|
3340
|
+
setExchangeRate(from, to, rate) {
|
|
3341
|
+
this._rates.set(_rateKey(from, to), rate);
|
|
3342
|
+
this._rates.set(_rateKey(to, from), parseFloat((1 / rate).toFixed(6)));
|
|
3343
|
+
}
|
|
3344
|
+
getExchangeRate(from, to) {
|
|
3345
|
+
return this._rates.get(_rateKey(from, to)) ?? null;
|
|
3346
|
+
}
|
|
3347
|
+
setChannelFee(channel, fee) {
|
|
3348
|
+
this._fees.set(channel, fee);
|
|
3349
|
+
}
|
|
3350
|
+
getFee(channel) {
|
|
3351
|
+
return this._fees.get(channel) ?? DEFAULT_FEES[channel];
|
|
3352
|
+
}
|
|
3353
|
+
// ── Quote ─────────────────────────────────────────────────────────────────
|
|
3354
|
+
getQuote(sendAmount, sendCurrency, receiveCurrency, channel = "swift") {
|
|
3355
|
+
if (sendAmount <= 0) throw new Error("sendAmount must be positive");
|
|
3356
|
+
const rate = sendCurrency === receiveCurrency ? 1 : this._rates.get(_rateKey(sendCurrency, receiveCurrency)) ?? null;
|
|
3357
|
+
if (rate === null) throw new Error(`No exchange rate set for ${sendCurrency} \u2192 ${receiveCurrency}`);
|
|
3358
|
+
const fee = this.getFee(channel);
|
|
3359
|
+
const receiveAmount = parseFloat(((sendAmount - fee) * rate).toFixed(2));
|
|
3360
|
+
if (receiveAmount <= 0) throw new Error("Send amount is too small to cover the fee");
|
|
3361
|
+
return {
|
|
3362
|
+
sendAmount,
|
|
3363
|
+
sendCurrency,
|
|
3364
|
+
receiveAmount,
|
|
3365
|
+
receiveCurrency,
|
|
3366
|
+
exchangeRate: rate,
|
|
3367
|
+
fee,
|
|
3368
|
+
totalDebit: parseFloat((sendAmount + fee).toFixed(2)),
|
|
3369
|
+
estimatedArrivalMs: ARRIVAL_MS[channel],
|
|
3370
|
+
channel
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
// ── Initiate ──────────────────────────────────────────────────────────────
|
|
3374
|
+
initiate(data) {
|
|
3375
|
+
const quote = this.getQuote(data.sendAmount, data.sendCurrency, data.receiveCurrency, data.channel);
|
|
3376
|
+
const now = Date.now();
|
|
3377
|
+
const remittance = {
|
|
3378
|
+
id: _uid11(),
|
|
3379
|
+
ref: this._generateRef(),
|
|
3380
|
+
status: "initiated",
|
|
3381
|
+
...quote,
|
|
3382
|
+
senderName: data.senderName,
|
|
3383
|
+
senderAccount: data.senderAccount,
|
|
3384
|
+
recipientName: data.recipientName,
|
|
3385
|
+
recipientAccount: data.recipientAccount,
|
|
3386
|
+
recipientBankCode: data.recipientBankCode,
|
|
3387
|
+
recipientCountry: data.recipientCountry,
|
|
3388
|
+
purposeCode: data.purposeCode,
|
|
3389
|
+
notes: data.notes,
|
|
3390
|
+
initiatedAt: now,
|
|
3391
|
+
estimatedArrival: now + quote.estimatedArrivalMs
|
|
3392
|
+
};
|
|
3393
|
+
this._remittances.set(remittance.id, remittance);
|
|
3394
|
+
this._change$.next({ ...remittance });
|
|
3395
|
+
return { ...remittance };
|
|
3396
|
+
}
|
|
3397
|
+
// ── Status management ─────────────────────────────────────────────────────
|
|
3398
|
+
cancel(id) {
|
|
3399
|
+
return this._transition(id, "cancelled", (r) => {
|
|
3400
|
+
if (!["initiated", "processing"].includes(r.status)) throw new Error(`Cannot cancel remittance in '${r.status}' status`);
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
updateStatus(id, status, options = {}) {
|
|
3404
|
+
return this._transition(id, status, (r) => {
|
|
3405
|
+
if (options.notes) r.notes = options.notes;
|
|
3406
|
+
if (status === "completed") r.completedAt = Date.now();
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
3410
|
+
getById(id) {
|
|
3411
|
+
const r = this._remittances.get(id);
|
|
3412
|
+
return r ? { ...r } : void 0;
|
|
3413
|
+
}
|
|
3414
|
+
getByRef(ref) {
|
|
3415
|
+
return Array.from(this._remittances.values()).find((r) => r.ref === ref);
|
|
3416
|
+
}
|
|
3417
|
+
getByStatus(status) {
|
|
3418
|
+
return Array.from(this._remittances.values()).filter((r) => r.status === status).map((r) => ({ ...r }));
|
|
3419
|
+
}
|
|
3420
|
+
getHistory(limit) {
|
|
3421
|
+
const all = Array.from(this._remittances.values()).sort((a, b) => b.initiatedAt - a.initiatedAt);
|
|
3422
|
+
return (limit ? all.slice(0, limit) : all).map((r) => ({ ...r }));
|
|
3423
|
+
}
|
|
3424
|
+
getTotalSent(currency) {
|
|
3425
|
+
return Array.from(this._remittances.values()).filter((r) => r.status === "completed" && (!currency || r.sendCurrency === currency)).reduce((s, r) => s + r.sendAmount, 0);
|
|
3426
|
+
}
|
|
3427
|
+
change$() {
|
|
3428
|
+
return this._change$.asObservable();
|
|
3429
|
+
}
|
|
3430
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
3431
|
+
_transition(id, status, mutate) {
|
|
3432
|
+
const r = this._remittances.get(id);
|
|
3433
|
+
if (!r) throw new Error(`Remittance '${id}' not found`);
|
|
3434
|
+
if (mutate) mutate(r);
|
|
3435
|
+
r.status = status;
|
|
3436
|
+
this._change$.next({ ...r });
|
|
3437
|
+
return { ...r };
|
|
3438
|
+
}
|
|
3439
|
+
_generateRef() {
|
|
3440
|
+
this._counter++;
|
|
3441
|
+
return `RMT-${(/* @__PURE__ */ new Date()).getFullYear()}-${String(this._counter).padStart(6, "0")}`;
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
function _rateKey(from, to) {
|
|
3445
|
+
return `${from.toUpperCase()}:${to.toUpperCase()}`;
|
|
3446
|
+
}
|
|
3447
|
+
function _uid11() {
|
|
3448
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// src/banking/virtual-account.service.ts
|
|
3452
|
+
var JoopVirtualAccountService = class {
|
|
3453
|
+
_accounts = /* @__PURE__ */ new Map();
|
|
3454
|
+
_credits = /* @__PURE__ */ new Map();
|
|
3455
|
+
// vaId → credits
|
|
3456
|
+
_counter = 0;
|
|
3457
|
+
_change$ = new JoopSubject();
|
|
3458
|
+
// ── Create / manage ───────────────────────────────────────────────────────
|
|
3459
|
+
create(data) {
|
|
3460
|
+
const now = Date.now();
|
|
3461
|
+
const accountNumber = this.generateAccountNumber();
|
|
3462
|
+
const va = {
|
|
3463
|
+
id: _uid12(),
|
|
3464
|
+
accountNumber,
|
|
3465
|
+
iban: data.ibanPrefix ? `${data.ibanPrefix}${accountNumber}` : void 0,
|
|
3466
|
+
parentAccountId: data.parentAccountId,
|
|
3467
|
+
label: data.label,
|
|
3468
|
+
purpose: data.purpose,
|
|
3469
|
+
status: "active",
|
|
3470
|
+
currency: data.currency ?? "USD",
|
|
3471
|
+
collectedAmount: 0,
|
|
3472
|
+
expectedAmount: data.expectedAmount,
|
|
3473
|
+
reference: data.reference,
|
|
3474
|
+
expiresAt: data.expiresAt,
|
|
3475
|
+
metadata: data.metadata,
|
|
3476
|
+
createdAt: now,
|
|
3477
|
+
updatedAt: now
|
|
3478
|
+
};
|
|
3479
|
+
this._accounts.set(va.id, va);
|
|
3480
|
+
this._credits.set(va.id, []);
|
|
3481
|
+
this._change$.next({ ...va });
|
|
3482
|
+
return { ...va };
|
|
3483
|
+
}
|
|
3484
|
+
close(id) {
|
|
3485
|
+
return this._setStatus(id, "closed");
|
|
3486
|
+
}
|
|
3487
|
+
deactivate(id) {
|
|
3488
|
+
return this._setStatus(id, "inactive");
|
|
3489
|
+
}
|
|
3490
|
+
activate(id) {
|
|
3491
|
+
const va = this._require(id);
|
|
3492
|
+
if (va.status === "closed") throw new Error("Cannot activate a closed virtual account");
|
|
3493
|
+
return this._setStatus(id, "active");
|
|
3494
|
+
}
|
|
3495
|
+
update(id, patch) {
|
|
3496
|
+
const va = this._require(id);
|
|
3497
|
+
Object.assign(va, patch, { updatedAt: Date.now() });
|
|
3498
|
+
this._change$.next({ ...va });
|
|
3499
|
+
return { ...va };
|
|
3500
|
+
}
|
|
3501
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
3502
|
+
get(id) {
|
|
3503
|
+
const va = this._accounts.get(id);
|
|
3504
|
+
return va ? { ...va } : void 0;
|
|
3505
|
+
}
|
|
3506
|
+
getAll() {
|
|
3507
|
+
return Array.from(this._accounts.values()).map((va) => ({ ...va }));
|
|
3508
|
+
}
|
|
3509
|
+
getActive(parentAccountId) {
|
|
3510
|
+
let list = this.getAll().filter((va) => va.status === "active");
|
|
3511
|
+
if (parentAccountId) list = list.filter((va) => va.parentAccountId === parentAccountId);
|
|
3512
|
+
return list;
|
|
3513
|
+
}
|
|
3514
|
+
getByReference(reference) {
|
|
3515
|
+
return Array.from(this._accounts.values()).find((va) => va.reference === reference);
|
|
3516
|
+
}
|
|
3517
|
+
getByPurpose(purpose) {
|
|
3518
|
+
return this.getAll().filter((va) => va.purpose === purpose);
|
|
3519
|
+
}
|
|
3520
|
+
getExpiring(withinMs, asOf = Date.now()) {
|
|
3521
|
+
const cutoff = asOf + withinMs;
|
|
3522
|
+
return this.getActive().filter((va) => va.expiresAt !== void 0 && va.expiresAt <= cutoff && va.expiresAt > asOf);
|
|
3523
|
+
}
|
|
3524
|
+
// ── Credits ───────────────────────────────────────────────────────────────
|
|
3525
|
+
recordCredit(virtualAccountId, amount, options = {}) {
|
|
3526
|
+
const va = this._require(virtualAccountId);
|
|
3527
|
+
if (va.status !== "active") throw new Error(`Virtual account is ${va.status}`);
|
|
3528
|
+
if (amount <= 0) throw new Error("Credit amount must be positive");
|
|
3529
|
+
const credit = {
|
|
3530
|
+
id: _uid12(),
|
|
3531
|
+
virtualAccountId,
|
|
3532
|
+
amount,
|
|
3533
|
+
currency: options.currency ?? va.currency,
|
|
3534
|
+
senderName: options.senderName,
|
|
3535
|
+
senderRef: options.senderRef,
|
|
3536
|
+
receivedAt: Date.now(),
|
|
3537
|
+
notes: options.notes
|
|
3538
|
+
};
|
|
3539
|
+
this._credits.get(virtualAccountId).push(credit);
|
|
3540
|
+
va.collectedAmount = parseFloat((va.collectedAmount + amount).toFixed(2));
|
|
3541
|
+
va.updatedAt = Date.now();
|
|
3542
|
+
this._change$.next({ ...va });
|
|
3543
|
+
return { ...credit };
|
|
3544
|
+
}
|
|
3545
|
+
getCredits(virtualAccountId) {
|
|
3546
|
+
return [...this._credits.get(virtualAccountId) ?? []];
|
|
3547
|
+
}
|
|
3548
|
+
getTotalCollected(virtualAccountId) {
|
|
3549
|
+
const va = this._require(virtualAccountId);
|
|
3550
|
+
return va.collectedAmount;
|
|
3551
|
+
}
|
|
3552
|
+
isFullyCollected(virtualAccountId) {
|
|
3553
|
+
const va = this._require(virtualAccountId);
|
|
3554
|
+
return va.expectedAmount !== void 0 && va.collectedAmount >= va.expectedAmount;
|
|
3555
|
+
}
|
|
3556
|
+
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
3557
|
+
generateAccountNumber(prefix = "VA") {
|
|
3558
|
+
this._counter++;
|
|
3559
|
+
return `${prefix}${String(Date.now()).slice(-8)}${String(this._counter).padStart(4, "0")}`;
|
|
3560
|
+
}
|
|
3561
|
+
change$() {
|
|
3562
|
+
return this._change$.asObservable();
|
|
3563
|
+
}
|
|
3564
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
3565
|
+
_require(id) {
|
|
3566
|
+
const va = this._accounts.get(id);
|
|
3567
|
+
if (!va) throw new Error(`Virtual account '${id}' not found`);
|
|
3568
|
+
return va;
|
|
3569
|
+
}
|
|
3570
|
+
_setStatus(id, status) {
|
|
3571
|
+
const va = this._require(id);
|
|
3572
|
+
va.status = status;
|
|
3573
|
+
va.updatedAt = Date.now();
|
|
3574
|
+
this._change$.next({ ...va });
|
|
3575
|
+
return { ...va };
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
function _uid12() {
|
|
3579
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// src/banking/ledger.service.ts
|
|
3583
|
+
function _uid13() {
|
|
3584
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3585
|
+
}
|
|
3586
|
+
function _round2(n, dp = 2) {
|
|
3587
|
+
return Math.round(n * 10 ** dp) / 10 ** dp;
|
|
3588
|
+
}
|
|
3589
|
+
var DEBIT_NORMAL = /* @__PURE__ */ new Set(["asset", "expense"]);
|
|
3590
|
+
var JoopLedgerService = class {
|
|
3591
|
+
_accounts = /* @__PURE__ */ new Map();
|
|
3592
|
+
_entries = [];
|
|
3593
|
+
_seq = 0;
|
|
3594
|
+
_change$ = new JoopSubject();
|
|
3595
|
+
// ── Accounts ─────────────────────────────────────────────────────────────
|
|
3596
|
+
addAccount(code, name, type, description) {
|
|
3597
|
+
if (!code.trim()) throw new Error("Account code required");
|
|
3598
|
+
if (this._accounts.has(code)) throw new Error(`Account ${code} already exists`);
|
|
3599
|
+
const account = { code, name, type, description, createdAt: Date.now() };
|
|
3600
|
+
this._accounts.set(code, account);
|
|
3601
|
+
return account;
|
|
3602
|
+
}
|
|
3603
|
+
getAccount(code) {
|
|
3604
|
+
return this._accounts.get(code);
|
|
3605
|
+
}
|
|
3606
|
+
getAccounts() {
|
|
3607
|
+
return Array.from(this._accounts.values());
|
|
3608
|
+
}
|
|
3609
|
+
removeAccount(code) {
|
|
3610
|
+
if (!this._accounts.has(code)) throw new Error(`Account ${code} not found`);
|
|
3611
|
+
const hasEntries = this._entries.some((e) => e.lines.some((l) => l.accountCode === code));
|
|
3612
|
+
if (hasEntries) throw new Error(`Cannot remove account ${code} \u2014 it has journal entries`);
|
|
3613
|
+
this._accounts.delete(code);
|
|
3614
|
+
}
|
|
3615
|
+
// ── Journal ───────────────────────────────────────────────────────────────
|
|
3616
|
+
postEntry(description, lines, opts = {}) {
|
|
3617
|
+
if (lines.length < 2) throw new Error("A journal entry requires at least 2 lines");
|
|
3618
|
+
for (const l of lines) {
|
|
3619
|
+
if (!this._accounts.has(l.accountCode)) throw new Error(`Unknown account: ${l.accountCode}`);
|
|
3620
|
+
if (l.debit < 0 || l.credit < 0) throw new Error("Debit/credit amounts must be non-negative");
|
|
3621
|
+
}
|
|
3622
|
+
const totalDebits = _round2(lines.reduce((s, l) => s + l.debit, 0));
|
|
3623
|
+
const totalCredits = _round2(lines.reduce((s, l) => s + l.credit, 0));
|
|
3624
|
+
if (totalDebits !== totalCredits) {
|
|
3625
|
+
throw new Error(`Journal entry does not balance \u2014 debits ${totalDebits} \u2260 credits ${totalCredits}`);
|
|
3626
|
+
}
|
|
3627
|
+
this._seq += 1;
|
|
3628
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
3629
|
+
const ref = `JE-${year}-${String(this._seq).padStart(6, "0")}`;
|
|
3630
|
+
const entry = {
|
|
3631
|
+
id: _uid13(),
|
|
3632
|
+
ref,
|
|
3633
|
+
description,
|
|
3634
|
+
date: opts.date ?? Date.now(),
|
|
3635
|
+
lines: lines.map((l) => ({ ...l, debit: _round2(l.debit), credit: _round2(l.credit) })),
|
|
3636
|
+
postedAt: Date.now(),
|
|
3637
|
+
postedBy: opts.postedBy,
|
|
3638
|
+
tags: opts.tags
|
|
3639
|
+
};
|
|
3640
|
+
this._entries.push(entry);
|
|
3641
|
+
this._change$.next(entry);
|
|
3642
|
+
return entry;
|
|
3643
|
+
}
|
|
3644
|
+
getEntry(id) {
|
|
3645
|
+
return this._entries.find((e) => e.id === id);
|
|
3646
|
+
}
|
|
3647
|
+
getJournal(from, to) {
|
|
3648
|
+
return this._entries.filter((e) => (from === void 0 || e.date >= from) && (to === void 0 || e.date <= to)).sort((a, b) => a.date - b.date);
|
|
3649
|
+
}
|
|
3650
|
+
// ── Balances ──────────────────────────────────────────────────────────────
|
|
3651
|
+
getBalance(code) {
|
|
3652
|
+
const acct = this._accounts.get(code);
|
|
3653
|
+
if (!acct) throw new Error(`Account ${code} not found`);
|
|
3654
|
+
let debits = 0;
|
|
3655
|
+
let credits = 0;
|
|
3656
|
+
for (const entry of this._entries) {
|
|
3657
|
+
for (const line of entry.lines) {
|
|
3658
|
+
if (line.accountCode === code) {
|
|
3659
|
+
debits += line.debit;
|
|
3660
|
+
credits += line.credit;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
return _round2(DEBIT_NORMAL.has(acct.type) ? debits - credits : credits - debits);
|
|
3665
|
+
}
|
|
3666
|
+
getTrialBalance() {
|
|
3667
|
+
const rows = [];
|
|
3668
|
+
let totalDebits = 0;
|
|
3669
|
+
let totalCredits = 0;
|
|
3670
|
+
for (const acct of this._accounts.values()) {
|
|
3671
|
+
let d = 0, c = 0;
|
|
3672
|
+
for (const entry of this._entries) {
|
|
3673
|
+
for (const line of entry.lines) {
|
|
3674
|
+
if (line.accountCode === acct.code) {
|
|
3675
|
+
d += line.debit;
|
|
3676
|
+
c += line.credit;
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
const balance = _round2(DEBIT_NORMAL.has(acct.type) ? d - c : c - d);
|
|
3681
|
+
rows.push({ code: acct.code, name: acct.name, type: acct.type, totalDebits: _round2(d), totalCredits: _round2(c), balance });
|
|
3682
|
+
totalDebits += d;
|
|
3683
|
+
totalCredits += c;
|
|
3684
|
+
}
|
|
3685
|
+
totalDebits = _round2(totalDebits);
|
|
3686
|
+
totalCredits = _round2(totalCredits);
|
|
3687
|
+
return { asOf: Date.now(), rows, totalDebits, totalCredits, isBalanced: totalDebits === totalCredits };
|
|
3688
|
+
}
|
|
3689
|
+
getBalancesByType(type) {
|
|
3690
|
+
const result = {};
|
|
3691
|
+
for (const acct of this._accounts.values()) {
|
|
3692
|
+
if (acct.type === type) result[acct.code] = this.getBalance(acct.code);
|
|
3693
|
+
}
|
|
3694
|
+
return result;
|
|
3695
|
+
}
|
|
3696
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
3697
|
+
change$() {
|
|
3698
|
+
return this._change$;
|
|
3699
|
+
}
|
|
3700
|
+
};
|
|
3701
|
+
|
|
3702
|
+
// src/banking/reconciliation.service.ts
|
|
3703
|
+
function _uid14() {
|
|
3704
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3705
|
+
}
|
|
3706
|
+
var JoopReconciliationService = class {
|
|
3707
|
+
_sessions = /* @__PURE__ */ new Map();
|
|
3708
|
+
_change$ = new JoopSubject();
|
|
3709
|
+
// ── Sessions ──────────────────────────────────────────────────────────────
|
|
3710
|
+
createSession(accountId, bankItems, internalItems = []) {
|
|
3711
|
+
const session = {
|
|
3712
|
+
id: _uid14(),
|
|
3713
|
+
accountId,
|
|
3714
|
+
startedAt: Date.now(),
|
|
3715
|
+
internalItems: internalItems.map((i) => ({ ...i, id: _uid14(), source: "internal" })),
|
|
3716
|
+
bankItems: bankItems.map((i) => ({ ...i, id: _uid14(), source: "bank" })),
|
|
3717
|
+
matches: []
|
|
3718
|
+
};
|
|
3719
|
+
this._sessions.set(session.id, session);
|
|
3720
|
+
return session;
|
|
3721
|
+
}
|
|
3722
|
+
getSession(id) {
|
|
3723
|
+
return this._sessions.get(id);
|
|
3724
|
+
}
|
|
3725
|
+
closeSession(id) {
|
|
3726
|
+
const s = this._get(id);
|
|
3727
|
+
s.closedAt = Date.now();
|
|
3728
|
+
this._change$.next(s);
|
|
3729
|
+
return s;
|
|
3730
|
+
}
|
|
3731
|
+
addInternalItems(sessionId, items) {
|
|
3732
|
+
const s = this._get(sessionId);
|
|
3733
|
+
for (const item of items) s.internalItems.push({ ...item, id: _uid14(), source: "internal" });
|
|
3734
|
+
this._change$.next(s);
|
|
3735
|
+
return s;
|
|
3736
|
+
}
|
|
3737
|
+
// ── Matching ──────────────────────────────────────────────────────────────
|
|
3738
|
+
autoMatch(sessionId, toleranceAmount = 0, toleranceDays = 3) {
|
|
3739
|
+
const s = this._get(sessionId);
|
|
3740
|
+
const matchedInternalIds = new Set(s.matches.map((m) => m.internalId));
|
|
3741
|
+
const matchedBankIds = new Set(s.matches.map((m) => m.bankId));
|
|
3742
|
+
const newMatches = [];
|
|
3743
|
+
const dayMs = 864e5;
|
|
3744
|
+
for (const intItem of s.internalItems) {
|
|
3745
|
+
if (matchedInternalIds.has(intItem.id)) continue;
|
|
3746
|
+
for (const bankItem of s.bankItems) {
|
|
3747
|
+
if (matchedBankIds.has(bankItem.id)) continue;
|
|
3748
|
+
const amountDiff = Math.abs(intItem.amount - bankItem.amount);
|
|
3749
|
+
const daysDiff = Math.abs(intItem.date - bankItem.date) / dayMs;
|
|
3750
|
+
if (amountDiff <= toleranceAmount && daysDiff <= toleranceDays) {
|
|
3751
|
+
const match = {
|
|
3752
|
+
id: _uid14(),
|
|
3753
|
+
internalId: intItem.id,
|
|
3754
|
+
bankId: bankItem.id,
|
|
3755
|
+
matchedAt: Date.now(),
|
|
3756
|
+
isManual: false,
|
|
3757
|
+
difference: intItem.amount - bankItem.amount
|
|
3758
|
+
};
|
|
3759
|
+
s.matches.push(match);
|
|
3760
|
+
matchedInternalIds.add(intItem.id);
|
|
3761
|
+
matchedBankIds.add(bankItem.id);
|
|
3762
|
+
newMatches.push(match);
|
|
3763
|
+
break;
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
if (newMatches.length) this._change$.next(s);
|
|
3768
|
+
return newMatches;
|
|
3769
|
+
}
|
|
3770
|
+
manualMatch(sessionId, internalId, bankId) {
|
|
3771
|
+
const s = this._get(sessionId);
|
|
3772
|
+
const intItem = s.internalItems.find((i) => i.id === internalId);
|
|
3773
|
+
const bankItem = s.bankItems.find((i) => i.id === bankId);
|
|
3774
|
+
if (!intItem) throw new Error(`Internal item ${internalId} not found`);
|
|
3775
|
+
if (!bankItem) throw new Error(`Bank item ${bankId} not found`);
|
|
3776
|
+
if (s.matches.some((m) => m.internalId === internalId || m.bankId === bankId)) {
|
|
3777
|
+
throw new Error("One or both items are already matched");
|
|
3778
|
+
}
|
|
3779
|
+
const match = {
|
|
3780
|
+
id: _uid14(),
|
|
3781
|
+
internalId,
|
|
3782
|
+
bankId,
|
|
3783
|
+
matchedAt: Date.now(),
|
|
3784
|
+
isManual: true,
|
|
3785
|
+
difference: intItem.amount - bankItem.amount
|
|
3786
|
+
};
|
|
3787
|
+
s.matches.push(match);
|
|
3788
|
+
this._change$.next(s);
|
|
3789
|
+
return match;
|
|
3790
|
+
}
|
|
3791
|
+
unmatch(sessionId, matchId) {
|
|
3792
|
+
const s = this._get(sessionId);
|
|
3793
|
+
const idx = s.matches.findIndex((m) => m.id === matchId);
|
|
3794
|
+
if (idx === -1) throw new Error(`Match ${matchId} not found`);
|
|
3795
|
+
s.matches.splice(idx, 1);
|
|
3796
|
+
this._change$.next(s);
|
|
3797
|
+
}
|
|
3798
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
3799
|
+
getUnmatched(sessionId) {
|
|
3800
|
+
const s = this._get(sessionId);
|
|
3801
|
+
const matchedInternalIds = new Set(s.matches.map((m) => m.internalId));
|
|
3802
|
+
const matchedBankIds = new Set(s.matches.map((m) => m.bankId));
|
|
3803
|
+
return {
|
|
3804
|
+
internal: s.internalItems.filter((i) => !matchedInternalIds.has(i.id)),
|
|
3805
|
+
bank: s.bankItems.filter((i) => !matchedBankIds.has(i.id))
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3808
|
+
getSummary(sessionId) {
|
|
3809
|
+
const s = this._get(sessionId);
|
|
3810
|
+
const unmatched = this.getUnmatched(sessionId);
|
|
3811
|
+
const totalDifference = s.matches.reduce((sum, m) => sum + m.difference, 0);
|
|
3812
|
+
return {
|
|
3813
|
+
sessionId,
|
|
3814
|
+
totalInternal: s.internalItems.length,
|
|
3815
|
+
totalBank: s.bankItems.length,
|
|
3816
|
+
matchedPairs: s.matches.length,
|
|
3817
|
+
unmatchedInternal: unmatched.internal.length,
|
|
3818
|
+
unmatchedBank: unmatched.bank.length,
|
|
3819
|
+
totalDifference: Math.round(totalDifference * 100) / 100,
|
|
3820
|
+
isReconciled: unmatched.internal.length === 0 && unmatched.bank.length === 0
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
3824
|
+
change$() {
|
|
3825
|
+
return this._change$;
|
|
3826
|
+
}
|
|
3827
|
+
_get(id) {
|
|
3828
|
+
const s = this._sessions.get(id);
|
|
3829
|
+
if (!s) throw new Error(`Reconciliation session ${id} not found`);
|
|
3830
|
+
return s;
|
|
3831
|
+
}
|
|
3832
|
+
};
|
|
3833
|
+
|
|
3834
|
+
// src/banking/limit-management.service.ts
|
|
3835
|
+
function _uid15() {
|
|
3836
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3837
|
+
}
|
|
3838
|
+
function _periodStart(type, now) {
|
|
3839
|
+
const d = new Date(now);
|
|
3840
|
+
switch (type) {
|
|
3841
|
+
case "per-transaction":
|
|
3842
|
+
return 0;
|
|
3843
|
+
// always fresh
|
|
3844
|
+
case "daily":
|
|
3845
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
3846
|
+
case "weekly": {
|
|
3847
|
+
const day = d.getDay();
|
|
3848
|
+
const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - (day + 6) % 7);
|
|
3849
|
+
return mon.getTime();
|
|
3850
|
+
}
|
|
3851
|
+
case "monthly":
|
|
3852
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
3853
|
+
case "yearly":
|
|
3854
|
+
return new Date(d.getFullYear(), 0, 1).getTime();
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
var JoopLimitManagementService = class {
|
|
3858
|
+
_limits = /* @__PURE__ */ new Map();
|
|
3859
|
+
_usage = [];
|
|
3860
|
+
_change$ = new JoopSubject();
|
|
3861
|
+
// ── Limit CRUD ────────────────────────────────────────────────────────────
|
|
3862
|
+
setLimit(scope, scopeId, type, maxAmount, currency = "USD", notes) {
|
|
3863
|
+
if (maxAmount <= 0) throw new Error("maxAmount must be positive");
|
|
3864
|
+
const existing = this._findLimit(scope, scopeId, type, currency);
|
|
3865
|
+
if (existing) {
|
|
3866
|
+
existing.maxAmount = maxAmount;
|
|
3867
|
+
existing.notes = notes;
|
|
3868
|
+
this._change$.next(existing);
|
|
3869
|
+
return existing;
|
|
3870
|
+
}
|
|
3871
|
+
const limit = {
|
|
3872
|
+
id: _uid15(),
|
|
3873
|
+
scope,
|
|
3874
|
+
scopeId,
|
|
3875
|
+
type,
|
|
3876
|
+
maxAmount,
|
|
3877
|
+
currency,
|
|
3878
|
+
enabled: true,
|
|
3879
|
+
createdAt: Date.now(),
|
|
3880
|
+
notes
|
|
3881
|
+
};
|
|
3882
|
+
this._limits.set(limit.id, limit);
|
|
3883
|
+
this._change$.next(limit);
|
|
3884
|
+
return limit;
|
|
3885
|
+
}
|
|
3886
|
+
getLimit(id) {
|
|
3887
|
+
return this._limits.get(id);
|
|
3888
|
+
}
|
|
3889
|
+
getLimits(scope, scopeId) {
|
|
3890
|
+
return Array.from(this._limits.values()).filter(
|
|
3891
|
+
(l) => (scope === void 0 || l.scope === scope) && (scopeId === void 0 || l.scopeId === scopeId)
|
|
3892
|
+
);
|
|
3893
|
+
}
|
|
3894
|
+
removeLimit(limitId) {
|
|
3895
|
+
if (!this._limits.has(limitId)) throw new Error(`Limit ${limitId} not found`);
|
|
3896
|
+
this._limits.delete(limitId);
|
|
3897
|
+
this._usage = this._usage.filter((u) => u.limitId !== limitId);
|
|
3898
|
+
}
|
|
3899
|
+
enableLimit(limitId) {
|
|
3900
|
+
this._toggle(limitId, true);
|
|
3901
|
+
}
|
|
3902
|
+
disableLimit(limitId) {
|
|
3903
|
+
this._toggle(limitId, false);
|
|
3904
|
+
}
|
|
3905
|
+
// ── Check & Record ────────────────────────────────────────────────────────
|
|
3906
|
+
checkLimit(scope, scopeId, type, amount, currency = "USD") {
|
|
3907
|
+
const limit = this._findLimit(scope, scopeId, type, currency);
|
|
3908
|
+
if (!limit || !limit.enabled) return null;
|
|
3909
|
+
const usage = this._currentUsage(limit, Date.now());
|
|
3910
|
+
const remaining = Math.max(0, limit.maxAmount - usage);
|
|
3911
|
+
const allowed = amount <= remaining;
|
|
3912
|
+
return {
|
|
3913
|
+
allowed,
|
|
3914
|
+
limit,
|
|
3915
|
+
currentUsage: usage,
|
|
3916
|
+
remaining,
|
|
3917
|
+
requestedAmount: amount,
|
|
3918
|
+
reason: allowed ? void 0 : `${type} limit of ${limit.maxAmount} ${currency} exceeded (used: ${usage})`
|
|
3919
|
+
};
|
|
3920
|
+
}
|
|
3921
|
+
checkAllLimits(scope, scopeId, amount, currency = "USD") {
|
|
3922
|
+
const results = [];
|
|
3923
|
+
for (const limit of this._limits.values()) {
|
|
3924
|
+
if (limit.scope === scope && limit.scopeId === scopeId && limit.currency === currency && limit.enabled) {
|
|
3925
|
+
const check = this.checkLimit(scope, scopeId, limit.type, amount, currency);
|
|
3926
|
+
if (check) results.push(check);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
return results;
|
|
3930
|
+
}
|
|
3931
|
+
recordUsage(scope, scopeId, type, amount, currency = "USD", reference) {
|
|
3932
|
+
const limit = this._findLimit(scope, scopeId, type, currency);
|
|
3933
|
+
if (!limit) return;
|
|
3934
|
+
this._usage.push({ limitId: limit.id, amount, recordedAt: Date.now(), reference });
|
|
3935
|
+
}
|
|
3936
|
+
resetUsage(limitId) {
|
|
3937
|
+
this._usage = this._usage.filter((u) => u.limitId !== limitId);
|
|
3938
|
+
}
|
|
3939
|
+
getUsageSummary(scope, scopeId) {
|
|
3940
|
+
const limits = this.getLimits(scope, scopeId);
|
|
3941
|
+
const now = Date.now();
|
|
3942
|
+
return {
|
|
3943
|
+
scope,
|
|
3944
|
+
scopeId,
|
|
3945
|
+
limits: limits.map((l) => {
|
|
3946
|
+
const used = this._currentUsage(l, now);
|
|
3947
|
+
const remaining = Math.max(0, l.maxAmount - used);
|
|
3948
|
+
return {
|
|
3949
|
+
limitId: l.id,
|
|
3950
|
+
type: l.type,
|
|
3951
|
+
maxAmount: l.maxAmount,
|
|
3952
|
+
used,
|
|
3953
|
+
remaining,
|
|
3954
|
+
utilizationPercent: Math.round(used / l.maxAmount * 100 * 100) / 100
|
|
3955
|
+
};
|
|
3956
|
+
})
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
3960
|
+
change$() {
|
|
3961
|
+
return this._change$;
|
|
3962
|
+
}
|
|
3963
|
+
_findLimit(scope, scopeId, type, currency) {
|
|
3964
|
+
for (const l of this._limits.values()) {
|
|
3965
|
+
if (l.scope === scope && l.scopeId === scopeId && l.type === type && l.currency === currency) return l;
|
|
3966
|
+
}
|
|
3967
|
+
return void 0;
|
|
3968
|
+
}
|
|
3969
|
+
_currentUsage(limit, now) {
|
|
3970
|
+
if (limit.type === "per-transaction") return 0;
|
|
3971
|
+
const periodStart = _periodStart(limit.type, now);
|
|
3972
|
+
return this._usage.filter((u) => u.limitId === limit.id && u.recordedAt >= periodStart).reduce((s, u) => s + u.amount, 0);
|
|
3973
|
+
}
|
|
3974
|
+
_toggle(limitId, enabled) {
|
|
3975
|
+
const l = this._limits.get(limitId);
|
|
3976
|
+
if (!l) throw new Error(`Limit ${limitId} not found`);
|
|
3977
|
+
l.enabled = enabled;
|
|
3978
|
+
this._change$.next(l);
|
|
3979
|
+
}
|
|
3980
|
+
};
|
|
3981
|
+
|
|
3982
|
+
// src/banking/standing-order.service.ts
|
|
3983
|
+
function _uid16() {
|
|
3984
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
3985
|
+
}
|
|
3986
|
+
function _computeNextDate(current, frequency) {
|
|
3987
|
+
const d = new Date(current);
|
|
3988
|
+
switch (frequency) {
|
|
3989
|
+
case "daily":
|
|
3990
|
+
d.setDate(d.getDate() + 1);
|
|
3991
|
+
break;
|
|
3992
|
+
case "weekly":
|
|
3993
|
+
d.setDate(d.getDate() + 7);
|
|
3994
|
+
break;
|
|
3995
|
+
case "biweekly":
|
|
3996
|
+
d.setDate(d.getDate() + 14);
|
|
3997
|
+
break;
|
|
3998
|
+
case "monthly":
|
|
3999
|
+
d.setMonth(d.getMonth() + 1);
|
|
4000
|
+
break;
|
|
4001
|
+
case "quarterly":
|
|
4002
|
+
d.setMonth(d.getMonth() + 3);
|
|
4003
|
+
break;
|
|
4004
|
+
case "half-yearly":
|
|
4005
|
+
d.setMonth(d.getMonth() + 6);
|
|
4006
|
+
break;
|
|
4007
|
+
case "yearly":
|
|
4008
|
+
d.setFullYear(d.getFullYear() + 1);
|
|
4009
|
+
break;
|
|
4010
|
+
}
|
|
4011
|
+
return d.getTime();
|
|
4012
|
+
}
|
|
4013
|
+
var JoopStandingOrderService = class {
|
|
4014
|
+
_orders = [];
|
|
4015
|
+
_executions = [];
|
|
4016
|
+
_seq = 0;
|
|
4017
|
+
_change$ = new JoopSubject();
|
|
4018
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
4019
|
+
create(params) {
|
|
4020
|
+
if (params.amount <= 0) throw new Error("amount must be positive");
|
|
4021
|
+
if (!params.fromAccount.trim()) throw new Error("fromAccount required");
|
|
4022
|
+
if (!params.toAccount.trim()) throw new Error("toAccount required");
|
|
4023
|
+
if (params.endDate !== void 0 && params.endDate <= params.startDate) {
|
|
4024
|
+
throw new Error("endDate must be after startDate");
|
|
4025
|
+
}
|
|
4026
|
+
this._seq += 1;
|
|
4027
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
4028
|
+
const ref = `SO-${year}-${String(this._seq).padStart(6, "0")}`;
|
|
4029
|
+
const order = {
|
|
4030
|
+
id: _uid16(),
|
|
4031
|
+
ref,
|
|
4032
|
+
fromAccount: params.fromAccount,
|
|
4033
|
+
toAccount: params.toAccount,
|
|
4034
|
+
toBeneficiaryName: params.toBeneficiaryName,
|
|
4035
|
+
amount: params.amount,
|
|
4036
|
+
currency: params.currency ?? "USD",
|
|
4037
|
+
frequency: params.frequency,
|
|
4038
|
+
startDate: params.startDate,
|
|
4039
|
+
endDate: params.endDate,
|
|
4040
|
+
status: "active",
|
|
4041
|
+
nextExecutionAt: params.startDate,
|
|
4042
|
+
executionCount: 0,
|
|
4043
|
+
maxExecutions: params.maxExecutions,
|
|
4044
|
+
reference: params.reference,
|
|
4045
|
+
notes: params.notes,
|
|
4046
|
+
createdAt: Date.now()
|
|
4047
|
+
};
|
|
4048
|
+
this._orders.push(order);
|
|
4049
|
+
this._change$.next(order);
|
|
4050
|
+
return order;
|
|
4051
|
+
}
|
|
4052
|
+
update(id, patch) {
|
|
4053
|
+
const o = this._get(id);
|
|
4054
|
+
if (patch.amount !== void 0 && patch.amount <= 0) throw new Error("amount must be positive");
|
|
4055
|
+
Object.assign(o, patch);
|
|
4056
|
+
this._change$.next(o);
|
|
4057
|
+
return o;
|
|
4058
|
+
}
|
|
4059
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
4060
|
+
pause(id) {
|
|
4061
|
+
const o = this._get(id);
|
|
4062
|
+
if (o.status !== "active") throw new Error(`Cannot pause order with status ${o.status}`);
|
|
4063
|
+
o.status = "paused";
|
|
4064
|
+
this._change$.next(o);
|
|
4065
|
+
return o;
|
|
4066
|
+
}
|
|
4067
|
+
resume(id) {
|
|
4068
|
+
const o = this._get(id);
|
|
4069
|
+
if (o.status !== "paused") throw new Error(`Cannot resume order with status ${o.status}`);
|
|
4070
|
+
o.status = "active";
|
|
4071
|
+
this._change$.next(o);
|
|
4072
|
+
return o;
|
|
4073
|
+
}
|
|
4074
|
+
cancel(id) {
|
|
4075
|
+
const o = this._get(id);
|
|
4076
|
+
if (o.status === "cancelled") throw new Error("Order is already cancelled");
|
|
4077
|
+
o.status = "cancelled";
|
|
4078
|
+
this._change$.next(o);
|
|
4079
|
+
return o;
|
|
4080
|
+
}
|
|
4081
|
+
// ── Execution ─────────────────────────────────────────────────────────────
|
|
4082
|
+
execute(id, status = "success", opts = {}) {
|
|
4083
|
+
const o = this._get(id);
|
|
4084
|
+
if (o.status !== "active") throw new Error(`Cannot execute order with status ${o.status}`);
|
|
4085
|
+
const exec = {
|
|
4086
|
+
id: _uid16(),
|
|
4087
|
+
orderId: id,
|
|
4088
|
+
executedAt: Date.now(),
|
|
4089
|
+
amount: o.amount,
|
|
4090
|
+
currency: o.currency,
|
|
4091
|
+
status,
|
|
4092
|
+
failureReason: opts.failureReason,
|
|
4093
|
+
transactionRef: opts.transactionRef
|
|
4094
|
+
};
|
|
4095
|
+
this._executions.push(exec);
|
|
4096
|
+
if (status === "success") {
|
|
4097
|
+
o.executionCount += 1;
|
|
4098
|
+
const next = _computeNextDate(o.nextExecutionAt ?? Date.now(), o.frequency);
|
|
4099
|
+
if (o.endDate && next > o.endDate || o.maxExecutions && o.executionCount >= o.maxExecutions) {
|
|
4100
|
+
o.status = "completed";
|
|
4101
|
+
o.nextExecutionAt = void 0;
|
|
4102
|
+
} else {
|
|
4103
|
+
o.nextExecutionAt = next;
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
this._change$.next(o);
|
|
4107
|
+
return exec;
|
|
4108
|
+
}
|
|
4109
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
4110
|
+
get(id) {
|
|
4111
|
+
return this._orders.find((o) => o.id === id);
|
|
4112
|
+
}
|
|
4113
|
+
getAll() {
|
|
4114
|
+
return [...this._orders];
|
|
4115
|
+
}
|
|
4116
|
+
getActive() {
|
|
4117
|
+
return this._orders.filter((o) => o.status === "active");
|
|
4118
|
+
}
|
|
4119
|
+
getByAccount(accountId) {
|
|
4120
|
+
return this._orders.filter((o) => o.fromAccount === accountId || o.toAccount === accountId);
|
|
4121
|
+
}
|
|
4122
|
+
getDue(asOf = Date.now()) {
|
|
4123
|
+
return this._orders.filter((o) => o.status === "active" && o.nextExecutionAt !== void 0 && o.nextExecutionAt <= asOf);
|
|
4124
|
+
}
|
|
4125
|
+
getExecutions(orderId) {
|
|
4126
|
+
return this._executions.filter((e) => e.orderId === orderId);
|
|
4127
|
+
}
|
|
4128
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
4129
|
+
change$() {
|
|
4130
|
+
return this._change$;
|
|
4131
|
+
}
|
|
4132
|
+
_get(id) {
|
|
4133
|
+
const o = this._orders.find((x) => x.id === id);
|
|
4134
|
+
if (!o) throw new Error(`Standing order ${id} not found`);
|
|
4135
|
+
return o;
|
|
4136
|
+
}
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
// src/banking/loan-servicing.service.ts
|
|
4140
|
+
function _uid17() {
|
|
4141
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4142
|
+
}
|
|
4143
|
+
function _round3(n, dp = 2) {
|
|
4144
|
+
return Math.round(n * 10 ** dp) / 10 ** dp;
|
|
4145
|
+
}
|
|
4146
|
+
function _addMonths(ts, months) {
|
|
4147
|
+
const d = new Date(ts);
|
|
4148
|
+
d.setMonth(d.getMonth() + months);
|
|
4149
|
+
return d.getTime();
|
|
4150
|
+
}
|
|
4151
|
+
function _calcEmi(principal, annualRate, tenureMonths) {
|
|
4152
|
+
if (annualRate === 0) return _round3(principal / tenureMonths);
|
|
4153
|
+
const r = annualRate / 100 / 12;
|
|
4154
|
+
return _round3(principal * r * Math.pow(1 + r, tenureMonths) / (Math.pow(1 + r, tenureMonths) - 1));
|
|
4155
|
+
}
|
|
4156
|
+
function _buildSchedule(loanId, principal, annualRate, tenureMonths, startDate, emi) {
|
|
4157
|
+
const schedule = [];
|
|
4158
|
+
let outstanding = principal;
|
|
4159
|
+
const r = annualRate / 100 / 12;
|
|
4160
|
+
for (let i = 1; i <= tenureMonths; i++) {
|
|
4161
|
+
const interest = _round3(outstanding * r);
|
|
4162
|
+
const principalC = i < tenureMonths ? _round3(emi - interest) : _round3(outstanding);
|
|
4163
|
+
const total = _round3(interest + principalC);
|
|
4164
|
+
outstanding = _round3(outstanding - principalC);
|
|
4165
|
+
if (outstanding < 0) outstanding = 0;
|
|
4166
|
+
schedule.push({
|
|
4167
|
+
installmentNumber: i,
|
|
4168
|
+
dueDate: _addMonths(startDate, i),
|
|
4169
|
+
principalComponent: principalC,
|
|
4170
|
+
interestComponent: interest,
|
|
4171
|
+
totalDue: total,
|
|
4172
|
+
outstandingAfter: outstanding,
|
|
4173
|
+
status: "pending",
|
|
4174
|
+
paidAmount: 0
|
|
4175
|
+
});
|
|
4176
|
+
}
|
|
4177
|
+
return schedule;
|
|
4178
|
+
}
|
|
4179
|
+
var JoopLoanServicingService = class {
|
|
4180
|
+
_loans = [];
|
|
4181
|
+
_schedules = /* @__PURE__ */ new Map();
|
|
4182
|
+
_payments = /* @__PURE__ */ new Map();
|
|
4183
|
+
_seq = 0;
|
|
4184
|
+
_change$ = new JoopSubject();
|
|
4185
|
+
// ── Create ────────────────────────────────────────────────────────────────
|
|
4186
|
+
createLoan(params) {
|
|
4187
|
+
if (params.principalAmount <= 0) throw new Error("principalAmount must be positive");
|
|
4188
|
+
if (params.annualInterestRatePercent < 0) throw new Error("annualInterestRatePercent must be non-negative");
|
|
4189
|
+
if (params.tenureMonths <= 0) throw new Error("tenureMonths must be positive");
|
|
4190
|
+
this._seq += 1;
|
|
4191
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
4192
|
+
const ref = `LN-${year}-${String(this._seq).padStart(6, "0")}`;
|
|
4193
|
+
const disbursedAt = params.disbursedAt ?? Date.now();
|
|
4194
|
+
const emi = _calcEmi(params.principalAmount, params.annualInterestRatePercent, params.tenureMonths);
|
|
4195
|
+
const loan = {
|
|
4196
|
+
id: _uid17(),
|
|
4197
|
+
ref,
|
|
4198
|
+
borrowerName: params.borrowerName,
|
|
4199
|
+
borrowerId: params.borrowerId,
|
|
4200
|
+
principalAmount: params.principalAmount,
|
|
4201
|
+
outstandingPrincipal: params.principalAmount,
|
|
4202
|
+
annualInterestRatePercent: params.annualInterestRatePercent,
|
|
4203
|
+
tenureMonths: params.tenureMonths,
|
|
4204
|
+
disbursedAt,
|
|
4205
|
+
status: "active",
|
|
4206
|
+
emiAmount: emi,
|
|
4207
|
+
currency: params.currency ?? "USD",
|
|
4208
|
+
nextDueDate: _addMonths(disbursedAt, 1),
|
|
4209
|
+
totalPaid: 0,
|
|
4210
|
+
penaltyRatePercent: params.penaltyRatePercent ?? 2,
|
|
4211
|
+
notes: params.notes
|
|
4212
|
+
};
|
|
4213
|
+
this._loans.push(loan);
|
|
4214
|
+
this._schedules.set(loan.id, _buildSchedule(loan.id, params.principalAmount, params.annualInterestRatePercent, params.tenureMonths, disbursedAt, emi));
|
|
4215
|
+
this._payments.set(loan.id, []);
|
|
4216
|
+
this._change$.next(loan);
|
|
4217
|
+
return loan;
|
|
4218
|
+
}
|
|
4219
|
+
// ── Payments ──────────────────────────────────────────────────────────────
|
|
4220
|
+
recordPayment(loanId, amount, opts = {}) {
|
|
4221
|
+
const loan = this._get(loanId);
|
|
4222
|
+
if (loan.status !== "active") throw new Error(`Cannot record payment on loan with status ${loan.status}`);
|
|
4223
|
+
if (amount <= 0) throw new Error("amount must be positive");
|
|
4224
|
+
const schedule = this._schedules.get(loanId);
|
|
4225
|
+
const paidAt = opts.paidAt ?? Date.now();
|
|
4226
|
+
let remaining = amount;
|
|
4227
|
+
let interestPaid = 0;
|
|
4228
|
+
let principalPaid = 0;
|
|
4229
|
+
for (const inst of schedule) {
|
|
4230
|
+
if (remaining <= 0) break;
|
|
4231
|
+
if (inst.status === "paid" || inst.status === "waived") continue;
|
|
4232
|
+
const owed = _round3(inst.totalDue - inst.paidAmount);
|
|
4233
|
+
const apply = Math.min(remaining, owed);
|
|
4234
|
+
const instTotal = inst.interestComponent + inst.principalComponent;
|
|
4235
|
+
const intShare = instTotal > 0 ? _round3(apply * (inst.interestComponent / instTotal)) : 0;
|
|
4236
|
+
const priShare = _round3(apply - intShare);
|
|
4237
|
+
interestPaid += intShare;
|
|
4238
|
+
principalPaid += priShare;
|
|
4239
|
+
inst.paidAmount = _round3(inst.paidAmount + apply);
|
|
4240
|
+
remaining = _round3(remaining - apply);
|
|
4241
|
+
if (Math.abs(inst.paidAmount - inst.totalDue) < 0.01) {
|
|
4242
|
+
inst.status = "paid";
|
|
4243
|
+
inst.paidAt = paidAt;
|
|
4244
|
+
} else {
|
|
4245
|
+
inst.status = "partial";
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
if (remaining > 0) {
|
|
4249
|
+
principalPaid += remaining;
|
|
4250
|
+
remaining = 0;
|
|
4251
|
+
}
|
|
4252
|
+
interestPaid = _round3(interestPaid);
|
|
4253
|
+
principalPaid = _round3(principalPaid);
|
|
4254
|
+
loan.outstandingPrincipal = _round3(loan.outstandingPrincipal - principalPaid);
|
|
4255
|
+
if (loan.outstandingPrincipal < 0) loan.outstandingPrincipal = 0;
|
|
4256
|
+
loan.totalPaid = _round3(loan.totalPaid + amount);
|
|
4257
|
+
const nextPending = schedule.find((i) => i.status !== "paid" && i.status !== "waived");
|
|
4258
|
+
loan.nextDueDate = nextPending?.dueDate ?? loan.nextDueDate;
|
|
4259
|
+
if (loan.outstandingPrincipal === 0) {
|
|
4260
|
+
loan.status = "closed";
|
|
4261
|
+
}
|
|
4262
|
+
const payment = {
|
|
4263
|
+
id: _uid17(),
|
|
4264
|
+
loanId,
|
|
4265
|
+
amount,
|
|
4266
|
+
paidAt,
|
|
4267
|
+
interestPaid,
|
|
4268
|
+
principalPaid,
|
|
4269
|
+
penaltyPaid: 0,
|
|
4270
|
+
reference: opts.reference
|
|
4271
|
+
};
|
|
4272
|
+
this._payments.get(loanId).push(payment);
|
|
4273
|
+
this._change$.next(loan);
|
|
4274
|
+
return payment;
|
|
4275
|
+
}
|
|
4276
|
+
waveInstallment(loanId, installmentNumber) {
|
|
4277
|
+
const schedule = this._schedules.get(loanId);
|
|
4278
|
+
if (!schedule) throw new Error(`Loan ${loanId} not found`);
|
|
4279
|
+
const inst = schedule.find((i) => i.installmentNumber === installmentNumber);
|
|
4280
|
+
if (!inst) throw new Error(`Installment ${installmentNumber} not found`);
|
|
4281
|
+
inst.status = "waived";
|
|
4282
|
+
inst.paidAt = Date.now();
|
|
4283
|
+
}
|
|
4284
|
+
markDefaulted(loanId) {
|
|
4285
|
+
const loan = this._get(loanId);
|
|
4286
|
+
if (loan.status !== "active") throw new Error(`Loan is not active`);
|
|
4287
|
+
loan.status = "defaulted";
|
|
4288
|
+
this._change$.next(loan);
|
|
4289
|
+
return loan;
|
|
4290
|
+
}
|
|
4291
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
4292
|
+
getLoan(id) {
|
|
4293
|
+
return this._loans.find((l) => l.id === id);
|
|
4294
|
+
}
|
|
4295
|
+
getAll() {
|
|
4296
|
+
return [...this._loans];
|
|
4297
|
+
}
|
|
4298
|
+
getActive() {
|
|
4299
|
+
return this._loans.filter((l) => l.status === "active");
|
|
4300
|
+
}
|
|
4301
|
+
getByBorrower(borrowerId) {
|
|
4302
|
+
return this._loans.filter((l) => l.borrowerId === borrowerId);
|
|
4303
|
+
}
|
|
4304
|
+
getSchedule(loanId) {
|
|
4305
|
+
return this._schedules.get(loanId) ?? [];
|
|
4306
|
+
}
|
|
4307
|
+
getPendingInstallments(loanId) {
|
|
4308
|
+
this._markOverdue(loanId);
|
|
4309
|
+
return (this._schedules.get(loanId) ?? []).filter((i) => i.status !== "paid" && i.status !== "waived");
|
|
4310
|
+
}
|
|
4311
|
+
getOverdueInstallments(loanId) {
|
|
4312
|
+
this._markOverdue(loanId);
|
|
4313
|
+
return (this._schedules.get(loanId) ?? []).filter((i) => i.status === "overdue");
|
|
4314
|
+
}
|
|
4315
|
+
getOutstandingBalance(loanId) {
|
|
4316
|
+
return this._get(loanId).outstandingPrincipal;
|
|
4317
|
+
}
|
|
4318
|
+
getAccruedInterest(loanId, asOf = Date.now()) {
|
|
4319
|
+
const loan = this._get(loanId);
|
|
4320
|
+
const daysSinceDue = Math.max(0, (asOf - loan.nextDueDate) / 864e5);
|
|
4321
|
+
if (daysSinceDue <= 0) return 0;
|
|
4322
|
+
return _round3(loan.outstandingPrincipal * (loan.annualInterestRatePercent / 100 / 365) * daysSinceDue);
|
|
4323
|
+
}
|
|
4324
|
+
getLoanStatement(loanId) {
|
|
4325
|
+
const loan = this._get(loanId);
|
|
4326
|
+
const schedule = this._schedules.get(loanId);
|
|
4327
|
+
const payments = this._payments.get(loanId);
|
|
4328
|
+
this._markOverdue(loanId);
|
|
4329
|
+
const totalOverdue = schedule.filter((i) => i.status === "overdue").reduce((s, i) => s + _round3(i.totalDue - i.paidAmount), 0);
|
|
4330
|
+
return {
|
|
4331
|
+
loan,
|
|
4332
|
+
schedule,
|
|
4333
|
+
payments,
|
|
4334
|
+
outstandingPrincipal: loan.outstandingPrincipal,
|
|
4335
|
+
accruedInterest: this.getAccruedInterest(loanId),
|
|
4336
|
+
totalOverdue: _round3(totalOverdue)
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
4340
|
+
change$() {
|
|
4341
|
+
return this._change$;
|
|
4342
|
+
}
|
|
4343
|
+
_get(id) {
|
|
4344
|
+
const l = this._loans.find((x) => x.id === id);
|
|
4345
|
+
if (!l) throw new Error(`Loan ${id} not found`);
|
|
4346
|
+
return l;
|
|
4347
|
+
}
|
|
4348
|
+
_markOverdue(loanId) {
|
|
4349
|
+
const now = Date.now();
|
|
4350
|
+
const schedule = this._schedules.get(loanId);
|
|
4351
|
+
if (!schedule) return;
|
|
4352
|
+
for (const inst of schedule) {
|
|
4353
|
+
if ((inst.status === "pending" || inst.status === "partial") && inst.dueDate < now) {
|
|
4354
|
+
inst.status = "overdue";
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
};
|
|
4359
|
+
|
|
4360
|
+
// src/banking/digital-wallet.service.ts
|
|
4361
|
+
function _uid18() {
|
|
4362
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4363
|
+
}
|
|
4364
|
+
function _round4(n) {
|
|
4365
|
+
return Math.round(n * 100) / 100;
|
|
4366
|
+
}
|
|
4367
|
+
var JoopDigitalWalletService = class {
|
|
4368
|
+
_wallets = /* @__PURE__ */ new Map();
|
|
4369
|
+
_transactions = [];
|
|
4370
|
+
_balance$ = new JoopBehaviorSubject(null);
|
|
4371
|
+
// ── Wallet lifecycle ──────────────────────────────────────────────────────
|
|
4372
|
+
createWallet(userId, opts = {}) {
|
|
4373
|
+
const wallet = {
|
|
4374
|
+
id: _uid18(),
|
|
4375
|
+
userId,
|
|
4376
|
+
label: opts.label,
|
|
4377
|
+
currency: opts.currency ?? "USD",
|
|
4378
|
+
balance: 0,
|
|
4379
|
+
status: "active",
|
|
4380
|
+
createdAt: Date.now(),
|
|
4381
|
+
dailyLimit: opts.dailyLimit,
|
|
4382
|
+
monthlyLimit: opts.monthlyLimit
|
|
4383
|
+
};
|
|
4384
|
+
this._wallets.set(wallet.id, wallet);
|
|
4385
|
+
return wallet;
|
|
4386
|
+
}
|
|
4387
|
+
freeze(walletId) {
|
|
4388
|
+
const w = this._getActive(walletId);
|
|
4389
|
+
w.status = "frozen";
|
|
4390
|
+
return w;
|
|
4391
|
+
}
|
|
4392
|
+
unfreeze(walletId) {
|
|
4393
|
+
const w = this._get(walletId);
|
|
4394
|
+
if (w.status === "closed") throw new Error("Cannot unfreeze a closed wallet");
|
|
4395
|
+
w.status = "active";
|
|
4396
|
+
return w;
|
|
4397
|
+
}
|
|
4398
|
+
close(walletId) {
|
|
4399
|
+
const w = this._get(walletId);
|
|
4400
|
+
if (w.status === "closed") throw new Error("Wallet already closed");
|
|
4401
|
+
if (w.balance > 0) throw new Error("Cannot close wallet with positive balance \u2014 withdraw funds first");
|
|
4402
|
+
w.status = "closed";
|
|
4403
|
+
return w;
|
|
4404
|
+
}
|
|
4405
|
+
updateLimits(walletId, limits) {
|
|
4406
|
+
const w = this._get(walletId);
|
|
4407
|
+
if (limits.dailyLimit !== void 0) w.dailyLimit = limits.dailyLimit;
|
|
4408
|
+
if (limits.monthlyLimit !== void 0) w.monthlyLimit = limits.monthlyLimit;
|
|
4409
|
+
return w;
|
|
4410
|
+
}
|
|
4411
|
+
// ── Funds ─────────────────────────────────────────────────────────────────
|
|
4412
|
+
topUp(walletId, amount, opts = {}) {
|
|
4413
|
+
if (amount <= 0) throw new Error("amount must be positive");
|
|
4414
|
+
const w = this._getActive(walletId);
|
|
4415
|
+
return this._record(w, "topup", amount, opts.description ?? "Top-up", opts.reference);
|
|
4416
|
+
}
|
|
4417
|
+
withdraw(walletId, amount, opts = {}) {
|
|
4418
|
+
if (amount <= 0) throw new Error("amount must be positive");
|
|
4419
|
+
const w = this._getActive(walletId);
|
|
4420
|
+
this._assertSufficientBalance(w, amount);
|
|
4421
|
+
return this._record(w, "withdrawal", -amount, opts.description ?? "Withdrawal", opts.reference);
|
|
4422
|
+
}
|
|
4423
|
+
pay(walletId, amount, merchantId, merchantName, opts = {}) {
|
|
4424
|
+
if (amount <= 0) throw new Error("amount must be positive");
|
|
4425
|
+
const w = this._getActive(walletId);
|
|
4426
|
+
this._assertSufficientBalance(w, amount);
|
|
4427
|
+
const txn = this._record(w, "payment", -amount, `Payment to ${merchantName}`, opts.reference);
|
|
4428
|
+
txn.counterpartyId = merchantId;
|
|
4429
|
+
txn.counterpartyName = merchantName;
|
|
4430
|
+
if (opts.metadata) txn.metadata = opts.metadata;
|
|
4431
|
+
return txn;
|
|
4432
|
+
}
|
|
4433
|
+
transfer(fromWalletId, toWalletId, amount, opts = {}) {
|
|
4434
|
+
if (amount <= 0) throw new Error("amount must be positive");
|
|
4435
|
+
if (fromWalletId === toWalletId) throw new Error("Cannot transfer to the same wallet");
|
|
4436
|
+
const from = this._getActive(fromWalletId);
|
|
4437
|
+
const to = this._getActive(toWalletId);
|
|
4438
|
+
this._assertSufficientBalance(from, amount);
|
|
4439
|
+
if (from.currency !== to.currency) throw new Error("Cross-currency transfers not supported \u2014 use FX service");
|
|
4440
|
+
const fromTxn = this._record(from, "transfer-out", -amount, opts.note ?? `Transfer to ${to.userId}`, opts.reference);
|
|
4441
|
+
fromTxn.counterpartyId = toWalletId;
|
|
4442
|
+
const toTxn = this._record(to, "transfer-in", amount, opts.note ?? `Transfer from ${from.userId}`, opts.reference);
|
|
4443
|
+
toTxn.counterpartyId = fromWalletId;
|
|
4444
|
+
return { fromTxn, toTxn };
|
|
4445
|
+
}
|
|
4446
|
+
refund(originalTxnId, opts = {}) {
|
|
4447
|
+
const original = this._transactions.find((t) => t.id === originalTxnId);
|
|
4448
|
+
if (!original) throw new Error(`Transaction ${originalTxnId} not found`);
|
|
4449
|
+
if (original.type !== "payment") throw new Error("Can only refund payment transactions");
|
|
4450
|
+
const w = this._getActive(original.walletId);
|
|
4451
|
+
const amount = opts.amount ?? Math.abs(original.amount);
|
|
4452
|
+
if (amount > Math.abs(original.amount)) throw new Error("Refund exceeds original payment amount");
|
|
4453
|
+
return this._record(w, "refund", amount, opts.reason ?? `Refund for ${originalTxnId}`, originalTxnId);
|
|
4454
|
+
}
|
|
4455
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
4456
|
+
getWallet(id) {
|
|
4457
|
+
return this._wallets.get(id);
|
|
4458
|
+
}
|
|
4459
|
+
getWalletsByUser(userId) {
|
|
4460
|
+
return Array.from(this._wallets.values()).filter((w) => w.userId === userId);
|
|
4461
|
+
}
|
|
4462
|
+
getBalance(walletId) {
|
|
4463
|
+
return this._get(walletId).balance;
|
|
4464
|
+
}
|
|
4465
|
+
getTransactions(walletId, limit) {
|
|
4466
|
+
const all = this._transactions.filter((t) => t.walletId === walletId).sort((a, b) => b.createdAt - a.createdAt);
|
|
4467
|
+
return limit ? all.slice(0, limit) : all;
|
|
4468
|
+
}
|
|
4469
|
+
getTotalSpent(walletId, from, to) {
|
|
4470
|
+
return _round4(
|
|
4471
|
+
this._transactions.filter((t) => t.walletId === walletId && t.type === "payment" && (from === void 0 || t.createdAt >= from) && (to === void 0 || t.createdAt <= to)).reduce((s, t) => s + Math.abs(t.amount), 0)
|
|
4472
|
+
);
|
|
4473
|
+
}
|
|
4474
|
+
getTotalTopUp(walletId) {
|
|
4475
|
+
return _round4(
|
|
4476
|
+
this._transactions.filter((t) => t.walletId === walletId && t.type === "topup").reduce((s, t) => s + t.amount, 0)
|
|
4477
|
+
);
|
|
4478
|
+
}
|
|
4479
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
4480
|
+
balance$() {
|
|
4481
|
+
return this._balance$;
|
|
4482
|
+
}
|
|
4483
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
4484
|
+
_get(id) {
|
|
4485
|
+
const w = this._wallets.get(id);
|
|
4486
|
+
if (!w) throw new Error(`Wallet ${id} not found`);
|
|
4487
|
+
return w;
|
|
4488
|
+
}
|
|
4489
|
+
_getActive(id) {
|
|
4490
|
+
const w = this._get(id);
|
|
4491
|
+
if (w.status !== "active") throw new Error(`Wallet is ${w.status}`);
|
|
4492
|
+
return w;
|
|
4493
|
+
}
|
|
4494
|
+
_assertSufficientBalance(w, amount) {
|
|
4495
|
+
if (w.balance < amount) throw new Error(`Insufficient balance \u2014 available: ${w.balance}, requested: ${amount}`);
|
|
4496
|
+
}
|
|
4497
|
+
_record(w, type, amount, description, reference) {
|
|
4498
|
+
const balanceBefore = w.balance;
|
|
4499
|
+
w.balance = _round4(w.balance + amount);
|
|
4500
|
+
const txn = {
|
|
4501
|
+
id: _uid18(),
|
|
4502
|
+
walletId: w.id,
|
|
4503
|
+
type,
|
|
4504
|
+
amount,
|
|
4505
|
+
balanceBefore,
|
|
4506
|
+
balanceAfter: w.balance,
|
|
4507
|
+
description,
|
|
4508
|
+
reference,
|
|
4509
|
+
createdAt: Date.now()
|
|
4510
|
+
};
|
|
4511
|
+
this._transactions.push(txn);
|
|
4512
|
+
this._balance$.next({ walletId: w.id, balance: w.balance });
|
|
4513
|
+
return txn;
|
|
4514
|
+
}
|
|
4515
|
+
};
|
|
4516
|
+
|
|
4517
|
+
// src/banking/fx-forward.service.ts
|
|
4518
|
+
function _uid19() {
|
|
4519
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
4520
|
+
}
|
|
4521
|
+
function _round5(n, dp = 4) {
|
|
4522
|
+
return Math.round(n * 10 ** dp) / 10 ** dp;
|
|
4523
|
+
}
|
|
4524
|
+
var JoopFxForwardService = class {
|
|
4525
|
+
_forwards = [];
|
|
4526
|
+
_settlements = [];
|
|
4527
|
+
_spotRates = /* @__PURE__ */ new Map();
|
|
4528
|
+
_seq = 0;
|
|
4529
|
+
_change$ = new JoopSubject();
|
|
4530
|
+
// ── Spot rates ────────────────────────────────────────────────────────────
|
|
4531
|
+
setSpotRate(baseCurrency, quoteCurrency, rate) {
|
|
4532
|
+
if (rate <= 0) throw new Error("rate must be positive");
|
|
4533
|
+
this._spotRates.set(`${baseCurrency}:${quoteCurrency}`, rate);
|
|
4534
|
+
this._spotRates.set(`${quoteCurrency}:${baseCurrency}`, _round5(1 / rate));
|
|
4535
|
+
}
|
|
4536
|
+
getSpotRate(baseCurrency, quoteCurrency) {
|
|
4537
|
+
if (baseCurrency === quoteCurrency) return 1;
|
|
4538
|
+
return this._spotRates.get(`${baseCurrency}:${quoteCurrency}`);
|
|
4539
|
+
}
|
|
4540
|
+
// ── Forward contracts ─────────────────────────────────────────────────────
|
|
4541
|
+
createForward(params) {
|
|
4542
|
+
if (params.notionalAmount <= 0) throw new Error("notionalAmount must be positive");
|
|
4543
|
+
if (params.contractRate <= 0) throw new Error("contractRate must be positive");
|
|
4544
|
+
if (params.maturityDate <= Date.now()) throw new Error("maturityDate must be in the future");
|
|
4545
|
+
const spotRate = this.getSpotRate(params.baseCurrency, params.quoteCurrency) ?? params.contractRate;
|
|
4546
|
+
this._seq += 1;
|
|
4547
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
4548
|
+
const ref = `FWD-${year}-${String(this._seq).padStart(6, "0")}`;
|
|
4549
|
+
const forward = {
|
|
4550
|
+
id: _uid19(),
|
|
4551
|
+
ref,
|
|
4552
|
+
type: params.type,
|
|
4553
|
+
baseCurrency: params.baseCurrency,
|
|
4554
|
+
quoteCurrency: params.quoteCurrency,
|
|
4555
|
+
notionalAmount: params.notionalAmount,
|
|
4556
|
+
contractRate: params.contractRate,
|
|
4557
|
+
spotRateAtCreation: spotRate,
|
|
4558
|
+
maturityDate: params.maturityDate,
|
|
4559
|
+
status: "open",
|
|
4560
|
+
counterpartyId: params.counterpartyId,
|
|
4561
|
+
counterpartyName: params.counterpartyName,
|
|
4562
|
+
createdAt: Date.now(),
|
|
4563
|
+
notes: params.notes
|
|
4564
|
+
};
|
|
4565
|
+
this._forwards.push(forward);
|
|
4566
|
+
this._change$.next(forward);
|
|
4567
|
+
return forward;
|
|
4568
|
+
}
|
|
4569
|
+
settleForward(id, spotRate) {
|
|
4570
|
+
const fwd = this._get(id);
|
|
4571
|
+
if (fwd.status !== "open") throw new Error(`Cannot settle forward with status ${fwd.status}`);
|
|
4572
|
+
const rate = spotRate ?? this.getSpotRate(fwd.baseCurrency, fwd.quoteCurrency) ?? fwd.contractRate;
|
|
4573
|
+
const pnl = fwd.type === "sell" ? _round5((fwd.contractRate - rate) * fwd.notionalAmount, 2) : _round5((rate - fwd.contractRate) * fwd.notionalAmount, 2);
|
|
4574
|
+
fwd.status = "settled";
|
|
4575
|
+
fwd.settledAt = Date.now();
|
|
4576
|
+
fwd.settlementSpotRate = rate;
|
|
4577
|
+
fwd.pnl = pnl;
|
|
4578
|
+
const settlement = {
|
|
4579
|
+
forwardId: id,
|
|
4580
|
+
ref: fwd.ref,
|
|
4581
|
+
settledAt: fwd.settledAt,
|
|
4582
|
+
contractRate: fwd.contractRate,
|
|
4583
|
+
spotRate: rate,
|
|
4584
|
+
notionalAmount: fwd.notionalAmount,
|
|
4585
|
+
baseCurrency: fwd.baseCurrency,
|
|
4586
|
+
quoteCurrency: fwd.quoteCurrency,
|
|
4587
|
+
pnl
|
|
4588
|
+
};
|
|
4589
|
+
this._settlements.push(settlement);
|
|
4590
|
+
this._change$.next(fwd);
|
|
4591
|
+
return settlement;
|
|
4592
|
+
}
|
|
4593
|
+
cancelForward(id, notes) {
|
|
4594
|
+
const fwd = this._get(id);
|
|
4595
|
+
if (fwd.status !== "open") throw new Error(`Cannot cancel forward with status ${fwd.status}`);
|
|
4596
|
+
fwd.status = "cancelled";
|
|
4597
|
+
if (notes) fwd.notes = notes;
|
|
4598
|
+
this._change$.next(fwd);
|
|
4599
|
+
return fwd;
|
|
4600
|
+
}
|
|
4601
|
+
expireForward(id) {
|
|
4602
|
+
const fwd = this._get(id);
|
|
4603
|
+
if (fwd.status !== "open") throw new Error(`Cannot expire forward with status ${fwd.status}`);
|
|
4604
|
+
if (fwd.maturityDate > Date.now()) throw new Error("Forward has not yet matured");
|
|
4605
|
+
fwd.status = "expired";
|
|
4606
|
+
this._change$.next(fwd);
|
|
4607
|
+
return fwd;
|
|
4608
|
+
}
|
|
4609
|
+
// ── Queries ───────────────────────────────────────────────────────────────
|
|
4610
|
+
get(id) {
|
|
4611
|
+
return this._forwards.find((f) => f.id === id);
|
|
4612
|
+
}
|
|
4613
|
+
getAll(status) {
|
|
4614
|
+
return status ? this._forwards.filter((f) => f.status === status) : [...this._forwards];
|
|
4615
|
+
}
|
|
4616
|
+
getExpiring(withinMs, asOf = Date.now()) {
|
|
4617
|
+
return this._forwards.filter((f) => f.status === "open" && f.maturityDate <= asOf + withinMs && f.maturityDate > asOf);
|
|
4618
|
+
}
|
|
4619
|
+
getSettlements() {
|
|
4620
|
+
return [...this._settlements];
|
|
4621
|
+
}
|
|
4622
|
+
getSettlement(forwardId) {
|
|
4623
|
+
return this._settlements.find((s) => s.forwardId === forwardId);
|
|
4624
|
+
}
|
|
4625
|
+
getExposure(currency, type) {
|
|
4626
|
+
const open = this._forwards.filter(
|
|
4627
|
+
(f) => f.status === "open" && (f.baseCurrency === currency || f.quoteCurrency === currency) && (type === void 0 || f.type === type)
|
|
4628
|
+
);
|
|
4629
|
+
const map = /* @__PURE__ */ new Map();
|
|
4630
|
+
for (const f of open) {
|
|
4631
|
+
const key = `${currency}:${f.type}`;
|
|
4632
|
+
const cur = map.get(key) ?? { currency, type: f.type, totalNotional: 0, contractCount: 0 };
|
|
4633
|
+
cur.totalNotional += f.notionalAmount;
|
|
4634
|
+
cur.contractCount += 1;
|
|
4635
|
+
map.set(key, cur);
|
|
4636
|
+
}
|
|
4637
|
+
return Array.from(map.values());
|
|
4638
|
+
}
|
|
4639
|
+
getTotalPnL() {
|
|
4640
|
+
return _round5(this._settlements.reduce((s, settlement) => s + settlement.pnl, 0), 2);
|
|
4641
|
+
}
|
|
4642
|
+
getMarkToMarket(id) {
|
|
4643
|
+
const fwd = this._get(id);
|
|
4644
|
+
if (fwd.status !== "open") return fwd.pnl ?? 0;
|
|
4645
|
+
const spot = this.getSpotRate(fwd.baseCurrency, fwd.quoteCurrency) ?? fwd.contractRate;
|
|
4646
|
+
return fwd.type === "sell" ? _round5((fwd.contractRate - spot) * fwd.notionalAmount, 2) : _round5((spot - fwd.contractRate) * fwd.notionalAmount, 2);
|
|
4647
|
+
}
|
|
4648
|
+
// ── Observable ───────────────────────────────────────────────────────────
|
|
4649
|
+
change$() {
|
|
4650
|
+
return this._change$;
|
|
4651
|
+
}
|
|
4652
|
+
_get(id) {
|
|
4653
|
+
const f = this._forwards.find((x) => x.id === id);
|
|
4654
|
+
if (!f) throw new Error(`Forward ${id} not found`);
|
|
4655
|
+
return f;
|
|
4656
|
+
}
|
|
4657
|
+
};
|
|
4658
|
+
|
|
4659
|
+
export { JoopBeneficiaryService, JoopBillPaymentService, JoopCardManagementService, JoopChequebookService, JoopDigitalWalletService, JoopDisputeService, JoopFraudDetection, JoopFxForwardService, JoopInsuranceService, JoopLedgerService, JoopLimitManagementService, JoopLoanServicingService, JoopMandateService, JoopNotificationCenterService, JoopOpenBankingClient, JoopPaymentOrchestrator, JoopPaymentUriService, JoopReceiptService, JoopReconciliationService, JoopRemittanceService, JoopScheduledPaymentService, JoopSplitPaymentService, JoopStandingOrderService, JoopStatementGeneratorService, JoopStatementParser, JoopVirtualAccountService, clampAmount, convertCurrency, formatAmount, formatCard, formatCardNumber, formatCurrency, formatIban, formatMyKad, fromMinorUnits, generateBatchId, generateReferenceNumber, generateTransactionId, getCardInfo, getCardNetwork, getCurrencySymbol, getIbanCountry, getSupportedIbanCountries, isCardExpired, isValidAmount, isValidTransactionId, luhnCheckDigit, maskAccountNumber, maskCard, maskEmail, maskIban, maskInterleaved, maskKeepMiddle, maskKeepSeparators, maskMyKad, maskName, maskNric, maskPattern, maskPhone, maskRange, maskShowFirst, maskShowLast, parseCurrencyString, parseIban, parseTransactionId, roundAmount, safeAdd, safeDivide, safeMultiply, safeSubtract, sumAmounts, toMinorUnits, validateCvv, validateIban, validateLuhn, validateMyKad, validateNric };
|
|
4660
|
+
//# sourceMappingURL=index.mjs.map
|
|
4661
|
+
//# sourceMappingURL=index.mjs.map
|