radacct update: use sqlradius for datasrc, not plandata options (whew)
[freeside.git] / FS / FS / cust_svc.pm
1 package FS::cust_svc;
2
3 use strict;
4 use vars qw( @ISA );
5 use Carp qw( cluck );
6 use FS::Record qw( qsearch qsearchs dbh );
7 use FS::cust_pkg;
8 use FS::part_pkg;
9 use FS::part_svc;
10 use FS::pkg_svc;
11 use FS::svc_acct;
12 use FS::svc_domain;
13 use FS::svc_forward;
14 use FS::domain_record;
15 use FS::part_export;
16
17 @ISA = qw( FS::Record );
18
19 sub _cache {
20   my $self = shift;
21   my ( $hashref, $cache ) = @_;
22   if ( $hashref->{'username'} ) {
23     $self->{'_svc_acct'} = FS::svc_acct->new($hashref, '');
24   }
25   if ( $hashref->{'svc'} ) {
26     $self->{'_svcpart'} = FS::part_svc->new($hashref);
27   }
28 }
29
30 =head1 NAME
31
32 FS::cust_svc - Object method for cust_svc objects
33
34 =head1 SYNOPSIS
35
36   use FS::cust_svc;
37
38   $record = new FS::cust_svc \%hash
39   $record = new FS::cust_svc { 'column' => 'value' };
40
41   $error = $record->insert;
42
43   $error = $new_record->replace($old_record);
44
45   $error = $record->delete;
46
47   $error = $record->check;
48
49   ($label, $value) = $record->label;
50
51 =head1 DESCRIPTION
52
53 An FS::cust_svc represents a service.  FS::cust_svc inherits from FS::Record.
54 The following fields are currently supported:
55
56 =over 4
57
58 =item svcnum - primary key (assigned automatically for new services)
59
60 =item pkgnum - Package (see L<FS::cust_pkg>)
61
62 =item svcpart - Service definition (see L<FS::part_svc>)
63
64 =back
65
66 =head1 METHODS
67
68 =over 4
69
70 =item new HASHREF
71
72 Creates a new service.  To add the refund to the database, see L<"insert">.
73 Services are normally created by creating FS::svc_ objects (see
74 L<FS::svc_acct>, L<FS::svc_domain>, and L<FS::svc_forward>, among others).
75
76 =cut
77
78 sub table { 'cust_svc'; }
79
80 =item insert
81
82 Adds this service to the database.  If there is an error, returns the error,
83 otherwise returns false.
84
85 =item delete
86
87 Deletes this service from the database.  If there is an error, returns the
88 error, otherwise returns false.  Note that this only removes the cust_svc
89 record - you should probably use the B<cancel> method instead.
90
91 =item cancel
92
93 Cancels the relevant service by calling the B<cancel> method of the associated
94 FS::svc_XXX object (i.e. an FS::svc_acct object or FS::svc_domain object),
95 deleting the FS::svc_XXX record and then deleting this record.
96
97 If there is an error, returns the error, otherwise returns false.
98
99 =cut
100
101 sub cancel {
102   my $self = shift;
103
104   local $SIG{HUP} = 'IGNORE';
105   local $SIG{INT} = 'IGNORE';
106   local $SIG{QUIT} = 'IGNORE'; 
107   local $SIG{TERM} = 'IGNORE';
108   local $SIG{TSTP} = 'IGNORE';
109   local $SIG{PIPE} = 'IGNORE';
110
111   my $oldAutoCommit = $FS::UID::AutoCommit;
112   local $FS::UID::AutoCommit = 0;
113   my $dbh = dbh;
114
115   my $part_svc = $self->part_svc;
116
117   $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
118     $dbh->rollback if $oldAutoCommit;
119     return "Illegal svcdb value in part_svc!";
120   };
121   my $svcdb = $1;
122   require "FS/$svcdb.pm";
123
124   my $svc = $self->svc_x;
125   if ($svc) {
126     my $error = $svc->cancel;
127     if ( $error ) {
128       $dbh->rollback if $oldAutoCommit;
129       return "Error canceling service: $error";
130     }
131     $error = $svc->delete;
132     if ( $error ) {
133       $dbh->rollback if $oldAutoCommit;
134       return "Error deleting service: $error";
135     }
136   }
137
138   my $error = $self->delete;
139   if ( $error ) {
140     $dbh->rollback if $oldAutoCommit;
141     return "Error deleting cust_svc: $error";
142   }
143
144   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
145
146   ''; #no errors
147
148 }
149
150 =item replace OLD_RECORD
151
152 Replaces the OLD_RECORD with this one in the database.  If there is an error,
153 returns the error, otherwise returns false.
154
155 =cut
156
157 sub replace {
158   my ( $new, $old ) = ( shift, shift );
159
160   local $SIG{HUP} = 'IGNORE';
161   local $SIG{INT} = 'IGNORE';
162   local $SIG{QUIT} = 'IGNORE';
163   local $SIG{TERM} = 'IGNORE';
164   local $SIG{TSTP} = 'IGNORE';
165   local $SIG{PIPE} = 'IGNORE';
166
167   my $oldAutoCommit = $FS::UID::AutoCommit;
168   local $FS::UID::AutoCommit = 0;
169   my $dbh = dbh;
170
171   my $error = $new->SUPER::replace($old);
172   if ( $error ) {
173     $dbh->rollback if $oldAutoCommit;
174     return $error if $error;
175   }
176
177   if ( $new->svcpart != $old->svcpart ) {
178     my $svc_x = $new->svc_x;
179     my $new_svc_x = ref($svc_x)->new({$svc_x->hash});
180     my $error = $new_svc_x->replace($svc_x);
181     if ( $error ) {
182       $dbh->rollback if $oldAutoCommit;
183       return $error if $error;
184     }
185   }
186
187   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
188   ''; #no error
189
190 }
191
192 =item check
193
194 Checks all fields to make sure this is a valid service.  If there is an error,
195 returns the error, otehrwise returns false.  Called by the insert and
196 replace methods.
197
198 =cut
199
200 sub check {
201   my $self = shift;
202
203   my $error =
204     $self->ut_numbern('svcnum')
205     || $self->ut_numbern('pkgnum')
206     || $self->ut_number('svcpart')
207   ;
208   return $error if $error;
209
210   my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
211   return "Unknown svcpart" unless $part_svc;
212
213   if ( $self->pkgnum ) {
214     my $cust_pkg = qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
215     return "Unknown pkgnum" unless $cust_pkg;
216     my $pkg_svc = qsearchs( 'pkg_svc', {
217       'pkgpart' => $cust_pkg->pkgpart,
218       'svcpart' => $self->svcpart,
219     });
220     # or new FS::pkg_svc ( { 'pkgpart'  => $cust_pkg->pkgpart,
221     #                        'svcpart'  => $self->svcpart,
222     #                        'quantity' => 0                   } );
223
224     my @cust_svc = qsearch('cust_svc', {
225       'pkgnum'  => $self->pkgnum,
226       'svcpart' => $self->svcpart,
227     });
228     return "Already ". scalar(@cust_svc). " ". $part_svc->svc.
229            " services for pkgnum ". $self->pkgnum
230       if scalar(@cust_svc) >= $pkg_svc->quantity;
231   }
232
233   ''; #no error
234 }
235
236 =item part_svc
237
238 Returns the definition for this service, as a FS::part_svc object (see
239 L<FS::part_svc>).
240
241 =cut
242
243 sub part_svc {
244   my $self = shift;
245   $self->{'_svcpart'}
246     ? $self->{'_svcpart'}
247     : qsearchs( 'part_svc', { 'svcpart' => $self->svcpart } );
248 }
249
250 =item cust_pkg
251
252 Returns the definition for this service, as a FS::part_svc object (see
253 L<FS::part_svc>).
254
255 =cut
256
257 sub cust_pkg {
258   my $self = shift;
259   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
260 }
261
262 =item label
263
264 Returns a list consisting of:
265 - The name of this service (from part_svc)
266 - A meaningful identifier (username, domain, or mail alias)
267 - The table name (i.e. svc_domain) for this service
268
269 =cut
270
271 sub label {
272   my $self = shift;
273   my $svcdb = $self->part_svc->svcdb;
274   my $svc_x = $self->svc_x
275     or die "can't find $svcdb.svcnum ". $self->svcnum;
276   my $tag;
277   if ( $svcdb eq 'svc_acct' ) {
278     $tag = $svc_x->email;
279   } elsif ( $svcdb eq 'svc_forward' ) {
280     my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $svc_x->srcsvc } );
281     $tag = $svc_acct->email. '->';
282     if ( $svc_x->dstsvc ) {
283       $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $svc_x->dstsvc } );
284       $tag .= $svc_acct->email;
285     } else {
286       $tag .= $svc_x->dst;
287     }
288   } elsif ( $svcdb eq 'svc_domain' ) {
289     $tag = $svc_x->getfield('domain');
290   } elsif ( $svcdb eq 'svc_www' ) {
291     my $domain = qsearchs( 'domain_record', { 'recnum' => $svc_x->recnum } );
292     $tag = $domain->reczone;
293   } elsif ( $svcdb eq 'svc_broadband' ) {
294     $tag = $svc_x->ip_addr . '/' . $svc_x->ip_netmask;
295   } else {
296     cluck "warning: asked for label of unsupported svcdb; using svcnum";
297     $tag = $svc_x->getfield('svcnum');
298   }
299   $self->part_svc->svc, $tag, $svcdb;
300 }
301
302 =item svc_x
303
304 Returns the FS::svc_XXX object for this service (i.e. an FS::svc_acct object or
305 FS::svc_domain object, etc.)
306
307 =cut
308
309 sub svc_x {
310   my $self = shift;
311   my $svcdb = $self->part_svc->svcdb;
312   if ( $svcdb eq 'svc_acct' && $self->{'_svc_acct'} ) {
313     $self->{'_svc_acct'};
314   } else {
315     qsearchs( $svcdb, { 'svcnum' => $self->svcnum } );
316   }
317 }
318
319 =item seconds_since TIMESTAMP
320
321 See L<FS::svc_acct/seconds_since>.  Equivalent to
322 $cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
323 where B<svcdb> is not "svc_acct".
324
325 =cut
326
327 #note: implementation here, POD in FS::svc_acct
328 sub seconds_since {
329   my($self, $since) = @_;
330   my $dbh = dbh;
331   my $sth = $dbh->prepare(' SELECT SUM(logout-login) FROM session
332                               WHERE svcnum = ?
333                                 AND login >= ?
334                                 AND logout IS NOT NULL'
335   ) or die $dbh->errstr;
336   $sth->execute($self->svcnum, $since) or die $sth->errstr;
337   $sth->fetchrow_arrayref->[0];
338 }
339
340 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END 
341
342 See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
343 $cust_svc->svc_x->seconds_since, but more efficient.  Meaningless for records
344 where B<svcdb> is not "svc_acct".
345
346 =cut
347
348 #note: implementation here, POD in FS::svc_acct
349 sub seconds_since_sqlradacct {
350   my($self, $start, $end) = @_;
351
352   my $username = $self->svc_x->username;
353
354   my @part_export = $self->part_svc->part_export('sqlradius')
355     or die "no sqlradius export configured for this service type";
356     #or return undef;
357
358   my $seconds = 0;
359   foreach my $part_export ( @part_export ) {
360
361     my $dbh = DBI->connect( map { $part_export->option($_) }
362                             qw(datasrc username password)    )
363       or die "can't connect to sqlradius database: ". $DBI::errstr;
364   
365     #select a unix time conversion function based on database type
366     my $str2time;
367     if ( $dbh->{Driver}->{Name} eq 'mysql' ) {
368       $str2time = 'UNIX_TIMESTAMP(';
369     } elsif ( $dbh->{Driver}->{Name} eq 'Pg' ) {
370       $str2time = 'EXTRACT( EPOCH FROM ';
371     } else {
372       warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
373            "; guessing how to convert to UNIX timestamps";
374       $str2time = 'extract(epoch from ';
375     }
376   
377     #find closed sessions completely within the given range
378     my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
379                                FROM radacct
380                                WHERE UserName = ?
381                                  AND $str2time AcctStartTime) >= ?
382                                  AND $str2time AcctStopTime ) <  ?
383                                  AND AcctStopTime =! 0
384                                  AND AcctStopTime IS NOT NULL"
385     ) or die $dbh->errstr;
386     $sth->execute($username, $start, $end) or die $sth->errstr;
387     my $regular = $sth->fetchrow_arrayref->[0];
388   
389     #find open sessions which start in the range, count session start->range end
390     # don't count them if they are over 1 day old (probably missing stop record)
391     $sth = $dbh->prepare("SELECT SUM( ? - $str2time AcctStartTime ) )
392                             FROM radacct
393                             WHERE UserName = ?
394                               AND $str2time AcctStartTime ) >= ?
395                               AND ( ? - $str2time AcctStartTime ) < 86400
396                               AND (    AcctStopTime = 0
397                                     OR AcctStopTime IS NULL )"
398     ) or die $dbh->errstr;
399     $sth->execute($end, $username, $start, $end) or die $sth->errstr;
400     my $start_during = $sth->fetchrow_arrayref->[0];
401   
402     #find closed sessions which start before the range but stop during,
403     #count range start->session end
404     $sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? ) 
405                             FROM radacct
406                             WHERE UserName = ?
407                               AND $str2time AcctStartTime ) < ?
408                               AND $str2time AcctStopTime  ) >= ?
409                               AND $str2time AcctStopTime  ) <  ?
410                               AND AcctStopTime != 0
411                               AND AcctStopTime IS NOT NULL"
412     ) or die $dbh->errstr;
413     $sth->execute($start, $username, $start, $start, $end ) or die $sth->errstr;
414     my $end_during = $sth->fetchrow_arrayref->[0];
415   
416     #find closed (not anymore - or open) sessions which start before the range
417     # but stop # after, or are still open, count range start->range end
418     # don't count open sessions (probably missing stop record)
419     $sth = $dbh->prepare("SELECT COUNT(*)
420                             FROM radacct
421                             WHERE UserName = ?
422                               AND $str2time AcctStartTime ) < ?
423                               AND ( $str2time AcctStopTime ) >= ?
424                                                             )"
425                               #      OR AcctStopTime =  0
426                               #      OR AcctStopTime IS NULL )"
427     ) or die $dbh->errstr;
428     $sth->execute($username, $start, $end ) or die $sth->errstr;
429     my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
430   
431     $seconds += $regular + $end_during + $start_during + $entire_range;
432
433   }
434
435   $seconds;
436
437 }
438
439 =back
440
441 =head1 VERSION
442
443 $Id: cust_svc.pm,v 1.19 2002-10-17 14:16:17 ivan Exp $
444
445 =head1 BUGS
446
447 Behaviour of changing the svcpart of cust_svc records is undefined and should
448 possibly be prohibited, and pkg_svc records are not checked.
449
450 pkg_svc records are not checked in general (here).
451
452 Deleting this record doesn't check or delete the svc_* record associated
453 with this record.
454
455 In seconds_since_sqlradacct, specifying a DATASRC/USERNAME/PASSWORD instead of
456 a DBI database handle is not yet implemented.
457
458 =head1 SEE ALSO
459
460 L<FS::Record>, L<FS::cust_pkg>, L<FS::part_svc>, L<FS::pkg_svc>, 
461 schema.html from the base documentation
462
463 =cut
464
465 1;
466