|
package SL::DB::Helper::Payment;
|
|
|
|
use strict;
|
|
|
|
use parent qw(Exporter);
|
|
our @EXPORT = qw(pay_invoice);
|
|
our @EXPORT_OK = qw(skonto_date amount_less_skonto within_skonto_period percent_skonto reference_account open_amount skonto_amount valid_skonto_amount validate_payment_type get_payment_select_options_for_bank_transaction forex _skonto_charts_and_tax_correction get_exchangerate_for_bank_transaction get_exchangerate _add_bank_fx_fees open_amount_fx);
|
|
our %EXPORT_TAGS = (
|
|
"ALL" => [@EXPORT, @EXPORT_OK],
|
|
);
|
|
|
|
require SL::DB::Chart;
|
|
|
|
use Carp;
|
|
use Data::Dumper;
|
|
use DateTime;
|
|
use List::Util qw(sum);
|
|
use Params::Validate qw(:all);
|
|
|
|
use SL::DATEV qw(:CONSTANTS);
|
|
use SL::DB::Exchangerate;
|
|
use SL::DB::Currency;
|
|
use SL::HTML::Util;
|
|
use SL::Locale::String qw(t8);
|
|
|
|
#
|
|
# Public functions not exported by default
|
|
#
|
|
|
|
sub pay_invoice {
|
|
my ($self, %params) = @_;
|
|
# todo named 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 @new_acc_ids;
|
|
my $paid_amount = 0; # the amount that will be later added to $self->paid, should be in default currency
|
|
|
|
# default values if not set
|
|
$params{payment_type} = 'without_skonto' unless $params{payment_type};
|
|
validate_payment_type($params{payment_type});
|
|
|
|
# check for required parameters and optional params depending on payment_type
|
|
Common::check_params(\%params, qw(chart_id transdate amount));
|
|
Common::check_params(\%params, qw(bt_id)) unless $params{payment_type} eq 'without_skonto';
|
|
|
|
# three valid cases, test logical params in depth, before proceeding ...
|
|
if ( $params{'payment_type'} eq 'without_skonto' && abs($params{'amount'}) < 0) {
|
|
croak "invalid amount for payment_type 'without_skonto': $params{'amount'}\n";
|
|
} elsif ($params{'payment_type'} eq 'free_skonto') {
|
|
# we dont like too much automagic for this payment type.
|
|
# we force caller input for amount and skonto amount
|
|
Common::check_params(\%params, qw(skonto_amount));
|
|
# secondly we dont want to handle credit notes and purchase credit notes
|
|
croak("Cannot use 'free skonto' for credit or debit notes") if ($params{amount} < 0 || $params{skonto_amount} <= 0);
|
|
# both amount have to be rounded
|
|
$params{skonto_amount} = _round($params{skonto_amount});
|
|
$params{amount} = _round($params{amount});
|
|
# lastly skonto_amount has to be smaller or equal than the open invoice amount
|
|
if ($params{skonto_amount} > _round($self->open_amount)) {
|
|
croak("Skonto amount:" . $params{skonto_amount} . " higher than the payment or open invoice amount:" . $self->open_amount);
|
|
}
|
|
} elsif ( $params{'payment_type'} eq 'with_skonto_pt' ) {
|
|
# options with_skonto_pt doesn't require the parameter
|
|
# amount, but if amount is passed, make sure it matches the expected value
|
|
# note: the parameter isn't used at all - amount_less_skonto will always be used
|
|
# partial skonto payments are therefore impossible to book
|
|
croak "amount $params{amount} doesn't match amount less skonto: " . $self->amount_less_skonto . "\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;
|
|
}
|
|
|
|
my $transdate_obj;
|
|
if (ref($params{transdate}) eq 'DateTime') {
|
|
$transdate_obj = $params{transdate};
|
|
} else {
|
|
$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 );
|
|
};
|
|
|
|
# currency has to be passed and caller has to be sure to assign it for a forex invoice
|
|
# dies if called for a invoice with the default currency (TODO: Params::Validate before)
|
|
my ($exchangerate, $currency);
|
|
my $fx_gain_loss_amount = 0;
|
|
my $return_bank_amount = 0;
|
|
if ($params{currency} || $params{currency_id} && $self->forex) { # currency was specified
|
|
$currency = SL::DB::Manager::Currency->find_by(name => $params{currency}) || SL::DB::Manager::Currency->find_by(id => $params{currency_id});
|
|
|
|
die "No currency found" unless ref $currency eq 'SL::DB::Currency';
|
|
die "No exchange rate" unless $params{exchangerate} > 0;
|
|
|
|
$exchangerate = $params{exchangerate};
|
|
|
|
my $new_open_amount = ( $self->open_amount / $self->get_exchangerate ) * $exchangerate;
|
|
# caller wants to book fees and set a fee amount
|
|
if ($params{fx_book} && $params{fx_fee_amount} > 0) {
|
|
die "Bank Fees can only be added for AP transactions or Sales Credit Notes"
|
|
unless $self->invoice_type =~ m/^purchase_invoice$|^ap_transaction$|^credit_note$/;
|
|
|
|
$self->_add_bank_fx_fees(fee => _round($params{fx_fee_amount}),
|
|
bt_id => $params{bt_id},
|
|
bank_chart_id => $params{chart_id},
|
|
memo => $params{memo},
|
|
source => $params{source},
|
|
transdate_obj => $transdate_obj );
|
|
# invoice_amount add gl booking
|
|
$return_bank_amount += _round($params{fx_fee_amount}); # invoice_type needs negative bank_amount
|
|
}
|
|
} elsif (!$self->forex) { # invoices uses default currency. no exchangerate
|
|
$exchangerate = 1;
|
|
} else {
|
|
die "Cannot calculate exchange rate, if invoices uses the default currency";
|
|
}
|
|
|
|
# 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 with id " . $params{chart_id} 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} ); # / $exchangerate);
|
|
my $db = $self->db;
|
|
$db->with_transaction(sub {
|
|
my $new_acc_trans;
|
|
|
|
# all three payment type create 1 AR/AP booking (the paid part)
|
|
# with_skonto_pt creates 1 bank booking and n skonto bookings (1 for each tax type)
|
|
# and one tax correction as a gl booking
|
|
# without_skonto creates 1 bank booking
|
|
|
|
unless ( $rounded_params_amount == 0 ) {
|
|
# cases with_skonto_pt, free_skonto and without_skonto
|
|
|
|
# for case with_skonto_pt we need to know the corrected amount at this
|
|
# stage because we don't 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;
|
|
|
|
my $amount = (-1 * $pay_amount) * $mult;
|
|
|
|
# total amount against bank, do we already know this by now?
|
|
# Yes, method requires this
|
|
$new_acc_trans = SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $account_bank->id,
|
|
chart_link => $account_bank->link,
|
|
amount => $amount,
|
|
transdate => $transdate_obj,
|
|
source => $source,
|
|
memo => $memo,
|
|
project_id => $params{project_id} ? $params{project_id} : undef,
|
|
taxkey => 0,
|
|
tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id);
|
|
$new_acc_trans->save;
|
|
$return_bank_amount += $amount;
|
|
push @new_acc_ids, $new_acc_trans->acc_trans_id;
|
|
# deal with fxtransaction ...
|
|
# if invoice exchangerate differs from exchangerate of payment
|
|
# add fxloss or fxgain
|
|
if ($exchangerate != 1 && $self->get_exchangerate and $self->get_exchangerate != 1 and $self->get_exchangerate != $exchangerate) {
|
|
my $fxgain_chart = SL::DB::Manager::Chart->find_by(id => $::instance_conf->get_fxgain_accno_id) || die "Can't determine fxgain chart";
|
|
my $fxloss_chart = SL::DB::Manager::Chart->find_by(id => $::instance_conf->get_fxloss_accno_id) || die "Can't determine fxloss chart";
|
|
#
|
|
# AMOUNT == EUR / fx rate payment * (fx rate invoice - fx rate payment)
|
|
# AR.pm
|
|
# $amount = $form->round_amount($form->{"paid_$i"} * ($form->{exchangerate} - $form->{"exchangerate_$i"}) * -1, 2);
|
|
# AP.pm
|
|
# exchangerate gain/loss
|
|
# $amount = $form->round_amount($form->{"paid_$i"} * ($form->{exchangerate} - $form->{"exchangerate_$i"}), 2);
|
|
# =>
|
|
my $fx_gain_loss_sign = $is_sales ? -1 : 1; # multiplier for getting the right sign depending on ar/ap
|
|
|
|
$fx_gain_loss_amount = _round($params{amount} / $exchangerate * ( $self->get_exchangerate - $exchangerate)) * $fx_gain_loss_sign;
|
|
|
|
my $gain_loss_chart = $fx_gain_loss_amount > 0 ? $fxgain_chart : $fxloss_chart;
|
|
# for sales add loss to ar.paid and subtract gain from ar.paid
|
|
$paid_amount += abs($fx_gain_loss_amount) if $fx_gain_loss_amount < 0 && $self->is_sales; # extract if we have fx_loss
|
|
$paid_amount -= abs($fx_gain_loss_amount) if $fx_gain_loss_amount > 0 && $self->is_sales; # but add if to match original invoice amount (arap)
|
|
# for purchase add gain to ap.paid and subtract loss from ap.paid
|
|
$paid_amount += abs($fx_gain_loss_amount) if $fx_gain_loss_amount > 0 && !$self->is_sales; # but add if to match original invoice amount (arap)
|
|
$paid_amount -= abs($fx_gain_loss_amount) if $fx_gain_loss_amount < 0 && !$self->is_sales; # extract if we have fx_loss
|
|
|
|
$new_acc_trans = SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $gain_loss_chart->id,
|
|
chart_link => $gain_loss_chart->link,
|
|
amount => $fx_gain_loss_amount,
|
|
transdate => $transdate_obj,
|
|
source => $source,
|
|
memo => $memo,
|
|
taxkey => 0,
|
|
fx_transaction => 0, # probably indicates a real bank account in foreign currency
|
|
tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id);
|
|
$new_acc_trans->save;
|
|
push @new_acc_ids, $new_acc_trans->acc_trans_id;
|
|
}
|
|
}
|
|
# skonto cases
|
|
if ($params{payment_type} eq 'with_skonto_pt' or $params{payment_type} eq 'free_skonto' ) {
|
|
|
|
my $total_skonto_amount;
|
|
if ( $params{payment_type} eq 'with_skonto_pt' ) {
|
|
$total_skonto_amount = $self->skonto_amount;
|
|
} elsif ( $params{payment_type} eq 'free_skonto') {
|
|
$total_skonto_amount = $params{skonto_amount};
|
|
}
|
|
my @skonto_bookings = $self->_skonto_charts_and_tax_correction(amount => $total_skonto_amount, bt_id => $params{bt_id},
|
|
transdate_obj => $transdate_obj, memo => $params{memo},
|
|
source => $params{source});
|
|
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);
|
|
|
|
# the acc_trans entries are saved individually, not added to $self and then saved all at once
|
|
$new_acc_trans->save;
|
|
push @new_acc_ids, $new_acc_trans->acc_trans_id;
|
|
|
|
$reference_amount -= abs($amount);
|
|
$paid_amount += -1 * $amount;
|
|
$skonto_amount_check -= $skonto_booking->{'skonto_amount'};
|
|
}
|
|
}
|
|
my $arap_amount = 0;
|
|
|
|
if ( $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;
|
|
} elsif ( $params{payment_type} eq 'free_skonto' ) {
|
|
# we forced positive values and forced rounding at the beginning
|
|
# therefore the above comment can be safely applied for this payment type
|
|
$arap_amount = $params{amount} + $params{skonto_amount};
|
|
}
|
|
|
|
# regardless of payment_type there is always only exactly one arap booking
|
|
# TODO: compare $arap_amount to running total and/or use this as running total for ar.paid|ap.paid
|
|
my $arap_booking= SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $reference_account->id,
|
|
chart_link => $reference_account->link,
|
|
amount => _round($arap_amount * $mult - $fx_gain_loss_amount),
|
|
transdate => $transdate_obj,
|
|
source => '', #$params{source},
|
|
taxkey => 0,
|
|
tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id);
|
|
$arap_booking->save;
|
|
push @new_acc_ids, $arap_booking->acc_trans_id;
|
|
|
|
# hook for invoice_for_advance_payment DATEV always pairs, acc_trans_id has to be higher than arap_booking ;-)
|
|
if ($self->invoice_type eq 'invoice_for_advance_payment') {
|
|
my $clearing_chart = SL::DB::Chart->new(id => $::instance_conf->get_advance_payment_clearing_chart_id)->load;
|
|
die "No Clearing Chart for Advance Payment" unless ref $clearing_chart eq 'SL::DB::Chart';
|
|
|
|
# what does ptc say
|
|
my %inv_calc = $self->calculate_prices_and_taxes();
|
|
my @trans_ids = keys %{ $inv_calc{amounts} };
|
|
die "Invalid state for advance payment more than one trans_id" if (scalar @trans_ids > 1);
|
|
my $entry = delete $inv_calc{amounts}{$trans_ids[0]};
|
|
my $tax;
|
|
if ($entry->{tax_id}) {
|
|
$tax = SL::DB::Manager::Tax->find_by(id => $entry->{tax_id}); # || die "Can't find tax with id " . $entry->{tax_id};
|
|
}
|
|
if ($tax and $tax->rate != 0) {
|
|
my ($netamount, $taxamount);
|
|
my $roundplaces = 2;
|
|
# we dont have a clue about skonto, that's why we use $arap_amount as taxincluded
|
|
($netamount, $taxamount) = Form->calculate_tax($arap_amount, $tax->rate, 1, $roundplaces);
|
|
# for debugging database set
|
|
my $fullmatch = $netamount == $entry->{amount} ? '::netamount total true' : '';
|
|
my $transfer_chart = $tax->taxkey == 2 ? SL::DB::Chart->new(id => $::instance_conf->get_advance_payment_taxable_7_id)->load
|
|
: $tax->taxkey == 3 ? SL::DB::Chart->new(id => $::instance_conf->get_advance_payment_taxable_19_id)->load
|
|
: undef;
|
|
die "No Transfer Chart for Advance Payment" unless ref $transfer_chart eq 'SL::DB::Chart';
|
|
|
|
my $arap_full_booking= SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $clearing_chart->id,
|
|
chart_link => $clearing_chart->link,
|
|
amount => $arap_amount * -1, # full amount
|
|
transdate => $transdate_obj,
|
|
source => 'Automatic Tax Booking for Payment in Advance' . $fullmatch,
|
|
taxkey => 0,
|
|
tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id);
|
|
$arap_full_booking->save;
|
|
push @new_acc_ids, $arap_full_booking->acc_trans_id;
|
|
|
|
my $arap_tax_booking= SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $transfer_chart->id,
|
|
chart_link => $transfer_chart->link,
|
|
amount => _round($netamount), # full amount
|
|
transdate => $transdate_obj,
|
|
source => 'Automatic Tax Booking for Payment in Advance' . $fullmatch,
|
|
taxkey => $tax->taxkey,
|
|
tax_id => $tax->id);
|
|
$arap_tax_booking->save;
|
|
push @new_acc_ids, $arap_tax_booking->acc_trans_id;
|
|
|
|
my $tax_booking= SL::DB::AccTransaction->new(trans_id => $self->id,
|
|
chart_id => $tax->chart_id,
|
|
chart_link => $tax->chart->link,
|
|
amount => _round($taxamount),
|
|
transdate => $transdate_obj,
|
|
source => 'Automatic Tax Booking for Payment in Advance' . $fullmatch,
|
|
taxkey => 0,
|
|
tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id);
|
|
|
|
$tax_booking->save;
|
|
push @new_acc_ids, $tax_booking->acc_trans_id;
|
|
}
|
|
}
|
|
$self->paid($self->paid + _round($paid_amount)) if $paid_amount;
|
|
$self->datepaid($transdate_obj);
|
|
$self->save;
|
|
|
|
# make sure transactions will be reloaded the next time $self->transactions
|
|
# is called, as pay_invoice saves the acc_trans objects individually rather
|
|
# than adding them to the transaction relation array.
|
|
$self->forget_related('transactions');
|
|
|
|
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(
|
|
dbh => $db->dbh,
|
|
trans_id => $self->{id},
|
|
);
|
|
|
|
$datev->generate_datev_data;
|
|
|
|
if ($datev->errors) {
|
|
# this exception should be caught by with_transaction, which handles the rollback
|
|
die join "\n", $::locale->text('DATEV check returned errors:'), $datev->errors;
|
|
}
|
|
}
|
|
|
|
1;
|
|
|
|
}) || die t8('error while paying invoice #1 : ', $self->invnumber) . $db->error . "\n";
|
|
|
|
$return_bank_amount *= -1; # negative booking is positive bank transaction
|
|
# positive booking is negative bank transaction
|
|
return wantarray ? ( { return_bank_amount => $return_bank_amount }, @new_acc_ids) : 1;
|
|
}
|
|
|
|
sub skonto_date {
|
|
my $self = shift;
|
|
|
|
return undef unless ref $self->payment_terms eq 'SL::DB::PaymentTerm';
|
|
return undef unless $self->payment_terms->terms_skonto > 0;
|
|
|
|
return DateTime->from_object(object => $self->transdate)->add(days => $self->payment_terms->terms_skonto);
|
|
}
|
|
|
|
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,
|
|
'!chart_id' => $::instance_conf->get_advance_payment_clearing_chart_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 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 // 0) - ($self->paid // 0);
|
|
}
|
|
|
|
sub open_amount_fx {
|
|
validate_pos(
|
|
@_,
|
|
{ |