1 package SL::BackgroundJob::CreatePeriodicInvoices;
 
   5 use parent qw(SL::BackgroundJob::Base);
 
   8 use DateTime::Format::Strptime;
 
   9 use English qw(-no_match_vars);
 
  10 use List::MoreUtils qw(uniq);
 
  17 use SL::DB::PeriodicInvoice;
 
  18 use SL::DB::PeriodicInvoicesConfig;
 
  20 use SL::Helper::CreatePDF qw(create_pdf find_template);
 
  22 use SL::Util qw(trim);
 
  23 use SL::System::Process;
 
  26   $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
 
  31   $self->{db_obj} = shift;
 
  33   $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
 
  35   if (!$self->{db_obj}->db->with_transaction(sub {
 
  38     my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
 
  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;
 
  45     my (@invoices_to_print, @invoices_to_email);
 
  47     _log_msg("Number of configs: " . scalar(@{ $configs}));
 
  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;
 
  56       my @dates = _calculate_dates($config);
 
  58       _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
 
  60       foreach my $date (@dates) {
 
  61         my $data = $self->_create_periodic_invoice($config, $date);
 
  64         _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
 
  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;
 
  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;
 
  80     foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
 
  81     foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
 
  83     $self->_send_summary_email;
 
  87       $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
 
  91     if (@{ $self->{job_errors} }) {
 
  92       my $msg = join "\n", @{ $self->{job_errors} };
 
  93       _log_msg("Errors: $msg");
 
 101   my $message  = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
 
 102   $message    .= "\n" unless $message =~ m/\n$/;
 
 103   $::lxdebug->message(LXDebug::DEBUG1(), $message);
 
 106 sub _generate_time_period_variables {
 
 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);
 
 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'));
 
 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 } ],
 
 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 } ],
 
 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 ] } ],
 
 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 } ],
 
 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]) } ],
 
 141   my $sub      = $params{attribute};
 
 142   my $str      = $params{object}->$sub // '';
 
 143   my $sub_fmt  = lc($params{attribute_format} // 'text');
 
 145   my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
 
 146   my @invoice_keys          = $params{invoice} ? (map { $_->name } $params{invoice}->meta->columns) : ();
 
 147   my $key_name_re           = join '|', map { quotemeta } (@invoice_keys, keys %{ $params{vars} });
 
 149   $str =~ s{ ${start_tag} ($key_name_re) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
 
 150     my ($key, $format) = ($1, $3);
 
 151     $key               = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
 
 154     if ($params{vars}->{$key} && $format) {
 
 155       $format    = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
 
 157       $new_value = DateTime::Format::Strptime->new(
 
 160         time_zone   => 'local',
 
 161       )->format_datetime($params{vars}->{$key}->[0]);
 
 163     } elsif ($params{vars}->{$key}) {
 
 164       $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
 
 166     } elsif ($params{invoice} && $params{invoice}->can($key)) {
 
 167       $new_value = $params{invoice}->$key;
 
 171     $new_value   = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
 
 177   $params{object}->$sub($str);
 
 180 sub _adjust_sellprices_for_period_lengths {
 
 183   return if $params{config}->periodicity eq 'o';
 
 185   my $billing_len     = $params{config}->get_billing_period_length;
 
 186   my $order_value_len = $params{config}->get_order_value_period_length;
 
 188   return if $billing_len == $order_value_len;
 
 190   my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
 
 192   _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");
 
 194   if ($order_value_len < $billing_len) {
 
 195     my $num_orders_per_invoice = $billing_len / $order_value_len;
 
 197     $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
 
 202   my $num_invoices_in_cycle = $order_value_len / $billing_len;
 
 204   foreach my $item (@{ $params{invoice}->items }) {
 
 205     my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
 
 207     if ($is_last_invoice_in_cycle) {
 
 208       $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
 
 211       $item->sellprice($sellprice_one_invoice);
 
 216 sub _create_periodic_invoice {
 
 219   my $period_start_date = shift;
 
 221   my $time_period_vars  = _generate_time_period_variables($config, $period_start_date);
 
 223   my $invdate           = DateTime->today_local;
 
 225   my $order   = $config->order;
 
 227   if (!$self->{db_obj}->db->with_transaction(sub {
 
 228     1;                          # make Emacs happy
 
 230     $invoice = SL::DB::Invoice->new_from($order);
 
 232     my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
 
 234     while ($tax_point < $period_start_date) {
 
 235       $tax_point->add(months => $config->get_billing_period_length);
 
 238     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
 
 239     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
 
 241     $invoice->assign_attributes(deliverydate => $period_start_date,
 
 242                                 tax_point    => $tax_point,
 
 243                                 intnotes     => $intnotes,
 
 244                                 employee     => $order->employee, # new_from sets employee to import user
 
 245                                 direct_debit => $config->direct_debit,
 
 248     _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
 
 250     foreach my $item (@{ $invoice->items }) {
 
 251       _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
 
 254     _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
 
 256     $invoice->post(ar_id => $config->ar_chart_id) || die;
 
 258     $order->link_to_record($invoice);
 
 260     foreach my $item (@{ $invoice->items }) {
 
 261       foreach (qw(orderitems)) {    # expand if needed (delivery_order_items)
 
 262           if ($item->{"converted_from_${_}_id"}) {
 
 263             die unless $item->{id};
 
 264             RecordLinks->create_links('mode'       => 'ids',
 
 266                                       'from_ids'   => $item->{"converted_from_${_}_id"},
 
 267                                       'to_table'   => 'invoice',
 
 268                                       'to_id'      => $item->{id},
 
 270             delete $item->{"converted_from_${_}_id"};
 
 275     SL::DB::PeriodicInvoice->new(config_id         => $config->id,
 
 276                                  ar_id             => $invoice->id,
 
 277                                  period_start_date => $period_start_date)
 
 280     _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
 
 282     # die $invoice->transaction_description;
 
 286     $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
 
 292     period_start_date => $period_start_date,
 
 294     time_period_vars  => $time_period_vars,
 
 298 sub _calculate_dates {
 
 300   return $config->calculate_invoice_dates(end_date => DateTime->today_local);
 
 303 sub _send_summary_email {
 
 305   my %config = %::lx_office_conf;
 
 307   return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
 
 309   return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
 
 311   my $email = $config{periodic_invoices}->{send_email_to};
 
 312   if ($email !~ m{\@}) {
 
 313     my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
 
 314     $email   = $user ? $user->get_config_value('email') : undef;
 
 317   _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
 
 319   return unless $email;
 
 321   my $template = Template->new({ 'INTERPOLATE' => 0,
 
 327   return unless $template;
 
 329   my $email_template = $config{periodic_invoices}->{email_template};
 
 330   my $filename       = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
 
 331   my %params         = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
 
 334   $template->process($filename, \%params, \$output) || die $template->error;
 
 336   my $mail              = Mailer->new;
 
 337   $mail->{from}         = $config{periodic_invoices}->{email_from};
 
 338   $mail->{to}           = $email;
 
 339   $mail->{subject}      = $config{periodic_invoices}->{email_subject};
 
 340   $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
 
 341   $mail->{message}      = $output;
 
 346 sub _store_pdf_in_webdav {
 
 347   my ($self, $pdf_file_name, $invoice) = @_;
 
 349   return unless $::instance_conf->get_webdav_documents;
 
 351   my $form = Form->new('');
 
 353   $form->{cwd}              = SL::System::Process->exe_dir;
 
 354   $form->{tmpdir}           = ($pdf_file_name =~ m{(.+)/})[0];
 
 355   $form->{tmpfile}          = ($pdf_file_name =~ m{.+/(.+)})[0];
 
 356   $form->{format}           = 'pdf';
 
 357   $form->{formname}         = 'invoice';
 
 358   $form->{type}             = 'invoice';
 
 359   $form->{vc}               = 'customer';
 
 360   $form->{invnumber}        = $invoice->invnumber;
 
 361   $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
 
 363   Common::copy_file_to_webdav_folder($form);
 
 366 sub _store_pdf_in_filemanagement {
 
 367   my ($self, $pdf_file, $invoice) = @_;
 
 369   return unless $::instance_conf->get_doc_storage;
 
 371   # create a form for generate_attachment_filename
 
 372   my $form = Form->new('');
 
 373   $form->{invnumber} = $invoice->invnumber;
 
 374   $form->{type}      = 'invoice';
 
 375   $form->{format}    = 'pdf';
 
 376   $form->{formname}  = 'invoice';
 
 377   $form->{language}  = '_' . $invoice->language->template_code if $invoice->language;
 
 378   my $doc_name       = $form->generate_attachment_filename();
 
 380   SL::File->save(object_id   => $invoice->id,
 
 381                  object_type => 'invoice',
 
 382                  mime_type   => 'application/pdf',
 
 384                  file_type   => 'document',
 
 385                  file_name   => $doc_name,
 
 386                  file_path   => $pdf_file);
 
 390   my ($self, $data) = @_;
 
 392   my $invoice       = $data->{invoice};
 
 393   my $config        = $data->{config};
 
 395   return unless $config->print && $config->printer_id && $config->printer->printer_command;
 
 397   my $form = Form->new;
 
 398   $invoice->flatten_to_form($form, format_amounts => 1);
 
 400   $form->{printer_code} = $config->printer->template_code;
 
 401   $form->{copies}       = $config->copies;
 
 402   $form->{formname}     = $form->{type};
 
 403   $form->{format}       = 'pdf';
 
 404   $form->{media}        = 'printer';
 
 405   $form->{OUT}          = $config->printer->printer_command;
 
 406   $form->{OUT_MODE}     = '|-';
 
 408   $form->{TEMPLATE_DRIVER_OPTIONS} = { };
 
 409   $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
 
 411   $form->prepare_for_printing;
 
 413   $form->throw_on_error(sub {
 
 415       $form->parse_template(\%::myconfig);
 
 416       push @{ $self->{printed_invoices} }, $invoice;
 
 419       push @{ $self->{job_errors} }, $EVAL_ERROR->error;
 
 420       push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
 
 426   my ($self, $data) = @_;
 
 428   $data->{config}->load;
 
 430   return unless $data->{config}->send_email;
 
 437     (split(m{,}, $data->{config}->email_recipient_address),
 
 438      $data->{config}->email_recipient_contact   ? ($data->{config}->email_recipient_contact->cp_email) : (),
 
 439      $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
 
 442   return unless @recipients;
 
 444   my $language      = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
 
 445   my %create_params = (
 
 446     template               => scalar($self->find_template(name => 'invoice', language => $language)),
 
 447     variables              => Form->new(''),
 
 448     return                 => 'file_name',
 
 449     record                 => $data->{invoice},
 
 450     variable_content_types => {
 
 451       longdescription => 'html',
 
 454       $::form->get_variable_content_types_for_cvars,
 
 458   $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
 
 459   $create_params{variables}->prepare_for_printing;
 
 462   my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
 
 465     $pdf_file_name = $self->create_pdf(%create_params);
 
 467     $self->_store_pdf_in_webdav        ($pdf_file_name, $data->{invoice});
 
 468     $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
 
 470     for (qw(email_subject email_body)) {
 
 472         object           => $data->{config},
 
 473         invoice          => $data->{invoice},
 
 474         vars             => $data->{time_period_vars},
 
 476         attribute_format => ($_ eq 'email_body' ? 'html' : 'text')
 
 480     my $global_bcc = SL::DB::Default->get->global_bcc;
 
 483     for my $recipient (@recipients) {
 
 484       my $mail             = Mailer->new;
 
 485       $mail->{record_id}   = $data->{invoice}->id,
 
 486       $mail->{record_type} = 'invoice',
 
 487       $mail->{from}        = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
 
 488       $mail->{to}          = $recipient;
 
 489       $mail->{bcc}         = $global_bcc;
 
 490       $mail->{subject}     = $data->{config}->email_subject;
 
 491       $mail->{message}     = $data->{config}->email_body;
 
 492       $mail->{message}    .= SL::DB::Default->get->signature;
 
 493       $mail->{content_type} = 'text/html';
 
 494       $mail->{attachments} = [{
 
 495         path     => $pdf_file_name,
 
 496         name     => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
 
 499       my $error        = $mail->send;
 
 502         push @{ $self->{job_errors} }, $error;
 
 503         push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
 
 508     push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
 
 513     push @{ $self->{job_errors} }, $EVAL_ERROR;
 
 514     push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
 
 517   unlink $pdf_file_name if $pdf_file_name;
 
 530 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
 
 535 Iterate over all periodic invoice configurations, extend them if
 
 536 applicable, calculate the dates for which invoices have to be posted
 
 537 and post those invoices by converting the order into an invoice for
 
 546 Strings like month names are hardcoded to German in this file.
 
 552 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>