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 );
8 use List::Util qw( max );
10 use FS::Record qw( qsearch qsearchs dbh str2time_sql );
14 use FS::domain_record;
19 #most FS::svc_ classes are autoloaded in svc_x emthod
20 use FS::svc_acct; #this one is used in the cache stuff
28 #ask FS::UID to run this stuff for us later
29 FS::UID->install_callback( sub {
31 $ticket_system = $conf->config('ticket_system')
36 my ( $hashref, $cache ) = @_;
37 if ( $hashref->{'username'} ) {
38 $self->{'_svc_acct'} = FS::svc_acct->new($hashref, '');
40 if ( $hashref->{'svc'} ) {
41 $self->{'_svcpart'} = FS::part_svc->new($hashref);
47 FS::cust_svc - Object method for cust_svc objects
53 $record = new FS::cust_svc \%hash
54 $record = new FS::cust_svc { 'column' => 'value' };
56 $error = $record->insert;
58 $error = $new_record->replace($old_record);
60 $error = $record->delete;
62 $error = $record->check;
64 ($label, $value) = $record->label;
68 An FS::cust_svc represents a service. FS::cust_svc inherits from FS::Record.
69 The following fields are currently supported:
73 =item svcnum - primary key (assigned automatically for new services)
75 =item pkgnum - Package (see L<FS::cust_pkg>)
77 =item svcpart - Service definition (see L<FS::part_svc>)
79 =item agent_svcid - Optional legacy service ID
81 =item overlimit - date the service exceeded its usage limit
91 Creates a new service. To add the refund to the database, see L<"insert">.
92 Services are normally created by creating FS::svc_ objects (see
93 L<FS::svc_acct>, L<FS::svc_domain>, and L<FS::svc_forward>, among others).
97 sub table { 'cust_svc'; }
101 Adds this service to the database. If there is an error, returns the error,
102 otherwise returns false.
106 Deletes this service from the database. If there is an error, returns the
107 error, otherwise returns false. Note that this only removes the cust_svc
108 record - you should probably use the B<cancel> method instead.
117 my $cust_pkg = $self->cust_pkg;
118 my $custnum = $cust_pkg->custnum if $cust_pkg;
120 my $error = $self->SUPER::delete;
121 return $error if $error;
123 if ( $ticket_system eq 'RT_Internal' ) {
124 unless ( $rt_session ) {
125 FS::TicketSystem->init;
126 $rt_session = FS::TicketSystem->session;
128 my $links = RT::Links->new($rt_session->{CurrentUser});
129 my $svcnum = $self->svcnum;
130 $links->Limit(FIELD => 'Target',
131 VALUE => 'freeside://freeside/cust_svc/'.$svcnum);
132 while ( my $l = $links->Next ) {
135 # re-link to point to the customer instead
137 $l->SetTarget('freeside://freeside/cust_main/'.$custnum);
140 ($val, $msg) = $l->Delete;
142 # can't do anything useful on error
143 warn "error unlinking ticket $svcnum: $msg\n" if !$val;
150 Cancels the relevant service by calling the B<cancel> method of the associated
151 FS::svc_XXX object (i.e. an FS::svc_acct object or FS::svc_domain object),
152 deleting the FS::svc_XXX record and then deleting this record.
154 If there is an error, returns the error, otherwise returns false.
161 local $SIG{HUP} = 'IGNORE';
162 local $SIG{INT} = 'IGNORE';
163 local $SIG{QUIT} = 'IGNORE';
164 local $SIG{TERM} = 'IGNORE';
165 local $SIG{TSTP} = 'IGNORE';
166 local $SIG{PIPE} = 'IGNORE';
168 my $oldAutoCommit = $FS::UID::AutoCommit;
169 local $FS::UID::AutoCommit = 0;
172 my $part_svc = $self->part_svc;
174 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
175 $dbh->rollback if $oldAutoCommit;
176 return "Illegal svcdb value in part_svc!";
179 require "FS/$svcdb.pm";
181 my $svc = $self->svc_x;
183 if ( %opt && $opt{'date'} ) {
184 my $error = $svc->expire($opt{'date'});
186 $dbh->rollback if $oldAutoCommit;
187 return "Error expiring service: $error";
190 my $error = $svc->cancel;
192 $dbh->rollback if $oldAutoCommit;
193 return "Error canceling service: $error";
195 $error = $svc->delete; #this deletes this cust_svc record as well
197 $dbh->rollback if $oldAutoCommit;
198 return "Error deleting service: $error";
205 warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
206 "; deleting cust_svc only\n";
208 my $error = $self->delete;
210 $dbh->rollback if $oldAutoCommit;
211 return "Error deleting cust_svc: $error";
216 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
222 =item overlimit [ ACTION ]
224 Retrieves or sets the overlimit date. If ACTION is absent, return
225 the present value of overlimit. If ACTION is present, it can
226 have the value 'suspend' or 'unsuspend'. In the case of 'suspend' overlimit
227 is set to the current time if it is not already set. The 'unsuspend' value
228 causes the time to be cleared.
230 If there is an error on setting, returns the error, otherwise returns false.
236 my $action = shift or return $self->getfield('overlimit');
238 local $SIG{HUP} = 'IGNORE';
239 local $SIG{INT} = 'IGNORE';
240 local $SIG{QUIT} = 'IGNORE';
241 local $SIG{TERM} = 'IGNORE';
242 local $SIG{TSTP} = 'IGNORE';
243 local $SIG{PIPE} = 'IGNORE';
245 my $oldAutoCommit = $FS::UID::AutoCommit;
246 local $FS::UID::AutoCommit = 0;
249 if ( $action eq 'suspend' ) {
250 $self->setfield('overlimit', time) unless $self->getfield('overlimit');
251 }elsif ( $action eq 'unsuspend' ) {
252 $self->setfield('overlimit', '');
254 die "unexpected action value: $action";
257 local $ignore_quantity = 1;
258 my $error = $self->replace;
260 $dbh->rollback if $oldAutoCommit;
261 return "Error setting overlimit: $error";
264 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
270 =item replace OLD_RECORD
272 Replaces the OLD_RECORD with this one in the database. If there is an error,
273 returns the error, otherwise returns false.
280 # my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
282 # : $new->replace_old;
283 my ( $new, $old ) = ( shift, shift );
284 $old = $new->replace_old unless defined($old);
286 local $SIG{HUP} = 'IGNORE';
287 local $SIG{INT} = 'IGNORE';
288 local $SIG{QUIT} = 'IGNORE';
289 local $SIG{TERM} = 'IGNORE';
290 local $SIG{TSTP} = 'IGNORE';
291 local $SIG{PIPE} = 'IGNORE';
293 my $oldAutoCommit = $FS::UID::AutoCommit;
294 local $FS::UID::AutoCommit = 0;
297 if ( $new->svcpart != $old->svcpart ) {
298 my $svc_x = $new->svc_x;
299 my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
300 local($FS::Record::nowarn_identical) = 1;
301 my $error = $new_svc_x->replace($svc_x);
303 $dbh->rollback if $oldAutoCommit;
304 return $error if $error;
308 # #trigger a re-export on pkgnum changes?
309 # # (of prepaid packages), for Expiration RADIUS attribute
310 # if ( $new->pkgnum != $old->pkgnum && $new->cust_pkg->part_pkg->is_prepaid ) {
311 # my $svc_x = $new->svc_x;
312 # local($FS::Record::nowarn_identical) = 1;
313 # my $error = $svc_x->export('replace');
315 # $dbh->rollback if $oldAutoCommit;
316 # return $error if $error;
320 #trigger a pkg_change export on pkgnum changes
321 if ( $new->pkgnum != $old->pkgnum ) {
322 my $error = $new->svc_x->export('pkg_change', $new->cust_pkg,
326 $dbh->rollback if $oldAutoCommit;
327 return $error if $error;
331 #my $error = $new->SUPER::replace($old, @_);
332 my $error = $new->SUPER::replace($old);
334 $dbh->rollback if $oldAutoCommit;
335 return $error if $error;
338 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
345 Checks all fields to make sure this is a valid service. If there is an error,
346 returns the error, otherwise returns false. Called by the insert and
355 $self->ut_numbern('svcnum')
356 || $self->ut_numbern('pkgnum')
357 || $self->ut_number('svcpart')
358 || $self->ut_numbern('agent_svcid')
359 || $self->ut_numbern('overlimit')
361 return $error if $error;
363 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
364 return "Unknown svcpart" unless $part_svc;
366 if ( $self->pkgnum && ! $ignore_quantity ) {
368 #slightly inefficient since ->pkg_svc will also look it up, but fixing
369 # a much larger perf problem and have bigger fish to fry
370 my $cust_pkg = $self->cust_pkg;
372 my $pkg_svc = $self->pkg_svc
373 or return "No svcpart ". $self->svcpart.
374 " services in pkgpart ". $cust_pkg->pkgpart;
376 my $num_cust_svc = $cust_pkg->num_cust_svc( $self->svcpart );
378 #false laziness w/cust_pkg->part_svc
379 my $num_avail = max( 0, ($cust_pkg->quantity || 1) * $pkg_svc->quantity
383 return "Already $num_cust_svc ". $pkg_svc->part_svc->svc.
384 " services for pkgnum ". $self->pkgnum
394 Returns the displayed service number for this service: agent_svcid if it has a
395 value, svcnum otherwise
401 $self->agent_svcid || $self->svcnum;
406 Returns the definition for this service, as a FS::part_svc object (see
414 ? $self->{'_svcpart'}
415 : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
420 Returns the package this service belongs to, as a FS::cust_pkg object (see
425 Returns the pkg_svc record for for this service, if applicable.
431 my $cust_pkg = $self->cust_pkg;
432 return undef unless $cust_pkg;
434 qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
435 'pkgpart' => $cust_pkg->pkgpart,
442 Returns the date this service was inserted.
448 $self->h_date('insert');
451 =item pkg_cancel_date
453 Returns the date this service's package was canceled. This normally only
454 exists for a service that's been preserved through cancellation with the
455 part_pkg.preserve flag.
459 sub pkg_cancel_date {
461 my $cust_pkg = $self->cust_pkg or return;
462 return $cust_pkg->getfield('cancel') || '';
467 Returns a list consisting of:
468 - The name of this service (from part_svc)
469 - A meaningful identifier (username, domain, or mail alias)
470 - The table name (i.e. svc_domain) for this service
475 my($label, $value, $svcdb) = $cust_svc->label;
479 Like the B<label> method, except the second item in the list ("meaningful
480 identifier") may be longer - typically, a full name is included.
484 sub label { shift->_label('svc_label', @_); }
485 sub label_long { shift->_label('svc_label_long', @_); }
490 my $svc_x = $self->svc_x
491 or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
493 $self->$method($svc_x);
496 sub svc_label { shift->_svc_label('label', @_); }
497 sub svc_label_long { shift->_svc_label('label_long', @_); }
500 my( $self, $method, $svc_x ) = ( shift, shift, shift );
503 $self->part_svc->svc,
505 $self->part_svc->svcdb,
513 Returns a listref of html elements associated with this service's exports.
519 my $svc_x = $self->svc_x
520 or return [ "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum ];
522 $svc_x->export_links;
525 =item export_getsettings
527 Returns two hashrefs of settings associated with this service's exports.
531 sub export_getsettings {
533 my $svc_x = $self->svc_x
534 or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
536 $svc_x->export_getsettings;
542 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
543 FS::svc_domain object, etc.)
549 my $svcdb = $self->part_svc->svcdb;
550 if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
551 $self->{'_svc_acct'};
553 require "FS/$svcdb.pm";
554 warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
555 ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
557 qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
561 =item seconds_since TIMESTAMP
563 See L<FS::svc_acct/seconds_since>. Equivalent to
564 $cust_svc->svc_x->seconds_since, but more efficient. Meaningless for records
565 where B<svcdb> is not "svc_acct".
569 #internal session db deprecated (or at least on hold)
570 sub seconds_since { 'internal session db deprecated'; };
571 ##note: implementation here, POD in FS::svc_acct
573 # my($self, $since) = @_;
575 # my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
578 # AND logout IS NOT NULL'
579 # ) or die $dbh->errstr;
580 # $sth->execute($self->svcnum, $since) or die $sth->errstr;
581 # $sth->fetchrow_arrayref->[0];
584 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
586 See L<FS::svc_acct/seconds_since_sqlradacct>. Equivalent to
587 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless
588 for records where B<svcdb> is not "svc_acct".
592 #note: implementation here, POD in FS::svc_acct
593 sub seconds_since_sqlradacct {
594 my($self, $start, $end) = @_;
596 my $mes = "$me seconds_since_sqlradacct:";
598 my $svc_x = $self->svc_x;
600 my @part_export = $self->part_svc->part_export_usage;
601 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
602 " service definition"
607 foreach my $part_export ( @part_export ) {
609 next if $part_export->option('ignore_accounting');
611 warn "$mes connecting to sqlradius database\n"
614 my $dbh = DBI->connect( map { $part_export->option($_) }
615 qw(datasrc username password) )
616 or die "can't connect to sqlradius database: ". $DBI::errstr;
618 warn "$mes connected to sqlradius database\n"
621 #select a unix time conversion function based on database type
622 my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
624 my $username = $part_export->export_username($svc_x);
628 warn "$mes finding closed sessions completely within the given range\n"
633 if ($part_export->option('process_single_realm')) {
634 $realm = 'AND Realm = ?';
635 $realmparam = $part_export->option('realm');
638 my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
642 AND $str2time AcctStartTime) >= ?
643 AND $str2time AcctStopTime ) < ?
644 AND $str2time AcctStopTime ) > 0
645 AND AcctStopTime IS NOT NULL"
646 ) or die $dbh->errstr;
647 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
649 my $regular = $sth->fetchrow_arrayref->[0];
651 warn "$mes finding open sessions which start in the range\n"
654 # count session start->range end
655 $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
659 AND $str2time AcctStartTime ) >= ?
660 AND $str2time AcctStartTime ) < ?
661 AND ( ? - $str2time AcctStartTime ) ) < 86400
662 AND ( $str2time AcctStopTime ) = 0
663 OR AcctStopTime IS NULL )";
664 $sth = $dbh->prepare($query) or die $dbh->errstr;
667 ($realm ? $realmparam : ()),
671 or die $sth->errstr. " executing query $query";
672 my $start_during = $sth->fetchrow_arrayref->[0];
674 warn "$mes finding closed sessions which start before the range but stop during\n"
677 #count range start->session end
678 $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? )
682 AND $str2time AcctStartTime ) < ?
683 AND $str2time AcctStopTime ) >= ?
684 AND $str2time AcctStopTime ) < ?
685 AND $str2time AcctStopTime ) > 0
686 AND AcctStopTime IS NOT NULL"
687 ) or die $dbh->errstr;
688 $sth->execute( $start,
690 ($realm ? $realmparam : ()),
695 my $end_during = $sth->fetchrow_arrayref->[0];
697 warn "$mes finding closed sessions which start before the range but stop after\n"
700 # count range start->range end
701 # don't count open sessions anymore (probably missing stop record)
702 $sth = $dbh->prepare("SELECT COUNT(*)
706 AND $str2time AcctStartTime ) < ?
707 AND ( $str2time AcctStopTime ) >= ?
709 # OR AcctStopTime = 0
710 # OR AcctStopTime IS NULL )"
711 ) or die $dbh->errstr;
712 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
714 my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
716 $seconds += $regular + $end_during + $start_during + $entire_range;
718 warn "$mes done finding sessions\n"
727 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
729 See L<FS::svc_acct/attribute_since_sqlradacct>. Equivalent to
730 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless
731 for records where B<svcdb> is not "svc_acct".
735 #note: implementation here, POD in FS::svc_acct
736 #(false laziness w/seconds_since_sqlradacct above)
737 sub attribute_since_sqlradacct {
738 my($self, $start, $end, $attrib) = @_;
740 my $mes = "$me attribute_since_sqlradacct:";
742 my $svc_x = $self->svc_x;
744 my @part_export = $self->part_svc->part_export_usage;
745 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
746 " service definition"
752 foreach my $part_export ( @part_export ) {
754 next if $part_export->option('ignore_accounting');
756 warn "$mes connecting to sqlradius database\n"
759 my $dbh = DBI->connect( map { $part_export->option($_) }
760 qw(datasrc username password) )
761 or die "can't connect to sqlradius database: ". $DBI::errstr;
763 warn "$mes connected to sqlradius database\n"
766 #select a unix time conversion function based on database type
767 my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
769 my $username = $part_export->export_username($svc_x);
771 warn "$mes SUMing $attrib sessions\n"
776 if ($part_export->option('process_single_realm')) {
777 $realm = 'AND Realm = ?';
778 $realmparam = $part_export->option('realm');
781 my $sth = $dbh->prepare("SELECT SUM($attrib)
785 AND $str2time AcctStopTime ) >= ?
786 AND $str2time AcctStopTime ) < ?
787 AND AcctStopTime IS NOT NULL"
788 ) or die $dbh->errstr;
789 $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
792 my $row = $sth->fetchrow_arrayref;
793 $sum += $row->[0] if defined($row->[0]);
795 warn "$mes done SUMing sessions\n"
804 =item get_session_history TIMESTAMP_START TIMESTAMP_END
806 See L<FS::svc_acct/get_session_history>. Equivalent to
807 $cust_svc->svc_x->get_session_history, but more efficient. Meaningless for
808 records where B<svcdb> is not "svc_acct".
812 sub get_session_history {
813 my($self, $start, $end, $attrib) = @_;
817 my @part_export = $self->part_svc->part_export_usage;
818 die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
819 " service definition"
825 foreach my $part_export ( @part_export ) {
827 @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
834 =item tickets [ STATUS ]
836 Returns an array of hashes representing the tickets linked to this service.
838 An optional status (or arrayref or hashref of statuses) may be specified.
844 my $status = ( @_ && $_[0] ) ? shift : '';
846 my $conf = FS::Conf->new;
847 my $num = $conf->config('cust_main-max_tickets') || 10;
850 if ( $conf->config('ticket_system') ) {
851 unless ( $conf->config('ticket_system-custom_priority_field') ) {
853 @tickets = @{ FS::TicketSystem->service_tickets( $self->svcnum,
862 foreach my $priority (
863 $conf->config('ticket_system-custom_priority_field-values'), ''
865 last if scalar(@tickets) >= $num;
867 @{ FS::TicketSystem->service_tickets( $self->svcnum,
868 $num - scalar(@tickets),
886 =item smart_search OPTION => VALUE ...
888 Accepts the option I<search>, the string to search for. The string will
889 be searched for as a username, email address, IP address, MAC address,
890 phone number, and hardware serial number. Unlike the I<smart_search> on
891 customers, this always requires an exact match.
895 # though perhaps it should be fuzzy in some cases?
898 my %param = __PACKAGE__->smart_search_param(@_);
902 sub smart_search_param {
906 my $string = $opt{'search'};
907 $string =~ s/(^\s+|\s+$)//; #trim leading & trailing whitespace
910 map { my $table = $_;
911 my $search_sql = "FS::$table"->search_sql($string);
913 "SELECT $table.svcnum AS svcnum, '$table' AS svcdb ".
914 "FROM $table WHERE $search_sql";
916 FS::part_svc->svc_tables;
918 if ( $string =~ /^(\d+)$/ ) {
919 unshift @or, "SELECT cust_svc.svcnum, NULL as svcdb FROM cust_svc WHERE agent_svcid = $1";
922 my $addl_from = " RIGHT JOIN (\n" . join("\nUNION\n", @or) . "\n) AS svc_all ".
923 " ON (svc_all.svcnum = cust_svc.svcnum) ";
927 push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql(
928 'null_right' => 'View/link unlinked services'
930 my $extra_sql = ' WHERE '.join(' AND ', @extra_sql);
932 $addl_from .= ' LEFT JOIN cust_pkg USING ( pkgnum )'.
933 FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg').
934 ' LEFT JOIN part_svc USING ( svcpart )';
937 'table' => 'cust_svc',
938 'select' => 'svc_all.svcnum AS svcnum, '.
939 'COALESCE(svc_all.svcdb, part_svc.svcdb) AS svcdb, '.
941 'addl_from' => $addl_from,
943 'extra_sql' => $extra_sql,
950 # fix missing (deleted by mistake) svc_x records
951 warn "searching for missing svc_x records...\n";
953 'table' => 'cust_svc',
954 'select' => 'cust_svc.*',
955 'addl_from' => ' LEFT JOIN ( ' .
957 map { "SELECT svcnum FROM $_" }
958 FS::part_svc->svc_tables
959 ) . ' ) AS svc_all ON cust_svc.svcnum = svc_all.svcnum',
960 'extra_sql' => ' WHERE svc_all.svcnum IS NULL',
962 my @svcs = qsearch(\%search);
963 warn "found ".scalar(@svcs)."\n";
965 local $FS::Record::nowarn_classload = 1; # for h_svc_
966 local $FS::svc_Common::noexport_hack = 1; # because we're inserting services
969 'hashref' => { history_action => 'delete' },
970 'order_by' => ' ORDER BY history_date DESC LIMIT 1',
972 foreach my $cust_svc (@svcs) {
973 my $svcnum = $cust_svc->svcnum;
974 my $svcdb = $cust_svc->part_svc->svcdb;
975 $h_search{'hashref'}{'svcnum'} = $svcnum;
976 $h_search{'table'} = "h_$svcdb";
977 my $h_svc_x = qsearchs(\%h_search)
979 my $class = "FS::$svcdb";
980 my $new_svc_x = $class->new({ $h_svc_x->hash });
981 my $error = $new_svc_x->insert;
982 warn "error repairing svcnum $svcnum ($svcdb) from history:\n$error\n"
993 Behaviour of changing the svcpart of cust_svc records is undefined and should
994 possibly be prohibited, and pkg_svc records are not checked.
996 pkg_svc records are not checked in general (here).
998 Deleting this record doesn't check or delete the svc_* record associated
1001 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
1002 a DBI database handle is not yet implemented.
1006 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>,
1007 schema.html from the base documentation