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