Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / svc_phone.pm
1 package FS::svc_phone;
2
3 use strict;
4 use base qw( FS::svc_Domain_Mixin FS::location_Mixin FS::svc_Common );
5 use vars qw( $DEBUG $me @pw_set $conf $phone_name_max );
6 use Data::Dumper;
7 use Scalar::Util qw( blessed );
8 use FS::Conf;
9 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::PagedSearch qw( psearch );
11 use FS::Msgcat qw(gettext);
12 use FS::part_svc;
13 use FS::phone_device;
14 use FS::svc_pbx;
15 use FS::svc_domain;
16 use FS::cust_location;
17 use FS::phone_avail;
18
19 $me = '[' . __PACKAGE__ . ']';
20 $DEBUG = 0;
21
22 #avoid l 1 and o O 0
23 @pw_set = ( 'a'..'k', 'm','n', 'p-z', 'A'..'N', 'P'..'Z' , '2'..'9' );
24
25 #ask FS::UID to run this stuff for us later
26 $FS::UID::callback{'FS::svc_acct'} = sub { 
27   $conf = new FS::Conf;
28   $phone_name_max = $conf->config('svc_phone-phone_name-max_length');
29 };
30
31 =head1 NAME
32
33 FS::svc_phone - Object methods for svc_phone records
34
35 =head1 SYNOPSIS
36
37   use FS::svc_phone;
38
39   $record = new FS::svc_phone \%hash;
40   $record = new FS::svc_phone { 'column' => 'value' };
41
42   $error = $record->insert;
43
44   $error = $new_record->replace($old_record);
45
46   $error = $record->delete;
47
48   $error = $record->check;
49
50   $error = $record->suspend;
51
52   $error = $record->unsuspend;
53
54   $error = $record->cancel;
55
56 =head1 DESCRIPTION
57
58 An FS::svc_phone object represents a phone number.  FS::svc_phone inherits
59 from FS::Record.  The following fields are currently supported:
60
61 =over 4
62
63 =item svcnum
64
65 primary key
66
67 =item countrycode
68
69 =item phonenum
70
71 =item sip_password
72
73 =item pin
74
75 Voicemail PIN
76
77 =item phone_name
78
79 =item pbxsvc
80
81 Optional svcnum from svc_pbx
82
83 =item forwarddst
84
85 Forwarding destination
86
87 =item email
88
89 Email address for virtual fax (fax-to-email) services
90
91 =item lnp_status
92
93 LNP Status (can be null, native, portedin, portingin, portin-reject,
94 portingout, portout-reject)
95
96 =item portable
97
98 =item lrn
99
100 =item lnp_desired_due_date
101
102 =item lnp_due_date
103
104 =item lnp_other_provider
105
106 If porting the number in or out, name of the losing or winning provider, 
107 respectively.
108
109 =item lnp_other_provider_account
110
111 Account number of other provider. See lnp_other_provider.
112
113 =item lnp_reject_reason
114
115 See lnp_status. If lnp_status is portin-reject or portout-reject, this is an
116 optional reject reason.
117
118 =back
119
120 =head1 METHODS
121
122 =over 4
123
124 =item new HASHREF
125
126 Creates a new phone number.  To add the number to the database, see L<"insert">.
127
128 Note that this stores the hash reference, not a distinct copy of the hash it
129 points to.  You can ask the object for a copy with the I<hash> method.
130
131 =cut
132
133 # the new method can be inherited from FS::Record, if a table method is defined
134 #
135 sub table_info {
136  my %dis2 = ( disable_inventory=>1, disable_select=>1 );
137   {
138     'name' => 'Phone number',
139     'sorts' => 'phonenum',
140     'display_weight' => 60,
141     'cancel_weight'  => 80,
142     'fields' => {
143         'svcnum'       => 'Service',
144         'countrycode'  => { label => 'Country code',
145                             type  => 'text',
146                             disable_inventory => 1,
147                             disable_select => 1,
148                           },
149         'phonenum'     => 'Phone number',
150         'pin'          => { label => 'Voicemail PIN', #'Personal Identification Number',
151                             type  => 'text',
152                             disable_inventory => 1,
153                             disable_select => 1,
154                           },
155         'sip_password' => 'SIP password',
156         'phone_name'   => 'Name',
157         'pbxsvc'       => { label => 'PBX',
158                             type  => 'select-svc_pbx.html',
159                             disable_inventory => 1,
160                             disable_select => 1, #UI wonky, pry works otherwise
161                           },
162         'domsvc'    => {
163                          label     => 'Domain',
164                          type      => 'select',
165                          select_table => 'svc_domain',
166                          select_key   => 'svcnum',
167                          select_label => 'domain',
168                          disable_inventory => 1,
169                        },
170         'locationnum' => {
171                            label => 'E911 location',
172                            disable_inventory => 1,
173                            disable_select    => 1,
174                          },
175         'forwarddst' => {       label => 'Forward Destination', 
176                                 %dis2,
177                         },
178         'email' => {            label => 'Email',
179                                 %dis2,
180                     },
181         'lnp_status' => {       label => 'LNP Status',
182                                 type => 'select-lnp_status.html',
183                                 %dis2,
184                         },
185         'lnp_reject_reason' => { 
186                                 label => 'LNP Reject Reason',
187                                 %dis2,
188                         },
189         'portable' =>   {       label => 'Portable?', %dis2, },
190         'lrn'   =>      {       label => 'LRN', 
191                                 disable_inventory => 1, 
192                         },
193         'lnp_desired_due_date' =>
194                         { label => 'LNP Desired Due Date', %dis2 },
195         'lnp_due_date' =>
196                         { label => 'LNP Due Date', %dis2 },
197         'lnp_other_provider' =>
198                         {       label => 'LNP Other Provider', 
199                                 disable_inventory => 1, 
200                         },
201         'lnp_other_provider_account' =>
202                         {       label => 'LNP Other Provider Account #', 
203                                 %dis2 
204                         },
205     },
206   };
207 }
208
209 sub table { 'svc_phone'; }
210
211 sub table_dupcheck_fields { ( 'countrycode', 'phonenum' ); }
212
213 =item search_sql STRING
214
215 Class method which returns an SQL fragment to search for the given string.
216
217 =cut
218
219 sub search_sql {
220   my( $class, $string ) = @_;
221
222   my $conf = new FS::Conf;
223
224   if ( $conf->exists('svc_phone-allow_alpha_phonenum') ) {
225     $string =~ s/\W//g;
226   } else {
227     $string =~ s/\D//g;
228   }
229
230   my $ccode = (    $conf->exists('default_phone_countrycode')
231                 && $conf->config('default_phone_countrycode')
232               )
233                 ? $conf->config('default_phone_countrycode') 
234                 : '1';
235
236   $string =~ s/^$ccode//;
237
238   $class->search_sql_field('phonenum', $string );
239 }
240
241 =item label
242
243 Returns the phone number.
244
245 =cut
246
247 sub label {
248   my $self = shift;
249   my $phonenum = $self->phonenum; #XXX format it better
250   my $label = $phonenum;
251   $label .= '@'.$self->domain if $self->domsvc;
252   $label .= ' ('.$self->phone_name.')' if $self->phone_name;
253   $label;
254 }
255
256 =item insert
257
258 Adds this phone number to the database.  If there is an error, returns the
259 error, otherwise returns false.
260
261 =cut
262
263 sub insert {
264   my $self = shift;
265   my %options = @_;
266
267   if ( $DEBUG ) {
268     warn "[$me] insert called on $self: ". Dumper($self).
269          "\nwith options: ". Dumper(%options);
270   }
271
272   local $SIG{HUP} = 'IGNORE';
273   local $SIG{INT} = 'IGNORE';
274   local $SIG{QUIT} = 'IGNORE';
275   local $SIG{TERM} = 'IGNORE';
276   local $SIG{TSTP} = 'IGNORE';
277   local $SIG{PIPE} = 'IGNORE';
278
279   my $oldAutoCommit = $FS::UID::AutoCommit;
280   local $FS::UID::AutoCommit = 0;
281   my $dbh = dbh;
282
283   #false laziness w/cust_pkg.pm... move this to location_Mixin?  that would
284   #make it more of a base class than a mixin... :)
285   if ( $options{'cust_location'}
286          && ( ! $self->locationnum || $self->locationnum == -1 ) ) {
287     my $error = $options{'cust_location'}->insert;
288     if ( $error ) {
289       $dbh->rollback if $oldAutoCommit;
290       return "inserting cust_location (transaction rolled back): $error";
291     }
292     $self->locationnum( $options{'cust_location'}->locationnum );
293   }
294   #what about on-the-fly edits?  if the ui supports it?
295
296   my $error = $self->SUPER::insert(%options);
297   if ( $error ) {
298     $dbh->rollback if $oldAutoCommit;
299     return $error;
300   }
301
302   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
303   '';
304
305 }
306
307 =item delete
308
309 Delete this record from the database.
310
311 =cut
312
313 sub delete {
314   my $self = shift;
315
316   local $SIG{HUP} = 'IGNORE';
317   local $SIG{INT} = 'IGNORE';
318   local $SIG{QUIT} = 'IGNORE';
319   local $SIG{TERM} = 'IGNORE';
320   local $SIG{TSTP} = 'IGNORE';
321   local $SIG{PIPE} = 'IGNORE';
322
323   my $oldAutoCommit = $FS::UID::AutoCommit;
324   local $FS::UID::AutoCommit = 0;
325   my $dbh = dbh;
326
327   foreach my $phone_device ( $self->phone_device ) {
328     my $error = $phone_device->delete;
329     if ( $error ) {
330       $dbh->rollback if $oldAutoCommit;
331       return $error;
332     }
333   }
334
335   my @phone_avail = qsearch('phone_avail', { 'svcnum' => $self->svcnum } );
336   foreach my $phone_avail ( @phone_avail ) {
337     $phone_avail->svcnum('');
338     my $error = $phone_avail->replace;
339     if ( $error ) {
340       $dbh->rollback if $oldAutoCommit;
341       return $error;
342     }
343   }
344
345   my $error = $self->SUPER::delete;
346   if ( $error ) {
347     $dbh->rollback if $oldAutoCommit;
348     return $error;
349   }
350
351   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
352   '';
353
354 }
355
356 # the delete method can be inherited from FS::Record
357
358 =item replace OLD_RECORD
359
360 Replaces the OLD_RECORD with this one in the database.  If there is an error,
361 returns the error, otherwise returns false.
362
363 =cut
364
365 sub replace {
366   my $new = shift;
367
368   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
369               ? shift
370               : $new->replace_old;
371
372   my %options = @_;
373
374   if ( $DEBUG ) {
375     warn "[$me] replacing $old with $new\n".
376          "\nwith options: ". Dumper(%options);
377   }
378
379   local $SIG{HUP} = 'IGNORE';
380   local $SIG{INT} = 'IGNORE';
381   local $SIG{QUIT} = 'IGNORE';
382   local $SIG{TERM} = 'IGNORE';
383   local $SIG{TSTP} = 'IGNORE';
384   local $SIG{PIPE} = 'IGNORE';
385
386   my $oldAutoCommit = $FS::UID::AutoCommit;
387   local $FS::UID::AutoCommit = 0;
388   my $dbh = dbh;
389
390   #false laziness w/cust_pkg.pm... move this to location_Mixin?  that would
391   #make it more of a base class than a mixin... :)
392   if ( $options{'cust_location'}
393          && ( ! $new->locationnum || $new->locationnum == -1 ) ) {
394     my $error = $options{'cust_location'}->insert;
395     if ( $error ) {
396       $dbh->rollback if $oldAutoCommit;
397       return "inserting cust_location (transaction rolled back): $error";
398     }
399     $new->locationnum( $options{'cust_location'}->locationnum );
400   }
401   #what about on-the-fly edits?  if the ui supports it?
402
403   # LNP data validation
404  return 'Invalid LNP status' # if someone does really stupid stuff
405     if (  ($old->lnp_status eq 'portingout' && $new->lnp_status eq 'portingin')
406         || ($old->lnp_status eq 'portout-reject' && $new->lnp_status eq 'portingin')
407         || ($old->lnp_status eq 'portin-reject' && $new->lnp_status eq 'portingout')
408         || ($old->lnp_status eq 'portingin' && $new->lnp_status eq 'native')
409         || ($old->lnp_status eq 'portin-reject' && $new->lnp_status eq 'native')
410         || ($old->lnp_status eq 'portingin' && $new->lnp_status eq 'portingout')
411         || ($old->lnp_status eq 'portingout' && $new->lnp_status eq 'portin-reject')
412         );
413
414   my $error = $new->SUPER::replace($old, %options);
415   if ( $error ) {
416     $dbh->rollback if $oldAutoCommit;
417     return $error if $error;
418   }
419
420   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
421   ''; #no error
422 }
423
424 =item suspend
425
426 Called by the suspend method of FS::cust_pkg (see L<FS::cust_pkg>).
427
428 =item unsuspend
429
430 Called by the unsuspend method of FS::cust_pkg (see L<FS::cust_pkg>).
431
432 =item cancel
433
434 Called by the cancel method of FS::cust_pkg (see L<FS::cust_pkg>).
435
436 =item check
437
438 Checks all fields to make sure this is a valid phone number.  If there is
439 an error, returns the error, otherwise returns false.  Called by the insert
440 and replace methods.
441
442 =cut
443
444 # the check method should currently be supplied - FS::Record contains some
445 # data checking routines
446
447 sub check {
448   my $self = shift;
449
450   my $conf = new FS::Conf;
451
452   my $phonenum = $self->phonenum;
453   my $phonenum_check_method;
454   if ( $conf->exists('svc_phone-allow_alpha_phonenum') ) {
455     $phonenum =~ s/\W//g;
456     $phonenum_check_method = 'ut_alpha';
457   } else {
458     $phonenum =~ s/\D//g;
459     $phonenum_check_method = 'ut_number';
460   }
461   $self->phonenum($phonenum);
462
463   $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
464
465   my $error = 
466     $self->ut_numbern('svcnum')
467     || $self->ut_numbern('countrycode')
468     || $self->$phonenum_check_method('phonenum')
469     || $self->ut_anything('sip_password')
470     || $self->ut_numbern('pin')
471     || $self->ut_textn('phone_name')
472     || $self->ut_foreign_keyn('pbxsvc', 'svc_pbx',    'svcnum' )
473     || $self->ut_foreign_keyn('domsvc', 'svc_domain', 'svcnum' )
474     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
475     || $self->ut_numbern('forwarddst')
476     || $self->ut_textn('email')
477     || $self->ut_numbern('lrn')
478     || $self->ut_numbern('lnp_desired_due_date')
479     || $self->ut_numbern('lnp_due_date')
480     || $self->ut_textn('lnp_other_provider')
481     || $self->ut_textn('lnp_other_provider_account')
482     || $self->ut_enumn('lnp_status', ['','portingin','portingout','portedin',
483                                 'native', 'portin-reject', 'portout-reject'])
484     || $self->ut_enumn('portable', ['','Y'])
485     || $self->ut_textn('lnp_reject_reason')
486   ;
487   return $error if $error;
488
489     # LNP data validation
490     return 'Cannot set LNP fields: no LNP in progress'
491         if ( ($self->lnp_desired_due_date || $self->lnp_due_date 
492             || $self->lnp_other_provider || $self->lnp_other_provider_account
493             || $self->lnp_reject_reason) 
494             && (!$self->lnp_status || $self->lnp_status eq 'native') );
495     return 'Cannot set LNP reject reason: no LNP in progress or status is not reject'
496         if ($self->lnp_reject_reason && (!$self->lnp_status 
497                             || $self->lnp_status !~ /^port(in|out)-reject$/) );
498     return 'Cannot port-out a non-portable number' 
499         if (!$self->portable && $self->lnp_status eq 'portingout');
500
501
502   return 'Name ('. $self->phone_name.
503          ") is longer than $phone_name_max characters"
504     if $phone_name_max && length($self->phone_name) > $phone_name_max;
505
506   $self->countrycode(1) unless $self->countrycode;
507
508   unless ( length($self->pin) ) {
509     my $random_pin = $conf->config('svc_phone-random_pin');
510     if ( $random_pin =~ /^\d+$/ ) {
511       $self->pin(
512         join('', map int(rand(10)), 0..($random_pin-1))
513       );
514     }
515   }
516
517   unless ( length($self->sip_password) ) { # option for this?
518
519     $self->sip_password(
520       join('', map $pw_set[ int(rand $#pw_set) ], (0..16) )
521     );
522
523   }
524
525   $self->SUPER::check;
526 }
527
528 =item _check duplicate
529
530 Internal method to check for duplicate phone numers.
531
532 =cut
533
534 #false laziness w/svc_acct.pm's _check_duplicate.
535 sub _check_duplicate {
536   my $self = shift;
537
538   my $global_unique = $conf->config('global_unique-phonenum') || 'none';
539   return '' if $global_unique eq 'disabled';
540
541   $self->lock_table;
542
543   my @dup_ccphonenum =
544     grep { !$self->svcnum || $_->svcnum != $self->svcnum }
545     qsearch( 'svc_phone', {
546       'countrycode' => $self->countrycode,
547       'phonenum'    => $self->phonenum,
548     });
549
550   return gettext('phonenum_in_use')
551     if $global_unique eq 'countrycode+phonenum' && @dup_ccphonenum;
552
553   my $part_svc = qsearchs('part_svc', { 'svcpart' => $self->svcpart } );
554   unless ( $part_svc ) {
555     return 'unknown svcpart '. $self->svcpart;
556   }
557
558   if ( @dup_ccphonenum ) {
559
560     my $exports = FS::part_export::export_info('svc_phone');
561     my %conflict_ccphonenum_svcpart = ( $self->svcpart => 'SELF', );
562
563     foreach my $part_export ( $part_svc->part_export ) {
564
565       #this will catch to the same exact export
566       my @svcparts = map { $_->svcpart } $part_export->export_svc;
567
568       $conflict_ccphonenum_svcpart{$_} = $part_export->exportnum
569         foreach @svcparts;
570
571     }
572
573     foreach my $dup_ccphonenum ( @dup_ccphonenum ) {
574       my $dup_svcpart = $dup_ccphonenum->cust_svc->svcpart;
575       if ( exists($conflict_ccphonenum_svcpart{$dup_svcpart}) ) {
576         return "duplicate phone number ".
577                $self->countrycode. ' '. $self->phonenum.
578                ": conflicts with svcnum ". $dup_ccphonenum->svcnum.
579                " via exportnum ". $conflict_ccphonenum_svcpart{$dup_svcpart};
580       }
581     }
582
583   }
584
585   return '';
586
587 }
588
589 =item check_pin
590
591 Checks the supplied PIN against the PIN in the database.  Returns true for a
592 sucessful authentication, false if no match.
593
594 =cut
595
596 sub check_pin {
597   my($self, $check_pin) = @_;
598   length($self->pin) && $check_pin eq $self->pin;
599 }
600
601 =item radius_reply
602
603 =cut
604
605 sub radius_reply {
606   my $self = shift;
607   #XXX Session-Timeout!  holy shit, need rlm_perl to ask for this in realtime
608   ();
609 }
610
611 =item radius_check
612
613 =cut
614
615 sub radius_check {
616   my $self = shift;
617   my %check = ();
618
619   my $conf = new FS::Conf;
620
621   $check{'User-Password'} = $conf->config('svc_phone-radius-default_password');
622
623   %check;
624 }
625
626 sub radius_groups {
627   ();
628 }
629
630 =item phone_device
631
632 Returns any FS::phone_device records associated with this service.
633
634 =cut
635
636 sub phone_device {
637   my $self = shift;
638   qsearch('phone_device', { 'svcnum' => $self->svcnum } );
639 }
640
641 #override location_Mixin version cause we want to try the cust_pkg location
642 #in between us and cust_main
643 # XXX what to do in the unlinked case???  return a pseudo-object that returns
644 # empty fields?
645 sub cust_location_or_main {
646   my $self = shift;
647   return $self->cust_location if $self->locationnum;
648   my $cust_pkg = $self->cust_svc->cust_pkg;
649   $cust_pkg ? $cust_pkg->cust_location_or_main : '';
650 }
651
652 =item psearch_cdrs OPTIONS
653
654 Returns a paged search (L<FS::PagedSearch>) for Call Detail Records 
655 associated with this service.  By default, "associated with" means that 
656 either the "src" or the "charged_party" field of the CDR matches the 
657 "phonenum" field of the service.  To access the CDRs themselves, call
658 "->fetch" on the resulting object.
659
660 =over 2
661
662 Accepts the following options:
663
664 =item for_update => 1: SELECT the CDRs "FOR UPDATE".
665
666 =item status => "" (or "processing-tiered", "done"): Return only CDRs with that processing status.
667
668 =item inbound => 1: Return CDRs for inbound calls.  With "status", will filter 
669 on inbound processing status.
670
671 =item default_prefix => "XXX": Also accept the phone number of the service prepended 
672 with the chosen prefix.
673
674 =item begin, end: Start and end of a date range, as unix timestamp.
675
676 =item cdrtypenum: Only return CDRs with this type number.
677
678 =item disable_src => 1: Only match on "charged_party", not "src".
679
680 =item by_svcnum: not supported for svc_phone
681
682 =item billsec_sum: Instead of returning all of the CDRs, return a single
683 record (as an L<FS::cdr> object) with the sum of the 'billsec' field over 
684 the entire result set.
685
686 =back
687
688 =cut
689
690 sub psearch_cdrs {
691
692   my($self, %options) = @_;
693   my @fields;
694   my %hash;
695   my @where;
696
697   if ( $options{'inbound'} ) {
698
699     @fields = ( 'dst' );
700     if ( exists($options{'status'}) ) {
701       my $status = $options{'status'};
702       if ( $status ) {
703         push @where, 'EXISTS ( SELECT 1 FROM cdr_termination '.
704           'WHERE cdr.acctid = cdr_termination.acctid '.
705           "AND cdr_termination.status = '$status' ". #quoting kludge
706           'AND cdr_termination.termpart = 1 )';
707       } else {
708         push @where, 'NOT EXISTS ( SELECT 1 FROM cdr_termination '.
709           'WHERE cdr.acctid = cdr_termination.acctid '.
710           'AND cdr_termination.termpart = 1 )';
711       }
712     }
713
714   } else {
715
716     @fields = ( 'charged_party' );
717     push @fields, 'src' if !$options{'disable_src'};
718     $hash{'freesidestatus'} = $options{'status'}
719       if exists($options{'status'});
720   }
721
722   if ($options{'cdrtypenum'}) {
723     $hash{'cdrtypenum'} = $options{'cdrtypenum'};
724   }
725   
726   my $for_update = $options{'for_update'} ? 'FOR UPDATE' : '';
727
728   my $number = $self->phonenum;
729
730   my $prefix = $options{'default_prefix'};
731
732   my @orwhere =  map " $_ = '$number'        ", @fields;
733   push @orwhere, map " $_ = '$prefix$number' ", @fields
734     if defined($prefix) && length($prefix);
735   if ( $prefix && $prefix =~ /^\+(\d+)$/ ) {
736     push @orwhere, map " $_ = '$1$number' ", @fields
737   }
738
739   push @where, ' ( '. join(' OR ', @orwhere ). ' ) ';
740
741   if ( $options{'begin'} ) {
742     push @where, 'startdate >= '. $options{'begin'};
743   }
744   if ( $options{'end'} ) {
745     push @where, 'startdate < '.  $options{'end'};
746   }
747
748   my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where );
749
750   psearch( {
751       'table'      => 'cdr',
752       'hashref'    => \%hash,
753       'extra_sql'  => $extra_sql,
754       'order_by'   => $options{'billsec_sum'} ? '' : "ORDER BY startdate $for_update",
755       'select'     => $options{'billsec_sum'} ? 'sum(billsec) as billsec_sum' : '*',
756   } );
757 }
758
759 =item get_cdrs (DEPRECATED)
760
761 Like psearch_cdrs, but returns all the L<FS::cdr> objects at once, in a 
762 single list.  Arguments are the same as for psearch_cdrs.  This can take 
763 an unreasonably large amount of memory and is best avoided.
764
765 =cut
766
767 sub get_cdrs {
768   my $self = shift;
769   my $psearch = $self->psearch_cdrs(@_);
770   qsearch ( $psearch->{query} )
771 }
772
773
774 =back
775
776 =head1 BUGS
777
778 =head1 SEE ALSO
779
780 L<FS::svc_Common>, L<FS::Record>, L<FS::cust_svc>, L<FS::part_svc>,
781 L<FS::cust_pkg>, schema.html from the base documentation.
782
783 =cut
784
785 1;
786