X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=4cfc59d10c21334983cc2b207a59ecb17b816f76;hp=30db4699f251bf07dfc47086238d13b1fd599a11;hb=9c7e761f0c45bf2085a4486269e8d70b9f87cde2;hpb=6cd87c0d3b5280446301c647fa5f1ec5a593fa3f diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 30db4699f..4cfc59d10 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,22 +1,29 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $conf $add1 $add2 $add3 $add4 ); +use vars qw( @ISA $conf $money_char ); +use vars qw( $invoice_lines @buf ); #yuck use Date::Format; +use Text::Template; +use FS::UID qw( datasrc ); use FS::Record qw( qsearch qsearchs ); +use FS::Misc qw( send_email ); 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::cust_pay_batch; +use FS::cust_bill_event; @ISA = qw( FS::Record ); #ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_bill'} = sub { +FS::UID->install_callback( sub { $conf = new FS::Conf; - ( $add1, $add2, $add3, $add4 ) = ( $conf->config('address'), '', '', '', '' ); -}; + $money_char = $conf->config('money_char') || '$'; +} ); =head1 NAME @@ -45,13 +52,17 @@ FS::cust_bill - Object methods for cust_bill records @cust_pay_objects = $cust_bill->cust_pay; + $tax_amount = $record->tax; + @lines = $cust_bill->print_text; @lines = $cust_bill->print_text $time; =head1 DESCRIPTION -An FS::cust_bill object represents an invoice. FS::cust_bill inherits from -FS::Record. The following fields are currently supported: +An FS::cust_bill object represents an invoice; a declaration that a customer +owes you money. The specific charges are itemized as B records +(see L). FS::cust_bill inherits from FS::Record. The +following fields are currently supported: =over 4 @@ -64,11 +75,9 @@ L and L for conversion functions. =item charged - amount of this invoice -=item owed - amount still outstanding on this invoice, which is charged minus -all payments (see L). +=item printed - deprecated -=item printed - how many times this invoice has been printed automatically -(see L). +=item closed - books closed flag, empty or `Y' =back @@ -91,21 +100,6 @@ sub table { 'cust_bill'; } Adds this invoice to the database ("Posts" the invoice). If there is an error, returns the error, otherwise returns false. -When adding new invoices, owed must be charged (or null, in which case it is -automatically set to charged). - -=cut - -sub insert { - my $self = shift; - - $self->owed( $self->charged ) if $self->owed eq ''; - return "owed != charged!" - unless $self->owed == $self->charged; - - $self->SUPER::insert; -} - =item delete Currently unimplemented. I don't remove invoices because there would then be @@ -114,7 +108,9 @@ no record you ever posted this invoice (which is bad, no?) =cut sub delete { - return "Can't remove invoice!" + my $self = shift; + return "Can't delete closed invoice" if $self->closed =~ /^Y/i; + $self->SUPER::delete(@_); } =item replace OLD_RECORD @@ -122,9 +118,8 @@ sub delete { Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. -Only owed and printed may be changed. Owed is normally updated by creating and -inserting a payment (see L). Printed is normally updated by -calling the collect method of a customer object (see L). +Only printed may be changed. printed is normally updated by calling the +collect method of a customer object (see L). =cut @@ -134,7 +129,6 @@ sub replace { #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 "(New) owed can't be > (new) charged!" if $new->owed > $new->charged; $new->SUPER::replace($old); } @@ -155,8 +149,8 @@ sub check { || $self->ut_number('custnum') || $self->ut_numbern('_date') || $self->ut_money('charged') - || $self->ut_money('owed') || $self->ut_numbern('printed') + || $self->ut_enum('closed', [ '', 'Y' ]) ; return $error if $error; @@ -167,7 +161,7 @@ sub check { $self->printed(0) if $self->printed eq ''; - ''; #no error + $self->SUPER::check; } =item previous @@ -199,41 +193,503 @@ sub cust_bill_pkg { qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } ); } +=item cust_bill_event + +Returns the completed invoice events (see L) for this +invoice. + +=cut + +sub cust_bill_event { + my $self = shift; + qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } ); +} + + +=item cust_main + +Returns the customer (see L) for this invoice. + +=cut + +sub cust_main { + my $self = shift; + qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); +} + =item cust_credit -Returns a list consisting of the total previous credited (see -L) for this customer, followed by the previous outstanding -credits (FS::cust_credit objects). +Depreciated. See the cust_credited method. + + #Returns a list consisting of the total previous credited (see + #L) and unapplied for this customer, followed by the previous + #outstanding credits (FS::cust_credit objects). =cut sub cust_credit { - my $self = shift; - my $total = 0; - my @cust_credit = sort { $a->_date <=> $b->date } - grep { $_->credited != 0 && $_->_date < $self->_date } - qsearch('cust_credit', { 'custnum' => $self->custnum } ) - ; - foreach (@cust_credit) { $total += $_->credited; } - $total, @cust_credit; + use Carp; + croak "FS::cust_bill->cust_credit depreciated; see ". + "FS::cust_bill->cust_credit_bill"; + #my $self = shift; + #my $total = 0; + #my @cust_credit = sort { $a->_date <=> $b->_date } + # grep { $_->credited != 0 && $_->_date < $self->_date } + # qsearch('cust_credit', { 'custnum' => $self->custnum } ) + #; + #foreach (@cust_credit) { $total += $_->credited; } + #$total, @cust_credit; } =item cust_pay -Returns all payments (see L) for this invoice. +Depreciated. See the cust_bill_pay method. + +#Returns all payments (see L) for this invoice. =cut sub cust_pay { + use Carp; + croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay"; + #my $self = shift; + #sort { $a->_date <=> $b->_date } + # qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) + #; +} + +=item cust_bill_pay + +Returns all payment applications (see L) for this invoice. + +=cut + +sub cust_bill_pay { my $self = shift; - sort { $a->_date <=> $b->date } - qsearch( 'cust_pay', { 'invnum' => $self->invnum } ) + sort { $a->_date <=> $b->_date } + qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } ); +} + +=item cust_credited + +Returns all applied credits (see L) for this invoice. + +=cut + +sub cust_credited { + my $self = shift; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } ) ; } -=item print_text [TIME]; +=item tax + +Returns the tax amount (see L) for this invoice. + +=cut + +sub tax { + my $self = shift; + my $total = 0; + my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum , + 'pkgnum' => 0 } ); + foreach (@taxlines) { $total += $_->setup; } + $total; +} + +=item owed + +Returns the amount owed (still outstanding) on this invoice, which is charged +minus all payment applications (see L) and credit +applications (see L). + +=cut + +sub owed { + my $self = shift; + my $balance = $self->charged; + $balance -= $_->amount foreach ( $self->cust_bill_pay ); + $balance -= $_->amount foreach ( $self->cust_credited ); + $balance = sprintf( "%.2f", $balance); + $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp + $balance; +} + +=item send [ TEMPLATENAME [ , AGENTNUM ] ] + +Sends this invoice to the destinations configured for this customer: send +emails or print. See L. + +TEMPLATENAME, if specified, is the name of a suffix for alternate invoices. + +AGENTNUM, if specified, means that this invoice will only be sent for customers +of the specified agent. + +=cut + +sub send { + my $self = shift; + my $template = scalar(@_) ? shift : ''; + return '' if scalar(@_) && $_[0] && $self->cust_main->agentnum ne shift; + + my @print_text = $self->print_text('', $template); + my @invoicing_list = $self->cust_main->invoicing_list; + + if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list ) { #email + + #better to notify this person than silence + @invoicing_list = ($conf->config('invoice_from')) unless @invoicing_list; + + my $error = send_email( + 'from' => $conf->config('invoice_from'), + 'to' => [ grep { $_ ne 'POST' } @invoicing_list ], + 'subject' => 'Invoice', + 'body' => \@print_text, + ); + die "can't email invoice: $error\n" if $error; + + } + + if ( $conf->config('invoice_latex') ) { + @print_text = $self->print_ps('', $template); + } + + if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal + my $lpr = $conf->config('lpr'); + open(LPR, "|$lpr") + or die "Can't open pipe to $lpr: $!\n"; + print LPR @print_text; + close LPR + or die $! ? "Error closing $lpr: $!\n" + : "Exit status $? from $lpr\n"; + } + + ''; + +} + +=item send_csv OPTIONS + +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. + +The fields of the CSV file is 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 is either C or C + +If B is C, this is a primary invoice record. The +last five fields (B through B) are irrelevant, and all other +fields are filled in. + +If B is C, this is a line item record. Only the +first two fields (B and B) and the last five fields +(B through B) 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 and B will be defined) + +=item recur - line item recurring fee (one or both of B and B will be defined) + +=item sdate - start date for recurring fee + +=item edate - end date for recurring fee + +=back + +=cut + +sub send_csv { + my($self, %opt) = @_; + + #part one: create file + + my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill"; + mkdir $spooldir, 0700 unless -d $spooldir; + + my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time); + + open(CSV, ">$file") or die "can't open $file: $!"; + + eval "use Text::CSV_XS"; + die $@ if $@; + + my $csv = Text::CSV_XS->new({'always_quote'=>1}); + + my $cust_main = $self->cust_main; + + $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), + ); + + } 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"; + print CSV $csv->string. "\n"; + + } + + close CSV or die "can't close CSV: $!"; + + #part two: upload it + + 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 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 ) = @_; + + 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 { $_->cust_pkg->part_pkg->pkg } + grep { $_->pkgnum } $self->cust_bill_pkg + ); + $description = eval qq("$dtempl"); + } + + $cust_main->realtime_bop($method, $amount, + 'description' => $description, + 'invnum' => $self->invnum, + ); + +} + +=item batch_card + +Adds a payment for this invoice to the pending credit card batch (see +L). + +=cut + +sub batch_card { + my $self = shift; + 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->getfield('payinfo'), + 'exp' => $cust_main->getfield('paydate'), + 'payname' => $cust_main->getfield('payname'), + 'amount' => $self->owed, + } ); + my $error = $cust_pay_batch->insert; + die $error if $error; + + ''; +} + +sub _agent_template { + my $self = shift; + + my $cust_bill_event = qsearchs( 'part_bill_event', + { + 'payby' => $self->cust_main->payby, + 'plan' => 'send_agent', + 'eventcode' => { 'op' => 'LIKE', + 'value' => '_%, '. $self->cust_main->agentnum. ');' }, + }, + '', + 'ORDER BY seconds LIMIT 1' + ); + + return '' unless $cust_bill_event; + + if ( $cust_bill_event->eventcode =~ /\(\s*'(.*)'\s*,\s*(\d+)\s*\)\;$/ ) { + return $1; + } else { + warn "can't parse eventcode for agent-specific invoice template"; + return ''; + } + +} + +=item print_text [ TIME [ , TEMPLATE ] ] -Returns an ASCII invoice, as a list of lines. +Returns an text invoice, as a list of lines. 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. @@ -244,190 +700,739 @@ L and L for conversion functions. sub print_text { - my( $self, $today ) = ( shift, shift ); + my( $self, $today, $template ) = @_; $today ||= time; - my $invnum = $self->invnum; +# my $invnum = $self->invnum; my $cust_main = qsearchs('cust_main', { 'custnum', $self->custnum } ); $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') ) - unless $cust_main->payname; + unless $cust_main->payname && $cust_main->payby ne 'CHEK'; 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; - - #overdue? - my $overdue = ( - $balance_due > 0 - && $today > $self->_date - && $self->printed > 1 - ); - - #printing bits here (yuck!) - - my @collect = (); +# 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; - my($description,$amount); - my(@buf); - - #format address - my($l,@address)=(0,'','','','','','',''); - $address[$l++] = - $cust_main->payname. - ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo - ? " (P.O. #". $cust_main->payinfo. ")" - : '' - ) - ; - $address[$l++]=$cust_main->company if $cust_main->company; - $address[$l++]=$cust_main->address1; - $address[$l++]=$cust_main->address2 if $cust_main->address2; - $address[$l++]=$cust_main->city. ", ". $cust_main->state. " ". - $cust_main->zip; - $address[$l++]=$cust_main->country unless $cust_main->country eq 'US'; + #my @collect = (); + #my($description,$amount); + @buf = (); #previous balance foreach ( @pr_cust_bill ) { - push @buf, ( + push @buf, [ "Previous Balance, Invoice #". $_->invnum. " (". time2str("%x",$_->_date). ")", - '$'. sprintf("%10.2f",$_->owed) - ); + $money_char. sprintf("%10.2f",$_->owed) + ]; } if (@pr_cust_bill) { - push @buf,('','-----------'); - push @buf,('Total Previous Balance','$' . sprintf("%10.2f",$pr_total ) ); - push @buf,('',''); + push @buf,['','-----------']; + push @buf,[ 'Total Previous Balance', + $money_char. sprintf("%10.2f",$pr_total ) ]; + push @buf,['','']; } #new charges - foreach ( $self->cust_bill_pkg ) { + foreach my $cust_bill_pkg ( + ( grep { $_->pkgnum } $self->cust_bill_pkg ), #packages first + ( grep { ! $_->pkgnum } $self->cust_bill_pkg ), #then taxes + ) { + + if ( $cust_bill_pkg->pkgnum ) { + + 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->setup != 0 ) { + my $description = $pkg; + $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->labels; + } - if ( $_->pkgnum ) { + if ( $cust_bill_pkg->recur != 0 ) { + push @buf, [ + "$pkg (" . 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->labels; + } - my($cust_pkg)=qsearchs('cust_pkg', { 'pkgnum', $_->pkgnum } ); - my($part_pkg)=qsearchs('part_pkg',{'pkgpart'=>$cust_pkg->pkgpart}); - my($pkg)=$part_pkg->pkg; + push @buf, map { [ " $_", '' ] } $cust_bill_pkg->details; - if ( $_->setup != 0 ) { - push @buf, ( "$pkg Setup",'$' . sprintf("%10.2f",$_->setup) ); - push @buf, map { " ". $_->[0]. ": ". $_->[1], '' } $cust_pkg->labels; + } 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, + $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ]; } - - if ( $_->recur != 0 ) { - push @buf, ( - "$pkg (" . time2str("%x",$_->sdate) . " - " . - time2str("%x",$_->edate) . ")", - '$' . sprintf("%10.2f",$_->recur) - ); - push @buf, map { " ". $_->[0]. ": ". $_->[1], '' } $cust_pkg->labels; + if ( $cust_bill_pkg->recur != 0 ) { + push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - " + . time2str("%x", $cust_bill_pkg->edate). ")", + $money_char. sprintf("%10.2f", $cust_bill_pkg->recur) + ]; } - - } else { #pkgnum Tax - push @buf,("Tax",'$' . sprintf("%10.2f",$_->setup) ) - if $_->setup != 0; } } - push @buf,('','-----------'); - push @buf,('Total New Charges', - '$' . sprintf("%10.2f",$self->charged) ); - push @buf,('',''); + push @buf,['','-----------']; + push @buf,['Total New Charges', + $money_char. sprintf("%10.2f",$self->charged) ]; + push @buf,['','']; - push @buf,('','-----------'); - push @buf,('Total Charges', - '$' . sprintf("%10.2f",$self->charged + $pr_total) ); - push @buf,('',''); + push @buf,['','-----------']; + push @buf,['Total Charges', + $money_char. sprintf("%10.2f",$self->charged + $pr_total) ]; + push @buf,['','']; #credits - foreach ( @cr_cust_credit ) { - push @buf,( - "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", - '$' . sprintf("%10.2f",$_->credited) - ); + foreach ( $self->cust_credited ) { + + #something more elaborate if $_->amount ne $_->cust_credit->credited ? + + my $reason = substr($_->cust_credit->reason,0,32); + $reason .= '...' if length($reason) < length($_->cust_credit->reason); + $reason = " ($reason) " if $reason; + push @buf,[ + "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")". + $reason, + $money_char. sprintf("%10.2f",$_->amount) + ]; } + #foreach ( @cr_cust_credit ) { + # push @buf,[ + # "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", + # $money_char. sprintf("%10.2f",$_->credited) + # ]; + #} #get & print payments - foreach ( $self->cust_pay ) { - push @buf,( - "Payment received ". time2str("%x",$_->_date ), - '$' . sprintf("%10.2f",$_->paid ) - ); + foreach ( $self->cust_bill_pay ) { + + #something more elaborate if $_->amount ne ->cust_pay->paid ? + + push @buf,[ + "Payment received ". time2str("%x",$_->cust_pay->_date ), + $money_char. sprintf("%10.2f",$_->amount ) + ]; } #balance due - push @buf,('','-----------'); - push @buf,('Balance Due','$' . - sprintf("%10.2f",$self->owed + $pr_total - $cr_total ) ); - - #now print + my $balance_due_msg = $self->balance_due_msg; + + push @buf,['','-----------']; + push @buf,[$balance_due_msg, $money_char. + sprintf("%10.2f", $balance_due ) ]; + + #create the template + $template ||= $self->_agent_template; + my $templatefile = 'invoice_template'; + $templatefile .= "_$template" if length($template); + my @invoice_template = $conf->config($templatefile) + or die "cannot load config file $templatefile"; + $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?" unless $wasfunc; + my $invoice_template = new Text::Template ( + TYPE => 'ARRAY', + SOURCE => [ map "$_\n", @invoice_template ], + ) or die "can't create new Text::Template object: $Text::Template::ERROR"; + $invoice_template->compile() + or die "can't compile template: $Text::Template::ERROR"; + + #setup template variables + package FS::cust_bill::_template; #! + use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent ); + + $invnum = $self->invnum; + $date = $self->_date; + $page = 1; + $agent = $self->cust_main->agent->agent; + + if ( $FS::cust_bill::invoice_lines ) { + $total_pages = + int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines ); + $total_pages++ + if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines; + } else { + $total_pages = 1; + } - my $tot_lines = 50; #should be configurable - #header is 17 lines - my $tot_pages = int( scalar(@buf) / ( 2 * ( $tot_lines - 17 ) ) ); - $tot_pages++ if scalar(@buf) % ( 2 * ( $tot_lines - 17 ) ); + #format address (variable for the template) + my $l = 0; + @address = ( '', '', '', '', '', '' ); + package FS::cust_bill; #! + $FS::cust_bill::_template::address[$l++] = + $cust_main->payname. + ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo + ? " (P.O. #". $cust_main->payinfo. ")" + : '' + ) + ; + $FS::cust_bill::_template::address[$l++] = $cust_main->company + if $cust_main->company; + $FS::cust_bill::_template::address[$l++] = $cust_main->address1; + $FS::cust_bill::_template::address[$l++] = $cust_main->address2 + if $cust_main->address2; + $FS::cust_bill::_template::address[$l++] = + $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip; + $FS::cust_bill::_template::address[$l++] = $cust_main->country + unless $cust_main->country eq 'US'; + + # #overdue? (variable for the template) + # $FS::cust_bill::_template::overdue = ( + # $balance_due > 0 + # && $today > $self->_date + ## && $self->printed > 1 + # && $self->printed > 0 + # ); + + #and subroutine for the template + sub FS::cust_bill::_template::invoice_lines { + my $lines = shift || scalar(@buf); + map { + scalar(@buf) ? shift @buf : [ '', '' ]; + } + ( 1 .. $lines ); + } - my $page = 1; + #and fill it in + $FS::cust_bill::_template::page = 1; my $lines; + my @collect; while (@buf) { - $lines = $tot_lines; - my @header = &header( - $page, $tot_pages, $self->_date, $self->invnum, @address + push @collect, split("\n", + $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' ) + ); + $FS::cust_bill::_template::page++; + } + + map "$_\n", @collect; + +} + +=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). + +See print_ps and print_pdf for methods that return PostScript and PDF output. + +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. Also see +L and L for conversion functions. + +=cut + +#still some false laziness w/print_text +sub print_latex { + + my( $self, $today, $template ) = @_; + $today ||= time; + +# my $invnum = $self->invnum; + my $cust_main = $self->cust_main; + $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') ) + unless $cust_main->payname && $cust_main->payby ne 'CHEK'; + + 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; + + #my @collect = (); + #my($description,$amount); + @buf = (); + + #create the template + $template ||= $self->_agent_template; + my $templatefile = 'invoice_latex'; + my $suffix = length($template) ? "_$template" : ''; + $templatefile .= $suffix; + my @invoice_template = $conf->config($templatefile) + or die "cannot load config file $templatefile"; + + my %invoice_data = ( + 'invnum' => $self->invnum, + 'date' => time2str('%b %o, %Y', $self->_date), + '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), + 'country' => _latex_escape($cust_main->country), + 'footer' => join("\n", $conf->config('invoice_latexfooter') ), + 'smallfooter' => join("\n", $conf->config('invoice_latexsmallfooter') ), + 'quantity' => 1, + 'terms' => $conf->config('invoice_default_terms') || 'Payable upon receipt', + #'notes' => join("\n", $conf->config('invoice_latexnotes') ), + ); + + my $countrydefault = $conf->config('countrydefault') || 'US'; + $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault; + + #do variable substitutions in notes + $invoice_data{'notes'} = + join("\n", + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + $conf->config_orbase('invoice_latexnotes', $suffix) ); - push @collect, @header; - $lines -= scalar(@header); - while ( $lines-- && @buf ) { - $description=shift(@buf); - $amount=shift(@buf); - push @collect, myswrite($description, $amount); + $invoice_data{'footer'} =~ s/\n+$//; + $invoice_data{'smallfooter'} =~ s/\n+$//; + $invoice_data{'notes'} =~ s/\n+$//; + + $invoice_data{'po_line'} = + ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) + ? _latex_escape("Purchase Order #". $cust_main->payinfo) + : '~'; + + my @line_item = (); + my @total_item = (); + my @filled_in = (); + while ( @invoice_template ) { + my $line = shift @invoice_template; + + if ( $line =~ /^%%Detail\s*$/ ) { + + while ( ( my $line_item_line = shift @invoice_template ) + !~ /^%%EndDetail\s*$/ ) { + push @line_item, $line_item_line; + } + foreach my $line_item ( $self->_items ) { + #foreach my $line_item ( $self->_items_pkg ) { + $invoice_data{'ref'} = $line_item->{'pkgnum'}; + $invoice_data{'description'} = _latex_escape($line_item->{'description'}); + if ( exists $line_item->{'ext_description'} ) { + $invoice_data{'description'} .= + "\\tabularnewline\n~~". + join("\\tabularnewline\n~~", map { _latex_escape($_) } @{$line_item->{'ext_description'}} ); + } + $invoice_data{'amount'} = $line_item->{'amount'}; + $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + push @filled_in, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item; + } + + } elsif ( $line =~ /^%%TotalDetails\s*$/ ) { + + while ( ( my $total_item_line = shift @invoice_template ) + !~ /^%%EndTotalDetails\s*$/ ) { + push @total_item, $total_item_line; + } + + my @total_fill = (); + + my $taxtotal = 0; + foreach my $tax ( $self->_items_tax ) { + $invoice_data{'total_item'} = _latex_escape($tax->{'description'}); + $taxtotal += ( $invoice_data{'total_amount'} = $tax->{'amount'} ); + push @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + } + + if ( $taxtotal ) { + $invoice_data{'total_item'} = 'Sub-total'; + $invoice_data{'total_amount'} = + '\dollar '. sprintf('%.2f', $self->charged - $taxtotal ); + unshift @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + } + + $invoice_data{'total_item'} = '\textbf{Total}'; + $invoice_data{'total_amount'} = + '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}'; + push @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + + #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments + + # credits + foreach my $credit ( $self->_items_credits ) { + $invoice_data{'total_item'} = _latex_escape($credit->{'description'}); + #$credittotal + $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'}; + push @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + } + + # payments + foreach my $payment ( $self->_items_payments ) { + $invoice_data{'total_item'} = _latex_escape($payment->{'description'}); + #$paymenttotal + $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'}; + push @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + } + + $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}'; + $invoice_data{'total_amount'} = + '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}'; + push @total_fill, + map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } + @total_item; + + push @filled_in, @total_fill; + + } else { + #$line =~ s/\$(\w+)/$invoice_data{$1}/eg; + $line =~ s/\$(\w+)/exists($invoice_data{$1}) ? $invoice_data{$1} : nounder($1)/eg; + push @filled_in, $line; } - $page++; + } - while ( $lines-- ) { - push @collect, myswrite('', ''); + + sub nounder { + my $var = $1; + $var =~ s/_/\-/g; + $var; } - return @collect; - - sub header { #17 lines - my ( $page, $tot_pages, $date, $invnum, @address ) = @_ ; - push @address, '', '', '', ''; - - my @return = (); - my $i = ' 'x32; - push @return, - '', - $i. 'Invoice', - $i. substr("Page $page of $tot_pages".' 'x10, 0, 20). - time2str("%x", $date ). " FS-". $invnum, - '', - '', - $add1, - $add2, - $add3, - $add4, - '', - splice @address, 0, 7; - ; - return map $_. "\n", @return; + my $dir = '/tmp'; #! /usr/local/etc/freeside/invoices.datasrc/ + my $unique = int(rand(2**31)); #UGH... use File::Temp or something + + chdir($dir); + my $file = $self->invnum. ".$unique"; + + open(TEX,">$file.tex") or die "can't open $file.tex: $!\n"; + print TEX join("\n", @filled_in ), "\n"; + close TEX; + + return $file; + +} + +=item print_ps [ TIME [ , TEMPLATE ] ] + +Returns an postscript invoice, as a scalar. + +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. Also see +L and L for conversion functions. + +=cut + +sub print_ps { + my $self = shift; + + my $file = $self->print_latex(@_); + + system("pslatex $file.tex >/dev/null 2>&1") == 0 + or die "pslatex failed: $!"; + system("pslatex $file.tex >/dev/null 2>&1") == 0 + or die "pslatex failed: $!"; + + system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0 + or die "dbips 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 .= $_; } - sub myswrite { - my $format = <. Also see +L and L for conversion functions. + +=cut + +sub print_pdf { + my $self = shift; + + my $file = $self->print_latex(@_); + + #system('pdflatex', "$file.tex"); + #system('pdflatex', "$file.tex"); + #! LaTeX Error: Unknown graphics extension: .eps. + + system("pslatex $file.tex >/dev/null 2>&1") == 0 + or die "pslatex failed: $!"; + system("pslatex $file.tex >/dev/null 2>&1") == 0 + or die "pslatex failed: $!"; + + #system('dvipdf', "$file.dvi", "$file.pdf" ); + system( + "dvips -q -t letter -f $file.dvi ". + "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$file.pdf ". + " -c save pop -" + ) == 0 + or die "dvips failed: $!"; + + open(PDF, "<$file.pdf") + or die "can't open $file.pdf: $! (error in LaTeX template?)\n"; + + unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex"); + + my $pdf = ''; + while () { + $pdf .= $_; } + close PDF; + + return $pdf; + } -=back +# 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". ( length($2) ? "\\$2" : '' )/ge; + $value; +} -=head1 VERSION +#utility methods for print_* -$Id: cust_bill.pm,v 1.1 1999-08-04 09:03:53 ivan Exp $ +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'); + } + $msg; +} + +sub _items { + my $self = shift; + my @display = scalar(@_) + ? @_ + : qw( _items_previous _items_pkg ); + #: qw( _items_pkg ); + #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments ); + my @b = (); + foreach my $display ( @display ) { + push @b, $self->$display(@_); + } + @b; +} + +sub _items_previous { + my $self = shift; + my $cust_main = $self->cust_main; + my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance + my @b = (); + foreach ( @pr_cust_bill ) { + push @b, { + 'description' => 'Previous Balance, Invoice #'. $_->invnum. + ' ('. time2str('%x',$_->_date). ')', + #'pkgpart' => 'N/A', + 'pkgnum' => 'N/A', + 'amount' => sprintf("%10.2f", $_->owed), + }; + } + @b; + + #{ + # 'description' => 'Previous Balance', + # #'pkgpart' => 'N/A', + # 'pkgnum' => 'N/A', + # 'amount' => sprintf("%10.2f", $pr_total ), + # 'ext_description' => [ map { + # "Invoice ". $_->invnum. + # " (". time2str("%x",$_->_date). ") ". + # sprintf("%10.2f", $_->owed) + # } @pr_cust_bill ], + + #}; +} + +sub _items_pkg { + my $self = shift; + my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg; + $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); +} + +sub _items_tax { + my $self = shift; + my @cust_bill_pkg = grep { ! $_->pkgnum } $self->cust_bill_pkg; + $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); +} + +sub _items_cust_bill_pkg { + my $self = shift; + my $cust_bill_pkg = shift; + + my @b = (); + foreach my $cust_bill_pkg ( @$cust_bill_pkg ) { + + if ( $cust_bill_pkg->pkgnum ) { + + 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; + + my %labels; + #tie %labels, 'Tie::IxHash'; + push @{ $labels{$_->[0]} }, $_->[1] foreach $cust_pkg->labels; + my @ext_description; + foreach my $label ( keys %labels ) { + my @values = @{ $labels{$label} }; + my $num = scalar(@values); + if ( $num > 5 ) { + push @ext_description, "$label ($num)"; + } else { + push @ext_description, map { "$label: $_" } @values; + } + } + + if ( $cust_bill_pkg->setup != 0 ) { + my $description = $pkg; + $description .= ' Setup' if $cust_bill_pkg->recur != 0; + my @d = @ext_description; + push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0; + push @b, { + 'description' => $description, + #'pkgpart' => $part_pkg->pkgpart, + 'pkgnum' => $cust_pkg->pkgnum, + 'amount' => sprintf("%10.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). ')', + #'pkgpart' => $part_pkg->pkgpart, + 'pkgnum' => $cust_pkg->pkgnum, + 'amount' => sprintf("%10.2f", $cust_bill_pkg->recur), + 'ext_description' => [ @ext_description, + $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, + 'amount' => sprintf("%10.2f", $cust_bill_pkg->setup), + }; + } + if ( $cust_bill_pkg->recur != 0 ) { + push @b, { + 'description' => "$itemdesc (". + time2str("%x", $cust_bill_pkg->sdate). ' - '. + time2str("%x", $cust_bill_pkg->edate). ')', + 'amount' => sprintf("%10.2f", $cust_bill_pkg->recur), + }; + } + + } + + } + + @b; + +} + +sub _items_credits { + my $self = shift; + + my @b; + #credits + foreach ( $self->cust_credited ) { + + #something more elaborate if $_->amount ne $_->cust_credit->credited ? + + my $reason = $_->cust_credit->reason; + #my $reason = substr($_->cust_credit->reason,0,32); + #$reason .= '...' if length($reason) < length($_->cust_credit->reason); + $reason = " ($reason) " if $reason; + push @b, { + #'description' => 'Credit ref\#'. $_->crednum. + # " (". time2str("%x",$_->cust_credit->_date) .")". + # $reason, + 'description' => 'Credit applied'. + time2str("%x",$_->cust_credit->_date). $reason, + 'amount' => sprintf("%10.2f",$_->amount), + }; + } + #foreach ( @cr_cust_credit ) { + # push @buf,[ + # "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")", + # $money_char. sprintf("%10.2f",$_->credited) + # ]; + #} + + @b; + +} + +sub _items_payments { + my $self = shift; + + my @b; + #get & print payments + foreach ( $self->cust_bill_pay ) { + + #something more elaborate if $_->amount ne ->cust_pay->paid ? + + push @b, { + 'description' => "Payment received ". + time2str("%x",$_->cust_pay->_date ), + 'amount' => sprintf("%10.2f", $_->amount ) + }; + } + + @b; + +} + +=back =head1 BUGS @@ -436,13 +1441,11 @@ 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 ($=). -missing print_ps for a nice postscript copy (maybe HylaFAX-cover-page-style -or something similar so the look can be completely customized?) - =head1 SEE ALSO -L, L, L, L, -L, schema.html from the base documentation. +L, L, L, L, +L, L, schema.html from the base +documentation. =cut