1 package FS::TaxEngine::compliance_solutions;
3 #some false laziness w/ suretax... uses/based on cch data? or just imitating
4 # parts of their interface?
7 use base qw( FS::TaxEngine );
9 use FS::Record qw( dbh ); #qw( qsearch qsearchs dbh);
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
19 our $json = Cpanel::JSON::XS->new->pretty(1);
21 our %taxproduct_cache;
25 FS::UID->install_callback( sub {
26 $conf = FS::Conf->new;
27 # should we enable conf caching here?
30 our %REGCODE = ( # can be selected per agent
46 sub add_sale { } # nothing to do here
49 my( $self, $cust_bill ) = @_;
51 my $cust_main = $cust_bill->cust_main;
53 %taxproduct_cache = ();
55 # assemble invoice line items
56 my @lines = map { $self->build_input_item($_, $cust_bill, $cust_main) }
57 $cust_bill->cust_bill_pkg;
65 sub build_input_item {
66 my( $self, $cust_bill_pkg, $cust_bill, $cust_main ) = @_;
68 # get the part_pkg/fee for this line item, and the relevant part of the
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} ||= {};
78 my $recur_without_usage = $cust_bill_pkg->recur;
84 # cursor all this stuff; data sets can be LARGE
85 # (if it gets really out of hand, we can also incrementally write JSON
88 my $details = FS::Cursor->new('cust_bill_pkg_detail', {
89 billpkgnum => $cust_bill_pkg->billpkgnum,
90 amount => { op => '>', value => 0 }
92 while ( my $cust_bill_pkg_detail = $details->fetch ) {
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 : '';
100 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
101 ", usage class $classnum\n"
104 my $cdrs = FS::Cursor->new('cdr', {
105 detailnum => $cust_bill_pkg_detail->detailnum,
106 freesidestatus => 'done',
108 while ( my $cdr = $cdrs->fetch ) {
110 $self->generic_item($cust_bill, $cust_main),
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 ),
123 } # while ($cdrs->fetch)
125 # decrement the recurring charge
126 $recur_without_usage -= $cust_bill_pkg_detail->amount;
128 } # while ($details->fetch)
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 : '';
139 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
140 " recurring charge\n"
144 $self->generic_item($cust_bill, $cust_main),
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),
152 # when billing on cancellation there are no units
153 $item{units} = $self->{cancel} ? 0 : $cust_bill_pkg->units;
155 my $location = $cust_bill_pkg->tax_location
156 || ( $conf->exists('tax-ship_address')
157 ? $cust_main->ship_location
158 : $cust_main->bill_location
160 $item{location_a} = $location->zip;
162 unshift @items, \%item;
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 : '';
174 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
179 $self->generic_item($cust_bill, $cust_main),
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,
188 my $location = $cust_bill_pkg->tax_location
189 || ( $conf->exists('tax-ship_address')
190 ? $cust_main->ship_location
191 : $cust_main->bill_location
193 $item{location_a} = $location->zip;
195 unshift @items, \%item;
203 my( $self, $cust_bill, $cust_main ) = @_;
205 warn 'regcode '. $self->{regcode} if $DEBUG;
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,
220 my( $self, $cust_bill ) = @_;
222 die "compliance_solutions-regulatory_code setting is not configured\n"
223 unless $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum);
225 $self->{regcode} = $REGCODE{ $conf->config('compliance_solutions-regulatory_code', $cust_bill->cust_main->agentnum) };
227 warn 'regcode '. $self->{regcode} if $DEBUG;
229 # assemble the request hash
230 my $input = $self->build_input($cust_bill);
232 warn "no taxable items in invoice; skipping Compliance Solutions request\n" if $DEBUG;
236 warn "sending Compliance Solutions request\n" if $DEBUG;
237 my $request_json = $json->encode(
239 'access_code' => $conf->config('compliance_solutions-access_code'),
240 'reference' => 'Invoice #'. $cust_bill->invnum,
244 warn $request_json if $DEBUG > 1;
245 $cust_bill->taxengine_request($request_json);
247 my $soap = SOAP::Lite->service("http://tcms1.csilongwood.com/cgi-bin/taxcalc.wsdl");
249 $soap->soapversion('1.2'); #service appears to be flaky with the default 1.1
251 my $results = $soap->tax_rate($request_json);
253 my %json_result = %{ $json->decode( $results ) };
254 warn Dumper(%json_result) if $DEBUG > 1;
256 # handle $results is empty / API/connection failure?
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";
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";
273 # error_codes / No errors (for all records... check them individually in loop?
278 foreach my $tax_data ( @{ $json_result{tax_data} } ) {
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',
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??
291 my $error = $tax_rate->find_or_insert;
292 die "error inserting tax_rate record for '$taxname': $error\n"
294 $tax_rate = $tax_rate->replace_old;
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'},
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"
308 $tax_rate_location = $tax_rate_location->replace_old;
310 #unique id: a cust_bill_pkg (setup/recur) or cdr record
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;
318 die 'unparseable unique_id '. $tax_data->{'unique_id'};
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}),
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',
341 'description' => $desc,
344 return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
346 return "illegal compliance solutions tax code '$desc'";