X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=c59948808347f1e3ec9d883b94a2ad69f18a4c77;hb=45c73e0d548b950dd29c21d863c239f6114a2083;hp=f353ea84c95f98a4aee5fc29b3b01e3ed64d6649;hpb=172c50ae6b9bef1e72c9f454d0c3621aabe210fb;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index f353ea84c..c59948808 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,10 +1,10 @@ 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 IPC::Run3; +use List::Util qw(min max); use Date::Format; use Text::Template 1.20; use File::Temp 0.14; @@ -12,8 +12,8 @@ use String::ShellQuote; use HTML::Entities; use Locale::Country; use FS::UID qw( datasrc ); -use FS::Misc qw( send_email send_fax ); -use FS::Record qw( qsearch qsearchs ); +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; @@ -21,15 +21,20 @@ 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::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 { @@ -121,8 +126,14 @@ returns the error, otherwise returns false. =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 @@ -142,14 +153,20 @@ collect method of a customer object (see L). =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 @@ -212,10 +229,50 @@ sub cust_bill_pkg { qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } ); } +=item cust_pkg + +Returns the packages (see L) 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) for this -invoice. +Returns the completed invoice events (deprecated, old-style events - see L) for this invoice. =cut @@ -224,6 +281,54 @@ sub cust_bill_event { 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) 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) 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) 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 @@ -236,6 +341,25 @@ sub 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. @@ -335,6 +459,103 @@ sub owed { $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 @@ -370,7 +591,7 @@ sub generate_email { '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)$/ } @@ -421,16 +642,17 @@ sub generate_email { '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 ( defined($args{'_template'}) && length($args{'_template'}) - && -e "$path/logo_". $args{'_template'}. ".png" + if ( defined($args{'template'}) && length($args{'template'}) + && -e "$path/logo_". $args{'template'}. ".png" ) { - $file = "$path/logo_". $args{'_template'}. ".png"; + $file = "$path/logo_". $args{'template'}. ".png"; } else { $file = "$path/logo.png"; } @@ -578,6 +800,21 @@ INVOICE_FROM, if specified, overrides the default email invoice From: address. =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 : ''; @@ -616,6 +853,21 @@ INVOICE_FROM, if specified, overrides the default email invoice From: address. =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 : ''; @@ -669,15 +921,7 @@ sub print { my $self = shift; my $template = scalar(@_) ? shift : ''; - my $lpr = $conf->config('lpr'); - - my $outerr = ''; - run3 $lpr, $self->lpr_data($template), \$outerr, \$outerr; - if ( $? ) { - $outerr = ": $outerr" if length($outerr); - die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n"; - } - + do_print $self->lpr_data($template); } =item fax [ TEMPLATENAME ] @@ -739,16 +983,9 @@ server username password dir -format - 'default' or 'billco' - -#??? -If I is not specified or "default", the file will be named -"N-YYYYMMDDHHMMSS.csv" where N is the invoice number and YYMMDDHHMMSS is a -timestamp. -#??? -If I is "billco", two files will be created and uploaded. They will be named "N-YYYYMMDDHHMMSS-header.csv" and "N-YYYYMMDDHHMMSS-detail.csv" where N -is the invoice number and YYMMDDHHMMSS is a timestamp(???). +The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number +and YYMMDDHHMMSS is a timestamp. See L for a description of the output format. @@ -763,27 +1000,13 @@ sub send_csv { mkdir $spooldir, 0700 unless -d $spooldir; my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time); - my $file = "$spooldir/$tracctnum"; - if ( lc($opt{'format'}) eq 'billco' ) { - $file .= '-header.csv'; - } else { - #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); - $file .= '.csv'; - } + 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; - my $oldfile = ''; - if ( lc($opt{'format'}) eq 'billco' ) { - close CSV; - $oldfile = $file; - $file = "$spooldir/$tracctnum-detail.csv"; - open(CSV,">$file") or die "can't open $file: $!"; - } - print CSV $detail; close CSV; @@ -804,14 +1027,10 @@ sub send_csv { $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}"; - if ( $oldfile) { - $net->put($oldfile) or die "can't put $oldfile: $!"; - } $net->put($file) or die "can't put $file: $!"; $net->quit; - unlink $oldfile if $oldfile; unlink $file; } @@ -822,26 +1041,47 @@ Spools CSV invoice data. Options are: -format - 'default' or 'billco' +=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). + +=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) = @_; - #create file(s) + 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/spool"; - if ( lc($opt{'format'}) eq 'billco' ) { - $file .= '-header.csv'; - } else { - #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); - $file .= '.csv'; - } + + 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 ); @@ -851,14 +1091,15 @@ sub spool_csv { print CSV $header; - my $oldfile = ''; if ( lc($opt{'format'}) eq 'billco' ) { flock(CSV, LOCK_UN); close CSV; - $oldfile = $file; - $file = "$spooldir/spool-detail.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); @@ -870,6 +1111,8 @@ sub spool_csv { flock(CSV, LOCK_UN); close CSV; + return ''; + } =item print_csv OPTION => VALUE, ... @@ -1009,10 +1252,7 @@ sub print_csv { my $taxtotal = 0; $taxtotal += $_->{'amount'} foreach $self->_items_tax; - my $duedate = ''; - if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) { - $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) ); - } + my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format? my( $previous_balance, @unused ) = $self->previous; #previous balance @@ -1241,78 +1481,31 @@ sub realtime_bop { } -=item batch_card +=item batch_card OPTION => VALUE... Adds a payment for this invoice to the pending credit card batch (see -L). +L), or, if the B 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 ] ] @@ -1383,8 +1576,12 @@ sub print_text { if ( $cust_bill_pkg->recur != 0 ) { push @buf, [ - "$desc (" . 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, @@ -1484,12 +1681,16 @@ sub print_text { #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 = @@ -1531,6 +1732,10 @@ sub print_text { # && $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); @@ -1558,7 +1763,8 @@ sub print_text { =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. @@ -1614,32 +1820,49 @@ sub print_latex { 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' => $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'; @@ -1649,18 +1872,24 @@ sub print_latex { $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 )) { - $invoice_data{'footer'} =~ s/\n+$//; - $invoice_data{'smallfooter'} =~ s/\n+$//; - $invoice_data{'notes'} =~ s/\n+$//; + 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"; + + $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 ) @@ -1863,7 +2092,23 @@ sub print_latex { 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', @@ -1879,7 +2124,7 @@ sub print_latex { close $fh; $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename; - return $1; + return ($1, $invoice_data{'logo_file'}); } @@ -1897,34 +2142,10 @@ L and L for conversion functions. 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 () { - $ps .= $_; - } - - close POSTSCRIPT; - - return $ps; + my ($file, $lfile) = $self->print_latex(@_); + my $ps = generate_ps($file); + unlink($lfile); + $ps; } @@ -1942,9 +2163,9 @@ L and L for conversion functions. 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"); @@ -1970,6 +2191,7 @@ sub print_pdf { 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 () { @@ -2022,31 +2244,37 @@ sub print_html { 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' => $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", ); if ( defined( $conf->config_orbase('invoice_htmlreturnaddress', $template) ) && length( $conf->config_orbase('invoice_htmlreturnaddress', $template) ) ) { + $invoice_data{'returnaddress'} = - join("\n", $conf->config('invoice_htmlreturnaddress', $template) ); - } else { + 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; @@ -2058,6 +2286,19 @@ sub print_html { $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'; @@ -2077,12 +2318,14 @@ sub print_html { } else { $invoice_data{'notes'} = join("\n", map { - s/%%(.*)$//; - s/\\section\*\{\\textsc\{(.)(.*)\}\}/

$1<\/font>\U$2<\/b>/; - s/\\begin\{enumerate\}/

    /; - s/\\item /
  1. /; - s/\\end\{enumerate\}/<\/ol>/; - s/\\textbf\{(.*)\}/$1<\/b>/; + s/%%(.*)$//g; + s/\\section\*\{\\textsc\{(.)(.*)\}\}/

    $1<\/font>\U$2<\/b>/g; + s/\\begin\{enumerate\}/

      /g; + s/\\item /
    1. /g; + s/\\end\{enumerate\}/<\/ol>/g; + s/\\textbf\{(.*)\}/$1<\/b>/g; + s/\\\\\*/ /; + s/\\dollar ?/\$/g; $_; } $conf->config_orbase('invoice_latexnotes', $template) @@ -2209,14 +2452,41 @@ sub _latex_escape { #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; } @@ -2304,9 +2574,12 @@ sub _items_cust_bill_pkg { if ( $cust_bill_pkg->recur != 0 ) { push @b, { - description => "$desc (" . - 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_bill_pkg->pkgnum, amount => sprintf("%.2f", $cust_bill_pkg->recur), @@ -2396,6 +2669,7 @@ sub _items_payments { } + =back =head1 SUBROUTINES @@ -2431,6 +2705,7 @@ use Data::Dumper; 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; @@ -2445,49 +2720,23 @@ sub process_re_X { 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 @@ -2514,13 +2763,151 @@ sub re_X { =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, L, L, L,