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