6151ec158581d46c8bc6fe8e0221063a6b1a91bb
[kivitendo-erp.git] / SL / BackgroundJob / CreatePeriodicInvoices.pm
1 package SL::BackgroundJob::CreatePeriodicInvoices;
2
3 use strict;
4
5 use parent qw(SL::BackgroundJob::Base);
6
7 use Config::Std;
8 use DateTime::Format::Strptime;
9 use English qw(-no_match_vars);
10 use List::MoreUtils qw(uniq);
11
12 use SL::Common;
13 use SL::DB::AuthUser;
14 use SL::DB::Default;
15 use SL::DB::Order;
16 use SL::DB::Invoice;
17 use SL::DB::PeriodicInvoice;
18 use SL::DB::PeriodicInvoicesConfig;
19 use SL::File;
20 use SL::Helper::CreatePDF qw(create_pdf find_template);
21 use SL::Mailer;
22 use SL::Util qw(trim);
23 use SL::System::Process;
24
25 sub create_job {
26   $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
27 }
28
29 sub run {
30   my $self        = shift;
31   $self->{db_obj} = shift;
32
33   $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
34
35   if (!$self->{db_obj}->db->with_transaction(sub {
36     1;                          # make Emacs happy
37
38     my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
39
40     foreach my $config (@{ $configs }) {
41       my $new_end_date = $config->handle_automatic_extension;
42       _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
43     }
44
45     my (@invoices_to_print, @invoices_to_email);
46
47     _log_msg("Number of configs: " . scalar(@{ $configs}));
48
49     foreach my $config (@{ $configs }) {
50       # A configuration can be set to inactive by
51       # $config->handle_automatic_extension. Therefore the check in
52       # ...->get_all() does not suffice.
53       _log_msg("Config " . $config->id . " active " . $config->active);
54       next unless $config->active;
55
56       my @dates = _calculate_dates($config);
57
58       _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
59
60       foreach my $date (@dates) {
61         my $data = $self->_create_periodic_invoice($config, $date);
62         next unless $data;
63
64         _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
65
66         push @{ $self->{posted_invoices} }, $data->{invoice};
67         push @invoices_to_print, $data if $config->print;
68         push @invoices_to_email, $data if $config->send_email;
69
70         my $inactive_ordnumber = $config->disable_one_time_config;
71         if ($inactive_ordnumber) {
72           # disable one time configs and skip eventual invoices
73           _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
74           push @{ $self->{disabled_orders} }, $inactive_ordnumber;
75           last;
76         }
77       }
78     }
79
80     foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
81     foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
82
83     $self->_send_summary_email;
84
85       1;
86     })) {
87       $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
88       return undef;
89     }
90
91     if (@{ $self->{job_errors} }) {
92       my $msg = join "\n", @{ $self->{job_errors} };
93       _log_msg("Errors: $msg");
94       die $msg;
95     }
96
97   return 1;
98 }
99
100 sub _log_msg {
101   my $message  = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
102   $message    .= "\n" unless $message =~ m/\n$/;
103   $::lxdebug->message(LXDebug::DEBUG1(), $message);
104 }
105
106 sub _generate_time_period_variables {
107   my $config            = shift;
108   my $period_start_date = shift;
109   my $period_end_date   = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
110
111   my @month_names       = ('',
112                            $::locale->text('January'), $::locale->text('February'), $::locale->text('March'),     $::locale->text('April'),   $::locale->text('May'),      $::locale->text('June'),
113                            $::locale->text('July'),    $::locale->text('August'),   $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
114
115   my $vars = {
116     current_quarter     => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->quarter } ],
117     previous_quarter    => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
118     next_quarter        => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 3), sub { $_[0]->quarter } ],
119
120     current_month       => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->month } ],
121     previous_month      => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
122     next_month          => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $_[0]->month } ],
123
124     current_month_long  => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $month_names[ $_[0]->month ] } ],
125     previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
126     next_month_long     => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $month_names[ $_[0]->month ] } ],
127
128     current_year        => [ $period_start_date->clone->truncate(to => 'year'),                         sub { $_[0]->year } ],
129     previous_year       => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1),   sub { $_[0]->year } ],
130     next_year           => [ $period_start_date->clone->truncate(to => 'year')->add(     years => 1),   sub { $_[0]->year } ],
131
132     period_start_date   => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
133     period_end_date     => [ $period_end_date,          sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
134   };
135
136   return $vars;
137 }
138
139 sub _replace_vars {
140   my (%params) = @_;
141   my $sub      = $params{attribute};
142   my $str      = $params{object}->$sub // '';
143   my $sub_fmt  = lc($params{attribute_format} // 'text');
144
145   my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('&lt;%', '%&gt;') : ('<%', '%>');
146
147   $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
148     my ($key, $format) = ($1, $3);
149     $key               = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
150     my $new_value;
151
152     if ($params{vars}->{$key} && $format) {
153       $format    = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
154
155       $new_value = DateTime::Format::Strptime->new(
156         pattern     => $format,
157         locale      => 'de_DE',
158         time_zone   => 'local',
159       )->format_datetime($params{vars}->{$key}->[0]);
160
161     } elsif ($params{vars}->{$key}) {
162       $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
163
164     } elsif ($params{invoice} && $params{invoice}->can($key)) {
165       $new_value = $params{invoice}->$key;
166     }
167
168     $new_value //= '';
169     $new_value   = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
170
171     $new_value;
172
173   }eigx;
174
175   $params{object}->$sub($str);
176 }
177
178 sub _adjust_sellprices_for_period_lengths {
179   my (%params) = @_;
180
181   return if $params{config}->periodicity eq 'o';
182
183   my $billing_len     = $params{config}->get_billing_period_length;
184   my $order_value_len = $params{config}->get_order_value_period_length;
185
186   return if $billing_len == $order_value_len;
187
188   my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
189
190   _log_msg("_adjust_sellprices_for_period_lengths: period_start_date $params{period_start_date} is_last_invoice_in_cycle $is_last_invoice_in_cycle billing_len $billing_len order_value_len $order_value_len");
191
192   if ($order_value_len < $billing_len) {
193     my $num_orders_per_invoice = $billing_len / $order_value_len;
194
195     $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
196
197     return;
198   }
199
200   my $num_invoices_in_cycle = $order_value_len / $billing_len;
201
202   foreach my $item (@{ $params{invoice}->items }) {
203     my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
204
205     if ($is_last_invoice_in_cycle) {
206       $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
207
208     } else {
209       $item->sellprice($sellprice_one_invoice);
210     }
211   }
212 }
213
214 sub _create_periodic_invoice {
215   my $self              = shift;
216   my $config            = shift;
217   my $period_start_date = shift;
218
219   my $time_period_vars  = _generate_time_period_variables($config, $period_start_date);
220
221   my $invdate           = DateTime->today_local;
222
223   my $order   = $config->order;
224   my $invoice;
225   if (!$self->{db_obj}->db->with_transaction(sub {
226     1;                          # make Emacs happy
227
228     $invoice = SL::DB::Invoice->new_from($order);
229
230     my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
231
232     while ($tax_point < $period_start_date) {
233       $tax_point->add(months => $config->get_billing_period_length);
234     }
235
236     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
237     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
238
239     $invoice->assign_attributes(deliverydate => $period_start_date,
240                                 tax_point    => $tax_point,
241                                 intnotes     => $intnotes,
242                                 employee     => $order->employee, # new_from sets employee to import user
243                                 direct_debit => $config->direct_debit,
244                                );
245
246     _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
247
248     foreach my $item (@{ $invoice->items }) {
249       _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
250     }
251
252     _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
253
254     $invoice->post(ar_id => $config->ar_chart_id) || die;
255
256     $order->link_to_record($invoice);
257
258     foreach my $item (@{ $invoice->items }) {
259       foreach (qw(orderitems)) {    # expand if needed (delivery_order_items)
260           if ($item->{"converted_from_${_}_id"}) {
261             die unless $item->{id};
262             RecordLinks->create_links('mode'       => 'ids',
263                                       'from_table' => $_,
264                                       'from_ids'   => $item->{"converted_from_${_}_id"},
265                                       'to_table'   => 'invoice',
266                                       'to_id'      => $item->{id},
267             ) || die;
268             delete $item->{"converted_from_${_}_id"};
269          }
270       }
271     }
272
273     SL::DB::PeriodicInvoice->new(config_id         => $config->id,
274                                  ar_id             => $invoice->id,
275                                  period_start_date => $period_start_date)
276       ->save;
277
278     _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
279
280     # die $invoice->transaction_description;
281
282     1;
283   })) {
284     $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
285     return undef;
286   }
287
288   return {
289     config            => $config,
290     period_start_date => $period_start_date,
291     invoice           => $invoice,
292     time_period_vars  => $time_period_vars,
293   };
294 }
295
296 sub _calculate_dates {
297   my ($config) = @_;
298   return $config->calculate_invoice_dates(end_date => DateTime->today_local);
299 }
300
301 sub _send_summary_email {
302   my ($self) = @_;
303   my %config = %::lx_office_conf;
304
305   return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
306
307   return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
308
309   my $email = $config{periodic_invoices}->{send_email_to};
310   if ($email !~ m{\@}) {
311     my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
312     $email   = $user ? $user->get_config_value('email') : undef;
313   }
314
315   _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
316
317   return unless $email;
318
319   my $template = Template->new({ 'INTERPOLATE' => 0,
320                                  'EVAL_PERL'   => 0,
321                                  'ABSOLUTE'    => 1,
322                                  'CACHE_SIZE'  => 0,
323                                });
324
325   return unless $template;
326
327   my $email_template = $config{periodic_invoices}->{email_template};
328   my $filename       = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
329   my %params         = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
330
331   my $output;
332   $template->process($filename, \%params, \$output) || die $template->error;
333
334   my $mail              = Mailer->new;
335   $mail->{from}         = $config{periodic_invoices}->{email_from};
336   $mail->{to}           = $email;
337   $mail->{subject}      = $config{periodic_invoices}->{email_subject};
338   $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
339   $mail->{message}      = $output;
340
341   $mail->send;
342 }
343
344 sub _store_pdf_in_webdav {
345   my ($self, $pdf_file_name, $invoice) = @_;
346
347   return unless $::instance_conf->get_webdav_documents;
348
349   my $form = Form->new('');
350
351   $form->{cwd}              = SL::System::Process->exe_dir;
352   $form->{tmpdir}           = ($pdf_file_name =~ m{(.+)/})[0];
353   $form->{tmpfile}          = ($pdf_file_name =~ m{.+/(.+)})[0];
354   $form->{format}           = 'pdf';
355   $form->{formname}         = 'invoice';
356   $form->{type}             = 'invoice';
357   $form->{vc}               = 'customer';
358   $form->{invnumber}        = $invoice->invnumber;
359   $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
360
361   Common::copy_file_to_webdav_folder($form);
362 }
363
364 sub _store_pdf_in_filemanagement {
365   my ($self, $pdf_file, $invoice) = @_;
366
367   return unless $::instance_conf->get_doc_storage;
368
369   # create a form for generate_attachment_filename
370   my $form = Form->new('');
371   $form->{invnumber} = $invoice->invnumber;
372   $form->{type}      = 'invoice';
373   $form->{format}    = 'pdf';
374   $form->{formname}  = 'invoice';
375   $form->{language}  = '_' . $invoice->language->template_code if $invoice->language;
376   my $doc_name       = $form->generate_attachment_filename();
377
378   SL::File->save(object_id   => $invoice->id,
379                  object_type => 'invoice',
380                  mime_type   => 'application/pdf',
381                  source      => 'created',
382                  file_type   => 'document',
383                  file_name   => $doc_name,
384                  file_path   => $pdf_file);
385 }
386
387 sub _print_invoice {
388   my ($self, $data) = @_;
389
390   my $invoice       = $data->{invoice};
391   my $config        = $data->{config};
392
393   return unless $config->print && $config->printer_id && $config->printer->printer_command;
394
395   my $form = Form->new;
396   $invoice->flatten_to_form($form, format_amounts => 1);
397
398   $form->{printer_code} = $config->printer->template_code;
399   $form->{copies}       = $config->copies;
400   $form->{formname}     = $form->{type};
401   $form->{format}       = 'pdf';
402   $form->{media}        = 'printer';
403   $form->{OUT}          = $config->printer->printer_command;
404   $form->{OUT_MODE}     = '|-';
405
406   $form->{TEMPLATE_DRIVER_OPTIONS} = { };
407   $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
408
409   $form->prepare_for_printing;
410
411   $form->throw_on_error(sub {
412     eval {
413       $form->parse_template(\%::myconfig);
414       push @{ $self->{printed_invoices} }, $invoice;
415       1;
416     } or do {
417       push @{ $self->{job_errors} }, $EVAL_ERROR->error;
418       push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
419     };
420   });
421 }
422
423 sub _email_invoice {
424   my ($self, $data) = @_;
425
426   $data->{config}->load;
427
428   return unless $data->{config}->send_email;
429
430   my @recipients =
431     uniq
432     map  { lc       }
433     grep { $_       }
434     map  { trim($_) }
435     (split(m{,}, $data->{config}->email_recipient_address),
436      $data->{config}->email_recipient_contact   ? ($data->{config}->email_recipient_contact->cp_email) : (),
437      $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
438     );
439
440   return unless @recipients;
441
442   my $language      = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
443   my %create_params = (
444     template               => scalar($self->find_template(name => 'invoice', language => $language)),
445     variables              => Form->new(''),
446     return                 => 'file_name',
447     record                 => $data->{invoice},
448     variable_content_types => {
449       longdescription => 'html',
450       partnotes       => 'html',
451       notes           => 'html',
452     },
453   );
454
455   $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
456   $create_params{variables}->prepare_for_printing;
457
458   my $pdf_file_name;
459   my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
460
461   eval {
462     $pdf_file_name = $self->create_pdf(%create_params);
463
464     $self->_store_pdf_in_webdav        ($pdf_file_name, $data->{invoice});
465     $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
466
467     for (qw(email_subject email_body)) {
468       _replace_vars(
469         object           => $data->{config},
470         invoice          => $data->{invoice},
471         vars             => $data->{time_period_vars},
472         attribute        => $_,
473         attribute_format => 'text'
474       );
475     }
476
477     my $global_bcc = SL::DB::Default->get->global_bcc;
478     my $overall_error;
479
480     for my $recipient (@recipients) {
481       my $mail             = Mailer->new;
482       $mail->{record_id}   = $data->{invoice}->id,
483       $mail->{record_type} = 'invoice',
484       $mail->{from}        = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
485       $mail->{to}          = $recipient;
486       $mail->{bcc}         = $global_bcc;
487       $mail->{subject}     = $data->{config}->email_subject;
488       $mail->{message}     = $data->{config}->email_body;
489       $mail->{attachments} = [{
490         path     => $pdf_file_name,
491         name     => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
492       }];
493
494       my $error        = $mail->send;
495
496       if ($error) {
497         push @{ $self->{job_errors} }, $error;
498         push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
499         $overall_error = 1;
500       }
501     }
502
503     push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
504
505     1;
506
507   } or do {
508     push @{ $self->{job_errors} }, $EVAL_ERROR;
509     push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
510   };
511
512   unlink $pdf_file_name if $pdf_file_name;
513 }
514
515 1;
516
517 __END__
518
519 =pod
520
521 =encoding utf8
522
523 =head1 NAME
524
525 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
526 invoices for orders
527
528 =head1 SYNOPSIS
529
530 Iterate over all periodic invoice configurations, extend them if
531 applicable, calculate the dates for which invoices have to be posted
532 and post those invoices by converting the order into an invoice for
533 each date.
534
535 =head1 TOTO
536
537 =over 4
538
539 =item *
540
541 Strings like month names are hardcoded to German in this file.
542
543 =back
544
545 =head1 AUTHOR
546
547 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
548
549 =cut