add 'send_if_newest' invoice event, closes: Bug#977
[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_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
381
382 Like B<send>, but only sends the invoice if it is the newest open invoice for
383 this customer.
384
385 =cut
386
387 sub send_if_newest {
388   my $self = shift;
389
390   return ''
391     if scalar(
392                grep { $_->owed > 0 } 
393                     qsearch('cust_bill', {
394                       'custnum' => $self->custnum,
395                       #'_date'   => { op=>'>', value=>$self->_date },
396                       'invnum'  => { op=>'>', value=>$self->invnum },
397                     } )
398              );
399     
400   $self->send(@_);
401 }
402
403 =item send_csv OPTIONS
404
405 Sends invoice as a CSV data-file to a remote host with the specified protocol.
406
407 Options are:
408
409 protocol - currently only "ftp"
410 server
411 username
412 password
413 dir
414
415 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
416 and YYMMDDHHMMSS is a timestamp.
417
418 The fields of the CSV file is as follows:
419
420 record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
421
422 =over 4
423
424 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
425
426 If B<record_type> is C<cust_bill>, this is a primary invoice record.  The
427 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
428 fields are filled in.
429
430 If B<record_type> is C<cust_bill_pkg>, this is a line item record.  Only the
431 first two fields (B<record_type> and B<invnum>) and the last five fields
432 (B<pkg> through B<edate>) are filled in.
433
434 =item invnum - invoice number
435
436 =item custnum - customer number
437
438 =item _date - invoice date
439
440 =item charged - total invoice amount
441
442 =item first - customer first name
443
444 =item last - customer first name
445
446 =item company - company name
447
448 =item address1 - address line 1
449
450 =item address2 - address line 1
451
452 =item city
453
454 =item state
455
456 =item zip
457
458 =item country
459
460 =item pkg - line item description
461
462 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
463
464 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
465
466 =item sdate - start date for recurring fee
467
468 =item edate - end date for recurring fee
469
470 =back
471
472 =cut
473
474 sub send_csv {
475   my($self, %opt) = @_;
476
477   #part one: create file
478
479   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
480   mkdir $spooldir, 0700 unless -d $spooldir;
481
482   my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
483
484   open(CSV, ">$file") or die "can't open $file: $!";
485
486   eval "use Text::CSV_XS";
487   die $@ if $@;
488
489   my $csv = Text::CSV_XS->new({'always_quote'=>1});
490
491   my $cust_main = $self->cust_main;
492
493   $csv->combine(
494     'cust_bill',
495     $self->invnum,
496     $self->custnum,
497     time2str("%x", $self->_date),
498     sprintf("%.2f", $self->charged),
499     ( map { $cust_main->getfield($_) }
500         qw( first last company address1 address2 city state zip country ) ),
501     map { '' } (1..5),
502   ) or die "can't create csv";
503   print CSV $csv->string. "\n";
504
505   #new charges (false laziness w/print_text)
506   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
507
508     my($pkg, $setup, $recur, $sdate, $edate);
509     if ( $cust_bill_pkg->pkgnum ) {
510     
511       ($pkg, $setup, $recur, $sdate, $edate) = (
512         $cust_bill_pkg->cust_pkg->part_pkg->pkg,
513         ( $cust_bill_pkg->setup != 0
514           ? sprintf("%.2f", $cust_bill_pkg->setup )
515           : '' ),
516         ( $cust_bill_pkg->recur != 0
517           ? sprintf("%.2f", $cust_bill_pkg->recur )
518           : '' ),
519         time2str("%x", $cust_bill_pkg->sdate),
520         time2str("%x", $cust_bill_pkg->edate),
521       );
522
523     } else { #pkgnum tax
524       next unless $cust_bill_pkg->setup != 0;
525       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
526                        ? ( $cust_bill_pkg->itemdesc || 'Tax' )
527                        : 'Tax';
528       ($pkg, $setup, $recur, $sdate, $edate) =
529         ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
530     }
531
532     $csv->combine(
533       'cust_bill_pkg',
534       $self->invnum,
535       ( map { '' } (1..11) ),
536       ($pkg, $setup, $recur, $sdate, $edate)
537     ) or die "can't create csv";
538     print CSV $csv->string. "\n";
539
540   }
541
542   close CSV or die "can't close CSV: $!";
543
544   #part two: upload it
545
546   my $net;
547   if ( $opt{protocol} eq 'ftp' ) {
548     eval "use Net::FTP;";
549     die $@ if $@;
550     $net = Net::FTP->new($opt{server}) or die @$;
551   } else {
552     die "unknown protocol: $opt{protocol}";
553   }
554
555   $net->login( $opt{username}, $opt{password} )
556     or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
557
558   $net->binary or die "can't set binary mode";
559
560   $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
561
562   $net->put($file) or die "can't put $file: $!";
563
564   $net->quit;
565
566   unlink $file;
567
568 }
569
570 =item comp
571
572 Pays this invoice with a compliemntary payment.  If there is an error,
573 returns the error, otherwise returns false.
574
575 =cut
576
577 sub comp {
578   my $self = shift;
579   my $cust_pay = new FS::cust_pay ( {
580     'invnum'   => $self->invnum,
581     'paid'     => $self->owed,
582     '_date'    => '',
583     'payby'    => 'COMP',
584     'payinfo'  => $self->cust_main->payinfo,
585     'paybatch' => '',
586   } );
587   $cust_pay->insert;
588 }
589
590 =item realtime_card
591
592 Attempts to pay this invoice with a credit card payment via a
593 Business::OnlinePayment realtime gateway.  See
594 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
595 for supported processors.
596
597 =cut
598
599 sub realtime_card {
600   my $self = shift;
601   $self->realtime_bop( 'CC', @_ );
602 }
603
604 =item realtime_ach
605
606 Attempts to pay this invoice with an electronic check (ACH) payment via a
607 Business::OnlinePayment realtime gateway.  See
608 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
609 for supported processors.
610
611 =cut
612
613 sub realtime_ach {
614   my $self = shift;
615   $self->realtime_bop( 'ECHECK', @_ );
616 }
617
618 =item realtime_lec
619
620 Attempts to pay this invoice with phone bill (LEC) payment via a
621 Business::OnlinePayment realtime gateway.  See
622 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
623 for supported processors.
624
625 =cut
626
627 sub realtime_lec {
628   my $self = shift;
629   $self->realtime_bop( 'LEC', @_ );
630 }
631
632 sub realtime_bop {
633   my( $self, $method ) = @_;
634
635   my $cust_main = $self->cust_main;
636   my $balance = $cust_main->balance;
637   my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
638   $amount = sprintf("%.2f", $amount);
639   return "not run (balance $balance)" unless $amount > 0;
640
641   my $description = 'Internet Services';
642   if ( $conf->exists('business-onlinepayment-description') ) {
643     my $dtempl = $conf->config('business-onlinepayment-description');
644
645     my $agent_obj = $cust_main->agent
646       or die "can't retreive agent for $cust_main (agentnum ".
647              $cust_main->agentnum. ")";
648     my $agent = $agent_obj->agent;
649     my $pkgs = join(', ',
650       map { $_->cust_pkg->part_pkg->pkg }
651         grep { $_->pkgnum } $self->cust_bill_pkg
652     );
653     $description = eval qq("$dtempl");
654   }
655
656   $cust_main->realtime_bop($method, $amount,
657     'description' => $description,
658     'invnum'      => $self->invnum,
659   );
660
661 }
662
663 =item batch_card
664
665 Adds a payment for this invoice to the pending credit card batch (see
666 L<FS::cust_pay_batch>).
667
668 =cut
669
670 sub batch_card {
671   my $self = shift;
672   my $cust_main = $self->cust_main;
673
674   my $cust_pay_batch = new FS::cust_pay_batch ( {
675     'invnum'   => $self->getfield('invnum'),
676     'custnum'  => $cust_main->getfield('custnum'),
677     'last'     => $cust_main->getfield('last'),
678     'first'    => $cust_main->getfield('first'),
679     'address1' => $cust_main->getfield('address1'),
680     'address2' => $cust_main->getfield('address2'),
681     'city'     => $cust_main->getfield('city'),
682     'state'    => $cust_main->getfield('state'),
683     'zip'      => $cust_main->getfield('zip'),
684     'country'  => $cust_main->getfield('country'),
685     'cardnum'  => $cust_main->getfield('payinfo'),
686     'exp'      => $cust_main->getfield('paydate'),
687     'payname'  => $cust_main->getfield('payname'),
688     'amount'   => $self->owed,
689   } );
690   my $error = $cust_pay_batch->insert;
691   die $error if $error;
692
693   '';
694 }
695
696 sub _agent_template {
697   my $self = shift;
698   $self->_agent_plandata('agent_templatename');
699 }
700
701 sub _agent_invoice_from {
702   my $self = shift;
703   $self->_agent_plandata('agent_invoice_from');
704 }
705
706 sub _agent_plandata {
707   my( $self, $option ) = @_;
708
709   my $part_bill_event = qsearchs( 'part_bill_event',
710     {
711       'payby'     => $self->cust_main->payby,
712       'plan'      => 'send_agent',
713       'plandata'  => { 'op'    => '~',
714                        'value' => "(^|\n)agentnum ".
715                                   $self->cust_main->agentnum.
716                                   "(\n|\$)",
717                      },
718     },
719     '',
720     'ORDER BY seconds LIMIT 1'
721   );
722
723   return '' unless $part_bill_event;
724
725   if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
726     return $1;
727   } else {
728     warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
729          " plandata for $option";
730     return '';
731   }
732
733 }
734
735 =item print_text [ TIME [ , TEMPLATE ] ]
736
737 Returns an text invoice, as a list of lines.
738
739 TIME an optional value used to control the printing of overdue messages.  The
740 default is now.  It isn't the date of the invoice; that's the `_date' field.
741 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
742 L<Time::Local> and L<Date::Parse> for conversion functions.
743
744 =cut
745
746 #still some false laziness w/print_text
747 sub print_text {
748
749   my( $self, $today, $template ) = @_;
750   $today ||= time;
751
752 #  my $invnum = $self->invnum;
753   my $cust_main = $self->cust_main;
754   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
755     unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
756
757   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
758 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
759   #my $balance_due = $self->owed + $pr_total - $cr_total;
760   my $balance_due = $self->owed + $pr_total;
761
762   #my @collect = ();
763   #my($description,$amount);
764   @buf = ();
765
766   #previous balance
767   foreach ( @pr_cust_bill ) {
768     push @buf, [
769       "Previous Balance, Invoice #". $_->invnum. 
770                  " (". time2str("%x",$_->_date). ")",
771       $money_char. sprintf("%10.2f",$_->owed)
772     ];
773   }
774   if (@pr_cust_bill) {
775     push @buf,['','-----------'];
776     push @buf,[ 'Total Previous Balance',
777                 $money_char. sprintf("%10.2f",$pr_total ) ];
778     push @buf,['',''];
779   }
780
781   #new charges
782   foreach my $cust_bill_pkg (
783     ( grep {   $_->pkgnum } $self->cust_bill_pkg ),  #packages first
784     ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
785   ) {
786
787     if ( $cust_bill_pkg->pkgnum ) {
788
789       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
790       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
791       my $pkg = $part_pkg->pkg;
792
793       if ( $cust_bill_pkg->setup != 0 ) {
794         my $description = $pkg;
795         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
796         push @buf, [ $description,
797                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
798         push @buf,
799           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
800       }
801
802       if ( $cust_bill_pkg->recur != 0 ) {
803         push @buf, [
804           "$pkg (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
805                                 time2str("%x", $cust_bill_pkg->edate) . ")",
806           $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
807         ];
808         push @buf,
809           map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
810       }
811
812       push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
813
814     } else { #pkgnum tax or one-shot line item
815       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
816                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
817                      : 'Tax';
818       if ( $cust_bill_pkg->setup != 0 ) {
819         push @buf, [ $itemdesc,
820                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
821       }
822       if ( $cust_bill_pkg->recur != 0 ) {
823         push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - "
824                                   . time2str("%x", $cust_bill_pkg->edate). ")",
825                      $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
826                    ];
827       }
828     }
829   }
830
831   push @buf,['','-----------'];
832   push @buf,['Total New Charges',
833              $money_char. sprintf("%10.2f",$self->charged) ];
834   push @buf,['',''];
835
836   push @buf,['','-----------'];
837   push @buf,['Total Charges',
838              $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
839   push @buf,['',''];
840
841   #credits
842   foreach ( $self->cust_credited ) {
843
844     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
845
846     my $reason = substr($_->cust_credit->reason,0,32);
847     $reason .= '...' if length($reason) < length($_->cust_credit->reason);
848     $reason = " ($reason) " if $reason;
849     push @buf,[
850       "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".
851         $reason,
852       $money_char. sprintf("%10.2f",$_->amount)
853     ];
854   }
855   #foreach ( @cr_cust_credit ) {
856   #  push @buf,[
857   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
858   #    $money_char. sprintf("%10.2f",$_->credited)
859   #  ];
860   #}
861
862   #get & print payments
863   foreach ( $self->cust_bill_pay ) {
864
865     #something more elaborate if $_->amount ne ->cust_pay->paid ?
866
867     push @buf,[
868       "Payment received ". time2str("%x",$_->cust_pay->_date ),
869       $money_char. sprintf("%10.2f",$_->amount )
870     ];
871   }
872
873   #balance due
874   my $balance_due_msg = $self->balance_due_msg;
875
876   push @buf,['','-----------'];
877   push @buf,[$balance_due_msg, $money_char. 
878     sprintf("%10.2f", $balance_due ) ];
879
880   #create the template
881   $template ||= $self->_agent_template;
882   my $templatefile = 'invoice_template';
883   $templatefile .= "_$template" if length($template);
884   my @invoice_template = $conf->config($templatefile)
885     or die "cannot load config file $templatefile";
886   $invoice_lines = 0;
887   my $wasfunc = 0;
888   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
889     /invoice_lines\((\d*)\)/;
890     $invoice_lines += $1 || scalar(@buf);
891     $wasfunc=1;
892   }
893   die "no invoice_lines() functions in template?" unless $wasfunc;
894   my $invoice_template = new Text::Template (
895     TYPE   => 'ARRAY',
896     SOURCE => [ map "$_\n", @invoice_template ],
897   ) or die "can't create new Text::Template object: $Text::Template::ERROR";
898   $invoice_template->compile()
899     or die "can't compile template: $Text::Template::ERROR";
900
901   #setup template variables
902   package FS::cust_bill::_template; #!
903   use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent );
904
905   $invnum = $self->invnum;
906   $date = $self->_date;
907   $page = 1;
908   $agent = $self->cust_main->agent->agent;
909
910   if ( $FS::cust_bill::invoice_lines ) {
911     $total_pages =
912       int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines );
913     $total_pages++
914       if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines;
915   } else {
916     $total_pages = 1;
917   }
918
919   #format address (variable for the template)
920   my $l = 0;
921   @address = ( '', '', '', '', '', '' );
922   package FS::cust_bill; #!
923   $FS::cust_bill::_template::address[$l++] =
924     $cust_main->payname.
925       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
926         ? " (P.O. #". $cust_main->payinfo. ")"
927         : ''
928       )
929   ;
930   $FS::cust_bill::_template::address[$l++] = $cust_main->company
931     if $cust_main->company;
932   $FS::cust_bill::_template::address[$l++] = $cust_main->address1;
933   $FS::cust_bill::_template::address[$l++] = $cust_main->address2
934     if $cust_main->address2;
935   $FS::cust_bill::_template::address[$l++] =
936     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
937   $FS::cust_bill::_template::address[$l++] = $cust_main->country
938     unless $cust_main->country eq 'US';
939
940         #  #overdue? (variable for the template)
941         #  $FS::cust_bill::_template::overdue = ( 
942         #    $balance_due > 0
943         #    && $today > $self->_date 
944         ##    && $self->printed > 1
945         #    && $self->printed > 0
946         #  );
947
948   #and subroutine for the template
949   sub FS::cust_bill::_template::invoice_lines {
950     my $lines = shift || scalar(@buf);
951     map { 
952       scalar(@buf) ? shift @buf : [ '', '' ];
953     }
954     ( 1 .. $lines );
955   }
956
957   #and fill it in
958   $FS::cust_bill::_template::page = 1;
959   my $lines;
960   my @collect;
961   while (@buf) {
962     push @collect, split("\n",
963       $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' )
964     );
965     $FS::cust_bill::_template::page++;
966   }
967
968   map "$_\n", @collect;
969
970 }
971
972 =item print_latex [ TIME [ , TEMPLATE ] ]
973
974 Internal method - returns a filename of a filled-in LaTeX template for this
975 invoice (Note: add ".tex" to get the actual filename).
976
977 See print_ps and print_pdf for methods that return PostScript and PDF output.
978
979 TIME an optional value used to control the printing of overdue messages.  The
980 default is now.  It isn't the date of the invoice; that's the `_date' field.
981 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
982 L<Time::Local> and L<Date::Parse> for conversion functions.
983
984 =cut
985
986 #still some false laziness w/print_text
987 sub print_latex {
988
989   my( $self, $today, $template ) = @_;
990   $today ||= time;
991
992 #  my $invnum = $self->invnum;
993   my $cust_main = $self->cust_main;
994   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
995     unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
996
997   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
998 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
999   #my $balance_due = $self->owed + $pr_total - $cr_total;
1000   my $balance_due = $self->owed + $pr_total;
1001
1002   #my @collect = ();
1003   #my($description,$amount);
1004   @buf = ();
1005
1006   #create the template
1007   $template ||= $self->_agent_template;
1008   my $templatefile = 'invoice_latex';
1009   my $suffix = length($template) ? "_$template" : '';
1010   $templatefile .= $suffix;
1011   my @invoice_template = $conf->config($templatefile)
1012     or die "cannot load config file $templatefile";
1013
1014   my %invoice_data = (
1015     'invnum'       => $self->invnum,
1016     'date'         => time2str('%b %o, %Y', $self->_date),
1017     'agent'        => _latex_escape($cust_main->agent->agent),
1018     'payname'      => _latex_escape($cust_main->payname),
1019     'company'      => _latex_escape($cust_main->company),
1020     'address1'     => _latex_escape($cust_main->address1),
1021     'address2'     => _latex_escape($cust_main->address2),
1022     'city'         => _latex_escape($cust_main->city),
1023     'state'        => _latex_escape($cust_main->state),
1024     'zip'          => _latex_escape($cust_main->zip),
1025     'country'      => _latex_escape($cust_main->country),
1026     'footer'       => join("\n", $conf->config('invoice_latexfooter') ),
1027     'smallfooter'  => join("\n", $conf->config('invoice_latexsmallfooter') ),
1028     'quantity'     => 1,
1029     'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
1030     #'notes'        => join("\n", $conf->config('invoice_latexnotes') ),
1031   );
1032
1033   my $countrydefault = $conf->config('countrydefault') || 'US';
1034   $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault;
1035
1036   #do variable substitutions in notes
1037   $invoice_data{'notes'} =
1038     join("\n",
1039       map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1040         $conf->config_orbase('invoice_latexnotes', $suffix)
1041     );
1042
1043   $invoice_data{'footer'} =~ s/\n+$//;
1044   $invoice_data{'smallfooter'} =~ s/\n+$//;
1045   $invoice_data{'notes'} =~ s/\n+$//;
1046
1047   $invoice_data{'po_line'} =
1048     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
1049       ? _latex_escape("Purchase Order #". $cust_main->payinfo)
1050       : '~';
1051
1052   my @line_item = ();
1053   my @total_item = ();
1054   my @filled_in = ();
1055   while ( @invoice_template ) {
1056     my $line = shift @invoice_template;
1057
1058     if ( $line =~ /^%%Detail\s*$/ ) {
1059
1060       while ( ( my $line_item_line = shift @invoice_template )
1061               !~ /^%%EndDetail\s*$/                            ) {
1062         push @line_item, $line_item_line;
1063       }
1064       foreach my $line_item ( $self->_items ) {
1065       #foreach my $line_item ( $self->_items_pkg ) {
1066         $invoice_data{'ref'} = $line_item->{'pkgnum'};
1067         $invoice_data{'description'} = _latex_escape($line_item->{'description'});
1068         if ( exists $line_item->{'ext_description'} ) {
1069           $invoice_data{'description'} .=
1070             "\\tabularnewline\n~~".
1071             join("\\tabularnewline\n~~", map { _latex_escape($_) } @{$line_item->{'ext_description'}} );
1072         }
1073         $invoice_data{'amount'} = $line_item->{'amount'};
1074         $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1075         push @filled_in,
1076           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item;
1077       }
1078
1079     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1080
1081       while ( ( my $total_item_line = shift @invoice_template )
1082               !~ /^%%EndTotalDetails\s*$/                      ) {
1083         push @total_item, $total_item_line;
1084       }
1085
1086       my @total_fill = ();
1087
1088       my $taxtotal = 0;
1089       foreach my $tax ( $self->_items_tax ) {
1090         $invoice_data{'total_item'} = _latex_escape($tax->{'description'});
1091         $taxtotal += ( $invoice_data{'total_amount'} = $tax->{'amount'} );
1092         push @total_fill,
1093           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1094               @total_item;
1095       }
1096
1097       if ( $taxtotal ) {
1098         $invoice_data{'total_item'} = 'Sub-total';
1099         $invoice_data{'total_amount'} =
1100           '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
1101         unshift @total_fill,
1102           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1103               @total_item;
1104       }
1105
1106       $invoice_data{'total_item'} = '\textbf{Total}';
1107       $invoice_data{'total_amount'} =
1108         '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
1109       push @total_fill,
1110         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1111             @total_item;
1112
1113       #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1114
1115       # credits
1116       foreach my $credit ( $self->_items_credits ) {
1117         $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
1118         #$credittotal
1119         $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
1120         push @total_fill, 
1121           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1122               @total_item;
1123       }
1124
1125       # payments
1126       foreach my $payment ( $self->_items_payments ) {
1127         $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
1128         #$paymenttotal
1129         $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
1130         push @total_fill, 
1131           map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1132               @total_item;
1133       }
1134
1135       $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
1136       $invoice_data{'total_amount'} =
1137         '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
1138       push @total_fill,
1139         map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
1140             @total_item;
1141
1142       push @filled_in, @total_fill;
1143
1144     } else {
1145       #$line =~ s/\$(\w+)/$invoice_data{$1}/eg;
1146       $line =~ s/\$(\w+)/exists($invoice_data{$1}) ? $invoice_data{$1} : nounder($1)/eg;
1147       push @filled_in, $line;
1148     }
1149
1150   }
1151
1152   sub nounder {
1153     my $var = $1;
1154     $var =~ s/_/\-/g;
1155     $var;
1156   }
1157
1158   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1159   my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
1160                            DIR      => $dir,
1161                            SUFFIX   => '.tex',
1162                            UNLINK   => 0,
1163                          ) or die "can't open temp file: $!\n";
1164   print $fh join("\n", @filled_in ), "\n";
1165   close $fh;
1166
1167   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
1168   return $1;
1169
1170 }
1171
1172 =item print_ps [ TIME [ , TEMPLATE ] ]
1173
1174 Returns an postscript invoice, as a scalar.
1175
1176 TIME an optional value used to control the printing of overdue messages.  The
1177 default is now.  It isn't the date of the invoice; that's the `_date' field.
1178 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1179 L<Time::Local> and L<Date::Parse> for conversion functions.
1180
1181 =cut
1182
1183 sub print_ps {
1184   my $self = shift;
1185
1186   my $file = $self->print_latex(@_);
1187
1188   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1189   chdir($dir);
1190
1191   my $sfile = shell_quote $file;
1192
1193   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1194     or die "pslatex $file.tex failed: $!";
1195   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1196     or die "pslatex $file.tex failed: $!";
1197
1198   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
1199     or die "dvips failed: $!";
1200
1201   open(POSTSCRIPT, "<$file.ps")
1202     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
1203
1204   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
1205
1206   my $ps = '';
1207   while (<POSTSCRIPT>) {
1208     $ps .= $_;
1209   }
1210
1211   close POSTSCRIPT;
1212
1213   return $ps;
1214
1215 }
1216
1217 =item print_pdf [ TIME [ , TEMPLATE ] ]
1218
1219 Returns an PDF invoice, as a scalar.
1220
1221 TIME an optional value used to control the printing of overdue messages.  The
1222 default is now.  It isn't the date of the invoice; that's the `_date' field.
1223 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1224 L<Time::Local> and L<Date::Parse> for conversion functions.
1225
1226 =cut
1227
1228 sub print_pdf {
1229   my $self = shift;
1230
1231   my $file = $self->print_latex(@_);
1232
1233   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1234   chdir($dir);
1235
1236   #system('pdflatex', "$file.tex");
1237   #system('pdflatex', "$file.tex");
1238   #! LaTeX Error: Unknown graphics extension: .eps.
1239
1240   my $sfile = shell_quote $file;
1241
1242   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1243     or die "pslatex $file.tex failed: $!";
1244   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
1245     or die "pslatex $file.tex failed: $!";
1246
1247   #system('dvipdf', "$file.dvi", "$file.pdf" );
1248   system(
1249     "dvips -q -t letter -f $sfile.dvi ".
1250     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
1251     "     -c save pop -"
1252   ) == 0
1253     or die "dvips | gs failed: $!";
1254
1255   open(PDF, "<$file.pdf")
1256     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
1257
1258   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
1259
1260   my $pdf = '';
1261   while (<PDF>) {
1262     $pdf .= $_;
1263   }
1264
1265   close PDF;
1266
1267   return $pdf;
1268
1269 }
1270
1271 # quick subroutine for print_latex
1272 #
1273 # There are ten characters that LaTeX treats as special characters, which
1274 # means that they do not simply typeset themselves: 
1275 #      # $ % & ~ _ ^ \ { }
1276 #
1277 # TeX ignores blanks following an escaped character; if you want a blank (as
1278 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1279
1280 sub _latex_escape {
1281   my $value = shift;
1282   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1283   $value;
1284 }
1285
1286 #utility methods for print_*
1287
1288 sub balance_due_msg {
1289   my $self = shift;
1290   my $msg = 'Balance Due';
1291   return $msg unless $conf->exists('invoice_default_terms');
1292   if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
1293     $msg .= ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
1294   } elsif ( $conf->config('invoice_default_terms') ) {
1295     $msg .= ' - '. $conf->config('invoice_default_terms');
1296   }
1297   $msg;
1298 }
1299
1300 sub _items {
1301   my $self = shift;
1302   my @display = scalar(@_)
1303                 ? @_
1304                 : qw( _items_previous _items_pkg );
1305                 #: qw( _items_pkg );
1306                 #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
1307   my @b = ();
1308   foreach my $display ( @display ) {
1309     push @b, $self->$display(@_);
1310   }
1311   @b;
1312 }
1313
1314 sub _items_previous {
1315   my $self = shift;
1316   my $cust_main = $self->cust_main;
1317   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
1318   my @b = ();
1319   foreach ( @pr_cust_bill ) {
1320     push @b, {
1321       'description' => 'Previous Balance, Invoice #'. $_->invnum. 
1322                        ' ('. time2str('%x',$_->_date). ')',
1323       #'pkgpart'     => 'N/A',
1324       'pkgnum'      => 'N/A',
1325       'amount'      => sprintf("%10.2f", $_->owed),
1326     };
1327   }
1328   @b;
1329
1330   #{
1331   #    'description'     => 'Previous Balance',
1332   #    #'pkgpart'         => 'N/A',
1333   #    'pkgnum'          => 'N/A',
1334   #    'amount'          => sprintf("%10.2f", $pr_total ),
1335   #    'ext_description' => [ map {
1336   #                                 "Invoice ". $_->invnum.
1337   #                                 " (". time2str("%x",$_->_date). ") ".
1338   #                                 sprintf("%10.2f", $_->owed)
1339   #                         } @pr_cust_bill ],
1340
1341   #};
1342 }
1343
1344 sub _items_pkg {
1345   my $self = shift;
1346   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
1347   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1348 }
1349
1350 sub _items_tax {
1351   my $self = shift;
1352   my @cust_bill_pkg = grep { ! $_->pkgnum } $self->cust_bill_pkg;
1353   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
1354 }
1355
1356 sub _items_cust_bill_pkg {
1357   my $self = shift;
1358   my $cust_bill_pkg = shift;
1359
1360   my @b = ();
1361   foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
1362
1363     if ( $cust_bill_pkg->pkgnum ) {
1364
1365       my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
1366       my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
1367       my $pkg = $part_pkg->pkg;
1368
1369       my %labels;
1370       #tie %labels, 'Tie::IxHash';
1371       push @{ $labels{$_->[0]} }, $_->[1] foreach $cust_pkg->labels;
1372       my @ext_description;
1373       foreach my $label ( keys %labels ) {
1374         my @values = @{ $labels{$label} };
1375         my $num = scalar(@values);
1376         if ( $num > 5 ) {
1377           push @ext_description, "$label ($num)";
1378         } else {
1379           push @ext_description, map { "$label: $_" } @values;
1380         }
1381       }
1382
1383       if ( $cust_bill_pkg->setup != 0 ) {
1384         my $description = $pkg;
1385         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
1386         my @d = @ext_description;
1387         push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
1388         push @b, {
1389           'description'     => $description,
1390           #'pkgpart'         => $part_pkg->pkgpart,
1391           'pkgnum'          => $cust_pkg->pkgnum,
1392           'amount'          => sprintf("%10.2f", $cust_bill_pkg->setup),
1393           'ext_description' => \@d,
1394         };
1395       }
1396
1397       if ( $cust_bill_pkg->recur != 0 ) {
1398         push @b, {
1399           'description'     => "$pkg (" .
1400                                time2str('%x', $cust_bill_pkg->sdate). ' - '.
1401                                time2str('%x', $cust_bill_pkg->edate). ')',
1402           #'pkgpart'         => $part_pkg->pkgpart,
1403           'pkgnum'          => $cust_pkg->pkgnum,
1404           'amount'          => sprintf("%10.2f", $cust_bill_pkg->recur),
1405           'ext_description' => [ @ext_description,
1406                                  $cust_bill_pkg->details,
1407                                ],
1408         };
1409       }
1410
1411     } else { #pkgnum tax or one-shot line item (??)
1412
1413       my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
1414                      ? ( $cust_bill_pkg->itemdesc || 'Tax' )
1415                      : 'Tax';
1416       if ( $cust_bill_pkg->setup != 0 ) {
1417         push @b, {
1418           'description' => $itemdesc,
1419           'amount'      => sprintf("%10.2f", $cust_bill_pkg->setup),
1420         };
1421       }
1422       if ( $cust_bill_pkg->recur != 0 ) {
1423         push @b, {
1424           'description' => "$itemdesc (".
1425                            time2str("%x", $cust_bill_pkg->sdate). ' - '.
1426                            time2str("%x", $cust_bill_pkg->edate). ')',
1427           'amount'      => sprintf("%10.2f", $cust_bill_pkg->recur),
1428         };
1429       }
1430
1431     }
1432
1433   }
1434
1435   @b;
1436
1437 }
1438
1439 sub _items_credits {
1440   my $self = shift;
1441
1442   my @b;
1443   #credits
1444   foreach ( $self->cust_credited ) {
1445
1446     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
1447
1448     my $reason = $_->cust_credit->reason;
1449     #my $reason = substr($_->cust_credit->reason,0,32);
1450     #$reason .= '...' if length($reason) < length($_->cust_credit->reason);
1451     $reason = " ($reason) " if $reason;
1452     push @b, {
1453       #'description' => 'Credit ref\#'. $_->crednum.
1454       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
1455       #                 $reason,
1456       'description' => 'Credit applied '.
1457                        time2str("%x",$_->cust_credit->_date). $reason,
1458       'amount'      => sprintf("%10.2f",$_->amount),
1459     };
1460   }
1461   #foreach ( @cr_cust_credit ) {
1462   #  push @buf,[
1463   #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
1464   #    $money_char. sprintf("%10.2f",$_->credited)
1465   #  ];
1466   #}
1467
1468   @b;
1469
1470 }
1471
1472 sub _items_payments {
1473   my $self = shift;
1474
1475   my @b;
1476   #get & print payments
1477   foreach ( $self->cust_bill_pay ) {
1478
1479     #something more elaborate if $_->amount ne ->cust_pay->paid ?
1480
1481     push @b, {
1482       'description' => "Payment received ".
1483                        time2str("%x",$_->cust_pay->_date ),
1484       'amount'      => sprintf("%10.2f", $_->amount )
1485     };
1486   }
1487
1488   @b;
1489
1490 }
1491
1492 =back
1493
1494 =head1 BUGS
1495
1496 The delete method.
1497
1498 print_text formatting (and some logic :/) is in source, but needs to be
1499 slurped in from a file.  Also number of lines ($=).
1500
1501 =head1 SEE ALSO
1502
1503 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
1504 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base
1505 documentation.
1506
1507 =cut
1508
1509 1;
1510