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.
Files changed (48) hide show
  1. cbpr_rules/__init__.py +21 -0
  2. cbpr_rules/cli.py +176 -0
  3. cbpr_rules/engine.py +100 -0
  4. cbpr_rules/helpers.py +420 -0
  5. cbpr_rules/loader.py +77 -0
  6. cbpr_rules/message.py +170 -0
  7. cbpr_rules/models.py +83 -0
  8. cbpr_rules/py.typed +0 -0
  9. cbpr_rules/reference/__init__.py +9 -0
  10. cbpr_rules/reference/countries.py +28 -0
  11. cbpr_rules/reference/currencies.py +25 -0
  12. cbpr_rules/registry.py +107 -0
  13. cbpr_rules/rules/__init__.py +1 -0
  14. cbpr_rules/rules/y2025/__init__.py +1 -0
  15. cbpr_rules/rules/y2025/camt_052.py +224 -0
  16. cbpr_rules/rules/y2025/camt_054.py +176 -0
  17. cbpr_rules/rules/y2025/pacs_002.py +212 -0
  18. cbpr_rules/rules/y2025/pacs_004.py +831 -0
  19. cbpr_rules/rules/y2025/pacs_008.py +375 -0
  20. cbpr_rules/rules/y2025/pacs_008_stp.py +367 -0
  21. cbpr_rules/rules/y2025/pacs_009.py +273 -0
  22. cbpr_rules/rules/y2025/pacs_009_adv.py +255 -0
  23. cbpr_rules/rules/y2025/pacs_009_cov.py +358 -0
  24. cbpr_rules/rules/y2025/pain_001.py +306 -0
  25. cbpr_rules/rules/y2026/__init__.py +1 -0
  26. cbpr_rules/rules/y2026/camt_052.py +191 -0
  27. cbpr_rules/rules/y2026/camt_054.py +182 -0
  28. cbpr_rules/rules/y2026/pacs_002.py +208 -0
  29. cbpr_rules/rules/y2026/pacs_004.py +491 -0
  30. cbpr_rules/rules/y2026/pacs_008.py +377 -0
  31. cbpr_rules/rules/y2026/pacs_008_stp.py +369 -0
  32. cbpr_rules/rules/y2026/pacs_009.py +260 -0
  33. cbpr_rules/rules/y2026/pacs_009_adv.py +256 -0
  34. cbpr_rules/rules/y2026/pacs_009_cov.py +324 -0
  35. cbpr_rules/rules/y2026/pain_001.py +272 -0
  36. cbpr_rules/schema.py +97 -0
  37. cbpr_rules/validators/__init__.py +16 -0
  38. cbpr_rules/validators/bic.py +21 -0
  39. cbpr_rules/validators/country.py +11 -0
  40. cbpr_rules/validators/currency.py +11 -0
  41. cbpr_rules/validators/iban.py +26 -0
  42. cbpr_rules/validators/lei.py +17 -0
  43. cbpr_usage_rules-0.1.0.dist-info/METADATA +335 -0
  44. cbpr_usage_rules-0.1.0.dist-info/RECORD +48 -0
  45. cbpr_usage_rules-0.1.0.dist-info/WHEEL +5 -0
  46. cbpr_usage_rules-0.1.0.dist-info/entry_points.txt +2 -0
  47. cbpr_usage_rules-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. cbpr_usage_rules-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,369 @@
1
+ """CBPR+ SR2026 usage rules for pacs.008.001.08 STP (FIToFICustomerCreditTransfer).
2
+
3
+ Structure mirrors the reference module ``rules.y2025.pacs_008``: each Rules-sheet
4
+ R-index is registered with its real rule number, name token and description, using
5
+ shared combinators where the formal logic matches a known shape and bespoke
6
+ ``fn(msg, report)`` checks otherwise. Algorithmic field validations are added as
7
+ extra VAL-* rules for the data types that appear in this message.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from ...registry import advisory, rule
14
+ from ...validators import is_valid_bic, is_valid_country, is_valid_currency, is_valid_lei
15
+ from ...helpers import (
16
+ bic_presence_exclusive,
17
+ business_msg_id_carries_group_id,
18
+ charges_required_when_amounts_differ,
19
+ each_value_valid,
20
+ header_msg_def_id_matches,
21
+ mutually_exclusive,
22
+ no_postal_address_duplication,
23
+ not_matching_pattern,
24
+ requires_if_present,
25
+ required_when_absent,
26
+ same_value,
27
+ )
28
+
29
+ MT = "pacs.008_stp"
30
+ YEAR = 2026
31
+ ROOT = "/Document/FIToFICstmrCdtTrf"
32
+ TX = ROOT + "/CdtTrfTxInf"
33
+
34
+ D_PARTY_NAME_ADR = "If Postal Address is present then Name is mandatory."
35
+ D_PARTY_ANY_BIC = (
36
+ "If AnyBIC is absent then Name is mandatory and it is recommended to also "
37
+ "provide the Postal Address."
38
+ )
39
+
40
+
41
+ def reg(number: str, name: str, description: str, check) -> None:
42
+ """Register a combinator-built check as a rule."""
43
+ rule(MT, YEAR, number, name, description)(check)
44
+
45
+
46
+ def _values_match(msg, report, path_a, path_b, label):
47
+ a_nodes = msg.find(path_a)
48
+ if not a_nodes:
49
+ return
50
+ b_vals = {msg.text_of(n) for n in msg.find(path_b)}
51
+ if not b_vals:
52
+ return
53
+ a_vals = {msg.text_of(n) for n in a_nodes}
54
+ if a_vals != b_vals:
55
+ report(a_nodes[0], detail=label)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # BAH / cross-schema consistency
60
+ # ---------------------------------------------------------------------------
61
+ reg("R1", "CBPR_BusinessMessageIdentifier_FormalRule",
62
+ "The Business Message Identifier must match the Message Identification in the Group Header.",
63
+ same_value("/AppHdr/BizMsgIdr", ROOT + "/GrpHdr/MsgId"))
64
+
65
+
66
+ @rule(MT, YEAR, "R2", "CBPR_Priority_Instruction_Priority_FormalRule",
67
+ 'If "Priority" is used in the BAH for pacs messages, the value should be '
68
+ 'identical to the one in the Payment Type Information/InstructionPriority if present.')
69
+ def _r2(msg, report):
70
+ if msg.present("/AppHdr/Prty") and msg.present(TX + "/PmtTpInf/InstrPrty"):
71
+ _values_match(msg, report, "/AppHdr/Prty", TX + "/PmtTpInf/InstrPrty",
72
+ "BAH Priority must equal InstructionPriority")
73
+
74
+
75
+ @rule(MT, YEAR, "R3", "CBPR_To_Instructed_Agent_BIC_2_FormalRule",
76
+ 'BAH "To" BIC must match "Instructed Agent" BIC if CopyDuplicate is absent.')
77
+ def _r3(msg, report):
78
+ if not msg.absent("/AppHdr/CpyDplct"):
79
+ return
80
+ _values_match(msg, report, "/AppHdr/To/FIId/FinInstnId/BICFI",
81
+ TX + "/InstdAgt/FinInstnId/BICFI", "To vs Instructed Agent")
82
+
83
+
84
+ @rule(MT, YEAR, "R4", "CBPR_To_Instructed_Agent_BIC_1_FormalRule",
85
+ 'BAH "To" BIC must match "Instructed Agent" BIC, except where BAH '
86
+ "CopyDuplicate = COPY or = CODU")
87
+ def _r4(msg, report):
88
+ if any(v in {"COPY", "CODU"} for v in msg.values("/AppHdr/CpyDplct")):
89
+ return
90
+ _values_match(msg, report, "/AppHdr/To/FIId/FinInstnId/BICFI",
91
+ TX + "/InstdAgt/FinInstnId/BICFI", "To vs Instructed Agent")
92
+
93
+
94
+ reg("R5", "CBPR_From_Instructing_Agent_BIC_FormalRule",
95
+ 'BAH "From" BIC must match "Instructing Agent" BIC',
96
+ same_value("/AppHdr/Fr/FIId/FinInstnId/BICFI", TX + "/InstgAgt/FinInstnId/BICFI"))
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Service level / remittance / charges
101
+ # ---------------------------------------------------------------------------
102
+ @rule(MT, YEAR, "R10", "CBPR_GPI_ServiceLevel_Code_FormalRule",
103
+ "The GPI ServiceLevel Code in pacs.008 STP must be 'G001'.")
104
+ def _r10(msg, report):
105
+ forbidden = {"G002", "G003", "G004", "G005", "G006", "G007", "G009"}
106
+ for doc in msg.each(ROOT):
107
+ if msg.absent("CdtTrfTxInf/PmtTpInf/SvcLvl", doc):
108
+ continue
109
+ for node in msg.find("CdtTrfTxInf/PmtTpInf/SvcLvl/Cd", doc):
110
+ if msg.text_of(node) in forbidden:
111
+ report(node, detail="ServiceLevel Code must be G001")
112
+
113
+
114
+ reg("R11", "CBPR_Related_Remit_Info_Remit_Info_Mutually_Exclusive_FormalRule",
115
+ "In the interbank space, Related Remittance Information and Remittance "
116
+ "Information are mutually exclusive and all may be absent.",
117
+ mutually_exclusive(TX, ["RltdRmtInf", "RmtInf"]))
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Domestic / jurisdiction country-couple rules (R12, R15-R18)
122
+ # ---------------------------------------------------------------------------
123
+ def _bic_country(bic: str) -> str:
124
+ return bic[4:6] if bic and len(bic) >= 6 else ""
125
+
126
+
127
+ def _domestic_name_iban(number: str, name: str, description: str, countries):
128
+ """Debtor/Creditor agent BICs both in `countries` => Debtor & Creditor Name +
129
+ Account/IBAN must be present."""
130
+ cset = set(countries)
131
+
132
+ @rule(MT, YEAR, number, name, description)
133
+ def _check(msg, report, _cset=cset):
134
+ for tx in msg.each(TX):
135
+ dbtr_bics = msg.values("DbtrAgt/FinInstnId/BICFI", tx)
136
+ cdtr_bics = msg.values("CdtrAgt/FinInstnId/BICFI", tx)
137
+ if not dbtr_bics or not cdtr_bics:
138
+ continue
139
+ if not all(_bic_country(b) in _cset for b in dbtr_bics):
140
+ continue
141
+ if not all(_bic_country(b) in _cset for b in cdtr_bics):
142
+ continue
143
+ required = {
144
+ "Debtor/Name": "Dbtr/Nm",
145
+ "Creditor/Name": "Cdtr/Nm",
146
+ "DebtorAccount/IBAN": "DbtrAcct/Id/IBAN",
147
+ "CreditorAccount/IBAN": "CdtrAcct/Id/IBAN",
148
+ }
149
+ missing = [lbl for lbl, p in required.items() if msg.absent(p, tx)]
150
+ if missing:
151
+ report(tx, detail="domestic transaction requires " + ", ".join(missing))
152
+
153
+
154
+ _SEPA = ["AT", "BE", "BG", "BV", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR",
155
+ "GB", "GF", "GI", "GP", "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT",
156
+ "LU", "LV", "MQ", "MT", "NL", "NO", "PL", "PM", "PT", "RE", "RO", "SE",
157
+ "SI", "SJ", "SK"]
158
+
159
+ _domestic_name_iban(
160
+ "R12", "CBPR_Debtor_Creditor_IBAN_FormalRule",
161
+ "IF Creditor Agent and Debtor Agent BICs are part of following countries: "
162
+ "AT, BE, BG, BV, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GF, GI, GP, GR, HR, HU, "
163
+ "IE, IS, IT, LI, LT, LU, LV, MQ (FR), MT, NL, NO, PL, PM (FR), PT, RE (FR), "
164
+ "RO, SE, SI, SJ, SK Then: Debtor and Creditor must be identified using a "
165
+ "Name and the Account/IBAN.",
166
+ _SEPA)
167
+
168
+ _domestic_name_iban(
169
+ "R15", "CBPR_Debtor_Creditor_IT/VA_FormalRule",
170
+ "Transactions exchanged within these country couples are considered as "
171
+ "domestic ones. IF Creditor Agent and Debtor Agent BICs are part of "
172
+ "following countries: IT, VA Then: Debtor and Creditor must be identified "
173
+ "using a Name and the Account/IBAN.",
174
+ ["IT", "VA"])
175
+
176
+ _domestic_name_iban(
177
+ "R16", "CBPR_Debtor_Creditor_FR/MC_FormalRule",
178
+ "Transactions exchanged within these country couples are considered as "
179
+ "domestic ones. IF Creditor Agent and Debtor Agent BICs are part of "
180
+ "following countries: FR, MC Then: Debtor and Creditor must be identified "
181
+ "using a Name and the Account/IBAN.",
182
+ ["FR", "MC"])
183
+
184
+ _domestic_name_iban(
185
+ "R17", "CBPR_Debtor_Creditor_ES/AD_FormalRule",
186
+ "Transactions exchanged within these country couples are considered as "
187
+ "domestic ones. IF Creditor Agent and Debtor Agent BICs are part of "
188
+ "following countries: ES, AD Then: Debtor and Creditor must be identified "
189
+ "using a Name and the Account/IBAN.",
190
+ ["ES", "AD"])
191
+
192
+ _domestic_name_iban(
193
+ "R18", "CBPR_Debtor_Creditor_IT/SM_FormalRule",
194
+ "Transactions exchanged within these country couples are considered as "
195
+ "domestic ones. IF Creditor Agent and Debtor Agent BICs are part of "
196
+ "following countries: IT, SM Then: Debtor and Creditor must be identified "
197
+ "using a Name and the Account/IBAN.",
198
+ ["IT", "SM"])
199
+
200
+
201
+ @rule(MT, YEAR, "R13", "CBPR_DEBT_FormalRule",
202
+ 'If "Charge Bearer/DEBT" is present, then only one occurrence of '
203
+ '"Charge Information" is allowed.')
204
+ def _r13(msg, report):
205
+ for tx in msg.each(TX):
206
+ if "DEBT" in msg.values("ChrgBr", tx) and len(msg.find("ChrgsInf", tx)) > 1:
207
+ report(tx, detail="only one ChargesInformation allowed when ChargeBearer is DEBT")
208
+
209
+
210
+ @rule(MT, YEAR, "R14", "CBPR_CRED_FormalRule",
211
+ "Charge information is mandatory if CRED is present - if no charges are "
212
+ 'taken, Zero must be used in "Amount" (any agent in the payment chain).')
213
+ def _r14(msg, report):
214
+ for tx in msg.each(TX):
215
+ cb = msg.values("ChrgBr", tx)
216
+ if cb and all(v == "CRED" for v in cb) and msg.absent("ChrgsInf", tx):
217
+ report(tx, detail="ChargesInformation required when ChargeBearer is CRED")
218
+
219
+
220
+ reg("R19", "CBPR_Instruction_Identification_FormalRule",
221
+ "This element must not start or end with a slash '/' and must not contain "
222
+ "two consecutive slashes '//'.",
223
+ not_matching_pattern(TX + "/PmtId/InstrId", r"(/.*)|(.*/)|(.*//.*)"))
224
+
225
+
226
+ @rule(MT, YEAR, "R21", "CBPR_Interbank_Settlement_Currency_FormalRule",
227
+ "The codes XAU, XAG, XPD and XPT are not allowed, as these are codes are "
228
+ "only used for commodities.")
229
+ def _r21(msg, report):
230
+ for el, ccy in msg.attr_nodes(TX + "/IntrBkSttlmAmt", "Ccy"):
231
+ if ccy in {"XAU", "XAG", "XPD", "XPT"}:
232
+ report(el, detail=f"commodity currency '{ccy}' not allowed")
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Party name / postal address / AnyBIC (formal)
237
+ # ---------------------------------------------------------------------------
238
+ def _party_name_adr(number: str, party: str) -> None:
239
+ reg(number, "CBPR_Party_Name_Postal_Address_FormalRule", D_PARTY_NAME_ADR,
240
+ requires_if_present(party, "PstlAdr", "Nm"))
241
+
242
+
243
+ _party_name_adr("R28", TX + "/UltmtDbtr")
244
+ _party_name_adr("R30", TX + "/InitgPty")
245
+ _party_name_adr("R31", TX + "/Dbtr")
246
+ _party_name_adr("R40", TX + "/Cdtr")
247
+ _party_name_adr("R43", TX + "/UltmtCdtr")
248
+
249
+
250
+ def _party_any_bic(number: str, party: str) -> None:
251
+ reg(number, "CBPR_Party_Name_Any_BIC_FormalRule", D_PARTY_ANY_BIC,
252
+ required_when_absent(party, "Id/OrgId/AnyBIC", ["Nm"]))
253
+
254
+
255
+ _party_any_bic("R33", TX + "/Dbtr")
256
+ _party_any_bic("R41", TX + "/Cdtr")
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Promoted from advisory to enforced (conservative, no false positives)
261
+ # ---------------------------------------------------------------------------
262
+ reg("R7", "CBPR_Business_Message_Identifier_TextualRule",
263
+ "The Business Message Identifier is the unique identifier of the Business "
264
+ "Message instance that is being transported with this header, as defined by "
265
+ "the sending application or system. Must contain the Message Identification "
266
+ "element from the Group Header of the underlying message, where available.",
267
+ business_msg_id_carries_group_id())
268
+
269
+ reg("R8", "CBPR_Message_Definition_Identifier_TextualRule",
270
+ "The Message Definition Identifier of the Business Message instance that is "
271
+ "being transported with this header. In general, it must be formatted exactly "
272
+ "as it appears in the namespace of the Business Message instance.",
273
+ header_msg_def_id_matches())
274
+
275
+ reg("R23", "CBPR_DEBT_Rule_1_TextualRule",
276
+ "If Instructed amount and Interbank Settlement Amount are expressed in the "
277
+ "same currency: if Charge Bearer/DEBT is used then Charge Information is only "
278
+ "mandatory in case of prepaid charges and in that case zero amount is not "
279
+ "allowed; otherwise Charge information is optional.",
280
+ charges_required_when_amounts_differ(TX, "InstdAmt", "IntrBkSttlmAmt", "ChrgsInf"))
281
+
282
+ reg("R29", "CBPR_Duplication_Postal_Address_TextualRule",
283
+ "Data present in structured elements within the Postal Address must not, "
284
+ "under any circumstances be repeated in AddressLine.",
285
+ no_postal_address_duplication())
286
+
287
+ reg("R35", "CBPR_Debtor_BIC_Presence_TextualRule",
288
+ "If Any BIC is present, then (Name and Postal Address) is NOT allowed (other "
289
+ "elements remain optional). However, in case of conflicting information, "
290
+ "AnyBIC will always take precedence.",
291
+ bic_presence_exclusive(TX + "/Dbtr"))
292
+
293
+ reg("R38", "CBPR_Creditor_BIC_Presence_TextualRule",
294
+ "If Any BIC is present, then (Name and Postal Address) is NOT allowed (other "
295
+ "elements remain optional). However, in case of conflicting information, "
296
+ "AnyBIC will always take precedence.",
297
+ bic_presence_exclusive(TX + "/Cdtr"))
298
+
299
+
300
+ @rule(MT, YEAR, "R20", "CBPR_EndToEndIdentification_TextualRule",
301
+ "If no EndToEndIdentification is provided by the Debtor, then the element "
302
+ 'must be populated with "NOTPROVIDED".')
303
+ def _r20(msg, report):
304
+ for node in msg.find(TX + "/PmtId/EndToEndId"):
305
+ if not msg.text_of(node).strip():
306
+ report(node, detail='EndToEndIdentification must be populated (use "NOTPROVIDED" when none provided)')
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Algorithmic field validation (VAL-*) - only for fields present in this message
311
+ # ---------------------------------------------------------------------------
312
+ reg("VAL-CCY", "CBPR_Valid_Settlement_Currency",
313
+ "Interbank Settlement Amount currency must be a valid ISO 4217 code.",
314
+ lambda msg, report: [
315
+ report(el, detail=f"invalid currency '{ccy}'")
316
+ for el, ccy in msg.attr_nodes(TX + "/IntrBkSttlmAmt", "Ccy")
317
+ if ccy and not is_valid_currency(ccy)
318
+ ])
319
+
320
+ reg("VAL-BIC", "CBPR_Valid_Agent_BIC",
321
+ "Instructing/Instructed Agent BICFI must be a structurally valid BIC.",
322
+ each_value_valid(TX + "/InstgAgt/FinInstnId/BICFI", is_valid_bic, "BIC"))
323
+
324
+ reg("VAL-LEI", "CBPR_Valid_Agent_LEI",
325
+ "Any Debtor Agent LEI must be a structurally valid LEI.",
326
+ each_value_valid(TX + "/DbtrAgt/FinInstnId/LEI", is_valid_lei, "LEI"))
327
+
328
+ reg("VAL-CTRY", "CBPR_Valid_Country",
329
+ "Every PostalAddress Country must be a valid ISO 3166 alpha-2 code.",
330
+ each_value_valid(TX + "/Dbtr/PstlAdr/Ctry", is_valid_country, "country"))
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Advisory textual rules (not mechanically enforceable - surfaced as guidance)
335
+ # ---------------------------------------------------------------------------
336
+ _ADVISORY = {
337
+ "R6": ("CBPR_Related_Business_Application_Header_TextualRule",
338
+ "If used, the Related BAH must transport the exact same information as in the BAH of the related message."),
339
+ "R9": ("CBPR_RelatedBAHBusinessService_TextualRule",
340
+ "If related BAH is present, it should transport the element Business Service."),
341
+ "R22": ("CBPR_DEBT_Rule_2_TextualRule",
342
+ "If Instructed amount and Interbank Settlement Amount are not expressed in the same currency: if Charge Bearer/DEBT is used then Charge Information is only mandatory in case of prepaid charges and in that case zero amount is not allowed; otherwise Charge information is optional."),
343
+ "R24": ("CBPR_SHAR_TextualRule",
344
+ "If deduct taken then charge information is mandatory. It is optional for initiator (not taking deduct)."),
345
+ "R25": ("CBPR_Ultimate_Debtor_Option_2_TextualRule",
346
+ "Name AND [(Structured Postal Address) OR (Hybrid Postal Address) with minimum Town Name & Country - it is recommended to add Post code when available] AND (Identification: Private or Organisation)."),
347
+ "R26": ("CBPR_UltimateDebtor_Option_3_Jurisdictions_only_TextualRule",
348
+ "For Jurisdictional transactions, Name and/or Identification (Private or Organisation). The jurisdictional rules apply only when all agents in the payment chain underly the same jurisdiction."),
349
+ "R27": ("CBPR_Ultimate_Debtor_Option_1_TextualRule",
350
+ "Name AND [(Structured Postal Address) OR (Hybrid Postal Address) with minimum Town Name & Country - it is recommended to add Post code when available)."),
351
+ "R32": ("CBPR_Debtor_Option_3_Jurisdictions_only_TextualRule",
352
+ "For Jurisdictional transactions, Debtor/Name is mandatory with either Debtor Account OR Debtor Identification. The jurisdictional rules apply only when all agents in the payment chain underly the same jurisdiction."),
353
+ "R34": ("CBPR_Debtor_Option_2_TextualRule",
354
+ "Name AND ([Structured Address with minimum Town Name & Country (+ recommended to add Postal code when available)]) AND (Account Number OR Identification: Private or Organisation)."),
355
+ "R36": ("CBPR_Debtor_Option_1_TextualRule",
356
+ "Organisation Identification/AnyBIC AND (Account Number OR Organisation Identification/Other)."),
357
+ "R37": ("CBPR_Creditor_Option_1_TextualRule",
358
+ "Organisation Identification/AnyBIC AND (Account Number OR Organisation Identification/Other)."),
359
+ "R39": ("CBPR_Creditor_Option_2_TextualRule",
360
+ "Name AND ([Structured Address with minimum Town Name & Country (+ recommended to add Postal code when available)]) AND (Account Number OR Identification: Private or Organisation)."),
361
+ "R42": ("CBPR_Creditor_Option_3_Jurisdictions_only_TextualRule",
362
+ "For Jurisdictional transactions, Creditor/Name is mandatory with either Creditor Account OR Creditor Identification. The jurisdictional rules apply only when all agents in the payment chain underly the same jurisdiction."),
363
+ "R44": ("CBPR_Ultimate_Creditor_Option_1_TextualRule",
364
+ "Name AND [(Structured Postal Address) OR (Hybrid Postal Address) with minimum Town Name & Country - it is recommended to add Post code when available)]. Other elements are optional, e.g. Identification: Private or Organisation."),
365
+ "R45": ("CBPR_UltimateCreditor_Option_2_Jurisdictions_only_TextualRule",
366
+ "For Jurisdictional transactions, Name and/or Identification (Private or Organisation). The jurisdictional rules apply only when all agents in the payment chain underly the same jurisdiction."),
367
+ }
368
+ for _num, (_name, _desc) in _ADVISORY.items():
369
+ advisory(MT, YEAR, _num, _name, _desc)
@@ -0,0 +1,260 @@
1
+ """CBPR+ SR2026 usage rules for pacs.009.001.08 (FinancialInstitutionCreditTransfer).
2
+
3
+ Authored against the published usage guideline's Rules sheet. Each R-index is
4
+ registered with its real rule number, name token and description; formal rules
5
+ use shared combinators where the shape matches, bespoke ``fn(msg, report)`` for
6
+ cross-field / cross-schema logic. Textual rules are enforced where mechanizable,
7
+ otherwise surfaced as advisories.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ...registry import advisory, rule
12
+ from ...validators import is_valid_bic, is_valid_currency
13
+ from ...helpers import (
14
+ business_msg_id_carries_group_id,
15
+ header_msg_def_id_matches,
16
+ no_postal_address_duplication,
17
+ not_matching_pattern,
18
+ presence_together,
19
+ value_not_in,
20
+ )
21
+
22
+ MT = "pacs.009"
23
+ YEAR = 2026
24
+ ROOT = "/Document/FICdtTrf"
25
+ TX = ROOT + "/CdtTrfTxInf"
26
+
27
+ D_AGENT_NAME_ADR = "Name and Address must always be present together."
28
+
29
+
30
+ def reg(number: str, name: str, description: str, check) -> None:
31
+ """Register a combinator-built check as a rule."""
32
+ rule(MT, YEAR, number, name, description)(check)
33
+
34
+
35
+ def _values_match(msg, report, path_a, path_b, label):
36
+ a_nodes = msg.find(path_a)
37
+ if not a_nodes:
38
+ return
39
+ b_vals = {msg.text_of(n) for n in msg.find(path_b)}
40
+ if not b_vals:
41
+ return
42
+ a_vals = {msg.text_of(n) for n in a_nodes}
43
+ if a_vals != b_vals:
44
+ report(a_nodes[0], detail=label)
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Bespoke cross-field / cross-schema rules
49
+ # ---------------------------------------------------------------------------
50
+ @rule(MT, YEAR, "R1", "CBPR_BusinessMessageIdentifier_FormalRule",
51
+ "The Business Message Identifier must match the Message Identification in "
52
+ "the Group Header.")
53
+ def _r1(msg, report):
54
+ _values_match(msg, report, "/AppHdr/BizMsgIdr", ROOT + "/GrpHdr/MsgId",
55
+ "BusinessMessageIdentifier must equal GroupHeader/MessageIdentification")
56
+
57
+
58
+ @rule(MT, YEAR, "R2", "CBPR_Priority_Instruction_Priority_FormalRule",
59
+ 'If "Priority" is used in the BAH for pacs messages, the value should be '
60
+ 'identical to the one in the "Payment Type Information/InstructionPriority" '
61
+ "if present.")
62
+ def _r2(msg, report):
63
+ if msg.present("/AppHdr/Prty") and msg.present(TX + "/PmtTpInf/InstrPrty"):
64
+ _values_match(msg, report, "/AppHdr/Prty", TX + "/PmtTpInf/InstrPrty",
65
+ "BAH Priority must equal InstructionPriority")
66
+
67
+
68
+ @rule(MT, YEAR, "R3", "CBPR_To_Instructed_Agent_BIC_1_FormalRule",
69
+ 'BAH "To" BIC must match "Instructed Agent" BIC, except where BAH '
70
+ "CopyDuplicate = COPY or = CODU")
71
+ def _r3(msg, report):
72
+ if any(v in {"COPY", "CODU"} for v in msg.values("/AppHdr/CpyDplct")):
73
+ return
74
+ _values_match(msg, report, "/AppHdr/To/FIId/FinInstnId/BICFI",
75
+ TX + "/InstdAgt/FinInstnId/BICFI", "To vs Instructed Agent")
76
+
77
+
78
+ @rule(MT, YEAR, "R4", "CBPR_To_Instructed_Agent_BIC_2_FormalRule",
79
+ 'BAH "To" BIC must match "Instructed Agent" BIC if CopyDuplicate is absent.')
80
+ def _r4(msg, report):
81
+ if not msg.absent("/AppHdr/CpyDplct"):
82
+ return
83
+ _values_match(msg, report, "/AppHdr/To/FIId/FinInstnId/BICFI",
84
+ TX + "/InstdAgt/FinInstnId/BICFI", "To vs Instructed Agent")
85
+
86
+
87
+ @rule(MT, YEAR, "R5", "CBPR_From_Instructing_Agent_BIC_FormalRule",
88
+ 'BAH "From" BIC must match "Instructing Agent" BIC')
89
+ def _r5(msg, report):
90
+ _values_match(msg, report, "/AppHdr/Fr/FIId/FinInstnId/BICFI",
91
+ TX + "/InstgAgt/FinInstnId/BICFI", "From vs Instructing Agent")
92
+
93
+
94
+ @rule(MT, YEAR, "R10", "CBPR_GPI_ServiceLevel_Code_FormalRule",
95
+ "The GPI ServiceLevel Code in pacs.009 CORE must be 'G004'.")
96
+ def _r10(msg, report):
97
+ for tx in msg.each(TX):
98
+ if msg.absent("PmtTpInf/SvcLvl", tx):
99
+ continue
100
+ for code in msg.find("PmtTpInf/SvcLvl/Cd", tx):
101
+ if msg.text_of(code) in {"G001", "G002", "G003", "G005", "G006", "G007", "G009"}:
102
+ report(code, detail="ServiceLevel/Code must be 'G004'")
103
+
104
+
105
+ @rule(MT, YEAR, "R11", "CBPR_Instruction_For_Creditor_Presence_Code_FormalRule",
106
+ 'Each code can only be used once for element "Instruction For Creditor Agent".')
107
+ def _r11(msg, report):
108
+ for tx in msg.each(TX):
109
+ codes = msg.values("InstrForCdtrAgt/Cd", tx)
110
+ if len(codes) != len(set(codes)):
111
+ report(tx, detail="duplicate InstructionForCreditorAgent code")
112
+
113
+
114
+ reg("R12", "CBPR_Instruction_Identification_FormalRule",
115
+ "This element must not start or end with a slash '/' and must not contain "
116
+ "two consecutive slashes '//'.",
117
+ not_matching_pattern(TX + "/PmtId/InstrId", r"(/.*)|(.*/)|(.*//.*)"))
118
+
119
+
120
+ @rule(MT, YEAR, "R13", "CBPR_End_To_End_Identification_FormalRule",
121
+ "For the E2E identification, the below restrictions apply to the first 16 "
122
+ "characters: - The first one and the 16th one cannot be “/” and - "
123
+ "The string of 16 characters cannot contain “//”")
124
+ def _r13(msg, report):
125
+ import re as _re
126
+ pats = [_re.compile(r"/.*"), _re.compile(r".{15}/.*"), _re.compile(r".{0,14}//.*")]
127
+ for node in msg.find(TX + "/PmtId/EndToEndId"):
128
+ val = msg.text_of(node)
129
+ if val and any(p.fullmatch(val) for p in pats):
130
+ report(node, detail="EndToEndIdentification matches a forbidden pattern")
131
+
132
+
133
+ reg("R16", "CBPR_Interbank_Settlement_Currency_FormalRule",
134
+ "The codes XAU, XAG, XPD and XPT are not allowed, as these are codes are "
135
+ "only used for commodities.",
136
+ lambda msg, report: [
137
+ report(el, detail=f"commodity currency '{ccy}' not allowed")
138
+ for el, ccy in msg.attr_nodes(TX + "/IntrBkSttlmAmt", "Ccy")
139
+ if ccy in {"XAU", "XAG", "XPD", "XPT"}
140
+ ])
141
+
142
+
143
+ # Agent Name + Postal Address presence-together (FinInstnId of each agent/party).
144
+ _AGENT_NAME_ADR = {
145
+ "R21": TX + "/PrvsInstgAgt1/FinInstnId",
146
+ "R23": TX + "/PrvsInstgAgt2/FinInstnId",
147
+ "R24": TX + "/PrvsInstgAgt3/FinInstnId",
148
+ "R25": TX + "/IntrmyAgt1/FinInstnId",
149
+ "R26": TX + "/IntrmyAgt2/FinInstnId",
150
+ "R27": TX + "/IntrmyAgt3/FinInstnId",
151
+ "R28": TX + "/Dbtr/FinInstnId",
152
+ "R29": TX + "/DbtrAgt/FinInstnId",
153
+ "R30": TX + "/CdtrAgt/FinInstnId",
154
+ "R31": TX + "/Cdtr/FinInstnId",
155
+ }
156
+ for _num, _path in _AGENT_NAME_ADR.items():
157
+ reg(_num, "CBPR_Agent_Name_Postal_Address_FormalRule", D_AGENT_NAME_ADR,
158
+ presence_together(_path, "Nm", "PstlAdr"))
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Algorithmic field validation (brief), only for fields present in pacs.009.
163
+ # ---------------------------------------------------------------------------
164
+ reg("VAL-CCY", "CBPR_Valid_Settlement_Currency",
165
+ "Interbank Settlement Amount currency must be a valid ISO 4217 code.",
166
+ lambda msg, report: [
167
+ report(el, detail=f"invalid currency '{ccy}'")
168
+ for el, ccy in msg.attr_nodes(TX + "/IntrBkSttlmAmt", "Ccy")
169
+ if ccy and not is_valid_currency(ccy)
170
+ ])
171
+
172
+
173
+ @rule(MT, YEAR, "VAL-BIC", "CBPR_Valid_Agent_BIC",
174
+ "Every Agent BICFI in the message must be a structurally valid BIC.")
175
+ def _val_bic(msg, report):
176
+ paths = [
177
+ "/AppHdr/Fr/FIId/FinInstnId/BICFI",
178
+ "/AppHdr/To/FIId/FinInstnId/BICFI",
179
+ TX + "/InstgAgt/FinInstnId/BICFI",
180
+ TX + "/InstdAgt/FinInstnId/BICFI",
181
+ TX + "/PrvsInstgAgt1/FinInstnId/BICFI",
182
+ TX + "/PrvsInstgAgt2/FinInstnId/BICFI",
183
+ TX + "/PrvsInstgAgt3/FinInstnId/BICFI",
184
+ TX + "/IntrmyAgt1/FinInstnId/BICFI",
185
+ TX + "/IntrmyAgt2/FinInstnId/BICFI",
186
+ TX + "/IntrmyAgt3/FinInstnId/BICFI",
187
+ TX + "/Dbtr/FinInstnId/BICFI",
188
+ TX + "/DbtrAgt/FinInstnId/BICFI",
189
+ TX + "/CdtrAgt/FinInstnId/BICFI",
190
+ TX + "/Cdtr/FinInstnId/BICFI",
191
+ ]
192
+ for p in paths:
193
+ for node in msg.find(p):
194
+ val = msg.text_of(node)
195
+ if val and not is_valid_bic(val):
196
+ report(node, detail=f"invalid BIC: '{val}'")
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Promoted from advisory to enforced (mechanizable, conservative).
201
+ # ---------------------------------------------------------------------------
202
+ reg("R7", "CBPR_Business_Message_Identifier_TextualRule",
203
+ "The Business Message Identifier is the unique identifier of the "
204
+ "Business Message instance that is being transported with this header, "
205
+ "as defined by the sending application or system. Must contain the "
206
+ "Message Identification element from the Group Header.",
207
+ business_msg_id_carries_group_id())
208
+
209
+ reg("R8", "CBPR_Message_Definition_Identifier_TextualRule",
210
+ "The Message Definition Identifier of the Business Message instance "
211
+ "that is being transported with this header. In general, it must be "
212
+ "formatted exactly as it appears in the namespace of the Business "
213
+ "Message instance.",
214
+ header_msg_def_id_matches())
215
+
216
+ reg("R22", "CBPR_Duplication_Postal_Address_TextualRule",
217
+ "Data present in structured elements within the Postal Address must "
218
+ "not, under any circumstances be repeated in AddressLine.",
219
+ no_postal_address_duplication())
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Advisory textual rules (not mechanically enforceable).
224
+ # ---------------------------------------------------------------------------
225
+ _ADVISORY = {
226
+ "R6": ("CBPR_Related_Business_Application_Header_TextualRule",
227
+ "If used, the Related BAH must transport the exact same information as "
228
+ "in the BAH of the related message."),
229
+ "R9": ("CBPR_Related_BAH_Business_Service_TextualRule",
230
+ "If related BAH is present, it should transport the element Business "
231
+ "Service."),
232
+ "R14": ("CBPR_E2E_CORE_ADV_TextualRule",
233
+ "If pacs.009CORE is used to cover pacs.009ADV, the E2E identification "
234
+ "should transport the instruction identification of the underlying "
235
+ "pacs.009 ADV."),
236
+ "R15": ("CBPR_UETR_TextualRule",
237
+ "If the pacs.009 is used to settle a pacs.009 Advice, the UETR should "
238
+ "transport the UETR of the underlying pacs.009 Advice."),
239
+ "R17": ("CBPR_Agent_Option_2_TextualRule",
240
+ "(Clearing Code OR LEI) AND (Name AND ([Structured postal address "
241
+ "with minimum Town Name and Country] OR [Hybrid postal address with "
242
+ "minimum Town Name and Country]). It is recommended to also add the "
243
+ "post code when available."),
244
+ "R18": ("CBPR_Agent_National_only_TextualRule",
245
+ "Whenever Debtor Agent, Creditor Agent and all agents in between are "
246
+ "located within the same country, the clearing code only may be used."),
247
+ "R19": ("CBPR_Agent_Option_3_TextualRule",
248
+ "Name AND ([Structured postal address with minimum Town Name and "
249
+ "Country] OR [Hybrid postal address with minimum Town Name and "
250
+ "Country]). It is recommended to also add the post code when available."),
251
+ "R20": ("CBPR_Agent_Option_1_TextualRule",
252
+ "BICFI, complemented optionally with a LEI (preferred option)"),
253
+ "R32": ("CBPR_Instruction_Information_TextualRule",
254
+ "If the pacs.009 is used to settle a pacs.009 Advice, the last "
255
+ "available occurrence (of the element Instruction For Creditor "
256
+ "Agent/Instruction Information) preceded by /UDLC/ must be used to "
257
+ "capture the /UDLC/ (Underlying Creditor) provided."),
258
+ }
259
+ for _num, (_name, _desc) in _ADVISORY.items():
260
+ advisory(MT, YEAR, _num, _name, _desc)