File Coverage

blib/lib/PDF/FacturX/XML.pm
Criterion Covered Total %
statement 274 308 88.9
branch 80 130 61.5
condition 65 150 43.3
subroutine 27 27 100.0
pod 4 4 100.0
total 450 619 72.7


line stmt bran cond sub pod time code
1             package PDF::FacturX::XML;
2 9     9   1002442 use strict;
  9         20  
  9         373  
3 9     9   61 use warnings;
  9         17  
  9         512  
4 9     9   51 use utf8;
  9         16  
  9         103  
5 9     9   377 use Exporter 'import';
  9         21  
  9         364  
6 9     9   4270 use XML::LibXML;
  9         345705  
  9         61  
7 9     9   1885 use Encode qw(decode_utf8);
  9         21  
  9         1176  
8 9     9   5835 use File::ShareDir qw(dist_dir);
  9         331089  
  9         767  
9 9     9   104 use File::Spec;
  9         28  
  9         49554  
10              
11             our $VERSION = '0.01';
12              
13             our @EXPORT_OK = qw(build_xml guideline_id validate_xml xsd_root_for);
14              
15             my %NS = (
16             rsm => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
17             qdt => 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
18             ram => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
19             xsi => 'http://www.w3.org/2001/XMLSchema-instance',
20             udt => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100',
21             );
22              
23             my %GUIDELINE = (
24             minimum => 'urn:factur-x.eu:1p0:minimum',
25             basicwl => 'urn:factur-x.eu:1p0:basicwl',
26             basic => 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic',
27             en16931 => 'urn:cen.eu:en16931:2017',
28             );
29              
30             my %VALID_VAT_CAT = map { $_ => 1 } qw(S Z E AE K G L M O);
31              
32             sub guideline_id {
33 2     2 1 186037 my ($profile) = @_;
34 2   50     16 return $GUIDELINE{ $profile // 'basic' };
35             }
36              
37             # Résout le dossier XSD pour un profil donné. En mode installé, utilise
38             # File::ShareDir; en mode dev (avant install), tombe sur le `share/` à
39             # la racine du checkout via le chemin de ce module.
40             sub xsd_root_for {
41 6     6 1 163558 my ($profile) = @_;
42 6   50     36 $profile //= 'basic';
43             die "Profil Factur-X inconnu: $profile\n"
44 6 50       21 unless exists $GUIDELINE{$profile};
45              
46 6         9 my $share_dir;
47 6 50       10 eval { $share_dir = dist_dir('PDF-FacturX'); 1 } or do {
  6         39  
  6         970  
48             # Fallback dev : ../../../../share relatif à lib/PDF/FacturX/XML.pm
49 0         0 my $here = __FILE__;
50 0         0 my @parts = File::Spec->splitpath($here);
51 0         0 my $dir = $parts[1];
52 0         0 $share_dir = File::Spec->catdir($dir, '..', '..', '..', 'share');
53             };
54 6         81 return File::Spec->catdir($share_dir, 'xsd', $profile);
55             }
56              
57             ###############################################################################
58             # build_xml($invoice_hashref, $profile)
59             #
60             # Génère le XML CrossIndustryInvoice (Factur-X 1.0.8) pour la facture
61             # décrite par $invoice. Profil par défaut : 'basic'. Lève une exception
62             # si le hash ne respecte pas le minimum requis pour le profil.
63             #
64             # Format attendu :
65             #
66             # {
67             # number => 'FA-2026-0042',
68             # date => '2026-04-19', # ISO YYYY-MM-DD
69             # due_date => '2026-05-19',
70             # currency => 'EUR',
71             # type_code => 380, # 380 = facture ; 381 = avoir
72             # notes => '...',
73             #
74             # seller => { name, address_1, address_2, postcode, city, country,
75             # siret, vat },
76             # buyer => { name, address_1, address_2, postcode, city, country },
77             #
78             # lines => [
79             # { name, qty, unit, unit_price,
80             # vat_rate, vat_cat, # vat_cat : S|Z|E|AE|K|G|L|M|O
81             # vat_exemption_reason, # optionnel, par ligne
82             # }, ...
83             # ],
84             #
85             # allowances => [
86             # { amount, reason, vat_rate, vat_cat, is_charge => 0|1 }, ...
87             # ],
88             #
89             # vat_exemption_reason => '...', # global, par défaut pour les lignes
90             # # exonérées sans motif explicite.
91             #
92             # payment => { terms, iban, bic },
93             # }
94             ###############################################################################
95             sub build_xml {
96 20     20 1 649575 my ($invoice, $profile) = @_;
97 20   50     69 $profile //= 'basic';
98 20         110 _validate_invoice($invoice, $profile);
99              
100 8         231 my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
101 8         158 my $root = $doc->createElementNS($NS{rsm}, 'rsm:CrossIndustryInvoice');
102 8         63 $root->setNamespace($NS{qdt}, 'qdt', 0);
103 8         259 $root->setNamespace($NS{ram}, 'ram', 0);
104 8         97 $root->setNamespace($NS{xsi}, 'xsi', 0);
105 8         99 $root->setNamespace($NS{udt}, 'udt', 0);
106 8         97 $doc->setDocumentElement($root);
107              
108 8         176 _build_document_context($root, $profile);
109 8         404 _build_exchanged_document($root, $invoice);
110 8         171 _build_trade_transaction($root, $invoice, $profile);
111              
112 8         436 return decode_utf8($doc->toString(1));
113             }
114              
115             ###############################################################################
116             # validate_xml($xml_string, $profile, $xsd_root?)
117             # → (1, 'OK') ou (0, "message d'erreur")
118             #
119             # Si $xsd_root n'est pas fourni, le résoud automatiquement via File::ShareDir.
120             ###############################################################################
121             sub validate_xml {
122 5     5 1 1039 my ($xml_string, $profile, $xsd_root) = @_;
123 5   50     14 $profile //= 'basic';
124 5   33     28 $xsd_root //= xsd_root_for($profile);
125 5 50 33     83 return (0, "xsd_root introuvable : $xsd_root")
126             unless $xsd_root && -d $xsd_root;
127              
128             # Le fichier racine est celui SANS suffixe `urn_un_unece`. Les 3 autres
129             # XSD sont inclus via xs:include / xs:import avec schemaLocation relatif.
130 5         1062 my ($main_xsd) = grep { !/urn_un_unece/i } glob("$xsd_root/Factur-X_*.xsd");
  20         91  
131 5 50 33     109 return (0, "XSD racine introuvable dans $xsd_root")
132             unless $main_xsd && -r $main_xsd;
133              
134 5         11 my $schema = eval { XML::LibXML::Schema->new(location => $main_xsd) };
  5         41  
135 5 50       8009 return (0, "chargement XSD KO : $@") unless $schema;
136              
137 5         11 my $doc = eval { XML::LibXML->load_xml(string => $xml_string) };
  5         27  
138 5 50       2155 return (0, "parse XML KO : $@") unless $doc;
139              
140 5 100       30 eval { $schema->validate($doc); 1 } or do {
  5         392  
  4         14  
141 1   50     150 my $err = $@ // 'erreur inconnue';
142 1         5 chomp $err;
143 1         240 return (0, $err);
144             };
145 4         403 return (1, 'OK');
146             }
147              
148             ###############################################################################
149             # Validation maison (couche 1) : champs requis + formats.
150             # Lève une exception avec un message FR explicite. Le XSD valide ensuite
151             # (couche 2) les contraintes structurelles que ce code ne sait pas exprimer
152             # proprement (ordre des éléments, business rules BR-CO-*).
153             ###############################################################################
154             sub _validate_invoice {
155 20     20   37 my ($inv, $profile) = @_;
156             die "Profil Factur-X inconnu: $profile (attendu: "
157             . join('|', sort keys %GUIDELINE) . ")\n"
158 20 100       97 unless exists $GUIDELINE{$profile};
159              
160 18 100       72 die "invoice doit être un hashref\n"
161             unless ref $inv eq 'HASH';
162              
163 17         45 for my $field (qw(number date)) {
164             die "champ requis manquant : $field\n"
165 33 100 66     163 unless defined $inv->{$field} && length $inv->{$field};
166             }
167              
168 16         55 _check_iso_date('date', $inv->{date});
169 15 100       82 _check_iso_date('due_date', $inv->{due_date}) if defined $inv->{due_date};
170              
171 14         24 for my $party (qw(seller buyer)) {
172             die "champ requis manquant : $party\n"
173 26 100       76 unless ref $inv->{$party} eq 'HASH';
174             die "champ requis manquant : $party.name\n"
175 25 100 66     127 unless defined $inv->{$party}{name} && length $inv->{$party}{name};
176             }
177              
178 11 50       34 if (defined $inv->{currency}) {
179             die "currency : code ISO 4217 attendu (3 lettres maj), reçu : $inv->{currency}\n"
180 11 100       70 unless $inv->{currency} =~ /^[A-Z]{3}$/;
181             }
182              
183             # Lignes : requises pour profils basic et en16931, optionnelles pour
184             # minimum et basicwl. Si présentes, on valide leur structure.
185 10   100     210 my $needs_lines = ($profile eq 'basic' || $profile eq 'en16931');
186 10 100       32 if ($needs_lines) {
187             die "champ requis manquant pour profil $profile : lines (tableau non vide)\n"
188 7 100 66     51 unless ref $inv->{lines} eq 'ARRAY' && @{ $inv->{lines} };
  6         22  
189             }
190 9 50       30 if (ref $inv->{lines} eq 'ARRAY') {
191 9         20 my $i = 0;
192 9         17 for my $line (@{ $inv->{lines} }) {
  9         39  
193 14         23 $i++;
194 14 50       56 die "ligne #$i : doit être un hashref\n"
195             unless ref $line eq 'HASH';
196 14         25 for my $f (qw(name qty unit_price)) {
197             die "ligne #$i : champ requis manquant : $f\n"
198 42 50 33     152 unless defined $line->{$f} && length $line->{$f};
199             }
200 14   50     56 my $cat = $line->{vat_cat} // 'S';
201             die "ligne #$i : vat_cat invalide : $cat (attendu: "
202             . join('|', sort keys %VALID_VAT_CAT) . ")\n"
203 14 100       62 unless $VALID_VAT_CAT{$cat};
204             }
205             }
206              
207 8 50       70 if (ref $inv->{allowances} eq 'ARRAY') {
208 0         0 my $i = 0;
209 0         0 for my $a (@{ $inv->{allowances} }) {
  0         0  
210 0         0 $i++;
211 0 0       0 die "allowance #$i : doit être un hashref\n"
212             unless ref $a eq 'HASH';
213             die "allowance #$i : champ requis manquant : amount\n"
214 0 0       0 unless defined $a->{amount};
215 0   0     0 my $cat = $a->{vat_cat} // 'S';
216             die "allowance #$i : vat_cat invalide : $cat\n"
217 0 0       0 unless $VALID_VAT_CAT{$cat};
218             }
219             }
220              
221 8         17 return 1;
222             }
223              
224             sub _check_iso_date {
225 25     25   48 my ($name, $val) = @_;
226 25 50       56 return unless defined $val;
227 25 100       160 die "$name : format ISO YYYY-MM-DD attendu, reçu : $val\n"
228             unless $val =~ /^\d{4}-\d{2}-\d{2}$/;
229             }
230              
231             # ── rsm:ExchangedDocumentContext ─────────────────────────────────────────────
232             sub _build_document_context {
233 8     8   21 my ($root, $profile) = @_;
234 8         30 my $ctx = _el($root, 'rsm:ExchangedDocumentContext');
235 8         447 my $gp = _el($ctx, 'ram:GuidelineSpecifiedDocumentContextParameter');
236 8         108 _el($gp, 'ram:ID', $GUIDELINE{$profile});
237             }
238              
239             # ── rsm:ExchangedDocument ────────────────────────────────────────────────────
240             sub _build_exchanged_document {
241 8     8   22 my ($root, $inv) = @_;
242 8         17 my $doc = _el($root, 'rsm:ExchangedDocument');
243 8         100 _el($doc, 'ram:ID', $inv->{number});
244 8   50     165 _el($doc, 'ram:TypeCode', $inv->{type_code} // 380);
245 8         170 my $dt = _el($doc, 'ram:IssueDateTime');
246 8         139 _el($dt, 'udt:DateTimeString', _fmt_date($inv->{date}), format => '102');
247              
248 8 50 33     166 if (defined $inv->{notes} && length $inv->{notes}) {
249 0         0 my $n = _el($doc, 'ram:IncludedNote');
250 0         0 _el($n, 'ram:Content', $inv->{notes});
251             }
252             }
253              
254             # ── rsm:SupplyChainTradeTransaction ──────────────────────────────────────────
255             sub _build_trade_transaction {
256 8     8   20 my ($root, $inv, $profile) = @_;
257 8         18 my $trx = _el($root, 'rsm:SupplyChainTradeTransaction');
258              
259 8   100     103 my $with_lines = ($profile eq 'basic' || $profile eq 'en16931');
260 8 100       33 if ($with_lines) {
261 5         11 my $i = 0;
262 5 50       24 for my $line (@{ $inv->{lines} || [] }) {
  5         23  
263 8         257 $i++;
264 8         25 _build_line_item($trx, $line, $i);
265             }
266             }
267              
268 8         477 _build_header_agreement($trx, $inv, $profile);
269 8         187 _build_header_delivery($trx, $inv, $profile);
270 8         247 _build_header_settlement($trx, $inv, $profile);
271             }
272              
273             # Le XSD MINIMUM définit HeaderTradeDeliveryType comme un complexType vide :
274             # on ne peut rien y mettre. À partir de BASIC WL le type accepte (notamment)
275             # ActualDeliverySupplyChainEvent ; PEPPOL-EN16931-R008 interdit les
276             # éléments vides → on pose la date effective (repli date de facture).
277             sub _build_header_delivery {
278 8     8   19 my ($trx, $inv, $profile) = @_;
279 8         33 my $dlv = _el($trx, 'ram:ApplicableHeaderTradeDelivery');
280 8 100       95 return if $profile eq 'minimum';
281 6   33     33 my $date = $inv->{delivery_date} // $inv->{date};
282 6 50       19 return unless $date;
283 6         15 my $evt = _el($dlv, 'ram:ActualDeliverySupplyChainEvent');
284 6         66 my $occ = _el($evt, 'ram:OccurrenceDateTime');
285 6         69 _el($occ, 'udt:DateTimeString', _fmt_date($date), format => '102');
286             }
287              
288             sub _build_line_item {
289 8     8   21 my ($trx, $line, $line_id) = @_;
290 8         16 my $item = _el($trx, 'ram:IncludedSupplyChainTradeLineItem');
291              
292 8         121 my $adl = _el($item, 'ram:AssociatedDocumentLineDocument');
293 8         98 _el($adl, 'ram:LineID', $line_id);
294              
295 8         152 my $prod = _el($item, 'ram:SpecifiedTradeProduct');
296 8         102 _el($prod, 'ram:Name', $line->{name});
297              
298 8         169 my $agr = _el($item, 'ram:SpecifiedLineTradeAgreement');
299 8         161 my $price = _el($agr, 'ram:NetPriceProductTradePrice');
300 8         114 _el($price, 'ram:ChargeAmount', _fmt_amt($line->{unit_price}));
301              
302 8         150 my $dlv = _el($item, 'ram:SpecifiedLineTradeDelivery');
303             _el($dlv, 'ram:BilledQuantity', _fmt_qty($line->{qty}),
304 8   50     148 unitCode => ($line->{unit} || 'C62'));
305              
306 8         157 my $set = _el($item, 'ram:SpecifiedLineTradeSettlement');
307 8         94 my $tax = _el($set, 'ram:ApplicableTradeTax');
308 8   50     127 my $cat = $line->{vat_cat} || 'S';
309 8         24 _el($tax, 'ram:TypeCode', 'VAT');
310 8         148 _el($tax, 'ram:CategoryCode', $cat);
311             # BR-O-05 : pas de RateApplicablePercent pour la catégorie 'O'.
312 8 50 50     200 _el($tax, 'ram:RateApplicablePercent', _fmt_amt($line->{vat_rate} || 0))
313             unless $cat eq 'O';
314              
315 8   50     207 my $amount = ($line->{qty} || 0) * ($line->{unit_price} || 0);
      50        
316 8         45 my $sum = _el($set, 'ram:SpecifiedTradeSettlementLineMonetarySummation');
317 8         98 _el($sum, 'ram:LineTotalAmount', _fmt_amt($amount));
318             }
319              
320             sub _build_header_agreement {
321 8     8   22 my ($trx, $inv, $profile) = @_;
322 8         52 my $agr = _el($trx, 'ram:ApplicableHeaderTradeAgreement');
323 8   50     113 _build_trade_party($agr, 'ram:SellerTradeParty', $inv->{seller} || {}, 1, $profile);
324 8   50     308 _build_trade_party($agr, 'ram:BuyerTradeParty', $inv->{buyer} || {}, 0, $profile);
325             }
326              
327             # Ajoute SIRET + n° TVA seulement côté vendeur. Le profil MINIMUM restreint
328             # l'adresse postale à CountryID seulement (TradeAddressType minimum).
329             sub _build_trade_party {
330 16     16   43 my ($parent, $qname, $party, $is_seller, $profile) = @_;
331 16   50     36 $profile //= 'basic';
332 16         36 my $p = _el($parent, $qname);
333 16   50     406 _el($p, 'ram:Name', $party->{name} || '');
334              
335 16 100 100     306 if ($is_seller && $party->{siret}) {
336 6         25 my $org = _el($p, 'ram:SpecifiedLegalOrganization');
337             # schemeID 0002 = SIRENE (France)
338 6         70 _el($org, 'ram:ID', $party->{siret}, schemeID => '0002');
339             }
340              
341 16         155 my $addr = _el($p, 'ram:PostalTradeAddress');
342 16 100       215 if ($profile ne 'minimum') {
343 12 50       52 _el($addr, 'ram:PostcodeCode', $party->{postcode}) if $party->{postcode};
344 12 50       252 _el($addr, 'ram:LineOne', $party->{address_1}) if $party->{address_1};
345 12 50       225 _el($addr, 'ram:LineTwo', $party->{address_2}) if $party->{address_2};
346 12 50       248 _el($addr, 'ram:CityName', $party->{city}) if $party->{city};
347             }
348 16   50     300 _el($addr, 'ram:CountryID', $party->{country} || 'FR');
349              
350 16 100 100     292 if ($is_seller && $party->{vat}) {
351 6         10 my $tax = _el($p, 'ram:SpecifiedTaxRegistration');
352             # schemeID VA = TVA intracommunautaire
353 6         64 _el($tax, 'ram:ID', $party->{vat}, schemeID => 'VA');
354             }
355             }
356              
357             # ── ApplicableHeaderTradeSettlement ──────────────────────────────────────────
358             sub _build_header_settlement {
359 8     8   37 my ($trx, $inv, $profile) = @_;
360 8   50     849 $profile //= 'basic';
361 8         25 my $set = _el($trx, 'ram:ApplicableHeaderTradeSettlement');
362 8   50     171 _el($set, 'ram:InvoiceCurrencyCode', $inv->{currency} || 'EUR');
363              
364             # MINIMUM : seulement InvoiceCurrencyCode + MonetarySummation réduit.
365             # Pas de PaymentMeans, ApplicableTradeTax, AllowanceCharge, PaymentTerms.
366 8 100       177 if ($profile eq 'minimum') {
367 2         9 return _build_minimum_summary($set, $inv);
368             }
369              
370             # ORDRE XSD (CII BASIC) : PaymentMeans AVANT ApplicableTradeTax,
371             # ApplicableTradeTax avant SpecifiedTradePaymentTerms, et le
372             # MonetarySummation ferme le bloc.
373 6   100     25 my $pay = $inv->{payment} || {};
374 6 100       24 if ($pay->{iban}) {
375 5         24 my $means = _el($set, 'ram:SpecifiedTradeSettlementPaymentMeans');
376 5         58 _el($means, 'ram:TypeCode', '58'); # 58 = SEPA credit transfer
377 5         105 my $acct = _el($means, 'ram:PayeePartyCreditorFinancialAccount');
378 5         58 _el($acct, 'ram:IBANID', $pay->{iban});
379             # BIC (BT-86) — interdit par les profils MINIMUM/BASIC WL/BASIC.
380 5 100 100     93 if ($pay->{bic} && $profile eq 'en16931') {
381 1         3 my $fi = _el($means, 'ram:PayeeSpecifiedCreditorFinancialInstitution');
382 1         16 _el($fi, 'ram:BICID', $pay->{bic});
383             }
384             }
385              
386             # Agrégation TVA par (CategoryCode, taux). Un motif d'exonération est
387             # attaché au groupe quand la catégorie est E/AE/O/K/G ; la première
388             # ligne non vide gagne, avec repli sur $inv->{vat_exemption_reason}.
389 6         100 my %by_rate;
390 6         14 my $global_reason = $inv->{vat_exemption_reason};
391 6 50       9 for my $line (@{ $inv->{lines} || [] }) {
  6         27  
392 10   50     61 my $rate = 0 + ($line->{vat_rate} || 0);
393 10   50     30 my $cat = $line->{vat_cat} || 'S';
394 10   50     39 my $amt = ($line->{qty} || 0) * ($line->{unit_price} || 0);
      50        
395 10         56 my $key = "$cat:$rate";
396 10         38 $by_rate{$key}{cat} = $cat;
397 10         31 $by_rate{$key}{rate} = $rate;
398 10         22 $by_rate{$key}{basis} += $amt;
399 10         15 my $reason = $line->{vat_exemption_reason};
400 10 50 33     37 $reason = $global_reason if !defined $reason || !length $reason;
401 10 50 0     73 $by_rate{$key}{reason} //= $reason if defined $reason && length $reason;
      33        
402             }
403 6         10 my $allowance_total = 0;
404 6 50       12 for my $a (@{ $inv->{allowances} || [] }) {
  6         56  
405 0   0     0 my $rate = 0 + ($a->{vat_rate} || 0);
406 0   0     0 my $cat = $a->{vat_cat} || 'S';
407 0   0     0 my $amt = abs(0 + ($a->{amount} || 0));
408 0         0 my $key = "$cat:$rate";
409 0   0     0 $by_rate{$key}{cat} //= $cat;
410 0   0     0 $by_rate{$key}{rate} //= $rate;
411 0         0 $by_rate{$key}{basis} -= $amt;
412 0         0 $allowance_total += $amt;
413             }
414              
415 6         18 my ($line_total, $tax_total) = (0, 0);
416 6         39 for my $key (sort keys %by_rate) {
417 10         108 my $r = $by_rate{$key};
418 10         34 my $tax_amt = $r->{basis} * $r->{rate} / 100;
419 10         19 $line_total += $r->{basis};
420 10         19 $tax_total += $tax_amt;
421              
422 10         21 my $tax = _el($set, 'ram:ApplicableTradeTax');
423             # ORDRE IMPOSÉ PAR LE XSD : CalculatedAmount, TypeCode,
424             # ExemptionReason?, BasisAmount, CategoryCode, RateApplicablePercent.
425 10         164 _el($tax, 'ram:CalculatedAmount', _fmt_amt($tax_amt));
426 10         197 _el($tax, 'ram:TypeCode', 'VAT');
427 10 50       247 if ($r->{cat} =~ /^(E|AE|O|K|G)$/) {
428 0   0     0 my $reason = $r->{reason} // $global_reason;
429 0 0 0     0 _el($tax, 'ram:ExemptionReason', $reason)
430             if defined $reason && length $reason;
431             }
432 10         30 _el($tax, 'ram:BasisAmount', _fmt_amt($r->{basis}));
433 10         259 _el($tax, 'ram:CategoryCode', $r->{cat});
434             # BR-O-05 : pas de RateApplicablePercent pour la catégorie 'O'.
435             _el($tax, 'ram:RateApplicablePercent', _fmt_amt($r->{rate}))
436 10 50       208 unless $r->{cat} eq 'O';
437             }
438              
439             # Remises/charges globales (après ApplicableTradeTax, avant PaymentTerms).
440 6 50       232 for my $a (@{ $inv->{allowances} || [] }) {
  6         57  
441 0         0 my $ac = _el($set, 'ram:SpecifiedTradeAllowanceCharge');
442 0         0 my $ind = _el($ac, 'ram:ChargeIndicator');
443             # ChargeIndicator: true = charge (ajout), false = allowance (remise).
444 0 0       0 _el($ind, 'udt:Indicator', $a->{is_charge} ? 'true' : 'false');
445 0   0     0 _el($ac, 'ram:ActualAmount', _fmt_amt(abs($a->{amount} || 0)));
446 0 0       0 _el($ac, 'ram:Reason', $a->{reason}) if $a->{reason};
447 0         0 my $ctt = _el($ac, 'ram:CategoryTradeTax');
448 0         0 _el($ctt, 'ram:TypeCode', 'VAT');
449 0   0     0 _el($ctt, 'ram:CategoryCode', $a->{vat_cat} || 'S');
450             _el($ctt, 'ram:RateApplicablePercent', _fmt_amt($a->{vat_rate} || 0))
451 0 0 0     0 unless ($a->{vat_cat} // 'S') eq 'O';
      0        
452             }
453              
454 6         37 my $terms = _el($set, 'ram:SpecifiedTradePaymentTerms');
455 6 100       99 _el($terms, 'ram:Description', $pay->{terms}) if $pay->{terms};
456 6 50       106 if ($inv->{due_date}) {
457 6         15 my $d = _el($terms, 'ram:DueDateDateTime');
458 6         100 _el($d, 'udt:DateTimeString', _fmt_date($inv->{due_date}), format => '102');
459             }
460              
461             # Somme brute des lignes (avant remises). $line_total agrégé plus haut
462             # déduisait déjà les allowances — on recalcule le net proprement.
463 6         271 my $lines_gross = 0;
464 6 50       12 for my $line (@{ $inv->{lines} || [] }) {
  6         32  
465 10   50     67 $lines_gross += ($line->{qty} || 0) * ($line->{unit_price} || 0);
      50        
466             }
467 6         17 my $tax_basis = $lines_gross - $allowance_total;
468 6         12 my $grand = $tax_basis + $tax_total;
469              
470             # ORDRE XSD : LineTotal, ChargeTotal, AllowanceTotal, TaxBasis,
471             # TaxTotal, GrandTotal, TotalPrepaid, DuePayable.
472 6         14 my $sum = _el($set, 'ram:SpecifiedTradeSettlementHeaderMonetarySummation');
473 6         96 _el($sum, 'ram:LineTotalAmount', _fmt_amt($lines_gross));
474 6 50       115 _el($sum, 'ram:AllowanceTotalAmount', _fmt_amt($allowance_total))
475             if $allowance_total > 0;
476 6         17 _el($sum, 'ram:TaxBasisTotalAmount', _fmt_amt($tax_basis));
477             _el($sum, 'ram:TaxTotalAmount', _fmt_amt($tax_total),
478 6   50     130 currencyID => ($inv->{currency} || 'EUR'));
479 6         122 _el($sum, 'ram:GrandTotalAmount', _fmt_amt($grand));
480 6         119 _el($sum, 'ram:DuePayableAmount', _fmt_amt($grand));
481             }
482              
483             # Profil MINIMUM : MonetarySummation se limite à
484             # TaxBasisTotal, TaxTotal?, GrandTotal, DuePayable.
485             sub _build_minimum_summary {
486 2     2   5 my ($set, $inv) = @_;
487 2         4 my $lines_gross = 0;
488 2         3 my $tax_total = 0;
489 2 50       4 for my $line (@{ $inv->{lines} || [] }) {
  2         10  
490 3   50     11 my $base = ($line->{qty} || 0) * ($line->{unit_price} || 0);
      50        
491 3         5 $lines_gross += $base;
492 3   50     12 $tax_total += $base * ($line->{vat_rate} || 0) / 100;
493             }
494 2         4 my $allowance_total = 0;
495 2 50       3 for my $a (@{ $inv->{allowances} || [] }) {
  2         23  
496 0   0     0 $allowance_total += abs($a->{amount} || 0);
497             }
498 2         4 my $tax_basis = $lines_gross - $allowance_total;
499 2         4 my $grand = $tax_basis + $tax_total;
500              
501 2         5 my $sum = _el($set, 'ram:SpecifiedTradeSettlementHeaderMonetarySummation');
502 2         26 _el($sum, 'ram:TaxBasisTotalAmount', _fmt_amt($tax_basis));
503             _el($sum, 'ram:TaxTotalAmount', _fmt_amt($tax_total),
504 2   50     31 currencyID => ($inv->{currency} || 'EUR'));
505 2         30 _el($sum, 'ram:GrandTotalAmount', _fmt_amt($grand));
506 2         31 _el($sum, 'ram:DuePayableAmount', _fmt_amt($grand));
507 2         30 return;
508             }
509              
510             ###############################################################################
511             # HELPERS
512             ###############################################################################
513              
514             sub _el {
515 533     533   1027 my ($parent, $qname, $text, @attrs) = @_;
516 533         1196 my ($prefix) = split /:/, $qname, 2;
517 533 50       1439 my $ns = $NS{$prefix} or die "Préfixe XML inconnu: $prefix";
518 533         1793 my $doc = $parent->ownerDocument;
519 533         2919 my $el = $doc->createElementNS($ns, $qname);
520 533         1475 while (my ($k, $v) = splice(@attrs, 0, 2)) {
521 48         128 $el->setAttribute($k, $v);
522             }
523 533 100 66     3116 $el->appendText($text) if defined $text && length $text;
524 533         2177 $parent->appendChild($el);
525 533         1509 return $el;
526             }
527              
528             sub _fmt_amt {
529 92     92   178 my ($n) = @_;
530 92   50     782 return sprintf('%.2f', 0 + ($n // 0));
531             }
532              
533             sub _fmt_qty {
534 8     8   25 my ($n) = @_;
535 8   50     120 my $v = 0 + ($n // 0);
536 8 50       90 return $v == int($v) ? sprintf('%d', $v) : sprintf('%.4f', $v);
537             }
538              
539             sub _fmt_date {
540 20     20   36 my ($iso) = @_;
541 20 50       49 return '' unless defined $iso;
542 20 50       140 $iso =~ /^(\d{4})-(\d{2})-(\d{2})/ or die "Date ISO invalide: $iso";
543 20         145 return "$1$2$3";
544             }
545              
546             1;
547              
548             __END__