use Storable qw( freeze thaw );
use GD::Barcode;
use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax do_print );
+use FS::Misc qw( send_fax do_print );
use FS::Record qw( qsearch qsearchs dbh );
use FS::cust_main;
use FS::cust_statement;
sub void {
my $self = shift;
my $reason = scalar(@_) ? shift : '';
+ my $reprocess_cdrs = scalar(@_) ? shift : '';
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
}
foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
- my $error = $cust_bill_pkg->void($reason);
+ my $error = $cust_bill_pkg->void($reason, $reprocess_cdrs);
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return $error;
sub previous {
my $self = shift;
- my $total = 0;
- my @cust_bill = sort { $a->_date <=> $b->_date }
- grep { $_->owed != 0 }
- qsearch( 'cust_bill', { 'custnum' => $self->custnum,
- #'_date' => { op=>'<', value=>$self->_date },
- 'invnum' => { op=>'<', value=>$self->invnum },
- } )
- ;
- foreach ( @cust_bill ) { $total += $_->owed; }
- $total, @cust_bill;
+ # simple memoize; we use this a lot
+ if (!$self->get('previous')) {
+ my $total = 0;
+ my @cust_bill = sort { $a->_date <=> $b->_date }
+ grep { $_->owed != 0 }
+ qsearch( 'cust_bill', { 'custnum' => $self->custnum,
+ #'_date' => { op=>'<', value=>$self->_date },
+ 'invnum' => { op=>'<', value=>$self->invnum },
+ } )
+ ;
+ foreach ( @cust_bill ) { $total += $_->owed; }
+ $self->set('previous', [$total, @cust_bill]);
+ }
+ return @{ $self->get('previous') };
}
=item enable_previous
sub cust_bill_pkg {
my $self = shift;
qsearch(
- { 'table' => 'cust_bill_pkg',
+ {
+ 'select' => 'cust_bill_pkg.*, pkg_category.categoryname',
+ 'table' => 'cust_bill_pkg',
+ 'addl_from' => ' LEFT JOIN cust_pkg USING ( pkgnum ) '.
+ ' LEFT JOIN part_pkg USING ( pkgpart ) '.
+ ' LEFT JOIN pkg_class USING ( classnum ) '.
+ ' LEFT JOIN pkg_category USING ( categorynum ) ',
'hashref' => { 'invnum' => $self->invnum },
'order_by' => 'ORDER BY billpkgnum',
}
=item apply_payments_and_credits [ OPTION => VALUE ... ]
Applies unapplied payments and credits to this invoice.
+Payments with the no_auto_apply flag set will not be applied.
A hash of optional arguments may be passed. Currently "manual" is supported.
If true, a payment receipt is sent instead of a statement when
$self->select_for_update; #mutex
- my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
+ my @payments = grep { $_->unapplied > 0 }
+ grep { !$_->no_auto_apply }
+ $self->cust_main->cust_pay;
my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
if ( $conf->exists('pkg-balances') ) {
}
-=item generate_email OPTION => VALUE ...
-
-Options:
-
-=over 4
-
-=item from
-
-sender address, required
-
-=item template
-
-alternate template name, optional
-
-=item print_text
-
-text attachment arrayref, optional
-
-=item subject
-
-email subject, optional
-
-=item notice_name
-
-notice name instead of "Invoice", optional
-
-=back
-
-Returns an argument list to be passed to L<FS::Misc::send_email>.
-
-=cut
-
-use MIME::Entity;
-
-sub generate_email {
-
- my $self = shift;
- my %args = @_;
- my $conf = $self->conf;
-
- my $me = '[FS::cust_bill::generate_email]';
-
- my %return = (
- 'from' => $args{'from'},
- 'subject' => ($args{'subject'} || $self->email_subject),
- 'custnum' => $self->custnum,
- 'msgtype' => 'invoice',
- );
-
- $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
-
- my $cust_main = $self->cust_main;
-
- if (ref($args{'to'}) eq 'ARRAY') {
- $return{'to'} = $args{'to'};
- } else {
- $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
- $cust_main->invoicing_list
- ];
- }
-
- if ( $conf->exists('invoice_html') ) {
-
- warn "$me creating HTML/text multipart message"
- if $DEBUG;
-
- $return{'nobody'} = 1;
-
- my $alternative = build MIME::Entity
- 'Type' => 'multipart/alternative',
- #'Encoding' => '7bit',
- 'Disposition' => 'inline'
- ;
-
- my $data;
- if ( $conf->exists('invoice_email_pdf')
- and scalar($conf->config('invoice_email_pdf_note')) ) {
-
- warn "$me using 'invoice_email_pdf_note' in multipart message"
- if $DEBUG;
- $data = [ map { $_ . "\n" }
- $conf->config('invoice_email_pdf_note')
- ];
-
- } else {
-
- warn "$me not using 'invoice_email_pdf_note' in multipart message"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $data = $args{'print_text'};
- } else {
- $data = [ $self->print_text(\%args) ];
- }
-
- }
-
- $alternative->attach(
- 'Type' => 'text/plain',
- 'Encoding' => 'quoted-printable',
- #'Encoding' => '7bit',
- 'Data' => $data,
- 'Disposition' => 'inline',
- );
-
-
- my $htmldata;
- my $image = '';
- my $barcode = '';
- if ( $conf->exists('invoice_email_pdf')
- and scalar($conf->config('invoice_email_pdf_note')) ) {
-
- $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
-
- } else {
-
- $args{'from'} =~ /\@([\w\.\-]+)/;
- my $from = $1 || 'example.com';
- my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
-
- my $logo;
- my $agentnum = $cust_main->agentnum;
- if ( defined($args{'template'}) && length($args{'template'})
- && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
- )
- {
- $logo = 'logo_'. $args{'template'}. '.png';
- } else {
- $logo = "logo.png";
- }
- my $image_data = $conf->config_binary( $logo, $agentnum);
-
- $image = build MIME::Entity
- 'Type' => 'image/png',
- 'Encoding' => 'base64',
- 'Data' => $image_data,
- 'Filename' => 'logo.png',
- 'Content-ID' => "<$content_id>",
- ;
-
- if ($conf->exists('invoice-barcode')) {
- my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
- $barcode = build MIME::Entity
- 'Type' => 'image/png',
- 'Encoding' => 'base64',
- 'Data' => $self->invoice_barcode(0),
- 'Filename' => 'barcode.png',
- 'Content-ID' => "<$barcode_content_id>",
- ;
- $args{'barcode_cid'} = $barcode_content_id;
- }
-
- $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
- }
-
- $alternative->attach(
- 'Type' => 'text/html',
- 'Encoding' => 'quoted-printable',
- 'Data' => [ '<html>',
- ' <head>',
- ' <title>',
- ' '. encode_entities($return{'subject'}),
- ' </title>',
- ' </head>',
- ' <body bgcolor="#e8e8e8">',
- $htmldata,
- ' </body>',
- '</html>',
- ],
- 'Disposition' => 'inline',
- #'Filename' => 'invoice.pdf',
- );
-
-
- my @otherparts = ();
- if ( $cust_main->email_csv_cdr ) {
-
- push @otherparts, build MIME::Entity
- 'Type' => 'text/csv',
- 'Encoding' => '7bit',
- 'Data' => [ map { "$_\n" }
- $self->call_details('prepend_billed_number' => 1)
- ],
- 'Disposition' => 'attachment',
- 'Filename' => 'usage-'. $self->invnum. '.csv',
- ;
-
- }
-
- if ( $conf->exists('invoice_email_pdf') ) {
-
- #attaching pdf too:
- # multipart/mixed
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
- # application/pdf
-
- my $related = build MIME::Entity 'Type' => 'multipart/related',
- 'Encoding' => '7bit';
-
- #false laziness w/Misc::send_email
- $related->head->replace('Content-type',
- $related->mime_type.
- '; boundary="'. $related->head->multipart_boundary. '"'.
- '; type=multipart/alternative'
- );
-
- $related->add_part($alternative);
-
- $related->add_part($image) if $image;
-
- my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
-
- $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
-
- } else {
-
- #no other attachment:
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
-
- $return{'content-type'} = 'multipart/related';
- if ($conf->exists('invoice-barcode') && $barcode) {
- $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
- } else {
- $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
- }
- $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
- #$return{'disposition'} = 'inline';
-
- }
-
- } else {
-
- if ( $conf->exists('invoice_email_pdf') ) {
- warn "$me creating PDF attachment"
- if $DEBUG;
-
- #mime parts arguments a la MIME::Entity->build().
- $return{'mimeparts'} = [
- { $self->mimebuild_pdf(\%args) }
- ];
- }
-
- if ( $conf->exists('invoice_email_pdf')
- and scalar($conf->config('invoice_email_pdf_note')) ) {
-
- warn "$me using 'invoice_email_pdf_note'"
- if $DEBUG;
- $return{'body'} = [ map { $_ . "\n" }
- $conf->config('invoice_email_pdf_note')
- ];
-
- } else {
-
- warn "$me not using 'invoice_email_pdf_note'"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $return{'body'} = $args{'print_text'};
- } else {
- $return{'body'} = [ $self->print_text(\%args) ];
- }
-
- }
-
- }
-
- %return;
-
-}
-
-=item mimebuild_pdf
-
-Returns a list suitable for passing to MIME::Entity->build(), representing
-this invoice as PDF attachment.
-
-=cut
-
-sub mimebuild_pdf {
- my $self = shift;
- (
- 'Type' => 'application/pdf',
- 'Encoding' => 'base64',
- 'Data' => [ $self->print_pdf(@_) ],
- 'Disposition' => 'attachment',
- 'Filename' => 'invoice-'. $self->invnum. '.pdf',
- );
-}
-
=item send HASHREF
Sends this invoice to the destinations configured for this customer: sends
I<agentnum>: obsolete, now does nothing.
-I<invoice_from> overrides the default email invoice From: address.
+I<from> overrides the default email invoice From: address.
I<amount>: obsolete, does nothing
$self->email($opt)
if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
- && ! $self->invoice_noemail;
+ && ! $cust_main->invoice_noemail;
$self->print($opt)
if grep { $_ eq 'POST' } @invoicing_list; #postal
}
-=item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ]
-
-Sends this invoice to the customer's email destination(s).
-
-Options must be passed as a hashref. Positional parameters are no longer
-allowed.
-
-I<template>, if specified, is the name of a suffix for alternate invoices.
-
-I<invoice_from>, if specified, overrides the default email invoice From:
-address.
-
-I<notice_name> is the name of the sent document.
-
-=cut
-
-sub queueable_email {
- my %opt = @_;
-
- my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
- or die "invalid invoice number: " . $opt{invnum};
-
- my %args = map {$_ => $opt{$_}}
- grep { $opt{$_} }
- qw( invoice_from notice_name no_coupon template );
-
- my $error = $self->email( \%args );
- die $error if $error;
-
-}
-
sub email {
my $self = shift;
- return if $self->hide;
- my $conf = $self->conf;
my $opt = shift || {};
if ($opt and !ref($opt)) {
- die "FS::cust_bill::email called with positional parameters";
+ die ref($self). '->email called with positional parameters';
}
- my $template = $opt->{template};
- my $from = delete $opt->{invoice_from};
+ my $conf = $self->conf;
+
+ my $from = delete $opt->{from};
# this is where we set the From: address
$from ||= $self->_agent_invoice_from || #XXX should go away
- $conf->config('invoice_from', $self->cust_main->agentnum );
+ $conf->invoice_from_full( $self->cust_main->agentnum );
- my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
- $self->cust_main->invoicing_list;
+ my @invoicing_list = $self->cust_main->invoicing_list_emailonly;
if ( ! @invoicing_list ) { #no recipients
if ( $conf->exists('cust_bill-no_recipients-error') ) {
}
}
- # this is where we set the Subject:
- my $subject = $self->email_subject($template);
+ $self->SUPER::email( {
+ 'from' => $from,
+ 'to' => \@invoicing_list,
+ %$opt,
+ });
- my $error = send_email(
- $self->generate_email(
- 'from' => $from,
- 'to' => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
- 'subject' => $subject,
- %$opt, # template, etc.
- )
- );
- die "can't email invoice: $error\n" if $error;
- #die "$error\n" if $error;
+}
+
+#this stays here for now because its explicitly used as
+# FS::cust_bill::queueable_email
+sub queueable_email {
+ my %opt = @_;
+
+ my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+ or die "invalid invoice number: " . $opt{invnum};
+
+ $self->set('mode', $opt{mode})
+ if $opt{mode};
+
+ my %args = map {$_ => $opt{$_}}
+ grep { $opt{$_} }
+ qw( from notice_name no_coupon template );
+
+ my $error = $self->email( \%args );
+ die $error if $error;
}
eval qq("$subject");
}
+sub pdf_filename {
+ my $self = shift;
+ 'Invoice-'. $self->invnum. '.pdf';
+}
+
=item lpr_data HASHREF
Returns the postscript or plaintext for this invoice as an arrayref.
batchnum => $bill_batch->batchnum,
invnum => $self->invnum,
});
+ if ( $self->mode ) {
+ $opt->{mode} ||= $self->mode;
+ $opt->{mode} = $opt->{mode}->modenum if ref $opt->{mode};
+ }
return $cust_bill_batch->insert($opt);
}
if ( lc($opt{'format'}) eq 'billco' ) {
my $lineseq = 0;
- foreach my $item ( $self->_items_pkg ) {
+ my %items_opt = ( format => 'template',
+ escape_function => sub { shift } );
+ # I don't know what characters billco actually tolerates in spool entries.
+ # Text::CSV will take care of delimiters, though.
+
+ my @items = ( $self->_items_pkg(%items_opt),
+ $self->_items_fee(%items_opt) );
+ foreach my $item (@items) {
+
+ my $description = $item->{'description'};
+ if ( $item->{'_is_discount'} and exists($item->{ext_description}[0]) ) {
+ $description .= ': ' . $item->{ext_description}[0];
+ }
$csv->combine(
'', # 1 | N/A-Leave Empty CHAR 2
$tracctnum, # 3 | Account Number CHAR 15
$self->invnum, # 4 | Invoice Number CHAR 15
$lineseq++, # 5 | Line Sequence (sort order) NUM 6
- $item->{'description'}, # 6 | Transaction Detail CHAR 100
+ $description, # 6 | Transaction Detail CHAR 100
$item->{'amount'}, # 7 | Amount NUM* 9
'', # 8 | Line Format Control** CHAR 2
'', # 9 | Grouping Code CHAR 2
my %classnums = ();
my %lines = ();
- my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
my %classnums = ();
my %lines = ();
- my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
$usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
my %opt = @_;
my $escape = $opt{escape} || sub { $_[0] };
+ my $money_char = $opt{money_char};
my $invnum = $self->invnum;
my @classes = qsearch({
'table' => 'usage_class',
- 'select' => 'classnum, classname, SUM(amount) AS amount',
+ 'select' => 'classnum, classname, SUM(amount) AS amount,'.
+ ' COUNT(*) AS calls, SUM(duration) AS duration',
'addl_from' => ' LEFT JOIN cust_bill_pkg_detail USING (classnum)' .
' LEFT JOIN cust_bill_pkg USING (billpkgnum)',
'extra_sql' => " WHERE cust_bill_pkg.invnum = $invnum".
my @l;
my $section = {
description => &{$escape}($self->mt('Usage Summary')),
- no_subtotal => 1,
usage_section => 1,
+ subtotal => 0,
};
foreach my $class (@classes) {
+ $section->{subtotal} += $class->get('amount');
push @l, {
'description' => &{$escape}($class->classname),
- 'amount' => sprintf('%.2f', $class->amount),
+ 'amount' => $money_char.sprintf('%.2f', $class->get('amount')),
+ 'quantity' => $class->get('calls'),
+ 'duration' => $class->get('duration'),
'usage_classnum' => $class->classnum,
'section' => $section,
};
}
+ $section->{subtotal} = $money_char.sprintf('%.2f', $section->{subtotal});
return @l;
}
sub _items_credits {
my( $self, %opt ) = @_;
- my $trim_len = $opt{'trim_len'} || 60;
+ my $trim_len = $opt{'trim_len'} || 40;
my @b;
#credits
}
+sub _items_total {
+ my $self = shift;
+ my $conf = $self->conf;
+
+ my @items;
+ my ($pr_total) = $self->previous;
+ my ($previous_charges_desc, $new_charges_desc, $new_charges_amount);
+
+ if ( $conf->exists('previous_balance-exclude_from_total') ) {
+ # if enabled, specifically add a line for the previous balance total
+ $previous_charges_desc = $self->mt(
+ $conf->config('previous_balance-text') || 'Previous Balance'
+ );
+
+ # then return separate lines for previous balance and total new charges
+ if ( $pr_total ) {
+ push @items,
+ { total_item => $previous_charges_desc,
+ total_amount => sprintf('%.2f',$pr_total)
+ };
+ }
+ }
+
+ if ( $conf->exists('previous_balance-exclude_from_total')
+ or !$self->enable_previous ) {
+ # show new charges only
+
+ $new_charges_desc = $self->mt(
+ $conf->config('previous_balance-text-total_new_charges')
+ || 'Total New Charges'
+ );
+
+ $new_charges_amount = $self->charged;
+
+ } else {
+ # show new charges + previous invoice total
+
+ $new_charges_desc = $self->mt('Total Charges');
+ if ( $self->enable_previous ) {
+ $new_charges_amount = sprintf('%.2f', $self->charged + $pr_total);
+ } else {
+ $new_charges_amount = sprintf('%.2f', $self->charged);
+ }
+
+ }
+ if ( $conf->exists('invoice_show_prior_due_date') && !$conf->exists('invoice_omit_due_date') ) {
+ # then the due date should be shown with Total New Charges,
+ # and should NOT be shown with the Balance Due message.
+ if ( $self->due_date ) {
+ $new_charges_desc .= $self->invoice_pay_by_msg;
+ } elsif ( $self->terms ) {
+ # phrases like "due on receipt" should be localized
+ $new_charges_desc .= ' - ' . $self->mt($self->terms);
+ }
+ }
+
+ push @items,
+ { total_item => $new_charges_desc,
+ total_amount => $new_charges_amount,
+ };
+
+ @items;
+}
+
+
+
+=item has_call_details
+
+Returns true if this invoice has call details.
+
+=cut
+
+sub has_call_details {
+ my $self = shift;
+ $self->scalar_sql("
+ SELECT 1 FROM cust_bill_pkg_detail
+ LEFT JOIN cust_bill_pkg USING (billpkgnum)
+ WHERE cust_bill_pkg_detail.format = 'C'
+ AND cust_bill_pkg.invnum = ?
+ LIMIT 1
+ ", $self->invnum);
+}
+
=item call_details [ OPTION => VALUE ... ]
Returns an array of CSV strings representing the call details for this invoice
}
+# this is called from search/cust_bill.html and given all its search
+# parameters, so it needs to perform the same search.
+
sub re_X {
# spool_invoice ftp_invoice fax_invoice print_invoice
my($method, $job, %param ) = @_;
}
#some false laziness w/search/cust_bill.html
- my $distinct = '';
- my $orderby = 'ORDER BY cust_bill._date';
-
- my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
-
- my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
-
- my @cust_bill = qsearch( {
- #'select' => "cust_bill.*",
- 'table' => 'cust_bill',
- 'addl_from' => $addl_from,
- 'hashref' => {},
- 'extra_sql' => $extra_sql,
- 'order_by' => $orderby,
- 'debug' => 1,
- } );
+ $param{'order_by'} = 'cust_bill._date';
+
+ my $query = FS::cust_bill->search(\%param);
+ delete $query->{'count_query'};
+ delete $query->{'count_addl'};
+
+ $query->{debug} = 1; # was in here before, is obviously useful
+
+ my @cust_bill = qsearch( $query );
$method .= '_invoice' unless $method eq 'email' || $method eq 'print';
=cut
sub due_date_sql {
+ die "don't use: doesn't account for agent-specific invoice_default_terms";
+
+ #we're passed a $conf but not a specific customer (that's in the query), so
+ # to make this work we'd need an agentnum-aware "condition_sql_conf" like
+ # "condition_sql_option" that retreives a conf value with SQL in an agent-
+ # aware fashion
+
my $conf = new FS::Conf;
'COALESCE(
SUBSTRING(