Merge branch 'f-zugferd'
authorMoritz Bunkus <m.bunkus@linet-services.de>
Fri, 13 Mar 2020 13:54:52 +0000 (14:54 +0100)
committerMoritz Bunkus <m.bunkus@linet-services.de>
Fri, 13 Mar 2020 13:54:52 +0000 (14:54 +0100)
51 files changed:
SL/Controller/ClientConfig.pm
SL/Controller/RequirementSpec.pm
SL/Controller/SimpleSystemSetting.pm
SL/Controller/ZUGFeRD.pm [new file with mode: 0644]
SL/DATEV.pm
SL/DATEV/CSV.pm
SL/DB/Customer.pm
SL/DB/Default.pm
SL/DB/Helper/PDF_A.pm [new file with mode: 0644]
SL/DB/Helper/VATIDNrValidation.pm [new file with mode: 0644]
SL/DB/Helper/ZUGFeRD.pm [new file with mode: 0644]
SL/DB/Invoice.pm
SL/DB/MetaSetup/BankAccount.pm
SL/DB/MetaSetup/Customer.pm
SL/DB/MetaSetup/Default.pm
SL/DB/Vendor.pm
SL/Form.pm
SL/Helper/CreatePDF.pm
SL/Helper/ISO3166.pm [new file with mode: 0644]
SL/Helper/ISO4217.pm [new file with mode: 0644]
SL/Helper/UNECERecommendation20.pm [new file with mode: 0644]
SL/InstallationCheck.pm
SL/InstanceConfiguration.pm
SL/Template.pm
SL/Template/LaTeX.pm
SL/Template/XML.pm [deleted file]
SL/VATIDNr.pm [new file with mode: 0644]
SL/X.pm
SL/ZUGFeRD.pm [new file with mode: 0644]
bin/mozilla/generictranslations.pl
bin/mozilla/io.pl
doc/changelog
doc/dokumentation.xml
locale/de/all
locale/de/special_chars
menus/user/00-erp.yaml
scripts/installation_check.pl
sql/Pg-upgrade2/bank_account_flag_for_zugferd_usage.sql [new file with mode: 0644]
sql/Pg-upgrade2/customer_create_zugferd_invoices.sql [new file with mode: 0644]
sql/Pg-upgrade2/defaults_create_zugferd_data.sql [new file with mode: 0644]
sql/Pg-upgrade2/defaults_split_address.pl [new file with mode: 0644]
sql/Pg-upgrade2/defaults_zugferd_test_mode.sql [new file with mode: 0644]
t/structure/instance_conf_method_names.t
templates/pdf/pdf_a_metadata.xmp [new file with mode: 0644]
templates/webpages/client_config/_features.html
templates/webpages/client_config/_miscellaneous.html
templates/webpages/customer_vendor/tabs/billing.html
templates/webpages/generictranslations/edit_zugferd_notes.html [new file with mode: 0644]
templates/webpages/simple_system_setting/_bank_account_form.html
templates/webpages/zugferd/form.html [new file with mode: 0644]
texmf/embedfile.sty [new file with mode: 0644]

index 3d5b770..9bf0eda 100644 (file)
@@ -20,6 +20,7 @@ use SL::Template;
 use SL::Controller::TopQuickSearch;
 use SL::DB::Helper::AccountingPeriod qw(get_balance_startdate_method_options);
 use SL::Helper::ShippedQty;
+use SL::VATIDNr;
 
 __PACKAGE__->run_before('check_auth');
 
@@ -99,6 +100,11 @@ sub action_save {
     }
   }
 
+  my $cleaned_ustid = SL::VATIDNr->clean($defaults->{co_ustid});
+  if ($cleaned_ustid && !SL::VATIDNr->validate($cleaned_ustid)) {
+    push @errors, t8("The VAT ID number '#1' is invalid.", $defaults->{co_ustid});
+  }
+
   # Show form again if there were any errors. Nothing's been changed
   # yet in the database.
   if (@errors) {
index 875f170..6ba1c4e 100644 (file)
@@ -23,6 +23,7 @@ use SL::DB::RequirementSpec;
 use SL::Helper::CreatePDF qw();
 use SL::Helper::Flash;
 use SL::Locale::String;
+use SL::System::Process;
 use SL::Template::LaTeX;
 
 use Rose::Object::MakeMethods::Generic
@@ -217,9 +218,16 @@ sub action_revert_to {
 sub action_create_pdf {
   my ($self, %params) = @_;
 
+  my $keep_temp_files = $::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files};
+  my $temp_dir        = File::Temp->newdir(
+    "kivitendo-print-XXXXXX",
+    DIR     => SL::System::Process::exe_dir() . "/" . $::lx_office_conf{paths}->{userspath},
+    CLEANUP => !$keep_temp_files,
+  );
+
   my $base_name       = $self->requirement_spec->type->template_file_name || 'requirement_spec';
-  my @pictures        = $self->prepare_pictures_for_printing;
-  my %result          = SL::Template::LaTeX->parse_and_create_pdf("${base_name}.tex", SELF => $self, rspec => $self->requirement_spec);
+  my @pictures        = $self->prepare_pictures_for_printing($temp_dir->dirname);
+  my %result          = SL::Template::LaTeX->parse_and_create_pdf("${base_name}.tex", SELF => $self, rspec => $self->requirement_spec, userspath => $temp_dir->dirname);
 
   unlink @pictures unless ($::lx_office_conf{debug} || {})->{keep_temp_files};
 
@@ -599,11 +607,11 @@ sub render_first_pasted_section_as_list {
 }
 
 sub prepare_pictures_for_printing {
-  my ($self) = @_;
+  my ($self, $userspath) = @_;
 
   my @files;
-  my $userspath = File::Spec->rel2abs($::lx_office_conf{paths}->{userspath});
-  my $target    =  "${userspath}/kivitendo-print-requirement-spec-picture-" . Common::unique_id() . '-';
+  $userspath ||= SL::System::Process::exe_dir() . "/" . $::lx_office_conf{paths}->{userspath};
+  my $target   = "${userspath}/kivitendo-print-requirement-spec-picture-" . Common::unique_id() . '-';
 
   foreach my $picture (map { @{ $_->pictures } } @{ $self->requirement_spec->text_blocks }) {
     my $output_file_name        = $target . $picture->id . '.' . $picture->get_default_file_name_extension;
index 2e559a3..4fec19a 100644 (file)
@@ -35,6 +35,7 @@ my %supported_types = (
       { method => 'bank',                                      title => t8('Bank'), },
       { method => 'bank_code',                                 title => t8('Bank code'), },
       { method => 'bic',                                       title => t8('BIC'), },
+      {                                                        title => t8('Use for ZUGFeRD'), formatter => sub { $_[0]->use_for_zugferd ? t8('yes') : t8('no') } },
       { method => 'reconciliation_starting_date_as_date',      title => t8('Date'),    align => 'right' },
       { method => 'reconciliation_starting_balance_as_number', title => t8('Balance'), align => 'right' },
     ],
diff --git a/SL/Controller/ZUGFeRD.pm b/SL/Controller/ZUGFeRD.pm
new file mode 100644 (file)
index 0000000..29d7e07
--- /dev/null
@@ -0,0 +1,225 @@
+package SL::Controller::ZUGFeRD;
+use strict;
+use parent qw(SL::Controller::Base);
+
+use SL::DB::RecordTemplate;
+use SL::Locale::String qw(t8);
+use SL::Helper::DateTime;
+use SL::VATIDNr;
+use SL::ZUGFeRD;
+
+use XML::LibXML;
+
+
+__PACKAGE__->run_before('check_auth');
+
+sub action_upload_zugferd {
+  my ($self, %params) = @_;
+
+  $self->setup_zugferd_action_bar;
+  $self->render('zugferd/form', title => $::locale->text('ZUGFeRD import'));
+}
+
+sub action_import_zugferd {
+  my ($self, %params) = @_;
+
+  die t8("missing file for action import") unless $::form->{file};
+  die t8("can only parse a pdf file")      unless $::form->{file} =~ m/^%PDF/;
+
+  my $info = SL::ZUGFeRD->extract_from_pdf($::form->{file});
+
+  if ($info->{result} != SL::ZUGFeRD::RES_OK()) {
+    # An error occurred; log message from parser:
+    $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $info->{message});
+    die t8("Could not extract ZUGFeRD data, data and error message:") . $info->{message};
+  }
+  # valid ZUGFeRD metadata
+  my $dom   = XML::LibXML->load_xml(string => $info->{invoice_xml});
+
+  # 1. check if ZUGFeRD SellerTradeParty has a VAT-ID
+  my $ustid = $dom->findnodes('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration')->string_value;
+  die t8("No VAT Info for this ZUGFeRD invoice," .
+         " please ask your vendor to add this for his ZUGFeRD data.") unless $ustid;
+
+  $ustid = SL::VATIDNr->normalize($ustid);
+
+  # 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid)
+  my $vc     = $dom->findnodes('//ram:SellerTradeParty/ram:Name')->string_value;
+  my $vendor = SL::DB::Manager::Vendor->find_by(
+    ustid => $ustid,
+    or    => [
+      obsolete => undef,
+      obsolete => 0,
+    ]);
+
+  if (!$vendor) {
+    # 1.2 If no vendor with the exact VAT ID number is found, the
+    # number might be stored slightly different in the database
+    # (e.g. with spaces breaking up groups of numbers). Iterate over
+    # all existing vendors with VAT ID numbers, normalize their
+    # representation and compare those.
+
+    my $vendors = SL::DB::Manager::Vendor->get_all(
+      where => [
+        '!ustid' => undef,
+        '!ustid' => '',
+        or       => [
+          obsolete => undef,
+          obsolete => 0,
+        ],
+      ]);
+
+    foreach my $other_vendor (@{ $vendors }) {
+      next unless SL::VATIDNr->normalize($other_vendor->ustid) eq $ustid;
+
+      $vendor = $other_vendor;
+      last;
+    }
+  }
+
+  die t8("Please add a valid VAT-ID for this vendor: " . $vc) unless (ref $vendor eq 'SL::DB::Vendor');
+
+  # 2. check if we have a ap record template for this vendor (TODO only the oldest template is choosen)
+  my $template_ap = SL::DB::Manager::RecordTemplate->get_first(where => [vendor_id => $vendor->id]);
+  die t8("No AP Record Template for this vendor found, please add one") unless (ref $template_ap eq 'SL::DB::RecordTemplate');
+
+
+  # 3. parse the zugferd data and fill the ap record template
+  # -> no need to check sign (credit notes will be negative) just record thei ZUGFeRD type in ap.notes
+  # -> check direct debit (defaults to no)
+  # -> set amount (net amount) and unset taxincluded
+  #    (template and user cares for tax and if there is more than one booking accno)
+  # -> date (can be empty)
+  # -> duedate (may be empty)
+  # -> compare record iban and generate a warning if this differs from vendor's master data iban
+  my $total     = $dom->findnodes('//ram:SpecifiedTradeSettlementHeaderMonetarySummation' .
+                                  '/ram:TaxBasisTotalAmount')->string_value;
+
+  my $invnumber = $dom->findnodes('//rsm:ExchangedDocument/ram:ID')->string_value;
+
+  # parse dates to kivi if set/valid
+  my ($transdate, $duedate, $dt_to_kivi, $due_dt_to_kivi);
+  $transdate = $dom->findnodes('//ram:IssueDateTime')->string_value;
+  $duedate   = $dom->findnodes('//ram:DueDateDateTime')->string_value;
+  $transdate =~ s/^\s+|\s+$//g;
+  $duedate   =~ s/^\s+|\s+$//g;
+
+  if ($transdate =~ /^[0-9]{8}$/) {
+    $dt_to_kivi = DateTime->new(year  => substr($transdate,0,4),
+                                month => substr ($transdate,4,2),
+                                day   => substr($transdate,6,2))->to_kivitendo;
+  }
+  if ($duedate =~ /^[0-9]{8}$/) {
+    $due_dt_to_kivi = DateTime->new(year  => substr($duedate,0,4),
+                                    month => substr ($duedate,4,2),
+                                    day   => substr($duedate,6,2))->to_kivitendo;
+  }
+
+  my $type = $dom->findnodes('//rsm:ExchangedDocument/ram:TypeCode')->string_value;
+
+  my $dd   = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement' .
+                             '/ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode')->string_value;
+  my $direct_debit = $dd == 59 ? 1 : 0;
+
+  my $iban = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementPaymentMeans' .
+                             '/ram:PayeePartyCreditorFinancialAccount/ram:IBANID')->string_value;
+  my $ibanmessage;
+  $ibanmessage = $iban ne $vendor->iban ? "Record IBAN $iban doesn't match vendor IBAN " . $vendor->iban : $iban if $iban;
+
+  my $url = $self->url_for(
+    controller                           => 'ap.pl',
+    action                               => 'load_record_template',
+    id                                   => $template_ap->id,
+    'form_defaults.amount_1'             => $::form->format_amount(\%::myconfig, $total, 2),
+    'form_defaults.transdate'            => $dt_to_kivi,
+    'form_defaults.invnumber'            => $invnumber,
+    'form_defaults.duedate'              => $due_dt_to_kivi,
+    'form_defaults.no_payment_bookings'  => 0,
+    'form_defaults.paid_1_suggestion'    => $::form->format_amount(\%::myconfig, $total, 2),
+    'form_defaults.notes'                => "ZUGFeRD Import. Type: $type\nIBAN: " . $ibanmessage,
+    'form_defaults.taxincluded'          => 0,
+    'form_defaults.direct_debit'          => $direct_debit,
+  );
+
+  $self->redirect_to($url);
+
+}
+
+sub check_auth {
+  $::auth->assert('ap_transactions');
+}
+sub setup_zugferd_action_bar {
+  my ($self) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        $::locale->text('Import'),
+        submit    => [ '#form', { action => 'ZUGFeRD/import_zugferd' } ],
+        accesskey => 'enter',
+      ],
+    );
+  }
+}
+
+
+1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::Controller::ZUGFeRD
+Controller for importing ZUGFeRD pdf files to kivitendo
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<action_upload_zugferd>
+
+Creates a web from with a single upload dialog.
+
+=item C<action_import_zugferd $pdf>
+
+Expects a single pdf with ZUGFeRD 2.0 metadata.
+Checks if the param <C$pdf> is set and a valid pdf file.
+Calls helper functions to validate and extract the ZUGFeRD data.
+Needs a valid VAT ID (EU) for this vendor and
+expects one ap template for this vendor in kivitendo.
+
+Parses some basic ZUGFeRD data (invnumber, total net amount,
+transdate, duedate, vendor VAT ID, IBAN) and uses the first
+found ap template for this vendor to fill this template with
+ZUGFeRD data.
+If the vendor's master data contain a IBAN and the
+ZUGFeRD record has a IBAN also these values will be compared.
+If they  don't match a warning will be writte in ap.notes.
+Furthermore the ZUGFeRD type code will be written to ap.notes.
+No callback implemented.
+
+=back
+
+=head1 TODO and CAVEAT
+
+This is just a very basic Parser for ZUGFeRD data.
+We assume that the ZUGFeRD generator is a company with a
+valid European VAT ID. Furthermore this vendor needs only
+one and just noe ap template (the first match will be used).
+
+The ZUGFeRD data should also be extracted in the helper package
+and maybe a model should be used for this.
+The user should set one ap template as a default for ZUGFeRD.
+The ZUGFeRD pdf should be written to WebDAV or DMS.
+If the ZUGFeRD data has a payment purpose set, this should
+be the default for the SEPA-XML export.
+
+
+=head1 AUTHOR
+
+Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
+
+=cut
index 99c8523..c5d75ea 100644 (file)
@@ -37,6 +37,7 @@ use SL::DB;
 use SL::HTML::Util ();
 use SL::Iconv;
 use SL::Locale::String qw(t8);
+use SL::VATIDNr;
 
 use Data::Dumper;
 use DateTime;
@@ -1045,7 +1046,7 @@ sub generate_datev_lines {
         $datev_data{buchungstext} = $transaction->[$haben]->{'name'};
       }
       if (($transaction->[$haben]->{'ustid'} // '') ne "") {
-        $datev_data{ustid} = $transaction->[$haben]->{'ustid'};
+        $datev_data{ustid} = SL::VATIDNr->normalize($transaction->[$haben]->{'ustid'});
       }
       if (($transaction->[$haben]->{'duedate'} // '') ne "") {
         $datev_data{belegfeld2} = $transaction->[$haben]->{'duedate'};
@@ -1164,7 +1165,7 @@ sub kne_buchungsexport {
     $name =~ s/\ *$//;
     $kne_file->add_block("\x1E" . $name . "\x1C");
 
-    $kne_file->add_block("\xBA" . $kne->{'ustid'}    . "\x1C") if $kne->{'ustid'};
+    $kne_file->add_block("\xBA" . SL::VATIDNr->normalize($kne->{'ustid'}) . "\x1C") if $kne->{'ustid'};
 
     $kne_file->add_block("\xB3" . $kne->{'waehrung'} . "\x1C" . "\x79");
   };
index 65f6cf0..05598b1 100644 (file)
@@ -11,6 +11,7 @@ use SL::DB::Chart;
 use SL::Helper::DateTime;
 use SL::Locale::String qw(t8);
 use SL::Util qw(trim);
+use SL::VATIDNr;
 
 use Rose::Object::MakeMethods::Generic (
   scalar => [ qw(datev_lines from to locked warnings) ],
@@ -240,14 +241,13 @@ my @kivitendo_to_datev = (
                               input_check     => sub {
                                                        my ($ustid) = @_;
                                                        return 1 if ('' eq $ustid);
-                                                       $ustid =~ s{\s+}{}g;
-                                                       return ($ustid =~ m/^CH|^[A-Z]{2}\w{5,13}$/);
+                                                       return SL::VATIDNr->validate($ustid);
                                                      },
                               formatter       => sub { my ($input) = @_; $input =~ s/\s//g; return $input },
                               valid_check     => sub {
                                                        my ($ustid) = @_;
                                                        return 1 if ('' eq $ustid);
-                                                       return ($ustid =~ m/^CH|^[A-Z]{2}\w{5,13}$/);
+                                                       return SL::VATIDNr->validate($ustid);
                                                      },
                             }, # pos 40
                             {
index cb51f90..71c762a 100644 (file)
@@ -10,6 +10,7 @@ use SL::DB::MetaSetup::Customer;
 use SL::DB::Manager::Customer;
 use SL::DB::Helper::IBANValidation;
 use SL::DB::Helper::TransNumberGenerator;
+use SL::DB::Helper::VATIDNrValidation;
 use SL::DB::Helper::CustomVariables (
   module      => 'CT',
   cvars_alias => 1,
@@ -61,6 +62,7 @@ sub validate {
   my @errors;
   push @errors, $::locale->text('The customer name is missing.') if !$self->name;
   push @errors, $self->validate_ibans;
+  push @errors, $self->validate_vat_id_numbers;
 
   return @errors;
 }
@@ -98,4 +100,12 @@ sub is_vendor   { 0 };
 sub payment_terms { goto &payment }
 sub number { goto &customernumber }
 
+sub create_zugferd_invoices_for_this_customer {
+  my ($self) = @_;
+
+  no warnings 'once';
+  return $::instance_conf->get_create_zugferd_invoices if $self->create_zugferd_invoices == -1;
+  return $self->create_zugferd_invoices;
+}
+
 1;
index ee9e2c2..f7eeb92 100644 (file)
@@ -2,6 +2,7 @@ package SL::DB::Default;
 
 use strict;
 
+use Carp;
 use SL::DB::MetaSetup::Default;
 
 __PACKAGE__->meta->initialize;
@@ -21,4 +22,16 @@ sub get {
   return SL::DB::Manager::Default->get_all(limit => 1)->[0];
 }
 
+sub address {
+  # Compatibility function: back in the day there was only a single
+  # address field.
+  my $self = shift;
+
+  croak("SL::DB::Default::address is a read-only accessor") if @_;
+
+  my $zipcode_city = join ' ', grep { $_ } ($self->address_zipcode, $self->address_city);
+
+  return join "\n", grep { $_ } ($self->address_street1, $self->address_street2, $zipcode_city, $self->address_country);
+}
+
 1;
diff --git a/SL/DB/Helper/PDF_A.pm b/SL/DB/Helper/PDF_A.pm
new file mode 100644 (file)
index 0000000..1e1e172
--- /dev/null
@@ -0,0 +1,69 @@
+package SL::DB::Helper::PDF_A;
+
+use strict;
+
+use parent qw(Exporter);
+our @EXPORT = qw(create_pdf_a_print_options);
+
+use Carp;
+use Template;
+
+sub _create_xmp_data {
+  my ($self, %params) = @_;
+
+  my $template = Template->new({
+    INTERPOLATE  => 0,
+    EVAL_PERL    => 0,
+    ABSOLUTE     => 1,
+    PLUGIN_BASE  => 'SL::Template::Plugin',
+    ENCODING     => 'utf8',
+  }) || croak;
+
+  my $output = '';
+  $template->process(SL::System::Process::exe_dir() . '/templates/pdf/pdf_a_metadata.xmp', \%params, \$output) || croak $template->error;
+
+  return $output;
+}
+
+sub create_pdf_a_print_options {
+  my ($self) = @_;
+
+  require SL::DB::Language;
+
+  my $language_code = $self->can('language_id') && $self->language_id ? SL::DB::Language->load_cached($self->language_id)->template_code : undef;
+  $language_code  ||= 'de';
+  my $pdf_language  = $language_code =~ m{deutsch|german|^de$}i   ? 'de-DE'
+                    : $language_code =~ m{englisch|english|^en$}i ? 'en-US'
+                    :                                               '';
+  my $author        = do {
+    no warnings 'once';
+    $::instance_conf->get_company
+  };
+
+  my $timestamp =  DateTime->now_local->strftime('%Y-%m-%dT%H:%M:%S%z');
+  $timestamp    =~ s{(..)$}{:$1};
+
+  return {
+    version                => '3b',
+    xmp                    => _create_xmp_data(
+      $self,
+      pdf_a_version        => '3',
+      pdf_a_conformance    => 'B',
+      producer             => 'pdfTeX',
+      timestamp            => $timestamp, # 2019-11-05T15:26:20+01:00
+      meta_data            => {
+        title              => $self->displayable_name,
+        author             => $author,
+        language           => $pdf_language,
+      },
+      zugferd              => {
+        conformance_level  => 'EXTENDED',
+        document_file_name => 'ZUGFeRD-invoice.xml',
+        document_type      => 'INVOICE',
+        version            => '1.0',
+      },
+    ),
+  };
+}
+
+1;
diff --git a/SL/DB/Helper/VATIDNrValidation.pm b/SL/DB/Helper/VATIDNrValidation.pm
new file mode 100644 (file)
index 0000000..8e4047a
--- /dev/null
@@ -0,0 +1,100 @@
+package SL::DB::Helper::VATIDNrValidation;
+
+use strict;
+
+use Carp;
+use SL::Locale::String qw(t8);
+use SL::VATIDNr;
+
+my $_validator;
+
+sub _validate {
+  my ($self, $attribute) = @_;
+
+  my $number = SL::VATIDNr->clean($self->$attribute);
+
+  return () unless length($number);
+  return () if     SL::VATIDNr->validate($number);
+  return ($::locale->text("The VAT ID number '#1' is invalid.", $self->$attribute));
+}
+
+sub import {
+  my ($package, @attributes) = @_;
+
+  my $caller_package         = caller;
+  @attributes                = qw(ustid) unless @attributes;
+
+  no strict 'refs';
+
+  *{ $caller_package . '::validate_vat_id_numbers' } = sub {
+    my ($self) = @_;
+
+    return map { SL::DB::Helper::VATIDNrValidation::_validate($self, $_) } @attributes;
+  };
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::DB::Helper::VATIDNrValidation - Mixin for validating VAT ID number attributes
+
+=head1 SYNOPSIS
+
+  package SL::DB::SomeObject;
+  use SL::DB::Helper::VATIDNrValidation [ ATTRIBUTES ];
+
+  sub validate {
+    my ($self) = @_;
+
+    my @errors;
+    …
+    push @errors, $self->validate_vat_id_numbers;
+
+    return @errors;
+  }
+
+This mixin provides a function C<validate_vat_id_numbers> that returns
+a list of error messages, one for each attribute that fails the VAT ID
+number validation. If all attributes are valid or empty then an empty
+list is returned.
+
+The names of attributes to check can be given as an import list to the
+mixin package. If no attributes are given the single attribute C<ustid>
+is used.
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<validate_vat_id_numbers>
+
+This function iterates over all configured attributes and validates
+their content according to how VAT ID numbers are supposed to be
+formatted in the European Union (or the enterprise identification
+numbers in Switzerland). An attribute that is undefined, empty or
+consists solely of whitespace is considered valid, too.
+
+The function returns a list of human-readable error messages suitable
+for use in a general C<validate> function (see SYNOPSIS). For each
+attribute failing the check the list will include one error message.
+
+If all attributes are valid then an empty list is returned.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
diff --git a/SL/DB/Helper/ZUGFeRD.pm b/SL/DB/Helper/ZUGFeRD.pm
new file mode 100644 (file)
index 0000000..99e0825
--- /dev/null
@@ -0,0 +1,665 @@
+package SL::DB::Helper::ZUGFeRD;
+
+use strict;
+use utf8;
+
+use parent qw(Exporter);
+our @EXPORT = qw(create_zugferd_data create_zugferd_xmp_data);
+
+use SL::DB::BankAccount;
+use SL::DB::GenericTranslation;
+use SL::DB::Tax;
+use SL::DB::TaxKey;
+use SL::Helper::ISO3166;
+use SL::Helper::ISO4217;
+use SL::Helper::UNECERecommendation20;
+use SL::VATIDNr;
+
+use Carp;
+use Encode qw(encode);
+use List::MoreUtils qw(any pairwise);
+use List::Util qw(first sum);
+use Template;
+use XML::Writer;
+
+my @line_names = qw(LineOne LineTwo LineThree);
+
+sub _u8 {
+  my ($value) = @_;
+  return encode('UTF-8', $value // '');
+}
+
+sub _r2 {
+  my ($value) = @_;
+  return $::form->round_amount($value, 2);
+}
+
+sub _type_name {
+  my ($self) = @_;
+  my $type   = $self->invoice_type;
+
+  no warnings 'once';
+  return $type eq 'ar_transaction' ? $::locale->text('Invoice') : $self->displayable_type;
+}
+
+sub _type_code {
+  my ($self) = @_;
+  my $type   = $self->invoice_type;
+
+  # 326 (Partial invoice)
+  # 380 (Commercial invoice)
+  # 384 (Corrected Invoice)
+  # 381 (Credit note)
+  # 389 (Credit note, self billed invoice)
+
+  return $type eq 'credit_note'        ? 381
+       : $type eq 'invoice_storno'     ? 457
+       : $type eq 'credit_note_storno' ? 458
+       :                                 380;
+}
+
+sub _unit_code {
+  my ($unit) = @_;
+
+  # Mapping from kivitendo's units to UN/ECE Recommendation 20 & 21.
+  my $code = SL::Helper::UNECERecommendation20::map_name_to_code($unit);
+  return $code if $code;
+
+  $::lxdebug->message(LXDebug::WARN(), "ZUGFeRD unit name mapping: no UN/ECE Recommendation 20/21 unit known for kivitendo unit '$unit'; using 'C62'");
+
+  return 'C62';
+}
+
+sub _parse_our_address {
+  my @result;
+  my @street = grep { $_ } ($::instance_conf->get_address_street1, $::instance_conf->get_address_street2);
+
+  push @result, [ 'PostcodeCode', $::instance_conf->get_address_zipcode ] if $::instance_conf->get_address_zipcode;
+  push @result, grep { $_->[1] } pairwise { [ $a, $b] } @line_names, @street;
+  push @result, [ 'CityName', $::instance_conf->get_address_city ] if $::instance_conf->get_address_city;
+  push @result, [ 'CountryID', SL::Helper::ISO3166::map_name_to_alpha_2_code($::instance_conf->get_address_country) // 'DE' ];
+
+  return @result;
+}
+
+sub _customer_postal_trade_address {
+  my (%params) = @_;
+
+  #       <ram:PostalTradeAddress>
+  $params{xml}->startTag("ram:PostalTradeAddress");
+
+  my @parts = grep { $_ } map { $params{customer}->$_ } qw(department_1 department_2 street);
+
+  $params{xml}->dataElement("ram:PostcodeCode", _u8($params{customer}->zipcode));
+  $params{xml}->dataElement("ram:" . $_->[0],   _u8($_->[1])) for grep { $_->[1] } pairwise { [ $a, $b] } @line_names, @parts;
+  $params{xml}->dataElement("ram:CityName",     _u8($params{customer}->city));
+  $params{xml}->dataElement("ram:CountryID",    _u8(SL::Helper::ISO3166::map_name_to_alpha_2_code($params{customer}->country) // 'DE'));
+  $params{xml}->endTag;
+  #       </ram:PostalTradeAddress>
+}
+
+sub _tax_rate_and_code {
+  my ($taxzone, $tax) = @_;
+
+  my ($tax_rate, $tax_code) = @_;
+
+  if ($taxzone->description =~ m{Au.*erhalb}) {
+    $tax_rate = 0;
+    $tax_code = 'G';
+
+  } elsif ($taxzone->description =~ m{EU mit}) {
+    $tax_rate = 0;
+    $tax_code = 'K';
+
+  } else {
+    $tax_rate = $tax->rate * 100;
+    $tax_code = !$tax_rate ? 'Z' : 'S';
+  }
+
+  return (rate => $tax_rate, code => $tax_code);
+}
+
+sub _line_item {
+  my ($self, %params) = @_;
+
+  my $item_ptc = $params{ptc_data}->{items}->[$params{line_number}];
+
+  my $taxkey   = $item_ptc->{taxkey_id} ? SL::DB::TaxKey->load_cached($item_ptc->{taxkey_id}) : undef;
+  my $tax      = $item_ptc->{taxkey_id} ? SL::DB::Tax->load_cached($taxkey->tax_id)           : undef;
+  my %tax_info = _tax_rate_and_code($self->taxzone, $tax);
+
+  # <ram:IncludedSupplyChainTradeLineItem>
+  $params{xml}->startTag("ram:IncludedSupplyChainTradeLineItem");
+
+  #   <ram:AssociatedDocumentLineDocument>
+  $params{xml}->startTag("ram:AssociatedDocumentLineDocument");
+  $params{xml}->dataElement("ram:LineID", $params{line_number} + 1);
+  $params{xml}->endTag;
+
+  $params{xml}->startTag("ram:SpecifiedTradeProduct");
+  $params{xml}->dataElement("ram:SellerAssignedID", _u8($params{item}->part->partnumber));
+  $params{xml}->dataElement("ram:Name",             _u8($params{item}->description));
+  $params{xml}->endTag;
+
+  $params{xml}->startTag("ram:SpecifiedLineTradeAgreement");
+  $params{xml}->startTag("ram:NetPriceProductTradePrice");
+  $params{xml}->dataElement("ram:ChargeAmount", _r2($item_ptc->{sellprice}));
+  $params{xml}->endTag;
+  $params{xml}->endTag;
+  #   </ram:SpecifiedLineTradeAgreement>
+
+  #   <ram:SpecifiedLineTradeDelivery>
+  $params{xml}->startTag("ram:SpecifiedLineTradeDelivery");
+  $params{xml}->dataElement("ram:BilledQuantity", $params{item}->qty, unitCode => _unit_code($params{item}->unit));
+  $params{xml}->endTag;
+  #   </ram:SpecifiedLineTradeDelivery>
+
+  #   <ram:SpecifiedLineTradeSettlement>
+  $params{xml}->startTag("ram:SpecifiedLineTradeSettlement");
+
+  #     <ram:ApplicableTradeTax>
+  $params{xml}->startTag("ram:ApplicableTradeTax");
+  $params{xml}->dataElement("ram:TypeCode",              "VAT");
+  $params{xml}->dataElement("ram:CategoryCode",          $tax_info{code});
+  $params{xml}->dataElement("ram:RateApplicablePercent", _r2($tax_info{rate}));
+  $params{xml}->endTag;
+  #     </ram:ApplicableTradeTax>
+
+  #     <ram:SpecifiedTradeSettlementLineMonetarySummation>
+  $params{xml}->startTag("ram:SpecifiedTradeSettlementLineMonetarySummation");
+  $params{xml}->dataElement("ram:LineTotalAmount", _r2($item_ptc->{linetotal}));
+  $params{xml}->endTag;
+  #     </ram:SpecifiedTradeSettlementLineMonetarySummation>
+
+  $params{xml}->endTag;
+  #   </ram:SpecifiedLineTradeSettlement>
+
+  $params{xml}->endTag;
+  # <ram:IncludedSupplyChainTradeLineItem>
+}
+
+sub _specified_trade_settlement_payment_means {
+  my ($self, %params) = @_;
+
+  #     <ram:SpecifiedTradeSettlementPaymentMeans>
+  $params{xml}->startTag('ram:SpecifiedTradeSettlementPaymentMeans');
+  $params{xml}->dataElement('ram:TypeCode', $self->direct_debit ? 59 : 58); # 59 = SEPA direct debit, 58 = SEPA credit transfer
+
+  if ($self->direct_debit) {
+    $params{xml}->startTag('ram:PayerPartyDebtorFinancialAccount');
+    $params{xml}->dataElement('ram:IBANID', $self->customer->iban);
+    $params{xml}->endTag;
+
+  } else {
+    $params{xml}->startTag('ram:PayeePartyCreditorFinancialAccount');
+    $params{xml}->dataElement('ram:IBANID', $params{bank_account}->iban);
+    $params{xml}->endTag;
+  }
+
+  $params{xml}->endTag;
+  #     </ram:SpecifiedTradeSettlementPaymentMeans>
+}
+
+sub _taxes {
+  my ($self, %params) = @_;
+
+  my %taxkey_info;
+
+  foreach my $item (@{ $params{ptc_data}->{items} }) {
+    $taxkey_info{$item->{taxkey_id}} //= {
+      linetotal  => 0,
+      tax_amount => 0,
+    };
+    my $info             = $taxkey_info{$item->{taxkey_id}};
+    $info->{taxkey}    //= SL::DB::TaxKey->load_cached($item->{taxkey_id});
+    $info->{tax}       //= SL::DB::Tax->load_cached($info->{taxkey}->tax_id);
+    $info->{linetotal}  += $item->{linetotal};
+    $info->{tax_amount} += $item->{tax_amount};
+  }
+
+  foreach my $taxkey_id (sort keys %taxkey_info) {
+    my $info     = $taxkey_info{$taxkey_id};
+    my %tax_info = _tax_rate_and_code($self->taxzone, $info->{tax});
+
+    #     <ram:ApplicableTradeTax>
+    $params{xml}->startTag("ram:ApplicableTradeTax");
+    $params{xml}->dataElement("ram:CalculatedAmount",      _r2($params{ptc_data}->{taxes}->{$info->{tax}->{chart_id}}));
+    $params{xml}->dataElement("ram:TypeCode",              "VAT");
+    $params{xml}->dataElement("ram:BasisAmount",           _r2($info->{linetotal}));
+    $params{xml}->dataElement("ram:CategoryCode",          $tax_info{code});
+    $params{xml}->dataElement("ram:RateApplicablePercent", _r2($tax_info{rate}));
+    $params{xml}->endTag;
+    #     </ram:ApplicableTradeTax>
+  }
+}
+
+sub _calculate_payment_terms_values {
+  my ($self) = @_;
+
+  my (%vars, %amounts, %formatted_amounts);
+
+  local $::myconfig{numberformat} = $::myconfig{numberformat};
+  local $::myconfig{dateformat}   = $::myconfig{dateformat};
+
+  if ($self->language_id) {
+    my $language = SL::DB::Language->load_cached($self->language_id);
+    $::myconfig{dateformat}   = $language->output_dateformat   if $language->output_dateformat;
+    $::myconfig{numberformat} = $language->output_numberformat if $language->output_numberformat;
+  }
+
+  $vars{currency}              = $self->currency->name if $self->currency;
+  $vars{$_}                    = $self->customer->$_      for qw(account_number bank bank_code bic iban mandate_date_of_signature mandator_id);
+  $vars{$_}                    = $self->payment_terms->$_ for qw(terms_netto terms_skonto percent_skonto);
+  $vars{payment_description}   = $self->payment_terms->description;
+  $vars{netto_date}            = $self->payment_terms->calc_date(reference_date => $self->transdate, due_date => $self->duedate, terms => 'net')->to_kivitendo;
+  $vars{skonto_date}           = $self->payment_terms->calc_date(reference_date => $self->transdate, due_date => $self->duedate, terms => 'discount')->to_kivitendo;
+
+  $amounts{invtotal}           = $self->amount;
+  $amounts{total}              = $self->amount - $self->paid;
+
+  $amounts{skonto_in_percent}  = 100.0 * $vars{percent_skonto};
+  $amounts{skonto_amount}      = $amounts{invtotal} * $vars{percent_skonto};
+  $amounts{invtotal_wo_skonto} = $amounts{invtotal} * (1 - $vars{percent_skonto});
+  $amounts{total_wo_skonto}    = $amounts{total}    * (1 - $vars{percent_skonto});
+
+  foreach (keys %amounts) {
+    $amounts{$_}           = $::form->round_amount($amounts{$_}, 2);
+    $formatted_amounts{$_} = $::form->format_amount(\%::myconfig, $amounts{$_}, 2);
+  }
+
+  return (
+    vars              => \%vars,
+    amounts           => \%amounts,
+    formatted_amounts => \%formatted_amounts,
+  );
+}
+
+sub _format_payment_terms_description {
+  my ($self, %params) = @_;
+
+  my $description = ($self->payment_terms->translated_attribute('description_long_invoice', $self->language_id) // '') || $self->payment_terms->description_long_invoice;
+  $description    =~ s{<\%$_\%>}{ $params{vars}->{$_} }ge              for keys %{ $params{vars} };
+  $description    =~ s{<\%$_\%>}{ $params{formatted_amounts}->{$_} }ge for keys %{ $params{formatted_amounts} };
+
+  return $description;
+}
+
+sub _payment_terms {
+  my ($self, %params) = @_;
+
+  return unless $self->payment_terms;
+
+  my %payment_terms_vars = _calculate_payment_terms_values($self);
+
+  #     <ram:SpecifiedTradePaymentTerms>
+  $params{xml}->startTag("ram:SpecifiedTradePaymentTerms");
+
+  $params{xml}->dataElement("ram:Description", _u8(_format_payment_terms_description($self, %payment_terms_vars)));
+
+  #       <ram:DueDateDateTime>
+  $params{xml}->startTag("ram:DueDateDateTime");
+  $params{xml}->dataElement("udt:DateTimeString", $self->duedate->strftime('%Y%m%d'), format => "102");
+  $params{xml}->endTag;
+  #       </ram:DueDateDateTime>
+
+  if ($self->payment_terms->percent_skonto && $self->payment_terms->terms_skonto) {
+    my $currency_id = _u8(SL::Helper::ISO4217::map_currency_name_to_code($self->currency->name) // 'EUR');
+
+    #       <ram:ApplicableTradePaymentDiscountTerms>
+    $params{xml}->startTag("ram:ApplicableTradePaymentDiscountTerms");
+    $params{xml}->dataElement("ram:BasisPeriodMeasure", $self->payment_terms->terms_skonto, unitCode => "DAY");
+    $params{xml}->dataElement("ram:BasisAmount",        _r2($payment_terms_vars{amounts}->{invtotal}), currencyID => $currency_id);
+    $params{xml}->dataElement("ram:CalculationPercent", _r2($self->payment_terms->percent_skonto * 100));
+    $params{xml}->endTag;
+    #       </ram:ApplicableTradePaymentDiscountTerms>
+  }
+
+  $params{xml}->endTag;
+  #     </ram:SpecifiedTradePaymentTerms>
+}
+
+sub _totals {
+  my ($self, %params) = @_;
+
+  #     <ram:SpecifiedTradeSettlementHeaderMonetarySummation>
+  $params{xml}->startTag("ram:SpecifiedTradeSettlementHeaderMonetarySummation");
+
+  $params{xml}->dataElement("ram:LineTotalAmount",     _r2($self->netamount));
+  $params{xml}->dataElement("ram:TaxBasisTotalAmount", _r2($self->netamount));
+  $params{xml}->dataElement("ram:TaxTotalAmount",      _r2(sum(values %{ $params{ptc_data}->{taxes} })), currencyID => "EUR");
+  $params{xml}->dataElement("ram:GrandTotalAmount",    _r2($self->amount));
+  $params{xml}->dataElement("ram:TotalPrepaidAmount",  _r2($self->paid));
+  $params{xml}->dataElement("ram:DuePayableAmount",    _r2($self->amount - $self->paid));
+
+  $params{xml}->endTag;
+  #     </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
+}
+
+sub _exchanged_document_context {
+  my ($self, %params) = @_;
+
+  #   <rsm:ExchangedDocumentContext>
+  $params{xml}->startTag("rsm:ExchangedDocumentContext");
+
+  if ($self->customer->create_zugferd_invoices_for_this_customer == 2) {
+    $params{xml}->startTag("ram:TestIndicator");
+    $params{xml}->dataElement("udt:Indicator", "true");
+    $params{xml}->endTag;
+  }
+
+  $params{xml}->startTag("ram:GuidelineSpecifiedDocumentContextParameter");
+  $params{xml}->dataElement("ram:ID", "urn:cen.eu:en16931:2017#conformant#urn:zugferd.de:2p0:extended");
+  $params{xml}->endTag;
+  $params{xml}->endTag;
+  #   </rsm:ExchangedDocumentContext>
+}
+
+sub _included_note {
+  my ($self, %params) = @_;
+
+  $params{xml}->startTag("ram:IncludedNote");
+  $params{xml}->dataElement("ram:Content", _u8($params{note}));
+  $params{xml}->endTag;
+}
+
+sub _exchanged_document {
+  my ($self, %params) = @_;
+
+  #   <rsm:ExchangedDocument>
+  $params{xml}->startTag("rsm:ExchangedDocument");
+
+  $params{xml}->dataElement("ram:ID",       _u8($self->invnumber));
+  $params{xml}->dataElement("ram:Name",     _u8(_type_name($self)));
+  $params{xml}->dataElement("ram:TypeCode", _u8(_type_code($self)));
+
+  #     <ram:IssueDateTime>
+  $params{xml}->startTag("ram:IssueDateTime");
+  $params{xml}->dataElement("udt:DateTimeString", $self->transdate->strftime('%Y%m%d'), format => "102");
+  $params{xml}->endTag;
+  #     </ram:IssueDateTime>
+
+  if ($self->language && (($self->language->template_code // '') =~ m{^(de|en)}i)) {
+    $params{xml}->dataElement("ram:LanguageID", uc($1));
+  }
+
+  my $std_notes = SL::DB::Manager::GenericTranslation->get_all(
+    where => [
+      translation_type => 'ZUGFeRD/notes',
+      or               => [
+        language_id    => undef,
+        language_id    => $self->language_id,
+      ],
+      '!translation'   => undef,
+      '!translation'   => '',
+    ],
+  );
+
+  my $std_note = first { $_->language_id == $self->language_id } @{ $std_notes };
+  $std_note  //= first { !defined $_->language_id }              @{ $std_notes };
+
+  my $notes = $self->notes_as_stripped_html;
+
+  _included_note($self, %params, note => $self->transaction_description) if $self->transaction_description;
+  _included_note($self, %params, note => $notes)                         if $notes;
+  _included_note($self, %params, note => $std_note->translation)         if $std_note;
+
+  $params{xml}->endTag;
+  #   </rsm:ExchangedDocument>
+}
+
+sub _specified_tax_registration {
+  my ($ustid_nr, %params) = @_;
+
+  #         <ram:SpecifiedTaxRegistration>
+  $params{xml}->startTag("ram:SpecifiedTaxRegistration");
+  $params{xml}->dataElement("ram:ID", _u8(SL::VATIDNr->normalize($ustid_nr)), schemeID => "VA");
+  $params{xml}->endTag;
+  #         </ram:SpecifiedTaxRegistration>
+}
+
+sub _seller_trade_party {
+  my ($self, %params) = @_;
+
+  my @our_address            = _parse_our_address();
+
+  my $sales_person           = $self->salesman;
+  my $sales_person_auth      = SL::DB::Manager::AuthUser->find_by(login => $sales_person->login);
+  my %sales_person_cfg       = $sales_person_auth ? %{ $sales_person_auth->config_values } : ();
+  $sales_person_cfg{email} ||= $sales_person->deleted_email;
+  $sales_person_cfg{tel}   ||= $sales_person->deleted_tel;
+
+  #       <ram:SellerTradeParty>
+  $params{xml}->startTag("ram:SellerTradeParty");
+  $params{xml}->dataElement("ram:ID",   _u8($self->customer->c_vendor_id)) if ($self->customer->c_vendor_id // '') ne '';
+  $params{xml}->dataElement("ram:Name", _u8($::instance_conf->get_company));
+
+  #         <ram:DefinedTradeContact>
+  $params{xml}->startTag("ram:DefinedTradeContact");
+
+  $params{xml}->dataElement("ram:PersonName", _u8($sales_person_cfg{name} || $sales_person_cfg{login}));
+
+  if ($sales_person_cfg{tel}) {
+    $params{xml}->startTag("ram:TelephoneUniversalCommunication");
+    $params{xml}->dataElement("ram:CompleteNumber", _u8($sales_person_cfg{tel}));
+    $params{xml}->endTag;
+  }
+
+  if ($sales_person_cfg{email}) {
+    $params{xml}->startTag("ram:EmailURIUniversalCommunication");
+    $params{xml}->dataElement("ram:URIID", _u8($sales_person_cfg{email}));
+    $params{xml}->endTag;
+  }
+
+  $params{xml}->endTag;
+  #         </ram:DefinedTradeContact>
+
+  if (@our_address) {
+    #         <ram:PostalTradeAddress>
+    $params{xml}->startTag("ram:PostalTradeAddress");
+    foreach my $element (@our_address) {
+      $params{xml}->dataElement("ram:" . $element->[0], _u8($element->[1]));
+    }
+    $params{xml}->endTag;
+    #         </ram:PostalTradeAddress>
+  }
+
+  _specified_tax_registration($::instance_conf->get_co_ustid, %params);
+
+  $params{xml}->endTag;
+  #     </ram:SellerTradeParty>
+}
+
+sub _buyer_trade_party {
+  my ($self, %params) = @_;
+
+  #       <ram:BuyerTradeParty>
+  $params{xml}->startTag("ram:BuyerTradeParty");
+  $params{xml}->dataElement("ram:ID",   _u8($self->customer->customernumber));
+  $params{xml}->dataElement("ram:Name", _u8($self->customer->name));
+
+  _customer_postal_trade_address(%params, customer => $self->customer);
+  _specified_tax_registration($self->customer->ustid, %params);
+
+  $params{xml}->endTag;
+  #       </ram:BuyerTradeParty>
+}
+
+sub _included_supply_chain_trade_line_item {
+  my ($self, %params) = @_;
+
+  my $line_number = 0;
+  foreach my $item (@{ $self->items }) {
+    _line_item($self, %params, item => $item, line_number => $line_number);
+    $line_number++;
+  }
+}
+
+sub _applicable_header_trade_agreement {
+  my ($self, %params) = @_;
+
+  #     <ram:ApplicableHeaderTradeAgreement>
+  $params{xml}->startTag("ram:ApplicableHeaderTradeAgreement");
+
+  _seller_trade_party($self, %params);
+  _buyer_trade_party($self, %params);
+
+  if ($self->cusordnumber) {
+    #     <ram:BuyerOrderReferencedDocument>
+    $params{xml}->startTag("ram:BuyerOrderReferencedDocument");
+    $params{xml}->dataElement("ram:IssuerAssignedID", _u8($self->cusordnumber));
+    $params{xml}->endTag;
+    #     </ram:BuyerOrderReferencedDocument>
+  }
+
+  $params{xml}->endTag;
+  #     </ram:ApplicableHeaderTradeAgreement>
+}
+
+sub _applicable_header_trade_delivery {
+  my ($self, %params) = @_;
+
+  #     <ram:ApplicableHeaderTradeDelivery>
+  $params{xml}->startTag("ram:ApplicableHeaderTradeDelivery");
+  #       <ram:ActualDeliverySupplyChainEvent>
+  $params{xml}->startTag("ram:ActualDeliverySupplyChainEvent");
+
+  $params{xml}->startTag("ram:OccurrenceDateTime");
+  $params{xml}->dataElement("udt:DateTimeString", ($self->deliverydate // $self->transdate)->strftime('%Y%m%d'), format => "102");
+  $params{xml}->endTag;
+
+  $params{xml}->endTag;
+  #       </ram:ActualDeliverySupplyChainEvent>
+  $params{xml}->endTag;
+  #     </ram:ApplicableHeaderTradeDelivery>
+}
+
+sub _applicable_header_trade_settlement {
+  my ($self, %params) = @_;
+
+  #     <ram:ApplicableHeaderTradeSettlement>
+  $params{xml}->startTag("ram:ApplicableHeaderTradeSettlement");
+  $params{xml}->dataElement("ram:InvoiceCurrencyCode", _u8(SL::Helper::ISO4217::map_currency_name_to_code($self->currency->name) // 'EUR'));
+
+  _specified_trade_settlement_payment_means($self, %params);
+  _taxes($self, %params);
+  _payment_terms($self, %params);
+  _totals($self, %params);
+
+  $params{xml}->endTag;
+  #     </ram:ApplicableHeaderTradeSettlement>
+}
+
+sub _supply_chain_trade_transaction {
+  my ($self, %params) = @_;
+
+  #   <rsm:SupplyChainTradeTransaction>
+  $params{xml}->startTag("rsm:SupplyChainTradeTransaction");
+
+  _included_supply_chain_trade_line_item($self, %params);
+  _applicable_header_trade_agreement($self, %params);
+  _applicable_header_trade_delivery($self, %params);
+  _applicable_header_trade_settlement($self, %params);
+
+  $params{xml}->endTag;
+  #   </rsm:SupplyChainTradeTransaction>
+}
+
+sub _validate_data {
+  my ($self) = @_;
+
+  my %result;
+  my $prefix = $::locale->text('The ZUGFeRD invoice data cannot be generated because the data validation failed.') . ' ';
+
+  if (!$::instance_conf->get_co_ustid) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The VAT registration number is missing in the client configuration.'));
+  }
+
+  if (!SL::VATIDNr->validate($::instance_conf->get_co_ustid)) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text("The VAT ID number in the client configuration is invalid."));
+  }
+
+  if (!$::instance_conf->get_company || any { my $get = "get_address_$_"; !$::instance_conf->$get } qw(street1 zipcode city)) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The company\'s address information is incomplete in the client configuration.'));
+  }
+
+  if ($::instance_conf->get_address_country && !SL::Helper::ISO3166::map_name_to_alpha_2_code($::instance_conf->get_address_country)) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The country from the company\'s address in the client configuration cannot be mapped to an ISO 3166-1 alpha 2 code.'));
+  }
+
+  if ($self->customer->country && !SL::Helper::ISO3166::map_name_to_alpha_2_code($self->customer->country)) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The country from the customer\'s address cannot be mapped to an ISO 3166-1 alpha 2 code.'));
+  }
+
+  if (!SL::Helper::ISO4217::map_currency_name_to_code($self->currency->name)) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The currency "#1" cannot be mapped to an ISO 4217 currency code.', $self->currency->name));
+  }
+
+  my $failed_unit = first { !SL::Helper::UNECERecommendation20::map_name_to_code($_) } map { $_->unit } @{ $self->items };
+  if ($failed_unit) {
+    SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('One of the units used (#1) cannot be mapped to a known unit code from the UN/ECE Recommendation 20 list.', $failed_unit));
+  }
+
+  if ($self->direct_debit) {
+    if (!$self->customer->iban) {
+      SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('The customer\'s bank account number (IBAN) is missing.'));
+    }
+
+  } else {
+    my $bank_accounts     = SL::DB::Manager::BankAccount->get_all;
+    $result{bank_account} = scalar(@{ $bank_accounts }) == 1 ? $bank_accounts->[0] : first { $_->use_for_zugferd } @{ $bank_accounts };
+
+    if (!$result{bank_account}) {
+      SL::X::ZUGFeRDValidation->throw(message => $prefix . $::locale->text('No bank account flagged for ZUGFeRD usage was found.'));
+    }
+  }
+
+  return %result;
+}
+
+sub create_zugferd_data {
+  my ($self)        = @_;
+
+  my $output        = '';
+
+  my %params        = _validate_data($self);
+  $params{ptc_data} = { $self->calculate_prices_and_taxes };
+  $params{xml}      = XML::Writer->new(
+    OUTPUT          => \$output,
+    DATA_MODE       => 1,
+    DATA_INDENT     => 2,
+    ENCODING        => 'utf-8',
+  );
+
+  $params{xml}->xmlDecl();
+
+  # <rsm:CrossIndustryInvoice>
+  $params{xml}->startTag("rsm:CrossIndustryInvoice",
+                         "xmlns:a"   => "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
+                         "xmlns:rsm" => "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
+                         "xmlns:qdt" => "urn:un:unece:uncefact:data:standard:QualifiedDataType:10",
+                         "xmlns:ram" => "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
+                         "xmlns:xs"  => "http://www.w3.org/2001/XMLSchema",
+                         "xmlns:udt" => "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100");
+
+  _exchanged_document_context($self, %params);
+  _exchanged_document($self, %params);
+  _supply_chain_trade_transaction($self, %params);
+
+  $params{xml}->endTag;
+  # </rsm:CrossIndustryInvoice>
+
+  return $output;
+}
+
+sub create_zugferd_xmp_data {
+  my ($self) = @_;
+
+  return {
+    conformance_level  => 'EXTENDED',
+    document_file_name => 'ZUGFeRD-invoice.xml',
+    document_type      => 'INVOICE',
+    version            => '1.0',
+  };
+}
+
+1;
index 6f8438f..f33d75d 100644 (file)
@@ -13,9 +13,11 @@ use SL::DB::Helper::AttrHTML;
 use SL::DB::Helper::AttrSorted;
 use SL::DB::Helper::FlattenToForm;
 use SL::DB::Helper::LinkedRecords;
+use SL::DB::Helper::PDF_A;
 use SL::DB::Helper::PriceTaxCalculator;
 use SL::DB::Helper::PriceUpdater;
 use SL::DB::Helper::TransNumberGenerator;
+use SL::DB::Helper::ZUGFeRD;
 use SL::Locale::String qw(t8);
 use SL::DB::CustomVariable;
 
index b72a170..5589ee4 100644 (file)
@@ -21,6 +21,7 @@ __PACKAGE__->meta->columns(
   reconciliation_starting_balance => { type => 'numeric', precision => 15, scale => 5 },
   reconciliation_starting_date    => { type => 'date' },
   sortkey                         => { type => 'integer', not_null => 1 },
+  use_for_zugferd                 => { type => 'boolean', default => 'false', not_null => 1 },
 );
 
 __PACKAGE__->meta->primary_key_columns([ 'id' ]);
index 775ee8b..ef84112 100644 (file)
@@ -22,6 +22,7 @@ __PACKAGE__->meta->columns(
   contact                   => { type => 'text' },
   contact_origin            => { type => 'text' },
   country                   => { type => 'text' },
+  create_zugferd_invoices   => { type => 'integer', default => '-1', not_null => 1 },
   creditlimit               => { type => 'numeric', default => '0', precision => 15, scale => 5 },
   currency_id               => { type => 'integer', not_null => 1 },
   customernumber            => { type => 'text' },
index 9ad1109..76373ea 100644 (file)
@@ -10,7 +10,11 @@ __PACKAGE__->meta->table('defaults');
 
 __PACKAGE__->meta->columns(
   accounting_method                         => { type => 'text' },
-  address                                   => { type => 'text' },
+  address_city                              => { type => 'text' },
+  address_country                           => { type => 'text' },
+  address_street1                           => { type => 'text' },
+  address_street2                           => { type => 'text' },
+  address_zipcode                           => { type => 'text' },
   allow_new_purchase_delivery_order         => { type => 'boolean', default => 'true', not_null => 1 },
   allow_new_purchase_invoice                => { type => 'boolean', default => 'true', not_null => 1 },
   allow_sales_invoice_from_sales_order      => { type => 'boolean', default => 'true', not_null => 1 },
@@ -37,6 +41,7 @@ __PACKAGE__->meta->columns(
   coa                                       => { type => 'text' },
   company                                   => { type => 'text' },
   create_part_if_not_found                  => { type => 'boolean', default => 'false' },
+  create_zugferd_invoices                   => { type => 'integer' },
   currency_id                               => { type => 'integer', not_null => 1 },
   customer_hourly_rate                      => { type => 'numeric', precision => 8, scale => 2 },
   customer_projects_only_in_sales           => { type => 'boolean', default => 'false', not_null => 1 },
index 1c1bffd..6844b63 100644 (file)
@@ -10,6 +10,7 @@ use SL::DB::MetaSetup::Vendor;
 use SL::DB::Manager::Vendor;
 use SL::DB::Helper::IBANValidation;
 use SL::DB::Helper::TransNumberGenerator;
+use SL::DB::Helper::VATIDNrValidation;
 use SL::DB::Helper::CustomVariables (
   module      => 'CT',
   cvars_alias => 1,
@@ -60,6 +61,7 @@ sub validate {
   my @errors;
   push @errors, $::locale->text('The vendor name is missing.') if !$self->name;
   push @errors, $self->validate_ibans;
+  push @errors, $self->validate_vat_id_numbers;
 
   return @errors;
 }
index 5eb6994..e814351 100644 (file)
@@ -47,6 +47,7 @@ use CGI;
 use Cwd;
 use Encode;
 use File::Copy;
+use File::Temp ();
 use IO::File;
 use Math::BigInt;
 use POSIX qw(strftime);
@@ -909,11 +910,18 @@ sub parse_template {
 
   local (*IN, *OUT);
 
-  my $defaults  = SL::DB::Default->get;
-  my $userspath = $::lx_office_conf{paths}->{userspath};
+  my $defaults        = SL::DB::Default->get;
 
-  $self->{"cwd"} = getcwd();
-  $self->{"tmpdir"} = $self->{cwd} . "/${userspath}";
+  my $keep_temp_files = $::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files};
+  $self->{cwd}        = getcwd();
+  my $temp_dir        = File::Temp->newdir(
+    "kivitendo-print-XXXXXX",
+    DIR     => $self->{cwd} . "/" . $::lx_office_conf{paths}->{userspath},
+    CLEANUP => !$keep_temp_files,
+  );
+
+  my $userspath   = File::Spec->abs2rel($temp_dir->dirname);
+  $self->{tmpdir} = $temp_dir->dirname;
 
   my $ext_for_format;
 
@@ -930,13 +938,6 @@ sub parse_template {
     $template_type  = 'HTML';
     $ext_for_format = 'html';
 
-  } elsif (($self->{"format"} =~ /xml/i) || (!$self->{"format"} && ($self->{"IN"} =~ /xml$/i))) {
-    $template_type  = 'XML';
-    $ext_for_format = 'xml';
-
-  } elsif ( $self->{"format"} =~ /elster(?:winston|taxbird)/i ) {
-    $template_type = 'XML';
-
   } elsif ( $self->{"format"} =~ /excel/i ) {
     $template_type  = 'Excel';
     $ext_for_format = 'xls';
@@ -980,7 +981,6 @@ sub parse_template {
 
   # OUT is used for the media, screen, printer, email
   # for postscript we store a copy in a temporary file
-  my $keep_temp_files = $::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files};
 
   my ($temp_fh, $suffix);
   $suffix =  $self->{IN};
@@ -1119,8 +1119,8 @@ sub send_email {
   if (($self->{format} eq 'html') && ($self->{sendmode} eq 'inline')) {
     $mail->{content_type}   =  "text/html";
     $mail->{message}        =~ s/\r//g;
-    $mail->{message}        =~ s/\n/<br>\n/g;
-    $full_signature         =~ s/\n/<br>\n/g;
+    $mail->{message}        =~ s{\n}{<br>\n}g;
+    $full_signature         =~ s{\n}{<br>\n}g;
     $mail->{message}       .=  $full_signature;
 
     open(IN, "<", $self->{tmpfile})
@@ -1130,7 +1130,7 @@ sub send_email {
 
   } elsif (($self->{attachment_policy} // '') ne 'no_file') {
     my $attachment_name  =  $self->{attachment_filename}  || $self->{tmpfile};
-    $attachment_name     =~ s/\.(.+?)$/.${ext_for_format}/ if ($ext_for_format);
+    $attachment_name     =~ s{\.(.+?)$}{.${ext_for_format}} if ($ext_for_format);
 
     if (($self->{attachment_policy} // '') eq 'old_file') {
       my ( $attfile ) = SL::File->get_all(object_id   => $self->{id},
index 13d5174..6a570fa 100644 (file)
@@ -17,6 +17,7 @@ use SL::Common;
 use SL::DB::Language;
 use SL::DB::Printer;
 use SL::MoreCommon;
+use SL::System::Process;
 use SL::Template;
 use SL::Template::LaTeX;
 
@@ -39,15 +40,22 @@ sub create_pdf {
 sub create_parsed_file {
   my ($class, %params) = @_;
 
-  my $userspath      = $::lx_office_conf{paths}->{userspath};
+  my $keep_temp_files = $::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files};
+  my $userspath       = SL::System::Process::exe_dir() . "/" . $::lx_office_conf{paths}->{userspath};
+  my $temp_dir        = File::Temp->newdir(
+    "kivitendo-print-XXXXXX",
+    DIR     => $userspath,
+    CLEANUP => !$keep_temp_files,
+  );
+
   my $vars           = $params{variables} || {};
   my $form           = Form->new('');
   $form->{$_}        = $vars->{$_} for keys %{$vars};
   $form->{format}    = lc($params{format} || 'pdf');
-  $form->{cwd}       = getcwd();
+  $form->{cwd}       = SL::System::Process::exe_dir();
   $form->{templates} = $::instance_conf->get_templates;
   $form->{IN}        = $params{template};
-  $form->{tmpdir}    = $form->{cwd} . '/' . $userspath;
+  $form->{tmpdir}    = $temp_dir->dirname;
   my $tmpdir         = $form->{tmpdir};
   my ($suffix)       = $params{template} =~ m{\.(.+)};
 
@@ -55,7 +63,7 @@ sub create_parsed_file {
     'kivitendo-printXXXXXX',
     SUFFIX => ".${suffix}",
     DIR    => $form->{tmpdir},
-    UNLINK => ($::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files})? 0 : 1,
+    UNLINK => !$keep_temp_files,
   );
 
   $form->{tmpfile} = $tmpfile;
@@ -87,7 +95,7 @@ sub create_parsed_file {
   my ($volume, $directory, $file_name) = File::Spec->splitpath($form->{tmpfile});
   my $full_file_name                   = File::Spec->catfile($tmpdir, $file_name);
   if (($params{return} || 'content') eq 'file_name') {
-    my $new_name = File::Spec->catfile($tmpdir, 'keep-' . $form->{tmpfile});
+    my $new_name = File::Spec->catfile($userspath, 'keep-' . $form->{tmpfile});
     rename $full_file_name, $new_name;
 
     $form->cleanup;
diff --git a/SL/Helper/ISO3166.pm b/SL/Helper/ISO3166.pm
new file mode 100644 (file)
index 0000000..9e8b660
--- /dev/null
@@ -0,0 +1,278 @@
+package SL::Helper::ISO3166;
+
+use strict;
+use warnings;
+use utf8;
+
+use Exporter qw(import);
+our @EXPORT_OK = qw(map_name_to_alpha_2_code);
+
+use List::Util qw(first);
+
+my @alpha_2_mappings = (
+  [ 'AD', qr{^(?:AD|Andorra)$}i ],
+  [ 'AE', qr{^(?:AE|United Arab Emirates)$}i ],
+  [ 'AF', qr{^(?:AF|Afghanistan)$}i ],
+  [ 'AG', qr{^(?:AG|Antigua and Barbuda)$}i ],
+  [ 'AI', qr{^(?:AI|Anguilla)$}i ],
+  [ 'AL', qr{^(?:AL|Albania)$}i ],
+  [ 'AM', qr{^(?:AM|Armenia)$}i ],
+  [ 'AO', qr{^(?:AO|Angola)$}i ],
+  [ 'AQ', qr{^(?:AQ|Antarctica)$}i ],
+  [ 'AR', qr{^(?:AR|Argentina)$}i ],
+  [ 'AS', qr{^(?:AS|American Samoa)$}i ],
+  [ 'AT', qr{^(?:AT|A|Austria|Österreich)$}i ],
+  [ 'AU', qr{^(?:AU|Australia)$}i ],
+  [ 'AW', qr{^(?:AW|Aruba)$}i ],
+  [ 'AX', qr{^(?:AX|Åland Islands)$}i ],
+  [ 'AZ', qr{^(?:AZ|Azerbaijan)$}i ],
+  [ 'BA', qr{^(?:BA|Bosnia and Herzegovina)$}i ],
+  [ 'BB', qr{^(?:BB|Barbados)$}i ],
+  [ 'BD', qr{^(?:BD|Bangladesh)$}i ],
+  [ 'BE', qr{^(?:BE|Belgium)$}i ],
+  [ 'BF', qr{^(?:BF|Burkina Faso)$}i ],
+  [ 'BG', qr{^(?:BG|Bulgaria)$}i ],
+  [ 'BH', qr{^(?:BH|Bahrain)$}i ],
+  [ 'BI', qr{^(?:BI|Burundi)$}i ],
+  [ 'BJ', qr{^(?:BJ|Benin)$}i ],
+  [ 'BL', qr{^(?:BL|Saint Barthélemy)$}i ],
+  [ 'BM', qr{^(?:BM|Bermuda)$}i ],
+  [ 'BN', qr{^(?:BN|Brunei Darussalam)$}i ],
+  [ 'BO', qr{^(?:BO|Bolivia \(Plurinational State of\))$}i ],
+  [ 'BQ', qr{^(?:BQ|Bonaire, Sint Eustatius and Saba)$}i ],
+  [ 'BR', qr{^(?:BR|Brazil)$}i ],
+  [ 'BS', qr{^(?:BS|Bahamas)$}i ],
+  [ 'BT', qr{^(?:BT|Bhutan)$}i ],
+  [ 'BV', qr{^(?:BV|Bouvet Island)$}i ],
+  [ 'BW', qr{^(?:BW|Botswana)$}i ],
+  [ 'BY', qr{^(?:BY|Belarus)$}i ],
+  [ 'BZ', qr{^(?:BZ|Belize)$}i ],
+  [ 'CA', qr{^(?:CA|Canada)$}i ],
+  [ 'CC', qr{^(?:CC|Cocos \(Keeling\) Islands|Cocos Islands|Keeling Islands)$}i ],
+  [ 'CD', qr{^(?:CD|Congo, Democratic Republic of the)$}i ],
+  [ 'CF', qr{^(?:CF|Central African Republic)$}i ],
+  [ 'CG', qr{^(?:CG|Congo)$}i ],
+  [ 'CH', qr{^(?:CH|Switzerland|Schweiz)$}i ],
+  [ 'CI', qr{^(?:CI|Côte d'Ivoire)$}i ],
+  [ 'CK', qr{^(?:CK|Cook Islands)$}i ],
+  [ 'CL', qr{^(?:CL|Chile)$}i ],
+  [ 'CM', qr{^(?:CM|Cameroon)$}i ],
+  [ 'CN', qr{^(?:CN|China)$}i ],
+  [ 'CO', qr{^(?:CO|Colombia)$}i ],
+  [ 'CR', qr{^(?:CR|Costa Rica)$}i ],
+  [ 'CU', qr{^(?:CU|Cuba)$}i ],
+  [ 'CV', qr{^(?:CV|Cabo Verde)$}i ],
+  [ 'CW', qr{^(?:CW|Curaçao)$}i ],
+  [ 'CX', qr{^(?:CX|Christmas Island)$}i ],
+  [ 'CY', qr{^(?:CY|Cyprus)$}i ],
+  [ 'CZ', qr{^(?:CZ|Czechia)$}i ],
+  [ 'DE', qr{^(?:DE|Germany|D|Deutschland)$}i ],
+  [ 'DJ', qr{^(?:DJ|Djibouti)$}i ],
+  [ 'DK', qr{^(?:DK|Denmark)$}i ],
+  [ 'DM', qr{^(?:DM|Dominica)$}i ],
+  [ 'DO', qr{^(?:DO|Dominican Republic)$}i ],
+  [ 'DZ', qr{^(?:DZ|Algeria)$}i ],
+  [ 'EC', qr{^(?:EC|Ecuador)$}i ],
+  [ 'EE', qr{^(?:EE|Estonia)$}i ],
+  [ 'EG', qr{^(?:EG|Egypt)$}i ],
+  [ 'EH', qr{^(?:EH|Western Sahara)$}i ],
+  [ 'ER', qr{^(?:ER|Eritrea)$}i ],
+  [ 'ES', qr{^(?:ES|Spain)$}i ],
+  [ 'ET', qr{^(?:ET|Ethiopia)$}i ],
+  [ 'FI', qr{^(?:FI|Finland)$}i ],
+  [ 'FJ', qr{^(?:FJ|Fiji)$}i ],
+  [ 'FK', qr{^(?:FK|Falkland Islands \(Malvinas\)|Falkland Islands|Falklands)$}i ],
+  [ 'FM', qr{^(?:FM|Micronesia \(Federated States of\)|Micronesia)$}i ],
+  [ 'FO', qr{^(?:FO|Faroe Islands)$}i ],
+  [ 'FR', qr{^(?:FR|France)$}i ],
+  [ 'GA', qr{^(?:GA|Gabon)$}i ],
+  [ 'GB', qr{^(?:GB|United Kingdom of Great Britain and Northern Ireland)$}i ],
+  [ 'GD', qr{^(?:GD|Grenada)$}i ],
+  [ 'GE', qr{^(?:GE|Georgia)$}i ],
+  [ 'GF', qr{^(?:GF|French Guiana)$}i ],
+  [ 'GG', qr{^(?:GG|Guernsey)$}i ],
+  [ 'GH', qr{^(?:GH|Ghana)$}i ],
+  [ 'GI', qr{^(?:GI|Gibraltar)$}i ],
+  [ 'GL', qr{^(?:GL|Greenland)$}i ],
+  [ 'GM', qr{^(?:GM|Gambia)$}i ],
+  [ 'GN', qr{^(?:GN|Guinea)$}i ],
+  [ 'GP', qr{^(?:GP|Guadeloupe)$}i ],
+  [ 'GQ', qr{^(?:GQ|Equatorial Guinea)$}i ],
+  [ 'GR', qr{^(?:GR|Greece)$}i ],
+  [ 'GS', qr{^(?:GS|South Georgia and the South Sandwich Islands)$}i ],
+  [ 'GT', qr{^(?:GT|Guatemala)$}i ],
+  [ 'GU', qr{^(?:GU|Guam)$}i ],
+  [ 'GW', qr{^(?:GW|Guinea-Bissau)$}i ],
+  [ 'GY', qr{^(?:GY|Guyana)$}i ],
+  [ 'HK', qr{^(?:HK|Hong Kong)$}i ],
+  [ 'HM', qr{^(?:HM|Heard Island and McDonald Islands)$}i ],
+  [ 'HN', qr{^(?:HN|Honduras)$}i ],
+  [ 'HR', qr{^(?:HR|Croatia)$}i ],
+  [ 'HT', qr{^(?:HT|Haiti)$}i ],
+  [ 'HU', qr{^(?:HU|Hungary)$}i ],
+  [ 'ID', qr{^(?:ID|Indonesia)$}i ],
+  [ 'IE', qr{^(?:IE|Ireland)$}i ],
+  [ 'IL', qr{^(?:IL|Israel)$}i ],
+  [ 'IM', qr{^(?:IM|Isle of Man)$}i ],
+  [ 'IN', qr{^(?:IN|India)$}i ],
+  [ 'IO', qr{^(?:IO|British Indian Ocean Territory)$}i ],
+  [ 'IQ', qr{^(?:IQ|Iraq)$}i ],
+  [ 'IR', qr{^(?:IR|Iran \(Islamic Republic of\)|Iran)$}i ],
+  [ 'IS', qr{^(?:IS|Iceland)$}i ],
+  [ 'IT', qr{^(?:IT|Italy)$}i ],
+  [ 'JE', qr{^(?:JE|Jersey)$}i ],
+  [ 'JM', qr{^(?:JM|Jamaica)$}i ],
+  [ 'JO', qr{^(?:JO|Jordan)$}i ],
+  [ 'JP', qr{^(?:JP|Japan)$}i ],
+  [ 'KE', qr{^(?:KE|Kenya)$}i ],
+  [ 'KG', qr{^(?:KG|Kyrgyzstan)$}i ],
+  [ 'KH', qr{^(?:KH|Cambodia)$}i ],
+  [ 'KI', qr{^(?:KI|Kiribati)$}i ],
+  [ 'KM', qr{^(?:KM|Comoros)$}i ],
+  [ 'KN', qr{^(?:KN|Saint Kitts and Nevis)$}i ],
+  [ 'KP', qr{^(?:KP|Korea \(Democratic People's Republic of\))$}i ],
+  [ 'KR', qr{^(?:KR|Korea, Republic of|Republic of Korea|Korea)$}i ],
+  [ 'KW', qr{^(?:KW|Kuwait)$}i ],
+  [ 'KY', qr{^(?:KY|Cayman Islands)$}i ],
+  [ 'KZ', qr{^(?:KZ|Kazakhstan)$}i ],
+  [ 'LA', qr{^(?:LA|Lao People's Democratic Republic)$}i ],
+  [ 'LB', qr{^(?:LB|Lebanon)$}i ],
+  [ 'LC', qr{^(?:LC|Saint Lucia)$}i ],
+  [ 'LI', qr{^(?:LI|Liechtenstein)$}i ],
+  [ 'LK', qr{^(?:LK|Sri Lanka)$}i ],
+  [ 'LR', qr{^(?:LR|Liberia)$}i ],
+  [ 'LS', qr{^(?:LS|Lesotho)$}i ],
+  [ 'LT', qr{^(?:LT|Lithuania)$}i ],
+  [ 'LU', qr{^(?:LU|Luxembourg)$}i ],
+  [ 'LV', qr{^(?:LV|Latvia)$}i ],
+  [ 'LY', qr{^(?:LY|Libya)$}i ],
+  [ 'MA', qr{^(?:MA|Morocco)$}i ],
+  [ 'MC', qr{^(?:MC|Monaco)$}i ],
+  [ 'MD', qr{^(?:MD|Moldova, Republic of)$}i ],
+  [ 'ME', qr{^(?:ME|Montenegro)$}i ],
+  [ 'MF', qr{^(?:MF|Saint Martin \(French part\)|Saint Martin)$}i ],
+  [ 'MG', qr{^(?:MG|Madagascar)$}i ],
+  [ 'MH', qr{^(?:MH|Marshall Islands)$}i ],
+  [ 'MK', qr{^(?:MK|North Macedonia)$}i ],
+  [ 'ML', qr{^(?:ML|Mali)$}i ],
+  [ 'MM', qr{^(?:MM|Myanmar)$}i ],
+  [ 'MN', qr{^(?:MN|Mongolia)$}i ],
+  [ 'MO', qr{^(?:MO|Macao)$}i ],
+  [ 'MP', qr{^(?:MP|Northern Mariana Islands)$}i ],
+  [ 'MQ', qr{^(?:MQ|Martinique)$}i ],
+  [ 'MR', qr{^(?:MR|Mauritania)$}i ],
+  [ 'MS', qr{^(?:MS|Montserrat)$}i ],
+  [ 'MT', qr{^(?:MT|Malta)$}i ],
+  [ 'MU', qr{^(?:MU|Mauritius)$}i ],
+  [ 'MV', qr{^(?:MV|Maldives)$}i ],
+  [ 'MW', qr{^(?:MW|Malawi)$}i ],
+  [ 'MX', qr{^(?:MX|Mexico)$}i ],
+  [ 'MY', qr{^(?:MY|Malaysia)$}i ],
+  [ 'MZ', qr{^(?:MZ|Mozambique)$}i ],
+  [ 'NA', qr{^(?:NA|Namibia)$}i ],
+  [ 'NC', qr{^(?:NC|New Caledonia)$}i ],
+  [ 'NE', qr{^(?:NE|Niger)$}i ],
+  [ 'NF', qr{^(?:NF|Norfolk Island)$}i ],
+  [ 'NG', qr{^(?:NG|Nigeria)$}i ],
+  [ 'NI', qr{^(?:NI|Nicaragua)$}i ],
+  [ 'NL', qr{^(?:NL|Netherlands)$}i ],
+  [ 'NO', qr{^(?:NO|Norway)$}i ],
+  [ 'NP', qr{^(?:NP|Nepal)$}i ],
+  [ 'NR', qr{^(?:NR|Nauru)$}i ],
+  [ 'NU', qr{^(?:NU|Niue)$}i ],
+  [ 'NZ', qr{^(?:NZ|New Zealand)$}i ],
+  [ 'OM', qr{^(?:OM|Oman)$}i ],
+  [ 'PA', qr{^(?:PA|Panama)$}i ],
+  [ 'PE', qr{^(?:PE|Peru)$}i ],
+  [ 'PF', qr{^(?:PF|French Polynesia)$}i ],
+  [ 'PG', qr{^(?:PG|Papua New Guinea)$}i ],
+  [ 'PH', qr{^(?:PH|Philippines)$}i ],
+  [ 'PK', qr{^(?:PK|Pakistan)$}i ],
+  [ 'PL', qr{^(?:PL|Poland)$}i ],
+  [ 'PM', qr{^(?:PM|Saint Pierre and Miquelon)$}i ],
+  [ 'PN', qr{^(?:PN|Pitcairn)$}i ],
+  [ 'PR', qr{^(?:PR|Puerto Rico)$}i ],
+  [ 'PS', qr{^(?:PS|Palestine, State of)$}i ],
+  [ 'PT', qr{^(?:PT|Portugal)$}i ],
+  [ 'PW', qr{^(?:PW|Palau)$}i ],
+  [ 'PY', qr{^(?:PY|Paraguay)$}i ],
+  [ 'QA', qr{^(?:QA|Qatar)$}i ],
+  [ 'RE', qr{^(?:RE|Réunion)$}i ],
+  [ 'RO', qr{^(?:RO|Romania)$}i ],
+  [ 'RS', qr{^(?:RS|Serbia)$}i ],
+  [ 'RU', qr{^(?:RU|Russian Federation)$}i ],
+  [ 'RW', qr{^(?:RW|Rwanda)$}i ],
+  [ 'SA', qr{^(?:SA|Saudi Arabia)$}i ],
+  [ 'SB', qr{^(?:SB|Solomon Islands)$}i ],
+  [ 'SC', qr{^(?:SC|Seychelles)$}i ],
+  [ 'SD', qr{^(?:SD|Sudan)$}i ],
+  [ 'SE', qr{^(?:SE|Sweden)$}i ],
+  [ 'SG', qr{^(?:SG|Singapore)$}i ],
+  [ 'SH', qr{^(?:SH|Saint Helena, Ascension and Tristan da Cunha)$}i ],
+  [ 'SI', qr{^(?:SI|Slovenia)$}i ],
+  [ 'SJ', qr{^(?:SJ|Svalbard and Jan Mayen)$}i ],
+  [ 'SK', qr{^(?:SK|Slovakia)$}i ],
+  [ 'SL', qr{^(?:SL|Sierra Leone)$}i ],
+  [ 'SM', qr{^(?:SM|San Marino)$}i ],
+  [ 'SN', qr{^(?:SN|Senegal)$}i ],
+  [ 'SO', qr{^(?:SO|Somalia)$}i ],
+  [ 'SR', qr{^(?:SR|Suriname)$}i ],
+  [ 'SS', qr{^(?:SS|South Sudan)$}i ],
+  [ 'ST', qr{^(?:ST|Sao Tome and Principe)$}i ],
+  [ 'SV', qr{^(?:SV|El Salvador)$}i ],
+  [ 'SX', qr{^(?:SX|Sint Maarten \(Dutch part\)|Sint Maarten)$}i ],
+  [ 'SY', qr{^(?:SY|Syrian Arab Republic)$}i ],
+  [ 'SZ', qr{^(?:SZ|Eswatini)$}i ],
+  [ 'TC', qr{^(?:TC|Turks and Caicos Islands)$}i ],
+  [ 'TD', qr{^(?:TD|Chad)$}i ],
+  [ 'TF', qr{^(?:TF|French Southern Territories)$}i ],
+  [ 'TG', qr{^(?:TG|Togo)$}i ],
+  [ 'TH', qr{^(?:TH|Thailand)$}i ],
+  [ 'TJ', qr{^(?:TJ|Tajikistan)$}i ],
+  [ 'TK', qr{^(?:TK|Tokelau)$}i ],
+  [ 'TL', qr{^(?:TL|Timor-Leste)$}i ],
+  [ 'TM', qr{^(?:TM|Turkmenistan)$}i ],
+  [ 'TN', qr{^(?:TN|Tunisia)$}i ],
+  [ 'TO', qr{^(?:TO|Tonga)$}i ],
+  [ 'TR', qr{^(?:TR|Turkey)$}i ],
+  [ 'TT', qr{^(?:TT|Trinidad and Tobago)$}i ],
+  [ 'TV', qr{^(?:TV|Tuvalu)$}i ],
+  [ 'TW', qr{^(?:TW|Taiwan, Province of China)$}i ],
+  [ 'TZ', qr{^(?:TZ|Tanzania, United Republic of)$}i ],
+  [ 'UA', qr{^(?:UA|Ukraine)$}i ],
+  [ 'UG', qr{^(?:UG|Uganda)$}i ],
+  [ 'UM', qr{^(?:UM|United States Minor Outlying Islands)$}i ],
+  [ 'US', qr{^(?:US|United States of America)$}i ],
+  [ 'UY', qr{^(?:UY|Uruguay)$}i ],
+  [ 'UZ', qr{^(?:UZ|Uzbekistan)$}i ],
+  [ 'VA', qr{^(?:VA|Holy See)$}i ],
+  [ 'VC', qr{^(?:VC|Saint Vincent and the Grenadines)$}i ],
+  [ 'VE', qr{^(?:VE|Venezuela \(Bolivian Republic of\)|Venezuela)$}i ],
+  [ 'VG', qr{^(?:VG|Virgin Islands \(British\))$}i ],
+  [ 'VI', qr{^(?:VI|Virgin Islands \(U\.?S\.?\))$}i ],
+  [ 'VN', qr{^(?:VN|Viet Nam)$}i ],
+  [ 'VU', qr{^(?:VU|Vanuatu)$}i ],
+  [ 'WF', qr{^(?:WF|Wallis and Futuna)$}i ],
+  [ 'WS', qr{^(?:WS|Samoa)$}i ],
+  [ 'YE', qr{^(?:YE|Yemen)$}i ],
+  [ 'YT', qr{^(?:YT|Mayotte)$}i ],
+  [ 'ZA', qr{^(?:ZA|South Africa)$}i ],
+  [ 'ZM', qr{^(?:ZM|Zambia)$}i ],
+  [ 'ZW', qr{^(?:ZW|Zimbabwe)$}i ],
+);
+
+sub map_name_to_alpha_2_code {
+  my ($country) = @_;
+
+  return undef if ($country // '') eq '';
+
+  my $code = first { $country =~ $_->[1] } @alpha_2_mappings;
+  return $code->[0] if $code;
+
+  no warnings 'once';
+  $::lxdebug->message(LXDebug::WARN(), "ISO3166::map_name_to_alpha_2_code: no mapping found for '$country'");
+
+  return undef;
+}
+
+1;
diff --git a/SL/Helper/ISO4217.pm b/SL/Helper/ISO4217.pm
new file mode 100644 (file)
index 0000000..89d996b
--- /dev/null
@@ -0,0 +1,211 @@
+package SL::Helper::ISO4217;
+
+use strict;
+use warnings;
+use utf8;
+
+use Exporter qw(import);
+our @EXPORT_OK = qw(map_currency_name_to_code);
+
+use List::Util qw(first);
+
+my @currency_name_to_code_mappings = (
+  [ 'AED', qr{^(?:UAE Dirham|AED)$}i ],
+  [ 'AFN', qr{^(?:Afghani|AFN)$}i ],
+  [ 'ALL', qr{^(?:Lek|ALL)$}i ],
+  [ 'AMD', qr{^(?:Armenian Dram|AMD)$}i ],
+  [ 'ANG', qr{^(?:Netherlands Antillean Guilder|ANG)$}i ],
+  [ 'AOA', qr{^(?:Kwanza|AOA)$}i ],
+  [ 'ARS', qr{^(?:Argentine Peso|ARS)$}i ],
+  [ 'AUD', qr{^(?:Australian Dollar|AUD)$}i ],
+  [ 'AWG', qr{^(?:Aruban Florin|AWG)$}i ],
+  [ 'AZN', qr{^(?:Azerbaijan Manat|AZN)$}i ],
+  [ 'BAM', qr{^(?:Convertible Mark|BAM)$}i ],
+  [ 'BBD', qr{^(?:Barbados Dollar|BBD)$}i ],
+  [ 'BDT', qr{^(?:Taka|BDT)$}i ],
+  [ 'BGN', qr{^(?:Bulgarian Lev|BGN)$}i ],
+  [ 'BHD', qr{^(?:Bahraini Dinar|BHD)$}i ],
+  [ 'BIF', qr{^(?:Burundi Franc|BIF)$}i ],
+  [ 'BMD', qr{^(?:Bermudian Dollar|BMD)$}i ],
+  [ 'BND', qr{^(?:Brunei Dollar|BND)$}i ],
+  [ 'BOB', qr{^(?:Boliviano|BOB)$}i ],
+  [ 'BOV', qr{^(?:US Dollar|BOV)$}i ],
+  [ 'BRL', qr{^(?:Brazilian Real|BRL)$}i ],
+  [ 'BSD', qr{^(?:Bahamian Dollar|BSD)$}i ],
+  [ 'BTN', qr{^(?:Ngultrum|BTN)$}i ],
+  [ 'BWP', qr{^(?:Pula|BWP)$}i ],
+  [ 'BYN', qr{^(?:Belarusian Ruble|BYN)$}i ],
+  [ 'BZD', qr{^(?:Belize Dollar|BZD)$}i ],
+  [ 'CAD', qr{^(?:Canadian Dollar|CAD)$}i ],
+  [ 'CDF', qr{^(?:Congolese Franc|CDF)$}i ],
+  [ 'CHE', qr{^(?:Syrian Pound|CHE)$}i ],
+  [ 'CHF', qr{^(?:Swiss Franc|CHF)$}i ],
+  [ 'CHW', qr{^(?:Syrian Pound|CHW)$}i ],
+  [ 'CLF', qr{^(?:Yuan Renminbi|CLF)$}i ],
+  [ 'CLP', qr{^(?:Chilean Peso|CLP)$}i ],
+  [ 'CNY', qr{^(?:Yuan Renminbi|CNY)$}i ],
+  [ 'COP', qr{^(?:Colombian Peso|COP)$}i ],
+  [ 'COU', qr{^(?:Comorian Franc |COU)$}i ],
+  [ 'CRC', qr{^(?:Costa Rican Colon|CRC)$}i ],
+  [ 'CUC', qr{^(?:Peso Convertible|CUC)$}i ],
+  [ 'CUP', qr{^(?:Cuban Peso|CUP)$}i ],
+  [ 'CVE', qr{^(?:Cabo Verde Escudo|CVE)$}i ],
+  [ 'CZK', qr{^(?:Czech Koruna|CZK)$}i ],
+  [ 'DJF', qr{^(?:Djibouti Franc|DJF)$}i ],
+  [ 'DKK', qr{^(?:Danish Krone|DKK)$}i ],
+  [ 'DOP', qr{^(?:Dominican Peso|DOP)$}i ],
+  [ 'DZD', qr{^(?:Algerian Dinar|DZD)$}i ],
+  [ 'EGP', qr{^(?:Egyptian Pound|EGP)$}i ],
+  [ 'ERN', qr{^(?:Nakfa|ERN)$}i ],
+  [ 'ETB', qr{^(?:Ethiopian Birr|ETB)$}i ],
+  [ 'EUR', qr{^(?:Euro|EUR|€)$}i ],
+  [ 'FJD', qr{^(?:Fiji Dollar|FJD)$}i ],
+  [ 'FKP', qr{^(?:Falkland Islands Pound|FKP)$}i ],
+  [ 'GBP', qr{^(?:Pound Sterling|GBP)$}i ],
+  [ 'GEL', qr{^(?:Lari|GEL)$}i ],
+  [ 'GHS', qr{^(?:Ghana Cedi|GHS)$}i ],
+  [ 'GIP', qr{^(?:Gibraltar Pound|GIP)$}i ],
+  [ 'GMD', qr{^(?:Dalasi|GMD)$}i ],
+  [ 'GNF', qr{^(?:Guinean Franc|GNF)$}i ],
+  [ 'GTQ', qr{^(?:Quetzal|GTQ)$}i ],
+  [ 'GYD', qr{^(?:Guyana Dollar|GYD)$}i ],
+  [ 'HKD', qr{^(?:Hong Kong Dollar|HKD)$}i ],
+  [ 'HNL', qr{^(?:Lempira|HNL)$}i ],
+  [ 'HRK', qr{^(?:Kuna|HRK)$}i ],
+  [ 'HTG', qr{^(?:Gourde|HTG)$}i ],
+  [ 'HUF', qr{^(?:Forint|HUF)$}i ],
+  [ 'IDR', qr{^(?:Rupiah|IDR)$}i ],
+  [ 'ILS', qr{^(?:New Israeli Sheqel|ILS)$}i ],
+  [ 'INR', qr{^(?:Indian Rupee|INR)$}i ],
+  [ 'IQD', qr{^(?:Iraqi Dinar|IQD)$}i ],
+  [ 'IRR', qr{^(?:Iranian Rial|IRR)$}i ],
+  [ 'ISK', qr{^(?:Iceland Krona|ISK)$}i ],
+  [ 'JMD', qr{^(?:Jamaican Dollar|JMD)$}i ],
+  [ 'JOD', qr{^(?:Jordanian Dinar|JOD)$}i ],
+  [ 'JPY', qr{^(?:Yen|JPY|¥)$}i ],
+  [ 'KES', qr{^(?:Kenyan Shilling|KES)$}i ],
+  [ 'KGS', qr{^(?:Som|KGS)$}i ],
+  [ 'KHR', qr{^(?:Riel|KHR)$}i ],
+  [ 'KMF', qr{^(?:Comorian Franc |KMF)$}i ],
+  [ 'KPW', qr{^(?:North Korean Won|KPW)$}i ],
+  [ 'KRW', qr{^(?:Won|KRW)$}i ],
+  [ 'KWD', qr{^(?:Kuwaiti Dinar|KWD)$}i ],
+  [ 'KYD', qr{^(?:Cayman Islands Dollar|KYD)$}i ],
+  [ 'KZT', qr{^(?:Tenge|KZT)$}i ],
+  [ 'LAK', qr{^(?:Lao Kip|LAK)$}i ],
+  [ 'LBP', qr{^(?:Lebanese Pound|LBP)$}i ],
+  [ 'LKR', qr{^(?:Sri Lanka Rupee|LKR)$}i ],
+  [ 'LRD', qr{^(?:Liberian Dollar|LRD)$}i ],
+  [ 'LSL', qr{^(?:Loti|LSL)$}i ],
+  [ 'LYD', qr{^(?:Libyan Dinar|LYD)$}i ],
+  [ 'MAD', qr{^(?:Moroccan Dirham|MAD)$}i ],
+  [ 'MDL', qr{^(?:Moldovan Leu|MDL)$}i ],
+  [ 'MGA', qr{^(?:Malagasy Ariary|MGA)$}i ],
+  [ 'MKD', qr{^(?:Denar|MKD)$}i ],
+  [ 'MMK', qr{^(?:Kyat|MMK)$}i ],
+  [ 'MNT', qr{^(?:Tugrik|MNT)$}i ],
+  [ 'MOP', qr{^(?:Pataca|MOP)$}i ],
+  [ 'MRU', qr{^(?:Ouguiya|MRU)$}i ],
+  [ 'MUR', qr{^(?:Mauritius Rupee|MUR)$}i ],
+  [ 'MVR', qr{^(?:Rufiyaa|MVR)$}i ],
+  [ 'MWK', qr{^(?:Malawi Kwacha|MWK)$}i ],
+  [ 'MXN', qr{^(?:Mexican Peso|MXN)$}i ],
+  [ 'MXV', qr{^(?:US Dollar|MXV)$}i ],
+  [ 'MYR', qr{^(?:Malaysian Ringgit|MYR)$}i ],
+  [ 'MZN', qr{^(?:Mozambique Metical|MZN)$}i ],
+  [ 'NAD', qr{^(?:Namibia Dollar|NAD)$}i ],
+  [ 'NGN', qr{^(?:Naira|NGN)$}i ],
+  [ 'NIO', qr{^(?:Cordoba Oro|NIO)$}i ],
+  [ 'NOK', qr{^(?:Norwegian Krone|NOK)$}i ],
+  [ 'NPR', qr{^(?:Nepalese Rupee|NPR)$}i ],
+  [ 'NZD', qr{^(?:New Zealand Dollar|NZD)$}i ],
+  [ 'OMR', qr{^(?:Rial Omani|OMR)$}i ],
+  [ 'PAB', qr{^(?:Balboa|PAB)$}i ],
+  [ 'PAB', qr{^(?:No universal currency|PAB)$}i ],
+  [ 'PEN', qr{^(?:Sol|PEN)$}i ],
+  [ 'PGK', qr{^(?:Kina|PGK)$}i ],
+  [ 'PHP', qr{^(?:Philippine Peso|PHP)$}i ],
+  [ 'PKR', qr{^(?:Pakistan Rupee|PKR)$}i ],
+  [ 'PLN', qr{^(?:Zloty|PLN)$}i ],
+  [ 'PYG', qr{^(?:Guarani|PYG)$}i ],
+  [ 'QAR', qr{^(?:Qatari Rial|QAR)$}i ],
+  [ 'RON', qr{^(?:Romanian Leu|RON)$}i ],
+  [ 'RSD', qr{^(?:Serbian Dinar|RSD)$}i ],
+  [ 'RUB', qr{^(?:Russian Ruble|RUB)$}i ],
+  [ 'RWF', qr{^(?:Rwanda Franc|RWF)$}i ],
+  [ 'SAR', qr{^(?:Saudi Riyal|SAR)$}i ],
+  [ 'SBD', qr{^(?:Solomon Islands Dollar|SBD)$}i ],
+  [ 'SCR', qr{^(?:Seychelles Rupee|SCR)$}i ],
+  [ 'SDG', qr{^(?:Sudanese Pound|SDG)$}i ],
+  [ 'SEK', qr{^(?:Swedish Krona|SEK)$}i ],
+  [ 'SGD', qr{^(?:Singapore Dollar|SGD)$}i ],
+  [ 'SHP', qr{^(?:Saint Helena Pound|SHP)$}i ],
+  [ 'SLL', qr{^(?:Leone|SLL)$}i ],
+  [ 'SOS', qr{^(?:Somali Shilling|SOS)$}i ],
+  [ 'SRD', qr{^(?:Surinam Dollar|SRD)$}i ],
+  [ 'SSP', qr{^(?:No universal currency|SSP)$}i ],
+  [ 'SSP', qr{^(?:South Sudanese Pound|SSP)$}i ],
+  [ 'STN', qr{^(?:Dobra|STN)$}i ],
+  [ 'SVC', qr{^(?:El Salvador Colon|SVC)$}i ],
+  [ 'SYP', qr{^(?:Syrian Pound|SYP)$}i ],
+  [ 'SZL', qr{^(?:Lilangeni|SZL)$}i ],
+  [ 'THB', qr{^(?:Baht|THB)$}i ],
+  [ 'TJS', qr{^(?:Somoni|TJS)$}i ],
+  [ 'TMT', qr{^(?:Turkmenistan New Manat|TMT)$}i ],
+  [ 'TND', qr{^(?:Tunisian Dinar|TND)$}i ],
+  [ 'TOP', qr{^(?:Pa’anga|TOP)$}i ],
+  [ 'TRY', qr{^(?:Turkish Lira|TRY)$}i ],
+  [ 'TTD', qr{^(?:Trinidad and Tobago Dollar|TTD)$}i ],
+  [ 'TWD', qr{^(?:New Taiwan Dollar|TWD)$}i ],
+  [ 'TZS', qr{^(?:Tanzanian Shilling|TZS)$}i ],
+  [ 'UAH', qr{^(?:Hryvnia|UAH)$}i ],
+  [ 'UGX', qr{^(?:Uganda Shilling|UGX)$}i ],
+  [ 'USD', qr{^(?:US Dollar|USD|\$)$}i ],
+  [ 'USN', qr{^(?:Peso Uruguayo|USN)$}i ],
+  [ 'UYI', qr{^(?:Unidad Previsional|UYI)$}i ],
+  [ 'UYU', qr{^(?:Peso Uruguayo|UYU)$}i ],
+  [ 'UYW', qr{^(?:Unidad Previsional|UYW)$}i ],
+  [ 'UZS', qr{^(?:Uzbekistan Sum|UZS)$}i ],
+  [ 'VES', qr{^(?:Bolívar Soberano|VES)$}i ],
+  [ 'VND', qr{^(?:Dong|VND)$}i ],
+  [ 'VUV', qr{^(?:Vatu|VUV)$}i ],
+  [ 'WST', qr{^(?:Tala|WST)$}i ],
+  [ 'XAF', qr{^(?:CFA Franc BEAC|XAF)$}i ],
+  [ 'XAG', qr{^(?:Silver|XAG)$}i ],
+  [ 'XAU', qr{^(?:Gold|XAU)$}i ],
+  [ 'XBA', qr{^(?:Bond Markets Unit European Composite Unit \(EURCO\)|XBA)$}i ],
+  [ 'XBB', qr{^(?:Bond Markets Unit European Monetary Unit \(E\.?M\.?U\.?-6\)|XBB)$}i ],
+  [ 'XBC', qr{^(?:Bond Markets Unit European Unit of Account 9 \(E\.?U\.?A\.?-9\)|XBC)$}i ],
+  [ 'XBD', qr{^(?:Bond Markets Unit European Unit of Account 17 \(E\.?U\.?A\.?-17\)|XBD)$}i ],
+  [ 'XCD', qr{^(?:East Caribbean Dollar|XCD)$}i ],
+  [ 'XCD', qr{^(?:No universal currency|XCD)$}i ],
+  [ 'XDR', qr{^(?:SDR \(Special Drawing Right\)|SDR|XDR)$}i ],
+  [ 'XOF', qr{^(?:CFA Franc BCEAO|XOF)$}i ],
+  [ 'XPD', qr{^(?:Palladium|XPD)$}i ],
+  [ 'XPF', qr{^(?:CFP Franc|XPF)$}i ],
+  [ 'XPT', qr{^(?:Platinum|XPT)$}i ],
+  [ 'XSU', qr{^(?:Sucre|XSU)$}i ],
+  [ 'XTS', qr{^(?:Codes specifically reserved for testing purposes|XTS)$}i ],
+  [ 'XUA', qr{^(?:ADB Unit of Account|XUA)$}i ],
+  [ 'XXX', qr{^(?:The codes assigned for transactions where no currency is involved|XXX)$}i ],
+  [ 'YER', qr{^(?:Yemeni Rial|YER)$}i ],
+  [ 'ZAR', qr{^(?:Rand|ZAR)$}i ],
+  [ 'ZMW', qr{^(?:Zambian Kwacha|ZMW)$}i ],
+  [ 'ZWL', qr{^(?:Zimbabwe Dollar|ZWL)$}i ],
+);
+
+sub map_currency_name_to_code {
+  my ($currency) = @_;
+
+  return undef if ($currency // '') eq '';
+
+  my $code = first { $currency =~ $_->[1] } @currency_name_to_code_mappings;
+  return $code->[0] if $code;
+
+  no warnings 'once';
+  $::lxdebug->message(LXDebug::WARN(), "ISO4217::map_currency_name_to_code: no mapping found for '$currency'");
+
+  return undef;
+}
+
+1;
diff --git a/SL/Helper/UNECERecommendation20.pm b/SL/Helper/UNECERecommendation20.pm
new file mode 100644 (file)
index 0000000..b2ab903
--- /dev/null
@@ -0,0 +1,58 @@
+package SL::Helper::UNECERecommendation20;
+
+use strict;
+use warnings;
+use utf8;
+
+use Exporter qw(import);
+our @EXPORT_OK = qw(map_name_to_alpha_2_code);
+
+use List::Util qw(first);
+
+my @mappings = (
+  # space and time
+  # areas
+  [ 'MTK', qr{^(?:m²|qm|quadrat *meter|quadrat *metre)$}i ],
+
+  # distances
+  [ 'CMT', qr{^(?:cm|centi *meter|centi *metre)$}i ],
+  [ 'MTR', qr{^(?:m|meter|metre)$}i ],
+  [ 'KMT', qr{^(?:km|kilo *meter|kilo *metre)$}i ],
+
+  # durations
+  [ 'SEC', qr{^(?:s|sec|second|sek|sekunde)$}i ],
+  [ 'MIN', qr{^min(?:ute)?$}i ],
+  [ 'HUR', qr{^(?:h(?:our)?|std(?:unde)?)$}i ],
+  [ 'DAY', qr{^(?:day|tag)$}i ],
+  [ 'MON', qr{^mon(?:th|at|atlich)?$}i ],
+  [ 'ANN', qr{^(?:yearly|annually|jährlich|Jahr)?$}i ],
+
+  # mass
+  [ 'MGM', qr{^(?:mg|milli *gramm?)$}i ],
+  [ 'GRM', qr{^(?:g|gramm?)$}i ],
+  [ 'KGM', qr{^(?:kg|kilo *gramm?)$}i ],
+  [ 'KTN', qr{^(?:t|tonne|kilo *tonne)$}i ],
+
+  # volumes
+  [ 'MLT', qr{^(?:ml|milli *liter|milli *litre)$}i ],
+  [ 'LTR', qr{^(?:l|liter|litre)$}i ],
+
+  # miscellaneous
+  [ 'C62', qr{^(?:stck|stück|pieces?|pc|psch|pauschal)$}i ],
+);
+
+sub map_name_to_code {
+  my ($unit) = @_;
+
+  return undef if ($unit // '') eq '';
+
+  my $code = first { $unit =~ $_->[1] } @mappings;
+  return $code->[0] if $code;
+
+  no warnings 'once';
+  $::lxdebug->message(LXDebug::WARN(), "UNECERecommendation20::map_name_code: no mapping found for '$unit'");
+
+  return undef;
+}
+
+1;
index a54a488..9f85ff2 100644 (file)
@@ -19,6 +19,7 @@ BEGIN {
   { name => "parent",                              url => "http://search.cpan.org/~corion/",    debian => 'libparent-perl' },
   { name => "Algorithm::CheckDigits",              url => "http://search.cpan.org/~mamawe/",    debian => 'libalgorithm-checkdigits-perl' },
   { name => "Archive::Zip",    version => '1.16',  url => "http://search.cpan.org/~phred/",     debian => 'libarchive-zip-perl' },
+  { name => "CAM::PDF",                            url => "https://metacpan.org/pod/CAM::PDF",  debian => 'libcam-pdf-perl' },
   { name => "CGI",             version => '3.43',  url => "http://search.cpan.org/~leejo/",     debian => 'libcgi-pm-perl' }, # 4.09 is not core anymore (perl 5.20)
   { name => "Clone",                               url => "http://search.cpan.org/~rdf/",       debian => 'libclone-perl' },
   { name => "Config::Std",                         url => "http://search.cpan.org/~dconway/",   debian => 'libconfig-std-perl' },
@@ -62,6 +63,7 @@ BEGIN {
   { name => "Text::Iconv",     version => '1.2',   url => "http://search.cpan.org/~mpiotr/",    debian => 'libtext-iconv-perl' },
   { name => "Text::Unidecode",                     url => "http://search.cpan.org/~sburke/",    debian => 'libtext-unidecode-perl' },
   { name => "URI",             version => '1.35',  url => "http://search.cpan.org/~gaas/",      debian => 'liburi-perl' },
+  { name => "XML::LibXML",                         url => "https://metacpan.org/pod/XML::LibXML", debian => 'libxml-libxml-perl' },
   { name => "XML::Writer",     version => '0.602', url => "http://search.cpan.org/~josephw/",   debian => 'libxml-writer-perl' },
   { name => "YAML",            version => '0.62',  url => "http://search.cpan.org/~ingy/",      debian => 'libyaml-perl' },
 );
index 59ca8a0..4c76796 100644 (file)
@@ -52,6 +52,16 @@ sub get_currencies {
   return @{ $self->currencies };
 }
 
+sub get_address {
+  # Compatibility function: back in the day there was only a single
+  # address field.
+  my ($self) = @_;
+
+  my $zipcode_city = join ' ', grep { $_ } ($self->get_address_zipcode, $self->get_address_city);
+
+  return join "\n", grep { $_ } ($self->get_address_street1, $self->get_address_street2, $zipcode_city, $self->get_address_country);
+}
+
 sub AUTOLOAD {
   our $AUTOLOAD;
 
index 39157d2..8682c7e 100644 (file)
@@ -19,7 +19,6 @@ use SL::Template::LaTeX;
 use SL::Template::OpenDocument;
 use SL::Template::PlainText;
 use SL::Template::ShellCommand;
-use SL::Template::XML;
 
 sub create {
   my %params  = @_;
@@ -47,7 +46,7 @@ sub available_templates {
   my @alldir  = sort grep {
        -d ($::lx_office_conf{paths}->{templates} . "/$_")
     && !/^\.\.?$/
-    && !m/\.(?:html|tex|sty|odt|xml|txb)$/
+    && !m/\.(?:html|tex|sty|odt)$/
     && !m/^(?:webpages$|print$|mail$|\.)/
   } keys %dir_h;
 
index 6a5d8e4..422a293 100644 (file)
@@ -11,9 +11,11 @@ use File::Basename;
 use File::Temp;
 use HTML::Entities ();
 use List::MoreUtils qw(any);
+use Scalar::Util qw(blessed);
 use Unicode::Normalize qw();
 
 use SL::DB::Default;
+use SL::System::Process;
 
 my %text_markup_replace = (
   b => 'textbf',
@@ -388,33 +390,71 @@ sub _parse_config_lines {
   }
 }
 
+sub _embed_file_directive {
+  my ($self, $file) = @_;
+
+  # { source      => $xmlfile,
+  #   name        => 'ZUGFeRD-invoice.xml',
+  #   description => $::locale->text('ZUGFeRD invoice'), }
+
+  my $file_name  =  blessed($file->{source}) && $file->{source}->can('filename') ? $file->{source}->filename : "" . $file->{source}->filename;
+  my $embed_name =  $file->{name} // $file_name;
+  $embed_name    =~ s{.*/}{};
+  my @options;
+
+  my $add_opt = sub {
+    my ($name, $value) = @_;
+    return if ($value // '') eq '';
+    push @options, sprintf('%s={%s}', $name, $value); # TODO: escaping
+  };
+
+ $add_opt->('ucfilespec',     $embed_name);
+ $add_opt->('desc',           $file->{description});
+ $add_opt->('afrelationship', $file->{relationship});
+ $add_opt->('mimetype',       $file->{mime_type});
+
+  return sprintf('\embedfile[%s]{%s}', join(',', @options), $file_name);
+}
+
 sub _force_mandatory_packages {
-  my $self  = shift;
-  my $lines = shift;
+  my ($self, @lines) = @_;
+  my @new_lines;
 
-  my (%used_packages, $document_start_line, $last_usepackage_line);
+  my %used_packages;
+  my @required_packages = qw(textcomp ulem);
+  push @required_packages, 'embedfile' if $self->{pdf_a};
 
-  foreach my $i (0 .. scalar @{ $lines } - 1) {
-    if ($lines->[$i] =~ m/\\usepackage[^\{]*{(.*?)}/) {
+  foreach my $line (@lines) {
+    if ($line =~ m/\\usepackage[^\{]*{(.*?)}/) {
       $used_packages{$1} = 1;
-      $last_usepackage_line = $i;
 
-    } elsif ($lines->[$i] =~ m/\\begin\{document\}/) {
-      $document_start_line = $i;
-      last;
+    } elsif ($line =~ m/\\begin\{document\}/) {
+      if ($self->{pdf_a} && $self->{pdf_a}->{xmp}) {
+        my $version       = $self->{pdf_a}->{version}   // '3a';
+        my $xmp_file_name = $self->{userspath} . "/pdfa.xmp";
+        my $out           = IO::File->new($xmp_file_name, ">:encoding(utf-8)") || croak "Error creating ${xmp_file_name}: $!";
+        $out->print(Encode::encode('utf-8', $self->{pdf_a}->{xmp}));
+        $out->close;
+
+        push @new_lines, (
+          "\\usepackage[a-${version},mathxmp]{pdfx}[2018/12/22]\n",
+          "\\usepackage[genericmode]{tagpdf}\n",
+          "\\tagpdfsetup{activate-all}\n",
+          "\\hypersetup{pdfstartview=}\n",
+        );
+      }
 
-    }
-  }
+      push @new_lines, map { "\\usepackage{$_}\n" } grep { !$used_packages{$_} } @required_packages;
+      push @new_lines, $line;
+      push @new_lines, map { $self->_embed_file_directive($_) } @{ $self->{pdf_attachments} // [] };
 
-  my $insertion_point = defined($document_start_line)  ? $document_start_line
-                      : defined($last_usepackage_line) ? $last_usepackage_line
-                      :                                  scalar @{ $lines } - 1;
+      next;
+    }
 
-  foreach my $package (qw(textcomp ulem)) {
-    next if $used_packages{$package};
-    splice @{ $lines }, $insertion_point, 0, "\\usepackage{${package}}\n";
-    $insertion_point++;
+    push @new_lines, $line;
   }
+
+  return @new_lines;
 }
 
 sub parse {
@@ -431,7 +471,7 @@ sub parse {
   close(IN);
 
   $self->_parse_config_lines(\@lines);
-  $self->_force_mandatory_packages(\@lines) if (ref $self eq 'SL::Template::LaTeX');
+  @lines = $self->_force_mandatory_packages(@lines) if (ref $self eq 'SL::Template::LaTeX');
 
   my $contents = join("", @lines);
 
@@ -476,12 +516,21 @@ sub parse {
   }
 }
 
+sub _texinputs_path {
+  my ($self, $templates_path) = @_;
+
+  my $exe_dir     = SL::System::Process::exe_dir();
+  $templates_path = $exe_dir . '/' . $templates_path unless $templates_path =~ m{^/};
+
+  return join(':', grep({ $_ } ('.', $exe_dir . '/texmf', $exe_dir . '/users', $templates_path, $ENV{TEXINPUTS})), '');
+}
+
 sub convert_to_postscript {
   my ($self) = @_;
   my ($form, $userspath) = ($self->{"form"}, $self->{"userspath"});
 
   # Convert the tex file to postscript
-  local $ENV{TEXINPUTS} = ".:" . $form->{cwd} . "/" . $form->{templates} . ":" . $ENV{TEXINPUTS};
+  local $ENV{TEXINPUTS} = $self->_texinputs_path($form->{templates});
 
   if (!chdir("$userspath")) {
     $self->{"error"} = "chdir : $!";
@@ -535,7 +584,7 @@ sub convert_to_pdf {
   my ($form, $userspath) = ($self->{"form"}, $self->{"userspath"});
 
   # Convert the tex file to PDF
-  local $ENV{TEXINPUTS} = ".:" . $form->{cwd} . "/" . $form->{templates} . ":" . $ENV{TEXINPUTS};
+  local $ENV{TEXINPUTS} = $self->_texinputs_path($form->{templates});
 
   if (!chdir("$userspath")) {
     $self->{"error"} = "chdir : $!";
@@ -595,11 +644,12 @@ sub uses_temp_file {
 sub parse_and_create_pdf {
   my ($class, $template_file_name, %params) = @_;
 
+  my $userspath                = delete($params{userspath}) || $::lx_office_conf{paths}->{userspath};
   my $keep_temp                = $::lx_office_conf{debug} && $::lx_office_conf{debug}->{keep_temp_files};
   my ($tex_fh, $tex_file_name) = File::Temp::tempfile(
     'kivitendo-printXXXXXX',
     SUFFIX => '.tex',
-    DIR    => $::lx_office_conf{paths}->{userspath},
+    DIR    => $userspath,
     UNLINK => $keep_temp ? 0 : 1,,
   );
 
@@ -608,7 +658,7 @@ sub parse_and_create_pdf {
   my $local_form           = Form->new('');
   $local_form->{cwd}       = $old_wd;
   $local_form->{IN}        = $template_file_name;
-  $local_form->{tmpdir}    = $::lx_office_conf{paths}->{userspath};
+  $local_form->{tmpdir}    = $userspath;
   $local_form->{tmpfile}   = $tex_file_name;
   $local_form->{templates} = SL::DB::Default->get->templates;
 
@@ -619,7 +669,7 @@ sub parse_and_create_pdf {
 
   my $error;
   eval {
-    my $template = SL::Template::LaTeX->new(file_name => $template_file_name, form => $local_form);
+    my $template = SL::Template::LaTeX->new(file_name => $template_file_name, form => $local_form, userspath => $userspath);
     my $result   = $template->parse($tex_fh) && $template->convert_to_pdf;
 
     die $template->{error} unless $result;
diff --git a/SL/Template/XML.pm b/SL/Template/XML.pm
deleted file mode 100644 (file)
index d9bd766..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-package SL::Template::XML;
-
-use parent qw(SL::Template::HTML);
-
-use strict;
-
-sub new {
-  #evtl auskommentieren
-  my $type = shift;
-
-  return $type->SUPER::new(@_);
-}
-
-sub format_string {
-  my ($self, $variable) = @_;
-  my $form = $self->{"form"};
-
-  $variable = $main::locale->quote_special_chars('Template/XML', $variable);
-
-  # Allow no markup to be converted into the output format
-  my @markup_replace = ('b', 'i', 's', 'u', 'sub', 'sup');
-
-  foreach my $key (@markup_replace) {
-    $variable =~ s/\&lt;(\/?)${key}\&gt;//g;
-  }
-
-  return $variable;
-}
-
-sub get_mime_type() {
-  my ($self) = @_;
-
-  return "text";
-
-}
-
-sub uses_temp_file {
-  # tempfile needet for XML Output
-  return 1;
-}
-
-1;
diff --git a/SL/VATIDNr.pm b/SL/VATIDNr.pm
new file mode 100644 (file)
index 0000000..dc3fbf5
--- /dev/null
@@ -0,0 +1,110 @@
+package SL::VATIDNr;
+
+use strict;
+use warnings;
+
+use Algorithm::CheckDigits;
+
+sub clean {
+  my ($class, $ustid) = @_;
+
+  $ustid //= '';
+  $ustid   =~ s{[[:space:].-]+}{}g;
+
+  return $ustid;
+}
+
+sub normalize {
+  my ($class, $ustid) = @_;
+
+  $ustid = $class->clean($ustid);
+
+  if ($ustid =~ m{^CHE(\d{3})(\d{3})(\d{3})$}) {
+    return sprintf('CHE-%s.%s.%s', $1, $2, $3);
+  }
+
+  return $ustid;
+}
+
+sub _validate_switzerland {
+  my ($ustid) = @_;
+
+  return $ustid =~ m{^CHE\d{9}$} ? 1 : 0;
+}
+
+sub _validate_european_union {
+  my ($ustid) = @_;
+
+  # 1. Two upper-case letters with the ISO 3166-1 Alpha-2 country code (exception: Greece uses EL instead of GR)
+  # 2. Up to twelve alphanumeric characters
+
+  return 0 unless $ustid =~ m{^(?:AT|BE|BG|CY|CZ|DE|DK|EE|EL|ES|FI|FR|GB|HR|HU|IE|IT|LT|LU|LV|MT|NL|PL|PT|RO|SE|SI|SK|SM)[[:alnum:]]{1,12}$};
+
+  my $algo_name = "ustid_" . lc(substr($ustid, 0, 2));
+  my $checker   = eval { CheckDigits($algo_name) };
+
+  return $checker->is_valid(substr($ustid, 2)) if $checker;
+  return 1;
+}
+
+sub validate {
+  my ($class, $ustid) = @_;
+
+  $ustid = $class->clean($ustid);
+
+  return _validate_switzerland($ustid) if $ustid =~ m{^CHE};
+  return _validate_european_union($ustid);
+}
+
+1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::VATIDNr - Helper routines for dealing with VAT ID numbers
+("Umsatzsteuer-Identifikationsnummern", "UStID-Nr" in German) and
+Switzerland's enterprise identification numbers (UIDs)
+
+=head1 SYNOPSIS
+
+    my $is_valid = SL::VATIDNr->validate($ustid);
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<clean> C<$ustid>
+
+Returns the number with all spaces, dashes & points removed.
+
+=item C<normalize> C<$ustid>
+
+Normalizes the given number to the format usually used in the country
+given by the country code at the start of the number
+(e.g. C<CHE-123.456.789> for a Swiss UID or DE123456789 for a German
+VATIDNr).
+
+=item C<validate> C<$ustid>
+
+Returns whether or not a number is valid. Depending on the country
+code at the start several tests are done including check digit
+validation.
+
+The number in question is first run through the L</clean> function and
+may therefore contain certain ignored characters.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
diff --git a/SL/X.pm b/SL/X.pm
index 756793b..b8ae60a 100644 (file)
--- a/SL/X.pm
+++ b/SL/X.pm
@@ -27,6 +27,9 @@ use Exception::Class (
   'SL::X::DBUtilsError' => {
     isa                 => 'SL::X::DBError',
   },
+  'SL::X::ZUGFeRDValidation' => {
+    isa                 => 'SL::X::Base',
+  },
 );
 
 1;
diff --git a/SL/ZUGFeRD.pm b/SL/ZUGFeRD.pm
new file mode 100644 (file)
index 0000000..780122c
--- /dev/null
@@ -0,0 +1,219 @@
+package SL::ZUGFeRD;
+
+use strict;
+use warnings;
+use utf8;
+
+use CAM::PDF;
+use Data::Dumper;
+use List::Util qw(first);
+use XML::LibXML;
+
+use constant RES_OK                              => 0;
+use constant RES_ERR_FILE_OPEN                   => 1;
+use constant RES_ERR_NO_XMP_METADATA             => 2;
+use constant RES_ERR_NO_XML_INVOICE              => 3;
+use constant RES_ERR_NOT_ZUGFERD                 => 4;
+use constant RES_ERR_UNSUPPORTED_ZUGFERD_VERSION => 5;
+
+sub _extract_zugferd_invoice_xml {
+  my $doc        = shift;
+  my $names_dict = $doc->getValue($doc->getRootDict->{Names}) or return {};
+  my $files_tree = $names_dict->{EmbeddedFiles}               or return {};
+  my @agenda     = $files_tree;
+  my $ret        = {};
+
+  # Hardly ever more than single leaf, but...
+
+  while (@agenda) {
+    my $item = $doc->getValue(shift @agenda);
+
+    if ($item->{Kids}) {
+      my $kids = $doc->getValue($item->{Kids});
+      push @agenda, @$kids
+
+    } else {
+      my $nodes = $doc->getValue($item->{Names});
+      my @names = map { $doc->getValue($_)} @$nodes;
+
+      while (@names) {
+        my ($k, $v)  = splice @names, 0, 2;
+        my $ef_node  = $v->{EF};
+        my $ef_dict  = $doc->getValue($ef_node);
+        my $fnode    = (values %$ef_dict)[0];
+        my $any_num  = $fnode->{value};
+        my $obj_node = $doc->dereference($any_num);
+        my $content  = $doc->decodeOne($obj_node->{value}, 0) // '';
+
+        #print "1\n";
+
+        next if $content !~ m{<rsm:CrossIndustryInvoice};
+        #print "2\n";
+
+        my $dom = eval { XML::LibXML->load_xml(string => $content) };
+        return $content if $dom && ($dom->documentElement->nodeName eq 'rsm:CrossIndustryInvoice');
+      }
+    }
+  }
+
+  return undef;
+}
+
+sub _get_xmp_metadata {
+  my ($doc) = @_;
+
+  my $node = $doc->getValue($doc->getRootDict->{Metadata});
+  if ($node && $node->{StreamData} && defined($node->{StreamData}->{value})) {
+    return $node->{StreamData}->{value};
+  }
+
+  return undef;
+}
+
+sub extract_from_pdf {
+  my ($self, $file_name) = @_;
+
+  my $pdf_doc = CAM::PDF->new($file_name);
+
+  if (!$pdf_doc) {
+    return {
+      result  => RES_ERR_FILE_OPEN(),
+      message => $::locale->text('The file \'#1\' could not be opened for reading.', $file_name),
+    };
+  }
+
+  my $xmp = _get_xmp_metadata($pdf_doc);
+  if (!defined $xmp) {
+    return {
+      result  => RES_ERR_NO_XMP_METADATA(),
+      message => $::locale->text('The file \'#1\' does not contain the required XMP meta data.', $file_name),
+    };
+  }
+
+  my $bad = {
+    result  => RES_ERR_NO_XMP_METADATA(),
+    message => $::locale->text('Parsing the XMP metadata failed.'),
+  };
+
+  my $dom = eval { XML::LibXML->load_xml(string => $xmp) };
+
+  return $bad if !$dom;
+
+  my $xpc = XML::LibXML::XPathContext->new($dom);
+  $xpc->registerNs('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
+
+  my $zugferd_version;
+
+  foreach my $node ($xpc->findnodes('/x:xmpmeta/rdf:RDF/rdf:Description')) {
+    my $ns = first { ref($_) eq 'XML::LibXML::Namespace' } $node->attributes;
+    next unless $ns;
+
+    if ($ns->getData =~ m{urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0}) {
+      $zugferd_version = '2p0';
+      last;
+    }
+
+    if ($ns->getData =~ m{zugferd}i) {
+      $zugferd_version = 'unsupported';
+      last;
+    }
+  }
+
+  if (!$zugferd_version) {
+    return {
+      result  => RES_ERR_NOT_ZUGFERD(),
+      message => $::locale->text('The XMP metadata does not declare the ZUGFeRD data.'),
+    };
+  }
+
+  if ($zugferd_version !~ m{^2p}) {
+    return {
+      result  => RES_ERR_UNSUPPORTED_ZUGFERD_VERSION(),
+      message => $::locale->text('The ZUGFeRD version used is not supported.'),
+    };
+  }
+
+  my $invoice_xml = _extract_zugferd_invoice_xml($pdf_doc);
+
+  if (!defined $invoice_xml) {
+    return {
+      result  => RES_ERR_NO_XML_INVOICE(),
+      message => $::locale->text('The ZUGFeRD XML invoice was not found.'),
+    };
+  }
+
+  return {
+    result       => RES_OK(),
+    metadata_xmp => $xmp,
+    invoice_xml  => $invoice_xml,
+  };
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::ZUGFeRD - Helper functions for dealing with PDFs containing ZUGFeRD invoice data
+
+=head1 SYNOPSIS
+
+    my $pdf  = '/path/to/my.pdf';
+    my $info = SL::ZUGFeRD->extract_from_pdf($pdf);
+
+    if ($info->{result} != SL::ZUGFeRD::RES_OK()) {
+      # An error occurred; log message from parser:
+      $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data from $pdf: " . $info->{message});
+      return;
+    }
+
+    # Parse & handle invoice XML:
+    my $dom = XML::LibXML->load_xml(string => $info->{invoice_xml});
+
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<extract_from_pdf> C<$file_name>
+
+Opens an existing PDF in the file system and tries to extract ZUGFeRD
+invoice data from it. First it'll parse the XMP metadata and look for
+the ZUGFeRD declaration inside. If the declaration isn't found or the
+declared version isn't 2p0, an error is returned.
+
+Otherwise it'll continue to look through all embedded files in the
+PDF. The first embedded XML file with a root node of
+C<rsm:CrossCountryInvoice> will be returnd.
+
+Always returns a hash ref containing the key C<result>, a number that
+can be one of the following constants:
+
+=over 4
+
+=item C<RES_OK> (0): parsing was OK; the returned hash will also
+contain the keys C<xmp_metadata> and C<invoice_xml> which will contain
+the XML text of the metadata & the ZUGFeRD invoice.
+
+=item C<RES_ERR_…> (all values E<gt> 0): parsing failed; the hash will
+also contain a key C<message> which contains a human-readable
+information about what exactly failed.
+
+=back
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
index 0261ef1..9fc60c5 100644 (file)
@@ -210,6 +210,48 @@ sub save_email_strings {
   $main::lxdebug->leave_sub();
 }
 
+sub edit_zugferd_notes {
+  $::auth->assert('config');
+
+  $::form->get_lists('languages' => 'LANGUAGES');
+
+  my $translation_list = GenericTranslations->list(translation_type => 'ZUGFeRD/notes');
+  my %translations     = map { ( ($_->{language_id} || 'default') => $_->{translation} ) } @{ $translation_list };
+
+  unshift @{ $::form->{LANGUAGES} }, { 'id' => 'default', };
+
+  foreach my $language (@{ $::form->{LANGUAGES} }) {
+    $language->{translation} = $translations{$language->{id}};
+  }
+
+  setup_generictranslations_edit_zugferd_notes_action_bar();
+
+  $::form->{title} = $::locale->text('Edit ZUGFeRD notes');
+  $::form->header;
+  print $::form->parse_html_template('generictranslations/edit_zugferd_notes');
+}
+
+sub save_zugferd_notes {
+  $::auth->assert('config');
+
+  $::form->get_lists('languages' => 'LANGUAGES');
+
+  unshift @{ $::form->{LANGUAGES} }, { };
+
+  foreach my $language (@{ $::form->{LANGUAGES} }) {
+    GenericTranslations->save(
+      translation_type => 'ZUGFeRD/notes',
+      translation_id   => undef,
+      language_id      => $language->{id},
+      translation      => $::form->{"translation__" . ($language->{id} || 'default')},
+    );
+  }
+
+  $::form->{message} = $::locale->text('The ZUGFeRD notes have been saved.');
+
+  edit_zugferd_notes();
+}
+
 sub setup_generictranslations_edit_greetings_action_bar {
   my %params = @_;
 
@@ -237,6 +279,7 @@ sub setup_generictranslations_edit_sepa_strings_action_bar {
     );
   }
 }
+
 sub setup_generictranslations_edit_email_strings_action_bar {
   my %params = @_;
 
@@ -251,4 +294,18 @@ sub setup_generictranslations_edit_email_strings_action_bar {
   }
 }
 
+sub setup_generictranslations_edit_zugferd_notes_action_bar {
+  my %params = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Save'),
+        submit    => [ '#form', { action => "save_zugferd_notes" } ],
+        accesskey => 'enter',
+      ],
+    );
+  }
+}
+
 1;
index eff922e..27aab18 100644 (file)
@@ -40,7 +40,7 @@
 use Carp;
 use CGI;
 use List::MoreUtils qw(any uniq apply);
-use List::Util qw(min max first);
+use List::Util qw(sum min max first);
 use List::UtilsBy qw(sort_by uniq_by);
 
 use SL::ClientJS;
@@ -1288,6 +1288,10 @@ sub print_form {
     $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
   }
 
+  if ($form->{format} =~ m{pdf}) {
+    _maybe_attach_zugferd_data($form);
+  }
+
   $form->isblank("email", $locale->text('E-mail address missing!'))
     if ($form->{media} eq 'email');
   $form->isblank("${inv}date",
@@ -1848,7 +1852,7 @@ sub _remove_billed_or_delivered_rows {
 # TODO: both of these are makeshift so that price sources can operate on rdbo objects. if
 # this ever gets rewritten in controller style, throw this out
 sub _make_record_item {
-  my ($row) = @_;
+  my ($row, %params) = @_;
 
   my $class = {
     sales_order             => 'OrderItem',
@@ -1896,12 +1900,19 @@ sub _make_record_item {
       if ($obj->meta->column($method)->isa('Rose::DB::Object::Metadata::Column::Date')) {
         $obj->${\"$method\_as_date"}($value);
       } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Numeric|Float|DoublePrecsion)$/) {
-        $obj->${\"$method\_as_number"}($value);
+        $obj->${\"$method\_as_number"}(($value // '') eq '' ? undef : $value);
       } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::Boolean$/) {
         $obj->$method(!!$value);
+      } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Big)?(?:Int(?:eger)?|Serial)$/) {
+        $obj->$method(($value // '') eq '' ? undef : $value * 1);
       } else {
         $obj->$method($value);
       }
+
+      if ($method eq 'discount') {
+        $obj->discount($obj->discount / 100.0);
+      }
+
     } else {
       $obj->{__additional_form_attributes}{$method} = $value;
     }
@@ -1911,6 +1922,11 @@ sub _make_record_item {
     $obj->part(SL::DB::Part->load_cached($::form->{"id_$row"}));
   }
 
+  if ($obj->can('qty')) {
+    $obj->qty(     $obj->qty      * $params{factor});
+    $obj->base_qty($obj->base_qty * $params{factor});
+  }
+
   return $obj;
 }
 
@@ -1930,6 +1946,8 @@ sub _make_record {
            : do { die 'unknown invoice type' };
   }
 
+  my $factor = $::form->{type} =~ m{credit_note} ? -1 : 1;
+
   return unless $class;
 
   $class = 'SL::DB::' . $class;
@@ -1947,9 +1965,11 @@ sub _make_record {
     if ($obj->meta->column($method)->isa('Rose::DB::Object::Metadata::Column::Date')) {
       $obj->${\"$method\_as_date"}($::form->{$method});
     } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Numeric|Float|DoublePrecsion)$/) {
-      $obj->${\"$method\_as_number"}($::form->{$method});
+      $obj->${\"$method\_as_number"}(($::form->{$method} // '') eq '' ? undef : $::form->{$method});
     } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::Boolean$/) {
       $obj->$method(!!$::form->{$method});
+    } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Big)?(?:Int(?:eger)?|Serial)$/) {
+      $obj->$method(($::form->{$method} // '') eq '' ? undef : $::form->{$method} * 1);
     } else {
       $obj->$method($::form->{$method});
     }
@@ -1958,12 +1978,21 @@ sub _make_record {
   my @items;
   for my $i (1 .. $::form->{rowcount}) {
     next unless $::form->{"id_$i"};
-    push @items, _make_record_item($i);
+    push @items, _make_record_item($i, factor => $factor);
   }
 
   $obj->items(@items) if @items;
   $obj->is_sales(!!$obj->customer_id) if $class eq 'SL::DB::DeliveryOrder';
 
+  if ($class eq 'SL::DB::Invoice') {
+    my $paid = $factor *
+      sum
+      map  { $::form->parse_amount(\%::myconfig, $::form->{$_}) }
+      grep { m{^paid_\d+$} }
+      keys %{ $::form };
+    $obj->paid($paid);
+  }
+
   return $obj;
 }
 
@@ -2101,3 +2130,36 @@ sub send_sales_purchase_email {
 
   print $::form->redirect_header($script . '?action=edit&id=' . $::form->escape($id) . '&type=' . $::form->escape($type));
 }
+
+sub _maybe_attach_zugferd_data {
+  my ($form) = @_;
+
+  my $record = _make_record();
+
+  return if !$record
+    || !$record->can('customer')
+    || !$record->customer
+    || !$record->can('create_pdf_a_print_options')
+    || !$record->can('create_zugferd_data')
+    || !$record->customer->create_zugferd_invoices_for_this_customer;
+
+  eval {
+    my $xmlfile = File::Temp->new;
+    $xmlfile->print($record->create_zugferd_data);
+    $xmlfile->close;
+
+    $form->{TEMPLATE_DRIVER_OPTIONS}->{pdf_a}           = $record->create_pdf_a_print_options(zugferd_xmp_data => $record->create_zugferd_xmp_data);
+    $form->{TEMPLATE_DRIVER_OPTIONS}->{pdf_attachments} = [
+      { source       => $xmlfile,
+        name         => 'ZUGFeRD-invoice.xml',
+        description  => $::locale->text('ZUGFeRD invoice'),
+        relationship => '/Alternative',
+        mime_type    => 'text/xml',
+      }
+    ];
+  };
+
+  if (my $e = SL::X::ZUGFeRDValidation->caught) {
+    $::form->error($e->message);
+  }
+}
index d1974d2..9d4481c 100644 (file)
@@ -10,6 +10,10 @@ Mittelgroße neue Features:
  - komplette Überarbeitung der Standard-LaTeX-Druckvorlagen von PeiTeX
    S.a.: templates/print/marei/Readme.md
 
+Administrative Änderungen
+
+  - Die zwei Perl-Module "CAM::PDF" und "XML::LibXML" werden nun benötigt.
+
 2019-12-11 - Release 3.5.5
 
 Mittelgroße neue Features:
index d68cfe6..c0da68b 100644 (file)
   libdatetime-set-perl libset-infinite-perl liblist-utilsby-perl\
   libdaemon-generic-perl libfile-flock-perl libfile-slurp-perl\
   libfile-mimeinfo-perl libpbkdf2-tiny-perl libregexp-ipv6-perl \
-  libdatetime-event-cron-perl libexception-class-perl
+  libdatetime-event-cron-perl libexception-class-perl libcam-pdf-perl \
+  libxml-libxml-perl
 </programlisting>
 <para>Sollten Pakete nicht zu Verfügung stehen, so können diese auch mittels CPAN installiert werden. Ferner muss für Ubuntu das Repository "Universe" aktiv sein (s.a. Anmerkungen).</para>
           <note id="ubuntu-universe">
index 5e06ff3..9628145 100755 (executable)
@@ -651,6 +651,7 @@ $self->{texts} = {
   'Company'                     => 'Firma',
   'Company Name'                => 'Firmenname',
   'Company name'                => 'Firmenname',
+  'Company name and address'    => 'Firmenname und -adresse',
   'Company settings'            => 'Firmeneinstellungen',
   'Compare to'                  => 'Gegenüberstellen zu',
   'Complexities'                => 'Komplexitätsgrade',
@@ -689,6 +690,7 @@ $self->{texts} = {
   'Correct taxkey'              => 'Richtiger Steuerschlüssel',
   'Cost Center'                 => 'Kostenstelle',
   'Costs'                       => 'Kosten',
+  'Could not extract ZUGFeRD data, data and error message:' => 'Konnte keine ZUGFeRD Daten extrahieren, folgende Fehlermeldung und das PDF:',
   'Could not find an entry for this part in the pricegroup.' => 'Konnte keine Eintrag für diesen Artikel in der Preisgruppe finden.',
   'Could not load class #1 (#2): "#3"' => 'Konnte Klasse #1 (#2) nicht laden: "#3"',
   'Could not load class #1, #2' => 'Konnte Klasse #1 nicht laden: "#2"',
@@ -709,6 +711,8 @@ $self->{texts} = {
   'Create Date'                 => 'Erstelldatum',
   'Create HTML'                 => 'HTML erzeugen',
   'Create PDF'                  => 'PDF erzeugen',
+  'Create ZUGFeRD invoices'     => 'ZUGFeRD-Rechnungen erzeugen',
+  'Create ZUGFeRD invoices in test mode' => 'ZUGFeRD-Rechnungen im Testmodus erzeugen',
   'Create a new background job' => 'Einen neuen Hintergrund-Job anlegen',
   'Create a new client'         => 'Einen neuen Mandanten anlegen',
   'Create a new delivery term'  => 'Neue Lieferbedingungen anlegen',
@@ -762,6 +766,7 @@ $self->{texts} = {
   'Create new version'          => 'Neue Version anlegen',
   'Create one from the context menu by right-clicking on this text.' => 'Erstellen Sie einen aus dem Kontextmenü, indem Sie auf diesen Text rechtsklicken.',
   'Create order'                => 'Auftrag erstellen',
+  'Create sales invoices with ZUGFeRD data' => 'Verkaufsrechnungen mit ZUGFeRD-Daten erzeugen',
   'Create tables'               => 'Tabellen anlegen',
   'Created by'                  => 'Erstellt von',
   'Created for'                 => 'Erstellt für',
@@ -1030,6 +1035,7 @@ $self->{texts} = {
   'Displayable Name Preferences' => 'Einstellungen für Anzeigenamen',
   'Do not change the tax rate of taxkey 0.' => 'Ändern Sie nicht den Steuersatz vom Steuerschlüssel 0.',
   'Do not check for duplicates' => 'Nicht nach Dubletten suchen',
+  'Do not create ZUGFeRD invoices' => 'Keine ZUGFeRD-Rechnungen erzeugen',
   'Do not link to a project.'   => 'Nicht mit einem Projekt verknüpfen.',
   'Do not modify this position' => 'Diese Position nicht verändern',
   'Do not run the task server for this client' => 'Task-Server nicht für diesen Mandanten ausführen',
@@ -1176,6 +1182,7 @@ $self->{texts} = {
   'Edit Vendor'                 => 'Lieferant editieren',
   'Edit Vendor Invoice'         => 'Einkaufsrechnung bearbeiten',
   'Edit Warehouse'              => 'Lager bearbeiten',
+  'Edit ZUGFeRD notes'          => 'ZUGFeRD-Notizen bearbeiten',
   'Edit acceptance status'      => 'Abnahmestatus bearbeiten',
   'Edit additional articles'    => 'Zusätzliche Artikel bearbeiten',
   'Edit all drafts'             => 'Entwürfe von allen Benutzern bearbeiten',
@@ -1583,6 +1590,7 @@ $self->{texts} = {
   'If disabled purchase invoices can only be created by conversion from existing requests for quotations, purchase orders and purchase delivery orders.' => 'Falls deaktiviert, so können Einkaufsrechnungen nur durch Umwandlung aus bestehenden Preisanfragen, Lieferantenaufträgen und Einkaufslieferscheinen angelegt werden.',
   'If disabled sales orders cannot be converted into sales invoices directly.' => 'Falls deaktiviert, so können Verkaufsaufträge nicht direkt in Verkaufsrechnungen umgewandelt werden.',
   'If disabled sales quotations cannot be converted into sales invoices directly.' => 'Falls deaktiviert, so können Verkaufsangebote nicht direkt in Verkaufsrechnungen umgewandelt werden.',
+  'If enabled ZUGFeRD-conformant sales invoice PDFs will be created.' => 'Falls aktiviert, werden ZUGFeRD-konforme PDFs für Verkaufsrechnungen erzeugt.',
   'If enabled a column will be shown in sales and purchase orders that lists both the amount and the value not shipped yet for each item.' => 'Falls eingeschaltet, wird für jede Position in Auftragsbestätigungen und Lieferantenaufträgen eine Spalte mit noch nicht gelieferter Menge und Wert angezeigt.',
   'If enabled a warning will be shown in sales and purchase orders if there are two or more positions of the same part (new controller only).' => 'Falls eingeschaltet, wird eine Warnung angezeigt, wenn der Auftrag mehrere gleiche Artikel enthält (nur neuer Controller).',
   'If enabled a warning will be shown in sales and purchase orders if there the delivery date is empty.' => 'Falls aktiviert, Warnungen ausgeben sobald Aufträge (Einkauf- und Verkauf) keinen Liefertermin haben.',
@@ -1597,6 +1605,7 @@ $self->{texts} = {
   'If the counted quantity differs more than this threshold from the quantity in the database, a warning will be shown. Set to 0 to switch of this feature.' => 'Wenn die gezählte Menge mehr als diesen Schwellenwert von der Menge in der Datenbank abweicht, wird eine Warnmeldung angezeigt. Setzen Sie den Schwellenwert auf 0, um dieses Feature abzuschalten.',
   'If the database user listed above does not have the right to create a database then enter the name and password of the superuser below:' => 'Falls der oben genannte Datenbankbenutzer nicht die Berechtigung zum Anlegen neuer Datenbanken hat, so können Sie hier den Namen und das Passwort des Datenbankadministratoraccounts angeben:',
   'If the default transfer out always succeed use this bin for negative stock quantity.' => 'Standardlagerplatz für Auslagern ohne Prüfung auf Bestand',
+  'If the test mode is enabled, the ZUGFeRD invoices will be flagged so that they\'re only fit to be used for testing purposes.' => 'Wenn der Testmodus aktiviert ist, werden ZUGFeRD-Rechnungen so markiert, dass sie nur für Testzwecke dienen dürfen.',
   'If yes, delivery order positions are considered "delivered" only if they have been stocked out of the inventory. Otherwise saving the delivery order is considered delivered.' => 'Wenn diese Option aktiviert ist, gelten Lieferscheinpositionen nur dann als geliefert wenn sie im Lieferschein ausgelagert wurden, und die Ware aus dem Lager ausgebucht wurde. Andernfalls gilt das Speichern des Lieferscheins als Lieferung.',
   'If you enter values for the part number and / or part description then only those bins containing parts whose part number or part description match your input will be shown.' => 'Wenn Sie für die Artikelnummer und / oder die Beschreibung etwas eingeben, so werden nur die Lagerplätze angezeigt, in denen Waren eingelagert sind, die Ihre Suchbegriffe enthalten.',
   'If you have not chosen for example the category revenue for a tax and you choose an revenue account to create a transfer in the general ledger, this tax will not be displayed in the tax dropdown.' => 'Wenn Sie z.B. die Kategory Erlös für eine Steuer nicht gewählt haben und ein Erlöskonto beim Erstellen einer Dialogbuchung wählen, wird diese Steuer auch nicht im Dropdown-Menü für die Steuern angezeigt.',
@@ -1618,6 +1627,7 @@ $self->{texts} = {
   'Import CSV'                  => 'CSV-Import',
   'Import Status'               => 'Import Status',
   'Import a MT940 file:'        => 'Laden Sie eine MT940 Datei hoch:',
+  'Import a ZUGFeRD file:'      => 'Eine ZUGFeRD-Datei importieren',
   'Import all'                  => 'Importiere Alle',
   'Import documents from #1'    => 'Importiere Dateien von Quelle \'#1\'',
   'Import file'                 => 'Import-Datei',
@@ -1982,6 +1992,7 @@ $self->{texts} = {
   'Next run at'                 => 'Nächste Ausführung um',
   'No'                          => 'Nein',
   'No 1:n or n:1 relation'      => 'Keine 1:n oder n:1 Beziehung',
+  'No AP Record Template for this vendor found, please add one' => 'Konnte keine Kreditorenbuchungsvorlage für diesen Lieferanten finden, bitte legen Sie eine an.',
   'No AP template was found.'   => 'Keine Kreditorenbuchungsvorlage gefunden.',
   'No Company Address given'    => 'Keine Firmenadresse hinterlegt!',
   'No Company Name given'       => 'Kein Firmenname hinterlegt!',
@@ -1990,6 +2001,7 @@ $self->{texts} = {
   'No Journal'                  => 'Kein Journal',
   'No Shopdescription'          => 'Keine Shop-Artikelbeschreibung',
   'No Shopimages'               => 'Keine Shop-Bilder',
+  'No VAT Info for this ZUGFeRD invoice, please ask your vendor to add this for his ZUGFeRD data.' => 'Konnte keine UST-ID für diese ZUGFeRD Rechnungen finden, bitte fragen Sie bei Ihren Lieferanten nach, ob dieses Feld im ZUGFeRD Datensatz gesetzt wird.',
   'No Vendor was found matching the search parameters.' => 'Zu dem Suchbegriff wurde kein Händler gefunden',
   'No action defined.'          => 'Keine Aktion definiert.',
   'No article has been selected yet.' => 'Es wurde noch kein Artikel ausgewählt.',
@@ -1997,6 +2009,7 @@ $self->{texts} = {
   'No assembly has been selected yet.' => 'Es wurde noch kein Erzeugnis ausgewahlt.',
   'No background job has been created yet.' => 'Es wurden noch keine Hintergrund-Jobs angelegt.',
   'No bank account chosen!'     => 'Kein Bankkonto ausgewählt!',
+  'No bank account flagged for ZUGFeRD usage was found.' => 'Es wurde kein Bankkonto gefunden, das für Nutzung mit ZUGFeRD markiert ist.',
   'No bank information has been entered in this customer\'s master data entry. You cannot create bank collections unless you enter bank information.' => 'Für diesen Kunden wurden in seinen Stammdaten keine Kontodaten hinterlegt. Solange dies nicht geschehen ist, können Sie keine Überweisungen für den Lieferanten anlegen.',
   'No bank information has been entered in this vendor\'s master data entry. You cannot create bank transfers unless you enter bank information.' => 'Für diesen Lieferanten wurden in seinen Stammdaten keine Kontodaten hinterlegt. Solange dies nicht geschehen ist, können Sie keine Überweisungen für den Lieferanten anlegen.',
   'No bins have been added to this warehouse yet.' => 'Es wurden zu diesem Lager noch keine Lagerplätze angelegt.',
@@ -2131,6 +2144,7 @@ $self->{texts} = {
   'On Order'                    => 'Ist bestellt',
   'On the next page the type of all variables can be set.' => 'Auf der folgenden Seite können die Typen aller Variablen gesetzt werden.',
   'One of the columns "qty" or "target_qty" must be given. If "target_qty" is given, the quantity to transfer for each transfer will be calculate, so that the quantity for this part, warehouse and bin will result in the given "target_qty" after each transfer.' => 'Eine der Spalten "qty" oder "target_qty" muss angegeben werden. Wird "target_qty" angegeben, so wird die zu bewegende Menge für jede Lagerbewegung so berechnet, dass die Lagermenge für diesen Artikel, Lager und Lagerplatz nach jeder Lagerbewegung der angegebenen Zielmenge entspricht.',
+  'One of the units used (#1) cannot be mapped to a known unit code from the UN/ECE Recommendation 20 list.' => 'Eine der verwendeten Einheiten (#1) kann keinem der bekannten Einheiten-Codes aus der Liste UN/ECE Recommendation 20 zugeordnet werden.',
   'One or more Perl modules missing' => 'Ein oder mehr Perl-Module fehlen',
   'Onhand only sets the quantity in master data, not in inventory. This is only a legacy info field and will be overwritten as soon as a inventory transfer happens.' => 'Das Import-Feld Auf Lager setzt nur die Menge in den Stammdaten, nicht im Lagerbereich. Dies ist historisch gewachsen nur ein Informationsfeld was mit dem tatsächlichen Wert überschrieben wird, sobald eine wirkliche Lagerbewegung stattfindet (DB-Trigger).',
   'Only Price'                  => 'Nur Preis',
@@ -2219,6 +2233,7 @@ $self->{texts} = {
   'Page #1/#2'                  => 'Seite #1/#2',
   'Paid'                        => 'bezahlt',
   'Paid amount'                 => 'Bezahlter Betrag',
+  'Parsing the XMP metadata failed.' => 'Parsen der XMP-Metadaten schlug fehl.',
   'Part'                        => 'Ware',
   'Part "#1" has chargenumber or best before date set. So it cannot be transfered automatically.' => 'Bei Artikel "#1" ist eine Chargenummer oder ein Mindesthaltbarkeitsdatum vergeben. Deshalb kann dieser Artikel nicht automatisch ausgelagert werden.',
   'Part (database ID)'          => 'Artikel (Datenbank-ID)',
@@ -2301,6 +2316,7 @@ $self->{texts} = {
   'Pictures for search parts'   => 'Bilder für Warensuche',
   'Please Check the bank information for each customer:' => 'Bitte überprüfen Sie die Bankinformationen der Kunden:',
   'Please Check the bank information for each vendor:' => 'Bitte überprüfen Sie die Kontoinformationen der Lieferanten:',
+  'Please add a valid VAT-ID for this vendor: ' => 'Bitte prüfen Sie ob dieser Lieferant eine valide UST-ID (Großschreibungen und Leerzeichen beachten) besitzt:',
   'Please ask your administrator to create warehouses and bins.' => 'Bitten Sie Ihren Administrator, dass er Lager und Lagerplätze anlegt.',
   'Please change the partnumber of the following parts and run the update again:' => 'Bitte ändern Sie daher die Artikelnummer folgender Artikel:',
   'Please choose a part.'       => 'Bitte wählen Sie einen Artikel aus.',
@@ -3050,6 +3066,8 @@ $self->{texts} = {
   'Storno (one letter abbreviation)' => 'S',
   'Storno Invoice'              => 'Stornorechnung',
   'Street'                      => 'Straße',
+  'Street 1'                    => 'Straße 1',
+  'Street 2'                    => 'Straße 2',
   'Strict and halt'             => 'Strikt und Abbruch',
   'Strict but replace'          => 'Strikt mit Ersetzungen',
   'Style the picture with the following CSS code' => 'Bildeigenschaft mit folgendem CSS-Style versehen',
@@ -3179,7 +3197,15 @@ $self->{texts} = {
   'The SQL query can be parameterized with variables named as follows: <%name%>.' => 'Die SQL-Abfrage kann mittels Variablen wie folgt parametrisiert werden: <%Variablenname%>.',
   'The SQL query does not contain any parameter that need to be configured.' => 'Die SQL-Abfrage enthält keine Parameter, die angegeben werden müssten.',
   'The URL is missing.'         => 'URL fehlt',
+  'The VAT ID number \'#1\' is invalid.' => 'Die UStID-Nummer »#1« ist ungültig.',
+  'The VAT ID number in the client configuration is invalid.' => 'Die UStID-Nummer in der Mandantenkonfiguraiton ist ungültig.',
+  'The VAT registration number is missing in the client configuration.' => 'Die Umsatzsteuer-ID-Nummer fehlt in der Mandantenkonfiguration.',
   'The WebDAV feature has been used.' => 'Das WebDAV-Feature wurde benutzt.',
+  'The XMP metadata does not declare the ZUGFeRD data.' => 'Die XMP-Metadaten enthalten keine ZUGFeRD-Deklaration.',
+  'The ZUGFeRD XML invoice was not found.' => 'Die ZUGFeRD-XML-Rechnungsdaten wurden nicht gefunden.',
+  'The ZUGFeRD invoice data cannot be generated because the data validation failed.' => 'Die ZUGFeRD-Rechnungsdaten können nicht erzeugt werden, da die Validierung fehlschlug.',
+  'The ZUGFeRD notes have been saved.' => 'Die ZUGFeRD-Notizen wurden gespeichert.',
+  'The ZUGFeRD version used is not supported.' => 'Die verwendete ZUGFeRD-Version wird nicht unterstützt.',
   'The abbreviation is missing.' => 'Abkürzung fehlt',
   'The access rights a user has within a client instance is still governed by his group membership.' => 'Welche Zugriffsrechte ein Benutzer innerhalb eines Mandanten hat, wird weiterhin über Gruppenmitgliedschaften geregelt.',
   'The access rights have been saved.' => 'Die Zugriffsrechte wurden gespeichert.',
@@ -3232,6 +3258,7 @@ $self->{texts} = {
   'The columns &quot;Dunning Duedate&quot;, &quot;Total Fees&quot; and &quot;Interest&quot; show data for the previous dunning created for this invoice.' => 'Die Spalten &quot;Zahlbar bis&quot;, &quot;Kumulierte Gebühren&quot; und &quot;Zinsen&quot; zeigen Daten der letzten für diese Rechnung erzeugten Mahnung.',
   'The combination of database host, port and name is not unique.' => 'Die Kombination aus Datenbankhost, -port und -name ist nicht eindeutig.',
   'The command is missing.'     => 'Der Befehl fehlt.',
+  'The company\'s address information is incomplete in the client configuration.' => 'Die Firmenadresse in der Mandantenkonfiguration ist unvollständig.',
   'The connection to the LDAP server cannot be encrypted (SSL/TLS startup failure). Please check config/kivitendo.conf.' => 'Die Verbindung zum LDAP-Server kann nicht verschlüsselt werden (Fehler bei SSL/TLS-Initialisierung). Bitte überprüfen Sie die Angaben in config/kivitendo.conf.',
   'The connection to the authentication database failed:' => 'Die Verbindung zur Authentifizierungsdatenbank schlug fehl:',
   'The connection to the configured client database "#1" on host "#2:#3" failed.' => 'Die Verbindung zur konfigurierten Datenbank "#1" auf Host "#2:#3" schlug fehl.',
@@ -3241,8 +3268,11 @@ $self->{texts} = {
   'The connection to the template database failed:' => 'Die Verbindung zur Vorlagendatenbank schlug fehl:',
   'The connection was established successfully.' => 'Die Verbindung zur Datenbank wurde erfolgreich hergestellt.',
   'The contact person attribute "birthday" is converted from a free-form text field into a date field.' => 'Das Kontaktpersonenfeld "Geburtstag" wird von einem freien Textfeld auf ein Datumsfeld umgestellt.',
+  'The country from the company\'s address in the client configuration cannot be mapped to an ISO 3166-1 alpha 2 code.' => 'Das Land der Firmenadresse in der Mandantenkonfiguration kann keinem der bekannten ISO 3166-1 Alpha 2-Codes zugeordnet werden.',
+  'The country from the customer\'s address cannot be mapped to an ISO 3166-1 alpha 2 code.' => 'Das Land aus der Kunden-Rechnungsadresse kann keinem der bekannten ISO 3166-1 Alpha 2-Codes zugeordnet werden.',
   'The creation of the authentication database failed:' => 'Das Anlegen der Authentifizierungsdatenbank schlug fehl:',
   'The credentials (username & password) for connecting database are wrong.' => 'Die Daten (Benutzername & Passwort) für das Login zur Datenbank sind falsch.',
+  'The currency "#1" cannot be mapped to an ISO 4217 currency code.' => 'Die Währung "#1" kann keinem der bekannten ISO 4217-Codes zugeordnet werden.',
   'The custom data export has been deleted.' => 'Der benutzerdefinierte Datenexport wurde gelöscht.',
   'The custom data export has been saved.' => 'Der benutzerdefinierte Datenexport wurde gespeichert.',
   'The custom variable has been created.' => 'Die benutzerdefinierte Variable wurde erfasst.',
@@ -3250,6 +3280,7 @@ $self->{texts} = {
   'The custom variable has been saved.' => 'Die benutzerdefinierte Variable wurde gespeichert.',
   'The custom variable is in use and cannot be deleted.' => 'Die benutzerdefinierte Variable ist in Benutzung und kann nicht gelöscht werden.',
   'The customer name is missing.' => 'Der Kundenname fehlt.',
+  'The customer\'s bank account number (IBAN) is missing.' => 'Die Kontonummer (IBAN) des Kunden fehlt.',
   'The database for user management and authentication does not exist. You can create let kivitendo create it with the following parameters:' => 'Die Datenbank für die Benutzeranmeldung existiert nicht. Sie können Sie von kivitendo automatisch mit den folgenden Parametern anlegen lassen:',
   'The database host is missing.' => 'Der Datenbankhost fehlt.',
   'The database name is missing.' => 'Der Datenbankname fehlt.',
@@ -3289,6 +3320,8 @@ $self->{texts} = {
   'The export failed because of malformed transactions. Please fix those before exporting.' => 'Es sind fehlerhafte Buchungen im Exportzeitraum vorhanden. Bitte korrigieren Sie diese vor dem Export.',
   'The factor is missing in row %d.' => 'Der Faktor fehlt in Zeile %d.',
   'The factor is missing.'      => 'Der Faktor fehlt.',
+  'The file \'#1\' could not be opened for reading.' => 'Die Datei \'#1\' konnte nicht zum Lesen geöffnet werden.',
+  'The file \'#1\' does not contain the required XMP meta data.' => 'Die Datei \'#1\' enthält die erforderlichen XMP-Metadaten nicht.',
   'The file has been sent to the printer.' => 'Die Datei wurde an den Drucker geschickt.',
   'The file is available for download.' => 'Die Datei ist zum Herunterladen verfügbar.',
   'The file name is missing'    => 'Der Dateiname fehlt',
@@ -3784,8 +3817,10 @@ $self->{texts} = {
   'Use default warehouse for assembly transfer' => 'Zum Fertigen Standardlager des Bestandteils verwenden',
   'Use existing templates'      => 'Vorhandene Druckvorlagen verwenden',
   'Use fill up when calculating shipped quantities?' => 'Sollen nicht verlinkte Positionen abgeglichen werden?',
+  'Use for ZUGFeRD'             => 'Nutzung mit ZUGFeRD',
   'Use linked items'            => 'Verknüpfte Positionen verwenden',
   'Use master default bin for Default Transfer, if no default bin for the part is configured' => 'Standardlagerplatz für Ein- / Auslagern über Standard-Lagerplatz, falls für die Ware kein expliziter Lagerplatz konfiguriert ist',
+  'Use settings from client configuration' => 'Einstellungen aus Mandantenkonfiguration folgen',
   'Use this storage backend for all generated PDF-Files' => 'Verwende dieses Backend für generierte PDF-Dateien',
   'Use this storage backend for all uploaded attachments' => 'Verwende dieses Backend für hochgeladene Dateien',
   'Use this storage backend for uploaded images' => 'Verwende dieses Backend für hochgeladene Bilder',
@@ -3975,11 +4010,16 @@ $self->{texts} = {
   'Your download does not exist anymore. Please re-run the DATEV export assistant.' => 'Ihr Download existiert nicht mehr. Bitte starten Sie den DATEV-Exportassistenten erneut.',
   'Your import is being processed.' => 'Ihr Import wird verarbeitet',
   'Your target quantity will be added to the stocked quantity.' => 'Ihre gezählte Zielmenge wird zum Lagerbestand hinzugezählt.',
+  'ZUGFeRD import'              => 'ZUGFeRD Import',
+  'ZUGFeRD invoice'             => 'ZUGFeRD-Rechnung',
+  'ZUGFeRD notes for each invoice' => 'ZUGFeRD-Notizen für jede Rechnung',
   'Zeitraum'                    => 'Zeitraum',
   'Zero amount posting!'        => 'Buchung ohne Wert',
   'Zip'                         => 'PLZ',
   'Zip, City'                   => 'PLZ, Ort',
   'Zipcode'                     => 'PLZ',
+  'Zipcode and city'            => 'PLZ und Stadt',
+  'ZugFeRD Import'              => 'ZUGFeRD Import',
   '[email]'                     => '[email]',
   'absolute'                    => 'absolut',
   'account_description'         => 'Beschreibung',
@@ -4014,6 +4054,7 @@ $self->{texts} = {
   'brutto'                      => 'brutto',
   'building data'               => 'Verarbeite Daten',
   'building report'             => 'Erstelle Bericht',
+  'can only parse a pdf file'   => 'Kann nur eine gültige PDF-Datei verwenden.',
   'cash'                        => 'Ist-Versteuerung',
   'chargenumber #1'             => 'Chargennummer #1',
   'chart_of_accounts'           => 'kontenuebersicht',
@@ -4118,6 +4159,7 @@ $self->{texts} = {
   'male'                        => 'männlich',
   'max filesize'                => 'maximale Dateigröße',
   'missing'                     => 'Fehlbestand',
+  'missing file for action import' => 'Es wurde keine Datei zum Hochladen ausgewählt',
   'missing_br'                  => 'Fehl.',
   'month'                       => 'Monatliche Abgabe',
   'monthly'                     => 'monatlich',
index 964fa49..e9e7e1e 100644 (file)
@@ -27,12 +27,6 @@ order=< > \n
 >=&gt;
 \n=<br>
 
-[Template/XML]
-order=< > \n
-<=&lt;
->=&gt;
-\n=<br>
-
 [Template/LaTeX]
 order=\\ <pagebreak> & \n \r " $ <bullet> % _ # ^ { } < > £ ± ² ³ ° § ® © ~ \xad \xa0 ➔ → ← ↔ ↕ | − ≤ ≥ ‐ ​ Ω μ Δ ‑
 \\=\\textbackslash\s
index ebcafbd..3486fa6 100644 (file)
   access: general_ledger
   params:
     action: YearEndTransactions/form
+- parent: general_ledger
+  id: zugferd_import
+  name: ZugFeRD Import
+  icon: cbob
+  order: 485
+  access: ap_transactions
+  params:
+    action: ZUGFeRD/upload_zugferd
 - parent: general_ledger
   id: general_ledger_reports
   name: Reports
   module: generictranslations.pl
   params:
     action: edit_sepa_strings
+- parent: system_languages_and_translations
+  id: system_languages_and_translations_zugferd_notes
+  name: ZUGFeRD notes for each invoice
+  order: 450
+  module: generictranslations.pl
+  params:
+    action: edit_zugferd_notes
 - parent: system_languages_and_translations
   id: system_languages_and_translations_email_strings
   name: Preset email strings
index fa60838..887013b 100755 (executable)
@@ -13,6 +13,7 @@ BEGIN {
 
 use strict;
 use Getopt::Long;
+use List::MoreUtils qw(uniq);
 use Pod::Usage;
 use Term::ANSIColor;
 use Text::Wrap;
@@ -156,7 +157,12 @@ sub check_template_dir {
 
   print_header("Checking LaTeX Dependencies for Master Templates '$dir'");
   kpsewhich($path, 'cls', $_) for SL::InstallationCheck::classes_from_latex($path, '\documentclass');
-  kpsewhich($path, 'sty', $_) for SL::InstallationCheck::classes_from_latex($path, '\usepackage');
+
+  my @sty = sort { $a cmp $b } uniq (
+    SL::InstallationCheck::classes_from_latex($path, '\usepackage'),
+    qw(textcomp ulem pdfx embedfile)
+  );
+  kpsewhich($path, 'sty', $_) for @sty;
 }
 
 our $mastertemplate_path = './templates/print/';
diff --git a/sql/Pg-upgrade2/bank_account_flag_for_zugferd_usage.sql b/sql/Pg-upgrade2/bank_account_flag_for_zugferd_usage.sql
new file mode 100644 (file)
index 0000000..7935754
--- /dev/null
@@ -0,0 +1,15 @@
+-- @tag: bank_account_flag_for_zugferd_usage
+-- @description: Bankkonto für die Nutzung mit ZUGFeRD markieren
+-- @depends: release_3_5_5
+ALTER TABLE bank_accounts
+ADD COLUMN use_for_zugferd BOOLEAN;
+
+UPDATE bank_accounts
+SET use_for_zugferd = (
+  SELECT COUNT(*)
+  FROM bank_accounts
+) = 1;
+
+ALTER TABLE bank_accounts
+ALTER COLUMN use_for_zugferd SET DEFAULT FALSE,
+ALTER COLUMN use_for_zugferd SET NOT NULL;
diff --git a/sql/Pg-upgrade2/customer_create_zugferd_invoices.sql b/sql/Pg-upgrade2/customer_create_zugferd_invoices.sql
new file mode 100644 (file)
index 0000000..3d85d94
--- /dev/null
@@ -0,0 +1,12 @@
+-- @tag: customer_create_zugferd_invoices
+-- @description: Kundenstammdaten: Einstellungen für ZUGFeRD-Rechnungen
+-- @depends: release_3_5_5
+ALTER TABLE customer
+ADD COLUMN create_zugferd_invoices INTEGER;
+
+UPDATE customer
+SET create_zugferd_invoices = -1;
+
+ALTER TABLE customer
+ALTER COLUMN create_zugferd_invoices SET DEFAULT -1,
+ALTER COLUMN create_zugferd_invoices SET NOT NULL;
diff --git a/sql/Pg-upgrade2/defaults_create_zugferd_data.sql b/sql/Pg-upgrade2/defaults_create_zugferd_data.sql
new file mode 100644 (file)
index 0000000..aa938fe
--- /dev/null
@@ -0,0 +1,5 @@
+-- @tag: defaults_create_zugferd_data
+-- @description: ZUGFeRD-Informationserzeugung option abstellen
+-- @depends: release_3_5_5
+ALTER TABLE defaults ADD COLUMN create_zugferd_invoices BOOLEAN;
+UPDATE defaults SET create_zugferd_invoices = TRUE;
diff --git a/sql/Pg-upgrade2/defaults_split_address.pl b/sql/Pg-upgrade2/defaults_split_address.pl
new file mode 100644 (file)
index 0000000..a1a4653
--- /dev/null
@@ -0,0 +1,53 @@
+# @tag: defaults_split_address
+# @description: Adress-Feld in Mandantenkonfiguration in einzelne Bestandteile aufteilen
+# @depends: release_3_5_4
+package SL::DBUpgrade2::defaults_split_address;
+
+use strict;
+use utf8;
+
+use parent qw(SL::DBUpgrade2::Base);
+
+sub run {
+  my ($self) = @_;
+
+  my ($address) = $self->dbh->selectrow_array("SELECT address FROM defaults");
+
+  my (@street, $zipcode, $city, $country);
+  my @lines = grep { $_ } split m{\r*\n+}, $address // '';
+
+  foreach my $line (@lines) {
+    if ($line =~ m{^(?:[a-z]+[ -])?(\d+) +(.+)}i) {
+      ($zipcode, $city) = ($1, $2);
+
+    } elsif ($zipcode) {
+      $country = $line;
+
+    } else {
+      push @street, $line;
+    }
+  }
+
+  $self->db_query(<<SQL);
+    ALTER TABLE defaults
+    ADD COLUMN  address_street1 TEXT,
+    ADD COLUMN  address_street2 TEXT,
+    ADD COLUMN  address_zipcode TEXT,
+    ADD COLUMN  address_city    TEXT,
+    ADD COLUMN  address_country TEXT,
+    DROP COLUMN address
+SQL
+
+  $self->db_query(<<SQL, bind => [ map { $_ // '' } ($street[0], $street[1], $zipcode, $city, $country) ]);
+    UPDATE defaults
+    SET address_street1 = ?,
+        address_street2 = ?,
+        address_zipcode = ?,
+        address_city    = ?,
+        address_country = ?
+SQL
+
+  return 1;
+}
+
+1;
diff --git a/sql/Pg-upgrade2/defaults_zugferd_test_mode.sql b/sql/Pg-upgrade2/defaults_zugferd_test_mode.sql
new file mode 100644 (file)
index 0000000..924b3df
--- /dev/null
@@ -0,0 +1,6 @@
+-- @tag: defaults_zugferd_test_mode
+-- @description: ZUGFeRD optional nur im Test-Modus
+-- @depends: defaults_create_zugferd_data
+ALTER TABLE defaults
+ALTER COLUMN create_zugferd_invoices TYPE INTEGER
+USING create_zugferd_invoices::INTEGER;
index e442fa6..b252321 100755 (executable)
@@ -7,6 +7,7 @@ use File::Slurp;
 use Test::More;
 
 my %default_columns;
+my %compatibility_functions = map { ($_ => 1) } qw(address);
 
 sub read_default_columns {
   my $content   =  read_file('SL/DB/MetaSetup/Default.pm');
@@ -23,7 +24,7 @@ sub test_file_content {
   my $content = read_file($file);
 
   while ($content =~ m{(?:INSTANCE_CONF\.|\$(?:main)?::instance_conf->)get_([a-z0-9_]+)}gi) {
-    ok($default_columns{$1}, "'get_${1}' is a valid method call on \$::instance_conf in $file");
+    ok($default_columns{$1} || $compatibility_functions{$1}, "'get_${1}' is a valid method call on \$::instance_conf in $file");
   }
 }
 
diff --git a/templates/pdf/pdf_a_metadata.xmp b/templates/pdf/pdf_a_metadata.xmp
new file mode 100644 (file)
index 0000000..a31dfdb
--- /dev/null
@@ -0,0 +1,138 @@
+<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d' ?>
+
+<x:xmpmeta xmlns:x="adobe:ns:meta/"
+           x:xmptk="Adobe XMP Core 4.0-c316 44.253921, Sun Oct 01 2006 17:14:39">
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+  <rdf:Description rdf:about=""
+                   xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
+                   xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
+                   xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#"
+                   >
+   <pdfaExtension:schemas>
+    <rdf:Bag>
+     <rdf:li rdf:parseType="Resource">
+      <pdfaSchema:namespaceURI>http://ns.adobe.com/pdfx/1.3/</pdfaSchema:namespaceURI>
+      <pdfaSchema:prefix>pdfx</pdfaSchema:prefix>
+      <pdfaSchema:schema>PDF/X Schema</pdfaSchema:schema>
+      <pdfaSchema:property><rdf:Seq>
+       <rdf:li rdf:parseType="Resource">
+        <pdfaProperty:category>external</pdfaProperty:category>
+        <pdfaProperty:description>URL to an online version or preprint</pdfaProperty:description>
+        <pdfaProperty:name>AuthoritativeDomain</pdfaProperty:name>
+        <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+       </rdf:li></rdf:Seq>
+      </pdfaSchema:property>
+     </rdf:li>
+     <rdf:li rdf:parseType="Resource">
+      <pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>
+      <pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>
+      <pdfaSchema:schema>PDF/UA ID Schema</pdfaSchema:schema>
+      <pdfaSchema:property><rdf:Seq>
+       <rdf:li rdf:parseType="Resource">
+        <pdfaProperty:category>internal</pdfaProperty:category>
+        <pdfaProperty:description>Part of PDF/UA standard</pdfaProperty:description>
+        <pdfaProperty:name>part</pdfaProperty:name>
+        <pdfaProperty:valueType>Integer</pdfaProperty:valueType>
+       </rdf:li></rdf:Seq>
+      </pdfaSchema:property>
+     </rdf:li>
+     <rdf:li rdf:parseType="Resource">
+      <pdfaSchema:schema>PRISM metadata</pdfaSchema:schema>
+      <pdfaSchema:namespaceURI>http://prismstandard.org/namespaces/basic/2.2/</pdfaSchema:namespaceURI>
+      <pdfaSchema:prefix>prism</pdfaSchema:prefix>
+      <pdfaSchema:property><rdf:Seq>
+       <rdf:li rdf:parseType="Resource">
+        <pdfaProperty:name>aggregationType</pdfaProperty:name>
+        <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+        <pdfaProperty:category>external</pdfaProperty:category>
+        <pdfaProperty:description>The type of publication. If defined, must be one of book, catalog, feed, journal, magazine, manual, newsletter, pamphlet.</pdfaProperty:description>
+       </rdf:li>
+       <rdf:li rdf:parseType="Resource">
+        <pdfaProperty:name>url</pdfaProperty:name>
+        <pdfaProperty:valueType>URL</pdfaProperty:valueType>
+        <pdfaProperty:category>external</pdfaProperty:category>
+        <pdfaProperty:description>URL for the article or unit of content</pdfaProperty:description>
+       </rdf:li>
+      </rdf:Seq></pdfaSchema:property>
+     </rdf:li>
+[% IF zugferd %]
+     <rdf:li rdf:parseType="Resource">
+      <pdfaSchema:schema>ZUGFeRD PDFA Extension Schema</pdfaSchema:schema>
+      <pdfaSchema:namespaceURI>urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0#</pdfaSchema:namespaceURI>
+      <pdfaSchema:prefix>fx</pdfaSchema:prefix>
+      <pdfaSchema:property>
+       <rdf:Seq>
+        <rdf:li rdf:parseType="Resource">
+         <pdfaProperty:name>DocumentFileName</pdfaProperty:name>
+         <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+         <pdfaProperty:category>external</pdfaProperty:category>
+         <pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
+        </rdf:li>
+        <rdf:li rdf:parseType="Resource">
+         <pdfaProperty:name>DocumentType</pdfaProperty:name>
+         <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+         <pdfaProperty:category>external</pdfaProperty:category>
+         <pdfaProperty:description>INVOICE</pdfaProperty:description>
+        </rdf:li>
+        <rdf:li rdf:parseType="Resource">
+         <pdfaProperty:name>Version</pdfaProperty:name>
+         <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+         <pdfaProperty:category>external</pdfaProperty:category>
+         <pdfaProperty:description>The actual version of the ZUGFeRD data</pdfaProperty:description>
+        </rdf:li>
+        <rdf:li rdf:parseType="Resource">
+         <pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
+         <pdfaProperty:valueType>Text</pdfaProperty:valueType>
+         <pdfaProperty:category>external</pdfaProperty:category>
+         <pdfaProperty:description>The conformance level of the ZUGFeRD data</pdfaProperty:description>
+        </rdf:li>
+       </rdf:Seq>
+      </pdfaSchema:property>
+     </rdf:li>
+[% END %]
+    </rdf:Bag>
+   </pdfaExtension:schemas>
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
+   <pdf:Producer>[% producer | xml %]</pdf:Producer>
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
+   <dc:format>application/pdf</dc:format>
+[% IF meta_data.title %]
+   <dc:title><rdf:Alt><rdf:li xml:lang="x-default">[% meta_data.title | xml %]</rdf:li></rdf:Alt></dc:title>
+[% END %]
+   <dc:creator><rdf:Seq><rdf:li>v3</rdf:li></rdf:Seq></dc:creator>
+[% IF meta_data.language %]
+   <dc:language><rdf:Bag><rdf:li>[% meta_data.language | xml %]</rdf:li></rdf:Bag></dc:language>
+[% END %]
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:prism="http://prismstandard.org/namespaces/basic/2.2/">
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:pdfx="http://ns.adobe.com/pdfx/1.3/">
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
+   <pdfaid:part>[% pdf_a_version | xml %]</pdfaid:part>
+   <pdfaid:conformance>[% pdf_a_conformance | xml %]</pdfaid:conformance>
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
+   <xmp:CreatorTool>[% producer | xml %]</xmp:CreatorTool>
+   <xmp:ModifyDate>[% timestamp | xml %]</xmp:ModifyDate>
+   <xmp:CreateDate>[% timestamp | xml %]</xmp:CreateDate>
+   <xmp:MetadataDate>[% timestamp | xml %]</xmp:MetadataDate>
+  </rdf:Description>
+  <rdf:Description rdf:about="" xmlns:xmpRights = "http://ns.adobe.com/xap/1.0/rights/">
+  </rdf:Description>
+
+[% IF zugferd %]
+  <rdf:Description xmlns:fx="urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0#"
+                   fx:ConformanceLevel="[% zugferd.conformance_level | xml %]"
+                   fx:DocumentFileName="[% zugferd.document_file_name | xml %]"
+                   fx:DocumentType="[% zugferd.document_type | xml %]"
+                   fx:Version="[% zugferd.version %]"
+                   rdf:about=""/>
+[% END %]
+
+ </rdf:RDF>
+</x:xmpmeta>
+
+<?xpacket end='w'?>
index 09c8075..c868571 100644 (file)
    <td>[% L.yes_no_tag("defaults.order_warn_no_deliverydate", SELF.defaults.order_warn_no_deliverydate) %]</td>
    <td>[% LxERP.t8("If enabled a warning will be shown in sales and purchase orders if there the delivery date is empty.") %]</td>
   </tr>
+  <tr>
+   <td align="right">[% LxERP.t8("Create sales invoices with ZUGFeRD data") %]</td>
+   <td>[% L.select_tag("defaults.create_zugferd_invoices", [ [ 0, LxERP.t8('Do not create ZUGFeRD invoices') ], [ 1, LxERP.t8('Create ZUGFeRD invoices') ], [ 2, LxERP.t8('Create ZUGFeRD invoices in test mode') ] ],
+                       default=SELF.defaults.create_zugferd_invoices) %]</td>
+   <td>
+     [% LxERP.t8("If enabled ZUGFeRD-conformant sales invoice PDFs will be created.") %]
+     [% LxERP.t8("If the test mode is enabled, the ZUGFeRD invoices will be flagged so that they're only fit to be used for testing purposes.") %]
+   </td>
+  </tr>
 
   <tr><td class="listheading" colspan="4">[% LxERP.t8("E-mail") %]</td></tr>
 
index 35a9728..f938f72 100644 (file)
@@ -2,7 +2,7 @@
 [% SET style="width: 400px" %]
 <div id="miscellaneous">
  <table>
-  <tr><td class="listheading" colspan="4">[% LxERP.t8("Company settings") %]</td></tr>
+  <tr><td class="listheading" colspan="4">[% LxERP.t8("Company name and address") %]</td></tr>
 
   <tr>
    <td align="right">[% LxERP.t8("Company name") %]</td>
   </tr>
 
   <tr>
-   <td align="right" valign="top">[% LxERP.t8("Address") %]</td>
-   <td valign="top">[% L.textarea_tag('defaults.address', SELF.defaults.address, style=style, rows=4) %]</td>
+   <td align="right" valign="top">[% LxERP.t8("Street 1") %]</td>
+   <td>[% L.input_tag('defaults.address_street1', SELF.defaults.address_street1, style=style) %]</td>
+  </tr>
+
+  <tr>
+   <td align="right" valign="top">[% LxERP.t8("Street 2") %]</td>
+   <td>[% L.input_tag('defaults.address_street2', SELF.defaults.address_street2, style=style) %]</td>
   </tr>
 
+  <tr>
+   <td align="right" valign="top">[% LxERP.t8("Zipcode and city") %]</td>
+   <td>
+     [% L.input_tag('defaults.address_zipcode', SELF.defaults.address_zipcode, size=8) %]
+     [% L.input_tag('defaults.address_city', SELF.defaults.address_city, size=30) %]
+   </td>
+  </tr>
+
+  <tr>
+   <td align="right" valign="top">[% LxERP.t8("Country") %]</td>
+   <td>[% L.input_tag('defaults.address_country', SELF.defaults.address_country, style=style) %]</td>
+  </tr>
+
+  <tr><td class="listheading" colspan="4">[% LxERP.t8("Company settings") %]</td></tr>
+
   <tr>
    <td align="right" valign="top">[% LxERP.t8("Signature") %]</td>
    <td valign="top">[% L.textarea_tag('defaults.signature', SELF.defaults.signature, style=style, rows=4) %]</td>
index b06b517..7bcca62 100644 (file)
       <td>
         [% L.checkbox_tag('cv.order_lock', checked = SELF.cv.order_lock, for_submit=1) %]
       </td>
+      <th align="right">[% LxERP.t8("Create sales invoices with ZUGFeRD data") %]</th>
+      <td>[% L.select_tag("cv.create_zugferd_invoices",
+                          [ [ -1, LxERP.t8('Use settings from client configuration') ],
+                            [ 0, LxERP.t8('Do not create ZUGFeRD invoices') ],
+                            [ 1, LxERP.t8('Create ZUGFeRD invoices') ],
+                            [ 2, LxERP.t8('Create ZUGFeRD invoices in test mode') ] ],
+                          default=SELF.cv.create_zugferd_invoices) %]</td>
      </tr>
     [% END %]
   </table>
diff --git a/templates/webpages/generictranslations/edit_zugferd_notes.html b/templates/webpages/generictranslations/edit_zugferd_notes.html
new file mode 100644 (file)
index 0000000..04f1051
--- /dev/null
@@ -0,0 +1,33 @@
+[%- USE T8 %]
+[%- USE HTML %]
+<h1>[% HTML.escape(title) %]</h1>
+
+ [%- IF message %]
+ <p>
+  [% HTML.escape(message) %]
+ </p>
+ [%- END %]
+
+ <form method="post" action="generictranslations.pl" id="form">
+
+  <table>
+
+   <tr>
+    <th class="listheading">&nbsp;</th>
+    <th class="listheading">[% 'ZUGFeRD notes for each invoice' | $T8 %]</th>
+   </tr>
+
+   [%- FOREACH language = LANGUAGES %]
+   <tr>
+    <td>
+     [%- IF language.id == 'default' %]
+     [% 'Default (no language selected)' | $T8 %]
+     [%- ELSE %]
+     [%- HTML.escape(language.description) %]
+     [%- END %]
+    </td>
+    <td><input name="translation__[% language.id %]" size="40" value="[% HTML.escape(language.translation) %]"></td>
+   </tr>
+   [%- END %]
+  </table>
+ </form>
index f3da6cc..21e58fc 100644 (file)
   <th align="right">[% LxERP.t8('Chart') %]</th>
   <td>[% P.chart.picker('object.chart_id', SELF.object.chart_id, type='AR_paid,AP_paid', category='A,L,Q', choose=1, style=style, "data-validate"="required", "data-title"=LxERP.t8("Chart")) %]</td>
  </tr>
+ <tr>
+  <th align="right">[% LxERP.t8('Use for ZUGFeRD') %]</th>
+  <td>[% L.checkbox_tag('object.use_for_zugferd', checked = SELF.object.use_for_zugferd, for_submit=1) %]</td>
+ </tr>
  <tr>
   <th align="right">[% LxERP.t8('Obsolete') %]</th>
   <td>[% L.checkbox_tag('object.obsolete', checked = SELF.object.obsolete, for_submit=1) %]</td>
diff --git a/templates/webpages/zugferd/form.html b/templates/webpages/zugferd/form.html
new file mode 100644 (file)
index 0000000..dd8662f
--- /dev/null
@@ -0,0 +1,15 @@
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE T8 %]
+[%- INCLUDE 'common/flash.html' %]
+ <div class="listtop">[% FORM.title %]</div>
+
+ <p>
+ [% "Import a ZUGFeRD file:" | $T8 %]
+ </p>
+
+ <form method="post" action="controller.pl" enctype="multipart/form-data" id="form">
+    [% L.input_tag('file', '', type => 'file', accept => '.pdf') %]
+ </form>
+
diff --git a/texmf/embedfile.sty b/texmf/embedfile.sty
new file mode 100644 (file)
index 0000000..167dea1
--- /dev/null
@@ -0,0 +1,799 @@
+%% !!NOTE NOTE NOTE!!
+%%
+%% This is a modified version of `embedfile.sty' generated from a
+%% modified `embedfile.dtx' incorporating the following pull request:
+%% https://github.com/ho-tex/oberdiek/pull/72
+%%
+%% This PR adds support for creating PDF/A-compliant attachments. See
+%% also the following issue:
+%% https://github.com/ho-tex/oberdiek/issues/37
+%%
+%% !!END OF NOTE NOTE NOTE!!
+%%
+%%
+%% This is file `embedfile.sty',
+%% generated with the docstrip utility.
+%%
+%% The original source files were:
+%%
+%% embedfile.dtx  (with options: `package')
+%%
+%% This is a generated file.
+%%
+%% Project: embedfile
+%% Version: 2018/11/01 v2.8
+%%
+%% Copyright (C) 2006-2011 by
+%%    Heiko Oberdiek <heiko.oberdiek at googlemail.com>
+%%
+%% This work may be distributed and/or modified under the
+%% conditions of the LaTeX Project Public License, either
+%% version 1.3c of this license or (at your option) any later
+%% version. This version of this license is in
+%%    http://www.latex-project.org/lppl/lppl-1-3c.txt
+%% and the latest version of this license is in
+%%    http://www.latex-project.org/lppl.txt
+%% and version 1.3 or later is part of all distributions of
+%% LaTeX version 2005/12/01 or later.
+%%
+%% This work has the LPPL maintenance status "maintained".
+%%
+%% This Current Maintainer of this work is Heiko Oberdiek.
+%%
+%% The Base Interpreter refers to any `TeX-Format',
+%% because some files are installed in TDS:tex/generic//.
+%%
+%% This work consists of the main source file embedfile.dtx
+%% and the derived files
+%%    embedfile.sty, embedfile.pdf, embedfile.ins, embedfile.drv,
+%%    dtx-attach.sty, embedfile-example-plain.tex,
+%%    embedfile-example-collection.tex, embedfile-test1.tex,
+%%    embedfile-test2.tex, embedfile-test3.tex,
+%%    embedfile-test4.tex.
+%%
+\begingroup\catcode61\catcode48\catcode32=10\relax%
+  \catcode13=5 % ^^M
+  \endlinechar=13 %
+  \catcode35=6 % #
+  \catcode39=12 % '
+  \catcode44=12 % ,
+  \catcode45=12 % -
+  \catcode46=12 % .
+  \catcode58=12 % :
+  \catcode64=11 % @
+  \catcode123=1 % {
+  \catcode125=2 % }
+  \expandafter\let\expandafter\x\csname ver@embedfile.sty\endcsname
+  \ifx\x\relax % plain-TeX, first loading
+  \else
+    \def\empty{}%
+    \ifx\x\empty % LaTeX, first loading,
+      % variable is initialized, but \ProvidesPackage not yet seen
+    \else
+      \expandafter\ifx\csname PackageInfo\endcsname\relax
+        \def\x#1#2{%
+          \immediate\write-1{Package #1 Info: #2.}%
+        }%
+      \else
+        \def\x#1#2{\PackageInfo{#1}{#2, stopped}}%
+      \fi
+      \x{embedfile}{The package is already loaded}%
+      \aftergroup\endinput
+    \fi
+  \fi
+\endgroup%
+\begingroup\catcode61\catcode48\catcode32=10\relax%
+  \catcode13=5 % ^^M
+  \endlinechar=13 %
+  \catcode35=6 % #
+  \catcode39=12 % '
+  \catcode40=12 % (
+  \catcode41=12 % )
+  \catcode44=12 % ,
+  \catcode45=12 % -
+  \catcode46=12 % .
+  \catcode47=12 % /
+  \catcode58=12 % :
+  \catcode64=11 % @
+  \catcode91=12 % [
+  \catcode93=12 % ]
+  \catcode123=1 % {
+  \catcode125=2 % }
+  \expandafter\ifx\csname ProvidesPackage\endcsname\relax
+    \def\x#1#2#3[#4]{\endgroup
+      \immediate\write-1{Package: #3 #4}%
+      \xdef#1{#4}%
+    }%
+  \else
+    \def\x#1#2[#3]{\endgroup
+      #2[{#3}]%
+      \ifx#1\@undefined
+        \xdef#1{#3}%
+      \fi
+      \ifx#1\relax
+        \xdef#1{#3}%
+      \fi
+    }%
+  \fi
+\expandafter\x\csname ver@embedfile.sty\endcsname
+\ProvidesPackage{embedfile}%
+  [2018/11/01 v2.8 Embed files into PDF (HO)]%
+\begingroup\catcode61\catcode48\catcode32=10\relax%
+  \catcode13=5 % ^^M
+  \endlinechar=13 %
+  \catcode123=1 % {
+  \catcode125=2 % }
+  \catcode64=11 % @
+  \def\x{\endgroup
+    \expandafter\edef\csname EmFi@AtEnd\endcsname{%
+      \endlinechar=\the\endlinechar\relax
+      \catcode13=\the\catcode13\relax
+      \catcode32=\the\catcode32\relax
+      \catcode35=\the\catcode35\relax
+      \catcode61=\the\catcode61\relax
+      \catcode64=\the\catcode64\relax
+      \catcode123=\the\catcode123\relax
+      \catcode125=\the\catcode125\relax
+    }%
+  }%
+\x\catcode61\catcode48\catcode32=10\relax%
+\catcode13=5 % ^^M
+\endlinechar=13 %
+\catcode35=6 % #
+\catcode64=11 % @
+\catcode123=1 % {
+\catcode125=2 % }
+\def\TMP@EnsureCode#1#2{%
+  \edef\EmFi@AtEnd{%
+    \EmFi@AtEnd
+    \catcode#1=\the\catcode#1\relax
+  }%
+  \catcode#1=#2\relax
+}
+\TMP@EnsureCode{39}{12}% '
+\TMP@EnsureCode{40}{12}% (
+\TMP@EnsureCode{41}{12}% )
+\TMP@EnsureCode{44}{12}% ,
+\TMP@EnsureCode{46}{12}% .
+\TMP@EnsureCode{47}{12}% /
+\TMP@EnsureCode{58}{12}% :
+\TMP@EnsureCode{60}{12}% <
+\TMP@EnsureCode{62}{12}% >
+\TMP@EnsureCode{91}{12}% [
+\TMP@EnsureCode{93}{12}% ]
+\TMP@EnsureCode{96}{12}% `
+\edef\EmFi@AtEnd{\EmFi@AtEnd\noexpand\endinput}
+\begingroup\expandafter\expandafter\expandafter\endgroup
+\expandafter\ifx\csname RequirePackage\endcsname\relax
+  \def\EmFi@RequirePackage#1[#2]{%
+    \input #1.sty\relax
+  }%
+\else
+  \let\EmFi@RequirePackage\RequirePackage
+\fi
+\EmFi@RequirePackage{infwarerr}[2007/09/09]%
+\def\EmFi@Error{%
+  \@PackageError{embedfile}%
+}
+\ifx\pdfextension\@undefined\else
+    \protected\def\pdflastobj {\numexpr\pdffeedback lastobj\relax}
+    \protected\def\pdfnames   {\pdfextension names }
+    \protected\def\pdfobj     {\pdfextension obj }
+    \let\pdfoutput            \outputmode
+\fi
+\EmFi@RequirePackage{ifpdf}[2007/09/09]
+\ifpdf
+\else
+  \EmFi@Error{%
+    Missing pdfTeX in PDF mode%
+  }{%
+    Currently other drivers are not supported. %
+    Package loading is aborted.%
+  }%
+  \expandafter\EmFi@AtEnd
+\fi%
+\EmFi@RequirePackage{pdftexcmds}[2007/11/11]
+\EmFi@RequirePackage{ltxcmds}[2010/03/01]
+\EmFi@RequirePackage{kvsetkeys}[2010/03/01]
+\EmFi@RequirePackage{kvdefinekeys}[2010/03/01]
+\begingroup\expandafter\expandafter\expandafter\endgroup
+\expandafter\ifx\csname pdf@filesize\endcsname\relax
+  \EmFi@Error{%
+    Unsupported pdfTeX version%
+  }{%
+    At least version 1.30 is necessary. Package loading is aborted.%
+  }%
+  \expandafter\EmFi@AtEnd
+\fi%
+\EmFi@RequirePackage{pdfescape}[2007/11/11]
+\def\EmFi@temp#1{%
+  \expandafter\EdefSanitize\csname EmFi@S@#1\endcsname{#1}%
+}
+\EmFi@temp{details}%
+\EmFi@temp{tile}%
+\EmFi@temp{hidden}%
+\EmFi@temp{text}
+\EmFi@temp{date}
+\EmFi@temp{number}
+\EmFi@temp{file}
+\EmFi@temp{desc}
+\EmFi@temp{afrelationship}
+\EmFi@temp{moddate}
+\EmFi@temp{creationdate}
+\EmFi@temp{size}
+\EmFi@temp{ascending}
+\EmFi@temp{descending}
+\EmFi@temp{true}
+\EmFi@temp{false}
+\ltx@newif\ifEmFi@collection
+\ltx@newif\ifEmFi@sort
+\ltx@newif\ifEmFi@visible
+\ltx@newif\ifEmFi@edit
+\ltx@newif\ifEmFi@item
+\ltx@newif\ifEmFi@finished
+\ltx@newif\ifEmFi@id
+\def\EmFi@GlobalKey#1#2{%
+  \global\expandafter\let\csname KV@#1@#2\expandafter\endcsname
+                         \csname KV@#1@#2\endcsname
+}
+\def\EmFi@GlobalDefaultKey#1#2{%
+  \EmFi@GlobalKey{#1}{#2}%
+  \global\expandafter\let
+      \csname KV@#1@#2@default\expandafter\endcsname
+      \csname KV@#1@#2@default\endcsname
+}
+\def\EmFi@DefineKey#1#2{%
+  \kv@define@key{EmFi}{#1}{%
+    \expandafter\def\csname EmFi@#1\endcsname{##1}%
+  }%
+  \expandafter\def\csname EmFi@#1\endcsname{#2}%
+}
+\EmFi@DefineKey{mimetype}{}
+\EmFi@DefineKey{filespec}{\EmFi@file}
+\EmFi@DefineKey{ucfilespec}{}
+\EmFi@DefineKey{filesystem}{}
+\EmFi@DefineKey{desc}{}
+\EmFi@DefineKey{afrelationship}{}
+\EmFi@DefineKey{stringmethod}{%
+  \ifx\pdfstringdef\@undefined
+    escape%
+  \else
+    \ifx\pdfstringdef\relax
+      escape%
+    \else
+      psd%
+    \fi
+  \fi
+}
+\kv@define@key{EmFi}{id}{%
+  \def\EmFi@id{#1}%
+  \EmFi@idtrue
+}
+\def\EmFi@defobj#1{%
+  \ifEmFi@id
+    \expandafter\xdef\csname EmFi@#1@\EmFi@id\endcsname{%
+      \the\pdflastobj\ltx@space 0 R%
+    }%
+  \fi
+}
+\def\embedfileifobjectexists#1#2{%
+  \expandafter\ifx\csname EmFi@#2@#1\endcsname\relax
+    \expandafter\ltx@secondoftwo
+  \else
+    \expandafter\ltx@firstoftwo
+  \fi
+}
+\def\embedfilegetobject#1#2{%
+  \embedfileifobjectexists{#1}{#2}{%
+    \csname EmFi@#2@#1\endcsname
+  }{%
+    0 0 R%
+  }%
+}
+\kv@define@key{EmFi}{view}[]{%
+  \EdefSanitize\EmFi@temp{#1}%
+  \def\EmFi@next{%
+    \global\EmFi@collectiontrue
+  }%
+  \ifx\EmFi@temp\ltx@empty
+    \let\EmFi@view\EmFi@S@details
+  \else\ifx\EmFi@temp\EmFi@S@details
+    \let\EmFi@view\EmFi@S@details
+  \else\ifx\EmFi@temp\EmFi@S@tile
+    \let\EmFi@view\EmFi@S@tile
+  \else\ifx\EmFi@temp\EmFi@S@hidden
+    \let\EmFi@view\EmFi@S@hidden
+  \else
+    \let\EmFi@next\relax
+    \EmFi@Error{%
+      Unknown value `\EmFi@temp' for key `view'.\MessageBreak
+      Supported values: `details', `tile', `hidden'.%
+    }\@ehc
+  \fi\fi\fi\fi
+  \EmFi@next
+}
+\EmFi@DefineKey{initialfile}{}
+\def\embedfilesetup{%
+  \ifEmFi@finished
+    \def\EmFi@next##1{}%
+    \EmFi@Error{%
+      \string\embedfilefield\ltx@space after \string\embedfilefinish
+    }{%
+      The list of embedded files is already written.%
+    }%
+  \else
+    \def\EmFi@next{%
+      \kvsetkeys{EmFi}%
+    }%
+  \fi
+  \EmFi@next
+}
+\def\EmFi@schema{}
+\gdef\EmFi@order{0}
+\let\EmFi@@order\relax
+\def\EmFi@fieldlist{}
+\def\EmFi@sortcase{0}%
+\def\embedfilefield#1#2{%
+  \ifEmFi@finished
+    \EmFi@Error{%
+      \string\embedfilefield\ltx@space after \string\embedfilefinish
+    }{%
+      The list of embedded files is already written.%
+    }%
+  \else
+    \global\EmFi@collectiontrue
+    \EdefSanitize\EmFi@key{#1}%
+    \expandafter\ifx\csname KV@EmFi@\EmFi@key.prefix\endcsname\relax
+      \begingroup
+        \count@=\EmFi@order
+        \advance\count@ 1 %
+        \xdef\EmFi@order{\the\count@}%
+        \let\EmFi@title\EmFi@key
+        \let\EmFi@type\EmFi@S@text
+        \EmFi@visibletrue
+        \EmFi@editfalse
+        \kvsetkeys{EmFiFi}{#2}%
+        \EmFi@convert\EmFi@title\EmFi@title
+        \xdef\EmFi@schema{%
+          \EmFi@schema
+          /\pdf@escapename{\EmFi@key}<<%
+            /Subtype/%
+            \ifx\EmFi@type\EmFi@S@date D%
+            \else\ifx\EmFi@type\EmFi@S@number N%
+            \else\ifx\EmFi@type\EmFi@S@file F%
+            \else\ifx\EmFi@type\EmFi@S@desc Desc%
+            \else\ifx\EmFi@type\EmFi@S@afrelationship AFRelationship%
+            \else\ifx\EmFi@type\EmFi@S@moddate ModDate%
+            \else\ifx\EmFi@type\EmFi@S@creationdate CreationDate%
+            \else\ifx\EmFi@type\EmFi@S@size Size%
+            \else S%
+            \fi\fi\fi\fi\fi\fi\fi
+            /N(\EmFi@title)%
+            \EmFi@@order{\EmFi@order}%
+            \ifEmFi@visible
+            \else
+              /V false%
+            \fi
+            \ifEmFi@edit
+              /E true%
+            \fi
+          >>%
+        }%
+        \let\do\relax
+        \xdef\EmFi@fieldlist{%
+          \EmFi@fieldlist
+          \do{\EmFi@key}%
+        }%
+        \ifx\EmFi@type\EmFi@S@text
+          \kv@define@key{EmFi}{\EmFi@key.value}{%
+            \EmFi@itemtrue
+            \def\EmFi@temp{##1}%
+            \EmFi@convert\EmFi@temp\EmFi@temp
+            \expandafter\def\csname EmFi@V@#1%
+            \expandafter\endcsname\expandafter{%
+              \expandafter(\EmFi@temp)%
+            }%
+          }%
+          \EmFi@GlobalKey{EmFi}{\EmFi@key.value}%
+        \else\ifx\EmFi@type\EmFi@S@date
+          \kv@define@key{EmFi}{\EmFi@key.value}{%
+            \EmFi@itemtrue
+            \def\EmFi@temp{##1}%
+            \EmFi@convert\EmFi@temp\EmFi@temp
+            \expandafter\def\csname EmFi@V@#1%
+            \expandafter\endcsname\expandafter{%
+              \expandafter(\EmFi@temp)%
+            }%
+          }%
+          \EmFi@GlobalKey{EmFi}{\EmFi@key.value}%
+        \else\ifx\EmFi@type\EmFi@S@number
+          \kv@define@key{EmFi}{\EmFi@key.value}{%
+            \EmFi@itemtrue
+            \expandafter\EdefSanitize\csname EmFi@V@#1\endcsname{ ##1}%
+          }%
+          \EmFi@GlobalKey{EmFi}{\EmFi@key.value}%
+        \fi\fi\fi
+        \kv@define@key{EmFi}{\EmFi@key.prefix}{%
+          \EmFi@itemtrue
+          \expandafter\def\csname EmFi@P@#1\endcsname{##1}%
+        }%
+        \EmFi@GlobalKey{EmFi}{\EmFi@key.prefix}%
+        \kv@define@key{EmFiSo}{\EmFi@key}[ascending]{%
+          \EdefSanitize\EmFi@temp{##1}%
+          \ifx\EmFi@temp\EmFi@S@ascending
+            \def\EmFi@temp{true}%
+          \else\ifx\EmFi@temp\EmFi@S@descending
+            \def\EmFi@temp{false}%
+          \else
+            \def\EmFi@temp{}%
+            \EmFi@Error{%
+              Unknown sort order `\EmFi@temp'.\MessageBreak
+              Supported values: `\EmFi@S@ascending', %
+              `\EmFi@S@descending
+            }\@ehc
+          \fi\fi
+          \ifx\EmFi@temp\ltx@empty
+          \else
+            \xdef\EmFi@sortkeys{%
+              \EmFi@sortkeys
+              /\pdf@escapename{#1}%
+            }%
+            \ifx\EmFi@sortorders\ltx@empty
+              \global\let\EmFi@sortorders\EmFi@temp
+              \gdef\EmFi@sortcase{1}%
+            \else
+              \xdef\EmFi@sortorders{%
+                \EmFi@sortorders
+                \ltx@space
+                \EmFi@temp
+              }%
+              \xdef\EmFi@sortcase{2}%
+            \fi
+          \fi
+        }%
+        \EmFi@GlobalDefaultKey{EmFiSo}\EmFi@key
+      \endgroup
+    \else
+      \EmFi@Error{%
+        Field `\EmFi@key' is already defined%
+      }\@ehc
+    \fi
+  \fi
+}
+\kv@define@key{EmFiFi}{type}{%
+  \EdefSanitize\EmFi@temp{#1}%
+  \ifx\EmFi@temp\EmFi@S@text
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@date
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@number
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@file
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@desc
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@afrelationship
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@moddate
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@creationdate
+    \let\EmFi@type\EmFi@temp
+  \else\ifx\EmFi@temp\EmFi@S@size
+    \let\EmFi@type\EmFi@temp
+  \else
+    \EmFi@Error{%
+      Unknown type `\EmFi@temp'.\MessageBreak
+      Supported types: `text', `date', `number', `file',\MessageBreak
+      `desc', `afrelationship', `moddate', `creationdate', `size'%
+    }%
+  \fi\fi\fi\fi\fi\fi\fi\fi\fi
+}
+\kv@define@key{EmFiFi}{title}{%
+  \def\EmFi@title{#1}%
+}
+\def\EmFi@setboolean#1#2{%
+  \EdefSanitize\EmFi@temp{#2}%
+  \ifx\EmFi@temp\EmFi@S@true
+    \csname EmFi@#1true\endcsname
+  \else
+    \ifx\EmFi@temp\EmFi@S@false
+      \csname EmFi@#1false\endcsname
+    \else
+      \EmFi@Error{%
+        Unknown value `\EmFi@temp' for key `#1'.\MessageBreak
+        Supported values: `true', `false'%
+      }\@ehc
+    \fi
+  \fi
+}
+\kv@define@key{EmFiFi}{visible}[true]{%
+  \EmFi@setboolean{visible}{#1}%
+}
+\kv@define@key{EmFiFi}{edit}[true]{%
+  \EmFi@setboolean{edit}{#1}%
+}
+\def\EmFi@sortkeys{}
+\def\EmFi@sortorders{}
+\def\embedfilesort{%
+  \kvsetkeys{EmFiSo}%
+}
+\def\embedfile{%
+  \ltx@ifnextchar[\EmFi@embedfile{\EmFi@embedfile[]}%
+}
+\def\EmFi@embedfile[#1]#2{%
+  \ifEmFi@finished
+    \EmFi@Error{%
+      \string\embedfile\ltx@space after \string\embedfilefinish
+    }{%
+      The list of embedded files is already written.%
+    }%
+  \else
+    \begingroup
+      \def\EmFi@file{#2}%
+      \kvsetkeys{EmFi}{#1}%
+      \expandafter\expandafter\expandafter
+      \ifx\expandafter\expandafter\expandafter
+          \\\pdf@filesize{\EmFi@file}\\%
+        \EmFi@Error{%
+          File `\EmFi@file' not found%
+        }{%
+          The unknown file is not embedded.%
+        }%
+      \else
+        \edef\EmFi@@filespec{%
+          \pdf@escapestring{\EmFi@filespec}%
+        }%
+        \ifx\EmFi@ucfilespec\ltx@empty
+          \let\EmFi@@ucfilespec\ltx@empty
+        \else
+          \EmFi@convert\EmFi@ucfilespec\EmFi@@ucfilespec
+        \fi
+        \ifx\EmFi@desc\ltx@empty
+          \let\EmFi@@desc\ltx@empty
+        \else
+          \EmFi@convert\EmFi@desc\EmFi@@desc
+        \fi
+        \ifx\EmFi@afrelationship\ltx@empty
+          \let\EmFi@@afrelationship\ltx@empty
+        \else
+          \EmFi@convert\EmFi@afrelationship\EmFi@@afrelationship
+        \fi
+        \ifEmFi@item
+          \let\do\EmFi@do
+          \immediate\pdfobj{%
+            <<%
+              \EmFi@fieldlist
+            >>%
+          }%
+          \edef\EmFi@ci{\the\pdflastobj}%
+        \fi
+        \immediate\pdfobj stream attr{%
+          /Type/EmbeddedFile%
+          \ifx\EmFi@mimetype\ltx@empty
+          \else
+            /Subtype/\pdf@escapename{\EmFi@mimetype}%
+          \fi
+          /Params<<%
+            /ModDate(\pdf@filemoddate{\EmFi@file})%
+            /Size \pdf@filesize{\EmFi@file}%
+            /CheckSum<\pdf@filemdfivesum{\EmFi@file}>%
+          >>%
+        }file{\EmFi@file}\relax
+        \EmFi@defobj{EmbeddedFile}%
+        \immediate\pdfobj{%
+          <<%
+            /Type/Filespec%
+            \ifx\EmFi@filesystem\ltx@empty
+            \else
+            /FS/\pdf@escapename{\EmFi@filesystem}%
+            \fi
+            /F(\EmFi@@filespec)%
+            \ifx\EmFi@@ucfilespec\ltx@empty
+            \else
+              /UF(\EmFi@@ucfilespec)%
+            \fi
+            \ifx\EmFi@@desc\ltx@empty
+            \else
+              /Desc(\EmFi@@desc)%
+            \fi
+            \ifx\EmFi@@afrelationship\ltx@empty
+            \else
+              /AFRelationship\EmFi@@afrelationship%
+            \fi
+            /EF<<%
+              /F \the\pdflastobj\ltx@space 0 R%
+            >>%
+            \ifEmFi@item
+              /CI \EmFi@ci\ltx@space 0 R%
+            \fi
+          >>%
+        }%
+        \EmFi@defobj{Filespec}%
+        \EmFi@add{%
+          \EmFi@@filespec
+        }{\the\pdflastobj\ltx@space 0 R}%
+      \fi
+    \endgroup
+  \fi
+}
+\def\EmFi@do#1{%
+  \expandafter\ifx\csname EmFi@P@#1\endcsname\relax
+    \expandafter\ifx\csname EmFi@V@#1\endcsname\relax
+    \else
+      /\pdf@escapename{#1}\csname EmFi@V@#1\endcsname
+    \fi
+  \else
+    /\pdf@escapename{#1}<<%
+      \expandafter\ifx\csname EmFi@V@#1\endcsname\relax
+      \else
+        /D\csname EmFi@V@#1\endcsname
+      \fi
+      /P(\csname EmFi@P@#1\endcsname)%
+    >>%
+  \fi
+}
+\def\EmFi@convert#1#2{%
+  \ifnum\pdf@strcmp{\EmFi@stringmethod}{psd}=0 %
+    \pdfstringdef\EmFi@temp{#1}%
+    \let#2\EmFi@temp
+  \else
+    \edef#2{\pdf@escapestring{#1}}%
+  \fi
+}
+\global\let\EmFi@list\ltx@empty
+\def\EmFi@add#1#2{%
+  \begingroup
+    \ifx\EmFi@list\ltx@empty
+      \xdef\EmFi@list{\noexpand\do{#1}{#2}}%
+    \else
+      \def\do##1##2{%
+        \ifnum\pdf@strcmp{##1}{#1}>0 %
+          \edef\x{%
+            \toks@{%
+              \the\toks@%
+              \noexpand\do{#1}{#2}%
+              \noexpand\do{##1}{##2}%
+            }%
+          }%
+          \x
+          \def\do####1####2{%
+            \toks@\expandafter{\the\toks@\do{####1}{####2}}%
+          }%
+          \def\stop{%
+            \xdef\EmFi@list{\the\toks@}%
+          }%
+        \else
+          \toks@\expandafter{\the\toks@\do{##1}{##2}}%
+        \fi
+      }%
+      \def\stop{%
+        \xdef\EmFi@list{\the\toks@\noexpand\do{#1}{#2}}%
+      }%
+      \toks@{}%
+      \EmFi@list\stop
+    \fi
+  \endgroup
+}
+\def\embedfilefinish{%
+  \ifEmFi@finished
+    \EmFi@Error{%
+      Too many invocations of \string\embedfilefinish
+    }{%
+      The list of embedded files is already written.%
+    }%
+  \else
+    \ifx\EmFi@list\ltx@empty
+    \else
+      \global\EmFi@finishedtrue
+      \begingroup
+        \def\do##1##2{%
+          (##1)##2%
+        }%
+        \immediate\pdfobj{%
+          <<%
+            /Names[\EmFi@list]%
+          >>%
+        }%
+        \pdfnames{%
+          /EmbeddedFiles \the\pdflastobj\ltx@space 0 R%
+        }%
+      \endgroup
+      \begingroup
+        \def\do##1##2{%
+          \ltx@space##2%
+        }%
+        \immediate\pdfobj{%
+          [\EmFi@list]%
+        }%
+        \pdfcatalog{%
+          /AF \the\pdflastobj\ltx@space 0 R%
+        }%
+      \endgroup
+      \ifx\EmFi@initialfile\ltx@empty
+      \else
+        \EmFi@collectiontrue
+      \fi
+      \ifEmFi@collection
+        \ifx\EmFi@initialfile\ltx@empty
+          \let\EmFi@@initialfile\ltx@empty
+        \else
+          \edef\EmFi@@initialfile{%
+            \pdf@escapestring{\EmFi@initialfile}%
+          }%
+        \fi
+        \begingroup
+          \let\f=N%
+          \def\do##1##2{%
+            \def\x{##1}%
+            \ifx\x\EmFi@@initialfile
+              \let\f=Y%
+              \let\do\ltx@gobbletwo
+            \fi
+          }%
+          \EmFi@list
+        \expandafter\endgroup
+        \ifx\f Y%
+        \else
+          \@PackageWarningNoLine{embedfile}{%
+            Missing initial file `\EmFi@initialfile'\MessageBreak
+            among the embedded files%
+          }%
+          \let\EmFi@initialfile\ltx@empty
+          \let\EmFi@@initialfile\ltx@empty
+        \fi
+        \ifcase\EmFi@sortcase
+          \def\EmFi@temp{}%
+        \or
+          \def\EmFi@temp{%
+            /S\EmFi@sortkeys
+            /A \EmFi@sortorders
+          }%
+        \else
+          \def\EmFi@temp{%
+            /S[\EmFi@sortkeys]%
+            /A[\EmFi@sortorders]%
+          }%
+        \fi
+        \def\EmFi@@order##1{%
+          \ifnum\EmFi@order>1 %
+            /O ##1%
+          \fi
+        }%
+        \immediate\pdfobj{%
+          <<%
+            \ifx\EmFi@schema\ltx@empty
+            \else
+              /Schema<<\EmFi@schema>>%
+            \fi
+            \ifx\EmFi@@initialfile\ltx@empty
+            \else
+              /D(\EmFi@@initialfile)%
+            \fi
+            \ifx\EmFi@view\EmFi@S@tile
+              /View/T%
+            \else\ifx\EmFi@view\EmFi@S@hidden
+              /View/H%
+            \fi\fi
+            \ifx\EmFi@temp\ltx@empty
+              \EmFi@temp
+            \else
+              /Sort<<\EmFi@temp>>%
+            \fi
+          >>%
+        }%
+        \pdfcatalog{%
+          /Collection \the\pdflastobj\ltx@space0 R%
+        }%
+      \fi
+    \fi
+  \fi
+}
+\begingroup\expandafter\expandafter\expandafter\endgroup
+\expandafter\ifx\csname AtEndDocument\endcsname\relax
+\else
+  \AtEndDocument{\embedfilefinish}%
+\fi
+\EmFi@AtEnd%
+\endinput
+%%
+%% End of file `embedfile.sty'.