b0a2713e3198524b9eee274a5cdb5e8805463b3f
[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 }
165
166 sub upgrade_overlimit_groups {
167     my $conf = shift;
168     my $agentnum = shift;
169     my @groups = $conf->config('overlimit_groups',$agentnum); 
170     if(scalar(@groups)) {
171         my $groups = join(',',@groups);
172         my @groupnums;
173         my $error = '';
174         if ( $groups !~ /^[\d,]+$/ ) {
175             foreach my $groupname ( @groups ) {
176                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
177                 unless ( $g ) {
178                     $g = new FS::radius_group {
179                                     'groupname' => $groupname,
180                                     'description' => $groupname,
181                                     };
182                     $error = $g->insert;
183                     die $error if $error;
184                 }
185                 push @groupnums, $g->groupnum;
186             }
187             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
188         }
189     }
190 }
191
192 sub upgrade_invoice_from {
193   my ($conf, $agentnum, $agentonly) = @_;
194   if (
195       (!$conf->exists('invoice_from_name',$agentnum,$agentonly)) && 
196       ($conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/)
197   ) {
198     my $realemail = $1;
199     $realemail =~ s/^\s*//; # remove leading spaces
200     $realemail =~ s/\s*$//; # remove trailing spaces
201     my $realname = $conf->config('invoice_from',$agentnum);
202     $realname =~ s/\<.*\>//; # remove email address
203     $realname =~ s/^\s*//; # remove leading spaces
204     $realname =~ s/\s*$//; # remove trailing spaces
205     # properly quote names that contain punctuation
206     if (($realname =~ /[^[:alnum:][:space:]]/) && ($realname !~ /^\".*\"$/)) {
207       $realname = '"' . $realname . '"';
208     }
209     $conf->set('invoice_from_name', $realname, $agentnum);
210     $conf->set('invoice_from', $realemail, $agentnum);
211   }
212 }
213
214 =item upgrade
215
216 =cut
217
218 sub upgrade {
219   my %opt = @_;
220
221   my $data = upgrade_data(%opt);
222
223   my $oldAutoCommit = $FS::UID::AutoCommit;
224   local $FS::UID::AutoCommit = 0;
225   local $FS::UID::AutoCommit = 0;
226
227   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
228
229
230   foreach my $table ( keys %$data ) {
231
232     my $class = "FS::$table";
233     eval "use $class;";
234     die $@ if $@;
235
236     if ( $class->can('_upgrade_data') ) {
237       warn "Upgrading $table...\n";
238
239       my $start = time;
240
241       $class->_upgrade_data(%opt);
242
243       # New interface for async upgrades: a class can declare a 
244       # "queueable_upgrade" method, which will run as part of the normal 
245       # upgrade, but if the -j option is passed, will instead be run from 
246       # the job queue.
247       if ( $class->can('queueable_upgrade') ) {
248         my $jobname = $class . '::queueable_upgrade';
249         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
250         if ($num_jobs > 0) {
251           warn "$class upgrade already scheduled.\n";
252         } else {
253           if ( $opt{'queue'} ) {
254             warn "Scheduling $class upgrade.\n";
255             my $job = FS::queue->new({ job => $jobname });
256             $job->insert($class, %opt);
257           } else {
258             $class->queueable_upgrade(%opt);
259           }
260         } #$num_jobs == 0
261       }
262
263       if ( $oldAutoCommit ) {
264         warn "  committing\n";
265         dbh->commit or die dbh->errstr;
266       }
267       
268       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
269       warn "  done in ". (time-$start). " seconds\n";
270
271     } else {
272       warn "WARNING: asked for upgrade of $table,".
273            " but FS::$table has no _upgrade_data method\n";
274     }
275
276 #    my @records = @{ $data->{$table} };
277 #
278 #    foreach my $record ( @records ) {
279 #      my $args = delete($record->{'_upgrade_args'}) || [];
280 #      my $object = $class->new( $record );
281 #      my $error = $object->insert( @$args );
282 #      die "error inserting record into $table: $error\n"
283 #        if $error;
284 #    }
285
286   }
287
288   local($FS::cust_main::ignore_expired_card) = 1;
289   local($FS::cust_main::ignore_illegal_zip) = 1;
290   local($FS::cust_main::ignore_banned_card) = 1;
291   local($FS::cust_main::skip_fuzzyfiles) = 1;
292
293   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
294   # kind of a weird spot for this, but it's better than duplicating
295   # all this code in each class...
296   my @decrypt_tables = qw( cust_main cust_pay_void cust_pay cust_refund cust_pay_pending );
297   foreach my $table ( @decrypt_tables ) {
298       my @objects = qsearch({
299         'table'     => $table,
300         'hashref'   => {},
301         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
302                        " AND LENGTH(payinfo) > 100",
303       });
304       foreach my $object ( @objects ) {
305           my $payinfo = $object->decrypt($object->payinfo);
306           die "error decrypting payinfo" if $payinfo eq $object->payinfo;
307           $object->payinfo($payinfo);
308           my $error = $object->replace;
309           die $error if $error;
310       }
311   }
312
313 }
314
315 =item upgrade_data
316
317 =cut
318
319 sub upgrade_data {
320   my %opt = @_;
321
322   tie my %hash, 'Tie::IxHash', 
323
324     #cust_main (remove paycvv from history)
325     'cust_main' => [],
326
327     #msgcat
328     'msgcat' => [],
329
330     #reason type and reasons
331     'reason_type'     => [],
332     'cust_pkg_reason' => [],
333
334     #need part_pkg before cust_credit...
335     'part_pkg' => [],
336
337     #customer credits
338     'cust_credit' => [],
339
340     #duplicate history records
341     'h_cust_svc'  => [],
342
343     #populate cust_pay.otaker
344     'cust_pay'    => [],
345
346     #populate part_pkg_taxclass for starters
347     'part_pkg_taxclass' => [],
348
349     #remove bad pending records
350     'cust_pay_pending' => [],
351
352     #replace invnum and pkgnum with billpkgnum
353     'cust_bill_pkg_detail' => [],
354
355     #usage_classes if we have none
356     'usage_class' => [],
357
358     #phone_type if we have none
359     'phone_type' => [],
360
361     #fixup access rights
362     'access_right' => [],
363
364     #change recur_flat and enable_prorate
365     'part_pkg_option' => [],
366
367     #add weights to pkg_category
368     'pkg_category' => [],
369
370     #cdrbatch fixes
371     'cdr' => [],
372
373     #otaker->usernum
374     'cust_attachment' => [],
375     #'cust_credit' => [],
376     #'cust_main' => [],
377     'cust_main_note' => [],
378     #'cust_pay' => [],
379     'cust_pay_void' => [],
380     'cust_pkg' => [],
381     #'cust_pkg_reason' => [],
382     'cust_pkg_discount' => [],
383     'cust_refund' => [],
384     'banned_pay' => [],
385
386     #default namespace
387     'payment_gateway' => [],
388
389     #migrate to templates
390     'msg_template' => [],
391
392     #return unprovisioned numbers to availability
393     'phone_avail' => [],
394
395     #insert scripcondition
396     'TicketSystem' => [],
397     
398     #insert LATA data if not already present
399     'lata' => [],
400     
401     #insert MSA data if not already present
402     'msa' => [],
403
404     # migrate to radius_group and groupnum instead of groupname
405     'radius_usergroup' => [],
406     'part_svc'         => [],
407     'part_export'      => [],
408
409     #insert default tower_sector if not present
410     'tower' => [],
411
412     #repair improperly deleted services
413     'cust_svc' => [],
414
415     #routernum/blocknum
416     'svc_broadband' => [],
417
418     #set up payment gateways if needed
419     'pay_batch' => [],
420
421     #flag monthly tax exemptions
422     'cust_tax_exempt_pkg' => [],
423
424     #kick off tax location history upgrade
425     'cust_bill_pkg' => [],
426
427     #fix taxable line item links
428     'cust_bill_pkg_tax_location' => [],
429
430     #populate state FIPS codes if not already done
431     'state' => [],
432
433     #set default locations on quoted packages
434     'quotation_pkg' => [],
435
436     #mark certain taxes as system-maintained
437     'cust_main_county' => [],
438   ;
439
440   \%hash;
441
442 }
443
444 =item upgrade_schema
445
446 =cut
447
448 sub upgrade_schema {
449   my %opt = @_;
450
451   my $data = upgrade_schema_data(%opt);
452
453   my $oldAutoCommit = $FS::UID::AutoCommit;
454   local $FS::UID::AutoCommit = 0;
455   local $FS::UID::AutoCommit = 0;
456
457   foreach my $table ( keys %$data ) {
458
459     my $class = "FS::$table";
460     eval "use $class;";
461     die $@ if $@;
462
463     if ( $class->can('_upgrade_schema') ) {
464       warn "Upgrading $table schema...\n";
465
466       my $start = time;
467
468       $class->_upgrade_schema(%opt);
469
470       if ( $oldAutoCommit ) {
471         warn "  committing\n";
472         dbh->commit or die dbh->errstr;
473       }
474       
475       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
476       warn "  done in ". (time-$start). " seconds\n";
477
478     } else {
479       warn "WARNING: asked for schema upgrade of $table,".
480            " but FS::$table has no _upgrade_schema method\n";
481     }
482
483   }
484
485 }
486
487 =item upgrade_schema_data
488
489 =cut
490
491 sub upgrade_schema_data {
492   my %opt = @_;
493
494   tie my %hash, 'Tie::IxHash', 
495
496     #fix classnum character(1)
497     'cust_bill_pkg_detail' => [],
498     #add necessary columns to RT schema
499     'TicketSystem' => [],
500     #remove possible dangling records
501     'password_history' => [],
502     'cust_pay_pending' => [],
503   ;
504
505   \%hash;
506
507 }
508
509 sub upgrade_sqlradius {
510   #my %opt = @_;
511
512   my $conf = new FS::Conf;
513
514   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
515
516   foreach my $part_export ( @part_export ) {
517
518     my $errmsg = 'Error adding FreesideStatus to '.
519                  $part_export->option('datasrc'). ': ';
520
521     my $dbh = DBI->connect(
522       ( map $part_export->option($_), qw ( datasrc username password ) ),
523       { PrintError => 0, PrintWarn => 0 }
524     ) or do {
525       warn $errmsg.$DBI::errstr;
526       next;
527     };
528
529     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
530     my $group = "UserName";
531     $group .= ",Realm"
532       if ref($part_export) =~ /withdomain/
533       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
534
535     my $sth_alter = $dbh->prepare(
536       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
537     );
538     if ( $sth_alter ) {
539       if ( $sth_alter->execute ) {
540         my $sth_update = $dbh->prepare(
541          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
542         ) or die $errmsg.$dbh->errstr;
543         $sth_update->execute or die $errmsg.$sth_update->errstr;
544       } else {
545         my $error = $sth_alter->errstr;
546         warn $errmsg.$error
547           unless $error =~ /Duplicate column name/i  #mysql
548               || $error =~ /already exists/i;        #Pg
549 ;
550       }
551     } else {
552       my $error = $dbh->errstr;
553       warn $errmsg.$error; #unless $error =~ /exists/i;
554     }
555
556     my $sth_index = $dbh->prepare(
557       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
558     );
559     if ( $sth_index ) {
560       unless ( $sth_index->execute ) {
561         my $error = $sth_index->errstr;
562         warn $errmsg.$error
563           unless $error =~ /Duplicate key name/i #mysql
564               || $error =~ /already exists/i;    #Pg
565       }
566     } else {
567       my $error = $dbh->errstr;
568       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
569     }
570
571     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
572       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
573       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
574
575     my $sth = $dbh->prepare("SELECT UserName,
576                                     Realm,
577                                     $str2time max(AcctStartTime)),
578                                     $str2time max(AcctStopTime))
579                               FROM radacct
580                               WHERE FreesideStatus = 'done'
581                                 AND $times
582                               GROUP BY $group
583                             ")
584       or die $errmsg.$dbh->errstr;
585     $sth->execute() or die $errmsg.$sth->errstr;
586   
587     while (my $row = $sth->fetchrow_arrayref ) {
588       my ($username, $realm, $start, $stop) = @$row;
589   
590       $username = lc($username) unless $conf->exists('username-uppercase');
591
592       my $exportnum = $part_export->exportnum;
593       my $extra_sql = " AND exportnum = $exportnum ".
594                       " AND exportsvcnum IS NOT NULL ";
595
596       if ( ref($part_export) =~ /withdomain/ ) {
597         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
598                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
599       }
600   
601       my $svc_acct = qsearchs({
602         'select'    => 'svc_acct.*',
603         'table'     => 'svc_acct',
604         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
605                        'LEFT JOIN export_svc USING ( svcpart )',
606         'hashref'   => { 'username' => $username },
607         'extra_sql' => $extra_sql,
608       });
609
610       if ($svc_acct) {
611         $svc_acct->last_login($start)
612           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
613         $svc_acct->last_logout($stop)
614           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
615       }
616     }
617   }
618
619 }
620
621 =back
622
623 =head1 BUGS
624
625 Sure.
626
627 =head1 SEE ALSO
628
629 =cut
630
631 1;
632