kivitendo/SL/Controller/ZUGFeRD.pm @ 2435a25a
9077dc27 | Jan Büren | package SL::Controller::ZUGFeRD;
|
||
use strict;
|
||||
b525a340 | Johannes Grassler | use warnings;
|
||
9077dc27 | Jan Büren | use parent qw(SL::Controller::Base);
|
||
use SL::DB::RecordTemplate;
|
||||
use SL::Locale::String qw(t8);
|
||||
use SL::Helper::DateTime;
|
||||
b525a340 | Johannes Grassler | use SL::XMLInvoice;
|
||
d0809fbb | Moritz Bunkus | use SL::VATIDNr;
|
||
9077dc27 | Jan Büren | use SL::ZUGFeRD;
|
||
834abeb4 | Tamino Steinert | use SL::SessionFile;
|
||
9077dc27 | Jan Büren | |||
use XML::LibXML;
|
||||
ffe592ca | Tamino Steinert | use List::Util qw(first);
|
||
9077dc27 | Jan Büren | |||
__PACKAGE__->run_before('check_auth');
|
||||
sub action_upload_zugferd {
|
||||
my ($self, %params) = @_;
|
||||
834abeb4 | Tamino Steinert | $self->pre_render();
|
||
ba40069b | Moritz Bunkus | $self->render('zugferd/form', title => $::locale->text('Factur-X/ZUGFeRD import'));
|
||
9077dc27 | Jan Büren | }
|
||
b525a340 | Johannes Grassler | sub find_vendor_by_taxnumber {
|
||
my $taxnumber = shift @_;
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | # 1.1 check if we a have a vendor with this tax number (vendor.taxnumber)
|
||
my $vendor = SL::DB::Manager::Vendor->find_by(
|
||||
taxnumber => $taxnumber,
|
||||
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.
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | my $vendors = SL::DB::Manager::Vendor->get_all(
|
||
where => [
|
||||
'!taxnumber' => undef,
|
||||
'!taxnumber' => '',
|
||||
or => [
|
||||
obsolete => undef,
|
||||
obsolete => 0,
|
||||
],
|
||||
]);
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | foreach my $other_vendor (@{ $vendors }) {
|
||
next unless $other_vendor->taxnumber eq $taxnumber;
|
||||
$vendor = $other_vendor;
|
||||
last;
|
||||
}
|
||||
9077dc27 | Jan Büren | }
|
||
b525a340 | Johannes Grassler | }
|
||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | sub find_vendor_by_ustid {
|
||
my $ustid = shift @_;
|
||||
9077dc27 | Jan Büren | |||
d0809fbb | Moritz Bunkus | $ustid = SL::VATIDNr->normalize($ustid);
|
||
9077dc27 | Jan Büren | |||
# 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid)
|
||||
d0809fbb | Moritz Bunkus | 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;
|
||||
}
|
||||
}
|
||||
b525a340 | Johannes Grassler | return $vendor;
|
||
}
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | sub find_vendor {
|
||
my ($ustid, $taxnumber) = @_;
|
||||
my $vendor;
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | if ( $ustid ) {
|
||
$vendor = find_vendor_by_ustid($ustid);
|
||||
}
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
|
||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | if ( $taxnumber ) {
|
||
$vendor = find_vendor_by_taxnumber($taxnumber);
|
||||
}
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
|
||
return undef;
|
||||
}
|
||||
sub action_import_zugferd {
|
||||
my ($self, %params) = @_;
|
||||
my $file = $::form->{file};
|
||||
my $file_name = $::form->{file_name};
|
||||
my %res; # result data structure returned by SL::ZUGFeRD->extract_from_{pdf,xml}()
|
||||
my $parser; # SL::XMLInvoice object created by SL::ZUGFeRD->extract_from_{pdf,xml}()
|
||||
my $vendor; # SL::DB::Vendor object
|
||||
ffe592ca | Tamino Steinert | die t8("missing file for action import") unless $file;
|
||
die t8("can only parse a pdf or xml file") unless $file =~ m/^%PDF|<\?xml/;
|
||||
b525a340 | Johannes Grassler | |||
if ( $::form->{file} =~ m/^%PDF/ ) {
|
||||
5b916c02 | Johannes Grassler | %res = %{SL::ZUGFeRD->extract_from_pdf($file)};
|
||
b525a340 | Johannes Grassler | } else {
|
||
5b916c02 | Johannes Grassler | %res = %{SL::ZUGFeRD->extract_from_xml($file)};
|
||
9077dc27 | Jan Büren | }
|
||
b525a340 | Johannes Grassler | |||
if ($res{'result'} != SL::ZUGFeRD::RES_OK()) {
|
||||
# An error occurred; log message from parser:
|
||||
die(t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . " $res{'message'}");
|
||||
9077dc27 | Jan Büren | }
|
||
b525a340 | Johannes Grassler | $parser = $res{'invoice_xml'};
|
||
my %metadata = %{$parser->metadata};
|
||||
my @items = @{$parser->items};
|
||||
9077dc27 | Jan Büren | |||
2b573ea7 | Tamino Steinert | my $intnotes = t8("ZUGFeRD Import. Type: #1", $metadata{'type'})->translated;
|
||
b525a340 | Johannes Grassler | my $iban = $metadata{'iban'};
|
||
my $invnumber = $metadata{'invnumber'};
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | if ( ! ($metadata{'ustid'} or $metadata{'taxnumber'}) ) {
|
||
die t8("Cannot process this invoice: neither VAT ID nor tax ID present.");
|
||||
1522aeb7 | Johannes Grassler | }
|
||
b525a340 | Johannes Grassler | |||
$vendor = find_vendor($metadata{'ustid'}, $metadata{'taxnumber'});
|
||||
51c76e20 | Johannes Grassler | die t8("Vendor with VAT ID (#1) and/or tax ID (#2) not found. Please check if the vendor " .
|
||
"#3 exists and whether it has the correct tax ID/VAT ID." ,
|
||||
$metadata{'ustid'},
|
||||
$metadata{'taxnumber'},
|
||||
$metadata{'vendor_name'},
|
||||
) unless $vendor;
|
||||
b525a340 | Johannes Grassler | |||
# Check IBAN specified on bill matches the one we've got in
|
||||
# the database for this vendor.
|
||||
ffe592ca | Tamino Steinert | if ($iban) {
|
||
4c7d81ed | Tamino Steinert | $intnotes .= "\nIBAN: ";
|
||
$intnotes .= $iban ne $vendor->iban ?
|
||||
ffe592ca | Tamino Steinert | t8("Record IBAN #1 doesn't match vendor IBAN #2", $iban, $vendor->iban)
|
||
: $iban
|
||||
}
|
||||
9077dc27 | Jan Büren | |||
834abeb4 | Tamino Steinert | # save the zugferd file to session file for reuse in ap.pl
|
||
my $session_file = SL::SessionFile->new($file_name, mode => 'w');
|
||||
$session_file->fh->print($file);
|
||||
$session_file->fh->close;
|
||||
b525a340 | Johannes Grassler | # Use invoice creation date as due date if there's no due date
|
||
$metadata{'duedate'} = $metadata{'transdate'} unless defined $metadata{'duedate'};
|
||||
# parse dates to kivi if set/valid
|
||||
foreach my $key ( qw(transdate duedate) ) {
|
||||
next unless defined $metadata{$key};
|
||||
$metadata{$key} =~ s/^\s+|\s+$//g;
|
||||
if ($metadata{$key} =~ /^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$/) {
|
||||
$metadata{$key} = DateTime->new(year => $1,
|
||||
month => $2,
|
||||
day => $3)->to_kivitendo;
|
||||
}
|
||||
}
|
||||
# Try to fill in AP account to book against
|
||||
my $ap_chart_id = $::instance_conf->get_ap_chart_id;
|
||||
unless ( defined $ap_chart_id ) {
|
||||
# If no default account is configured, just use the first AP account found.
|
||||
my $ap_chart = SL::DB::Manager::Chart->get_all(
|
||||
where => [ link => 'AP' ],
|
||||
sort_by => [ 'accno' ],
|
||||
);
|
||||
$ap_chart_id = ${$ap_chart}[0]->id;
|
||||
}
|
||||
my $currency = SL::DB::Manager::Currency->find_by(
|
||||
name => $metadata{'currency'},
|
||||
);
|
||||
ffe592ca | Tamino Steinert | my $default_ap_amount_chart = SL::DB::Manager::Chart->find_by(
|
||
id => $::instance_conf->get_expense_accno_id
|
||||
);
|
||||
# Fallback if there's no default AP amount chart configured
|
||||
$default_ap_amount_chart ||= SL::DB::Manager::Chart->find_by(charttype => 'A');
|
||||
3c21d790 | Tamino Steinert | my $active_taxkey = $default_ap_amount_chart->get_active_taxkey;
|
||
ffe592ca | Tamino Steinert | my $taxes = SL::DB::Manager::Tax->get_all(
|
||
where => [ chart_categories => {
|
||||
like => '%' . $default_ap_amount_chart->category . '%'
|
||||
}],
|
||||
sort_by => 'taxkey, rate',
|
||||
);
|
||||
die t8(
|
||||
"No tax found for chart #1", $default_ap_amount_chart->displayable_name
|
||||
) unless scalar @{$taxes};
|
||||
# parse items
|
||||
my $row = 0;
|
||||
my %item_form = ();
|
||||
foreach my $i (@items) {
|
||||
$row++;
|
||||
my %item = %{$i};
|
||||
my $net_total = $::form->format_amount(\%::myconfig, $item{'subtotal'}, 2);
|
||||
my $tax_rate = $item{'tax_rate'};
|
||||
$tax_rate /= 100 if $tax_rate > 1; # XML data is usually in percent
|
||||
my $tax = first { $tax_rate == $_->rate } @{ $taxes };
|
||||
$tax //= first { $active_taxkey->tax_id == $_->id } @{ $taxes };
|
||||
$tax //= $taxes->[0];
|
||||
$item_form{"AP_amount_chart_id_${row}"} = $default_ap_amount_chart->id;
|
||||
$item_form{"previous_AP_amount_chart_id_${row}"} = $default_ap_amount_chart->id;
|
||||
$item_form{"amount_${row}"} = $net_total;
|
||||
$item_form{"taxchart_${row}"} = $tax->id . '--' . $tax->rate;
|
||||
}
|
||||
$item_form{rowcount} = $row;
|
||||
834abeb4 | Tamino Steinert | $self->redirect_to(
|
||
ffe592ca | Tamino Steinert | controller => 'ap.pl',
|
||
action => 'load_zugferd',
|
||||
7bbb4f9a | Tamino Steinert | form_defaults => {
|
||
zugferd_session_file => $file_name,
|
||||
04b948f7 | Tamino Steinert | callback => $self->url_for(action => 'upload_zugferd'),
|
||
7bbb4f9a | Tamino Steinert | vendor_id => $vendor->id,
|
||
vendor => $vendor->name,
|
||||
invnumber => $invnumber,
|
||||
transdate => $metadata{'transdate'},
|
||||
duedate => $metadata{'duedate'},
|
||||
no_payment_bookings => 0,
|
||||
4c7d81ed | Tamino Steinert | intnotes => $intnotes,
|
||
7bbb4f9a | Tamino Steinert | taxincluded => 0,
|
||
direct_debit => $metadata{'direct_debit'},
|
||||
currency => $currency->name,
|
||||
AP_chart_id => $ap_chart_id,
|
||||
paid_1_suggestion => $::form->format_amount(\%::myconfig, $metadata{'total'}, 2),
|
||||
%item_form,
|
||||
},
|
||||
9077dc27 | Jan Büren | );
|
||
}
|
||||
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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
834abeb4 | Tamino Steinert | sub pre_render {
|
||
my ($self) = @_;
|
||||
$::request->{layout}->use_javascript("${_}.js") for qw(
|
||||
kivi.ZUGFeRD
|
||||
);
|
||||
$self->setup_zugferd_action_bar;
|
||||
}
|
||||
9077dc27 | Jan Büren | |||
1;
|
||||
__END__
|
||||
=pod
|
||||
=encoding utf8
|
||||
=head1 NAME
|
||||
b525a340 | Johannes Grassler | SL::Controller::ZUGFeRD - Controller for importing ZUGFeRD PDF files or XML invoices to kivitendo
|
||
9077dc27 | Jan Büren | |||
=head1 FUNCTIONS
|
||||
=over 4
|
||||
=item C<action_upload_zugferd>
|
||||
Creates a web from with a single upload dialog.
|
||||
b525a340 | Johannes Grassler | =item C<action_import_zugferd $file>
|
||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | Expects a single PDF with ZUGFeRD, Factur-X or XRechnung
|
||
metadata. Alternatively, it can also process said data as a
|
||||
standalone XML file.
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | Checks if the param <C$pdf> is set and a valid PDF or XML
|
||
file. Calls helper functions to validate and extract the
|
||||
ZUGFeRD/Factur-X/XRechnung data. The invoice needs to have a
|
||||
valid VAT ID (EU) or tax number (Germany) and a vendor with
|
||||
the same VAT ID or tax number enrolled in Kivitendo.
|
||||
It parses some basic ZUGFeRD data (invnumber, total net amount,
|
||||
transdate, duedate, vendor VAT ID, IBAN, etc.) and also
|
||||
extracts the invoice's items.
|
||||
If the invoice has a IBAN also, it will be be compared to the
|
||||
IBAN saved for the vendor (if any). If they don't match a
|
||||
4c7d81ed | Tamino Steinert | warning will be writte in ap.intnotes. Furthermore the ZUGFeRD
|
||
type code will be written to ap.intnotes. No callback
|
||||
b525a340 | Johannes Grassler | implemented.
|
||
9077dc27 | Jan Büren | |||
=back
|
||||
b525a340 | Johannes Grassler | =head1 CAVEAT
|
||
This is just a very basic Parser for ZUGFeRD/Factur-X/XRechnung invoices.
|
||||
We assume that the invoice's creator is a company with a valid
|
||||
European VAT ID or German tax number and enrolled in
|
||||
7bbb4f9a | Tamino Steinert | Kivitendo.
|
||
b525a340 | Johannes Grassler | |||
=head1 TODO
|
||||
This implementation could be improved as follows:
|
||||
=over 4
|
||||
=item Automatic upload of invoice
|
||||
Right now, one has to use the "Book and upload" button to
|
||||
upload the raw invoice document to WebDAV or DMS and attach it
|
||||
to the invoice. This should be a simple matter of setting a
|
||||
check box when uploading.
|
||||
=item Handling of vendor invoices
|
||||
There is no reason this functionality could not be used to
|
||||
import vendor invoices as well. Since these tend to be very
|
||||
lengthy, the ability to import them would be very beneficial.
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | =item Automatic handling of payment purpose
|
||
9077dc27 | Jan Büren | |||
If the ZUGFeRD data has a payment purpose set, this should
|
||||
be the default for the SEPA-XML export.
|
||||
b525a340 | Johannes Grassler | =back
|
||
=head1 AUTHORS
|
||||
=over 4
|
||||
=item Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
|
||||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | =item Johannes Graßler E<lt>info@computer-grassler.deE<gt>,
|
||
9077dc27 | Jan Büren | |||
b525a340 | Johannes Grassler | =back
|
||
9077dc27 | Jan Büren | |||
=cut
|