don't throw 'Use of uninitialized value in addition (+) at /usr/local/share/perl...
[freeside.git] / FS / FS / cust_svc.pm
1 package FS::cust_svc;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me $ignore_quantity );
5 use Carp;
6 #use Scalar::Util qw( blessed );
7 use FS::Conf;
8 use FS::Record qw( qsearch qsearchs dbh str2time_sql );
9 use FS::cust_pkg;
10 use FS::part_pkg;
11 use FS::part_svc;
12 use FS::pkg_svc;
13 use FS::domain_record;
14 use FS::part_export;
15 use FS::cdr;
16
17 #most FS::svc_ classes are autoloaded in svc_x emthod
18 use FS::svc_acct;  #this one is used in the cache stuff
19
20 @ISA = qw( FS::cust_main_Mixin FS::option_Common ); #FS::Record );
21
22 $DEBUG = 0;
23 $me = '[cust_svc]';
24
25 $ignore_quantity = 0;
26
27 sub _cache {
28   my $self = shift;
29   my ( $hashref, $cache ) = @_;
30   if ( $hashref->{'username'} ) {
31     $self->{'_svc_acct'} = FS::svc_acct->new($hashref, '');
32   }
33   if ( $hashref->{'svc'} ) {
34     $self->{'_svcpart'} = FS::part_svc->new($hashref);
35   }
36 }
37
38 =head1 NAME
39
40 FS::cust_svc - Object method for cust_svc objects
41
42 =head1 SYNOPSIS
43
44   use FS::cust_svc;
45
46   $record = new FS::cust_svc \%hash
47   $record = new FS::cust_svc { 'column' => 'value' };
48
49   $error = $record->insert;
50
51   $error = $new_record->replace($old_record);
52
53   $error = $record->delete;
54
55   $error = $record->check;
56
57   ($label, $value) = $record->label;
58
59 =head1 DESCRIPTION
60
61 An FS::cust_svc represents a service.  FS::cust_svc inherits from FS::Record.
62 The following fields are currently supported:
63
64 =over 4
65
66 =item svcnum - primary key (assigned automatically for new services)
67
68 =item pkgnum - Package (see L<FS::cust_pkg>)
69
70 =item svcpart - Service definition (see L<FS::part_svc>)
71
72 =item overlimit - date the service exceeded its usage limit
73
74 =back
75
76 =head1 METHODS
77
78 =over 4
79
80 =item new HASHREF
81
82 Creates a new service.  To add the refund to the database, see L<"insert">.
83 Services are normally created by creating FS::svc_ objects (see
84 L<FS::svc_acct>, L<FS::svc_domain>, and L<FS::svc_forward>, among others).
85
86 =cut
87
88 sub table { 'cust_svc'; }
89
90 =item insert
91
92 Adds this service to the database.  If there is an error, returns the error,
93 otherwise returns false.
94
95 =item delete
96
97 Deletes this service from the database.  If there is an error, returns the
98 error, otherwise returns false.  Note that this only removes the cust_svc
99 record - you should probably use the B<cancel> method instead.
100
101 =item cancel
102
103 Cancels the relevant service by calling the B<cancel> method of the associated
104 FS::svc_XXX object (i.e. an FS::svc_acct object or FS::svc_domain object),
105 deleting the FS::svc_XXX record and then deleting this record.
106
107 If there is an error, returns the error, otherwise returns false.
108
109 =cut
110
111 sub cancel {
112   my $self = shift;
113
114   local $SIG{HUP} = 'IGNORE';
115   local $SIG{INT} = 'IGNORE';
116   local $SIG{QUIT} = 'IGNORE'; 
117   local $SIG{TERM} = 'IGNORE';
118   local $SIG{TSTP} = 'IGNORE';
119   local $SIG{PIPE} = 'IGNORE';
120
121   my $oldAutoCommit = $FS::UID::AutoCommit;
122   local $FS::UID::AutoCommit = 0;
123   my $dbh = dbh;
124
125   my $part_svc = $self->part_svc;
126
127   $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
128     $dbh->rollback if $oldAutoCommit;
129     return "Illegal svcdb value in part_svc!";
130   };
131   my $svcdb = $1;
132   require "FS/$svcdb.pm";
133
134   my $svc = $self->svc_x;
135   if ($svc) {
136
137     my $error = $svc->cancel;
138     if ( $error ) {
139       $dbh->rollback if $oldAutoCommit;
140       return "Error canceling service: $error";
141     }
142     $error = $svc->delete; #this deletes this cust_svc record as well
143     if ( $error ) {
144       $dbh->rollback if $oldAutoCommit;
145       return "Error deleting service: $error";
146     }
147
148   } else {
149
150     #huh?
151     warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
152          "; deleting cust_svc only\n"; 
153
154     my $error = $self->delete;
155     if ( $error ) {
156       $dbh->rollback if $oldAutoCommit;
157       return "Error deleting cust_svc: $error";
158     }
159
160   }
161
162   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
163
164   ''; #no errors
165
166 }
167
168 =item overlimit [ ACTION ]
169
170 Retrieves or sets the overlimit date.  If ACTION is absent, return
171 the present value of overlimit.  If ACTION is present, it can
172 have the value 'suspend' or 'unsuspend'.  In the case of 'suspend' overlimit
173 is set to the current time if it is not already set.  The 'unsuspend' value
174 causes the time to be cleared.  
175
176 If there is an error on setting, returns the error, otherwise returns false.
177
178 =cut
179
180 sub overlimit {
181   my $self = shift;
182   my $action = shift or return $self->getfield('overlimit');
183
184   local $SIG{HUP} = 'IGNORE';
185   local $SIG{INT} = 'IGNORE';
186   local $SIG{QUIT} = 'IGNORE'; 
187   local $SIG{TERM} = 'IGNORE';
188   local $SIG{TSTP} = 'IGNORE';
189   local $SIG{PIPE} = 'IGNORE';
190
191   my $oldAutoCommit = $FS::UID::AutoCommit;
192   local $FS::UID::AutoCommit = 0;
193   my $dbh = dbh;
194
195   if ( $action eq 'suspend' ) {
196     $self->setfield('overlimit', time) unless $self->getfield('overlimit');
197   }elsif ( $action eq 'unsuspend' ) {
198     $self->setfield('overlimit', '');
199   }else{
200     die "unexpected action value: $action";
201   }
202
203   local $ignore_quantity = 1;
204   my $error = $self->replace;
205   if ( $error ) {
206     $dbh->rollback if $oldAutoCommit;
207     return "Error setting overlimit: $error";
208   }
209
210   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
211
212   ''; #no errors
213
214 }
215
216 =item replace OLD_RECORD
217
218 Replaces the OLD_RECORD with this one in the database.  If there is an error,
219 returns the error, otherwise returns false.
220
221 =cut
222
223 sub replace {
224 #  my $new = shift;
225 #
226 #  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
227 #              ? shift
228 #              : $new->replace_old;
229   my ( $new, $old ) = ( shift, shift );
230   $old = $new->replace_old unless defined($old);
231
232   local $SIG{HUP} = 'IGNORE';
233   local $SIG{INT} = 'IGNORE';
234   local $SIG{QUIT} = 'IGNORE';
235   local $SIG{TERM} = 'IGNORE';
236   local $SIG{TSTP} = 'IGNORE';
237   local $SIG{PIPE} = 'IGNORE';
238
239   my $oldAutoCommit = $FS::UID::AutoCommit;
240   local $FS::UID::AutoCommit = 0;
241   my $dbh = dbh;
242
243   if ( $new->svcpart != $old->svcpart ) {
244     my $svc_x = $new->svc_x;
245     my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
246     local($FS::Record::nowarn_identical) = 1;
247     my $error = $new_svc_x->replace($svc_x);
248     if ( $error ) {
249       $dbh->rollback if $oldAutoCommit;
250       return $error if $error;
251     }
252   }
253
254   #my $error = $new->SUPER::replace($old, @_);
255   my $error = $new->SUPER::replace($old);
256   if ( $error ) {
257     $dbh->rollback if $oldAutoCommit;
258     return $error if $error;
259   }
260
261   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
262   ''; #no error
263
264 }
265
266 =item check
267
268 Checks all fields to make sure this is a valid service.  If there is an error,
269 returns the error, otherwise returns false.  Called by the insert and
270 replace methods.
271
272 =cut
273
274 sub check {
275   my $self = shift;
276
277   my $error =
278     $self->ut_numbern('svcnum')
279     || $self->ut_numbern('pkgnum')
280     || $self->ut_number('svcpart')
281     || $self->ut_numbern('overlimit')
282   ;
283   return $error if $error;
284
285   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
286   return "Unknown svcpart" unless $part_svc;
287
288   if ( $self->pkgnum ) {
289     my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
290     return "Unknown pkgnum" unless $cust_pkg;
291     my $pkg_svc = qsearchs( 'pkg_svc', {
292       'pkgpart' => $cust_pkg->pkgpart,
293       'svcpart' => $self->svcpart,
294     });
295     # or new FS::pkg_svc ( { 'pkgpart'  => $cust_pkg->pkgpart,
296     #                        'svcpart'  => $self->svcpart,
297     #                        'quantity' => 0                   } );
298     my $quantity = $pkg_svc ? $pkg_svc->quantity : 0;
299
300     my @cust_svc = qsearch('cust_svc', {
301       'pkgnum'  => $self->pkgnum,
302       'svcpart' => $self->svcpart,
303     });
304     return "Already ". scalar(@cust_svc). " ". $part_svc->svc.
305            " services for pkgnum ". $self->pkgnum
306       if scalar(@cust_svc) >= $quantity && !$ignore_quantity;
307   }
308
309   $self->SUPER::check;
310 }
311
312 =item part_svc
313
314 Returns the definition for this service, as a FS::part_svc object (see
315 L<FS::part_svc>).
316
317 =cut
318
319 sub part_svc {
320   my $self = shift;
321   $self->{'_svcpart'}
322     ? $self->{'_svcpart'}
323     : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
324 }
325
326 =item cust_pkg
327
328 Returns the package this service belongs to, as a FS::cust_pkg object (see
329 L<FS::cust_pkg>).
330
331 =cut
332
333 sub cust_pkg {
334   my $self = shift;
335   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
336 }
337
338 =item pkg_svc
339
340 Returns the pkg_svc record for for this service, if applicable.
341
342 =cut
343
344 sub pkg_svc {
345   my $self = shift;
346   my $cust_pkg = $self->cust_pkg;
347   return undef unless $cust_pkg;
348
349   qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
350                          'pkgpart' => $cust_pkg->pkgpart,
351                        }
352           );
353 }
354
355 =item date_inserted
356
357 Returns the date this service was inserted.
358
359 =cut
360
361 sub date_inserted {
362   my $self = shift;
363   $self->h_date('insert');
364 }
365
366 =item label
367
368 Returns a list consisting of:
369 - The name of this service (from part_svc)
370 - A meaningful identifier (username, domain, or mail alias)
371 - The table name (i.e. svc_domain) for this service
372 - svcnum
373
374 Usage example:
375
376   my($label, $value, $svcdb) = $cust_svc->label;
377
378 =cut
379
380 sub label {
381   my $self = shift;
382   carp "FS::cust_svc::label called on $self" if $DEBUG;
383   my $svc_x = $self->svc_x
384     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
385
386   $self->_svc_label($svc_x);
387 }
388
389 sub _svc_label {
390   my( $self, $svc_x ) = ( shift, shift );
391
392   (
393     $self->part_svc->svc,
394     $svc_x->label(@_),
395     $self->part_svc->svcdb,
396     $self->svcnum
397   );
398
399 }
400
401 =item export_links
402
403 Returns a list of html elements associated with this services exports.
404
405 =cut
406
407 sub export_links {
408   my $self = shift;
409   my $svc_x = $self->svc_x
410     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
411
412   $svc_x->export_links;
413 }
414
415 =item svc_x
416
417 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
418 FS::svc_domain object, etc.)
419
420 =cut
421
422 sub svc_x {
423   my $self = shift;
424   my $svcdb = $self->part_svc->svcdb;
425   if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
426     $self->{'_svc_acct'};
427   } else {
428     require "FS/$svcdb.pm";
429     warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
430          ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
431       if $DEBUG;
432     qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
433   }
434 }
435
436 =item seconds_since TIMESTAMP
437
438 See L<FS::svc_acct/seconds_since>.  Equivalent to
439 $cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
440 where B<svcdb> is not "svc_acct".
441
442 =cut
443
444 #note: implementation here, POD in FS::svc_acct
445 sub seconds_since {
446   my($self, $since) = @_;
447   my $dbh = dbh;
448   my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
449                               WHERE svcnum = ?
450                                 AND login >= ?
451                                 AND logout IS NOT NULL'
452   ) or die $dbh->errstr;
453   $sth->execute($self->svcnum, $since) or die $sth->errstr;
454   $sth->fetchrow_arrayref->[0];
455 }
456
457 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
458
459 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
460 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
461 for records where B<svcdb> is not "svc_acct".
462
463 =cut
464
465 #note: implementation here, POD in FS::svc_acct
466 sub seconds_since_sqlradacct {
467   my($self, $start, $end) = @_;
468
469   my $mes = "$me seconds_since_sqlradacct:";
470
471   my $svc_x = $self->svc_x;
472
473   my @part_export = $self->part_svc->part_export_usage;
474   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
475       " service definition"
476     unless @part_export;
477     #or return undef;
478
479   my $seconds = 0;
480   foreach my $part_export ( @part_export ) {
481
482     next if $part_export->option('ignore_accounting');
483
484     warn "$mes connecting to sqlradius database\n"
485       if $DEBUG;
486
487     my $dbh = DBI->connect( map { $part_export->option($_) }
488                             qw(datasrc username password)    )
489       or die "can't connect to sqlradius database: ". $DBI::errstr;
490
491     warn "$mes connected to sqlradius database\n"
492       if $DEBUG;
493
494     #select a unix time conversion function based on database type
495     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
496     
497     my $username = $part_export->export_username($svc_x);
498
499     my $query;
500
501     warn "$mes finding closed sessions completely within the given range\n"
502       if $DEBUG;
503   
504     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
505                                FROM radacct
506                                WHERE UserName = ?
507                                  AND $str2time AcctStartTime) >= ?
508                                  AND $str2time AcctStopTime ) <  ?
509                                  AND $str2time AcctStopTime ) > 0
510                                  AND AcctStopTime IS NOT NULL"
511     ) or die $dbh->errstr;
512     $sth->execute($username, $start, $end) or die $sth->errstr;
513     my $regular = $sth->fetchrow_arrayref->[0];
514   
515     warn "$mes finding open sessions which start in the range\n"
516       if $DEBUG;
517
518     # count session start->range end
519     $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
520                 FROM radacct
521                 WHERE UserName = ?
522                   AND $str2time AcctStartTime ) >= ?
523                   AND $str2time AcctStartTime ) <  ?
524                   AND ( ? - $str2time AcctStartTime ) ) < 86400
525                   AND (    $str2time AcctStopTime ) = 0
526                                     OR AcctStopTime IS NULL )";
527     $sth = $dbh->prepare($query) or die $dbh->errstr;
528     $sth->execute($end, $username, $start, $end, $end)
529       or die $sth->errstr. " executing query $query";
530     my $start_during = $sth->fetchrow_arrayref->[0];
531   
532     warn "$mes finding closed sessions which start before the range but stop during\n"
533       if $DEBUG;
534
535     #count range start->session end
536     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
537                             FROM radacct
538                             WHERE UserName = ?
539                               AND $str2time AcctStartTime ) < ?
540                               AND $str2time AcctStopTime  ) >= ?
541                               AND $str2time AcctStopTime  ) <  ?
542                               AND $str2time AcctStopTime ) > 0
543                               AND AcctStopTime IS NOT NULL"
544     ) or die $dbh->errstr;
545     $sth->execute($start, $username, $start, $start, $end ) or die $sth->errstr;
546     my $end_during = $sth->fetchrow_arrayref->[0];
547   
548     warn "$mes finding closed sessions which start before the range but stop after\n"
549       if $DEBUG;
550
551     # count range start->range end
552     # don't count open sessions anymore (probably missing stop record)
553     $sth = $dbh->prepare("SELECT COUNT(*)
554                             FROM radacct
555                             WHERE UserName = ?
556                               AND $str2time AcctStartTime ) < ?
557                               AND ( $str2time AcctStopTime ) >= ?
558                                                                   )"
559                               #      OR AcctStopTime =  0
560                               #      OR AcctStopTime IS NULL       )"
561     ) or die $dbh->errstr;
562     $sth->execute($username, $start, $end ) or die $sth->errstr;
563     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
564
565     $seconds += $regular + $end_during + $start_during + $entire_range;
566
567     warn "$mes done finding sessions\n"
568       if $DEBUG;
569
570   }
571
572   $seconds;
573
574 }
575
576 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
577
578 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
579 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
580 for records where B<svcdb> is not "svc_acct".
581
582 =cut
583
584 #note: implementation here, POD in FS::svc_acct
585 #(false laziness w/seconds_since_sqlradacct above)
586 sub attribute_since_sqlradacct {
587   my($self, $start, $end, $attrib) = @_;
588
589   my $mes = "$me attribute_since_sqlradacct:";
590
591   my $svc_x = $self->svc_x;
592
593   my @part_export = $self->part_svc->part_export_usage;
594   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
595       " service definition"
596     unless @part_export;
597     #or return undef;
598
599   my $sum = 0;
600
601   foreach my $part_export ( @part_export ) {
602
603     next if $part_export->option('ignore_accounting');
604
605     warn "$mes connecting to sqlradius database\n"
606       if $DEBUG;
607
608     my $dbh = DBI->connect( map { $part_export->option($_) }
609                             qw(datasrc username password)    )
610       or die "can't connect to sqlradius database: ". $DBI::errstr;
611
612     warn "$mes connected to sqlradius database\n"
613       if $DEBUG;
614
615     #select a unix time conversion function based on database type
616     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
617
618     my $username = $part_export->export_username($svc_x);
619
620     warn "$mes SUMing $attrib sessions\n"
621       if $DEBUG;
622
623     my $sth = $dbh->prepare("SELECT SUM($attrib)
624                                FROM radacct
625                                WHERE UserName = ?
626                                  AND $str2time AcctStopTime ) >= ?
627                                  AND $str2time AcctStopTime ) <  ?
628                                  AND AcctStopTime IS NOT NULL"
629     ) or die $dbh->errstr;
630     $sth->execute($username, $start, $end) or die $sth->errstr;
631
632     my $row = $sth->fetchrow_arrayref;
633     $sum += $row->[0] if defined($row->[0]);
634
635     warn "$mes done SUMing sessions\n"
636       if $DEBUG;
637
638   }
639
640   $sum;
641
642 }
643
644 =item get_session_history TIMESTAMP_START TIMESTAMP_END
645
646 See L<FS::svc_acct/get_session_history>.  Equivalent to
647 $cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
648 records where B<svcdb> is not "svc_acct".
649
650 =cut
651
652 sub get_session_history {
653   my($self, $start, $end, $attrib) = @_;
654
655   #$attrib ???
656
657   my @part_export = $self->part_svc->part_export_usage;
658   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
659       " service definition"
660     unless @part_export;
661     #or return undef;
662                      
663   my @sessions = ();
664
665   foreach my $part_export ( @part_export ) {
666     push @sessions,
667       @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
668   }
669
670   @sessions;
671
672 }
673
674 =item get_cdrs_for_update
675
676 Returns (and SELECTs "FOR UPDATE") all unprocessed (freesidestatus NULL) CDR
677 objects (see L<FS::cdr>) associated with this service.
678
679 CDRs are associated with svc_phone services via svc_phone.phonenum
680
681 =cut
682
683 sub get_cdrs_for_update {
684   my($self, %options) = @_;
685
686   my @fields = ( 'charged_party' );
687   push @fields, 'src' unless $options{'disable_src'};
688
689   #CDRs are now associated with svc_phone services via svc_phone.phonenum
690   #return () unless $self->svc_x->isa('FS::svc_phone');
691   return () unless $self->part_svc->svcdb eq 'svc_phone';
692   my $number = $self->svc_x->phonenum;
693
694   my $prefix = $options{'default_prefix'};
695
696   my @where =  map " $_ = '$number'        ", @fields;
697   push @where, map " $_ = '$prefix$number' ", @fields
698     if length($prefix);
699   if ( $prefix =~ /^\+(\d+)$/ ) {
700     push @where, map " $_ = '$1$number' ", @fields
701   }
702
703   my $extra_sql = ' AND ( '. join(' OR ', @where ). ' ) ';
704
705   my @cdrs =
706     qsearch( {
707       'table'      => 'cdr',
708       'hashref'    => { 'freesidestatus' => '', },
709       'extra_sql'  => "$extra_sql FOR UPDATE",
710     } );
711
712   @cdrs;
713 }
714
715 =back
716
717 =head1 BUGS
718
719 Behaviour of changing the svcpart of cust_svc records is undefined and should
720 possibly be prohibited, and pkg_svc records are not checked.
721
722 pkg_svc records are not checked in general (here).
723
724 Deleting this record doesn't check or delete the svc_* record associated
725 with this record.
726
727 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
728 a DBI database handle is not yet implemented.
729
730 =head1 SEE ALSO
731
732 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
733 schema.html from the base documentation
734
735 =cut
736
737 1;
738