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