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