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;
 
  19 use SL::Helper::CreatePDF qw(create_pdf find_template);
 
  21 use SL::Util qw(trim);
 
  22 use SL::System::Process;
 
  25   $_[0]->create_standard_job('0 3 1 * *'); # first day of month at 3:00 am
 
  30   $self->{db_obj} = shift;
 
  32   $self->{$_} = [] for qw(job_errors posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
 
  34   if (!$self->{db_obj}->db->with_transaction(sub {
 
  37     my $configs = SL::DB::Manager::PeriodicInvoicesConfig->get_all(query => [ active => 1 ]);
 
  39     foreach my $config (@{ $configs }) {
 
  40       my $new_end_date = $config->handle_automatic_extension;
 
  41       _log_msg("Periodic invoice configuration ID " . $config->id . " extended through " . $new_end_date->strftime('%d.%m.%Y') . "\n") if $new_end_date;
 
  44     my (@invoices_to_print, @invoices_to_email);
 
  46     _log_msg("Number of configs: " . scalar(@{ $configs}));
 
  48     foreach my $config (@{ $configs }) {
 
  49       # A configuration can be set to inactive by
 
  50       # $config->handle_automatic_extension. Therefore the check in
 
  51       # ...->get_all() does not suffice.
 
  52       _log_msg("Config " . $config->id . " active " . $config->active);
 
  53       next unless $config->active;
 
  55       my @dates = _calculate_dates($config);
 
  57       _log_msg("Dates: " . join(' ', map { $_->to_lxoffice } @dates));
 
  59       foreach my $date (@dates) {
 
  60         my $data = $self->_create_periodic_invoice($config, $date);
 
  63         _log_msg("Invoice " . $data->{invoice}->invnumber . " posted for config ID " . $config->id . ", period start date " . $::locale->format_date(\%::myconfig, $date) . "\n");
 
  65         push @{ $self->{posted_invoices} }, $data->{invoice};
 
  66         push @invoices_to_print, $data if $config->print;
 
  67         push @invoices_to_email, $data if $config->send_email;
 
  69         my $inactive_ordnumber = $config->disable_one_time_config;
 
  70         if ($inactive_ordnumber) {
 
  71           # disable one time configs and skip eventual invoices
 
  72           _log_msg("Order " . $inactive_ordnumber . " deavtivated \n");
 
  73           push @{ $self->{disabled_orders} }, $inactive_ordnumber;
 
  79     foreach my $inv ( @invoices_to_print ) { $self->_print_invoice($inv); }
 
  80     foreach my $inv ( @invoices_to_email ) { $self->_email_invoice($inv); }
 
  82     $self->_send_summary_email;
 
  86       $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
 
  90     if (@{ $self->{job_errors} }) {
 
  91       my $msg = join "\n", @{ $self->{job_errors} };
 
  92       _log_msg("Errors: $msg");
 
 100   my $message  = join('', 'SL::BackgroundJob::CreatePeriodicInvoices: ', @_);
 
 101   $message    .= "\n" unless $message =~ m/\n$/;
 
 102   $::lxdebug->message(LXDebug::DEBUG1(), $message);
 
 105 sub _generate_time_period_variables {
 
 107   my $period_start_date = shift;
 
 108   my $period_end_date   = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1);
 
 110   my @month_names       = ('',
 
 111                            $::locale->text('January'), $::locale->text('February'), $::locale->text('March'),     $::locale->text('April'),   $::locale->text('May'),      $::locale->text('June'),
 
 112                            $::locale->text('July'),    $::locale->text('August'),   $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
 
 115     current_quarter     => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->quarter } ],
 
 116     previous_quarter    => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
 
 117     next_quarter        => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 3), sub { $_[0]->quarter } ],
 
 119     current_month       => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $_[0]->month } ],
 
 120     previous_month      => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
 
 121     next_month          => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $_[0]->month } ],
 
 123     current_month_long  => [ $period_start_date->clone->truncate(to => 'month'),                        sub { $month_names[ $_[0]->month ] } ],
 
 124     previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
 
 125     next_month_long     => [ $period_start_date->clone->truncate(to => 'month')->add(     months => 1), sub { $month_names[ $_[0]->month ] } ],
 
 127     current_year        => [ $period_start_date->clone->truncate(to => 'year'),                         sub { $_[0]->year } ],
 
 128     previous_year       => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1),   sub { $_[0]->year } ],
 
 129     next_year           => [ $period_start_date->clone->truncate(to => 'year')->add(     years => 1),   sub { $_[0]->year } ],
 
 131     period_start_date   => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
 
 132     period_end_date     => [ $period_end_date,          sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
 
 140   my $sub      = $params{attribute};
 
 141   my $str      = $params{object}->$sub // '';
 
 142   my $sub_fmt  = lc($params{attribute_format} // 'text');
 
 144   my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
 
 146   $str =~ s{ ${start_tag} ([a-z0-9_]+) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
 
 147     my ($key, $format) = ($1, $3);
 
 148     $key               = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
 
 151     if ($params{vars}->{$key} && $format) {
 
 152       $format    = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
 
 154       $new_value = DateTime::Format::Strptime->new(
 
 157         time_zone   => 'local',
 
 158       )->format_datetime($params{vars}->{$key}->[0]);
 
 160     } elsif ($params{vars}->{$key}) {
 
 161       $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
 
 163     } elsif ($params{invoice} && $params{invoice}->can($key)) {
 
 164       $new_value = $params{invoice}->$key;
 
 168     $new_value   = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
 
 174   $params{object}->$sub($str);
 
 177 sub _adjust_sellprices_for_period_lengths {
 
 180   my $billing_len     = $params{config}->get_billing_period_length;
 
 181   my $order_value_len = $params{config}->get_order_value_period_length;
 
 183   return if $billing_len == $order_value_len;
 
 185   my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
 
 187   _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");
 
 189   if ($order_value_len < $billing_len) {
 
 190     my $num_orders_per_invoice = $billing_len / $order_value_len;
 
 192     $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
 
 197   my $num_invoices_in_cycle = $order_value_len / $billing_len;
 
 199   foreach my $item (@{ $params{invoice}->items }) {
 
 200     my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
 
 202     if ($is_last_invoice_in_cycle) {
 
 203       $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
 
 206       $item->sellprice($sellprice_one_invoice);
 
 211 sub _create_periodic_invoice {
 
 214   my $period_start_date = shift;
 
 216   my $time_period_vars  = _generate_time_period_variables($config, $period_start_date);
 
 218   my $invdate           = DateTime->today_local;
 
 220   my $order   = $config->order;
 
 222   if (!$self->{db_obj}->db->with_transaction(sub {
 
 223     1;                          # make Emacs happy
 
 225     $invoice = SL::DB::Invoice->new_from($order);
 
 227     my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
 
 229     while ($tax_point < $period_start_date) {
 
 230       $tax_point->add(months => $config->get_billing_period_length);
 
 233     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
 
 234     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
 
 236     $invoice->assign_attributes(deliverydate => $period_start_date,
 
 237                                 tax_point    => $tax_point,
 
 238                                 intnotes     => $intnotes,
 
 239                                 employee     => $order->employee, # new_from sets employee to import user
 
 240                                 direct_debit => $config->direct_debit,
 
 243     _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
 
 245     foreach my $item (@{ $invoice->items }) {
 
 246       _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
 
 249     _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
 
 251     $invoice->post(ar_id => $config->ar_chart_id) || die;
 
 253     $order->link_to_record($invoice);
 
 255     foreach my $item (@{ $invoice->items }) {
 
 256       foreach (qw(orderitems)) {    # expand if needed (delivery_order_items)
 
 257           if ($item->{"converted_from_${_}_id"}) {
 
 258             die unless $item->{id};
 
 259             RecordLinks->create_links('mode'       => 'ids',
 
 261                                       'from_ids'   => $item->{"converted_from_${_}_id"},
 
 262                                       'to_table'   => 'invoice',
 
 263                                       'to_id'      => $item->{id},
 
 265             delete $item->{"converted_from_${_}_id"};
 
 270     SL::DB::PeriodicInvoice->new(config_id         => $config->id,
 
 271                                  ar_id             => $invoice->id,
 
 272                                  period_start_date => $period_start_date)
 
 275     _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
 
 277     # die $invoice->transaction_description;
 
 281     $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
 
 287     period_start_date => $period_start_date,
 
 289     time_period_vars  => $time_period_vars,
 
 293 sub _calculate_dates {
 
 295   return $config->calculate_invoice_dates(end_date => DateTime->today_local);
 
 298 sub _send_summary_email {
 
 300   my %config = %::lx_office_conf;
 
 302   return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
 
 304   return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
 
 306   my $email = $config{periodic_invoices}->{send_email_to};
 
 307   if ($email !~ m{\@}) {
 
 308     my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
 
 309     $email   = $user ? $user->get_config_value('email') : undef;
 
 312   _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
 
 314   return unless $email;
 
 316   my $template = Template->new({ 'INTERPOLATE' => 0,
 
 322   return unless $template;
 
 324   my $email_template = $config{periodic_invoices}->{email_template};
 
 325   my $filename       = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
 
 326   my %params         = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
 
 329   $template->process($filename, \%params, \$output) || die $template->error;
 
 331   my $mail              = Mailer->new;
 
 332   $mail->{from}         = $config{periodic_invoices}->{email_from};
 
 333   $mail->{to}           = $email;
 
 334   $mail->{subject}      = $config{periodic_invoices}->{email_subject};
 
 335   $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
 
 336   $mail->{message}      = $output;
 
 341 sub _store_pdf_in_webdav {
 
 342   my ($self, $pdf_file_name, $invoice) = @_;
 
 344   return unless $::instance_conf->get_webdav_documents;
 
 346   my $form = Form->new('');
 
 348   $form->{cwd}              = SL::System::Process->exe_dir;
 
 349   $form->{tmpdir}           = ($pdf_file_name =~ m{(.+)/})[0];
 
 350   $form->{tmpfile}          = ($pdf_file_name =~ m{.+/(.+)})[0];
 
 351   $form->{format}           = 'pdf';
 
 352   $form->{formname}         = 'invoice';
 
 353   $form->{type}             = 'invoice';
 
 354   $form->{vc}               = 'customer';
 
 355   $form->{invnumber}        = $invoice->invnumber;
 
 356   $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
 
 358   Common::copy_file_to_webdav_folder($form);
 
 362   my ($self, $data) = @_;
 
 364   my $invoice       = $data->{invoice};
 
 365   my $config        = $data->{config};
 
 367   return unless $config->print && $config->printer_id && $config->printer->printer_command;
 
 369   my $form = Form->new;
 
 370   $invoice->flatten_to_form($form, format_amounts => 1);
 
 372   $form->{printer_code} = $config->printer->template_code;
 
 373   $form->{copies}       = $config->copies;
 
 374   $form->{formname}     = $form->{type};
 
 375   $form->{format}       = 'pdf';
 
 376   $form->{media}        = 'printer';
 
 377   $form->{OUT}          = $config->printer->printer_command;
 
 378   $form->{OUT_MODE}     = '|-';
 
 380   $form->{TEMPLATE_DRIVER_OPTIONS} = { };
 
 381   $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
 
 383   $form->prepare_for_printing;
 
 385   $form->throw_on_error(sub {
 
 387       $form->parse_template(\%::myconfig);
 
 388       push @{ $self->{printed_invoices} }, $invoice;
 
 391       push @{ $self->{job_errors} }, $EVAL_ERROR->error;
 
 392       push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
 
 398   my ($self, $data) = @_;
 
 400   $data->{config}->load;
 
 402   return unless $data->{config}->send_email;
 
 409     (split(m{,}, $data->{config}->email_recipient_address),
 
 410      $data->{config}->email_recipient_contact   ? ($data->{config}->email_recipient_contact->cp_email) : (),
 
 411      $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
 
 414   return unless @recipients;
 
 416   my $language      = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
 
 417   my %create_params = (
 
 418     template               => scalar($self->find_template(name => 'invoice', language => $language)),
 
 419     variables              => Form->new(''),
 
 420     return                 => 'file_name',
 
 421     record                 => $data->{invoice},
 
 422     variable_content_types => {
 
 423       longdescription => 'html',
 
 429   $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
 
 430   $create_params{variables}->prepare_for_printing;
 
 433   my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
 
 436     $pdf_file_name = $self->create_pdf(%create_params);
 
 438     $self->_store_pdf_in_webdav($pdf_file_name, $data->{invoice});
 
 440     for (qw(email_subject email_body)) {
 
 442         object           => $data->{config},
 
 443         invoice          => $data->{invoice},
 
 444         vars             => $data->{time_period_vars},
 
 446         attribute_format => 'text'
 
 450     my $global_bcc = SL::DB::Default->get->global_bcc;
 
 453     for my $recipient (@recipients) {
 
 454       my $mail             = Mailer->new;
 
 455       $mail->{record_id}   = $data->{invoice}->id,
 
 456       $mail->{record_type} = 'invoice',
 
 457       $mail->{from}        = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
 
 458       $mail->{to}          = $recipient;
 
 459       $mail->{bcc}         = $global_bcc;
 
 460       $mail->{subject}     = $data->{config}->email_subject;
 
 461       $mail->{message}     = $data->{config}->email_body;
 
 462       $mail->{attachments} = [{
 
 463         path     => $pdf_file_name,
 
 464         name     => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
 
 467       my $error        = $mail->send;
 
 470         push @{ $self->{job_errors} }, $error;
 
 471         push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
 
 476     push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
 
 481     push @{ $self->{job_errors} }, $EVAL_ERROR;
 
 482     push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
 
 485   unlink $pdf_file_name if $pdf_file_name;
 
 498 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
 
 503 Iterate over all periodic invoice configurations, extend them if
 
 504 applicable, calculate the dates for which invoices have to be posted
 
 505 and post those invoices by converting the order into an invoice for
 
 514 Strings like month names are hardcoded to German in this file.
 
 520 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>