Tables added to the list with data upgrade because of reason and void_reason
[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   enable_banned_pay_pad() unless length($conf->config('banned_pay-pad'));
164
165 }
166
167 sub upgrade_overlimit_groups {
168     my $conf = shift;
169     my $agentnum = shift;
170     my @groups = $conf->config('overlimit_groups',$agentnum); 
171     if(scalar(@groups)) {
172         my $groups = join(',',@groups);
173         my @groupnums;
174         my $error = '';
175         if ( $groups !~ /^[\d,]+$/ ) {
176             foreach my $groupname ( @groups ) {
177                 my $g = qsearchs('radius_group', { 'groupname' => $groupname } );
178                 unless ( $g ) {
179                     $g = new FS::radius_group {
180                                     'groupname' => $groupname,
181                                     'description' => $groupname,
182                                     };
183                     $error = $g->insert;
184                     die $error if $error;
185                 }
186                 push @groupnums, $g->groupnum;
187             }
188             $conf->set('overlimit_groups',join("\n",@groupnums),$agentnum);
189         }
190     }
191 }
192
193 sub upgrade_invoice_from {
194   my ($conf, $agentnum, $agentonly) = @_;
195   if (
196       (!$conf->exists('invoice_from_name',$agentnum,$agentonly)) && 
197       ($conf->config('invoice_from',$agentnum,$agentonly) =~ /\<(.*)\>/)
198   ) {
199     my $realemail = $1;
200     $realemail =~ s/^\s*//; # remove leading spaces
201     $realemail =~ s/\s*$//; # remove trailing spaces
202     my $realname = $conf->config('invoice_from',$agentnum);
203     $realname =~ s/\<.*\>//; # remove email address
204     $realname =~ s/^\s*//; # remove leading spaces
205     $realname =~ s/\s*$//; # remove trailing spaces
206     # properly quote names that contain punctuation
207     if (($realname =~ /[^[:alnum:][:space:]]/) && ($realname !~ /^\".*\"$/)) {
208       $realname = '"' . $realname . '"';
209     }
210     $conf->set('invoice_from_name', $realname, $agentnum);
211     $conf->set('invoice_from', $realemail, $agentnum);
212   }
213 }
214
215 =item upgrade
216
217 =cut
218
219 sub upgrade {
220   my %opt = @_;
221
222   my $data = upgrade_data(%opt);
223
224   my $oldAutoCommit = $FS::UID::AutoCommit;
225   local $FS::UID::AutoCommit = 0;
226   local $FS::UID::AutoCommit = 0;
227
228   local $FS::cust_pkg::upgrade = 1; #go away after setup+start dates cleaned up for old customers
229
230
231   foreach my $table ( keys %$data ) {
232
233     my $class = "FS::$table";
234     eval "use $class;";
235     die $@ if $@;
236
237     if ( $class->can('_upgrade_data') ) {
238       warn "Upgrading $table...\n";
239
240       my $start = time;
241
242       $class->_upgrade_data(%opt);
243
244       # New interface for async upgrades: a class can declare a 
245       # "queueable_upgrade" method, which will run as part of the normal 
246       # upgrade, but if the -j option is passed, will instead be run from 
247       # the job queue.
248       if ( $class->can('queueable_upgrade') ) {
249         my $jobname = $class . '::queueable_upgrade';
250         my $num_jobs = FS::queue->count("job = '$jobname' and status != 'failed'");
251         if ($num_jobs > 0) {
252           warn "$class upgrade already scheduled.\n";
253         } else {
254           if ( $opt{'queue'} ) {
255             warn "Scheduling $class upgrade.\n";
256             my $job = FS::queue->new({ job => $jobname });
257             $job->insert($class, %opt);
258           } else {
259             $class->queueable_upgrade(%opt);
260           }
261         } #$num_jobs == 0
262       }
263
264       if ( $oldAutoCommit ) {
265         warn "  committing\n";
266         dbh->commit or die dbh->errstr;
267       }
268       
269       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
270       warn "  done in ". (time-$start). " seconds\n";
271
272     } else {
273       warn "WARNING: asked for upgrade of $table,".
274            " but FS::$table has no _upgrade_data method\n";
275     }
276
277 #    my @records = @{ $data->{$table} };
278 #
279 #    foreach my $record ( @records ) {
280 #      my $args = delete($record->{'_upgrade_args'}) || [];
281 #      my $object = $class->new( $record );
282 #      my $error = $object->insert( @$args );
283 #      die "error inserting record into $table: $error\n"
284 #        if $error;
285 #    }
286
287   }
288
289   local($FS::cust_main::ignore_expired_card) = 1;
290   #this is long-gone... would need to set an equivalent in cust_location #local($FS::cust_main::ignore_illegal_zip) = 1;
291   local($FS::cust_main::ignore_banned_card) = 1;
292   local($FS::cust_main::skip_fuzzyfiles) = 1;
293
294   local($FS::cust_payby::ignore_expired_card) = 1;
295   local($FS::cust_payby::ignore_banned_card) = 1;
296
297   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
298   # kind of a weird spot for this, but it's better than duplicating
299   # all this code in each class...
300   my @decrypt_tables = qw( cust_main cust_pay_void cust_pay cust_refund cust_pay_pending );
301   foreach my $table ( @decrypt_tables ) {
302       my @objects = qsearch({
303         'table'     => $table,
304         'hashref'   => {},
305         'extra_sql' => "WHERE payby NOT IN ( 'CARD', 'DCRD', 'CHEK', 'DCHK' ) ".
306                        " AND LENGTH(payinfo) > 100",
307       });
308       foreach my $object ( @objects ) {
309           my $payinfo = $object->decrypt($object->payinfo);
310           die "error decrypting payinfo" if $payinfo eq $object->payinfo;
311           $object->payinfo($payinfo);
312           my $error = $object->replace;
313           die $error if $error;
314       }
315   }
316
317 }
318
319 =item upgrade_data
320
321 =cut
322
323 sub upgrade_data {
324   my %opt = @_;
325
326   tie my %hash, 'Tie::IxHash', 
327
328     #payby conditions to new ones
329     'part_event_condition' => [],
330
331     #payby actions to new ones
332     'part_event' => [],
333
334     #cust_main (remove paycvv from history, locations, cust_payby, etc)
335     'cust_main' => [],
336
337     #contact -> cust_contact / prospect_contact
338     'contact' => [],
339
340     #msgcat
341     'msgcat' => [],
342
343     #reason type and reasons
344     'reason_type'     => [],
345     'cust_pkg_reason' => [],
346
347     #need part_pkg before cust_credit...
348     'part_pkg' => [],
349
350     #customer credits
351     'cust_credit' => [],
352
353     # reason / void_reason migration to reasonnum / void_reasonnum
354     'cust_credit_void' => [],
355     'cust_bill_void' => [],
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     #populate tax statuses
455     'tax_status' => [],
456   ;
457
458   \%hash;
459
460 }
461
462 =item upgrade_schema
463
464 =cut
465
466 sub upgrade_schema {
467   my %opt = @_;
468
469   my $data = upgrade_schema_data(%opt);
470
471   my $oldAutoCommit = $FS::UID::AutoCommit;
472   local $FS::UID::AutoCommit = 0;
473   local $FS::UID::AutoCommit = 0;
474
475   foreach my $table ( keys %$data ) {
476
477     my $class = "FS::$table";
478     eval "use $class;";
479     die $@ if $@;
480
481     if ( $class->can('_upgrade_schema') ) {
482       warn "Upgrading $table schema...\n";
483
484       my $start = time;
485
486       $class->_upgrade_schema(%opt);
487
488       if ( $oldAutoCommit ) {
489         warn "  committing\n";
490         dbh->commit or die dbh->errstr;
491       }
492       
493       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
494       warn "  done in ". (time-$start). " seconds\n";
495
496     } else {
497       warn "WARNING: asked for schema upgrade of $table,".
498            " but FS::$table has no _upgrade_schema method\n";
499     }
500
501   }
502
503 }
504
505 =item upgrade_schema_data
506
507 =cut
508
509 sub upgrade_schema_data {
510   my %opt = @_;
511
512   tie my %hash, 'Tie::IxHash', 
513
514     #fix classnum character(1)
515     'cust_bill_pkg_detail' => [],
516     #add necessary columns to RT schema
517     'TicketSystem' => [],
518
519   ;
520
521   \%hash;
522
523 }
524
525 sub upgrade_sqlradius {
526   #my %opt = @_;
527
528   my $conf = new FS::Conf;
529
530   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
531
532   foreach my $part_export ( @part_export ) {
533
534     my $errmsg = 'Error adding FreesideStatus to '.
535                  $part_export->option('datasrc'). ': ';
536
537     my $dbh = DBI->connect(
538       ( map $part_export->option($_), qw ( datasrc username password ) ),
539       { PrintError => 0, PrintWarn => 0 }
540     ) or do {
541       warn $errmsg.$DBI::errstr;
542       next;
543     };
544
545     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
546     my $group = "UserName";
547     $group .= ",Realm"
548       if ref($part_export) =~ /withdomain/
549       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
550
551     my $sth_alter = $dbh->prepare(
552       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
553     );
554     if ( $sth_alter ) {
555       if ( $sth_alter->execute ) {
556         my $sth_update = $dbh->prepare(
557          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
558         ) or die $errmsg.$dbh->errstr;
559         $sth_update->execute or die $errmsg.$sth_update->errstr;
560       } else {
561         my $error = $sth_alter->errstr;
562         warn $errmsg.$error
563           unless $error =~ /Duplicate column name/i  #mysql
564               || $error =~ /already exists/i;        #Pg
565 ;
566       }
567     } else {
568       my $error = $dbh->errstr;
569       warn $errmsg.$error; #unless $error =~ /exists/i;
570     }
571
572     my $sth_index = $dbh->prepare(
573       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
574     );
575     if ( $sth_index ) {
576       unless ( $sth_index->execute ) {
577         my $error = $sth_index->errstr;
578         warn $errmsg.$error
579           unless $error =~ /Duplicate key name/i #mysql
580               || $error =~ /already exists/i;    #Pg
581       }
582     } else {
583       my $error = $dbh->errstr;
584       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
585     }
586
587     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
588       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
589       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
590
591     my $sth = $dbh->prepare("SELECT UserName,
592                                     Realm,
593                                     $str2time max(AcctStartTime)),
594                                     $str2time max(AcctStopTime))
595                               FROM radacct
596                               WHERE FreesideStatus = 'done'
597                                 AND $times
598                               GROUP BY $group
599                             ")
600       or die $errmsg.$dbh->errstr;
601     $sth->execute() or die $errmsg.$sth->errstr;
602   
603     while (my $row = $sth->fetchrow_arrayref ) {
604       my ($username, $realm, $start, $stop) = @$row;
605   
606       $username = lc($username) unless $conf->exists('username-uppercase');
607
608       my $exportnum = $part_export->exportnum;
609       my $extra_sql = " AND exportnum = $exportnum ".
610                       " AND exportsvcnum IS NOT NULL ";
611
612       if ( ref($part_export) =~ /withdomain/ ) {
613         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
614                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
615       }
616   
617       my $svc_acct = qsearchs({
618         'select'    => 'svc_acct.*',
619         'table'     => 'svc_acct',
620         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
621                        'LEFT JOIN export_svc USING ( svcpart )',
622         'hashref'   => { 'username' => $username },
623         'extra_sql' => $extra_sql,
624       });
625
626       if ($svc_acct) {
627         $svc_acct->last_login($start)
628           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
629         $svc_acct->last_logout($stop)
630           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
631       }
632     }
633   }
634
635 }
636
637 =back
638
639 =head1 BUGS
640
641 Sure.
642
643 =head1 SEE ALSO
644
645 =cut
646
647 1;
648