RT# 83122 Utility to issue credits against taxnums
[freeside.git] / FS / bin / freeside-issue-credit-for-taxnums
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 DateTime;
10 use DateTime::Format::DateParse;
11 use FS::cust_credit;
12 use FS::cust_credit_bill;
13 use FS::Log;
14 use FS::Record qw( qsearch qsearchs );
15 use FS::UID qw( adminsuidsetup );
16 use Getopt::Long;
17 use Pod::Usage;
18
19 # Begin transaction
20 local $FS::UID::AutoCommit = 0;
21
22 my (
23   $csv_dir,
24   $start_date,
25   $end_date,
26   $taxnums,
27   @taxnums,
28   $credit_reasonnum,
29   $credit_addlinfo,
30   $insert_credits,
31   $apply_credits,
32   $freeside_user,
33 );
34
35 GetOptions(
36   'csv_dir=s'           => \$csv_dir,
37   'start-date=s'        => \$start_date,
38   'end-date=s'          => \$end_date,
39   'taxnums=s'           => \@taxnums,
40   'credit-reasonnum:s'  => \$credit_reasonnum,
41   'credit-addlinfo:s'   => \$credit_addlinfo,
42   'insert-credits'      => \$insert_credits,
43   'apply-credits'       => \$apply_credits,
44 );
45
46 validate_opts();
47
48 print_opts();
49
50 my $dbh = adminsuidsetup( $freeside_user )
51   or die "Bad  username: $freeside_user\n";
52
53 my $log = FS::Log->new('freeside-issue-credit-for-taxnums');
54
55 my @tax_rows = get_tax_rows()
56   or die "No tax rows found matching search criteria\n";
57
58 say sprintf 'Found %s rows from cust_bill_pkg_tax_location', scalar @tax_rows;
59
60 write_tax_rows_csv_file( \@tax_rows );
61
62 my @credits = get_cust_credit_amounts( \@tax_rows );
63
64 # warn Dumper({ credits => \@credits }); exit;
65
66 write_cust_credit_summary_csv_file( @credits );
67
68 unless ( $insert_credits ) {
69   die "
70     Option --apply-credits was not specified, no credits written to customers
71
72     Please review the generated CSV files, and re-run with --apply-credits
73     to issue credit adjustments
74   \n\n"
75 }
76
77 apply_cust_credits( @credits );
78
79 $dbh->commit();
80 $FS::UID::AutoCommit = 1;
81 say "Done - credits written to database";
82
83 exit();
84
85 sub apply_cust_credits {
86   my @credits = shift;
87
88   my $csv_fn = "$csv_dir/cust_credit.csv";
89
90   open my $csv_fh, '>', $csv_fn
91     or die "Unable to write to CSV file $csv_fn: $!";
92
93   my @csv_cols = qw(
94     crednum
95     invnum
96     custnum
97     _date
98     amount
99     usernum
100     reasonnum
101     addlinfo
102   );
103
104   say $csv_fh join ',' => @csv_cols;
105
106   for my $credit ( @credits ) {
107
108     my $cust_credit = FS::cust_credit->new({
109       custnum    => $credit->{custnum},
110       amount     => $credit->{amount},
111       reasonnum  => $credit_reasonnum,
112       addlinfo   => $credit_addlinfo,
113       usernum    => 6, # nobody
114     });
115
116     if ( my $error = $cust_credit->insert ) {
117       die $error;
118     }
119
120     say $log->info(
121       sprintf 'Issued credit to custnum:%s for invnum:%s for amount %s',
122         $credit->{custnum},
123         $credit->{invnum},
124         $credit->{amount},
125     );
126
127     say $csv_fh join ',' => (
128       map { $_ =~ /\D/ ? qq["$_"] : $_ } (
129         $cust_credit->crednum,
130         $credit->{invnum},
131         $credit->{custnum},
132         map { $cust_credit->$_ }
133           qw/ _date amount usernum reasonnum addlinfo/
134       )
135     );
136
137     if ( $apply_credits ) {
138       my $cust_credit_bill = FS::cust_credit_bill->new({
139         crednum => $cust_credit->crednum,
140         invnum  => $credit->{invnum},
141         amount  => $credit->{amount},
142       });
143       if ( my $error = $cust_credit_bill->insert ) {
144         die $error;
145       }
146     }
147
148   }
149
150   close $csv_fh;
151
152   say sprintf 'Wrote %s customer credits to [%s]', scalar( @credits ), $csv_fn;
153 }
154
155 sub write_cust_credit_summary_csv_file {
156   my @credits = @_;
157
158   my $csv_fn = "$csv_dir/customer_adjustments.csv";
159
160   open my $csv_fh, '>', $csv_fn
161     or die "Unable to write to CSV file $csv_fn: $!";
162
163   say $csv_fh join ',' => qw(
164     credit_amount
165     custnum
166     invnum
167     first
168     last
169     address
170     city
171     state
172     zip
173   );
174
175   for my $credit ( @credits ) {
176     my $cust_main = qsearchs( cust_main => { custnum => $credit->{custnum} })
177       or die "Error finding custnum($credit->{custnum}) in database!";
178
179     say $csv_fh join ',' => (
180       map { $_ =~ /\D/ ? qq["$_"] : $_ } (
181         sprintf('%.2f', $credit->{amount}),
182         $credit->{custnum},
183         $credit->{invnum},
184         $cust_main->first,
185         $cust_main->last,
186         $cust_main->ship_location->address1,
187         $cust_main->ship_location->city,
188         $cust_main->ship_location->state,
189         $cust_main->ship_location->zip
190       ),
191     );
192   }
193
194   close $csv_fh;
195
196   say sprintf 'Wrote %s customer credits to [%s]', scalar( @credits ), $csv_fn;
197 }
198
199 sub get_cust_credit_amounts {
200   my $tax_rows = shift;
201
202   my @credits;
203
204   for my $row (@$tax_rows ) {
205     push @credits, {
206       custnum => $row->cust_bill_pkg->cust_bill->custnum,
207       invnum  => $row->cust_bill_pkg->cust_bill->invnum,
208       amount  => $row->amount,
209     };
210   }
211
212   @credits;
213 }
214
215 sub write_tax_rows_csv_file {
216
217   my $tax_rows = shift;
218
219   my $csv_fn = "$csv_dir/cust_bill_pkg_tax_location.csv";
220
221   open my $csv_fh, '>', $csv_fn
222     or die "Unable to write to CSV file $csv_fn: $!";
223
224   my @cols = qw(
225     billpkgtaxlocationnum
226     billpkgnum
227     taxnum
228     taxtype
229     pkgnum
230     locationnum
231     amount
232     currency
233     taxable_billpkgnum
234     custnum
235     invnum
236   );
237
238   say $csv_fh join ',' => @cols;
239
240   for my $row ( @$tax_rows ) {
241     say $csv_fh join ',' => (
242       (
243         map { $_ =~ /\D/ ? qq["$_"] : $_ }
244         map { $row->$_ }
245         @cols
246       ),
247       $row->cust_bill_pkg->cust_bill->custnum,
248       $row->cust_bill_pkg->invnum,
249     );
250   }
251
252   close $csv_fh;
253
254   say sprintf 'Wrote %s matched rows into [%s]', scalar(@$tax_rows), $csv_fn;
255
256 }
257
258 sub get_tax_rows {
259   my $start_epoch =
260     DateTime::Format::DateParse
261       ->parse_datetime( $start_date )
262       ->set_hour(0)
263       ->set_minute(0)
264       ->set_second(0)
265       ->epoch();
266   my $end_eopch =
267     DateTime::Format::DateParse
268       ->parse_datetime( $end_date )
269       ->set_hour(23)
270       ->set_minute(59)
271       ->set_second(59)
272       ->epoch();
273
274   return qsearch({
275     table => 'cust_bill_pkg_tax_location',
276     addl_from => "
277       LEFT JOIN cust_bill_pkg USING (billpkgnum)
278       LEFT JOIN cust_bill ON cust_bill_pkg.invnum = cust_bill.invnum
279     ",
280     extra_sql => "
281       WHERE cust_bill_pkg_tax_location.taxnum IN (".join(',',@taxnums).")
282       AND taxtype = 'FS::cust_main_county'
283       AND cust_bill._date >= $start_epoch
284       AND cust_bill._date <= $end_eopch
285     "
286   });
287 }
288
289 sub validate_opts {
290
291   $freeside_user = shift @ARGV
292     or error_and_help('freesidee_user parameter required');
293
294   error_and_help( '--csv_dir is required' )
295     unless $csv_dir;
296   error_and_help( '--start_date is required' )
297     unless $start_date;
298   error_and_help( '--end-date is required' )
299     unless $end_date;
300   error_and_help( '--taxnums is required' )
301     unless @taxnums;
302   error_and_help( '--credit-reasonnum is required with --apply-credits' )
303     if $insert_credits && !$credit_reasonnum;
304   error_and_help( '--credit-addlinfo is required with --apply-credits' )
305     if $insert_credits && !$credit_addlinfo;
306
307   error_and_help( "csv dir ($csv_dir) is not a writable directoryu" )
308     unless -d $csv_dir && -r $csv_dir;
309
310   error_and_help( "start_date($start_date) is not a valid date string")
311     unless DateTime::Format::DateParse->parse_datetime( $start_date );
312   error_and_help( "end_date($end_date) is not a valid date string")
313     unless DateTime::Format::DateParse->parse_datetime( $end_date );
314
315   @taxnums = split(/,/,join(',',@taxnums));
316   error_and_help( "taxnum($_) is not a valid integer" )
317     for grep { $_ =~ /\D/ } @taxnums;
318
319   error_and_help( "credit-reasonnum($credit_reasonnum) is not a valid integer" )
320     if $credit_reasonnum && $credit_reasonnum =~ /\D/;
321 }
322
323 sub print_opts {
324   $Data::Dumper::Sortkeys = 1;
325   $Data::Dumper::Indent   = 1;
326   $Data::Dumper::Varname  = 'OPTIONS';
327
328   say "\nProceeding with options:\n";
329
330   say Dumper({
331     '--csv_dir'          => $csv_dir,
332     '--start-date'       => $start_date,
333     '--end_date'         => $end_date,
334     '--taxnums'          => join(',',@taxnums),
335     '--credit-reasonnum' => $credit_reasonnum || 'undef',
336     '--credit-addlinfo'  => $credit_addlinfo || 'undef',
337     '--insert-credits'   => $insert_credits ? 'True' : 'False',
338     '--apply-credits'    => $apply_credits ? 'True' : 'False',
339   })."\n";
340
341   if ( $insert_credits ) {
342     print "\nYou have chosed to write credits to the database\n"
343         . "Please review your choices\n\n"
344         . "Continue? [y/N]";
345     my $yn = <STDIN>; chomp $yn;
346     die "ABORT!\n\n" unless lc $yn eq 'y';
347   }
348 }
349
350 sub error_and_help {
351   pod2usage({
352     -message => sprintf( "\n\nError:\n\t%s\n\n", shift ),
353     -exitval => 2,
354     verbose => 1,
355   });
356 }
357
358 __END__
359
360 =head1 NAME
361
362 freeside-issue-credit-for-taxnums
363
364 =head1 SYNOPSIS
365
366 freeside freeside-issue-credit-for-taxnums [options] [freeside_user]
367
368 =head1 OPTIONS
369
370 =over 4
371
372 =item B<--help>
373
374 Display help and exit
375
376 =item B<--csv_dir> [directory]
377
378 Directory to save CSV reports into
379
380 =over 4
381
382 =item cust_bill_pkg_location.csv
383
384 Contains a list of all rows from cust_bill_pkg to be credited
385
386 =item cust_adjustments.csv
387
388 Contains a list of all intended customer adjustments amounts
389
390 =item cust_credit.csv
391
392 Contains all rows created in cust_credit to issue customer adjustments
393
394 =back
395
396 =item B<--start-date> [yyyy-mm-dd]
397
398 The start of the date range to search for invoices containing taxes to credit
399
400 =item B<--end-date> [yyyy-mm-dd]
401
402 The end of the date range to search for invoices containing taxes to credit
403
404 =item B<--taxnums> [123,124,125,126]
405
406 A comma separated list, with no spaces, of taxnums to issue credits for
407
408 =item B<--credit-reasonnum> [22]
409
410 The credit num to be attached to issued credits
411
412 =item B<--credit-addlinfo> "[Credits happen for this reason]"
413
414 Comment field attached to issued creits.  Enclose text within quotes.
415
416 =item B<--insert-credits>
417
418 Unless this flag is set, no changes will be written to customer accounts
419
420 =item B<--apply-credits>
421
422 If this flag is set, created credits will be applied to the original bill
423 that created the charge to be refunded.  If you want the credit to be
424 created as an unapplied credit, do not set this flag
425
426 =back
427
428 =head1 DESCRIPTION
429
430 Tool to issue credit to customers when taxes were charged in error
431
432 Given a list of taxnums, and a date range, utility will compile a CSV report
433 of customer charges for those taxnums.
434
435 When directed, utility will issue a credit to the account of each of those
436 customers, and generate a CSV report describing those credits for reporting
437
438 =cut