1 package SL::Controller::ZUGFeRD;
4 use parent qw(SL::Controller::Base);
6 use SL::DB::RecordTemplate;
7 use SL::Locale::String qw(t8);
8 use SL::Helper::DateTime;
15 use List::Util qw(first);
18 __PACKAGE__->run_before('check_auth');
20 sub action_upload_zugferd {
21 my ($self, %params) = @_;
24 $self->render('zugferd/form', title => $::locale->text('Factur-X/ZUGFeRD import'));
27 sub find_vendor_by_taxnumber {
28 my $taxnumber = shift @_;
30 # 1.1 check if we a have a vendor with this tax number (vendor.taxnumber)
31 my $vendor = SL::DB::Manager::Vendor->find_by(
32 taxnumber => $taxnumber,
39 # 1.2 If no vendor with the exact VAT ID number is found, the
40 # number might be stored slightly different in the database
41 # (e.g. with spaces breaking up groups of numbers). Iterate over
42 # all existing vendors with VAT ID numbers, normalize their
43 # representation and compare those.
45 my $vendors = SL::DB::Manager::Vendor->get_all(
47 '!taxnumber' => undef,
55 foreach my $other_vendor (@{ $vendors }) {
56 next unless $other_vendor->taxnumber eq $taxnumber;
58 $vendor = $other_vendor;
64 sub find_vendor_by_ustid {
67 $ustid = SL::VATIDNr->normalize($ustid);
69 # 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid)
70 my $vendor = SL::DB::Manager::Vendor->find_by(
78 # 1.2 If no vendor with the exact VAT ID number is found, the
79 # number might be stored slightly different in the database
80 # (e.g. with spaces breaking up groups of numbers). Iterate over
81 # all existing vendors with VAT ID numbers, normalize their
82 # representation and compare those.
84 my $vendors = SL::DB::Manager::Vendor->get_all(
94 foreach my $other_vendor (@{ $vendors }) {
95 next unless SL::VATIDNr->normalize($other_vendor->ustid) eq $ustid;
97 $vendor = $other_vendor;
106 my ($ustid, $taxnumber) = @_;
110 $vendor = find_vendor_by_ustid($ustid);
113 if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
116 $vendor = find_vendor_by_taxnumber($taxnumber);
119 if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
124 sub action_import_zugferd {
125 my ($self, %params) = @_;
127 my $file = $::form->{file};
128 my $file_name = $::form->{file_name};
130 my %res; # result data structure returned by SL::ZUGFeRD->extract_from_{pdf,xml}()
132 die t8("missing file for action import") unless $file;
133 die t8("can only parse a pdf or xml file") unless $file =~ m/^%PDF|<\?xml/;
135 if ( $::form->{file} =~ m/^%PDF/ ) {
136 %res = %{SL::ZUGFeRD->extract_from_pdf($file)};
138 %res = %{SL::ZUGFeRD->extract_from_xml($file)};
141 if ($res{'result'} != SL::ZUGFeRD::RES_OK()) {
142 # An error occurred; log message from parser:
143 die(t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . " $res{'message'}");
146 my $form_defaults = $self->build_ap_transaction_form_defaults(\%res);
148 # save the zugferd file to session file for reuse in ap.pl
149 my $session_file = SL::SessionFile->new($file_name, mode => 'w');
150 $session_file->fh->print($file);
151 $session_file->fh->close;
152 $form_defaults->{zugferd_session_file} = $file_name;
154 $form_defaults->{callback} = $self->url_for(action => 'upload_zugferd');
157 controller => 'ap.pl',
158 action => 'load_zugferd',
159 form_defaults => $form_defaults,
163 sub build_ap_transaction_form_defaults {
164 my ($self, $data, %params) = @_;
165 my $vendor = $params{vendor};
167 my $parser = $data->{'invoice_xml'};
169 my %metadata = %{$parser->metadata};
170 my @items = @{$parser->items};
172 my $intnotes = t8("ZUGFeRD Import. Type: #1", $metadata{'type'})->translated;
173 my $iban = $metadata{'iban'};
174 my $invnumber = $metadata{'invnumber'};
177 if ($metadata{'ustid'} && $vendor->ustid && ($metadata{'ustid'} ne $vendor->ustid)) {
178 $intnotes .= "\n" . t8('USt-IdNr.') . ': '
179 . t8("Record VAT ID #1 doesn't match vendor VAT ID #2", $metadata{'ustid'}, $vendor->ustid);
181 if ($metadata{'taxnumber'} && $vendor->taxnumber && ($metadata{'taxnumber'} ne $vendor->taxnumber)) {
182 $intnotes .= "\n" . t8("Tax Number") . ': '
183 . t8("Record tax ID #1 doesn't match vendor tax ID #2", $metadata{'taxnumber'}, $vendor->taxnumber);
186 if ( ! ($metadata{'ustid'} or $metadata{'taxnumber'}) ) {
187 die t8("Cannot process this invoice: neither VAT ID nor tax ID present.");
190 $vendor = find_vendor($metadata{'ustid'}, $metadata{'taxnumber'});
192 die t8("Vendor with VAT ID (#1) and/or tax ID (#2) not found. Please check if the vendor " .
193 "#3 exists and whether it has the correct tax ID/VAT ID." ,
195 $metadata{'taxnumber'},
196 $metadata{'vendor_name'},
201 # Check IBAN specified on bill matches the one we've got in
202 # the database for this vendor.
204 $intnotes .= "\nIBAN: ";
205 $intnotes .= $iban ne $vendor->iban ?
206 t8("Record IBAN #1 doesn't match vendor IBAN #2", $iban, $vendor->iban)
210 # Use invoice creation date as due date if there's no due date
211 $metadata{'duedate'} = $metadata{'transdate'} unless defined $metadata{'duedate'};
213 # parse dates to kivi if set/valid
214 foreach my $key ( qw(transdate duedate) ) {
215 next unless defined $metadata{$key};
216 $metadata{$key} =~ s/^\s+|\s+$//g;
218 if ($metadata{$key} =~ /^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$/) {
219 $metadata{$key} = DateTime->new(year => $1,
221 day => $3)->to_kivitendo;
225 # Try to fill in AP account to book against
226 my $ap_chart_id = $::instance_conf->get_ap_chart_id;
228 unless ( defined $ap_chart_id ) {
229 # If no default account is configured, just use the first AP account found.
230 my ($ap_chart) = @{SL::DB::Manager::Chart->get_all(
231 where => [ link => 'AP' ],
232 sort_by => [ 'accno' ],
234 $ap_chart_id = $ap_chart->id;
237 my $currency = SL::DB::Manager::Currency->find_by(
238 name => $metadata{'currency'},
241 my $default_ap_amount_chart = SL::DB::Manager::Chart->find_by(
242 id => $::instance_conf->get_expense_accno_id
244 # Fallback if there's no default AP amount chart configured
245 $default_ap_amount_chart ||= SL::DB::Manager::Chart->find_by(charttype => 'A');
247 my $active_taxkey = $default_ap_amount_chart->get_active_taxkey;
248 my $taxes = SL::DB::Manager::Tax->get_all(
249 where => [ chart_categories => {
250 like => '%' . $default_ap_amount_chart->category . '%'
252 sort_by => 'taxkey, rate',
255 "No tax found for chart #1", $default_ap_amount_chart->displayable_name
256 ) unless scalar @{$taxes};
261 foreach my $i (@items) {
266 my $net_total = $::form->format_amount(\%::myconfig, $item{'subtotal'}, 2);
268 my $tax_rate = $item{'tax_rate'};
269 $tax_rate /= 100 if $tax_rate > 1; # XML data is usually in percent
271 my $tax = first { $tax_rate == $_->rate } @{ $taxes };
272 $tax //= first { $active_taxkey->tax_id == $_->id } @{ $taxes };
273 $tax //= $taxes->[0];
275 $item_form{"AP_amount_chart_id_${row}"} = $default_ap_amount_chart->id;
276 $item_form{"previous_AP_amount_chart_id_${row}"} = $default_ap_amount_chart->id;
277 $item_form{"amount_${row}"} = $net_total;
278 $item_form{"taxchart_${row}"} = $tax->id . '--' . $tax->rate;
280 $item_form{rowcount} = $row;
283 vendor_id => $vendor->id,
284 vendor => $vendor->name,
285 invnumber => $invnumber,
286 transdate => $metadata{'transdate'},
287 duedate => $metadata{'duedate'},
288 no_payment_bookings => 0,
289 intnotes => $intnotes,
291 direct_debit => $metadata{'direct_debit'},
292 currency => $currency->name,
293 AP_chart_id => $ap_chart_id,
294 paid_1_suggestion => $::form->format_amount(\%::myconfig, $metadata{'total'}, 2),
300 $::auth->assert('ap_transactions');
302 sub setup_zugferd_action_bar {
305 for my $bar ($::request->layout->get('actionbar')) {
308 $::locale->text('Import'),
309 submit => [ '#form', { action => 'ZUGFeRD/import_zugferd' } ],
310 accesskey => 'enter',
319 $::request->{layout}->use_javascript("${_}.js") for qw(
323 $self->setup_zugferd_action_bar;
336 SL::Controller::ZUGFeRD - Controller for importing ZUGFeRD PDF files or XML invoices to kivitendo
342 =item C<action_upload_zugferd>
344 Creates a web from with a single upload dialog.
346 =item C<action_import_zugferd $file>
348 Expects a single PDF with ZUGFeRD, Factur-X or XRechnung
349 metadata. Alternatively, it can also process said data as a
352 Checks if the param <C$pdf> is set and a valid PDF or XML
353 file. Calls helper functions to validate and extract the
354 ZUGFeRD/Factur-X/XRechnung data. The invoice needs to have a
355 valid VAT ID (EU) or tax number (Germany) and a vendor with
356 the same VAT ID or tax number enrolled in Kivitendo.
358 It parses some basic ZUGFeRD data (invnumber, total net amount,
359 transdate, duedate, vendor VAT ID, IBAN, etc.) and also
360 extracts the invoice's items.
362 If the invoice has a IBAN also, it will be be compared to the
363 IBAN saved for the vendor (if any). If they don't match a
364 warning will be writte in ap.intnotes. Furthermore the ZUGFeRD
365 type code will be written to ap.intnotes. No callback
372 This is just a very basic Parser for ZUGFeRD/Factur-X/XRechnung invoices.
373 We assume that the invoice's creator is a company with a valid
374 European VAT ID or German tax number and enrolled in
379 This implementation could be improved as follows:
383 =item Automatic upload of invoice
385 Right now, one has to use the "Book and upload" button to
386 upload the raw invoice document to WebDAV or DMS and attach it
387 to the invoice. This should be a simple matter of setting a
388 check box when uploading.
390 =item Handling of vendor invoices
392 There is no reason this functionality could not be used to
393 import vendor invoices as well. Since these tend to be very
394 lengthy, the ability to import them would be very beneficial.
396 =item Automatic handling of payment purpose
398 If the ZUGFeRD data has a payment purpose set, this should
399 be the default for the SEPA-XML export.
407 =item Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
409 =item Johannes Graßler E<lt>info@computer-grassler.deE<gt>,