bb12de042e2befc6ca31ea80342062cf9f0deebb
[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 $cust_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 $cust_bill_event;
701
702   if ( $cust_bill_event->plandata =~ /^$option (.*)$/m ) {
703     return $1;
704   } else {
705     warn "can't parse plandata for $1";
706     return '';
707   }
708
709 }
710
711 =item print_text [ TIME [ , TEMPLATE ] ]
712
713 Returns an text invoice, as a list of lines.
714
715 TIME an optional value used to control the printing of overdue messages.  The
716 default is now.  It isn't the date of the invoice; that's the `_date' field.
717 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
718 L<Time::Local> and L<Date::Parse> for conversion functions.
719
720 =cut
721
722 sub print_text {
723
724   my( $self, $today, $template ) = @_;
725   $today ||= time;
726 #  my $invnum = $self->invnum;
727   my $cust_main = qsearchs('cust_main', { 'custnum', $self->custnum } );
728   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
729     unless $cust_main->payname && $cust_main->payby ne 'CHEK';
730
731   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
732 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
733   #my $balance_due = $self->owed + $pr_total - $cr_total;
734   my $balance_due = $self->owed + $pr_total;
735
736   #my @collect = ();
737   #my($description,$amount);
738   @buf = ();
739
740   #previous balance
741   foreach ( @pr_cust_bill ) {
742     push @buf, [
743       "Previous Balance, Invoice #". $_->invnum. 
744                  " (". time2str("%x",$_->_date). ")",
745       $money_char. sprintf("%10.2f",$_->owed)
746     ];
747   }
748   if (@pr_cust_bill) {
749     push @buf,['','-----------'];
750     push @buf,[ 'Total Previous Balance',
751                 $money_char. sprintf("%10.2f",$pr_total ) ];
752     push @buf,['',''];
753   }
754
755   #new charges
756   foreach my $cust_bill_pkg (
757     ( grep {   $_->pkgnum } $self->cust_bill_pkg ),  #packages first
758     ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
759   ) {
760
761     if ( $cust_bill_pkg->pkgnum ) {
762
763       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
764       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
765       my $pkg = $part_pkg->pkg;
766
767       if ( $cust_bill_pkg->setup != 0 ) {
768         my $description = $pkg;
769         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
770         push @buf, [ $description,
771                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
772         push @buf,
773           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
774       }
775
776       if ( $cust_bill_pkg->recur != 0 ) {
777         push @buf, [
778           "$pkg (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
779                                 time2str("%x", $cust_bill_pkg->edate) . ")",
780           $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
781         ];
782         push @buf,
783           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
784       }
785
786       push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
787
788     } else { #pkgnum tax or one-shot line item
789       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
790                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
791                      : 'Tax';
792       if ( $cust_bill_pkg->setup != 0 ) {
793         push @buf, [ $itemdesc,
794                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
795       }
796       if ( $cust_bill_pkg->recur != 0 ) {
797         push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - "
798                                   . time2str("%x", $cust_bill_pkg->edate). ")",
799                      $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
800                    ];
801       }
802     }
803   }
804
805   push @buf,['','-----------'];
806   push @buf,['Total New Charges',
807              $money_char. sprintf("%10.2f",$self->charged) ];
808   push @buf,['',''];
809
810   push @buf,['','-----------'];
811   push @buf,['Total Charges',
812              $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
813   push @buf,['',''];
814
815   #credits
816   foreach ( $self->cust_credited ) {
817
818     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
819
820     my $reason = substr($_->cust_credit->reason,0,32);
821     $reason .= '...' if length($reason) < length($_->cust_credit->reason);
822     $reason = " ($reason) " if $reason;
823     push @buf,[
824       "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".
825         $reason,
826       $money_char. sprintf("%10.2f",$_->amount)
827     ];
828   }
829   #foreach ( @cr_cust_credit ) {
830   #  push @buf,[
831   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
832   #    $money_char. sprintf("%10.2f",$_->credited)
833   #  ];
834   #}
835
836   #get & print payments
837   foreach ( $self->cust_bill_pay ) {
838
839     #something more elaborate if $_->amount ne ->cust_pay->paid ?
840
841     push @buf,[
842       "Payment received ". time2str("%x",$_->cust_pay->_date ),
843       $money_char. sprintf("%10.2f",$_->amount )
844     ];
845   }
846
847   #balance due
848   my $balance_due_msg = $self->balance_due_msg;
849
850   push @buf,['','-----------'];
851   push @buf,[$balance_due_msg, $money_char. 
852     sprintf("%10.2f", $balance_due ) ];
853
854   #create the template
855   $template ||= $self->_agent_template;
856   my $templatefile = 'invoice_template';
857   $templatefile .= "_$template" if length($template);
858   my @invoice_template = $conf->config($templatefile)
859     or die "cannot load config file $templatefile";
860   $invoice_lines = 0;
861   my $wasfunc = 0;
862   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
863     /invoice_lines\((\d*)\)/;
864     $invoice_lines += $1 || scalar(@buf);
865     $wasfunc=1;
866   }
867   die "no invoice_lines() functions in template?" unless $wasfunc;
868   my $invoice_template = new Text::Template (
869     TYPE   => 'ARRAY',
870     SOURCE => [ map "$_\n", @invoice_template ],
871   ) or die "can't create new Text::Template object: $Text::Template::ERROR";
872   $invoice_template->compile()
873     or die "can't compile template: $Text::Template::ERROR";
874
875   #setup template variables
876   package FS::cust_bill::_template; #!
877   use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent );
878
879   $invnum = $self->invnum;
880   $date = $self->_date;
881   $page = 1;
882   $agent = $self->cust_main->agent->agent;
883
884   if ( $FS::cust_bill::invoice_lines ) {
885     $total_pages =
886       int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines );
887     $total_pages++
888       if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines;
889   } else {
890     $total_pages = 1;
891   }
892
893   #format address (variable for the template)
894   my $l = 0;
895   @address = ( '', '', '', '', '', '' );
896   package FS::cust_bill; #!
897   $FS::cust_bill::_template::address[$l++] =
898     $cust_main->payname.
899       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
900         ? " (P.O. #". $cust_main->payinfo. ")"
901         : ''
902       )
903   ;
904   $FS::cust_bill::_template::address[$l++] = $cust_main->company
905     if $cust_main->company;
906   $FS::cust_bill::_template::address[$l++] = $cust_main->address1;
907   $FS::cust_bill::_template::address[$l++] = $cust_main->address2
908     if $cust_main->address2;
909   $FS::cust_bill::_template::address[$l++] =
910     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
911   $FS::cust_bill::_template::address[$l++] = $cust_main->country
912     unless $cust_main->country eq 'US';
913
914         #  #overdue? (variable for the template)
915         #  $FS::cust_bill::_template::overdue = ( 
916         #    $balance_due > 0
917         #    && $today > $self->_date 
918         ##    && $self->printed > 1
919         #    && $self->printed > 0
920         #  );
921
922   #and subroutine for the template
923   sub FS::cust_bill::_template::invoice_lines {
924     my $lines = shift || scalar(@buf);
925     map { 
926       scalar(@buf) ? shift @buf : [ '', '' ];
927     }
928     ( 1 .. $lines );
929   }
930
931   #and fill it in
932   $FS::cust_bill::_template::page = 1;
933   my $lines;
934   my @collect;
935   while (@buf) {
936     push @collect, split("\n",
937       $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' )
938     );
939     $FS::cust_bill::_template::page++;
940   }
941
942   map "$_\n", @collect;
943
944 }
945
946 =item print_latex [ TIME [ , TEMPLATE ] ]
947
948 Internal method - returns a filename of a filled-in LaTeX template for this
949 invoice (Note: add ".tex" to get the actual filename).
950
951 See print_ps and print_pdf for methods that return PostScript and PDF output.
952
953 TIME an optional value used to control the printing of overdue messages.  The
954 default is now.  It isn't the date of the invoice; that's the `_date' field.
955 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
956 L<Time::Local> and L<Date::Parse> for conversion functions.
957
958 =cut
959
960 #still some false laziness w/print_text
961 sub print_latex {
962
963   my( $self, $today, $template ) = @_;
964   $today ||= time;
965
966 #  my $invnum = $self->invnum;
967   my $cust_main = $self->cust_main;
968   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
969     unless $cust_main->payname && $cust_main->payby ne 'CHEK';
970
971   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
972 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
973   #my $balance_due = $self->owed + $pr_total - $cr_total;
974   my $balance_due = $self->owed + $pr_total;
975
976   #my @collect = ();
977   #my($description,$amount);
978   @buf = ();
979
980   #create the template
981   $template ||= $self->_agent_template;
982   my $templatefile = 'invoice_latex';
983   my $suffix = length($template) ? "_$template" : '';
984   $templatefile .= $suffix;
985   my @invoice_template = $conf->config($templatefile)
986     or die "cannot load config file $templatefile";
987
988   my %invoice_data = (
989     'invnum'       => $self->invnum,
990     'date'         => time2str('%b %o, %Y', $self->_date),
991     'agent'        => _latex_escape($cust_main->agent->agent),
992     'payname'      => _latex_escape($cust_main->payname),
993     'company'      => _latex_escape($cust_main->company),
994     'address1'     => _latex_escape($cust_main->address1),
995     'address2'     => _latex_escape($cust_main->address2),
996     'city'         => _latex_escape($cust_main->city),
997     'state'        => _latex_escape($cust_main->state),
998     'zip'          => _latex_escape($cust_main->zip),
999     'country'      => _latex_escape($cust_main->country),
1000     'footer'       => join("\n", $conf->config('invoice_latexfooter') ),
1001     'smallfooter'  => join("\n", $conf->config('invoice_latexsmallfooter') ),
1002     'quantity'     => 1,
1003     'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
1004     #'notes'        => join("\n", $conf->config('invoice_latexnotes') ),
1005   );
1006
1007   my $countrydefault = $conf->config('countrydefault') || 'US';
1008   $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault;
1009
1010   #do variable substitutions in notes
1011   $invoice_data{'notes'} =
1012     join("\n",
1013       map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1014         $conf->config_orbase('invoice_latexnotes', $suffix)
1015     );
1016
1017   $invoice_data{'footer'} =~ s/\n+$//;
1018   $invoice_data{'smallfooter'} =~ s/\n+$//;
1019   $invoice_data{'notes'} =~ s/\n+$//;
1020
1021   $invoice_data{'po_line'} =
1022     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
1023       ? _latex_escape("Purchase Order #". $cust_main->payinfo)
1024       : '~';
1025
1026   my @line_item = ();
1027   my @total_item = ();
1028   my @filled_in = ();
1029   while ( @invoice_template ) {
1030     my $line = shift @invoice_template;
1031
1032     if ( $line =~ /^%%Detail\s*$/ ) {
1033
1034       while ( ( my $line_item_line = shift @invoice_template )
1035               !~ /^%%EndDetail\s*$/                            ) {
1036         push @line_item, $line_item_line;
1037       }
1038       foreach my $line_item ( $self->_items ) {
1039       #foreach my $line_item ( $self->_items_pkg ) {
1040         $invoice_data{'ref'} = $line_item->{'pkgnum'};
1041         $invoice_data{'description'} = _latex_escape($line_item->{'description'});
1042         if ( exists $line_item->{'ext_description'} ) {
1043           $invoice_data{'description'} .=
1044             "\\tabularnewline\n~~".
1045             join("\\tabularnewline\n~~", map { _latex_escape($_) } @{$line_item->{'ext_description'}} );
1046         }
1047         $invoice_data{'amount'} = $line_item->{'amount'};
1048         $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1049         push @filled_in,
1050           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item;
1051       }
1052
1053     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1054
1055       while ( ( my $total_item_line = shift @invoice_template )
1056               !~ /^%%EndTotalDetails\s*$/                      ) {
1057         push @total_item, $total_item_line;
1058       }
1059
1060       my @total_fill = ();
1061
1062       my $taxtotal = 0;
1063       foreach my $tax ( $self->_items_tax ) {
1064         $invoice_data{'total_item'} = _latex_escape($tax->{'description'});
1065         $taxtotal += ( $invoice_data{'total_amount'} = $tax->{'amount'} );
1066         push @total_fill,
1067           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1068               @total_item;
1069       }
1070
1071       if ( $taxtotal ) {
1072         $invoice_data{'total_item'} = 'Sub-total';
1073         $invoice_data{'total_amount'} =
1074           '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
1075         unshift @total_fill,
1076           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1077               @total_item;
1078       }
1079
1080       $invoice_data{'total_item'} = '\textbf{Total}';
1081       $invoice_data{'total_amount'} =
1082         '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
1083       push @total_fill,
1084         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1085             @total_item;
1086
1087       #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1088
1089       # credits
1090       foreach my $credit ( $self->_items_credits ) {
1091         $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
1092         #$credittotal
1093         $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
1094         push @total_fill, 
1095           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1096               @total_item;
1097       }
1098
1099       # payments
1100       foreach my $payment ( $self->_items_payments ) {
1101         $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
1102         #$paymenttotal
1103         $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
1104         push @total_fill, 
1105           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1106               @total_item;
1107       }
1108
1109       $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
1110       $invoice_data{'total_amount'} =
1111         '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
1112       push @total_fill,
1113         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1114             @total_item;
1115
1116       push @filled_in, @total_fill;
1117
1118     } else {
1119       #$line =~ s/\$(\w+)/$invoice_data{$1}/eg;
1120       $line =~ s/\$(\w+)/exists($invoice_data{$1}) ? $invoice_data{$1} : nounder($1)/eg;
1121       push @filled_in, $line;
1122     }
1123
1124   }
1125
1126   sub nounder {
1127     my $var = $1;
1128     $var =~ s/_/\-/g;
1129     $var;
1130   }
1131
1132   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1133   my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
1134                            DIR      => $dir,
1135                            SUFFIX   => '.tex',
1136                            UNLINK   => 0,
1137                          ) or die "can't open temp file: $!\n";
1138   print $fh join("\n", @filled_in ), "\n";
1139   close $fh;
1140
1141   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
1142   return $1;
1143
1144 }
1145
1146 =item print_ps [ TIME [ , TEMPLATE ] ]
1147
1148 Returns an postscript invoice, as a scalar.
1149
1150 TIME an optional value used to control the printing of overdue messages.  The
1151 default is now.  It isn't the date of the invoice; that's the `_date' field.
1152 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1153 L<Time::Local> and L<Date::Parse> for conversion functions.
1154
1155 =cut
1156
1157 sub print_ps {
1158   my $self = shift;
1159
1160   my $file = $self->print_latex(@_);
1161
1162   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1163   chdir($dir);
1164
1165   system("pslatex $file.tex >/dev/null 2>&1") == 0
1166     or die "pslatex $file.tex failed: $!";
1167   system("pslatex $file.tex >/dev/null 2>&1") == 0
1168     or die "pslatex $file.tex failed: $!";
1169
1170   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
1171     or die "dvips failed: $!";
1172
1173   open(POSTSCRIPT, "<$file.ps")
1174     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
1175
1176   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
1177
1178   my $ps = '';
1179   while (<POSTSCRIPT>) {
1180     $ps .= $_;
1181   }
1182
1183   close POSTSCRIPT;
1184
1185   return $ps;
1186
1187 }
1188
1189 =item print_pdf [ TIME [ , TEMPLATE ] ]
1190
1191 Returns an PDF invoice, as a scalar.
1192
1193 TIME an optional value used to control the printing of overdue messages.  The
1194 default is now.  It isn't the date of the invoice; that's the `_date' field.
1195 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1196 L<Time::Local> and L<Date::Parse> for conversion functions.
1197
1198 =cut
1199
1200 sub print_pdf {
1201   my $self = shift;
1202
1203   my $file = $self->print_latex(@_);
1204
1205   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1206   chdir($dir);
1207
1208   #system('pdflatex', "$file.tex");
1209   #system('pdflatex', "$file.tex");
1210   #! LaTeX Error: Unknown graphics extension: .eps.
1211
1212   system("pslatex $file.tex >/dev/null 2>&1") == 0
1213     or die "pslatex $file.tex failed: $!";
1214   system("pslatex $file.tex >/dev/null 2>&1") == 0
1215     or die "pslatex $file.tex failed: $!";
1216
1217   #system('dvipdf', "$file.dvi", "$file.pdf" );
1218   my $sfile = shell_quote $file;
1219   system(
1220     "dvips -q -t letter -f $sfile.dvi ".
1221     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
1222     "     -c save pop -"
1223   ) == 0
1224     or die "dvips | gs failed: $!";
1225
1226   open(PDF, "<$file.pdf")
1227     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
1228
1229   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
1230
1231   my $pdf = '';
1232   while (<PDF>) {
1233     $pdf .= $_;
1234   }
1235
1236   close PDF;
1237
1238   return $pdf;
1239
1240 }
1241
1242 # quick subroutine for print_latex
1243 #
1244 # There are ten characters that LaTeX treats as special characters, which
1245 # means that they do not simply typeset themselves: 
1246 #      # $ % & ~ _ ^ \ { }
1247 #
1248 # TeX ignores blanks following an escaped character; if you want a blank (as
1249 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1250
1251 sub _latex_escape {
1252   my $value = shift;
1253   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( length($2) ? "\\$2" : '' )/ge;
1254   $value;
1255 }
1256
1257 #utility methods for print_*
1258
1259 sub balance_due_msg {
1260   my $self = shift;
1261   my $msg = 'Balance Due';
1262   return $msg unless $conf->exists('invoice_default_terms');
1263   if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
1264     $msg .= ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
1265   } elsif ( $conf->config('invoice_default_terms') ) {
1266     $msg .= ' - '. $conf->config('invoice_default_terms');
1267   }
1268   $msg;
1269 }
1270
1271 sub _items {
1272   my $self = shift;
1273   my @display = scalar(@_)
1274                 ? @_
1275                 : qw( _items_previous _items_pkg );
1276                 #: qw( _items_pkg );
1277                 #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
1278   my @b = ();
1279   foreach my $display ( @display ) {
1280     push @b, $self->$display(@_);
1281   }
1282   @b;
1283 }
1284
1285 sub _items_previous {
1286   my $self = shift;
1287   my $cust_main = $self->cust_main;
1288   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
1289   my @b = ();
1290   foreach ( @pr_cust_bill ) {
1291     push @b, {
1292       'description' => 'Previous Balance, Invoice #'. $_->invnum. 
1293                        ' ('. time2str('%x',$_->_date). ')',
1294       #'pkgpart'     => 'N/A',
1295       'pkgnum'      => 'N/A',
1296       'amount'      => sprintf("%10.2f", $_->owed),
1297     };
1298   }
1299   @b;
1300
1301   #{
1302   #    'description'     => 'Previous Balance',
1303   #    #'pkgpart'         => 'N/A',
1304   #    'pkgnum'          => 'N/A',
1305   #    'amount'          => sprintf("%10.2f", $pr_total ),
1306   #    'ext_description' => [ map {
1307   #                                 "Invoice ". $_->invnum.
1308   #                                 " (". time2str("%x",$_->_date). ") ".
1309   #                                 sprintf("%10.2f", $_->owed)
1310   #                         } @pr_cust_bill ],
1311
1312   #};
1313 }
1314
1315 sub _items_pkg {
1316   my $self = shift;
1317   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
1318   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1319 }
1320
1321 sub _items_tax {
1322   my $self = shift;
1323   my @cust_bill_pkg = grep { ! $_->pkgnum } $self->cust_bill_pkg;
1324   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1325 }
1326
1327 sub _items_cust_bill_pkg {
1328   my $self = shift;
1329   my $cust_bill_pkg = shift;
1330
1331   my @b = ();
1332   foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
1333
1334     if ( $cust_bill_pkg->pkgnum ) {
1335
1336       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
1337       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
1338       my $pkg = $part_pkg->pkg;
1339
1340       my %labels;
1341       #tie %labels, 'Tie::IxHash';
1342       push @{ $labels{$_->[0]} }, $_->[1] foreach $cust_pkg->labels;
1343       my @ext_description;
1344       foreach my $label ( keys %labels ) {
1345         my @values = @{ $labels{$label} };
1346         my $num = scalar(@values);
1347         if ( $num > 5 ) {
1348           push @ext_description, "$label ($num)";
1349         } else {
1350           push @ext_description, map { "$label: $_" } @values;
1351         }
1352       }
1353
1354       if ( $cust_bill_pkg->setup != 0 ) {
1355         my $description = $pkg;
1356         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
1357         my @d = @ext_description;
1358         push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
1359         push @b, {
1360           'description'     => $description,
1361           #'pkgpart'         => $part_pkg->pkgpart,
1362           'pkgnum'          => $cust_pkg->pkgnum,
1363           'amount'          => sprintf("%10.2f", $cust_bill_pkg->setup),
1364           'ext_description' => \@d,
1365         };
1366       }
1367
1368       if ( $cust_bill_pkg->recur != 0 ) {
1369         push @b, {
1370           'description'     => "$pkg (" .
1371                                time2str('%x', $cust_bill_pkg->sdate). ' - '.
1372                                time2str('%x', $cust_bill_pkg->edate). ')',
1373           #'pkgpart'         => $part_pkg->pkgpart,
1374           'pkgnum'          => $cust_pkg->pkgnum,
1375           'amount'          => sprintf("%10.2f", $cust_bill_pkg->recur),
1376           'ext_description' => [ @ext_description,
1377                                  $cust_bill_pkg->details,
1378                                ],
1379         };
1380       }
1381
1382     } else { #pkgnum tax or one-shot line item (??)
1383
1384       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
1385                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
1386                      : 'Tax';
1387       if ( $cust_bill_pkg->setup != 0 ) {
1388         push @b, {
1389           'description' => $itemdesc,
1390           'amount'      => sprintf("%10.2f", $cust_bill_pkg->setup),
1391         };
1392       }
1393       if ( $cust_bill_pkg->recur != 0 ) {
1394         push @b, {
1395           'description' => "$itemdesc (".
1396                            time2str("%x", $cust_bill_pkg->sdate). ' - '.
1397                            time2str("%x", $cust_bill_pkg->edate). ')',
1398           'amount'      => sprintf("%10.2f", $cust_bill_pkg->recur),
1399         };
1400       }
1401
1402     }
1403
1404   }
1405
1406   @b;
1407
1408 }
1409
1410 sub _items_credits {
1411   my $self = shift;
1412
1413   my @b;
1414   #credits
1415   foreach ( $self->cust_credited ) {
1416
1417     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
1418
1419     my $reason = $_->cust_credit->reason;
1420     #my $reason = substr($_->cust_credit->reason,0,32);
1421     #$reason .= '...' if length($reason) < length($_->cust_credit->reason);
1422     $reason = " ($reason) " if $reason;
1423     push @b, {
1424       #'description' => 'Credit ref\#'. $_->crednum.
1425       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
1426       #                 $reason,
1427       'description' => 'Credit applied '.
1428                        time2str("%x",$_->cust_credit->_date). $reason,
1429       'amount'      => sprintf("%10.2f",$_->amount),
1430     };
1431   }
1432   #foreach ( @cr_cust_credit ) {
1433   #  push @buf,[
1434   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
1435   #    $money_char. sprintf("%10.2f",$_->credited)
1436   #  ];
1437   #}
1438
1439   @b;
1440
1441 }
1442
1443 sub _items_payments {
1444   my $self = shift;
1445
1446   my @b;
1447   #get & print payments
1448   foreach ( $self->cust_bill_pay ) {
1449
1450     #something more elaborate if $_->amount ne ->cust_pay->paid ?
1451
1452     push @b, {
1453       'description' => "Payment received ".
1454                        time2str("%x",$_->cust_pay->_date ),
1455       'amount'      => sprintf("%10.2f", $_->amount )
1456     };
1457   }
1458
1459   @b;
1460
1461 }
1462
1463 =back
1464
1465 =head1 BUGS
1466
1467 The delete method.
1468
1469 print_text formatting (and some logic :/) is in source, but needs to be
1470 slurped in from a file.  Also number of lines ($=).
1471
1472 =head1 SEE ALSO
1473
1474 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
1475 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base
1476 documentation.
1477
1478 =cut
1479
1480 1;
1481