package FS::cust_bill;
use strict;
-use vars qw( @ISA $DEBUG $conf $money_char );
+use vars qw( @ISA $DEBUG $me $conf $money_char );
use vars qw( $invoice_lines @buf ); #yuck
+use Fcntl qw(:flock); #for spool_csv
+use List::Util qw(min max);
use Date::Format;
use Text::Template 1.20;
use File::Temp 0.14;
use HTML::Entities;
use Locale::Country;
use FS::UID qw( datasrc );
-use FS::Record qw( qsearch qsearchs );
-use FS::Misc qw( send_email send_fax );
+use FS::Misc qw( send_email send_fax generate_ps do_print );
+use FS::Record qw( qsearch qsearchs dbh );
+use FS::cust_main_Mixin;
use FS::cust_main;
use FS::cust_bill_pkg;
use FS::cust_credit;
use FS::cust_pay;
use FS::cust_pkg;
use FS::cust_credit_bill;
+use FS::pay_batch;
use FS::cust_pay_batch;
use FS::cust_bill_event;
+use FS::cust_event;
use FS::part_pkg;
use FS::cust_bill_pay;
+use FS::cust_bill_pay_batch;
use FS::part_bill_event;
+use FS::payby;
-@ISA = qw( FS::Record );
+@ISA = qw( FS::cust_main_Mixin FS::Record );
$DEBUG = 0;
+$me = '[FS::cust_bill]';
#ask FS::UID to run this stuff for us later
FS::UID->install_callback( sub {
sub table { 'cust_bill'; }
+sub cust_linked { $_[0]->cust_main_custnum; }
+sub cust_unlinked_msg {
+ my $self = shift;
+ "WARNING: can't find cust_main.custnum ". $self->custnum.
+ ' (cust_bill.invnum '. $self->invnum. ')';
+}
+
=item insert
Adds this invoice to the database ("Posts" the invoice). If there is an error,
=item delete
-Currently unimplemented. I don't remove invoices because there would then be
-no record you ever posted this invoice (which is bad, no?)
+This method now works but you probably shouldn't use it. Instead, apply a
+credit against the invoice.
+
+Using this method to delete invoices outright is really, really bad. There
+would be no record you ever posted this invoice, and there are no check to
+make sure charged = 0 or that there are no associated cust_bill_pkg records.
+
+Really, don't use it.
=cut
=cut
-sub replace {
+#replace can be inherited from Record.pm
+
+# replace_check is now the preferred way to #implement replace data checks
+# (so $object->replace() works without an argument)
+
+sub replace_check {
my( $new, $old ) = ( shift, shift );
return "Can't change custnum!" unless $old->custnum == $new->custnum;
#return "Can't change _date!" unless $old->_date eq $new->_date;
return "Can't change _date!" unless $old->_date == $new->_date;
- return "Can't change charged!" unless $old->charged == $new->charged;
+ return "Can't change charged!" unless $old->charged == $new->charged
+ || $old->charged == 0;
- $new->SUPER::replace($old);
+ '';
}
=item check
qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } );
}
+=item cust_pkg
+
+Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
+this invoice.
+
+=cut
+
+sub cust_pkg {
+ my $self = shift;
+ my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg;
+ my %saw = ();
+ grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
+}
+
+=item open_cust_bill_pkg
+
+Returns the open line items for this invoice.
+
+Note that cust_bill_pkg with both setup and recur fees are returned as two
+separate line items, each with only one fee.
+
+=cut
+
+# modeled after cust_main::open_cust_bill
+sub open_cust_bill_pkg {
+ my $self = shift;
+
+ # grep { $_->owed > 0 } $self->cust_bill_pkg
+
+ my %other = ( 'recur' => 'setup',
+ 'setup' => 'recur', );
+ my @open = ();
+ foreach my $field ( qw( recur setup )) {
+ push @open, map { $_->set( $other{$field}, 0 ); $_; }
+ grep { $_->owed($field) > 0 }
+ $self->cust_bill_pkg;
+ }
+
+ @open;
+}
+
=item cust_bill_event
-Returns the completed invoice events (see L<FS::cust_bill_event>) for this
-invoice.
+Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
=cut
qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
}
+=item num_cust_bill_event
+
+Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
+
+=cut
+
+sub num_cust_bill_event {
+ my $self = shift;
+ my $sql =
+ "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
+ my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
+ $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
+ $sth->fetchrow_arrayref->[0];
+}
+
+=item cust_event
+
+Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
+
+=cut
+
+#false laziness w/cust_pkg.pm
+sub cust_event {
+ my $self = shift;
+ qsearch({
+ 'table' => 'cust_event',
+ 'addl_from' => 'JOIN part_event USING ( eventpart )',
+ 'hashref' => { 'tablenum' => $self->invnum },
+ 'extra_sql' => " AND eventtable = 'cust_bill' ",
+ });
+}
+
+=item num_cust_event
+
+Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
+
+=cut
+
+#false laziness w/cust_pkg.pm
+sub num_cust_event {
+ my $self = shift;
+ my $sql =
+ "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
+ " WHERE tablenum = ? AND eventtable = 'cust_bill'";
+ my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
+ $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
+ $sth->fetchrow_arrayref->[0];
+}
=item cust_main
qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
}
+=item cust_suspend_if_balance_over AMOUNT
+
+Suspends the customer associated with this invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
+
+Returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cust_suspend_if_balance_over {
+ my( $self, $amount ) = ( shift, shift );
+ my $cust_main = $self->cust_main;
+ if ( $cust_main->total_owed_date($self->_date) < $amount ) {
+ return ();
+ } else {
+ $cust_main->suspend(@_);
+ }
+}
+
=item cust_credit
Depreciated. See the cust_credited method.
$balance;
}
+=item apply_payments_and_credits
+
+=cut
+
+sub apply_payments_and_credits {
+ my $self = shift;
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
+ local $SIG{TERM} = 'IGNORE';
+ local $SIG{TSTP} = 'IGNORE';
+ local $SIG{PIPE} = 'IGNORE';
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ $self->select_for_update; #mutex
+
+ my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
+ my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit;
+
+ while ( $self->owed > 0 and ( @payments || @credits ) ) {
+
+ my $app = '';
+ if ( @payments && @credits ) {
+
+ #decide which goes first by weight of top (unapplied) line item
+
+ my @open_lineitems = $self->open_cust_bill_pkg;
+
+ my $max_pay_weight =
+ max( map { $_->part_pkg->pay_weight || 0 }
+ grep { $_ }
+ map { $_->cust_pkg }
+ @open_lineitems
+ );
+ my $max_credit_weight =
+ max( map { $_->part_pkg->credit_weight || 0 }
+ grep { $_ }
+ map { $_->cust_pkg }
+ @open_lineitems
+ );
+
+ #if both are the same... payments first? it has to be something
+ if ( $max_pay_weight >= $max_credit_weight ) {
+ $app = 'pay';
+ } else {
+ $app = 'credit';
+ }
+
+ } elsif ( @payments ) {
+ $app = 'pay';
+ } elsif ( @credits ) {
+ $app = 'credit';
+ } else {
+ die "guru meditation #12 and 35";
+ }
+
+ if ( $app eq 'pay' ) {
+
+ my $payment = shift @payments;
+
+ $app = new FS::cust_bill_pay {
+ 'paynum' => $payment->paynum,
+ 'amount' => sprintf('%.2f', min( $payment->unapplied, $self->owed ) ),
+ };
+
+ } elsif ( $app eq 'credit' ) {
+
+ my $credit = shift @credits;
+
+ $app = new FS::cust_credit_bill {
+ 'crednum' => $credit->crednum,
+ 'amount' => sprintf('%.2f', min( $credit->credited, $self->owed ) ),
+ };
+
+ } else {
+ die "guru meditation #12 and 35";
+ }
+
+ $app->invnum( $self->invnum );
+
+ my $error = $app->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Error inserting ". $app->table. " record: $error";
+ }
+ die $error if $error;
+
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ ''; #no error
+
+}
=item generate_email PARAMHASH
'subject' => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
);
- if (ref($args{'to'} eq 'ARRAY')) {
+ if (ref($args{'to'}) eq 'ARRAY') {
$return{'to'} = $args{'to'};
} else {
$return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
'Disposition' => 'inline',
);
- $args{'from'} =~ /\@([\w\.\-]+)/ or $1 = 'example.com';
- my $content_id = join('.', rand()*(2**32), $$, time). "\@$1";
+ $args{'from'} =~ /\@([\w\.\-]+)/;
+ my $from = $1 || 'example.com';
+ my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
my $file;
- if ( [ -e "$path/logo_". $args{'_template'}. ".png" ] ) {
- $file = "$path/logo_". $args{'_template'}. ".png";
+ if ( defined($args{'template'}) && length($args{'template'})
+ && -e "$path/logo_". $args{'template'}. ".png"
+ )
+ {
+ $file = "$path/logo_". $args{'template'}. ".png";
} else {
$file = "$path/logo.png";
}
=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
-Sends this invoice to the destinations configured for this customer: send
-emails or print. See L<FS::cust_main_invoice>.
+Sends this invoice to the destinations configured for this customer: sends
+email, prints and/or faxes. See L<FS::cust_main_invoice>.
TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
=cut
+sub queueable_send {
+ my %opt = @_;
+
+ my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+ or die "invalid invoice number: " . $opt{invnum};
+
+ my @args = ( $opt{template}, $opt{agentnum} );
+ push @args, $opt{invoice_from}
+ if exists($opt{invoice_from}) && $opt{invoice_from};
+
+ my $error = $self->send( @args );
+ die $error if $error;
+
+}
+
sub send {
my $self = shift;
my $template = scalar(@_) ? shift : '';
=cut
+sub queueable_email {
+ my %opt = @_;
+
+ my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+ or die "invalid invoice number: " . $opt{invnum};
+
+ my @args = ( $opt{template} );
+ push @args, $opt{invoice_from}
+ if exists($opt{invoice_from}) && $opt{invoice_from};
+
+ my $error = $self->email( @args );
+ die $error if $error;
+
+}
+
sub email {
my $self = shift;
my $template = scalar(@_) ? shift : '';
=item lpr_data [ TEMPLATENAME ]
-Returns the postscript or plaintext for this invoice.
+Returns the postscript or plaintext for this invoice as an arrayref.
TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
my $self = shift;
my $template = scalar(@_) ? shift : '';
- my $lpr = $conf->config('lpr');
- open(LPR, "|$lpr")
- or die "Can't open pipe to $lpr: $!\n";
- print LPR @{ $self->lpr_data($template) };
- close LPR
- or die $! ? "Error closing $lpr: $!\n"
- : "Exit status $? from $lpr\n";
+ do_print $self->lpr_data($template);
}
=item fax [ TEMPLATENAME ]
$self->send(@_);
}
-=item send_csv OPTIONS
+=item send_csv OPTION => VALUE, ...
Sends invoice as a CSV data-file to a remote host with the specified protocol.
The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
and YYMMDDHHMMSS is a timestamp.
-The fields of the CSV file is as follows:
+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
=item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
-If B<record_type> is C<cust_bill>, this is a primary invoice record. The
+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.
-If B<record_type> is C<cust_bill_pkg>, this is a line item record. Only the
-first two fields (B<record_type> and B<invnum>) and the last five fields
-(B<pkg> through B<edate>) 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
=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 send_csv {
+sub print_csv {
my($self, %opt) = @_;
+
+ eval "use Text::CSV_XS";
+ die $@ if $@;
- #part one: create file
+ my $cust_main = $self->cust_main;
- my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
- mkdir $spooldir, 0700 unless -d $spooldir;
+ my $csv = Text::CSV_XS->new({'always_quote'=>1});
- my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
+ if ( lc($opt{'format'}) eq 'billco' ) {
- open(CSV, ">$file") or die "can't open $file: $!";
+ my $taxtotal = 0;
+ $taxtotal += $_->{'amount'} foreach $self->_items_tax;
- eval "use Text::CSV_XS";
- die $@ if $@;
+ my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
- my $csv = Text::CSV_XS->new({'always_quote'=>1});
+ my( $previous_balance, @unused ) = $self->previous; #previous balance
- my $cust_main = $self->cust_main;
+ my $pmt_cr_applied = 0;
+ $pmt_cr_applied += $_->{'amount'}
+ foreach ( $self->_items_payments, $self->_items_credits ) ;
- $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";
- print CSV $csv->string. "\n";
-
- #new charges (false laziness w/print_text)
- 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->cust_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 )
- : '' ),
- time2str("%x", $cust_bill_pkg->sdate),
- time2str("%x", $cust_bill_pkg->edate),
- );
+ my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
- } else { #pkgnum tax
- next unless $cust_bill_pkg->setup != 0;
- my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
- ? ( $cust_bill_pkg->itemdesc || 'Tax' )
- : 'Tax';
- ($pkg, $setup, $recur, $sdate, $edate) =
- ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
- }
+ $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_pkg',
+ 'cust_bill',
$self->invnum,
- ( map { '' } (1..11) ),
- ($pkg, $setup, $recur, $sdate, $edate)
+ $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";
- print CSV $csv->string. "\n";
-
}
- close CSV or die "can't close 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
+ );
- #part two: upload it
+ $detail .= $csv->string. "\n";
- 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: $@";
+ } else {
- $net->binary or die "can't set binary mode";
+ 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->cust_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;
+ my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
+ ? ( $cust_bill_pkg->itemdesc || 'Tax' )
+ : 'Tax';
+ ($pkg, $setup, $recur, $sdate, $edate) =
+ ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
+ }
+
+ $csv->combine(
+ 'cust_bill_pkg',
+ $self->invnum,
+ ( map { '' } (1..11) ),
+ ($pkg, $setup, $recur, $sdate, $edate)
+ ) or die "can't create csv";
- $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
+ $detail .= $csv->string. "\n";
- $net->put($file) or die "can't put $file: $!";
+ }
- $net->quit;
+ }
- unlink $file;
+ ( $header, $detail );
}
}
-=item batch_card
+=item batch_card OPTION => VALUE...
Adds a payment for this invoice to the pending credit card batch (see
-L<FS::cust_pay_batch>).
+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 = shift;
+ my ($self, %options) = @_;
my $cust_main = $self->cust_main;
- my $cust_pay_batch = new FS::cust_pay_batch ( {
- 'invnum' => $self->getfield('invnum'),
- 'custnum' => $cust_main->getfield('custnum'),
- 'last' => $cust_main->getfield('last'),
- 'first' => $cust_main->getfield('first'),
- 'address1' => $cust_main->getfield('address1'),
- 'address2' => $cust_main->getfield('address2'),
- 'city' => $cust_main->getfield('city'),
- 'state' => $cust_main->getfield('state'),
- 'zip' => $cust_main->getfield('zip'),
- 'country' => $cust_main->getfield('country'),
- 'cardnum' => $cust_main->payinfo,
- 'exp' => $cust_main->getfield('paydate'),
- 'payname' => $cust_main->getfield('payname'),
- 'amount' => $self->owed,
- } );
- my $error = $cust_pay_batch->insert;
- die $error if $error;
-
- '';
+ $options{invnum} = $self->invnum;
+
+ $cust_main->batch_card(%options);
}
sub _agent_template {
my $self = shift;
- $self->_agent_plandata('agent_templatename');
+ $self->cust_main->agent_template;
}
sub _agent_invoice_from {
my $self = shift;
- $self->_agent_plandata('agent_invoice_from');
-}
-
-sub _agent_plandata {
- my( $self, $option ) = @_;
-
- my $part_bill_event = qsearchs( 'part_bill_event',
- {
- 'payby' => $self->cust_main->payby,
- 'plan' => 'send_agent',
- 'plandata' => { 'op' => '~',
- 'value' => "(^|\n)agentnum ".
- '([0-9]*, )*'.
- $self->cust_main->agentnum.
- '(, [0-9]*)*'.
- "(\n|\$)",
- },
- },
- '',
- 'ORDER BY seconds LIMIT 1'
- );
-
- return '' unless $part_bill_event;
-
- if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
- return $1;
- } else {
- warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
- " plandata for $option";
- return '';
- }
-
+ $self->cust_main->agent_invoice_from;
}
=item print_text [ TIME [ , TEMPLATE ] ]
=cut
-#still some false laziness w/print_text
+#still some false laziness w/_items stuff (and send_csv)
sub print_text {
my( $self, $today, $template ) = @_;
( grep { ! $_->pkgnum } $self->cust_bill_pkg ), #then taxes
) {
- if ( $cust_bill_pkg->pkgnum > 0 ) {
+ my $desc = $cust_bill_pkg->desc;
- my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
- my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
- my $pkg = $part_pkg->pkg;
+ if ( $cust_bill_pkg->pkgnum > 0 ) {
if ( $cust_bill_pkg->setup != 0 ) {
- my $description = $pkg;
+ my $description = $desc;
$description .= ' Setup' if $cust_bill_pkg->recur != 0;
push @buf, [ $description,
$money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
push @buf,
map { [ " ". $_->[0]. ": ". $_->[1], '' ] }
- $cust_pkg->h_labels($self->_date);
+ $cust_bill_pkg->cust_pkg->h_labels($self->_date);
}
if ( $cust_bill_pkg->recur != 0 ) {
push @buf, [
- "$pkg (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
- time2str("%x", $cust_bill_pkg->edate) . ")",
+ $desc .
+ ( $conf->exists('disable_line_item_date_ranges')
+ ? ''
+ : " (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
+ time2str("%x", $cust_bill_pkg->edate) . ")"
+ ),
$money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
];
push @buf,
map { [ " ". $_->[0]. ": ". $_->[1], '' ] }
- $cust_pkg->h_labels($cust_bill_pkg->edate, $cust_bill_pkg->sdate);
+ $cust_bill_pkg->cust_pkg->h_labels( $cust_bill_pkg->edate,
+ $cust_bill_pkg->sdate );
}
push @buf, map { [ " $_", '' ] } $cust_bill_pkg->details;
} else { #pkgnum tax or one-shot line item
- my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
- ? ( $cust_bill_pkg->itemdesc || 'Tax' )
- : 'Tax';
+
if ( $cust_bill_pkg->setup != 0 ) {
- push @buf, [ $itemdesc,
+ push @buf, [ $desc,
$money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
}
if ( $cust_bill_pkg->recur != 0 ) {
- push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - "
- . time2str("%x", $cust_bill_pkg->edate). ")",
+ push @buf, [ "$desc (". time2str("%x", $cust_bill_pkg->sdate). " - "
+ . time2str("%x", $cust_bill_pkg->edate). ")",
$money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
];
}
+
}
+
}
push @buf,['','-----------'];
#setup template variables
package FS::cust_bill::_template; #!
- use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent );
+ use vars qw( $company_name $company_address
+ $custnum $invnum $date $agent @address $overdue
+ $page $total_pages @buf
+ );
+ $custnum = $self->custnum;
$invnum = $self->invnum;
$date = $self->_date;
- $page = 1;
$agent = $self->cust_main->agent->agent;
+ $page = 1;
if ( $FS::cust_bill::invoice_lines ) {
$total_pages =
# && $self->printed > 0
# );
+ $FS::cust_bill::_template::company_name = $conf->config('company_name');
+ $FS::cust_bill::_template::company_address =
+ join("\n", $conf->config('company_address') ). "\n";
+
#and subroutine for the template
sub FS::cust_bill::_template::invoice_lines {
my $lines = shift || scalar(@buf);
=item print_latex [ TIME [ , TEMPLATE ] ]
Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename).
+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.
=cut
-#still some false laziness w/print_text
+#still some false laziness w/print_text and print_html (and send_csv) (mostly print_text should use _items stuff though)
sub print_latex {
my( $self, $today, $template ) = @_;
my $returnaddress;
if ( length($conf->config_orbase('invoice_latexreturnaddress', $template)) ) {
+
$returnaddress = join("\n",
$conf->config_orbase('invoice_latexreturnaddress', $template)
);
+
+ } elsif ( grep /\S/, $conf->config('company_address') ) {
+
+ $returnaddress =
+ join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
+ $conf->config('company_address')
+ );
+
} else {
+
+ my $warning = "Couldn't find a return address; ".
+ "do you need to set the company_address configuration value?";
+ warn "$warning\n";
$returnaddress = '~';
+ #$returnaddress = $warning;
+
}
my %invoice_data = (
- 'invnum' => $self->invnum,
- 'date' => time2str('%b %o, %Y', $self->_date),
- 'today' => time2str('%b %o, %Y', $today),
- 'agent' => _latex_escape($cust_main->agent->agent),
- 'payname' => _latex_escape($cust_main->payname),
- 'company' => _latex_escape($cust_main->company),
- 'address1' => _latex_escape($cust_main->address1),
- 'address2' => _latex_escape($cust_main->address2),
- 'city' => _latex_escape($cust_main->city),
- 'state' => _latex_escape($cust_main->state),
- 'zip' => _latex_escape($cust_main->zip),
- 'footer' => join("\n", $conf->config_orbase('invoice_latexfooter', $template) ),
- 'smallfooter' => join("\n", $conf->config_orbase('invoice_latexsmallfooter', $template) ),
- 'returnaddress' => $returnaddress,
- 'quantity' => 1,
- 'terms' => $conf->config('invoice_default_terms') || 'Payable upon receipt',
- #'notes' => join("\n", $conf->config('invoice_latexnotes') ),
- 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+ 'company_name' => scalar( $conf->config('company_name') ),
+ 'company_address' => join("\n", $conf->config('company_address') ). "\n",
+ 'custnum' => $self->custnum,
+ 'invnum' => $self->invnum,
+ 'date' => time2str('%b %o, %Y', $self->_date),
+ 'today' => time2str('%b %o, %Y', $today),
+ 'agent' => _latex_escape($cust_main->agent->agent),
+ 'payname' => _latex_escape($cust_main->payname),
+ 'company' => _latex_escape($cust_main->company),
+ 'address1' => _latex_escape($cust_main->address1),
+ 'address2' => _latex_escape($cust_main->address2),
+ 'city' => _latex_escape($cust_main->city),
+ 'state' => _latex_escape($cust_main->state),
+ 'zip' => _latex_escape($cust_main->zip),
+ 'returnaddress' => $returnaddress,
+ 'quantity' => 1,
+ 'terms' => $self->terms,
+ #'notes' => join("\n", $conf->config('invoice_latexnotes') ),
+ # better hang on to conf_dir for a while
+ 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
);
my $countrydefault = $conf->config('countrydefault') || 'US';
$invoice_data{'country'} = _latex_escape(code2country($cust_main->country));
}
- $invoice_data{'notes'} =
- join("\n",
-# #do variable substitutions in notes
-# map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
- $conf->config_orbase('invoice_latexnotes', $template)
- );
- warn "invoice notes: ". $invoice_data{'notes'}. "\n"
- if $DEBUG;
+ #do variable substitution in notes, footer, smallfooter
+ foreach my $include (qw( notes footer smallfooter )) {
+
+ my $inc_tt = new Text::Template (
+ TYPE => 'ARRAY',
+ SOURCE => [ map "$_\n",
+ $conf->config_orbase("invoice_latex$include", $template )
+ ],
+ DELIMITERS => [ '[@--', '--@]' ],
+ ) or die "can't create new Text::Template object: $Text::Template::ERROR";
- $invoice_data{'footer'} =~ s/\n+$//;
- $invoice_data{'smallfooter'} =~ s/\n+$//;
- $invoice_data{'notes'} =~ s/\n+$//;
+ $inc_tt->compile()
+ or die "can't compile template: $Text::Template::ERROR";
+
+ $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+ $invoice_data{$include} =~ s/\n+$//;
+ }
$invoice_data{'po_line'} =
( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
die "guru meditation #54";
}
- my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+ 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";
+
+ if ($template && $conf->exists("logo_${template}.eps")) {
+ print $lh $conf->config_binary("logo_${template}.eps")
+ or die "can't write temp file: $!\n";
+ }else{
+ print $lh $conf->config_binary('logo.eps')
+ or die "can't write temp file: $!\n";
+ }
+ close $lh;
+ $invoice_data{'logo_file'} = $lh->filename;
+
my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
DIR => $dir,
SUFFIX => '.tex',
close $fh;
$fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
- return $1;
+ return ($1, $invoice_data{'logo_file'});
}
sub print_ps {
my $self = shift;
- my $file = $self->print_latex(@_);
-
- my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
- chdir($dir);
-
- my $sfile = shell_quote $file;
-
- system("pslatex $sfile.tex >/dev/null 2>&1") == 0
- or die "pslatex $file.tex failed; see $file.log for details?\n";
- system("pslatex $sfile.tex >/dev/null 2>&1") == 0
- or die "pslatex $file.tex failed; see $file.log for details?\n";
-
- system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
- or die "dvips failed";
-
- open(POSTSCRIPT, "<$file.ps")
- or die "can't open $file.ps: $! (error in LaTeX template?)\n";
-
- unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
-
- my $ps = '';
- while (<POSTSCRIPT>) {
- $ps .= $_;
- }
-
- close POSTSCRIPT;
-
- return $ps;
+ my ($file, $lfile) = $self->print_latex(@_);
+ my $ps = generate_ps($file);
+ unlink($lfile);
+ $ps;
}
sub print_pdf {
my $self = shift;
- my $file = $self->print_latex(@_);
+ my ($file, $lfile) = $self->print_latex(@_);
- my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
chdir($dir);
#system('pdflatex', "$file.tex");
or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
+ unlink("$lfile");
my $pdf = '';
while (<PDF>) {
=cut
+#some falze laziness w/print_text and print_latex (and send_csv)
sub print_html {
my( $self, $today, $template, $cid ) = @_;
$today ||= time;
or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
my %invoice_data = (
- 'invnum' => $self->invnum,
- 'date' => time2str('%b %o, %Y', $self->_date),
- 'today' => time2str('%b %o, %Y', $today),
- 'agent' => encode_entities($cust_main->agent->agent),
- 'payname' => encode_entities($cust_main->payname),
- 'company' => encode_entities($cust_main->company),
- 'address1' => encode_entities($cust_main->address1),
- 'address2' => encode_entities($cust_main->address2),
- 'city' => encode_entities($cust_main->city),
- 'state' => encode_entities($cust_main->state),
- 'zip' => encode_entities($cust_main->zip),
- 'terms' => $conf->config('invoice_default_terms')
- || 'Payable upon receipt',
- 'cid' => $cid,
- 'template' => $template,
-# 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+ 'company_name' => scalar( $conf->config('company_name') ),
+ 'company_address' => join("\n", $conf->config('company_address') ). "\n",
+ 'custnum' => $self->custnum,
+ 'invnum' => $self->invnum,
+ 'date' => time2str('%b %o, %Y', $self->_date),
+ 'today' => time2str('%b %o, %Y', $today),
+ 'agent' => encode_entities($cust_main->agent->agent),
+ 'payname' => encode_entities($cust_main->payname),
+ 'company' => encode_entities($cust_main->company),
+ 'address1' => encode_entities($cust_main->address1),
+ 'address2' => encode_entities($cust_main->address2),
+ 'city' => encode_entities($cust_main->city),
+ 'state' => encode_entities($cust_main->state),
+ 'zip' => encode_entities($cust_main->zip),
+ 'terms' => $self->terms,
+ 'cid' => $cid,
+ 'template' => $template,
+# 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
);
- $invoice_data{'returnaddress'} =
- length( $conf->config_orbase('invoice_htmlreturnaddress', $template) )
- ? join("\n", $conf->config('invoice_htmlreturnaddress', $template) )
- : join("\n", map {
- s/~/ /g;
- s/\\\\\*?\s*$/<BR>/;
- s/\\hyphenation\{[\w\s\-]+\}//;
- $_;
- }
- $conf->config_orbase('invoice_latexreturnaddress', $template)
- );
+ if (
+ defined( $conf->config_orbase('invoice_htmlreturnaddress', $template) )
+ && length( $conf->config_orbase('invoice_htmlreturnaddress', $template) )
+ ) {
+
+ $invoice_data{'returnaddress'} =
+ join("\n", $conf->config_orbase('invoice_htmlreturnaddress', $template) );
+
+ } elsif ( grep /\S/,
+ $conf->config_orbase( 'invoice_latexreturnaddress', $template ) ) {
+
+ $invoice_data{'returnaddress'} =
+ join("\n", map {
+ s/~/ /g;
+ s/\\\\\*?\s*$/<BR>/;
+ s/\\hyphenation\{[\w\s\-]+\}//;
+ $_;
+ }
+ $conf->config_orbase( 'invoice_latexreturnaddress',
+ $template
+ )
+ );
+
+ } elsif ( grep /\S/, $conf->config('company_address') ) {
+
+ $invoice_data{'returnaddress'} =
+ join("\n", $conf->config('company_address') );
+
+ } else {
+
+ my $warning = "Couldn't find a return address; ".
+ "do you need to set the company_address configuration value?";
+ warn "$warning\n";
+ #$invoice_data{'returnaddress'} = $warning;
+
+ }
my $countrydefault = $conf->config('countrydefault') || 'US';
if ( $cust_main->country eq $countrydefault ) {
encode_entities(code2country($cust_main->country));
}
- $invoice_data{'notes'} =
- length($conf->config_orbase('invoice_htmlnotes', $template))
- ? join("\n", $conf->config_orbase('invoice_htmlnotes', $template) )
- : join("\n", map {
- s/%%(.*)$/<!-- $1 -->/;
- s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/;
- s/\\begin\{enumerate\}/<ol>/;
- s/\\item / <li>/;
- s/\\end\{enumerate\}/<\/ol>/;
- s/\\textbf\{(.*)\}/<b>$1<\/b>/;
- $_;
- }
- $conf->config_orbase('invoice_latexnotes', $template)
- );
+ if (
+ defined( $conf->config_orbase('invoice_htmlnotes', $template) )
+ && length( $conf->config_orbase('invoice_htmlnotes', $template) )
+ ) {
+ $invoice_data{'notes'} =
+ join("\n", $conf->config_orbase('invoice_htmlnotes', $template) );
+ } else {
+ $invoice_data{'notes'} =
+ join("\n", 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/\\\\\*/ /;
+ s/\\dollar ?/\$/g;
+ $_;
+ }
+ $conf->config_orbase('invoice_latexnotes', $template)
+ );
+ }
# #do variable substitutions in notes
# $invoice_data{'notes'} =
# $conf->config_orbase('invoice_latexnotes', $suffix)
# );
+ if (
+ defined( $conf->config_orbase('invoice_htmlfooter', $template) )
+ && length( $conf->config_orbase('invoice_htmlfooter', $template) )
+ ) {
$invoice_data{'footer'} =
- length($conf->config_orbase('invoice_htmlfooter', $template))
- ? join("\n", $conf->config_orbase('invoice_htmlfooter', $template) )
- : join("\n", map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; }
- $conf->config_orbase('invoice_latexfooter', $template)
- );
+ join("\n", $conf->config_orbase('invoice_htmlfooter', $template) );
+ } else {
+ $invoice_data{'footer'} =
+ join("\n", map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; }
+ $conf->config_orbase('invoice_latexfooter', $template)
+ );
+ }
$invoice_data{'po_line'} =
( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
push @{$invoice_data{'total_items'}}, $total;
}
+ warn "filling in HTML template for invoice ". $self->invnum. "\n"
+ if $DEBUG;
+ warn join("\n", map " $_ => ".$invoice_data{$_}, keys %invoice_data ). "\n"
+ if $DEBUG > 1;
+
$html_template->fill_in( HASH => \%invoice_data);
}
#utility methods for print_*
+sub terms {
+ my $self = shift;
+
+ #check for an invoice- specific override (eventually)
+
+ #check for a customer- specific override
+ return $self->cust_main->invoice_terms
+ if $self->cust_main->invoice_terms;
+
+ #use configured default or default default
+ $conf->config('invoice_default_terms') || 'Payable upon receipt';
+}
+
+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 = 'Balance Due';
- return $msg unless $conf->exists('invoice_default_terms');
- if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
- $msg .= ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
- } elsif ( $conf->config('invoice_default_terms') ) {
- $msg .= ' - '. $conf->config('invoice_default_terms');
+ return $msg unless $self->terms;
+ if ( $self->due_date ) {
+ $msg .= ' - Please pay by '. $self->due_date2str('%x');
+ } elsif ( $self->terms ) {
+ $msg .= ' - '. $self->terms;
}
$msg;
}
my @b = ();
foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
- if ( $cust_bill_pkg->pkgnum > 0 ) {
+ my $desc = $cust_bill_pkg->desc;
- my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
- my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
- my $pkg = $part_pkg->pkg;
+ if ( $cust_bill_pkg->pkgnum > 0 ) {
if ( $cust_bill_pkg->setup != 0 ) {
- my $description = $pkg;
+ my $description = $desc;
$description .= ' Setup' if $cust_bill_pkg->recur != 0;
- my @d = $cust_pkg->h_labels_short($self->_date);
+ my @d = $cust_bill_pkg->cust_pkg->h_labels_short($self->_date);
push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
push @b, {
description => $description,
#pkgpart => $part_pkg->pkgpart,
- pkgnum => $cust_pkg->pkgnum,
+ pkgnum => $cust_bill_pkg->pkgnum,
amount => sprintf("%.2f", $cust_bill_pkg->setup),
ext_description => \@d,
};
if ( $cust_bill_pkg->recur != 0 ) {
push @b, {
- description => "$pkg (" .
- time2str('%x', $cust_bill_pkg->sdate). ' - '.
- time2str('%x', $cust_bill_pkg->edate). ')',
+ description => $desc .
+ ( $conf->exists('disable_line_item_date_ranges')
+ ? ''
+ : " (" .time2str("%x", $cust_bill_pkg->sdate).
+ " - ".time2str("%x", $cust_bill_pkg->edate).")"
+ ),
#pkgpart => $part_pkg->pkgpart,
- pkgnum => $cust_pkg->pkgnum,
+ pkgnum => $cust_bill_pkg->pkgnum,
amount => sprintf("%.2f", $cust_bill_pkg->recur),
- ext_description => [ $cust_pkg->h_labels_short($cust_bill_pkg->edate,
- $cust_bill_pkg->sdate),
- $cust_bill_pkg->details,
- ],
+ ext_description =>
+ [ $cust_bill_pkg->cust_pkg->h_labels_short( $cust_bill_pkg->edate,
+ $cust_bill_pkg->sdate),
+ $cust_bill_pkg->details,
+ ],
};
}
} else { #pkgnum tax or one-shot line item (??)
- my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
- ? ( $cust_bill_pkg->itemdesc || 'Tax' )
- : 'Tax';
if ( $cust_bill_pkg->setup != 0 ) {
push @b, {
- 'description' => $itemdesc,
+ 'description' => $desc,
'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
};
}
if ( $cust_bill_pkg->recur != 0 ) {
push @b, {
- 'description' => "$itemdesc (".
+ 'description' => "$desc (".
time2str("%x", $cust_bill_pkg->sdate). ' - '.
time2str("%x", $cust_bill_pkg->edate). ')',
'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
}
+
=back
=head1 SUBROUTINES
use MIME::Base64;
sub process_re_X {
my( $method, $job ) = ( shift, shift );
+ warn "process_re_X $method for job $job\n" if $DEBUG;
my $param = thaw(decode_base64(shift));
warn Dumper($param) if $DEBUG;
sub re_X {
my($method, $job, %param ) = @_;
-# [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ],
+ if ( $DEBUG ) {
+ warn "re_X $method for job $job with param:\n".
+ join( '', map { " $_ => ". $param{$_}. "\n" } keys %param );
+ }
#some false laziness w/search/cust_bill.html
my $distinct = '';
my $orderby = 'ORDER BY cust_bill._date';
- my @where;
-
- if ( $param{'begin'} =~ /^(\d+)$/ ) {
- push @where, "cust_bill._date >= $1";
- }
- if ( $param{'end'} =~ /^(\d+)$/ ) {
- push @where, "cust_bill._date < $1";
- }
- if ( $param{'agentnum'} =~ /^(\d+)$/ ) {
- push @where, "cust_main.agentnum = $1";
- }
-
- my $owed =
- "charged - ( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
- WHERE cust_bill_pay.invnum = cust_bill.invnum )
- - ( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
- WHERE cust_credit_bill.invnum = cust_bill.invnum )";
-
- push @where, "0 != $owed"
- if $param{'open'};
-
- push @where, "cust_bill._date < ". (time-86400*$param{'days'})
- if $param{'days'};
-
- my $extra_sql = scalar(@where) ? 'WHERE '. join(' AND ', @where) : '';
+ my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
my $addl_from = 'left join cust_main using ( custnum )';
-
- if ( $param{'newest_percust'} ) {
- $distinct = 'DISTINCT ON ( cust_bill.custnum )';
- $orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
- #$count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
- }
my @cust_bill = qsearch( 'cust_bill',
{},
- "$distinct cust_bill.*",
+ #"$distinct cust_bill.*",
+ "cust_bill.*",
$extra_sql,
'',
$addl_from
=back
+=head1 CLASS METHODS
+
+=over 4
+
+=item owed_sql
+
+Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
+
+=cut
+
+sub owed_sql {
+ my $class = shift;
+ 'charged - '. $class->paid_sql. ' - '. $class->credited_sql;
+}
+
+=item net_sql
+
+Returns an SQL fragment to retreive the net amount (charged minus credited).
+
+=cut
+
+sub net_sql {
+ my $class = shift;
+ 'charged - '. $class->credited_sql;
+}
+
+=item paid_sql
+
+Returns an SQL fragment to retreive the amount paid against this invoice.
+
+=cut
+
+sub paid_sql {
+ #my $class = shift;
+ "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
+ WHERE cust_bill.invnum = cust_bill_pay.invnum )";
+}
+
+=item credited_sql
+
+Returns an SQL fragment to retreive the amount credited against this invoice.
+
+=cut
+
+sub credited_sql {
+ #my $class = shift;
+ "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
+ WHERE cust_bill.invnum = cust_credit_bill.invnum )";
+}
+
+=item search_sql HASHREF
+
+Class method which returns an SQL WHERE fragment to search for parameters
+specified in HASHREF. Valid parameters are
+
+=over 4
+
+=item begin
+
+Epoch date (UNIX timestamp) setting a lower bound for _date values
+
+=item end
+
+Epoch date (UNIX timestamp) setting an upper bound for _date values
+
+=item invnum_min
+
+=item invnum_max
+
+=item agentnum
+
+=item owed
+
+=item net
+
+=item days
+
+=item newest_percust
+
+=back
+
+Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+
+=cut
+
+sub search_sql {
+ my($class, $param) = @_;
+ my @search = ();
+
+ if ( $param->{'begin'} =~ /^(\d+)$/ ) {
+ push @search, "cust_bill._date >= $1";
+ }
+ if ( $param->{'end'} =~ /^(\d+)$/ ) {
+ push @search, "cust_bill._date < $1";
+ }
+ if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
+ push @search, "cust_bill.invnum >= $1";
+ }
+ if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
+ push @search, "cust_bill.invnum <= $1";
+ }
+ if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+ push @search, "cust_main.agentnum = $1";
+ }
+
+ push @search, '0 != '. FS::cust_bill->owed_sql
+ if $param->{'open'};
+
+ push @search, '0 != '. FS::cust_bill->net_sql
+ if $param->{'net'};
+
+ push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
+ if $param->{'days'};
+
+ if ( $param->{'newest_percust'} ) {
+
+ #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
+ #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
+
+ my @newest_where = map { my $x = $_;
+ $x =~ s/\bcust_bill\./newest_cust_bill./g;
+ $x;
+ }
+ grep ! /^cust_main./, @search;
+ my $newest_where = scalar(@newest_where)
+ ? ' AND '. join(' AND ', @newest_where)
+ : '';
+
+
+ push @search, "cust_bill._date = (
+ SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
+ WHERE newest_cust_bill.custnum = cust_bill.custnum
+ $newest_where
+ )";
+
+ }
+
+ push @search, $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+ join(' AND ', @search );
+
+}
+
+=back
+
=head1 BUGS
The delete method.
-print_text formatting (and some logic :/) is in source, but needs to be
-slurped in from a file. Also number of lines ($=).
-
=head1 SEE ALSO
L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,