cbpr-validate 1.0.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_validate/__init__.py +18 -0
- cbpr_validate/api/__init__.py +0 -0
- cbpr_validate/api/main.py +120 -0
- cbpr_validate/cli.py +247 -0
- cbpr_validate/codesets/__init__.py +0 -0
- cbpr_validate/codesets/data/iso20022_codesets.json +276 -0
- cbpr_validate/codesets/loader.py +35 -0
- cbpr_validate/config.py +37 -0
- cbpr_validate/core.py +132 -0
- cbpr_validate/match/__init__.py +20 -0
- cbpr_validate/match/matcher.py +449 -0
- cbpr_validate/match/result.py +98 -0
- cbpr_validate/model/__init__.py +15 -0
- cbpr_validate/model/finding.py +21 -0
- cbpr_validate/model/payment.py +89 -0
- cbpr_validate/model/validation_result.py +21 -0
- cbpr_validate/parsers/__init__.py +0 -0
- cbpr_validate/parsers/_common.py +97 -0
- cbpr_validate/parsers/detect.py +35 -0
- cbpr_validate/parsers/pacs002.py +39 -0
- cbpr_validate/parsers/pacs004.py +52 -0
- cbpr_validate/parsers/pacs008.py +108 -0
- cbpr_validate/parsers/pacs009.py +82 -0
- cbpr_validate/parsers/parse.py +50 -0
- cbpr_validate/report/__init__.py +0 -0
- cbpr_validate/report/json_report.py +57 -0
- cbpr_validate/report/junit_report.py +89 -0
- cbpr_validate/report/text_report.py +103 -0
- cbpr_validate/rules/__init__.py +0 -0
- cbpr_validate/rules/address.py +99 -0
- cbpr_validate/rules/agents.py +142 -0
- cbpr_validate/rules/amounts.py +87 -0
- cbpr_validate/rules/codes.py +77 -0
- cbpr_validate/rules/registry.py +58 -0
- cbpr_validate/rules/returns.py +222 -0
- cbpr_validate/rules/structural.py +123 -0
- cbpr_validate/schema/__init__.py +0 -0
- cbpr_validate/schema/xsd.py +104 -0
- cbpr_validate-1.0.0.dist-info/METADATA +209 -0
- cbpr_validate-1.0.0.dist-info/RECORD +42 -0
- cbpr_validate-1.0.0.dist-info/WHEEL +4 -0
- cbpr_validate-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""cbpr-validate: validate ISO 20022 CBPR+ messages against the usage guidelines.
|
|
2
|
+
|
|
3
|
+
from cbpr_validate import validate_file
|
|
4
|
+
|
|
5
|
+
result = validate_file("payment.xml")
|
|
6
|
+
result.is_compliant, result.errors, result.warnings
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from cbpr_validate.core import validate_bytes, validate_file, validate_string
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"validate_bytes",
|
|
16
|
+
"validate_file",
|
|
17
|
+
"validate_string",
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""FastAPI service: ``POST /validate`` and ``POST /correlate``.
|
|
2
|
+
|
|
3
|
+
Like the CLI, this is a shell over the library. Both endpoints build their
|
|
4
|
+
response from the same ``report.json_report`` envelopes the CLI's ``--format
|
|
5
|
+
json`` prints, so the two interfaces cannot disagree about a message. The
|
|
6
|
+
response models below exist to give the OpenAPI document real schemas rather
|
|
7
|
+
than a bare ``object`` - their fields mirror those envelopes exactly, and a test
|
|
8
|
+
pins that correspondence.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from cbpr_validate import __version__
|
|
17
|
+
from cbpr_validate.match.matcher import UnsupportedPairError, correlate
|
|
18
|
+
from cbpr_validate.match.result import Direction, MatchKey, MessageRef, Scenario
|
|
19
|
+
from cbpr_validate.model.finding import Finding
|
|
20
|
+
from cbpr_validate.model.payment import Payment
|
|
21
|
+
from cbpr_validate.parsers.parse import UnsupportedMessageTypeError, parse_message
|
|
22
|
+
from cbpr_validate.report.json_report import match_to_dict, validation_to_dict
|
|
23
|
+
from cbpr_validate.rules.registry import run_all
|
|
24
|
+
|
|
25
|
+
app = FastAPI(
|
|
26
|
+
title="cbpr-validate",
|
|
27
|
+
version=__version__,
|
|
28
|
+
summary="Validate ISO 20022 CBPR+ messages against the usage guidelines, not just the XSD.",
|
|
29
|
+
description=(
|
|
30
|
+
"Two operations. `/validate` runs every registered usage-guideline rule "
|
|
31
|
+
"against a single message. `/correlate` checks whether two specific "
|
|
32
|
+
"messages correctly reference each other (pairwise and stateless - no "
|
|
33
|
+
"message store).\n\n"
|
|
34
|
+
"**XSD-valid is not CBPR+-compliant.** The optional XSD layer is CLI-only: "
|
|
35
|
+
"it needs schemas this project does not redistribute."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ValidateRequest(BaseModel):
|
|
41
|
+
message: str = Field(
|
|
42
|
+
...,
|
|
43
|
+
description="The ISO 20022 message as XML (pacs.008, pacs.009, pacs.002 or pacs.004).",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CorrelateRequest(BaseModel):
|
|
48
|
+
message_a: str = Field(..., description="First message, as XML.")
|
|
49
|
+
message_b: str = Field(..., description="Second message, as XML. Order does not matter.")
|
|
50
|
+
direction: Direction | None = Field(
|
|
51
|
+
None,
|
|
52
|
+
description=(
|
|
53
|
+
"Required for a pacs.002 <-> pacs.008 pair: whether the caller sent the "
|
|
54
|
+
"pacs.008 (outbound) or received it (inbound). Never inferred from BICs."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ValidationResponse(BaseModel):
|
|
60
|
+
"""Mirrors ``report.json_report.validation_to_dict``."""
|
|
61
|
+
|
|
62
|
+
is_compliant: bool
|
|
63
|
+
summary: dict[str, int]
|
|
64
|
+
findings: list[Finding]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MatchResponse(BaseModel):
|
|
68
|
+
"""Mirrors ``report.json_report.match_to_dict``."""
|
|
69
|
+
|
|
70
|
+
matched: bool
|
|
71
|
+
is_consistent: bool
|
|
72
|
+
scenario: Scenario
|
|
73
|
+
match_key: MatchKey
|
|
74
|
+
uetr: str | None
|
|
75
|
+
direction: Direction | None
|
|
76
|
+
fallback_used: bool
|
|
77
|
+
linked_fields: list[str]
|
|
78
|
+
message_a: MessageRef
|
|
79
|
+
message_b: MessageRef
|
|
80
|
+
summary: dict[str, int]
|
|
81
|
+
mismatches: list[Finding]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HealthResponse(BaseModel):
|
|
85
|
+
status: str
|
|
86
|
+
version: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse(xml: str, label: str) -> Payment:
|
|
90
|
+
try:
|
|
91
|
+
return parse_message(xml.encode())
|
|
92
|
+
except UnsupportedMessageTypeError as exc:
|
|
93
|
+
raise HTTPException(status_code=400, detail=f"{label}: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/health", response_model=HealthResponse, tags=["meta"])
|
|
97
|
+
def health() -> HealthResponse:
|
|
98
|
+
"""Liveness probe."""
|
|
99
|
+
return HealthResponse(status="ok", version=__version__)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.post("/validate", response_model=ValidationResponse, tags=["validate"])
|
|
103
|
+
def validate(request: ValidateRequest) -> ValidationResponse:
|
|
104
|
+
"""Run every registered CBPR+ usage-guideline rule against one message."""
|
|
105
|
+
payment = _parse(request.message, "message")
|
|
106
|
+
return ValidationResponse(**validation_to_dict(run_all(payment)))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.post("/correlate", response_model=MatchResponse, tags=["correlate"])
|
|
110
|
+
def correlate_messages(request: CorrelateRequest) -> MatchResponse:
|
|
111
|
+
"""Check whether two specific messages correctly reference each other."""
|
|
112
|
+
a = _parse(request.message_a, "message_a")
|
|
113
|
+
b = _parse(request.message_b, "message_b")
|
|
114
|
+
try:
|
|
115
|
+
result = correlate(a, b, request.direction)
|
|
116
|
+
except UnsupportedPairError as exc:
|
|
117
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
118
|
+
except ValueError as exc: # missing direction for the pacs.002 scenario
|
|
119
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
120
|
+
return MatchResponse(**match_to_dict(result))
|
cbpr_validate/cli.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Typer CLI: ``cbpr-validate check`` and ``cbpr-validate match``.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin shell. Parsing, rule execution, correlation and formatting all
|
|
4
|
+
live in the library, so the CLI and the API cannot drift apart in what they
|
|
5
|
+
report - only in how they are invoked.
|
|
6
|
+
|
|
7
|
+
Exit codes (stable, so this is usable as a CI gate):
|
|
8
|
+
|
|
9
|
+
* ``0`` - clean, or findings below the ``--fail-on`` threshold
|
|
10
|
+
* ``1`` - findings at or above the threshold / the two messages do not correlate
|
|
11
|
+
* ``2`` - the input could not be used (unreadable, unparseable, no XSD, bad pair)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from cbpr_validate.core import validate_file as validate_message_file
|
|
24
|
+
from cbpr_validate.match.matcher import UnsupportedPairError, correlate
|
|
25
|
+
from cbpr_validate.match.result import Direction
|
|
26
|
+
from cbpr_validate.model.payment import Payment
|
|
27
|
+
from cbpr_validate.model.validation_result import ValidationResult
|
|
28
|
+
from cbpr_validate.parsers.detect import detect_message_type
|
|
29
|
+
from cbpr_validate.parsers.parse import UnsupportedMessageTypeError, parse_message
|
|
30
|
+
from cbpr_validate.report.json_report import match_to_json, validation_to_json
|
|
31
|
+
from cbpr_validate.report.junit_report import match_to_junit, validation_to_junit
|
|
32
|
+
from cbpr_validate.report.text_report import match_to_text, validation_to_text
|
|
33
|
+
from cbpr_validate.rules.registry import run_all
|
|
34
|
+
from cbpr_validate.schema.xsd import XsdUnavailableError, validate_against_xsd
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
help="cbpr-validate: ISO 20022 CBPR+ usage-guideline validator",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_FINDINGS = 1
|
|
43
|
+
EXIT_INPUT_ERROR = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OutputFormat(StrEnum):
|
|
47
|
+
TEXT = "text"
|
|
48
|
+
JSON = "json"
|
|
49
|
+
JUNIT = "junit"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FailOn(StrEnum):
|
|
53
|
+
ERROR = "error"
|
|
54
|
+
WARN = "warn"
|
|
55
|
+
NEVER = "never"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FailOnLevel(StrEnum):
|
|
59
|
+
"""Threshold for `validate`. Narrower on purpose: a gate wants a floor, not
|
|
60
|
+
an off switch, so there is no "never" here."""
|
|
61
|
+
|
|
62
|
+
ERROR = "error"
|
|
63
|
+
WARNING = "warning"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MessageArgument = Annotated[
|
|
67
|
+
Path,
|
|
68
|
+
typer.Argument(
|
|
69
|
+
exists=True,
|
|
70
|
+
dir_okay=False,
|
|
71
|
+
readable=True,
|
|
72
|
+
help="Path to an ISO 20022 message (XML).",
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
FormatOption = Annotated[
|
|
76
|
+
OutputFormat, typer.Option("--format", "-f", help="Output format.")
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read(path: Path) -> bytes:
|
|
81
|
+
try:
|
|
82
|
+
return path.read_bytes()
|
|
83
|
+
except OSError as exc: # pragma: no cover - argument callback catches most cases
|
|
84
|
+
typer.echo(f"error: could not read {path}: {exc}", err=True)
|
|
85
|
+
raise typer.Exit(EXIT_INPUT_ERROR) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse(path: Path) -> Payment:
|
|
89
|
+
try:
|
|
90
|
+
return parse_message(_read(path))
|
|
91
|
+
except UnsupportedMessageTypeError as exc:
|
|
92
|
+
typer.echo(f"error: {path}: {exc}", err=True)
|
|
93
|
+
raise typer.Exit(EXIT_INPUT_ERROR) from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _should_fail(result: ValidationResult, fail_on: FailOn) -> bool:
|
|
97
|
+
if fail_on is FailOn.NEVER:
|
|
98
|
+
return False
|
|
99
|
+
if fail_on is FailOn.WARN:
|
|
100
|
+
return bool(result.errors or result.warnings)
|
|
101
|
+
return bool(result.errors)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def validate(
|
|
106
|
+
message: MessageArgument,
|
|
107
|
+
as_json: Annotated[
|
|
108
|
+
bool, typer.Option("--json", help="Emit the result as JSON instead of text.")
|
|
109
|
+
] = False,
|
|
110
|
+
fail_on: Annotated[
|
|
111
|
+
FailOnLevel,
|
|
112
|
+
typer.Option("--fail-on", help="Lowest severity that makes this command exit 1."),
|
|
113
|
+
] = FailOnLevel.ERROR,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Validate a message against the CBPR+ usage guidelines.
|
|
116
|
+
|
|
117
|
+
Goes through ``cbpr_validate.core.validate_file``, so an unreadable,
|
|
118
|
+
malformed or unrecognised document is reported as an ``ORCH-*`` finding and
|
|
119
|
+
still exits 1 - never a traceback.
|
|
120
|
+
"""
|
|
121
|
+
result = validate_message_file(message)
|
|
122
|
+
|
|
123
|
+
if as_json:
|
|
124
|
+
typer.echo(validation_to_json(result))
|
|
125
|
+
else:
|
|
126
|
+
for finding in result.findings:
|
|
127
|
+
line = f"{finding.rule_id} | {finding.severity.value} | {finding.message}"
|
|
128
|
+
if finding.location:
|
|
129
|
+
line += f" | {finding.location}"
|
|
130
|
+
typer.echo(line)
|
|
131
|
+
errors = len(result.errors)
|
|
132
|
+
warnings = len(result.warnings)
|
|
133
|
+
infos = len(result.findings) - errors - warnings
|
|
134
|
+
verdict = "COMPLIANT" if result.is_compliant else "NOT COMPLIANT"
|
|
135
|
+
typer.echo(
|
|
136
|
+
f"{len(result.findings)} finding(s): {errors} error, {warnings} warn, "
|
|
137
|
+
f"{infos} info - {verdict}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
tripped = result.errors or (fail_on is FailOnLevel.WARNING and result.warnings)
|
|
141
|
+
raise typer.Exit(EXIT_FINDINGS if tripped else EXIT_OK)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def check(
|
|
146
|
+
message: MessageArgument,
|
|
147
|
+
output_format: FormatOption = OutputFormat.TEXT,
|
|
148
|
+
xsd: Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
typer.Option(
|
|
151
|
+
"--xsd/--no-xsd",
|
|
152
|
+
help="Also run the optional XSD structural layer (requires your own schemas).",
|
|
153
|
+
),
|
|
154
|
+
] = False,
|
|
155
|
+
xsd_dir: Annotated[
|
|
156
|
+
Path | None,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--xsd-dir",
|
|
159
|
+
help="Directory holding your licensed ISO 20022 schemas. "
|
|
160
|
+
"Defaults to $CBPR_VALIDATE_XSD_DIR.",
|
|
161
|
+
),
|
|
162
|
+
] = None,
|
|
163
|
+
fail_on: Annotated[
|
|
164
|
+
FailOn, typer.Option("--fail-on", help="Severity that makes this command exit 1.")
|
|
165
|
+
] = FailOn.ERROR,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Validate one message against the CBPR+ usage guidelines."""
|
|
168
|
+
raw = _read(message)
|
|
169
|
+
payment = _parse(message)
|
|
170
|
+
result = run_all(payment)
|
|
171
|
+
|
|
172
|
+
if xsd:
|
|
173
|
+
try:
|
|
174
|
+
result.findings.extend(
|
|
175
|
+
validate_against_xsd(raw, detect_message_type(raw), xsd_dir=xsd_dir)
|
|
176
|
+
)
|
|
177
|
+
except XsdUnavailableError as exc:
|
|
178
|
+
typer.echo(f"error: {exc}", err=True)
|
|
179
|
+
raise typer.Exit(EXIT_INPUT_ERROR) from exc
|
|
180
|
+
|
|
181
|
+
source = message.name
|
|
182
|
+
if output_format is OutputFormat.JSON:
|
|
183
|
+
typer.echo(validation_to_json(result))
|
|
184
|
+
elif output_format is OutputFormat.JUNIT:
|
|
185
|
+
typer.echo(validation_to_junit(result, source=source))
|
|
186
|
+
else:
|
|
187
|
+
typer.echo(validation_to_text(result, source=source))
|
|
188
|
+
|
|
189
|
+
raise typer.Exit(EXIT_FINDINGS if _should_fail(result, fail_on) else EXIT_OK)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def match(
|
|
194
|
+
message_a: MessageArgument,
|
|
195
|
+
message_b: MessageArgument,
|
|
196
|
+
direction: Annotated[
|
|
197
|
+
Direction | None,
|
|
198
|
+
typer.Option(
|
|
199
|
+
"--direction",
|
|
200
|
+
help="Required for pacs.002 <-> pacs.008: whether YOU sent the pacs.008 "
|
|
201
|
+
"(outbound) or received it (inbound). Never inferred from BICs.",
|
|
202
|
+
),
|
|
203
|
+
] = None,
|
|
204
|
+
output_format: FormatOption = OutputFormat.TEXT,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Correlate two related messages (COV<->008, 002<->008, 004<->008)."""
|
|
207
|
+
a = _parse(message_a)
|
|
208
|
+
b = _parse(message_b)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
result = correlate(a, b, direction)
|
|
212
|
+
except (UnsupportedPairError, ValueError) as exc:
|
|
213
|
+
typer.echo(f"error: {exc}", err=True)
|
|
214
|
+
raise typer.Exit(EXIT_INPUT_ERROR) from exc
|
|
215
|
+
|
|
216
|
+
if output_format is OutputFormat.JSON:
|
|
217
|
+
typer.echo(match_to_json(result))
|
|
218
|
+
elif output_format is OutputFormat.JUNIT:
|
|
219
|
+
typer.echo(match_to_junit(result))
|
|
220
|
+
else:
|
|
221
|
+
typer.echo(match_to_text(result))
|
|
222
|
+
|
|
223
|
+
raise typer.Exit(EXIT_OK if result.is_consistent else EXIT_FINDINGS)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def version() -> None:
|
|
228
|
+
"""Print the installed version."""
|
|
229
|
+
from cbpr_validate import __version__
|
|
230
|
+
|
|
231
|
+
typer.echo(__version__)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
# Findings quote message content verbatim, and a real payment can carry
|
|
236
|
+
# characters the console encoding cannot represent (a non-Latin party name,
|
|
237
|
+
# say). Degrade those to a placeholder rather than dying with
|
|
238
|
+
# UnicodeEncodeError halfway through a report.
|
|
239
|
+
for stream in (sys.stdout, sys.stderr):
|
|
240
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
241
|
+
if reconfigure is not None:
|
|
242
|
+
reconfigure(errors="replace")
|
|
243
|
+
app()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__": # pragma: no cover - console-script entry point
|
|
247
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ExternalPurpose1Code": [
|
|
3
|
+
"CORT",
|
|
4
|
+
"DVPM",
|
|
5
|
+
"SALA",
|
|
6
|
+
"TREA",
|
|
7
|
+
"CASH"
|
|
8
|
+
],
|
|
9
|
+
"ExternalCategoryPurpose1Code": [
|
|
10
|
+
"CASH",
|
|
11
|
+
"CCRD",
|
|
12
|
+
"CPRC",
|
|
13
|
+
"SALA"
|
|
14
|
+
],
|
|
15
|
+
"ExternalStatusReason1Code": [
|
|
16
|
+
"ACCP",
|
|
17
|
+
"RJCT",
|
|
18
|
+
"PDNG"
|
|
19
|
+
],
|
|
20
|
+
"ExternalReturnReason1Code": [
|
|
21
|
+
"CURR",
|
|
22
|
+
"DNCL",
|
|
23
|
+
"CANC"
|
|
24
|
+
],
|
|
25
|
+
"ISO3166-1-alpha-2": [
|
|
26
|
+
"AD",
|
|
27
|
+
"AE",
|
|
28
|
+
"AF",
|
|
29
|
+
"AG",
|
|
30
|
+
"AI",
|
|
31
|
+
"AL",
|
|
32
|
+
"AM",
|
|
33
|
+
"AO",
|
|
34
|
+
"AQ",
|
|
35
|
+
"AR",
|
|
36
|
+
"AS",
|
|
37
|
+
"AT",
|
|
38
|
+
"AU",
|
|
39
|
+
"AW",
|
|
40
|
+
"AX",
|
|
41
|
+
"AZ",
|
|
42
|
+
"BA",
|
|
43
|
+
"BB",
|
|
44
|
+
"BD",
|
|
45
|
+
"BE",
|
|
46
|
+
"BF",
|
|
47
|
+
"BG",
|
|
48
|
+
"BH",
|
|
49
|
+
"BI",
|
|
50
|
+
"BJ",
|
|
51
|
+
"BL",
|
|
52
|
+
"BM",
|
|
53
|
+
"BN",
|
|
54
|
+
"BO",
|
|
55
|
+
"BQ",
|
|
56
|
+
"BR",
|
|
57
|
+
"BS",
|
|
58
|
+
"BT",
|
|
59
|
+
"BV",
|
|
60
|
+
"BW",
|
|
61
|
+
"BY",
|
|
62
|
+
"BZ",
|
|
63
|
+
"CA",
|
|
64
|
+
"CC",
|
|
65
|
+
"CD",
|
|
66
|
+
"CF",
|
|
67
|
+
"CG",
|
|
68
|
+
"CH",
|
|
69
|
+
"CI",
|
|
70
|
+
"CK",
|
|
71
|
+
"CL",
|
|
72
|
+
"CM",
|
|
73
|
+
"CN",
|
|
74
|
+
"CO",
|
|
75
|
+
"CR",
|
|
76
|
+
"CU",
|
|
77
|
+
"CV",
|
|
78
|
+
"CW",
|
|
79
|
+
"CX",
|
|
80
|
+
"CY",
|
|
81
|
+
"CZ",
|
|
82
|
+
"DE",
|
|
83
|
+
"DJ",
|
|
84
|
+
"DK",
|
|
85
|
+
"DM",
|
|
86
|
+
"DO",
|
|
87
|
+
"DZ",
|
|
88
|
+
"EC",
|
|
89
|
+
"EE",
|
|
90
|
+
"EG",
|
|
91
|
+
"EH",
|
|
92
|
+
"ER",
|
|
93
|
+
"ES",
|
|
94
|
+
"ET",
|
|
95
|
+
"FI",
|
|
96
|
+
"FJ",
|
|
97
|
+
"FK",
|
|
98
|
+
"FM",
|
|
99
|
+
"FO",
|
|
100
|
+
"FR",
|
|
101
|
+
"GA",
|
|
102
|
+
"GB",
|
|
103
|
+
"GD",
|
|
104
|
+
"GE",
|
|
105
|
+
"GF",
|
|
106
|
+
"GG",
|
|
107
|
+
"GH",
|
|
108
|
+
"GI",
|
|
109
|
+
"GL",
|
|
110
|
+
"GM",
|
|
111
|
+
"GN",
|
|
112
|
+
"GP",
|
|
113
|
+
"GQ",
|
|
114
|
+
"GR",
|
|
115
|
+
"GS",
|
|
116
|
+
"GT",
|
|
117
|
+
"GU",
|
|
118
|
+
"GW",
|
|
119
|
+
"GY",
|
|
120
|
+
"HK",
|
|
121
|
+
"HM",
|
|
122
|
+
"HN",
|
|
123
|
+
"HR",
|
|
124
|
+
"HT",
|
|
125
|
+
"HU",
|
|
126
|
+
"ID",
|
|
127
|
+
"IE",
|
|
128
|
+
"IL",
|
|
129
|
+
"IM",
|
|
130
|
+
"IN",
|
|
131
|
+
"IO",
|
|
132
|
+
"IQ",
|
|
133
|
+
"IR",
|
|
134
|
+
"IS",
|
|
135
|
+
"IT",
|
|
136
|
+
"JE",
|
|
137
|
+
"JM",
|
|
138
|
+
"JO",
|
|
139
|
+
"JP",
|
|
140
|
+
"KE",
|
|
141
|
+
"KG",
|
|
142
|
+
"KH",
|
|
143
|
+
"KI",
|
|
144
|
+
"KM",
|
|
145
|
+
"KN",
|
|
146
|
+
"KP",
|
|
147
|
+
"KR",
|
|
148
|
+
"KW",
|
|
149
|
+
"KY",
|
|
150
|
+
"KZ",
|
|
151
|
+
"LA",
|
|
152
|
+
"LB",
|
|
153
|
+
"LC",
|
|
154
|
+
"LI",
|
|
155
|
+
"LK",
|
|
156
|
+
"LR",
|
|
157
|
+
"LS",
|
|
158
|
+
"LT",
|
|
159
|
+
"LU",
|
|
160
|
+
"LV",
|
|
161
|
+
"LY",
|
|
162
|
+
"MA",
|
|
163
|
+
"MC",
|
|
164
|
+
"MD",
|
|
165
|
+
"ME",
|
|
166
|
+
"MF",
|
|
167
|
+
"MG",
|
|
168
|
+
"MH",
|
|
169
|
+
"MK",
|
|
170
|
+
"ML",
|
|
171
|
+
"MM",
|
|
172
|
+
"MN",
|
|
173
|
+
"MO",
|
|
174
|
+
"MP",
|
|
175
|
+
"MQ",
|
|
176
|
+
"MR",
|
|
177
|
+
"MS",
|
|
178
|
+
"MT",
|
|
179
|
+
"MU",
|
|
180
|
+
"MV",
|
|
181
|
+
"MW",
|
|
182
|
+
"MX",
|
|
183
|
+
"MY",
|
|
184
|
+
"MZ",
|
|
185
|
+
"NA",
|
|
186
|
+
"NC",
|
|
187
|
+
"NE",
|
|
188
|
+
"NF",
|
|
189
|
+
"NG",
|
|
190
|
+
"NI",
|
|
191
|
+
"NL",
|
|
192
|
+
"NO",
|
|
193
|
+
"NP",
|
|
194
|
+
"NR",
|
|
195
|
+
"NU",
|
|
196
|
+
"NZ",
|
|
197
|
+
"OM",
|
|
198
|
+
"PA",
|
|
199
|
+
"PE",
|
|
200
|
+
"PF",
|
|
201
|
+
"PG",
|
|
202
|
+
"PH",
|
|
203
|
+
"PK",
|
|
204
|
+
"PL",
|
|
205
|
+
"PM",
|
|
206
|
+
"PN",
|
|
207
|
+
"PR",
|
|
208
|
+
"PS",
|
|
209
|
+
"PT",
|
|
210
|
+
"PW",
|
|
211
|
+
"PY",
|
|
212
|
+
"QA",
|
|
213
|
+
"RE",
|
|
214
|
+
"RO",
|
|
215
|
+
"RS",
|
|
216
|
+
"RU",
|
|
217
|
+
"RW",
|
|
218
|
+
"SA",
|
|
219
|
+
"SB",
|
|
220
|
+
"SC",
|
|
221
|
+
"SD",
|
|
222
|
+
"SE",
|
|
223
|
+
"SG",
|
|
224
|
+
"SH",
|
|
225
|
+
"SI",
|
|
226
|
+
"SJ",
|
|
227
|
+
"SK",
|
|
228
|
+
"SL",
|
|
229
|
+
"SM",
|
|
230
|
+
"SN",
|
|
231
|
+
"SO",
|
|
232
|
+
"SR",
|
|
233
|
+
"SS",
|
|
234
|
+
"ST",
|
|
235
|
+
"SV",
|
|
236
|
+
"SX",
|
|
237
|
+
"SY",
|
|
238
|
+
"SZ",
|
|
239
|
+
"TC",
|
|
240
|
+
"TD",
|
|
241
|
+
"TF",
|
|
242
|
+
"TG",
|
|
243
|
+
"TH",
|
|
244
|
+
"TJ",
|
|
245
|
+
"TK",
|
|
246
|
+
"TL",
|
|
247
|
+
"TM",
|
|
248
|
+
"TN",
|
|
249
|
+
"TO",
|
|
250
|
+
"TR",
|
|
251
|
+
"TT",
|
|
252
|
+
"TV",
|
|
253
|
+
"TW",
|
|
254
|
+
"TZ",
|
|
255
|
+
"UA",
|
|
256
|
+
"UG",
|
|
257
|
+
"UM",
|
|
258
|
+
"US",
|
|
259
|
+
"UY",
|
|
260
|
+
"UZ",
|
|
261
|
+
"VA",
|
|
262
|
+
"VC",
|
|
263
|
+
"VE",
|
|
264
|
+
"VG",
|
|
265
|
+
"VI",
|
|
266
|
+
"VN",
|
|
267
|
+
"VU",
|
|
268
|
+
"WF",
|
|
269
|
+
"WS",
|
|
270
|
+
"YE",
|
|
271
|
+
"YT",
|
|
272
|
+
"ZA",
|
|
273
|
+
"ZM",
|
|
274
|
+
"ZW"
|
|
275
|
+
]
|
|
276
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Revision marker rather than a new date: the ISO 20022 External Code Set
|
|
7
|
+
# vintage is unchanged: only the ISO 3166-1 alpha-2 list was completed, from a
|
|
8
|
+
# 10-country stub to the full 249 officially assigned codes. Dating it 2026-08
|
|
9
|
+
# would claim a code-set release that did not happen.
|
|
10
|
+
SNAPSHOT_VERSION = "iso20022-codesets-2026-06-r2"
|
|
11
|
+
_DATA_PATH = Path(__file__).resolve().parent / "data" / "iso20022_codesets.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_snapshot() -> dict[str, set[str]]:
|
|
15
|
+
if not _DATA_PATH.exists():
|
|
16
|
+
return {}
|
|
17
|
+
with _DATA_PATH.open("r", encoding="utf-8") as handle:
|
|
18
|
+
raw = json.load(handle)
|
|
19
|
+
return {name: set(values) for name, values in raw.items()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_CODES = _load_snapshot()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_snapshot_version() -> str:
|
|
26
|
+
return SNAPSHOT_VERSION
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_valid(set_name: str, code: str) -> bool:
|
|
30
|
+
values = _CODES.get(set_name, set())
|
|
31
|
+
return code in values
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_codes(set_name: str) -> set[str]:
|
|
35
|
+
return set(_CODES.get(set_name, set()))
|