X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=4cfc59d10c21334983cc2b207a59ecb17b816f76;hp=c15dad4338e3c046f44c633957f9abd22ec82cc7;hb=9c7e761f0c45bf2085a4486269e8d70b9f87cde2;hpb=d0bc83c16f5c9b38dda97671de8a49894fb08b4d diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index c15dad433..4cfc59d10 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -316,15 +316,23 @@ sub owed { $balance; } -=item send +=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,$template) = @_; + 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; @@ -339,18 +347,22 @@ sub send { 'subject' => 'Invoice', 'body' => \@print_text, ); - return "can't send invoice: $error" if $error; + 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 return "Can't open pipe to $lpr: $!"; + or die "Can't open pipe to $lpr: $!\n"; print LPR @print_text; close LPR - or return $! ? "Error closing $lpr: $!" - : "Exit status $? from $lpr"; + or die $! ? "Error closing $lpr: $!\n" + : "Exit status $? from $lpr\n"; } ''; @@ -590,7 +602,10 @@ sub realtime_bop { my( $self, $method ) = @_; my $cust_main = $self->cust_main; - my $amount = $self->owed; + 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') ) { @@ -647,7 +662,32 @@ sub batch_card { ''; } -=item print_text [TIME]; +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 text invoice, as a list of lines. @@ -704,7 +744,9 @@ sub print_text { my $pkg = $part_pkg->pkg; if ( $cust_bill_pkg->setup != 0 ) { - push @buf, [ "$pkg Setup", + 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; @@ -782,15 +824,18 @@ sub print_text { } #balance due + my $balance_due_msg = $self->balance_due_msg; + push @buf,['','-----------']; - push @buf,['Balance Due', $money_char. + 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 $template; + $templatefile .= "_$template" if length($template); my @invoice_template = $conf->config($templatefile) - or die "cannot load config file $templatefile"; + or die "cannot load config file $templatefile"; $invoice_lines = 0; my $wasfunc = 0; foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy @@ -877,6 +922,516 @@ 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). + +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) + ); + + $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; + } + + } + + sub nounder { + my $var = $1; + $var =~ s/_/\-/g; + $var; + } + + 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 .= $_; + } + + close POSTSCRIPT; + + return $ps; + +} + +=item print_pdf [ TIME [ , TEMPLATE ] ] + +Returns an PDF 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_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; + +} + +# 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; +} + +#utility methods for print_* + +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 @@ -886,9 +1441,6 @@ 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,