contacts can be shared among customers / "duplicate contact emails", RT#27943
[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     #cust_main (remove paycvv from history)
313     'cust_main' => [],
314
315     #contact -> cust_contact / prospect_contact
316     'contact' => [],
317
318     #msgcat
319     'msgcat' => [],
320
321     #reason type and reasons
322     'reason_type'     => [],
323     'cust_pkg_reason' => [],
324
325     #need part_pkg before cust_credit...
326     'part_pkg' => [],
327
328     #customer credits
329     'cust_credit' => [],
330
331     #duplicate history records
332     'h_cust_svc'  => [],
333
334     #populate cust_pay.otaker
335     'cust_pay'    => [],
336
337     #populate part_pkg_taxclass for starters
338     'part_pkg_taxclass' => [],
339
340     #remove bad pending records
341     'cust_pay_pending' => [],
342
343     #replace invnum and pkgnum with billpkgnum
344     'cust_bill_pkg_detail' => [],
345
346     #usage_classes if we have none
347     'usage_class' => [],
348
349     #phone_type if we have none
350     'phone_type' => [],
351
352     #fixup access rights
353     'access_right' => [],
354
355     #change recur_flat and enable_prorate
356     'part_pkg_option' => [],
357
358     #add weights to pkg_category
359     'pkg_category' => [],
360
361     #cdrbatch fixes
362     'cdr' => [],
363
364     #otaker->usernum
365     'cust_attachment' => [],
366     #'cust_credit' => [],
367     #'cust_main' => [],
368     'cust_main_note' => [],
369     #'cust_pay' => [],
370     'cust_pay_void' => [],
371     'cust_pkg' => [],
372     #'cust_pkg_reason' => [],
373     'cust_pkg_discount' => [],
374     'cust_refund' => [],
375     'banned_pay' => [],
376
377     #default namespace
378     'payment_gateway' => [],
379
380     #migrate to templates
381     'msg_template' => [],
382
383     #return unprovisioned numbers to availability
384     'phone_avail' => [],
385
386     #insert scripcondition
387     'TicketSystem' => [],
388     
389     #insert LATA data if not already present
390     'lata' => [],
391     
392     #insert MSA data if not already present
393     'msa' => [],
394
395     # migrate to radius_group and groupnum instead of groupname
396     'radius_usergroup' => [],
397     'part_svc'         => [],
398     'part_export'      => [],
399
400     #insert default tower_sector if not present
401     'tower' => [],
402
403     #repair improperly deleted services
404     'cust_svc' => [],
405
406     #routernum/blocknum
407     'svc_broadband' => [],
408
409     #set up payment gateways if needed
410     'pay_batch' => [],
411
412     #flag monthly tax exemptions
413     'cust_tax_exempt_pkg' => [],
414
415     #kick off tax location history upgrade
416     'cust_bill_pkg' => [],
417
418     #fix taxable line item links
419     'cust_bill_pkg_tax_location' => [],
420
421     #populate state FIPS codes if not already done
422     'state' => [],
423
424     #populate tax statuses
425     'tax_status' => [],
426   ;
427
428   \%hash;
429
430 }
431
432 =item upgrade_schema
433
434 =cut
435
436 sub upgrade_schema {
437   my %opt = @_;
438
439   my $data = upgrade_schema_data(%opt);
440
441   my $oldAutoCommit = $FS::UID::AutoCommit;
442   local $FS::UID::AutoCommit = 0;
443   local $FS::UID::AutoCommit = 0;
444
445   foreach my $table ( keys %$data ) {
446
447     my $class = "FS::$table";
448     eval "use $class;";
449     die $@ if $@;
450
451     if ( $class->can('_upgrade_schema') ) {
452       warn "Upgrading $table schema...\n";
453
454       my $start = time;
455
456       $class->_upgrade_schema(%opt);
457
458       if ( $oldAutoCommit ) {
459         warn "  committing\n";
460         dbh->commit or die dbh->errstr;
461       }
462       
463       #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
464       warn "  done in ". (time-$start). " seconds\n";
465
466     } else {
467       warn "WARNING: asked for schema upgrade of $table,".
468            " but FS::$table has no _upgrade_schema method\n";
469     }
470
471   }
472
473 }
474
475 =item upgrade_schema_data
476
477 =cut
478
479 sub upgrade_schema_data {
480   my %opt = @_;
481
482   tie my %hash, 'Tie::IxHash', 
483
484     #fix classnum character(1)
485     'cust_bill_pkg_detail' => [],
486     #add necessary columns to RT schema
487     'TicketSystem' => [],
488
489   ;
490
491   \%hash;
492
493 }
494
495 sub upgrade_sqlradius {
496   #my %opt = @_;
497
498   my $conf = new FS::Conf;
499
500   my @part_export = FS::part_export::sqlradius->all_sqlradius_withaccounting();
501
502   foreach my $part_export ( @part_export ) {
503
504     my $errmsg = 'Error adding FreesideStatus to '.
505                  $part_export->option('datasrc'). ': ';
506
507     my $dbh = DBI->connect(
508       ( map $part_export->option($_), qw ( datasrc username password ) ),
509       { PrintError => 0, PrintWarn => 0 }
510     ) or do {
511       warn $errmsg.$DBI::errstr;
512       next;
513     };
514
515     my $str2time = str2time_sql( $dbh->{Driver}->{Name} );
516     my $group = "UserName";
517     $group .= ",Realm"
518       if ref($part_export) =~ /withdomain/
519       || $dbh->{Driver}->{Name} =~ /^Pg/; #hmm
520
521     my $sth_alter = $dbh->prepare(
522       "ALTER TABLE radacct ADD COLUMN FreesideStatus varchar(32) NULL"
523     );
524     if ( $sth_alter ) {
525       if ( $sth_alter->execute ) {
526         my $sth_update = $dbh->prepare(
527          "UPDATE radacct SET FreesideStatus = 'done' WHERE FreesideStatus IS NULL"
528         ) or die $errmsg.$dbh->errstr;
529         $sth_update->execute or die $errmsg.$sth_update->errstr;
530       } else {
531         my $error = $sth_alter->errstr;
532         warn $errmsg.$error
533           unless $error =~ /Duplicate column name/i  #mysql
534               || $error =~ /already exists/i;        #Pg
535 ;
536       }
537     } else {
538       my $error = $dbh->errstr;
539       warn $errmsg.$error; #unless $error =~ /exists/i;
540     }
541
542     my $sth_index = $dbh->prepare(
543       "CREATE INDEX FreesideStatus ON radacct ( FreesideStatus )"
544     );
545     if ( $sth_index ) {
546       unless ( $sth_index->execute ) {
547         my $error = $sth_index->errstr;
548         warn $errmsg.$error
549           unless $error =~ /Duplicate key name/i #mysql
550               || $error =~ /already exists/i;    #Pg
551       }
552     } else {
553       my $error = $dbh->errstr;
554       warn $errmsg.$error. ' (preparing statement)';#unless $error =~ /exists/i;
555     }
556
557     my $times = ($dbh->{Driver}->{Name} =~ /^mysql/)
558       ? ' AcctStartTime != 0 AND AcctStopTime != 0 '
559       : ' AcctStartTime IS NOT NULL AND AcctStopTime IS NOT NULL ';
560
561     my $sth = $dbh->prepare("SELECT UserName,
562                                     Realm,
563                                     $str2time max(AcctStartTime)),
564                                     $str2time max(AcctStopTime))
565                               FROM radacct
566                               WHERE FreesideStatus = 'done'
567                                 AND $times
568                               GROUP BY $group
569                             ")
570       or die $errmsg.$dbh->errstr;
571     $sth->execute() or die $errmsg.$sth->errstr;
572   
573     while (my $row = $sth->fetchrow_arrayref ) {
574       my ($username, $realm, $start, $stop) = @$row;
575   
576       $username = lc($username) unless $conf->exists('username-uppercase');
577
578       my $exportnum = $part_export->exportnum;
579       my $extra_sql = " AND exportnum = $exportnum ".
580                       " AND exportsvcnum IS NOT NULL ";
581
582       if ( ref($part_export) =~ /withdomain/ ) {
583         $extra_sql = " AND '$realm' = ( SELECT domain FROM svc_domain
584                          WHERE svc_domain.svcnum = svc_acct.domsvc ) ";
585       }
586   
587       my $svc_acct = qsearchs({
588         'select'    => 'svc_acct.*',
589         'table'     => 'svc_acct',
590         'addl_from' => 'LEFT JOIN cust_svc   USING ( svcnum )'.
591                        'LEFT JOIN export_svc USING ( svcpart )',
592         'hashref'   => { 'username' => $username },
593         'extra_sql' => $extra_sql,
594       });
595
596       if ($svc_acct) {
597         $svc_acct->last_login($start)
598           if $start && (!$svc_acct->last_login || $start > $svc_acct->last_login);
599         $svc_acct->last_logout($stop)
600           if $stop && (!$svc_acct->last_logout || $stop > $svc_acct->last_logout);
601       }
602     }
603   }
604
605 }
606
607 =back
608
609 =head1 BUGS
610
611 Sure.
612
613 =head1 SEE ALSO
614
615 =cut
616
617 1;
618