default to a session cookie instead of setting an explicit timeout, weird timezone...
[freeside.git] / FS / bin / freeside-wa-tax-table-resolve
1 #!/usr/bin/env perl
2 use v5.10;
3 use strict;
4 use warnings;
5
6 our $VERSION = '1.0';
7
8 use Data::Dumper;
9 use FS::cust_main_county;
10 use FS::Log;
11 use FS::Record qw( qsearch qsearchs );
12 use FS::UID qw( adminsuidsetup );
13 use Getopt::Long;
14 use Pod::Usage;
15
16 # Begin transaction
17 local $FS::UID::AutoCommit = 0;
18
19 my(
20   $dbh,
21   $freeside_user,
22   $opt_check,
23   $opt_fix_usf,
24   @opt_merge,
25   $opt_merge_all,
26   @opt_set_source_null,
27 );
28
29 GetOptions(
30   'check'             => \$opt_check,
31   'fix-usf'           => \$opt_fix_usf,
32   'merge=s'           => \@opt_merge,
33   'merge-all'         => \$opt_merge_all,
34   'set-source-null=s' => \@opt_set_source_null,
35 );
36 @opt_merge = split(',',join(',',@opt_merge));
37 @opt_set_source_null = split(',',join(',',@opt_set_source_null));
38
39
40 # say Dumper({
41 #   check => $opt_check,
42 #   merge => \@opt_merge,
43 #   set_source_numm => \@opt_set_source_null,
44 # });
45
46 validate_opts();
47
48 $dbh = adminsuidsetup( $freeside_user )
49   or die "Bad  username: $freeside_user\n";
50
51 my $log = FS::Log->new('freeside-wa-tax-table-resolve');
52
53 if ( $opt_check ) {
54   check();
55 } elsif ( @opt_merge ) {
56   merge();
57 } elsif ( @opt_set_source_null ) {
58   set_source_null();
59 } elsif ( $opt_merge_all ) {
60   merge_all();
61 } elsif ( $opt_fix_usf ) {
62   fix_usf();
63 } else {
64   error_and_help('No options selected');
65 }
66
67 # Commit transaction
68 $dbh->commit;
69 local $FS::UID::AutoCommit = 1;
70
71 exit;
72
73
74 sub set_source_null {
75   my @cust_main_county;
76   for my $taxnum ( @opt_set_source_null ) {
77     my $row = qsearchs( cust_main_county => { taxnum => $taxnum } );
78     if ( $row ) {
79       push @cust_main_county, $row;
80     } else {
81       error_and_help("Invalid taxnum specified: $taxnum");
82     }
83   }
84
85   say "=== Specified tax rows ===";
86   print_taxnum($_) for @cust_main_county;
87
88   confirm_to_continue("
89
90     The source column will be set to NULL for each of the
91     tax rows listed.  The tax row will no longer be managed
92     by the washington state sales tax table update utilities.
93
94     The listed taxes should be manually created taxes, that
95     were never intended to be managed by the auto updater.
96
97   ");
98
99   for my $row ( @cust_main_county ) {
100
101     $row->setfield( source => undef );
102     my $error = $row->replace;
103
104     if ( $error ) {
105       $dbh->rollback;
106
107       my $message = sprintf 'Error setting source=null taxnum %s: %s',
108           $row->taxnum, $error;
109
110       $log->error( $message );
111       say $message;
112
113       return;
114     }
115
116     my $message = sprintf 'Source column set to null for taxnum %s',
117       $row->taxnum;
118
119     $log->warn( $message );
120     say $message;
121   }
122 }
123
124 sub merge {
125   my $source = qsearchs( cust_main_county => { taxnum => $opt_merge[0] });
126   my $target = qsearchs( cust_main_county => { taxnum => $opt_merge[1] });
127
128   error_and_help("Invalid source taxnum: $opt_merge[0]")
129     unless $source;
130   error_and_help("Invalid target taxnum: $opt_merge[1]")
131     unless $target;
132
133   local $| = 1; # disable output buffering
134
135   say '==== source row ====';
136   print_taxnum( $source );
137
138   say '==== target row ====';
139   print_taxnum( $target );
140
141   confirm_to_continue("
142   
143     The source tax will be merged into the target tax.
144     All references to the source tax on customer invoices
145     will be replaced with references to the target tax.
146     The source tax will be removed from the tax tables.
147
148   ");
149
150   merge_into( $source, $target );
151 }
152
153 sub merge_into {
154   my ( $source, $target ) = @_;
155
156   local $@;
157   eval { $source->_merge_into( $target, { identical_record_check => 0 } ) };
158   if ( $@ ) {
159     $dbh->rollback;
160   
161     my $message = sprintf 'Failed to merge wa sales tax %s into %s: %s',
162         $source->taxnum, $target->taxnum, $@;
163
164     say $message;
165     $log->error( $message );
166
167   } else {
168     my $message = sprintf 'Merged wa sales tax %s into %s for district %s',
169         $source->taxnum, $target->taxnum, $source->district;
170
171     say $message;
172     $log->warn( $message );
173   }
174 }
175
176 sub merge_all {
177   my @dupes = FS::cust_main_county->find_wa_tax_dupes;
178
179   unless ( @dupes ) {
180     say 'No duplicate tax rows detected for WA sales tax districts';
181     return;
182   }
183
184   confirm_to_continue(sprintf "
185
186     %s blocking duplicate rows detected
187
188     Duplicate rows will be merged using FS::cust_main_county::_merge_into()
189
190     Rows are considered duplicates when they:
191     - Share the same tax class
192     - Share the same district
193     - Contain 'wa_sales' in the source column
194
195   ", scalar @dupes);
196
197   # Sort dupes into buckets to be merged, by taxclass and district
198   # $to_merge{taxclass}->{district} = [ @rows_to_merge ]
199   my %to_merge;
200   for my $row ( @dupes ) {
201     my $taxclass = $row->taxclass || 'none';
202     $to_merge{$taxclass} ||= {};
203     $to_merge{$taxclass}->{$row->district} ||= [];
204     push @{ $to_merge{$taxclass}->{$row->district} }, $row;
205   }
206
207   # Merge the duplicates
208   for my $taxclass ( keys %to_merge ) {
209     for my $district ( keys %{ $to_merge{$taxclass} }) {
210
211       # Keep the first row in the list as the target.
212       # Merge the remaining rows into the target
213       my $rows = $to_merge{$taxclass}->{$district};
214       my $target = shift @$rows;
215
216       while ( @$rows ) {
217         merge_into( shift(@$rows), $target );
218       }
219     }
220   }
221
222   say "
223
224     Merge operations completed
225
226     Please run freeside-wa-tax-table-update.  This will update
227     the merged district rows with correct county and city names
228
229   ";
230
231 }
232
233 sub fix_usf {
234   confirm_to_continue("
235
236     Search for duplicate districts within the tax tables with
237     - duplicate district column values
238     - source = NULL
239     - district = NOT NULL
240     - taxclass = USF
241     - tax > 17
242
243     Merge these rows into a single USF row for each tax district
244
245   ");
246
247   my @rows = qsearch( cust_main_county => {
248     taxclass => 'USF',
249     source   => undef,
250     state    => 'WA',
251     country  => 'US',
252     tax      => { op => '>',  value => 17 },
253     district => { op => '!=', value => undef },
254   });
255
256   my %to_merge;
257   for my $row (@rows) {
258     $to_merge{$row->district} ||= [];
259     push @{ $to_merge{$row->district} }, $row;
260   }
261
262   for my $dist_rows ( values %to_merge ) {
263     my $target = shift @$dist_rows;
264     while ( @$dist_rows ) {
265       merge_into( shift(@$dist_rows), $target );
266     }
267   }
268
269   say "
270
271     USF clean up completed
272
273     Please run freeside-wa-tax-table-update.  This will update
274     the merged district rows with correct county and city names
275
276   ";
277 }
278
279 sub validate_opts {
280
281   $freeside_user = shift @ARGV
282     or error_and_help('freeside_user parameter required');
283
284   if ( @opt_merge ) {
285     error_and_help(( '--merge requires a comma separated list of two taxnums'))
286       unless scalar(@opt_merge) == 2
287           && $opt_merge[0] =~ /^\d+$/
288           && $opt_merge[1] =~ /^\d+$/;
289   }
290
291   for my $taxnum ( @opt_set_source_null ) {
292     if ( $taxnum =~ /\D/ ) {
293       error_and_help( "Invalid taxnum ($taxnum)" );
294     }
295   }
296 }
297
298 sub check {
299   my @dupes = FS::cust_main_county->find_wa_tax_dupes;
300
301   unless ( @dupes ) {
302     say 'No duplicate tax rows detected for WA sales tax districts';
303     return;
304   }
305
306   say sprintf '=== Detected %s duplicate tax rows ===', scalar @dupes;
307
308   print_taxnum($_) for sort { $a->district <=> $b->district } @dupes;
309
310   $log->error(
311     sprintf 'Detected %s duplicate wa sales tax rows: %s',
312       scalar( @dupes ),
313       join( ',', map{ $_->taxnum } @dupes )
314   );
315
316   say "
317
318     Rows are considered duplicates when they:
319     - Share the same tax class
320     - Share the same district
321     - Contain 'wa_sales' in the source column
322
323   ";
324 }
325
326 sub print_taxnum {
327   my $taxnum = shift;
328   die unless ref $taxnum;
329
330   say 'taxnum: '.$taxnum->taxnum;
331   say join "\n" => (
332     map { sprintf('  %s:%s', $_, $taxnum->$_ ) }
333     qw/district city county state tax taxname taxclass source/
334   );
335   print "\n";
336 }
337
338 sub confirm_to_continue {
339   say shift;
340   print "Confirm: [y/N]: ";
341   my $yn = <STDIN>;
342   chomp $yn;
343   if ( lc $yn ne 'y' ) {
344     say "\nAborted\n";
345     exit;
346   }
347 }
348
349 sub error_and_help {
350   pod2usage({
351     -message => sprintf( "\n\nError:\n\t%s\n\n", shift ),
352     -exitval => 2,
353     verbose => 1,
354   });
355   exit;
356 }
357
358 __END__
359
360 =head1 name
361
362 freeside-wa-tax-table-resolve
363
364 =head1 SYNOPSIS
365
366   freeside-wa-tax-table-resolve --help
367   freeside-wa-tax-table-resolve --check [freeside_user]
368   freeside-wa-tax-table-resolve --merge 123,234 [freeside_user]
369   freeside-wa-tax-table-resolve --set-source-null 1337,6553 [freeside_user]
370   freeside-wa-tax-table-resolve --merge-all [freeside_user]
371   freeside-wa-tax-table-resolve --fix-usf [freeside_user]
372
373 =head1 OPTIONS
374
375 =over 4
376
377 =item B<--help>
378
379 Display help and exit
380
381 =item B<--check>
382
383 Display info on any taxnums considered blocking duplicates
384
385 =item B<--merge> [source-taxnum],[target-taxnum]
386
387 Update all records referring to [source-taxnum], so they now
388 refer to [target-taxnum].  [source-taxnum] is deleted.
389
390 Used to merge duplicate taxnums
391
392 =item B<--set-source-null> [taxnum],[taxnum],...
393
394 Update all records for the given taxnums, by setting the
395 I<source> column to NULL.
396
397 Used for manually entered tax entries, incorrectly labelled
398 as created and managed for Washington State Sales Taxes
399
400 =item B<--merge-all>
401
402 Automatically merge all blocking duplicate taxnums.
403
404 If after reviewing all blocking duplicate taxnum rows with --check,
405 if all duplicate rows are safe to merge, this option will merge them all.
406
407 =item B<--fix-usf>
408
409 Fix routine for a particular USF issue
410
411 Search for duplicate districts within the tax tables with
412
413   - duplicate district column values
414   - source = NULL
415   - district = NOT NULL
416   - taxclass = USF
417   - tax > 17
418
419 Merge these rows into a single USF row for each tax district
420
421 =back
422
423 =head1 DESCRIPTION
424
425 Tool to resolve tax table issues for customer using Washington state
426 sales tax districts.
427
428 If Freeside detects duplicate rows within the wa sales tax tables,
429 tax table updates are blocked, and a log message directs the
430 sysadmin to this tool.
431
432 Duplicate rows may be manually entered taxes, not related
433 to WA sales tax.  Or duplicate rows may have been manually entered
434 into freeside for other tax purposes.
435
436 Use --check to display which tax entries were detected as dupes.
437
438 For each tax entry, decide if it is a duplicate wa sales tax entry,
439 or some other manually entered tax.
440
441 if the row is a duplicate, merge the duplicates with the --merge
442 option of this script
443
444 If the row is a manually entered tax, not for WA state sales taxes,
445 keep the tax but remove the flag incorrectly labeling it as WA state
446 sales taxes with the --set-source-null option of this script
447
448 Once --check no longer returns problematic tax entries, the
449 wa state tax tables will be able to complete their automatic
450 tax rate updates
451
452 =cut