4fcd9afbd0a0eb2c20b9828d1efaeb19e34da6e5
[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 #internal session db deprecated (or at least on hold)
472 sub seconds_since { 'internal session db deprecated'; };
473 ##note: implementation here, POD in FS::svc_acct
474 #sub seconds_since {
475 #  my($self, $since) = @_;
476 #  my $dbh = dbh;
477 #  my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
478 #                              WHERE svcnum = ?
479 #                                AND login >= ?
480 #                                AND logout IS NOT NULL'
481 #  ) or die $dbh->errstr;
482 #  $sth->execute($self->svcnum, $since) or die $sth->errstr;
483 #  $sth->fetchrow_arrayref->[0];
484 #}
485
486 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
487
488 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
489 $cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
490 for records where B<svcdb> is not "svc_acct".
491
492 =cut
493
494 #note: implementation here, POD in FS::svc_acct
495 sub seconds_since_sqlradacct {
496   my($self, $start, $end) = @_;
497
498   my $mes = "$me seconds_since_sqlradacct:";
499
500   my $svc_x = $self->svc_x;
501
502   my @part_export = $self->part_svc->part_export_usage;
503   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
504       " service definition"
505     unless @part_export;
506     #or return undef;
507
508   my $seconds = 0;
509   foreach my $part_export ( @part_export ) {
510
511     next if $part_export->option('ignore_accounting');
512
513     warn "$mes connecting to sqlradius database\n"
514       if $DEBUG;
515
516     my $dbh = DBI->connect( map { $part_export->option($_) }
517                             qw(datasrc username password)    )
518       or die "can't connect to sqlradius database: ". $DBI::errstr;
519
520     warn "$mes connected to sqlradius database\n"
521       if $DEBUG;
522
523     #select a unix time conversion function based on database type
524     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
525     
526     my $username = $part_export->export_username($svc_x);
527
528     my $query;
529
530     warn "$mes finding closed sessions completely within the given range\n"
531       if $DEBUG;
532   
533     my $realm = '';
534     my $realmparam = '';
535     if ($part_export->option('process_single_realm')) {
536       $realm = 'AND Realm = ?';
537       $realmparam = $part_export->option('realm');
538     }
539
540     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
541                                FROM radacct
542                                WHERE UserName = ?
543                                  $realm
544                                  AND $str2time AcctStartTime) >= ?
545                                  AND $str2time AcctStopTime ) <  ?
546                                  AND $str2time AcctStopTime ) > 0
547                                  AND AcctStopTime IS NOT NULL"
548     ) or die $dbh->errstr;
549     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
550       or die $sth->errstr;
551     my $regular = $sth->fetchrow_arrayref->[0];
552   
553     warn "$mes finding open sessions which start in the range\n"
554       if $DEBUG;
555
556     # count session start->range end
557     $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
558                 FROM radacct
559                 WHERE UserName = ?
560                   $realm
561                   AND $str2time AcctStartTime ) >= ?
562                   AND $str2time AcctStartTime ) <  ?
563                   AND ( ? - $str2time AcctStartTime ) ) < 86400
564                   AND (    $str2time AcctStopTime ) = 0
565                                     OR AcctStopTime IS NULL )";
566     $sth = $dbh->prepare($query) or die $dbh->errstr;
567     $sth->execute( $end,
568                    $username,
569                    ($realm ? $realmparam : ()),
570                    $start,
571                    $end,
572                    $end )
573       or die $sth->errstr. " executing query $query";
574     my $start_during = $sth->fetchrow_arrayref->[0];
575   
576     warn "$mes finding closed sessions which start before the range but stop during\n"
577       if $DEBUG;
578
579     #count range start->session end
580     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
581                             FROM radacct
582                             WHERE UserName = ?
583                               $realm
584                               AND $str2time AcctStartTime ) < ?
585                               AND $str2time AcctStopTime  ) >= ?
586                               AND $str2time AcctStopTime  ) <  ?
587                               AND $str2time AcctStopTime ) > 0
588                               AND AcctStopTime IS NOT NULL"
589     ) or die $dbh->errstr;
590     $sth->execute( $start,
591                    $username,
592                    ($realm ? $realmparam : ()),
593                    $start,
594                    $start,
595                    $end )
596       or die $sth->errstr;
597     my $end_during = $sth->fetchrow_arrayref->[0];
598   
599     warn "$mes finding closed sessions which start before the range but stop after\n"
600       if $DEBUG;
601
602     # count range start->range end
603     # don't count open sessions anymore (probably missing stop record)
604     $sth = $dbh->prepare("SELECT COUNT(*)
605                             FROM radacct
606                             WHERE UserName = ?
607                               $realm
608                               AND $str2time AcctStartTime ) < ?
609                               AND ( $str2time AcctStopTime ) >= ?
610                                                                   )"
611                               #      OR AcctStopTime =  0
612                               #      OR AcctStopTime IS NULL       )"
613     ) or die $dbh->errstr;
614     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
615       or die $sth->errstr;
616     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
617
618     $seconds += $regular + $end_during + $start_during + $entire_range;
619
620     warn "$mes done finding sessions\n"
621       if $DEBUG;
622
623   }
624
625   $seconds;
626
627 }
628
629 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
630
631 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
632 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
633 for records where B<svcdb> is not "svc_acct".
634
635 =cut
636
637 #note: implementation here, POD in FS::svc_acct
638 #(false laziness w/seconds_since_sqlradacct above)
639 sub attribute_since_sqlradacct {
640   my($self, $start, $end, $attrib) = @_;
641
642   my $mes = "$me attribute_since_sqlradacct:";
643
644   my $svc_x = $self->svc_x;
645
646   my @part_export = $self->part_svc->part_export_usage;
647   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
648       " service definition"
649     unless @part_export;
650     #or return undef;
651
652   my $sum = 0;
653
654   foreach my $part_export ( @part_export ) {
655
656     next if $part_export->option('ignore_accounting');
657
658     warn "$mes connecting to sqlradius database\n"
659       if $DEBUG;
660
661     my $dbh = DBI->connect( map { $part_export->option($_) }
662                             qw(datasrc username password)    )
663       or die "can't connect to sqlradius database: ". $DBI::errstr;
664
665     warn "$mes connected to sqlradius database\n"
666       if $DEBUG;
667
668     #select a unix time conversion function based on database type
669     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
670
671     my $username = $part_export->export_username($svc_x);
672
673     warn "$mes SUMing $attrib sessions\n"
674       if $DEBUG;
675
676     my $realm = '';
677     my $realmparam = '';
678     if ($part_export->option('process_single_realm')) {
679       $realm = 'AND Realm = ?';
680       $realmparam = $part_export->option('realm');
681     }
682
683     my $sth = $dbh->prepare("SELECT SUM($attrib)
684                                FROM radacct
685                                WHERE UserName = ?
686                                  $realm
687                                  AND $str2time AcctStopTime ) >= ?
688                                  AND $str2time AcctStopTime ) <  ?
689                                  AND AcctStopTime IS NOT NULL"
690     ) or die $dbh->errstr;
691     $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
692       or die $sth->errstr;
693
694     my $row = $sth->fetchrow_arrayref;
695     $sum += $row->[0] if defined($row->[0]);
696
697     warn "$mes done SUMing sessions\n"
698       if $DEBUG;
699
700   }
701
702   $sum;
703
704 }
705
706 =item get_session_history TIMESTAMP_START TIMESTAMP_END
707
708 See L<FS::svc_acct/get_session_history>.  Equivalent to
709 $cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
710 records where B<svcdb> is not "svc_acct".
711
712 =cut
713
714 sub get_session_history {
715   my($self, $start, $end, $attrib) = @_;
716
717   #$attrib ???
718
719   my @part_export = $self->part_svc->part_export_usage;
720   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
721       " service definition"
722     unless @part_export;
723     #or return undef;
724                      
725   my @sessions = ();
726
727   foreach my $part_export ( @part_export ) {
728     push @sessions,
729       @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
730   }
731
732   @sessions;
733
734 }
735
736 =back
737
738 =head1 BUGS
739
740 Behaviour of changing the svcpart of cust_svc records is undefined and should
741 possibly be prohibited, and pkg_svc records are not checked.
742
743 pkg_svc records are not checked in general (here).
744
745 Deleting this record doesn't check or delete the svc_* record associated
746 with this record.
747
748 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
749 a DBI database handle is not yet implemented.
750
751 =head1 SEE ALSO
752
753 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
754 schema.html from the base documentation
755
756 =cut
757
758 1;
759