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' ? ('<%', '%>') : ('<%', '%>');
 
 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';
 
 152     if ($params{vars}->{$key} && $format) {
 
 153       $format    = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
 
 155       $new_value = DateTime::Format::Strptime->new(
 
 158         time_zone   => 'local',
 
 159       )->format_datetime($params{vars}->{$key}->[0]);
 
 161     } elsif ($params{vars}->{$key}) {
 
 162       $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
 
 164     } elsif ($params{invoice} && $params{invoice}->can($key)) {
 
 165       $new_value = $params{invoice}->$key;
 
 169     $new_value   = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
 
 175   $params{object}->$sub($str);
 
 178 sub _adjust_sellprices_for_period_lengths {
 
 181   return if $params{config}->periodicity eq 'o';
 
 183   my $billing_len     = $params{config}->get_billing_period_length;
 
 184   my $order_value_len = $params{config}->get_order_value_period_length;
 
 186   return if $billing_len == $order_value_len;
 
 188   my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
 
 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");
 
 192   if ($order_value_len < $billing_len) {
 
 193     my $num_orders_per_invoice = $billing_len / $order_value_len;
 
 195     $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
 
 200   my $num_invoices_in_cycle = $order_value_len / $billing_len;
 
 202   foreach my $item (@{ $params{invoice}->items }) {
 
 203     my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
 
 205     if ($is_last_invoice_in_cycle) {
 
 206       $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
 
 209       $item->sellprice($sellprice_one_invoice);
 
 214 sub _create_periodic_invoice {
 
 217   my $period_start_date = shift;
 
 219   my $time_period_vars  = _generate_time_period_variables($config, $period_start_date);
 
 221   my $invdate           = DateTime->today_local;
 
 223   my $order   = $config->order;
 
 225   if (!$self->{db_obj}->db->with_transaction(sub {
 
 226     1;                          # make Emacs happy
 
 228     $invoice = SL::DB::Invoice->new_from($order);
 
 230     my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
 
 232     while ($tax_point < $period_start_date) {
 
 233       $tax_point->add(months => $config->get_billing_period_length);
 
 236     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
 
 237     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
 
 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,
 
 246     _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
 
 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);
 
 252     _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
 
 254     $invoice->post(ar_id => $config->ar_chart_id) || die;
 
 256     $order->link_to_record($invoice);
 
 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',
 
 264                                       'from_ids'   => $item->{"converted_from_${_}_id"},
 
 265                                       'to_table'   => 'invoice',
 
 266                                       'to_id'      => $item->{id},
 
 268             delete $item->{"converted_from_${_}_id"};
 
 273     SL::DB::PeriodicInvoice->new(config_id         => $config->id,
 
 274                                  ar_id             => $invoice->id,
 
 275                                  period_start_date => $period_start_date)
 
 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);
 
 280     # die $invoice->transaction_description;
 
 284     $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
 
 290     period_start_date => $period_start_date,
 
 292     time_period_vars  => $time_period_vars,
 
 296 sub _calculate_dates {
 
 298   return $config->calculate_invoice_dates(end_date => DateTime->today_local);
 
 301 sub _send_summary_email {
 
 303   my %config = %::lx_office_conf;
 
 305   return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
 
 307   return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
 
 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;
 
 315   _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
 
 317   return unless $email;
 
 319   my $template = Template->new({ 'INTERPOLATE' => 0,
 
 325   return unless $template;
 
 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);
 
 332   $template->process($filename, \%params, \$output) || die $template->error;
 
 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;
 
 344 sub _store_pdf_in_webdav {
 
 345   my ($self, $pdf_file_name, $invoice) = @_;
 
 347   return unless $::instance_conf->get_webdav_documents;
 
 349   my $form = Form->new('');
 
 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 : '';
 
 361   Common::copy_file_to_webdav_folder($form);
 
 364 sub _store_pdf_in_filemanagement {
 
 365   my ($self, $pdf_file, $invoice) = @_;
 
 367   return unless $::instance_conf->get_doc_storage;
 
 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();
 
 378   SL::File->save(object_id   => $invoice->id,
 
 379                  object_type => 'invoice',
 
 380                  mime_type   => 'application/pdf',
 
 382                  file_type   => 'document',
 
 383                  file_name   => $doc_name,
 
 384                  file_path   => $pdf_file);
 
 388   my ($self, $data) = @_;
 
 390   my $invoice       = $data->{invoice};
 
 391   my $config        = $data->{config};
 
 393   return unless $config->print && $config->printer_id && $config->printer->printer_command;
 
 395   my $form = Form->new;
 
 396   $invoice->flatten_to_form($form, format_amounts => 1);
 
 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}     = '|-';
 
 406   $form->{TEMPLATE_DRIVER_OPTIONS} = { };
 
 407   $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
 
 409   $form->prepare_for_printing;
 
 411   $form->throw_on_error(sub {
 
 413       $form->parse_template(\%::myconfig);
 
 414       push @{ $self->{printed_invoices} }, $invoice;
 
 417       push @{ $self->{job_errors} }, $EVAL_ERROR->error;
 
 418       push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
 
 424   my ($self, $data) = @_;
 
 426   $data->{config}->load;
 
 428   return unless $data->{config}->send_email;
 
 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) : ()
 
 440   return unless @recipients;
 
 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',
 
 455   $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
 
 456   $create_params{variables}->prepare_for_printing;
 
 459   my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
 
 462     $pdf_file_name = $self->create_pdf(%create_params);
 
 464     $self->_store_pdf_in_webdav        ($pdf_file_name, $data->{invoice});
 
 465     $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
 
 467     for (qw(email_subject email_body)) {
 
 469         object           => $data->{config},
 
 470         invoice          => $data->{invoice},
 
 471         vars             => $data->{time_period_vars},
 
 473         attribute_format => 'text'
 
 477     my $global_bcc = SL::DB::Default->get->global_bcc;
 
 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),
 
 494       my $error        = $mail->send;
 
 497         push @{ $self->{job_errors} }, $error;
 
 498         push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
 
 503     push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
 
 508     push @{ $self->{job_errors} }, $EVAL_ERROR;
 
 509     push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
 
 512   unlink $pdf_file_name if $pdf_file_name;
 
 525 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
 
 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
 
 541 Strings like month names are hardcoded to German in this file.
 
 547 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>