correct tax selection and *actually* handle fee based taxes
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me
5              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6              %tax_passtypes );
7 use Date::Parse;
8 use Storable qw( thaw );
9 use MIME::Base64;
10 use FS::Record qw( qsearchs dbh );
11 use FS::tax_class;
12 use FS::cust_bill_pkg;
13 use FS::cust_tax_location;
14 use FS::part_pkg_taxrate;
15
16 @ISA = qw( FS::Record );
17
18 $DEBUG = 0;
19 $me = '[FS::tax_rate]';
20
21 =head1 NAME
22
23 FS::tax_rate - Object methods for tax_rate objects
24
25 =head1 SYNOPSIS
26
27   use FS::tax_rate;
28
29   $record = new FS::tax_rate \%hash;
30   $record = new FS::tax_rate { 'column' => 'value' };
31
32   $error = $record->insert;
33
34   $error = $new_record->replace($old_record);
35
36   $error = $record->delete;
37
38   $error = $record->check;
39
40 =head1 DESCRIPTION
41
42 An FS::tax_rate object represents a tax rate, defined by locale.
43 FS::tax_rate inherits from FS::Record.  The following fields are
44 currently supported:
45
46 =over 4
47
48 =item taxnum
49
50 primary key (assigned automatically for new tax rates)
51
52 =item geocode
53
54 a geographic location code provided by a tax data vendor
55
56 =item data_vendor
57
58 the tax data vendor
59
60 =item location
61
62 a location code provided by a tax authority
63
64 =item taxclassnum
65
66 a foreign key into FS::tax_class - the type of tax
67 referenced but FS::part_pkg_taxrate
68 eitem effective_date
69
70 the time after which the tax applies
71
72 =item tax
73
74 percentage
75
76 =item excessrate
77
78 second bracket percentage 
79
80 =item taxbase
81
82 the amount to which the tax applies (first bracket)
83
84 =item taxmax
85
86 a cap on the amount of tax if a cap exists
87
88 =item usetax
89
90 percentage on out of jurisdiction purchases
91
92 =item useexcessrate
93
94 second bracket percentage on out of jurisdiction purchases
95
96 =item unittype
97
98 one of the values in %tax_unittypes
99
100 =item fee
101
102 amount of tax per unit
103
104 =item excessfee
105
106 second bracket amount of tax per unit
107
108 =item feebase
109
110 the number of units to which the fee applies (first bracket)
111
112 =item feemax
113
114 the most units to which fees apply (first and second brackets)
115
116 =item maxtype
117
118 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
119
120 =item taxname
121
122 if defined, printed on invoices instead of "Tax"
123
124 =item taxauth
125
126 a value from %tax_authorities
127
128 =item basetype
129
130 a value from %tax_basetypes indicating the tax basis
131
132 =item passtype
133
134 a value from %tax_passtypes indicating how the tax should displayed to the customer
135
136 =item passflag
137
138 'Y', 'N', or blank indicating the tax can be passed to the customer
139
140 =item setuptax
141
142 if 'Y', this tax does not apply to setup fees
143
144 =item recurtax
145
146 if 'Y', this tax does not apply to recurring fees
147
148 =item manual
149
150 if 'Y', has been manually edited
151
152 =back
153
154 =head1 METHODS
155
156 =over 4
157
158 =item new HASHREF
159
160 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
161
162 =cut
163
164 sub table { 'tax_rate'; }
165
166 =item insert
167
168 Adds this tax rate to the database.  If there is an error, returns the error,
169 otherwise returns false.
170
171 =item delete
172
173 Deletes this tax rate from the database.  If there is an error, returns the
174 error, otherwise returns false.
175
176 =item replace OLD_RECORD
177
178 Replaces the OLD_RECORD with this one in the database.  If there is an error,
179 returns the error, otherwise returns false.
180
181 =item check
182
183 Checks all fields to make sure this is a valid tax rate.  If there is an error,
184 returns the error, otherwise returns false.  Called by the insert and replace
185 methods.
186
187 =cut
188
189 sub check {
190   my $self = shift;
191
192   foreach (qw( taxbase taxmax )) {
193     $self->$_(0) unless $self->$_;
194   }
195
196   $self->ut_numbern('taxnum')
197     || $self->ut_text('geocode')
198     || $self->ut_textn('data_vendor')
199     || $self->ut_textn('location')
200     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
201     || $self->ut_numbern('effective_date')
202     || $self->ut_float('tax')
203     || $self->ut_floatn('excessrate')
204     || $self->ut_money('taxbase')
205     || $self->ut_money('taxmax')
206     || $self->ut_floatn('usetax')
207     || $self->ut_floatn('useexcessrate')
208     || $self->ut_numbern('unittype')
209     || $self->ut_floatn('fee')
210     || $self->ut_floatn('excessfee')
211     || $self->ut_floatn('feemax')
212     || $self->ut_numbern('maxtype')
213     || $self->ut_textn('taxname')
214     || $self->ut_numbern('taxauth')
215     || $self->ut_numbern('basetype')
216     || $self->ut_numbern('passtype')
217     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
218     || $self->ut_enum('setuptax', [ '', 'Y' ] )
219     || $self->ut_enum('recurtax', [ '', 'Y' ] )
220     || $self->ut_enum('manual', [ '', 'Y' ] )
221     || $self->SUPER::check
222     ;
223
224 }
225
226 =item taxclass_description
227
228 Returns the human understandable value associated with the related
229 FS::tax_class.
230
231 =cut
232
233 sub taxclass_description {
234   my $self = shift;
235   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
236   $tax_class ? $tax_class->description : '';
237 }
238
239 =item unittype_name
240
241 Returns the human understandable value associated with the unittype column
242
243 =cut
244
245 %tax_unittypes = ( '0' => 'access line',
246                    '1' => 'minute',
247                    '2' => 'account',
248 );
249
250 sub unittype_name {
251   my $self = shift;
252   $tax_unittypes{$self->unittype};
253 }
254
255 =item maxtype_name
256
257 Returns the human understandable value associated with the maxtype column
258
259 =cut
260
261 %tax_maxtypes = ( '0' => 'receipts per invoice',
262                   '1' => 'receipts per item',
263                   '2' => 'total utility charges per utility tax year',
264                   '3' => 'total charges per utility tax year',
265                   '4' => 'receipts per access line',
266                   '9' => 'monthly receipts per location',
267 );
268
269 sub maxtype_name {
270   my $self = shift;
271   $tax_maxtypes{$self->maxtype};
272 }
273
274 =item basetype_name
275
276 Returns the human understandable value associated with the basetype column
277
278 =cut
279
280 %tax_basetypes = ( '0'  => 'sale price',
281                    '1'  => 'gross receipts',
282                    '2'  => 'sales taxable telecom revenue',
283                    '3'  => 'minutes carried',
284                    '4'  => 'minutes billed',
285                    '5'  => 'gross operating revenue',
286                    '6'  => 'access line',
287                    '7'  => 'account',
288                    '8'  => 'gross revenue',
289                    '9'  => 'portion gross receipts attributable to interstate service',
290                    '10' => 'access line',
291                    '11' => 'gross profits',
292                    '12' => 'tariff rate',
293                    '14' => 'account',
294 );
295
296 sub basetype_name {
297   my $self = shift;
298   $tax_basetypes{$self->basetype};
299 }
300
301 =item taxauth_name
302
303 Returns the human understandable value associated with the taxauth column
304
305 =cut
306
307 %tax_authorities = ( '0' => 'federal',
308                      '1' => 'state',
309                      '2' => 'county',
310                      '3' => 'city',
311                      '4' => 'local',
312                      '5' => 'county administered by state',
313                      '6' => 'city administered by state',
314                      '7' => 'city administered by county',
315                      '8' => 'local administered by state',
316                      '9' => 'local administered by county',
317 );
318
319 sub taxauth_name {
320   my $self = shift;
321   $tax_authorities{$self->taxauth};
322 }
323
324 =item passtype_name
325
326 Returns the human understandable value associated with the passtype column
327
328 =cut
329
330 %tax_passtypes = ( '0' => 'separate tax line',
331                    '1' => 'separate surcharge line',
332                    '2' => 'surcharge not separated',
333                    '3' => 'included in base rate',
334 );
335
336 sub passtype_name {
337   my $self = shift;
338   $tax_passtypes{$self->passtype};
339 }
340
341 =item taxline CUST_BILL_PKG, ...
342
343 Returns a listref of a name and an amount of tax calculated for the list
344 of packages.  If an error occurs, a message is returned as a scalar.
345
346 =cut
347
348 sub taxline {
349   my $self = shift;
350   my @cust_bill_pkg = @_;
351
352   warn "calculating taxes for ". $self->taxnum. " on ".
353     join (",", map { $_->pkgnum } @cust_bill_pkg)
354     if $DEBUG;
355
356   if ($self->passflag eq 'N') {
357     return "fatal: can't (yet) handle taxes not passed to the customer";
358   }
359
360   if ($self->maxtype != 0 && $self->maxtype != 9) {
361     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
362       '" threshold';
363   }
364
365   if ($self->maxtype == 9) {
366     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
367       '" threshold';  # "texas" tax
368   }
369
370   if ($self->basetype != 0 && $self->basetype != 1 &&
371       $self->basetype != 6 && $self->basetype != 7 &&
372       $self->basetype != 14
373   ) {
374     return qq!fatal: can't (yet) handle tax with "!. $self->basetype_name. 
375       '" basis';
376   }
377
378   my $name = $self->taxname;
379   $name = 'Other surcharges'
380     if ($self->passtype == 2);
381   my $amount = 0;
382   
383   my $taxable_charged = 0;
384   unless ($self->setuptax =~ /^Y$/i) {
385     $taxable_charged += $_->setup foreach @cust_bill_pkg;
386   }
387   unless ($self->recurtax =~ /^Y$/i) {
388     $taxable_charged += $_->recur foreach @cust_bill_pkg;
389   }
390
391   my $taxable_units = 0;
392   unless ($self->recurtax =~ /^Y$/i) {
393     if ($self->unittype == 0) {
394       $taxable_units += $_->units foreach @cust_bill_pkg;
395     }elsif ($self->unittype == 1) {
396       return qq!fatal: can't (yet) handle fee with minute unit type!;
397     }elsif ($self->unittype == 2) {
398       $taxable_units = 1;
399     }else {
400       return qq!fatal: can't (yet) handle unknown unit type in tax!.
401         $self->taxnum;
402     }
403   }
404
405   #
406   # XXX insert exemption handling here
407   #
408   # the tax or fee is applied to taxbase or feebase and then
409   # the excessrate or excess fee is applied to taxmax or feemax
410   #
411
412   $amount += $taxable_charged * $self->tax;
413   $amount += $taxable_units * $self->fee;
414   
415   warn "calculated taxes as [ $name, $amount ]\n"
416     if $DEBUG;
417
418   return [$name, $amount];
419
420 }
421
422 =back
423
424 =head1 SUBROUTINES
425
426 =over 4
427
428 =item batch_import
429
430 =cut
431
432 sub batch_import {
433   my ($param, $job) = @_;
434
435   my $fh = $param->{filehandle};
436   my $format = $param->{'format'};
437
438   my %insert = ();
439   my %delete = ();
440
441   my @fields;
442   my $hook;
443
444   my $line;
445   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
446   if ( $job ) {
447     $count++
448       while ( defined($line=<$fh>) );
449     seek $fh, 0, 0;
450   }
451   $count *=2;
452
453   if ( $format eq 'cch' || $format eq 'cch-update' ) {
454     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
455                   excessrate effective_date taxauth taxtype taxcat taxname
456                   usetax useexcessrate fee unittype feemax maxtype passflag
457                   passtype basetype );
458     push @fields, 'actionflag' if $format eq 'cch-update';
459
460     $hook = sub {
461       my $hash = shift;
462
463       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
464       $hash->{'data_vendor'} ='cch';
465       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
466
467       my $taxclassid =
468         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
469
470       my %tax_class = ( 'data_vendor'  => 'cch', 
471                         'taxclass' => $taxclassid,
472                       );
473
474       my $tax_class = qsearchs( 'tax_class', \%tax_class );
475       return "Error updating tax rate: no tax class $taxclassid"
476         unless $tax_class;
477
478       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
479
480       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
481         delete($hash->{$_});
482       }
483
484       my %passflagmap = ( '0' => '',
485                           '1' => 'Y',
486                           '2' => 'N',
487                         );
488       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
489         if exists $passflagmap{$hash->{'passflag'}};
490
491       foreach (keys %$hash) {
492         $hash->{$_} = substr($hash->{$_}, 0, 80)
493           if length($hash->{$_}) > 80;
494       }
495
496       my $actionflag = delete($hash->{'actionflag'});
497       if ($actionflag eq 'I') {
498         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
499       }elsif ($actionflag eq 'D') {
500         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
501       }else{
502         return "Unexpected action flag: ". $hash->{'actionflag'};
503       }
504
505       '';
506
507     };
508
509   } elsif ( $format eq 'extended' ) {
510     die "unimplemented\n";
511     @fields = qw( );
512     $hook = sub {};
513   } else {
514     die "unknown format $format";
515   }
516
517   eval "use Text::CSV_XS;";
518   die $@ if $@;
519
520   my $csv = new Text::CSV_XS;
521
522   my $imported = 0;
523
524   local $SIG{HUP} = 'IGNORE';
525   local $SIG{INT} = 'IGNORE';
526   local $SIG{QUIT} = 'IGNORE';
527   local $SIG{TERM} = 'IGNORE';
528   local $SIG{TSTP} = 'IGNORE';
529   local $SIG{PIPE} = 'IGNORE';
530
531   my $oldAutoCommit = $FS::UID::AutoCommit;
532   local $FS::UID::AutoCommit = 0;
533   my $dbh = dbh;
534   
535   while ( defined($line=<$fh>) ) {
536     $csv->parse($line) or do {
537       $dbh->rollback if $oldAutoCommit;
538       return "can't parse: ". $csv->error_input();
539     };
540
541     if ( $job ) {  # progress bar
542       if ( time - $min_sec > $last ) {
543         my $error = $job->update_statustext(
544           int( 100 * $imported / $count )
545         );
546         die $error if $error;
547         $last = time;
548       }
549     }
550
551     my @columns = $csv->fields();
552
553     my %tax_rate = ( 'data_vendor' => $format );
554     foreach my $field ( @fields ) {
555       $tax_rate{$field} = shift @columns; 
556     }
557     if ( scalar( @columns ) ) {
558       $dbh->rollback if $oldAutoCommit;
559       return "Unexpected trailing columns in line (wrong format?): $line";
560     }
561
562     my $error = &{$hook}(\%tax_rate);
563     if ( $error ) {
564       $dbh->rollback if $oldAutoCommit;
565       return $error;
566     }
567
568     $imported++;
569
570   }
571
572   for (grep { !exists($delete{$_}) } keys %insert) {
573     if ( $job ) {  # progress bar
574       if ( time - $min_sec > $last ) {
575         my $error = $job->update_statustext(
576           int( 100 * $imported / $count )
577         );
578         die $error if $error;
579         $last = time;
580       }
581     }
582
583     my $tax_rate = new FS::tax_rate( $insert{$_} );
584     my $error = $tax_rate->insert;
585
586     if ( $error ) {
587       $dbh->rollback if $oldAutoCommit;
588       return "can't insert tax_rate for $line: $error";
589     }
590
591     $imported++;
592   }
593
594   for (grep { exists($delete{$_}) } keys %insert) {
595     if ( $job ) {  # progress bar
596       if ( time - $min_sec > $last ) {
597         my $error = $job->update_statustext(
598           int( 100 * $imported / $count )
599         );
600         die $error if $error;
601         $last = time;
602       }
603     }
604
605     my $old = qsearchs( 'tax_rate', $delete{$_} );
606     unless ($old) {
607       $dbh->rollback if $oldAutoCommit;
608       $old = $delete{$_};
609       return "can't find tax_rate to replace for: ".
610         #join(" ", map { "$_ => ". $old->{$_} } @fields);
611         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
612     }
613     my $new = new FS::tax_rate( $insert{$_} );
614     $new->taxnum($old->taxnum);
615     my $error = $new->replace($old);
616
617     if ( $error ) {
618       $dbh->rollback if $oldAutoCommit;
619       return "can't insert tax_rate for $line: $error";
620     }
621
622     $imported++;
623     $imported++;
624   }
625
626   for (grep { !exists($insert{$_}) } keys %delete) {
627     if ( $job ) {  # progress bar
628       if ( time - $min_sec > $last ) {
629         my $error = $job->update_statustext(
630           int( 100 * $imported / $count )
631         );
632         die $error if $error;
633         $last = time;
634       }
635     }
636
637     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
638     unless ($tax_rate) {
639       $dbh->rollback if $oldAutoCommit;
640       $tax_rate = $delete{$_};
641       return "can't find tax_rate to delete for: ".
642         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
643         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
644     }
645     my $error = $tax_rate->delete;
646
647     if ( $error ) {
648       $dbh->rollback if $oldAutoCommit;
649       return "can't insert tax_rate for $line: $error";
650     }
651
652     $imported++;
653   }
654
655   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
656
657   return "Empty file!" unless $imported;
658
659   ''; #no error
660
661 }
662
663 =item process_batch
664
665 Load an batch import as a queued JSRPC job
666
667 =cut
668
669 sub process_batch {
670   my $job = shift;
671
672   my $param = thaw(decode_base64(shift));
673   my $format = $param->{'format'};        #well... this is all cch specific
674
675   my $files = $param->{'uploaded_files'}
676     or die "No files provided.";
677
678   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
679
680   if ($format eq 'cch') {
681
682     my $oldAutoCommit = $FS::UID::AutoCommit;
683     local $FS::UID::AutoCommit = 0;
684     my $dbh = dbh;
685     my $error = '';
686
687     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
688                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
689                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
690                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
691                );
692     while( scalar(@list) ) {
693       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
694       unless ($files{$file}) {
695         $error = "No $name supplied";
696         next;
697       }
698       my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
699       my $filename = "$dir/".  $files{$file};
700       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
701
702       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
703       close $fh;
704       unlink $filename or warn "Can't delete $filename: $!";
705     }
706     
707     if ($error) {
708       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
709       die $error;
710     }else{
711       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
712     }
713
714   }elsif ($format eq 'cch-update') {
715
716     my $oldAutoCommit = $FS::UID::AutoCommit;
717     local $FS::UID::AutoCommit = 0;
718     my $dbh = dbh;
719     my $error = '';
720     my @insert_list = ();
721     my @delete_list = ();
722
723     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
724                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
725                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
726                );
727     my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
728     while( scalar(@list) ) {
729       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
730       unless ($files{$file}) {
731         $error = "No $name supplied";
732         next;
733       }
734       my $filename = "$dir/".  $files{$file};
735       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
736       unlink $filename or warn "Can't delete $filename: $!";
737
738       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
739                                 DIR      => $dir,
740                                 UNLINK   => 0,     #meh
741                               ) or die "can't open temp file: $!\n";
742
743       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
744                                 DIR      => $dir,
745                                 UNLINK   => 0,     #meh
746                               ) or die "can't open temp file: $!\n";
747
748       while(<$fh>) {
749         my $handle = '';
750         $handle = $ifh if $_ =~ /"I"\s*$/;
751         $handle = $dfh if $_ =~ /"D"\s*$/;
752         unless ($handle) {
753           $error = "bad input line: $_" unless $handle;
754           last;
755         }
756         print $handle $_;
757       }
758       close $fh;
759       close $ifh;
760       close $dfh;
761
762       push @insert_list, $name, $ifh->filename, $import_sub;
763       unshift @delete_list, $name, $dfh->filename, $import_sub;
764
765     }
766     while( scalar(@insert_list) ) {
767       my ($name, $file, $import_sub) =
768         (shift @insert_list, shift @insert_list, shift @insert_list);
769
770       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
771       $error ||=
772         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
773       close $fh;
774       unlink $file or warn "Can't delete $file: $!";
775     }
776     
777     $error = "No DETAIL supplied"
778       unless ($files{detail});
779     open my $fh, "< $dir/". $files{detail}
780       or $error ||= "Can't open DETAIL file: $!";
781     $error ||=
782       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
783                                   $job);
784     close $fh;
785     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
786       if $files{detail};
787
788     while( scalar(@delete_list) ) {
789       my ($name, $file, $import_sub) =
790         (shift @delete_list, shift @delete_list, shift @delete_list);
791
792       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
793       $error ||=
794         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
795       close $fh;
796       unlink $file or warn "Can't delete $file: $!";
797     }
798     
799     if ($error) {
800       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
801       die $error;
802     }else{
803       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
804     }
805
806   }else{
807     die "Unknown format: $format";
808   }
809
810 }
811
812 =back
813
814 =head1 BUGS
815
816   Mixing automatic and manual editing works poorly at present.
817
818 =head1 SEE ALSO
819
820 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
821 documentation.
822
823 =cut
824
825 1;
826