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