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