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