the master control program has chosen YOU to serve your system on the game grid
[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 $svc_x = $self->svc_x;
470
471   my @part_export = $self->part_svc->part_export_usage;
472   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
473       " service definition"
474     unless @part_export;
475     #or return undef;
476
477   my $seconds = 0;
478   foreach my $part_export ( @part_export ) {
479
480     next if $part_export->option('ignore_accounting');
481
482     my $dbh = DBI->connect( map { $part_export->option($_) }
483                             qw(datasrc username password)    )
484       or die "can't connect to sqlradius database: ". $DBI::errstr;
485
486     #select a unix time conversion function based on database type
487     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
488     
489     my $username = $part_export->export_username($svc_x);
490
491     my $query;
492   
493     #find closed sessions completely within the given range
494     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
495                                FROM radacct
496                                WHERE UserName = ?
497                                  AND $str2time AcctStartTime) >= ?
498                                  AND $str2time AcctStopTime ) <  ?
499                                  AND $str2time AcctStopTime ) > 0
500                                  AND AcctStopTime IS NOT NULL"
501     ) or die $dbh->errstr;
502     $sth->execute($username, $start, $end) or die $sth->errstr;
503     my $regular = $sth->fetchrow_arrayref->[0];
504   
505     #find open sessions which start in the range, count session start->range end
506     $query = "SELECT SUM( ? - $str2time AcctStartTime ) )
507                 FROM radacct
508                 WHERE UserName = ?
509                   AND $str2time AcctStartTime ) >= ?
510                   AND $str2time AcctStartTime ) <  ?
511                   AND ( ? - $str2time AcctStartTime ) ) < 86400
512                   AND (    $str2time AcctStopTime ) = 0
513                                     OR AcctStopTime IS NULL )";
514     $sth = $dbh->prepare($query) or die $dbh->errstr;
515     $sth->execute($end, $username, $start, $end, $end)
516       or die $sth->errstr. " executing query $query";
517     my $start_during = $sth->fetchrow_arrayref->[0];
518   
519     #find closed sessions which start before the range but stop during,
520     #count range start->session end
521     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
522                             FROM radacct
523                             WHERE UserName = ?
524                               AND $str2time AcctStartTime ) < ?
525                               AND $str2time AcctStopTime  ) >= ?
526                               AND $str2time AcctStopTime  ) <  ?
527                               AND $str2time AcctStopTime ) > 0
528                               AND AcctStopTime IS NOT NULL"
529     ) or die $dbh->errstr;
530     $sth->execute($start, $username, $start, $start, $end ) or die $sth->errstr;
531     my $end_during = $sth->fetchrow_arrayref->[0];
532   
533     #find closed (not anymore - or open) sessions which start before the range
534     # but stop after, or are still open, count range start->range end
535     # don't count open sessions (probably missing stop record)
536     $sth = $dbh->prepare("SELECT COUNT(*)
537                             FROM radacct
538                             WHERE UserName = ?
539                               AND $str2time AcctStartTime ) < ?
540                               AND ( $str2time AcctStopTime ) >= ?
541                                                                   )"
542                               #      OR AcctStopTime =  0
543                               #      OR AcctStopTime IS NULL       )"
544     ) or die $dbh->errstr;
545     $sth->execute($username, $start, $end ) or die $sth->errstr;
546     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
547
548     $seconds += $regular + $end_during + $start_during + $entire_range;
549
550   }
551
552   $seconds;
553
554 }
555
556 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
557
558 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
559 $cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
560 for records where B<svcdb> is not "svc_acct".
561
562 =cut
563
564 #note: implementation here, POD in FS::svc_acct
565 #(false laziness w/seconds_since_sqlradacct above)
566 sub attribute_since_sqlradacct {
567   my($self, $start, $end, $attrib) = @_;
568
569   my $svc_x = $self->svc_x;
570
571   my @part_export = $self->part_svc->part_export_usage;
572   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
573       " service definition"
574     unless @part_export;
575     #or return undef;
576
577   my $sum = 0;
578
579   foreach my $part_export ( @part_export ) {
580
581     next if $part_export->option('ignore_accounting');
582
583     my $dbh = DBI->connect( map { $part_export->option($_) }
584                             qw(datasrc username password)    )
585       or die "can't connect to sqlradius database: ". $DBI::errstr;
586
587     #select a unix time conversion function based on database type
588     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
589
590     my $username = $part_export->export_username($svc_x);
591
592     my $sth = $dbh->prepare("SELECT SUM($attrib)
593                                FROM radacct
594                                WHERE UserName = ?
595                                  AND $str2time AcctStopTime ) >= ?
596                                  AND $str2time AcctStopTime ) <  ?
597                                  AND AcctStopTime IS NOT NULL"
598     ) or die $dbh->errstr;
599     $sth->execute($username, $start, $end) or die $sth->errstr;
600
601     $sum += $sth->fetchrow_arrayref->[0];
602
603   }
604
605   $sum;
606
607 }
608
609 =item get_session_history TIMESTAMP_START TIMESTAMP_END
610
611 See L<FS::svc_acct/get_session_history>.  Equivalent to
612 $cust_svc->svc_x->get_session_history, but more efficient.  Meaningless for
613 records where B<svcdb> is not "svc_acct".
614
615 =cut
616
617 sub get_session_history {
618   my($self, $start, $end, $attrib) = @_;
619
620   #$attrib ???
621
622   my @part_export = $self->part_svc->part_export_usage;
623   die "no accounting-capable exports are enabled for ". $self->part_svc->svc.
624       " service definition"
625     unless @part_export;
626     #or return undef;
627                      
628   my @sessions = ();
629
630   foreach my $part_export ( @part_export ) {
631     push @sessions,
632       @{ $part_export->usage_sessions( $start, $end, $self->svc_x ) };
633   }
634
635   @sessions;
636
637 }
638
639 =item get_cdrs_for_update
640
641 Returns (and SELECTs "FOR UPDATE") all unprocessed (freesidestatus NULL) CDR
642 objects (see L<FS::cdr>) associated with this service.
643
644 CDRs are associated with svc_phone services via svc_phone.phonenum
645
646 =cut
647
648 sub get_cdrs_for_update {
649   my($self, %options) = @_;
650
651   my @fields = ( 'charged_party' );
652   push @fields, 'src' unless $options{'disable_src'};
653
654   #CDRs are now associated with svc_phone services via svc_phone.phonenum
655   #return () unless $self->svc_x->isa('FS::svc_phone');
656   return () unless $self->part_svc->svcdb eq 'svc_phone';
657   my $number = $self->svc_x->phonenum;
658
659   my $prefix = $options{'default_prefix'};
660
661   my @where =  map " $_ = '$number'        ", @fields;
662   push @where, map " $_ = '$prefix$number' ", @fields
663     if length($prefix);
664   if ( $prefix =~ /^\+(\d+)$/ ) {
665     push @where, map " $_ = '$1$number' ", @fields
666   }
667
668   my $extra_sql = ' AND ( '. join(' OR ', @where ). ' ) ';
669
670   my @cdrs =
671     qsearch( {
672       'table'      => 'cdr',
673       'hashref'    => { 'freesidestatus' => '', },
674       'extra_sql'  => "$extra_sql FOR UPDATE",
675     } );
676
677   @cdrs;
678 }
679
680 =back
681
682 =head1 BUGS
683
684 Behaviour of changing the svcpart of cust_svc records is undefined and should
685 possibly be prohibited, and pkg_svc records are not checked.
686
687 pkg_svc records are not checked in general (here).
688
689 Deleting this record doesn't check or delete the svc_* record associated
690 with this record.
691
692 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
693 a DBI database handle is not yet implemented.
694
695 =head1 SEE ALSO
696
697 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
698 schema.html from the base documentation
699
700 =cut
701
702 1;
703