+Place this invoice into the open batch (see C<FS::bill_batch>). If there
+isn't an open batch, one will be created.
+
+=cut
+
+sub batch_invoice {
+ my ($self, $opt) = @_;
+ my $bill_batch = $self->get_open_bill_batch;
+ my $cust_bill_batch = FS::cust_bill_batch->new({
+ batchnum => $bill_batch->batchnum,
+ invnum => $self->invnum,
+ });
+ return $cust_bill_batch->insert($opt);
+}
+
+=item get_open_batch
+
+Returns the currently open batch as an FS::bill_batch object, creating a new
+one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is
+enabled)
+
+=cut
+
+sub get_open_bill_batch {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $hashref = { status => 'O' };
+ $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
+ ? $self->cust_main->agentnum
+ : '';
+ my $batch = qsearchs('bill_batch', $hashref);
+ return $batch if $batch;
+ $batch = FS::bill_batch->new($hashref);
+ my $error = $batch->insert;
+ die $error if $error;
+ return $batch;
+}
+
+=item ftp_invoice [ TEMPLATENAME ]
+
+Sends this invoice data via FTP.
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub ftp_invoice {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $template = scalar(@_) ? shift : '';
+
+ $self->send_csv(
+ 'protocol' => 'ftp',
+ 'server' => $conf->config('cust_bill-ftpserver'),
+ 'username' => $conf->config('cust_bill-ftpusername'),
+ 'password' => $conf->config('cust_bill-ftppassword'),
+ 'dir' => $conf->config('cust_bill-ftpdir'),
+ 'format' => $conf->config('cust_bill-ftpformat'),
+ );
+}
+
+=item spool_invoice [ TEMPLATENAME ]
+
+Spools this invoice data (see L<FS::spool_csv>)
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub spool_invoice {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $template = scalar(@_) ? shift : '';
+
+ $self->spool_csv(
+ 'format' => $conf->config('cust_bill-spoolformat'),
+ 'agent_spools' => $conf->exists('cust_bill-spoolagent'),
+ );
+}
+
+=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
+
+Like B<send>, but only sends the invoice if it is the newest open invoice for
+this customer.
+
+=cut
+
+sub send_if_newest {
+ my $self = shift;
+
+ return ''
+ if scalar(
+ grep { $_->owed > 0 }
+ qsearch('cust_bill', {
+ 'custnum' => $self->custnum,
+ #'_date' => { op=>'>', value=>$self->_date },
+ 'invnum' => { op=>'>', value=>$self->invnum },
+ } )
+ );
+
+ $self->send(@_);
+}
+
+=item send_csv OPTION => VALUE, ...
+
+Sends invoice as a CSV data-file to a remote host with the specified protocol.
+
+Options are:
+
+protocol - currently only "ftp"
+server
+username
+password
+dir
+
+The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
+and YYMMDDHHMMSS is a timestamp.
+
+See L</print_csv> for a description of the output format.
+
+=cut
+
+sub send_csv {
+ my($self, %opt) = @_;
+
+ #create file(s)
+
+ my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
+ mkdir $spooldir, 0700 unless -d $spooldir;
+
+ my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
+ my $file = "$spooldir/$tracctnum.csv";
+
+ my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
+
+ open(CSV, ">$file") or die "can't open $file: $!";
+ print CSV $header;
+
+ print CSV $detail;
+
+ close CSV;
+
+ my $net;
+ if ( $opt{protocol} eq 'ftp' ) {
+ eval "use Net::FTP;";
+ die $@ if $@;
+ $net = Net::FTP->new($opt{server}) or die @$;
+ } else {
+ die "unknown protocol: $opt{protocol}";
+ }
+
+ $net->login( $opt{username}, $opt{password} )
+ or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
+
+ $net->binary or die "can't set binary mode";
+
+ $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
+
+ $net->put($file) or die "can't put $file: $!";
+
+ $net->quit;
+
+ unlink $file;
+
+}
+
+=item spool_csv
+
+Spools CSV invoice data.
+
+Options are:
+
+=over 4
+
+=item format - 'default' or 'billco'
+
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+
+=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+
+=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+
+=back
+
+=cut
+
+sub spool_csv {
+ my($self, %opt) = @_;
+
+ my $cust_main = $self->cust_main;
+
+ if ( $opt{'dest'} ) {
+ my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
+ $cust_main->invoicing_list;
+ return 'N/A' unless $invoicing_list{$opt{'dest'}}
+ || ! keys %invoicing_list;
+ }
+
+ if ( $opt{'balanceover'} ) {
+ return 'N/A'
+ if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
+ }
+
+ my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
+ mkdir $spooldir, 0700 unless -d $spooldir;
+
+ my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
+
+ my $file =
+ "$spooldir/".
+ ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+ ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
+ '.csv';
+
+ my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
+
+ open(CSV, ">>$file") or die "can't open $file: $!";
+ flock(CSV, LOCK_EX);
+ seek(CSV, 0, 2);
+
+ print CSV $header;
+
+ if ( lc($opt{'format'}) eq 'billco' ) {
+
+ flock(CSV, LOCK_UN);
+ close CSV;
+
+ $file =
+ "$spooldir/".
+ ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+ '-detail.csv';
+
+ open(CSV,">>$file") or die "can't open $file: $!";
+ flock(CSV, LOCK_EX);
+ seek(CSV, 0, 2);
+ }
+
+ print CSV $detail;
+
+ flock(CSV, LOCK_UN);
+ close CSV;
+
+ return '';
+
+}
+
+=item print_csv OPTION => VALUE, ...
+
+Returns CSV data for this invoice.
+
+Options are:
+
+format - 'default' or 'billco'
+
+Returns a list consisting of two scalars. The first is a single line of CSV
+header information for this invoice. The second is one or more lines of CSV
+detail information for this invoice.
+
+If I<format> is not specified or "default", the fields of the CSV file are as
+follows:
+
+record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+
+=over 4
+
+=item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
+
+B<record_type> is C<cust_bill> for the initial header line only. The
+last five fields (B<pkg> through B<edate>) are irrelevant, and all other
+fields are filled in.
+
+B<record_type> is C<cust_bill_pkg> for detail lines. Only the first two fields
+(B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
+are filled in.
+
+=item invnum - invoice number
+
+=item custnum - customer number
+
+=item _date - invoice date
+
+=item charged - total invoice amount
+
+=item first - customer first name
+
+=item last - customer first name
+
+=item company - company name
+
+=item address1 - address line 1
+
+=item address2 - address line 1
+
+=item city
+
+=item state
+
+=item zip
+
+=item country
+
+=item pkg - line item description
+
+=item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
+
+=item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
+
+=item sdate - start date for recurring fee
+
+=item edate - end date for recurring fee
+
+=back
+
+If I<format> is "billco", the fields of the header CSV file are as follows:
+
+ +-------------------------------------------------------------------+
+ | FORMAT HEADER FILE |
+ |-------------------------------------------------------------------|
+ | Field | Description | Name | Type | Width |
+ | 1 | N/A-Leave Empty | RC | CHAR | 2 |
+ | 2 | N/A-Leave Empty | CUSTID | CHAR | 15 |
+ | 3 | Transaction Account No | TRACCTNUM | CHAR | 15 |
+ | 4 | Transaction Invoice No | TRINVOICE | CHAR | 15 |
+ | 5 | Transaction Zip Code | TRZIP | CHAR | 5 |
+ | 6 | Transaction Company Bill To | TRCOMPANY | CHAR | 30 |
+ | 7 | Transaction Contact Bill To | TRNAME | CHAR | 30 |
+ | 8 | Additional Address Unit Info | TRADDR1 | CHAR | 30 |
+ | 9 | Bill To Street Address | TRADDR2 | CHAR | 30 |
+ | 10 | Ancillary Billing Information | TRADDR3 | CHAR | 30 |
+ | 11 | Transaction City Bill To | TRCITY | CHAR | 20 |
+ | 12 | Transaction State Bill To | TRSTATE | CHAR | 2 |
+ | 13 | Bill Cycle Close Date | CLOSEDATE | CHAR | 10 |
+ | 14 | Bill Due Date | DUEDATE | CHAR | 10 |
+ | 15 | Previous Balance | BALFWD | NUM* | 9 |
+ | 16 | Pmt/CR Applied | CREDAPPLY | NUM* | 9 |
+ | 17 | Total Current Charges | CURRENTCHG | NUM* | 9 |
+ | 18 | Total Amt Due | TOTALDUE | NUM* | 9 |
+ | 19 | Total Amt Due | AMTDUE | NUM* | 9 |
+ | 20 | 30 Day Aging | AMT30 | NUM* | 9 |
+ | 21 | 60 Day Aging | AMT60 | NUM* | 9 |
+ | 22 | 90 Day Aging | AMT90 | NUM* | 9 |
+ | 23 | Y/N | AGESWITCH | CHAR | 1 |
+ | 24 | Remittance automation | SCANLINE | CHAR | 100 |
+ | 25 | Total Taxes & Fees | TAXTOT | NUM* | 9 |
+ | 26 | Customer Reference Number | CUSTREF | CHAR | 15 |
+ | 27 | Federal Tax*** | FEDTAX | NUM* | 9 |
+ | 28 | State Tax*** | STATETAX | NUM* | 9 |
+ | 29 | Other Taxes & Fees*** | OTHERTAX | NUM* | 9 |
+ +-------+-------------------------------+------------+------+-------+
+
+If I<format> is "billco", the fields of the detail CSV file are as follows:
+
+ FORMAT FOR DETAIL FILE
+ | | | |
+ Field | Description | Name | Type | Width
+ 1 | N/A-Leave Empty | RC | CHAR | 2
+ 2 | N/A-Leave Empty | CUSTID | CHAR | 15
+ 3 | Account Number | TRACCTNUM | CHAR | 15
+ 4 | Invoice Number | TRINVOICE | CHAR | 15
+ 5 | Line Sequence (sort order) | LINESEQ | NUM | 6
+ 6 | Transaction Detail | DETAILS | CHAR | 100
+ 7 | Amount | AMT | NUM* | 9
+ 8 | Line Format Control** | LNCTRL | CHAR | 2
+ 9 | Grouping Code | GROUP | CHAR | 2
+ 10 | User Defined | ACCT CODE | CHAR | 15
+
+=cut
+
+sub print_csv {
+ my($self, %opt) = @_;
+
+ eval "use Text::CSV_XS";
+ die $@ if $@;
+
+ my $cust_main = $self->cust_main;
+
+ my $csv = Text::CSV_XS->new({'always_quote'=>1});
+
+ if ( lc($opt{'format'}) eq 'billco' ) {
+
+ my $taxtotal = 0;
+ $taxtotal += $_->{'amount'} foreach $self->_items_tax;
+
+ my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
+
+ my( $previous_balance, @unused ) = $self->previous; #previous balance
+
+ my $pmt_cr_applied = 0;
+ $pmt_cr_applied += $_->{'amount'}
+ foreach ( $self->_items_payments, $self->_items_credits ) ;
+
+ my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
+
+ $csv->combine(
+ '', # 1 | N/A-Leave Empty CHAR 2
+ '', # 2 | N/A-Leave Empty CHAR 15
+ $opt{'tracctnum'}, # 3 | Transaction Account No CHAR 15
+ $self->invnum, # 4 | Transaction Invoice No CHAR 15
+ $cust_main->zip, # 5 | Transaction Zip Code CHAR 5
+ $cust_main->company, # 6 | Transaction Company Bill To CHAR 30
+ #$cust_main->payname, # 7 | Transaction Contact Bill To CHAR 30
+ $cust_main->contact, # 7 | Transaction Contact Bill To CHAR 30
+ $cust_main->address2, # 8 | Additional Address Unit Info CHAR 30
+ $cust_main->address1, # 9 | Bill To Street Address CHAR 30
+ '', # 10 | Ancillary Billing Information CHAR 30
+ $cust_main->city, # 11 | Transaction City Bill To CHAR 20
+ $cust_main->state, # 12 | Transaction State Bill To CHAR 2
+
+ # XXX ?
+ time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR 10
+
+ # XXX ?
+ $duedate, # 14 | Bill Due Date CHAR 10
+
+ $previous_balance, # 15 | Previous Balance NUM* 9
+ $pmt_cr_applied, # 16 | Pmt/CR Applied NUM* 9
+ sprintf("%.2f", $self->charged), # 17 | Total Current Charges NUM* 9
+ $totaldue, # 18 | Total Amt Due NUM* 9
+ $totaldue, # 19 | Total Amt Due NUM* 9
+ '', # 20 | 30 Day Aging NUM* 9
+ '', # 21 | 60 Day Aging NUM* 9
+ '', # 22 | 90 Day Aging NUM* 9
+ 'N', # 23 | Y/N CHAR 1
+ '', # 24 | Remittance automation CHAR 100
+ $taxtotal, # 25 | Total Taxes & Fees NUM* 9
+ $self->custnum, # 26 | Customer Reference Number CHAR 15
+ '0', # 27 | Federal Tax*** NUM* 9
+ sprintf("%.2f", $taxtotal), # 28 | State Tax*** NUM* 9
+ '0', # 29 | Other Taxes & Fees*** NUM* 9
+ );
+
+ } else {
+
+ $csv->combine(
+ 'cust_bill',
+ $self->invnum,
+ $self->custnum,
+ time2str("%x", $self->_date),
+ sprintf("%.2f", $self->charged),
+ ( map { $cust_main->getfield($_) }
+ qw( first last company address1 address2 city state zip country ) ),
+ map { '' } (1..5),
+ ) or die "can't create csv";
+ }
+
+ my $header = $csv->string. "\n";
+
+ my $detail = '';
+ if ( lc($opt{'format'}) eq 'billco' ) {
+
+ my $lineseq = 0;
+ foreach my $item ( $self->_items_pkg ) {
+
+ $csv->combine(
+ '', # 1 | N/A-Leave Empty CHAR 2
+ '', # 2 | N/A-Leave Empty CHAR 15
+ $opt{'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
+ $item->{'amount'}, # 7 | Amount NUM* 9
+ '', # 8 | Line Format Control** CHAR 2
+ '', # 9 | Grouping Code CHAR 2
+ '', # 10 | User Defined CHAR 15
+ );
+
+ $detail .= $csv->string. "\n";
+
+ }
+
+ } else {
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+
+ my($pkg, $setup, $recur, $sdate, $edate);
+ if ( $cust_bill_pkg->pkgnum ) {
+
+ ($pkg, $setup, $recur, $sdate, $edate) = (
+ $cust_bill_pkg->part_pkg->pkg,
+ ( $cust_bill_pkg->setup != 0
+ ? sprintf("%.2f", $cust_bill_pkg->setup )
+ : '' ),
+ ( $cust_bill_pkg->recur != 0
+ ? sprintf("%.2f", $cust_bill_pkg->recur )
+ : '' ),
+ ( $cust_bill_pkg->sdate
+ ? time2str("%x", $cust_bill_pkg->sdate)
+ : '' ),
+ ($cust_bill_pkg->edate
+ ?time2str("%x", $cust_bill_pkg->edate)
+ : '' ),
+ );
+
+ } else { #pkgnum tax
+ next unless $cust_bill_pkg->setup != 0;
+ $pkg = $cust_bill_pkg->desc;
+ $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
+ ( $sdate, $edate ) = ( '', '' );
+ }
+
+ $csv->combine(
+ 'cust_bill_pkg',
+ $self->invnum,
+ ( map { '' } (1..11) ),
+ ($pkg, $setup, $recur, $sdate, $edate)
+ ) or die "can't create csv";
+
+ $detail .= $csv->string. "\n";
+
+ }
+
+ }
+
+ ( $header, $detail );
+
+}
+
+=item comp
+
+Pays this invoice with a compliemntary payment. If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+sub comp {
+ my $self = shift;
+ my $cust_pay = new FS::cust_pay ( {
+ 'invnum' => $self->invnum,
+ 'paid' => $self->owed,
+ '_date' => '',
+ 'payby' => 'COMP',
+ 'payinfo' => $self->cust_main->payinfo,
+ 'paybatch' => '',
+ } );
+ $cust_pay->insert;
+}
+
+=item realtime_card
+
+Attempts to pay this invoice with a credit card payment via a
+Business::OnlinePayment realtime gateway. See
+http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
+for supported processors.
+
+=cut
+
+sub realtime_card {
+ my $self = shift;
+ $self->realtime_bop( 'CC', @_ );
+}
+
+=item realtime_ach
+
+Attempts to pay this invoice with an electronic check (ACH) payment via a
+Business::OnlinePayment realtime gateway. See
+http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
+for supported processors.
+
+=cut
+
+sub realtime_ach {
+ my $self = shift;
+ $self->realtime_bop( 'ECHECK', @_ );
+}
+
+=item realtime_lec
+
+Attempts to pay this invoice with phone bill (LEC) payment via a
+Business::OnlinePayment realtime gateway. See
+http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
+for supported processors.
+
+=cut
+
+sub realtime_lec {
+ my $self = shift;
+ $self->realtime_bop( 'LEC', @_ );
+}
+
+sub realtime_bop {
+ my( $self, $method ) = (shift,shift);
+ my $conf = $self->conf;
+ my %opt = @_;
+
+ my $cust_main = $self->cust_main;
+ my $balance = $cust_main->balance;
+ my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
+ $amount = sprintf("%.2f", $amount);
+ return "not run (balance $balance)" unless $amount > 0;
+
+ my $description = 'Internet Services';
+ if ( $conf->exists('business-onlinepayment-description') ) {
+ my $dtempl = $conf->config('business-onlinepayment-description');
+
+ my $agent_obj = $cust_main->agent
+ or die "can't retreive agent for $cust_main (agentnum ".
+ $cust_main->agentnum. ")";
+ my $agent = $agent_obj->agent;
+ my $pkgs = join(', ',
+ map { $_->part_pkg->pkg }
+ grep { $_->pkgnum } $self->cust_bill_pkg
+ );
+ $description = eval qq("$dtempl");
+ }
+
+ $cust_main->realtime_bop($method, $amount,
+ 'description' => $description,
+ 'invnum' => $self->invnum,
+#this didn't do what we want, it just calls apply_payments_and_credits
+# 'apply' => 1,
+ 'apply_to_invoice' => 1,
+ %opt,
+ #what we want:
+ #this changes application behavior: auto payments
+ #triggered against a specific invoice are now applied
+ #to that invoice instead of oldest open.
+ #seem okay to me...
+ );
+
+}
+
+=item batch_card OPTION => VALUE...
+
+Adds a payment for this invoice to the pending credit card batch (see
+L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
+runs the payment using a realtime gateway.
+
+=cut
+
+sub batch_card {
+ my ($self, %options) = @_;
+ my $cust_main = $self->cust_main;
+
+ $options{invnum} = $self->invnum;
+
+ $cust_main->batch_card(%options);
+}
+
+sub _agent_template {
+ my $self = shift;
+ $self->cust_main->agent_template;
+}
+
+sub _agent_invoice_from {
+ my $self = shift;
+ $self->cust_main->agent_invoice_from;
+}
+
+=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+
+Returns an text invoice, as a list of lines.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_text {
+ my $self = shift;
+ my( $today, $template, %opt );
+ if ( ref($_[0]) ) {
+ %opt = %{ shift() };
+ $today = delete($opt{'time'}) || '';
+ $template = delete($opt{template}) || '';
+ } else {
+ ( $today, $template, %opt ) = @_;
+ }
+
+ my %params = ( 'format' => 'template' );
+ $params{'time'} = $today if $today;
+ $params{'template'} = $template if $template;
+ $params{$_} = $opt{$_}
+ foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+
+ $self->print_generic( %params );
+}
+
+=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+
+Internal method - returns a filename of a filled-in LaTeX template for this
+invoice (Note: add ".tex" to get the actual filename), and a filename of
+an associated logo (with the .eps extension included).
+
+See print_ps and print_pdf for methods that return PostScript and PDF output.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_latex {
+ my $self = shift;
+ my $conf = $self->conf;
+ my( $today, $template, %opt );
+ if ( ref($_[0]) ) {
+ %opt = %{ shift() };
+ $today = delete($opt{'time'}) || '';
+ $template = delete($opt{template}) || '';
+ } else {
+ ( $today, $template, %opt ) = @_;
+ }
+
+ my %params = ( 'format' => 'latex' );
+ $params{'time'} = $today if $today;
+ $params{'template'} = $template if $template;
+ $params{$_} = $opt{$_}
+ foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+
+ $template ||= $self->_agent_template;
+
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ my $lh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
+ DIR => $dir,
+ SUFFIX => '.eps',
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+
+ my $agentnum = $self->cust_main->agentnum;
+
+ if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
+ print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
+ or die "can't write temp file: $!\n";
+ } else {
+ print $lh $conf->config_binary('logo.eps', $agentnum)
+ or die "can't write temp file: $!\n";
+ }
+ close $lh;
+ $params{'logo_file'} = $lh->filename;
+
+ if($conf->exists('invoice-barcode')){
+ my $png_file = $self->invoice_barcode($dir);
+ my $eps_file = $png_file;
+ $eps_file =~ s/\.png$/.eps/g;
+ $png_file =~ /(barcode.*png)/;
+ $png_file = $1;
+ $eps_file =~ /(barcode.*eps)/;
+ $eps_file = $1;
+
+ my $curr_dir = cwd();
+ chdir($dir);
+ # after painfuly long experimentation, it was determined that sam2p won't
+ # accept : and other chars in the path, no matter how hard I tried to
+ # escape them, hence the chdir (and chdir back, just to be safe)
+ system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
+ or die "sam2p failed: $!\n";
+ unlink($png_file);
+ chdir($curr_dir);
+
+ $params{'barcode_file'} = $eps_file;
+ }
+
+ my @filled_in = $self->print_generic( %params );
+
+ my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
+ DIR => $dir,
+ SUFFIX => '.tex',
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+ binmode($fh, ':utf8'); # language support
+ print $fh join('', @filled_in );
+ close $fh;
+
+ $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+ return ($1, $params{'logo_file'}, $params{'barcode_file'});
+
+}
+
+=item invoice_barcode DIR_OR_FALSE
+
+Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
+it is taken as the temp directory where the PNG file will be generated and the
+PNG file name is returned. Otherwise, the PNG image itself is returned.
+
+=cut
+
+sub invoice_barcode {
+ my ($self, $dir) = (shift,shift);
+
+ my $gdbar = new GD::Barcode('Code39',$self->invnum);
+ die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
+ my $gd = $gdbar->plot(Height => 30);
+
+ if($dir) {
+ my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
+ DIR => $dir,
+ SUFFIX => '.png',
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+ print $bh $gd->png or die "cannot write barcode to file: $!\n";
+ my $png_file = $bh->filename;
+ close $bh;
+ return $png_file;
+ }
+ return $gd->png;
+}
+
+=item print_generic OPTION => VALUE ...
+
+Internal method - returns a filled-in template for this invoice as a scalar.
+
+See print_ps and print_pdf for methods that return PostScript and PDF output.
+
+Non optional options include
+ format - latex, html, template
+
+Optional options include
+
+template - a value used as a suffix for a configuration template
+
+time - a value used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+cid -
+
+unsquelch_cdr - overrides any per customer cdr squelching when true
+
+notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+#what's with all the sprintf('%10.2f')'s in here? will it cause any
+# (alignment in text invoice?) problems to change them all to '%.2f' ?
+# yes: fixed width (dot matrix) text printing will be borked
+sub print_generic {
+ my( $self, %params ) = @_;
+ my $conf = $self->conf;
+ my $today = $params{today} ? $params{today} : time;
+ warn "$me print_generic called on $self with suffix $params{template}\n"
+ if $DEBUG;
+
+ my $format = $params{format};
+ die "Unknown format: $format"
+ unless $format =~ /^(latex|html|template)$/;
+
+ my $cust_main = $self->cust_main;
+ $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
+ unless $cust_main->payname
+ && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
+
+ my %delimiters = ( 'latex' => [ '[@--', '--@]' ],
+ 'html' => [ '<%=', '%>' ],
+ 'template' => [ '{', '}' ],
+ );
+
+ warn "$me print_generic creating template\n"
+ if $DEBUG > 1;
+
+ #create the template
+ my $template = $params{template} ? $params{template} : $self->_agent_template;
+ my $templatefile = "invoice_$format";
+ $templatefile .= "_$template"
+ if length($template) && $conf->exists($templatefile."_$template");
+ my @invoice_template = map "$_\n", $conf->config($templatefile)
+ or die "cannot load config data $templatefile";
+
+ my $old_latex = '';
+ if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
+ #change this to a die when the old code is removed
+ warn "old-style invoice template $templatefile; ".
+ "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
+ $old_latex = 'true';
+ @invoice_template = _translate_old_latex_format(@invoice_template);
+ }
+
+ warn "$me print_generic creating T:T object\n"
+ if $DEBUG > 1;
+
+ my $text_template = new Text::Template(
+ TYPE => 'ARRAY',
+ SOURCE => \@invoice_template,
+ DELIMITERS => $delimiters{$format},
+ );
+
+ warn "$me print_generic compiling T:T object\n"
+ if $DEBUG > 1;
+
+ $text_template->compile()
+ or die "Can't compile $templatefile: $Text::Template::ERROR\n";
+
+
+ # additional substitution could possibly cause breakage in existing templates
+ my %convert_maps = (
+ 'latex' => {
+ 'notes' => sub { map "$_", @_ },
+ 'footer' => sub { map "$_", @_ },
+ 'smallfooter' => sub { map "$_", @_ },
+ 'returnaddress' => sub { map "$_", @_ },
+ 'coupon' => sub { map "$_", @_ },
+ 'summary' => sub { map "$_", @_ },
+ },
+ 'html' => {
+ 'notes' =>
+ sub {
+ map {
+ s/%%(.*)$/<!-- $1 -->/g;
+ s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
+ s/\\begin\{enumerate\}/<ol>/g;
+ s/\\item / <li>/g;
+ s/\\end\{enumerate\}/<\/ol>/g;
+ s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
+ s/\\\\\*/<br>/g;
+ s/\\dollar ?/\$/g;
+ s/\\#/#/g;
+ s/~/ /g;
+ $_;
+ } @_
+ },
+ 'footer' =>
+ sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+ 'smallfooter' =>
+ sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+ 'returnaddress' =>
+ sub {
+ map {
+ s/~/ /g;
+ s/\\\\\*?\s*$/<BR>/;
+ s/\\hyphenation\{[\w\s\-]+}//;
+ s/\\([&])/$1/g;
+ $_;
+ } @_
+ },
+ 'coupon' => sub { "" },
+ 'summary' => sub { "" },
+ },
+ 'template' => {
+ 'notes' =>
+ sub {
+ map {
+ s/%%.*$//g;
+ s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
+ s/\\begin\{enumerate\}//g;
+ s/\\item / * /g;
+ s/\\end\{enumerate\}//g;
+ s/\\textbf\{(.*)\}/$1/g;
+ s/\\\\\*/ /;
+ s/\\dollar ?/\$/g;
+ $_;
+ } @_
+ },
+ 'footer' =>
+ sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
+ 'smallfooter' =>
+ sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
+ 'returnaddress' =>
+ sub {
+ map {
+ s/~/ /g;
+ s/\\\\\*?\s*$/\n/; # dubious
+ s/\\hyphenation\{[\w\s\-]+}//;
+ $_;
+ } @_
+ },
+ 'coupon' => sub { "" },
+ 'summary' => sub { "" },
+ },
+ );
+
+
+ # hashes for differing output formats
+ my %nbsps = ( 'latex' => '~',
+ 'html' => '', # '&nbps;' would be nice
+ 'template' => '', # not used
+ );
+ my $nbsp = $nbsps{$format};
+
+ my %escape_functions = ( 'latex' => \&_latex_escape,
+ 'html' => \&_html_escape_nbsp,#\&encode_entities,
+ 'template' => sub { shift },
+ );
+ my $escape_function = $escape_functions{$format};
+ my $escape_function_nonbsp = ($format eq 'html')
+ ? \&_html_escape : $escape_function;
+
+ my %date_formats = ( 'latex' => $date_format_long,
+ 'html' => $date_format_long,
+ 'template' => '%s',
+ );
+ $date_formats{'html'} =~ s/ / /g;
+
+ my $date_format = $date_formats{$format};
+
+ my %embolden_functions = ( 'latex' => sub { return '\textbf{'. shift(). '}'
+ },
+ 'html' => sub { return '<b>'. shift(). '</b>'
+ },
+ 'template' => sub { shift },
+ );
+ my $embolden_function = $embolden_functions{$format};
+
+ my %newline_tokens = ( 'latex' => '\\\\',
+ 'html' => '<br>',
+ 'template' => "\n",
+ );
+ my $newline_token = $newline_tokens{$format};
+
+ warn "$me generating template variables\n"
+ if $DEBUG > 1;
+
+ # generate template variables
+ my $returnaddress;
+ if (
+ defined( $conf->config_orbase( "invoice_${format}returnaddress",
+ $template
+ )
+ )
+ && length( $conf->config_orbase( "invoice_${format}returnaddress",
+ $template
+ )
+ )
+ ) {
+
+ $returnaddress = join("\n",
+ $conf->config_orbase("invoice_${format}returnaddress", $template)
+ );
+
+ } elsif ( grep /\S/,
+ $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
+
+ my $convert_map = $convert_maps{$format}{'returnaddress'};
+ $returnaddress =
+ join( "\n",
+ &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
+ $template
+ )
+ )
+ );
+ } elsif ( grep /\S/, $conf->config('company_address', $self->cust_main->agentnum) ) {
+
+ my $convert_map = $convert_maps{$format}{'returnaddress'};
+ $returnaddress = join( "\n", &$convert_map(
+ map { s/( {2,})/'~' x length($1)/eg;
+ s/$/\\\\\*/;
+ $_
+ }
+ ( $conf->config('company_name', $self->cust_main->agentnum),
+ $conf->config('company_address', $self->cust_main->agentnum),
+ )
+ )
+ );
+
+ } else {
+
+ my $warning = "Couldn't find a return address; ".
+ "do you need to set the company_address configuration value?";
+ warn "$warning\n";
+ $returnaddress = $nbsp;
+ #$returnaddress = $warning;
+
+ }
+
+ warn "$me generating invoice data\n"
+ if $DEBUG > 1;
+
+ my $agentnum = $self->cust_main->agentnum;
+
+ my %invoice_data = (
+
+ #invoice from info
+ 'company_name' => scalar( $conf->config('company_name', $agentnum) ),
+ 'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
+ 'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
+ 'returnaddress' => $returnaddress,
+ 'agent' => &$escape_function($cust_main->agent->agent),
+
+ #invoice info
+ 'invnum' => $self->invnum,
+ 'date' => time2str($date_format, $self->_date),
+ 'today' => time2str($date_format_long, $today),
+ 'terms' => $self->terms,
+ 'template' => $template, #params{'template'},
+ 'notice_name' => ($params{'notice_name'} || 'Invoice'),#escape_function?
+ 'current_charges' => sprintf("%.2f", $self->charged),
+ 'duedate' => $self->due_date2str($rdate_format), #date_format?
+
+ #customer info
+ 'custnum' => $cust_main->display_custnum,
+ 'agent_custid' => &$escape_function($cust_main->agent_custid),
+ ( map { $_ => &$escape_function($cust_main->$_()) } qw(
+ payname company address1 address2 city state zip fax
+ )),
+
+ #global config
+ 'ship_enable' => $conf->exists('invoice-ship_address'),
+ 'unitprices' => $conf->exists('invoice-unitprice'),
+ 'smallernotes' => $conf->exists('invoice-smallernotes'),
+ 'smallerfooter' => $conf->exists('invoice-smallerfooter'),
+ 'balance_due_below_line' => $conf->exists('balance_due_below_line'),
+
+ #layout info -- would be fancy to calc some of this and bury the template
+ # here in the code
+ 'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)),
+ 'headsep' => scalar($conf->config('invoice_latexheadsep', $agentnum)),
+ 'textheight' => scalar($conf->config('invoice_latextextheight', $agentnum)),
+ 'extracouponspace' => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
+ 'couponfootsep' => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
+ 'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
+ 'addresssep' => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
+ 'amountenclosedsep' => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
+ 'coupontoaddresssep' => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
+ 'addcompanytoaddress' => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
+
+ # better hang on to conf_dir for a while (for old templates)
+ 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+
+ #these are only used when doing paged plaintext
+ 'page' => 1,
+ 'total_pages' => 1,
+
+ );
+
+ #localization
+ my $lh = FS::L10N->get_handle($cust_main->locale);
+ $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
+ my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
+ # eval to avoid death for unimplemented languages
+ my $dh = eval { Date::Language->new($info{'name'}) } ||
+ Date::Language->new(); # fall back to English
+ $invoice_data{'time2str'} = sub { $dh->time2str(@_) };
+ # eventually use this date handle everywhere in here, too
+
+ my $min_sdate = 999999999999;
+ my $max_edate = 0;
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+ $min_sdate = $cust_bill_pkg->sdate
+ if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
+ $max_edate = $cust_bill_pkg->edate
+ if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
+ }
+
+ $invoice_data{'bill_period'} = '';
+ $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate)
+ . " to " . time2str('%e %h', $max_edate)
+ if ($max_edate != 0 && $min_sdate != 999999999999);
+
+ $invoice_data{finance_section} = '';
+ if ( $conf->config('finance_pkgclass') ) {
+ my $pkg_class =
+ qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
+ $invoice_data{finance_section} = $pkg_class->categoryname;
+ }
+ $invoice_data{finance_amount} = '0.00';
+ $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
+
+ my $countrydefault = $conf->config('countrydefault') || 'US';
+ my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
+ foreach ( qw( contact company address1 address2 city state zip country fax) ){
+ my $method = $prefix.$_;
+ $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+ }
+ $invoice_data{'ship_country'} = ''
+ if ( $invoice_data{'ship_country'} eq $countrydefault );
+
+ $invoice_data{'cid'} = $params{'cid'}
+ if $params{'cid'};
+
+ if ( $cust_main->country eq $countrydefault ) {
+ $invoice_data{'country'} = '';
+ } else {
+ $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+ }
+
+ my @address = ();
+ $invoice_data{'address'} = \@address;
+ push @address,
+ $cust_main->payname.
+ ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
+ ? " (P.O. #". $cust_main->payinfo. ")"
+ : ''
+ )
+ ;
+ push @address, $cust_main->company
+ if $cust_main->company;
+ push @address, $cust_main->address1;
+ push @address, $cust_main->address2
+ if $cust_main->address2;
+ push @address,
+ $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip;
+ push @address, $invoice_data{'country'}
+ if $invoice_data{'country'};
+ push @address, ''
+ while (scalar(@address) < 5);
+
+ $invoice_data{'logo_file'} = $params{'logo_file'}
+ if $params{'logo_file'};
+ $invoice_data{'barcode_file'} = $params{'barcode_file'}
+ if $params{'barcode_file'};
+ $invoice_data{'barcode_img'} = $params{'barcode_img'}
+ if $params{'barcode_img'};
+ $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
+ if $params{'barcode_cid'};
+
+ my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
+# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
+ #my $balance_due = $self->owed + $pr_total - $cr_total;
+ my $balance_due = $self->owed + $pr_total;
+
+ # the customer's current balance as shown on the invoice before this one
+ $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+
+ # the change in balance from that invoice to this one
+ $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
+
+ # the sum of amount owed on all previous invoices
+ $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
+ # the sum of amount owed on all invoices
+ $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
+
+ # info from customer's last invoice before this one, for some
+ # summary formats
+ $invoice_data{'last_bill'} = {};
+ my $last_bill = $pr_cust_bill[-1];
+ if ( $last_bill ) {
+ $invoice_data{'last_bill'} = {
+ '_date' => $last_bill->_date, #unformatted
+ # all we need for now
+ };
+ }
+
+ my $summarypage = '';
+ if ( $conf->exists('invoice_usesummary', $agentnum) ) {
+ $summarypage = 1;
+ }
+ $invoice_data{'summarypage'} = $summarypage;
+
+ warn "$me substituting variables in notes, footer, smallfooter\n"
+ if $DEBUG > 1;
+
+ my @include = (qw( notes footer smallfooter ));
+ push @include, 'coupon' unless $params{'no_coupon'};
+ foreach my $include (@include) {
+
+ my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+ my @inc_src;
+
+ if ( $conf->exists($inc_file, $agentnum)
+ && length( $conf->config($inc_file, $agentnum) ) ) {
+
+ @inc_src = $conf->config($inc_file, $agentnum);
+
+ } else {
+
+ $inc_file = $conf->key_orbase("invoice_latex$include", $template);
+
+ my $convert_map = $convert_maps{$format}{$include};
+
+ @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+ s/--\@\]/$delimiters{$format}[1]/g;
+ $_;
+ }
+ &$convert_map( $conf->config($inc_file, $agentnum) );
+
+ }
+
+ my $inc_tt = new Text::Template (
+ TYPE => 'ARRAY',
+ SOURCE => [ map "$_\n", @inc_src ],
+ DELIMITERS => $delimiters{$format},
+ ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+ unless ( $inc_tt->compile() ) {
+ my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+ warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+ die $error;
+ }
+
+ $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+ $invoice_data{$include} =~ s/\n+$//
+ if ($format eq 'latex');
+ }
+
+ # let invoices use either of these as needed
+ $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL')
+ ? $cust_main->payinfo : '';
+ $invoice_data{'po_line'} =
+ ( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
+ ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
+ : $nbsp;
+
+ my %money_chars = ( 'latex' => '',
+ 'html' => $conf->config('money_char') || '$',
+ 'template' => '',
+ );
+ my $money_char = $money_chars{$format};
+
+ my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
+ 'html' => $conf->config('money_char') || '$',
+ 'template' => '',
+ );
+ my $other_money_char = $other_money_chars{$format};
+ $invoice_data{'dollar'} = $other_money_char;
+
+ my @detail_items = ();
+ my @total_items = ();
+ my @buf = ();
+ my @sections = ();
+
+ $invoice_data{'detail_items'} = \@detail_items;
+ $invoice_data{'total_items'} = \@total_items;
+ $invoice_data{'buf'} = \@buf;
+ $invoice_data{'sections'} = \@sections;
+
+ warn "$me generating sections\n"
+ if $DEBUG > 1;
+
+ my $previous_section = { 'description' => $self->mt('Previous Charges'),
+ 'subtotal' => $other_money_char.
+ sprintf('%.2f', $pr_total),
+ 'summarized' => $summarypage ? 'Y' : '',
+ };
+ $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
+ join(' / ', map { $cust_main->balance_date_range(@$_) }
+ $self->_prior_month30s
+ )
+ if $conf->exists('invoice_include_aging');
+
+ my $taxtotal = 0;
+ my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
+ 'subtotal' => $taxtotal, # adjusted below
+ 'summarized' => $summarypage ? 'Y' : '',
+ };
+ my $tax_weight = _pkg_category($tax_section->{description})
+ ? _pkg_category($tax_section->{description})->weight
+ : 0;
+ $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+ $tax_section->{'sort_weight'} = $tax_weight;
+
+
+ my $adjusttotal = 0;
+ my $adjust_section = { 'description' =>
+ $self->mt('Credits, Payments, and Adjustments'),
+ 'subtotal' => 0, # adjusted below
+ 'summarized' => $summarypage ? 'Y' : '',
+ };
+ my $adjust_weight = _pkg_category($adjust_section->{description})
+ ? _pkg_category($adjust_section->{description})->weight
+ : 0;
+ $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+ $adjust_section->{'sort_weight'} = $adjust_weight;
+
+ my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
+ my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+ $invoice_data{'multisection'} = $multisection;
+ my $late_sections = [];
+ my $extra_sections = [];
+ my $extra_lines = ();
+ if ( $multisection ) {
+ ($extra_sections, $extra_lines) =
+ $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
+ if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
+
+ push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
+
+ push @detail_items, @$extra_lines if $extra_lines;
+ push @sections,
+ $self->_items_sections( $late_sections, # this could stand a refactor
+ $summarypage,
+ $escape_function_nonbsp,
+ $extra_sections,
+ $format, #bah
+ );
+ if ($conf->exists('svc_phone_sections')) {
+ my ($phone_sections, $phone_lines) =
+ $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
+ push @{$late_sections}, @$phone_sections;
+ push @detail_items, @$phone_lines;
+ }
+ if ($conf->exists('voip-cust_accountcode_cdr') && $cust_main->accountcode_cdr) {
+ my ($accountcode_section, $accountcode_lines) =
+ $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
+ if ( scalar(@$accountcode_lines) ) {
+ push @{$late_sections}, $accountcode_section;
+ push @detail_items, @$accountcode_lines;
+ }
+ }
+ } else {# not multisection
+ # make a default section
+ push @sections, { 'description' => '', 'subtotal' => '' };
+ # and calculate the finance charge total, since it won't get done otherwise.
+ # XXX possibly other totals?
+ # XXX possibly finance_pkgclass should not be used in this manner?
+ if ( $conf->exists('finance_pkgclass') ) {
+ my @finance_charges;
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ if ( grep { $_->section eq $invoice_data{finance_section} }
+ $cust_bill_pkg->cust_bill_pkg_display ) {
+ # I think these are always setup fees, but just to be sure...
+ push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
+ }
+ }
+ $invoice_data{finance_amount} =
+ sprintf('%.2f', sum( @finance_charges ) || 0);
+ }
+ }
+
+ unless ( $conf->exists('disable_previous_balance')
+ || $conf->exists('previous_balance-summary_only')
+ )
+ {
+
+ warn "$me adding previous balances\n"
+ if $DEBUG > 1;
+
+ foreach my $line_item ( $self->_items_previous ) {
+
+ my $detail = {
+ ext_description => [],
+ };
+ $detail->{'ref'} = $line_item->{'pkgnum'};
+ $detail->{'quantity'} = 1;
+ $detail->{'section'} = $previous_section;
+ $detail->{'description'} = &$escape_function($line_item->{'description'});
+ if ( exists $line_item->{'ext_description'} ) {
+ @{$detail->{'ext_description'}} = map {
+ &$escape_function($_);
+ } @{$line_item->{'ext_description'}};
+ }
+ $detail->{'amount'} = ( $old_latex ? '' : $money_char).
+ $line_item->{'amount'};
+ $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+ push @detail_items, $detail;
+ push @buf, [ $detail->{'description'},
+ $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+ ];
+ }
+
+ }
+
+ if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+ push @buf, ['','-----------'];
+ push @buf, [ $self->mt('Total Previous Balance'),
+ $money_char. sprintf("%10.2f", $pr_total) ];
+ push @buf, ['',''];
+ }
+
+ if ( $conf->exists('svc_phone-did-summary') ) {
+ warn "$me adding DID summary\n"
+ if $DEBUG > 1;
+
+ my ($didsummary,$minutes) = $self->_did_summary;
+ my $didsummary_desc = 'DID Activity Summary (since last invoice)';
+ push @detail_items,
+ { 'description' => $didsummary_desc,
+ 'ext_description' => [ $didsummary, $minutes ],
+ };
+ }
+
+ foreach my $section (@sections, @$late_sections) {
+
+ warn "$me adding section \n". Dumper($section)
+ if $DEBUG > 1;
+
+ # begin some normalization
+ $section->{'subtotal'} = $section->{'amount'}
+ if $multisection
+ && !exists($section->{subtotal})
+ && exists($section->{amount});
+
+ $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
+ if ( $invoice_data{finance_section} &&
+ $section->{'description'} eq $invoice_data{finance_section} );
+
+ $section->{'subtotal'} = $other_money_char.
+ sprintf('%.2f', $section->{'subtotal'})
+ if $multisection;
+
+ # continue some normalization
+ $section->{'amount'} = $section->{'subtotal'}
+ if $multisection;
+
+
+ if ( $section->{'description'} ) {
+ push @buf, ( [ &$escape_function($section->{'description'}), '' ],
+ [ '', '' ],
+ );
+ }
+
+ warn "$me setting options\n"
+ if $DEBUG > 1;
+
+ my $multilocation = scalar($cust_main->cust_location); #too expensive?
+ my %options = ();
+ $options{'section'} = $section if $multisection;
+ $options{'format'} = $format;
+ $options{'escape_function'} = $escape_function;
+ $options{'format_function'} = sub { () } unless $unsquelched;
+ $options{'unsquelched'} = $unsquelched;
+ $options{'summary_page'} = $summarypage;
+ $options{'skip_usage'} =
+ scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
+ $options{'multilocation'} = $multilocation;
+ $options{'multisection'} = $multisection;
+
+ warn "$me searching for line items\n"
+ if $DEBUG > 1;
+
+ foreach my $line_item ( $self->_items_pkg(%options) ) {
+
+ warn "$me adding line item $line_item\n"
+ if $DEBUG > 1;
+
+ my $detail = {
+ ext_description => [],
+ };
+ $detail->{'ref'} = $line_item->{'pkgnum'};
+ $detail->{'quantity'} = $line_item->{'quantity'};
+ $detail->{'section'} = $section;
+ $detail->{'description'} = &$escape_function($line_item->{'description'});
+ if ( exists $line_item->{'ext_description'} ) {
+ @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
+ }
+ $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
+ $line_item->{'amount'};
+ $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
+ $line_item->{'unit_amount'};
+ $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+ $detail->{'sdate'} = $line_item->{'sdate'};
+ $detail->{'edate'} = $line_item->{'edate'};
+ $detail->{'seconds'} = $line_item->{'seconds'};
+
+ push @detail_items, $detail;
+ push @buf, ( [ $detail->{'description'},
+ $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+ ],
+ map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+ );
+ }
+
+ if ( $section->{'description'} ) {
+ push @buf, ( ['','-----------'],
+ [ $section->{'description'}. ' sub-total',
+ $section->{'subtotal'} # already formatted this
+ ],
+ [ '', '' ],
+ [ '', '' ],
+ );
+ }
+
+ }
+
+ $invoice_data{current_less_finance} =
+ sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
+
+ if ( $multisection && !$conf->exists('disable_previous_balance')
+ || $conf->exists('previous_balance-summary_only') )
+ {
+ unshift @sections, $previous_section if $pr_total;
+ }
+
+ warn "$me adding taxes\n"
+ if $DEBUG > 1;
+
+ foreach my $tax ( $self->_items_tax ) {
+
+ $taxtotal += $tax->{'amount'};
+
+ my $description = &$escape_function( $tax->{'description'} );
+ my $amount = sprintf( '%.2f', $tax->{'amount'} );
+
+ if ( $multisection ) {
+
+ my $money = $old_latex ? '' : $money_char;
+ push @detail_items, {
+ ext_description => [],
+ ref => '',
+ quantity => '',
+ description => $description,
+ amount => $money. $amount,
+ product_code => '',
+ section => $tax_section,
+ };
+
+ } else {
+
+ push @total_items, {
+ 'total_item' => $description,
+ 'total_amount' => $other_money_char. $amount,
+ };
+
+ }
+
+ push @buf,[ $description,
+ $money_char. $amount,
+ ];
+
+ }
+
+ if ( $taxtotal ) {
+ my $total = {};
+ $total->{'total_item'} = $self->mt('Sub-total');
+ $total->{'total_amount'} =
+ $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
+
+ if ( $multisection ) {
+ $tax_section->{'subtotal'} = $other_money_char.
+ sprintf('%.2f', $taxtotal);
+ $tax_section->{'pretotal'} = 'New charges sub-total '.
+ $total->{'total_amount'};
+ push @sections, $tax_section if $taxtotal;
+ }else{
+ unshift @total_items, $total;
+ }
+ }
+ $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
+
+ push @buf,['','-----------'];
+ push @buf,[$self->mt(
+ $conf->exists('disable_previous_balance')
+ ? 'Total Charges'
+ : 'Total New Charges'
+ ),
+ $money_char. sprintf("%10.2f",$self->charged) ];
+ push @buf,['',''];
+
+ {
+ my $total = {};
+ my $item = 'Total';
+ $item = $conf->config('previous_balance-exclude_from_total')
+ || 'Total New Charges'
+ if $conf->exists('previous_balance-exclude_from_total');
+ my $amount = $self->charged +
+ ( $conf->exists('disable_previous_balance') ||
+ $conf->exists('previous_balance-exclude_from_total')
+ ? 0
+ : $pr_total
+ );
+ $total->{'total_item'} = &$embolden_function($self->mt($item));
+ $total->{'total_amount'} =
+ &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) );
+ if ( $multisection ) {
+ if ( $adjust_section->{'sort_weight'} ) {
+ $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+ $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
+ } else {
+ $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+ $other_money_char. sprintf('%.2f', $self->charged );
+ }
+ }else{
+ push @total_items, $total;
+ }
+ push @buf,['','-----------'];
+ push @buf,[$item,
+ $money_char.
+ sprintf( '%10.2f', $amount )
+ ];
+ push @buf,['',''];
+ }
+
+ unless ( $conf->exists('disable_previous_balance') ) {
+ #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+
+ # credits
+ my $credittotal = 0;
+ foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
+
+ my $total;
+ $total->{'total_item'} = &$escape_function($credit->{'description'});
+ $credittotal += $credit->{'amount'};
+ $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'};
+ $adjusttotal += $credit->{'amount'};
+ if ( $multisection ) {
+ my $money = $old_latex ? '' : $money_char;
+ push @detail_items, {
+ ext_description => [],
+ ref => '',
+ quantity => '',
+ description => &$escape_function($credit->{'description'}),
+ amount => $money. $credit->{'amount'},
+ product_code => '',
+ section => $adjust_section,
+ };
+ } else {
+ push @total_items, $total;
+ }
+
+ }
+ $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
+
+ #credits (again)
+ foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
+ push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
+ }
+
+ # payments
+ my $paymenttotal = 0;
+ foreach my $payment ( $self->_items_payments ) {
+ my $total = {};
+ $total->{'total_item'} = &$escape_function($payment->{'description'});
+ $paymenttotal += $payment->{'amount'};
+ $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
+ $adjusttotal += $payment->{'amount'};
+ if ( $multisection ) {
+ my $money = $old_latex ? '' : $money_char;
+ push @detail_items, {
+ ext_description => [],
+ ref => '',
+ quantity => '',
+ description => &$escape_function($payment->{'description'}),
+ amount => $money. $payment->{'amount'},
+ product_code => '',
+ section => $adjust_section,
+ };
+ }else{
+ push @total_items, $total;
+ }
+ push @buf, [ $payment->{'description'},
+ $money_char. sprintf("%10.2f", $payment->{'amount'}),
+ ];
+ }
+ $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
+
+ if ( $multisection ) {
+ $adjust_section->{'subtotal'} = $other_money_char.
+ sprintf('%.2f', $adjusttotal);
+ push @sections, $adjust_section
+ unless $adjust_section->{sort_weight};
+ }
+
+ {
+ my $total;
+ $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
+ $total->{'total_amount'} =
+ &$embolden_function(
+ $other_money_char. sprintf('%.2f', $summarypage
+ ? $self->charged +
+ $self->billing_balance
+ : $self->owed + $pr_total
+ )
+ );
+ if ( $multisection && !$adjust_section->{sort_weight} ) {
+ $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
+ $total->{'total_amount'};
+ }else{
+ push @total_items, $total;
+ }
+ push @buf,['','-----------'];
+ push @buf,[$self->balance_due_msg, $money_char.
+ sprintf("%10.2f", $balance_due ) ];
+ }
+
+ if ( $conf->exists('previous_balance-show_credit')
+ and $cust_main->balance < 0 ) {
+ my $credit_total = {
+ 'total_item' => &$embolden_function($self->credit_balance_msg),
+ 'total_amount' => &$embolden_function(
+ $other_money_char. sprintf('%.2f', -$cust_main->balance)
+ ),
+ };
+ if ( $multisection ) {
+ $adjust_section->{'posttotal'} .= $newline_token .
+ $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
+ }
+ else {
+ push @total_items, $credit_total;
+ }
+ push @buf,['','-----------'];
+ push @buf,[$self->credit_balance_msg, $money_char.
+ sprintf("%10.2f", -$cust_main->balance ) ];
+ }
+ }
+
+ if ( $multisection ) {
+ if ($conf->exists('svc_phone_sections')) {
+ my $total;
+ $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
+ $total->{'total_amount'} =
+ &$embolden_function(
+ $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+ );
+ my $last_section = pop @sections;
+ $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
+ $total->{'total_amount'};
+ push @sections, $last_section;
+ }
+ push @sections, @$late_sections
+ if $unsquelched;
+ }
+
+ my @includelist = ();
+ push @includelist, 'summary' if $summarypage;
+ foreach my $include ( @includelist ) {
+
+ my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+ my @inc_src;
+
+ if ( length( $conf->config($inc_file, $agentnum) ) ) {
+
+ @inc_src = $conf->config($inc_file, $agentnum);
+
+ } else {
+
+ $inc_file = $conf->key_orbase("invoice_latex$include", $template);
+
+ my $convert_map = $convert_maps{$format}{$include};
+
+ @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+ s/--\@\]/$delimiters{$format}[1]/g;
+ $_;
+ }
+ &$convert_map( $conf->config($inc_file, $agentnum) );
+
+ }
+
+ my $inc_tt = new Text::Template (
+ TYPE => 'ARRAY',
+ SOURCE => [ map "$_\n", @inc_src ],
+ DELIMITERS => $delimiters{$format},
+ ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+ unless ( $inc_tt->compile() ) {
+ my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+ warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+ die $error;
+ }
+
+ $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+ $invoice_data{$include} =~ s/\n+$//
+ if ($format eq 'latex');
+ }
+
+ $invoice_lines = 0;
+ my $wasfunc = 0;
+ foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
+ /invoice_lines\((\d*)\)/;
+ $invoice_lines += $1 || scalar(@buf);
+ $wasfunc=1;
+ }
+ die "no invoice_lines() functions in template?"
+ if ( $format eq 'template' && !$wasfunc );
+
+ if ($format eq 'template') {
+
+ if ( $invoice_lines ) {
+ $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
+ $invoice_data{'total_pages'}++
+ if scalar(@buf) % $invoice_lines;
+ }
+
+ #setup subroutine for the template
+ #sub FS::cust_bill::_template::invoice_lines { # good god, no
+ $invoice_data{invoice_lines} = sub { # much better
+ my $lines = shift || scalar(@buf);
+ map {
+ scalar(@buf)
+ ? shift @buf
+ : [ '', '' ];
+ }
+ ( 1 .. $lines );
+ };
+
+ my $lines;
+ my @collect;
+ while (@buf) {
+ push @collect, split("\n",
+ $text_template->fill_in( HASH => \%invoice_data )
+ );
+ $invoice_data{'page'}++;
+ }
+ map "$_\n", @collect;
+ }else{
+ # this is where we actually create the invoice
+ warn "filling in template for invoice ". $self->invnum. "\n"
+ if $DEBUG;
+ warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
+ if $DEBUG > 1;
+
+ $text_template->fill_in(HASH => \%invoice_data);
+ }
+}
+
+# helper routine for generating date ranges
+sub _prior_month30s {
+ my $self = shift;
+ my @ranges = (
+ [ 1, 2592000 ], # 0-30 days ago
+ [ 2592000, 5184000 ], # 30-60 days ago
+ [ 5184000, 7776000 ], # 60-90 days ago
+ [ 7776000, 0 ], # 90+ days ago
+ );
+
+ map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
+ $_->[1] ? $self->_date - $_->[1] - 1 : '',
+ ] }
+ @ranges;
+}
+
+=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
+
+Returns an postscript invoice, as a scalar.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_ps {
+ my $self = shift;
+
+ my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
+ my $ps = generate_ps($file);
+ unlink($logofile);
+ unlink($barcodefile) if $barcodefile;
+
+ $ps;
+}
+
+=item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
+
+Returns an PDF invoice, as a scalar.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_pdf {
+ my $self = shift;
+
+ my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
+ my $pdf = generate_pdf($file);
+ unlink($logofile);
+ unlink($barcodefile) if $barcodefile;
+
+ $pdf;
+}
+
+=item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
+
+Returns an HTML invoice, as a scalar.
+
+I<time> an optional value used to control the printing of overdue messages. The
+default is now. It isn't the date of the invoice; that's the `_date' field.
+It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
+when emailing the invoice as part of a multipart/related MIME email.
+
+=cut
+
+sub print_html {
+ my $self = shift;
+ my %params;
+ if ( ref($_[0]) ) {
+ %params = %{ shift() };
+ }else{
+ $params{'time'} = shift;
+ $params{'template'} = shift;
+ $params{'cid'} = shift;
+ }
+
+ $params{'format'} = 'html';
+
+ $self->print_generic( %params );
+}
+
+# quick subroutine for print_latex
+#
+# There are ten characters that LaTeX treats as special characters, which
+# means that they do not simply typeset themselves:
+# # $ % & ~ _ ^ \ { }
+#
+# TeX ignores blanks following an escaped character; if you want a blank (as
+# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
+
+sub _latex_escape {
+ my $value = shift;
+ $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+ $value =~ s/([<>])/\$$1\$/g;
+ $value;
+}
+
+sub _html_escape {
+ my $value = shift;
+ encode_entities($value);
+ $value;
+}
+
+sub _html_escape_nbsp {
+ my $value = _html_escape(shift);
+ $value =~ s/ +/ /g;
+ $value;
+}
+
+#utility methods for print_*
+
+sub _translate_old_latex_format {
+ warn "_translate_old_latex_format called\n"
+ if $DEBUG;
+
+ my @template = ();
+ while ( @_ ) {
+ my $line = shift;
+
+ if ( $line =~ /^%%Detail\s*$/ ) {
+
+ push @template, q![@--!,
+ q! foreach my $_tr_line (@detail_items) {!,
+ q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
+ q! $_tr_line->{'description'} .= !,
+ q! "\\tabularnewline\n~~".!,
+ q! join( "\\tabularnewline\n~~",!,
+ q! @{$_tr_line->{'ext_description'}}!,
+ q! );!,
+ q! }!;
+
+ while ( ( my $line_item_line = shift )
+ !~ /^%%EndDetail\s*$/ ) {
+ $line_item_line =~ s/'/\\'/g; # nice LTS
+ $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
+ $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
+ push @template, " \$OUT .= '$line_item_line';";
+ }
+
+ push @template, '}',
+ '--@]';
+ #' doh, gvim
+ } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
+
+ push @template, '[@--',
+ ' foreach my $_tr_line (@total_items) {';
+
+ while ( ( my $total_item_line = shift )
+ !~ /^%%EndTotalDetails\s*$/ ) {
+ $total_item_line =~ s/'/\\'/g; # nice LTS
+ $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
+ $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
+ push @template, " \$OUT .= '$total_item_line';";
+ }
+
+ push @template, '}',
+ '--@]';
+
+ } else {
+ $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
+ push @template, $line;
+ }
+
+ }
+
+ if ($DEBUG) {
+ warn "$_\n" foreach @template;
+ }
+
+ (@template);
+}
+
+sub terms {
+ my $self = shift;
+ my $conf = $self->conf;
+
+ #check for an invoice-specific override
+ return $self->invoice_terms if $self->invoice_terms;
+
+ #check for a customer- specific override
+ my $cust_main = $self->cust_main;
+ return $cust_main->invoice_terms if $cust_main->invoice_terms;
+
+ #use configured default
+ $conf->config('invoice_default_terms') || '';
+}
+
+sub due_date {
+ my $self = shift;
+ my $duedate = '';
+ if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+ $duedate = $self->_date() + ( $1 * 86400 );
+ }
+ $duedate;
+}
+
+sub due_date2str {
+ my $self = shift;
+ $self->due_date ? time2str(shift, $self->due_date) : '';
+}
+
+sub balance_due_msg {
+ my $self = shift;
+ my $msg = $self->mt('Balance Due');
+ return $msg unless $self->terms;
+ if ( $self->due_date ) {
+ $msg .= ' - ' . $self->mt('Please pay by'). ' '.
+ $self->due_date2str($date_format);
+ } elsif ( $self->terms ) {
+ $msg .= ' - '. $self->terms;
+ }
+ $msg;
+}
+
+sub balance_due_date {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $duedate = '';
+ if ( $conf->exists('invoice_default_terms')
+ && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
+ $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
+ }
+ $duedate;
+}
+
+sub credit_balance_msg {
+ my $self = shift;
+ $self->mt('Credit Balance Remaining')
+}
+
+=item invnum_date_pretty
+
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)"
+
+=cut
+
+sub invnum_date_pretty {
+ my $self = shift;
+ $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
+}
+
+=item _date_pretty
+
+Returns a string with the date, for example: "3/20/2008"
+
+=cut
+
+sub _date_pretty {
+ my $self = shift;
+ time2str($date_format, $self->_date);
+}
+
+=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
+
+Generate section information for all items appearing on this invoice.
+This will only be called for multi-section invoices.
+
+For each line item (L<FS::cust_bill_pkg> record), this will fetch all
+related display records (L<FS::cust_bill_pkg_display>) and organize
+them into two groups ("early" and "late" according to whether they come
+before or after the total), then into sections. A subtotal is calculated
+for each section.
+
+Section descriptions are returned in sort weight order. Each consists
+of a hash containing:
+
+description: the package category name, escaped
+subtotal: the total charges in that section
+tax_section: a flag indicating that the section contains only tax charges
+summarized: same as tax_section, for some reason
+sort_weight: the package category's sort weight
+
+If 'condense' is set on the display record, it also contains everything
+returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
+coderefs to generate parts of the invoice. This is not advised.
+
+Arguments:
+
+LATE: an arrayref to push the "late" section hashes onto. The "early"
+group is simply returned from the method.
+
+SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+Turning this on has the following effects:
+- Ignores display items with the 'summary' flag.
+- Combines all items into the "early" group.
+- Creates sections for all non-disabled package categories, even if they
+have no charges on this invoice, as well as a section with no name.
+
+ESCAPE: an escape function to use for section titles.
+
+EXTRA_SECTIONS: an arrayref of additional sections to return after the
+sorted list. If there are any of these, section subtotals exclude
+usage charges.
+
+FORMAT: 'latex', 'html', or 'template' (i.e. text). Not used, but
+passed through to C<_condense_section()>.
+
+=cut
+
+use vars qw(%pkg_category_cache);
+sub _items_sections {
+ my $self = shift;
+ my $late = shift;
+ my $summarypage = shift;
+ my $escape = shift;
+ my $extra_sections = shift;
+ my $format = shift;
+
+ my %subtotal = ();
+ my %late_subtotal = ();
+ my %not_tax = ();
+
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+ {
+
+ my $usage = $cust_bill_pkg->usage;
+
+ foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
+ next if ( $display->summary && $summarypage );
+
+ my $section = $display->section;
+ my $type = $display->type;
+
+ $not_tax{$section} = 1
+ unless $cust_bill_pkg->pkgnum == 0;
+
+ if ( $display->post_total && !$summarypage ) {
+ if (! $type || $type eq 'S') {
+ $late_subtotal{$section} += $cust_bill_pkg->setup
+ if $cust_bill_pkg->setup != 0;
+ }
+
+ if (! $type) {
+ $late_subtotal{$section} += $cust_bill_pkg->recur
+ if $cust_bill_pkg->recur != 0;
+ }
+
+ if ($type && $type eq 'R') {
+ $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
+ if $cust_bill_pkg->recur != 0;
+ }
+
+ if ($type && $type eq 'U') {
+ $late_subtotal{$section} += $usage
+ unless scalar(@$extra_sections);
+ }
+
+ } else {
+
+ next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+
+ if (! $type || $type eq 'S') {
+ $subtotal{$section} += $cust_bill_pkg->setup
+ if $cust_bill_pkg->setup != 0;
+ }
+
+ if (! $type) {
+ $subtotal{$section} += $cust_bill_pkg->recur
+ if $cust_bill_pkg->recur != 0;
+ }
+
+ if ($type && $type eq 'R') {
+ $subtotal{$section} += $cust_bill_pkg->recur - $usage
+ if $cust_bill_pkg->recur != 0;
+ }
+
+ if ($type && $type eq 'U') {
+ $subtotal{$section} += $usage
+ unless scalar(@$extra_sections);
+ }
+
+ }
+
+ }
+
+ }
+
+ %pkg_category_cache = ();
+
+ push @$late, map { { 'description' => &{$escape}($_),
+ 'subtotal' => $late_subtotal{$_},
+ 'post_total' => 1,
+ 'sort_weight' => ( _pkg_category($_)
+ ? _pkg_category($_)->weight
+ : 0
+ ),
+ ((_pkg_category($_) && _pkg_category($_)->condense)
+ ? $self->_condense_section($format)
+ : ()
+ ),
+ } }
+ sort _sectionsort keys %late_subtotal;
+
+ my @sections;
+ if ( $summarypage ) {
+ @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
+ map { $_->categoryname } qsearch('pkg_category', {});
+ push @sections, '' if exists($subtotal{''});
+ } else {
+ @sections = keys %subtotal;
+ }
+
+ my @early = map { { 'description' => &{$escape}($_),
+ 'subtotal' => $subtotal{$_},
+ 'summarized' => $not_tax{$_} ? '' : 'Y',
+ 'tax_section' => $not_tax{$_} ? '' : 'Y',
+ 'sort_weight' => ( _pkg_category($_)
+ ? _pkg_category($_)->weight
+ : 0
+ ),
+ ((_pkg_category($_) && _pkg_category($_)->condense)
+ ? $self->_condense_section($format)
+ : ()
+ ),
+ }
+ } @sections;
+ push @early, @$extra_sections if $extra_sections;
+
+ sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
+
+}
+
+#helper subs for above
+
+sub _sectionsort {
+ _pkg_category($a)->weight <=> _pkg_category($b)->weight;
+}
+
+sub _pkg_category {
+ my $categoryname = shift;
+ $pkg_category_cache{$categoryname} ||=
+ qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
+}
+
+my %condensed_format = (
+ 'label' => [ qw( Description Qty Amount ) ],
+ 'fields' => [
+ sub { shift->{description} },
+ sub { shift->{quantity} },
+ sub { my($href, %opt) = @_;
+ ($opt{dollar} || ''). $href->{amount};
+ },
+ ],
+ 'align' => [ qw( l r r ) ],
+ 'span' => [ qw( 5 1 1 ) ], # unitprices?
+ 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
+);
+
+sub _condense_section {
+ my ( $self, $format ) = ( shift, shift );
+ ( 'condensed' => 1,
+ map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
+ qw( description_generator
+ header_generator
+ total_generator
+ total_line_generator
+ )
+ );
+}
+
+sub _condensed_generator_defaults {
+ my ( $self, $format ) = ( shift, shift );
+ return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
+}
+
+my %html_align = (
+ 'c' => 'center',
+ 'l' => 'left',
+ 'r' => 'right',
+);
+
+sub _condensed_header_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
+ $suffix = "\\\\\n\\hline";
+ $separator = "&\n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ } elsif ( $format eq 'html' ) {
+ $prefix = '<th></th>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<th align="$html_align{$a}">$d</th>!;
+ };
+ }
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
+ }
+
+ $prefix. join($separator, @result). $suffix;
+ };
+
+}
+
+sub _condensed_description_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+
+ my $money_char = '$';
+ if ($format eq 'latex') {
+ $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
+ $suffix = '\\\\';
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+ };
+ $money_char = '\\dollar';
+ }elsif ( $format eq 'html' ) {
+ $prefix = '"><td align="center"></td>';
+ $suffix = '';
+ $separator = '';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}">$d</td>!;
+ };
+ #$money_char = $conf->config('money_char') || '$';
+ $money_char = ''; # this is madness
+ }
+
+ sub {
+ #my @args = @_;
+ my $href = shift;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ my $dollar = '';
+ $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+sub _condensed_total_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ # my $r = &{$f->{fields}->[$i]}(@args);
+ # $r .= ' Total' unless $i;
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+=item total_line_generator FORMAT
+
+Returns a coderef used for generation of invoice total line items for this
+usage_class. FORMAT is either html or latex
+
+=cut
+
+# should not be used: will have issues with hash element names (description vs
+# total_item and amount vs total_amount -- another array of functions?
+
+sub _condensed_total_line_generator {
+ my ( $self, $format ) = ( shift, shift );
+
+ my ( $f, $prefix, $suffix, $separator, $column ) =
+ _condensed_generator_defaults($format);
+ my $style = '';
+
+ if ($format eq 'latex') {
+ $prefix = "& ";
+ $suffix = "\\\\\n";
+ $separator = " & \n";
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+ };
+ }elsif ( $format eq 'html' ) {
+ $prefix = '';
+ $suffix = '';
+ $separator = '';
+ $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+ $column =
+ sub { my ($d,$a,$s,$w) = @_;
+ return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+ };
+ }
+
+
+ sub {
+ my @args = @_;
+ my @result = ();
+
+ foreach (my $i = 0; $f->{label}->[$i]; $i++) {
+ push @result,
+ &{$column}( &{$f->{fields}->[$i]}(@args),
+ map { $f->{$_}->[$i] } qw(align span width)
+ );
+ }
+
+ $prefix. join( $separator, @result ). $suffix;
+ };
+
+}
+
+#sub _items_extra_usage_sections {
+# my $self = shift;
+# my $escape = shift;
+#
+# my %sections = ();
+#
+# my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {});
+# foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+# {
+# next unless $cust_bill_pkg->pkgnum > 0;
+#
+# foreach my $section ( keys %usage_class ) {
+#
+# my $usage = $cust_bill_pkg->usage($section);
+#
+# next unless $usage && $usage > 0;
+#
+# $sections{$section} ||= 0;
+# $sections{$section} += $usage;
+#
+# }
+#
+# }
+#
+# map { { 'description' => &{$escape}($_),
+# 'subtotal' => $sections{$_},
+# 'summarized' => '',
+# 'tax_section' => '',
+# }
+# }
+# sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
+#
+#}
+
+sub _items_extra_usage_sections {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $escape = shift;
+ my $format = shift;
+
+ my %sections = ();
+ my %classnums = ();
+ my %lines = ();
+
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+
+ my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ next unless $cust_bill_pkg->pkgnum > 0;
+
+ foreach my $classnum ( keys %usage_class ) {
+ my $section = $usage_class{$classnum}->classname;
+ $classnums{$section} = $classnum;
+
+ foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
+ my $amount = $detail->amount;
+ next unless $amount && $amount > 0;
+
+ $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
+ $sections{$section}{amount} += $amount; #subtotal
+ $sections{$section}{calls}++;
+ $sections{$section}{duration} += $detail->duration;
+
+ my $desc = $detail->regionname;
+ my $description = $desc;
+ $description = substr($desc, 0, $maxlength). '...'
+ if $format eq 'latex' && length($desc) > $maxlength;
+
+ $lines{$section}{$desc} ||= {
+ description => &{$escape}($description),
+ #pkgpart => $part_pkg->pkgpart,
+ pkgnum => $cust_bill_pkg->pkgnum,
+ ref => '',
+ amount => 0,
+ calls => 0,
+ duration => 0,
+ #unit_amount => $cust_bill_pkg->unitrecur,
+ quantity => $cust_bill_pkg->quantity,
+ product_code => 'N/A',
+ ext_description => [],
+ };
+
+ $lines{$section}{$desc}{amount} += $amount;
+ $lines{$section}{$desc}{calls}++;
+ $lines{$section}{$desc}{duration} += $detail->duration;