tax engine refactoring for Avalara and Billsoft tax vendors, #25718
[freeside.git] / FS / FS / part_pkg_taxproduct.pm
1 package FS::part_pkg_taxproduct;
2
3 use strict;
4 use vars qw( @ISA $delete_kludge );
5 use FS::Record qw( qsearch dbh );
6 use Text::CSV_XS;
7
8 @ISA = qw(FS::Record);
9 $delete_kludge = 0;
10
11 =head1 NAME
12
13 FS::part_pkg_taxproduct - Object methods for part_pkg_taxproduct records
14
15 =head1 SYNOPSIS
16
17   use FS::part_pkg_taxproduct;
18
19   $record = new FS::part_pkg_taxproduct \%hash;
20   $record = new FS::part_pkg_taxproduct { 'column' => 'value' };
21
22   $error = $record->insert;
23
24   $error = $new_record->replace($old_record);
25
26   $error = $record->delete;
27
28   $error = $record->check;
29
30 =head1 DESCRIPTION
31
32 An FS::part_pkg_taxproduct object represents a tax product.
33 FS::part_pkg_taxproduct inherits from FS::Record.  The following fields are
34 currently supported:
35
36 =over 4
37
38 =item taxproductnum
39
40 Primary key
41
42 =item data_vendor
43
44 Tax data vendor
45
46 =item taxproduct
47
48 Tax product id from the vendor
49
50 =item description
51
52 A human readable description of the id in taxproduct
53
54 =back
55
56 =head1 METHODS
57
58 =over 4
59
60 =item new HASHREF
61
62 Creates a new tax product.  To add the tax product to the database, see L<"insert">.
63
64 Note that this stores the hash reference, not a distinct copy of the hash it
65 points to.  You can ask the object for a copy with the I<hash> method.
66
67 =cut
68
69 sub table { 'part_pkg_taxproduct'; }
70
71 =item insert
72
73 Adds this record to the database.  If there is an error, returns the error,
74 otherwise returns false.
75
76 =cut
77
78 =item delete
79
80 Delete this record from the database.
81
82 =cut
83
84 sub delete {
85   my $self = shift;
86
87   return "Can't delete a tax product which has attached package tax rates!"
88     if qsearch( 'part_pkg_taxrate', { 'taxproductnum' => $self->taxproductnum } );
89
90   unless ( $delete_kludge ) {
91     return "Can't delete a tax product which has attached packages!"
92       if qsearch( 'part_pkg', { 'taxproductnum' => $self->taxproductnum } );
93   }
94
95   $self->SUPER::delete(@_);
96 }
97
98 =item replace OLD_RECORD
99
100 Replaces the OLD_RECORD with this one in the database.  If there is an error,
101 returns the error, otherwise returns false.
102
103 =cut
104
105 =item check
106
107 Checks all fields to make sure this is a valid tax product.  If there is
108 an error, returns the error, otherwise returns false.  Called by the insert
109 and replace methods.
110
111 =cut
112
113 sub check {
114   my $self = shift;
115
116   my $error = 
117     $self->ut_numbern('taxproductnum')
118     || $self->ut_textn('data_vendor')
119     || $self->ut_text('taxproduct')
120     || $self->ut_textn('description')
121   ;
122   return $error if $error;
123
124   $self->SUPER::check;
125 }
126
127 =item part_pkg_taxrate GEOCODE
128
129 Returns the L<FS::part_pkg_taxrate> records (tax definitions) that can apply 
130 to this tax product category in the location identified by GEOCODE.
131
132 =cut
133
134 # actually only returns one arbitrary record for each taxclassnum, making 
135 # it useful only for retrieving the taxclassnums
136
137 sub part_pkg_taxrate {
138   my $self = shift;
139   my $data_vendor = $self->data_vendor; # because duh
140   my $geocode = shift;
141
142   my $dbh = dbh;
143
144   # CCH oddness in m2m
145   my $extra_sql .= "AND part_pkg_taxrate.data_vendor = '$data_vendor' ".
146                    "AND (".
147     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
148                  qw(10 5 2)
149         ).
150     ')';
151   # much more CCH oddness in m2m -- this is kludgy
152   my $tpnums = join(',',
153     map { $_->taxproductnum }
154     $self->expand_cch_taxproduct
155   );
156   $extra_sql .= "AND taxproductnum IN($tpnums)";
157
158   my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
159   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
160   my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
161
162   # should qsearch preface columns with the table to facilitate joins?
163   qsearch( { 'table'     => 'part_pkg_taxrate',
164              'select'    => $select,
165              'hashref'   => { 'taxable' => 'Y' },
166              'addl_from' => $addl_from,
167              'extra_sql' => $extra_sql,
168              'order_by'  => $order_by,
169          } );
170 }
171
172 =item expand_cch_taxproduct
173
174 Returns the full set of part_pkg_taxproduct records that are "implied" by 
175 this one.
176
177 =cut
178
179 sub expand_cch_taxproduct {
180   my $self = shift;
181   my $class = shift;
182
183   my ($a,$b,$c,$d) = split ':', $self->taxproduct;
184   $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
185   my $taxproducts = join(',',
186     "'${a}:${b}:${c}:${d}'",
187     "'${a}:${b}:${c}:'",
188     "'${a}:${b}::${d}'",
189     "'${a}:${b}::'"
190   );
191   qsearch( {
192       'table'     => 'part_pkg_taxproduct',
193       'hashref'   => { 'data_vendor'=>'cch' },
194       'extra_sql' => "AND taxproduct IN($taxproducts)",
195   } );
196 }
197
198
199 =back
200
201 =cut
202
203 sub batch_import {
204   my ($param, $job) = @_;
205
206   my $oldAutoCommit = $FS::UID::AutoCommit;
207   local $FS::UID::AutoCommit = 0;
208   my $dbh = dbh;
209
210   my $fh = $param->{filehandle};
211   my $format = $param->{format};
212   die "unsupported part_pkg_taxproduct format '$format'"
213     unless $format eq 'billsoft';
214
215   # this is slightly silly
216   my @lines = <$fh>;
217   my $lines = scalar @lines;
218   seek($fh, 0, 0);
219   
220   my $imported = 0;
221   my $csv = Text::CSV_XS->new;
222   # fields: taxproduct, description
223   while ( my $row = $csv->getline($fh) ) {
224     if (!defined $row) {
225       $dbh->rollback if $oldAutoCommit;
226       return "can't parse: ". $csv->error_input();
227     }
228
229     if ( $job ) {
230       $job->update_statustext(
231         int( 100 * $imported / $lines ) . ',Inserting tax product records'
232       );
233     }
234
235     my $new = FS::part_pkg_taxproduct->new({
236         'data_vendor' => 'billsoft',
237         'taxproduct'  => $row->[0],
238         'description' => $row->[1],
239     });
240     my $error = $new->insert;
241     if ( $error ) {
242       $dbh->rollback if $oldAutoCommit;
243       return "error inserting part_pkg_taxproduct: $error\n";
244     }
245     $imported++;
246   }
247
248   $dbh->commit if $oldAutoCommit;
249   return '';
250 }
251
252 =head1 BUGS
253
254 Confusingly named.  It has nothing to do with part_pkg.
255
256 =head1 SEE ALSO
257
258 L<FS::Record>, schema.html from the base documentation.
259
260 =cut
261
262 1;
263