From: G. Richardson Date: Tue, 28 Apr 2015 15:08:59 +0000 (+0200) Subject: Sammelcommit Bankerweiterung und Skonto X-Git-Tag: release-3.3.0beta~100^2~6 X-Git-Url: http://wagnertech.de/git?a=commitdiff_plain;h=15f58ff3dfd79651a95535b53f864ea0e8cb6620;p=kivitendo-erp.git Sammelcommit Bankerweiterung und Skonto Überarbeitung der Bankerweiterung vom Stand Niclas, und Einführung von Bezahlung mit Skonto (alter payment Branch). Mehr Details siehe changelog. * changelog * Ungültige Bankkonten ausblenden * Punktesystem in Hash %points ausgelagert * format_amount beim Erstellen von Kreditorenbuchungen behoben * Debug-Modus in manchen Templates für zusätzliche Tabellenfelder, muß im Template angeschaltet werden: [% SET debug=1 %] Rechnung zuweisen: * Filterdatum bei Rechnungszuweisung repariert * bank_transactions vor reconciliation_starting_date ignorieren * Rechnungen mit offenem Betrag < 1 Cent ignorieren * Reihenfolge der Bankfelder in Tabelle angepasst Bankbuchungen * Sortierreihenfolge ist standardmäßig Neueste zuerst * aqbanking Binary in configuration hinterlegen acc_tran in acc_trans umbenannt in MetaSetup/ReconciliationLink Kontenabgleich - EB- und SB-Buchungen ignorieren Payment Helper mit Skontomodus und Skontoautomatik neuer DB Helper zum Bezahlen von Rechnungsobjekten: pay_invoice Drei Bezahlarten: * ohne Skonto * mit Skonto laut Zahlungsbedingungen * Differenz als Skonto Neue Helpermethoden rund um Rechnungen für Einkauf und Verkauf. Für das automatische Verbuchen von Skonto muß für jeden Steuertyp ein Skontoautomatikkonto für Verkauf oder Einkauf konfiguriert (bei Steuerschlüssel 0 und 1 beides). Skontomodi und pay_invoice für SEPA umgesetzt Beim Auswählen von Rechnungen für den SEPA-Lauf kann nun auch Skonto berücksichtigt werden. Berichte Bankbewegungen - Export repariert Bankkonten - Validierung beim Speichern temporär ausgeschaltet CsvImport bank transactions - show name of bank in preview Csv-Import Bank transaction - join remote names like for purpose, join remote_name and remote_name_1 into one field Punktesystem in Hash pflegen, und die Regeln, die matchen, protokollieren, wird aber noch nirgends angezeigt. Zahlungsauswahl bei el. Kontoauszug im Tooltip auch Skonto-Information anzeigen beim Auswählen Dropdown mit Bezahltyp anzeigen pay_invoice aus Helper nutzt transdate_to_kivitendo Bericht Bankbuchungen - Name des Bankkontos in eigener Spalte anzeigen Übersetzung für Kontenabgleich - Bank und Buchung vertauscht Kontenabgleich - bei Vorschlägen Rechnungsnummer verlinken Kontenabgleich - "nicht abgeglichen" als Default Vorschläge Kontenabgleich - Beschriftung und Template Default Bankerweiterung CSV Import - Standardeinstellungen Bankerweiterung - in Tooltip Skonto nur bei Bedarf anzeigen BankAccounts Upgrade - bestehende Konten auf obsolete false setzen displayable_name für BankAccount kann im Bankkonto select_tag als title_key statt label_sub verwendet werden. SEPA - Umstellung auf Bankkonten-Controller Bankerweiterung - BankAccount Dropdowns mit displayable_name statt label_subs Bankerweiterung - Übersetzung korrigiert bank_accounts mit obsolete NOT NULL und DEFAULT FALSE intialisieren SEPA payment - noch offene SEPA-Überweisungen mit berücksichtigen Beim SEPA-Einzug und der SEPA-Überweisung wurden beim offenen Betrag die noch nicht geschlossenen SEPA Aufträge mit berücksichtigt. Dies wird jetzt auch bei der Skonto-Erweiterung berücksichtigt. Dies ist v.A. dann wichtig, wenn man eine Rechnung in mehreren Schritten per SEPA-Überweisung bezahlen möchte, oder vielleicht von mehreren Bankkonten aus. Beim SEPA-Einzug kommt der Fall wohl eher nicht vor. Skontoerweiterung - Übersetzungen nachgepflegt Bankerweiterung - Zahlungsverkehr Menü überarbeitet CSV-Import der Bankbuchungen nach Menü "Zahlungsverkehr" verschoben Reihenfolge geändert, entspricht der üblichen Abarbeitungsfolge: Import -> Verbuchen -> Abgleich SEPA Export - kein "Differenz als Skonto" vorschlagen bei SEPA Export soll immer Geld fließen, wenn noch ein Betrag offen ist, der nicht bezahlt werden soll, muß dies außerhalb des SEPA-Exports verbucht werden. Nur die Optionen "ohne Skonto" ohne "mit Skonto nach ZB" machen Sinn. BankTransaction - diverse Änderungen * Punktesystem erweitert * Kontonummer und IBAN vergleichen * zutreffende Punkt-Regeln in Template im Debug-Modus anzeigen, als Mouseover Tooltip in der Punktespalte Kontenabgleich verbessert * bei schon verknüpften Belegen kleine Abweichungen (Rundungsungenauigkeiten im Subcent-Bereich) tolerieren * Sowohl auf IBAN als auf Kontonummer prüfen * Kontenabgleichsstartdatum bei Gesamtsumme verwenden Bankerweiterung: Bei Rechnung zuweisen Skontosumme anzeigen Kontoauszug verbuchen: add_invoices um Parameter skonto erweitert Wenn Zahlbetrag mit Skontobetrag übereinstimmt dann Dropdown mit "mit Skonto nach ZB" vorausgewählt übergeben. Kann an der Stelle im Template aber noch nicht prüfen, ob Zahlungseingangsdatum innerhalb Skontofrist liegt. Kontoauszug verbuchen - Payment Dropdown konditional nur anzeigen, wenn überhaupt Skonto in Frage kommt Es wird aber immer noch nicht auf Datum überprüft bank_transactions: itime hinzugefügt wird aber noch nirgends verwendet Kontenabgleich - Gesamtsaldobeschriftungen waren vertauscht BankTransaction-Controller: Paginaten beim CSV-/PDF-Export ausschalten Analog zu Commit 06837707 Kontenabgleich - reconciliate nach reconcile umbenannt Bankauszug verbuchen - leeren Konteninhaber nicht matchen BankTransaction - Idee für Negativpunkt für Überzahlung Kontoauszug verbuchen - korrekter Titel Kontoauszug verbuchen - offenen Betrag berücksichtigen und anzeigen und auf leere Regex-Ausdrücke prüfen Payment Helper - Fließkommadifferenzen berücksichtigen in pay_invoice für die Fälle "Differenz als Skonto" und "mit Skonto nach ZB". Moved BankTransaction matching from Controller to DB There is now a new function in SL::DB::BankTransaction called get_agreement_with_invoice that takes a Invoice or PurchaseInvoice object as an argument and calculates the agreement. Kontoauszug verbuchen - bei Zuweisung Zahlungsart berücksichtigen Übernimmt man einen Vorschlag, wird nun per AJAX geprüft, ob die Kombination aus $bt und $invoice mit Skonto verbucht wird oder nicht. Es wird ein Optionsliste für eine select als HTML-Blog zurückgeliefert und unter "Zugewiesene Rechnungen" eingefügt. Wenn der Zahlungsbetrag genau dem Rechnungsbetrag abzgl. Skonto entspricht, und die Zahlung innerhalb der Skontofrist erfolgt ist, wird "mit Skonto nach ZB" vorausgewählt". Ist die Skontofrist vorbei wird "ohne Skonto" vorausgewählt, dadurch bleibt der Skontobetrag offen, sofern man nicht manuell auf "mit Skonto nach ZB" umstellt. Gibt es für die Rechnung keine Skontooption so wird auch keine Dropdown angezeigt. Kontoauszug verbuchen - Übersetzung für "Add invoices" korrigiert BankTransaction - Offene Subcent-Rechnungen rausfiltern damit diese nicht mehr zum Zuweisen zur Verfügung stehen. Payment Helper - Export get_payment_select_options_for_bank_transaction EXPORT_OK angepasst und alle Methoden in SL::DB::Invoice und SL::DB::PurchaseInvoice importieren Doku von SL/DB/Helper/Payment.pm angepasst Kontauszug verbuchen - max agreement refactored Nicht mehr von einer hohen Zahl in einer Schleife herunterzählen, sondern den Wert der höchsten Matches per map und max raussuchen und direkt danach die Vorschläge greppen. Kontoauszug verbuchen - Refactoring von Rechnung zuweisen (html) * das HTML wird jetzt nicht mehr per javascript, sondern in SL/DB/Helper/Payment.pm mit Hilfe des Presenters (für die Tags) als HTML Blob zusammengebaut * der Rückgabewert von get_payment_select_options_for_bank_transaction ist jetzt ein Array, mit dem direkt das select_tag erstellt werden kann. * Die Daten werden jetzt an to_json als to_json( { 'html' => $html } ... übergeben, und im AJAX code als data.html ausgelesen. Reconciliation auf record umgestellt Reconciliation - ajax as POST bei Vorschlägen, weil url zu lang werden kann --- diff --git a/SL/AM.pm b/SL/AM.pm index 7306640b8..cbbfae03f 100644 --- a/SL/AM.pm +++ b/SL/AM.pm @@ -45,6 +45,7 @@ use SL::DBUtils; use SL::DB::AuthUser; use SL::DB::Default; use SL::DB::Employee; +use SL::DB::Chart; use SL::GenericTranslations; use strict; @@ -1286,7 +1287,11 @@ sub taxes { t.taxdescription, round(t.rate * 100, 2) AS rate, (SELECT accno FROM chart WHERE id = chart_id) AS taxnumber, - (SELECT description FROM chart WHERE id = chart_id) AS account_description + (SELECT description FROM chart WHERE id = chart_id) AS account_description, + (SELECT accno FROM chart WHERE id = skonto_sales_chart_id) AS skonto_chart_accno, + (SELECT description FROM chart WHERE id = skonto_sales_chart_id) AS skonto_chart_description, + (SELECT accno FROM chart WHERE id = skonto_purchase_chart_id) AS skonto_chart_purchase_accno, + (SELECT description FROM chart WHERE id = skonto_purchase_chart_id) AS skonto_chart_purchase_description FROM tax t ORDER BY taxkey, rate|; @@ -1328,6 +1333,17 @@ sub get_tax_accounts { push @{ $form->{ACCOUNTS} }, $ref; } + $form->{AR_PAID} = SL::DB::Manager::Chart->get_all(where => [ link => { like => '%AR_paid%' } ], sort_by => 'accno ASC'); + $form->{AP_PAID} = SL::DB::Manager::Chart->get_all(where => [ link => { like => '%AP_paid%' } ], sort_by => 'accno ASC'); + + $form->{skontochart_value_title_sub} = sub { + my $item = shift; + return [ + $item->{id}, + $item->{accno} .' '. $item->{description}, + ]; + }; + $sth->finish; $dbh->disconnect; @@ -1350,7 +1366,9 @@ sub get_tax { chart_id, chart_categories, (id IN (SELECT tax_id - FROM acc_trans)) AS tax_already_used + FROM acc_trans)) AS tax_already_used, + skonto_sales_chart_id, + skonto_purchase_chart_id FROM tax WHERE id = ? |; @@ -1414,15 +1432,17 @@ sub save_tax { $chart_categories .= 'E' if $form->{expense}; $chart_categories .= 'C' if $form->{costs}; - my @values = ($form->{taxkey}, $form->{taxdescription}, $form->{rate}, conv_i($form->{chart_id}), conv_i($form->{chart_id}), $chart_categories); + my @values = ($form->{taxkey}, $form->{taxdescription}, $form->{rate}, conv_i($form->{chart_id}), conv_i($form->{chart_id}), conv_i($form->{skonto_sales_chart_id}), conv_i($form->{skonto_purchase_chart_id}), $chart_categories); if ($form->{id} ne "") { $query = qq|UPDATE tax SET - taxkey = ?, - taxdescription = ?, - rate = ?, - chart_id = ?, - taxnumber = (SELECT accno FROM chart WHERE id= ? ), - chart_categories = ? + taxkey = ?, + taxdescription = ?, + rate = ?, + chart_id = ?, + taxnumber = (SELECT accno FROM chart WHERE id = ? ), + skonto_sales_chart_id = ?, + skonto_purchase_chart_id = ?, + chart_categories = ? WHERE id = ?|; } else { @@ -1434,10 +1454,12 @@ sub save_tax { rate, chart_id, taxnumber, + skonto_sales_chart_id, + skonto_purchase_chart_id, chart_categories, id ) - VALUES (?, ?, ?, ?, (SELECT accno FROM chart WHERE id = ?), ?, ?)|; + VALUES (?, ?, ?, ?, (SELECT accno FROM chart WHERE id = ?), ?, ?, ?, ?)|; } push(@values, $form->{id}); do_query($form, $dbh, $query, @values); diff --git a/SL/Controller/BankAccount.pm b/SL/Controller/BankAccount.pm index 15d5db308..dff431282 100644 --- a/SL/Controller/BankAccount.pm +++ b/SL/Controller/BankAccount.pm @@ -1,4 +1,4 @@ - SL::Controller::BankAccount; +package SL::Controller::BankAccount; use strict; diff --git a/SL/Controller/BankTransaction.pm b/SL/Controller/BankTransaction.pm index afa06e9d4..918d6aac1 100644 --- a/SL/Controller/BankTransaction.pm +++ b/SL/Controller/BankTransaction.pm @@ -23,6 +23,8 @@ use SL::DB::AccTransaction; use SL::DB::Tax; use SL::DB::Draft; use SL::DB::BankAccount; +use SL::Presenter; +use List::Util qw(max); use Rose::Object::MakeMethods::Generic ( @@ -39,22 +41,19 @@ __PACKAGE__->run_before('check_auth'); sub action_search { my ($self) = @_; - my $bank_accounts = SL::DB::Manager::BankAccount->get_all(); + my $bank_accounts = SL::DB::Manager::BankAccount->get_all_sorted( query => [ obsolete => 0 ] ); $self->render('bank_transactions/search', - label_sub => sub { t8('#1 - Account number #2, bank code #3, #4', $_[0]->name, $_[0]->account_number, $_[0]->bank_code, $_[0]->bank, )}, BANK_ACCOUNTS => $bank_accounts); } sub action_list_all { my ($self) = @_; - my $transactions = $self->models->get; - $self->make_filter_summary; $self->prepare_report; - $self->report_generator_list_objects(report => $self->{report}, objects => $transactions); + $self->report_generator_list_objects(report => $self->{report}, objects => $self->models->get); } sub action_list { @@ -70,13 +69,19 @@ sub action_list { $sort_by = 'transdate' if $sort_by eq 'proposal'; $sort_by .= $::form->{sort_dir} ? ' DESC' : ' ASC'; - my $fromdate = $::locale->parse_date_to_object(\%::myconfig, $::form->{filter}->{fromdate}); - my $todate = $::locale->parse_date_to_object(\%::myconfig, $::form->{filter}->{todate}); + my $fromdate = $::locale->parse_date_to_object($::form->{filter}->{fromdate}); + my $todate = $::locale->parse_date_to_object($::form->{filter}->{todate}); $todate->add( days => 1 ) if $todate; my @where = (); push @where, (transdate => { ge => $fromdate }) if ($fromdate); push @where, (transdate => { lt => $todate }) if ($todate); + my $bank_account = SL::DB::Manager::BankAccount->find_by( id => $::form->{filter}{bank_account} ); + # bank_transactions no younger than starting date, + # but OPEN invoices to be matched may be from before + if ( $bank_account->reconciliation_starting_date ) { + push @where, (transdate => { gt => $bank_account->reconciliation_starting_date }); + }; my $bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => [ amount => {ne => \'invoice_amount'}, local_bank_account_id => $::form->{filter}{bank_account}, @@ -88,160 +93,73 @@ sub action_list { my $all_open_ap_invoices = SL::DB::Manager::PurchaseInvoice->get_all(where => [amount => { gt => \'paid' }], with_objects => 'vendor'); my @all_open_invoices; - push @all_open_invoices, @{ $all_open_ar_invoices }; - push @all_open_invoices, @{ $all_open_ap_invoices }; + # filter out invoices with less than 1 cent outstanding + push @all_open_invoices, grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ar_invoices }; + push @all_open_invoices, grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ap_invoices }; + + # try to match each bank_transaction with each of the possible open invoices + # by awarding points foreach my $bt (@{ $bank_transactions }) { next unless $bt->{remote_name}; # bank has no name, usually fees, use create invoice to assign - foreach my $open_invoice (@all_open_invoices){ - $open_invoice->{agreement} = 0; - - #compare banking arrangements - my ($bank_code, $account_number); - $bank_code = $open_invoice->customer->bank_code if $open_invoice->is_sales; - $account_number = $open_invoice->customer->account_number if $open_invoice->is_sales; - $bank_code = $open_invoice->vendor->bank_code if ! $open_invoice->is_sales; - $account_number = $open_invoice->vendor->account_number if ! $open_invoice->is_sales; - ($bank_code eq $bt->remote_bank_code - && $account_number eq $bt->remote_account_number) ? ($open_invoice->{agreement} += 2) : (); - - my $datediff = $bt->transdate->{utc_rd_days} - $open_invoice->transdate->{utc_rd_days}; - $open_invoice->{datediff} = $datediff; - - #compare amount -# (abs($open_invoice->amount) == abs($bt->amount)) ? ($open_invoice->{agreement} += 2) : (); -# do we need double abs here? - (abs(abs($open_invoice->amount) - abs($bt->amount)) < 0.01) ? ($open_invoice->{agreement} += 4) : (); - - #search invoice number in purpose - my $invnumber = $open_invoice->invnumber; -# possible improvement: match has to have more than 1 character? - $bt->purpose =~ /\b$invnumber\b/i ? ($open_invoice->{agreement} += 2) : (); - - #check sign - if ( $open_invoice->is_sales && $bt->amount < 0 ) { - $open_invoice->{agreement} -= 1; - }; - if ( ! $open_invoice->is_sales && $bt->amount > 0 ) { - $open_invoice->{agreement} -= 1; - }; - #search customer/vendor number in purpose - my $cvnumber; - $cvnumber = $open_invoice->customer->customernumber if $open_invoice->is_sales; - $cvnumber = $open_invoice->vendor->vendornumber if ! $open_invoice->is_sales; - $bt->purpose =~ /\b$cvnumber\b/i ? ($open_invoice->{agreement}++) : (); - - #compare customer/vendor name and account holder - my $cvname; - $cvname = $open_invoice->customer->name if $open_invoice->is_sales; - $cvname = $open_invoice->vendor->name if ! $open_invoice->is_sales; - $bt->remote_name =~ /\b$cvname\b/i ? ($open_invoice->{agreement}++) : (); - - #Compare transdate of bank_transaction with transdate of invoice - #Check if words in remote_name appear in cvname - $open_invoice->{agreement} += &check_string($bt->remote_name,$cvname); - - $open_invoice->{agreement} -= 1 if $datediff < -5; # dies hebelt eventuell Vorkasse aus - $open_invoice->{agreement} += 1 if $datediff < 30; # dies hebelt eventuell Vorkasse aus - - # only if we already have a good agreement, let date further change value of agreement. - # this is so that if there are several open invoices which are all equal (rent jan, rent feb...) the one with the best date match is chose over the others - # another way around this is to just pre-filter by periods instead of matching everything - if ( $open_invoice->{agreement} > 5 ) { - if ( $datediff == 0 ) { - $open_invoice->{agreement} += 3; - } elsif ( $datediff > 0 and $datediff <= 14 ) { - $open_invoice->{agreement} += 2; - } elsif ( $datediff >14 and $datediff < 35) { - $open_invoice->{agreement} += 1; - } elsif ( $datediff >34 and $datediff < 120) { - $open_invoice->{agreement} += 1; - } elsif ( $datediff < 0 ) { - $open_invoice->{agreement} -= 1; - } else { - # e.g. datediff > 120 - }; - }; + $bt->{remote_name} .= $bt->{remote_name_1} if $bt->{remote_name_1}; + + # try to match the current $bt to each of the open_invoices, saving the + # results of get_agreement_with_invoice in $open_invoice->{agreement} and + # $open_invoice->{rule_matches}. + + # The values are overwritten each time a new bt is checked, so at the end + # of each bt the likely results are filtered and those values are stored in + # the arrays $bt->{proposals} and $bt->{rule_matches}, and the agreement + # score is stored in $bt->{agreement} - #if ($open_invoice->transdate->{utc_rd_days} == $bt->transdate->{utc_rd_days}) { - #$open_invoice->{agreement} += 4; - #print FH "found matching date for invoice " . $open_invoice->invnumber . " ( " . $bt->transdate->{utc_rd_days} . " . \n"; - #} elsif (($open_invoice->transdate->{utc_rd_days} + 30) < $bt->transdate->{utc_rd_days}) { - #$open_invoice->{agreement} -= 1; - #} else { - #$open_invoice->{agreement} -= 2; - #print FH "found nomatch date -2 for invoice " . $open_invoice->invnumber . " ( " . $bt->transdate->{utc_rd_days} . " . \n"; - #}; - #print FH "agreement after date_agreement: " . $open_invoice->{agreement} . "\n"; + foreach my $open_invoice (@all_open_invoices){ + ($open_invoice->{agreement}, $open_invoice->{rule_matches}) = $bt->get_agreement_with_invoice($open_invoice); + }; + $bt->{proposals} = []; + my $agreement = 15; + my $min_agreement = 3; # suggestions must have at least this score - } -# finished going through all open_invoices + my $max_agreement = max map { $_->{agreement} } @all_open_invoices; - # go through each bt - # for each open_invoice try to match it to each open_invoice and store agreement in $open_invoice->{agreement} (which gets overwritten each time for each bt) - # calculate -# + # add open_invoices with highest agreement into array $bt->{proposals} + if ( $max_agreement >= $min_agreement ) { + $bt->{proposals} = [ grep { $_->{agreement} == $max_agreement } @all_open_invoices ]; + $bt->{agreement} = $max_agreement; #scalar @{ $bt->{proposals} } ? $agreement + 1 : ''; - $bt->{proposals} = []; - my $agreement = 11; - # wird nie ausgeführt, bzw. nur ganz am Ende -# oder einmal am Anfang? -# es werden maximal 7 vorschläge gemacht? - # 7 mal wird geprüft, ob etwas passt - while (scalar @{ $bt->{proposals} } < 1 && $agreement-- > 0) { - $bt->{proposals} = [ grep { $_->{agreement} > $agreement } @all_open_invoices ]; - #Kann wahrscheinlich weg: -# map { $_->{style} = "green" } @{ $bt->{proposals} } if $agreement >= 5; -# map { $_->{style} = "orange" } @{ $bt->{proposals} } if $agreement < 5 and $agreement >= 3; -# map { $_->{style} = "red" } @{ $bt->{proposals} } if $agreement < 3; - $bt->{agreement} = $agreement; # agreement value at cutoff, will correspond to several results if threshold is 7 and several are already above 7 - } + # store the rule_matches in a separate array, so they can be displayed in template + foreach ( @{ $bt->{proposals} } ) { + push(@{$bt->{rule_matches}}, $_->{rule_matches}); + }; + }; } # finished one bt # finished all bt # separate filter for proposals (second tab, agreement >= 5 and exactly one match) # to qualify as a proposal there has to be - # * agreement >= 5 - # * there must be only one exact match + # * agreement >= 5 TODO: make threshold configurable in configuration + # * there must be only one exact match # * depending on whether sales or purchase the amount has to have the correct sign (so Gutschriften don't work?) - - my @proposals = grep { $_->{agreement} >= 5 + my $proposal_threshold = 5; + my @proposals = grep { $_->{agreement} >= $proposal_threshold and 1 == scalar @{ $_->{proposals} } and (@{ $_->{proposals} }[0]->is_sales ? abs(@{ $_->{proposals} }[0]->amount - $_->amount) < 0.01 : abs(@{ $_->{proposals} }[0]->amount + $_->amount) < 0.01) } @{ $bank_transactions }; - #Sort bank transactions by quality of proposal + # sort bank transaction proposals by quality (score) of proposal $bank_transactions = [ sort { $a->{agreement} <=> $b->{agreement} } @{ $bank_transactions } ] if $::form->{sort_by} eq 'proposal' and $::form->{sort_dir} == 1; $bank_transactions = [ sort { $b->{agreement} <=> $a->{agreement} } @{ $bank_transactions } ] if $::form->{sort_by} eq 'proposal' and $::form->{sort_dir} == 0; $self->render('bank_transactions/list', - title => t8('List of bank transactions'), + title => t8('Bank transactions MT940'), BANK_TRANSACTIONS => $bank_transactions, PROPOSALS => \@proposals, - bank_account => SL::DB::Manager::BankAccount->find_by(id => $::form->{filter}{bank_account}) ); + bank_account => $bank_account ); } -sub check_string { - my $bankstring = shift; - my $namestring = shift; - return 0 unless $bankstring and $namestring; - - my @bankwords = grep(/^\w+$/, split(/\b/,$bankstring)); - - my $match = 0; - foreach my $bankword ( @bankwords ) { - # only try to match strings with more than 2 characters - next unless length($bankword)>2; - if ( $namestring =~ /\b$bankword\b/i ) { - $match++; - }; - }; - return $match; -}; - sub action_assign_invoice { my ($self) = @_; @@ -289,6 +207,33 @@ sub action_create_invoice { ); } +sub action_ajax_payment_suggestion { + my ($self) = @_; + + # based on a BankTransaction ID and a Invoice or PurchaseInvoice ID passed via $::form, + # create an HTML blob to be used by the js function add_invoices in templates/webpages/bank_transactions/list.html + # and return encoded as JSON + + my $bt = SL::DB::Manager::BankTransaction->find_by( id => $::form->{bt_id} ); + my $invoice = SL::DB::Manager::Invoice->find_by( id => $::form->{prop_id} ); + $invoice = SL::DB::Manager::PurchaseInvoice->find_By( id => $::form->{prop_id} ) unless $invoice; + + die unless $bt and $invoice; + + my @select_options = $invoice->get_payment_select_options_for_bank_transaction($::form->{bt_id}); + + my $html; + $html .= SL::Presenter->input_tag('invoice_ids.' . $::form->{bt_id} . '[]', $::form->{prop_id} , type => 'hidden'); + $html .= SL::Presenter->escape( $invoice->invnumber ); + $html .= SL::Presenter->select_tag('invoice_skontos.' . $::form->{bt_id} . '[]', \@select_options, + value_key => 'payment_type', + title_key => 'display' ) if @select_options; + $html .= 'x'; + $html = SL::Presenter->html_tag('div', $html, id => $::form->{bt_id} . '.' . $::form->{prop_id}); + + $self->render(\ SL::JSON::to_json( { 'html' => $html } ), { layout => 0, type => 'json', process => 0 }); +}; + sub action_filter_drafts { my ($self) = @_; @@ -352,27 +297,30 @@ sub action_ajax_add_list { } if ($::form->{transdatefrom}) { - my $fromdate = $::locale->parse_date_to_object(\%::myconfig, $::form->{transdatefrom}); - push @where_sale, ('transdate' => { ge => $fromdate}); - push @where_purchase, ('transdate' => { ge => $fromdate}); + my $fromdate = $::locale->parse_date_to_object($::form->{transdatefrom}); + if ( ref($fromdate) eq 'DateTime' ) { + push @where_sale, ('transdate' => { ge => $fromdate}); + push @where_purchase, ('transdate' => { ge => $fromdate}); + }; } if ($::form->{transdateto}) { - my $todate = $::locale->parse_date_to_object(\%::myconfig, $::form->{transdateto}); - $todate->add(days => 1); - push @where_sale, ('transdate' => { lt => $todate}); - push @where_purchase, ('transdate' => { lt => $todate}); + my $todate = $::locale->parse_date_to_object($::form->{transdateto}); + if ( ref($todate) eq 'DateTime' ) { + $todate->add(days => 1); + push @where_sale, ('transdate' => { lt => $todate}); + push @where_purchase, ('transdate' => { lt => $todate}); + }; } my $all_open_ar_invoices = SL::DB::Manager::Invoice->get_all(where => \@where_sale, with_objects => 'customer'); my $all_open_ap_invoices = SL::DB::Manager::PurchaseInvoice->get_all(where => \@where_purchase, with_objects => 'vendor'); my @all_open_invoices; - push @all_open_invoices, @{ $all_open_ar_invoices }; - push @all_open_invoices, @{ $all_open_ap_invoices }; + # filter out subcent differences from ap invoices + push @all_open_invoices, grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ap_invoices }; @all_open_invoices = sort { $a->id <=> $b->id } @all_open_invoices; - #my $all_open_invoices = SL::DB::Manager::Invoice->get_all(where => \@where); my $output = $self->render( 'bank_transactions/add_list', @@ -404,7 +352,8 @@ sub action_ajax_accept_invoices { sub action_save_invoices { my ($self) = @_; - my $invoice_hash = delete $::form->{invoice_ids}; + my $invoice_hash = delete $::form->{invoice_ids}; # each key (the bt line with a bt_id) contains an array of invoice_ids + my $skonto_hash = delete $::form->{invoice_skontos} || {}; # array containing the payment type, could be empty while ( my ($bt_id, $invoice_ids) = each(%$invoice_hash) ) { my $bank_transaction = SL::DB::Manager::BankTransaction->find_by(id => $bt_id); @@ -423,8 +372,14 @@ sub action_save_invoices { return 1; } @invoices if $bank_transaction->amount < 0; foreach my $invoice (@invoices) { + my $payment_type; + if ( @$skonto_hash{"$bt_id"} ) { + $payment_type = shift( @$skonto_hash{"$bt_id"} ); + } else { + $payment_type = 'without_skonto'; + }; if ($amount_of_transaction == 0) { - flash('warning', $::locale->text('There are invoices which could not be payed by bank transaction #1 (Account number: #2, bank code: #3)!', + flash('warning', $::locale->text('There are invoices which could not be paid by bank transaction #1 (Account number: #2, bank code: #3)!', $bank_transaction->purpose, $bank_transaction->remote_account_number, $bank_transaction->remote_bank_code)); @@ -432,7 +387,11 @@ sub action_save_invoices { } #pay invoice or go to the next bank transaction if the amount is not sufficiently high if ($invoice->amount <= $amount_of_transaction) { - $invoice->pay_invoice(chart_id => $bank_transaction->local_bank_account->chart_id, trans_id => $invoice->id, amount => $invoice->amount, transdate => $bank_transaction->transdate); + $invoice->pay_invoice(chart_id => $bank_transaction->local_bank_account->chart_id, + trans_id => $invoice->id, + amount => $invoice->amount, + payment_type => $payment_type, + transdate => $bank_transaction->transdate->to_kivitendo); if ($invoice->is_sales) { $amount_of_transaction -= $sign * $invoice->amount; $bank_transaction->invoice_amount($bank_transaction->invoice_amount + $invoice->amount); @@ -441,7 +400,11 @@ sub action_save_invoices { $bank_transaction->invoice_amount($bank_transaction->invoice_amount - $invoice->amount); } } else { - $invoice->pay_invoice(chart_id => $bank_transaction->local_bank_account->chart_id, trans_id => $invoice->id, amount => $amount_of_transaction, transdate => $bank_transaction->transdate); + $invoice->pay_invoice(chart_id => $bank_transaction->local_bank_account->chart_id, + trans_id => $invoice->id, + amount => $amount_of_transaction, + payment_type => $payment_type, + transdate => $bank_transaction->transdate->to_kivitendo); $bank_transaction->invoice_amount($bank_transaction->amount) if $invoice->is_sales; $bank_transaction->invoice_amount($bank_transaction->amount) if !$invoice->is_sales; $amount_of_transaction = 0; @@ -480,7 +443,7 @@ sub action_save_proposals { $arap->pay_invoice(chart_id => $bt->local_bank_account->chart_id, trans_id => $arap->id, amount => $arap->amount, - transdate => $bt->transdate); + transdate => $bt->transdate->to_kivitendo); $arap->save; #create record link @@ -520,12 +483,12 @@ sub make_filter_summary { my @filter_strings; my @filters = ( - [ $filter->{"transdate:date::ge"}, $::locale->text('Transdate') . " " . $::locale->text('From Date') ], - [ $filter->{"transdate:date::le"}, $::locale->text('Transdate') . " " . $::locale->text('To Date') ], + [ $filter->{"transdate:date::ge"}, $::locale->text('Transdate') . " " . $::locale->text('From Date') ], + [ $filter->{"transdate:date::le"}, $::locale->text('Transdate') . " " . $::locale->text('To Date') ], [ $filter->{"valutadate:date::ge"}, $::locale->text('Valutadate') . " " . $::locale->text('From Date') ], [ $filter->{"valutadate:date::le"}, $::locale->text('Valutadate') . " " . $::locale->text('To Date') ], - [ $filter->{"amount:number"}, $::locale->text('Amount') ], - [ $filter->{"bank_account_id:integer"}, $::locale->text('Local bank account') ], + [ $filter->{"amount:number"}, $::locale->text('Amount') ], + [ $filter->{"bank_account_id:integer"}, $::locale->text('Local bank account') ], ); for (@filters) { @@ -543,8 +506,8 @@ sub prepare_report { my $report = SL::ReportGenerator->new(\%::myconfig, $::form); $self->{report} = $report; - my @columns = qw(transdate valudate remote_name remote_account_number remote_bank_code amount invoice_amount invoices currency purpose local_account_number local_bank_code id); - my @sortable = qw(transdate valudate remote_name remote_account_number remote_bank_code amount purpose local_account_number local_bank_code); + my @columns = qw(local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount invoice_amount invoices currency purpose local_account_number local_bank_code id); + my @sortable = qw(local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount purpose local_account_number local_bank_code); my %column_defs = ( transdate => { sub => sub { $_[0]->transdate_as_date } }, @@ -561,6 +524,7 @@ sub prepare_report { purpose => { }, local_account_number => { sub => sub { $_[0]->local_bank_account->account_number } }, local_bank_code => { sub => sub { $_[0]->local_bank_account->bank_code } }, + local_bank_name => { sub => sub { $_[0]->local_bank_account->name } }, id => {}, ); @@ -577,16 +541,15 @@ sub prepare_report { ); $report->set_columns(%column_defs); $report->set_column_order(@columns); - $report->set_export_options(qw(list filter)); + $report->set_export_options(qw(list_all filter)); $report->set_options_from_form; - $self->models->disable_pagination if $report->{options}{output_format} =~ /^(pdf|csv)$/i; + $self->models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i; $self->models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable); - my $bank_accounts = SL::DB::Manager::BankAccount->get_all(); - my $label_sub = sub { t8('#1 - Account number #2, bank code #3, #4', $_[0]->name, $_[0]->account_number, $_[0]->bank_code, $_[0]->bank )}; + my $bank_accounts = SL::DB::Manager::BankAccount->get_all_sorted(); $report->set_options( - raw_top_info_text => $self->render('bank_transactions/report_top', { output => 0 }, BANK_ACCOUNTS => $bank_accounts, label_sub => $label_sub), + raw_top_info_text => $self->render('bank_transactions/report_top', { output => 0 }, BANK_ACCOUNTS => $bank_accounts), raw_bottom_info_text => $self->render('bank_transactions/report_bottom', { output => 0 }), ); } @@ -599,7 +562,7 @@ sub init_models { sorted => { _default => { by => 'transdate', - dir => 1, + dir => 0, # 1 = ASC, 0 = DESC : default sort is newest at top }, transdate => t8('Transdate'), remote_name => t8('Remote name'), @@ -613,6 +576,7 @@ sub init_models { purpose => t8('Purpose'), local_account_number => t8('Local account number'), local_bank_code => t8('Local bank code'), + local_bank_name => t8('Bank account'), }, with_objects => [ 'local_bank_account', 'currency' ], ); diff --git a/SL/Controller/CsvImport.pm b/SL/Controller/CsvImport.pm index 3f65d0ef2..2bc295598 100644 --- a/SL/Controller/CsvImport.pm +++ b/SL/Controller/CsvImport.pm @@ -224,7 +224,7 @@ sub check_auth { sub check_type { my ($self) = @_; - die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors addresses contacts projects orders); + die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors addresses contacts projects orders bank_transactions mt940); $self->type($::form->{profile}->{type}); } @@ -292,13 +292,13 @@ sub test_and_import_deferred { $self->profile_from_form; - if ( $::form->{file} && $::form->{FILENAME} =~ /\.940$/ ) { my $mt940_file = SL::SessionFile->new($::form->{FILENAME}, mode => '>'); $mt940_file->fh->print($::form->{file}); $mt940_file->fh->close; - my $aqbin = '/usr/bin/aqbanking-cli'; + my $aqbin = $::lx_office_conf{applications}->{aqbanking}; + die "Can't find aqbanking-cli, please check your configuration file.\n" unless -f $aqbin; my $cmd = "$aqbin --cfgdir=\"users\" import --importer=\"swift\" --profile=\"SWIFT-MT940\" -f " . $mt940_file->file_name . " | $aqbin --cfgdir=\"users\" listtrans --exporter=\"csv\" --profile=\"AqMoney2\" "; my $converted_mt940; open(MT, "$cmd |"); diff --git a/SL/Controller/CsvImport/BankTransaction.pm b/SL/Controller/CsvImport/BankTransaction.pm index ae496169c..76e9020af 100644 --- a/SL/Controller/CsvImport/BankTransaction.pm +++ b/SL/Controller/CsvImport/BankTransaction.pm @@ -12,7 +12,7 @@ use parent qw(SL::Controller::CsvImport::Base); use Rose::Object::MakeMethods::Generic ( - 'scalar --get_set_init' => [ qw(table bank_accounts_by) ], + 'scalar --get_set_init' => [ qw(bank_accounts_by) ], ); sub init_class { @@ -23,13 +23,14 @@ sub init_class { sub init_bank_accounts_by { my ($self) = @_; - return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $self->all_bank_accounts } } ) } qw(id account_number) }; + return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $self->all_bank_accounts } } ) } qw(id account_number iban) }; } sub check_objects { my ($self) = @_; $self->controller->track_progress(phase => 'building data', progress => 0); + my $update_policy = $self->controller->profile->get('update_policy') || 'skip'; my $i; my $num_data = scalar @{ $self->controller->data }; @@ -38,14 +39,38 @@ sub check_objects { $self->check_bank_account($entry); $self->check_currency($entry, take_default => 1); - $self->join_purposes($entry); - #TODO: adde checks für die Variablen + $self->join_remote_names($entry); + $self->check_existing($entry) unless @{ $entry->{errors} }; } continue { $i++; } - $self->add_cvar_raw_data_columns; + $self->add_info_columns({ header => $::locale->text('Bank account'), method => 'local_bank_name' }); +} + +sub check_existing { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + # for each imported entry (line) we make a database call to find existing entries + # we don't use the init_by hash because we have to check several fields + # this means that we can't detect duplicates in the import file + + if ( $object->amount ) { + # check for same + # * purpose + # * transdate + # * remote_account_number (may be empty for records of our own bank) + # * amount + my $num; + if ( $num = SL::DB::Manager::BankTransaction->get_all_count(query =>[ remote_account_number => $object->remote_account_number, transdate => $object->transdate, purpose => $object->purpose, amount => $object->amount] ) ) { + push(@{$entry->{errors}}, $::locale->text('Skipping due to existing bank transaction in database')); + }; + } else { + push(@{$entry->{errors}}, $::locale->text('Skipping because transfer amount is empty.')); + }; } sub setup_displayable_columns { @@ -53,21 +78,20 @@ sub setup_displayable_columns { $self->SUPER::setup_displayable_columns; - $self->add_displayable_columns({ name => 'transaction_id', description => $::locale->text('Transaction ID') }, - { name => 'local_bank_code', description => $::locale->text('Own bank code') }, - { name => 'local_account_number', description => $::locale->text('Own bank account number') }, - { name => 'local_bank_account_id', description => $::locale->text('ID of own bank account') }, - { name => 'remote_bank_code', description => $::locale->text('Bank code of the goal/source') }, - { name => 'remote_account_number', description => $::locale->text('Account number of the goal/source') }, - { name => 'transdate', description => $::locale->text('Date of transaction') }, - { name => 'valutadate', description => $::locale->text('Valuta') }, - { name => 'amount', description => $::locale->text('Amount') }, - { name => 'currency', description => $::locale->text('Currency') }, - { name => 'currency_id', description => $::locale->text('Currency (database ID)') }, - { name => 'remote_name', description => $::locale->text('Name of the goal/source') }, - { name => 'remote_name_1', description => $::locale->text('Name of the goal/source') }, - { name => 'purpose', description => $::locale->text('Purpose') }, - ); + # TODO: don't show fields cleared, invoice_amount and transaction_id in the help text, as these should not be imported + $self->add_displayable_columns({ name => 'local_bank_code', description => $::locale->text('Own bank code') }, + { name => 'local_account_number', description => $::locale->text('Own bank account number or IBAN') }, + { name => 'local_bank_account_id', description => $::locale->text('ID of own bank account') }, + { name => 'remote_bank_code', description => $::locale->text('Bank code of the goal/source') }, + { name => 'remote_account_number', description => $::locale->text('Account number of the goal/source') }, + { name => 'transdate', description => $::locale->text('Date of transaction') }, + { name => 'valutadate', description => $::locale->text('Valuta date') }, + { name => 'amount', description => $::locale->text('Amount') }, + { name => 'currency', description => $::locale->text('Currency') }, + { name => 'currency_id', description => $::locale->text('Currency (database ID)') }, + { name => 'remote_name', description => $::locale->text('Name of the goal/source (if field names remote_name and remote_name_1 exist they will be combined into field "remote_name")') }, + { name => 'purpose', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') }, + ); } sub check_bank_account { @@ -75,7 +99,7 @@ sub check_bank_account { my $object = $entry->{object}; - # Check whether or not local_bank_account ID is valid. + # Check whether or not local_bank_account ID exists and is valid. if ($object->local_bank_account_id && !$self->bank_accounts_by->{id}->{ $object->local_bank_account_id }) { push @{ $entry->{errors} }, $::locale->text('Error: Invalid local bank account'); return 0; @@ -95,9 +119,13 @@ sub check_bank_account { } - # Map account information to ID if given. + # Map account information to ID via local_account_number if no local_bank_account_id was given + # local_account_number checks for match of account number or IBAN if (!$object->local_bank_account_id && $entry->{raw_data}->{local_account_number}) { my $bank_account = $self->bank_accounts_by->{account_number}->{ $entry->{raw_data}->{local_account_number} }; + if (!$bank_account) { + $bank_account = $self->bank_accounts_by->{iban}->{ $entry->{raw_data}->{local_account_number} }; + }; if (!$bank_account) { push @{ $entry->{errors} }, $::locale->text('Error: Invalid local bank account'); return 0; @@ -108,6 +136,7 @@ sub check_bank_account { } $object->local_bank_account_id($bank_account->id); + $entry->{info_data}->{local_bank_name} = $bank_account->name; } return $object->local_bank_account_id ? 1 : 0; @@ -119,18 +148,29 @@ sub join_purposes { my $object = $entry->{object}; my $purpose = join('', $entry->{raw_data}->{purpose}, - $entry->{raw_data}->{purpose1}, - $entry->{raw_data}->{purpose2}, - $entry->{raw_data}->{purpose3}, - $entry->{raw_data}->{purpose4}, - $entry->{raw_data}->{purpose5}, - $entry->{raw_data}->{purpose6}, - $entry->{raw_data}->{purpose7}, - $entry->{raw_data}->{purpose8}, - $entry->{raw_data}->{purpose9}, - $entry->{raw_data}->{purpose10}, - $entry->{raw_data}->{purpose11} ); + $entry->{raw_data}->{purpose1}, + $entry->{raw_data}->{purpose2}, + $entry->{raw_data}->{purpose3}, + $entry->{raw_data}->{purpose4}, + $entry->{raw_data}->{purpose5}, + $entry->{raw_data}->{purpose6}, + $entry->{raw_data}->{purpose7}, + $entry->{raw_data}->{purpose8}, + $entry->{raw_data}->{purpose9}, + $entry->{raw_data}->{purpose10}, + $entry->{raw_data}->{purpose11} ); $object->purpose($purpose); + +} + +sub join_remote_names { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + my $remote_name = join('', $entry->{raw_data}->{remote_name}, + $entry->{raw_data}->{remote_name_1} ); + $object->remote_name($remote_name); } 1; diff --git a/SL/Controller/CsvImport/Base.pm b/SL/Controller/CsvImport/Base.pm index b896d9930..3090182c1 100644 --- a/SL/Controller/CsvImport/Base.pm +++ b/SL/Controller/CsvImport/Base.pm @@ -146,7 +146,7 @@ sub init_all_languages { sub init_all_bank_accounts { my ($self) = @_; - return SL::DB::Manager::BankAccount->get_all; + return SL::DB::Manager::BankAccount->get_all_sorted( query => [ obsolete => 0 ] ); } sub init_payment_terms_by { diff --git a/SL/Controller/Reconciliation.pm b/SL/Controller/Reconciliation.pm index cd5707b20..c6faef15f 100644 --- a/SL/Controller/Reconciliation.pm +++ b/SL/Controller/Reconciliation.pm @@ -10,9 +10,10 @@ use SL::Controller::Helper::ParseFilter; use SL::Helper::Flash; use SL::DB::BankTransaction; -use SL::DB::BankAccount; +use SL::DB::Manager::BankAccount; use SL::DB::AccTransaction; use SL::DB::ReconciliationLink; +use List::Util qw(sum); use Rose::Object::MakeMethods::Generic ( 'scalar --get_set_init' => [ qw(cleared BANK_ACCOUNTS) ], @@ -28,12 +29,7 @@ __PACKAGE__->run_before('_bank_account'); sub action_search { my ($self) = @_; - $self->render('reconciliation/search', - label_sub => sub { t8('#1 - Account number #2, bank code #3, #4', - $_[0]->name, - $_[0]->bank, - $_[0]->account_number, - $_[0]->bank_code) }); + $self->render('reconciliation/search'); } sub action_reconciliation { @@ -44,12 +40,7 @@ sub action_reconciliation { $self->_get_balances; $self->render('reconciliation/form', - title => t8('Reconciliation'), - label_sub => sub { t8('#1 - Account number #2, bank code #3, #4', - $_[0]->name, - $_[0]->bank, - $_[0]->account_number, - $_[0]->bank_code) }); + title => t8('Reconciliation')); } sub action_load_overview { @@ -74,11 +65,11 @@ sub action_filter_overview { $self->_get_balances; my $output = $self->render('reconciliation/_linked_transactions', { output => 0 }); - my %result = ( html => $output, - absolut_bt_balance => $::form->format_amount(\%::myconfig, $self->{absolut_bt_balance}, 2), + my %result = ( html => $output, + absolut_bt_balance => $::form->format_amount(\%::myconfig, $self->{absolut_bt_balance}, 2), absolut_bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{absolut_bb_balance}, 2), - bt_balance => $::form->format_amount(\%::myconfig, $self->{bt_balance}, 2), - bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{bb_balance}, 2) + bt_balance => $::form->format_amount(\%::myconfig, $self->{bt_balance}, 2), + bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{bb_balance}, 2) ); $self->render(\to_json(\%result), { type => 'json', process => 0 }); @@ -100,20 +91,20 @@ sub action_update_reconciliation_table { $self->render(\to_json(\%result), { type => 'json', process => 0 }); } -sub action_reconciliate { +sub action_reconcile { my ($self) = @_; #Check elements my @errors = $self->_get_elements_and_validate; if (@errors) { - unshift(@errors, (t8('Could not reconciliate chosen elements!'))); + unshift(@errors, (t8('Could not reconcile chosen elements!'))); flash('error', @errors); $self->action_reconciliation; return; } - $self->_reconciliate; + $self->_reconcile; $self->action_reconciliation; } @@ -140,11 +131,11 @@ sub action_delete_reconciliation { $self->_get_balances; my $output = $self->render('reconciliation/_linked_transactions', { output => 0 }); - my %result = ( html => $output, - absolut_bt_balance => $::form->format_amount(\%::myconfig, $self->{absolut_bt_balance}, 2), - absolut_bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{absolut_bb_balance}, 2), - bt_balance => $::form->format_amount(\%::myconfig, $self->{bt_balance}, 2), - bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{bb_balance}, 2) + my %result = ( html => $output, + absolut_bt_balance => $::form->format_amount(\%::myconfig, $self ->{absolut_bt_balance}, 2), + absolut_bb_balance => $::form->format_amount(\%::myconfig, -1 * $self ->{absolut_bb_balance}, 2), + bt_balance => $::form->format_amount(\%::myconfig, $self ->{bt_balance}, 2), + bb_balance => $::form->format_amount(\%::myconfig, -1 * $self ->{bb_balance}, 2) ); $self->render(\to_json(\%result), { type => 'json', process => 0 }); @@ -168,17 +159,17 @@ sub action_filter_proposals { $self->_get_proposals; my $output = $self->render('reconciliation/proposals', { output => 0 }); - my %result = ( html => $output, - absolut_bt_balance => $::form->format_amount(\%::myconfig, $self->{absolut_bt_balance}, 2), - absolut_bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{absolut_bb_balance}, 2), - bt_balance => $::form->format_amount(\%::myconfig, $self->{bt_balance}, 2), - bb_balance => $::form->format_amount(\%::myconfig, -1 * $self->{bb_balance}, 2) + my %result = ( html => $output, + absolut_bt_balance => $::form->format_amount(\%::myconfig, $self ->{absolut_bt_balance}, 2), + absolut_bb_balance => $::form->format_amount(\%::myconfig, -1 * $self ->{absolut_bb_balance}, 2), + bt_balance => $::form->format_amount(\%::myconfig, $self ->{bt_balance}, 2), + bb_balance => $::form->format_amount(\%::myconfig, -1 * $self ->{bb_balance}, 2) ); $self->render(\to_json(\%result), { type => 'json', process => 0 }); } -sub action_reconciliate_proposals { +sub action_reconcile_proposals { my ($self) = @_; my $counter = 0; @@ -229,6 +220,15 @@ sub _bank_account { sub _get_proposals { my ($self) = @_; + # reconciliation suggestion is based on: + # * record_link exists (was paid by bank transaction) + # or acc_trans entry exists where + # * amount is exactly the same + # * date is the same + # * IBAN or account number have to match exactly (cv details, no spaces) + # * not a gl storno + # * there is exactly one match for all conditions + $self->_filter_to_where; my $bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => [ @{ $self->{bt_where} }, cleared => '0' ]); @@ -243,6 +243,7 @@ sub _get_proposals { $proposal->{BT} = $bt; $proposal->{BB} = []; + # first of all check if any of the bank_transactions are already linked (i.e. were paid via bank transactions) my $linked_records = SL::DB::Manager::RecordLink->get_all(where => [ from_table => 'bank_transactions', from_id => $bt->id ]); foreach my $linked_record (@{ $linked_records }) { my $invoice; @@ -268,18 +269,27 @@ sub _get_proposals { #add proposal if something in acc_trans was found #otherwise try to find another entry in acc_trans and add it - if (scalar @{ $proposal->{BB} } and !$check_sum) { + # for linked_records we allow a slight difference / imprecision, for acc_trans search we don't + if (scalar @{ $proposal->{BB} } and abs($check_sum) <= 0.01 ) { push @proposals, $proposal; } elsif (!scalar @{ $proposal->{BB} }) { + # use account_number and iban for matching remote account number + # don't suggest gl stornos (ar and ap stornos shouldn't have any payments) + + my @account_number_match = ( + ( 'ar.customer.iban' => $bt->remote_account_number ), + ( 'ar.customer.account_number' => $bt->remote_account_number ), + ( 'ap.vendor.iban' => $bt->remote_account_number ), + ( 'ap.vendor.account_number' => $bt->remote_account_number ), + ( 'gl.storno' => '0' ), + ); + my $acc_transactions = SL::DB::Manager::AccTransaction->get_all(where => [ @{ $self->{bb_where} }, amount => -1 * $bt->amount, cleared => '0', - or => [ - and => [ 'ar.customer.account_number' => $bt->remote_account_number, - 'ar.customer.bank_code' => $bt->remote_bank_code, ], - and => [ 'ap.vendor.account_number' => $bt->remote_account_number, - 'ap.vendor.bank_code' => $bt->remote_bank_code, ], - 'gl.storno' => '0' ]], + 'transdate' => $bt->transdate, + or => [ @account_number_match ] + ], with_objects => [ 'ar', 'ap', 'ar.customer', 'ap.vendor', 'gl' ]); if (scalar @{ $acc_transactions } == 1) { push @{ $proposal->{BB} }, @{ $acc_transactions }[0]; @@ -339,17 +349,17 @@ sub _get_elements_and_validate { return @errors; } -sub _reconciliate { +sub _reconcile { my ($self) = @_; - #1. Step: Set AccTrans and BankTransactions to 'cleared' + # 1. step: set AccTrans and BankTransactions to 'cleared' foreach my $element (@{ $self->{ELEMENTS} }) { $element->cleared('1'); $element->invoice_amount($element->amount) if $element->isa('SL::DB::BankTransaction'); $element->save; } - #2. Step: Insert entry in reconciliation_links + # 2. step: insert entry in reconciliation_links my $rec_group = SL::DB::Manager::ReconciliationLink->get_new_rec_group(); #There is either a 1:n relation or a n:1 relation if (scalar @{ $::form->{bt_ids} } == 1) { @@ -378,32 +388,41 @@ sub _filter_to_where { my %filter = @{ $parse_filter{query} }; my (@rl_where, @bt_where, @bb_where); - @rl_where = ('bank_transaction.local_bank_account_id' => $filter{local_bank_account_id}); + @rl_where = ('bank_transaction.local_bank_account_id' => $filter{local_bank_account_id}); @bt_where = (local_bank_account_id => $filter{local_bank_account_id}); @bb_where = (chart_id => $self->{bank_account}->chart_id); if ($filter{fromdate} and $filter{todate}) { - push @rl_where, (or => [ and => [ 'acc_tran.transdate' => $filter{fromdate}, - 'acc_tran.transdate' => $filter{todate} ], + push @rl_where, (or => [ and => [ 'acc_trans.transdate' => $filter{fromdate}, + 'acc_trans.transdate' => $filter{todate} ], and => [ 'bank_transaction.transdate' => $filter{fromdate}, - 'bank_transaction.transdate' => $filter{todate} ] ] ); + 'bank_transaction.transdate' => $filter{todate} ] ] ); - push @bt_where, (transdate => $filter{todate} ); - push @bt_where, (transdate => $filter{fromdate} ); - push @bb_where, (transdate => $filter{todate} ); - push @bb_where, (transdate => $filter{fromdate} ); + push @bt_where, (transdate => $filter{todate} ); + push @bt_where, (transdate => $filter{fromdate} ); + push @bb_where, (transdate => $filter{todate} ); + push @bb_where, (transdate => $filter{fromdate} ); + } + + if ( $self->{bank_account}->reconciliation_starting_date ) { + push @bt_where, (transdate => { gt => $self->{bank_account}->reconciliation_starting_date }); + push @bb_where, (transdate => { gt => $self->{bank_account}->reconciliation_starting_date }); } + # don't try to reconcile opening and closing balance transactions + push @bb_where, ('acc_trans.ob_transaction' => 0); + push @bb_where, ('acc_trans.cb_transaction' => 0); + if ($filter{fromdate} and not $filter{todate}) { - push @rl_where, (or => [ 'acc_tran.transdate' => $filter{fromdate}, + push @rl_where, (or => [ 'acc_trans.transdate' => $filter{fromdate}, 'bank_transaction.transdate' => $filter{fromdate} ] ); push @bt_where, (transdate => $filter{fromdate} ); push @bb_where, (transdate => $filter{fromdate} ); } if ($filter{todate} and not $filter{fromdate}) { - push @rl_where, ( or => [ 'acc_tran.transdate' => $filter{todate} , + push @rl_where, ( or => [ 'acc_trans.transdate' => $filter{todate} , 'bank_transaction.transdate' => $filter{todate} ] ); push @bt_where, (transdate => $filter{todate} ); push @bb_where, (transdate => $filter{todate} ); @@ -411,7 +430,7 @@ sub _filter_to_where { if ($filter{cleared}) { $filter{cleared} = $filter{cleared} eq 'FALSE' ? '0' : '1'; - push @rl_where, ('acc_tran.cleared' => $filter{cleared} ); + push @rl_where, ('acc_trans.cleared' => $filter{cleared} ); push @bt_where, (cleared => $filter{cleared} ); push @bb_where, (cleared => $filter{cleared} ); @@ -428,6 +447,9 @@ sub _get_linked_transactions { $self->_filter_to_where; my (@where, @bt_where, @bb_where); + # don't try to reconcile opening and closing balances + # instead use an offset in configuration + @where = (@{ $self->{rl_where} }); @bt_where = (@{ $self->{bt_where} }, cleared => '0'); @bb_where = (@{ $self->{bb_where} }, cleared => '0'); @@ -437,17 +459,17 @@ sub _get_linked_transactions { my $reconciliation_groups = SL::DB::Manager::ReconciliationLink->get_all(distinct => 1, select => ['rec_group'], where => \@where, - with_objects => ['bank_transaction', 'acc_tran']); + with_objects => ['bank_transaction', 'acc_trans']); - my $fromdate = $::locale->parse_date_to_object(\%::myconfig, $::form->{filter}->{fromdate_date__ge}); - my $todate = $::locale->parse_date_to_object(\%::myconfig, $::form->{filter}->{todate_date__le}); + my $fromdate = $::locale->parse_date_to_object($::form->{filter}->{fromdate_date__ge}); + my $todate = $::locale->parse_date_to_object($::form->{filter}->{todate_date__le}); foreach my $rec_group (@{ $reconciliation_groups }) { - my $linked_transactions = SL::DB::Manager::ReconciliationLink->get_all(where => [rec_group => $rec_group->rec_group], with_objects => ['bank_transaction', 'acc_tran']); + my $linked_transactions = SL::DB::Manager::ReconciliationLink->get_all(where => [rec_group => $rec_group->rec_group], with_objects => ['bank_transaction', 'acc_trans']); my $line; my $first_transaction = shift @{ $linked_transactions }; my $first_bt = $first_transaction->bank_transaction; - my $first_bb = $first_transaction->acc_tran; + my $first_bb = $first_transaction->acc_trans; if (defined $fromdate) { $first_bt->{class} = 'out_of_balance' if ( $first_bt->transdate lt $fromdate ); @@ -466,7 +488,7 @@ sub _get_linked_transactions { my ($previous_bt_id, $previous_acc_trans_id) = ($first_transaction->bank_transaction_id, $first_transaction->acc_trans_id); foreach my $linked_transaction (@{ $linked_transactions }) { my $bank_transaction = $linked_transaction->bank_transaction; - my $acc_transaction = $linked_transaction->acc_tran; + my $acc_transaction = $linked_transaction->acc_trans; if (defined $fromdate) { $bank_transaction->{class} = 'out_of_balance' if ( $bank_transaction->transdate lt $fromdate ); $acc_transaction->{class} = 'out_of_balance' if ( $acc_transaction->transdate lt $fromdate ); @@ -485,7 +507,7 @@ sub _get_linked_transactions { push @rows, $line; } - #add non-cleared bank transactions + # add non-cleared bank transactions my $bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => \@bt_where); foreach my $bt (@{ $bank_transactions }) { my $line; @@ -495,10 +517,10 @@ sub _get_linked_transactions { push @rows, $line; } - #add non-cleared bookings on bank + # add non-cleared bookings on bank my $bookings_on_bank = SL::DB::Manager::AccTransaction->get_all(where => \@bb_where); foreach my $bb (@{ $bookings_on_bank }) { - if ($::form->{filter}->{show_stornos} or !$bb->get_transaction->storno) { + if ($::form->{filter}->{show_stornos} or !$bb->record->storno) { my $line; $line->{BB} = [ $bb ]; $line->{type} = 'BB'; @@ -539,33 +561,46 @@ sub _get_balances { @bt_where = @{ $self->{bt_where} }; @bb_where = @{ $self->{bb_where} }; - my $bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => \@bt_where ); - my $payments = SL::DB::Manager::AccTransaction ->get_all(where => \@bb_where ); - - #for absolute balance get all bookings till todate - my $todate = $::locale->parse_date_to_object(\%::myconfig, $::form->{filter}->{todate_date__le}); - my @all_bt_where = (local_bank_account_id => $self->{bank_account}->id); my @all_bb_where = (chart_id => $self->{bank_account}->chart_id); + my ($bt_balance, $bb_balance) = (0,0); + my ($absolut_bt_balance, $absolut_bb_balance) = (0,0); + + if ( $self->{bank_account}->reconciliation_starting_date ) { + $bt_balance = $self->{bank_account}->reconciliation_starting_balance; + $bb_balance = $self->{bank_account}->reconciliation_starting_balance * -1; + $absolut_bt_balance = $self->{bank_account}->reconciliation_starting_balance; + $absolut_bb_balance = $self->{bank_account}->reconciliation_starting_balance * -1; + + push @all_bt_where, ( transdate => { gt => $self->{bank_account}->reconciliation_starting_date }); + push @all_bb_where, ( transdate => { gt => $self->{bank_account}->reconciliation_starting_date }); + } + + my $bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => \@bt_where ); + my $payments = SL::DB::Manager::AccTransaction ->get_all(where => \@bb_where ); + + # for absolute balance get all bookings until todate + my $todate = $::locale->parse_date_to_object($::form->{filter}->{todate_date__le}); + my $fromdate = $::locale->parse_date_to_object($::form->{filter}->{fromdate_date__le}); + if ($todate) { push @all_bt_where, (transdate => { le => $todate }); push @all_bb_where, (transdate => { le => $todate }); } my $all_bank_transactions = SL::DB::Manager::BankTransaction->get_all(where => \@all_bt_where); - my $all_payments = SL::DB::Manager::AccTransaction ->get_all(where => \@all_bb_where); + my $all_payments = SL::DB::Manager::AccTransaction ->get_all(where => \@all_bb_where); - my ($bt_balance, $bb_balance) = (0,0); - my ($absolut_bt_balance, $absolut_bb_balance) = (0,0); + $bt_balance += sum map { $_->amount } @{ $bank_transactions }; + $bb_balance += sum map { $_->amount if ($::form->{filter}->{show_stornos} or !$_->record->storno) } @{ $payments }; + + $absolut_bt_balance += sum map { $_->amount } @{ $all_bank_transactions }; + $absolut_bb_balance += sum map { $_->amount } @{ $all_payments }; - map { $bt_balance += $_->amount } @{ $bank_transactions }; - map { $bb_balance += $_->amount if ($::form->{filter}->{show_stornos} or !$_->get_transaction->storno) } @{ $payments }; - map { $absolut_bt_balance += $_->amount } @{ $all_bank_transactions }; - map { $absolut_bb_balance += $_->amount } @{ $all_payments }; - $self->{bt_balance} = $bt_balance || 0; - $self->{bb_balance} = $bb_balance || 0; + $self->{bt_balance} = $bt_balance || 0; + $self->{bb_balance} = $bb_balance || 0; $self->{absolut_bt_balance} = $absolut_bt_balance || 0; $self->{absolut_bb_balance} = $absolut_bb_balance || 0; @@ -579,7 +614,7 @@ sub init_cleared { } sub init_BANK_ACCOUNTS { - SL::DB::Manager::BankAccount->get_all(); + SL::DB::Manager::BankAccount->get_all_sorted( query => [ obsolete => 0 ] ); } 1; diff --git a/SL/DB/BankAccount.pm b/SL/DB/BankAccount.pm index 9ac755beb..7bbd89767 100644 --- a/SL/DB/BankAccount.pm +++ b/SL/DB/BankAccount.pm @@ -24,8 +24,8 @@ sub validate { # chart_id) my $chart_id = $self->chart_id; - my $chart = SL::DB::Chart->new( id => $chart_id ); - if ( $chart->load(speculative => 1) ) { + my $chart = SL::DB::Manager::Chart->find_by( id => $chart_id ); + if ( $chart ) { my $linked_bank = SL::DB::Manager::BankAccount->find_by( chart_id => $chart_id ); if ( $linked_bank ) { if ( not $self->{id} or ( $self->{id} && $linked_bank->id != $self->{id} )) { @@ -42,4 +42,10 @@ sub validate { return @errors; } +sub displayable_name { + my ($self) = @_; + + return join ' ', grep $_, $self->name, $self->bank, $self->iban; +} + 1; diff --git a/SL/DB/BankTransaction.pm b/SL/DB/BankTransaction.pm index cc6a1ee0a..d5d7c5526 100644 --- a/SL/DB/BankTransaction.pm +++ b/SL/DB/BankTransaction.pm @@ -9,12 +9,11 @@ use SL::DB::MetaSetup::BankTransaction; use SL::DB::Manager::BankTransaction; use SL::DB::Helper::LinkedRecords; -__PACKAGE__->meta->initialize; +require SL::DB::Invoice; +require SL::DB::PurchaseInvoice; -use SL::DB::Invoice; -use SL::DB::PurchaseInvoice; +__PACKAGE__->meta->initialize; -use Data::Dumper; # Creates get_all, get_all_count, get_all_iterator, delete_all and update_all. #__PACKAGE__->meta->make_manager_class; @@ -44,10 +43,230 @@ sub linked_invoices { push @linked_invoices, SL::DB::Manager::PurchaseInvoice->find_by(id => $record_link->to_id)->invnumber if $record_link->to_table eq 'ap'; } -# $main::lxdebug->message(0, "linked invoices sind: " . Dumper(@linked_invoices)); -# $main::lxdebug->message(0, "record_links sind: " . Dumper($record_links)); - return [ @linked_invoices ]; } +sub get_agreement_with_invoice { + my ($self, $invoice) = @_; + + die "first argument is not an invoice object" + unless ref($invoice) eq 'SL::DB::Invoice' or ref($invoice) eq 'SL::DB::PurchaseInvoice'; + + my %points = ( + cust_vend_name_in_purpose => 1, + cust_vend_number_in_purpose => 1, + datebonus0 => 3, + datebonus14 => 2, + datebonus35 => 1, + datebonus120 => 0, + datebonus_negative => -1, + depositor_matches => 2, + exact_amount => 4, + exact_open_amount => 4, + invnumber_in_purpose => 2, + # overpayment => -1, # either other invoice is more likely, or several invoices paid at once + payment_before_invoice => -2, + payment_within_30_days => 1, + remote_account_number => 3, + skonto_exact_amount => 5, + wrong_sign => -1, + ); + + my ($agreement,$rule_matches); + + # compare banking arrangements + my ($iban, $bank_code, $account_number); + $bank_code = $invoice->customer->bank_code if $invoice->is_sales; + $account_number = $invoice->customer->account_number if $invoice->is_sales; + $iban = $invoice->customer->iban if $invoice->is_sales; + $bank_code = $invoice->vendor->bank_code if ! $invoice->is_sales; + $iban = $invoice->vendor->iban if ! $invoice->is_sales; + $account_number = $invoice->vendor->account_number if ! $invoice->is_sales; + if ( $bank_code eq $self->remote_bank_code && $account_number eq $self->remote_account_number ) { + $agreement += $points{remote_account_number}; + $rule_matches .= 'remote_account_number(' . $points{'remote_account_number'} . ') '; + }; + if ( $iban eq $self->remote_account_number ) { + $agreement += $points{remote_account_number}; + $rule_matches .= 'remote_account_number(' . $points{'remote_account_number'} . ') '; + }; + + my $datediff = $self->transdate->{utc_rd_days} - $invoice->transdate->{utc_rd_days}; + $invoice->{datediff} = $datediff; + + # compare amount + if (abs(abs($invoice->amount) - abs($self->amount)) < 0.01) { + $agreement += $points{exact_amount}; + $rule_matches .= 'exact_amount(' . $points{'exact_amount'} . ') '; + }; + + # compare open amount, preventing double points when open amount = invoice amount + if ( $invoice->amount != $invoice->open_amount && abs(abs($invoice->open_amount) - abs($self->amount)) < 0.01) { + $agreement += $points{exact_open_amount}; + $rule_matches .= 'exact_open_amount(' . $points{'exact_open_amount'} . ') '; + }; + + if ( $invoice->skonto_date && abs(abs($invoice->amount_less_skonto) - abs($self->amount)) < 0.01) { + $agreement += $points{skonto_exact_amount}; + $rule_matches .= 'skonto_exact_amount(' . $points{'skonto_exact_amount'} . ') '; + }; + + #search invoice number in purpose + my $invnumber = $invoice->invnumber; + # invnumbernhas to have at least 3 characters + if ( length($invnumber) > 2 && $self->purpose =~ /\b$invnumber\b/i ) { + $agreement += $points{invnumber_in_purpose}; + $rule_matches .= 'invnumber_in_purpose(' . $points{'invnumber_in_purpose'} . ') '; + }; + + #check sign + if ( $invoice->is_sales && $self->amount < 0 ) { + $agreement += $points{wrong_sign}; + $rule_matches .= 'wrong_sign(' . $points{'wrong_sign'} . ') '; + }; + if ( ! $invoice->is_sales && $self->amount > 0 ) { + $agreement += $points{wrong_sign}; + $rule_matches .= 'wrong_sign(' . $points{'wrong_sign'} . ') '; + }; + + # search customer/vendor number in purpose + my $cvnumber; + $cvnumber = $invoice->customer->customernumber if $invoice->is_sales; + $cvnumber = $invoice->vendor->vendornumber if ! $invoice->is_sales; + if ( $cvnumber && $self->purpose =~ /\b$cvnumber\b/i ) { + $agreement += $points{cust_vend_number_in_purpose}; + $rule_matches .= 'cust_vend_number_in_purpose(' . $points{'cust_vend_number_in_purpose'} . ') '; + } + + # search for customer/vendor name in purpose (may contain GMBH, CO KG, ...) + my $cvname; + $cvname = $invoice->customer->name if $invoice->is_sales; + $cvname = $invoice->vendor->name if ! $invoice->is_sales; + if ( $cvname && $self->purpose =~ /\b$cvname\b/i ) { + $agreement += $points{cust_vend_name_in_purpose}; + $rule_matches .= 'cust_vend_name_in_purpose(' . $points{'cust_vend_name_in_purpose'} . ') '; + }; + + # compare depositorname, don't try to match empty depositors + my $depositorname; + $depositorname = $invoice->customer->depositor if $invoice->is_sales; + $depositorname = $invoice->vendor->depositor if ! $invoice->is_sales; + if ( $depositorname && $self->remote_name =~ /$depositorname/ ) { + $agreement += $points{depositor_matches}; + $rule_matches .= 'depositor_matches(' . $points{'depositor_matches'} . ') '; + }; + + #Check if words in remote_name appear in cvname + my $check_string_points = _check_string($self->remote_name,$cvname); + if ( $check_string_points ) { + $agreement += $check_string_points; + $rule_matches .= 'remote_name(' . $check_string_points . ') '; + }; + + # transdate prefilter: compare transdate of bank_transaction with transdate of invoice + if ( $datediff < -5 ) { # this might conflict with advance payments + $agreement += $points{payment_before_invoice}; + $rule_matches .= 'payment_before_invoice(' . $points{'payment_before_invoice'} . ') '; + }; + if ( $datediff < 30 ) { + $agreement += $points{payment_within_30_days}; + $rule_matches .= 'payment_within_30_days(' . $points{'payment_within_30_days'} . ') '; + }; + + # only if we already have a good agreement, let date further change value of agreement. + # this is so that if there are several plausible open invoices which are all equal + # (rent jan, rent feb...) the one with the best date match is chosen over + # the others + + # another way around this is to just pre-filter by periods instead of matching everything + if ( $agreement > 5 ) { + if ( $datediff == 0 ) { + $agreement += $points{datebonus0}; + $rule_matches .= 'datebonus0(' . $points{'datebonus0'} . ') '; + } elsif ( $datediff > 0 and $datediff <= 14 ) { + $agreement += $points{datebonus14}; + $rule_matches .= 'datebonus14(' . $points{'datebonus14'} . ') '; + } elsif ( $datediff >14 and $datediff < 35) { + $agreement += $points{datebonus35}; + $rule_matches .= 'datebonus35(' . $points{'datebonus35'} . ') '; + } elsif ( $datediff >34 and $datediff < 120) { + $agreement += $points{datebonus120}; + $rule_matches .= 'datebonus120(' . $points{'datebonus120'} . ') '; + } elsif ( $datediff < 0 ) { + $agreement += $points{datebonus_negative}; + $rule_matches .= 'datebonus_negative(' . $points{'datebonus_negative'} . ') '; + } else { + # e.g. datediff > 120 + }; + }; + + return ($agreement,$rule_matches); +}; + +sub _check_string { + my $bankstring = shift; + my $namestring = shift; + return 0 unless $bankstring and $namestring; + + my @bankwords = grep(/^\w+$/, split(/\b/,$bankstring)); + + my $match = 0; + foreach my $bankword ( @bankwords ) { + # only try to match strings with more than 2 characters + next unless length($bankword)>2; + if ( $namestring =~ /\b$bankword\b/i ) { + $match++; + }; + }; + return $match; +}; + 1; + +__END__ + +=pod + +=head1 NAME + +SL::DB::BankTransaction + +=head1 FUNCTIONS + +=over 4 + +=item C + +Using a point system this function checks whether the bank transaction matches +an invoices, using a variety of tests, such as + +=over 2 + +=item * amount + +=item * amount_less_skonto + +=item * payment date + +=item * invoice number in purpose + +=item * customer or vendor name in purpose + +=item * account number matches account number of customer or vendor + +=back + +The total number of points, and the rules that matched, are returned. + +Example: + my $bt = SL::DB::Manager::BankTransaction->find_by(id => 522); + my $invoice = SL::DB::Manager::Invoice->find_by(invnumber => '198'); + my ($agreement,rule_matches) = $bt->get_agreement_with_invoice($invoice); + +=back + +=head1 AUTHOR + +G. Richardson Egrichardson@kivitendo-premium.de + +=cut diff --git a/SL/DB/CsvImportProfile.pm b/SL/DB/CsvImportProfile.pm index 1902a51ec..9980ad528 100644 --- a/SL/DB/CsvImportProfile.pm +++ b/SL/DB/CsvImportProfile.pm @@ -34,13 +34,6 @@ sub new_with_default { sub set_defaults { my ($self) = @_; - $self->_set_defaults(sep_char => ',', - quote_char => '"', - escape_char => '"', - charset => 'CP850', - numberformat => $::myconfig{numberformat}, - duplicates => 'no_check', - ); if ($self->type eq 'parts') { my $bugru = SL::DB::Manager::Buchungsgruppe->find_by(description => { like => 'Standard%19%' }); @@ -59,10 +52,33 @@ sub set_defaults { item_column => $::locale->text('OrderItem'), max_amount_diff => 0.02, ); + } elsif ($self->type eq 'mt940') { + $self->_set_defaults(charset => 'UTF8', + sep_char => ';', + numberformat => '1000.00', + update_policy => 'skip', + ); + } elsif ($self->type eq 'bank_transactions') { + $self->_set_defaults(charset => 'UTF8', + update_policy => 'skip', + ); } else { $self->_set_defaults(table => 'customer'); } + # TODO: move the defaults into their own controller + # defaults can only be set once, so use these values as default if they + # haven't already been set above for one of the special import types + # If the values have been set above they won't be overwritten here: + + $self->_set_defaults(sep_char => ',', + quote_char => '"', + escape_char => '"', + charset => 'CP850', + numberformat => $::myconfig{numberformat}, + duplicates => 'no_check', + ); + return $self; } diff --git a/SL/DB/Helper/Paginated.pm b/SL/DB/Helper/Paginated.pm index 02e772814..e75de4ceb 100644 --- a/SL/DB/Helper/Paginated.pm +++ b/SL/DB/Helper/Paginated.pm @@ -79,13 +79,13 @@ __END__ =head1 NAME -SL::Helper::Paginated - Manager mixin for paginating results. +SL::DB::Helper::Paginated - Manager mixin for paginating results. =head1 SYNOPSIS In the manager: - use SL::Helper::Paginated; + use SL::DB::Helper::Paginated; __PACKAGE__->default_objects_per_page(10); # optional, defaults to 20 @@ -124,7 +124,7 @@ since they don't make sense with paginating. C should contain a value between 1 and the maximum pages. Will be sanitized. -The parameter C is optional. If not given the default value of the +The parameter C is optional, otherwise the default value of the Manager will be used. =back diff --git a/SL/DB/Helper/Payment.pm b/SL/DB/Helper/Payment.pm new file mode 100644 index 000000000..baa23668e --- /dev/null +++ b/SL/DB/Helper/Payment.pm @@ -0,0 +1,995 @@ +package SL::DB::Helper::Payment; + +use strict; + +use parent qw(Exporter); +our @EXPORT = qw(pay_invoice); +our @EXPORT_OK = qw(skonto_date skonto_charts amount_less_skonto within_skonto_period percent_skonto reference_account reference_amount transactions open_amount open_percent remaining_skonto_days skonto_amount check_skonto_configuration valid_skonto_amount get_payment_suggestions validate_payment_type open_sepa_transfer_amount get_payment_select_options_for_bank_transaction); +our %EXPORT_TAGS = ( + "ALL" => [@EXPORT, @EXPORT_OK], +); + +require SL::DB::Chart; +use Data::Dumper; +use DateTime; +use SL::DATEV qw(:CONSTANTS); +use SL::Locale::String qw(t8); +use List::Util qw(sum); +use Carp; + +# +# Public functions not exported by default +# + +sub pay_invoice { + my ($self, %params) = @_; + + require SL::DB::Tax; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + my $mult = $is_sales ? 1 : -1; # multiplier for getting the right sign depending on ar/ap + + my $paid_amount = 0; # the amount that will be later added to $self->paid + + # default values if not set + $params{payment_type} = 'without_skonto' unless $params{payment_type}; + validate_payment_type($params{payment_type}); + + # check for required parameters + Common::check_params(\%params, qw(chart_id transdate)); + + my $transdate_obj = $::locale->parse_date_to_object($params{transdate}); + croak t8('Illegal date') unless ref $transdate_obj; + + # check for closed period + my $closedto = $::locale->parse_date_to_object($::instance_conf->get_closedto); + if ( ref $closedto && $transdate_obj < $closedto ) { + croak t8('Cannot post payment for a closed period!'); + }; + + # check for maximum number of future days + if ( $::instance_conf->get_max_future_booking_interval > 0 ) { + croak t8('Cannot post transaction above the maximum future booking date!') if $transdate_obj > DateTime->now->add( days => $::instance_conf->get_max_future_booking_interval ); + }; + + # input checks: + if ( $params{'payment_type'} eq 'without_skonto' ) { + croak "invalid amount for payment_type 'without_skonto': $params{'amount'}\n" unless abs($params{'amount'}) > 0; + }; + + # options with_skonto_pt and difference_as_skonto don't require the parameter + # amount, but if amount is passed, make sure it matches the expected value + if ( $params{'payment_type'} eq 'difference_as_skonto' ) { + croak "amount $params{amount} doesn't match open amount " . $self->open_amount . ", diff = " . ($params{amount}-$self->open_amount) if $params{amount} && abs($self->open_amount - $params{amount} ) > 0.0000001; + } elsif ( $params{'payment_type'} eq 'with_skonto_pt' ) { + croak "amount $params{amount} doesn't match amount less skonto: " . $self->open_amount . "\n" if $params{amount} && abs($self->amount_less_skonto - $params{amount} ) > 0.0000001; + croak "payment type with_skonto_pt can't be used if payments have already been made" if $self->paid != 0; + }; + + # absolute skonto amount for invoice, use as reference sum to see if the + # calculated skontos add up + # only needed for payment_term "with_skonto_pt" + + my $skonto_amount_check = $self->skonto_amount; # variable should be zero after calculating all skonto + my $total_open_amount = $self->open_amount; + + # account where money is paid to/from: bank account or cash + my $account_bank = SL::DB::Manager::Chart->find_by(id => $params{chart_id}); + croak "can't find bank account" unless ref $account_bank; + + my $reference_account = $self->reference_account; + croak "can't find reference account (link = AR/AP) for invoice" unless ref $reference_account; + + my $memo = $params{'memo'} || ''; + my $source = $params{'source'} || ''; + + my $rounded_params_amount = _round( $params{amount} ); + + my $db = $self->db; + $db->do_transaction(sub { + my $new_acc_trans; + + # all three payment type create 1 AR/AP booking (the paid part) + # difference_as_skonto creates n skonto bookings (1 for each tax type) + # with_skonto_pt creates 1 bank booking and n skonto bookings (1 for each tax type) + # without_skonto creates 1 bank booking + + # as long as there is no automatic tax, payments are always booked with + # taxkey 0 + + unless ( $params{payment_type} eq 'difference_as_skonto' ) { + # cases with_skonto_pt and without_skonto + + # for case with_skonto_pt we need to know the corrected amount at this + # stage if we are going to use $params{amount} + + my $pay_amount = $rounded_params_amount; + $pay_amount = $self->amount_less_skonto if $params{payment_type} eq 'with_skonto_pt'; + + # bank account and AR/AP + $paid_amount += $pay_amount; + + # total amount against bank, do we already know this by now? + $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $self->id, + chart_id => $account_bank->id, + chart_link => $account_bank->link, + amount => (-1 * $pay_amount) * $mult, + transdate => $transdate_obj, + source => $source, + memo => $memo, + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + $new_acc_trans->save; + }; + + if ( $params{payment_type} eq 'difference_as_skonto' or $params{payment_type} eq 'with_skonto_pt' ) { + + my $total_skonto_amount; + if ( $params{payment_type} eq 'with_skonto_pt' ) { + $total_skonto_amount = $self->skonto_amount; + } elsif ( $params{payment_type} eq 'difference_as_skonto' ) { + $total_skonto_amount = $self->open_amount; + }; + + my @skonto_bookings = $self->skonto_charts($total_skonto_amount); + + # error checking: + if ( $params{payment_type} eq 'difference_as_skonto' ) { + my $calculated_skonto_sum = sum map { $_->{skonto_amount} } @skonto_bookings; + croak "calculated skonto for difference_as_skonto = $calculated_skonto_sum doesn't add up open amount: " . $self->open_amount unless _round($calculated_skonto_sum) == _round($self->open_amount); + }; + + my $reference_amount = $total_skonto_amount; + + # create an acc_trans entry for each result of $self->skonto_charts + foreach my $skonto_booking ( @skonto_bookings ) { + next unless $skonto_booking->{'chart_id'}; + next unless $skonto_booking->{'skonto_amount'} != 0; + my $amount = -1 * $skonto_booking->{skonto_amount}; + $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $self->id, + chart_id => $skonto_booking->{'chart_id'}, + chart_link => SL::DB::Manager::Chart->find_by(id => $skonto_booking->{'chart_id'})->{'link'}, + amount => $amount * $mult, + transdate => $transdate_obj, + source => $params{source}, + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + $new_acc_trans->save; + + $reference_amount -= abs($amount); + $paid_amount += -1 * $amount; + $skonto_amount_check -= $skonto_booking->{'skonto_amount'}; + }; + die "difference_as_skonto calculated incorrectly, sum of calculated payments doesn't add up to open amount $total_open_amount, reference_amount = $reference_amount\n" unless _round($reference_amount) == 0; + + }; + + my $arap_amount = 0; + + if ( $params{payment_type} eq 'difference_as_skonto' ) { + $arap_amount = $total_open_amount; + } elsif ( $params{payment_type} eq 'without_skonto' ) { + $arap_amount = $rounded_params_amount; + } elsif ( $params{payment_type} eq 'with_skonto_pt' ) { + # this should be amount + sum(amount+skonto), but while we only allow + # with_skonto_pt for completely unpaid invoices we just use the value + # from the invoice + $arap_amount = $total_open_amount; + }; + + # regardless of payment_type there is always only exactly one arap booking + # TODO: compare $arap_amount to running total + my $arap_booking= SL::DB::AccTransaction->new(trans_id => $self->id, + chart_id => $reference_account->id, + chart_link => $reference_account->link, + amount => $arap_amount * $mult, + transdate => $transdate_obj, + source => '', #$params{source}, + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + $arap_booking->save; + + $self->paid($self->paid+$paid_amount) if $paid_amount; + $self->datepaid($transdate_obj); + $self->save; + + my $datev_check = 0; + if ( $is_sales ) { + if ( ( $self->invoice && $::instance_conf->get_datev_check_on_sales_invoice ) || + ( !$self->invoice && $::instance_conf->get_datev_check_on_ar_transaction )) { + $datev_check = 1; + }; + } else { + if ( ( $self->invoice && $::instance_conf->get_datev_check_on_purchase_invoice ) || + ( !$self->invoice && $::instance_conf->get_datev_check_on_ap_transaction )) { + $datev_check = 1; + }; + }; + + if ( $datev_check ) { + + my $datev = SL::DATEV->new( + exporttype => DATEV_ET_BUCHUNGEN, + format => DATEV_FORMAT_KNE, + dbh => $db->dbh, + trans_id => $self->{id}, + ); + + $datev->clean_temporary_directories; + $datev->export; + + if ($datev->errors) { + # this exception should be caught by do_transaction, which handles the rollback + die join "\n", $::locale->text('DATEV check returned errors:'), $datev->errors; + } + }; + + }) || die t8('error while paying invoice #1 : ', $self->invnumber) . $db->error . "\n"; + + return 1; +}; + +sub skonto_date { + + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + my $skonto_date; + + if ( $is_sales ) { + return undef unless ref $self->payment_terms; + return undef unless $self->payment_terms->terms_skonto > 0; + $skonto_date = DateTime->from_object(object => $self->transdate)->add(days => $self->payment_terms->terms_skonto); + } else { + return undef unless ref $self->vendor->payment_terms; + return undef unless $self->vendor->payment_terms->terms_skonto > 0; + $skonto_date = DateTime->from_object(object => $self->transdate)->add(days => $self->vendor->payment_terms->terms_skonto); + }; + + return $skonto_date; +}; + +sub reference_account { + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + require SL::DB::Manager::AccTransaction; + + my $link_filter = $is_sales ? 'AR' : 'AP'; + + my $acc_trans = SL::DB::Manager::AccTransaction->find_by( + trans_id => $self->id, + SL::DB::Manager::AccTransaction->chart_link_filter("$link_filter") + ); + + return undef unless ref $acc_trans; + + my $reference_account = SL::DB::Manager::Chart->find_by(id => $acc_trans->chart_id); + + return $reference_account; +}; + +sub reference_amount { + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + require SL::DB::Manager::AccTransaction; + + my $link_filter = $is_sales ? 'AR' : 'AP'; + + my $acc_trans = SL::DB::Manager::AccTransaction->find_by( + trans_id => $self->id, + SL::DB::Manager::AccTransaction->chart_link_filter("$link_filter") + ); + + return undef unless ref $acc_trans; + + # this should be the same as $self->amount + return $acc_trans->amount; +}; + + +sub open_amount { + my $self = shift; + + # in the future maybe calculate this from acc_trans + + # if the difference is 0.01 Cent this may end up as 0.009999999999998 + # numerically, so round this value when checking for cent threshold >= 0.01 + + return $self->amount - $self->paid; +}; + +sub open_percent { + my $self = shift; + + return 0 if $self->amount == 0; + my $open_percent; + if ( $self->open_amount < 0 ) { + # overpaid, currently treated identically + $open_percent = $self->open_amount * 100 / $self->amount; + } else { + $open_percent = $self->open_amount * 100 / $self->amount; + }; + + return _round($open_percent) || 0; +}; + +sub skonto_amount { + my $self = shift; + + return $self->amount - $self->amount_less_skonto; +}; + +sub remaining_skonto_days { + my $self = shift; + + return undef unless ref $self->skonto_date; + + my $dur = DateTime::Duration->new($self->skonto_date - DateTime->today); + return $dur->delta_days(); + +}; + +sub percent_skonto { + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + my $percent_skonto = 0; + + if ( $is_sales ) { + return undef unless ref $self->payment_terms; + return undef unless $self->payment_terms->percent_skonto > 0; + $percent_skonto = $self->payment_terms->percent_skonto; + } else { + return undef unless ref $self->vendor->payment_terms; + return undef unless $self->vendor->payment_terms->terms_skonto > 0; + $percent_skonto = $self->vendor->payment_terms->percent_skonto; + }; + + return $percent_skonto; +}; + +sub amount_less_skonto { + # amount that has to be paid if skonto applies, always return positive rounded values + # the result is rounded so we can directly compare it with the user input + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + my $percent_skonto = $self->percent_skonto; + + return _round($self->amount - ( $self->amount * $percent_skonto) ); + +}; + +sub check_skonto_configuration { + my $self = shift; + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + my $skonto_configured = 1; # default is assume skonto works + + my $transactions = $self->transactions; + foreach my $transaction (@{ $transactions }) { + # find all transactions with an AR_amount or AP_amount link + my $tax = SL::DB::Manager::Tax->get_first( where => [taxkey => $transaction->{taxkey}]); + croak "no tax for taxkey " . $transaction->{taxkey} unless ref $tax; + + $transaction->{chartlinks} = { map { $_ => 1 } split(m/:/, $transaction->{chart_link}) }; + if ( $is_sales && $transaction->{chartlinks}->{AR_amount} ) { + $skonto_configured = 0 unless $tax->skonto_sales_chart_id; + } elsif ( !$is_sales && $transaction->{chartlinks}->{AP_amount}) { + $skonto_configured = 0 unless $tax->skonto_purchase_chart_id; + }; + }; + + return $skonto_configured; +}; + +sub open_sepa_transfer_amount { + my $self = shift; + + my ($vc, $key, $type); + if ( ref($self) eq 'SL::DB::Invoice' ) { + $vc = 'customer'; + $key = 'ap_id'; + $type = 'ar'; + } else { + $vc = 'vendor'; + $key = 'ap_id'; + $type = 'ap'; + }; + + my $sql = qq|SELECT SUM(sei.amount) AS amount FROM sepa_export_items sei | . + qq| LEFT JOIN sepa_export se ON (sei.sepa_export_id = se.id) | . + qq| WHERE $key = ? AND NOT se.closed AND (se.vc = '$vc') |; + + my ($open_sepa_amount) = $self->db->dbh->selectrow_array($sql, undef, $self->id); + + return $open_sepa_amount || 0; + +}; + + +sub skonto_charts { + my $self = shift; + + # TODO: use param for amount, may also want to calculate skonto_amounts by + # passing percentage in the future + + my $amount = shift || $self->skonto_amount; + + croak "no amount passed to skonto_charts" unless abs(_round($amount)) >= 0.01; + + # TODO: check whether there are negative values in invoice / acc_trans ... credited items + + # don't check whether skonto applies, because user may want to override this + # return undef unless $self->percent_skonto; # for is_sales + # return undef unless $self->vendor->payment_terms->percent_skonto; # for purchase + + my $is_sales = ref($self) eq 'SL::DB::Invoice'; + + my $mult = $is_sales ? 1 : -1; # multiplier for getting the right sign + + my @skonto_charts; # resulting array with all income/expense accounts that have to be corrected + + # calculate effective skonto (percentage) in difference_as_skonto mode + # only works if there are no negative acc_trans values + my $effective_skonto_rate = $amount ? $amount / $self->amount : 0; + + # checks: + my $total_skonto_amount = 0; + my $total_rounding_error = 0; + + my $reference_ARAP_amount = 0; + + my $transactions = $self->transactions; + foreach my $transaction (@{ $transactions }) { + # find all transactions with an AR_amount or AP_amount link + $transaction->{chartlinks} = { map { $_ => 1 } split(m/:/, $transaction->{chart_link}) }; + # second condition is that we can determine an automatic Skonto account for each AR_amount entry + + if ( ( $is_sales && $transaction->{chartlinks}->{AR_amount} ) or ( !$is_sales && $transaction->{chartlinks}->{AP_amount}) ) { + # $reference_ARAP_amount += $transaction->{amount} * $mult; + + # quick hack that works around problem of non-unique tax keys in SKR04 + my $tax = SL::DB::Manager::Tax->get_first( where => [taxkey => $transaction->{taxkey}]); + croak "no tax for taxkey " . $transaction->{taxkey} unless ref $tax; + + if ( $is_sales ) { + die t8('no skonto_chart configured for taxkey #1 : #2 : #3', $transaction->{taxkey} , $tax->taxdescription , $tax->rate*100) unless ref $tax->skonto_sales_chart; + } else { + die t8('no skonto_chart configured for taxkey #1 : #2 : #3', $transaction->{taxkey} , $tax->taxdescription , $tax->rate*100) unless ref $tax->skonto_purchase_chart; + }; + + my $skonto_amount_unrounded; + + my $skonto_percent_abs = $self->amount ? abs($transaction->amount * (1 + $tax->rate) * 100 / $self->amount) : 0; + + my $transaction_amount = abs($transaction->{amount} * (1 + $tax->rate)); + my $transaction_skonto_percent = abs($transaction_amount/$self->amount); # abs($transaction->{amount} * (1 + $tax->rate)); + + + $skonto_amount_unrounded = abs($amount * $transaction_skonto_percent); + my $skonto_amount_rounded = _round($skonto_amount_unrounded); + my $rounding_error = $skonto_amount_unrounded - $skonto_amount_rounded; + my $rounded_rounding_error = _round($rounding_error); + + $total_rounding_error += $rounding_error; + $total_skonto_amount += $skonto_amount_rounded; + + my $rec = { + # skonto_percent_abs: relative part of amount + tax to the total invoice amount + 'skonto_percent_abs' => $skonto_percent_abs, + 'chart_id' => $is_sales ? $tax->skonto_sales_chart->id : $tax->skonto_purchase_chart->id, + 'skonto_amount' => $skonto_amount_rounded, + # 'rounding_error' => $rounding_error, + # 'rounded_rounding_error' => $rounded_rounding_error, + }; + + push @skonto_charts, $rec; + }; + }; + + # if the rounded sum of all rounding_errors reaches 0.01 this sum is + # subtracted from the largest skonto_amount + my $rounded_total_rounding_error = abs(_round($total_rounding_error)); + + if ( $rounded_total_rounding_error > 0 ) { + my $highest_amount_pos = 0; + my $highest_amount = 0; + my $i = -1; + foreach my $ref ( @skonto_charts ) { + $i++; + if ( $ref->{skonto_amount} > $highest_amount ) { + $highest_amount = $ref->{skonto_amount}; + $highest_amount_pos = $i; + }; + }; + $skonto_charts[$i]->{skonto_amount} -= $rounded_total_rounding_error; + }; + + return @skonto_charts; +}; + + +sub within_skonto_period { + my $self = shift; + my $dateref = shift || DateTime->now->truncate( to => 'day' ); + + return undef unless ref $dateref eq 'DateTime'; + return 0 unless $self->skonto_date; + + # return 1 if requested date (or today) is inside skonto period + # this will also return 1 if date is before the invoice date + return $dateref <= $self->skonto_date; +}; + +sub valid_skonto_amount { + my $self = shift; + my $amount = shift || 0; + my $max_skonto_percent = 0.10; + + return 0 unless $amount > 0; + + # does this work for other currencies? + return ($self->amount*$max_skonto_percent) > $amount; +}; + +sub get_payment_select_options_for_bank_transaction { + my ($self, $bt_id, %params) = @_; + + my $bt = SL::DB::Manager::BankTransaction->find_by( id => $bt_id ); + die unless $bt; + + my $open_amount = $self->open_amount; + + my @options; + if ( $open_amount && # invoice amount not 0 + abs(abs($self->amount_less_skonto) - abs($bt->amount)) < 0.01 && + $self->check_skonto_configuration) { + if ( $self->within_skonto_period($bt->transdate) ) { + push(@options, { payment_type => 'without_skonto', display => t8('without skonto') }); + push(@options, { payment_type => 'with_skonto_pt', display => t8('with skonto acc. to pt'), selected => 1 }); + } else { + push(@options, { payment_type => 'without_skonto', display => t8('without skonto') }, selected => 1 ); + push(@options, { payment_type => 'with_skonto_pt', display => t8('with skonto acc. to pt')}); + }; + }; + + return @options; + +}; + + +sub get_payment_suggestions { + + my ($self, %params) = @_; + + my $open_amount = $self->open_amount; + $open_amount -= $self->open_sepa_transfer_amount if $params{sepa}; + + $self->{invoice_amount_suggestion} = $open_amount; + undef $self->{payment_select_options}; + push(@{$self->{payment_select_options}} , { payment_type => 'without_skonto', display => t8('without skonto') }); + if ( $self->within_skonto_period ) { + # If there have been no payments yet suggest amount_less_skonto, otherwise the open amount + if ( $open_amount && # invoice amount not 0 + $open_amount == $self->amount && # no payments yet, or sum of payments and sepa export amounts is zero + $self->check_skonto_configuration) { + $self->{invoice_amount_suggestion} = $self->amount_less_skonto; + push(@{$self->{payment_select_options}} , { payment_type => 'with_skonto_pt', display => t8('with skonto acc. to pt') , selected => 1 }); + } else { + if ( ( $self->valid_skonto_amount($self->open_amount) || $self->valid_skonto_amount($open_amount) ) and not $params{sepa} ) { + $self->{invoice_amount_suggestion} = $open_amount; + # only suggest difference_as_skonto if open_amount exactly matches skonto_amount + # AND we aren't in SEPA mode + my $selected = 0; + $selected = 1 if _round($open_amount) == _round($self->skonto_amount); + push(@{$self->{payment_select_options}} , { payment_type => 'difference_as_skonto', display => t8('difference as skonto') , selected => $selected }); + }; + }; + } else { + # invoice was configured with skonto, but skonto date has passed, or no skonto available + $self->{invoice_amount_suggestion} = $open_amount; + # difference_as_skonto doesn't make any sense for SEPA transfer, as this doesn't cause any actual payment + if ( $self->valid_skonto_amount($self->open_amount) && not $params{sepa} ) { + push(@{$self->{payment_select_options}} , { payment_type => 'difference_as_skonto', display => t8('difference as skonto') , selected => 0 }); + }; + }; + return 1; +}; + +sub transactions { + my ($self) = @_; + + return unless $self->id; + + require SL::DB::AccTransaction; + SL::DB::Manager::AccTransaction->get_all(query => [ trans_id => $self->id ]); +} + +sub validate_payment_type { + my $payment_type = shift; + + my %allowed_payment_types = map { $_ => 1 } qw(without_skonto with_skonto_pt difference_as_skonto); + croak "illegal payment type: $payment_type, must be one of: " . join(' ', keys %allowed_payment_types) unless $allowed_payment_types{ $payment_type }; + + return 1; +} + +sub _round { + my $value = shift; + my $num_dec = 2; + return $::form->round_amount($value, 2); +} + +1; + +__END__ + +=pod + +=head1 NAME + +SL::DB::Helper::Payment Mixin providing helper methods for paying C + and C objects and using skonto + +=head1 SYNOPSIS + +In addition to actually causing a payment via pay_invoice this helper contains +many methods that help in determining information about the status of the +invoice, such as the remaining open amount, whether skonto applies, until which +date skonto applies, the skonto amount and relative percentages, what to do +with skonto, ... + +To prevent duplicate code this was all added in this mixin rather than directly +in SL::DB::Invoice and SL::DB::PurchaseInvoice. + +=over 4 + +=item C + +Create a payment booking for an existing invoice object (type ar/ap/is/ir) via +a configured bank account. + +This function deals with all the acc_trans entries and also updates paid and datepaid. + +Example: + + my $ap = SL::DB::Manager::PurchaseInvoice->find_by( invnumber => '1'); + my $bank = SL::DB::Manager::BankAccount->find_by( name => 'Bank'); + $ap->pay_invoice(chart_id => $bank->chart_id, + amount => $ap->open_amount, + transdate => DateTime->now->to_kivitendo, + memo => 'foobar; + source => 'barfoo; + payment_type => 'without_skonto', # default if not specified + ); + +or with skonto: + $ap->pay_invoice(chart_id => $bank->chart_id, + amount => $ap->amount, # doesn't need to be specified + transdate => DateTime->now->to_kivitendo, + memo => 'foobar; + source => 'barfoo; + payment_type => 'with_skonto', + ); + +Allowed payment types are: + without_skonto with_skonto_pt difference_as_skonto + +The option C allows for a basic skonto mechanism. + +C is the default mode, "amount" is paid to the account in +chart_id. This can also be used for partial payments and corrections via +negative amounts. + +C can't be used for partial payments. When used on unpaid +invoices the whole amount is paid, with the skonto part automatically being +booked according to the skonto chart configured in the tax settings for each +tax key. If an amount is passed it is ignored and the actual configured skonto +amount is used. + +C can only be used after partial payments have been made, +the whole specified amount is booked according to the skonto charts configured +in the tax settings for each tax key. + +So passing amount doesn't have any effect for the cases C and +C, as all necessary values are taken from the stored +invoice. + +The skonto modes automatically calculate the relative amounts for a mix of +taxes, e.g. items with 7% and 19% in one invoice. There is a helper method +skonto_charts, which calculates the relative percentages according to the +amounts in acc_trans (which are grouped by tax). + +There is currently no way of excluding certain items in an invoice from having +skonto applied to them. If this feature was added to parts the calculation +method of relative skonto would have to be completely rewritten using the +invoice items rather than acc_trans. + +The skonto modes also still don't automatically correct the tax, this still has +to be done manually. Therefore all payments generated by pay_invoice have +taxkey 0. + +There is currently no way to directly pay an invoice via this method if the +effective skonto differs from the skonto according to the payment terms +configured for the invoice/vendor. + +In this case one has to pay in two steps: first the actual paid amount via +"without skonto", and then the remainder via "difference_as_skonto". The user +has to there actively decide whether to accept the differing skonto. + +Because of the way skonto_charts works the calculation doesn't work if there +are negative values in acc_trans. E.g. one invoice with a positive value for +19% tax and a negative value for the acc_trans line with 7% + +Skonto doesn't/shouldn't apply if the invoice contains credited items. + +=item C + +Returns a chart object which is the chart of the invoice with link AR or AP. + +Example (1200 is the AR account for SKR04): + my $invoice = invoice(invnumber => '144'); + $invoice->reference_account->accno + # 1200 + +=item C + +Returns the configured skonto percentage of the payment terms of an invoice, +e.g. 0.02 for 2%. Payment terms come from invoice settings for ar, from vendor +settings for ap. + +=item C + +If the invoice has a payment term (via ar for sales, via vendor for purchase), +calculate the amount to be paid in the case of skonto. This doesn't check, +whether skonto applies (i.e. skonto doesn't wasn't exceeded), it just subtracts +the configured percentage (e.g. 2%) from the total amount. + +The returned value is rounded to two decimals. + +=item C + +The date up to which skonto may be taken. This is calculated from the invoice +date + the number of days configured in the payment terms. + +This method can also be used to determine whether skonto applies for the +invoice, as it returns undef if there is no payment term or skonto days is set +to 0. + +=item C + +Returns 0 or 1. + +Checks whether the invoice has payment terms configured, and whether the date +is within the skonto max date. If no date is passed the current date is used. + +You can also pass a dateref object as a parameter to check whether skonto +applies for that date rather than the current date. + +=item C + +Takes an amount as an argument and checks whether the amount is less than 10% +of the total amount of the invoice. The value of 10% is currently hardcoded in +the method. This method is currently used to check whether to offer the payment +option "difference as skonto". + +Example: + if ( $invoice->valid_skonto_amount($invoice->open_amount) ) { + # ... do something + } + +=item C + +Returns a list of chart_ids and some calculated numbers that can be used for +paying the invoice with skonto. This function will automatically calculate the +relative skonto amounts even if the invoice contains several types of taxes +(e.g. 7% and 19%). + +Example usage: + my $invoice = SL::DB::Manager::Invoice->find_by(invnumber => '211'); + my @skonto_charts = $invoice->skonto_charts; + +or with the total skonto amount as an argument: + my @skonto_charts = $invoice->skonto_charts($invoice->open_amount); + +The following values are generated for each chart: + +=over 2 + +=item C + +The chart id of the skonto amount to be booked. + +=item C + +The total amount to be paid to the account + +=item C + +The relative percentage of that skonto chart. This can be useful if the actual +ekonto that is paid deviates from the granted skonto, e.g. customer effectively +pays 2.6% skonto instead of 2%, and we accept this. Then we can still calculate +the relative skonto amounts for different taxes based on the absolute +percentages. Used for case C. + +=item C + +The absolute percentage of that skonto chart in relation to the total amount. +Used to calculate skonto_amount for case C. + +=back + +If the invoice contains several types of taxes then skonto_charts can be used +to calculate the relative amounts. + +Example in console of an invoice with 100 Euro at 7% and 100 Euro at 19% with +tax not included: + + my $invoice = invoice(invnumber => '144'); + $invoice->amount + 226.00000 + $invoice->payment_terms->percent_skonto + 0.02 + $invoice->skonto_charts + pp $invoice->skonto_charts + # $VAR1 = { + # 'chart_id' => 128, + # 'skonto_amount' => '2.14', + # 'skonto_percent' => '47.3451327433627' + # }; + # $VAR2 = { + # 'chart_id' => 130, + # 'skonto_amount' => '2.38', + # 'skonto_percent' => '52.654867256637' + # }; + +C always returns positive values (abs) for C and +C. + +C generates one entry for each acc_trans entry. ar and ap +bookings only have one acc_trans entry for each taxkey (e.g. 7% and 19%). This +is because all the items are grouped according to the Buchungsgruppen mechanism +and the totals are written to acc_trans. For is and ir it is possible to have +several acc_trans entries with the same tax. In this case skonto_charts +generates a skonto booking for each acc_trans income/expense entry. + +In the future this function may also be used to calculate the corrections for +the income tax. + +=item C + +Unrounded total open amount of invoice (amount - paid). +Doesn't take into account pending SEPA transfers. + +=item C + +Percentage of the invoice that is still unpaid, e.g. 100,00 if no payments have +been made yet, 0,00 if fully paid. + +=item C + +How many days skonto can still be taken, calculated from current day. Returns 0 +if current day is the max skonto date, and negative number if skonto date has +already passed. + +Returns undef if skonto is not configured for that invoice. + +=item C + +Creates data intended for an L.select_tag dropdown that can be used in a +template. Depending on the rules it will choose from the options +without_skonto, with_skonto_pt and difference_as_skonto, and select the most +likely one. + +If the parameter "sepa" is passed, the SEPA export payments that haven't been +executed yet are considered when determining the open amount of the invoice. + +The current rules are: + +=over 2 + +=item * without_skonto is always an option + +=item * with_skonto_pt is only offered if there haven't been any payments yet and the current date is within the skonto period. + +=item * difference_as_skonto is only offered if there have already been payments made and the open amount is smaller than 10% of the total amount. + +with_skonto_pt will only be offered, if all the AR_amount/AP_amount have a +taxkey with a configured skonto chart + +=back + +It will also fill $self->{invoice_amount_suggestion} with either the open +amount, or if with_skonto_pt is selected, with amount_less_skonto, so the +template can fill the input with the likely amount. + +Example in console: + my $ar = invoice( invnumber => '257'); + $ar->get_payment_suggestions; + print $ar->{invoice_amount_suggestion} . "\n"; + # 97.23 + pp $ar->{payment_select_options} + # $VAR1 = [ + # { + # 'display' => 'ohne Skonto', + # 'payment_type' => 'without_skonto' + # }, + # { + # 'display' => 'mit Skonto nach ZB', + # 'payment_type' => 'with_skonto_pt', + # 'selected' => 1 + # } + # ]; + +The resulting array $ar->{payment_select_options} can be used in a template +select_tag using value_key and title_key: + +[% L.select_tag('payment_type_' _ loop.count, invoice.payment_select_options, value_key => 'payment_type', title_key => 'display', id => 'payment_type_' _ loop.count) %] + +It would probably make sense to have different rules for the pre-selected items +for sales and purchase, and to also make these rules configurable in the +defaults. E.g. when creating a SEPA bank transfer for vendor invoices a company +might always want to pay quickly making use of skonto, while another company +might always want to pay as late as possible. + +=item C + +Returns all acc_trans Objects of an ar/ap object. + +Example in console to print account numbers and booked amounts of an invoice: + my $invoice = invoice(invnumber => '144'); + foreach my $acc_trans ( @{ $invoice->transactions } ) { + print $acc_trans->chart->accno . " : " . $acc_trans->amount_as_number . "\n" + }; + # 1200 : 226,00000 + # 1800 : -226,00000 + # 4300 : 100,00000 + # 3801 : 7,00000 + # 3806 : 19,00000 + # 4400 : 100,00000 + # 1200 : -226,00000 + +=item C + +Make suggestion for a skonto payment type by returning an HTML blob of the options +of a HTML drop-down select with the most likely option preselected. + +This is a helper function for BankTransaction/ajax_payment_suggestion. + +We are working with an existing payment, so difference_as_skonto never makes sense. + +If skonto is possible (skonto_date exists), add two possibilities: +without_skonto and with_skonto_pt if payment date is within skonto_date, +preselect with_skonto_pt, otherwise preselect without skonto. + +=back + +=head1 TODO AND CAVEATS + +=over 4 + +=item * + +when looking at open amount, maybe consider that there may already be queued +amounts in SEPA Export + +=item * + +Can only handle default currency. + +=back + +=head1 AUTHOR + +G. Richardson Egrichardson@kivitendo-premium.de + +=cut diff --git a/SL/DB/Invoice.pm b/SL/DB/Invoice.pm index 3ecc9575e..3ed6cd34e 100644 --- a/SL/DB/Invoice.pm +++ b/SL/DB/Invoice.pm @@ -9,9 +9,9 @@ use Carp; use List::Util qw(first); use Rose::DB::Object::Helpers (); - use SL::DB::MetaSetup::Invoice; use SL::DB::Manager::Invoice; +use SL::DB::Helper::Payment qw(:ALL); use SL::DB::Helper::AttrHTML; use SL::DB::Helper::FlattenToForm; use SL::DB::Helper::LinkedRecords; @@ -20,7 +20,6 @@ use SL::DB::Helper::PriceUpdater; use SL::DB::Helper::TransNumberGenerator; use SL::Locale::String qw(t8); use SL::DB::CustomVariable; -use SL::DB::AccTransaction; __PACKAGE__->meta->add_relationship( invoiceitems => { @@ -368,48 +367,6 @@ sub customervendor { goto &customer; } -sub pay_invoice { - my ($self, %params) = @_; - - #Mark invoice as paid - $self->paid($self->paid+$params{amount}); - $self->save; - - Common::check_params(\%params, qw(chart_id trans_id amount transdate)); - - #account of bank account or cash - my $account_bank = SL::DB::Manager::Chart->find_by(id => $params{chart_id}); - - #Search the contra account - my $acc_trans = SL::DB::Manager::AccTransaction->find_by(trans_id => $params{trans_id}, - or => [ chart_link => { like => "%:AR" }, - chart_link => { like => "AR:%" }, - chart_link => "AR" ]); - my $contra_account = SL::DB::Manager::Chart->find_by(id => $acc_trans->chart_id); - - #Two new transfers in acc_trans (for bank account and for contra account) - my $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $params{trans_id}, - chart_id => $account_bank->id, - chart_link => $account_bank->link, - amount => (-1 * $params{amount}), - transdate => $params{transdate}, - source => $params{source}, - memo => '', - taxkey => 0, - tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); - $new_acc_trans->save; - $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $params{trans_id}, - chart_id => $contra_account->id, - chart_link => $contra_account->link, - amount => $params{amount}, - transdate => $params{transdate}, - source => $params{source}, - memo => '', - taxkey => 0, - tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); - $new_acc_trans->save; -} - sub link { my ($self) = @_; @@ -420,7 +377,6 @@ sub link { return $html; } ->>>>>>> Test: Bank-Commit zusammengefasst 1; __END__ diff --git a/SL/DB/Manager/AccTransaction.pm b/SL/DB/Manager/AccTransaction.pm new file mode 100644 index 000000000..2eae95a60 --- /dev/null +++ b/SL/DB/Manager/AccTransaction.pm @@ -0,0 +1,61 @@ +package SL::DB::Manager::AccTransaction; + +use strict; + +use SL::DB::Helper::Manager; +use base qw(SL::DB::Helper::Manager); + +use SL::DB::Helper::Sorted; +use SL::DBUtils; + +sub object_class { 'SL::DB::AccTransaction' } + +__PACKAGE__->make_manager_methods; + +sub chart_link_filter { + my ($class, $link) = @_; + + return (or => [ chart_link => $link, + chart_link => { like => "${link}:\%" }, + chart_link => { like => "\%:${link}" }, + chart_link => { like => "\%:${link}:\%" } ]); +} + +1; + +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::DB::Manager::AccTransaction - Manager class for the model for the C table + +=head1 FUNCTIONS + +=over 4 + +=item C + +Returns a query builder filter that matches acc_trans lines whose 'C' +field contains C<$chart_link>. Matching is done so that the exact value of +C<$chart_link> matches but not if C<$chart_link> is only a substring of a +match. Therefore C<$chart_link = 'AR'> will match the column content 'C' +or 'C' but not 'C'. + +The code and functionality was copied from the function link_filter in +SL::DB::Manager::Chart. + +=back + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +G. Richardson Egrichardson@kivitendo-premium.de + +=cut diff --git a/SL/DB/Manager/BankAccount.pm b/SL/DB/Manager/BankAccount.pm new file mode 100644 index 000000000..673499955 --- /dev/null +++ b/SL/DB/Manager/BankAccount.pm @@ -0,0 +1,51 @@ +package SL::DB::Manager::BankAccount; + +use strict; + +use SL::DB::Helper::Manager; +use base qw(SL::DB::Helper::Manager); + +use SL::DB::Helper::Paginated; +use SL::DB::Helper::Sorted; + +sub object_class { 'SL::DB::BankAccount' } + +__PACKAGE__->make_manager_methods; + +sub _sort_spec { + return ( default => [ 'sortkey', 1 ], + columns => { SIMPLE => 'ALL' } ); +} + +sub get_default { + return $_[0]->get_first(where => [ obsolete => 0 ], sort_by => 'sortkey'); +} + +1; +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::DB::Manager::BankAccount - RDBO manager for the C table + +=head1 FUNCTIONS + +=over 4 + +=item C + +Returns an RDBO instance corresponding to the default bank account. The default +bank account is defined as the bank account with the highest sort order (usually 1) that +is not set to obsolete. + +Example: + + my $default_bank_account_id = SL::DB::Manager::BankAccount->get_default->id; + +=back + +=cut diff --git a/SL/DB/MetaSetup/BankAccount.pm b/SL/DB/MetaSetup/BankAccount.pm index d778138e9..8f1abeac6 100644 --- a/SL/DB/MetaSetup/BankAccount.pm +++ b/SL/DB/MetaSetup/BankAccount.pm @@ -17,7 +17,7 @@ __PACKAGE__->meta->columns( iban => { type => 'varchar', length => 100 }, id => { type => 'integer', not_null => 1, sequence => 'id' }, name => { type => 'text' }, - obsolete => { type => 'boolean' }, + obsolete => { type => 'boolean', default => 'false', not_null => 1 }, reconciliation_starting_balance => { type => 'numeric', precision => 15, scale => 5 }, reconciliation_starting_date => { type => 'date' }, sortkey => { type => 'integer', not_null => 1 }, diff --git a/SL/DB/MetaSetup/BankTransaction.pm b/SL/DB/MetaSetup/BankTransaction.pm index fefe1e587..a2dd2955e 100644 --- a/SL/DB/MetaSetup/BankTransaction.pm +++ b/SL/DB/MetaSetup/BankTransaction.pm @@ -9,17 +9,17 @@ use base qw(SL::DB::Object); __PACKAGE__->meta->table('bank_transactions'); __PACKAGE__->meta->columns( - amount => { type => 'numeric', not_null => 1, precision => 5, scale => 15 }, + amount => { type => 'numeric', not_null => 1, precision => 15, scale => 5 }, cleared => { type => 'boolean', default => 'false', not_null => 1 }, - currency_id => { type => 'integer' }, + currency_id => { type => 'integer', not_null => 1 }, id => { type => 'serial', not_null => 1 }, - invoice_amount => { type => 'numeric', default => '0', precision => 5, scale => 15 }, + invoice_amount => { type => 'numeric', default => '0', precision => 15, scale => 5 }, + itime => { type => 'timestamp', default => 'now()' }, local_bank_account_id => { type => 'integer', not_null => 1 }, purpose => { type => 'text' }, remote_account_number => { type => 'text' }, remote_bank_code => { type => 'text' }, remote_name => { type => 'text' }, - remote_name_1 => { type => 'text' }, transaction_id => { type => 'integer' }, transdate => { type => 'date', not_null => 1 }, valutadate => { type => 'date', not_null => 1 }, @@ -27,6 +27,8 @@ __PACKAGE__->meta->columns( __PACKAGE__->meta->primary_key_columns([ 'id' ]); +__PACKAGE__->meta->allow_inline_column_values(1); + __PACKAGE__->meta->foreign_keys( currency => { class => 'SL::DB::Currency', diff --git a/SL/DB/MetaSetup/ReconciliationLink.pm b/SL/DB/MetaSetup/ReconciliationLink.pm index 796d308c5..9b5c969a9 100644 --- a/SL/DB/MetaSetup/ReconciliationLink.pm +++ b/SL/DB/MetaSetup/ReconciliationLink.pm @@ -18,7 +18,7 @@ __PACKAGE__->meta->columns( __PACKAGE__->meta->primary_key_columns([ 'id' ]); __PACKAGE__->meta->foreign_keys( - acc_tran => { + acc_trans => { class => 'SL::DB::AccTransaction', key_columns => { acc_trans_id => 'acc_trans_id' }, }, diff --git a/SL/DB/MetaSetup/SepaExportItem.pm b/SL/DB/MetaSetup/SepaExportItem.pm index 81d98b321..6f5051b2b 100644 --- a/SL/DB/MetaSetup/SepaExportItem.pm +++ b/SL/DB/MetaSetup/SepaExportItem.pm @@ -20,9 +20,11 @@ __PACKAGE__->meta->columns( our_bic => { type => 'varchar', length => 100 }, our_depositor => { type => 'text' }, our_iban => { type => 'varchar', length => 100 }, + payment_type => { type => 'text', default => 'without_skonto' }, reference => { type => 'varchar', length => 35 }, requested_execution_date => { type => 'date' }, sepa_export_id => { type => 'integer', not_null => 1 }, + skonto_amount => { type => 'numeric', precision => 25, scale => 5 }, vc_bic => { type => 'varchar', length => 100 }, vc_depositor => { type => 'text' }, vc_iban => { type => 'varchar', length => 100 }, diff --git a/SL/DB/MetaSetup/Tax.pm b/SL/DB/MetaSetup/Tax.pm index dcc0d11e6..a3be4005e 100644 --- a/SL/DB/MetaSetup/Tax.pm +++ b/SL/DB/MetaSetup/Tax.pm @@ -9,15 +9,17 @@ use base qw(SL::DB::Object); __PACKAGE__->meta->table('tax'); __PACKAGE__->meta->columns( - chart_categories => { type => 'text', not_null => 1 }, - chart_id => { type => 'integer' }, - id => { type => 'integer', not_null => 1, sequence => 'id' }, - itime => { type => 'timestamp', default => 'now()' }, - mtime => { type => 'timestamp' }, - rate => { type => 'numeric', default => '0', not_null => 1, precision => 15, scale => 5 }, - taxdescription => { type => 'text', not_null => 1 }, - taxkey => { type => 'integer', not_null => 1 }, - taxnumber => { type => 'text' }, + chart_categories => { type => 'text', not_null => 1 }, + chart_id => { type => 'integer' }, + id => { type => 'integer', not_null => 1, sequence => 'id' }, + itime => { type => 'timestamp', default => 'now()' }, + mtime => { type => 'timestamp' }, + rate => { type => 'numeric', default => '0', not_null => 1, precision => 15, scale => 5 }, + skonto_purchase_chart_id => { type => 'integer' }, + skonto_sales_chart_id => { type => 'integer' }, + taxdescription => { type => 'text', not_null => 1 }, + taxkey => { type => 'integer', not_null => 1 }, + taxnumber => { type => 'text' }, ); __PACKAGE__->meta->primary_key_columns([ 'id' ]); @@ -29,6 +31,21 @@ __PACKAGE__->meta->foreign_keys( class => 'SL::DB::Chart', key_columns => { chart_id => 'id' }, }, + + skonto_purchase_chart => { + class => 'SL::DB::Chart', + key_columns => { skonto_purchase_chart_id => 'id' }, + }, + + skonto_sales_chart => { + class => 'SL::DB::Chart', + key_columns => { skonto_sales_chart_id => 'id' }, + }, + + skonto_sales_chart_obj => { + class => 'SL::DB::Chart', + key_columns => { skonto_sales_chart_id => 'id' }, + }, ); 1; diff --git a/SL/DB/Part.pm b/SL/DB/Part.pm index 2a74b74c6..57f0f31ab 100644 --- a/SL/DB/Part.pm +++ b/SL/DB/Part.pm @@ -243,7 +243,7 @@ flavours called: These types are sadly represented by data inside the class and cannot be migrated into a flag. To work around this, each C object knows what type -it currently is. Since the type ist data driven, there ist no explicit setting +it currently is. Since the type is data driven, there ist no explicit setting method for it, but you can construct them explicitly with C, C, and C. A Buchungsgruppe should be supplied in this case, but it will use the default Buchungsgruppe if you don't. @@ -263,7 +263,7 @@ L and others. =item C Will set the appropriate data fields so that the resulting instance will be of -tthe requested type. Since part of the distinction are accounting targets, +the requested type. Since accounting targets are part of the distinction, providing a C is recommended. If none is given the constructor will load a default one and set the accounting targets from it. @@ -336,12 +336,12 @@ The information retrieved by the function is cached. =item C -Checks if this articke is used in orders, invoices, delivery orders or +Checks if this article is used in orders, invoices, delivery orders or assemblies. =item C -Used to set the accounting informations from a L object. +Used to set the accounting information from a L object. Please note, that this is a write only accessor, the original Buchungsgruppe can not be retrieved from an article once set. diff --git a/SL/DB/PurchaseInvoice.pm b/SL/DB/PurchaseInvoice.pm index 78034c7f8..ed0523bb4 100644 --- a/SL/DB/PurchaseInvoice.pm +++ b/SL/DB/PurchaseInvoice.pm @@ -8,6 +8,7 @@ use SL::DB::MetaSetup::PurchaseInvoice; use SL::DB::Manager::PurchaseInvoice; use SL::DB::Helper::AttrHTML; use SL::DB::Helper::LinkedRecords; +use SL::DB::Helper::Payment qw(:ALL); use SL::Locale::String qw(t8); # The calculator hasn't been adjusted for purchase invoices yet. @@ -82,48 +83,6 @@ sub abbreviation { }; -sub pay_invoice { - my ($self, %params) = @_; - - #Mark invoice as paid - $self->paid($self->paid+$params{amount}); - $self->save; - - Common::check_params(\%params, qw(chart_id trans_id amount transdate)); - - #account of bank account or cash - my $account_bank = SL::DB::Manager::Chart->find_by(id => $params{chart_id}); - - #Search the contra account - my $acc_trans = SL::DB::Manager::AccTransaction->find_by(trans_id => $params{trans_id}, - or => [ chart_link => { like => "%:AP" }, - chart_link => { like => "AP:%" }, - chart_link => "AP" ]); - my $contra_account = SL::DB::Manager::Chart->find_by(id => $acc_trans->chart_id); - - #Two new transfers in acc_trans (for bank account and for contra account) - my $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $params{trans_id}, - chart_id => $account_bank->id, - chart_link => $account_bank->link, - amount => $params{amount}, - transdate => $params{transdate}, - source => $params{source}, - memo => '', - taxkey => 0, - tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); - $new_acc_trans->save; - $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $params{trans_id}, - chart_id => $contra_account->id, - chart_link => $contra_account->link, - amount => (-1 * $params{amount}), - transdate => $params{transdate}, - source => $params{source}, - memo => '', - taxkey => 0, - tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); - $new_acc_trans->save; -} - sub link { my ($self) = @_; diff --git a/SL/Presenter/Invoice.pm b/SL/Presenter/Invoice.pm index b18c064b7..151f500eb 100644 --- a/SL/Presenter/Invoice.pm +++ b/SL/Presenter/Invoice.pm @@ -5,10 +5,28 @@ use strict; use parent qw(Exporter); use Exporter qw(import); -our @EXPORT = qw(sales_invoice ar_transaction purchase_invoice ap_transaction gl_transaction); +our @EXPORT = qw(invoice sales_invoice ar_transaction purchase_invoice ap_transaction gl_transaction); use Carp; +sub invoice { + my ($self, $invoice, %params) = @_; + + if ( $invoice->is_sales ) { + if ( $invoice->invoice ) { + return _is_ir_record($self, $invoice, 'is', %params); + } else { + return _is_ir_record($self, $invoice, 'ar', %params); + } + } else { + if ( $invoice->invoice ) { + return _is_ir_record($self, $invoice, 'ir', %params); + } else { + return _is_ir_record($self, $invoice, 'ap', %params); + } + }; +}; + sub sales_invoice { my ($self, $invoice, %params) = @_; @@ -85,10 +103,36 @@ transaction, purchase invoice and AP transaction Rose::DB objects my $object = SL::DB::Manager::PurchaseInvoice->get_first(where => [ or => [ invoice => undef, invoice => 0 ]]); my $html = SL::Presenter->get->ar_transaction($object, display => 'inline'); + # use with any of the above ar/ap/is/ir types: + my $html = SL::Presenter->get->invoice($object, display => 'inline'); + =head1 FUNCTIONS =over 4 +=item C + +Returns a rendered version (actually an instance of +L) of an ar/ap/is/ir object C<$object> . Determines +which type (sales or purchase, invoice or not) the object is. + +C<%params> can include: + +=over 2 + +=item * display + +Either C (the default) or C. At the moment both +representations are identical and produce the invoice number linked +to the corresponding 'edit' action. + +=item * no_link + +If falsish (the default) then the invoice number will be linked to the +"edit invoice" dialog from the sales menu. + +=back + =item C Returns a rendered version (actually an instance of diff --git a/SL/SEPA.pm b/SL/SEPA.pm index 63efe2c5a..cbea8417e 100644 --- a/SL/SEPA.pm +++ b/SL/SEPA.pm @@ -4,7 +4,12 @@ use strict; use POSIX qw(strftime); +use Data::Dumper; use SL::DBUtils; +use SL::DB::Invoice; +use SL::DB::PurchaseInvoice; +use SL::Locale::String qw(t8); +use DateTime; sub retrieve_open_invoices { $main::lxdebug->enter_sub(); @@ -21,9 +26,21 @@ sub retrieve_open_invoices { my $mandate = $params{vc} eq 'customer' ? " AND COALESCE(vc.mandator_id, '') <> '' AND vc.mandate_date_of_signature IS NOT NULL " : ''; + # in query: for customers, use payment terms from invoice, for vendors use + # payment terms from vendor settings + # currently there is no option in vendor invoices for setting payment terms, + # so the vendor settings are always used + + my $payment_term_type = $params{vc} eq 'customer' ? "${arap}" : 'vc'; + + # open_amount is not the current open amount according to bookkeeping, but + # the open amount minus the SEPA transfer amounts that haven't been closed yet my $query = qq| - SELECT ${arap}.id, ${arap}.invnumber, ${arap}.${vc}_id as vc_id, ${arap}.amount AS invoice_amount, ${arap}.invoice, + SELECT ${arap}.id, ${arap}.invnumber, ${arap}.transdate, ${arap}.${vc}_id as vc_id, ${arap}.amount AS invoice_amount, ${arap}.invoice, + (${arap}.transdate + pt.terms_skonto) as skonto_date, (pt.percent_skonto * 100) as percent_skonto, + (${arap}.amount - (${arap}.amount * pt.percent_skonto)) as amount_less_skonto, + (${arap}.amount * pt.percent_skonto) as skonto_amount, vc.name AS vcname, vc.language_id, ${arap}.duedate as duedate, ${arap}.direct_debit, COALESCE(vc.iban, '') <> '' AND COALESCE(vc.bic, '') <> '' ${mandate} AS vc_bank_info_ok, @@ -40,6 +57,8 @@ sub retrieve_open_invoices { GROUP BY sei.ap_id) AS open_transfers ON (${arap}.id = open_transfers.ap_id) + LEFT JOIN payment_terms pt ON (${payment_term_type}.payment_id = pt.id) + WHERE ${arap}.amount > (COALESCE(open_transfers.amount, 0) + ${arap}.paid) ORDER BY lower(vc.name) ASC, lower(${arap}.invnumber) ASC @@ -47,6 +66,21 @@ sub retrieve_open_invoices { my $results = selectall_hashref_query($form, $dbh, $query); + # add some more data to $results: + # create drop-down data for payment types and suggest amount to be paid according + # to open amount or skonto + + foreach my $result ( @$results ) { + my $invoice = $vc eq 'customer' ? SL::DB::Manager::Invoice->find_by( id => $result->{id} ) + : SL::DB::Manager::PurchaseInvoice->find_by( id => $result->{id} ); + + $invoice->get_payment_suggestions(sepa => 1); # consider amounts of open entries in sepa_export_items + $result->{skonto_amount} = $invoice->skonto_amount; + $result->{within_skonto_period} = $invoice->within_skonto_period; + $result->{invoice_amount_suggestion} = $invoice->{invoice_amount_suggestion}; + $result->{payment_select_options} = $invoice->{payment_select_options}; + }; + $main::lxdebug->leave_sub(); return $results; @@ -84,10 +118,12 @@ sub create_export { my $q_insert = qq|INSERT INTO sepa_export_items (id, sepa_export_id, ${arap}_id, chart_id, amount, requested_execution_date, reference, end_to_end_id, - our_iban, our_bic, vc_iban, vc_bic ${c_mandate}) + our_iban, our_bic, vc_iban, vc_bic, + skonto_amount, payment_type ${c_mandate}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ? ${p_mandate})|; + ?, ?, ?, ?, + ?, ? ${p_mandate})|; my $h_insert = prepare_query($form, $dbh, $q_insert); my $q_reference = @@ -133,6 +169,17 @@ sub create_export { $transfer->{amount}, conv_date($transfer->{requested_execution_date}), $transfer->{reference}, $end_to_end_id, map { my $pfx = $_; map { $transfer->{"${pfx}_${_}"} } qw(iban bic) } qw(our vc)); + # save value of skonto_amount and payment_type + if ( $transfer->{payment_type} eq 'without_skonto' ) { + push(@values, 0); + } elsif ($transfer->{payment_type} eq 'difference_as_skonto' ) { + push(@values, $transfer->{amount}); + } elsif ($transfer->{payment_type} eq 'with_skonto_pt' ) { + push(@values, $transfer->{skonto_amount}); + } else { + die "illegal payment_type: " . $transfer->{payment_type} . "\n"; + }; + push(@values, $transfer->{payment_type}); push @values, $transfer->{vc_mandator_id}, conv_date($transfer->{vc_mandate_date_of_signature}) if $params{vc} eq 'customer'; @@ -392,6 +439,7 @@ sub post_payment { map { unshift @{ $_ }, prepare_query($form, $dbh, $_->[0]) } values %handles; foreach my $item (@items) { + my $item_id = conv_i($item->{id}); # Retrieve the item data belonging to the ID. @@ -400,23 +448,25 @@ sub post_payment { next if (!$orig_item); - # Retrieve the invoice's AR/AP chart ID. - do_statement($form, @{ $handles{get_arap} }, $orig_item->{"${arap}_id"}); - my ($arap_chart_id) = $handles{get_arap}->[0]->fetchrow_array(); - - # Record the payment in acc_trans offsetting AR/AP. - do_statement($form, @{ $handles{add_acc_trans} }, $orig_item->{"${arap}_id"}, $arap_chart_id, -1 * $mult * $orig_item->{amount}, $item->{execution_date}, '', $arap_chart_id); - do_statement($form, @{ $handles{add_acc_trans} }, $orig_item->{"${arap}_id"}, $orig_item->{chart_id}, $mult * $orig_item->{amount}, $item->{execution_date}, $orig_item->{reference}, - $orig_item->{chart_id}); - - # Update the invoice to reflect the new paid amount. - do_statement($form, @{ $handles{update_arap} }, $orig_item->{amount}, $orig_item->{"${arap}_id"}); - - # Update datepaid of invoice. set_datepaid (which has some extra logic) - # finds the date from acc_trans, where the payment has already been - # recorded above, so we don't need to explicitly pass - # $item->{execution_date} - IO->set_datepaid(table => "$arap", id => $orig_item->{"${arap}_id"}, dbh => $dbh); + # fetch item_id via Rose (same id as orig_item) + my $sepa_export_item = SL::DB::Manager::SepaExportItem->find_by( id => $item_id); + + my $invoice; + + if ( $sepa_export_item->ar_id ) { + $invoice = SL::DB::Manager::Invoice->find_by( id => $sepa_export_item->ar_id); + } elsif ( $sepa_export_item->ap_id ) { + $invoice = SL::DB::Manager::PurchaseInvoice->find_by( id => $sepa_export_item->ap_id); + } else { + die "sepa_export_item needs either ar_id or ap_id\n"; + }; + + $invoice->pay_invoice(amount => $sepa_export_item->amount, + payment_type => $sepa_export_item->payment_type, + chart_id => $sepa_export_item->chart_id, + source => $sepa_export_item->reference, + transdate => $item->{execution_date}, # value from user form + ); # Update the item to reflect that it has been posted. do_statement($form, @{ $handles{finish_item} }, $item->{execution_date}, $item_id); diff --git a/bin/mozilla/sepa.pl b/bin/mozilla/sepa.pl index c9db0713e..f2170ae89 100755 --- a/bin/mozilla/sepa.pl +++ b/bin/mozilla/sepa.pl @@ -4,7 +4,8 @@ use List::MoreUtils qw(any none uniq); use List::Util qw(sum first); use POSIX qw(strftime); -use SL::BankAccount; +use Data::Dumper; +use SL::DB::BankAccount; use SL::Chart; use SL::CT; use SL::Form; @@ -25,7 +26,7 @@ sub bank_transfer_add { $form->{title} = $vc eq 'customer' ? $::locale->text('Prepare bank collection via SEPA XML') : $locale->text('Prepare bank transfer via SEPA XML'); - my $bank_accounts = SL::BankAccount->list(); + my $bank_accounts = SL::DB::Manager::BankAccount->get_all_sorted( query => [ obsolete => 0 ] ); if (!scalar @{ $bank_accounts }) { $form->error($locale->text('You have not added bank accounts yet.')); @@ -47,8 +48,6 @@ sub bank_transfer_add { # from us automatically and we don't have to send money manually. $_->{checked} = ($vc eq 'customer' ? $_->{direct_debit} : !$_->{direct_debit}) for @{ $invoices }; - my $bank_account_label_sub = sub { $locale->text('#1 - Account number #2, bank code #3, #4', $_[0]->{name}, $_[0]->{account_number}, $_[0]->{bank_code}, $_[0]->{bank} ) }; - my $translation_list = GenericTranslations->list(translation_type => 'sepa_remittance_info_pfx'); my %translations = map { ( ($_->{language_id} || 'default') => $_->{translation} ) } @{ $translation_list }; @@ -62,7 +61,6 @@ sub bank_transfer_add { print $form->parse_html_template('sepa/bank_transfer_add', { 'INVOICES' => $invoices, 'BANK_ACCOUNTS' => $bank_accounts, - 'bank_account_label' => $bank_account_label_sub, 'vc' => $vc, }); @@ -79,21 +77,24 @@ sub bank_transfer_create { $form->{title} = $vc eq 'customer' ? $::locale->text('Create bank collection via SEPA XML') : $locale->text('Create bank transfer via SEPA XML'); - my $bank_accounts = SL::BankAccount->list(); - + my $bank_accounts = SL::DB::Manager::BankAccount->get_all_sorted( query => [ obsolete => 0 ] ); if (!scalar @{ $bank_accounts }) { $form->error($locale->text('You have not added bank accounts yet.')); } - my $bank_account = first { $form->{bank_account}->{id} == $_->{id} } @{ $bank_accounts }; + my $bank_account = SL::DB::Manager::BankAccount->find_by( id => $form->{bank_account} ); - if (!$bank_account) { + unless ( $bank_account ) { $form->error($locale->text('The selected bank account does not exist anymore.')); } my $arap_id = $vc eq 'customer' ? 'ar_id' : 'ap_id'; my $invoices = SL::SEPA->retrieve_open_invoices(vc => $vc); + # load all open invoices (again), but grep out the ones that were selected with checkboxes beforehand ($_->selected). At this stage we again have all the invoice information, including dropdown with payment_type options + # all the information from retrieve_open_invoices is then ADDED to what was passed via @{ $form->{bank_transfers} } + # parse amount from the entry in the form, but take skonto_amount from PT again + # the map inserts the values of invoice_map directly into the array of hashes my %invoices_map = map { $_->{id} => $_ } @{ $invoices }; my @bank_transfers = map +{ %{ $invoices_map{ $_->{$arap_id} } }, %{ $_ } }, @@ -101,6 +102,18 @@ sub bank_transfer_create { map { $_->{amount} = $form->parse_amount($myconfig, $_->{amount}); $_ } @{ $form->{bank_transfers} || [] }; + # override default payment_type selection and set it to the one chosen by the user + # in the previous step, so that we don't need the logic in the template + foreach my $bt (@bank_transfers) { + foreach my $type ( @{$bt->{payment_select_options}} ) { + if ( $type->{payment_type} eq $bt->{payment_type} ) { + $type->{selected} = 1; + } else { + $type->{selected} = 0; + }; + }; + }; + if (!scalar @bank_transfers) { $form->error($locale->text('You have selected none of the invoices.')); } @@ -130,15 +143,12 @@ sub bank_transfer_create { 'id' => \@vc_ids); my @vc_bank_info = sort { lc $a->{name} cmp lc $b->{name} } values %{ $vc_bank_info }; - my $bank_account_label_sub = sub { $locale->text('#1 - Account number #2, bank code #3, #4', $_[0]->{name}, $_[0]->{account_number}, $_[0]->{bank_code}, $_[0]->{bank} ) }; - $form->header(); print $form->parse_html_template('sepa/bank_transfer_create', { 'BANK_TRANSFERS' => \@bank_transfers, 'BANK_ACCOUNTS' => $bank_accounts, 'VC_BANK_INFO' => \@vc_bank_info, 'bank_account' => $bank_account, - 'bank_account_label' => $bank_account_label_sub, 'error_message' => $error_message, 'vc' => $vc, 'total_trans' => $total_trans, diff --git a/config/kivitendo.conf.default b/config/kivitendo.conf.default index 25a8d4c3b..0ff2aa176 100644 --- a/config/kivitendo.conf.default +++ b/config/kivitendo.conf.default @@ -116,6 +116,10 @@ latex = pdflatex # binary. python_uno = python +# Location of the aqbanking binary to use when converting MT940 files +# into the kivitendo import format +aqbanking = /usr/bin/aqbanking-cli + [environment] # Add the following paths to the PATH environment variable. path = /usr/local/bin:/usr/X11R6/bin:/usr/X11/bin diff --git a/doc/changelog b/doc/changelog index 6e6789e04..5c9aaab46 100644 --- a/doc/changelog +++ b/doc/changelog @@ -2,6 +2,45 @@ # Veränderungen von kivitendo # ############################### +2015-0x-xx - Release 3.2.2-unstable + + +Größere neue Features: + +Bankerweiterung und Skontobehandlung + + Bei der Bankerweiterung kann man + * Kontoauszüge importieren (für MT940 wird aqbanking-cli benötigt) + * anhand der Kontoauszüge Zahlungen verbuchen + * die FiBu-Buchungen auf die Bankkonten mit den importieren Auszügen + abgleichen + +__Es wurde ein neues Recht "Bankbewegungen" eingeführt. + + Beim Verbuchen der Zahlungen werden Rechnungsvorschläge gemacht, die anhand + eines internen Punktesystems bewertet werden. + + Es wurde eine Skontobehandlung bei der Zahlung der Rechnungen implementiert, + und zwar nach der Bruttomethode. D.h. es wird der skontierte Betrag auf + erhaltene oder gewährte Skonti gebucht, allerdings gibt es hier keine + Steuerautomatik, d.h. man muß am Monatsende die Salden noch manuell umbuchen. + + Die zu buchenden Skontokonten müssen unter System->Steuern konfiguriert + werden. + + Die Skontobehandlung wurde beim Verbuchen der Skontobelege und beim + SEPA-Einzug bzw der SEPA-Überweisung implementiert. + Beim Bezahlen von Rechnungen kann man auswählen ob man die Zahlung + * ohne Skonto + * mit Skonto laut Zahlungsbedingungen + * die Differenz als Skonto + buchen möchte. Es wird je nach Zahlungsbetrag und Zahlungsdatum ein sinnvoller + Vorschlag gemacht. + + +Kleinere neue Features und Detailverbesserungen: + + 2015-04-10 - Release 3.2.1 Dies ist ein Unstable Bugfix-Release für die 3.2. D.h. es wurden ein paar diff --git a/js/locale/de.js b/js/locale/de.js index 0d8484815..d5276b73e 100644 --- a/js/locale/de.js +++ b/js/locale/de.js @@ -64,6 +64,7 @@ namespace("kivi").setupLocale({ "Select template to paste":"Einzufügende Vorlage auswählen", "Text block actions":"Textblockaktionen", "Text block picture actions":"Aktionen für Textblockbilder", +"The IBAN is missing.":"Die IBAN fehlt.", "The description is missing.":"Die Beschreibung fehlt.", "The name is missing.":"Der Name fehlt.", "The name must only consist of letters, numbers and underscores and start with a letter.":"Der Name darf nur aus Buchstaben (keine Umlaute), Ziffern und Unterstrichen bestehen und muss mit einem Buchstaben beginnen.", diff --git a/locale/de/all b/locale/de/all index c9bb4401f..97f3b5b95 100755 --- a/locale/de/all +++ b/locale/de/all @@ -15,20 +15,17 @@ $self->{texts} = { ' Part Number missing!' => ' Artikelnummer fehlt!', ' missing!' => ' fehlt!', '#1 (custom variable)' => '#1 (benutzerdefinierte Variable)', - '#1 - Account number #2, bank code #3, #4' => '#1 - Kontonummber #2, BLZ #3, #4', '#1 MD' => '#1 PT', '#1 additional part(s)' => '#1 zusätzliche(r) Artikel', '#1 h' => '#1 h', '#1 of #2 importable objects were imported.' => '#1 von #2 importierbaren Objekten wurden importiert.', '#1 prices were updated.' => '#1 Preise wurden aktualisiert.', + '#1 proposal(s) saved.' => '#1 Vorschläge gespeichert.', '#1 section(s)' => '#1 Abschnitt(e)', '#1 text block(s) back' => '#1 Textlock/-blöcke vorne', '#1 text block(s) front' => '#1 Textblock/-blöcke hinten', '%' => '%', '(recommended) Insert the used currencies in the system. You can simply change the name of the currencies by editing the textfields above. Do not use a name of a currency that is already in use.' => '(empfohlen) Fügen Sie die verwaisten Währungen in Ihr System ein. Sie können den Namen der Währung einfach ändern, indem Sie die Felder oben bearbeiten. Benutzen Sie keine Namen von Währungen, die Sie bereits benutzen.', - '#1 proposal(s) saved.' => '#1 Vorschläge gespeichert.', - '#1, Account number #2, bank code #3' => '#1, Kontonummer #2, BLZ #3', - '(Purchase)' => '', '*/' => '*/', ', if set' => ', falls gesetzt', '---please select---' => '---bitte auswählen---', @@ -85,8 +82,8 @@ $self->{texts} = { 'AUTOMATICALLY MATCH BINS' => 'LAGERPLÄTZE AUTOMATISCH ZUWEISEN', 'Abort' => 'Abbrechen', 'Abrechnungsnummer' => 'Abrechnungsnummer', - 'Absolute BB Balance' => 'Gesamtsaldo laut Kontoauszug', - 'Absolute BT Balance' => 'Gesamtsaldo laut Bankbuchungen', + 'Absolute BB Balance' => 'Gesamtsaldo laut Bankbuchungen', + 'Absolute BT Balance' => 'Gesamtsaldo laut Kontoauszug', 'Abteilung' => 'Abteilung', 'Acceptance Statuses' => 'Abnahmestatus', 'Acc Transaction' => 'Hauptbuch', @@ -187,7 +184,7 @@ $self->{texts} = { 'Add bank account' => 'Bankkonto erfassen', 'Add custom variable' => 'Benutzerdefinierte Variable erfassen', 'Add function block' => 'Funktionsblock hinzufügen', - 'Add invoices' => 'Rechnung hinzufügen', + 'Add invoices' => 'Rechnungen hinzufügen', 'Add link: select records to link with' => 'Verknüpfungen hinzufügen: zu verknüpfende Belege auswählen', 'Add linked record' => 'Verknüpften Beleg hinzufügen', 'Add links' => 'Verknüpfungen hinzufügen', @@ -242,10 +239,11 @@ $self->{texts} = { 'Amended Advance Turnover Tax Return (Nr. 10)' => 'Ist dies eine berichtigte Anmeldung? (Nr. 10/Zeile 15 Steuererklärung)', 'Amount' => 'Betrag', 'Amount (for verification)' => 'Betrag (zur Überprüfung)', - 'Amount BB' => 'Betrag Bank', - 'Amount BT' => 'Betrag Buchungen', + 'Amount BB' => 'Betrag Buchungen', + 'Amount BT' => 'Betrag Bank', 'Amount Due' => 'Betrag fällig', 'Amount and net amount are calculated by kivitendo. "verify_amount" and "verify_netamount" can be used for sanity checks.' => 'Betrag und Nettobetrag werden von kivitendo berechnet. "verify_amount" und "verify_netamount" können für Plausibilitätsprüfungen angegeben werden.', + 'Amount less skonto' => 'Betrag abzgl. Skonto', 'Amount payable' => 'Noch zu bezahlender Betrag', 'Amount payable less discount' => 'Noch zu bezahlender Betrag abzüglich Skonto', 'An exception occurred during execution.' => 'Während der Ausführung trat eine Ausnahme auf.', @@ -318,6 +316,8 @@ $self->{texts} = { 'Auto Send?' => 'Auto. Versand?', 'Automatic deletion of leading, trailing and excessive (repetitive) spaces in customer or vendor names' => 'Automatisches Löschen von voran-/nachgestellten und aufeinanderfolgenden Leerzeichen im Kunden- oder Lieferantennamen', 'Automatic deletion of leading, trailing and excessive (repetitive) spaces in part description and part notes. Affects the CSV import as well.' => 'Automatisches Löschen von voran-/nachgestellten und aufeinanderfolgenden Leerzeichen in Artikelbeschreibungen und -bemerkungen. Betrifft auch den CSV-Import.', + 'Automatic skonto chart purchase' => 'Skontoautomatik Einkauf', + 'Automatic skonto chart sales' => 'Skontoautomatik Verkauf', 'Automatically created invoice for fee and interest for dunning %s' => 'Automatisch erzeugte Rechnung für Gebühren und Zinsen zu Mahnung %s', 'Available' => 'Verfügbar', 'Available Prices' => 'Mögliche Preise', @@ -340,13 +340,14 @@ $self->{texts} = { 'Balances' => 'Salden', 'Balancing' => 'Bilanzierung', 'Bank' => 'Bank', + 'Bank Account can\'t be found' => 'Bankkkonto kann nicht gefunden werden', 'Bank Code' => 'BLZ', 'Bank Code (long)' => 'Bankleitzahl (BLZ)', 'Bank Code Number' => 'Bankleitzahl', 'Bank Connection Tax Office' => 'Bankverbindung des Finanzamts', 'Bank Connections' => 'Bankverbindungen', + 'Bank Import' => 'Kontoauszug importieren', 'Bank Transaction' => 'Bankkonto', - 'Bank Transactions' => 'Bankbewegungen', 'Bank account' => 'Bankkonto', 'Bank accounts' => 'Bankkonten', 'Bank code' => 'Bankleitzahl', @@ -357,7 +358,7 @@ $self->{texts} = { 'Bank collections via SEPA' => 'Bankeinzüge via SEPA', 'Bank transaction' => 'Bankbuchung', 'Bank transactions' => 'Bankbewegungen', - 'Bank transactions MT940' => 'Elektr. Kontoauszug', + 'Bank transactions MT940' => 'Kontoauszug verbuchen', 'Bank transfer amount' => 'Überweisungssumme', 'Bank transfer payment list for export #1' => 'Überweisungszahlungsliste für SEPA-Export #1', 'Bank transfer via SEPA' => 'Überweisung via SEPA', @@ -450,7 +451,9 @@ $self->{texts} = { 'CRM termin' => 'Termine', 'CRM user' => 'Admin Benutzer', 'CSS style for pictures' => 'CSS Style für Bilder', + 'CSV' => 'CSV', 'CSV export -- options' => 'CSV-Export -- Optionen', + 'CSV import: MT940' => 'CSV Import: MT940', 'CSV import: bank transactions' => 'CSV Import: Bankbewegungen', 'CSV import: contacts' => 'CSV-Import: Ansprechpersonen', 'CSV import: customers and vendors' => 'CSV-Import: Kunden und Lieferanten', @@ -544,7 +547,6 @@ $self->{texts} = { 'Choose Vendor' => 'Händler wählen', 'Choose a Tax Number' => 'Bitte eine Steuernummer angeben', 'Choose bank account for reconciliation' => 'Wählen Sie das Bankkonto für den Kontenabgleich', - 'Choose chart' => 'Konto auswählen', 'City' => 'Stadt', 'Clear fields' => 'Felder leeren', 'Cleared Balance' => 'abgeschlossen', @@ -616,7 +618,7 @@ $self->{texts} = { 'Could not load this customer' => 'Konnte diesen Kunden nicht laden', 'Could not load this vendor' => 'Konnte diesen Lieferanten nicht laden', 'Could not print dunning.' => 'Die Mahnungen konnten nicht gedruckt werden.', - 'Could not reconciliate chosen elements!' => 'Die gewählten Elemente konnten nicht ausgeglichen werden!', + 'Could not reconcile chosen elements!' => 'Die gewählten Elemente konnten nicht ausgeglichen werden!', 'Could not spawn ghostscript.' => 'Die Anwendung "ghostscript" konnte nicht gestartet werden.', 'Could not spawn the printer command.' => 'Die Druckanwendung konnte nicht gestartet werden.', 'Could not update prices!' => 'Preise konnten nicht aktualisiert werden!', @@ -899,6 +901,7 @@ $self->{texts} = { 'Details (one letter abbreviation)' => 'D', 'Dial command missing in kivitendo configuration\'s [cti] section' => 'Wählbefehl fehlt im Abschnitt [cti] der kivitendo-Konfiguration', 'Difference' => 'Differenz', + 'Difference as skonto' => 'Differenz als Skonto', 'Dimensions' => 'Abmessungen', 'Directory' => 'Verzeichnis', 'Disabled Price Sources' => 'Deaktivierte Preisquellen', @@ -1122,7 +1125,7 @@ $self->{texts} = { 'Error: Invalid delivery terms' => 'Fehler: Lieferbedingungen ungültig', 'Error: Invalid department' => 'Fehler: Abteilung ungültig', 'Error: Invalid language' => 'Fehler: Sprache ungültig', - 'Error: Invalid local bank account' => '', + 'Error: Invalid local bank account' => 'Fehler: ungültiges Bankkonto', 'Error: Invalid order for this order item' => 'Fehler: Auftrag für diese Position ungültig', 'Error: Invalid part' => 'Fehler: Artikel ungültig', 'Error: Invalid part type' => 'Fehler: Artikeltyp ungültig', @@ -1167,6 +1170,7 @@ $self->{texts} = { 'Execution status' => 'Ausführungsstatus', 'Execution type' => 'Ausführungsart', 'Existing Datasets' => 'Existierende Datenbanken', + 'Existing bank transactions' => 'Existierende Bankbuchungen', 'Existing contacts (with column \'cp_id\')' => 'Existierende Ansprechpersonen (mit Spalte \'cp_id\')', 'Existing customers/vendors with same customer/vendor number' => 'Existierende Kunden/Lieferanten mit derselben Kunden-/Lieferantennummer', 'Existing file on server' => 'Auf dem Server existierende Datei', @@ -1319,9 +1323,9 @@ $self->{texts} = { 'I' => 'I', 'IBAN' => 'IBAN', 'ID' => 'Buchungsnummer', - 'ID of own bank account' => '', + 'ID of own bank account' => 'Datenbank-ID des Bankkontos', 'ID-Nummer' => 'ID-Nummer (intern)', - 'ID/Acc_ID' => '', + 'ID/Acc_ID' => 'ID/Acc_ID', 'II' => 'II', 'III' => 'III', 'IV' => 'IV', @@ -1351,7 +1355,9 @@ $self->{texts} = { 'If you want to delete such a dataset you have to edit the client(s) that are using the dataset in question and have them use another dataset.' => 'Wenn Sie eine solche Datenbank löschen möchten, dann müssen Sie zuerst den/die Mandanten auf eine andere Datenbank umstellen, die die zu löschende Datenbank benutzen.', 'If you want to set up the authentication database yourself then log in to the administration panel. kivitendo will then create the database and tables for you.' => 'Wenn Sie die Authentifizierungs-Datenbank selber einrichten wollen, so melden Sie sich im Administrationsbereich an. kivitendo wird dann die Datenbank und die erforderlichen Tabellen für Sie anlegen.', 'If your old bins match exactly Bins in the Warehouse CLICK on AUTOMATICALLY MATCH BINS.' => 'Falls die alte Lagerplatz-Beschreibung in Stammdaten genau mit einem Lagerplatz in einem vorhandenem Lager übereinstimmt, KLICK auf LAGERPLÄTZE AUTOMATISCH ZUWEISEN', + 'Illegal amount' => 'Ungültiger Betrag', 'Illegal characters have been removed from the following fields: #1' => 'Ungültige Zeichen wurden aus den folgenden Feldern entfernt: #1', + 'Illegal date' => 'Ungültiges Datum', 'Image' => 'Grafik', 'Import' => 'Import', 'Import CSV' => 'CSV-Import', @@ -1388,6 +1394,7 @@ $self->{texts} = { 'Information' => 'Information', 'Initial version.' => 'Initiale Version.', 'Insert' => 'Einfügen', + 'Insert new' => 'Hinzufügen', 'Insert with new customer/vendor number' => 'Mit neuer Kunden-/Lieferantennummer anlegen', 'Insert with new database ID' => 'Neu anlegen mit neuer Datenbank-ID', 'Insert with new part number' => 'Mit neuer Artikelnummer einfügen', @@ -1417,13 +1424,18 @@ $self->{texts} = { 'Invnumber' => 'Rechnungsnummer', 'Invnumber missing!' => 'Rechnungsnummer fehlt!', 'Invoice' => 'Rechnung', + 'Invoice #1: paid #2 to bank #3, rest for skonto.' => 'Rechnung #1: #2 an Konto #3, Rest als Skonto', + 'Invoice #1: paid #2 to bank #3.' => 'Rechnung #1: #2 an Konto #3', + 'Invoice #1: paid #2 to skonto.' => 'Rechnung #1: #2 als Skonto bezahlt', 'Invoice (one letter abbreviation)' => 'R', 'Invoice Date' => 'Rechnungsdatum', 'Invoice Date missing!' => 'Rechnungsdatum fehlt!', 'Invoice Duedate' => 'Fälligkeitsdatum', 'Invoice Number' => 'Rechnungsnummer', 'Invoice Number missing!' => 'Rechnungsnummer fehlt!', + 'Invoice can\'t be found' => '', 'Invoice deleted!' => 'Rechnung gelöscht!', + 'Invoice filter' => 'Rechnungsfilter', 'Invoice for fees' => 'Rechnung über Gebühren', 'Invoice has already been storno\'d!' => 'Diese Rechnung wurde bereits storniert.', 'Invoice number' => 'Rechnungsnummer', @@ -1528,9 +1540,7 @@ $self->{texts} = { 'List Users, Clients and User Groups' => 'Benutzer, Mandanten und Benutzergruppen anzeigen', 'List current background jobs' => 'Aktuelle Hintergrund-Jobs anzeigen', 'List export' => 'Export anzeigen', - 'List of bank accounts' => 'Liste der Bankkonten', 'List of bank collections' => 'Bankeinzugsliste', - 'List of bank transactions' => 'Liste der Bankbewegungen', 'List of bank transfers' => 'Überweisungsliste', 'List of custom variables' => 'Liste der benutzerdefinierten Variablen', 'List of database upgrades to be applied:' => 'Liste der noch einzuspielenden Datenbankupgrades:', @@ -1543,7 +1553,7 @@ $self->{texts} = { 'Local Bank Code' => 'Lokale Bankleitzahl', 'Local Tax Office Preferences' => 'Angaben zum Finanzamt', 'Local account number' => 'Lokale Kontonummer', - 'Local bank account' => '', + 'Local bank account' => 'Lokales Bankkonto', 'Local bank code' => 'Lokale Bankleitzahl', 'Lock System' => 'System sperren', 'Lock and unlock installation' => 'Installation sperren/entsperren', @@ -1560,6 +1570,8 @@ $self->{texts} = { 'MAILED' => 'Gesendet', 'MD' => 'PT', 'MIME type' => 'MIME-Typ', + 'MT940' => 'MT940', + 'MT940 import' => 'MT940 Import', 'Machine' => 'Maschine', 'Main Preferences' => 'Grundeinstellungen', 'Main sorting' => 'Hauptsortierung', @@ -1634,11 +1646,9 @@ $self->{texts} = { 'Name and Street' => 'Name und Straße', 'Name does not make sense without any bsooqr options' => 'Option "Name in gewählten Belegen" wird ignoriert.', 'Name in Selected Records' => 'Name in gewählten Belegen', + 'Name of the goal/source (if field names remote_name and remote_name_1 exist they will be combined into field "remote_name")' => 'Name des Ziel- oder Quellkontos (wenn die Spalten remote_name und remote_name_1 existieren werden diese zu Feld "remote_name" zusammengefügt)', 'Negative reductions are possible to model price increases.' => 'Negative Abschläge sind möglich um Aufschläge zu modellieren.', 'Neither sections nor function blocks have been created yet.' => 'Es wurden bisher weder Abschnitte noch Funktionsblöcke angelegt.', - 'Name of the goal/source' => 'Name des Ziel- oder Quellkontos', - 'National Expenses' => 'Aufwand Inland', - 'National Revenues' => 'Erlöse Inland', 'Net Income Statement' => 'Einnahmenüberschußrechnung', 'Net amount' => 'Nettobetrag', 'Net amount (for verification)' => 'Nettobetrag (zur Überprüfung)', @@ -1721,7 +1731,7 @@ $self->{texts} = { 'No text blocks have been created for this position.' => 'Für diese Position wurden noch keine Textblöcke angelegt.', 'No text has been entered yet.' => 'Es wurde noch kein Text eingegeben.', 'No title yet' => 'Bisher ohne Titel', - 'No transaction on chart bank chosen!' => '', + 'No transaction on chart bank chosen!' => 'Keine Buchung auf Bankkonto gewählt.', 'No transaction selected!' => 'Keine Transaktion ausgewählt', 'No transactions yet.' => 'Bisher keine Buchungen.', 'No transfers were executed in this export.' => 'In diesem SEPA-Export wurden keine Überweisungen ausgeführt.', @@ -1835,7 +1845,7 @@ $self->{texts} = { 'Override' => 'Override', 'Override invoice language' => 'Diese Sprache verwenden', 'Overview' => 'Übersicht', - 'Own bank account number' => 'Eigene Kontonummer', + 'Own bank account number or IBAN' => 'Eigene Kontonummer oder IBAN', 'Own bank code' => 'Eigene Bankleitzahl', 'Owner of account' => 'Kontoinhaber', 'PAYMENT POSTED' => 'Rechung gebucht', @@ -1892,6 +1902,7 @@ $self->{texts} = { 'Payment terms' => 'Zahlungsbedingungen', 'Payment terms (database ID)' => 'Zahlungsbedingungen (Datenbank-ID)', 'Payment terms (name)' => 'Zahlungsbedingungen (Name)', + 'Payment type' => 'Zahlungsart', 'Payments' => 'Zahlungsausgänge', 'Payments Changeable' => 'Änderbarkeit von Zahlungen', 'Per. Inv.' => 'Wied. Rech.', @@ -2067,6 +2078,7 @@ $self->{texts} = { 'Purchase price total' => 'EK-Betrag', 'Purchasing & Sales' => 'Einkauf & Verkauf', 'Purpose' => 'Verwendungszweck', + 'Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")' => 'Verwendungszweck (wenn die Spalten purpose, purpose1, purpose2 ... existieren werden diese zum Feld "purpose" zusammengefügt)', 'Purpose/Reference' => 'Verwendungszweck und Referenz', 'Qty' => 'Menge', 'Qty according to delivery order' => 'Menge laut Lieferschein', @@ -2111,9 +2123,8 @@ $self->{texts} = { 'Receipt, payment, reconciliation' => 'Zahlungseingang, Zahlungsausgang, Kontenabgleich', 'Receipts' => 'Zahlungseingänge', 'Receivables' => 'Forderungen', - 'Rechnung/Buchung' => 'Invoice/transaction', 'Rechnungsnummer' => 'Rechnungsnummer', - 'Reconciliate' => 'Abgleichen', + 'Reconcile' => 'Abgleichen', 'Reconciliation' => 'Kontenabgleich', 'Reconciliation with bank' => 'Kontenabgleich mit Bank', 'Record Vendor Invoice' => 'Einkaufsrechnung erfassen', @@ -2137,7 +2148,6 @@ $self->{texts} = { 'Remote account number' => 'Fremde Kontonummer', 'Remote bank code' => 'Fremde Bankleitzahl', 'Remote name' => 'Fremder Kontoinhaber', - 'Remote name 1' => 'Fremder Kontoname 1', 'Removal' => 'Entnahme', 'Removal from Warehouse' => 'Lagerentnahme', 'Removal from warehouse' => 'Entnahme aus Lager', @@ -2286,6 +2296,7 @@ $self->{texts} = { 'Save settings as' => 'Einstellungen speichern unter', 'Saving failed. Error message from the database: #1' => 'Speichern schlug fehl. Fehlermeldung der Datenbank: #1', 'Saving the file \'%s\' failed. OS error message: %s' => 'Das Speichern der Datei \'%s\' schlug fehl. Fehlermeldung des Betriebssystems: %s', + 'Score' => 'Punkte', 'Screen' => 'Bildschirm', 'Search' => 'Suchen', 'Search AP Aging' => 'Offene Verbindlichkeiten', @@ -2415,11 +2426,15 @@ $self->{texts} = { 'Sketch' => 'Skizze', 'Skip' => 'Überspringen', 'Skip entry' => 'Eintrag überspringen', + 'Skipping because transfer amount is empty.' => 'Übersprungen wegen leeren Betrags.', + 'Skipping due to existing bank transaction in database' => 'Wegen schon existierender Bankbewegung in Datenbank übersprungen', 'Skipping due to existing entry in database' => 'Wegen existierendem Eintrag mit selber Nummer übersprungen', 'Skipping due to existing entry in database with different type' => 'Wegen existierendem Eintrag von unterschiedlichem Artikeltyp übersprungen', 'Skipping, for assemblies are not importable (yet)' => 'Übersprungen, da Erzeugnisse (noch) nicht importiert werden können', 'Skonto' => 'Skonto', 'Skonto Terms' => 'Zahlungsziel Skonto', + 'Skonto amount' => 'Skontobetrag', + 'Skonto information' => 'Skonto Information', 'So far you could use one partnumber for severel parts, for example a service and an article.' => 'Bisher war es möglich eine Artikelnummer für mehrere Artikel zu verwenden, zum Beispiel eine Artikelnummer für eine Dienstleistung, eine Ware und ein Erzeugnis.', 'Sold' => 'Verkauft', 'Soldtotal does not make sense without any bsooqr options' => 'Option "Menge in gewählten Belegen" ohne gewählte Belege wird ignoriert.', @@ -2839,7 +2854,7 @@ $self->{texts} = { 'There are entries in tax where taxkey is NULL.' => 'In der Datenbank sind Steuern ohne Steuerschlüssel vorhanden (in der Tabelle tax Spalte taxkey).', 'There are invalid taxnumbers in use.' => 'Es werden ungültige Steuerautomatik-Konten benutzt.', 'There are invalid transactions in your database.' => 'Sie haben ungültige Buchungen in Ihrer Datenbank.', - 'There are invoices which could not be payed by bank transaction #1 (Account number: #2, bank code: #3)!' => 'Einige Rechnungen konnten nicht durch die Bankbewegung #1 (Kontonummer: #2, Bankleitzahl: #3) bezahlt werden!', + 'There are invoices which could not be paid by bank transaction #1 (Account number: #2, bank code: #3)!' => 'Einige Rechnungen konnten nicht durch die Bankbewegung #1 (Kontonummer: #2, Bankleitzahl: #3) bezahlt werden!', 'There are no entries in the background job history.' => 'Es gibt keine Einträge im Hintergrund-Job-Verlauf.', 'There are no items in stock.' => 'Dieser Artikel ist nicht eingelagert.', 'There are no items on your TODO list at the moment.' => 'Ihre Aufgabenliste enthält momentan keine Einträge.', @@ -2958,10 +2973,10 @@ $self->{texts} = { 'Transactions without reference:' => 'Buchungen ohne Referenz:', 'Transactions, AR transactions, AP transactions' => 'Dialogbuchen, Debitorenrechnungen, Kreditorenrechnungen', 'Transdate' => 'Belegdatum', + 'Transdate from' => 'Kontoauszugsdatum von', 'Transdate is #1' => 'Belegdatum ist #1', 'Transdate is after #1' => 'Belegdatum ist nach #1', 'Transdate is before #1' => 'Belegdatum ist vor #1', - 'Transdate from' => 'Kontoauszugsdatum von', 'Transdate to' => 'Buchungsdatum bis', 'Transfer' => 'Umlagern', 'Transfer Quantity' => 'Umlagermenge', @@ -3064,7 +3079,7 @@ $self->{texts} = { 'Valid/Obsolete' => 'Gültig/ungültig', 'Value' => 'Wert', 'Value of transferred goods' => 'Verkaufswert der ausgelagerten Waren', - 'Valuta' => 'Valuta', + 'Valuta date' => 'Valutadatum', 'Valutadate' => 'Valutadatum', 'Valutadate from' => 'Valutadatum von', 'Valutadate to' => 'Valutadatum bis', @@ -3177,7 +3192,6 @@ $self->{texts} = { 'You have to define a unit as a multiple of a smaller unit.' => 'Sie müssen Einheiten als ein Vielfaches einer kleineren Einheit eingeben.', 'You have to enter a company name in the client configuration.' => 'Sie müssen in der Mandantenkonfiguration einen Firmennamen angeben.', 'You have to enter the SEPA creditor ID in the client configuration.' => 'Sie müssen in der Mandantenkonfiguration eine SEPA-Kreditoren-Identifikation angeben.', - 'You have to fill in at least a name, an account number, the bank code, the IBAN and the BIC.' => 'Sie müssen zumindest einen Namen, die Kontonummer, die Bankleitzahl, die IBAN und den BIC angeben.', 'You have to grant users access to one or more clients.' => 'Benutzern muss dann Zugriff auf einzelne Mandanten gewährt werden.', 'You have to specify a department.' => 'Sie müssen eine Abteilung wählen.', 'You have to specify an execution date for each antry.' => 'Sie müssen für jeden zu buchenden Eintrag ein Ausführungsdatum angeben.', @@ -3223,7 +3237,6 @@ $self->{texts} = { 'balance' => 'Betriebsvermögensvergleich/Bilanzierung', 'bank_collection_payment_list_#1' => 'bankeinzugszahlungsliste_#1', 'bank_transfer_payment_list_#1' => 'ueberweisungszahlungsliste_#1', - 'bankaccounts' => 'Bankkonten', 'banktransfers' => 'ueberweisungen', 'bestbefore #1' => 'Mindesthaltbarkeit #1', 'bin_list' => 'Lagerliste', @@ -3256,6 +3269,8 @@ $self->{texts} = { 'delete' => 'Löschen', 'delivered' => 'geliefert', 'deliverydate' => 'Lieferdatum', + 'difference as skonto' => 'Differenz als Skonto', + 'difference_as_skonto' => 'Differenz als Skonto', 'direct debit' => 'Lastschrifteinzug', 'disposed' => 'Entsorgung', 'do not include' => 'Nicht aufnehmen', @@ -3267,11 +3282,13 @@ $self->{texts} = { 'ea' => 'St.', 'emailed to' => 'gemailt an', 'empty' => 'leer', + 'error while paying invoice #1 : ' => 'Fehler beim Bezahlen von Rechnung #1 : ', 'every third month' => 'vierteljährlich', 'every time' => 'immer', 'executed' => 'ausgeführt', 'failed' => 'fehlgeschlagen', 'female' => 'weiblich', + 'finalised' => '', 'flat-rate position' => 'Pauschalposition', 'follow_up_list' => 'wiedervorlageliste', 'for' => 'für', @@ -3324,6 +3341,7 @@ $self->{texts} = { 'no article assigned yet' => 'noch kein Artikel zugewiesen', 'no bestbefore' => 'keine Mindesthaltbarkeit', 'no chargenumber' => 'keine Chargennummer', + 'no skonto_chart configured for taxkey #1 : #2 : #3' => 'Kein Skontokonto für Steuerschlüssel #1 : #2 : #3', 'not configured' => 'nicht konfiguriert', 'not delivered' => 'nicht geliefert', 'not executed' => 'nicht ausgeführt', @@ -3417,6 +3435,7 @@ $self->{texts} = { 'uncleared' => 'Nicht abgeglichen', 'unconfigured' => 'unkonfiguriert', 'uncorrect partnumber ' => 'Unbekannte Teilenummer ', + 'until' => 'bis', 'use program settings' => 'benutze Programmeinstellungen', 'use user config' => 'Verwende Benutzereinstellung', 'used' => 'Verbraucht', @@ -3426,6 +3445,10 @@ $self->{texts} = { 'vendor_list' => 'lieferantenliste', 'warehouse_journal_list' => 'lagerbuchungsliste', 'warehouse_report_list' => 'lagerbestandsliste', + 'with skonto acc. to pt' => 'mit Skonto nach ZB', + 'with_skonto_pt' => 'mit Skonto nach ZB', + 'without skonto' => 'ohne Skonto', + 'without_skonto' => 'ohne Skonto', 'working copy' => 'Arbeitskopie', 'wrongformat' => 'Falsches Format', 'yearly' => 'jährlich', diff --git a/locale/en/all b/locale/en/all index 92b686361..d3f54980f 100644 --- a/locale/en/all +++ b/locale/en/all @@ -2620,6 +2620,8 @@ $self->{texts} = { 'delete' => '', 'delivered' => '', 'deliverydate' => '', + 'difference as skonto' => '', + 'difference_as_skonto' => 'remainder as skonto', 'direct debit' => '', 'disposed' => '', 'do not include' => '', @@ -2778,6 +2780,10 @@ $self->{texts} = { 'vendor_list' => '', 'warehouse_journal_list' => '', 'warehouse_report_list' => '', + 'with skonto acc. to pt' => '' + 'with_skonto_pt' => 'with skonto payment terms', + 'without skonto' => '', + 'without_skonto' => 'without skonto', 'wrongformat' => '', 'yearly' => '', 'yes' => '', diff --git a/menus/erp.ini b/menus/erp.ini index 37b4f8013..05f2a2993 100644 --- a/menus/erp.ini +++ b/menus/erp.ini @@ -401,11 +401,6 @@ action=payment type=check vc=vendor -[Cash--Reconciliation] -ACCESS=cash -module=rc.pl -action=reconciliation - [Cash--Bank collection via SEPA] ACCESS=cash module=sepa.pl @@ -418,6 +413,23 @@ module=sepa.pl action=bank_transfer_add vc=vendor +[Cash--Bank Import] +module=menu.pl +action=acc_menu +submenu=1 + +[Cash--Bank Import--CSV] +ACCESS=bank_transaction +module=controller.pl +action=CsvImport/new +profile.type=bank_transactions + +[Cash--Bank Import--MT940] +ACCESS=bank_transaction +module=controller.pl +action=CsvImport/new +profile.type=mt940 + [Cash--Bank transactions MT940] ACCESS=bank_transaction module=controller.pl @@ -429,6 +441,11 @@ module=controller.pl action=Reconciliation/search next_sub=Reconciliation/reconciliation +[Cash--Reconciliation] +ACCESS=cash +module=rc.pl +action=reconciliation + [Cash--Reports] module=menu.pl action=acc_menu @@ -757,16 +774,6 @@ module=menu.pl action=acc_menu submenu=1 -[System--Import CSV--Bank Transactions] -module=controller.pl -action=CsvImport/new -profile.type=bank_transactions - -[System--Import CSV--MT940] -module=controller.pl -action=CsvImport/new -profile.type=mt940 - [System--Import CSV--Customers and vendors] module=controller.pl action=CsvImport/new diff --git a/sql/Pg-upgrade2/automatic_reconciliation.sql b/sql/Pg-upgrade2/automatic_reconciliation.sql index a911e07d8..924fceb76 100644 --- a/sql/Pg-upgrade2/automatic_reconciliation.sql +++ b/sql/Pg-upgrade2/automatic_reconciliation.sql @@ -1,6 +1,6 @@ -- @tag: automatic_reconciliation -- @description: Erstellt Tabelle reconiliation_links für den automatischen Kontenabgleich. --- @depends: release_3_0_0 bank_transactions +-- @depends: release_3_2_0 bank_transactions CREATE TABLE reconciliation_links ( id integer NOT NULL DEFAULT nextval('id'), diff --git a/sql/Pg-upgrade2/bank_accounts_unique_chart_constraint.sql b/sql/Pg-upgrade2/bank_accounts_unique_chart_constraint.sql index 59e0b0a2a..c5eb53fa8 100644 --- a/sql/Pg-upgrade2/bank_accounts_unique_chart_constraint.sql +++ b/sql/Pg-upgrade2/bank_accounts_unique_chart_constraint.sql @@ -1,6 +1,6 @@ -- @tag: bank_accounts_unique_chart_constraint -- @description: Bankkonto - Constraint für eindeutiges Konto --- @depends: release_3_2_0 +-- @depends: release_3_2_0 bank_accounts -- @encoding: utf-8 ALTER TABLE bank_accounts ADD CONSTRAINT chart_id_unique UNIQUE (chart_id); diff --git a/sql/Pg-upgrade2/bank_transactions.sql b/sql/Pg-upgrade2/bank_transactions.sql index 4b57741d8..6e9f91766 100644 --- a/sql/Pg-upgrade2/bank_transactions.sql +++ b/sql/Pg-upgrade2/bank_transactions.sql @@ -1,6 +1,6 @@ -- @tag: bank_transactions -- @description: Erstellen der Tabelle bank_transactions. --- @depends: release_3_0_0 currencies +-- @depends: release_3_2_0 currencies CREATE TABLE bank_transactions ( id SERIAL PRIMARY KEY, @@ -11,13 +11,12 @@ CREATE TABLE bank_transactions ( valutadate DATE NOT NULL, amount numeric(15,5) NOT NULL, remote_name TEXT, - remote_name_1 TEXT, purpose TEXT, invoice_amount numeric(15,5) DEFAULT 0, local_bank_account_id INTEGER NOT NULL, currency_id INTEGER NOT NULL, cleared BOOLEAN NOT NULL DEFAULT FALSE, - + itime TIMESTAMP DEFAULT now(), FOREIGN KEY (currency_id) REFERENCES currencies (id), FOREIGN KEY (local_bank_account_id) REFERENCES bank_accounts (id) ); diff --git a/sql/Pg-upgrade2/bankaccounts_sortkey_and_obsolete.sql b/sql/Pg-upgrade2/bankaccounts_sortkey_and_obsolete.sql index 49baff90e..cdda4648d 100644 --- a/sql/Pg-upgrade2/bankaccounts_sortkey_and_obsolete.sql +++ b/sql/Pg-upgrade2/bankaccounts_sortkey_and_obsolete.sql @@ -3,7 +3,8 @@ -- @depends: release_3_2_0 -- @encoding: utf-8 -ALTER TABLE bank_accounts ADD COLUMN obsolete BOOLEAN; +-- default false needed so that get_all_sorted( query => [ obsolete => 0 ] ) works +ALTER TABLE bank_accounts ADD COLUMN obsolete BOOLEAN NOT NULL DEFAULT false; ALTER TABLE bank_accounts ADD COLUMN sortkey INTEGER; CREATE SEQUENCE tmp_counter; diff --git a/sql/Pg-upgrade2/sepa_items_payment_type.sql b/sql/Pg-upgrade2/sepa_items_payment_type.sql new file mode 100644 index 000000000..c4444b611 --- /dev/null +++ b/sql/Pg-upgrade2/sepa_items_payment_type.sql @@ -0,0 +1,10 @@ +-- @tag: sepa_items_payment_type +-- @description: Zahlungsart und Skontobetrag in SEPA-Auftrag speichern +-- @depends: release_3_2_0 +-- @ignore: 0 + +ALTER TABLE sepa_export_items ADD COLUMN payment_type TEXT; +UPDATE sepa_export_items SET payment_type = 'without_skonto' WHERE payment_type IS NULL; +ALTER TABLE sepa_export_items ALTER COLUMN payment_type SET DEFAULT 'without_skonto'; + +ALTER TABLE sepa_export_items ADD COLUMN skonto_amount NUMERIC(25,5); diff --git a/sql/Pg-upgrade2/tax_skonto_automatic.sql b/sql/Pg-upgrade2/tax_skonto_automatic.sql new file mode 100644 index 000000000..6f378548e --- /dev/null +++ b/sql/Pg-upgrade2/tax_skonto_automatic.sql @@ -0,0 +1,18 @@ +-- @tag: tax_skonto_automatic +-- @description: Skontoautomatikkonten für Steuern mit minimaler Vorbelegung +-- @depends: release_3_2_0 +-- @ignore: 0 + +ALTER TABLE tax ADD COLUMN skonto_sales_chart_id integer; +ALTER TABLE tax ADD FOREIGN KEY (skonto_sales_chart_id) REFERENCES chart (id); +ALTER TABLE tax ADD COLUMN skonto_purchase_chart_id integer; +ALTER TABLE tax ADD FOREIGN KEY (skonto_purchase_chart_id) REFERENCES chart (id); + +UPDATE tax SET skonto_purchase_chart_id = (SELECT id FROM chart WHERE description LIKE '%Erhaltene Skonti %19%' limit 1) WHERE rate = '0.19' AND ( taxkey >= 7 AND taxkey <= 9 ); +UPDATE tax SET skonto_purchase_chart_id = (SELECT id FROM chart WHERE description LIKE '%Erhaltene Skonti 7%' limit 1) WHERE rate = '0.07' AND ( taxkey >= 7 AND taxkey <= 9 ); +UPDATE tax SET skonto_purchase_chart_id = (SELECT id FROM chart WHERE description LIKE '%Erhaltene Skonti %16%' limit 1) WHERE rate = '0.16' AND ( taxkey = 7 ); +UPDATE tax SET skonto_sales_chart_id = (SELECT id FROM chart WHERE description LIKE '%Gewährte Skonti 7%' limit 1) WHERE rate = '0.07' AND ( taxkey >= 2 AND taxkey <= 5 ); +UPDATE tax SET skonto_sales_chart_id = (SELECT id FROM chart WHERE description LIKE '%Gewährte Skonti %19%' limit 1) WHERE rate = '0.19' AND ( taxkey >= 2 AND taxkey <= 5 ); +UPDATE tax SET skonto_sales_chart_id = (SELECT id FROM chart WHERE description LIKE '%Gewährte Skonti %16%' limit 1) WHERE rate = '0.16' AND ( taxkey = 5 ); +UPDATE tax SET skonto_sales_chart_id = (SELECT id FROM chart WHERE description LIKE 'Gewährte Skonti' limit 1) WHERE rate = '0' AND ( taxkey = 0 or taxkey = 1 ); +UPDATE tax SET skonto_purchase_chart_id = (SELECT id FROM chart WHERE description LIKE 'Erhaltene Skonti' limit 1) WHERE rate = '0' AND ( taxkey = 0 or taxkey = 1 ); diff --git a/t/db_helper/payment.t b/t/db_helper/payment.t new file mode 100644 index 000000000..18da821f2 --- /dev/null +++ b/t/db_helper/payment.t @@ -0,0 +1,1031 @@ +use Test::More; + +use strict; + +use lib 't'; +use utf8; + +use Carp; +use Support::TestSetup; +use Test::Exception; +use List::Util qw(sum); + +use SL::DB::Buchungsgruppe; +use SL::DB::Currency; +use SL::DB::Customer; +use SL::DB::Vendor; +use SL::DB::Employee; +use SL::DB::Invoice; +use SL::DB::Part; +use SL::DB::Unit; +use SL::DB::TaxZone; +use SL::DB::BankAccount; +use SL::DB::PaymentTerm; + +my ($customer, $vendor, $currency_id, @parts, $buchungsgruppe, $buchungsgruppe7, $unit, $employee, $tax, $tax7, $taxzone, $payment_terms, $bank_account); + +my $ALWAYS_RESET = 1; + +my $reset_state_counter = 0; + +my $purchase_invoice_counter = 0; # used for generating purchase invnumber + +sub clear_up { + SL::DB::Manager::InvoiceItem->delete_all(all => 1); + SL::DB::Manager::Invoice->delete_all(all => 1); + SL::DB::Manager::PurchaseInvoice->delete_all(all => 1); + SL::DB::Manager::Part->delete_all(all => 1); + SL::DB::Manager::Customer->delete_all(all => 1); + SL::DB::Manager::Vendor->delete_all(all => 1); + SL::DB::Manager::BankAccount->delete_all(all => 1); + SL::DB::Manager::PaymentTerm->delete_all(all => 1); +}; + +sub reset_state { + my %params = @_; + + return if $reset_state_counter; + + $params{$_} ||= {} for qw(buchungsgruppe unit customer part tax vendor); + + clear_up(); + + + $buchungsgruppe = SL::DB::Manager::Buchungsgruppe->find_by(description => 'Standard 19%', %{ $params{buchungsgruppe} }) || croak "No accounting group"; + $buchungsgruppe7 = SL::DB::Manager::Buchungsgruppe->find_by(description => 'Standard 7%') || croak "No accounting group for 7\%"; + $unit = SL::DB::Manager::Unit->find_by(name => 'kg', %{ $params{unit} }) || croak "No unit"; + $employee = SL::DB::Manager::Employee->current || croak "No employee"; + $tax = SL::DB::Manager::Tax->find_by(taxkey => 3, rate => 0.19, %{ $params{tax} }) || croak "No tax"; + $tax7 = SL::DB::Manager::Tax->find_by(taxkey => 2, rate => 0.07) || croak "No tax for 7\%"; + $taxzone = SL::DB::Manager::TaxZone->find_by( description => 'Inland') || croak "No taxzone"; + + $currency_id = $::instance_conf->get_currency_id; + + $customer = SL::DB::Customer->new( + name => 'Test Customer', + currency_id => $currency_id, + taxzone_id => $taxzone->id, + %{ $params{customer} } + )->save; + + $bank_account = SL::DB::BankAccount->new( + account_number => '123', + bank_code => '123', + iban => '123', + bic => '123', + bank => '123', + chart_id => SL::DB::Manager::Chart->find_by( description => 'Bank' )->id, + name => SL::DB::Manager::Chart->find_by( description => 'Bank' )->description, + )->save; + + $payment_terms = SL::DB::PaymentTerm->new( + description => 'payment', + description_long => 'payment', + terms_netto => '30', + terms_skonto => '5', + percent_skonto => '0.05' + )->save; + + $vendor = SL::DB::Vendor->new( + name => 'Test Vendor', + currency_id => $currency_id, + taxzone_id => $taxzone->id, + payment_id => $payment_terms->id, + %{ $params{vendor} } + )->save; + + + @parts = (); + push @parts, SL::DB::Part->new( + partnumber => 'T4254', + description => 'Fourty-two fifty-four', + lastcost => 1.93, + sellprice => 2.34, + buchungsgruppen_id => $buchungsgruppe->id, + unit => $unit->name, + %{ $params{part1} } + )->save; + + push @parts, SL::DB::Part->new( + partnumber => 'T0815', + description => 'Zero EIGHT fifteeN @ 7%', + lastcost => 5.473, + sellprice => 9.714, + buchungsgruppen_id => $buchungsgruppe7->id, + unit => $unit->name, + %{ $params{part2} } + )->save; + push @parts, SL::DB::Part->new( + partnumber => '19%', + description => 'Testware 19%', + lastcost => 0, + sellprice => 50, + buchungsgruppen_id => $buchungsgruppe->id, + unit => $unit->name, + %{ $params{part3} } + )->save; + push @parts, SL::DB::Part->new( + partnumber => '7%', + description => 'Testware 7%', + lastcost => 0, + sellprice => 50, + buchungsgruppen_id => $buchungsgruppe7->id, + unit => $unit->name, + %{ $params{part4} } + )->save; + + $reset_state_counter++; +} + +sub new_invoice { + my %params = @_; + + return SL::DB::Invoice->new( + customer_id => $customer->id, + currency_id => $currency_id, + employee_id => $employee->id, + salesman_id => $employee->id, + gldate => DateTime->today_local->to_kivitendo, + taxzone_id => $taxzone->id, + transdate => DateTime->today_local->to_kivitendo, + invoice => 1, + type => 'invoice', + %params, + ); + +} + +sub new_purchase_invoice { + # my %params = @_; + # manually create a Kreditorenbuchung from scratch, ap + acc_trans bookings, as no helper exists yet, like $invoice->post. + # arap-Booking must come last in the acc_trans order + $purchase_invoice_counter++; + + my $purchase_invoice = SL::DB::PurchaseInvoice->new( + vendor_id => $vendor->id, + invnumber => 'newap ' . $purchase_invoice_counter , + currency_id => $currency_id, + employee_id => $employee->id, + gldate => DateTime->today_local->to_kivitendo, + taxzone_id => $taxzone->id, + transdate => DateTime->today_local->to_kivitendo, + invoice => 0, + type => 'invoice', + taxincluded => 0, + amount => '226', + netamount => '200', + paid => '0', + # %params, + )->save; + + my $today = DateTime->today_local->to_kivitendo; + my $expense_chart = SL::DB::Manager::Chart->find_by(accno => '3400'); + my $expense_chart_booking= SL::DB::AccTransaction->new( + trans_id => $purchase_invoice->id, + chart_id => $expense_chart->id, + chart_link => $expense_chart->link, + amount => '-100', + transdate => $today, + source => '', + taxkey => 9, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 9)->id); + $expense_chart_booking->save; + + my $tax_chart = SL::DB::Manager::Chart->find_by(accno => '1576'); + my $tax_chart_booking= SL::DB::AccTransaction->new( + trans_id => $purchase_invoice->id, + chart_id => $tax_chart->id, + chart_link => $tax_chart->link, + amount => '-19', + transdate => $today, + source => '', + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 9)->id); + $tax_chart_booking->save; + $expense_chart = SL::DB::Manager::Chart->find_by(accno => '3300'); + $expense_chart_booking= SL::DB::AccTransaction->new( + trans_id => $purchase_invoice->id, + chart_id => $expense_chart->id, + chart_link => $expense_chart->link, + amount => '-100', + transdate => $today, + source => '', + taxkey => 8, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 8)->id); + $expense_chart_booking->save; + + + $tax_chart = SL::DB::Manager::Chart->find_by(accno => '1571'); + $tax_chart_booking= SL::DB::AccTransaction->new( + trans_id => $purchase_invoice->id, + chart_id => $tax_chart->id, + chart_link => $tax_chart->link, + amount => '-7', + transdate => $today, + source => '', + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 8)->id); + $tax_chart_booking->save; + my $arap_chart = SL::DB::Manager::Chart->find_by(accno => '1600'); + my $arap_booking= SL::DB::AccTransaction->new(trans_id => $purchase_invoice->id, + chart_id => $arap_chart->id, + chart_link => $arap_chart->link, + amount => '226', + transdate => $today, + source => '', + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + $arap_booking->save; + + return $purchase_invoice; +} + +sub new_item { + my (%params) = @_; + + my $part = delete($params{part}) || $parts[0]; + + return SL::DB::InvoiceItem->new( + parts_id => $part->id, + lastcost => $part->lastcost, + sellprice => $part->sellprice, + description => $part->description, + unit => $part->unit, + %params, + ); +} + +sub number_of_payments { + my $transactions = shift; + + my $number_of_payments; + my $paid_amount; + foreach my $transaction ( @$transactions ) { + if ( $transaction->chart_link =~ /(AR_paid|AP_paid)/ ) { + $paid_amount += $transaction->amount ; + $number_of_payments++; + }; + }; + return ($number_of_payments, $paid_amount); +}; + +sub total_amount { + my $transactions = shift; + + my $total = sum map { $_->amount } @$transactions; + + return $::form->round_amount($total, 5); + +}; + + +# test 1 +sub test_default_invoice_one_item_19_without_skonto() { + reset_state() if $ALWAYS_RESET; + + my $item = new_item(qty => 2.5); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + my $purchase_invoice = new_purchase_invoice(); + + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '6.96'; + $params{payment_type} = 'without_skonto'; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, one item, 19% tax, without_skonto'; + + is($invoice->netamount, 5.85, "${title}: netamount"); + is($invoice->amount, 6.96, "${title}: amount"); + is($paid_amount, -6.96, "${title}: paid amount"); + is($number_of_payments, 1, "${title}: 1 AR_paid booking"); + is($invoice->paid, 6.96, "${title}: paid"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_invoice_one_item_19_without_skonto_overpaid() { + reset_state() if $ALWAYS_RESET; + + my $item = new_item(qty => 2.5); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + my $purchase_invoice = new_purchase_invoice(); + + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '16.96'; + $params{payment_type} = 'without_skonto'; + $invoice->pay_invoice( %params ); + + $params{amount} = '-10.00'; + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, one item, 19% tax, without_skonto'; + + is($invoice->netamount, 5.85, "${title}: netamount"); + is($invoice->amount, 6.96, "${title}: amount"); + is($paid_amount, -6.96, "${title}: paid amount"); + is($number_of_payments, 2, "${title}: 1 AR_paid booking"); + is($invoice->paid, 6.96, "${title}: paid"); + is($total, 0, "${title}: even balance"); + +} + + +# test 2 +sub test_default_invoice_two_items_19_7_tax_with_skonto() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{payment_type} = 'with_skonto_pt'; + $params{amount} = $invoice->amount_less_skonto; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax with_skonto_pt'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); + is($total, 0, "${title}: even balance"); +} + +sub test_default_invoice_two_items_19_7_tax_with_skonto_tax_included() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 1, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{payment_type} = 'with_skonto_pt'; + $params{amount} = $invoice->amount_less_skonto; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax with_skonto_pt'; + + is($invoice->netamount, 15.82, "${title}: netamount"); + is($invoice->amount, 17.51, "${title}: amount"); + is($paid_amount, -17.51, "${title}: paid amount"); + is($invoice->paid, 17.51, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); + is($total, 0, "${title}: even balance"); +} + +# test 3 : two items, without skonto +sub test_default_invoice_two_items_19_7_without_skonto() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '19.44'; # pass full amount + $params{payment_type} = 'without_skonto'; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax without skonto'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 1, "${title}: 1 AR_paid bookings"); + is($total, 0, "${title}: even balance"); +} + +# test 4 +sub test_default_invoice_two_items_19_7_without_skonto_incomplete_payment() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '9.44', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo, + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax without skonto incomplete payment'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -9.44, "${title}: paid amount"); + is($invoice->paid, 9.44, "${title}: paid"); + is($number_of_payments, 1, "${title}: 1 AR_paid bookings"); + is($total, 0, "${title}: even balance"); +} + +# test 5 +sub test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '9.44', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( amount => '10.00', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax not included'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 2, "${title}: 2 AR_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +# test 6 +sub test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '9.44', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( amount => '8.73', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( amount => $invoice->open_amount, + payment_type => 'difference_as_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax not included'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 4, "${title}: 4 AR_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto_1cent() { + reset_state() if $ALWAYS_RESET; + + # if there is only one cent left there can only be one skonto booking, the + # error handling should choose the highest amount, which is the 7% account + # (11.66) rather than the 19% account (5.85). The actual tax amount is + # higher for the 19% case, though (1.11 compared to 0.82) + + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '19.42', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( amount => $invoice->open_amount, + payment_type => 'difference_as_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax not included'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 3, "${title}: 2 AR_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto_2cent() { + reset_state() if $ALWAYS_RESET; + + # if there are two cents left there will be two skonto bookings, 1 cent each + my $item1 = new_item(qty => 2.5); + my $item2 = new_item(qty => 1.2, part => $parts[1]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '19.42', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( amount => $invoice->open_amount, + payment_type => 'difference_as_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax not included'; + + is($invoice->netamount, 5.85 + 11.66, "${title}: netamount"); + is($invoice->amount, 6.96 + 12.48, "${title}: amount"); + is($paid_amount, -19.44, "${title}: paid amount"); + is($invoice->paid, 19.44, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_invoice_one_item_19_multiple_payment_final_difference_as_skonto() { + reset_state() if $ALWAYS_RESET; + + my $item = new_item(qty => 2.5); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '2.32'; + $params{payment_type} = 'without_skonto'; + $invoice->pay_invoice( %params ); + + $params{amount} = '3.81'; + $params{payment_type} = 'without_skonto'; + $invoice->pay_invoice( %params ); + + $params{amount} = $invoice->open_amount; # set amount, otherwise previous 3.81 is used + $params{payment_type} = 'difference_as_skonto'; + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, one item, 19% tax, without_skonto'; + + is($invoice->netamount, 5.85, "${title}: netamount"); + is($invoice->amount, 6.96, "${title}: amount"); + is($paid_amount, -6.96, "${title}: paid amount"); + is($number_of_payments, 3, "${title}: 3 AR_paid booking"); + is($invoice->paid, 6.96, "${title}: paid"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_invoice_one_item_19_multiple_payment_final_difference_as_skonto_1cent() { + reset_state() if $ALWAYS_RESET; + + my $item = new_item(qty => 2.5); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '6.95'; + $params{payment_type} = 'without_skonto'; + $invoice->pay_invoice( %params ); + + $params{amount} = $invoice->open_amount; # set amount, otherwise previous value 6.95 is used + $params{payment_type} = 'difference_as_skonto'; + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, one item, 19% tax, without_skonto'; + + is($invoice->netamount, 5.85, "${title}: netamount"); + is($invoice->amount, 6.96, "${title}: amount"); + is($paid_amount, -6.96, "${title}: paid amount"); + is($number_of_payments, 2, "${title}: 3 AR_paid booking"); + is($invoice->paid, 6.96, "${title}: paid"); + is($total, 0, "${title}: even balance"); + +} + +# test 3 : two items, without skonto +sub test_default_purchase_invoice_two_charts_19_7_without_skonto() { + reset_state() if $ALWAYS_RESET; + + my $purchase_invoice = new_purchase_invoice(); + + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = '226'; # pass full amount + $params{payment_type} = 'without_skonto'; + + $purchase_invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($purchase_invoice->transactions); + my $total = total_amount($purchase_invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax without skonto'; + + is($paid_amount, 226, "${title}: paid amount"); + is($number_of_payments, 1, "${title}: 1 AP_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_purchase_invoice_two_charts_19_7_with_skonto() { + reset_state() if $ALWAYS_RESET; + + my $purchase_invoice = new_purchase_invoice(); + + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + # $params{amount} = '226'; # pass full amount + $params{payment_type} = 'with_skonto_pt'; + + $purchase_invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($purchase_invoice->transactions); + my $total = total_amount($purchase_invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax without skonto'; + + is($paid_amount, 226, "${title}: paid amount"); + is($number_of_payments, 3, "${title}: 1 AP_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +sub test_default_purchase_invoice_two_charts_19_7_tax_partial_unrounded_payment_without_skonto() { + # check whether unrounded amounts passed via $params{amount} are rounded for without_skonto case + reset_state() if $ALWAYS_RESET; + my $purchase_invoice = new_purchase_invoice(); + $purchase_invoice->pay_invoice( + amount => ( $purchase_invoice->amount / 3 * 2), + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + my ($number_of_payments, $paid_amount) = number_of_payments($purchase_invoice->transactions); + my $total = total_amount($purchase_invoice->transactions); + + my $title = 'default purchase_invoice, two charts, 19/7% tax multiple payments with final difference as skonto'; + + is($paid_amount, 150.67, "${title}: paid amount"); + is($number_of_payments, 1, "${title}: 1 AP_paid bookings"); + is($total, 0, "${title}: even balance"); +}; + + +sub test_default_purchase_invoice_two_charts_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto() { + reset_state() if $ALWAYS_RESET; + + my $purchase_invoice = new_purchase_invoice(); + + # pay 2/3 and 1/5, leaves 3.83% to be used as Skonto + $purchase_invoice->pay_invoice( + amount => ( $purchase_invoice->amount / 3 * 2), + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $purchase_invoice->pay_invoice( + amount => ( $purchase_invoice->amount / 5 ), + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $purchase_invoice->pay_invoice( + payment_type => 'difference_as_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($purchase_invoice->transactions); + my $total = total_amount($purchase_invoice->transactions); + + my $title = 'default purchase_invoice, two charts, 19/7% tax multiple payments with final difference as skonto'; + + is($paid_amount, 226, "${title}: paid amount"); + is($number_of_payments, 4, "${title}: 1 AP_paid bookings"); + is($total, 0, "${title}: even balance"); + +} + +# test +sub test_default_invoice_two_items_19_7_tax_with_skonto_50_50() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 1, part => $parts[2]); + my $item2 = new_item(qty => 1, part => $parts[3]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = $invoice->amount_less_skonto; + $params{payment_type} = 'with_skonto_pt'; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, two items, 19/7% tax with_skonto_pt 50/50'; + + is($invoice->netamount, 100, "${title}: netamount"); + is($invoice->amount, 113, "${title}: amount"); + is($paid_amount, -113, "${title}: paid amount"); + is($invoice->paid, 113, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); + is($total, 0, "${title}: even balance"); +} + +# test +sub test_default_invoice_four_items_19_7_tax_with_skonto_4x_25() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 0.5, part => $parts[2]); + my $item2 = new_item(qty => 0.5, part => $parts[3]); + my $item3 = new_item(qty => 0.5, part => $parts[2]); + my $item4 = new_item(qty => 0.5, part => $parts[3]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2, $item3, $item4 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = $invoice->amount_less_skonto; + $params{payment_type} = 'with_skonto_pt'; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, four items, 19/7% tax with_skonto_pt 4x25'; + + is($invoice->netamount , 100 , "${title}: netamount"); + is($invoice->amount , 113 , "${title}: amount"); + is($paid_amount , -113 , "${title}: paid amount"); + is($invoice->paid , 113 , "${title}: paid"); + is($number_of_payments , 3 , "${title}: 3 AR_paid bookings"); + is($total , 0 , "${title}: even balance"); +} + +sub test_default_invoice_four_items_19_7_tax_with_skonto_4x_25_tax_included() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 0.5, part => $parts[2]); + my $item2 = new_item(qty => 0.5, part => $parts[3]); + my $item3 = new_item(qty => 0.5, part => $parts[2]); + my $item4 = new_item(qty => 0.5, part => $parts[3]); + my $invoice = new_invoice( + taxincluded => 1, + invoiceitems => [ $item1, $item2, $item3, $item4 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + # default values + my %params = ( chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + $params{amount} = $invoice->amount_less_skonto; + $params{payment_type} = 'with_skonto_pt'; + + $invoice->pay_invoice( %params ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, four items, 19/7% tax with_skonto_pt 4x25'; + + is($invoice->netamount, 88.75, "${title}: netamount"); + is($invoice->amount, 100, "${title}: amount"); + is($paid_amount, -100, "${title}: paid amount"); + is($invoice->paid, 100, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); +# currently this test fails because the code writing the invoice is buggy, the calculation of skonto is correct + is($total, 0, "${title}: even balance: this will fail due to rounding error in invoice post, not the skonto"); +} + +sub test_default_invoice_four_items_19_7_tax_with_skonto_4x_25_multiple() { + reset_state() if $ALWAYS_RESET; + + my $item1 = new_item(qty => 0.5, part => $parts[2]); + my $item2 = new_item(qty => 0.5, part => $parts[3]); + my $item3 = new_item(qty => 0.5, part => $parts[2]); + my $item4 = new_item(qty => 0.5, part => $parts[3]); + my $invoice = new_invoice( + taxincluded => 0, + invoiceitems => [ $item1, $item2, $item3, $item4 ], + payment_id => $payment_terms->id, + ); + $invoice->post; + + $invoice->pay_invoice( amount => '90', + payment_type => 'without_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + $invoice->pay_invoice( payment_type => 'difference_as_skonto', + chart_id => $bank_account->chart_id, + transdate => DateTime->today_local->to_kivitendo + ); + + my ($number_of_payments, $paid_amount) = number_of_payments($invoice->transactions); + my $total = total_amount($invoice->transactions); + + my $title = 'default invoice, four items, 19/7% tax with_skonto_pt 4x25'; + + is($invoice->netamount, 100, "${title}: netamount"); + is($invoice->amount, 113, "${title}: amount"); + is($paid_amount, -113, "${title}: paid amount"); + is($invoice->paid, 113, "${title}: paid"); + is($number_of_payments, 3, "${title}: 3 AR_paid bookings"); + is($total, 0, "${title}: even balance: this will fail due to rounding error in invoice post, not the skonto"); +} + +Support::TestSetup::login(); + # die; + +# test cases: without_skonto + test_default_invoice_one_item_19_without_skonto(); + test_default_invoice_two_items_19_7_tax_with_skonto(); + test_default_invoice_two_items_19_7_without_skonto(); + test_default_invoice_two_items_19_7_without_skonto_incomplete_payment(); + test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments(); + test_default_purchase_invoice_two_charts_19_7_without_skonto(); + test_default_purchase_invoice_two_charts_19_7_tax_partial_unrounded_payment_without_skonto(); + test_default_invoice_one_item_19_without_skonto_overpaid(); + +# test cases: difference_as_skonto + test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto(); + test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto_1cent(); + test_default_invoice_two_items_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto_2cent(); + test_default_invoice_one_item_19_multiple_payment_final_difference_as_skonto(); + test_default_invoice_one_item_19_multiple_payment_final_difference_as_skonto_1cent(); + test_default_purchase_invoice_two_charts_19_7_tax_without_skonto_multiple_payments_final_difference_as_skonto(); + +# test cases: with_skonto_pt + test_default_invoice_two_items_19_7_tax_with_skonto_50_50(); + test_default_invoice_four_items_19_7_tax_with_skonto_4x_25(); + test_default_invoice_four_items_19_7_tax_with_skonto_4x_25_multiple(); + test_default_purchase_invoice_two_charts_19_7_with_skonto(); + test_default_invoice_four_items_19_7_tax_with_skonto_4x_25_tax_included(); + test_default_invoice_two_items_19_7_tax_with_skonto_tax_included(); + +# remove all created data at end of test +clear_up(); + +done_testing(); + +1; diff --git a/templates/webpages/am/edit_tax.html b/templates/webpages/am/edit_tax.html index 88c7fdc57..46c45d84a 100644 --- a/templates/webpages/am/edit_tax.html +++ b/templates/webpages/am/edit_tax.html @@ -43,7 +43,15 @@ [% 'tax_chartaccno' | $T8 %] - + + [% 'Automatic skonto chart sales' | $T8 %] + [% L.select_tag('skonto_sales_chart_id', AR_PAID, value_title_sub = \skontochart_value_title_sub, with_empty = 1, default = skonto_sales_chart_id) %] + + + [% 'Automatic skonto chart purchase' | $T8 %] + [% L.select_tag('skonto_purchase_chart_id', AP_PAID, value_title_sub = \skontochart_value_title_sub, with_empty = 1, default = skonto_purchase_chart_id) %] + + [% 'Account categories' | $T8 %] diff --git a/templates/webpages/am/list_tax.html b/templates/webpages/am/list_tax.html index 4dcfe041b..836cca355 100644 --- a/templates/webpages/am/list_tax.html +++ b/templates/webpages/am/list_tax.html @@ -9,6 +9,8 @@ + + [% SET row_odd = '1' %][% FOREACH row = TAX %] @@ -18,6 +20,8 @@ + + [% END %]
[% 'tax_rate' | $T8 %] [% 'taxnumber' | $T8 %] [% 'account_description' | $T8 %][% 'Automatic skonto chart sales' | $T8 %][% 'Automatic skonto chart purchase' | $T8 %]
[% HTML.escape(row.rate) %] % [% HTML.escape(row.taxnumber) %] [% HTML.escape(row.account_description) %][% HTML.escape(row.skonto_chart_accno) %] [% HTML.escape(row.skonto_chart_description) %][% HTML.escape(row.skonto_chart_purchase_accno) %] [% HTML.escape(row.skonto_chart_purchase_description) %]
diff --git a/templates/webpages/bank_transactions/_filter.html b/templates/webpages/bank_transactions/_filter.html index a72ff3399..0aaab1417 100644 --- a/templates/webpages/bank_transactions/_filter.html +++ b/templates/webpages/bank_transactions/_filter.html @@ -12,7 +12,7 @@ - + diff --git a/templates/webpages/bank_transactions/add_list.html b/templates/webpages/bank_transactions/add_list.html index 08eaf5b9b..ad48bfa07 100644 --- a/templates/webpages/bank_transactions/add_list.html +++ b/templates/webpages/bank_transactions/add_list.html @@ -8,6 +8,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/templates/webpages/bank_transactions/assign_invoice.html b/templates/webpages/bank_transactions/assign_invoice.html index f8a6697aa..26229b8cc 100644 --- a/templates/webpages/bank_transactions/assign_invoice.html +++ b/templates/webpages/bank_transactions/assign_invoice.html @@ -1,30 +1,32 @@ [%- USE HTML %][%- USE L %][%- USE LxERP %][%- USE T8 %] +[% SET debug = 0 %] + - Transaction + [%- LxERP.t8("Bank transaction") %]:
[% 'Bank account' | $T8 %][% L.select_tag('filter.local_bank_account_id', BANK_ACCOUNTS, default=filter.local_bank_account_id, title_sub=\label_sub, with_empty=1, style='width:250px') %][% L.select_tag('filter.local_bank_account_id', BANK_ACCOUNTS, default=filter.local_bank_account_id, title_key='displayable_name', with_empty=1, style='width:500px') %]
[%- LxERP.t8("Invoice number") %] [%- LxERP.t8("Amount") %] [%- LxERP.t8("Open amount") %][%- LxERP.t8("Amount less skonto") %] [%- LxERP.t8("Transdate") %] [%- LxERP.t8("Customer/Vendor number") %] [%- LxERP.t8("Customer/Vendor name") %][%- invoice.invnumber %] [%- LxERP.format_amount(invoice.amount, 2) %] [%- LxERP.format_amount(invoice.amount - invoice.paid, 2) %][%- LxERP.format_amount(invoice.amount_less_skonto, 2) %] [%- invoice.transdate_as_date %] [%- invoice.vendor.vendornumber %][%- invoice.customer.customernumber %] [%- invoice.vendor.name %][%- invoice.customer.name %]
- + [% IF debug %][% END %] + - - - + + - + [% IF debug %][% END %] + - - - + +
[%- LxERP.t8("ID") %]:[%- LxERP.t8("ID") %]:[%- LxERP.t8("Transdate") %]: [%- LxERP.t8("Amount") %]:[%- LxERP.t8("Remote bank code") %]:[%- LxERP.t8("Remote account number") %]: [%- LxERP.t8("Remote name") %]: [%- LxERP.t8("Purpose") %]:[%- LxERP.t8("Transdate") %]:[%- LxERP.t8("Remote account number") %]:[%- LxERP.t8("Remote bank code") %]:
[% SELF.transaction.id %][% SELF.transaction.id %][% SELF.transaction.transdate_as_date %] [% LxERP.format_amount(SELF.transaction.amount, 2) %][% SELF.transaction.remote_bank_code %][% SELF.transaction.remote_account_number %] [% SELF.transaction.remote_name %] [% SELF.transaction.purpose %][% SELF.transaction.transdate_as_date %][% SELF.transaction.remote_account_number %][% SELF.transaction.remote_bank_code %]
- Filter + [%- LxERP.t8("Invoice filter") %]: @@ -43,7 +45,7 @@ - + diff --git a/templates/webpages/bank_transactions/create_invoice.html b/templates/webpages/bank_transactions/create_invoice.html index 09af08ec8..cfc9ce893 100644 --- a/templates/webpages/bank_transactions/create_invoice.html +++ b/templates/webpages/bank_transactions/create_invoice.html @@ -72,7 +72,7 @@ [% FOREACH draft = DRAFTS %] - + diff --git a/templates/webpages/bank_transactions/list.html b/templates/webpages/bank_transactions/list.html index d9d52388c..0a38b2b5b 100644 --- a/templates/webpages/bank_transactions/list.html +++ b/templates/webpages/bank_transactions/list.html @@ -6,7 +6,7 @@ [%- INCLUDE 'common/flash.html' %] -

[% 'Account number' | $T8 %] [% bank_account.account_number %], [% 'Bank code' | $T8 %] [% bank_account.bank_code %], [% 'Bank' | $T8 %] [% bank_account.bank %]

+

[% HTML.escape(bank_account.name) %] [% HTML.escape(bank_account.iban) %], [% 'Bank code' | $T8 %] [% HTML.escape(bank_account.bank_code) %], [% 'Bank' | $T8 %] [% HTML.escape(bank_account.bank) %]

[% IF FORM.filter.fromdate %] [% 'From' | $T8 %] [% FORM.filter.fromdate %] [% END %] [% IF FORM.filter.todate %] [% 'to (date)' | $T8 %] [% FORM.filter.todate %][% END %] @@ -69,7 +69,8 @@ function assign_invoice(bt_id) { } function add_invoices(bt_id, prop_id, prop_invnumber) { - //prop_id is a proposed invoice_id + // prop_id is a proposed invoice_id + // remove the added invoice from all the other suggestions var number_of_elements = document.getElementsByName(prop_id).length; for( var i = 0; i < number_of_elements; i++ ) { var node = document.getElementsByName(prop_id)[0]; @@ -77,11 +78,13 @@ function add_invoices(bt_id, prop_id, prop_invnumber) { } UnTip(); var invoices = document.getElementById('assigned_invoices_' + bt_id); - var div_element = '

'; - var hidden_element = '' + prop_invnumber; - var link_element = 'x'; - var new_html = div_element + hidden_element + link_element + '
'; - invoices.innerHTML += new_html; + + $.ajax({ + url: 'controller.pl?action=BankTransaction/ajax_payment_suggestion&bt_id=' + bt_id + '&prop_id=' + prop_id, + success: function(data) { + invoices.innerHTML += data.html; + } + }); } function delete_invoice(bt_id, prop_id) { diff --git a/templates/webpages/bank_transactions/search.html b/templates/webpages/bank_transactions/search.html index adab511e3..3e234cdec 100644 --- a/templates/webpages/bank_transactions/search.html +++ b/templates/webpages/bank_transactions/search.html @@ -11,46 +11,10 @@

[%- LxERP.t8("Invoice number") %]
[%- LxERP.t8("Transdate from") %][%- LxERP.t8("Invdate from") %] [% L.date_tag('transdatefrom') %] [%- LxERP.t8("to (date)") %]
[% HTML.escape(draft.description) %][% HTML.escape(draft.description) %] [% HTML.escape(draft.vendor) %] [% HTML.escape(draft.employee.name) %] [% HTML.escape(draft.itime_as_date) %]
- - + diff --git a/templates/webpages/bank_transactions/tabs/all.html b/templates/webpages/bank_transactions/tabs/all.html index a392bb89f..359ca5f7a 100644 --- a/templates/webpages/bank_transactions/tabs/all.html +++ b/templates/webpages/bank_transactions/tabs/all.html @@ -1,11 +1,16 @@ [%- USE HTML -%][%- USE LxERP -%][%- USE L -%][%- USE T8 -%] +[% SET debug=1 %] +
[% 'Bank account' | $T8 %][% L.select_tag('filter.bank_account', BANK_ACCOUNTS, default=bank_acount, title_sub=\label_sub, with_empty=0, style='width:450px') %][% L.select_tag('filter.bank_account', BANK_ACCOUNTS, default=bank_account, title_key='displayable_name', with_empty=0, style='width:450px') %]
+ [% IF debug %] + + [% END %] - - - - - + + + + @@ -74,24 +78,26 @@ - + + [% IF debug %] + + [% END %] - - - - - + + + + [%- END %] diff --git a/templates/webpages/bank_transactions/tabs/automatic.html b/templates/webpages/bank_transactions/tabs/automatic.html index 3ce007a01..42f387fb3 100644 --- a/templates/webpages/bank_transactions/tabs/automatic.html +++ b/templates/webpages/bank_transactions/tabs/automatic.html @@ -5,7 +5,7 @@ - + @@ -36,11 +36,11 @@ [% FOREACH proposed_invoice = proposal.proposals %] - + - + [% L.hidden_tag("proposed_invoice_" _ proposal.id, proposed_invoice.id) %] diff --git a/templates/webpages/csv_import/_form_banktransactions.html b/templates/webpages/csv_import/_form_banktransactions.html new file mode 100644 index 000000000..d6da1178d --- /dev/null +++ b/templates/webpages/csv_import/_form_banktransactions.html @@ -0,0 +1,9 @@ +[% USE LxERP %] +[% USE L %] + + + + diff --git a/templates/webpages/csv_import/_form_mt940.html b/templates/webpages/csv_import/_form_mt940.html index 652bc98af..cb63e7de9 100644 --- a/templates/webpages/csv_import/_form_mt940.html +++ b/templates/webpages/csv_import/_form_mt940.html @@ -1,9 +1,12 @@ +[% USE LxERP %] +[% USE L %] - + + + + diff --git a/templates/webpages/csv_import/form.html b/templates/webpages/csv_import/form.html index 91b227d65..745ef161c 100644 --- a/templates/webpages/csv_import/form.html +++ b/templates/webpages/csv_import/form.html @@ -262,6 +262,8 @@ [%- INCLUDE 'csv_import/_form_orders.html' %] [%- ELSIF SELF.type == 'mt940' %] [%- INCLUDE 'csv_import/_form_mt940.html' %] +[%- ELSIF SELF.type == 'bank_transactions' %] + [%- INCLUDE 'csv_import/_form_banktransactions.html' %] [%- END %] diff --git a/templates/webpages/reconciliation/_linked_transactions.html b/templates/webpages/reconciliation/_linked_transactions.html index 29eaa08e0..c05be857d 100644 --- a/templates/webpages/reconciliation/_linked_transactions.html +++ b/templates/webpages/reconciliation/_linked_transactions.html @@ -3,6 +3,8 @@ [%- USE L %] [%- USE LxERP %] +[% SET debug = 0 %] + [% IF !SELF.LINKED_TRANSACTIONS.size %] @@ -21,7 +23,7 @@ - + [% IF debug %][% END %] @@ -36,12 +38,12 @@ - + [% IF debug %][% END %] - - + + @@ -58,7 +60,7 @@ - + [% IF debug %][% END %] @@ -77,12 +79,12 @@ - + [% IF debug %][% END %] - - + + diff --git a/templates/webpages/reconciliation/assigning_table.html b/templates/webpages/reconciliation/assigning_table.html index 2f6d4de34..0da95a2b2 100644 --- a/templates/webpages/reconciliation/assigning_table.html +++ b/templates/webpages/reconciliation/assigning_table.html @@ -35,5 +35,5 @@
[% 'Assigned invoices' | $T8 %][% 'Score' | $T8 %][% IF FORM.sort_by == 'proposal'%] [% 'Proposal' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] @@ -14,22 +19,6 @@ [% 'Proposal' | $T8 %] [% END %] [% IF FORM.sort_by == 'remote_bank_code'%] - - [% 'Remote bank code' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] - [% ELSE %] - - [% 'Remote bank code' | $T8 %] - [% END %] - [% IF FORM.sort_by == 'remote_account_number'%] - - [% 'Remote account number' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] - [% ELSE %] - - [% 'Remote account number' | $T8 %] - [% END %] - [% IF FORM.sort_by == 'transdate'%] [% 'Transdate' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] @@ -38,14 +27,6 @@ [% 'Transdate' | $T8 %] [% END %] [% IF FORM.sort_by == 'valutadate'%] - - [% 'Valutadate' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] - [% ELSE %] - - [% 'Valutadate' | $T8 %] - [% END %] - [% IF FORM.sort_by == 'amount'%] [% 'Amount' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] @@ -55,7 +36,6 @@ [% END %] [% 'Assigned' | $T8 %][% 'Currency' | $T8 %] [% IF FORM.sort_by == 'remote_name'%] [% 'Remote name' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] @@ -64,8 +44,32 @@ [% 'Remote name' | $T8 %] [% END %] [% 'Remote name 1' | $T8 %] [% 'Purpose' | $T8 %][% IF FORM.sort_by == 'remote_account_number'%] + + [% 'Remote account number' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] + [% ELSE %] + + [% 'Remote account number' | $T8 %] + [% END %] + [% IF FORM.sort_by == 'remote_bank_code'%] + + [% 'Remote bank code' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] + [% ELSE %] + + [% 'Remote bank code' | $T8 %] + [% END %] + [% IF FORM.sort_by == 'valutadate'%] + + [% 'Valutadate' | $T8 %][% IF FORM.sort_dir == 0 %][% ELSE %][% END %] + [% ELSE %] + + [% 'Valutadate' | $T8 %] + [% END %] + [% 'Currency' | $T8 %]
[% 'Assign invoice' | $T8 %] [% 'Create invoice' | $T8 %][% bt.agreement %] [% FOREACH prop = bt.proposals %] [% END %] [% HTML.escape(bt.remote_bank_code) %][% HTML.escape(bt.remote_account_number) %] [% bt.transdate_as_date %][% bt.valutadate_as_date %] [% bt.amount_as_number %] [% bt.invoice_amount_as_number %][% HTML.escape(bt.currency.name) %] [% HTML.escape(bt.remote_name) %][% HTML.escape(bt.remote_name_1) %] [% HTML.escape(bt.purpose) %][% HTML.escape(bt.remote_account_number) %][% HTML.escape(bt.remote_bank_code) %][% bt.valutadate_as_date %][% HTML.escape(bt.currency.name) %]
[% L.checkbox_tag('check_all') %][% 'Typ' | $T8 %][% 'Type' | $T8 %] [% 'ID' | $T8 %] [% 'Transdate' | $T8 %] [% 'Amount' | $T8 %]
[% 'Rechnung/Buchung' | $T8 %][% 'Invoice' | $T8 %] [% proposed_invoice.id %] [% proposed_invoice.transdate_as_date %] [% proposed_invoice.amount_as_number %][% HTML.escape(proposed_invoice.invnumber) %][% proposed_invoice.link %] [% HTML.escape(proposed_invoice.customer.name) %][% HTML.escape(proposed_invoice.vendor.name) %]
[%- LxERP.t8("Existing bank transactions") %]: + [% opts = [ [ 'skip', LxERP.t8('Skip entry') ] , [ 'insert_new', LxERP.t8('Insert new') ] ] %] + [% L.select_tag('settings.update_policy', opts, default = SELF.profile.get('update_policy'), style = 'width: 300px') %] +
[%- LxERP.t8("Existing bank transactions") %]: + [% opts = [ [ 'skip', LxERP.t8('Skip entry') ] , [ 'insert_new', LxERP.t8('Insert new') ] ] %] + [% L.select_tag('settings.update_policy', opts, default = SELF.profile.get('update_policy'), style = 'width: 300px') %] +

[% 'No data was found.' | $T8 %]

[% 'Bank Transaction' | $T8 %][% HTML.escape(bt.id) %][% HTML.escape(bt.id) %][% HTML.escape(bt.transdate_as_date) %] [% HTML.escape(bt.amount_as_number) %]
[% 'Acc Transaction' | $T8 %][% HTML.escape(bb.acc_trans_id) %][% HTML.escape(bb.acc_trans_id) %][% HTML.escape(bb.transdate_as_date) %] [% LxERP.format_amount(-1 * bb.amount, 2) %][% HTML.escape(bb.get_transaction.customer.name) %][% HTML.escape(bb.get_transaction.vendor.name) %][% HTML.escape(bb.get_transaction.description) %][% bb.get_transaction.link %][% HTML.escape(bb.record.customer.name) %][% HTML.escape(bb.record.vendor.name) %][% HTML.escape(bb.record.description) %][% bb.record.link %] [% HTML.escape(bb.source) %] [% HTML.escape(bb.memo) %] [% HTML.escape(bb.source) %] [% 'Bank Transaction' | $T8 %][% HTML.escape(bt.id) %][% HTML.escape(bt.id) %][% HTML.escape(bt.transdate_as_date) %] [% HTML.escape(bt.amount_as_number) %]
[% 'Acc Transaction' | $T8 %][% HTML.escape(bb.acc_trans_id) %][% HTML.escape(bb.acc_trans_id) %][% HTML.escape(bb.transdate_as_date) %] [% LxERP.format_amount(-1 * bb.amount, 2) %][% HTML.escape(bb.get_transaction.customer.name) %][% HTML.escape(bb.get_transaction.vendor.name) %][% HTML.escape(bb.get_transaction.description) %][% bb.get_transaction.link %][% HTML.escape(bb.record.customer.name) %][% HTML.escape(bb.record.vendor.name) %][% HTML.escape(bb.record.description) %][% bb.record.link %] [% HTML.escape(bb.source) %] [% HTML.escape(bb.memo) %] [% HTML.escape(bb.source) %]
- [% IF show_button %][% L.button_tag("submit_with_action('reconciliate')", LxERP.t8("Reconciliate")) %][% END %] + [% IF show_button %][% L.button_tag("submit_with_action('reconcile')", LxERP.t8("Reconcile")) %][% END %] [% END %] diff --git a/templates/webpages/reconciliation/form.html b/templates/webpages/reconciliation/form.html index 2c1a4dbb2..39847ac26 100644 --- a/templates/webpages/reconciliation/form.html +++ b/templates/webpages/reconciliation/form.html @@ -34,7 +34,8 @@ html, body { [% L.select_tag('filter.local_bank_account_id:number', SELF.BANK_ACCOUNTS, default=FORM.filter.local_bank_account_id_number, - title_sub=\label_sub, with_empty=0, + title_key='displayable_name', + with_empty=0, style='width:450px', onchange='filter_table();') %] @@ -95,9 +96,11 @@ html, body { + +$( ".type_target" ).change(function() { + type_id = $(this).attr('id'); + var id = type_id.match(/\d*$/); + // alert("found id " + id); + if ( $(this).val() == "without_skonto" ) { + $('#' + id).val( $('#invoice_open_amount_' + id).val() ); + } else if ( $(this).val() == "difference_as_skonto" ) { + $('#' + id).val( $('#invoice_open_amount_' + id).val() ); + } else if ( $(this).val() == "with_skonto_pt" ) { + $('#' + id).val( $('#amount_less_skonto_' + id).val() ); + } +}); + diff --git a/templates/webpages/sepa/bank_transfer_create.html b/templates/webpages/sepa/bank_transfer_create.html index cf3cfaddf..35b2fb988 100644 --- a/templates/webpages/sepa/bank_transfer_create.html +++ b/templates/webpages/sepa/bank_transfer_create.html @@ -10,12 +10,13 @@ [% SET arap = 'ar' %] [% SET iris = 'is' %] [%- END %] -

[% title %]

[%- IF error_message %]

[% error_message %]

[%- END %] +

[% title %]

+

1. [%- IF is_vendor %] @@ -24,12 +25,13 @@ [% 'Please select the destination bank account for the collections:' | $T8 %] [%- END %]
- [%- INCLUDE generic/multibox.html - name = 'bank_account.id', - DATA = BANK_ACCOUNTS, - id_key = 'id', - label_sub = 'bank_account_label', - -%] + [% L.select_tag('bank_account', + BANK_ACCOUNTS, + title_key='displayable_name', + default=bank_account.id, + with_empty=0, + style='width:450px', + ) %]

@@ -82,6 +84,8 @@ [% 'Open amount' | $T8 %] [% 'Purpose' | $T8 %] [%- IF is_vendor %][% 'Bank transfer amount' | $T8 %][%- ELSE %][%- LxERP.t8('Bank collection amount') %][%- END %] + [% LxERP.t8('Payment type') %] + [% LxERP.t8('Skonto information') %] [% 'Execution date' | $T8 %] @@ -89,6 +93,9 @@ + + + @@ -109,9 +116,13 @@ [% LxERP.format_amount(bank_transfer.invoice_amount, -2) %] [% LxERP.format_amount(bank_transfer.open_amount, -2) %] - + + + + + [% L.select_tag('bank_transfers[].payment_type', bank_transfer.payment_select_options, value_key => 'payment_type', title_key => 'display', id => 'payment_type_' _ loop.count, class => 'type_target' ) %] - + [%- IF bank_transfer.skonto_amount %] [% LxERP.format_amount(bank_transfer.percent_skonto, 2) %] % = [% LxERP.format_amount(bank_transfer.skonto_amount, 2) %] € [% 'until' | $T8 %] [% bank_transfer.skonto_date %] [% END %] [% L.date_tag('bank_transfers[].requested_execution_date', bank_transfer.requested_execution_date) %] @@ -136,3 +147,30 @@ + + diff --git a/templates/webpages/sepa/bank_transfer_edit.html b/templates/webpages/sepa/bank_transfer_edit.html index a0ccd97e6..37f1575e6 100644 --- a/templates/webpages/sepa/bank_transfer_edit.html +++ b/templates/webpages/sepa/bank_transfer_edit.html @@ -32,6 +32,8 @@ [% 'Source bank account' | $T8 %] [%- END %] [% 'Amount' | $T8 %] + [% 'Skonto amount' | $T8 %] + [% 'Payment type' | $T8 %] [% IF vc == 'customer' %] [% 'Mandator ID' | $T8 %] [%- END %] @@ -46,7 +48,7 @@ [% 'IBAN' | $T8 %] [% 'BIC' | $T8 %] [%- IF show_post_payments_button %] -   +   [% L.date_tag('set_all_execution_date', '', onchange='set_all_execution_date_fields(this);') %] @@ -75,6 +77,8 @@ [% HTML.escape(item.vc_iban) %] [% HTML.escape(item.vc_bic) %] [% HTML.escape(LxERP.format_amount(item.amount, 2)) %] + [% HTML.escape(LxERP.format_amount(item.skonto_amount, 2)) %] + [% item.payment_type | $T8 %] [% IF vc == 'customer' %] [% HTML.escape(item.mandator_id) %] [%- END %]