2 use base qw( FS::cust_main_Mixin FS::option_Common ); #FS::Record );
5 use vars qw( $DEBUG $me $ignore_quantity $conf $ticket_system );
7 #use Scalar::Util qw( blessed );
9 use FS::Record qw( qsearch qsearchs dbh str2time_sql );
13 use FS::domain_record;
18 #most FS::svc_ classes are autoloaded in svc_x emthod
19 use FS::svc_acct; #this one is used in the cache stuff
27 #ask FS::UID to run this stuff for us later
28 FS::UID->install_callback( sub {
30 $ticket_system = $conf->config('ticket_system')
35 my ( $hashref, $cache ) = @_;
36 if ( $hashref->{'username'} ) {
37 $self->{'_svc_acct'} = FS::svc_acct->new($hashref, '');
39 if ( $hashref->{'svc'} ) {
40 $self->{'_svcpart'} = FS::part_svc->new($hashref);
46 FS::cust_svc - Object method for cust_svc objects
52 $record = new FS::cust_svc \%hash
53 $record = new FS::cust_svc { 'column' => 'value' };
55 $error = $record->insert;
57 $error = $new_record->replace($old_record);
59 $error = $record->delete;
61 $error = $record->check;
63 ($label, $value) = $record->label;
67 An FS::cust_svc represents a service. FS::cust_svc inherits from FS::Record.
68 The following fields are currently supported:
72 =item svcnum - primary key (assigned automatically for new services)
74 =item pkgnum - Package (see L<FS::cust_pkg>)
76 =item svcpart - Service definition (see L<FS::part_svc>)
78 =item agent_svcid - Optional legacy service ID
80 =item overlimit - date the service exceeded its usage limit
90 Creates a new service. To add the refund to the database, see L<"insert">.
91 Services are normally created by creating FS::svc_ objects (see
92 L<FS::svc_acct>, L<FS::svc_domain>, and L<FS::svc_forward>, among others).
96 sub table { 'cust_svc'; }
100 Adds this service to the database. If there is an error, returns the error,
101 otherwise returns false.
105 Deletes this service from the database. If there is an error, returns the
106 error, otherwise returns false. Note that this only removes the cust_svc
107 record - you should probably use the B<cancel> method instead.
116 my $cust_pkg = $self->cust_pkg;
117 my $custnum = $cust_pkg->custnum if $cust_pkg;
119 my $error = $self->SUPER::delete;
120 return $error if $error;
122 if ( $ticket_system eq 'RT_Internal' ) {
123 unless ( $rt_session ) {
124 FS::TicketSystem->init;
125 $rt_session = FS::TicketSystem->session;
127 my $links = RT::Links->new($rt_session->{CurrentUser});
128 my $svcnum = $self->svcnum;
129 $links->Limit(FIELD => 'Target',
130 VALUE => 'freeside://freeside/cust_svc/'.$svcnum);
131 while ( my $l = $links->Next ) {
134 # re-link to point to the customer instead
136 $l->SetTarget('freeside://freeside/cust_main/'.$custnum);
139 ($val, $msg) = $l->Delete;
141 # can't do anything useful on error
142 warn "error unlinking ticket $svcnum: $msg\n" if !$val;
149 Cancels the relevant service by calling the B<cancel> method of the associated
150 FS::svc_XXX object (i.e. an FS::svc_acct object or FS::svc_domain object),
151 deleting the FS::svc_XXX record and then deleting this record.
153 If there is an error, returns the error, otherwise returns false.
160 local $SIG{HUP} = 'IGNORE';
161 local $SIG{INT} = 'IGNORE';
162 local $SIG{QUIT} = 'IGNORE';
163 local $SIG{TERM} = 'IGNORE';
164 local $SIG{TSTP} = 'IGNORE';
165 local $SIG{PIPE} = 'IGNORE';
167 my $oldAutoCommit = $FS::UID::AutoCommit;
168 local $FS::UID::AutoCommit = 0;
171 my $part_svc = $self->part_svc;
173 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
174 $dbh->rollback if $oldAutoCommit;
175 return "Illegal svcdb value in part_svc!";
178 require "FS/$svcdb.pm";
180 my $svc = $self->svc_x;
182 if ( %opt && $opt{'date'} ) {
183 my $error = $svc->expire($opt{'date'});
185 $dbh->rollback if $oldAutoCommit;
186 return "Error expiring service: $error";
189 my $error = $svc->cancel;
191 $dbh->rollback if $oldAutoCommit;
192 return "Error canceling service: $error";
194 $error = $svc->delete; #this deletes this cust_svc record as well
196 $dbh->rollback if $oldAutoCommit;
197 return "Error deleting service: $error";
204 warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
205 "; deleting cust_svc only\n";
207 my $error = $self->delete;
209 $dbh->rollback if $oldAutoCommit;
210 return "Error deleting cust_svc: $error";
215 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
221 =item overlimit [ ACTION ]
223 Retrieves or sets the overlimit date. If ACTION is absent, return
224 the present value of overlimit. If ACTION is present, it can
225 have the value 'suspend' or 'unsuspend'. In the case of 'suspend' overlimit
226 is set to the current time if it is not already set. The 'unsuspend' value
227 causes the time to be cleared.
229 If there is an error on setting, returns the error, otherwise returns false.
235 my $action = shift or return $self->getfield('overlimit');
237 local $SIG{HUP} = 'IGNORE';
238 local $SIG{INT} = 'IGNORE';
239 local $SIG{QUIT} = 'IGNORE';
240 local $SIG{TERM} = 'IGNORE';
241 local $SIG{TSTP} = 'IGNORE';
242 local $SIG{PIPE} = 'IGNORE';
244 my $oldAutoCommit = $FS::UID::AutoCommit;
245 local $FS::UID::AutoCommit = 0;
248 if ( $action eq 'suspend' ) {
249 $self->setfield('overlimit', time) unless $self->getfield('overlimit');
250 }elsif ( $action eq 'unsuspend' ) {
251 $self->setfield('overlimit', '');
253 die "unexpected action value: $action";
256 local $ignore_quantity = 1;
257 my $error = $self->replace;
259 $dbh->rollback if $oldAutoCommit;
260 return "Error setting overlimit: $error";
263 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
269 =item replace OLD_RECORD
271 Replaces the OLD_RECORD with this one in the database. If there is an error,
272 returns the error, otherwise returns false.
279 # my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
281 # : $new->replace_old;
282 my ( $new, $old ) = ( shift, shift );
283 $old = $new->replace_old unless defined($old);
285 local $SIG{HUP} = 'IGNORE';
286 local $SIG{INT} = 'IGNORE';
287 local $SIG{QUIT} = 'IGNORE';
288 local $SIG{TERM} = 'IGNORE';
289 local $SIG{TSTP} = 'IGNORE';
290 local $SIG{PIPE} = 'IGNORE';
292 my $oldAutoCommit = $FS::UID::AutoCommit;
293 local $FS::UID::AutoCommit = 0;
296 if ( $new->svcpart != $old->svcpart ) {
297 my $svc_x = $new->svc_x;
298 my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
299 local($FS::Record::nowarn_identical) = 1;
300 my $error = $new_svc_x->replace($svc_x);
302 $dbh->rollback if $oldAutoCommit;
303 return $error if $error;
307 # #trigger a re-export on pkgnum changes?
308 # # (of prepaid packages), for Expiration RADIUS attribute
309 # if ( $new->pkgnum != $old->pkgnum && $new->cust_pkg->part_pkg->is_prepaid ) {
310 # my $svc_x = $new->svc_x;
311 # local($FS::Record::nowarn_identical) = 1;
312 # my $error = $svc_x->export('replace');
314 # $dbh->rollback if $oldAutoCommit;
315 # return $error if $error;
319 #trigger a pkg_change export on pkgnum changes
320 if ( $new->pkgnum != $old->pkgnum ) {
321 my $error = $new->svc_x->export('pkg_change', $new->cust_pkg,
325 $dbh->rollback if $oldAutoCommit;
326 return $error if $error;
330 #my $error = $new->SUPER::replace($old, @_);
331 my $error = $new->SUPER::replace($old);
333 $dbh->rollback if $oldAutoCommit;
334 return $error if $error;
337 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
344 Checks all fields to make sure this is a valid service. If there is an error,
345 returns the error, otherwise returns false. Called by the insert and
354 $self->ut_numbern('svcnum')
355 || $self->ut_numbern('pkgnum')
356 || $self->ut_number('svcpart')
357 || $self->ut_numbern('agent_svcid')
358 || $self->ut_numbern('overlimit')
360 return $error if $error;
362 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
363 return "Unknown svcpart" unless $part_svc;
365 if ( $self->pkgnum && ! $ignore_quantity ) {
366 my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
367 return "Unknown pkgnum" unless $cust_pkg;
368 ($part_svc) = grep { $_->svcpart == $self->svcpart } $cust_pkg->part_svc;
369 return "No svcpart ". $self->svcpart.
370 " services in pkgpart ". $cust_pkg->pkgpart
371 unless $part_svc || $ignore_quantity;
372 return "Already ". $part_svc->get('num_cust_svc'). " ". $part_svc->svc.
373 " services for pkgnum ". $self->pkgnum
374 if !$ignore_quantity && $part_svc->get('num_avail') <= 0 ;
382 Returns the displayed service number for this service: agent_svcid if it has a
383 value, svcnum otherwise
389 $self->agent_svcid || $self->svcnum;
394 Returns the definition for this service, as a FS::part_svc object (see
402 ? $self->{'_svcpart'}
403 : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
408 Returns the package this service belongs to, as a FS::cust_pkg object (see
413 Returns the pkg_svc record for for this service, if applicable.
419 my $cust_pkg = $self->cust_pkg;
420 return undef unless $cust_pkg;
422 qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
423 'pkgpart' => $cust_pkg->pkgpart,
430 Returns the date this service was inserted.
436 $self->h_date('insert');
439 =item pkg_cancel_date
441 Returns the date this service's package was canceled. This normally only
442 exists for a service that's been preserved through cancellation with the
443 part_pkg.preserve flag.
447 sub pkg_cancel_date {
449 my $cust_pkg = $self->cust_pkg or return;
450 return $cust_pkg->getfield('cancel') || '';
455 Returns a list consisting of:
456 - The name of this service (from part_svc)
457 - A meaningful identifier (username, domain, or mail alias)
458 - The table name (i.e. svc_domain) for this service
463 my($label, $value, $svcdb) = $cust_svc->label;
467 Like the B<label> method, except the second item in the list ("meaningful
468 identifier") may be longer - typically, a full name is included.
472 sub label { shift->_label('svc_label', @_); }
473 sub label_long { shift->_label('svc_label_long', @_); }
478 my $svc_x = $self->svc_x
479 or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
481 $self->$method($svc_x);
484 sub svc_label { shift->_svc_label('label', @_); }
485 sub svc_label_long { shift->_svc_label('label_long', @_); }
488 my( $self, $method, $svc_x ) = ( shift, shift, shift );
491 $self->part_svc->svc,
493 $self->part_svc->svcdb,
501 Returns a listref of html elements associated with this service's exports.
507 my $svc_x = $self->svc_x
508 or return [ "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum ];
510 $svc_x->export_links;
513 =item export_getsettings
515 Returns two hashrefs of settings associated with this service's exports.
519 sub export_getsettings {
521 my $svc_x = $self->svc_x
522 or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
524 $svc_x->export_getsettings;
530 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
531 FS::svc_domain object, etc.)
537 my $svcdb = $self->part_svc->svcdb;
538 if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
539 $self->{'_svc_acct'};
541 require "FS/$svcdb.pm";
542 warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
543 ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
545 qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
549 =item seconds_since TIMESTAMP
551 See L<FS::svc_acct/seconds_since>. Equivalent to
552 $cust_svc->svc_x->seconds_since, but more efficient. Meaningless for records
553 where B<svcdb> is not "svc_acct".
557 #internal session db deprecated (or at least on hold)
558 sub seconds_since { 'internal session db deprecated'; };
559 ##note: implementation here, POD in FS::svc_acct
561 # my($self, $since) = @_;
563 # my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
566 # AND logout IS NOT NULL'
567 # ) or die $dbh->errstr;
568 # $sth->execute($self->svcnum, $since) or die $sth->errstr;
569 # $sth->fetchrow_arrayref->[0];
572 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
574 See L<FS::svc_acct/seconds_since_sqlradacct>. Equivalent to
575 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless
576 for records where B<svcdb> is not "svc_acct".
580 #note: implementation here, POD in FS::svc_acct
581 sub seconds_since_sqlradacct {
582 my($self, $start, $end) = @_;
584 my $mes = "$me seconds_since_sqlradacct:";
586 my $svc_x = $self->svc_x;
588 my @part_export = $self->part_svc->part_export_usage;
589 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
590 " service definition"
595 foreach my $part_export ( @part_export ) {
597 next if $part_export->option('ignore_accounting');
599 warn "$mes connecting to sqlradius database\n"
602 my $dbh = DBI->connect( map { $part_export->option($_) }
603 qw(datasrc username password) )
604 or die "can't connect to sqlradius database: ". $DBI::errstr;
606 warn "$mes connected to sqlradius database\n"
609 #select a unix time conversion function based on database type
610 my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
612 my $username = $part_export->export_username($svc_x);
616 warn "$mes finding closed sessions completely within the given range\n"
621 if ($part_export->option('process_single_realm')) {
622 $realm = 'AND Realm = ?';
623 $realmparam = $part_export->option('realm');
626 my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
630 AND $str2time AcctStartTime) >= ?
631 AND $str2time AcctStopTime ) < ?
632 AND $str2time AcctStopTime ) > 0
633 AND AcctStopTime IS NOT NULL"
634 ) or die $dbh->errstr;
635 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
637 my $regular = $sth->fetchrow_arrayref->[0];
639 warn "$mes finding open sessions which start in the range\n"
642 # count session start->range end
643 $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
647 AND $str2time AcctStartTime ) >= ?
648 AND $str2time AcctStartTime ) < ?
649 AND ( ? - $str2time AcctStartTime ) ) < 86400
650 AND ( $str2time AcctStopTime ) = 0
651 OR AcctStopTime IS NULL )";
652 $sth = $dbh->prepare($query) or die $dbh->errstr;
655 ($realm ? $realmparam : ()),
659 or die $sth->errstr. " executing query $query";
660 my $start_during = $sth->fetchrow_arrayref->[0];
662 warn "$mes finding closed sessions which start before the range but stop during\n"
665 #count range start->session end
666 $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? )
670 AND $str2time AcctStartTime ) < ?
671 AND $str2time AcctStopTime ) >= ?
672 AND $str2time AcctStopTime ) < ?
673 AND $str2time AcctStopTime ) > 0
674 AND AcctStopTime IS NOT NULL"
675 ) or die $dbh->errstr;
676 $sth->execute( $start,
678 ($realm ? $realmparam : ()),
683 my $end_during = $sth->fetchrow_arrayref->[0];
685 warn "$mes finding closed sessions which start before the range but stop after\n"
688 # count range start->range end
689 # don't count open sessions anymore (probably missing stop record)
690 $sth = $dbh->prepare("SELECT COUNT(*)
694 AND $str2time AcctStartTime ) < ?
695 AND ( $str2time AcctStopTime ) >= ?
697 # OR AcctStopTime = 0
698 # OR AcctStopTime IS NULL )"
699 ) or die $dbh->errstr;
700 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
702 my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
704 $seconds += $regular + $end_during + $start_during + $entire_range;
706 warn "$mes done finding sessions\n"
715 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
717 See L<FS::svc_acct/attribute_since_sqlradacct>. Equivalent to
718 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless
719 for records where B<svcdb> is not "svc_acct".
723 #note: implementation here, POD in FS::svc_acct
724 #(false laziness w/seconds_since_sqlradacct above)
725 sub attribute_since_sqlradacct {
726 my($self, $start, $end, $attrib) = @_;
728 my $mes = "$me attribute_since_sqlradacct:";
730 my $svc_x = $self->svc_x;
732 my @part_export = $self->part_svc->part_export_usage;
733 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
734 " service definition"
740 foreach my $part_export ( @part_export ) {
742 next if $part_export->option('ignore_accounting');
744 warn "$mes connecting to sqlradius database\n"
747 my $dbh = DBI->connect( map { $part_export->option($_) }
748 qw(datasrc username password) )
749 or die "can't connect to sqlradius database: ". $DBI::errstr;
751 warn "$mes connected to sqlradius database\n"
754 #select a unix time conversion function based on database type
755 my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
757 my $username = $part_export->export_username($svc_x);
759 warn "$mes SUMing $attrib sessions\n"
764 if ($part_export->option('process_single_realm')) {
765 $realm = 'AND Realm = ?';
766 $realmparam = $part_export->option('realm');
769 my $sth = $dbh->prepare("SELECT SUM($attrib)
773 AND $str2time AcctStopTime ) >= ?
774 AND $str2time AcctStopTime ) < ?
775 AND AcctStopTime IS NOT NULL"
776 ) or die $dbh->errstr;
777 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
780 my $row = $sth->fetchrow_arrayref;
781 $sum += $row->[0] if defined($row->[0]);
783 warn "$mes done SUMing sessions\n"
792 =item get_session_history TIMESTAMP_START TIMESTAMP_END
794 See L<FS::svc_acct/get_session_history>. Equivalent to
795 $cust_svc->svc_x->get_session_history, but more efficient. Meaningless for
796 records where B<svcdb> is not "svc_acct".
800 sub get_session_history {
801 my($self, $start, $end, $attrib) = @_;
805 my @part_export = $self->part_svc->part_export_usage;
806 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
807 " service definition"
813 foreach my $part_export ( @part_export ) {
815 @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
822 =item tickets [ STATUS ]
824 Returns an array of hashes representing the tickets linked to this service.
826 An optional status (or arrayref or hashref of statuses) may be specified.
832 my $status = ( @_ && $_[0] ) ? shift : '';
834 my $conf = FS::Conf->new;
835 my $num = $conf->config('cust_main-max_tickets') || 10;
838 if ( $conf->config('ticket_system') ) {
839 unless ( $conf->config('ticket_system-custom_priority_field') ) {
841 @tickets = @{ FS::TicketSystem->service_tickets( $self->svcnum,
850 foreach my $priority (
851 $conf->config('ticket_system-custom_priority_field-values'), ''
853 last if scalar(@tickets) >= $num;
855 @{ FS::TicketSystem->service_tickets( $self->svcnum,
856 $num - scalar(@tickets),
874 =item smart_search OPTION => VALUE ...
876 Accepts the option I<search>, the string to search for. The string will
877 be searched for as a username, email address, IP address, MAC address,
878 phone number, and hardware serial number. Unlike the I<smart_search> on
879 customers, this always requires an exact match.
883 # though perhaps it should be fuzzy in some cases?
886 my %param = __PACKAGE__->smart_search_param(@_);
890 sub smart_search_param {
894 my $string = $opt{'search'};
895 $string =~ s/(^\s+|\s+$)//; #trim leading & trailing whitespace
898 map { my $table = $_;
899 my $search_sql = "FS::$table"->search_sql($string);
901 "SELECT $table.svcnum AS svcnum, '$table' AS svcdb ".
902 "FROM $table WHERE $search_sql";
904 FS::part_svc->svc_tables;
906 if ( $string =~ /^(\d+)$/ ) {
907 unshift @or, "SELECT cust_svc.svcnum, NULL as svcdb FROM cust_svc WHERE agent_svcid = $1";
910 my $addl_from = " RIGHT JOIN (\n" . join("\nUNION\n", @or) . "\n) AS svc_all ".
911 " ON (svc_all.svcnum = cust_svc.svcnum) ";
915 push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql(
916 'null_right' => 'View/link unlinked services'
918 my $extra_sql = ' WHERE '.join(' AND ', @extra_sql);
920 $addl_from .= ' LEFT JOIN cust_pkg USING ( pkgnum )'.
921 FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg').
922 ' LEFT JOIN part_svc USING ( svcpart )';
925 'table' => 'cust_svc',
926 'select' => 'svc_all.svcnum AS svcnum, '.
927 'COALESCE(svc_all.svcdb, part_svc.svcdb) AS svcdb, '.
929 'addl_from' => $addl_from,
931 'extra_sql' => $extra_sql,
938 # fix missing (deleted by mistake) svc_x records
939 warn "searching for missing svc_x records...\n";
941 'table' => 'cust_svc',
942 'select' => 'cust_svc.*',
943 'addl_from' => ' LEFT JOIN ( ' .
945 map { "SELECT svcnum FROM $_" }
946 FS::part_svc->svc_tables
947 ) . ' ) AS svc_all ON cust_svc.svcnum = svc_all.svcnum',
948 'extra_sql' => ' WHERE svc_all.svcnum IS NULL',
950 my @svcs = qsearch(\%search);
951 warn "found ".scalar(@svcs)."\n";
953 local $FS::Record::nowarn_classload = 1; # for h_svc_
954 local $FS::svc_Common::noexport_hack = 1; # because we're inserting services
957 'hashref' => { history_action => 'delete' },
958 'order_by' => ' ORDER BY history_date DESC LIMIT 1',
960 foreach my $cust_svc (@svcs) {
961 my $svcnum = $cust_svc->svcnum;
962 my $svcdb = $cust_svc->part_svc->svcdb;
963 $h_search{'hashref'}{'svcnum'} = $svcnum;
964 $h_search{'table'} = "h_$svcdb";
965 my $h_svc_x = qsearchs(\%h_search)
967 my $class = "FS::$svcdb";
968 my $new_svc_x = $class->new({ $h_svc_x->hash });
969 my $error = $new_svc_x->insert;
970 warn "error repairing svcnum $svcnum ($svcdb) from history:\n$error\n"
981 Behaviour of changing the svcpart of cust_svc records is undefined and should
982 possibly be prohibited, and pkg_svc records are not checked.
984 pkg_svc records are not checked in general (here).
986 Deleting this record doesn't check or delete the svc_* record associated
989 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
990 a DBI database handle is not yet implemented.
994 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>,
995 schema.html from the base documentation