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