RT# 81946 Rename conf agent-disable_counts as config-disable_counts
[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   # Rename agent-disable_counts as config-disable_counts, flag now
210   # affects several configuration pages
211   for my $row ( qsearch( conf => { name => 'agent-disable_counts' } )) {
212     $row->name('config-disable_counts');
213     $row->replace;
214   }
215
216 }
217
218 sub upgrade_overlimit_groups {
219     my $conf = shift;
220     my $agentnum = shift;
221     my @groups = $conf->config('overlimit_groups',$agentnum); 
222     if(scalar(@groups)) {
223         my $groups = join(',',@groups);
224         my @groupnums;
225         my $error = '';
226         if ( $groups !~ /^[\d,]+$/ ) {
227             foreach my $groupname ( @groups ) {
228                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
229                 unless ( $g ) {
230                     $g = new FS::radius_group {
231                                     'groupname' => $groupname,
232                                     'description' => $groupname,
233                                     };
234                     $error = $g->insert;
235                     die $error if $error;
236                 }
237                 push @groupnums, $g->groupnum;
238             }
239             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
240         }
241     }
242 }
243
244 sub upgrade_invoice_from {
245   my ($conf, $agentnum, $agentonly) = @_;
246   if (
247           ! $conf->exists('invoice_from_name',$agentnum,$agentonly)
248        && $conf->exists('invoice_from',$agentnum,$agentonly)
249        && $conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/
250   ) {
251     my $realemail = $1;
252     $realemail =~ s/^\s*//; # remove leading spaces
253     $realemail =~ s/\s*$//; # remove trailing spaces
254     my $realname = $conf->config('invoice_from',$agentnum);
255     $realname =~ s/\<.*\>//; # remove email address
256     $realname =~ s/^\s*//; # remove leading spaces
257     $realname =~ s/\s*$//; # remove trailing spaces
258     # properly quote names that contain punctuation
259     if (($realname =~ /[^[:alnum:][:space:]]/) && ($realname !~ /^\".*\"$/)) {
260       $realname = '"' . $realname . '"';
261     }
262     $conf->set('invoice_from_name', $realname, $agentnum);
263     $conf->set('invoice_from', $realemail, $agentnum);
264   }
265 }
266
267 =item upgrade
268
269 =cut
270
271 sub upgrade {
272   my %opt = @_;
273
274   my $data = upgrade_data(%opt);
275
276   my $oldAutoCommit = $FS::UID::AutoCommit;
277   local $FS::UID::AutoCommit = 0;
278   local $FS::UID::AutoCommit = 0;
279
280   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
281
282
283   foreach my $table ( keys %$data ) {
284
285     my $class = "FS::$table";
286     eval "use $class;";
287     die $@ if $@;
288
289     if ( $class->can('_upgrade_data') ) {
290       warn "Upgrading $table...\n";
291
292       my $start = time;
293
294       $class->_upgrade_data(%opt);
295
296       # New interface for async upgrades: a class can declare a 
297       # "queueable_upgrade" method, which will run as part of the normal 
298       # upgrade, but if the -j option is passed, will instead be run from 
299       # the job queue.
300       if ( $class->can('queueable_upgrade') ) {
301         my $jobname = $class . '::queueable_upgrade';
302         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
303         if ($num_jobs > 0) {
304           warn "$class upgrade already scheduled.\n";
305         } else {
306           if ( $opt{'queue'} ) {
307             warn "Scheduling $class upgrade.\n";
308             my $job = FS::queue->new({ job => $jobname });
309             $job->insert($class, %opt);
310           } else {
311             $class->queueable_upgrade(%opt);
312           }
313         } #$num_jobs == 0
314       }
315
316       if ( $oldAutoCommit ) {
317         warn "  committing\n";
318         dbh->commit or die dbh->errstr;
319       }
320       
321       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
322       warn "  done in ". (time-$start). " seconds\n";
323
324     } else {
325       warn "WARNING: asked for upgrade of $table,".
326            " but FS::$table has no _upgrade_data method\n";
327     }
328
329 #    my @records = @{ $data->{$table} };
330 #
331 #    foreach my $record ( @records ) {
332 #      my $args = delete($record->{'_upgrade_args'}) || [];
333 #      my $object = $class->new( $record );
334 #      my $error = $object->insert( @$args );
335 #      die "error inserting record into $table: $error\n"
336 #        if $error;
337 #    }
338
339   }
340
341   local($FS::cust_main::ignore_expired_card) = 1;
342   #this is long-gone... would need to set an equivalent in cust_location #local($FS::cust_main::ignore_illegal_zip) = 1;
343   local($FS::cust_main::ignore_banned_card) = 1;
344   local($FS::cust_main::skip_fuzzyfiles) = 1;
345
346   local($FS::cust_payby::ignore_expired_card) = 1;
347   local($FS::cust_payby::ignore_banned_card) = 1;
348
349   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
350   # kind of a weird spot for this, but it's better than duplicating
351   # all this code in each class...
352   my @decrypt_tables = qw( cust_main cust_pay_void cust_pay cust_refund cust_pay_pending );
353   foreach my $table ( @decrypt_tables ) {
354       my @objects = qsearch({
355         'table'     => $table,
356         'hashref'   => {},
357         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
358                        " AND LENGTH(payinfo) > 100",
359       });
360       foreach my $object ( @objects ) {
361           my $payinfo = $object->decrypt($object->payinfo);
362           if ( $payinfo eq $object->payinfo ) {
363             warn "error decrypting payinfo for $table: $payinfo\n";
364             next;
365           }
366           $object->payinfo($payinfo);
367           my $error = $object->replace;
368           die $error if $error;
369       }
370   }
371
372 }
373
374 =item upgrade_data
375
376 =cut
377
378 sub upgrade_data {
379   my %opt = @_;
380
381   tie my %hash, 'Tie::IxHash', 
382
383     #remap log levels
384     'log' => [],
385
386     #payby conditions to new ones
387     'part_event_condition' => [],
388
389     #payby actions to new ones
390     'part_event' => [],
391
392     #fix whitespace - before cust_main
393     'cust_location' => [],
394
395     # need before cust_main tokenization upgrade,
396     # blocks tokenization upgrade if deprecated features still in use
397     'agent_payment_gateway' => [],
398
399     #remove bad source_paynum before cust_main
400     'cust_refund' => [],
401
402     #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
403     # (handles payinfo encryption/tokenization across all relevant tables)
404     'cust_main' => [],
405
406     #contact -> cust_contact / prospect_contact
407     'contact' => [],
408
409     #msgcat
410     'msgcat' => [],
411
412     #reason type and reasons
413     'reason_type'     => [],
414     'cust_pkg_reason' => [],
415
416     #need part_pkg before cust_credit...
417     'part_pkg' => [],
418
419     #customer credits
420     'cust_credit' => [],
421
422     # reason / void_reason migration to reasonnum / void_reasonnum
423     'cust_credit_void' => [],
424     'cust_bill_void' => [],
425     # also fix some tax allocation records
426     'cust_bill_pkg_void' => [],
427
428     #duplicate history records
429     'h_cust_svc'  => [],
430
431     #populate cust_pay.otaker
432     'cust_pay'    => [],
433
434     #populate part_pkg_taxclass for starters
435     'part_pkg_taxclass' => [],
436
437     #remove bad pending records
438     'cust_pay_pending' => [],
439
440     #replace invnum and pkgnum with billpkgnum
441     'cust_bill_pkg_detail' => [],
442
443     #usage_classes if we have none
444     'usage_class' => [],
445
446     #phone_type if we have none
447     'phone_type' => [],
448
449     #fixup access rights
450     'access_right' => [],
451
452     #change recur_flat and enable_prorate
453     'part_pkg_option' => [],
454
455     #add weights to pkg_category
456     'pkg_category' => [],
457
458     #cdrbatch fixes
459     'cdr' => [],
460
461     #otaker->usernum
462     'cust_attachment' => [],
463     #'cust_credit' => [],
464     #'cust_main' => [],
465     'cust_main_note' => [],
466     #'cust_pay' => [],
467     'cust_pay_void' => [],
468     'cust_pkg' => [],
469     #'cust_pkg_reason' => [],
470     'cust_pkg_discount' => [],
471     #'cust_refund' => [],
472     'banned_pay' => [],
473
474     #paycardtype
475     'cust_payby' => [],
476
477     #default namespace
478     'payment_gateway' => [],
479
480     #migrate to templates
481     'msg_template' => [],
482
483     #return unprovisioned numbers to availability
484     'phone_avail' => [],
485
486     #insert scripcondition
487     'TicketSystem' => [],
488     
489     #insert LATA data if not already present
490     'lata' => [],
491     
492     #insert MSA data if not already present
493     'msa' => [],
494
495     # migrate to radius_group and groupnum instead of groupname
496     'radius_usergroup' => [],
497     'part_svc'         => [],
498     'part_export'      => [],
499
500     #insert default tower_sector if not present
501     'tower' => [],
502
503     #repair improperly deleted services
504     'cust_svc' => [],
505
506     #routernum/blocknum
507     'svc_broadband' => [],
508
509     #set up payment gateways if needed
510     'pay_batch' => [],
511
512     #flag monthly tax exemptions
513     'cust_tax_exempt_pkg' => [],
514
515     #kick off tax location history upgrade
516     'cust_bill_pkg' => [],
517
518     #fix taxable line item links
519     'cust_bill_pkg_tax_location' => [],
520
521     #populate state FIPS codes if not already done
522     'state' => [],
523
524     #set default locations on quoted packages
525     'quotation_pkg' => [],
526
527     #populate tax statuses
528     'tax_status' => [],
529
530     #mark certain taxes as system-maintained,
531     # and fix whitespace
532     'cust_main_county' => [],
533
534     #'compliance solutions' -> 'compliance_solutions'
535     'tax_rate' => [],
536     'tax_rate_location' => [],
537
538     #upgrade part_event_condition_option agentnum to a multiple hash value
539     'part_event_condition_option' =>[],
540
541     #fix ip format
542     'svc_circuit' => [],
543
544     #fix ip format
545     'svc_hardware' => [],
546
547     #fix ip format
548     'svc_pbx' => [],
549
550     #fix ip format
551     'tower_sector' => [],
552
553
554   ;
555
556   \%hash;
557
558 }
559
560 =item upgrade_schema
561
562 =cut
563
564 sub upgrade_schema {
565   my %opt = @_;
566
567   my $data = upgrade_schema_data(%opt);
568
569   my $oldAutoCommit = $FS::UID::AutoCommit;
570   local $FS::UID::AutoCommit = 0;
571   local $FS::UID::AutoCommit = 0;
572
573   foreach my $table ( keys %$data ) {
574
575     my $class = "FS::$table";
576     eval "use $class;";
577     die $@ if $@;
578
579     if ( $class->can('_upgrade_schema') ) {
580       warn "Upgrading $table schema...\n";
581
582       my $start = time;
583
584       $class->_upgrade_schema(%opt);
585
586       if ( $oldAutoCommit ) {
587         warn "  committing\n";
588         dbh->commit or die dbh->errstr;
589       }
590       
591       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
592       warn "  done in ". (time-$start). " seconds\n";
593
594     } else {
595       warn "WARNING: asked for schema upgrade of $table,".
596            " but FS::$table has no _upgrade_schema method\n";
597     }
598
599   }
600
601 }
602
603 =item upgrade_schema_data
604
605 =cut
606
607 sub upgrade_schema_data {
608   my %opt = @_;
609
610   #auto-find tables/classes with an _update_schema method?
611
612   tie my %hash, 'Tie::IxHash', 
613
614     #fix classnum character(1)
615     'cust_bill_pkg_detail' => [],
616     #add necessary columns to RT schema
617     'TicketSystem' => [],
618     #remove h_access_user_log if it exists (since our regular auto schema
619     # upgrade doesn't have the drop tables flag turned on) 
620     'access_user_log' => [],
621     #remove possible dangling records
622     'password_history' => [],
623     'cust_pay_pending' => [],
624     #remove records referencing removed things with their FKs
625     'pkg_referral' => [],
626     'cust_bill_pkg_discount' => [],
627     'cust_msg' => [],
628     'cust_bill_pay_batch' => [],
629     'cust_event_fee' => [],
630     'radius_attr' => [],
631     'queue_depend' => [],
632     'cust_main_invoice' => [],
633     #update records referencing removed things with their FKs
634     'cust_pkg' => [],
635   ;
636
637   \%hash;
638
639 }
640
641 sub upgrade_sqlradius {
642   #my %opt = @_;
643
644   my $conf = new FS::Conf;
645
646   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
647
648   foreach my $part_export ( @part_export ) {
649
650     my $errmsg = 'Error adding FreesideStatus to '.
651                  $part_export->option('datasrc'). ': ';
652
653     my $dbh = FS::DBI->connect(
654       ( map $part_export->option($_), qw ( datasrc username password ) ),
655       { PrintError => 0, PrintWarn => 0 }
656     ) or do {
657       warn $errmsg.$FS::DBI::errstr;
658       next;
659     };
660
661     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
662     my $group = "UserName";
663     $group .= ",Realm"
664       if ref($part_export) =~ /withdomain/
665       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
666
667     my $sth_alter = $dbh->prepare(
668       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
669     );
670     if ( $sth_alter ) {
671       if ( $sth_alter->execute ) {
672         my $sth_update = $dbh->prepare(
673          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
674         ) or die $errmsg.$dbh->errstr;
675         $sth_update->execute or die $errmsg.$sth_update->errstr;
676       } else {
677         my $error = $sth_alter->errstr;
678         warn $errmsg.$error
679           unless $error =~ /Duplicate column name/i  #mysql
680               || $error =~ /already exists/i;        #Pg
681 ;
682       }
683     } else {
684       my $error = $dbh->errstr;
685       warn $errmsg.$error; #unless $error =~ /exists/i;
686     }
687
688     my $sth_index = $dbh->prepare(
689       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
690     );
691     if ( $sth_index ) {
692       unless ( $sth_index->execute ) {
693         my $error = $sth_index->errstr;
694         warn $errmsg.$error
695           unless $error =~ /Duplicate key name/i #mysql
696               || $error =~ /already exists/i;    #Pg
697       }
698     } else {
699       my $error = $dbh->errstr;
700       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
701     }
702
703     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
704       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
705       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
706
707     my $sth = $dbh->prepare("SELECT UserName,
708                                     Realm,
709                                     $str2time max(AcctStartTime)),
710                                     $str2time max(AcctStopTime))
711                               FROM radacct
712                               WHERE FreesideStatus = 'done'
713                                 AND $times
714                               GROUP BY $group
715                             ")
716       or die $errmsg.$dbh->errstr;
717     $sth->execute() or die $errmsg.$sth->errstr;
718   
719     while (my $row = $sth->fetchrow_arrayref ) {
720       my ($username, $realm, $start, $stop) = @$row;
721   
722       $username = lc($username) unless $conf->exists('username-uppercase');
723
724       my $exportnum = $part_export->exportnum;
725       my $extra_sql = " AND exportnum = $exportnum ".
726                       " AND exportsvcnum IS NOT NULL ";
727
728       if ( ref($part_export) =~ /withdomain/ ) {
729         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
730                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
731       }
732   
733       my $svc_acct = qsearchs({
734         'select'    => 'svc_acct.*',
735         'table'     => 'svc_acct',
736         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
737                        'LEFT JOIN export_svc USING ( svcpart )',
738         'hashref'   => { 'username' => $username },
739         'extra_sql' => $extra_sql,
740       });
741
742       if ($svc_acct) {
743         $svc_acct->last_login($start)
744           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
745         $svc_acct->last_logout($stop)
746           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
747       }
748     }
749   }
750
751 }
752
753 =back
754
755 =head1 BUGS
756
757 Sure.
758
759 =head1 SEE ALSO
760
761 =cut
762
763 1;