eliminate some false laziness in FS::Misc::send_email vs. msg_template/email.pm send_...
[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_payby 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     #fix whitespace - before cust_main
387     'cust_location' => [],
388
389     # need before cust_main tokenization upgrade,
390     # blocks tokenization upgrade if deprecated features still in use
391     'agent_payment_gateway' => [],
392
393     #remove bad source_paynum before cust_main
394     'cust_refund' => [],
395
396     #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
397     # (handles payinfo encryption/tokenization across all relevant tables)
398     'cust_main' => [],
399
400     #contact -> cust_contact / prospect_contact
401     'contact' => [],
402
403     #msgcat
404     'msgcat' => [],
405
406     #reason type and reasons
407     'reason_type'     => [],
408     'cust_pkg_reason' => [],
409
410     #need part_pkg before cust_credit...
411     'part_pkg' => [],
412
413     #customer credits
414     'cust_credit' => [],
415
416     # reason / void_reason migration to reasonnum / void_reasonnum
417     'cust_credit_void' => [],
418     'cust_bill_void' => [],
419     # also fix some tax allocation records
420     'cust_bill_pkg_void' => [],
421
422     #duplicate history records
423     'h_cust_svc'  => [],
424
425     #populate cust_pay.otaker
426     'cust_pay'    => [],
427
428     #populate part_pkg_taxclass for starters
429     'part_pkg_taxclass' => [],
430
431     #remove bad pending records
432     'cust_pay_pending' => [],
433
434     #replace invnum and pkgnum with billpkgnum
435     'cust_bill_pkg_detail' => [],
436
437     #usage_classes if we have none
438     'usage_class' => [],
439
440     #phone_type if we have none
441     'phone_type' => [],
442
443     #fixup access rights
444     'access_right' => [],
445
446     #change recur_flat and enable_prorate
447     'part_pkg_option' => [],
448
449     #add weights to pkg_category
450     'pkg_category' => [],
451
452     #cdrbatch fixes
453     'cdr' => [],
454
455     #otaker->usernum
456     'cust_attachment' => [],
457     #'cust_credit' => [],
458     #'cust_main' => [],
459     'cust_main_note' => [],
460     #'cust_pay' => [],
461     'cust_pay_void' => [],
462     'cust_pkg' => [],
463     #'cust_pkg_reason' => [],
464     'cust_pkg_discount' => [],
465     #'cust_refund' => [],
466     'banned_pay' => [],
467
468     #paycardtype
469     'cust_payby' => [],
470
471     #default namespace
472     'payment_gateway' => [],
473
474     #migrate to templates
475     'msg_template' => [],
476
477     #return unprovisioned numbers to availability
478     'phone_avail' => [],
479
480     #insert scripcondition
481     'TicketSystem' => [],
482     
483     #insert LATA data if not already present
484     'lata' => [],
485     
486     #insert MSA data if not already present
487     'msa' => [],
488
489     # migrate to radius_group and groupnum instead of groupname
490     'radius_usergroup' => [],
491     'part_svc'         => [],
492     'part_export'      => [],
493
494     #insert default tower_sector if not present
495     'tower' => [],
496
497     #repair improperly deleted services
498     'cust_svc' => [],
499
500     #routernum/blocknum
501     'svc_broadband' => [],
502
503     #set up payment gateways if needed
504     'pay_batch' => [],
505
506     #flag monthly tax exemptions
507     'cust_tax_exempt_pkg' => [],
508
509     #kick off tax location history upgrade
510     'cust_bill_pkg' => [],
511
512     #fix taxable line item links
513     'cust_bill_pkg_tax_location' => [],
514
515     #populate state FIPS codes if not already done
516     'state' => [],
517
518     #set default locations on quoted packages
519     'quotation_pkg' => [],
520
521     #populate tax statuses
522     'tax_status' => [],
523
524     #mark certain taxes as system-maintained,
525     # and fix whitespace
526     'cust_main_county' => [],
527
528     #'compliance solutions' -> 'compliance_solutions'
529     'tax_rate' => [],
530     'tax_rate_location' => [],
531
532     #upgrade part_event_condition_option agentnum to a multiple hash value
533     'part_event_condition_option' =>[],
534
535     #fix ip format
536     'svc_circuit' => [],
537
538     #fix ip format
539     'svc_hardware' => [],
540
541     #fix ip format
542     'svc_pbx' => [],
543
544     #fix ip format
545     'tower_sector' => [],
546
547
548   ;
549
550   \%hash;
551
552 }
553
554 =item upgrade_schema
555
556 =cut
557
558 sub upgrade_schema {
559   my %opt = @_;
560
561   my $data = upgrade_schema_data(%opt);
562
563   my $oldAutoCommit = $FS::UID::AutoCommit;
564   local $FS::UID::AutoCommit = 0;
565   local $FS::UID::AutoCommit = 0;
566
567   foreach my $table ( keys %$data ) {
568
569     my $class = "FS::$table";
570     eval "use $class;";
571     die $@ if $@;
572
573     if ( $class->can('_upgrade_schema') ) {
574       warn "Upgrading $table schema...\n";
575
576       my $start = time;
577
578       $class->_upgrade_schema(%opt);
579
580       if ( $oldAutoCommit ) {
581         warn "  committing\n";
582         dbh->commit or die dbh->errstr;
583       }
584       
585       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
586       warn "  done in ". (time-$start). " seconds\n";
587
588     } else {
589       warn "WARNING: asked for schema upgrade of $table,".
590            " but FS::$table has no _upgrade_schema method\n";
591     }
592
593   }
594
595 }
596
597 =item upgrade_schema_data
598
599 =cut
600
601 sub upgrade_schema_data {
602   my %opt = @_;
603
604   #auto-find tables/classes with an _update_schema method?
605
606   tie my %hash, 'Tie::IxHash', 
607
608     #fix classnum character(1)
609     'cust_bill_pkg_detail' => [],
610     #add necessary columns to RT schema
611     'TicketSystem' => [],
612     #remove h_access_user_log if it exists (since our regular auto schema
613     # upgrade doesn't have the drop tables flag turned on) 
614     'access_user_log' => [],
615     #remove possible dangling records
616     'password_history' => [],
617     'cust_pay_pending' => [],
618     #remove records referencing removed things with their FKs
619     'pkg_referral' => [],
620     'cust_bill_pkg_discount' => [],
621     'cust_msg' => [],
622     'cust_bill_pay_batch' => [],
623     'cust_event_fee' => [],
624     'radius_attr' => [],
625     'queue_depend' => [],
626     'cust_main_invoice' => [],
627     #update records referencing removed things with their FKs
628     'cust_pkg' => [],
629   ;
630
631   \%hash;
632
633 }
634
635 sub upgrade_sqlradius {
636   #my %opt = @_;
637
638   my $conf = new FS::Conf;
639
640   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
641
642   foreach my $part_export ( @part_export ) {
643
644     my $errmsg = 'Error adding FreesideStatus to '.
645                  $part_export->option('datasrc'). ': ';
646
647     my $dbh = FS::DBI->connect(
648       ( map $part_export->option($_), qw ( datasrc username password ) ),
649       { PrintError => 0, PrintWarn => 0 }
650     ) or do {
651       warn $errmsg.$FS::DBI::errstr;
652       next;
653     };
654
655     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
656     my $group = "UserName";
657     $group .= ",Realm"
658       if ref($part_export) =~ /withdomain/
659       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
660
661     my $sth_alter = $dbh->prepare(
662       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
663     );
664     if ( $sth_alter ) {
665       if ( $sth_alter->execute ) {
666         my $sth_update = $dbh->prepare(
667          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
668         ) or die $errmsg.$dbh->errstr;
669         $sth_update->execute or die $errmsg.$sth_update->errstr;
670       } else {
671         my $error = $sth_alter->errstr;
672         warn $errmsg.$error
673           unless $error =~ /Duplicate column name/i  #mysql
674               || $error =~ /already exists/i;        #Pg
675 ;
676       }
677     } else {
678       my $error = $dbh->errstr;
679       warn $errmsg.$error; #unless $error =~ /exists/i;
680     }
681
682     my $sth_index = $dbh->prepare(
683       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
684     );
685     if ( $sth_index ) {
686       unless ( $sth_index->execute ) {
687         my $error = $sth_index->errstr;
688         warn $errmsg.$error
689           unless $error =~ /Duplicate key name/i #mysql
690               || $error =~ /already exists/i;    #Pg
691       }
692     } else {
693       my $error = $dbh->errstr;
694       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
695     }
696
697     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
698       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
699       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
700
701     my $sth = $dbh->prepare("SELECT UserName,
702                                     Realm,
703                                     $str2time max(AcctStartTime)),
704                                     $str2time max(AcctStopTime))
705                               FROM radacct
706                               WHERE FreesideStatus = 'done'
707                                 AND $times
708                               GROUP BY $group
709                             ")
710       or die $errmsg.$dbh->errstr;
711     $sth->execute() or die $errmsg.$sth->errstr;
712   
713     while (my $row = $sth->fetchrow_arrayref ) {
714       my ($username, $realm, $start, $stop) = @$row;
715   
716       $username = lc($username) unless $conf->exists('username-uppercase');
717
718       my $exportnum = $part_export->exportnum;
719       my $extra_sql = " AND exportnum = $exportnum ".
720                       " AND exportsvcnum IS NOT NULL ";
721
722       if ( ref($part_export) =~ /withdomain/ ) {
723         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
724                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
725       }
726   
727       my $svc_acct = qsearchs({
728         'select'    => 'svc_acct.*',
729         'table'     => 'svc_acct',
730         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
731                        'LEFT JOIN export_svc USING ( svcpart )',
732         'hashref'   => { 'username' => $username },
733         'extra_sql' => $extra_sql,
734       });
735
736       if ($svc_acct) {
737         $svc_acct->last_login($start)
738           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
739         $svc_acct->last_logout($stop)
740           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
741       }
742     }
743   }
744
745 }
746
747 =back
748
749 =head1 BUGS
750
751 Sure.
752
753 =head1 SEE ALSO
754
755 =cut
756
757 1;