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