count available services correctly, RT#10340
[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,%opt) = @_;
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     if ( %opt && $opt{'date'} ) {
137         my $error = $svc->expire($opt{'date'});
138         if ( $error ) {
139           $dbh->rollback if $oldAutoCommit;
140           return "Error expiring service: $error";
141         }
142     } else {
143         my $error = $svc->cancel;
144         if ( $error ) {
145           $dbh->rollback if $oldAutoCommit;
146           return "Error canceling service: $error";
147         }
148         $error = $svc->delete; #this deletes this cust_svc record as well
149         if ( $error ) {
150           $dbh->rollback if $oldAutoCommit;
151           return "Error deleting service: $error";
152         }
153     }
154
155   } elsif ( !%opt ) {
156
157     #huh?
158     warn "WARNING: no svc_ record found for svcnum ". $self->svcnum.
159          "; deleting cust_svc only\n"; 
160
161     my $error = $self->delete;
162     if ( $error ) {
163       $dbh->rollback if $oldAutoCommit;
164       return "Error deleting cust_svc: $error";
165     }
166
167   }
168
169   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
170
171   ''; #no errors
172
173 }
174
175 =item overlimit [ ACTION ]
176
177 Retrieves or sets the overlimit date.  If ACTION is absent, return
178 the present value of overlimit.  If ACTION is present, it can
179 have the value 'suspend' or 'unsuspend'.  In the case of 'suspend' overlimit
180 is set to the current time if it is not already set.  The 'unsuspend' value
181 causes the time to be cleared.  
182
183 If there is an error on setting, returns the error, otherwise returns false.
184
185 =cut
186
187 sub overlimit {
188   my $self = shift;
189   my $action = shift or return $self->getfield('overlimit');
190
191   local $SIG{HUP} = 'IGNORE';
192   local $SIG{INT} = 'IGNORE';
193   local $SIG{QUIT} = 'IGNORE'; 
194   local $SIG{TERM} = 'IGNORE';
195   local $SIG{TSTP} = 'IGNORE';
196   local $SIG{PIPE} = 'IGNORE';
197
198   my $oldAutoCommit = $FS::UID::AutoCommit;
199   local $FS::UID::AutoCommit = 0;
200   my $dbh = dbh;
201
202   if ( $action eq 'suspend' ) {
203     $self->setfield('overlimit', time) unless $self->getfield('overlimit');
204   }elsif ( $action eq 'unsuspend' ) {
205     $self->setfield('overlimit', '');
206   }else{
207     die "unexpected action value: $action";
208   }
209
210   local $ignore_quantity = 1;
211   my $error = $self->replace;
212   if ( $error ) {
213     $dbh->rollback if $oldAutoCommit;
214     return "Error setting overlimit: $error";
215   }
216
217   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
218
219   ''; #no errors
220
221 }
222
223 =item replace OLD_RECORD
224
225 Replaces the OLD_RECORD with this one in the database.  If there is an error,
226 returns the error, otherwise returns false.
227
228 =cut
229
230 sub replace {
231 #  my $new = shift;
232 #
233 #  my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
234 #              ? shift
235 #              : $new->replace_old;
236   my ( $new, $old ) = ( shift, shift );
237   $old = $new->replace_old unless defined($old);
238
239   local $SIG{HUP} = 'IGNORE';
240   local $SIG{INT} = 'IGNORE';
241   local $SIG{QUIT} = 'IGNORE';
242   local $SIG{TERM} = 'IGNORE';
243   local $SIG{TSTP} = 'IGNORE';
244   local $SIG{PIPE} = 'IGNORE';
245
246   my $oldAutoCommit = $FS::UID::AutoCommit;
247   local $FS::UID::AutoCommit = 0;
248   my $dbh = dbh;
249
250   if ( $new->svcpart != $old->svcpart ) {
251     my $svc_x = $new->svc_x;
252     my $new_svc_x = ref($svc_x)->new({$svc_x->hash, svcpart=>$new->svcpart });
253     local($FS::Record::nowarn_identical) = 1;
254     my $error = $new_svc_x->replace($svc_x);
255     if ( $error ) {
256       $dbh->rollback if $oldAutoCommit;
257       return $error if $error;
258     }
259   }
260
261 #  #trigger a re-export on pkgnum changes?
262 #  # (of prepaid packages), for Expiration RADIUS attribute
263 #  if ( $new->pkgnum != $old->pkgnum && $new->cust_pkg->part_pkg->is_prepaid ) {
264 #    my $svc_x = $new->svc_x;
265 #    local($FS::Record::nowarn_identical) = 1;
266 #    my $error = $svc_x->export('replace');
267 #    if ( $error ) {
268 #      $dbh->rollback if $oldAutoCommit;
269 #      return $error if $error;
270 #    }
271 #  }
272
273   #my $error = $new->SUPER::replace($old, @_);
274   my $error = $new->SUPER::replace($old);
275   if ( $error ) {
276     $dbh->rollback if $oldAutoCommit;
277     return $error if $error;
278   }
279
280   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
281   ''; #no error
282
283 }
284
285 =item check
286
287 Checks all fields to make sure this is a valid service.  If there is an error,
288 returns the error, otherwise returns false.  Called by the insert and
289 replace methods.
290
291 =cut
292
293 sub check {
294   my $self = shift;
295
296   my $error =
297     $self->ut_numbern('svcnum')
298     || $self->ut_numbern('pkgnum')
299     || $self->ut_number('svcpart')
300     || $self->ut_numbern('overlimit')
301   ;
302   return $error if $error;
303
304   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
305   return "Unknown svcpart" unless $part_svc;
306
307   if ( $self->pkgnum ) {
308     my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
309     return "Unknown pkgnum" unless $cust_pkg;
310     ($part_svc) = grep { $_->svcpart == $self->svcpart } $cust_pkg->part_svc;
311
312     return "Already ". $part_svc->get('num_cust_svc'). " ". $part_svc->svc.
313            " services for pkgnum ". $self->pkgnum
314       if $part_svc->get('num_avail') == 0 and !$ignore_quantity;
315   }
316
317   $self->SUPER::check;
318 }
319
320 =item part_svc
321
322 Returns the definition for this service, as a FS::part_svc object (see
323 L<FS::part_svc>).
324
325 =cut
326
327 sub part_svc {
328   my $self = shift;
329   $self->{'_svcpart'}
330     ? $self->{'_svcpart'}
331     : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
332 }
333
334 =item cust_pkg
335
336 Returns the package this service belongs to, as a FS::cust_pkg object (see
337 L<FS::cust_pkg>).
338
339 =cut
340
341 sub cust_pkg {
342   my $self = shift;
343   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
344 }
345
346 =item pkg_svc
347
348 Returns the pkg_svc record for for this service, if applicable.
349
350 =cut
351
352 sub pkg_svc {
353   my $self = shift;
354   my $cust_pkg = $self->cust_pkg;
355   return undef unless $cust_pkg;
356
357   qsearchs( 'pkg_svc', { 'svcpart' => $self->svcpart,
358                          'pkgpart' => $cust_pkg->pkgpart,
359                        }
360           );
361 }
362
363 =item date_inserted
364
365 Returns the date this service was inserted.
366
367 =cut
368
369 sub date_inserted {
370   my $self = shift;
371   $self->h_date('insert');
372 }
373
374 =item label
375
376 Returns a list consisting of:
377 - The name of this service (from part_svc)
378 - A meaningful identifier (username, domain, or mail alias)
379 - The table name (i.e. svc_domain) for this service
380 - svcnum
381
382 Usage example:
383
384   my($label, $value, $svcdb) = $cust_svc->label;
385
386 =item label_long
387
388 Like the B<label> method, except the second item in the list ("meaningful
389 identifier") may be longer - typically, a full name is included.
390
391 =cut
392
393 sub label      { shift->_label('svc_label',      @_); }
394 sub label_long { shift->_label('svc_label_long', @_); }
395
396 sub _label {
397   my $self = shift;
398   my $method = shift;
399   my $svc_x = $self->svc_x
400     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
401
402   $self->$method($svc_x);
403 }
404
405 sub svc_label      { shift->_svc_label('label',      @_); }
406 sub svc_label_long { shift->_svc_label('label_long', @_); }
407
408 sub _svc_label {
409   my( $self, $method, $svc_x ) = ( shift, shift, shift );
410
411   (
412     $self->part_svc->svc,
413     $svc_x->$method(@_),
414     $self->part_svc->svcdb,
415     $self->svcnum
416   );
417
418 }
419
420 =item export_links
421
422 Returns a listref of html elements associated with this service's exports.
423
424 =cut
425
426 sub export_links {
427   my $self = shift;
428   my $svc_x = $self->svc_x
429     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
430
431   $svc_x->export_links;
432 }
433
434 =item export_getsettings
435
436 Returns two hashrefs of settings associated with this service's exports.
437
438 =cut
439
440 sub export_getsettings {
441   my $self = shift;
442   my $svc_x = $self->svc_x
443     or return "can't find ". $self->part_svc->svcdb. '.svcnum '. $self->svcnum;
444
445   $svc_x->export_getsettings;
446 }
447
448
449 =item svc_x
450
451 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
452 FS::svc_domain object, etc.)
453
454 =cut
455
456 sub svc_x {
457   my $self = shift;
458   my $svcdb = $self->part_svc->svcdb;
459   if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
460     $self->{'_svc_acct'};
461   } else {
462     require "FS/$svcdb.pm";
463     warn "$me svc_x: part_svc.svcpart ". $self->part_svc->svcpart.
464          ", so searching for $svcdb.svcnum ". $self->svcnum. "\n"
465       if $DEBUG;
466     qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
467   }
468 }
469
470 =item seconds_since TIMESTAMP
471
472 See L<FS::svc_acct/seconds_since>.  Equivalent to
473 $cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
474 where B<svcdb> is not "svc_acct".
475
476 =cut
477
478 #note: implementation here, POD in FS::svc_acct
479 sub seconds_since {
480   my($self, $since) = @_;
481   my $dbh = dbh;
482   my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
483                               WHERE svcnum = ?
484                                 AND login >= ?
485                                 AND logout IS NOT NULL'
486   ) or die $dbh->errstr;
487   $sth->execute($self->svcnum, $since) or die $sth->errstr;
488   $sth->fetchrow_arrayref->[0];
489 }
490
491 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
492
493 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
494 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
495 for records where B<svcdb> is not "svc_acct".
496
497 =cut
498
499 #note: implementation here, POD in FS::svc_acct
500 sub seconds_since_sqlradacct {
501   my($self, $start, $end) = @_;
502
503   my $mes = "$me seconds_since_sqlradacct:";
504
505   my $svc_x = $self->svc_x;
506
507   my @part_export = $self->part_svc->part_export_usage;
508   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
509       " service definition"
510     unless @part_export;
511     #or return undef;
512
513   my $seconds = 0;
514   foreach my $part_export ( @part_export ) {
515
516     next if $part_export->option('ignore_accounting');
517
518     warn "$mes connecting to sqlradius database\n"
519       if $DEBUG;
520
521     my $dbh = DBI->connect( map { $part_export->option($_) }
522                             qw(datasrc username password)    )
523       or die "can't connect to sqlradius database: ". $DBI::errstr;
524
525     warn "$mes connected to sqlradius database\n"
526       if $DEBUG;
527
528     #select a unix time conversion function based on database type
529     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
530     
531     my $username = $part_export->export_username($svc_x);
532
533     my $query;
534
535     warn "$mes finding closed sessions completely within the given range\n"
536       if $DEBUG;
537   
538     my $realm = '';
539     my $realmparam = '';
540     if ($part_export->option('process_single_realm')) {
541       $realm = 'AND Realm = ?';
542       $realmparam = $part_export->option('realm');
543     }
544
545     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
546                                FROM radacct
547                                WHERE UserName = ?
548                                  $realm
549                                  AND $str2time AcctStartTime) >= ?
550                                  AND $str2time AcctStopTime ) <  ?
551                                  AND $str2time AcctStopTime ) > 0
552                                  AND AcctStopTime IS NOT NULL"
553     ) or die $dbh->errstr;
554     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
555       or die $sth->errstr;
556     my $regular = $sth->fetchrow_arrayref->[0];
557   
558     warn "$mes finding open sessions which start in the range\n"
559       if $DEBUG;
560
561     # count session start->range end
562     $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
563                 FROM radacct
564                 WHERE UserName = ?
565                   $realm
566                   AND $str2time AcctStartTime ) >= ?
567                   AND $str2time AcctStartTime ) <  ?
568                   AND ( ? - $str2time AcctStartTime ) ) < 86400
569                   AND (    $str2time AcctStopTime ) = 0
570                                     OR AcctStopTime IS NULL )";
571     $sth = $dbh->prepare($query) or die $dbh->errstr;
572     $sth->execute( $end,
573                    $username,
574                    ($realm ? $realmparam : ()),
575                    $start,
576                    $end,
577                    $end )
578       or die $sth->errstr. " executing query $query";
579     my $start_during = $sth->fetchrow_arrayref->[0];
580   
581     warn "$mes finding closed sessions which start before the range but stop during\n"
582       if $DEBUG;
583
584     #count range start->session end
585     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
586                             FROM radacct
587                             WHERE UserName = ?
588                               $realm
589                               AND $str2time AcctStartTime ) < ?
590                               AND $str2time AcctStopTime  ) >= ?
591                               AND $str2time AcctStopTime  ) <  ?
592                               AND $str2time AcctStopTime ) > 0
593                               AND AcctStopTime IS NOT NULL"
594     ) or die $dbh->errstr;
595     $sth->execute( $start,
596                    $username,
597                    ($realm ? $realmparam : ()),
598                    $start,
599                    $start,
600                    $end )
601       or die $sth->errstr;
602     my $end_during = $sth->fetchrow_arrayref->[0];
603   
604     warn "$mes finding closed sessions which start before the range but stop after\n"
605       if $DEBUG;
606
607     # count range start->range end
608     # don't count open sessions anymore (probably missing stop record)
609     $sth = $dbh->prepare("SELECT COUNT(*)
610                             FROM radacct
611                             WHERE UserName = ?
612                               $realm
613                               AND $str2time AcctStartTime ) < ?
614                               AND ( $str2time AcctStopTime ) >= ?
615                                                                   )"
616                               #      OR AcctStopTime =  0
617                               #      OR AcctStopTime IS NULL       )"
618     ) or die $dbh->errstr;
619     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
620       or die $sth->errstr;
621     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
622
623     $seconds += $regular + $end_during + $start_during + $entire_range;
624
625     warn "$mes done finding sessions\n"
626       if $DEBUG;
627
628   }
629
630   $seconds;
631
632 }
633
634 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
635
636 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
637 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
638 for records where B<svcdb> is not "svc_acct".
639
640 =cut
641
642 #note: implementation here, POD in FS::svc_acct
643 #(false laziness w/seconds_since_sqlradacct above)
644 sub attribute_since_sqlradacct {
645   my($self, $start, $end, $attrib) = @_;
646
647   my $mes = "$me attribute_since_sqlradacct:";
648
649   my $svc_x = $self->svc_x;
650
651   my @part_export = $self->part_svc->part_export_usage;
652   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
653       " service definition"
654     unless @part_export;
655     #or return undef;
656
657   my $sum = 0;
658
659   foreach my $part_export ( @part_export ) {
660
661     next if $part_export->option('ignore_accounting');
662
663     warn "$mes connecting to sqlradius database\n"
664       if $DEBUG;
665
666     my $dbh = DBI->connect( map { $part_export->option($_) }
667                             qw(datasrc username password)    )
668       or die "can't connect to sqlradius database: ". $DBI::errstr;
669
670     warn "$mes connected to sqlradius database\n"
671       if $DEBUG;
672
673     #select a unix time conversion function based on database type
674     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
675
676     my $username = $part_export->export_username($svc_x);
677
678     warn "$mes SUMing $attrib sessions\n"
679       if $DEBUG;
680
681     my $realm = '';
682     my $realmparam = '';
683     if ($part_export->option('process_single_realm')) {
684       $realm = 'AND Realm = ?';
685       $realmparam = $part_export->option('realm');
686     }
687
688     my $sth = $dbh->prepare("SELECT SUM($attrib)
689                                FROM radacct
690                                WHERE UserName = ?
691                                  $realm
692                                  AND $str2time AcctStopTime ) >= ?
693                                  AND $str2time AcctStopTime ) <  ?
694                                  AND AcctStopTime IS NOT NULL"
695     ) or die $dbh->errstr;
696     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
697       or die $sth->errstr;
698
699     my $row = $sth->fetchrow_arrayref;
700     $sum += $row->[0] if defined($row->[0]);
701
702     warn "$mes done SUMing sessions\n"
703       if $DEBUG;
704
705   }
706
707   $sum;
708
709 }
710
711 =item get_session_history TIMESTAMP_START TIMESTAMP_END
712
713 See L<FS::svc_acct/get_session_history>.  Equivalent to
714 $cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
715 records where B<svcdb> is not "svc_acct".
716
717 =cut
718
719 sub get_session_history {
720   my($self, $start, $end, $attrib) = @_;
721
722   #$attrib ???
723
724   my @part_export = $self->part_svc->part_export_usage;
725   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
726       " service definition"
727     unless @part_export;
728     #or return undef;
729                      
730   my @sessions = ();
731
732   foreach my $part_export ( @part_export ) {
733     push @sessions,
734       @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
735   }
736
737   @sessions;
738
739 }
740
741 =back
742
743 =head1 BUGS
744
745 Behaviour of changing the svcpart of cust_svc records is undefined and should
746 possibly be prohibited, and pkg_svc records are not checked.
747
748 pkg_svc records are not checked in general (here).
749
750 Deleting this record doesn't check or delete the svc_* record associated
751 with this record.
752
753 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
754 a DBI database handle is not yet implemented.
755
756 =head1 SEE ALSO
757
758 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
759 schema.html from the base documentation
760
761 =cut
762
763 1;
764