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 my $billing_len = $params{config}->get_billing_period_length;
182 my $order_value_len = $params{config}->get_order_value_period_length;
184 return if $billing_len == $order_value_len;
186 my $is_last_invoice_in_cycle = $params{config}->is_last_bill_date_in_order_value_cycle(date => $params{period_start_date});
188 _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");
190 if ($order_value_len < $billing_len) {
191 my $num_orders_per_invoice = $billing_len / $order_value_len;
193 $_->sellprice($_->sellprice * $num_orders_per_invoice) for @{ $params{invoice}->items };
198 my $num_invoices_in_cycle = $order_value_len / $billing_len;
200 foreach my $item (@{ $params{invoice}->items }) {
201 my $sellprice_one_invoice = $::form->round_amount($item->sellprice * $billing_len / $order_value_len, 2);
203 if ($is_last_invoice_in_cycle) {
204 $item->sellprice($item->sellprice - ($num_invoices_in_cycle - 1) * $sellprice_one_invoice);
207 $item->sellprice($sellprice_one_invoice);
212 sub _create_periodic_invoice {
215 my $period_start_date = shift;
217 my $time_period_vars = _generate_time_period_variables($config, $period_start_date);
219 my $invdate = DateTime->today_local;
221 my $order = $config->order;
223 if (!$self->{db_obj}->db->with_transaction(sub {
224 1; # make Emacs happy
226 $invoice = SL::DB::Invoice->new_from($order);
228 my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
230 while ($tax_point < $period_start_date) {
231 $tax_point->add(months => $config->get_billing_period_length);
234 my $intnotes = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
235 $intnotes .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
237 $invoice->assign_attributes(deliverydate => $period_start_date,
238 tax_point => $tax_point,
239 intnotes => $intnotes,
240 employee => $order->employee, # new_from sets employee to import user
241 direct_debit => $config->direct_debit,
244 _replace_vars(object => $invoice, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'notes' ? 'html' : 'text')) for qw(notes intnotes transaction_description);
246 foreach my $item (@{ $invoice->items }) {
247 _replace_vars(object => $item, vars => $time_period_vars, attribute => $_, attribute_format => ($_ eq 'longdescription' ? 'html' : 'text')) for qw(description longdescription);
250 _adjust_sellprices_for_period_lengths(invoice => $invoice, config => $config, period_start_date => $period_start_date);
252 $invoice->post(ar_id => $config->ar_chart_id) || die;
254 $order->link_to_record($invoice);
256 foreach my $item (@{ $invoice->items }) {
257 foreach (qw(orderitems)) { # expand if needed (delivery_order_items)
258 if ($item->{"converted_from_${_}_id"}) {
259 die unless $item->{id};
260 RecordLinks->create_links('mode' => 'ids',
262 'from_ids' => $item->{"converted_from_${_}_id"},
263 'to_table' => 'invoice',
264 'to_id' => $item->{id},
266 delete $item->{"converted_from_${_}_id"};
271 SL::DB::PeriodicInvoice->new(config_id => $config->id,
272 ar_id => $invoice->id,
273 period_start_date => $period_start_date)
276 _log_msg("_create_invoice created for period start date $period_start_date id " . $invoice->id . " number " . $invoice->invnumber . " netamount " . $invoice->netamount . " amount " . $invoice->amount);
278 # die $invoice->transaction_description;
282 $::lxdebug->message(LXDebug->WARN(), "_create_invoice failed: " . join("\n", (split(/\n/, $self->{db_obj}->db->error))[0..2]));
288 period_start_date => $period_start_date,
290 time_period_vars => $time_period_vars,
294 sub _calculate_dates {
296 return $config->calculate_invoice_dates(end_date => DateTime->today_local);
299 sub _send_summary_email {
301 my %config = %::lx_office_conf;
303 return if !$config{periodic_invoices} || !$config{periodic_invoices}->{send_email_to} || !scalar @{ $self->{posted_invoices} };
305 return if $config{periodic_invoices}->{send_for_errors_only} && !@{ $self->{printed_failed} } && !@{ $self->{emailed_failed} };
307 my $email = $config{periodic_invoices}->{send_email_to};
308 if ($email !~ m{\@}) {
309 my $user = SL::DB::Manager::AuthUser->find_by(login => $email);
310 $email = $user ? $user->get_config_value('email') : undef;
313 _log_msg("_send_summary_email: about to send to '" . ($email || '') . "'");
315 return unless $email;
317 my $template = Template->new({ 'INTERPOLATE' => 0,
323 return unless $template;
325 my $email_template = $config{periodic_invoices}->{email_template};
326 my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/webpages") . "/oe/periodic_invoices_email.txt" );
327 my %params = map { (uc($_) => $self->{$_}) } qw(posted_invoices printed_invoices printed_failed emailed_invoices emailed_failed disabled_orders);
330 $template->process($filename, \%params, \$output) || die $template->error;
332 my $mail = Mailer->new;
333 $mail->{from} = $config{periodic_invoices}->{email_from};
334 $mail->{to} = $email;
335 $mail->{subject} = $config{periodic_invoices}->{email_subject};
336 $mail->{content_type} = $filename =~ m/.html$/ ? 'text/html' : 'text/plain';
337 $mail->{message} = $output;
342 sub _store_pdf_in_webdav {
343 my ($self, $pdf_file_name, $invoice) = @_;
345 return unless $::instance_conf->get_webdav_documents;
347 my $form = Form->new('');
349 $form->{cwd} = SL::System::Process->exe_dir;
350 $form->{tmpdir} = ($pdf_file_name =~ m{(.+)/})[0];
351 $form->{tmpfile} = ($pdf_file_name =~ m{.+/(.+)})[0];
352 $form->{format} = 'pdf';
353 $form->{formname} = 'invoice';
354 $form->{type} = 'invoice';
355 $form->{vc} = 'customer';
356 $form->{invnumber} = $invoice->invnumber;
357 $form->{recipient_locale} = $invoice->language ? $invoice->language->template_code : '';
359 Common::copy_file_to_webdav_folder($form);
362 sub _store_pdf_in_filemanagement {
363 my ($self, $pdf_file, $invoice) = @_;
365 return unless $::instance_conf->get_doc_storage;
367 # create a form for generate_attachment_filename
368 my $form = Form->new('');
369 $form->{invnumber} = $invoice->invnumber;
370 $form->{type} = 'invoice';
371 $form->{format} = 'pdf';
372 $form->{formname} = 'invoice';
373 $form->{language} = '_' . $invoice->language->template_code if $invoice->language;
374 my $doc_name = $form->generate_attachment_filename();
376 SL::File->save(object_id => $invoice->id,
377 object_type => 'invoice',
378 mime_type => 'application/pdf',
380 file_type => 'document',
381 file_name => $doc_name,
382 file_path => $pdf_file);
386 my ($self, $data) = @_;
388 my $invoice = $data->{invoice};
389 my $config = $data->{config};
391 return unless $config->print && $config->printer_id && $config->printer->printer_command;
393 my $form = Form->new;
394 $invoice->flatten_to_form($form, format_amounts => 1);
396 $form->{printer_code} = $config->printer->template_code;
397 $form->{copies} = $config->copies;
398 $form->{formname} = $form->{type};
399 $form->{format} = 'pdf';
400 $form->{media} = 'printer';
401 $form->{OUT} = $config->printer->printer_command;
402 $form->{OUT_MODE} = '|-';
404 $form->{TEMPLATE_DRIVER_OPTIONS} = { };
405 $form->{TEMPLATE_DRIVER_OPTIONS}->{variable_content_types} = $form->get_variable_content_types();
407 $form->prepare_for_printing;
409 $form->throw_on_error(sub {
411 $form->parse_template(\%::myconfig);
412 push @{ $self->{printed_invoices} }, $invoice;
415 push @{ $self->{job_errors} }, $EVAL_ERROR->error;
416 push @{ $self->{printed_failed} }, [ $invoice, $EVAL_ERROR->error ];
422 my ($self, $data) = @_;
424 $data->{config}->load;
426 return unless $data->{config}->send_email;
433 (split(m{,}, $data->{config}->email_recipient_address),
434 $data->{config}->email_recipient_contact ? ($data->{config}->email_recipient_contact->cp_email) : (),
435 $data->{invoice}->{customer}->invoice_mail ? ($data->{invoice}->{customer}->invoice_mail) : ()
438 return unless @recipients;
440 my $language = $data->{invoice}->language ? $data->{invoice}->language->template_code : undef;
441 my %create_params = (
442 template => scalar($self->find_template(name => 'invoice', language => $language)),
443 variables => Form->new(''),
444 return => 'file_name',
445 record => $data->{invoice},
446 variable_content_types => {
447 longdescription => 'html',
453 $data->{invoice}->flatten_to_form($create_params{variables}, format_amounts => 1);
454 $create_params{variables}->prepare_for_printing;
457 my $label = $language && Locale::is_supported($language) ? Locale->new($language)->text('Invoice') : $::locale->text('Invoice');
460 $pdf_file_name = $self->create_pdf(%create_params);
462 $self->_store_pdf_in_webdav ($pdf_file_name, $data->{invoice});
463 $self->_store_pdf_in_filemanagement($pdf_file_name, $data->{invoice});
465 for (qw(email_subject email_body)) {
467 object => $data->{config},
468 invoice => $data->{invoice},
469 vars => $data->{time_period_vars},
471 attribute_format => 'text'
475 my $global_bcc = SL::DB::Default->get->global_bcc;
478 for my $recipient (@recipients) {
479 my $mail = Mailer->new;
480 $mail->{record_id} = $data->{invoice}->id,
481 $mail->{record_type} = 'invoice',
482 $mail->{from} = $data->{config}->email_sender || $::lx_office_conf{periodic_invoices}->{email_from};
483 $mail->{to} = $recipient;
484 $mail->{bcc} = $global_bcc;
485 $mail->{subject} = $data->{config}->email_subject;
486 $mail->{message} = $data->{config}->email_body;
487 $mail->{attachments} = [{
488 path => $pdf_file_name,
489 name => sprintf('%s %s.pdf', $label, $data->{invoice}->invnumber),
492 my $error = $mail->send;
495 push @{ $self->{job_errors} }, $error;
496 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $error ];
501 push @{ $self->{emailed_invoices} }, $data->{invoice} unless $overall_error;
506 push @{ $self->{job_errors} }, $EVAL_ERROR;
507 push @{ $self->{emailed_failed} }, [ $data->{invoice}, $EVAL_ERROR ];
510 unlink $pdf_file_name if $pdf_file_name;
523 SL::BackgroundJob::CleanBackgroundJobHistory - Create periodic
528 Iterate over all periodic invoice configurations, extend them if
529 applicable, calculate the dates for which invoices have to be posted
530 and post those invoices by converting the order into an invoice for
539 Strings like month names are hardcoded to German in this file.
545 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>