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