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