package FS::tax_rate;
use strict;
-use vars qw( @ISA @EXPORT_OK $conf $DEBUG $me
+use vars qw( @ISA $DEBUG $me
%tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
- %tax_passtypes
- @tax_rate %tax_rate $countyflag );
-use Exporter;
+ %tax_passtypes );
use Date::Parse;
-use Tie::IxHash;
-use FS::Record qw( qsearchs qsearch dbh );
+use Storable qw( thaw );
+use MIME::Base64;
+use FS::Record qw( qsearchs dbh );
use FS::tax_class;
+use FS::cust_bill_pkg;
+use FS::cust_tax_location;
+use FS::part_pkg_taxrate;
@ISA = qw( FS::Record );
-@EXPORT_OK = qw( regionselector );
-$DEBUG = 1;
+$DEBUG = 0;
$me = '[FS::tax_rate]';
-@tax_rate = ();
-$countyflag = '';
-
-#ask FS::UID to run this stuff for us later
-$FS::UID::callback{'FS::tax_rate'} = sub {
- $conf = new FS::Conf;
-};
-
=head1 NAME
FS::tax_rate - Object methods for tax_rate objects
$error = $record->check;
- ($county_html, $state_html, $country_html) =
- FS::tax_rate::regionselector( $county, $state, $country );
-
=head1 DESCRIPTION
An FS::tax_rate object represents a tax rate, defined by locale.
a foreign key into FS::tax_class - the type of tax
referenced but FS::part_pkg_taxrate
-
-=item effective_date
+eitem effective_date
the time after which the tax applies
$tax_passtypes{$self->passtype};
}
-=back
-
-=head1 SUBROUTINES
+=item taxline CUST_BILL_PKG, ...
-=over 4
-
-=item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
+Returns a listref of a name and an amount of tax calculated for the list
+of packages. If an error occurs, a message is returned as a scalar.
=cut
-sub regionselector {
- my ( $selected_county, $selected_state, $selected_country,
- $prefix, $onchange, $disabled ) = @_;
+sub taxline {
+ my $self = shift;
+ my @cust_bill_pkg = @_;
- $prefix = '' unless defined $prefix;
+ warn "calculating taxes for ". $self->taxnum. " on ".
+ join (",", map { $_->pkgnum } @cust_bill_pkg)
+ if $DEBUG;
- $countyflag = 0;
+ if ($self->passflag eq 'N') {
+ return "fatal: can't (yet) handle taxes not passed to the customer";
+ }
-# unless ( @tax_rate ) { #cache
- @tax_rate = qsearch('tax_rate', {} );
- foreach my $c ( @tax_rate ) {
- $countyflag=1 if $c->county;
- #push @{$tax_rate{$c->country}{$c->state}}, $c->county;
- $tax_rate{$c->country}{$c->state}{$c->county} = 1;
- }
-# }
- $countyflag=1 if $selected_county;
-
- my $script_html = <<END;
- <SCRIPT>
- function opt(what,value,text) {
- var optionName = new Option(text, value, false, false);
- var length = what.length;
- what.options[length] = optionName;
- }
- function ${prefix}country_changed(what) {
- country = what.options[what.selectedIndex].text;
- for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
- what.form.${prefix}state.options[i] = null;
-END
- #what.form.${prefix}state.options[0] = new Option('', '', false, true);
-
- foreach my $country ( sort keys %tax_rate ) {
- $script_html .= "\nif ( country == \"$country\" ) {\n";
- foreach my $state ( sort keys %{$tax_rate{$country}} ) {
- ( my $dstate = $state ) =~ s/[\n\r]//g;
- my $text = $dstate || '(n/a)';
- $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
- }
- $script_html .= "}\n";
+ if ($self->maxtype != 0 && $self->maxtype != 9) {
+ return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name.
+ '" threshold';
}
- $script_html .= <<END;
- }
- function ${prefix}state_changed(what) {
-END
-
- if ( $countyflag ) {
- $script_html .= <<END;
- state = what.options[what.selectedIndex].text;
- country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
- for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
- what.form.${prefix}county.options[i] = null;
-END
-
- foreach my $country ( sort keys %tax_rate ) {
- $script_html .= "\nif ( country == \"$country\" ) {\n";
- foreach my $state ( sort keys %{$tax_rate{$country}} ) {
- $script_html .= "\nif ( state == \"$state\" ) {\n";
- #foreach my $county ( sort @{$tax_rate{$country}{$state}} ) {
- foreach my $county ( sort keys %{$tax_rate{$country}{$state}} ) {
- my $text = $county || '(n/a)';
- $script_html .=
- qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
- }
- $script_html .= "}\n";
- }
- $script_html .= "}\n";
- }
+ if ($self->maxtype == 9) {
+ return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name.
+ '" threshold'; # "texas" tax
}
- $script_html .= <<END;
- }
- </SCRIPT>
-END
+ if ($self->basetype != 0 && $self->basetype != 1 &&
+ $self->basetype != 6 && $self->basetype != 7 &&
+ $self->basetype != 14
+ ) {
+ return qq!fatal: can't (yet) handle tax with "!. $self->basetype_name.
+ '" basis';
+ }
- my $county_html = $script_html;
- if ( $countyflag ) {
- $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
- $county_html .= '</SELECT>';
- } else {
- $county_html .=
- qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
+ my $name = $self->taxname;
+ $name = 'Other surcharges'
+ if ($self->passtype == 2);
+ my $amount = 0;
+
+ my $taxable_charged = 0;
+ unless ($self->setuptax =~ /^Y$/i) {
+ $taxable_charged += $_->setup foreach @cust_bill_pkg;
+ }
+ unless ($self->recurtax =~ /^Y$/i) {
+ $taxable_charged += $_->recur foreach @cust_bill_pkg;
}
- my $state_html = qq!<SELECT NAME="${prefix}state" !.
- qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
- foreach my $state ( sort keys %{ $tax_rate{$selected_country} } ) {
- my $text = $state || '(n/a)';
- my $selected = $state eq $selected_state ? 'SELECTED' : '';
- $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
+ my $taxable_units = 0;
+ unless ($self->recurtax =~ /^Y$/i) {
+ if ($self->unittype == 0) {
+ $taxable_units += $_->units foreach @cust_bill_pkg;
+ }elsif ($self->unittype == 1) {
+ return qq!fatal: can't (yet) handle fee with minute unit type!;
+ }elsif ($self->unittype == 2) {
+ $taxable_units = 1;
+ }else {
+ return qq!fatal: can't (yet) handle unknown unit type in tax!.
+ $self->taxnum;
+ }
}
- $state_html .= '</SELECT>';
- $state_html .= '</SELECT>';
+ #
+ # XXX insert exemption handling here
+ #
+ # the tax or fee is applied to taxbase or feebase and then
+ # the excessrate or excess fee is applied to taxmax or feemax
+ #
- my $country_html = qq!<SELECT NAME="${prefix}country" !.
- qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
- my $countrydefault = $conf->config('countrydefault') || 'US';
- foreach my $country (
- sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
- keys %tax_rate
- ) {
- my $selected = $country eq $selected_country ? ' SELECTED' : '';
- $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
- }
- $country_html .= '</SELECT>';
+ $amount += $taxable_charged * $self->tax;
+ $amount += $taxable_units * $self->fee;
+
+ warn "calculated taxes as [ $name, $amount ]\n"
+ if $DEBUG;
- ($county_html, $state_html, $country_html);
+ return [$name, $amount];
}
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item batch_import
+
+=cut
+
sub batch_import {
- my $param = shift;
+ my ($param, $job) = @_;
my $fh = $param->{filehandle};
my $format = $param->{'format'};
+ my %insert = ();
+ my %delete = ();
+
my @fields;
my $hook;
- if ( $format eq 'cch' ) {
+
+ my $line;
+ my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
+ if ( $job ) {
+ $count++
+ while ( defined($line=<$fh>) );
+ seek $fh, 0, 0;
+ }
+ $count *=2;
+
+ if ( $format eq 'cch' || $format eq 'cch-update' ) {
@fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
excessrate effective_date taxauth taxtype taxcat taxname
usetax useexcessrate fee unittype feemax maxtype passflag
passtype basetype );
+ push @fields, 'actionflag' if $format eq 'cch-update';
+
$hook = sub {
my $hash = shift;
+ $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
+ $hash->{'data_vendor'} ='cch';
$hash->{'effective_date'} = str2time($hash->{'effective_date'});
my $taxclassid =
);
my $tax_class = qsearchs( 'tax_class', \%tax_class );
- return "Error inserting tax rate: no tax class $taxclassid"
+ return "Error updating tax rate: no tax class $taxclassid"
unless $tax_class;
$hash->{'taxclassnum'} = $tax_class->taxclassnum;
if length($hash->{$_}) > 80;
}
+ my $actionflag = delete($hash->{'actionflag'});
+ if ($actionflag eq 'I') {
+ $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
+ }elsif ($actionflag eq 'D') {
+ $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
+ }else{
+ return "Unexpected action flag: ". $hash->{'actionflag'};
+ }
+
+ '';
+
};
} elsif ( $format eq 'extended' ) {
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
- my $line;
while ( defined($line=<$fh>) ) {
$csv->parse($line) or do {
$dbh->rollback if $oldAutoCommit;
return "can't parse: ". $csv->error_input();
};
- warn "$me batch_import: $imported\n"
- if (!($imported % 100) && $DEBUG);
+ if ( $job ) { # progress bar
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int( 100 * $imported / $count )
+ );
+ die $error if $error;
+ $last = time;
+ }
+ }
my @columns = $csv->fields();
foreach my $field ( @fields ) {
$tax_rate{$field} = shift @columns;
}
+ if ( scalar( @columns ) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Unexpected trailing columns in line (wrong format?): $line";
+ }
+
my $error = &{$hook}(\%tax_rate);
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return $error;
}
- my $tax_rate = new FS::tax_rate( \%tax_rate );
- $error = $tax_rate->insert;
+ $imported++;
+
+ }
+
+ for (grep { !exists($delete{$_}) } keys %insert) {
+ if ( $job ) { # progress bar
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int( 100 * $imported / $count )
+ );
+ die $error if $error;
+ $last = time;
+ }
+ }
+
+ my $tax_rate = new FS::tax_rate( $insert{$_} );
+ my $error = $tax_rate->insert;
+
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't insert tax_rate for $line: $error";
+ }
+
+ $imported++;
+ }
+
+ for (grep { exists($delete{$_}) } keys %insert) {
+ if ( $job ) { # progress bar
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int( 100 * $imported / $count )
+ );
+ die $error if $error;
+ $last = time;
+ }
+ }
+
+ my $old = qsearchs( 'tax_rate', $delete{$_} );
+ unless ($old) {
+ $dbh->rollback if $oldAutoCommit;
+ $old = $delete{$_};
+ return "can't find tax_rate to replace for: ".
+ #join(" ", map { "$_ => ". $old->{$_} } @fields);
+ join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
+ }
+ my $new = new FS::tax_rate( $insert{$_} );
+ $new->taxnum($old->taxnum);
+ my $error = $new->replace($old);
+
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't insert tax_rate for $line: $error";
+ }
+
+ $imported++;
+ $imported++;
+ }
+
+ for (grep { !exists($insert{$_}) } keys %delete) {
+ if ( $job ) { # progress bar
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int( 100 * $imported / $count )
+ );
+ die $error if $error;
+ $last = time;
+ }
+ }
+
+ my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
+ unless ($tax_rate) {
+ $dbh->rollback if $oldAutoCommit;
+ $tax_rate = $delete{$_};
+ return "can't find tax_rate to delete for: ".
+ #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
+ join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
+ }
+ my $error = $tax_rate->delete;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
}
+=item process_batch
+
+Load an batch import as a queued JSRPC job
+
+=cut
+
+sub process_batch {
+ my $job = shift;
+
+ my $param = thaw(decode_base64(shift));
+ my $format = $param->{'format'}; #well... this is all cch specific
+
+ my $files = $param->{'uploaded_files'}
+ or die "No files provided.";
+
+ my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
+
+ if ($format eq 'cch') {
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+ my $error = '';
+
+ my @list = ( 'CODE', 'codefile', \&FS::tax_class::batch_import,
+ 'PLUS4', 'plus4file', \&FS::cust_tax_location::batch_import,
+ 'TXMATRIX', 'txmatrix', \&FS::part_pkg_taxrate::batch_import,
+ 'DETAIL', 'detail', \&FS::tax_rate::batch_import,
+ );
+ while( scalar(@list) ) {
+ my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
+ unless ($files{$file}) {
+ $error = "No $name supplied";
+ next;
+ }
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ my $filename = "$dir/". $files{$file};
+ open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
+
+ $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
+ close $fh;
+ unlink $filename or warn "Can't delete $filename: $!";
+ }
+
+ if ($error) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }else{
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ }
+
+ }elsif ($format eq 'cch-update') {
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+ my $error = '';
+ my @insert_list = ();
+ my @delete_list = ();
+
+ my @list = ( 'CODE', 'codefile', \&FS::tax_class::batch_import,
+ 'PLUS4', 'plus4file', \&FS::cust_tax_location::batch_import,
+ 'TXMATRIX', 'txmatrix', \&FS::part_pkg_taxrate::batch_import,
+ );
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ while( scalar(@list) ) {
+ my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
+ unless ($files{$file}) {
+ $error = "No $name supplied";
+ next;
+ }
+ my $filename = "$dir/". $files{$file};
+ open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
+ unlink $filename or warn "Can't delete $filename: $!";
+
+ my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
+ DIR => $dir,
+ UNLINK => 0, #meh
+ ) or die "can't open temp file: $!\n";
+
+ my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
+ DIR => $dir,
+ UNLINK => 0, #meh
+ ) or die "can't open temp file: $!\n";
+
+ while(<$fh>) {
+ my $handle = '';
+ $handle = $ifh if $_ =~ /"I"\s*$/;
+ $handle = $dfh if $_ =~ /"D"\s*$/;
+ unless ($handle) {
+ $error = "bad input line: $_" unless $handle;
+ last;
+ }
+ print $handle $_;
+ }
+ close $fh;
+ close $ifh;
+ close $dfh;
+
+ push @insert_list, $name, $ifh->filename, $import_sub;
+ unshift @delete_list, $name, $dfh->filename, $import_sub;
+
+ }
+ while( scalar(@insert_list) ) {
+ my ($name, $file, $import_sub) =
+ (shift @insert_list, shift @insert_list, shift @insert_list);
+
+ open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
+ $error ||=
+ &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
+ close $fh;
+ unlink $file or warn "Can't delete $file: $!";
+ }
+
+ $error = "No DETAIL supplied"
+ unless ($files{detail});
+ open my $fh, "< $dir/". $files{detail}
+ or $error ||= "Can't open DETAIL file: $!";
+ $error ||=
+ &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
+ $job);
+ close $fh;
+ unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
+ if $files{detail};
+
+ while( scalar(@delete_list) ) {
+ my ($name, $file, $import_sub) =
+ (shift @delete_list, shift @delete_list, shift @delete_list);
+
+ open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
+ $error ||=
+ &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
+ close $fh;
+ unlink $file or warn "Can't delete $file: $!";
+ }
+
+ if ($error) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }else{
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ }
+
+ }else{
+ die "Unknown format: $format";
+ }
+
+}
+
=back
=head1 BUGS
-regionselector? putting web ui components in here? they should probably live
-somewhere else...
+ Mixing automatic and manual editing works poorly at present.
=head1 SEE ALSO