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