default to a session cookie instead of setting an explicit timeout, weird timezone...
[freeside.git] / FS / FS / TaxEngine / compliance_solutions.pm
1 package FS::TaxEngine::compliance_solutions;
2
3 #some false laziness w/ suretax... uses/based on cch data?  or just imitating
4 # parts of their interface?
5
6 use strict;
7 use base qw( FS::TaxEngine );
8 use FS::Conf;
9 use FS::Record qw( dbh ); #qw( qsearch qsearchs dbh);
10 use Data::Dumper;
11 use Date::Format;
12 use Cpanel::JSON::XS;
13 use SOAP::Lite;
14
15 our $DEBUG = 1; # prints progress messages
16    $DEBUG = 2; # prints decoded request and response (noisy, be careful)
17 #   $DEBUG = 3; # prints raw response from the API, ridiculously unreadable
18
19 our $json = Cpanel::JSON::XS->new->pretty(1);
20
21 our %taxproduct_cache;
22
23 our $conf;
24
25 FS::UID->install_callback( sub {
26     $conf = FS::Conf->new;
27     # should we enable conf caching here?
28 });
29
30 our %REGCODE = ( # can be selected per agent
31 #  ''          => '99',
32   'ILEC'      => '00',
33   'IXC'       => '01',
34   'CLEC'      => '02',
35   'VOIP'      => '03',
36   'ISP'       => '04',
37   'Wireless'  => '05',
38 );
39
40 sub info {
41   { batch    => 0,
42     override => 0, #?
43   }
44 }
45
46 sub add_sale { } # nothing to do here
47
48 sub build_input {
49   my( $self, $cust_bill ) = @_;
50
51   my $cust_main = $cust_bill->cust_main;
52
53   %taxproduct_cache = ();
54
55   # assemble invoice line items 
56   my @lines = map { $self->build_input_item($_, $cust_bill, $cust_main) }
57                   $cust_bill->cust_bill_pkg;
58
59   return if !@lines;
60
61   return \@lines;
62
63 }
64
65 sub build_input_item {
66   my( $self, $cust_bill_pkg, $cust_bill, $cust_main ) = @_;
67
68   # get the part_pkg/fee for this line item, and the relevant part of the
69   # taxproduct cache
70   my $part_item = $cust_bill_pkg->part_X;
71   my $taxproduct_of_class = do {
72     my $part_id = $part_item->table . '#' . $part_item->get($part_item->primary_key);
73     $taxproduct_cache{$part_id} ||= {};
74   };
75
76   my @items = ();
77
78   my $recur_without_usage = $cust_bill_pkg->recur;
79
80   ###
81   # Usage charges
82   ###
83
84   # cursor all this stuff; data sets can be LARGE
85   # (if it gets really out of hand, we can also incrementally write JSON
86   # to a file)
87
88   my $details = FS::Cursor->new('cust_bill_pkg_detail', {
89       billpkgnum  => $cust_bill_pkg->billpkgnum,
90       amount      => { op => '>', value => 0 }
91   }, dbh() );
92   while ( my $cust_bill_pkg_detail = $details->fetch ) {
93
94     # look up the tax product for this class
95     my $classnum = $cust_bill_pkg_detail->classnum;
96     my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do {
97       my $part_pkg_taxproduct = $part_item->taxproduct($classnum);
98       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
99     };
100     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
101         ", usage class $classnum\n"
102         if !$taxproduct;
103
104     my $cdrs = FS::Cursor->new('cdr', {
105         detailnum       => $cust_bill_pkg_detail->detailnum,
106         freesidestatus  => 'done',
107     }, dbh() );
108     while ( my $cdr = $cdrs->fetch ) {
109       push @items, {
110         $self->generic_item($cust_bill, $cust_main),
111         record_type   => 'C',
112         unique_id     => 'cdr ' . $cdr->acctid.
113                          ' cust_bill_pkg '.$cust_bill_pkg->billpkgnum, 
114         productcode   => substr($taxproduct,0,4),
115         servicecode   => substr($taxproduct,4,3),
116         orig_Num      => $cdr->src,
117         term_Num      => $cdr->dst,
118         bill_Num      => $cdr->charged_party,
119         charge_amount => $cdr->rated_price, # 4 decimal places
120         minutes       => sprintf('%.1f', $cdr->billsec / 60 ),
121       };
122
123     } # while ($cdrs->fetch)
124
125     # decrement the recurring charge
126     $recur_without_usage -= $cust_bill_pkg_detail->amount;
127
128   } # while ($details->fetch)
129
130   ###
131   # Recurring charge
132   ###
133
134   if ( $recur_without_usage > 0 ) {
135     my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do {
136       my $part_pkg_taxproduct = $part_item->taxproduct('recur');
137       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
138     };
139     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
140         " recurring charge\n"
141         if !$taxproduct;
142
143     my %item = (
144       $self->generic_item($cust_bill, $cust_main),
145       record_type     => 'S',
146       unique_id       => 'cust_bill_pkg '. $cust_bill_pkg->billpkgnum. ' recur',
147       charge_amount   => $recur_without_usage,
148       productcode     => substr($taxproduct,0,4),
149       servicecode     => substr($taxproduct,4,3),
150     );
151
152     # when billing on cancellation there are no units
153     $item{units} = $self->{cancel} ? 0 : $cust_bill_pkg->units;
154
155     my $location =  $cust_bill_pkg->tax_location
156                  || ( $conf->exists('tax-ship_address')
157                         ? $cust_main->ship_location
158                         : $cust_main->bill_location
159                     );
160     $item{location_a} = $location->zip;
161
162     unshift @items, \%item;
163   }
164
165   ###
166   # Setup charge
167   ###
168
169   if ( $cust_bill_pkg->setup > 0 ) {
170     my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do {
171       my $part_pkg_taxproduct = $part_item->taxproduct('setup');
172       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
173     };
174     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
175         " setup charge\n"
176         if !$taxproduct;
177
178     my %item = (
179       $self->generic_item($cust_bill, $cust_main),
180       record_type     => 'S',
181       unique_id       => 'cust_bill_pkg '. $cust_bill_pkg->billpkgnum. ' setup',
182       charge_amount   => $cust_bill_pkg->setup,
183       productcode     => substr($taxproduct,0,4),
184       servicecode     => substr($taxproduct,4,3),
185       units           => $cust_bill_pkg->units,
186     );
187
188     my $location =  $cust_bill_pkg->tax_location
189                  || ( $conf->exists('tax-ship_address')
190                         ? $cust_main->ship_location
191                         : $cust_main->bill_location
192                     );
193     $item{location_a} = $location->zip;
194
195     unshift @items, \%item;
196   }
197
198   return @items;
199
200 }
201
202 sub generic_item {
203   my( $self, $cust_bill, $cust_main ) = @_;
204
205   warn 'regcode '. $self->{regcode} if $DEBUG;
206
207   (
208     account_number            => $cust_bill->custnum,
209     customer_type             => ( $cust_main->company =~ /\S/ ? '01' : '00' ),
210     invoice_date              => time2str('%Y%m%d', $cust_bill->_date),
211     invoice_number            => $cust_bill->invnum,
212     provider                  => $self->{regcode},
213     safe_harbor_override_flag => 'N',
214     exempt_code               => $cust_main->tax,
215   );
216
217 }
218
219 sub make_taxlines {
220   my( $self, $cust_bill ) = @_;
221
222   die "compliance_solutions-regulatory_code setting is not configured\n"
223     unless $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum);
224
225   $self->{regcode} = $REGCODE{ $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum) };
226
227   warn 'regcode '. $self->{regcode} if $DEBUG;
228
229   # assemble the request hash
230   my $input = $self->build_input($cust_bill);
231   if (!$input) {
232     warn "no taxable items in invoice; skipping Compliance Solutions request\n" if $DEBUG;
233     return;
234   }
235
236   warn "sending Compliance Solutions request\n" if $DEBUG;
237   my $request_json = $json->encode(
238     {
239       'access_code' => $conf->config('compliance_solutions-access_code'),
240       'reference'   => 'Invoice #'. $cust_bill->invnum,
241       'input'       => $input,
242     }
243   );
244   warn $request_json if $DEBUG > 1;
245   $cust_bill->taxengine_request($request_json);
246
247   my $soap = SOAP::Lite->service("http://tcms1.csilongwood.com/cgi-bin/taxcalc.wsdl");
248
249   $soap->soapversion('1.2'); #service appears to be flaky with the default 1.1
250
251   my $results = $soap->tax_rate($request_json);
252
253   my %json_result = %{ $json->decode( $results ) };
254   warn Dumper(%json_result) if $DEBUG > 1;
255
256   # handle $results is empty / API/connection failure?
257
258   # status OK
259   unless ( $json_result{status} =~ /^\s*OK\s*$/i ) {
260     warn Dumper($json_result{error_codes}) unless $DEBUG > 1;
261     die 'Compliance Solutions returned status '. $json_result{status}.
262            "; see log for error_codes detail\n";
263   }
264
265   # transmission_error No errors.
266   unless ( $json_result{transmission_error} =~ /^\s*No\s+errors\.\s*$/i ) {
267     warn Dumper($json_result{error_codes}) unless $DEBUG > 1;
268     die 'Compliance Solutions returned transmission_error '. $json_result{transmission_error}.
269            "; see log for error_codes detail\n";
270   }
271
272
273   # error_codes / No errors (for all records... check them individually in loop?
274
275   my @elements = ();
276
277   #handle the response
278   foreach my $tax_data ( @{ $json_result{tax_data} } ) {
279
280     # create a tax rate location if there isn't one yet
281     my $taxname = $tax_data->{descript};
282     my $tax_rate = FS::tax_rate->new({
283         data_vendor   => 'compliance_solutions',
284         taxname       => $taxname,
285         taxclassnum   => '',
286         taxauth       => $tax_data->{'taxauthtype'}, # federal / state / city / district
287         geocode       => $tax_data->{'geocode'},
288         tax           => 0, #not necessary because we query for rates on the
289         fee           => 0, # fly and only store this for the name -> code map??
290     });
291     my $error = $tax_rate->find_or_insert;
292     die "error inserting tax_rate record for '$taxname': $error\n"
293       if $error;
294     $tax_rate = $tax_rate->replace_old;
295
296     my $tax_rate_location = FS::tax_rate_location->new({
297         data_vendor => 'compliance_solutions',
298         geocode     => $tax_data->{'geocode'},
299         district    => $tax_data->{'geo_district'},
300         state       => $tax_data->{'geo_state'},
301         county      => $tax_data->{'geo_county'},
302         country     => 'US',
303     });
304     $error = $tax_rate_location->find_or_insert;
305     die 'error inserting tax_rate_location record for '.  $tax_data->{state}.
306         '/'. $tax_data->{country}. ' ('. $tax_data->{'geocode'}. "): $error\n"
307       if $error;
308     $tax_rate_location = $tax_rate_location->replace_old;
309
310     #unique id: a cust_bill_pkg (setup/recur) or cdr record
311
312     my $taxable_billpkgnum = '';
313     if ( $tax_data->{'unique_id'} =~ /^cust_bill_pkg (\d+)/ ) {
314       $taxable_billpkgnum = $1;
315     } elsif ( $tax_data->{'unique_id'} =~ /^cdr (\d+) cust_bill_pkg (\d+)$/ ) {
316       $taxable_billpkgnum = $2;
317     } else {
318       die 'unparseable unique_id '. $tax_data->{'unique_id'};
319     }
320
321     push @elements, FS::cust_bill_pkg_tax_rate_location->new({
322       taxable_billpkgnum  => $taxable_billpkgnum,
323       taxnum              => $tax_rate->taxnum,
324       taxtype             => 'FS::tax_rate',
325       taxratelocationnum  => $tax_rate_location->taxratelocationnum,
326       amount              => sprintf('%.2f', $tax_data->{taxamount}),
327     });
328
329   }
330
331   return @elements;
332 }
333
334 sub add_taxproduct {
335   my $class = shift;
336   my $desc = shift; # tax code and description, separated by a space.
337   if ($desc =~ s/^(\w{7}+) //) {
338     my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
339         'data_vendor' => 'compliance_solutions',
340         'taxproduct'  => $1,
341         'description' => $desc,
342     });
343     # $obj_or_error
344     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
345   } else {
346     return "illegal compliance solutions tax code '$desc'";
347   }
348 }
349
350 1;