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;
110 my $period_length = $config->periodicity eq 'o' ? $config->get_order_value_period_length : $config->get_billing_period_length;
111 my $period_end_date = $period_start_date->clone->add(months => $period_length)->subtract(days => 1);
113 my @month_names = ('',
114 $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'),
115 $::locale->text('July'), $::locale->text('August'), $::locale->text('September'), $::locale->text('October'), $::locale->text('November'), $::locale->text('December'));
118 current_quarter => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->quarter } ],
119 previous_quarter => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 3), sub { $_[0]->quarter } ],
120 next_quarter => [ $period_start_date->clone->truncate(to => 'month')->add( months => 3), sub { $_[0]->quarter } ],
122 current_month => [ $period_start_date->clone->truncate(to => 'month'), sub { $_[0]->month } ],
123 previous_month => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $_[0]->month } ],
124 next_month => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $_[0]->month } ],
126 current_month_long => [ $period_start_date->clone->truncate(to => 'month'), sub { $month_names[ $_[0]->month ] } ],
127 previous_month_long => [ $period_start_date->clone->truncate(to => 'month')->subtract(months => 1), sub { $month_names[ $_[0]->month ] } ],
128 next_month_long => [ $period_start_date->clone->truncate(to => 'month')->add( months => 1), sub { $month_names[ $_[0]->month ] } ],
130 current_year => [ $period_start_date->clone->truncate(to => 'year'), sub { $_[0]->year } ],
131 previous_year => [ $period_start_date->clone->truncate(to => 'year')->subtract(years => 1), sub { $_[0]->year } ],
132 next_year => [ $period_start_date->clone->truncate(to => 'year')->add( years => 1), sub { $_[0]->year } ],
134 period_start_date => [ $period_start_date->clone, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
135 period_end_date => [ $period_end_date, sub { $::locale->format_date(\%::myconfig, $_[0]) } ],
143 my $sub = $params{attribute};
144 my $str = $params{object}->$sub // '';
145 my $sub_fmt = lc($params{attribute_format} // 'text');
147 my ($start_tag, $end_tag) = $sub_fmt eq 'html' ? ('<%', '%>') : ('<%', '%>');
148 my @invoice_keys = $params{invoice} ? (map { $_->name } $params{invoice}->meta->columns) : ();
149 my $key_name_re = join '|', map { quotemeta } (@invoice_keys, keys %{ $params{vars} });
151 $str =~ s{ ${start_tag} ($key_name_re) ( \s+ format \s*=\s* (.*?) \s* )? ${end_tag} }{
152 my ($key, $format) = ($1, $3);
153 $key = $::locale->unquote_special_chars('html', $key) if $sub_fmt eq 'html';
156 if ($params{vars}->{$key} && $format) {
157 $format = $::locale->unquote_special_chars('html', $format) if $sub_fmt eq 'html';
159 $new_value = DateTime::Format::Strptime->new(
162 time_zone => 'local',
163 )->format_datetime($params{vars}->{$key}->[0]);
165 } elsif ($params{vars}->{$key}) {
166 $new_value = $params{vars}->{$1}->[1]->($params{vars}->{$1}->[0]);
168 } elsif ($params{invoice} && $params{invoice}->can($key)) {
169 $new_value = $params{invoice}->$key;
173 $new_value = $::locale->quote_special_chars('html', $new_value) if $sub_fmt eq 'html';
179 $params{object}->$sub($str);
182 sub _adjust_sellprices_for_period_lengths {
185 return if $params{config}->periodicity eq 'o';
187 my $billing_len = $params{config}->get_billing_period_length;
188 my $order_value_len = $params{config}->get_order_value_period_length;
190 return if $billing_len == $order_value_len;
192 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
194 _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");
196 if ($order_value_len < $billing_len) {
197 my $num_orders_per_invoice = $billing_len / $order_value_len;
199 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
204 my $num_invoices_in_cycle = $order_value_len / $billing_len;
206 foreach my $item (@{ $params{invoice}->items }) {
207 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
209 if ($is_last_invoice_in_cycle) {
210 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
213 $item->sellprice($sellprice_one_invoice);
218 sub _create_periodic_invoice {
221 my $period_start_date = shift;
223 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
225 my $invdate = DateTime->today_local;
227 my $order = $config->order;
229 if (!$self->{db_obj}->db->with_transaction(sub {
230 1; # make Emacs happy
232 $invoice = SL::DB::Invoice->new_from($order, honor_recurring_billing_mode => 1);
234 my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
236 while ($tax_point < $period_start_date) {
237 $tax_point->add(months => $config->get_billing_period_length);
240 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
241 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
243 $invoice->assign_attributes(deliverydate => $period_start_date,
244 tax_point => $tax_point,
245 intnotes => $intnotes,
246 employee => $order->employee, # new_from sets employee to import user
247 direct_debit => $config->direct_debit,
250 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
252 foreach my $item (@{ $invoice->items }) {
253 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
256 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
258 $invoice->post(ar_id => $config->ar_chart_id) || die;
260 foreach my $item (grep { ($_->recurring_billing_mode eq 'once') && !$_->recurring_billing_invoice_id } @{ $order->orderitems }) {
261 $item->update_attributes(recurring_billing_invoice_id => $invoice->id);
264 SL::DB::PeriodicInvoice->new(config_id => $config->id,
265 ar_id => $invoice->id,
266 period_start_date => $period_start_date)
269 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
271 # die $invoice->transaction_description;
275 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
281 period_start_date => $period_start_date,
283 time_period_vars => $time_period_vars,
287 sub _calculate_dates {
289 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
292 sub _send_summary_email {
294 my %config = %::lx_office_conf;
296 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
298 return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
300 my $email = $config{periodic_invoices}->{send_email_to};
301 if ($email !~ m{\@}) {
302 my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
303 $email = $user ? $user->get_config_value('email') : undef;
306 _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
308 return unless $email;
310 my $template = Template->new({ 'INTERPOLATE' => 0,
316 return unless $template;
318 my $email_template = $config{periodic_invoices}->{email_template};
319 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
320 my %params = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
323 $template->process($filename, \%params, \$output) || die $template->error;
325 my $mail = Mailer->new;
326 $mail->{from} = $config{periodic_invoices}->{email_from};
327 $mail->{to} = $email;
328 $mail->{subject} = $config{periodic_invoices}->{email_subject};
329 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
330 $mail->{message} = $output;
335 sub _store_pdf_in_webdav {
336 my ($self, $pdf_file_name, $invoice) = @_;
338 return unless $::instance_conf->get_webdav_documents;
340 my $form = Form->new('');
342 $form->{cwd} = SL::System::Process->exe_dir;
343 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
344 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
345 $form->{format} = 'pdf';
346 $form->{formname} = 'invoice';
347 $form->{type} = 'invoice';
348 $form->{vc} = 'customer';
349 $form->{invnumber} = $invoice->invnumber;
350 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
352 Common::copy_file_to_webdav_folder($form);
355 sub _store_pdf_in_filemanagement {
356 my ($self, $pdf_file, $invoice) = @_;
358 return unless $::instance_conf->get_doc_storage;
360 # create a form for generate_attachment_filename
361 my $form = Form->new('');
362 $form->{invnumber} = $invoice->invnumber;
363 $form->{type} = 'invoice';
364 $form->{format} = 'pdf';
365 $form->{formname} = 'invoice';
366 $form->{language} = '_' . $invoice->language->template_code if $invoice->language;
367 my $doc_name = $form->generate_attachment_filename();
369 SL::File->save(object_id => $invoice->id,
370 object_type => 'invoice',
371 mime_type => 'application/pdf',
373 file_type => 'document',
374 file_name => $doc_name,
375 file_path => $pdf_file);
379 my ($self, $data) = @_;
381 my $invoice = $data->{invoice};
382 my $config = $data->{config};
384 return unless $config->print && $config->printer_id && $config->printer->printer_command;
386 my $form = Form->new;
387 $invoice->flatten_to_form($form, format_amounts => 1);
389 $form->{printer_code} = $config->printer->template_code;
390 $form->{copies} = $config->copies;
391 $form->{formname} = $form->{type};
392 $form->{format} = 'pdf';
393 $form->{media} = 'printer';
394 $form->{OUT} = $config->printer->printer_command;
395 $form->{OUT_MODE} = '|-';
397 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
398 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
400 $form->prepare_for_printing;
402 $form->throw_on_error(sub {
404 $form->parse_template(\%::myconfig);
405 push @{ $self->{printed_invoices} }, $invoice;
408 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
409 push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
415 my ($self, $data) = @_;
417 $data->{config}->load;
419 return unless $data->{config}->send_email;
426 (split(m{,}, $data->{config}->email_recipient_address),
427 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
428 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
431 return unless @recipients;
433 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
434 my %create_params = (
435 template => scalar($self->find_template(name => 'invoice', language => $language)),
436 variables => Form->new(''),
437 return => 'file_name',
438 record => $data->{invoice},
439 variable_content_types => {
440 longdescription => 'html',
443 $::form->get_variable_content_types_for_cvars,
447 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
448 $create_params{variables}->prepare_for_printing;
451 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
454 $pdf_file_name = $self->create_pdf(%create_params);
456 $self->_store_pdf_in_webdav ($pdf_file_name, $data->{invoice});
457 $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
459 for (qw(email_subject email_body)) {
461 object => $data->{config},
462 invoice => $data->{invoice},
463 vars => $data->{time_period_vars},
465 attribute_format => ($_ eq 'email_body' ? 'html' : 'text')
469 my $global_bcc = SL::DB::Default->get->global_bcc;
472 for my $recipient (@recipients) {
473 my $mail = Mailer->new;
474 $mail->{record_id} = $data->{invoice}->id,
475 $mail->{record_type} = 'invoice',
476 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
477 $mail->{to} = $recipient;
478 $mail->{bcc} = $global_bcc;
479 $mail->{subject} = $data->{config}->email_subject;
480 $mail->{message} = $data->{config}->email_body;
481 $mail->{message} .= SL::DB::Default->get->signature;
482 $mail->{content_type} = 'text/html';
483 $mail->{attachments} = [{
484 path => $pdf_file_name,
485 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
488 my $error = $mail->send;
491 push @{ $self->{job_errors} }, $error;
492 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
497 push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
502 push @{ $self->{job_errors} }, $EVAL_ERROR;
503 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
506 unlink $pdf_file_name if $pdf_file_name;
519 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
524 Iterate over all periodic invoice configurations, extend them if
525 applicable, calculate the dates for which invoices have to be posted
526 and post those invoices by converting the order into an invoice for
535 Strings like month names are hardcoded to German in this file.
541 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>