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