cbpr-usage-rules 0.1.0__py3-none-any.whl
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.
- cbpr_rules/__init__.py +21 -0
- cbpr_rules/cli.py +176 -0
- cbpr_rules/engine.py +100 -0
- cbpr_rules/helpers.py +420 -0
- cbpr_rules/loader.py +77 -0
- cbpr_rules/message.py +170 -0
- cbpr_rules/models.py +83 -0
- cbpr_rules/py.typed +0 -0
- cbpr_rules/reference/__init__.py +9 -0
- cbpr_rules/reference/countries.py +28 -0
- cbpr_rules/reference/currencies.py +25 -0
- cbpr_rules/registry.py +107 -0
- cbpr_rules/rules/__init__.py +1 -0
- cbpr_rules/rules/y2025/__init__.py +1 -0
- cbpr_rules/rules/y2025/camt_052.py +224 -0
- cbpr_rules/rules/y2025/camt_054.py +176 -0
- cbpr_rules/rules/y2025/pacs_002.py +212 -0
- cbpr_rules/rules/y2025/pacs_004.py +831 -0
- cbpr_rules/rules/y2025/pacs_008.py +375 -0
- cbpr_rules/rules/y2025/pacs_008_stp.py +367 -0
- cbpr_rules/rules/y2025/pacs_009.py +273 -0
- cbpr_rules/rules/y2025/pacs_009_adv.py +255 -0
- cbpr_rules/rules/y2025/pacs_009_cov.py +358 -0
- cbpr_rules/rules/y2025/pain_001.py +306 -0
- cbpr_rules/rules/y2026/__init__.py +1 -0
- cbpr_rules/rules/y2026/camt_052.py +191 -0
- cbpr_rules/rules/y2026/camt_054.py +182 -0
- cbpr_rules/rules/y2026/pacs_002.py +208 -0
- cbpr_rules/rules/y2026/pacs_004.py +491 -0
- cbpr_rules/rules/y2026/pacs_008.py +377 -0
- cbpr_rules/rules/y2026/pacs_008_stp.py +369 -0
- cbpr_rules/rules/y2026/pacs_009.py +260 -0
- cbpr_rules/rules/y2026/pacs_009_adv.py +256 -0
- cbpr_rules/rules/y2026/pacs_009_cov.py +324 -0
- cbpr_rules/rules/y2026/pain_001.py +272 -0
- cbpr_rules/schema.py +97 -0
- cbpr_rules/validators/__init__.py +16 -0
- cbpr_rules/validators/bic.py +21 -0
- cbpr_rules/validators/country.py +11 -0
- cbpr_rules/validators/currency.py +11 -0
- cbpr_rules/validators/iban.py +26 -0
- cbpr_rules/validators/lei.py +17 -0
- cbpr_usage_rules-0.1.0.dist-info/METADATA +335 -0
- cbpr_usage_rules-0.1.0.dist-info/RECORD +48 -0
- cbpr_usage_rules-0.1.0.dist-info/WHEEL +5 -0
- cbpr_usage_rules-0.1.0.dist-info/entry_points.txt +2 -0
- cbpr_usage_rules-0.1.0.dist-info/licenses/LICENSE +21 -0
- cbpr_usage_rules-0.1.0.dist-info/top_level.txt +1 -0
cbpr_rules/helpers.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Reusable check builders for the recurring CBPR+ rule patterns.
|
|
2
|
+
|
|
3
|
+
Each builder returns a ``check(msg, report)`` function, so it can be registered
|
|
4
|
+
directly with the ``@rule`` decorator::
|
|
5
|
+
|
|
6
|
+
rule("pacs.008", 2025, "R20", NAME, DESC)(
|
|
7
|
+
presence_together(BASE, "Nm", "PstlAdr")
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
Hand-authored rules with bespoke logic just define ``fn(msg, report)`` directly
|
|
11
|
+
and use the query methods on ParsedMessage. These builders cover the common
|
|
12
|
+
templated patterns ("if X present then Y", "A must equal B", length/code lists)
|
|
13
|
+
so they need only be written once.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from decimal import Decimal, InvalidOperation
|
|
19
|
+
from typing import Callable, Iterable, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from lxml import etree
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def presence_together(base_path: str, a: str, b: str) -> Callable:
|
|
25
|
+
"""``a`` and ``b`` (relative to each ``base_path``) must be present together."""
|
|
26
|
+
|
|
27
|
+
def check(msg, report):
|
|
28
|
+
for ctx in msg.each(base_path):
|
|
29
|
+
has_a = msg.present(a, ctx)
|
|
30
|
+
has_b = msg.present(b, ctx)
|
|
31
|
+
if has_a != has_b:
|
|
32
|
+
report(ctx, detail=f"{a} and {b} must be present together")
|
|
33
|
+
|
|
34
|
+
return check
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def requires_if_present(base_path: str, trigger: str, required: str) -> Callable:
|
|
38
|
+
"""If ``trigger`` is present (per ``base_path``), ``required`` must be present."""
|
|
39
|
+
|
|
40
|
+
def check(msg, report):
|
|
41
|
+
for ctx in msg.each(base_path):
|
|
42
|
+
if msg.present(trigger, ctx) and msg.absent(required, ctx):
|
|
43
|
+
report(ctx, detail=f"{required} required when {trigger} is present")
|
|
44
|
+
|
|
45
|
+
return check
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def required_when_absent(
|
|
49
|
+
base_path: str, absent_path: str, required: Sequence[str], mode: str = "all"
|
|
50
|
+
) -> Callable:
|
|
51
|
+
"""When ``absent_path`` is absent, ``required`` paths must be present.
|
|
52
|
+
|
|
53
|
+
``mode="all"`` (default): every path in ``required`` must be present.
|
|
54
|
+
``mode="any"``: at least one of ``required`` must be present.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def check(msg, report):
|
|
58
|
+
for ctx in msg.each(base_path):
|
|
59
|
+
if not msg.absent(absent_path, ctx):
|
|
60
|
+
continue
|
|
61
|
+
present = [p for p in required if msg.present(p, ctx)]
|
|
62
|
+
ok = bool(present) if mode == "any" else len(present) == len(required)
|
|
63
|
+
if not ok:
|
|
64
|
+
joiner = " or " if mode == "any" else " and "
|
|
65
|
+
report(ctx, detail=f"{joiner.join(required)} required when {absent_path} is absent")
|
|
66
|
+
|
|
67
|
+
return check
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def required_when_present(
|
|
71
|
+
base_path: str, present_path: str, required: Sequence[str], mode: str = "all"
|
|
72
|
+
) -> Callable:
|
|
73
|
+
"""When ``present_path`` is present, ``required`` paths must be present too."""
|
|
74
|
+
|
|
75
|
+
def check(msg, report):
|
|
76
|
+
for ctx in msg.each(base_path):
|
|
77
|
+
if not msg.present(present_path, ctx):
|
|
78
|
+
continue
|
|
79
|
+
found = [p for p in required if msg.present(p, ctx)]
|
|
80
|
+
ok = bool(found) if mode == "any" else len(found) == len(required)
|
|
81
|
+
if not ok:
|
|
82
|
+
joiner = " or " if mode == "any" else " and "
|
|
83
|
+
report(ctx, detail=f"{joiner.join(required)} required when {present_path} is present")
|
|
84
|
+
|
|
85
|
+
return check
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mutually_exclusive(base_path: str, paths: Sequence[str]) -> Callable:
|
|
89
|
+
"""At most one of ``paths`` (relative to ``base_path``) may be present."""
|
|
90
|
+
|
|
91
|
+
def check(msg, report):
|
|
92
|
+
for ctx in msg.each(base_path):
|
|
93
|
+
present = [p for p in paths if msg.present(p, ctx)]
|
|
94
|
+
if len(present) > 1:
|
|
95
|
+
report(ctx, detail=f"mutually exclusive: {', '.join(present)}")
|
|
96
|
+
|
|
97
|
+
return check
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def forbidden_when_present(base_path: str, forbidden: str, when: str) -> Callable:
|
|
101
|
+
"""``forbidden`` must not be present when ``when`` is present."""
|
|
102
|
+
|
|
103
|
+
def check(msg, report):
|
|
104
|
+
for ctx in msg.each(base_path):
|
|
105
|
+
if msg.present(when, ctx) and msg.present(forbidden, ctx):
|
|
106
|
+
report(ctx, detail=f"{forbidden} cannot be present when {when} is present")
|
|
107
|
+
|
|
108
|
+
return check
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def value_not_in(path: str, forbidden_values: Iterable[str]) -> Callable:
|
|
112
|
+
"""No occurrence of ``path`` may hold a value in ``forbidden_values``."""
|
|
113
|
+
forbidden = set(forbidden_values)
|
|
114
|
+
|
|
115
|
+
def check(msg, report):
|
|
116
|
+
for node in msg.find(path):
|
|
117
|
+
val = msg.text_of(node)
|
|
118
|
+
if val in forbidden:
|
|
119
|
+
report(node, detail=f"'{val}' is not allowed")
|
|
120
|
+
|
|
121
|
+
return check
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def not_matching_pattern(path: str, pattern: str) -> Callable:
|
|
125
|
+
"""Every occurrence of ``path`` must NOT match ``pattern`` (a forbidden regex)."""
|
|
126
|
+
import re as _re
|
|
127
|
+
|
|
128
|
+
rx = _re.compile(pattern)
|
|
129
|
+
|
|
130
|
+
def check(msg, report):
|
|
131
|
+
for node in msg.find(path):
|
|
132
|
+
val = msg.text_of(node)
|
|
133
|
+
if val and rx.fullmatch(val):
|
|
134
|
+
report(node, detail="value matches a forbidden pattern")
|
|
135
|
+
|
|
136
|
+
return check
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Structured PostalAddress components (short ISO tags), excluding AddressLine.
|
|
140
|
+
ADDRESS_COMPONENTS = (
|
|
141
|
+
"Dept", "SubDept", "StrtNm", "BldgNb", "BldgNm", "Flr", "PstBx", "Room",
|
|
142
|
+
"PstCd", "TwnNm", "TwnLctnNm", "DstrctNm", "CtrySubDvsn", "Ctry",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def address_lines_max_length(postal_path: str, limit: int = 35) -> Callable:
|
|
147
|
+
"""CBPR+ "unstructured address" rule.
|
|
148
|
+
|
|
149
|
+
When a postal address uses only AddressLine (every structured component
|
|
150
|
+
absent), each AddressLine must not exceed ``limit`` characters.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def check(msg, report):
|
|
154
|
+
for adr in msg.each(postal_path):
|
|
155
|
+
if msg.absent("AdrLine", adr):
|
|
156
|
+
continue
|
|
157
|
+
if any(msg.present(s, adr) for s in ADDRESS_COMPONENTS):
|
|
158
|
+
continue
|
|
159
|
+
for line in msg.find("AdrLine", adr):
|
|
160
|
+
if len(msg.text_of(line)) > limit:
|
|
161
|
+
report(line, detail=f"AddressLine exceeds {limit} characters")
|
|
162
|
+
|
|
163
|
+
return check
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def address_hybrid(postal_path: str) -> Callable:
|
|
167
|
+
"""CBPR+ "hybrid address" rule.
|
|
168
|
+
|
|
169
|
+
When AddressLine and any structured component are both present, TownName and
|
|
170
|
+
Country are mandatory.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def check(msg, report):
|
|
174
|
+
for adr in msg.each(postal_path):
|
|
175
|
+
if msg.absent("AdrLine", adr):
|
|
176
|
+
continue
|
|
177
|
+
if not any(msg.present(s, adr) for s in ADDRESS_COMPONENTS):
|
|
178
|
+
continue
|
|
179
|
+
if not (msg.present("TwnNm", adr) and msg.present("Ctry", adr)):
|
|
180
|
+
report(adr, detail="TownName and Country required for a hybrid address")
|
|
181
|
+
|
|
182
|
+
return check
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def same_value(
|
|
186
|
+
path_a: str,
|
|
187
|
+
path_b: str,
|
|
188
|
+
unless_path: Optional[str] = None,
|
|
189
|
+
unless_values: Sequence[str] = (),
|
|
190
|
+
) -> Callable:
|
|
191
|
+
"""Every occurrence of ``path_a`` must equal every occurrence of ``path_b``.
|
|
192
|
+
|
|
193
|
+
Skipped when ``unless_path`` has any value in ``unless_values`` (e.g. a
|
|
194
|
+
CopyDuplicate of COPY/CODU). Both anchored (absolute) paths.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def check(msg, report):
|
|
198
|
+
if unless_path is not None and unless_values:
|
|
199
|
+
if any(v in set(unless_values) for v in msg.values(unless_path)):
|
|
200
|
+
return
|
|
201
|
+
a_nodes = msg.find(path_a)
|
|
202
|
+
b_vals = {msg.text_of(n) for n in msg.find(path_b)}
|
|
203
|
+
a_vals = {msg.text_of(n) for n in a_nodes}
|
|
204
|
+
if not a_nodes or not b_vals:
|
|
205
|
+
return
|
|
206
|
+
if a_vals != b_vals:
|
|
207
|
+
target = a_nodes[0]
|
|
208
|
+
report(target, detail=f"{path_a} must equal {path_b}")
|
|
209
|
+
|
|
210
|
+
return check
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def max_length(path: str, limit: int) -> Callable:
|
|
214
|
+
"""Every occurrence of ``path`` must not exceed ``limit`` characters."""
|
|
215
|
+
|
|
216
|
+
def check(msg, report):
|
|
217
|
+
for node in msg.find(path):
|
|
218
|
+
if len(msg.text_of(node)) > limit:
|
|
219
|
+
report(node, detail=f"exceeds {limit} characters")
|
|
220
|
+
|
|
221
|
+
return check
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def code_in(path: str, allowed: Iterable[str]) -> Callable:
|
|
225
|
+
"""Every occurrence of ``path`` must hold a value within ``allowed``."""
|
|
226
|
+
allowed_set = set(allowed)
|
|
227
|
+
|
|
228
|
+
def check(msg, report):
|
|
229
|
+
for node in msg.find(path):
|
|
230
|
+
val = msg.text_of(node)
|
|
231
|
+
if val and val not in allowed_set:
|
|
232
|
+
report(node, detail=f"'{val}' not in allowed code list")
|
|
233
|
+
|
|
234
|
+
return check
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def must_be_absent(path: str) -> Callable:
|
|
238
|
+
"""``path`` must not be present (element removed by the usage guideline)."""
|
|
239
|
+
|
|
240
|
+
def check(msg, report):
|
|
241
|
+
for node in msg.find(path):
|
|
242
|
+
report(node, detail="element must not be used")
|
|
243
|
+
|
|
244
|
+
return check
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def each_value_valid(path: str, validator: Callable[[str], bool], label: str) -> Callable:
|
|
248
|
+
"""Every non-empty value at ``path`` must satisfy ``validator`` (e.g. IBAN/BIC)."""
|
|
249
|
+
|
|
250
|
+
def check(msg, report):
|
|
251
|
+
for node in msg.find(path):
|
|
252
|
+
val = msg.text_of(node)
|
|
253
|
+
if val and not validator(val):
|
|
254
|
+
report(node, detail=f"invalid {label}: '{val}'")
|
|
255
|
+
|
|
256
|
+
return check
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Cross-cutting checks promoted from advisory rules (Tier A / Tier B).
|
|
261
|
+
# Each is conservative: it skips when its inputs are absent or ambiguous, so a
|
|
262
|
+
# previously-valid message can never be made to fail spuriously.
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def header_msg_def_id_matches() -> Callable:
|
|
266
|
+
"""``/AppHdr/MsgDefIdr``, if present, must equal the Document's definition id.
|
|
267
|
+
|
|
268
|
+
The expected id is the namespace suffix of the ``<Document>`` element, e.g.
|
|
269
|
+
``pacs.009.001.08``. Cross-schema (BAH vs Document); skips if either absent.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def check(msg, report):
|
|
273
|
+
if msg.bah is None or msg.document is None:
|
|
274
|
+
return
|
|
275
|
+
nodes = msg.find("/AppHdr/MsgDefIdr")
|
|
276
|
+
if not nodes:
|
|
277
|
+
return
|
|
278
|
+
ns = etree.QName(msg.document).namespace or ""
|
|
279
|
+
marker = "tech:xsd:"
|
|
280
|
+
expected = ns.split(marker, 1)[1] if marker in ns else None
|
|
281
|
+
val = msg.text_of(nodes[0])
|
|
282
|
+
if expected and val and val != expected:
|
|
283
|
+
report(nodes[0], detail=f"MsgDefIdr '{val}' does not match the message definition '{expected}'")
|
|
284
|
+
|
|
285
|
+
return check
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def business_msg_id_carries_group_id() -> Callable:
|
|
289
|
+
"""``/AppHdr/BizMsgIdr`` must contain the Document's ``GrpHdr/MsgId`` when present."""
|
|
290
|
+
|
|
291
|
+
def check(msg, report):
|
|
292
|
+
if msg.bah is None or msg.document is None:
|
|
293
|
+
return
|
|
294
|
+
bmi = msg.find("/AppHdr/BizMsgIdr")
|
|
295
|
+
if not bmi:
|
|
296
|
+
return
|
|
297
|
+
msgid = None
|
|
298
|
+
for grp in msg.iter_local("GrpHdr"):
|
|
299
|
+
kids = msg.find("MsgId", grp)
|
|
300
|
+
if kids:
|
|
301
|
+
msgid = msg.text_of(kids[0])
|
|
302
|
+
break
|
|
303
|
+
if not msgid:
|
|
304
|
+
return
|
|
305
|
+
if msgid not in msg.text_of(bmi[0]):
|
|
306
|
+
report(bmi[0], detail=f"BizMsgIdr should carry the GroupHeader MsgId '{msgid}'")
|
|
307
|
+
|
|
308
|
+
return check
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _token_contained(value: str, line: str) -> bool:
|
|
312
|
+
"""True if ``value`` equals ``line`` or appears token-bounded within it."""
|
|
313
|
+
if value == line:
|
|
314
|
+
return True
|
|
315
|
+
return re.search(r"(?<![A-Za-z0-9])" + re.escape(value) + r"(?![A-Za-z0-9])", line) is not None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def no_postal_address_duplication(min_len: int = 3) -> Callable:
|
|
319
|
+
"""No structured Postal Address value may be repeated inside an AddressLine.
|
|
320
|
+
|
|
321
|
+
Scans every ``PstlAdr`` in the message. Conservative: only flags structured
|
|
322
|
+
values of at least ``min_len`` characters that appear as a whole AddressLine
|
|
323
|
+
or as a token-bounded substring of one.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def check(msg, report):
|
|
327
|
+
for adr in msg.iter_local("PstlAdr"):
|
|
328
|
+
lines = [msg.text_of(line) for line in msg.find("AdrLine", adr)]
|
|
329
|
+
if not lines:
|
|
330
|
+
continue
|
|
331
|
+
for comp in ADDRESS_COMPONENTS:
|
|
332
|
+
for node in msg.find(comp, adr):
|
|
333
|
+
val = msg.text_of(node)
|
|
334
|
+
if len(val) < min_len:
|
|
335
|
+
continue
|
|
336
|
+
if any(_token_contained(val, line) for line in lines):
|
|
337
|
+
report(node, detail=f"structured value '{val}' is duplicated in AddressLine")
|
|
338
|
+
|
|
339
|
+
return check
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def bic_presence_exclusive(party_path: str) -> Callable:
|
|
343
|
+
"""For a party: if ``Id/OrgId/AnyBIC`` is present, ``Nm`` and ``PstlAdr`` are not allowed."""
|
|
344
|
+
|
|
345
|
+
def check(msg, report):
|
|
346
|
+
for party in msg.each(party_path):
|
|
347
|
+
if msg.present("Id/OrgId/AnyBIC", party) and (
|
|
348
|
+
msg.present("Nm", party) or msg.present("PstlAdr", party)
|
|
349
|
+
):
|
|
350
|
+
report(party, detail="Name/PostalAddress not allowed when AnyBIC is present")
|
|
351
|
+
|
|
352
|
+
return check
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def structured_remittance_max_total(path: str, limit: int = 9000) -> Callable:
|
|
356
|
+
"""Total text (excluding tags) of all Structured Remittance occurrences ≤ ``limit``."""
|
|
357
|
+
|
|
358
|
+
def check(msg, report):
|
|
359
|
+
nodes = msg.find(path)
|
|
360
|
+
if not nodes:
|
|
361
|
+
return
|
|
362
|
+
total = sum(
|
|
363
|
+
len("".join(t.strip() for t in node.itertext())) for node in nodes
|
|
364
|
+
)
|
|
365
|
+
if total > limit:
|
|
366
|
+
report(nodes[0], detail=f"structured remittance total {total} exceeds {limit} characters")
|
|
367
|
+
|
|
368
|
+
return check
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def charges_required_when_amounts_differ(
|
|
372
|
+
base_path: str, instructed_amt: str, settlement_amt: str, charges: str
|
|
373
|
+
) -> Callable:
|
|
374
|
+
"""If instructed & settlement amounts share a currency and differ, charges are mandatory.
|
|
375
|
+
|
|
376
|
+
All sub-paths are relative to each ``base_path`` (e.g. the transaction).
|
|
377
|
+
Skips unless both amounts are present with the same ``@Ccy`` and parse cleanly.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
def check(msg, report):
|
|
381
|
+
for ctx in msg.each(base_path):
|
|
382
|
+
inst = msg.find(instructed_amt, ctx)
|
|
383
|
+
sett = msg.find(settlement_amt, ctx)
|
|
384
|
+
if not inst or not sett:
|
|
385
|
+
continue
|
|
386
|
+
i_ccy, s_ccy = inst[0].get("Ccy"), sett[0].get("Ccy")
|
|
387
|
+
if not i_ccy or i_ccy != s_ccy:
|
|
388
|
+
continue
|
|
389
|
+
try:
|
|
390
|
+
iv = Decimal(msg.text_of(inst[0]))
|
|
391
|
+
sv = Decimal(msg.text_of(sett[0]))
|
|
392
|
+
except (InvalidOperation, ValueError):
|
|
393
|
+
continue
|
|
394
|
+
if iv != sv and msg.absent(charges, ctx):
|
|
395
|
+
report(ctx, detail="ChargesInformation is mandatory when instructed and settlement amounts differ in the same currency")
|
|
396
|
+
|
|
397
|
+
return check
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def amount_equals_sum(total_path: str, parts_path: str, tolerance: str = "0.01") -> Callable:
|
|
401
|
+
"""``total_path`` amount must equal the sum of ``parts_path`` amounts (same currency)."""
|
|
402
|
+
tol = Decimal(tolerance)
|
|
403
|
+
|
|
404
|
+
def check(msg, report):
|
|
405
|
+
totals = msg.find(total_path)
|
|
406
|
+
parts = msg.find(parts_path)
|
|
407
|
+
if not totals or not parts:
|
|
408
|
+
return
|
|
409
|
+
ccys = {n.get("Ccy") for n in [totals[0], *parts] if n.get("Ccy")}
|
|
410
|
+
if len(ccys) > 1:
|
|
411
|
+
return
|
|
412
|
+
try:
|
|
413
|
+
total = Decimal(msg.text_of(totals[0]))
|
|
414
|
+
summed = sum((Decimal(msg.text_of(p)) for p in parts), Decimal("0"))
|
|
415
|
+
except (InvalidOperation, ValueError):
|
|
416
|
+
return
|
|
417
|
+
if abs(total - summed) > tol:
|
|
418
|
+
report(totals[0], detail=f"total {total} does not equal the sum of records {summed}")
|
|
419
|
+
|
|
420
|
+
return check
|
cbpr_rules/loader.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Parse CBPR+ XML and locate the Business Application Header and Document.
|
|
2
|
+
|
|
3
|
+
The loader is deliberately tolerant of wrapper tags (``<RequestPayload>``,
|
|
4
|
+
``<DataPDU>``, ``<Saa:Envelope>`` ...): it finds the ``AppHdr`` (head.001) and
|
|
5
|
+
``Document`` elements wherever they sit in the tree, matching by *local name* so
|
|
6
|
+
namespace prefixes and versions never matter.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from lxml import etree
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def local_name(el) -> Optional[str]:
|
|
16
|
+
"""Local (namespace-stripped) tag name of an element, or None for comments/PIs."""
|
|
17
|
+
tag = el.tag
|
|
18
|
+
if not isinstance(tag, str):
|
|
19
|
+
return None
|
|
20
|
+
return tag.rsplit("}", 1)[-1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parser() -> "etree.XMLParser":
|
|
24
|
+
# huge_tree for large messages; keep line numbers; never touch the network.
|
|
25
|
+
return etree.XMLParser(
|
|
26
|
+
resolve_entities=False,
|
|
27
|
+
no_network=True,
|
|
28
|
+
huge_tree=True,
|
|
29
|
+
remove_comments=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_file(path: str) -> "etree._ElementTree":
|
|
34
|
+
return etree.parse(path, _parser())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_string(xml: str) -> "etree._ElementTree":
|
|
38
|
+
data = xml.encode("utf-8") if isinstance(xml, str) else xml
|
|
39
|
+
root = etree.fromstring(data, _parser())
|
|
40
|
+
return etree.ElementTree(root)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def locate(tree: "etree._ElementTree") -> Tuple[Optional["etree._Element"], Optional["etree._Element"]]:
|
|
44
|
+
"""Return (app_header, document) elements, found anywhere under the root."""
|
|
45
|
+
root = tree.getroot()
|
|
46
|
+
bah = None
|
|
47
|
+
doc = None
|
|
48
|
+
for el in root.iter():
|
|
49
|
+
ln = local_name(el)
|
|
50
|
+
if ln == "AppHdr" and bah is None:
|
|
51
|
+
bah = el
|
|
52
|
+
elif ln == "Document" and doc is None:
|
|
53
|
+
doc = el
|
|
54
|
+
if bah is not None and doc is not None:
|
|
55
|
+
break
|
|
56
|
+
return bah, doc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_message_type(doc: Optional["etree._Element"]) -> Optional[str]:
|
|
60
|
+
"""Derive the base message type (e.g. ``pacs.008``) from the Document namespace.
|
|
61
|
+
|
|
62
|
+
Note: business variants (STP/COV/ADV) share a base namespace and cannot be
|
|
63
|
+
distinguished from the XML alone - callers select those explicitly.
|
|
64
|
+
"""
|
|
65
|
+
if doc is None:
|
|
66
|
+
return None
|
|
67
|
+
qname = etree.QName(doc)
|
|
68
|
+
ns = qname.namespace or ""
|
|
69
|
+
marker = "tech:xsd:"
|
|
70
|
+
if marker in ns:
|
|
71
|
+
ident = ns.split(marker, 1)[1] # e.g. pacs.008.001.08
|
|
72
|
+
else:
|
|
73
|
+
ident = ns.rsplit(":", 1)[-1]
|
|
74
|
+
parts = ident.split(".")
|
|
75
|
+
if len(parts) >= 2:
|
|
76
|
+
return f"{parts[0]}.{parts[1]}" # pacs.008
|
|
77
|
+
return ident or None
|
cbpr_rules/message.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""ParsedMessage - namespace-agnostic path access over a CBPR+ message.
|
|
2
|
+
|
|
3
|
+
Rules are authored against the short ISO 20022 XML-tag paths exactly as they
|
|
4
|
+
appear in the published usage guideline's "XML Path" column, e.g.::
|
|
5
|
+
|
|
6
|
+
/AppHdr/Fr/FIId/FinInstnId/BICFI
|
|
7
|
+
/Document/FIToFICstmrCdtTrf/CdtTrfTxInf/PmtTpInf/InstrPrty
|
|
8
|
+
|
|
9
|
+
Paths are matched segment-by-segment by *local name*, so namespaces, prefixes
|
|
10
|
+
and wrapper tags are irrelevant. A path beginning with ``AppHdr`` is anchored at
|
|
11
|
+
the Business Application Header; one beginning with ``Document`` at the Document.
|
|
12
|
+
Any other path is treated as relative to a ``context`` element (used inside
|
|
13
|
+
"for each" iterations); a leading segment equal to the context's own name is
|
|
14
|
+
consumed so relative paths can restate the context name, matching the source
|
|
15
|
+
pseudo-code convention.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
from .loader import local_name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _split(path: str) -> List[str]:
|
|
25
|
+
return [s for s in (seg.strip() for seg in path.strip().strip("/").split("/")) if s]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _children_named(el, name: str) -> List["object"]:
|
|
29
|
+
out = []
|
|
30
|
+
for child in el:
|
|
31
|
+
if local_name(child) == name:
|
|
32
|
+
out.append(child)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _descend(elements: List["object"], segs: List[str]) -> List["object"]:
|
|
37
|
+
current = list(elements)
|
|
38
|
+
for seg in segs:
|
|
39
|
+
nxt: List[object] = []
|
|
40
|
+
for el in current:
|
|
41
|
+
nxt.extend(_children_named(el, seg))
|
|
42
|
+
current = nxt
|
|
43
|
+
if not current:
|
|
44
|
+
break
|
|
45
|
+
return current
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ParsedMessage:
|
|
49
|
+
"""A parsed CBPR+ message exposing the BAH and Document for rule checks."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, tree, bah, document, message_type=None, year=None):
|
|
52
|
+
self._tree = tree
|
|
53
|
+
self.bah = bah
|
|
54
|
+
self.document = document
|
|
55
|
+
self.message_type = message_type
|
|
56
|
+
self.year = year
|
|
57
|
+
|
|
58
|
+
# -- element metadata ------------------------------------------------
|
|
59
|
+
def xpath_of(self, el) -> str:
|
|
60
|
+
"""A readable local-name xpath, e.g. ``/Document/.../IntrBkSttlmAmt``.
|
|
61
|
+
|
|
62
|
+
Positional ``[n]`` is added only where same-named siblings exist, so
|
|
63
|
+
paths stay clean but remain unambiguous for repeated elements.
|
|
64
|
+
"""
|
|
65
|
+
if el is None:
|
|
66
|
+
return ""
|
|
67
|
+
steps = []
|
|
68
|
+
node = el
|
|
69
|
+
while node is not None and isinstance(node.tag, str):
|
|
70
|
+
name = local_name(node)
|
|
71
|
+
parent = node.getparent()
|
|
72
|
+
if parent is not None:
|
|
73
|
+
same = [c for c in parent if local_name(c) == name]
|
|
74
|
+
if len(same) > 1:
|
|
75
|
+
name = f"{name}[{same.index(node) + 1}]"
|
|
76
|
+
steps.append(name)
|
|
77
|
+
node = parent
|
|
78
|
+
return "/" + "/".join(reversed(steps))
|
|
79
|
+
|
|
80
|
+
def line_of(self, el) -> Optional[int]:
|
|
81
|
+
if el is None:
|
|
82
|
+
return None
|
|
83
|
+
return getattr(el, "sourceline", None)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def text_of(el) -> str:
|
|
87
|
+
if el is None:
|
|
88
|
+
return ""
|
|
89
|
+
return (el.text or "").strip()
|
|
90
|
+
|
|
91
|
+
def snippet_of(self, el, limit: int = 160) -> str:
|
|
92
|
+
"""A short, namespace-stripped view of the offending element.
|
|
93
|
+
|
|
94
|
+
Leaf elements render as ``<Tag attr="v">text</Tag>``; container
|
|
95
|
+
elements render as ``<Tag> containing {Child1, Child2, ...}`` so the
|
|
96
|
+
user can see what the file actually has at that location.
|
|
97
|
+
"""
|
|
98
|
+
if el is None or not isinstance(el.tag, str):
|
|
99
|
+
return ""
|
|
100
|
+
name = local_name(el)
|
|
101
|
+
attrs = " ".join(
|
|
102
|
+
f'{k.rsplit("}", 1)[-1]}="{v}"' for k, v in el.attrib.items()
|
|
103
|
+
)
|
|
104
|
+
head = name + (" " + attrs if attrs else "")
|
|
105
|
+
children = [local_name(c) for c in el if isinstance(c.tag, str)]
|
|
106
|
+
if children:
|
|
107
|
+
shown = ", ".join(children[:8]) + (", ..." if len(children) > 8 else "")
|
|
108
|
+
out = f"<{head}> containing {{{shown}}}"
|
|
109
|
+
else:
|
|
110
|
+
text = self.text_of(el)
|
|
111
|
+
out = f"<{head}>{text}</{name}>"
|
|
112
|
+
return out if len(out) <= limit else out[: limit - 1] + "…"
|
|
113
|
+
|
|
114
|
+
# -- path queries ----------------------------------------------------
|
|
115
|
+
def find(self, path: str, context=None) -> List["object"]:
|
|
116
|
+
"""Return all elements matching ``path`` (anchored or relative)."""
|
|
117
|
+
segs = _split(path)
|
|
118
|
+
if not segs:
|
|
119
|
+
return [context] if context is not None else []
|
|
120
|
+
head = segs[0]
|
|
121
|
+
if head == "AppHdr" or head.startswith("BusinessApplicationHeader"):
|
|
122
|
+
return _descend([self.bah], segs[1:]) if self.bah is not None else []
|
|
123
|
+
if head == "Document":
|
|
124
|
+
return _descend([self.document], segs[1:]) if self.document is not None else []
|
|
125
|
+
# relative to context
|
|
126
|
+
if context is not None:
|
|
127
|
+
if local_name(context) == head:
|
|
128
|
+
return _descend([context], segs[1:])
|
|
129
|
+
return _descend([context], segs)
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
def each(self, path: str, context=None) -> List["object"]:
|
|
133
|
+
"""Iteration helper - same as find(), named for "for each [path]" rules."""
|
|
134
|
+
return self.find(path, context)
|
|
135
|
+
|
|
136
|
+
def first(self, path: str, context=None):
|
|
137
|
+
matches = self.find(path, context)
|
|
138
|
+
return matches[0] if matches else None
|
|
139
|
+
|
|
140
|
+
def present(self, path: str, context=None) -> bool:
|
|
141
|
+
return bool(self.find(path, context))
|
|
142
|
+
|
|
143
|
+
def absent(self, path: str, context=None) -> bool:
|
|
144
|
+
return not self.present(path, context)
|
|
145
|
+
|
|
146
|
+
def values(self, path: str, context=None) -> List[str]:
|
|
147
|
+
"""Stripped text content of every element matching ``path``."""
|
|
148
|
+
return [self.text_of(el) for el in self.find(path, context)]
|
|
149
|
+
|
|
150
|
+
def attr_nodes(self, path: str, attr: str, context=None):
|
|
151
|
+
"""(element, attribute value) for every element matching ``path``.
|
|
152
|
+
|
|
153
|
+
Used for XML attributes such as the currency on an amount (``@Ccy``).
|
|
154
|
+
"""
|
|
155
|
+
return [(el, el.get(attr)) for el in self.find(path, context)]
|
|
156
|
+
|
|
157
|
+
def iter_local(self, name: str, roots=None):
|
|
158
|
+
"""Yield every descendant (at any depth) with local tag ``name``.
|
|
159
|
+
|
|
160
|
+
Scans the BAH and Document by default - used by cross-cutting checks
|
|
161
|
+
that apply wherever an element occurs (e.g. every PostalAddress).
|
|
162
|
+
"""
|
|
163
|
+
if roots is None:
|
|
164
|
+
roots = [r for r in (self.bah, self.document) if r is not None]
|
|
165
|
+
for root in roots:
|
|
166
|
+
if root is None:
|
|
167
|
+
continue
|
|
168
|
+
for el in root.iter():
|
|
169
|
+
if local_name(el) == name:
|
|
170
|
+
yield el
|