_replace_special_chars in Helper ausgelagert.
[kivitendo-erp.git] / SL / SEPA / XML.pm
1 package SL::SEPA::XML;
2
3 use strict;
4 use utf8;
5
6 use Carp;
7 use Encode;
8 use List::Util qw(first sum);
9 use List::MoreUtils qw(any);
10 use POSIX qw(strftime);
11 use XML::Writer;
12
13 use SL::Iconv;
14 use SL::SEPA::XML::Transaction;
15 use SL::DB::Helper::ReplaceSpecialChars qw(replace_special_chars);
16
17 sub new {
18   my $class = shift;
19   my $self  = {};
20
21   bless $self, $class;
22
23   $self->_init(@_);
24
25   return $self;
26 }
27
28 sub _init {
29   my $self              = shift;
30   my %params            = @_;
31
32   $self->{transactions} = [];
33   $self->{src_charset}  = 'UTF-8';
34   $self->{grouped}      = 0;
35
36   map { $self->{$_} = $params{$_} if (exists $params{$_}) } qw(src_charset company creditor_id message_id grouped collection);
37
38   $self->{iconv} = SL::Iconv->new($self->{src_charset}, "UTF-8") || croak "Unsupported source charset $self->{src_charset}.";
39
40   my $missing_parameter = first { !$self->{$_} } qw(company message_id);
41   croak "Missing parameter: $missing_parameter" if ($missing_parameter);
42   croak "Missing parameter: creditor_id"        if !$self->{creditor_id} && $self->{collection};
43
44   map { $self->{$_} = replace_special_chars($self->{iconv}->convert($self->{$_})) } qw(company message_id creditor_id);
45 }
46
47 sub add_transaction {
48   my $self = shift;
49
50   foreach my $transaction (@_) {
51     croak "Expecting hash reference." if (ref $transaction ne 'HASH');
52     push @{ $self->{transactions} }, SL::SEPA::XML::Transaction->new(%{ $transaction }, 'sepa' => $self);
53   }
54
55   return 1;
56 }
57
58 sub _format_amount {
59   my $self   = shift;
60   my $amount = shift;
61
62   return sprintf '%.02f', $amount;
63 }
64
65 sub _group_transactions {
66   my $self    = shift;
67
68   my $grouped = {
69     'sum_amount' => 0,
70     'groups'     => { },
71   };
72
73   foreach my $transaction (@{ $self->{transactions} }) {
74     my $key                      = $self->{grouped} ? join("\t", map { $transaction->get($_) } qw(src_bic src_iban execution_date)) : 'all';
75     $grouped->{groups}->{$key} ||= {
76       'sum_amount'   => 0,
77       'transactions' => [ ],
78     };
79
80     push @{ $grouped->{groups}->{$key}->{transactions} }, $transaction;
81
82     $grouped->{groups}->{$key}->{sum_amount} += $transaction->{amount};
83     $grouped->{sum_amount}                   += $transaction->{amount};
84   }
85
86   return $grouped;
87 }
88
89 sub _restricted_identification_sepa1 {
90   my ($self, $string) = @_;
91
92   $string =~ s/[^A-Za-z0-9\+\?\/\-:\(\)\.,' ]//g;
93   return substr $string, 0, 35;
94 }
95
96 sub _restricted_identification_sepa2 {
97   my ($self, $string) = @_;
98
99   $string =~ s/[^A-Za-z0-9\+\?\/\-:\(\)\.,']//g;
100   return substr $string, 0, 35;
101 }
102
103 sub to_xml {
104   my $self = shift;
105
106   croak "No transactions added yet." if (!@{ $self->{transactions} });
107
108   my $output = '';
109
110   my $xml    = XML::Writer->new(OUTPUT      => \$output,
111                                 DATA_MODE   => 1,
112                                 DATA_INDENT => 2,
113                                 ENCODING    => 'utf-8');
114
115   my @now       = localtime;
116   my $time_zone = strftime "%z", @now;
117   my $now_str   = strftime('%Y-%m-%dT%H:%M:%S', @now) . substr($time_zone, 0, 3) . ':' . substr($time_zone, 3, 2);
118
119   my $is_coll   = $self->{collection};
120   my $cd_src    = $is_coll ? 'Cdtr'              : 'Dbtr';
121   my $cd_dst    = $is_coll ? 'Dbtr'              : 'Cdtr';
122   my $pain_id   = $is_coll ? 'pain.008.002.02'   : 'pain.001.002.03';
123   my $pain_elmt = $is_coll ? 'CstmrDrctDbtInitn' : 'CstmrCdtTrfInitn';
124   my @pii_base  = (strftime('PII%Y%m%d%H%M%S', @now), rand(1000000000));
125
126   my $grouped_transactions = $self->_group_transactions();
127
128   $xml->xmlDecl();
129
130   $xml->startTag('Document',
131                  'xmlns'              => "urn:iso:std:iso:20022:tech:xsd:${pain_id}",
132                  'xmlns:xsi'          => 'http://www.w3.org/2001/XMLSchema-instance',
133                  'xsi:schemaLocation' => "urn:iso:std:iso:20022:tech:xsd:${pain_id} ${pain_id}.xsd");
134
135   $xml->startTag($pain_elmt);
136
137   $xml->startTag('GrpHdr');
138   $xml->dataElement('MsgId', encode('UTF-8', $self->_restricted_identification_sepa1($self->{message_id})));
139   $xml->dataElement('CreDtTm', $now_str);
140   $xml->dataElement('NbOfTxs', scalar @{ $self->{transactions} });
141   $xml->dataElement('CtrlSum', $self->_format_amount($grouped_transactions->{sum_amount}));
142
143   $xml->startTag('InitgPty');
144   $xml->dataElement('Nm', encode('UTF-8', substr($self->{company}, 0, 70)));
145   $xml->endTag('InitgPty');
146
147   $xml->endTag('GrpHdr');
148
149   foreach my $key (keys %{ $grouped_transactions->{groups} }) {
150     my $transaction_group  = $grouped_transactions->{groups}->{$key};
151     my $master_transaction = $transaction_group->{transactions}->[0];
152
153     $xml->startTag('PmtInf');
154     $xml->dataElement('PmtInfId', sprintf('%s%010d', @pii_base));
155     $pii_base[1]++;
156     $xml->dataElement('PmtMtd', $is_coll ? 'DD' : 'TRF');
157     $xml->dataElement('NbOfTxs', scalar @{ $transaction_group->{transactions} });
158     $xml->dataElement('CtrlSum', $self->_format_amount($transaction_group->{sum_amount}));
159
160     $xml->startTag('PmtTpInf');
161     $xml->startTag('SvcLvl');
162     $xml->dataElement('Cd', 'SEPA');
163     $xml->endTag('SvcLvl');
164
165     if ($is_coll) {
166       $xml->startTag('LclInstrm');
167       $xml->dataElement('Cd', 'CORE');
168       $xml->endTag('LclInstrm');
169       $xml->dataElement('SeqTp', 'OOFF');
170     }
171     $xml->endTag('PmtTpInf');
172
173     $xml->dataElement($is_coll ? 'ReqdColltnDt' : 'ReqdExctnDt', $master_transaction->get('execution_date'));
174     $xml->startTag($cd_src);
175     $xml->dataElement('Nm', encode('UTF-8', substr($self->{company}, 0, 70)));
176     $xml->endTag($cd_src);
177
178     $xml->startTag($cd_src . 'Acct');
179     $xml->startTag('Id');
180     $xml->dataElement('IBAN', $master_transaction->get('src_iban', 34));
181     $xml->endTag('Id');
182     $xml->endTag($cd_src . 'Acct');
183
184     $xml->startTag($cd_src . 'Agt');
185     $xml->startTag('FinInstnId');
186     $xml->dataElement('BIC', $master_transaction->get('src_bic', 20));
187     $xml->endTag('FinInstnId');
188     $xml->endTag($cd_src . 'Agt');
189
190     $xml->dataElement('ChrgBr', 'SLEV');
191
192     foreach my $transaction (@{ $transaction_group->{transactions} }) {
193       $xml->startTag($is_coll ? 'DrctDbtTxInf' : 'CdtTrfTxInf');
194
195       $xml->startTag('PmtId');
196       $xml->dataElement('EndToEndId', $self->_restricted_identification_sepa1($transaction->get('end_to_end_id')));
197       $xml->endTag('PmtId');
198
199       if ($is_coll) {
200         $xml->startTag('InstdAmt', 'Ccy' => 'EUR');
201         $xml->characters($self->_format_amount($transaction->{amount}));
202         $xml->endTag('InstdAmt');
203
204         $xml->startTag('DrctDbtTx');
205
206         $xml->startTag('MndtRltdInf');
207         $xml->dataElement('MndtId', $self->_restricted_identification_sepa2($transaction->get('mandator_id')));
208         $xml->dataElement('DtOfSgntr', $self->_restricted_identification_sepa2($transaction->get('date_of_signature')));
209         $xml->endTag('MndtRltdInf');
210
211         $xml->startTag('CdtrSchmeId');
212         $xml->startTag('Id');
213         $xml->startTag('PrvtId');
214         $xml->startTag('Othr');
215         $xml->dataElement('Id', encode('UTF-8', substr($self->{creditor_id}, 0, 35)));
216         $xml->startTag('SchmeNm');
217         $xml->dataElement('Prtry', 'SEPA');
218         $xml->endTag('SchmeNm');
219         $xml->endTag('Othr');
220         $xml->endTag('PrvtId');
221         $xml->endTag('Id');
222         $xml->endTag('CdtrSchmeId');
223
224         $xml->endTag('DrctDbtTx');
225
226       } else {
227         $xml->startTag('Amt');
228         $xml->startTag('InstdAmt', 'Ccy' => 'EUR');
229         $xml->characters($self->_format_amount($transaction->{amount}));
230         $xml->endTag('InstdAmt');
231         $xml->endTag('Amt');
232       }
233
234       $xml->startTag("${cd_dst}Agt");
235       $xml->startTag('FinInstnId');
236       $xml->dataElement('BIC', $transaction->get('dst_bic', 20));
237       $xml->endTag('FinInstnId');
238       $xml->endTag("${cd_dst}Agt");
239
240       $xml->startTag("${cd_dst}");
241       $xml->dataElement('Nm', $transaction->get('company', 70));
242       $xml->endTag("${cd_dst}");
243
244       $xml->startTag("${cd_dst}Acct");
245       $xml->startTag('Id');
246       $xml->dataElement('IBAN', $transaction->get('dst_iban', 34));
247       $xml->endTag('Id');
248       $xml->endTag("${cd_dst}Acct");
249
250       $xml->startTag('RmtInf');
251       $xml->dataElement('Ustrd', $transaction->get('reference', 140));
252       $xml->endTag('RmtInf');
253
254       $xml->endTag($is_coll ? 'DrctDbtTxInf' : 'CdtTrfTxInf');
255     }
256
257     $xml->endTag('PmtInf');
258   }
259
260   $xml->endTag($pain_elmt);
261   $xml->endTag('Document');
262
263   return $output;
264 }
265
266 1;
267
268 # Local Variables:
269 # coding: utf-8
270 # End: