prevent stuff passed from template/user from being used in searches by signup server
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use vars qw(@ISA);
5 use FS::UID qw( getotaker dbh );
6 use FS::Record qw( qsearch qsearchs );
7 use FS::cust_svc;
8 use FS::part_pkg;
9 use FS::cust_main;
10 use FS::type_pkgs;
11 use FS::pkg_svc;
12
13 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
14 # setup }
15 # because they load configuraion by setting FS::UID::callback (see TODO)
16 use FS::svc_acct;
17 use FS::svc_acct_sm;
18 use FS::svc_domain;
19 use FS::svc_www;
20 use FS::svc_forward;
21
22 @ISA = qw( FS::Record );
23
24 sub _cache {
25   my $self = shift;
26   my ( $hashref, $cache ) = @_;
27   #if ( $hashref->{'pkgpart'} ) {
28   if ( $hashref->{'pkg'} ) {
29     # #@{ $self->{'_pkgnum'} } = ();
30     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
31     # $self->{'_pkgpart'} = $subcache;
32     # #push @{ $self->{'_pkgnum'} },
33     #   FS::part_pkg->new_or_cached($hashref, $subcache);
34     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
35   }
36   if ( exists $hashref->{'svcnum'} ) {
37     #@{ $self->{'_pkgnum'} } = ();
38     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
39     $self->{'_svcnum'} = $subcache;
40     #push @{ $self->{'_pkgnum'} },
41     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
42   }
43 }
44
45 =head1 NAME
46
47 FS::cust_pkg - Object methods for cust_pkg objects
48
49 =head1 SYNOPSIS
50
51   use FS::cust_pkg;
52
53   $record = new FS::cust_pkg \%hash;
54   $record = new FS::cust_pkg { 'column' => 'value' };
55
56   $error = $record->insert;
57
58   $error = $new_record->replace($old_record);
59
60   $error = $record->delete;
61
62   $error = $record->check;
63
64   $error = $record->cancel;
65
66   $error = $record->suspend;
67
68   $error = $record->unsuspend;
69
70   $part_pkg = $record->part_pkg;
71
72   @labels = $record->labels;
73
74   $seconds = $record->seconds_since($timestamp);
75
76   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
77   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
78
79 =head1 DESCRIPTION
80
81 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
82 inherits from FS::Record.  The following fields are currently supported:
83
84 =over 4
85
86 =item pkgnum - primary key (assigned automatically for new billing items)
87
88 =item custnum - Customer (see L<FS::cust_main>)
89
90 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
91
92 =item setup - date
93
94 =item bill - date
95
96 =item susp - date
97
98 =item expire - date
99
100 =item cancel - date
101
102 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
103
104 =item manual_flag - If this field is set to 1, disables the automatic
105 unsuspension of this package when using the B<unsuspendauto> config file.
106
107 =back
108
109 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
110 see L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for
111 conversion functions.
112
113 =head1 METHODS
114
115 =over 4
116
117 =item new HASHREF
118
119 Create a new billing item.  To add the item to the database, see L<"insert">.
120
121 =cut
122
123 sub table { 'cust_pkg'; }
124
125 =item insert
126
127 Adds this billing item to the database ("Orders" the item).  If there is an
128 error, returns the error, otherwise returns false.
129
130 =cut
131
132 sub insert {
133   my $self = shift;
134
135   # custnum might not have have been defined in sub check (for one-shot new
136   # customers), so check it here instead
137   # (is this still necessary with transactions?)
138
139   my $error = $self->ut_number('custnum');
140   return $error if $error;
141
142   my $cust_main = $self->cust_main;
143   return "Unknown customer ". $self->custnum unless $cust_main;
144
145   my $agent = qsearchs( 'agent', { 'agentnum' => $cust_main->agentnum } );
146   my $pkgpart_href = $agent->pkgpart_hashref;
147   return "agent ". $agent->agentnum. " can't purchase pkgpart ". $self->pkgpart
148     unless $pkgpart_href->{ $self->pkgpart };
149
150   $self->SUPER::insert;
151
152 }
153
154 =item delete
155
156 This method now works but you probably shouldn't use it.
157
158 You don't want to delete billing items, because there would then be no record
159 the customer ever purchased the item.  Instead, see the cancel method.
160
161 =cut
162
163 #sub delete {
164 #  return "Can't delete cust_pkg records!";
165 #}
166
167 =item replace OLD_RECORD
168
169 Replaces the OLD_RECORD with this one in the database.  If there is an error,
170 returns the error, otherwise returns false.
171
172 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
173
174 Changing pkgpart may have disasterous effects.  See the order subroutine.
175
176 setup and bill are normally updated by calling the bill method of a customer
177 object (see L<FS::cust_main>).
178
179 suspend is normally updated by the suspend and unsuspend methods.
180
181 cancel is normally updated by the cancel method (and also the order subroutine
182 in some cases).
183
184 =cut
185
186 sub replace {
187   my( $new, $old ) = ( shift, shift );
188
189   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
190   return "Can't change otaker!" if $old->otaker ne $new->otaker;
191
192   #allow this *sigh*
193   #return "Can't change setup once it exists!"
194   #  if $old->getfield('setup') &&
195   #     $old->getfield('setup') != $new->getfield('setup');
196
197   #some logic for bill, susp, cancel?
198
199   $new->SUPER::replace($old);
200 }
201
202 =item check
203
204 Checks all fields to make sure this is a valid billing item.  If there is an
205 error, returns the error, otherwise returns false.  Called by the insert and
206 replace methods.
207
208 =cut
209
210 sub check {
211   my $self = shift;
212
213   my $error = 
214     $self->ut_numbern('pkgnum')
215     || $self->ut_numbern('custnum')
216     || $self->ut_number('pkgpart')
217     || $self->ut_numbern('setup')
218     || $self->ut_numbern('bill')
219     || $self->ut_numbern('susp')
220     || $self->ut_numbern('cancel')
221   ;
222   return $error if $error;
223
224   if ( $self->custnum ) { 
225     return "Unknown customer ". $self->custnum unless $self->cust_main;
226   }
227
228   return "Unknown pkgpart: ". $self->pkgpart
229     unless qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
230
231   $self->otaker(getotaker) unless $self->otaker;
232   $self->otaker =~ /^(\w{0,16})$/ or return "Illegal otaker";
233   $self->otaker($1);
234
235   if ( $self->dbdef_table->column('manual_flag') ) {
236     $self->manual_flag =~ /^([01]?)$/ or return "Illegal manual_flag";
237     $self->manual_flag($1);
238   }
239
240   ''; #no error
241 }
242
243 =item cancel
244
245 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
246 in this package, then cancels the package itself (sets the cancel field to
247 now).
248
249 If there is an error, returns the error, otherwise returns false.
250
251 =cut
252
253 sub cancel {
254   my $self = shift;
255   my $error;
256
257   local $SIG{HUP} = 'IGNORE';
258   local $SIG{INT} = 'IGNORE';
259   local $SIG{QUIT} = 'IGNORE'; 
260   local $SIG{TERM} = 'IGNORE';
261   local $SIG{TSTP} = 'IGNORE';
262   local $SIG{PIPE} = 'IGNORE';
263
264   my $oldAutoCommit = $FS::UID::AutoCommit;
265   local $FS::UID::AutoCommit = 0;
266   my $dbh = dbh;
267
268   foreach my $cust_svc (
269     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
270   ) {
271     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
272
273     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
274       $dbh->rollback if $oldAutoCommit;
275       return "Illegal svcdb value in part_svc!";
276     };
277     my $svcdb = $1;
278     require "FS/$svcdb.pm";
279
280     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
281     if ($svc) {
282       $error = $svc->cancel;
283       if ( $error ) {
284         $dbh->rollback if $oldAutoCommit;
285         return "Error cancelling service: $error" 
286       }
287       $error = $svc->delete;
288       if ( $error ) {
289         $dbh->rollback if $oldAutoCommit;
290         return "Error deleting service: $error";
291       }
292     }
293
294     $error = $cust_svc->delete;
295     if ( $error ) {
296       $dbh->rollback if $oldAutoCommit;
297       return "Error deleting cust_svc: $error";
298     }
299
300   }
301
302   unless ( $self->getfield('cancel') ) {
303     my %hash = $self->hash;
304     $hash{'cancel'} = time;
305     my $new = new FS::cust_pkg ( \%hash );
306     $error = $new->replace($self);
307     if ( $error ) {
308       $dbh->rollback if $oldAutoCommit;
309       return $error;
310     }
311   }
312
313   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
314
315   ''; #no errors
316 }
317
318 =item suspend
319
320 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
321 package, then suspends the package itself (sets the susp field to now).
322
323 If there is an error, returns the error, otherwise returns false.
324
325 =cut
326
327 sub suspend {
328   my $self = shift;
329   my $error ;
330
331   local $SIG{HUP} = 'IGNORE';
332   local $SIG{INT} = 'IGNORE';
333   local $SIG{QUIT} = 'IGNORE'; 
334   local $SIG{TERM} = 'IGNORE';
335   local $SIG{TSTP} = 'IGNORE';
336   local $SIG{PIPE} = 'IGNORE';
337
338   my $oldAutoCommit = $FS::UID::AutoCommit;
339   local $FS::UID::AutoCommit = 0;
340   my $dbh = dbh;
341
342   foreach my $cust_svc (
343     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
344   ) {
345     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
346
347     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
348       $dbh->rollback if $oldAutoCommit;
349       return "Illegal svcdb value in part_svc!";
350     };
351     my $svcdb = $1;
352     require "FS/$svcdb.pm";
353
354     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
355     if ($svc) {
356       $error = $svc->suspend;
357       if ( $error ) {
358         $dbh->rollback if $oldAutoCommit;
359         return $error;
360       }
361     }
362
363   }
364
365   unless ( $self->getfield('susp') ) {
366     my %hash = $self->hash;
367     $hash{'susp'} = time;
368     my $new = new FS::cust_pkg ( \%hash );
369     $error = $new->replace($self);
370     if ( $error ) {
371       $dbh->rollback if $oldAutoCommit;
372       return $error;
373     }
374   }
375
376   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
377
378   ''; #no errors
379 }
380
381 =item unsuspend
382
383 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
384 package, then unsuspends the package itself (clears the susp field).
385
386 If there is an error, returns the error, otherwise returns false.
387
388 =cut
389
390 sub unsuspend {
391   my $self = shift;
392   my($error);
393
394   local $SIG{HUP} = 'IGNORE';
395   local $SIG{INT} = 'IGNORE';
396   local $SIG{QUIT} = 'IGNORE'; 
397   local $SIG{TERM} = 'IGNORE';
398   local $SIG{TSTP} = 'IGNORE';
399   local $SIG{PIPE} = 'IGNORE';
400
401   my $oldAutoCommit = $FS::UID::AutoCommit;
402   local $FS::UID::AutoCommit = 0;
403   my $dbh = dbh;
404
405   foreach my $cust_svc (
406     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
407   ) {
408     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
409
410     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
411       $dbh->rollback if $oldAutoCommit;
412       return "Illegal svcdb value in part_svc!";
413     };
414     my $svcdb = $1;
415     require "FS/$svcdb.pm";
416
417     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
418     if ($svc) {
419       $error = $svc->unsuspend;
420       if ( $error ) {
421         $dbh->rollback if $oldAutoCommit;
422         return $error;
423       }
424     }
425
426   }
427
428   unless ( ! $self->getfield('susp') ) {
429     my %hash = $self->hash;
430     $hash{'susp'} = '';
431     my $new = new FS::cust_pkg ( \%hash );
432     $error = $new->replace($self);
433     if ( $error ) {
434       $dbh->rollback if $oldAutoCommit;
435       return $error;
436     }
437   }
438
439   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
440
441   ''; #no errors
442 }
443
444 =item part_pkg
445
446 Returns the definition for this billing item, as an FS::part_pkg object (see
447 L<FS::part_pkg>).
448
449 =cut
450
451 sub part_pkg {
452   my $self = shift;
453   #exists( $self->{'_pkgpart'} )
454   $self->{'_pkgpart'}
455     ? $self->{'_pkgpart'}
456     : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
457 }
458
459 =item cust_svc
460
461 Returns the services for this package, as FS::cust_svc objects (see
462 L<FS::cust_svc>)
463
464 =cut
465
466 sub cust_svc {
467   my $self = shift;
468   if ( $self->{'_svcnum'} ) {
469     values %{ $self->{'_svcnum'}->cache };
470   } else {
471     qsearch ( 'cust_svc', { 'pkgnum' => $self->pkgnum } );
472   }
473 }
474
475 =item labels
476
477 Returns a list of lists, calling the label method for all services
478 (see L<FS::cust_svc>) of this billing item.
479
480 =cut
481
482 sub labels {
483   my $self = shift;
484   map { [ $_->label ] } $self->cust_svc;
485 }
486
487 =item cust_main
488
489 Returns the parent customer object (see L<FS::cust_main>).
490
491 =cut
492
493 sub cust_main {
494   my $self = shift;
495   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
496 }
497
498 =item seconds_since TIMESTAMP
499
500 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
501 package have been online since TIMESTAMP.
502
503 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
504 L<Time::Local> and L<Date::Parse> for conversion functions.
505
506 =cut
507
508 sub seconds_since {
509   my($self, $since) = @_;
510   my $seconds = 0;
511
512   foreach my $cust_svc (
513     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
514   ) {
515     $seconds += $cust_svc->seconds_since($since);
516   }
517
518   $seconds;
519
520 }
521
522 =back
523
524 =head1 SUBROUTINES
525
526 =over 4
527
528 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
529
530 CUSTNUM is a customer (see L<FS::cust_main>)
531
532 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
533 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
534 permitted.
535
536 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
537 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
538 new billing items.  An error is returned if this is not possible (see
539 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
540 parameter.
541
542 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
543 newly-created cust_pkg objects.
544
545 =cut
546
547 sub order {
548   my($custnum, $pkgparts, $remove_pkgnums, $return_cust_pkg) = @_;
549   $remove_pkgnums = [] unless defined($remove_pkgnums);
550
551   my $oldAutoCommit = $FS::UID::AutoCommit;
552   local $FS::UID::AutoCommit = 0;
553   my $dbh = dbh;
554
555   # generate %part_pkg
556   # $part_pkg{$pkgpart} is true iff $custnum may purchase $pkgpart
557   #
558   my($cust_main)=qsearchs('cust_main',{'custnum'=>$custnum});
559   my($agent)=qsearchs('agent',{'agentnum'=> $cust_main->agentnum });
560   my %part_pkg = %{ $agent->pkgpart_hashref };
561
562   my(%svcnum);
563   # generate %svcnum
564   # for those packages being removed:
565   #@{ $svcnum{$svcpart} } goes from a svcpart to a list of FS::cust_svc objects
566   my($pkgnum);
567   foreach $pkgnum ( @{$remove_pkgnums} ) {
568     foreach my $cust_svc (qsearch('cust_svc',{'pkgnum'=>$pkgnum})) {
569       push @{ $svcnum{$cust_svc->getfield('svcpart')} }, $cust_svc;
570     }
571   }
572   
573   my @cust_svc;
574   #generate @cust_svc
575   # for those packages the customer is purchasing:
576   # @{$pkgparts} is a list of said packages, by pkgpart
577   # @cust_svc is a corresponding list of lists of FS::Record objects
578   foreach my $pkgpart ( @{$pkgparts} ) {
579     unless ( $part_pkg{$pkgpart} ) {
580       $dbh->rollback if $oldAutoCommit;
581       return "Customer not permitted to purchase pkgpart $pkgpart!";
582     }
583     push @cust_svc, [
584       map {
585         ( $svcnum{$_} && @{ $svcnum{$_} } ) ? shift @{ $svcnum{$_} } : ();
586       } map { $_->svcpart }
587           qsearch('pkg_svc', { pkgpart  => $pkgpart,
588                                quantity => { op=>'>', value=>'0', } } )
589     ];
590   }
591
592   #special-case until this can be handled better
593   # move services to new svcparts - even if the svcparts don't match (svcdb
594   # needs to...)
595   # looks like they're moved in no particular order, ewwwwwwww
596   # and looks like just one of each svcpart can be moved... o well
597
598   #start with still-leftover services
599   #foreach my $svcpart ( grep { scalar(@{ $svcnum{$_} }) } keys %svcnum ) {
600   foreach my $svcpart ( keys %svcnum ) {
601     next unless @{ $svcnum{$svcpart} };
602
603     my $svcdb = $svcnum{$svcpart}->[0]->part_svc->svcdb;
604
605     #find an empty place to put one
606     my $i = 0;
607     foreach my $pkgpart ( @{$pkgparts} ) {
608       my @pkg_svc =
609         qsearch('pkg_svc', { pkgpart  => $pkgpart,
610                              quantity => { op=>'>', value=>'0', } } );
611       #my @pkg_svc =
612       #  grep { $_->quantity > 0 } qsearch('pkg_svc', { pkgpart=>$pkgpart } );
613       if ( ! @{$cust_svc[$i]} #find an empty place to put them with 
614            && grep { $svcdb eq $_->part_svc->svcdb } #with appropriate svcdb
615                 @pkg_svc
616       ) {
617         my $new_svcpart =
618           ( grep { $svcdb eq $_->part_svc->svcdb } @pkg_svc )[0]->svcpart; 
619         my $cust_svc = shift @{$svcnum{$svcpart}};
620         $cust_svc->svcpart($new_svcpart);
621         #warn "changing from $svcpart to $new_svcpart!!!\n";
622         $cust_svc[$i] = [ $cust_svc ];
623       }
624       $i++;
625     }
626
627   }
628   
629   #check for leftover services
630   foreach (keys %svcnum) {
631     next unless @{ $svcnum{$_} };
632     $dbh->rollback if $oldAutoCommit;
633     return "Leftover services, svcpart $_: svcnum ".
634            join(', ', map { $_->svcnum } @{ $svcnum{$_} } );
635   }
636
637   #no leftover services, let's make changes.
638  
639   local $SIG{HUP} = 'IGNORE';
640   local $SIG{INT} = 'IGNORE'; 
641   local $SIG{QUIT} = 'IGNORE';
642   local $SIG{TERM} = 'IGNORE';
643   local $SIG{TSTP} = 'IGNORE'; 
644   local $SIG{PIPE} = 'IGNORE'; 
645
646   #first cancel old packages
647   foreach my $pkgnum ( @{$remove_pkgnums} ) {
648     my($old) = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
649     unless ( $old ) {
650       $dbh->rollback if $oldAutoCommit;
651       return "Package $pkgnum not found to remove!";
652     }
653     my(%hash) = $old->hash;
654     $hash{'cancel'}=time;   
655     my($new) = new FS::cust_pkg ( \%hash );
656     my($error)=$new->replace($old);
657     if ( $error ) {
658       $dbh->rollback if $oldAutoCommit;
659       return "Couldn't update package $pkgnum: $error";
660     }
661   }
662
663   #now add new packages, changing cust_svc records if necessary
664   my $pkgpart;
665   while ($pkgpart=shift @{$pkgparts} ) {
666  
667     my $new = new FS::cust_pkg {
668                                  'custnum' => $custnum,
669                                  'pkgpart' => $pkgpart,
670                                };
671     my $error = $new->insert;
672     if ( $error ) {
673       $dbh->rollback if $oldAutoCommit;
674       return "Couldn't insert new cust_pkg record: $error";
675     }
676     push @{$return_cust_pkg}, $new if $return_cust_pkg;
677     my $pkgnum = $new->pkgnum;
678  
679     foreach my $cust_svc ( @{ shift @cust_svc } ) {
680       my(%hash) = $cust_svc->hash;
681       $hash{'pkgnum'}=$pkgnum;
682       my $new = new FS::cust_svc ( \%hash );
683
684       #avoid Record diffing missing changed svcpart field from above.
685       my $old = qsearchs('cust_svc', { 'svcnum' => $cust_svc->svcnum } );
686
687       my $error = $new->replace($old);
688       if ( $error ) {
689         $dbh->rollback if $oldAutoCommit;
690         return "Couldn't link old service to new package: $error";
691       }
692     }
693   }  
694
695   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
696
697   ''; #no errors
698 }
699
700 =back
701
702 =head1 VERSION
703
704 $Id: cust_pkg.pm,v 1.21 2002-05-04 00:47:24 ivan Exp $
705
706 =head1 BUGS
707
708 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
709
710 In sub order, the @pkgparts array (passed by reference) is clobbered.
711
712 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
713 method to pass dates to the recur_prog expression, it should do so.
714
715 FS::svc_acct, FS::svc_acct_sm, and FS::svc_domain are loaded via 'use' at 
716 compile time, rather than via 'require' in sub { setup, suspend, unsuspend,
717 cancel } because they use %FS::UID::callback to load configuration values.
718 Probably need a subroutine which decides what to do based on whether or not
719 we've fetched the user yet, rather than a hash.  See FS::UID and the TODO.
720
721 Now that things are transactional should the check in the insert method be
722 moved to check ?
723
724 =head1 SEE ALSO
725
726 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
727 L<FS::pkg_svc>, schema.html from the base documentation
728
729 =cut
730
731 1;
732