802e2bfbde839dd3126d6a7fa1a69784abefd63b
[freeside.git] / FS / FS / cust_bill.pm
1 package FS::cust_bill;
2
3 use strict;
4 use vars qw( @ISA $conf $money_char );
5 use vars qw( $invoice_lines @buf ); #yuck
6 use Date::Format;
7 use Text::Template;
8 use File::Temp 0.14;
9 use String::ShellQuote;
10 use FS::UID qw( datasrc );
11 use FS::Record qw( qsearch qsearchs );
12 use FS::Misc qw( send_email );
13 use FS::cust_main;
14 use FS::cust_bill_pkg;
15 use FS::cust_credit;
16 use FS::cust_pay;
17 use FS::cust_pkg;
18 use FS::cust_credit_bill;
19 use FS::cust_pay_batch;
20 use FS::cust_bill_event;
21
22 @ISA = qw( FS::Record );
23
24 #ask FS::UID to run this stuff for us later
25 FS::UID->install_callback( sub { 
26   $conf = new FS::Conf;
27   $money_char = $conf->config('money_char') || '$';  
28 } );
29
30 =head1 NAME
31
32 FS::cust_bill - Object methods for cust_bill records
33
34 =head1 SYNOPSIS
35
36   use FS::cust_bill;
37
38   $record = new FS::cust_bill \%hash;
39   $record = new FS::cust_bill { 'column' => 'value' };
40
41   $error = $record->insert;
42
43   $error = $new_record->replace($old_record);
44
45   $error = $record->delete;
46
47   $error = $record->check;
48
49   ( $total_previous_balance, @previous_cust_bill ) = $record->previous;
50
51   @cust_bill_pkg_objects = $cust_bill->cust_bill_pkg;
52
53   ( $total_previous_credits, @previous_cust_credit ) = $record->cust_credit;
54
55   @cust_pay_objects = $cust_bill->cust_pay;
56
57   $tax_amount = $record->tax;
58
59   @lines = $cust_bill->print_text;
60   @lines = $cust_bill->print_text $time;
61
62 =head1 DESCRIPTION
63
64 An FS::cust_bill object represents an invoice; a declaration that a customer
65 owes you money.  The specific charges are itemized as B<cust_bill_pkg> records
66 (see L<FS::cust_bill_pkg>).  FS::cust_bill inherits from FS::Record.  The
67 following fields are currently supported:
68
69 =over 4
70
71 =item invnum - primary key (assigned automatically for new invoices)
72
73 =item custnum - customer (see L<FS::cust_main>)
74
75 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
76 L<Time::Local> and L<Date::Parse> for conversion functions.
77
78 =item charged - amount of this invoice
79
80 =item printed - deprecated
81
82 =item closed - books closed flag, empty or `Y'
83
84 =back
85
86 =head1 METHODS
87
88 =over 4
89
90 =item new HASHREF
91
92 Creates a new invoice.  To add the invoice to the database, see L<"insert">.
93 Invoices are normally created by calling the bill method of a customer object
94 (see L<FS::cust_main>).
95
96 =cut
97
98 sub table { 'cust_bill'; }
99
100 =item insert
101
102 Adds this invoice to the database ("Posts" the invoice).  If there is an error,
103 returns the error, otherwise returns false.
104
105 =item delete
106
107 Currently unimplemented.  I don't remove invoices because there would then be
108 no record you ever posted this invoice (which is bad, no?)
109
110 =cut
111
112 sub delete {
113   my $self = shift;
114   return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
115   $self->SUPER::delete(@_);
116 }
117
118 =item replace OLD_RECORD
119
120 Replaces the OLD_RECORD with this one in the database.  If there is an error,
121 returns the error, otherwise returns false.
122
123 Only printed may be changed.  printed is normally updated by calling the
124 collect method of a customer object (see L<FS::cust_main>).
125
126 =cut
127
128 sub replace {
129   my( $new, $old ) = ( shift, shift );
130   return "Can't change custnum!" unless $old->custnum == $new->custnum;
131   #return "Can't change _date!" unless $old->_date eq $new->_date;
132   return "Can't change _date!" unless $old->_date == $new->_date;
133   return "Can't change charged!" unless $old->charged == $new->charged;
134
135   $new->SUPER::replace($old);
136 }
137
138 =item check
139
140 Checks all fields to make sure this is a valid invoice.  If there is an error,
141 returns the error, otherwise returns false.  Called by the insert and replace
142 methods.
143
144 =cut
145
146 sub check {
147   my $self = shift;
148
149   my $error =
150     $self->ut_numbern('invnum')
151     || $self->ut_number('custnum')
152     || $self->ut_numbern('_date')
153     || $self->ut_money('charged')
154     || $self->ut_numbern('printed')
155     || $self->ut_enum('closed', [ '', 'Y' ])
156   ;
157   return $error if $error;
158
159   return "Unknown customer"
160     unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
161
162   $self->_date(time) unless $self->_date;
163
164   $self->printed(0) if $self->printed eq '';
165
166   $self->SUPER::check;
167 }
168
169 =item previous
170
171 Returns a list consisting of the total previous balance for this customer, 
172 followed by the previous outstanding invoices (as FS::cust_bill objects also).
173
174 =cut
175
176 sub previous {
177   my $self = shift;
178   my $total = 0;
179   my @cust_bill = sort { $a->_date <=> $b->_date }
180     grep { $_->owed != 0 && $_->_date < $self->_date }
181       qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) 
182   ;
183   foreach ( @cust_bill ) { $total += $_->owed; }
184   $total, @cust_bill;
185 }
186
187 =item cust_bill_pkg
188
189 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
190
191 =cut
192
193 sub cust_bill_pkg {
194   my $self = shift;
195   qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } );
196 }
197
198 =item cust_bill_event
199
200 Returns the completed invoice events (see L<FS::cust_bill_event>) for this
201 invoice.
202
203 =cut
204
205 sub cust_bill_event {
206   my $self = shift;
207   qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
208 }
209
210
211 =item cust_main
212
213 Returns the customer (see L<FS::cust_main>) for this invoice.
214
215 =cut
216
217 sub cust_main {
218   my $self = shift;
219   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
220 }
221
222 =item cust_credit
223
224 Depreciated.  See the cust_credited method.
225
226  #Returns a list consisting of the total previous credited (see
227  #L<FS::cust_credit>) and unapplied for this customer, followed by the previous
228  #outstanding credits (FS::cust_credit objects).
229
230 =cut
231
232 sub cust_credit {
233   use Carp;
234   croak "FS::cust_bill->cust_credit depreciated; see ".
235         "FS::cust_bill->cust_credit_bill";
236   #my $self = shift;
237   #my $total = 0;
238   #my @cust_credit = sort { $a->_date <=> $b->_date }
239   #  grep { $_->credited != 0 && $_->_date < $self->_date }
240   #    qsearch('cust_credit', { 'custnum' => $self->custnum } )
241   #;
242   #foreach (@cust_credit) { $total += $_->credited; }
243   #$total, @cust_credit;
244 }
245
246 =item cust_pay
247
248 Depreciated.  See the cust_bill_pay method.
249
250 #Returns all payments (see L<FS::cust_pay>) for this invoice.
251
252 =cut
253
254 sub cust_pay {
255   use Carp;
256   croak "FS::cust_bill->cust_pay depreciated; see FS::cust_bill->cust_bill_pay";
257   #my $self = shift;
258   #sort { $a->_date <=> $b->_date }
259   #  qsearch( 'cust_pay', { 'invnum' => $self->invnum } )
260   #;
261 }
262
263 =item cust_bill_pay
264
265 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
266
267 =cut
268
269 sub cust_bill_pay {
270   my $self = shift;
271   sort { $a->_date <=> $b->_date }
272     qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
273 }
274
275 =item cust_credited
276
277 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
278
279 =cut
280
281 sub cust_credited {
282   my $self = shift;
283   sort { $a->_date <=> $b->_date }
284     qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
285   ;
286 }
287
288 =item tax
289
290 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
291
292 =cut
293
294 sub tax {
295   my $self = shift;
296   my $total = 0;
297   my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
298                                              'pkgnum' => 0 } );
299   foreach (@taxlines) { $total += $_->setup; }
300   $total;
301 }
302
303 =item owed
304
305 Returns the amount owed (still outstanding) on this invoice, which is charged
306 minus all payment applications (see L<FS::cust_bill_pay>) and credit
307 applications (see L<FS::cust_credit_bill>).
308
309 =cut
310
311 sub owed {
312   my $self = shift;
313   my $balance = $self->charged;
314   $balance -= $_->amount foreach ( $self->cust_bill_pay );
315   $balance -= $_->amount foreach ( $self->cust_credited );
316   $balance = sprintf( "%.2f", $balance);
317   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
318   $balance;
319 }
320
321 =item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
322
323 Sends this invoice to the destinations configured for this customer: send
324 emails or print.  See L<FS::cust_main_invoice>.
325
326 TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
327
328 AGENTNUM, if specified, means that this invoice will only be sent for customers
329 of the specified agent.
330
331 INVOICE_FROM, if specified, overrides the default email invoice From: address.
332
333 =cut
334
335 sub send {
336   my $self = shift;
337   my $template = scalar(@_) ? shift : '';
338   return 'N/A' if scalar(@_) && $_[0] && $self->cust_main->agentnum != shift;
339   my $invoice_from =
340     scalar(@_)
341       ? shift
342       : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
343
344   my @print_text = $self->print_text('', $template);
345   my @invoicing_list = $self->cust_main->invoicing_list;
346
347   if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list  ) { #email
348
349     #better to notify this person than silence
350     @invoicing_list = ($invoice_from) unless @invoicing_list;
351
352     my $error = send_email(
353       'from'    => $invoice_from,
354       'to'      => [ grep { $_ ne 'POST' } @invoicing_list ],
355       'subject' => 'Invoice',
356       'body'    => \@print_text,
357     );
358     die "can't email invoice: $error\n" if $error;
359
360   }
361
362   if ( $conf->config('invoice_latex') ) {
363     @print_text = $self->print_ps('', $template);
364   }
365
366   if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal
367     my $lpr = $conf->config('lpr');
368     open(LPR, "|$lpr")
369       or die "Can't open pipe to $lpr: $!\n";
370     print LPR @print_text;
371     close LPR
372       or die $! ? "Error closing $lpr: $!\n"
373                 : "Exit status $? from $lpr\n";
374   }
375
376   '';
377
378 }
379
380 =item send_csv OPTIONS
381
382 Sends invoice as a CSV data-file to a remote host with the specified protocol.
383
384 Options are:
385
386 protocol - currently only "ftp"
387 server
388 username
389 password
390 dir
391
392 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
393 and YYMMDDHHMMSS is a timestamp.
394
395 The fields of the CSV file is as follows:
396
397 record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
398
399 =over 4
400
401 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
402
403 If B<record_type> is C<cust_bill>, this is a primary invoice record.  The
404 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
405 fields are filled in.
406
407 If B<record_type> is C<cust_bill_pkg>, this is a line item record.  Only the
408 first two fields (B<record_type> and B<invnum>) and the last five fields
409 (B<pkg> through B<edate>) are filled in.
410
411 =item invnum - invoice number
412
413 =item custnum - customer number
414
415 =item _date - invoice date
416
417 =item charged - total invoice amount
418
419 =item first - customer first name
420
421 =item last - customer first name
422
423 =item company - company name
424
425 =item address1 - address line 1
426
427 =item address2 - address line 1
428
429 =item city
430
431 =item state
432
433 =item zip
434
435 =item country
436
437 =item pkg - line item description
438
439 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
440
441 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
442
443 =item sdate - start date for recurring fee
444
445 =item edate - end date for recurring fee
446
447 =back
448
449 =cut
450
451 sub send_csv {
452   my($self, %opt) = @_;
453
454   #part one: create file
455
456   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
457   mkdir $spooldir, 0700 unless -d $spooldir;
458
459   my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
460
461   open(CSV, ">$file") or die "can't open $file: $!";
462
463   eval "use Text::CSV_XS";
464   die $@ if $@;
465
466   my $csv = Text::CSV_XS->new({'always_quote'=>1});
467
468   my $cust_main = $self->cust_main;
469
470   $csv->combine(
471     'cust_bill',
472     $self->invnum,
473     $self->custnum,
474     time2str("%x", $self->_date),
475     sprintf("%.2f", $self->charged),
476     ( map { $cust_main->getfield($_) }
477         qw( first last company address1 address2 city state zip country ) ),
478     map { '' } (1..5),
479   ) or die "can't create csv";
480   print CSV $csv->string. "\n";
481
482   #new charges (false laziness w/print_text)
483   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
484
485     my($pkg, $setup, $recur, $sdate, $edate);
486     if ( $cust_bill_pkg->pkgnum ) {
487     
488       ($pkg, $setup, $recur, $sdate, $edate) = (
489         $cust_bill_pkg->cust_pkg->part_pkg->pkg,
490         ( $cust_bill_pkg->setup != 0
491           ? sprintf("%.2f", $cust_bill_pkg->setup )
492           : '' ),
493         ( $cust_bill_pkg->recur != 0
494           ? sprintf("%.2f", $cust_bill_pkg->recur )
495           : '' ),
496         time2str("%x", $cust_bill_pkg->sdate),
497         time2str("%x", $cust_bill_pkg->edate),
498       );
499
500     } else { #pkgnum tax
501       next unless $cust_bill_pkg->setup != 0;
502       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
503                        ? ( $cust_bill_pkg->itemdesc || 'Tax' )
504                        : 'Tax';
505       ($pkg, $setup, $recur, $sdate, $edate) =
506         ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
507     }
508
509     $csv->combine(
510       'cust_bill_pkg',
511       $self->invnum,
512       ( map { '' } (1..11) ),
513       ($pkg, $setup, $recur, $sdate, $edate)
514     ) or die "can't create csv";
515     print CSV $csv->string. "\n";
516
517   }
518
519   close CSV or die "can't close CSV: $!";
520
521   #part two: upload it
522
523   my $net;
524   if ( $opt{protocol} eq 'ftp' ) {
525     eval "use Net::FTP;";
526     die $@ if $@;
527     $net = Net::FTP->new($opt{server}) or die @$;
528   } else {
529     die "unknown protocol: $opt{protocol}";
530   }
531
532   $net->login( $opt{username}, $opt{password} )
533     or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
534
535   $net->binary or die "can't set binary mode";
536
537   $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
538
539   $net->put($file) or die "can't put $file: $!";
540
541   $net->quit;
542
543   unlink $file;
544
545 }
546
547 =item comp
548
549 Pays this invoice with a compliemntary payment.  If there is an error,
550 returns the error, otherwise returns false.
551
552 =cut
553
554 sub comp {
555   my $self = shift;
556   my $cust_pay = new FS::cust_pay ( {
557     'invnum'   => $self->invnum,
558     'paid'     => $self->owed,
559     '_date'    => '',
560     'payby'    => 'COMP',
561     'payinfo'  => $self->cust_main->payinfo,
562     'paybatch' => '',
563   } );
564   $cust_pay->insert;
565 }
566
567 =item realtime_card
568
569 Attempts to pay this invoice with a credit card payment via a
570 Business::OnlinePayment realtime gateway.  See
571 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
572 for supported processors.
573
574 =cut
575
576 sub realtime_card {
577   my $self = shift;
578   $self->realtime_bop( 'CC', @_ );
579 }
580
581 =item realtime_ach
582
583 Attempts to pay this invoice with an electronic check (ACH) payment via a
584 Business::OnlinePayment realtime gateway.  See
585 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
586 for supported processors.
587
588 =cut
589
590 sub realtime_ach {
591   my $self = shift;
592   $self->realtime_bop( 'ECHECK', @_ );
593 }
594
595 =item realtime_lec
596
597 Attempts to pay this invoice with phone bill (LEC) payment via a
598 Business::OnlinePayment realtime gateway.  See
599 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
600 for supported processors.
601
602 =cut
603
604 sub realtime_lec {
605   my $self = shift;
606   $self->realtime_bop( 'LEC', @_ );
607 }
608
609 sub realtime_bop {
610   my( $self, $method ) = @_;
611
612   my $cust_main = $self->cust_main;
613   my $balance = $cust_main->balance;
614   my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
615   $amount = sprintf("%.2f", $amount);
616   return "not run (balance $balance)" unless $amount > 0;
617
618   my $description = 'Internet Services';
619   if ( $conf->exists('business-onlinepayment-description') ) {
620     my $dtempl = $conf->config('business-onlinepayment-description');
621
622     my $agent_obj = $cust_main->agent
623       or die "can't retreive agent for $cust_main (agentnum ".
624              $cust_main->agentnum. ")";
625     my $agent = $agent_obj->agent;
626     my $pkgs = join(', ',
627       map { $_->cust_pkg->part_pkg->pkg }
628         grep { $_->pkgnum } $self->cust_bill_pkg
629     );
630     $description = eval qq("$dtempl");
631   }
632
633   $cust_main->realtime_bop($method, $amount,
634     'description' => $description,
635     'invnum'      => $self->invnum,
636   );
637
638 }
639
640 =item batch_card
641
642 Adds a payment for this invoice to the pending credit card batch (see
643 L<FS::cust_pay_batch>).
644
645 =cut
646
647 sub batch_card {
648   my $self = shift;
649   my $cust_main = $self->cust_main;
650
651   my $cust_pay_batch = new FS::cust_pay_batch ( {
652     'invnum'   => $self->getfield('invnum'),
653     'custnum'  => $cust_main->getfield('custnum'),
654     'last'     => $cust_main->getfield('last'),
655     'first'    => $cust_main->getfield('first'),
656     'address1' => $cust_main->getfield('address1'),
657     'address2' => $cust_main->getfield('address2'),
658     'city'     => $cust_main->getfield('city'),
659     'state'    => $cust_main->getfield('state'),
660     'zip'      => $cust_main->getfield('zip'),
661     'country'  => $cust_main->getfield('country'),
662     'cardnum'  => $cust_main->getfield('payinfo'),
663     'exp'      => $cust_main->getfield('paydate'),
664     'payname'  => $cust_main->getfield('payname'),
665     'amount'   => $self->owed,
666   } );
667   my $error = $cust_pay_batch->insert;
668   die $error if $error;
669
670   '';
671 }
672
673 sub _agent_template {
674   my $self = shift;
675   $self->_agent_plandata('agent_templatename');
676 }
677
678 sub _agent_invoice_from {
679   my $self = shift;
680   $self->_agent_plandata('agent_invoice_from');
681 }
682
683 sub _agent_plandata {
684   my( $self, $option ) = @_;
685
686   my $part_bill_event = qsearchs( 'part_bill_event',
687     {
688       'payby'     => $self->cust_main->payby,
689       'plan'      => 'send_agent',
690       'plandata'  => { 'op'    => '~',
691                        'value' => "(^|\n)agentnum ".
692                                   $self->cust_main->agentnum.
693                                   "(\n|\$)",
694                      },
695     },
696     '',
697     'ORDER BY seconds LIMIT 1'
698   );
699
700   return '' unless $part_bill_event;
701
702   if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
703     return $1;
704   } else {
705     warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
706          " plandata for $option";
707     return '';
708   }
709
710 }
711
712 =item print_text [ TIME [ , TEMPLATE ] ]
713
714 Returns an text invoice, as a list of lines.
715
716 TIME an optional value used to control the printing of overdue messages.  The
717 default is now.  It isn't the date of the invoice; that's the `_date' field.
718 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
719 L<Time::Local> and L<Date::Parse> for conversion functions.
720
721 =cut
722
723 sub print_text {
724
725   my( $self, $today, $template ) = @_;
726   $today ||= time;
727 #  my $invnum = $self->invnum;
728   my $cust_main = qsearchs('cust_main', { 'custnum', $self->custnum } );
729   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
730     unless $cust_main->payname && $cust_main->payby ne 'CHEK';
731
732   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
733 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
734   #my $balance_due = $self->owed + $pr_total - $cr_total;
735   my $balance_due = $self->owed + $pr_total;
736
737   #my @collect = ();
738   #my($description,$amount);
739   @buf = ();
740
741   #previous balance
742   foreach ( @pr_cust_bill ) {
743     push @buf, [
744       "Previous Balance, Invoice #". $_->invnum. 
745                  " (". time2str("%x",$_->_date). ")",
746       $money_char. sprintf("%10.2f",$_->owed)
747     ];
748   }
749   if (@pr_cust_bill) {
750     push @buf,['','-----------'];
751     push @buf,[ 'Total Previous Balance',
752                 $money_char. sprintf("%10.2f",$pr_total ) ];
753     push @buf,['',''];
754   }
755
756   #new charges
757   foreach my $cust_bill_pkg (
758     ( grep {   $_->pkgnum } $self->cust_bill_pkg ),  #packages first
759     ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
760   ) {
761
762     if ( $cust_bill_pkg->pkgnum ) {
763
764       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
765       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
766       my $pkg = $part_pkg->pkg;
767
768       if ( $cust_bill_pkg->setup != 0 ) {
769         my $description = $pkg;
770         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
771         push @buf, [ $description,
772                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
773         push @buf,
774           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
775       }
776
777       if ( $cust_bill_pkg->recur != 0 ) {
778         push @buf, [
779           "$pkg (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
780                                 time2str("%x", $cust_bill_pkg->edate) . ")",
781           $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
782         ];
783         push @buf,
784           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
785       }
786
787       push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
788
789     } else { #pkgnum tax or one-shot line item
790       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
791                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
792                      : 'Tax';
793       if ( $cust_bill_pkg->setup != 0 ) {
794         push @buf, [ $itemdesc,
795                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
796       }
797       if ( $cust_bill_pkg->recur != 0 ) {
798         push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - "
799                                   . time2str("%x", $cust_bill_pkg->edate). ")",
800                      $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
801                    ];
802       }
803     }
804   }
805
806   push @buf,['','-----------'];
807   push @buf,['Total New Charges',
808              $money_char. sprintf("%10.2f",$self->charged) ];
809   push @buf,['',''];
810
811   push @buf,['','-----------'];
812   push @buf,['Total Charges',
813              $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
814   push @buf,['',''];
815
816   #credits
817   foreach ( $self->cust_credited ) {
818
819     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
820
821     my $reason = substr($_->cust_credit->reason,0,32);
822     $reason .= '...' if length($reason) < length($_->cust_credit->reason);
823     $reason = " ($reason) " if $reason;
824     push @buf,[
825       "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".
826         $reason,
827       $money_char. sprintf("%10.2f",$_->amount)
828     ];
829   }
830   #foreach ( @cr_cust_credit ) {
831   #  push @buf,[
832   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
833   #    $money_char. sprintf("%10.2f",$_->credited)
834   #  ];
835   #}
836
837   #get & print payments
838   foreach ( $self->cust_bill_pay ) {
839
840     #something more elaborate if $_->amount ne ->cust_pay->paid ?
841
842     push @buf,[
843       "Payment received ". time2str("%x",$_->cust_pay->_date ),
844       $money_char. sprintf("%10.2f",$_->amount )
845     ];
846   }
847
848   #balance due
849   my $balance_due_msg = $self->balance_due_msg;
850
851   push @buf,['','-----------'];
852   push @buf,[$balance_due_msg, $money_char. 
853     sprintf("%10.2f", $balance_due ) ];
854
855   #create the template
856   $template ||= $self->_agent_template;
857   my $templatefile = 'invoice_template';
858   $templatefile .= "_$template" if length($template);
859   my @invoice_template = $conf->config($templatefile)
860     or die "cannot load config file $templatefile";
861   $invoice_lines = 0;
862   my $wasfunc = 0;
863   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
864     /invoice_lines\((\d*)\)/;
865     $invoice_lines += $1 || scalar(@buf);
866     $wasfunc=1;
867   }
868   die "no invoice_lines() functions in template?" unless $wasfunc;
869   my $invoice_template = new Text::Template (
870     TYPE   => 'ARRAY',
871     SOURCE => [ map "$_\n", @invoice_template ],
872   ) or die "can't create new Text::Template object: $Text::Template::ERROR";
873   $invoice_template->compile()
874     or die "can't compile template: $Text::Template::ERROR";
875
876   #setup template variables
877   package FS::cust_bill::_template; #!
878   use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent );
879
880   $invnum = $self->invnum;
881   $date = $self->_date;
882   $page = 1;
883   $agent = $self->cust_main->agent->agent;
884
885   if ( $FS::cust_bill::invoice_lines ) {
886     $total_pages =
887       int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines );
888     $total_pages++
889       if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines;
890   } else {
891     $total_pages = 1;
892   }
893
894   #format address (variable for the template)
895   my $l = 0;
896   @address = ( '', '', '', '', '', '' );
897   package FS::cust_bill; #!
898   $FS::cust_bill::_template::address[$l++] =
899     $cust_main->payname.
900       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
901         ? " (P.O. #". $cust_main->payinfo. ")"
902         : ''
903       )
904   ;
905   $FS::cust_bill::_template::address[$l++] = $cust_main->company
906     if $cust_main->company;
907   $FS::cust_bill::_template::address[$l++] = $cust_main->address1;
908   $FS::cust_bill::_template::address[$l++] = $cust_main->address2
909     if $cust_main->address2;
910   $FS::cust_bill::_template::address[$l++] =
911     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
912   $FS::cust_bill::_template::address[$l++] = $cust_main->country
913     unless $cust_main->country eq 'US';
914
915         #  #overdue? (variable for the template)
916         #  $FS::cust_bill::_template::overdue = ( 
917         #    $balance_due > 0
918         #    && $today > $self->_date 
919         ##    && $self->printed > 1
920         #    && $self->printed > 0
921         #  );
922
923   #and subroutine for the template
924   sub FS::cust_bill::_template::invoice_lines {
925     my $lines = shift || scalar(@buf);
926     map { 
927       scalar(@buf) ? shift @buf : [ '', '' ];
928     }
929     ( 1 .. $lines );
930   }
931
932   #and fill it in
933   $FS::cust_bill::_template::page = 1;
934   my $lines;
935   my @collect;
936   while (@buf) {
937     push @collect, split("\n",
938       $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' )
939     );
940     $FS::cust_bill::_template::page++;
941   }
942
943   map "$_\n", @collect;
944
945 }
946
947 =item print_latex [ TIME [ , TEMPLATE ] ]
948
949 Internal method - returns a filename of a filled-in LaTeX template for this
950 invoice (Note: add ".tex" to get the actual filename).
951
952 See print_ps and print_pdf for methods that return PostScript and PDF output.
953
954 TIME an optional value used to control the printing of overdue messages.  The
955 default is now.  It isn't the date of the invoice; that's the `_date' field.
956 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
957 L<Time::Local> and L<Date::Parse> for conversion functions.
958
959 =cut
960
961 #still some false laziness w/print_text
962 sub print_latex {
963
964   my( $self, $today, $template ) = @_;
965   $today ||= time;
966
967 #  my $invnum = $self->invnum;
968   my $cust_main = $self->cust_main;
969   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
970     unless $cust_main->payname && $cust_main->payby ne 'CHEK';
971
972   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
973 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
974   #my $balance_due = $self->owed + $pr_total - $cr_total;
975   my $balance_due = $self->owed + $pr_total;
976
977   #my @collect = ();
978   #my($description,$amount);
979   @buf = ();
980
981   #create the template
982   $template ||= $self->_agent_template;
983   my $templatefile = 'invoice_latex';
984   my $suffix = length($template) ? "_$template" : '';
985   $templatefile .= $suffix;
986   my @invoice_template = $conf->config($templatefile)
987     or die "cannot load config file $templatefile";
988
989   my %invoice_data = (
990     'invnum'       => $self->invnum,
991     'date'         => time2str('%b %o, %Y', $self->_date),
992     'agent'        => _latex_escape($cust_main->agent->agent),
993     'payname'      => _latex_escape($cust_main->payname),
994     'company'      => _latex_escape($cust_main->company),
995     'address1'     => _latex_escape($cust_main->address1),
996     'address2'     => _latex_escape($cust_main->address2),
997     'city'         => _latex_escape($cust_main->city),
998     'state'        => _latex_escape($cust_main->state),
999     'zip'          => _latex_escape($cust_main->zip),
1000     'country'      => _latex_escape($cust_main->country),
1001     'footer'       => join("\n", $conf->config('invoice_latexfooter') ),
1002     'smallfooter'  => join("\n", $conf->config('invoice_latexsmallfooter') ),
1003     'quantity'     => 1,
1004     'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
1005     #'notes'        => join("\n", $conf->config('invoice_latexnotes') ),
1006   );
1007
1008   my $countrydefault = $conf->config('countrydefault') || 'US';
1009   $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault;
1010
1011   #do variable substitutions in notes
1012   $invoice_data{'notes'} =
1013     join("\n",
1014       map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1015         $conf->config_orbase('invoice_latexnotes', $suffix)
1016     );
1017
1018   $invoice_data{'footer'} =~ s/\n+$//;
1019   $invoice_data{'smallfooter'} =~ s/\n+$//;
1020   $invoice_data{'notes'} =~ s/\n+$//;
1021
1022   $invoice_data{'po_line'} =
1023     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
1024       ? _latex_escape("Purchase Order #". $cust_main->payinfo)
1025       : '~';
1026
1027   my @line_item = ();
1028   my @total_item = ();
1029   my @filled_in = ();
1030   while ( @invoice_template ) {
1031     my $line = shift @invoice_template;
1032
1033     if ( $line =~ /^%%Detail\s*$/ ) {
1034
1035       while ( ( my $line_item_line = shift @invoice_template )
1036               !~ /^%%EndDetail\s*$/                            ) {
1037         push @line_item, $line_item_line;
1038       }
1039       foreach my $line_item ( $self->_items ) {
1040       #foreach my $line_item ( $self->_items_pkg ) {
1041         $invoice_data{'ref'} = $line_item->{'pkgnum'};
1042         $invoice_data{'description'} = _latex_escape($line_item->{'description'});
1043         if ( exists $line_item->{'ext_description'} ) {
1044           $invoice_data{'description'} .=
1045             "\\tabularnewline\n~~".
1046             join("\\tabularnewline\n~~", map { _latex_escape($_) } @{$line_item->{'ext_description'}} );
1047         }
1048         $invoice_data{'amount'} = $line_item->{'amount'};
1049         $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1050         push @filled_in,
1051           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item;
1052       }
1053
1054     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1055
1056       while ( ( my $total_item_line = shift @invoice_template )
1057               !~ /^%%EndTotalDetails\s*$/                      ) {
1058         push @total_item, $total_item_line;
1059       }
1060
1061       my @total_fill = ();
1062
1063       my $taxtotal = 0;
1064       foreach my $tax ( $self->_items_tax ) {
1065         $invoice_data{'total_item'} = _latex_escape($tax->{'description'});
1066         $taxtotal += ( $invoice_data{'total_amount'} = $tax->{'amount'} );
1067         push @total_fill,
1068           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1069               @total_item;
1070       }
1071
1072       if ( $taxtotal ) {
1073         $invoice_data{'total_item'} = 'Sub-total';
1074         $invoice_data{'total_amount'} =
1075           '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
1076         unshift @total_fill,
1077           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1078               @total_item;
1079       }
1080
1081       $invoice_data{'total_item'} = '\textbf{Total}';
1082       $invoice_data{'total_amount'} =
1083         '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
1084       push @total_fill,
1085         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1086             @total_item;
1087
1088       #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1089
1090       # credits
1091       foreach my $credit ( $self->_items_credits ) {
1092         $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
1093         #$credittotal
1094         $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
1095         push @total_fill, 
1096           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1097               @total_item;
1098       }
1099
1100       # payments
1101       foreach my $payment ( $self->_items_payments ) {
1102         $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
1103         #$paymenttotal
1104         $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
1105         push @total_fill, 
1106           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1107               @total_item;
1108       }
1109
1110       $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
1111       $invoice_data{'total_amount'} =
1112         '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
1113       push @total_fill,
1114         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1115             @total_item;
1116
1117       push @filled_in, @total_fill;
1118
1119     } else {
1120       #$line =~ s/\$(\w+)/$invoice_data{$1}/eg;
1121       $line =~ s/\$(\w+)/exists($invoice_data{$1}) ? $invoice_data{$1} : nounder($1)/eg;
1122       push @filled_in, $line;
1123     }
1124
1125   }
1126
1127   sub nounder {
1128     my $var = $1;
1129     $var =~ s/_/\-/g;
1130     $var;
1131   }
1132
1133   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1134   my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
1135                            DIR      => $dir,
1136                            SUFFIX   => '.tex',
1137                            UNLINK   => 0,
1138                          ) or die "can't open temp file: $!\n";
1139   print $fh join("\n", @filled_in ), "\n";
1140   close $fh;
1141
1142   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
1143   return $1;
1144
1145 }
1146
1147 =item print_ps [ TIME [ , TEMPLATE ] ]
1148
1149 Returns an postscript invoice, as a scalar.
1150
1151 TIME an optional value used to control the printing of overdue messages.  The
1152 default is now.  It isn't the date of the invoice; that's the `_date' field.
1153 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1154 L<Time::Local> and L<Date::Parse> for conversion functions.
1155
1156 =cut
1157
1158 sub print_ps {
1159   my $self = shift;
1160
1161   my $file = $self->print_latex(@_);
1162
1163   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1164   chdir($dir);
1165
1166   my $sfile = shell_quote $file;
1167
1168   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1169     or die "pslatex $file.tex failed: $!";
1170   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1171     or die "pslatex $file.tex failed: $!";
1172
1173   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
1174     or die "dvips failed: $!";
1175
1176   open(POSTSCRIPT, "<$file.ps")
1177     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
1178
1179   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
1180
1181   my $ps = '';
1182   while (<POSTSCRIPT>) {
1183     $ps .= $_;
1184   }
1185
1186   close POSTSCRIPT;
1187
1188   return $ps;
1189
1190 }
1191
1192 =item print_pdf [ TIME [ , TEMPLATE ] ]
1193
1194 Returns an PDF invoice, as a scalar.
1195
1196 TIME an optional value used to control the printing of overdue messages.  The
1197 default is now.  It isn't the date of the invoice; that's the `_date' field.
1198 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1199 L<Time::Local> and L<Date::Parse> for conversion functions.
1200
1201 =cut
1202
1203 sub print_pdf {
1204   my $self = shift;
1205
1206   my $file = $self->print_latex(@_);
1207
1208   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1209   chdir($dir);
1210
1211   #system('pdflatex', "$file.tex");
1212   #system('pdflatex', "$file.tex");
1213   #! LaTeX Error: Unknown graphics extension: .eps.
1214
1215   my $sfile = shell_quote $file;
1216
1217   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1218     or die "pslatex $file.tex failed: $!";
1219   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1220     or die "pslatex $file.tex failed: $!";
1221
1222   #system('dvipdf', "$file.dvi", "$file.pdf" );
1223   system(
1224     "dvips -q -t letter -f $sfile.dvi ".
1225     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
1226     "     -c save pop -"
1227   ) == 0
1228     or die "dvips | gs failed: $!";
1229
1230   open(PDF, "<$file.pdf")
1231     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
1232
1233   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
1234
1235   my $pdf = '';
1236   while (<PDF>) {
1237     $pdf .= $_;
1238   }
1239
1240   close PDF;
1241
1242   return $pdf;
1243
1244 }
1245
1246 # quick subroutine for print_latex
1247 #
1248 # There are ten characters that LaTeX treats as special characters, which
1249 # means that they do not simply typeset themselves: 
1250 #      # $ % & ~ _ ^ \ { }
1251 #
1252 # TeX ignores blanks following an escaped character; if you want a blank (as
1253 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1254
1255 sub _latex_escape {
1256   my $value = shift;
1257   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( length($2) ? "\\$2" : '' )/ge;
1258   $value;
1259 }
1260
1261 #utility methods for print_*
1262
1263 sub balance_due_msg {
1264   my $self = shift;
1265   my $msg = 'Balance Due';
1266   return $msg unless $conf->exists('invoice_default_terms');
1267   if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
1268     $msg .= ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
1269   } elsif ( $conf->config('invoice_default_terms') ) {
1270     $msg .= ' - '. $conf->config('invoice_default_terms');
1271   }
1272   $msg;
1273 }
1274
1275 sub _items {
1276   my $self = shift;
1277   my @display = scalar(@_)
1278                 ? @_
1279                 : qw( _items_previous _items_pkg );
1280                 #: qw( _items_pkg );
1281                 #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
1282   my @b = ();
1283   foreach my $display ( @display ) {
1284     push @b, $self->$display(@_);
1285   }
1286   @b;
1287 }
1288
1289 sub _items_previous {
1290   my $self = shift;
1291   my $cust_main = $self->cust_main;
1292   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
1293   my @b = ();
1294   foreach ( @pr_cust_bill ) {
1295     push @b, {
1296       'description' => 'Previous Balance, Invoice #'. $_->invnum. 
1297                        ' ('. time2str('%x',$_->_date). ')',
1298       #'pkgpart'     => 'N/A',
1299       'pkgnum'      => 'N/A',
1300       'amount'      => sprintf("%10.2f", $_->owed),
1301     };
1302   }
1303   @b;
1304
1305   #{
1306   #    'description'     => 'Previous Balance',
1307   #    #'pkgpart'         => 'N/A',
1308   #    'pkgnum'          => 'N/A',
1309   #    'amount'          => sprintf("%10.2f", $pr_total ),
1310   #    'ext_description' => [ map {
1311   #                                 "Invoice ". $_->invnum.
1312   #                                 " (". time2str("%x",$_->_date). ") ".
1313   #                                 sprintf("%10.2f", $_->owed)
1314   #                         } @pr_cust_bill ],
1315
1316   #};
1317 }
1318
1319 sub _items_pkg {
1320   my $self = shift;
1321   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
1322   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1323 }
1324
1325 sub _items_tax {
1326   my $self = shift;
1327   my @cust_bill_pkg = grep { ! $_->pkgnum } $self->cust_bill_pkg;
1328   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1329 }
1330
1331 sub _items_cust_bill_pkg {
1332   my $self = shift;
1333   my $cust_bill_pkg = shift;
1334
1335   my @b = ();
1336   foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
1337
1338     if ( $cust_bill_pkg->pkgnum ) {
1339
1340       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
1341       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
1342       my $pkg = $part_pkg->pkg;
1343
1344       my %labels;
1345       #tie %labels, 'Tie::IxHash';
1346       push @{ $labels{$_->[0]} }, $_->[1] foreach $cust_pkg->labels;
1347       my @ext_description;
1348       foreach my $label ( keys %labels ) {
1349         my @values = @{ $labels{$label} };
1350         my $num = scalar(@values);
1351         if ( $num > 5 ) {
1352           push @ext_description, "$label ($num)";
1353         } else {
1354           push @ext_description, map { "$label: $_" } @values;
1355         }
1356       }
1357
1358       if ( $cust_bill_pkg->setup != 0 ) {
1359         my $description = $pkg;
1360         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
1361         my @d = @ext_description;
1362         push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
1363         push @b, {
1364           'description'     => $description,
1365           #'pkgpart'         => $part_pkg->pkgpart,
1366           'pkgnum'          => $cust_pkg->pkgnum,
1367           'amount'          => sprintf("%10.2f", $cust_bill_pkg->setup),
1368           'ext_description' => \@d,
1369         };
1370       }
1371
1372       if ( $cust_bill_pkg->recur != 0 ) {
1373         push @b, {
1374           'description'     => "$pkg (" .
1375                                time2str('%x', $cust_bill_pkg->sdate). ' - '.
1376                                time2str('%x', $cust_bill_pkg->edate). ')',
1377           #'pkgpart'         => $part_pkg->pkgpart,
1378           'pkgnum'          => $cust_pkg->pkgnum,
1379           'amount'          => sprintf("%10.2f", $cust_bill_pkg->recur),
1380           'ext_description' => [ @ext_description,
1381                                  $cust_bill_pkg->details,
1382                                ],
1383         };
1384       }
1385
1386     } else { #pkgnum tax or one-shot line item (??)
1387
1388       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
1389                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
1390                      : 'Tax';
1391       if ( $cust_bill_pkg->setup != 0 ) {
1392         push @b, {
1393           'description' => $itemdesc,
1394           'amount'      => sprintf("%10.2f", $cust_bill_pkg->setup),
1395         };
1396       }
1397       if ( $cust_bill_pkg->recur != 0 ) {
1398         push @b, {
1399           'description' => "$itemdesc (".
1400                            time2str("%x", $cust_bill_pkg->sdate). ' - '.
1401                            time2str("%x", $cust_bill_pkg->edate). ')',
1402           'amount'      => sprintf("%10.2f", $cust_bill_pkg->recur),
1403         };
1404       }
1405
1406     }
1407
1408   }
1409
1410   @b;
1411
1412 }
1413
1414 sub _items_credits {
1415   my $self = shift;
1416
1417   my @b;
1418   #credits
1419   foreach ( $self->cust_credited ) {
1420
1421     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
1422
1423     my $reason = $_->cust_credit->reason;
1424     #my $reason = substr($_->cust_credit->reason,0,32);
1425     #$reason .= '...' if length($reason) < length($_->cust_credit->reason);
1426     $reason = " ($reason) " if $reason;
1427     push @b, {
1428       #'description' => 'Credit ref\#'. $_->crednum.
1429       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
1430       #                 $reason,
1431       'description' => 'Credit applied '.
1432                        time2str("%x",$_->cust_credit->_date). $reason,
1433       'amount'      => sprintf("%10.2f",$_->amount),
1434     };
1435   }
1436   #foreach ( @cr_cust_credit ) {
1437   #  push @buf,[
1438   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
1439   #    $money_char. sprintf("%10.2f",$_->credited)
1440   #  ];
1441   #}
1442
1443   @b;
1444
1445 }
1446
1447 sub _items_payments {
1448   my $self = shift;
1449
1450   my @b;
1451   #get & print payments
1452   foreach ( $self->cust_bill_pay ) {
1453
1454     #something more elaborate if $_->amount ne ->cust_pay->paid ?
1455
1456     push @b, {
1457       'description' => "Payment received ".
1458                        time2str("%x",$_->cust_pay->_date ),
1459       'amount'      => sprintf("%10.2f", $_->amount )
1460     };
1461   }
1462
1463   @b;
1464
1465 }
1466
1467 =back
1468
1469 =head1 BUGS
1470
1471 The delete method.
1472
1473 print_text formatting (and some logic :/) is in source, but needs to be
1474 slurped in from a file.  Also number of lines ($=).
1475
1476 =head1 SEE ALSO
1477
1478 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
1479 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base
1480 documentation.
1481
1482 =cut
1483
1484 1;
1485