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