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