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