internal doc updates, #30003
[freeside.git] / FS / FS / cust_bill.pm
1 package FS::cust_bill;
2 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::Record );
3
4 use strict;
5 use vars qw( $DEBUG $me );
6              # but NOT $conf
7 use Fcntl qw(:flock); #for spool_csv
8 use Cwd;
9 use List::Util qw(min max sum);
10 use Date::Format;
11 use File::Temp 0.14;
12 use HTML::Entities;
13 use Storable qw( freeze thaw );
14 use GD::Barcode;
15 use FS::UID qw( datasrc );
16 use FS::Misc qw( send_email send_fax do_print );
17 use FS::Record qw( qsearch qsearchs dbh );
18 use FS::cust_statement;
19 use FS::cust_bill_pkg;
20 use FS::cust_bill_pkg_display;
21 use FS::cust_bill_pkg_detail;
22 use FS::cust_credit;
23 use FS::cust_pay;
24 use FS::cust_pkg;
25 use FS::cust_credit_bill;
26 use FS::pay_batch;
27 use FS::cust_bill_event;
28 use FS::cust_event;
29 use FS::part_pkg;
30 use FS::cust_bill_pay;
31 use FS::part_bill_event;
32 use FS::payby;
33 use FS::bill_batch;
34 use FS::cust_bill_batch;
35 use FS::cust_bill_pay_pkg;
36 use FS::cust_credit_bill_pkg;
37 use FS::discount_plan;
38 use FS::cust_bill_void;
39 use FS::L10N;
40
41 $DEBUG = 0;
42 $me = '[FS::cust_bill]';
43
44 =head1 NAME
45
46 FS::cust_bill - Object methods for cust_bill records
47
48 =head1 SYNOPSIS
49
50   use FS::cust_bill;
51
52   $record = new FS::cust_bill \%hash;
53   $record = new FS::cust_bill { 'column' => 'value' };
54
55   $error = $record->insert;
56
57   $error = $new_record->replace($old_record);
58
59   $error = $record->delete;
60
61   $error = $record->check;
62
63   ( $total_previous_balance, @previous_cust_bill ) = $record->previous;
64
65   @cust_bill_pkg_objects = $cust_bill->cust_bill_pkg;
66
67   ( $total_previous_credits, @previous_cust_credit ) = $record->cust_credit;
68
69   @cust_pay_objects = $cust_bill->cust_pay;
70
71   $tax_amount = $record->tax;
72
73   @lines = $cust_bill->print_text;
74   @lines = $cust_bill->print_text('time' => $time);
75
76 =head1 DESCRIPTION
77
78 An FS::cust_bill object represents an invoice; a declaration that a customer
79 owes you money.  The specific charges are itemized as B<cust_bill_pkg> records
80 (see L<FS::cust_bill_pkg>).  FS::cust_bill inherits from FS::Record.  The
81 following fields are currently supported:
82
83 Regular fields
84
85 =over 4
86
87 =item invnum - primary key (assigned automatically for new invoices)
88
89 =item custnum - customer (see L<FS::cust_main>)
90
91 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
92 L<Time::Local> and L<Date::Parse> for conversion functions.
93
94 =item charged - amount of this invoice
95
96 =item invoice_terms - optional terms override for this specific invoice
97
98 =back
99
100 Deprecated fields
101
102 =over 4
103
104 =item billing_balance - the customer's balance immediately before generating
105 this invoice.  DEPRECATED.  Use the L<FS::cust_main/balance_date> method 
106 to determine the customer's balance at a specific time.
107
108 =item previous_balance - the customer's balance immediately after generating
109 the invoice before this one.  DEPRECATED.
110
111 =item printed - formerly used to track the number of times an invoice had 
112 been printed; no longer used.
113
114 =back
115
116 Specific use cases
117
118 =over 4
119
120 =item closed - books closed flag, empty or `Y'
121
122 =item statementnum - invoice aggregation (see L<FS::cust_statement>)
123
124 =item agent_invid - legacy invoice number
125
126 =item promised_date - customer promised payment date, for collection
127
128 =back
129
130 =head1 METHODS
131
132 =over 4
133
134 =item new HASHREF
135
136 Creates a new invoice.  To add the invoice to the database, see L<"insert">.
137 Invoices are normally created by calling the bill method of a customer object
138 (see L<FS::cust_main>).
139
140 =cut
141
142 sub table { 'cust_bill'; }
143
144 # should be the ONLY occurrence of "Invoice" in invoice rendering code.
145 # (except email_subject and invnum_date_pretty)
146 sub notice_name {
147   my $self = shift;
148   $self->conf->config('notice_name') || 'Invoice'
149 }
150
151 sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
152 sub cust_unlinked_msg {
153   my $self = shift;
154   "WARNING: can't find cust_main.custnum ". $self->custnum.
155   ' (cust_bill.invnum '. $self->invnum. ')';
156 }
157
158 =item insert
159
160 Adds this invoice to the database ("Posts" the invoice).  If there is an error,
161 returns the error, otherwise returns false.
162
163 =cut
164
165 sub insert {
166   my $self = shift;
167   warn "$me insert called\n" if $DEBUG;
168
169   local $SIG{HUP} = 'IGNORE';
170   local $SIG{INT} = 'IGNORE';
171   local $SIG{QUIT} = 'IGNORE';
172   local $SIG{TERM} = 'IGNORE';
173   local $SIG{TSTP} = 'IGNORE';
174   local $SIG{PIPE} = 'IGNORE';
175
176   my $oldAutoCommit = $FS::UID::AutoCommit;
177   local $FS::UID::AutoCommit = 0;
178   my $dbh = dbh;
179
180   my $error = $self->SUPER::insert;
181   if ( $error ) {
182     $dbh->rollback if $oldAutoCommit;
183     return $error;
184   }
185
186   if ( $self->get('cust_bill_pkg') ) {
187     foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
188       $cust_bill_pkg->invnum($self->invnum);
189       my $error = $cust_bill_pkg->insert;
190       if ( $error ) {
191         $dbh->rollback if $oldAutoCommit;
192         return "can't create invoice line item: $error";
193       }
194     }
195   }
196
197   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
198   '';
199
200 }
201
202 =item void
203
204 Voids this invoice: deletes the invoice and adds a record of the voided invoice
205 to the FS::cust_bill_void table (and related tables starting from
206 FS::cust_bill_pkg_void).
207
208 =cut
209
210 sub void {
211   my $self = shift;
212   my $reason = scalar(@_) ? shift : '';
213
214   local $SIG{HUP} = 'IGNORE';
215   local $SIG{INT} = 'IGNORE';
216   local $SIG{QUIT} = 'IGNORE';
217   local $SIG{TERM} = 'IGNORE';
218   local $SIG{TSTP} = 'IGNORE';
219   local $SIG{PIPE} = 'IGNORE';
220
221   my $oldAutoCommit = $FS::UID::AutoCommit;
222   local $FS::UID::AutoCommit = 0;
223   my $dbh = dbh;
224
225   my $cust_bill_void = new FS::cust_bill_void ( {
226     map { $_ => $self->get($_) } $self->fields
227   } );
228   $cust_bill_void->reason($reason);
229   my $error = $cust_bill_void->insert;
230   if ( $error ) {
231     $dbh->rollback if $oldAutoCommit;
232     return $error;
233   }
234
235   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
236     my $error = $cust_bill_pkg->void($reason);
237     if ( $error ) {
238       $dbh->rollback if $oldAutoCommit;
239       return $error;
240     }
241   }
242
243   $error = $self->delete;
244   if ( $error ) {
245     $dbh->rollback if $oldAutoCommit;
246     return $error;
247   }
248
249   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
250
251   '';
252
253 }
254
255 =item delete
256
257 This method now works but you probably shouldn't use it.  Instead, apply a
258 credit against the invoice, or use the new void method.
259
260 Using this method to delete invoices outright is really, really bad.  There
261 would be no record you ever posted this invoice, and there are no check to
262 make sure charged = 0 or that there are no associated cust_bill_pkg records.
263
264 Really, don't use it.
265
266 =cut
267
268 sub delete {
269   my $self = shift;
270   return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
271
272   local $SIG{HUP} = 'IGNORE';
273   local $SIG{INT} = 'IGNORE';
274   local $SIG{QUIT} = 'IGNORE';
275   local $SIG{TERM} = 'IGNORE';
276   local $SIG{TSTP} = 'IGNORE';
277   local $SIG{PIPE} = 'IGNORE';
278
279   my $oldAutoCommit = $FS::UID::AutoCommit;
280   local $FS::UID::AutoCommit = 0;
281   my $dbh = dbh;
282
283   foreach my $table (qw(
284     cust_bill_event
285     cust_event
286     cust_credit_bill
287     cust_bill_pay
288     cust_pay_batch
289     cust_bill_pay_batch
290     cust_bill_batch
291     cust_bill_pkg
292   )) {
293
294     foreach my $linked ( $self->$table() ) {
295       my $error = $linked->delete;
296       if ( $error ) {
297         $dbh->rollback if $oldAutoCommit;
298         return $error;
299       }
300     }
301
302   }
303
304   my $error = $self->SUPER::delete(@_);
305   if ( $error ) {
306     $dbh->rollback if $oldAutoCommit;
307     return $error;
308   }
309
310   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
311
312   '';
313
314 }
315
316 =item replace [ OLD_RECORD ]
317
318 You can, but probably shouldn't modify invoices...
319
320 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
321 supplied, replaces this record.  If there is an error, returns the error,
322 otherwise returns false.
323
324 =cut
325
326 #replace can be inherited from Record.pm
327
328 # replace_check is now the preferred way to #implement replace data checks
329 # (so $object->replace() works without an argument)
330
331 sub replace_check {
332   my( $new, $old ) = ( shift, shift );
333   return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
334   #return "Can't change _date!" unless $old->_date eq $new->_date;
335   return "Can't change _date" unless $old->_date == $new->_date;
336   return "Can't change charged" unless $old->charged == $new->charged
337                                     || $old->charged == 0
338                                     || $new->{'Hash'}{'cc_surcharge_replace_hack'};
339
340   '';
341 }
342
343
344 =item add_cc_surcharge
345
346 Giant hack
347
348 =cut
349
350 sub add_cc_surcharge {
351     my ($self, $pkgnum, $amount) = (shift, shift, shift);
352
353     my $error;
354     my $cust_bill_pkg = new FS::cust_bill_pkg({
355                                     'invnum' => $self->invnum,
356                                     'pkgnum' => $pkgnum,
357                                     'setup' => $amount,
358                         });
359     $error = $cust_bill_pkg->insert;
360     return $error if $error;
361
362     $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
363     $self->charged($self->charged+$amount);
364     $error = $self->replace;
365     return $error if $error;
366
367     $self->apply_payments_and_credits;
368 }
369
370
371 =item check
372
373 Checks all fields to make sure this is a valid invoice.  If there is an error,
374 returns the error, otherwise returns false.  Called by the insert and replace
375 methods.
376
377 =cut
378
379 sub check {
380   my $self = shift;
381
382   my $error =
383     $self->ut_numbern('invnum')
384     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
385     || $self->ut_numbern('_date')
386     || $self->ut_money('charged')
387     || $self->ut_numbern('printed')
388     || $self->ut_enum('closed', [ '', 'Y' ])
389     || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
390     || $self->ut_numbern('agent_invid') #varchar?
391   ;
392   return $error if $error;
393
394   $self->_date(time) unless $self->_date;
395
396   $self->printed(0) if $self->printed eq '';
397
398   $self->SUPER::check;
399 }
400
401 =item display_invnum
402
403 Returns the displayed invoice number for this invoice: agent_invid if
404 cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
405
406 =cut
407
408 sub display_invnum {
409   my $self = shift;
410   if ( $self->agent_invid
411          && FS::Conf->new->exists('cust_bill-default_agent_invid') ) {
412     return $self->agent_invid;
413   } else {
414     return $self->invnum;
415   }
416 }
417
418 =item previous_bill
419
420 Returns the customer's last invoice before this one.
421
422 =cut
423
424 sub previous_bill {
425   my $self = shift;
426   if ( !$self->get('previous_bill') ) {
427     $self->set('previous_bill', qsearchs({
428           'table'     => 'cust_bill',
429           'hashref'   => { 'custnum'  => $self->custnum,
430                            '_date'    => { op=>'<', value=>$self->_date } },
431           'order_by'  => 'ORDER BY _date DESC LIMIT 1',
432     }) );
433   }
434   $self->get('previous_bill');
435 }
436
437 =item previous
438
439 Returns a list consisting of the total previous balance for this customer, 
440 followed by the previous outstanding invoices (as FS::cust_bill objects also).
441
442 =cut
443
444 sub previous {
445   my $self = shift;
446   my $total = 0;
447   my @cust_bill = sort { $a->_date <=> $b->_date }
448     grep { $_->owed != 0 }
449       qsearch( 'cust_bill', { 'custnum' => $self->custnum,
450                               #'_date'   => { op=>'<', value=>$self->_date },
451                               'invnum'   => { op=>'<', value=>$self->invnum },
452                             } ) 
453   ;
454   foreach ( @cust_bill ) { $total += $_->owed; }
455   $total, @cust_bill;
456 }
457
458 =item enable_previous
459
460 Whether to show the 'Previous Charges' section when printing this invoice.
461 The negation of the 'disable_previous_balance' config setting.
462
463 =cut
464
465 sub enable_previous {
466   my $self = shift;
467   my $agentnum = $self->cust_main->agentnum;
468   !$self->conf->exists('disable_previous_balance', $agentnum);
469 }
470
471 =item cust_bill_pkg
472
473 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
474
475 =cut
476
477 sub cust_bill_pkg {
478   my $self = shift;
479   qsearch(
480     { 'table'    => 'cust_bill_pkg',
481       'hashref'  => { 'invnum' => $self->invnum },
482       'order_by' => 'ORDER BY billpkgnum', #important?  otherwise we could use
483                                            # the AUTLOADED FK search.  or should
484                                            # that default to ORDER by the pkey?
485     }
486   );
487 }
488
489 =item cust_bill_pkg_pkgnum PKGNUM
490
491 Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
492 specified pkgnum.
493
494 =cut
495
496 sub cust_bill_pkg_pkgnum {
497   my( $self, $pkgnum ) = @_;
498   qsearch(
499     { 'table'    => 'cust_bill_pkg',
500       'hashref'  => { 'invnum' => $self->invnum,
501                       'pkgnum' => $pkgnum,
502                     },
503       'order_by' => 'ORDER BY billpkgnum',
504     }
505   );
506 }
507
508 =item cust_pkg
509
510 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
511 this invoice.
512
513 =cut
514
515 sub cust_pkg {
516   my $self = shift;
517   my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
518                      $self->cust_bill_pkg;
519   my %saw = ();
520   grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
521 }
522
523 =item no_auto
524
525 Returns true if any of the packages (or their definitions) corresponding to the
526 line items for this invoice have the no_auto flag set.
527
528 =cut
529
530 sub no_auto {
531   my $self = shift;
532   grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
533 }
534
535 =item open_cust_bill_pkg
536
537 Returns the open line items for this invoice.
538
539 Note that cust_bill_pkg with both setup and recur fees are returned as two
540 separate line items, each with only one fee.
541
542 =cut
543
544 # modeled after cust_main::open_cust_bill
545 sub open_cust_bill_pkg {
546   my $self = shift;
547
548   # grep { $_->owed > 0 } $self->cust_bill_pkg
549
550   my %other = ( 'recur' => 'setup',
551                 'setup' => 'recur', );
552   my @open = ();
553   foreach my $field ( qw( recur setup )) {
554     push @open, map  { $_->set( $other{$field}, 0 ); $_; }
555                 grep { $_->owed($field) > 0 }
556                 $self->cust_bill_pkg;
557   }
558
559   @open;
560 }
561
562 =item cust_bill_event
563
564 Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
565
566 =cut
567
568 sub cust_bill_event {
569   my $self = shift;
570   qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
571 }
572
573 =item num_cust_bill_event
574
575 Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
576
577 =cut
578
579 sub num_cust_bill_event {
580   my $self = shift;
581   my $sql =
582     "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
583   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
584   $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
585   $sth->fetchrow_arrayref->[0];
586 }
587
588 =item cust_event
589
590 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
591
592 =cut
593
594 #false laziness w/cust_pkg.pm
595 sub cust_event {
596   my $self = shift;
597   qsearch({
598     'table'     => 'cust_event',
599     'addl_from' => 'JOIN part_event USING ( eventpart )',
600     'hashref'   => { 'tablenum' => $self->invnum },
601     'extra_sql' => " AND eventtable = 'cust_bill' ",
602   });
603 }
604
605 =item num_cust_event
606
607 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
608
609 =cut
610
611 #false laziness w/cust_pkg.pm
612 sub num_cust_event {
613   my $self = shift;
614   my $sql =
615     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
616     "  WHERE tablenum = ? AND eventtable = 'cust_bill'";
617   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
618   $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
619   $sth->fetchrow_arrayref->[0];
620 }
621
622 =item cust_main
623
624 Returns the customer (see L<FS::cust_main>) for this invoice.
625
626 =item cust_suspend_if_balance_over AMOUNT
627
628 Suspends the customer associated with this invoice if the total amount owed on
629 this invoice and all older invoices is greater than the specified amount.
630
631 Returns a list: an empty list on success or a list of errors.
632
633 =cut
634
635 sub cust_suspend_if_balance_over {
636   my( $self, $amount ) = ( shift, shift );
637   my $cust_main = $self->cust_main;
638   if ( $cust_main->total_owed_date($self->_date) < $amount ) {
639     return ();
640   } else {
641     $cust_main->suspend(@_);
642   }
643 }
644
645 =item cust_bill_pay
646
647 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
648
649 =cut
650
651 sub cust_bill_pay {
652   my $self = shift;
653   map { $_ } #return $self->num_cust_bill_pay unless wantarray;
654   sort { $a->_date <=> $b->_date }
655     qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
656 }
657
658 =item cust_credited
659
660 =item cust_credit_bill
661
662 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
663
664 =cut
665
666 sub cust_credited {
667   my $self = shift;
668   map { $_ } #return $self->num_cust_credit_bill unless wantarray;
669   sort { $a->_date <=> $b->_date }
670     qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
671   ;
672 }
673
674 sub cust_credit_bill {
675   shift->cust_credited(@_);
676 }
677
678 #=item cust_bill_pay_pkgnum PKGNUM
679 #
680 #Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
681 #with matching pkgnum.
682 #
683 #=cut
684 #
685 #sub cust_bill_pay_pkgnum {
686 #  my( $self, $pkgnum ) = @_;
687 #  map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
688 #  sort { $a->_date <=> $b->_date }
689 #    qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
690 #                                'pkgnum' => $pkgnum,
691 #                              }
692 #           );
693 #}
694
695 =item cust_bill_pay_pkg PKGNUM
696
697 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
698 applied against the matching pkgnum.
699
700 =cut
701
702 sub cust_bill_pay_pkg {
703   my( $self, $pkgnum ) = @_;
704
705   qsearch({
706     'select'    => 'cust_bill_pay_pkg.*',
707     'table'     => 'cust_bill_pay_pkg',
708     'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
709                    ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
710     'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
711                    "   AND cust_bill_pkg.pkgnum = $pkgnum",
712   });
713
714 }
715
716 #=item cust_credited_pkgnum PKGNUM
717 #
718 #=item cust_credit_bill_pkgnum PKGNUM
719 #
720 #Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
721 #with matching pkgnum.
722 #
723 #=cut
724 #
725 #sub cust_credited_pkgnum {
726 #  my( $self, $pkgnum ) = @_;
727 #  map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
728 #  sort { $a->_date <=> $b->_date }
729 #    qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
730 #                                   'pkgnum' => $pkgnum,
731 #                                 }
732 #           );
733 #}
734 #
735 #sub cust_credit_bill_pkgnum {
736 #  shift->cust_credited_pkgnum(@_);
737 #}
738
739 =item cust_credit_bill_pkg PKGNUM
740
741 Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
742 applied against the matching pkgnum.
743
744 =cut
745
746 sub cust_credit_bill_pkg {
747   my( $self, $pkgnum ) = @_;
748
749   qsearch({
750     'select'    => 'cust_credit_bill_pkg.*',
751     'table'     => 'cust_credit_bill_pkg',
752     'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
753                    ' LEFT JOIN cust_bill_pkg    USING ( billpkgnum    ) ',
754     'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
755                    "   AND cust_bill_pkg.pkgnum = $pkgnum",
756   });
757
758 }
759
760 =item cust_bill_batch
761
762 Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
763
764 =cut
765
766 sub cust_bill_batch {
767   my $self = shift;
768   qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
769 }
770
771 =item discount_plans
772
773 Returns all discount plans (L<FS::discount_plan>) for this invoice, as a 
774 hash keyed by term length.
775
776 =cut
777
778 sub discount_plans {
779   my $self = shift;
780   FS::discount_plan->all($self);
781 }
782
783 =item tax
784
785 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
786
787 =cut
788
789 sub tax {
790   my $self = shift;
791   my $total = 0;
792   my @taxlines = qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum ,
793                                              'pkgnum' => 0 } );
794   foreach (@taxlines) { $total += $_->setup; }
795   $total;
796 }
797
798 =item owed
799
800 Returns the amount owed (still outstanding) on this invoice, which is charged
801 minus all payment applications (see L<FS::cust_bill_pay>) and credit
802 applications (see L<FS::cust_credit_bill>).
803
804 =cut
805
806 sub owed {
807   my $self = shift;
808   my $balance = $self->charged;
809   $balance -= $_->amount foreach ( $self->cust_bill_pay );
810   $balance -= $_->amount foreach ( $self->cust_credited );
811   $balance = sprintf( "%.2f", $balance);
812   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
813   $balance;
814 }
815
816 sub owed_pkgnum {
817   my( $self, $pkgnum ) = @_;
818
819   #my $balance = $self->charged;
820   my $balance = 0;
821   $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
822
823   $balance -= $_->amount            for $self->cust_bill_pay_pkg($pkgnum);
824   $balance -= $_->amount            for $self->cust_credit_bill_pkg($pkgnum);
825
826   $balance = sprintf( "%.2f", $balance);
827   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
828   $balance;
829 }
830
831 =item hide
832
833 Returns true if this invoice should be hidden.  See the
834 selfservice-hide_invoices-taxclass configuraiton setting.
835
836 =cut
837
838 sub hide {
839   my $self = shift;
840   my $conf = $self->conf;
841   my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
842     or return '';
843   my @cust_bill_pkg = $self->cust_bill_pkg;
844   my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
845   ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
846 }
847
848 =item apply_payments_and_credits [ OPTION => VALUE ... ]
849
850 Applies unapplied payments and credits to this invoice.
851
852 A hash of optional arguments may be passed.  Currently "manual" is supported.
853 If true, a payment receipt is sent instead of a statement when
854 'payment_receipt_email' configuration option is set.
855
856 If there is an error, returns the error, otherwise returns false.
857
858 =cut
859
860 sub apply_payments_and_credits {
861   my( $self, %options ) = @_;
862   my $conf = $self->conf;
863
864   local $SIG{HUP} = 'IGNORE';
865   local $SIG{INT} = 'IGNORE';
866   local $SIG{QUIT} = 'IGNORE';
867   local $SIG{TERM} = 'IGNORE';
868   local $SIG{TSTP} = 'IGNORE';
869   local $SIG{PIPE} = 'IGNORE';
870
871   my $oldAutoCommit = $FS::UID::AutoCommit;
872   local $FS::UID::AutoCommit = 0;
873   my $dbh = dbh;
874
875   $self->select_for_update; #mutex
876
877   my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
878   my @credits  = grep { $_->credited > 0 } $self->cust_main->cust_credit;
879
880   if ( $conf->exists('pkg-balances') ) {
881     # limit @payments & @credits to those w/ a pkgnum grepped from $self
882     my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
883     @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
884     @credits  = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
885   }
886
887   while ( $self->owed > 0 and ( @payments || @credits ) ) {
888
889     my $app = '';
890     if ( @payments && @credits ) {
891
892       #decide which goes first by weight of top (unapplied) line item
893
894       my @open_lineitems = $self->open_cust_bill_pkg;
895
896       my $max_pay_weight =
897         max( map  { $_->part_pkg->pay_weight || 0 }
898              grep { $_ }
899              map  { $_->cust_pkg }
900                   @open_lineitems
901            );
902       my $max_credit_weight =
903         max( map  { $_->part_pkg->credit_weight || 0 }
904              grep { $_ } 
905              map  { $_->cust_pkg }
906                   @open_lineitems
907            );
908
909       #if both are the same... payments first?  it has to be something
910       if ( $max_pay_weight >= $max_credit_weight ) {
911         $app = 'pay';
912       } else {
913         $app = 'credit';
914       }
915     
916     } elsif ( @payments ) {
917       $app = 'pay';
918     } elsif ( @credits ) {
919       $app = 'credit';
920     } else {
921       die "guru meditation #12 and 35";
922     }
923
924     my $unapp_amount;
925     if ( $app eq 'pay' ) {
926
927       my $payment = shift @payments;
928       $unapp_amount = $payment->unapplied;
929       $app = new FS::cust_bill_pay { 'paynum'  => $payment->paynum };
930       $app->pkgnum( $payment->pkgnum )
931         if $conf->exists('pkg-balances') && $payment->pkgnum;
932
933     } elsif ( $app eq 'credit' ) {
934
935       my $credit = shift @credits;
936       $unapp_amount = $credit->credited;
937       $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
938       $app->pkgnum( $credit->pkgnum )
939         if $conf->exists('pkg-balances') && $credit->pkgnum;
940
941     } else {
942       die "guru meditation #12 and 35";
943     }
944
945     my $owed;
946     if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
947       warn "owed_pkgnum ". $app->pkgnum;
948       $owed = $self->owed_pkgnum($app->pkgnum);
949     } else {
950       $owed = $self->owed;
951     }
952     next unless $owed > 0;
953
954     warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
955     $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
956
957     $app->invnum( $self->invnum );
958
959     my $error = $app->insert(%options);
960     if ( $error ) {
961       $dbh->rollback if $oldAutoCommit;
962       return "Error inserting ". $app->table. " record: $error";
963     }
964     die $error if $error;
965
966   }
967
968   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
969   ''; #no error
970
971 }
972
973 =item generate_email OPTION => VALUE ...
974
975 Options:
976
977 =over 4
978
979 =item from
980
981 sender address, required
982
983 =item template
984
985 alternate template name, optional
986
987 =item print_text
988
989 text attachment arrayref, optional
990
991 =item subject
992
993 email subject, optional
994
995 =item notice_name
996
997 notice name instead of "Invoice", optional
998
999 =back
1000
1001 Returns an argument list to be passed to L<FS::Misc::send_email>.
1002
1003 =cut
1004
1005 use MIME::Entity;
1006
1007 sub generate_email {
1008
1009   my $self = shift;
1010   my %args = @_;
1011   my $conf = $self->conf;
1012
1013   my $me = '[FS::cust_bill::generate_email]';
1014
1015   my %return = (
1016     'from'      => $args{'from'},
1017     'subject'   => ($args{'subject'} || $self->email_subject),
1018     'custnum'   => $self->custnum,
1019     'msgtype'   => 'invoice',
1020   );
1021
1022   $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
1023
1024   my $cust_main = $self->cust_main;
1025
1026   if (ref($args{'to'}) eq 'ARRAY') {
1027     $return{'to'} = $args{'to'};
1028   } else {
1029     $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
1030                            $cust_main->invoicing_list
1031                     ];
1032   }
1033
1034   if ( $conf->exists('invoice_html') ) {
1035
1036     warn "$me creating HTML/text multipart message"
1037       if $DEBUG;
1038
1039     $return{'nobody'} = 1;
1040
1041     my $alternative = build MIME::Entity
1042       'Type'        => 'multipart/alternative',
1043       #'Encoding'    => '7bit',
1044       'Disposition' => 'inline'
1045     ;
1046
1047     my $data;
1048     if ( $conf->exists('invoice_email_pdf')
1049          and scalar($conf->config('invoice_email_pdf_note')) ) {
1050
1051       warn "$me using 'invoice_email_pdf_note' in multipart message"
1052         if $DEBUG;
1053       $data = [ map { $_ . "\n" }
1054                     $conf->config('invoice_email_pdf_note')
1055               ];
1056
1057     } else {
1058
1059       warn "$me not using 'invoice_email_pdf_note' in multipart message"
1060         if $DEBUG;
1061       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1062         $data = $args{'print_text'};
1063       } else {
1064         $data = [ $self->print_text(\%args) ];
1065       }
1066
1067     }
1068
1069     $alternative->attach(
1070       'Type'        => 'text/plain',
1071       'Encoding'    => 'quoted-printable',
1072       'Charset'     => 'UTF-8',
1073       #'Encoding'    => '7bit',
1074       'Data'        => $data,
1075       'Disposition' => 'inline',
1076     );
1077
1078
1079     my $htmldata;
1080     my $image = '';
1081     my $barcode = '';
1082     if ( $conf->exists('invoice_email_pdf')
1083          and scalar($conf->config('invoice_email_pdf_note')) ) {
1084
1085       $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
1086
1087     } else {
1088
1089       $args{'from'} =~ /\@([\w\.\-]+)/;
1090       my $from = $1 || 'example.com';
1091       my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1092
1093       my $logo;
1094       my $agentnum = $cust_main->agentnum;
1095       if ( defined($args{'template'}) && length($args{'template'})
1096            && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
1097          )
1098       {
1099         $logo = 'logo_'. $args{'template'}. '.png';
1100       } else {
1101         $logo = "logo.png";
1102       }
1103       my $image_data = $conf->config_binary( $logo, $agentnum);
1104
1105       $image = build MIME::Entity
1106         'Type'       => 'image/png',
1107         'Encoding'   => 'base64',
1108         'Data'       => $image_data,
1109         'Filename'   => 'logo.png',
1110         'Content-ID' => "<$content_id>",
1111       ;
1112    
1113       if ($conf->exists('invoice-barcode')) {
1114         my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
1115         $barcode = build MIME::Entity
1116           'Type'       => 'image/png',
1117           'Encoding'   => 'base64',
1118           'Data'       => $self->invoice_barcode(0),
1119           'Filename'   => 'barcode.png',
1120           'Content-ID' => "<$barcode_content_id>",
1121         ;
1122         $args{'barcode_cid'} = $barcode_content_id;
1123       }
1124
1125       $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
1126     }
1127
1128     $alternative->attach(
1129       'Type'        => 'text/html',
1130       'Encoding'    => 'quoted-printable',
1131       'Data'        => [ '<html>',
1132                          '  <head>',
1133                          '    <title>',
1134                          '      '. encode_entities($return{'subject'}), 
1135                          '    </title>',
1136                          '  </head>',
1137                          '  <body bgcolor="#e8e8e8">',
1138                          $htmldata,
1139                          '  </body>',
1140                          '</html>',
1141                        ],
1142       'Disposition' => 'inline',
1143       #'Filename'    => 'invoice.pdf',
1144     );
1145
1146
1147     my @otherparts = ();
1148     if ( $cust_main->email_csv_cdr ) {
1149
1150       push @otherparts, build MIME::Entity
1151         'Type'        => 'text/csv',
1152         'Encoding'    => '7bit',
1153         'Data'        => [ map { "$_\n" }
1154                              $self->call_details('prepend_billed_number' => 1)
1155                          ],
1156         'Disposition' => 'attachment',
1157         'Filename'    => 'usage-'. $self->invnum. '.csv',
1158       ;
1159
1160     }
1161
1162     if ( $conf->exists('invoice_email_pdf') ) {
1163
1164       #attaching pdf too:
1165       # multipart/mixed
1166       #   multipart/related
1167       #     multipart/alternative
1168       #       text/plain
1169       #       text/html
1170       #     image/png
1171       #   application/pdf
1172
1173       my $related = build MIME::Entity 'Type'     => 'multipart/related',
1174                                        'Encoding' => '7bit';
1175
1176       #false laziness w/Misc::send_email
1177       $related->head->replace('Content-type',
1178         $related->mime_type.
1179         '; boundary="'. $related->head->multipart_boundary. '"'.
1180         '; type=multipart/alternative'
1181       );
1182
1183       $related->add_part($alternative);
1184
1185       $related->add_part($image) if $image;
1186
1187       my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
1188
1189       $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
1190
1191     } else {
1192
1193       #no other attachment:
1194       # multipart/related
1195       #   multipart/alternative
1196       #     text/plain
1197       #     text/html
1198       #   image/png
1199
1200       $return{'content-type'} = 'multipart/related';
1201       if ($conf->exists('invoice-barcode') && $barcode) {
1202         $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
1203       } else {
1204         $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
1205       }
1206       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
1207       #$return{'disposition'} = 'inline';
1208
1209     }
1210   
1211   } else {
1212
1213     if ( $conf->exists('invoice_email_pdf') ) {
1214       warn "$me creating PDF attachment"
1215         if $DEBUG;
1216
1217       #mime parts arguments a la MIME::Entity->build().
1218       $return{'mimeparts'} = [
1219         { $self->mimebuild_pdf(\%args) }
1220       ];
1221     }
1222   
1223     if ( $conf->exists('invoice_email_pdf')
1224          and scalar($conf->config('invoice_email_pdf_note')) ) {
1225
1226       warn "$me using 'invoice_email_pdf_note'"
1227         if $DEBUG;
1228       $return{'body'} = [ map { $_ . "\n" }
1229                               $conf->config('invoice_email_pdf_note')
1230                         ];
1231
1232     } else {
1233
1234       warn "$me not using 'invoice_email_pdf_note'"
1235         if $DEBUG;
1236       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
1237         $return{'body'} = $args{'print_text'};
1238       } else {
1239         $return{'body'} = [ $self->print_text(\%args) ];
1240       }
1241
1242     }
1243
1244   }
1245
1246   %return;
1247
1248 }
1249
1250 =item mimebuild_pdf
1251
1252 Returns a list suitable for passing to MIME::Entity->build(), representing
1253 this invoice as PDF attachment.
1254
1255 =cut
1256
1257 sub mimebuild_pdf {
1258   my $self = shift;
1259   (
1260     'Type'        => 'application/pdf',
1261     'Encoding'    => 'base64',
1262     'Data'        => [ $self->print_pdf(@_) ],
1263     'Disposition' => 'attachment',
1264     'Filename'    => 'invoice-'. $self->invnum. '.pdf',
1265   );
1266 }
1267
1268 =item send HASHREF
1269
1270 Sends this invoice to the destinations configured for this customer: sends
1271 email, prints and/or faxes.  See L<FS::cust_main_invoice>.
1272
1273 Options can be passed as a hashref.  Positional parameters are no longer
1274 allowed.
1275
1276 I<template>: a suffix for alternate invoices
1277
1278 I<agentnum>: obsolete, now does nothing.
1279
1280 I<invoice_from> overrides the default email invoice From: address.
1281
1282 I<amount>: obsolete, does nothing
1283
1284 I<notice_name> overrides "Invoice" as the name of the sent document 
1285 (templates from 10/2009 or newer required).
1286
1287 I<lpr> overrides the system 'lpr' option as the command to print a document
1288 from standard input.
1289
1290 =cut
1291
1292 sub send {
1293   my $self = shift;
1294   my $opt = ref($_[0]) ? $_[0] : +{ @_ };
1295   my $conf = $self->conf;
1296
1297   my $cust_main = $self->cust_main;
1298
1299   my @invoicing_list = $cust_main->invoicing_list;
1300
1301   $self->email($opt)
1302     if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
1303     && ! $self->invoice_noemail;
1304
1305   $self->print($opt)
1306     if grep { $_ eq 'POST' } @invoicing_list; #postal
1307
1308   #this has never been used post-$ORIGINAL_ISP afaik
1309   $self->fax_invoice($opt)
1310     if grep { $_ eq 'FAX' } @invoicing_list; #fax
1311
1312   '';
1313
1314 }
1315
1316 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ] 
1317
1318 Sends this invoice to the customer's email destination(s).
1319
1320 Options must be passed as a hashref.  Positional parameters are no longer
1321 allowed.
1322
1323 I<template>, if specified, is the name of a suffix for alternate invoices.
1324
1325 I<invoice_from>, if specified, overrides the default email invoice From: 
1326 address.
1327
1328 I<notice_name> is the name of the sent document.
1329
1330 =cut
1331
1332 sub queueable_email {
1333   my %opt = @_;
1334
1335   my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
1336     or die "invalid invoice number: " . $opt{invnum};
1337
1338   my %args = map {$_ => $opt{$_}} 
1339              grep { $opt{$_} }
1340               qw( invoice_from notice_name no_coupon template );
1341
1342   my $error = $self->email( \%args );
1343   die $error if $error;
1344
1345 }
1346
1347 sub email {
1348   my $self = shift;
1349   return if $self->hide;
1350   my $conf = $self->conf;
1351   my $opt = shift || {};
1352   if ($opt and !ref($opt)) {
1353     die "FS::cust_bill::email called with positional parameters";
1354   }
1355
1356   my $template = $opt->{template};
1357   my $from = delete $opt->{invoice_from};
1358
1359   # this is where we set the From: address
1360   $from ||= $self->_agent_invoice_from ||    #XXX should go away
1361             $conf->config('invoice_from', $self->cust_main->agentnum );
1362
1363   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
1364                             $self->cust_main->invoicing_list;
1365
1366   if ( ! @invoicing_list ) { #no recipients
1367     if ( $conf->exists('cust_bill-no_recipients-error') ) {
1368       die 'No recipients for customer #'. $self->custnum;
1369     } else {
1370       #default: better to notify this person than silence
1371       @invoicing_list = ($from);
1372     }
1373   }
1374
1375   # this is where we set the Subject:
1376   my $subject = $self->email_subject($template);
1377
1378   my $error = send_email(
1379     $self->generate_email(
1380       'from'        => $from,
1381       'to'          => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
1382       'subject'     => $subject,
1383       %$opt, # template, etc.
1384     )
1385   );
1386   die "can't email invoice: $error\n" if $error;
1387   #die "$error\n" if $error;
1388
1389 }
1390
1391 sub email_subject {
1392   my $self = shift;
1393   my $conf = $self->conf;
1394
1395   #my $template = scalar(@_) ? shift : '';
1396   #per-template?
1397
1398   my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
1399                 || 'Invoice';
1400
1401   my $cust_main = $self->cust_main;
1402   my $name = $cust_main->name;
1403   my $name_short = $cust_main->name_short;
1404   my $invoice_number = $self->invnum;
1405   my $invoice_date = $self->_date_pretty;
1406
1407   eval qq("$subject");
1408 }
1409
1410 =item lpr_data HASHREF
1411
1412 Returns the postscript or plaintext for this invoice as an arrayref.
1413
1414 Options must be passed as a hashref.  Positional parameters are no longer 
1415 allowed.
1416
1417 I<template>, if specified, is the name of a suffix for alternate invoices.
1418
1419 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1420
1421 =cut
1422
1423 sub lpr_data {
1424   my $self = shift;
1425   my $conf = $self->conf;
1426   my $opt = shift || {};
1427   if ($opt and !ref($opt)) {
1428     # nobody does this anyway
1429     die "FS::cust_bill::lpr_data called with positional parameters";
1430   }
1431
1432   my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
1433   [ $self->$method( $opt ) ];
1434 }
1435
1436 =item print HASHREF
1437
1438 Prints this invoice.
1439
1440 Options must be passed as a hashref.
1441
1442 I<template>, if specified, is the name of a suffix for alternate invoices.
1443
1444 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1445
1446 =cut
1447
1448 sub print {
1449   my $self = shift;
1450   return if $self->hide;
1451   my $conf = $self->conf;
1452   my $opt = shift || {};
1453   if ($opt and !ref($opt)) {
1454     die "FS::cust_bill::print called with positional parameters";
1455   }
1456
1457   my $lpr = delete $opt->{lpr};
1458   if($conf->exists('invoice_print_pdf')) {
1459     # Add the invoice to the current batch.
1460     $self->batch_invoice($opt);
1461   }
1462   else {
1463     do_print(
1464       $self->lpr_data($opt),
1465       'agentnum' => $self->cust_main->agentnum,
1466       'lpr'      => $lpr,
1467     );
1468   }
1469 }
1470
1471 =item fax_invoice HASHREF
1472
1473 Faxes this invoice.
1474
1475 Options must be passed as a hashref.
1476
1477 I<template>, if specified, is the name of a suffix for alternate invoices.
1478
1479 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1480
1481 =cut
1482
1483 sub fax_invoice {
1484   my $self = shift;
1485   return if $self->hide;
1486   my $conf = $self->conf;
1487   my $opt = shift || {};
1488   if ($opt and !ref($opt)) {
1489     die "FS::cust_bill::fax_invoice called with positional parameters";
1490   }
1491
1492   die 'FAX invoice destination not (yet?) supported with plain text invoices.'
1493     unless $conf->exists('invoice_latex');
1494
1495   my $dialstring = $self->cust_main->getfield('fax');
1496   #Check $dialstring?
1497
1498   my $error = send_fax( 'docdata'    => $self->lpr_data($opt),
1499                         'dialstring' => $dialstring,
1500                       );
1501   die $error if $error;
1502
1503 }
1504
1505 =item batch_invoice [ HASHREF ]
1506
1507 Place this invoice into the open batch (see C<FS::bill_batch>).  If there 
1508 isn't an open batch, one will be created.
1509
1510 =cut
1511
1512 sub batch_invoice {
1513   my ($self, $opt) = @_;
1514   my $bill_batch = $self->get_open_bill_batch;
1515   my $cust_bill_batch = FS::cust_bill_batch->new({
1516       batchnum => $bill_batch->batchnum,
1517       invnum   => $self->invnum,
1518   });
1519   return $cust_bill_batch->insert($opt);
1520 }
1521
1522 =item get_open_batch
1523
1524 Returns the currently open batch as an FS::bill_batch object, creating a new
1525 one if necessary.  (A per-agent batch if invoice_print_pdf-spoolagent is
1526 enabled)
1527
1528 =cut
1529
1530 sub get_open_bill_batch {
1531   my $self = shift;
1532   my $conf = $self->conf;
1533   my $hashref = { status => 'O' };
1534   $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
1535                              ? $self->cust_main->agentnum
1536                              : '';
1537   my $batch = qsearchs('bill_batch', $hashref);
1538   return $batch if $batch;
1539   $batch = FS::bill_batch->new($hashref);
1540   my $error = $batch->insert;
1541   die $error if $error;
1542   return $batch;
1543 }
1544
1545 =item ftp_invoice [ TEMPLATENAME ] 
1546
1547 Sends this invoice data via FTP.
1548
1549 TEMPLATENAME is unused?
1550
1551 =cut
1552
1553 sub ftp_invoice {
1554   my $self = shift;
1555   my $conf = $self->conf;
1556   my $template = scalar(@_) ? shift : '';
1557
1558   $self->send_csv(
1559     'protocol'   => 'ftp',
1560     'server'     => $conf->config('cust_bill-ftpserver'),
1561     'username'   => $conf->config('cust_bill-ftpusername'),
1562     'password'   => $conf->config('cust_bill-ftppassword'),
1563     'dir'        => $conf->config('cust_bill-ftpdir'),
1564     'format'     => $conf->config('cust_bill-ftpformat'),
1565   );
1566 }
1567
1568 =item spool_invoice [ TEMPLATENAME ] 
1569
1570 Spools this invoice data (see L<FS::spool_csv>)
1571
1572 TEMPLATENAME is unused?
1573
1574 =cut
1575
1576 sub spool_invoice {
1577   my $self = shift;
1578   my $conf = $self->conf;
1579   my $template = scalar(@_) ? shift : '';
1580
1581   $self->spool_csv(
1582     'format'       => $conf->config('cust_bill-spoolformat'),
1583     'agent_spools' => $conf->exists('cust_bill-spoolagent'),
1584   );
1585 }
1586
1587 =item send_csv OPTION => VALUE, ...
1588
1589 Sends invoice as a CSV data-file to a remote host with the specified protocol.
1590
1591 Options are:
1592
1593 protocol - currently only "ftp"
1594 server
1595 username
1596 password
1597 dir
1598
1599 The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
1600 and YYMMDDHHMMSS is a timestamp.
1601
1602 See L</print_csv> for a description of the output format.
1603
1604 =cut
1605
1606 sub send_csv {
1607   my($self, %opt) = @_;
1608
1609   #create file(s)
1610
1611   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1612   mkdir $spooldir, 0700 unless -d $spooldir;
1613
1614   # don't localize dates here, they're a defined format
1615   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
1616   my $file = "$spooldir/$tracctnum.csv";
1617   
1618   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
1619
1620   open(CSV, ">$file") or die "can't open $file: $!";
1621   print CSV $header;
1622
1623   print CSV $detail;
1624
1625   close CSV;
1626
1627   my $net;
1628   if ( $opt{protocol} eq 'ftp' ) {
1629     eval "use Net::FTP;";
1630     die $@ if $@;
1631     $net = Net::FTP->new($opt{server}) or die @$;
1632   } else {
1633     die "unknown protocol: $opt{protocol}";
1634   }
1635
1636   $net->login( $opt{username}, $opt{password} )
1637     or die "can't FTP to $opt{username}\@$opt{server}: login error: $@";
1638
1639   $net->binary or die "can't set binary mode";
1640
1641   $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
1642
1643   $net->put($file) or die "can't put $file: $!";
1644
1645   $net->quit;
1646
1647   unlink $file;
1648
1649 }
1650
1651 =item spool_csv
1652
1653 Spools CSV invoice data.
1654
1655 Options are:
1656
1657 =over 4
1658
1659 =item format - any of FS::Misc::::Invoicing::spool_formats
1660
1661 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
1662 customer has the corresponding invoice destinations set (see
1663 L<FS::cust_main_invoice>).
1664
1665 =item agent_spools - if set to a true value, will spool to per-agent files
1666 rather than a single global file
1667
1668 =item upload_targetnum - if set to a target (see L<FS::upload_target>), will
1669 append to that spool.  L<FS::Cron::upload> will then send the spool file to
1670 that destination.
1671
1672 =item balanceover - if set, only spools the invoice if the total amount owed on
1673 this invoice and all older invoices is greater than the specified amount.
1674
1675 =item time - the "current time".  Controls the printing of past due messages
1676 in the ICS format.
1677
1678 =back
1679
1680 =cut
1681
1682 sub spool_csv {
1683   my($self, %opt) = @_;
1684
1685   my $time = $opt{'time'} || time;
1686   my $cust_main = $self->cust_main;
1687
1688   if ( $opt{'dest'} ) {
1689     my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
1690                              $cust_main->invoicing_list;
1691     return 'N/A' unless $invoicing_list{$opt{'dest'}}
1692                      || ! keys %invoicing_list;
1693   }
1694
1695   if ( $opt{'balanceover'} ) {
1696     return 'N/A'
1697       if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
1698   }
1699
1700   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
1701   mkdir $spooldir, 0700 unless -d $spooldir;
1702
1703   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', $time);
1704
1705   my $file;
1706   if ( $opt{'agent_spools'} ) {
1707     $file = 'agentnum'.$cust_main->agentnum;
1708   } else {
1709     $file = 'spool';
1710   }
1711
1712   if ( $opt{'upload_targetnum'} ) {
1713     $spooldir .= '/target'.$opt{'upload_targetnum'};
1714     mkdir $spooldir, 0700 unless -d $spooldir;
1715   } # otherwise it just goes into export.xxx/cust_bill
1716
1717   if ( lc($opt{'format'}) eq 'billco' ) {
1718     $file .= '-header';
1719   }
1720
1721   $file = "$spooldir/$file.csv";
1722   
1723   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum);
1724
1725   open(CSV, ">>$file") or die "can't open $file: $!";
1726   flock(CSV, LOCK_EX);
1727   seek(CSV, 0, 2);
1728
1729   print CSV $header;
1730
1731   if ( lc($opt{'format'}) eq 'billco' ) {
1732
1733     flock(CSV, LOCK_UN);
1734     close CSV;
1735
1736     $file =~ s/-header.csv$/-detail.csv/;
1737
1738     open(CSV,">>$file") or die "can't open $file: $!";
1739     flock(CSV, LOCK_EX);
1740     seek(CSV, 0, 2);
1741   }
1742
1743   print CSV $detail if defined($detail);
1744
1745   flock(CSV, LOCK_UN);
1746   close CSV;
1747
1748   return '';
1749
1750 }
1751
1752 =item print_csv OPTION => VALUE, ...
1753
1754 Returns CSV data for this invoice.
1755
1756 Options are:
1757
1758 format - 'default', 'billco', 'oneline', 'bridgestone'
1759
1760 Returns a list consisting of two scalars.  The first is a single line of CSV
1761 header information for this invoice.  The second is one or more lines of CSV
1762 detail information for this invoice.
1763
1764 If I<format> is not specified or "default", the fields of the CSV file are as
1765 follows:
1766
1767 record_type, invnum, custnum, _date, charged, first, last, company, address1, 
1768 address2, city, state, zip, country, pkg, setup, recur, sdate, edate
1769
1770 =over 4
1771
1772 =item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
1773
1774 B<record_type> is C<cust_bill> for the initial header line only.  The
1775 last five fields (B<pkg> through B<edate>) are irrelevant, and all other
1776 fields are filled in.
1777
1778 B<record_type> is C<cust_bill_pkg> for detail lines.  Only the first two fields
1779 (B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
1780 are filled in.
1781
1782 =item invnum - invoice number
1783
1784 =item custnum - customer number
1785
1786 =item _date - invoice date
1787
1788 =item charged - total invoice amount
1789
1790 =item first - customer first name
1791
1792 =item last - customer first name
1793
1794 =item company - company name
1795
1796 =item address1 - address line 1
1797
1798 =item address2 - address line 1
1799
1800 =item city
1801
1802 =item state
1803
1804 =item zip
1805
1806 =item country
1807
1808 =item pkg - line item description
1809
1810 =item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
1811
1812 =item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
1813
1814 =item sdate - start date for recurring fee
1815
1816 =item edate - end date for recurring fee
1817
1818 =back
1819
1820 If I<format> is "billco", the fields of the header CSV file are as follows:
1821
1822   +-------------------------------------------------------------------+
1823   |                        FORMAT HEADER FILE                         |
1824   |-------------------------------------------------------------------|
1825   | Field | Description                   | Name       | Type | Width |
1826   | 1     | N/A-Leave Empty               | RC         | CHAR |     2 |
1827   | 2     | N/A-Leave Empty               | CUSTID     | CHAR |    15 |
1828   | 3     | Transaction Account No        | TRACCTNUM  | CHAR |    15 |
1829   | 4     | Transaction Invoice No        | TRINVOICE  | CHAR |    15 |
1830   | 5     | Transaction Zip Code          | TRZIP      | CHAR |     5 |
1831   | 6     | Transaction Company Bill To   | TRCOMPANY  | CHAR |    30 |
1832   | 7     | Transaction Contact Bill To   | TRNAME     | CHAR |    30 |
1833   | 8     | Additional Address Unit Info  | TRADDR1    | CHAR |    30 |
1834   | 9     | Bill To Street Address        | TRADDR2    | CHAR |    30 |
1835   | 10    | Ancillary Billing Information | TRADDR3    | CHAR |    30 |
1836   | 11    | Transaction City Bill To      | TRCITY     | CHAR |    20 |
1837   | 12    | Transaction State Bill To     | TRSTATE    | CHAR |     2 |
1838   | 13    | Bill Cycle Close Date         | CLOSEDATE  | CHAR |    10 |
1839   | 14    | Bill Due Date                 | DUEDATE    | CHAR |    10 |
1840   | 15    | Previous Balance              | BALFWD     | NUM* |     9 |
1841   | 16    | Pmt/CR Applied                | CREDAPPLY  | NUM* |     9 |
1842   | 17    | Total Current Charges         | CURRENTCHG | NUM* |     9 |
1843   | 18    | Total Amt Due                 | TOTALDUE   | NUM* |     9 |
1844   | 19    | Total Amt Due                 | AMTDUE     | NUM* |     9 |
1845   | 20    | 30 Day Aging                  | AMT30      | NUM* |     9 |
1846   | 21    | 60 Day Aging                  | AMT60      | NUM* |     9 |
1847   | 22    | 90 Day Aging                  | AMT90      | NUM* |     9 |
1848   | 23    | Y/N                           | AGESWITCH  | CHAR |     1 |
1849   | 24    | Remittance automation         | SCANLINE   | CHAR |   100 |
1850   | 25    | Total Taxes & Fees            | TAXTOT     | NUM* |     9 |
1851   | 26    | Customer Reference Number     | CUSTREF    | CHAR |    15 |
1852   | 27    | Federal Tax***                | FEDTAX     | NUM* |     9 |
1853   | 28    | State Tax***                  | STATETAX   | NUM* |     9 |
1854   | 29    | Other Taxes & Fees***         | OTHERTAX   | NUM* |     9 |
1855   +-------+-------------------------------+------------+------+-------+
1856
1857 If I<format> is "billco", the fields of the detail CSV file are as follows:
1858
1859                                   FORMAT FOR DETAIL FILE
1860         |                            |           |      |
1861   Field | Description                | Name      | Type | Width
1862   1     | N/A-Leave Empty            | RC        | CHAR |     2
1863   2     | N/A-Leave Empty            | CUSTID    | CHAR |    15
1864   3     | Account Number             | TRACCTNUM | CHAR |    15
1865   4     | Invoice Number             | TRINVOICE | CHAR |    15
1866   5     | Line Sequence (sort order) | LINESEQ   | NUM  |     6
1867   6     | Transaction Detail         | DETAILS   | CHAR |   100
1868   7     | Amount                     | AMT       | NUM* |     9
1869   8     | Line Format Control**      | LNCTRL    | CHAR |     2
1870   9     | Grouping Code              | GROUP     | CHAR |     2
1871   10    | User Defined               | ACCT CODE | CHAR |    15
1872
1873 If format is 'oneline', there is no detail file.  Each invoice has a 
1874 header line only, with the fields:
1875
1876 Agent number, agent name, customer number, first name, last name, address
1877 line 1, address line 2, city, state, zip, invoice date, invoice number,
1878 amount charged, amount due, previous balance, due date.
1879
1880 and then, for each line item, three columns containing the package number,
1881 description, and amount.
1882
1883 If format is 'bridgestone', there is no detail file.  Each invoice has a 
1884 header line with the following fields in a fixed-width format:
1885
1886 Customer number (in display format), date, name (first last), company,
1887 address 1, address 2, city, state, zip.
1888
1889 This is a mailing list format, and has no per-invoice fields.  To avoid
1890 sending redundant notices, the spooling event should have a "once" or 
1891 "once_percust_every" condition.
1892
1893 =cut
1894
1895 sub print_csv {
1896   my($self, %opt) = @_;
1897   
1898   eval "use Text::CSV_XS";
1899   die $@ if $@;
1900
1901   my $cust_main = $self->cust_main;
1902
1903   my $csv = Text::CSV_XS->new({'always_quote'=>1});
1904   my $format = lc($opt{'format'});
1905
1906   my $time = $opt{'time'} || time;
1907
1908   my $tracctnum = ''; #leaking out from billco-specific sections :/
1909   if ( $format eq 'billco' ) {
1910
1911     my $account_num =
1912       $self->conf->config('billco-account_num', $cust_main->agentnum);
1913
1914     $tracctnum = $account_num eq 'display_custnum'
1915                    ? $cust_main->display_custnum
1916                    : $opt{'tracctnum'};
1917
1918     my $taxtotal = 0;
1919     $taxtotal += $_->{'amount'} foreach $self->_items_tax;
1920
1921     my $duedate = $self->due_date2str('%m/%d/%Y'); # hardcoded, NOT date_format
1922
1923     my( $previous_balance, @unused ) = $self->previous; #previous balance
1924
1925     my $pmt_cr_applied = 0;
1926     $pmt_cr_applied += $_->{'amount'}
1927       foreach ( $self->_items_payments(%opt), $self->_items_credits(%opt) ) ;
1928
1929     my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
1930
1931     $csv->combine(
1932       '',                         #  1 | N/A-Leave Empty               CHAR   2
1933       '',                         #  2 | N/A-Leave Empty               CHAR  15
1934       $tracctnum,                 #  3 | Transaction Account No        CHAR  15
1935       $self->invnum,              #  4 | Transaction Invoice No        CHAR  15
1936       $cust_main->zip,            #  5 | Transaction Zip Code          CHAR   5
1937       $cust_main->company,        #  6 | Transaction Company Bill To   CHAR  30
1938       #$cust_main->payname,        #  7 | Transaction Contact Bill To   CHAR  30
1939       $cust_main->contact,        #  7 | Transaction Contact Bill To   CHAR  30
1940       $cust_main->address2,       #  8 | Additional Address Unit Info  CHAR  30
1941       $cust_main->address1,       #  9 | Bill To Street Address        CHAR  30
1942       '',                         # 10 | Ancillary Billing Information CHAR  30
1943       $cust_main->city,           # 11 | Transaction City Bill To      CHAR  20
1944       $cust_main->state,          # 12 | Transaction State Bill To     CHAR   2
1945
1946       # XXX ?
1947       time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR  10
1948
1949       # XXX ?
1950       $duedate,                   # 14 | Bill Due Date                 CHAR  10
1951
1952       $previous_balance,          # 15 | Previous Balance              NUM*   9
1953       $pmt_cr_applied,            # 16 | Pmt/CR Applied                NUM*   9
1954       sprintf("%.2f", $self->charged), # 17 | Total Current Charges    NUM*   9
1955       $totaldue,                  # 18 | Total Amt Due                 NUM*   9
1956       $totaldue,                  # 19 | Total Amt Due                 NUM*   9
1957       '',                         # 20 | 30 Day Aging                  NUM*   9
1958       '',                         # 21 | 60 Day Aging                  NUM*   9
1959       '',                         # 22 | 90 Day Aging                  NUM*   9
1960       'N',                        # 23 | Y/N                           CHAR   1
1961       '',                         # 24 | Remittance automation         CHAR 100
1962       $taxtotal,                  # 25 | Total Taxes & Fees            NUM*   9
1963       $self->custnum,             # 26 | Customer Reference Number     CHAR  15
1964       '0',                        # 27 | Federal Tax***                NUM*   9
1965       sprintf("%.2f", $taxtotal), # 28 | State Tax***                  NUM*   9
1966       '0',                        # 29 | Other Taxes & Fees***         NUM*   9
1967     );
1968
1969   } elsif ( $format eq 'oneline' ) { #name
1970   
1971     my ($previous_balance) = $self->previous; 
1972     $previous_balance = sprintf('%.2f', $previous_balance);
1973     my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
1974     my @items = map {
1975                       $_->{pkgnum},
1976                       $_->{description},
1977                       $_->{amount}
1978                     }
1979                   $self->_items_pkg, #_items_nontax?  no sections or anything
1980                                      # with this format
1981                   $self->_items_tax;
1982
1983     $csv->combine(
1984       $cust_main->agentnum,
1985       $cust_main->agent->agent,
1986       $self->custnum,
1987       $cust_main->first,
1988       $cust_main->last,
1989       $cust_main->company,
1990       $cust_main->address1,
1991       $cust_main->address2,
1992       $cust_main->city,
1993       $cust_main->state,
1994       $cust_main->zip,
1995
1996       # invoice fields
1997       time2str("%x", $self->_date),
1998       $self->invnum,
1999       $self->charged,
2000       $totaldue,
2001       $previous_balance,
2002       $self->due_date2str("%x"),
2003
2004       @items,
2005     );
2006
2007   } elsif ( $format eq 'bridgestone' ) {
2008
2009     # bypass the CSV stuff and just return this
2010     my $longdate = time2str('%B %d, %Y', $time); #current time, right?
2011     my $zip = $cust_main->zip;
2012     $zip =~ s/\D//;
2013     my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
2014       || '';
2015     return (
2016       sprintf(
2017         "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
2018         $prefix,
2019         $cust_main->display_custnum,
2020         $longdate,
2021         uc(substr($cust_main->contact_firstlast,0,30)),
2022         uc(substr($cust_main->company          ,0,30)),
2023         uc(substr($cust_main->address1         ,0,30)),
2024         uc(substr($cust_main->address2         ,0,30)),
2025         uc(substr($cust_main->city             ,0,20)),
2026         uc($cust_main->state),
2027         $zip
2028       ),
2029       '' #detail
2030       );
2031
2032   } elsif ( $format eq 'ics' ) {
2033
2034     my $bill = $cust_main->bill_location;
2035     my $zip = $bill->zip;
2036     my $zip4 = '';
2037
2038     $zip =~ s/\D//;
2039     if ( $zip =~ /^(\d{5})(\d{4})$/ ) {
2040       $zip = $1;
2041       $zip4 = $2;
2042     }
2043
2044     # minor false laziness with print_generic
2045     my ($previous_balance) = $self->previous;
2046     my $balance_due = $self->owed + $previous_balance;
2047     my $payment_total = sum(0, map { $_->{'amount'} } $self->_items_payments);
2048     my $credit_total  = sum(0, map { $_->{'amount'} } $self->_items_credits);
2049
2050     my $past_due = '';
2051     if ( $self->due_date and $time >= $self->due_date ) {
2052       $past_due = sprintf('Past due:$%0.2f Due Immediately', $balance_due);
2053     }
2054
2055     # again, bypass CSV
2056     my $header = sprintf(
2057       '%-10s%-30s%-48s%-2s%-50s%-30s%-30s%-25s%-2s%-5s%-4s%-8s%-8s%-10s%-10s%-10s%-10s%-10s%-10s%-480s%-35s',
2058       $cust_main->display_custnum, #BID
2059       uc($cust_main->first), #FNAME
2060       uc($cust_main->last), #LNAME
2061       '00', #BATCH, should this ever be anything else?
2062       uc($cust_main->company), #COMP
2063       uc($bill->address1), #STREET1
2064       uc($bill->address2), #STREET2
2065       uc($bill->city), #CITY
2066       uc($bill->state), #STATE
2067       $zip,
2068       $zip4,
2069       time2str('%Y%m%d', $self->_date), #BILL_DATE
2070       $self->due_date2str('%Y%m%d'), #DUE_DATE,
2071       ( map {sprintf('%0.2f', $_)}
2072         $balance_due, #AMNT_DUE
2073         $previous_balance, #PREV_BAL
2074         $payment_total, #PYMT_RCVD
2075         $credit_total, #CREDITS
2076         $previous_balance, #BEG_BAL--is this correct?
2077         $self->charged, #NEW_CHRG
2078       ),
2079       'img01', #MRKT_MSG?
2080       $past_due, #PAST_MSG
2081     );
2082
2083     my @details;
2084     my %svc_class = ('' => ''); # maybe cache this more persistently?
2085
2086     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2087
2088       my $show_pkgnum = $cust_bill_pkg->pkgnum || '';
2089       my $cust_pkg = $cust_bill_pkg->cust_pkg if $show_pkgnum;
2090
2091       if ( $cust_pkg ) {
2092
2093         my @dates = ( $self->_date, undef );
2094         if ( my $prev = $cust_bill_pkg->previous_cust_bill_pkg ) {
2095           $dates[1] = $prev->sdate; #questionable
2096         }
2097
2098         # generate an 01 detail for each service
2099         my @svcs = $cust_pkg->h_cust_svc(@dates, 'I');
2100         foreach my $cust_svc ( @svcs ) {
2101           $show_pkgnum = ''; # hide it if we're showing svcnums
2102
2103           my $svcpart = $cust_svc->svcpart;
2104           if (!exists($svc_class{$svcpart})) {
2105             my $classnum = $cust_svc->part_svc->classnum;
2106             my $part_svc_class = FS::part_svc_class->by_key($classnum)
2107               if $classnum;
2108             $svc_class{$svcpart} = $part_svc_class ? 
2109                                    $part_svc_class->classname :
2110                                    '';
2111           }
2112
2113           my @h_label = $cust_svc->label(@dates, 'I');
2114           push @details, sprintf('01%-9s%-20s%-47s',
2115             $cust_svc->svcnum,
2116             $svc_class{$svcpart},
2117             $h_label[1],
2118           );
2119         } #foreach $cust_svc
2120       } #if $cust_pkg
2121
2122       my $desc = $cust_bill_pkg->desc; # itemdesc or part_pkg.pkg
2123       if ($cust_bill_pkg->recur > 0) {
2124         $desc .= ' '.time2str('%d-%b-%Y', $cust_bill_pkg->sdate).' to '.
2125                      time2str('%d-%b-%Y', $cust_bill_pkg->edate - 86400);
2126       }
2127       push @details, sprintf('02%-6s%-60s%-10s',
2128         $show_pkgnum,
2129         $desc,
2130         sprintf('%0.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
2131       );
2132     } #foreach $cust_bill_pkg
2133
2134     # Tag this row so that we know whether this is one page (1), two pages
2135     # (2), # or "big" (B).  The tag will be stripped off before uploading.
2136     if ( scalar(@details) < 12 ) {
2137       push @details, '1';
2138     } elsif ( scalar(@details) < 58 ) {
2139       push @details, '2';
2140     } else {
2141       push @details, 'B';
2142     }
2143
2144     return join('', $header, @details, "\n");
2145
2146   } else { # default
2147   
2148     $csv->combine(
2149       'cust_bill',
2150       $self->invnum,
2151       $self->custnum,
2152       time2str("%x", $self->_date),
2153       sprintf("%.2f", $self->charged),
2154       ( map { $cust_main->getfield($_) }
2155           qw( first last company address1 address2 city state zip country ) ),
2156       map { '' } (1..5),
2157     ) or die "can't create csv";
2158   }
2159
2160   my $header = $csv->string. "\n";
2161
2162   my $detail = '';
2163   if ( lc($opt{'format'}) eq 'billco' ) {
2164
2165     my $lineseq = 0;
2166     foreach my $item ( $self->_items_pkg ) {
2167
2168       $csv->combine(
2169         '',                     #  1 | N/A-Leave Empty            CHAR   2
2170         '',                     #  2 | N/A-Leave Empty            CHAR  15
2171         $tracctnum,             #  3 | Account Number             CHAR  15
2172         $self->invnum,          #  4 | Invoice Number             CHAR  15
2173         $lineseq++,             #  5 | Line Sequence (sort order) NUM    6
2174         $item->{'description'}, #  6 | Transaction Detail         CHAR 100
2175         $item->{'amount'},      #  7 | Amount                     NUM*   9
2176         '',                     #  8 | Line Format Control**      CHAR   2
2177         '',                     #  9 | Grouping Code              CHAR   2
2178         '',                     # 10 | User Defined               CHAR  15
2179       );
2180
2181       $detail .= $csv->string. "\n";
2182
2183     }
2184
2185   } elsif ( lc($opt{'format'}) eq 'oneline' ) {
2186
2187     #do nothing
2188
2189   } else {
2190
2191     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2192
2193       my($pkg, $setup, $recur, $sdate, $edate);
2194       if ( $cust_bill_pkg->pkgnum ) {
2195       
2196         ($pkg, $setup, $recur, $sdate, $edate) = (
2197           $cust_bill_pkg->part_pkg->pkg,
2198           ( $cust_bill_pkg->setup != 0
2199             ? sprintf("%.2f", $cust_bill_pkg->setup )
2200             : '' ),
2201           ( $cust_bill_pkg->recur != 0
2202             ? sprintf("%.2f", $cust_bill_pkg->recur )
2203             : '' ),
2204           ( $cust_bill_pkg->sdate 
2205             ? time2str("%x", $cust_bill_pkg->sdate)
2206             : '' ),
2207           ($cust_bill_pkg->edate 
2208             ? time2str("%x", $cust_bill_pkg->edate)
2209             : '' ),
2210         );
2211   
2212       } else { #pkgnum tax
2213         next unless $cust_bill_pkg->setup != 0;
2214         $pkg = $cust_bill_pkg->desc;
2215         $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
2216         ( $sdate, $edate ) = ( '', '' );
2217       }
2218   
2219       $csv->combine(
2220         'cust_bill_pkg',
2221         $self->invnum,
2222         ( map { '' } (1..11) ),
2223         ($pkg, $setup, $recur, $sdate, $edate)
2224       ) or die "can't create csv";
2225
2226       $detail .= $csv->string. "\n";
2227
2228     }
2229
2230   }
2231
2232   ( $header, $detail );
2233
2234 }
2235
2236 =item comp
2237
2238 Pays this invoice with a compliemntary payment.  If there is an error,
2239 returns the error, otherwise returns false.
2240
2241 =cut
2242
2243 sub comp {
2244   my $self = shift;
2245   my $cust_pay = new FS::cust_pay ( {
2246     'invnum'   => $self->invnum,
2247     'paid'     => $self->owed,
2248     '_date'    => '',
2249     'payby'    => 'COMP',
2250     'payinfo'  => $self->cust_main->payinfo,
2251     'paybatch' => '',
2252   } );
2253   $cust_pay->insert;
2254 }
2255
2256 =item realtime_card
2257
2258 Attempts to pay this invoice with a credit card payment via a
2259 Business::OnlinePayment realtime gateway.  See
2260 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2261 for supported processors.
2262
2263 =cut
2264
2265 sub realtime_card {
2266   my $self = shift;
2267   $self->realtime_bop( 'CC', @_ );
2268 }
2269
2270 =item realtime_ach
2271
2272 Attempts to pay this invoice with an electronic check (ACH) payment via a
2273 Business::OnlinePayment realtime gateway.  See
2274 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2275 for supported processors.
2276
2277 =cut
2278
2279 sub realtime_ach {
2280   my $self = shift;
2281   $self->realtime_bop( 'ECHECK', @_ );
2282 }
2283
2284 =item realtime_lec
2285
2286 Attempts to pay this invoice with phone bill (LEC) payment via a
2287 Business::OnlinePayment realtime gateway.  See
2288 http://search.cpan.org/search?mode=module&query=Business%3A%3AOnlinePayment
2289 for supported processors.
2290
2291 =cut
2292
2293 sub realtime_lec {
2294   my $self = shift;
2295   $self->realtime_bop( 'LEC', @_ );
2296 }
2297
2298 sub realtime_bop {
2299   my( $self, $method ) = (shift,shift);
2300   my $conf = $self->conf;
2301   my %opt = @_;
2302
2303   my $cust_main = $self->cust_main;
2304   my $balance = $cust_main->balance;
2305   my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
2306   $amount = sprintf("%.2f", $amount);
2307   return "not run (balance $balance)" unless $amount > 0;
2308
2309   my $description = 'Internet Services';
2310   if ( $conf->exists('business-onlinepayment-description') ) {
2311     my $dtempl = $conf->config('business-onlinepayment-description');
2312
2313     my $agent_obj = $cust_main->agent
2314       or die "can't retreive agent for $cust_main (agentnum ".
2315              $cust_main->agentnum. ")";
2316     my $agent = $agent_obj->agent;
2317     my $pkgs = join(', ',
2318       map { $_->part_pkg->pkg }
2319         grep { $_->pkgnum } $self->cust_bill_pkg
2320     );
2321     $description = eval qq("$dtempl");
2322   }
2323
2324   $cust_main->realtime_bop($method, $amount,
2325     'description' => $description,
2326     'invnum'      => $self->invnum,
2327 #this didn't do what we want, it just calls apply_payments_and_credits
2328 #    'apply'       => 1,
2329     'apply_to_invoice' => 1,
2330     %opt,
2331  #what we want:
2332  #this changes application behavior: auto payments
2333                         #triggered against a specific invoice are now applied
2334                         #to that invoice instead of oldest open.
2335                         #seem okay to me...
2336   );
2337
2338 }
2339
2340 =item batch_card OPTION => VALUE...
2341
2342 Adds a payment for this invoice to the pending credit card batch (see
2343 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2344 runs the payment using a realtime gateway.
2345
2346 =cut
2347
2348 sub batch_card {
2349   my ($self, %options) = @_;
2350   my $cust_main = $self->cust_main;
2351
2352   $options{invnum} = $self->invnum;
2353   
2354   $cust_main->batch_card(%options);
2355 }
2356
2357 sub _agent_template {
2358   my $self = shift;
2359   $self->cust_main->agent_template;
2360 }
2361
2362 sub _agent_invoice_from {
2363   my $self = shift;
2364   $self->cust_main->agent_invoice_from;
2365 }
2366
2367 =item invoice_barcode DIR_OR_FALSE
2368
2369 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
2370 it is taken as the temp directory where the PNG file will be generated and the
2371 PNG file name is returned. Otherwise, the PNG image itself is returned.
2372
2373 =cut
2374
2375 sub invoice_barcode {
2376     my ($self, $dir) = (shift,shift);
2377     
2378     my $gdbar = new GD::Barcode('Code39',$self->invnum);
2379         die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
2380     my $gd = $gdbar->plot(Height => 30);
2381
2382     if($dir) {
2383         my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
2384                            DIR      => $dir,
2385                            SUFFIX   => '.png',
2386                            UNLINK   => 0,
2387                          ) or die "can't open temp file: $!\n";
2388         print $bh $gd->png or die "cannot write barcode to file: $!\n";
2389         my $png_file = $bh->filename;
2390         close $bh;
2391         return $png_file;
2392     }
2393     return $gd->png;
2394 }
2395
2396 =item invnum_date_pretty
2397
2398 Returns a string with the invoice number and date, for example:
2399 "Invoice #54 (3/20/2008)".
2400
2401 Intended for back-end context, with regard to translation and date formatting.
2402
2403 =cut
2404
2405 #note: this uses _date_pretty_unlocalized because _date_pretty is too expensive
2406 # for backend use (and also does the wrong thing, localizing for end customer
2407 # instead of backoffice configured date format)
2408 sub invnum_date_pretty {
2409   my $self = shift;
2410   #$self->mt('Invoice #').
2411   'Invoice #'. #XXX should be translated ala web UI user (not invoice customer)
2412     $self->invnum. ' ('. $self->_date_pretty_unlocalized. ')';
2413 }
2414
2415 #sub _items_extra_usage_sections {
2416 #  my $self = shift;
2417 #  my $escape = shift;
2418 #
2419 #  my %sections = ();
2420 #
2421 #  my %usage_class =  map{ $_->classname, $_ } qsearch('usage_class', {});
2422 #  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2423 #  {
2424 #    next unless $cust_bill_pkg->pkgnum > 0;
2425 #
2426 #    foreach my $section ( keys %usage_class ) {
2427 #
2428 #      my $usage = $cust_bill_pkg->usage($section);
2429 #
2430 #      next unless $usage && $usage > 0;
2431 #
2432 #      $sections{$section} ||= 0;
2433 #      $sections{$section} += $usage;
2434 #
2435 #    }
2436 #
2437 #  }
2438 #
2439 #  map { { 'description' => &{$escape}($_),
2440 #          'subtotal'    => $sections{$_},
2441 #          'summarized'  => '',
2442 #          'tax_section' => '',
2443 #        }
2444 #      }
2445 #    sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
2446 #
2447 #}
2448
2449 sub _items_extra_usage_sections {
2450   my $self = shift;
2451   my $conf = $self->conf;
2452   my $escape = shift;
2453   my $format = shift;
2454
2455   my %sections = ();
2456   my %classnums = ();
2457   my %lines = ();
2458
2459   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2460
2461   my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2462   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2463     next unless $cust_bill_pkg->pkgnum > 0;
2464
2465     foreach my $classnum ( keys %usage_class ) {
2466       my $section = $usage_class{$classnum}->classname;
2467       $classnums{$section} = $classnum;
2468
2469       foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
2470         my $amount = $detail->amount;
2471         next unless $amount && $amount > 0;
2472  
2473         $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
2474         $sections{$section}{amount} += $amount;  #subtotal
2475         $sections{$section}{calls}++;
2476         $sections{$section}{duration} += $detail->duration;
2477
2478         my $desc = $detail->regionname; 
2479         my $description = $desc;
2480         $description = substr($desc, 0, $maxlength). '...'
2481           if $format eq 'latex' && length($desc) > $maxlength;
2482
2483         $lines{$section}{$desc} ||= {
2484           description     => &{$escape}($description),
2485           #pkgpart         => $part_pkg->pkgpart,
2486           pkgnum          => $cust_bill_pkg->pkgnum,
2487           ref             => '',
2488           amount          => 0,
2489           calls           => 0,
2490           duration        => 0,
2491           #unit_amount     => $cust_bill_pkg->unitrecur,
2492           quantity        => $cust_bill_pkg->quantity,
2493           product_code    => 'N/A',
2494           ext_description => [],
2495         };
2496
2497         $lines{$section}{$desc}{amount} += $amount;
2498         $lines{$section}{$desc}{calls}++;
2499         $lines{$section}{$desc}{duration} += $detail->duration;
2500
2501       }
2502     }
2503   }
2504
2505   my %sectionmap = ();
2506   foreach (keys %sections) {
2507     my $usage_class = $usage_class{$classnums{$_}};
2508     $sectionmap{$_} = { 'description' => &{$escape}($_),
2509                         'amount'    => $sections{$_}{amount},    #subtotal
2510                         'calls'       => $sections{$_}{calls},
2511                         'duration'    => $sections{$_}{duration},
2512                         'summarized'  => '',
2513                         'tax_section' => '',
2514                         'sort_weight' => $usage_class->weight,
2515                         ( $usage_class->format
2516                           ? ( map { $_ => $usage_class->$_($format) }
2517                               qw( description_generator header_generator total_generator total_line_generator )
2518                             )
2519                           : ()
2520                         ), 
2521                       };
2522   }
2523
2524   my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
2525                  values %sectionmap;
2526
2527   my @lines = ();
2528   foreach my $section ( keys %lines ) {
2529     foreach my $line ( keys %{$lines{$section}} ) {
2530       my $l = $lines{$section}{$line};
2531       $l->{section}     = $sectionmap{$section};
2532       $l->{amount}      = sprintf( "%.2f", $l->{amount} );
2533       #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2534       push @lines, $l;
2535     }
2536   }
2537
2538   return(\@sections, \@lines);
2539
2540 }
2541
2542 sub _did_summary {
2543     my $self = shift;
2544     my $end = $self->_date;
2545
2546     # start at date of previous invoice + 1 second or 0 if no previous invoice
2547     my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
2548     $start = 0 if !$start;
2549     $start++;
2550
2551     my $cust_main = $self->cust_main;
2552     my @pkgs = $cust_main->all_pkgs;
2553     my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
2554         = (0,0,0,0,0);
2555     my @seen = ();
2556     foreach my $pkg ( @pkgs ) {
2557         my @h_cust_svc = $pkg->h_cust_svc($end);
2558         foreach my $h_cust_svc ( @h_cust_svc ) {
2559             next if grep {$_ eq $h_cust_svc->svcnum} @seen;
2560             next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
2561
2562             my $inserted = $h_cust_svc->date_inserted;
2563             my $deleted = $h_cust_svc->date_deleted;
2564             my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
2565             my $phone_deleted;
2566             $phone_deleted =  $h_cust_svc->h_svc_x($deleted) if $deleted;
2567             
2568 # DID either activated or ported in; cannot be both for same DID simultaneously
2569             if ($inserted >= $start && $inserted <= $end && $phone_inserted
2570                 && (!$phone_inserted->lnp_status 
2571                     || $phone_inserted->lnp_status eq ''
2572                     || $phone_inserted->lnp_status eq 'native')) {
2573                 $num_activated++;
2574             }
2575             else { # this one not so clean, should probably move to (h_)svc_phone
2576                  my $phone_portedin = qsearchs( 'h_svc_phone',
2577                       { 'svcnum' => $h_cust_svc->svcnum, 
2578                         'lnp_status' => 'portedin' },  
2579                       FS::h_svc_phone->sql_h_searchs($end),  
2580                     );
2581                  $num_portedin++ if $phone_portedin;
2582             }
2583
2584 # DID either deactivated or ported out; cannot be both for same DID simultaneously
2585             if($deleted >= $start && $deleted <= $end && $phone_deleted
2586                 && (!$phone_deleted->lnp_status 
2587                     || $phone_deleted->lnp_status ne 'portingout')) {
2588                 $num_deactivated++;
2589             } 
2590             elsif($deleted >= $start && $deleted <= $end && $phone_deleted 
2591                 && $phone_deleted->lnp_status 
2592                 && $phone_deleted->lnp_status eq 'portingout') {
2593                 $num_portedout++;
2594             }
2595
2596             # increment usage minutes
2597         if ( $phone_inserted ) {
2598             my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
2599             $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
2600         }
2601         else {
2602             warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
2603         }
2604
2605             # don't look at this service again
2606             push @seen, $h_cust_svc->svcnum;
2607         }
2608     }
2609
2610     $minutes = sprintf("%d", $minutes);
2611     ("Activated: $num_activated  Ported-In: $num_portedin  Deactivated: "
2612         . "$num_deactivated  Ported-Out: $num_portedout ",
2613             "Total Minutes: $minutes");
2614 }
2615
2616 sub _items_accountcode_cdr {
2617     my $self = shift;
2618     my $escape = shift;
2619     my $format = shift;
2620
2621     my $section = { 'amount'        => 0,
2622                     'calls'         => 0,
2623                     'duration'      => 0,
2624                     'sort_weight'   => '',
2625                     'phonenum'      => '',
2626                     'description'   => 'Usage by Account Code',
2627                     'post_total'    => '',
2628                     'summarized'    => '',
2629                     'header'        => '',
2630                   };
2631     my @lines;
2632     my %accountcodes = ();
2633
2634     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2635         next unless $cust_bill_pkg->pkgnum > 0;
2636
2637         my @header = $cust_bill_pkg->details_header;
2638         next unless scalar(@header);
2639         $section->{'header'} = join(',',@header);
2640
2641         foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2642
2643             $section->{'header'} = $detail->formatted('format' => $format)
2644                 if($detail->detail eq $section->{'header'}); 
2645       
2646             my $accountcode = $detail->accountcode;
2647             next unless $accountcode;
2648
2649             my $amount = $detail->amount;
2650             next unless $amount && $amount > 0;
2651
2652             $accountcodes{$accountcode} ||= {
2653                     description => $accountcode,
2654                     pkgnum      => '',
2655                     ref         => '',
2656                     amount      => 0,
2657                     calls       => 0,
2658                     duration    => 0,
2659                     quantity    => '',
2660                     product_code => 'N/A',
2661                     section     => $section,
2662                     ext_description => [ $section->{'header'} ],
2663                     detail_temp => [],
2664             };
2665
2666             $section->{'amount'} += $amount;
2667             $accountcodes{$accountcode}{'amount'} += $amount;
2668             $accountcodes{$accountcode}{calls}++;
2669             $accountcodes{$accountcode}{duration} += $detail->duration;
2670             push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
2671         }
2672     }
2673
2674     foreach my $l ( values %accountcodes ) {
2675         $l->{amount} = sprintf( "%.2f", $l->{amount} );
2676         my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
2677         foreach my $sorted_detail ( @sorted_detail ) {
2678             push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
2679         }
2680         delete $l->{detail_temp};
2681         push @lines, $l;
2682     }
2683
2684     my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
2685
2686     return ($section,\@sorted_lines);
2687 }
2688
2689 sub _items_svc_phone_sections {
2690   my $self = shift;
2691   my $conf = $self->conf;
2692   my $escape = shift;
2693   my $format = shift;
2694
2695   my %sections = ();
2696   my %classnums = ();
2697   my %lines = ();
2698
2699   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2700
2701   my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
2702   $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
2703
2704   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
2705     next unless $cust_bill_pkg->pkgnum > 0;
2706
2707     my @header = $cust_bill_pkg->details_header;
2708     next unless scalar(@header);
2709
2710     foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
2711
2712       my $phonenum = $detail->phonenum;
2713       next unless $phonenum;
2714
2715       my $amount = $detail->amount;
2716       next unless $amount && $amount > 0;
2717
2718       $sections{$phonenum} ||= { 'amount'      => 0,
2719                                  'calls'       => 0,
2720                                  'duration'    => 0,
2721                                  'sort_weight' => -1,
2722                                  'phonenum'    => $phonenum,
2723                                 };
2724       $sections{$phonenum}{amount} += $amount;  #subtotal
2725       $sections{$phonenum}{calls}++;
2726       $sections{$phonenum}{duration} += $detail->duration;
2727
2728       my $desc = $detail->regionname; 
2729       my $description = $desc;
2730       $description = substr($desc, 0, $maxlength). '...'
2731         if $format eq 'latex' && length($desc) > $maxlength;
2732
2733       $lines{$phonenum}{$desc} ||= {
2734         description     => &{$escape}($description),
2735         #pkgpart         => $part_pkg->pkgpart,
2736         pkgnum          => '',
2737         ref             => '',
2738         amount          => 0,
2739         calls           => 0,
2740         duration        => 0,
2741         #unit_amount     => '',
2742         quantity        => '',
2743         product_code    => 'N/A',
2744         ext_description => [],
2745       };
2746
2747       $lines{$phonenum}{$desc}{amount} += $amount;
2748       $lines{$phonenum}{$desc}{calls}++;
2749       $lines{$phonenum}{$desc}{duration} += $detail->duration;
2750
2751       my $line = $usage_class{$detail->classnum}->classname;
2752       $sections{"$phonenum $line"} ||=
2753         { 'amount' => 0,
2754           'calls' => 0,
2755           'duration' => 0,
2756           'sort_weight' => $usage_class{$detail->classnum}->weight,
2757           'phonenum' => $phonenum,
2758           'header'  => [ @header ],
2759         };
2760       $sections{"$phonenum $line"}{amount} += $amount;  #subtotal
2761       $sections{"$phonenum $line"}{calls}++;
2762       $sections{"$phonenum $line"}{duration} += $detail->duration;
2763
2764       $lines{"$phonenum $line"}{$desc} ||= {
2765         description     => &{$escape}($description),
2766         #pkgpart         => $part_pkg->pkgpart,
2767         pkgnum          => '',
2768         ref             => '',
2769         amount          => 0,
2770         calls           => 0,
2771         duration        => 0,
2772         #unit_amount     => '',
2773         quantity        => '',
2774         product_code    => 'N/A',
2775         ext_description => [],
2776       };
2777
2778       $lines{"$phonenum $line"}{$desc}{amount} += $amount;
2779       $lines{"$phonenum $line"}{$desc}{calls}++;
2780       $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
2781       push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
2782            $detail->formatted('format' => $format);
2783
2784     }
2785   }
2786
2787   my %sectionmap = ();
2788   my $simple = new FS::usage_class { format => 'simple' }; #bleh
2789   foreach ( keys %sections ) {
2790     my @header = @{ $sections{$_}{header} || [] };
2791     my $usage_simple =
2792       new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
2793     my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
2794     my $usage_class = $summary ? $simple : $usage_simple;
2795     my $ending = $summary ? ' usage charges' : '';
2796     my %gen_opt = ();
2797     unless ($summary) {
2798       $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
2799     }
2800     $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
2801                         'amount'    => $sections{$_}{amount},    #subtotal
2802                         'calls'       => $sections{$_}{calls},
2803                         'duration'    => $sections{$_}{duration},
2804                         'summarized'  => '',
2805                         'tax_section' => '',
2806                         'phonenum'    => $sections{$_}{phonenum},
2807                         'sort_weight' => $sections{$_}{sort_weight},
2808                         'post_total'  => $summary, #inspire pagebreak
2809                         (
2810                           ( map { $_ => $usage_class->$_($format, %gen_opt) }
2811                             qw( description_generator
2812                                 header_generator
2813                                 total_generator
2814                                 total_line_generator
2815                               )
2816                           )
2817                         ), 
2818                       };
2819   }
2820
2821   my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
2822                         $a->{sort_weight} <=> $b->{sort_weight}
2823                       }
2824                  values %sectionmap;
2825
2826   my @lines = ();
2827   foreach my $section ( keys %lines ) {
2828     foreach my $line ( keys %{$lines{$section}} ) {
2829       my $l = $lines{$section}{$line};
2830       $l->{section}     = $sectionmap{$section};
2831       $l->{amount}      = sprintf( "%.2f", $l->{amount} );
2832       #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
2833       push @lines, $l;
2834     }
2835   }
2836   
2837   if($conf->exists('phone_usage_class_summary')) { 
2838       # this only works with Latex
2839       my @newlines;
2840       my @newsections;
2841
2842       # after this, we'll have only two sections per DID:
2843       # Calls Summary and Calls Detail
2844       foreach my $section ( @sections ) {
2845         if($section->{'post_total'}) {
2846             $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
2847             $section->{'total_line_generator'} = sub { '' };
2848             $section->{'total_generator'} = sub { '' };
2849             $section->{'header_generator'} = sub { '' };
2850             $section->{'description_generator'} = '';
2851             push @newsections, $section;
2852             my %calls_detail = %$section;
2853             $calls_detail{'post_total'} = '';
2854             $calls_detail{'sort_weight'} = '';
2855             $calls_detail{'description_generator'} = sub { '' };
2856             $calls_detail{'header_generator'} = sub {
2857                 return ' & Date/Time & Called Number & Duration & Price'
2858                     if $format eq 'latex';
2859                 '';
2860             };
2861             $calls_detail{'description'} = 'Calls Detail: '
2862                                                     . $section->{'phonenum'};
2863             push @newsections, \%calls_detail;  
2864         }
2865       }
2866
2867       # after this, each usage class is collapsed/summarized into a single
2868       # line under the Calls Summary section
2869       foreach my $newsection ( @newsections ) {
2870         if($newsection->{'post_total'}) { # this means Calls Summary
2871             foreach my $section ( @sections ) {
2872                 next unless ($section->{'phonenum'} eq $newsection->{'phonenum'} 
2873                                 && !$section->{'post_total'});
2874                 my $newdesc = $section->{'description'};
2875                 my $tn = $section->{'phonenum'};
2876                 $newdesc =~ s/$tn//g;
2877                 my $line = {  ext_description => [],
2878                               pkgnum => '',
2879                               ref => '',
2880                               quantity => '',
2881                               calls => $section->{'calls'},
2882                               section => $newsection,
2883                               duration => $section->{'duration'},
2884                               description => $newdesc,
2885                               amount => sprintf("%.2f",$section->{'amount'}),
2886                               product_code => 'N/A',
2887                             };
2888                 push @newlines, $line;
2889             }
2890         }
2891       }
2892
2893       # after this, Calls Details is populated with all CDRs
2894       foreach my $newsection ( @newsections ) {
2895         if(!$newsection->{'post_total'}) { # this means Calls Details
2896             foreach my $line ( @lines ) {
2897                 next unless (scalar(@{$line->{'ext_description'}}) &&
2898                         $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
2899                             );
2900                 my @extdesc = @{$line->{'ext_description'}};
2901                 my @newextdesc;
2902                 foreach my $extdesc ( @extdesc ) {
2903                     $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
2904                     push @newextdesc, $extdesc;
2905                 }
2906                 $line->{'ext_description'} = \@newextdesc;
2907                 $line->{'section'} = $newsection;
2908                 push @newlines, $line;
2909             }
2910         }
2911       }
2912
2913       return(\@newsections, \@newlines);
2914   }
2915
2916   return(\@sections, \@lines);
2917
2918 }
2919
2920 =sub _items_usage_class_summary OPTIONS
2921
2922 Returns a list of detail items summarizing the usage charges on this 
2923 invoice.  Each one will have 'amount', 'description' (the usage charge name),
2924 and 'usage_classnum'.
2925
2926 OPTIONS can include 'escape' (a function to escape the descriptions).
2927
2928 =cut
2929
2930 sub _items_usage_class_summary {
2931   my $self = shift;
2932   my %opt = @_;
2933
2934   my $escape = $opt{escape} || sub { $_[0] };
2935   my $invnum = $self->invnum;
2936   my @classes = qsearch({
2937       'table'     => 'usage_class',
2938       'select'    => 'classnum, classname, SUM(amount) AS amount',
2939       'addl_from' => ' LEFT JOIN cust_bill_pkg_detail USING (classnum)' .
2940                      ' LEFT JOIN cust_bill_pkg USING (billpkgnum)',
2941       'extra_sql' => " WHERE cust_bill_pkg.invnum = $invnum".
2942                      ' GROUP BY classnum, classname, weight'.
2943                      ' HAVING (usage_class.disabled IS NULL OR SUM(amount) > 0)'.
2944                      ' ORDER BY weight ASC',
2945   });
2946   my @l;
2947   my $section = {
2948     description   => &{$escape}($self->mt('Usage Summary')),
2949     no_subtotal   => 1,
2950     usage_section => 1,
2951   };
2952   foreach my $class (@classes) {
2953     push @l, {
2954       'description'     => &{$escape}($class->classname),
2955       'amount'          => sprintf('%.2f', $class->amount),
2956       'usage_classnum'  => $class->classnum,
2957       'section'         => $section,
2958     };
2959   }
2960   return @l;
2961 }
2962
2963 sub _items_previous {
2964   my $self = shift;
2965   my $conf = $self->conf;
2966   my $cust_main = $self->cust_main;
2967   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
2968   my @b = ();
2969   foreach ( @pr_cust_bill ) {
2970     my $date = $conf->exists('invoice_show_prior_due_date')
2971                ? 'due '. $_->due_date2str('short')
2972                : $self->time2str_local('short', $_->_date);
2973     push @b, {
2974       'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
2975       #'pkgpart'     => 'N/A',
2976       'pkgnum'      => 'N/A',
2977       'amount'      => sprintf("%.2f", $_->owed),
2978     };
2979   }
2980   @b;
2981
2982   #{
2983   #    'description'     => 'Previous Balance',
2984   #    #'pkgpart'         => 'N/A',
2985   #    'pkgnum'          => 'N/A',
2986   #    'amount'          => sprintf("%10.2f", $pr_total ),
2987   #    'ext_description' => [ map {
2988   #                                 "Invoice ". $_->invnum.
2989   #                                 " (". time2str("%x",$_->_date). ") ".
2990   #                                 sprintf("%10.2f", $_->owed)
2991   #                         } @pr_cust_bill ],
2992
2993   #};
2994 }
2995
2996 sub _items_credits {
2997   my( $self, %opt ) = @_;
2998   my $trim_len = $opt{'trim_len'} || 60;
2999
3000   my @b;
3001   #credits
3002   my @objects;
3003   if ( $self->conf->exists('previous_balance-payments_since') ) {
3004     if ( $opt{'template'} eq 'statement' ) {
3005       # then the current bill is a "statement" (i.e. an invoice sent as
3006       # a payment receipt)
3007       # and in that case we want to see payments on or after THIS invoice
3008       @objects = qsearch('cust_credit', {
3009           'custnum' => $self->custnum,
3010           '_date'   => {op => '>=', value => $self->_date},
3011       });
3012     } else {
3013       my $date = 0;
3014       $date = $self->previous_bill->_date if $self->previous_bill;
3015       @objects = qsearch('cust_credit', {
3016           'custnum' => $self->custnum,
3017           '_date'   => {op => '>=', value => $date},
3018       });
3019     }
3020   } else {
3021     @objects = $self->cust_credited;
3022   }
3023
3024   foreach my $obj ( @objects ) {
3025     my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit;
3026
3027     my $reason = substr($cust_credit->reason, 0, $trim_len);
3028     $reason .= '...' if length($reason) < length($cust_credit->reason);
3029     $reason = " ($reason) " if $reason;
3030
3031     push @b, {
3032       #'description' => 'Credit ref\#'. $_->crednum.
3033       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
3034       #                 $reason,
3035       'description' => $self->mt('Credit applied').' '.
3036                        $self->time2str_local('short', $obj->_date). $reason,
3037       'amount'      => sprintf("%.2f",$obj->amount),
3038     };
3039   }
3040
3041   @b;
3042
3043 }
3044
3045 sub _items_payments {
3046   my $self = shift;
3047   my %opt = @_;
3048
3049   my @b;
3050   my $detailed = $self->conf->exists('invoice_payment_details');
3051   my @objects;
3052   if ( $self->conf->exists('previous_balance-payments_since') ) {
3053     # then show payments dated on/after the previous bill...
3054     if ( $opt{'template'} eq 'statement' ) {
3055       # then the current bill is a "statement" (i.e. an invoice sent as
3056       # a payment receipt)
3057       # and in that case we want to see payments on or after THIS invoice
3058       @objects = qsearch('cust_pay', {
3059           'custnum' => $self->custnum,
3060           '_date'   => {op => '>=', value => $self->_date},
3061       });
3062     } else {
3063       # the normal case: payments on or after the previous invoice
3064       my $date = 0;
3065       $date = $self->previous_bill->_date if $self->previous_bill;
3066       @objects = qsearch('cust_pay', {
3067         'custnum' => $self->custnum,
3068         '_date'   => {op => '>=', value => $date},
3069       });
3070       # and before the current bill...
3071       @objects = grep { $_->_date < $self->_date } @objects;
3072     }
3073   } else {
3074     @objects = $self->cust_bill_pay;
3075   }
3076
3077   foreach my $obj (@objects) {
3078     my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay;
3079     my $desc = $self->mt('Payment received').' '.
3080                $self->time2str_local('short', $cust_pay->_date );
3081     $desc .= $self->mt(' via ') .
3082              $cust_pay->payby_payinfo_pretty( $self->cust_main->locale )
3083       if $detailed;
3084
3085     push @b, {
3086       'description' => $desc,
3087       'amount'      => sprintf("%.2f", $obj->amount )
3088     };
3089   }
3090
3091   @b;
3092
3093 }
3094
3095 =item call_details [ OPTION => VALUE ... ]
3096
3097 Returns an array of CSV strings representing the call details for this invoice
3098 The only option available is the boolean prepend_billed_number
3099
3100 =cut
3101
3102 sub call_details {
3103   my ($self, %opt) = @_;
3104
3105   my $format_function = sub { shift };
3106
3107   if ($opt{prepend_billed_number}) {
3108     $format_function = sub {
3109       my $detail = shift;
3110       my $row = shift;
3111
3112       $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
3113       
3114     };
3115   }
3116
3117   my @details = map { $_->details( 'format_function' => $format_function,
3118                                    'escape_function' => sub{ return() },
3119                                  )
3120                     }
3121                   grep { $_->pkgnum }
3122                   $self->cust_bill_pkg;
3123   my $header = $details[0];
3124   ( $header, grep { $_ ne $header } @details );
3125 }
3126
3127
3128 =back
3129
3130 =head1 SUBROUTINES
3131
3132 =over 4
3133
3134 =item process_reprint
3135
3136 =cut
3137
3138 sub process_reprint {
3139   process_re_X('print', @_);
3140 }
3141
3142 =item process_reemail
3143
3144 =cut
3145
3146 sub process_reemail {
3147   process_re_X('email', @_);
3148 }
3149
3150 =item process_refax
3151
3152 =cut
3153
3154 sub process_refax {
3155   process_re_X('fax', @_);
3156 }
3157
3158 =item process_reftp
3159
3160 =cut
3161
3162 sub process_reftp {
3163   process_re_X('ftp', @_);
3164 }
3165
3166 =item respool
3167
3168 =cut
3169
3170 sub process_respool {
3171   process_re_X('spool', @_);
3172 }
3173
3174 use Storable qw(thaw);
3175 use Data::Dumper;
3176 use MIME::Base64;
3177 sub process_re_X {
3178   my( $method, $job ) = ( shift, shift );
3179   warn "$me process_re_X $method for job $job\n" if $DEBUG;
3180
3181   my $param = thaw(decode_base64(shift));
3182   warn Dumper($param) if $DEBUG;
3183
3184   re_X(
3185     $method,
3186     $job,
3187     %$param,
3188   );
3189
3190 }
3191
3192 sub re_X {
3193   # spool_invoice ftp_invoice fax_invoice print_invoice
3194   my($method, $job, %param ) = @_;
3195   if ( $DEBUG ) {
3196     warn "re_X $method for job $job with param:\n".
3197          join( '', map { "  $_ => ". $param{$_}. "\n" } keys %param );
3198   }
3199
3200   #some false laziness w/search/cust_bill.html
3201   my $distinct = '';
3202   my $orderby = 'ORDER BY cust_bill._date';
3203
3204   my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
3205
3206   my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
3207      
3208   my @cust_bill = qsearch( {
3209     #'select'    => "cust_bill.*",
3210     'table'     => 'cust_bill',
3211     'addl_from' => $addl_from,
3212     'hashref'   => {},
3213     'extra_sql' => $extra_sql,
3214     'order_by'  => $orderby,
3215     'debug' => 1,
3216   } );
3217
3218   $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
3219
3220   warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
3221     if $DEBUG;
3222
3223   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
3224   foreach my $cust_bill ( @cust_bill ) {
3225     $cust_bill->$method();
3226
3227     if ( $job ) { #progressbar foo
3228       $num++;
3229       if ( time - $min_sec > $last ) {
3230         my $error = $job->update_statustext(
3231           int( 100 * $num / scalar(@cust_bill) )
3232         );
3233         die $error if $error;
3234         $last = time;
3235       }
3236     }
3237
3238   }
3239
3240 }
3241
3242 sub API_getinfo {
3243   my $self = shift;
3244   +{ ( map { $_=>$self->$_ } $self->fields ),
3245      'owed' => $self->owed,
3246      #XXX last payment applied date
3247    };
3248 }
3249
3250 =back
3251
3252 =head1 CLASS METHODS
3253
3254 =over 4
3255
3256 =item owed_sql
3257
3258 Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
3259
3260 =cut
3261
3262 sub owed_sql {
3263   my ($class, $start, $end) = @_;
3264   'charged - '. 
3265     $class->paid_sql($start, $end). ' - '. 
3266     $class->credited_sql($start, $end);
3267 }
3268
3269 =item net_sql
3270
3271 Returns an SQL fragment to retreive the net amount (charged minus credited).
3272
3273 =cut
3274
3275 sub net_sql {
3276   my ($class, $start, $end) = @_;
3277   'charged - '. $class->credited_sql($start, $end);
3278 }
3279
3280 =item paid_sql
3281
3282 Returns an SQL fragment to retreive the amount paid against this invoice.
3283
3284 =cut
3285
3286 sub paid_sql {
3287   my ($class, $start, $end) = @_;
3288   $start &&= "AND cust_bill_pay._date <= $start";
3289   $end   &&= "AND cust_bill_pay._date > $end";
3290   $start = '' unless defined($start);
3291   $end   = '' unless defined($end);
3292   "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
3293        WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end  )";
3294 }
3295
3296 =item credited_sql
3297
3298 Returns an SQL fragment to retreive the amount credited against this invoice.
3299
3300 =cut
3301
3302 sub credited_sql {
3303   my ($class, $start, $end) = @_;
3304   $start &&= "AND cust_credit_bill._date <= $start";
3305   $end   &&= "AND cust_credit_bill._date >  $end";
3306   $start = '' unless defined($start);
3307   $end   = '' unless defined($end);
3308   "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
3309        WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end  )";
3310 }
3311
3312 =item due_date_sql
3313
3314 Returns an SQL fragment to retrieve the due date of an invoice.
3315 Currently only supported on PostgreSQL.
3316
3317 =cut
3318
3319 sub due_date_sql {
3320   my $conf = new FS::Conf;
3321 'COALESCE(
3322   SUBSTRING(
3323     COALESCE(
3324       cust_bill.invoice_terms,
3325       cust_main.invoice_terms,
3326       \''.($conf->config('invoice_default_terms') || '').'\'
3327     ), E\'Net (\\\\d+)\'
3328   )::INTEGER, 0
3329 ) * 86400 + cust_bill._date'
3330 }
3331
3332 =item search_sql_where HASHREF
3333
3334 Class method which returns an SQL WHERE fragment to search for parameters
3335 specified in HASHREF.  Valid parameters are
3336
3337 =over 4
3338
3339 =item _date
3340
3341 List reference of start date, end date, as UNIX timestamps.
3342
3343 =item invnum_min
3344
3345 =item invnum_max
3346
3347 =item agentnum
3348
3349 =item charged
3350
3351 List reference of charged limits (exclusive).
3352
3353 =item owed
3354
3355 List reference of charged limits (exclusive).
3356
3357 =item open
3358
3359 flag, return open invoices only
3360
3361 =item net
3362
3363 flag, return net invoices only
3364
3365 =item days
3366
3367 =item newest_percust
3368
3369 =item custnum
3370
3371 Return only invoices belonging to that customer.
3372
3373 =item cust_classnum
3374
3375 Limit to that customer class (single value or arrayref).
3376
3377 =item payby
3378
3379 Limit to customers with that payment method (single value or arrayref).
3380
3381 =item refnum
3382
3383 Limit to customers with that advertising source.
3384
3385 =back
3386
3387 Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
3388
3389 =cut
3390
3391 sub search_sql_where {
3392   my($class, $param) = @_;
3393   if ( $DEBUG ) {
3394     warn "$me search_sql_where called with params: \n".
3395          join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
3396   }
3397
3398   my @search = ();
3399
3400   #agentnum
3401   if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
3402     push @search, "cust_main.agentnum = $1";
3403   }
3404
3405   #refnum
3406   if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
3407     push @search, "cust_main.refnum = $1";
3408   }
3409
3410   #custnum
3411   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
3412     push @search, "cust_bill.custnum = $1";
3413   }
3414
3415   #customer classnum (false laziness w/ cust_main/Search.pm)
3416   if ( $param->{'cust_classnum'} ) {
3417
3418     my @classnum = ref( $param->{'cust_classnum'} )
3419                      ? @{ $param->{'cust_classnum'} }
3420                      :  ( $param->{'cust_classnum'} );
3421
3422     @classnum = grep /^(\d*)$/, @classnum;
3423
3424     if ( @classnum ) {
3425       push @search, '( '. join(' OR ', map {
3426                                              $_ ? "cust_main.classnum = $_"
3427                                                 : "cust_main.classnum IS NULL"
3428                                            }
3429                                            @classnum
3430                               ).
3431                     ' )';
3432     }
3433
3434   }
3435
3436   #payby
3437   if ( $param->{payby} ) {
3438     my $payby = $param->{payby};
3439     $payby = [ $payby ] unless ref $payby;
3440     my $payby_in = join(',', map {dbh->quote($_)} @$payby);
3441     push @search, "cust_main.payby IN($payby_in)" if length($payby_in);
3442   }
3443
3444   #_date
3445   if ( $param->{_date} ) {
3446     my($beginning, $ending) = @{$param->{_date}};
3447
3448     push @search, "cust_bill._date >= $beginning",
3449                   "cust_bill._date <  $ending";
3450   }
3451
3452   #invnum
3453   if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
3454     push @search, "cust_bill.invnum >= $1";
3455   }
3456   if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
3457     push @search, "cust_bill.invnum <= $1";
3458   }
3459
3460   #charged
3461   if ( $param->{charged} ) {
3462     my @charged = ref($param->{charged})
3463                     ? @{ $param->{charged} }
3464                     : ($param->{charged});
3465
3466     push @search, map { s/^charged/cust_bill.charged/; $_; }
3467                       @charged;
3468   }
3469
3470   my $owed_sql = FS::cust_bill->owed_sql;
3471
3472   #owed
3473   if ( $param->{owed} ) {
3474     my @owed = ref($param->{owed})
3475                  ? @{ $param->{owed} }
3476                  : ($param->{owed});
3477     push @search, map { s/^owed/$owed_sql/; $_; }
3478                       @owed;
3479   }
3480
3481   #open/net flags
3482   push @search, "0 != $owed_sql"
3483     if $param->{'open'};
3484   push @search, '0 != '. FS::cust_bill->net_sql
3485     if $param->{'net'};
3486
3487   #days
3488   push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
3489     if $param->{'days'};
3490
3491   #newest_percust
3492   if ( $param->{'newest_percust'} ) {
3493
3494     #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
3495     #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
3496
3497     my @newest_where = map { my $x = $_;
3498                              $x =~ s/\bcust_bill\./newest_cust_bill./g;
3499                              $x;
3500                            }
3501                            grep ! /^cust_main./, @search;
3502     my $newest_where = scalar(@newest_where)
3503                          ? ' AND '. join(' AND ', @newest_where)
3504                          : '';
3505
3506
3507     push @search, "cust_bill._date = (
3508       SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
3509         WHERE newest_cust_bill.custnum = cust_bill.custnum
3510           $newest_where
3511     )";
3512
3513   }
3514
3515   #promised_date - also has an option to accept nulls
3516   if ( $param->{promised_date} ) {
3517     my($beginning, $ending, $null) = @{$param->{promised_date}};
3518
3519     push @search, "(( cust_bill.promised_date >= $beginning AND ".
3520                     "cust_bill.promised_date <  $ending )" .
3521                     ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
3522   }
3523
3524   #agent virtualization
3525   my $curuser = $FS::CurrentUser::CurrentUser;
3526   if ( $curuser->username eq 'fs_queue'
3527        && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
3528     my $username = $1;
3529     my $newuser = qsearchs('access_user', {
3530       'username' => $username,
3531       'disabled' => '',
3532     } );
3533     if ( $newuser ) {
3534       $curuser = $newuser;
3535     } else {
3536       warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
3537     }
3538   }
3539   push @search, $curuser->agentnums_sql;
3540
3541   join(' AND ', @search );
3542
3543 }
3544
3545 =back
3546
3547 =head1 BUGS
3548
3549 The delete method.
3550
3551 =head1 SEE ALSO
3552
3553 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,
3554 L<FS::cust_bill_pkg>, L<FS::cust_bill_credit>, schema.html from the base
3555 documentation.
3556
3557 =cut
3558
3559 1;
3560