4 use vars qw(@ISA $disable_agentcheck $DEBUG);
5 use FS::UID qw( getotaker dbh );
6 use FS::Record qw( qsearch qsearchs );
12 use FS::cust_bill_pkg;
14 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
16 # because they load configuraion by setting FS::UID::callback (see TODO)
23 # need all this for sending cancel emails in sub cancel
27 use Mail::Internet 1.44;
30 @ISA = qw( FS::Record );
34 $disable_agentcheck = 0;
38 my ( $hashref, $cache ) = @_;
39 #if ( $hashref->{'pkgpart'} ) {
40 if ( $hashref->{'pkg'} ) {
41 # #@{ $self->{'_pkgnum'} } = ();
42 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
43 # $self->{'_pkgpart'} = $subcache;
44 # #push @{ $self->{'_pkgnum'} },
45 # FS::part_pkg->new_or_cached($hashref, $subcache);
46 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
48 if ( exists $hashref->{'svcnum'} ) {
49 #@{ $self->{'_pkgnum'} } = ();
50 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
51 $self->{'_svcnum'} = $subcache;
52 #push @{ $self->{'_pkgnum'} },
53 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
59 FS::cust_pkg - Object methods for cust_pkg objects
65 $record = new FS::cust_pkg \%hash;
66 $record = new FS::cust_pkg { 'column' => 'value' };
68 $error = $record->insert;
70 $error = $new_record->replace($old_record);
72 $error = $record->delete;
74 $error = $record->check;
76 $error = $record->cancel;
78 $error = $record->suspend;
80 $error = $record->unsuspend;
82 $part_pkg = $record->part_pkg;
84 @labels = $record->labels;
86 $seconds = $record->seconds_since($timestamp);
88 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
89 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
93 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
94 inherits from FS::Record. The following fields are currently supported:
98 =item pkgnum - primary key (assigned automatically for new billing items)
100 =item custnum - Customer (see L<FS::cust_main>)
102 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
106 =item bill - date (next bill date)
114 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
116 =item manual_flag - If this field is set to 1, disables the automatic
117 unsuspension of this package when using the B<unsuspendauto> config file.
121 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
122 see L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for
123 conversion functions.
131 Create a new billing item. To add the item to the database, see L<"insert">.
135 sub table { 'cust_pkg'; }
139 Adds this billing item to the database ("Orders" the item). If there is an
140 error, returns the error, otherwise returns false.
147 # custnum might not have have been defined in sub check (for one-shot new
148 # customers), so check it here instead
149 # (is this still necessary with transactions?)
151 my $error = $self->ut_number('custnum');
152 return $error if $error;
154 my $cust_main = $self->cust_main;
155 return "Unknown custnum: ". $self->custnum unless $cust_main;
157 unless ( $disable_agentcheck ) {
158 my $agent = qsearchs( 'agent', { 'agentnum' => $cust_main->agentnum } );
159 my $pkgpart_href = $agent->pkgpart_hashref;
160 return "agent ". $agent->agentnum.
161 " can't purchase pkgpart ". $self->pkgpart
162 unless $pkgpart_href->{ $self->pkgpart };
165 $self->SUPER::insert;
171 This method now works but you probably shouldn't use it.
173 You don't want to delete billing items, because there would then be no record
174 the customer ever purchased the item. Instead, see the cancel method.
179 # return "Can't delete cust_pkg records!";
182 =item replace OLD_RECORD
184 Replaces the OLD_RECORD with this one in the database. If there is an error,
185 returns the error, otherwise returns false.
187 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
189 Changing pkgpart may have disasterous effects. See the order subroutine.
191 setup and bill are normally updated by calling the bill method of a customer
192 object (see L<FS::cust_main>).
194 suspend is normally updated by the suspend and unsuspend methods.
196 cancel is normally updated by the cancel method (and also the order subroutine
202 my( $new, $old ) = ( shift, shift );
204 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
205 return "Can't change otaker!" if $old->otaker ne $new->otaker;
208 #return "Can't change setup once it exists!"
209 # if $old->getfield('setup') &&
210 # $old->getfield('setup') != $new->getfield('setup');
212 #some logic for bill, susp, cancel?
214 $new->SUPER::replace($old);
219 Checks all fields to make sure this is a valid billing item. If there is an
220 error, returns the error, otherwise returns false. Called by the insert and
229 $self->ut_numbern('pkgnum')
230 || $self->ut_numbern('custnum')
231 || $self->ut_number('pkgpart')
232 || $self->ut_numbern('setup')
233 || $self->ut_numbern('bill')
234 || $self->ut_numbern('susp')
235 || $self->ut_numbern('cancel')
237 return $error if $error;
239 if ( $self->custnum ) {
240 return "Unknown customer ". $self->custnum unless $self->cust_main;
243 return "Unknown pkgpart: ". $self->pkgpart
244 unless qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
246 $self->otaker(getotaker) unless $self->otaker;
247 $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
250 if ( $self->dbdef_table->column('manual_flag') ) {
251 $self->manual_flag('') if $self->manual_flag eq ' ';
252 $self->manual_flag =~ /^([01]?)$/
253 or return "Illegal manual_flag ". $self->manual_flag;
254 $self->manual_flag($1);
260 =item cancel [ OPTION => VALUE ... ]
262 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
263 in this package, then cancels the package itself (sets the cancel field to
266 Available options are: I<quiet>
268 I<quiet> can be set true to supress email cancellation notices.
270 If there is an error, returns the error, otherwise returns false.
275 my( $self, %options ) = @_;
278 local $SIG{HUP} = 'IGNORE';
279 local $SIG{INT} = 'IGNORE';
280 local $SIG{QUIT} = 'IGNORE';
281 local $SIG{TERM} = 'IGNORE';
282 local $SIG{TSTP} = 'IGNORE';
283 local $SIG{PIPE} = 'IGNORE';
285 my $oldAutoCommit = $FS::UID::AutoCommit;
286 local $FS::UID::AutoCommit = 0;
289 foreach my $cust_svc (
290 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
292 my $error = $cust_svc->cancel;
295 $dbh->rollback if $oldAutoCommit;
296 return "Error cancelling cust_svc: $error";
301 unless ( $self->getfield('cancel') ) {
302 my %hash = $self->hash;
303 $hash{'cancel'} = time;
304 my $new = new FS::cust_pkg ( \%hash );
305 $error = $new->replace($self);
307 $dbh->rollback if $oldAutoCommit;
312 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
314 my $conf = new FS::Conf;
316 if ( !$options{'quiet'} && $conf->exists('emailcancel')
317 && grep { $_ ne 'POST' } $self->cust_main->invoicing_list) {
319 my @invoicing_list = $self->cust_main->invoicing_list;
321 my $invoice_from = $conf->config('invoice_from');
322 my @print_text = map "$_\n", $conf->config('cancelmessage');
323 my $subject = $conf->config('cancelsubject');
324 my $smtpmachine = $conf->config('smtpmachine');
326 if ( grep { $_ ne 'POST' } @invoicing_list ) { #email invoice
327 #false laziness w/FS::cust_pay::delete & fs_signup_server && ::realtime_card
328 #$ENV{SMTPHOSTS} = $smtpmachine;
329 $ENV{MAILADDRESS} = $invoice_from;
330 my $header = new Mail::Header ( [
331 "From: $invoice_from",
332 "To: ". join(', ', grep { $_ ne 'POST' } @invoicing_list ),
333 "Sender: $invoice_from",
334 "Reply-To: $invoice_from",
335 "Date: ". time2str("%a, %d %b %Y %X %z", time),
338 my $message = new Mail::Internet (
340 'Body' => [ @print_text ],
343 $message->smtpsend( Host => $smtpmachine )
344 or $message->smtpsend( Host => $smtpmachine, Debug => 1 );
345 #should this return an error?
355 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
356 package, then suspends the package itself (sets the susp field to now).
358 If there is an error, returns the error, otherwise returns false.
366 local $SIG{HUP} = 'IGNORE';
367 local $SIG{INT} = 'IGNORE';
368 local $SIG{QUIT} = 'IGNORE';
369 local $SIG{TERM} = 'IGNORE';
370 local $SIG{TSTP} = 'IGNORE';
371 local $SIG{PIPE} = 'IGNORE';
373 my $oldAutoCommit = $FS::UID::AutoCommit;
374 local $FS::UID::AutoCommit = 0;
377 foreach my $cust_svc (
378 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
380 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
382 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
383 $dbh->rollback if $oldAutoCommit;
384 return "Illegal svcdb value in part_svc!";
387 require "FS/$svcdb.pm";
389 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
391 $error = $svc->suspend;
393 $dbh->rollback if $oldAutoCommit;
400 unless ( $self->getfield('susp') ) {
401 my %hash = $self->hash;
402 $hash{'susp'} = time;
403 my $new = new FS::cust_pkg ( \%hash );
404 $error = $new->replace($self);
406 $dbh->rollback if $oldAutoCommit;
411 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
418 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
419 package, then unsuspends the package itself (clears the susp field).
421 If there is an error, returns the error, otherwise returns false.
429 local $SIG{HUP} = 'IGNORE';
430 local $SIG{INT} = 'IGNORE';
431 local $SIG{QUIT} = 'IGNORE';
432 local $SIG{TERM} = 'IGNORE';
433 local $SIG{TSTP} = 'IGNORE';
434 local $SIG{PIPE} = 'IGNORE';
436 my $oldAutoCommit = $FS::UID::AutoCommit;
437 local $FS::UID::AutoCommit = 0;
440 foreach my $cust_svc (
441 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
443 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
445 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
446 $dbh->rollback if $oldAutoCommit;
447 return "Illegal svcdb value in part_svc!";
450 require "FS/$svcdb.pm";
452 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
454 $error = $svc->unsuspend;
456 $dbh->rollback if $oldAutoCommit;
463 unless ( ! $self->getfield('susp') ) {
464 my %hash = $self->hash;
466 my $new = new FS::cust_pkg ( \%hash );
467 $error = $new->replace($self);
469 $dbh->rollback if $oldAutoCommit;
474 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
481 Returns the last bill date, or if there is no last bill date, the setup date.
482 Useful for billing metered services.
488 if ( $self->dbdef_table->column('last_bill') ) {
489 return $self->setfield('last_bill', $_[1]) if @_;
490 return $self->getfield('last_bill') if $self->getfield('last_bill');
492 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
493 'edate' => $self->bill, } );
494 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
499 Returns the definition for this billing item, as an FS::part_pkg object (see
506 #exists( $self->{'_pkgpart'} )
508 ? $self->{'_pkgpart'}
509 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
514 Returns the services for this package, as FS::cust_svc objects (see
521 if ( $self->{'_svcnum'} ) {
522 values %{ $self->{'_svcnum'}->cache };
524 qsearch ( 'cust_svc', { 'pkgnum' => $self->pkgnum } );
530 Returns a list of lists, calling the label method for all services
531 (see L<FS::cust_svc>) of this billing item.
537 map { [ $_->label ] } $self->cust_svc;
542 Returns the parent customer object (see L<FS::cust_main>).
548 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
551 =item seconds_since TIMESTAMP
553 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
554 package have been online since TIMESTAMP, according to the session monitor.
556 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
557 L<Time::Local> and L<Date::Parse> for conversion functions.
562 my($self, $since) = @_;
565 foreach my $cust_svc (
566 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
568 $seconds += $cust_svc->seconds_since($since);
575 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
577 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
578 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
581 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
582 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
588 sub seconds_since_sqlradacct {
589 my($self, $start, $end) = @_;
593 foreach my $cust_svc (
595 my $part_svc = $_->part_svc;
596 $part_svc->svcdb eq 'svc_acct'
597 && scalar($part_svc->part_export('sqlradius'));
600 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
607 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
609 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
610 in this package for sessions ending between TIMESTAMP_START (inclusive) and
611 TIMESTAMP_END (exclusive).
613 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
614 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
619 sub attribute_since_sqlradacct {
620 my($self, $start, $end, $attrib) = @_;
624 foreach my $cust_svc (
626 my $part_svc = $_->part_svc;
627 $part_svc->svcdb eq 'svc_acct'
628 && scalar($part_svc->part_export('sqlradius'));
631 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
640 This method is deprecated. See the I<depend_jobnum> option to the insert and
641 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
648 local $SIG{HUP} = 'IGNORE';
649 local $SIG{INT} = 'IGNORE';
650 local $SIG{QUIT} = 'IGNORE';
651 local $SIG{TERM} = 'IGNORE';
652 local $SIG{TSTP} = 'IGNORE';
653 local $SIG{PIPE} = 'IGNORE';
655 my $oldAutoCommit = $FS::UID::AutoCommit;
656 local $FS::UID::AutoCommit = 0;
659 foreach my $cust_svc ( $self->cust_svc ) {
660 #false laziness w/svc_Common::insert
661 my $svc_x = $cust_svc->svc_x;
662 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
663 my $error = $part_export->export_insert($svc_x);
665 $dbh->rollback if $oldAutoCommit;
671 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
682 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
684 CUSTNUM is a customer (see L<FS::cust_main>)
686 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
687 L<FS::part_pkg>) to order for this customer. Duplicates are of course
690 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
691 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
692 new billing items. An error is returned if this is not possible (see
693 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
696 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
697 newly-created cust_pkg objects.
702 my($custnum, $pkgparts, $remove_pkgnums, $return_cust_pkg) = @_;
703 $remove_pkgnums = [] unless defined($remove_pkgnums);
705 my $oldAutoCommit = $FS::UID::AutoCommit;
706 local $FS::UID::AutoCommit = 0;
710 # $part_pkg{$pkgpart} is true iff $custnum may purchase $pkgpart
712 my($cust_main)=qsearchs('cust_main',{'custnum'=>$custnum});
713 my($agent)=qsearchs('agent',{'agentnum'=> $cust_main->agentnum });
714 my %part_pkg = %{ $agent->pkgpart_hashref };
718 # for those packages being removed:
719 #@{ $svcnum{$svcpart} } goes from a svcpart to a list of FS::cust_svc objects
721 foreach $pkgnum ( @{$remove_pkgnums} ) {
722 foreach my $cust_svc (qsearch('cust_svc',{'pkgnum'=>$pkgnum})) {
723 push @{ $svcnum{$cust_svc->getfield('svcpart')} }, $cust_svc;
727 foreach my $svcpart ( keys %svcnum ) {
728 warn "initial svcpart $svcpart: existing svcnums ".
729 join(', ', map { $_->svcnum } @{$svcnum{$svcpart}} ). "\n";
735 # for those packages the customer is purchasing:
736 # @{$pkgparts} is a list of said packages, by pkgpart
737 # @cust_svc is a corresponding list of lists of FS::Record objects
738 foreach my $pkgpart ( @{$pkgparts} ) {
739 unless ( $part_pkg{$pkgpart} ) {
740 $dbh->rollback if $oldAutoCommit;
741 return "Customer not permitted to purchase pkgpart $pkgpart!";
745 my $svcnum = $svcnum{$_->{svcpart}};
746 if ( $svcnum && @$svcnum ) {
747 my $num = ( $_->{quantity} < scalar(@$svcnum) )
750 splice @$svcnum, 0, $num;
754 } map { { 'svcpart' => $_->svcpart,
755 'quantity' => $_->quantity } }
756 qsearch('pkg_svc', { pkgpart => $pkgpart,
757 quantity => { op=>'>', value=>'0', } } )
762 foreach my $svcpart ( keys %svcnum ) {
763 warn "after regular move svcpart $svcpart: existing svcnums ".
764 join(', ', map { $_->svcnum } @{$svcnum{$svcpart}} ). "\n";
768 #special-case until this can be handled better
769 # move services to new svcparts - even if the svcparts don't match (svcdb
771 # looks like they're moved in no particular order, ewwwwwwww
772 # and looks like just one of each svcpart can be moved... o well
774 #start with still-leftover services
775 #foreach my $svcpart ( grep { scalar(@{ $svcnum{$_} }) } keys %svcnum ) {
776 foreach my $svcpart ( keys %svcnum ) {
777 next unless @{ $svcnum{$svcpart} };
779 my $svcdb = $svcnum{$svcpart}->[0]->part_svc->svcdb;
781 #find an empty place to put one
783 foreach my $pkgpart ( @{$pkgparts} ) {
785 qsearch('pkg_svc', { pkgpart => $pkgpart,
786 quantity => { op=>'>', value=>'0', } } );
788 # grep { $_->quantity > 0 } qsearch('pkg_svc', { pkgpart=>$pkgpart } );
789 if ( ! @{$cust_svc[$i]} #find an empty place to put them with
790 && grep { $svcdb eq $_->part_svc->svcdb } #with appropriate svcdb
794 ( grep { $svcdb eq $_->part_svc->svcdb } @pkg_svc )[0]->svcpart;
795 my $cust_svc = shift @{$svcnum{$svcpart}};
796 $cust_svc->svcpart($new_svcpart);
797 #warn "changing from $svcpart to $new_svcpart!!!\n";
798 $cust_svc[$i] = [ $cust_svc ];
806 foreach my $svcpart ( keys %svcnum ) {
807 warn "after special-case move svcpart $svcpart: existing svcnums ".
808 join(', ', map { $_->svcnum } @{$svcnum{$svcpart}} ). "\n";
813 #check for leftover services
814 foreach (keys %svcnum) {
815 next unless @{ $svcnum{$_} };
816 $dbh->rollback if $oldAutoCommit;
817 return "Leftover services, svcpart $_: svcnum ".
818 join(', ', map { $_->svcnum } @{ $svcnum{$_} } );
821 #no leftover services, let's make changes.
823 local $SIG{HUP} = 'IGNORE';
824 local $SIG{INT} = 'IGNORE';
825 local $SIG{QUIT} = 'IGNORE';
826 local $SIG{TERM} = 'IGNORE';
827 local $SIG{TSTP} = 'IGNORE';
828 local $SIG{PIPE} = 'IGNORE';
830 #first cancel old packages
831 foreach my $pkgnum ( @{$remove_pkgnums} ) {
832 my($old) = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
834 $dbh->rollback if $oldAutoCommit;
835 return "Package $pkgnum not found to remove!";
837 my(%hash) = $old->hash;
838 $hash{'cancel'}=time;
839 my($new) = new FS::cust_pkg ( \%hash );
840 my($error)=$new->replace($old);
842 $dbh->rollback if $oldAutoCommit;
843 return "Couldn't update package $pkgnum: $error";
847 #now add new packages, changing cust_svc records if necessary
849 while ($pkgpart=shift @{$pkgparts} ) {
851 my $new = new FS::cust_pkg {
852 'custnum' => $custnum,
853 'pkgpart' => $pkgpart,
855 my $error = $new->insert;
857 $dbh->rollback if $oldAutoCommit;
858 return "Couldn't insert new cust_pkg record: $error";
860 push @{$return_cust_pkg}, $new if $return_cust_pkg;
861 my $pkgnum = $new->pkgnum;
863 foreach my $cust_svc ( @{ shift @cust_svc } ) {
864 my(%hash) = $cust_svc->hash;
865 $hash{'pkgnum'}=$pkgnum;
866 my $new = new FS::cust_svc ( \%hash );
868 #avoid Record diffing missing changed svcpart field from above.
869 my $old = qsearchs('cust_svc', { 'svcnum' => $cust_svc->svcnum } );
871 my $error = $new->replace($old);
873 $dbh->rollback if $oldAutoCommit;
874 return "Couldn't link old service to new package: $error";
879 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
888 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
890 In sub order, the @pkgparts array (passed by reference) is clobbered.
892 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
893 method to pass dates to the recur_prog expression, it should do so.
895 FS::svc_acct, FS::svc_acct_sm, and FS::svc_domain are loaded via 'use' at
896 compile time, rather than via 'require' in sub { setup, suspend, unsuspend,
897 cancel } because they use %FS::UID::callback to load configuration values.
898 Probably need a subroutine which decides what to do based on whether or not
899 we've fetched the user yet, rather than a hash. See FS::UID and the TODO.
901 Now that things are transactional should the check in the insert method be
906 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
907 L<FS::pkg_svc>, schema.html from the base documentation