RT# 82942 Replace DBI->connect() with FS::DBI->connect()
[freeside.git] / FS / FS / Upgrade.pm
1 package FS::Upgrade;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Tie::IxHash;
7 use File::Slurp;
8 use FS::UID qw( dbh driver_name );
9 use FS::Conf;
10 use FS::Record qw(qsearchs qsearch str2time_sql);
11 use FS::queue;
12 use FS::upgrade_journal;
13 use FS::Setup qw( enable_banned_pay_pad );
14 use FS::DBI;
15
16 use FS::svc_domain;
17 $FS::svc_domain::whois_hack = 1;
18
19 @ISA = qw( Exporter );
20 @EXPORT_OK = qw( upgrade_schema upgrade_config upgrade upgrade_sqlradius );
21
22 $DEBUG = 1;
23
24 =head1 NAME
25
26 FS::Upgrade - Database upgrade routines
27
28 =head1 SYNOPSIS
29
30   use FS::Upgrade;
31
32 =head1 DESCRIPTION
33
34 Currently this module simply provides a place to store common subroutines for
35 database upgrades.
36
37 =head1 SUBROUTINES
38
39 =over 4
40
41 =item upgrade_config
42
43 =cut
44
45 #config upgrades
46 sub upgrade_config {
47   my %opt = @_;
48
49   my $conf = new FS::Conf;
50
51   # to simplify tokenization upgrades
52   die "Conf selfservice-payment_gateway no longer supported"
53     if $conf->config('selfservice-payment_gateway');
54
55   $conf->touch('payment_receipt')
56     if $conf->exists('payment_receipt_email')
57     || $conf->config('payment_receipt_msgnum');
58
59   $conf->touch('geocode-require_nw_coordinates')
60     if $conf->exists('svc_broadband-require-nw-coordinates');
61
62   unless ( $conf->config('echeck-country') ) {
63     if ( $conf->exists('cust_main-require-bank-branch') ) {
64       $conf->set('echeck-country', 'CA');
65     } elsif ( $conf->exists('echeck-nonus') ) {
66       $conf->set('echeck-country', 'XX');
67     } else {
68       $conf->set('echeck-country', 'US');
69     }
70   }
71
72   my @agents = qsearch('agent', {});
73
74   upgrade_overlimit_groups($conf);
75   map { upgrade_overlimit_groups($conf,$_->agentnum) } @agents;
76
77   upgrade_invoice_from($conf);
78   foreach my $agent (@agents) {
79     upgrade_invoice_from($conf,$agent->agentnum,1);
80   }
81
82   my $DIST_CONF = '/usr/local/etc/freeside/default_conf/';#DIST_CONF in Makefile
83   $conf->set($_, scalar(read_file( "$DIST_CONF/$_" )) )
84     foreach grep { ! $conf->exists($_) && -s "$DIST_CONF/$_" }
85       qw( quotation_html quotation_latex quotation_latexnotes );
86
87   # change 'fslongtable' to 'longtable'
88   # in invoice and quotation main templates, and also in all secondary 
89   # invoice templates
90   my @latex_confs =
91     qsearch('conf', { 'name' => {op=>'LIKE', value=>'%latex%'} });
92
93   foreach my $c (@latex_confs) {
94     my $value = $c->value;
95     if (length($value) and $value =~ /fslongtable/) {
96       $value =~ s/fslongtable/longtable/g;
97       $conf->set($c->name, $value, $c->agentnum);
98     }
99   }
100
101   # if there's a USPS tools login, assume that's the standardization method
102   # you want to use
103   $conf->set('address_standardize_method', 'usps')
104     if $conf->exists('usps_webtools-userid')
105     && length($conf->config('usps_webtools-userid')) > 0
106     && ! $conf->exists('address_standardize_method');
107
108   # this option has been renamed/expanded
109   if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
110     $conf->touch('cust_main-enable_spouse');
111     $conf->delete('cust_main-enable_spouse_birthdate');
112   }
113
114   # renamed/repurposed
115   if ( $conf->exists('cust_pkg-show_fcc_voice_grade_equivalent') ) {
116     $conf->touch('part_pkg-show_fcc_options');
117     $conf->delete('cust_pkg-show_fcc_voice_grade_equivalent');
118     warn "
119 You have FCC Form 477 package options enabled.
120
121 Starting with the October 2014 filing date, the FCC has redesigned 
122 Form 477 and introduced new service categories.  See bin/convert-477-options
123 to update your package configuration for the new report.
124
125 If you need to continue using the old Form 477 report, turn on the
126 'old_fcc_report' configuration option.
127 ";
128   }
129
130   # boolean invoice_sections_by_location option is now
131   # invoice_sections_method = 'location'
132   my @invoice_sections_confs =
133     qsearch('conf', { 'name' => { op=>'LIKE', value=>'%sections_by_location' } });
134   foreach my $c (@invoice_sections_confs) {
135     $c->name =~ /^(\w+)sections_by_location$/;
136     $conf->delete($c->name);
137     my $newname = $1.'sections_method';
138     $conf->set($newname, 'location');
139   }
140
141   # boolean enable_taxproducts is now tax_data_vendor = 'cch'
142   if ( $conf->exists('enable_taxproducts') ) {
143
144     $conf->delete('enable_taxproducts');
145     $conf->set('tax_data_vendor', 'cch');
146
147   }
148
149   # boolean tax-cust_exempt-groups-require_individual_nums is now -num_req all
150   if ( $conf->exists('tax-cust_exempt-groups-require_individual_nums') ) {
151     $conf->set('tax-cust_exempt-groups-num_req', 'all');
152     $conf->delete('tax-cust_exempt-groups-require_individual_nums');
153   }
154
155   # boolean+text previous_balance-exclude_from_total is now two separate options
156   my $total_new_charges = $conf->config('previous_balance-exclude_from_total');
157   if ( defined $total_new_charges && length($total_new_charges) > 0 ) {
158     $conf->set('previous_balance-text-total_new_charges', $total_new_charges);
159     $conf->set('previous_balance-exclude_from_total', '');
160   }
161
162   # switch from specifying an email address to boolean check
163   if ( $conf->exists('batch-errors_to') ) {
164     $conf->touch('batch-errors_not_fatal');
165     $conf->delete('batch-errors_to');
166   }
167
168   if ( $conf->exists('voip-cust_email_csv_cdr') ) {
169     $conf->set('voip_cdr_email_attach', 'csv');
170     $conf->delete('voip-cust_email_csv_cdr') ;
171   }
172
173   if ($conf->exists('unsuspendauto') && !$conf->config('unsuspend_balance')) {
174     $conf->set('unsuspend_balance','Zero');
175     $conf->delete('unsuspendauto');
176   }
177
178   my $cust_fields = $conf->config('cust-fields');
179   if ( defined $cust_fields && $cust_fields =~ / \| Payment Type/ ) {
180     # so we can potentially use 'Payment Types' or somesuch in the future
181     $cust_fields =~ s/ \| Payment Type( \|)/$1/;
182     $cust_fields =~ s/ \| Payment Type$//;
183     $conf->set('cust-fields',$cust_fields);
184   }
185
186   enable_banned_pay_pad() unless length($conf->config('banned_pay-pad'));
187
188   # if translate-auto-insert is enabled for a locale, ensure that invoice
189   # terms are in the msgcat (is there a better place for this?)
190   if (my $auto_locale = $conf->config('translate-auto-insert')) {
191     my $lh = FS::L10N->get_handle($auto_locale);
192     foreach (@FS::Conf::invoice_terms) {
193       $lh->maketext($_) if length($_);
194     }
195   }
196
197   unless ( FS::upgrade_journal->is_done('deprecate_unmask_ss') ) {
198     if ( $conf->config_bool( 'unmask_ss' )) {
199       warn "'unmask_ssn' deprecated from global configuration\n";
200       for my $access_group ( qsearch( access_group => {} )) {
201         $access_group->grant_access_right( 'Unmask customer SSN' );
202         warn " - 'Unmask customer SSN' access right granted to '" .
203              $access_group->groupname . "' employee group\n";
204       }
205     }
206     FS::upgrade_journal->set_done('deprecate_unmask_ss');
207   }
208
209 }
210
211 sub upgrade_overlimit_groups {
212     my $conf = shift;
213     my $agentnum = shift;
214     my @groups = $conf->config('overlimit_groups',$agentnum); 
215     if(scalar(@groups)) {
216         my $groups = join(',',@groups);
217         my @groupnums;
218         my $error = '';
219         if ( $groups !~ /^[\d,]+$/ ) {
220             foreach my $groupname ( @groups ) {
221                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
222                 unless ( $g ) {
223                     $g = new FS::radius_group {
224                                     'groupname' => $groupname,
225                                     'description' => $groupname,
226                                     };
227                     $error = $g->insert;
228                     die $error if $error;
229                 }
230                 push @groupnums, $g->groupnum;
231             }
232             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
233         }
234     }
235 }
236
237 sub upgrade_invoice_from {
238   my ($conf, $agentnum, $agentonly) = @_;
239   if (
240           ! $conf->exists('invoice_from_name',$agentnum,$agentonly)
241        && $conf->exists('invoice_from',$agentnum,$agentonly)
242        && $conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/
243   ) {
244     my $realemail = $1;
245     $realemail =~ s/^\s*//; # remove leading spaces
246     $realemail =~ s/\s*$//; # remove trailing spaces
247     my $realname = $conf->config('invoice_from',$agentnum);
248     $realname =~ s/\<.*\>//; # remove email address
249     $realname =~ s/^\s*//; # remove leading spaces
250     $realname =~ s/\s*$//; # remove trailing spaces
251     # properly quote names that contain punctuation
252     if (($realname =~ /[^[:alnum:][:space:]]/) && ($realname !~ /^\".*\"$/)) {
253       $realname = '"' . $realname . '"';
254     }
255     $conf->set('invoice_from_name', $realname, $agentnum);
256     $conf->set('invoice_from', $realemail, $agentnum);
257   }
258 }
259
260 =item upgrade
261
262 =cut
263
264 sub upgrade {
265   my %opt = @_;
266
267   my $data = upgrade_data(%opt);
268
269   my $oldAutoCommit = $FS::UID::AutoCommit;
270   local $FS::UID::AutoCommit = 0;
271   local $FS::UID::AutoCommit = 0;
272
273   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
274
275
276   foreach my $table ( keys %$data ) {
277
278     my $class = "FS::$table";
279     eval "use $class;";
280     die $@ if $@;
281
282     if ( $class->can('_upgrade_data') ) {
283       warn "Upgrading $table...\n";
284
285       my $start = time;
286
287       $class->_upgrade_data(%opt);
288
289       # New interface for async upgrades: a class can declare a 
290       # "queueable_upgrade" method, which will run as part of the normal 
291       # upgrade, but if the -j option is passed, will instead be run from 
292       # the job queue.
293       if ( $class->can('queueable_upgrade') ) {
294         my $jobname = $class . '::queueable_upgrade';
295         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
296         if ($num_jobs > 0) {
297           warn "$class upgrade already scheduled.\n";
298         } else {
299           if ( $opt{'queue'} ) {
300             warn "Scheduling $class upgrade.\n";
301             my $job = FS::queue->new({ job => $jobname });
302             $job->insert($class, %opt);
303           } else {
304             $class->queueable_upgrade(%opt);
305           }
306         } #$num_jobs == 0
307       }
308
309       if ( $oldAutoCommit ) {
310         warn "  committing\n";
311         dbh->commit or die dbh->errstr;
312       }
313       
314       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
315       warn "  done in ". (time-$start). " seconds\n";
316
317     } else {
318       warn "WARNING: asked for upgrade of $table,".
319            " but FS::$table has no _upgrade_data method\n";
320     }
321
322 #    my @records = @{ $data->{$table} };
323 #
324 #    foreach my $record ( @records ) {
325 #      my $args = delete($record->{'_upgrade_args'}) || [];
326 #      my $object = $class->new( $record );
327 #      my $error = $object->insert( @$args );
328 #      die "error inserting record into $table: $error\n"
329 #        if $error;
330 #    }
331
332   }
333
334   local($FS::cust_main::ignore_expired_card) = 1;
335   #this is long-gone... would need to set an equivalent in cust_location #local($FS::cust_main::ignore_illegal_zip) = 1;
336   local($FS::cust_main::ignore_banned_card) = 1;
337   local($FS::cust_main::skip_fuzzyfiles) = 1;
338
339   local($FS::cust_payby::ignore_expired_card) = 1;
340   local($FS::cust_payby::ignore_banned_card) = 1;
341
342   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
343   # kind of a weird spot for this, but it's better than duplicating
344   # all this code in each class...
345   my @decrypt_tables = qw( cust_payby cust_pay_void cust_pay cust_refund cust_pay_pending );
346   foreach my $table ( @decrypt_tables ) {
347       my @objects = qsearch({
348         'table'     => $table,
349         'hashref'   => {},
350         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
351                        " AND LENGTH(payinfo) > 100",
352       });
353       foreach my $object ( @objects ) {
354           my $payinfo = $object->decrypt($object->payinfo);
355           if ( $payinfo eq $object->payinfo ) {
356             warn "error decrypting payinfo for $table: $payinfo\n";
357             next;
358           }
359           $object->payinfo($payinfo);
360           my $error = $object->replace;
361           die $error if $error;
362       }
363   }
364
365 }
366
367 =item upgrade_data
368
369 =cut
370
371 sub upgrade_data {
372   my %opt = @_;
373
374   tie my %hash, 'Tie::IxHash', 
375
376     #remap log levels
377     'log' => [],
378
379     #fix whitespace - before cust_main
380     'cust_location' => [],
381
382     # need before cust_main tokenization upgrade,
383     # blocks tokenization upgrade if deprecated features still in use
384     'agent_payment_gateway' => [],
385
386     #remove bad source_paynum before cust_main
387     'cust_refund' => [],
388
389     #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
390     # (handles payinfo encryption/tokenization across all relevant tables)
391     'cust_main' => [],
392
393     #contact -> cust_contact / prospect_contact
394     'contact' => [],
395
396     #msgcat
397     'msgcat' => [],
398
399     #reason type and reasons
400     'reason_type'     => [],
401     'cust_pkg_reason' => [],
402
403     #need part_pkg before cust_credit...
404     'part_pkg' => [],
405
406     #customer credits
407     'cust_credit' => [],
408
409     # reason / void_reason migration to reasonnum / void_reasonnum
410     'cust_credit_void' => [],
411     'cust_bill_void' => [],
412     # also fix some tax allocation records
413     'cust_bill_pkg_void' => [],
414
415     #duplicate history records
416     'h_cust_svc'  => [],
417
418     #populate cust_pay.otaker
419     'cust_pay'    => [],
420
421     #populate part_pkg_taxclass for starters
422     'part_pkg_taxclass' => [],
423
424     #remove bad pending records
425     'cust_pay_pending' => [],
426
427     #replace invnum and pkgnum with billpkgnum
428     'cust_bill_pkg_detail' => [],
429
430     #usage_classes if we have none
431     'usage_class' => [],
432
433     #phone_type if we have none
434     'phone_type' => [],
435
436     #fixup access rights
437     'access_right' => [],
438
439     #change recur_flat and enable_prorate
440     'part_pkg_option' => [],
441
442     #add weights to pkg_category
443     'pkg_category' => [],
444
445     #cdrbatch fixes
446     'cdr' => [],
447
448     #otaker->usernum
449     'cust_attachment' => [],
450     #'cust_credit' => [],
451     #'cust_main' => [],
452     'cust_main_note' => [],
453     #'cust_pay' => [],
454     'cust_pay_void' => [],
455     'cust_pkg' => [],
456     #'cust_pkg_reason' => [],
457     'cust_pkg_discount' => [],
458     #'cust_refund' => [],
459     'banned_pay' => [],
460
461     #paycardtype
462     'cust_payby' => [],
463
464     #default namespace
465     'payment_gateway' => [],
466
467     #migrate to templates
468     'msg_template' => [],
469
470     #return unprovisioned numbers to availability
471     'phone_avail' => [],
472
473     #insert scripcondition
474     'TicketSystem' => [],
475     
476     #insert LATA data if not already present
477     'lata' => [],
478     
479     #insert MSA data if not already present
480     'msa' => [],
481
482     # migrate to radius_group and groupnum instead of groupname
483     'radius_usergroup' => [],
484     'part_svc'         => [],
485     'part_export'      => [],
486
487     #insert default tower_sector if not present
488     'tower' => [],
489
490     #repair improperly deleted services
491     'cust_svc' => [],
492
493     #routernum/blocknum
494     'svc_broadband' => [],
495
496     #set up payment gateways if needed
497     'pay_batch' => [],
498
499     #flag monthly tax exemptions
500     'cust_tax_exempt_pkg' => [],
501
502     #kick off tax location history upgrade
503     'cust_bill_pkg' => [],
504
505     #fix taxable line item links
506     'cust_bill_pkg_tax_location' => [],
507
508     #populate state FIPS codes if not already done
509     'state' => [],
510
511     #set default locations on quoted packages
512     'quotation_pkg' => [],
513
514     #populate tax statuses
515     'tax_status' => [],
516
517     #mark certain taxes as system-maintained,
518     # and fix whitespace
519     'cust_main_county' => [],
520
521     #'compliance solutions' -> 'compliance_solutions'
522     'tax_rate' => [],
523     'tax_rate_location' => [],
524
525     #upgrade part_event_condition_option agentnum to a multiple hash value
526     'part_event_condition_option' =>[],
527
528     #fix ip format
529     'svc_circuit' => [],
530
531     #fix ip format
532     'svc_hardware' => [],
533
534     #fix ip format
535     'svc_pbx' => [],
536
537     #fix ip format
538     'tower_sector' => [],
539
540
541   ;
542
543   \%hash;
544
545 }
546
547 =item upgrade_schema
548
549 =cut
550
551 sub upgrade_schema {
552   my %opt = @_;
553
554   my $data = upgrade_schema_data(%opt);
555
556   my $oldAutoCommit = $FS::UID::AutoCommit;
557   local $FS::UID::AutoCommit = 0;
558   local $FS::UID::AutoCommit = 0;
559
560   foreach my $table ( keys %$data ) {
561
562     my $class = "FS::$table";
563     eval "use $class;";
564     die $@ if $@;
565
566     if ( $class->can('_upgrade_schema') ) {
567       warn "Upgrading $table schema...\n";
568
569       my $start = time;
570
571       $class->_upgrade_schema(%opt);
572
573       if ( $oldAutoCommit ) {
574         warn "  committing\n";
575         dbh->commit or die dbh->errstr;
576       }
577       
578       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
579       warn "  done in ". (time-$start). " seconds\n";
580
581     } else {
582       warn "WARNING: asked for schema upgrade of $table,".
583            " but FS::$table has no _upgrade_schema method\n";
584     }
585
586   }
587
588 }
589
590 =item upgrade_schema_data
591
592 =cut
593
594 sub upgrade_schema_data {
595   my %opt = @_;
596
597   #auto-find tables/classes with an _update_schema method?
598
599   tie my %hash, 'Tie::IxHash', 
600
601     #fix classnum character(1)
602     'cust_bill_pkg_detail' => [],
603     #add necessary columns to RT schema
604     'TicketSystem' => [],
605     #remove h_access_user_log if it exists (since our regular auto schema
606     # upgrade doesn't have the drop tables flag turned on) 
607     'access_user_log' => [],
608     #remove possible dangling records
609     'password_history' => [],
610     'cust_pay_pending' => [],
611     #remove records referencing removed things with their FKs
612     'pkg_referral' => [],
613     'cust_bill_pkg_discount' => [],
614     'cust_msg' => [],
615     'cust_bill_pay_batch' => [],
616     'cust_event_fee' => [],
617     'radius_attr' => [],
618     'queue_depend' => [],
619     'cust_main_invoice' => [],
620     #update records referencing removed things with their FKs
621     'cust_pkg' => [],
622   ;
623
624   \%hash;
625
626 }
627
628 sub upgrade_sqlradius {
629   #my %opt = @_;
630
631   my $conf = new FS::Conf;
632
633   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
634
635   foreach my $part_export ( @part_export ) {
636
637     my $errmsg = 'Error adding FreesideStatus to '.
638                  $part_export->option('datasrc'). ': ';
639
640     my $dbh = FS::DBI->connect(
641       ( map $part_export->option($_), qw ( datasrc username password ) ),
642       { PrintError => 0, PrintWarn => 0 }
643     ) or do {
644       warn $errmsg.$FS::DBI::errstr;
645       next;
646     };
647
648     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
649     my $group = "UserName";
650     $group .= ",Realm"
651       if ref($part_export) =~ /withdomain/
652       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
653
654     my $sth_alter = $dbh->prepare(
655       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
656     );
657     if ( $sth_alter ) {
658       if ( $sth_alter->execute ) {
659         my $sth_update = $dbh->prepare(
660          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
661         ) or die $errmsg.$dbh->errstr;
662         $sth_update->execute or die $errmsg.$sth_update->errstr;
663       } else {
664         my $error = $sth_alter->errstr;
665         warn $errmsg.$error
666           unless $error =~ /Duplicate column name/i  #mysql
667               || $error =~ /already exists/i;        #Pg
668 ;
669       }
670     } else {
671       my $error = $dbh->errstr;
672       warn $errmsg.$error; #unless $error =~ /exists/i;
673     }
674
675     my $sth_index = $dbh->prepare(
676       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
677     );
678     if ( $sth_index ) {
679       unless ( $sth_index->execute ) {
680         my $error = $sth_index->errstr;
681         warn $errmsg.$error
682           unless $error =~ /Duplicate key name/i #mysql
683               || $error =~ /already exists/i;    #Pg
684       }
685     } else {
686       my $error = $dbh->errstr;
687       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
688     }
689
690     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
691       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
692       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
693
694     my $sth = $dbh->prepare("SELECT UserName,
695                                     Realm,
696                                     $str2time max(AcctStartTime)),
697                                     $str2time max(AcctStopTime))
698                               FROM radacct
699                               WHERE FreesideStatus = 'done'
700                                 AND $times
701                               GROUP BY $group
702                             ")
703       or die $errmsg.$dbh->errstr;
704     $sth->execute() or die $errmsg.$sth->errstr;
705   
706     while (my $row = $sth->fetchrow_arrayref ) {
707       my ($username, $realm, $start, $stop) = @$row;
708   
709       $username = lc($username) unless $conf->exists('username-uppercase');
710
711       my $exportnum = $part_export->exportnum;
712       my $extra_sql = " AND exportnum = $exportnum ".
713                       " AND exportsvcnum IS NOT NULL ";
714
715       if ( ref($part_export) =~ /withdomain/ ) {
716         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
717                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
718       }
719   
720       my $svc_acct = qsearchs({
721         'select'    => 'svc_acct.*',
722         'table'     => 'svc_acct',
723         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
724                        'LEFT JOIN export_svc USING ( svcpart )',
725         'hashref'   => { 'username' => $username },
726         'extra_sql' => $extra_sql,
727       });
728
729       if ($svc_acct) {
730         $svc_acct->last_login($start)
731           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
732         $svc_acct->last_logout($stop)
733           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
734       }
735     }
736   }
737
738 }
739
740 =back
741
742 =head1 BUGS
743
744 Sure.
745
746 =head1 SEE ALSO
747
748 =cut
749
750 1;