File Coverage

blib/lib/Business/CAMT.pm
Criterion Covered Total %
statement 33 116 28.4
branch 0 50 0.0
condition 0 23 0.0
subroutine 11 28 39.2
pod 12 13 92.3
total 56 230 24.3


line stmt bran cond sub pod time code
1             # This code is part of Perl distribution Business-CAMT version 0.14.
2             # The POD got stripped from this file by OODoc version 3.05.
3             # For contributors see file ChangeLog.
4              
5             # This software is copyright (c) 2024-2025 by Mark Overmeer.
6              
7             # This is free software; you can redistribute it and/or modify it under
8             # the same terms as the Perl 5 programming language system itself.
9             # SPDX-License-Identifier: Artistic-1.0-Perl OR GPL-1.0-or-later
10              
11             #oodist: *** DO NOT USE THIS VERSION FOR PRODUCTION ***
12             #oodist: This file contains OODoc-style documentation which will get stripped
13             #oodist: during its release in the distribution. You can use this file for
14             #oodist: testing, however the code of this development version may be broken!
15              
16             # https://www.betaalvereniging.nl/wp-content/uploads/IG-Bank-to-Customer-Statement-CAMT-053-v1-1.pdf
17              
18             package Business::CAMT;{
19             our $VERSION = '0.14';
20             }
21              
22              
23 1     1   1197 use strict;
  1         5  
  1         29  
24 1     1   3 use warnings;
  1         1  
  1         43  
25 1     1   4 use utf8;
  1         1  
  1         8  
26              
27 1     1   25 use Log::Report 'business-camt';
  1         2  
  1         5  
28              
29 1     1   709 use Path::Class ();
  1         30709  
  1         42  
30 1     1   12 use XML::LibXML ();
  1         2  
  1         13  
31 1     1   5 use XML::Compile::Cache ();
  1         3  
  1         26  
32 1     1   7 use Scalar::Util qw/blessed/;
  1         2  
  1         88  
33 1     1   5 use List::Util qw/first/;
  1         3  
  1         64  
34 1     1   6 use XML::Compile::Util qw/pack_type/;
  1         3  
  1         47  
35              
36 1     1   5 use Business::CAMT::Message ();
  1         1  
  1         2520  
37              
38             my $urnbase = 'urn:iso:std:iso:20022:tech:xsd';
39             my $moddir = Path::Class::File->new(__FILE__)->dir;
40             my $xsddir = $moddir->subdir('CAMT', 'xsd');
41             my $tagdir = $moddir->subdir('CAMT', 'tags');
42 0     0     sub _rootElement($) { pack_type $_[1], 'Document' } # $ns parameter
43              
44             # The XSD filename is like camt.052.001.12.xsd. camt.052.001.* is
45             # expected to be incompatible with camt.052.002.*, but *.12.xsd can
46             # usually parse *.11.xsd
47             my %xsd_files;
48              
49             # Translations from abbreviated XML tags to longer names, loaded on
50             # demand.
51             my $tagtable;
52              
53              
54             sub new(%)
55 0     0 1   { my ($class, %args) = @_;
56 0           (bless {}, $class)->init(\%args);
57             }
58              
59             sub init($)
60 0     0 0   { my ($self, $args) = @_;
61              
62             # Collect the names of all CAMT schemes in this distribution
63 0   0       foreach my $f (grep !$_->is_dir && $_->basename =~ /\.xsd$/, $xsddir->children)
64 0 0         { $f->basename =~ /^camt\.([0-9]{3}\.[0-9]{3})\.([0-9]+)\.xsd$/ or panic $f;
65 0           $xsd_files{$1}{$2} = $f->stringify;
66             }
67              
68 0   0       $self->{BC_rule} = delete $args->{match_schema} || 'NEWER';
69 0   0       $self->{BC_big} = delete $args->{big_numbers} || 0;
70 0   0       $self->{BC_long} = delete $args->{long_tagnames} || 0;
71 0           $self->{RC_schemas} = XML::Compile::Cache->new;
72              
73 0           $self;
74             }
75              
76             #--------------------
77              
78 0     0 1   sub schemas() { $_[0]->{RC_schemas} }
79              
80             #--------------------
81              
82             sub read($%)
83 0     0 1   { my ($self, $src, %args) = @_;
84              
85 0 0 0       my $dom
    0          
    0          
    0          
86             = ! ref $src ? XML::LibXML->load_xml($src =~ /\<.*\>/ ? (string => $src) : (location => $src))
87             : $src->isa('IO::Handle') || $src->isa('GLOB') ? XML::LibXML->load_xml(IO => $src)
88             : $src->isa('XML::LibXML::Node') ? $src
89             : error "Unrecognized input";
90              
91 0 0         my $xml = $dom->isa('XML::LibXML::Document') ? $dom->documentElement : $dom;
92              
93 0           my $ns = $xml->namespaceURI;
94 0 0         my ($set, $version) = $ns =~ m!^\Q$urnbase\E:camt\.([0-9]{3}\.[0-9]{3})\.([0-9]+)$!
95             or error __"Not a CAMT file.";
96              
97 0 0         my $versions = $xsd_files{$set}
98             or error __"Not a supported CAMT message type.";
99              
100             my $xsd_version = $self->matchSchema($set, $version, rule => $args{match_schema})
101 0 0         or error __"No compatible schema version available.";
102              
103 0 0         if($xsd_version != $version)
104 0           { trace "Using $set schema version $xsd_version to read a version $version message.";
105 0           $ns = "$urnbase:camt.$set.$xsd_version";
106 0           $xml->setNamespaceDeclURI('', $ns);
107             }
108              
109 0           my $reader = $self->schemaReader($set, $xsd_version, $ns);
110              
111 0           Business::CAMT::Message->fromData(
112             set => $set,
113             version => $xsd_version,
114             data => $reader->($xml),
115             camt => $self,
116             );
117             }
118              
119              
120             sub fromHASH($%)
121 0     0 1   { my ($self, $data, %args) = @_;
122 0 0         my $type = $args{type} or panic;
123 0 0         my ($set, $version) = $type =~ /^(?:camt\.)?([0-9]+\.[0-9]+)\.([0-9]+)$/
124             or error __x"Unknown message type '{type}'", type => $type;
125              
126 0           Business::CAMT::Message->fromData(
127             set => $set,
128             version => $version,
129             data => $data,
130             camt => $self,
131             );
132             }
133              
134              
135             sub create($$%)
136 0     0 1   { my ($self, $type, $data) = @_;
137 0 0         my ($set, $version) = $type =~ /^(?:camt\.)?([0-9]+\.[0-9]+)\.([0-9]+)$/
138             or error __x"Unknown message type '{type}'", type => $type;
139              
140 0           Business::CAMT::Message->create(
141             set => $set,
142             version => $version,
143             data => $data,
144             camt => $self,
145             );
146             }
147              
148              
149             sub write($$%)
150 0     0 1   { my ($self, $fn, $msg, %args) = @_;
151              
152 0           my $set = $msg->set;
153 0 0         my $versions = $xsd_files{$set}
154             or error __x"Message set '{set}' is unsupported.", set => $set;
155              
156 0           my @versions = sort { $a <=> $b } keys %$versions;
  0            
157 0           my $version = $msg->version;
158 0 0         grep $version eq $_, @versions
159             or error __x"Schema version {version} is not available, pick from {versions}.", version => $version, versions => \@versions;
160              
161 0           my $ns = "$urnbase:camt.$set.$version";
162 0           my $writer = $self->schemaWriter($set, $version, $ns);
163              
164 0           my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
165 0           my $xml = $writer->($doc, $msg);
166 0           $doc->setDocumentElement($xml);
167              
168 0 0         if(ref $fn eq 'GLOB') { $doc->toFH($fn, 1) } else { $doc->toFile($fn, 1) }
  0            
  0            
169              
170 0           $xml;
171             }
172              
173             #--------------------
174              
175             sub _loadXsd($$)
176 0     0     { my ($self, $set, $version) = @_;
177 0           my $file = $xsd_files{$set}{$version};
178 0 0         $self->{BC_loaded}{$file}++ or $self->schemas->importDefinitions($file);
179             }
180              
181             my %msg_readers;
182             sub schemaReader($$$)
183 0     0 1   { my ($self, $set, $version, $ns) = @_;
184 0   0       my $r = $self->{BC_r} ||= {};
185 0 0         return $r->{$ns} if $r->{$ns};
186              
187 0           $self->_loadXsd($set, $version);
188              
189             $r->{$ns} = $self->schemas->compile(
190             READER => $self->_rootElement($ns),
191             sloppy_floats => !$self->{BC_big},
192 0 0         key_rewrite => $self->{BC_long} ? $self->tag2fullnameTable : undef,
193             );
194             }
195              
196              
197             sub schemaWriter($$$)
198 0     0 1   { my ($self, $set, $version, $ns) = @_;
199 0   0       my $w = $self->{BC_w} ||= {};
200 0 0         return $w->{$ns} if $w->{$ns};
201              
202 0           $self->_loadXsd($set, $version);
203             $w->{$ns} = $self->schemas->compile(
204             WRITER => $self->_rootElement($ns),
205             sloppy_floats => !$self->{BC_big},
206 0 0         key_rewrite => $self->{BC_long} ? $self->tag2fullnameTable : undef,
207             ignore_unused_tags => qr/^_attrs$/,
208             prefixes => { $ns => '' },
209             );
210             }
211              
212              
213              
214             # called with ($set, $version, \@available_versions)
215 0     0     sub _exact { first { $_[1] eq $_ } @{$_[2]} }
  0     0      
  0            
216              
217             my %rules = (
218             EXACT => \&_exact,
219             NEWER => sub { (grep $_ >= $_[1], @{$_[2]})[0] },
220             NEWEST => sub { _exact(@_) || ($_[1] <= $_[2][-1] ? $_[2][-1] : undef) },
221             ANY => sub { _exact(@_) || $_[2][-1] },
222             );
223              
224             sub matchSchema($$%)
225 0     0 1   { my ($self, $set, $version, %args) = @_;
226 0 0         my $versions = $xsd_files{$set} or panic "Unknown set $set";
227              
228 0   0       my $ruler = $args{rule} ||= $self->{BC_rule};
229 0 0         my $rule = ref $ruler eq 'CODE' ? $ruler : $rules{$ruler}
    0          
230             or error __x"Unknown schema match rule '{rule}'.", rule => $ruler;
231              
232 0           $rule->($set, $version, [ sort { $a <=> $b } keys %$versions ]);
  0            
233             }
234              
235              
236             sub knownVersions(;$)
237 0     0 1   { my ($self, $set) = @_;
238 0           my @s;
239 0 0         foreach my $s ($set ? $set : sort keys %xsd_files)
240 0           { push @s, map "camt.$s.$_", sort {$a <=> $b} keys %{$xsd_files{$s}};
  0            
  0            
241             }
242 0           @s;
243             }
244              
245              
246             sub fullname2tagTable()
247 0     0 1   { my $self = shift;
248 0   0       $self->{BC_toAbbr} ||= +{ reverse %{$self->tag2fullnameTable} };
  0            
249             }
250              
251              
252             sub tag2fullnameTable()
253 0     0 1   { my $self = shift;
254             $self->{BC_toLong} ||= +{
255 0   0       map split(/,/, $_, 2), grep !/,$/, $tagdir->file('index.csv')->slurp(chomp => 1)
256             };
257             }
258              
259             #--------------------
260              
261             1;