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