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