Projekt

Allgemein

Profil

Herunterladen (10,8 KB) Statistiken
| Zweig: | Markierung: | Revision:
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