]> wagnertech.de Git - mfinanz.git/blob - SL/SEPA.pm
restart apache2 in postinst
[mfinanz.git] / SL / SEPA.pm
1 package SL::SEPA;
2
3 use strict;
4
5 use POSIX qw(strftime);
6
7 use Data::Dumper;
8 use SL::DBUtils;
9 use SL::DB::Invoice;
10 use SL::DB::PurchaseInvoice;
11 use SL::DB;
12 use SL::Locale::String qw(t8);
13 use DateTime;
14 use Carp;
15
16 sub retrieve_open_invoices {
17   $main::lxdebug->enter_sub();
18
19   my $self     = shift;
20   my %params   = @_;
21
22   my $myconfig = \%main::myconfig;
23   my $form     = $main::form;
24
25   my $dbh      = $params{dbh} || $form->get_standard_dbh($myconfig);
26   my $arap     = $params{vc} eq 'customer' ? 'ar'       : 'ap';
27   my $vc       = $params{vc} eq 'customer' ? 'customer' : 'vendor';
28   my $vc_vc_id = $params{vc} eq 'customer' ? 'c_vendor_id' : 'v_customer_id';
29
30   my $mandate  = $params{vc} eq 'customer' ? " AND COALESCE(vc.mandator_id, '') <> '' AND vc.mandate_date_of_signature IS NOT NULL " : '';
31
32   my $is_sepa_blocked = $params{vc} eq 'customer' ? 'FALSE' : "${arap}.is_sepa_blocked";
33
34   # open_amount is not the current open amount according to bookkeeping, but
35   # the open amount minus the SEPA transfer amounts that haven't been closed yet
36   my $query =
37     qq|
38        SELECT ${arap}.id, ${arap}.invnumber, ${arap}.transdate, ${arap}.${vc}_id as vc_id, ${arap}.amount AS invoice_amount, ${arap}.invoice,
39          (${arap}.transdate + pt.terms_skonto) as skonto_date, (pt.percent_skonto * 100) as percent_skonto,
40          (${arap}.amount - (${arap}.amount * pt.percent_skonto)) as amount_less_skonto,
41          (${arap}.amount * pt.percent_skonto) as skonto_amount,
42          vc.name AS vcname, vc.language_id, ${arap}.duedate as duedate, ${arap}.direct_debit,
43          ${is_sepa_blocked} AS is_sepa_blocked,
44          vc.${vc_vc_id} as vc_vc_id,
45
46          COALESCE(vc.iban, '') <> '' AND COALESCE(vc.bic, '') <> '' ${mandate} AS vc_bank_info_ok,
47
48          ${arap}.amount - ${arap}.paid - COALESCE(open_transfers.amount, 0) AS open_amount,
49          COALESCE(open_transfers.amount, 0) AS transfer_amount,
50          pt.description as pt_description,
51          (current_date < (${arap}.transdate + pt.terms_skonto)) as within_skonto_period
52        FROM ${arap}
53        LEFT JOIN ${vc} vc ON (${arap}.${vc}_id = vc.id)
54        LEFT JOIN (SELECT sei.${arap}_id, SUM(sei.amount) + SUM(COALESCE(sei.skonto_amount,0)) AS amount
55                   FROM sepa_export_items sei
56                   LEFT JOIN sepa_export se ON (sei.sepa_export_id = se.id)
57                   WHERE NOT se.closed
58                     AND (se.vc = '${vc}')
59                   GROUP BY sei.${arap}_id)
60          AS open_transfers ON (${arap}.id = open_transfers.${arap}_id)
61
62        LEFT JOIN payment_terms pt ON (${arap}.payment_id = pt.id)
63
64        WHERE (${arap}.amount - (COALESCE(open_transfers.amount, 0) + ${arap}.paid)) >= 0.01
65
66        ORDER BY lower(vc.name) ASC, lower(${arap}.invnumber) ASC
67 |;
68     #  $main::lxdebug->message(LXDebug->DEBUG2(),"sepa add query:".$query);
69
70   my $results = selectall_hashref_query($form, $dbh, $query);
71
72   # add some more data to $results:
73   # create drop-down data for payment types and suggest amount to be paid according
74   # to open amount or skonto
75   # One minor fault: amount_less_skonto does not subtract the not yet booked sepa transfer amounts
76
77   foreach my $result ( @$results ) {
78     my   @options;
79     push @options, { payment_type => 'without_skonto',  display => t8('without skonto') };
80     push @options, { payment_type => 'with_skonto_pt',  display => t8('with skonto acc. to pt'), selected => 1 } if $result->{within_skonto_period};
81     $result->{payment_select_options}  = \@options;
82   }
83
84   $main::lxdebug->leave_sub();
85
86   return $results;
87 }
88
89 sub create_export {
90   my ($self, %params) = @_;
91   $main::lxdebug->enter_sub();
92
93   my $rc = SL::DB->client->with_transaction(\&_create_export, $self, %params);
94
95   $::lxdebug->leave_sub;
96   return $rc;
97 }
98
99 sub _create_export {
100   my $self     = shift;
101   my %params   = @_;
102
103   Common::check_params(\%params, qw(employee bank_transfers vc));
104
105   my $myconfig = \%main::myconfig;
106   my $form     = $main::form;
107   my $arap     = $params{vc} eq 'customer' ? 'ar'       : 'ap';
108   my $vc       = $params{vc} eq 'customer' ? 'customer' : 'vendor';
109   my $ARAP     = uc $arap;
110
111   my $dbh      = $params{dbh} || SL::DB->client->dbh;
112
113   my ($export_id) = selectfirst_array_query($form, $dbh, qq|SELECT nextval('sepa_export_id_seq')|);
114   my $query       =
115     qq|INSERT INTO sepa_export (id, employee_id, vc)
116        VALUES (?, (SELECT id
117                    FROM employee
118                    WHERE login = ?), ?)|;
119   do_query($form, $dbh, $query, $export_id, $params{employee}, $vc);
120
121   my $q_item_id = qq|SELECT nextval('id')|;
122   my $h_item_id = prepare_query($form, $dbh, $q_item_id);
123   my $c_mandate = $params{vc} eq 'customer' ? ', vc_mandator_id, vc_mandate_date_of_signature' : '';
124   my $p_mandate = $params{vc} eq 'customer' ? ', ?, ?' : '';
125
126   my $q_insert =
127     qq|INSERT INTO sepa_export_items (id,          sepa_export_id,           ${arap}_id,  chart_id,
128                                       amount,      requested_execution_date, reference,   end_to_end_id,
129                                       our_iban,    our_bic,                  vc_iban,     vc_bic,
130                                       skonto_amount, payment_type ${c_mandate})
131        VALUES                        (?,           ?,                        ?,           ?,
132                                       ?,           ?,                        ?,           ?,
133                                       ?,           ?,                        ?,           ?,
134                                       ?,           ? ${p_mandate})|;
135   my $h_insert = prepare_query($form, $dbh, $q_insert);
136
137   my $q_reference =
138     qq|SELECT arap.invnumber,
139          (SELECT COUNT(at.*)
140           FROM acc_trans at
141           LEFT JOIN chart c ON (at.chart_id = c.id)
142           WHERE (at.trans_id = ?)
143             AND (c.link LIKE '%${ARAP}_paid%'))
144          +
145          (SELECT COUNT(sei.*)
146           FROM sepa_export_items sei
147           WHERE (sei.ap_id = ?))
148          AS num_payments
149        FROM ${arap} arap
150        WHERE id = ?|;
151   my $h_reference = prepare_query($form, $dbh, $q_reference);
152
153   my @now         = localtime;
154
155   foreach my $transfer (@{ $params{bank_transfers} }) {
156     if (!$transfer->{reference}) {
157       do_statement($form, $h_reference, $q_reference, (conv_i($transfer->{"${arap}_id"})) x 3);
158
159       my ($invnumber, $num_payments) = $h_reference->fetchrow_array();
160       $num_payments++;
161
162       $transfer->{reference} = "${invnumber}-${num_payments}";
163     }
164
165     $h_item_id->execute() || $::form->dberror($q_item_id);
166     my ($item_id)      = $h_item_id->fetchrow_array();
167
168     my $end_to_end_id  = strftime "KIVITENDO%Y%m%d%H%M%S", localtime;
169     my $item_id_len    = length "$item_id";
170     my $num_zeroes     = 35 - $item_id_len - length $end_to_end_id;
171     $end_to_end_id    .= '0' x $num_zeroes if (0 < $num_zeroes);
172     $end_to_end_id    .= $item_id;
173     $end_to_end_id     = substr $end_to_end_id, 0, 35;
174
175     my @values = ($item_id,                          $export_id,
176                   conv_i($transfer->{"${arap}_id"}), conv_i($transfer->{chart_id}),
177                   $transfer->{amount},               conv_date($transfer->{requested_execution_date}),
178                   $transfer->{reference},            $end_to_end_id,
179                   map { my $pfx = $_; map { $transfer->{"${pfx}_${_}"} } qw(iban bic) } qw(our vc));
180     # save value of skonto_amount and payment_type
181     if ( $transfer->{payment_type} eq 'without_skonto' ) {
182       push(@values, 0);
183     } elsif ($transfer->{payment_type} eq 'difference_as_skonto' ) {
184       push(@values, $transfer->{amount});
185     } elsif ($transfer->{payment_type} eq 'with_skonto_pt' ) {
186       push(@values, $transfer->{skonto_amount});
187     } else {
188       die "illegal payment_type: " . $transfer->{payment_type} . "\n";
189     };
190     push(@values, $transfer->{payment_type});
191
192     push @values, $transfer->{vc_mandator_id}, conv_date($transfer->{vc_mandate_date_of_signature}) if $params{vc} eq 'customer';
193
194     do_statement($form, $h_insert, $q_insert, @values);
195   }
196
197   $h_insert->finish();
198   $h_item_id->finish();
199
200   return $export_id;
201 }
202
203 sub retrieve_export {
204   $main::lxdebug->enter_sub();
205
206   my $self     = shift;
207   my %params   = @_;
208
209   Common::check_params(\%params, qw(id vc));
210
211   my $myconfig = \%main::myconfig;
212   my $form     = $main::form;
213   my $vc       = $params{vc} eq 'customer' ? 'customer' : 'vendor';
214   my $arap     = $params{vc} eq 'customer' ? 'ar'       : 'ap';
215
216   my $dbh      = $params{dbh} || $form->get_standard_dbh($myconfig);
217
218   my ($joins, $columns);
219
220   if ($params{details}) {
221     $columns = ', arap.invoice';
222     $joins   = "LEFT JOIN ${arap} arap ON (se.${arap}_id = arap.id)";
223   }
224
225   my $query =
226     qq|SELECT se.*,
227          CASE WHEN COALESCE(e.name, '') <> '' THEN e.name ELSE e.login END AS employee
228        FROM sepa_export se
229        LEFT JOIN employee e ON (se.employee_id = e.id)
230        WHERE se.id = ?|;
231
232   my $export = selectfirst_hashref_query($form, $dbh, $query, conv_i($params{id}));
233
234   if ($export->{id}) {
235     my ($columns, $joins);
236
237     my $mandator_id = $params{vc} eq 'customer' ? ', mandator_id, mandate_date_of_signature' : '';
238
239     if ($params{details}) {
240       $columns = qq|, arap.invnumber, arap.invoice, arap.transdate AS reference_date, vc.name AS vc_name, vc.${vc}number AS vc_number, c.accno AS chart_accno, c.description AS chart_description ${mandator_id}|;
241       $joins   = qq|LEFT JOIN ${arap} arap ON (sei.${arap}_id = arap.id)
242                     LEFT JOIN ${vc} vc     ON (arap.${vc}_id  = vc.id)
243                     LEFT JOIN chart c      ON (sei.chart_id   = c.id)|;
244     }
245
246     $query = qq|SELECT sei.*
247                   $columns
248                 FROM sepa_export_items sei
249                 $joins
250                 WHERE sei.sepa_export_id = ?
251                 ORDER BY sei.id|;
252
253     $export->{items} = selectall_hashref_query($form, $dbh, $query, conv_i($params{id}));
254
255   } else {
256     $export->{items} = [];
257   }
258
259   $main::lxdebug->leave_sub();
260
261   return $export;
262 }
263
264 sub close_export {
265   $main::lxdebug->enter_sub();
266
267   my $self     = shift;
268   my %params   = @_;
269
270   Common::check_params(\%params, qw(id));
271
272   my $myconfig = \%main::myconfig;
273   my $form     = $main::form;
274
275   SL::DB->client->with_transaction(sub {
276     my $dbh      = $params{dbh} || SL::DB->client->dbh;
277
278     my @ids          = ref $params{id} eq 'ARRAY' ? @{ $params{id} } : ($params{id});
279     my $placeholders = join ', ', ('?') x scalar @ids;
280     my $query        = qq|UPDATE sepa_export SET closed = TRUE WHERE id IN ($placeholders)|;
281
282     do_query($form, $dbh, $query, map { conv_i($_) } @ids);
283     1;
284   }) or do { die SL::DB->client->error };
285
286   $main::lxdebug->leave_sub();
287 }
288
289 sub undo_export {
290   $main::lxdebug->enter_sub();
291
292   my $self     = shift;
293   my %params   = @_;
294
295   Common::check_params(\%params, qw(id));
296
297   my $sepa_export = SL::DB::Manager::SepaExport->find_by(id => $params{id});
298
299   croak "Not a valid SEPA Export id: $params{id}" unless $sepa_export;
300   croak "Cannot undo closed exports."             if $sepa_export->closed;
301   croak "Cannot undo executed exports."           if $sepa_export->executed;
302
303   die "Could not undo $sepa_export->id" if !$sepa_export->delete();
304
305   $main::lxdebug->leave_sub();
306 }
307
308 sub list_exports {
309   $main::lxdebug->enter_sub();
310
311   my $self     = shift;
312   my %params   = @_;
313
314   my $myconfig = \%main::myconfig;
315   my $form     = $main::form;
316   my $vc       = $params{vc} eq 'customer' ? 'customer' : 'vendor';
317   my $arap     = $params{vc} eq 'customer' ? 'ar'       : 'ap';
318
319   my $dbh      = $params{dbh} || $form->get_standard_dbh($myconfig);
320
321   my %sort_columns = (
322     'id'          => [ 'se.id',                ],
323     'export_date' => [ 'se.itime',             ],
324     'employee'    => [ 'e.name',      'se.id', ],
325     'executed'    => [ 'se.executed', 'se.id', ],
326     'closed'      => [ 'se.closed',   'se.id', ],
327     );
328
329   my %sort_spec = create_sort_spec('defs' => \%sort_columns, 'default' => 'id', 'column' => $params{sortorder}, 'dir' => $params{sortdir});
330
331   my (@where, @values, @where_sub, @values_sub, %joins_sub);
332
333   my $filter = $params{filter} || { };
334
335   foreach (qw(executed closed)) {
336     push @where, $filter->{$_} ? "se.$_" : "NOT se.$_" if (exists $filter->{$_});
337   }
338
339   my %operators = ('from' => '>=',
340                    'to'   => '<=');
341
342   foreach my $dir (qw(from to)) {
343     next unless ($filter->{"export_date_${dir}"});
344     push @where,  "se.itime $operators{$dir} ?::date";
345     push @values, $filter->{"export_date_${dir}"};
346   }
347
348   if ($filter->{invnumber}) {
349     push @where_sub,  "arap.invnumber ILIKE ?";
350     push @values_sub, like($filter->{invnumber});
351     $joins_sub{$arap} = 1;
352   }
353
354   if ($filter->{message_id}) {
355     push @values, like($filter->{message_id});
356     push @where,  <<SQL;
357       se.id IN (
358         SELECT sepa_export_id
359         FROM sepa_export_message_ids
360         WHERE message_id ILIKE ?
361       )
362 SQL
363   }
364
365   if ($filter->{vc}) {
366     push @where_sub,  "vc.name ILIKE ?";
367     push @values_sub, like($filter->{vc});
368     $joins_sub{$arap} = 1;
369     $joins_sub{vc}    = 1;
370   }
371
372   foreach my $type (qw(requested_execution execution)) {
373     foreach my $dir (qw(from to)) {
374       next unless ($filter->{"${type}_date_${dir}"});
375       push @where_sub,  "(items.${type}_date IS NOT NULL) AND (items.${type}_date $operators{$dir} ?)";
376       push @values_sub, $filter->{"${type}_date_${_}"};
377     }
378   }
379
380   if (@where_sub) {
381     my $joins_sub  = '';
382     $joins_sub    .= " LEFT JOIN ${arap} arap ON (items.${arap}_id = arap.id)" if ($joins_sub{$arap});
383     $joins_sub    .= " LEFT JOIN ${vc} vc      ON (arap.${vc}_id   = vc.id)"   if ($joins_sub{vc});
384
385     my $where_sub  = join(' AND ', map { "(${_})" } @where_sub);
386
387     my $query_sub  = qq|se.id IN (SELECT items.sepa_export_id
388                                   FROM sepa_export_items items
389                                   $joins_sub
390                                   WHERE $where_sub)|;
391
392     push @where,  $query_sub;
393     push @values, @values_sub;
394   }
395
396   push @where,  'se.vc = ?';
397   push @values, $vc;
398
399   my $where = @where ? ' WHERE ' . join(' AND ', map { "(${_})" } @where) : '';
400
401   my $query =
402     qq|SELECT se.id, se.employee_id, se.executed, se.closed, itime::date AS export_date,
403          (SELECT COUNT(*)
404           FROM sepa_export_items sei
405           WHERE (sei.sepa_export_id = se.id)) AS num_invoices,
406          (SELECT SUM(sei.amount)
407           FROM sepa_export_items sei
408           WHERE (sei.sepa_export_id = se.id)) AS sum_amounts,
409          (SELECT string_agg(semi.message_id, ', ')
410           FROM sepa_export_message_ids semi
411           WHERE semi.sepa_export_id = se.id) AS message_ids,
412          e.name AS employee
413        FROM sepa_export se
414        LEFT JOIN (
415          SELECT emp.id,
416            CASE WHEN COALESCE(emp.name, '') <> '' THEN emp.name ELSE emp.login END AS name
417          FROM employee emp
418        ) AS e ON (se.employee_id = e.id)
419        $where
420        ORDER BY $sort_spec{sql}|;
421
422   my $results = selectall_hashref_query($form, $dbh, $query, @values);
423
424   $main::lxdebug->leave_sub();
425
426   return $results;
427 }
428
429 sub post_payment {
430   my ($self, %params) = @_;
431   $main::lxdebug->enter_sub();
432
433   my $rc = SL::DB->client->with_transaction(\&_post_payment, $self, %params);
434
435   $::lxdebug->leave_sub;
436   return $rc;
437 }
438
439 sub _post_payment {
440   my $self     = shift;
441   my %params   = @_;
442
443   Common::check_params(\%params, qw(items));
444
445   my $myconfig = \%main::myconfig;
446   my $form     = $main::form;
447   my $vc       = $params{vc} eq 'customer' ? 'customer' : 'vendor';
448   my $arap     = $params{vc} eq 'customer' ? 'ar'       : 'ap';
449   my $mult     = $params{vc} eq 'customer' ? -1         : 1;
450   my $ARAP     = uc $arap;
451
452   my $dbh      = $params{dbh} || SL::DB->client->dbh;
453
454   my @items    = ref $params{items} eq 'ARRAY' ? @{ $params{items} } : ($params{items});
455
456   my %handles  = (
457     'get_item'       => [ qq|SELECT sei.*
458                              FROM sepa_export_items sei
459                              WHERE sei.id = ?| ],
460
461     'get_arap'       => [ qq|SELECT at.chart_id
462                              FROM acc_trans at
463                              LEFT JOIN chart c ON (at.chart_id = c.id)
464                              WHERE (trans_id = ?)
465                                AND ((c.link LIKE '%:${ARAP}') OR (c.link LIKE '${ARAP}:%') OR (c.link = '${ARAP}'))
466                              LIMIT 1| ],
467
468     'add_acc_trans'  => [ qq|INSERT INTO acc_trans (trans_id, chart_id, amount, transdate, gldate,       source, memo, taxkey, tax_id ,                                     chart_link)
469                              VALUES                (?,        ?,        ?,      ?,         current_date, ?,      '',   0,      (SELECT id FROM tax WHERE taxkey=0 LIMIT 1), (SELECT link FROM chart WHERE id=?))| ],
470
471     'update_arap'    => [ qq|UPDATE ${arap}
472                              SET paid = paid + ?
473                              WHERE id = ?| ],
474
475     'finish_item'    => [ qq|UPDATE sepa_export_items
476                              SET execution_date = ?, executed = TRUE
477                              WHERE id = ?| ],
478
479     'has_unexecuted' => [ qq|SELECT sei1.id
480                              FROM sepa_export_items sei1
481                              WHERE (sei1.sepa_export_id = (SELECT sei2.sepa_export_id
482                                                            FROM sepa_export_items sei2
483                                                            WHERE sei2.id = ?))
484                                AND NOT COALESCE(sei1.executed, FALSE)
485                              LIMIT 1| ],
486
487     'do_close'       => [ qq|UPDATE sepa_export
488                              SET executed = TRUE, closed = TRUE
489                              WHERE (id = ?)| ],
490     );
491
492   map { unshift @{ $_ }, prepare_query($form, $dbh, $_->[0]) } values %handles;
493
494   foreach my $item (@items) {
495
496     my $item_id = conv_i($item->{id});
497
498     # Retrieve the item data belonging to the ID.
499     do_statement($form, @{ $handles{get_item} }, $item_id);
500     my $orig_item = $handles{get_item}->[0]->fetchrow_hashref();
501
502     next if (!$orig_item);
503
504     # fetch item_id via Rose (same id as orig_item)
505     my $sepa_export_item = SL::DB::Manager::SepaExportItem->find_by( id => $item_id);
506
507     my $invoice;
508
509     if ( $sepa_export_item->ar_id ) {
510       $invoice = SL::DB::Manager::Invoice->find_by( id => $sepa_export_item->ar_id);
511     } elsif ( $sepa_export_item->ap_id ) {
512       $invoice = SL::DB::Manager::PurchaseInvoice->find_by( id => $sepa_export_item->ap_id);
513     } else {
514       die "sepa_export_item needs either ar_id or ap_id\n";
515     };
516
517     $invoice->pay_invoice(amount       => $sepa_export_item->amount,
518                           payment_type => $sepa_export_item->payment_type,
519                           chart_id     => $sepa_export_item->chart_id,
520                           source       => $sepa_export_item->reference,
521                           transdate    => $item->{execution_date},  # value from user form
522                          );
523
524     # Update the item to reflect that it has been posted.
525     do_statement($form, @{ $handles{finish_item} }, $item->{execution_date}, $item_id);
526
527     # Check whether or not we can close the export itself if there are no unexecuted items left.
528     do_statement($form, @{ $handles{has_unexecuted} }, $item_id);
529     my ($has_unexecuted) = $handles{has_unexecuted}->[0]->fetchrow_array();
530
531     if (!$has_unexecuted) {
532       do_statement($form, @{ $handles{do_close} }, $orig_item->{sepa_export_id});
533     }
534   }
535
536   map { $_->[0]->finish() } values %handles;
537
538   return 1;
539 }
540
541 1;
542
543
544 __END__
545
546 =head1 NAME
547
548 SL::SEPA - Base class for SEPA objects
549
550 =head1 SYNOPSIS
551
552  # get all open invoices we like to pay via SEPA
553  my $invoices = SL::SEPA->retrieve_open_invoices(vc => 'vendor');
554
555  # add some IBAN and purposes for open transaction
556  # and assign this to a SEPA export
557  my $id = SL::SEPA->create_export('employee'       => $::myconfig{login},
558                                  'bank_transfers' => \@bank_transfers,
559                                  'vc'             => 'vendor');
560
561 =head1 DESCRIPTIONS
562
563 This is the base class for SEPA. SEPA and the underlying directories
564 (SEPA::XML etc) are used to genereate valid XML files for the SEPA
565 (Single European Payment Area) specification and offers this structure
566 as a download via a xml file.
567
568 An export can have one or more transaction which have to
569 comply to the specification (IBAN, BIC, amount, purpose, etc).
570
571 Furthermore kivitendo sepa exports have two
572 valid states: Open or closed and executed or not executed.
573
574 The state closed can be set via a user interface and the
575 state executed is automatically assigned if the action payment
576 is triggered.
577
578 =head1 FUNCTIONS
579
580 =head2 C<undo_export> $sepa_export_id
581
582 Needs a valid sepa_export id and deletes the sepa export if
583 the state of the export is neither executed nor closed.
584 Returns undef if the deletion was successfully.
585 Otherwise the function just dies with a short notice of the id.
586
587 =cut