db24215a7b7d8941d4659371931cd91760a13c6a
[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   upgrade_overlimit_groups($conf);
67   map { upgrade_overlimit_groups($conf,$_->agentnum) } qsearch('agent', {});
68
69   my $DIST_CONF = '/usr/local/etc/freeside/default_conf/';#DIST_CONF in Makefile
70   $conf->set($_, scalar(read_file( "$DIST_CONF/$_" )) )
71     foreach grep { ! $conf->exists($_) && -s "$DIST_CONF/$_" }
72       qw( quotation_html quotation_latex quotation_latexnotes );
73
74   # change 'fslongtable' to 'longtable'
75   # in invoice and quotation main templates, and also in all secondary 
76   # invoice templates
77   my @latex_confs =
78     qsearch('conf', { 'name' => {op=>'LIKE', value=>'%latex%'} });
79
80   foreach my $c (@latex_confs) {
81     my $value = $c->value;
82     if (length($value) and $value =~ /fslongtable/) {
83       $value =~ s/fslongtable/longtable/g;
84       $conf->set($c->name, $value, $c->agentnum);
85     }
86   }
87
88   # if there's a USPS tools login, assume that's the standardization method
89   # you want to use
90   $conf->set('address_standardize_method', 'usps')
91     if $conf->exists('usps_webtools-userid')
92     && length($conf->config('usps_webtools-userid')) > 0
93     && ! $conf->exists('address_standardize_method');
94
95   # this option has been renamed/expanded
96   if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
97     $conf->touch('cust_main-enable_spouse');
98     $conf->delete('cust_main-enable_spouse_birthdate');
99   }
100
101   # renamed/repurposed
102   if ( $conf->exists('cust_pkg-show_fcc_voice_grade_equivalent') ) {
103     $conf->touch('part_pkg-show_fcc_options');
104     $conf->delete('cust_pkg-show_fcc_voice_grade_equivalent');
105     warn "
106 You have FCC Form 477 package options enabled.
107
108 Starting with the October 2014 filing date, the FCC has redesigned 
109 Form 477 and introduced new service categories.  See bin/convert-477-options
110 to update your package configuration for the new report.
111
112 If you need to continue using the old Form 477 report, turn on the
113 'old_fcc_report' configuration option.
114 ";
115   }
116 }
117
118 sub upgrade_overlimit_groups {
119     my $conf = shift;
120     my $agentnum = shift;
121     my @groups = $conf->config('overlimit_groups',$agentnum); 
122     if(scalar(@groups)) {
123         my $groups = join(',',@groups);
124         my @groupnums;
125         my $error = '';
126         if ( $groups !~ /^[\d,]+$/ ) {
127             foreach my $groupname ( @groups ) {
128                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
129                 unless ( $g ) {
130                     $g = new FS::radius_group {
131                                     'groupname' => $groupname,
132                                     'description' => $groupname,
133                                     };
134                     $error = $g->insert;
135                     die $error if $error;
136                 }
137                 push @groupnums, $g->groupnum;
138             }
139             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
140         }
141     }
142 }
143
144 =item upgrade
145
146 =cut
147
148 sub upgrade {
149   my %opt = @_;
150
151   my $data = upgrade_data(%opt);
152
153   my $oldAutoCommit = $FS::UID::AutoCommit;
154   local $FS::UID::AutoCommit = 0;
155   local $FS::UID::AutoCommit = 0;
156
157   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
158
159
160   foreach my $table ( keys %$data ) {
161
162     my $class = "FS::$table";
163     eval "use $class;";
164     die $@ if $@;
165
166     if ( $class->can('_upgrade_data') ) {
167       warn "Upgrading $table...\n";
168
169       my $start = time;
170
171       $class->_upgrade_data(%opt);
172
173       # New interface for async upgrades: a class can declare a 
174       # "queueable_upgrade" method, which will run as part of the normal 
175       # upgrade, but if the -j option is passed, will instead be run from 
176       # the job queue.
177       if ( $class->can('queueable_upgrade') ) {
178         my $jobname = $class . '::queueable_upgrade';
179         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
180         if ($num_jobs > 0) {
181           warn "$class upgrade already scheduled.\n";
182         } else {
183           if ( $opt{'queue'} ) {
184             warn "Scheduling $class upgrade.\n";
185             my $job = FS::queue->new({ job => $jobname });
186             $job->insert($class, %opt);
187           } else {
188             $class->queueable_upgrade(%opt);
189           }
190         } #$num_jobs == 0
191       }
192
193       if ( $oldAutoCommit ) {
194         warn "  committing\n";
195         dbh->commit or die dbh->errstr;
196       }
197       
198       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
199       warn "  done in ". (time-$start). " seconds\n";
200
201     } else {
202       warn "WARNING: asked for upgrade of $table,".
203            " but FS::$table has no _upgrade_data method\n";
204     }
205
206 #    my @records = @{ $data->{$table} };
207 #
208 #    foreach my $record ( @records ) {
209 #      my $args = delete($record->{'_upgrade_args'}) || [];
210 #      my $object = $class->new( $record );
211 #      my $error = $object->insert( @$args );
212 #      die "error inserting record into $table: $error\n"
213 #        if $error;
214 #    }
215
216   }
217
218   local($FS::cust_main::ignore_expired_card) = 1;
219   local($FS::cust_main::ignore_illegal_zip) = 1;
220   local($FS::cust_main::ignore_banned_card) = 1;
221   local($FS::cust_main::skip_fuzzyfiles) = 1;
222
223   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
224   # kind of a weird spot for this, but it's better than duplicating
225   # all this code in each class...
226   my @decrypt_tables = qw( cust_main cust_pay_void cust_pay cust_refund cust_pay_pending );
227   foreach my $table ( @decrypt_tables ) {
228       my @objects = qsearch({
229         'table'     => $table,
230         'hashref'   => {},
231         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
232                        " AND LENGTH(payinfo) > 100",
233       });
234       foreach my $object ( @objects ) {
235           my $payinfo = $object->decrypt($object->payinfo);
236           die "error decrypting payinfo" if $payinfo eq $object->payinfo;
237           $object->payinfo($payinfo);
238           my $error = $object->replace;
239           die $error if $error;
240       }
241   }
242
243 }
244
245 =item upgrade_data
246
247 =cut
248
249 sub upgrade_data {
250   my %opt = @_;
251
252   tie my %hash, 'Tie::IxHash', 
253
254     #cust_main (remove paycvv from history)
255     'cust_main' => [],
256
257     #msgcat
258     'msgcat' => [],
259
260     #reason type and reasons
261     'reason_type'     => [],
262     'cust_pkg_reason' => [],
263
264     #need part_pkg before cust_credit...
265     'part_pkg' => [],
266
267     #customer credits
268     'cust_credit' => [],
269
270     #duplicate history records
271     'h_cust_svc'  => [],
272
273     #populate cust_pay.otaker
274     'cust_pay'    => [],
275
276     #populate part_pkg_taxclass for starters
277     'part_pkg_taxclass' => [],
278
279     #remove bad pending records
280     'cust_pay_pending' => [],
281
282     #replace invnum and pkgnum with billpkgnum
283     'cust_bill_pkg_detail' => [],
284
285     #usage_classes if we have none
286     'usage_class' => [],
287
288     #phone_type if we have none
289     'phone_type' => [],
290
291     #fixup access rights
292     'access_right' => [],
293
294     #change recur_flat and enable_prorate
295     'part_pkg_option' => [],
296
297     #add weights to pkg_category
298     'pkg_category' => [],
299
300     #cdrbatch fixes
301     'cdr' => [],
302
303     #otaker->usernum
304     'cust_attachment' => [],
305     #'cust_credit' => [],
306     #'cust_main' => [],
307     'cust_main_note' => [],
308     #'cust_pay' => [],
309     'cust_pay_void' => [],
310     'cust_pkg' => [],
311     #'cust_pkg_reason' => [],
312     'cust_pkg_discount' => [],
313     'cust_refund' => [],
314     'banned_pay' => [],
315
316     #default namespace
317     'payment_gateway' => [],
318
319     #migrate to templates
320     'msg_template' => [],
321
322     #return unprovisioned numbers to availability
323     'phone_avail' => [],
324
325     #insert scripcondition
326     'TicketSystem' => [],
327     
328     #insert LATA data if not already present
329     'lata' => [],
330     
331     #insert MSA data if not already present
332     'msa' => [],
333
334     # migrate to radius_group and groupnum instead of groupname
335     'radius_usergroup' => [],
336     'part_svc'         => [],
337     'part_export'      => [],
338
339     #insert default tower_sector if not present
340     'tower' => [],
341
342     #repair improperly deleted services
343     'cust_svc' => [],
344
345     #routernum/blocknum
346     'svc_broadband' => [],
347
348     #set up payment gateways if needed
349     'pay_batch' => [],
350
351     #flag monthly tax exemptions
352     'cust_tax_exempt_pkg' => [],
353
354     #kick off tax location history upgrade
355     'cust_bill_pkg' => [],
356
357     #fix taxable line item links
358     'cust_bill_pkg_tax_location' => [],
359
360     #populate state FIPS codes if not already done
361     'state' => [],
362   ;
363
364   \%hash;
365
366 }
367
368 =item upgrade_schema
369
370 =cut
371
372 sub upgrade_schema {
373   my %opt = @_;
374
375   my $data = upgrade_schema_data(%opt);
376
377   my $oldAutoCommit = $FS::UID::AutoCommit;
378   local $FS::UID::AutoCommit = 0;
379   local $FS::UID::AutoCommit = 0;
380
381   foreach my $table ( keys %$data ) {
382
383     my $class = "FS::$table";
384     eval "use $class;";
385     die $@ if $@;
386
387     if ( $class->can('_upgrade_schema') ) {
388       warn "Upgrading $table schema...\n";
389
390       my $start = time;
391
392       $class->_upgrade_schema(%opt);
393
394       if ( $oldAutoCommit ) {
395         warn "  committing\n";
396         dbh->commit or die dbh->errstr;
397       }
398       
399       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
400       warn "  done in ". (time-$start). " seconds\n";
401
402     } else {
403       warn "WARNING: asked for schema upgrade of $table,".
404            " but FS::$table has no _upgrade_schema method\n";
405     }
406
407   }
408
409 }
410
411 =item upgrade_schema_data
412
413 =cut
414
415 sub upgrade_schema_data {
416   my %opt = @_;
417
418   tie my %hash, 'Tie::IxHash', 
419
420     #fix classnum character(1)
421     'cust_bill_pkg_detail' => [],
422     #add necessary columns to RT schema
423     'TicketSystem' => [],
424
425   ;
426
427   \%hash;
428
429 }
430
431 sub upgrade_sqlradius {
432   #my %opt = @_;
433
434   my $conf = new FS::Conf;
435
436   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
437
438   foreach my $part_export ( @part_export ) {
439
440     my $errmsg = 'Error adding FreesideStatus to '.
441                  $part_export->option('datasrc'). ': ';
442
443     my $dbh = DBI->connect(
444       ( map $part_export->option($_), qw ( datasrc username password ) ),
445       { PrintError => 0, PrintWarn => 0 }
446     ) or do {
447       warn $errmsg.$DBI::errstr;
448       next;
449     };
450
451     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
452     my $group = "UserName";
453     $group .= ",Realm"
454       if ref($part_export) =~ /withdomain/
455       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
456
457     my $sth_alter = $dbh->prepare(
458       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
459     );
460     if ( $sth_alter ) {
461       if ( $sth_alter->execute ) {
462         my $sth_update = $dbh->prepare(
463          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
464         ) or die $errmsg.$dbh->errstr;
465         $sth_update->execute or die $errmsg.$sth_update->errstr;
466       } else {
467         my $error = $sth_alter->errstr;
468         warn $errmsg.$error
469           unless $error =~ /Duplicate column name/i  #mysql
470               || $error =~ /already exists/i;        #Pg
471 ;
472       }
473     } else {
474       my $error = $dbh->errstr;
475       warn $errmsg.$error; #unless $error =~ /exists/i;
476     }
477
478     my $sth_index = $dbh->prepare(
479       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
480     );
481     if ( $sth_index ) {
482       unless ( $sth_index->execute ) {
483         my $error = $sth_index->errstr;
484         warn $errmsg.$error
485           unless $error =~ /Duplicate key name/i #mysql
486               || $error =~ /already exists/i;    #Pg
487       }
488     } else {
489       my $error = $dbh->errstr;
490       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
491     }
492
493     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
494       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
495       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
496
497     my $sth = $dbh->prepare("SELECT UserName,
498                                     Realm,
499                                     $str2time max(AcctStartTime)),
500                                     $str2time max(AcctStopTime))
501                               FROM radacct
502                               WHERE FreesideStatus = 'done'
503                                 AND $times
504                               GROUP BY $group
505                             ")
506       or die $errmsg.$dbh->errstr;
507     $sth->execute() or die $errmsg.$sth->errstr;
508   
509     while (my $row = $sth->fetchrow_arrayref ) {
510       my ($username, $realm, $start, $stop) = @$row;
511   
512       $username = lc($username) unless $conf->exists('username-uppercase');
513
514       my $exportnum = $part_export->exportnum;
515       my $extra_sql = " AND exportnum = $exportnum ".
516                       " AND exportsvcnum IS NOT NULL ";
517
518       if ( ref($part_export) =~ /withdomain/ ) {
519         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
520                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
521       }
522   
523       my $svc_acct = qsearchs({
524         'select'    => 'svc_acct.*',
525         'table'     => 'svc_acct',
526         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
527                        'LEFT JOIN export_svc USING ( svcpart )',
528         'hashref'   => { 'username' => $username },
529         'extra_sql' => $extra_sql,
530       });
531
532       if ($svc_acct) {
533         $svc_acct->last_login($start)
534           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
535         $svc_acct->last_logout($stop)
536           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
537       }
538     }
539   }
540
541 }
542
543 =back
544
545 =head1 BUGS
546
547 Sure.
548
549 =head1 SEE ALSO
550
551 =cut
552
553 1;
554