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