[freeside-commits] freeside/FS/FS cust_tax_location.pm, NONE, 1.1 part_pkg_taxoverride.pm, NONE, 1.1 part_pkg_taxproduct.pm, NONE, 1.1 part_pkg_taxrate.pm, NONE, 1.1 tax_class.pm, NONE, 1.1 tax_rate.pm, NONE, 1.1 Conf.pm, 1.223, 1.224 Schema.pm, 1.80, 1.81 part_pkg.pm, 1.61, 1.62

Jeff Finucane,420,, jeff at wavetail.420.am
Mon Mar 31 17:54:44 PDT 2008


Update of /home/cvs/cvsroot/freeside/FS/FS
In directory wavetail.420.am:/tmp/cvs-serv19578/FS/FS

Modified Files:
	Conf.pm Schema.pm part_pkg.pm 
Added Files:
	cust_tax_location.pm part_pkg_taxoverride.pm 
	part_pkg_taxproduct.pm part_pkg_taxrate.pm tax_class.pm 
	tax_rate.pm 
Log Message:
checkpoint of new tax rating system

Index: Conf.pm
===================================================================
RCS file: /home/cvs/cvsroot/freeside/FS/FS/Conf.pm,v
retrieving revision 1.223
retrieving revision 1.224
diff -u -d -r1.223 -r1.224
--- Conf.pm	30 Mar 2008 01:26:13 -0000	1.223
+++ Conf.pm	1 Apr 2008 00:54:41 -0000	1.224
@@ -1406,6 +1406,13 @@
   },
 
   {
+    'key'         => 'enable_taxproducts',
+    'section'     => 'billing',
+    'description' => 'Enable per-package mapping to new style tax classes',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'welcome_email',
     'section'     => '',
     'description' => 'Template file for welcome email.  Welcome emails are sent to the customer email invoice destination(s) each time a svc_acct record is created.  See the <a href="http://search.cpan.org/~mjd/Text-Template/lib/Text/Template.pm">Text::Template</a> documentation for details on the template substitution language.  The following variables are available<ul><li><code>$username</code> <li><code>$password</code> <li><code>$first</code> <li><code>$last</code> <li><code>$pkg</code></ul>',

--- NEW FILE: part_pkg_taxproduct.pm ---
package FS::part_pkg_taxproduct;

use strict;
use vars qw( @ISA );
use FS::Record;

@ISA = qw(FS::Record);

=head1 NAME

FS::part_pkg_taxproduct - Object methods for part_pkg_taxproduct records

=head1 SYNOPSIS

  use FS::part_pkg_taxproduct;

  $record = new FS::part_pkg_taxproduct \%hash;
  $record = new FS::part_pkg_taxproduct { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $error = $record->check;

=head1 DESCRIPTION

An FS::part_pkg_taxproduct object represents a tax product. 
FS::part_pkg_taxproduct inherits from FS::Record.  The following fields are
currently supported:

=over 4

=item taxproductnum

Primary key

=item data_vendor

Tax data vendor

=item taxproduct

Tax product id from the vendor

=item description

A human readable description of the id in taxproduct

=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new tax product.  To add the tax product to the database, see L<"insert">.

Note that this stores the hash reference, not a distinct copy of the hash it
points to.  You can ask the object for a copy with the I<hash> method.

=cut

sub table { 'part_pkg_taxproduct'; }

=item insert

Adds this record to the database.  If there is an error, returns the error,
otherwise returns false.

=cut

=item delete

Delete this record from the database.

=cut

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=cut

=item check

Checks all fields to make sure this is a valid tax product.  If there is
an error, returns the error, otherwise returns false.  Called by the insert
and replace methods.

=cut

sub check {
  my $self = shift;

  my $error = 
    $self->ut_numbern('taxproductnum')
    || $self->ut_textn('data_vendor')
    || $self->ut_text('taxproduct')
    || $self->ut_textn('description')
  ;
  return $error if $error;

  $self->SUPER::check;
}

=back

=cut

=head1 BUGS

=head1 SEE ALSO

L<FS::Record>, schema.html from the base documentation.

=cut

1;


--- NEW FILE: part_pkg_taxrate.pm ---
package FS::part_pkg_taxrate;

use strict;
use vars qw( @ISA );
use Date::Parse;
use FS::UID qw(dbh);
use FS::Record qw( qsearch qsearchs );
use FS::part_pkg_taxproduct;

@ISA = qw(FS::Record);

=head1 NAME

FS::part_pkg_taxrate - Object methods for part_pkg_taxrate records

=head1 SYNOPSIS

  use FS::part_pkg_taxrate;

  $record = new FS::part_pkg_taxrate \%hash;
  $record = new FS::part_pkg_taxrate { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $error = $record->check;

=head1 DESCRIPTION

An FS::part_pkg_taxrate object maps packages onto tax rates.
FS::part_pkg_taxrate inherits from FS::Record.  The following fields are
currently supported:

=over 4

=item pkgtaxratenum

Primary key

=item data_vendor

Tax data vendor

=item geocode

Tax vendor location code

=item taxproductnum

Class of package for tax purposes, Index into FS::part_pkg_taxproduct

=item city

city

=item county

county

=item state

state

=item local

local

=item country

country

=item taxclassnum

Class of tax index into FS::tax_taxclass and FS::tax_rate

=item taxclassnumtaxed

Class of tax taxed by this entry.

=item taxable

taxable

=item effdate

effdate

=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new customer (location), package, tax rate mapping.  To add the
mapping to the database, see L<"insert">.

Note that this stores the hash reference, not a distinct copy of the hash it
points to.  You can ask the object for a copy with the I<hash> method.

=cut

sub table { 'part_pkg_taxrate'; }

=item insert

Adds this record to the database.  If there is an error, returns the error,
otherwise returns false.

=cut

=item delete

Delete this record from the database.

=cut

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=cut

=item check

Checks all fields to make sure this is a valid tax rate mapping.  If there is
an error, returns the error, otherwise returns false.  Called by the insert
and replace methods.

=cut

sub check {
  my $self = shift;

  my $error = 
    $self->ut_numbern('pkgtaxratenum')
    || $self->ut_textn('data_vendor')
    || $self->ut_textn('geocode')
    || $self->
         ut_foreign_key('taxproductnum', 'part_pkg_taxproduct', 'taxproductnum')
    || $self->ut_textn('city')
    || $self->ut_textn('county')
    || $self->ut_textn('state')
    || $self->ut_textn('local')
    || $self->ut_text('country')
    || $self->ut_foreign_keyn('taxclassnumtaxed', 'tax_class', 'taxclassnum')
    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
    || $self->ut_numbern('effective_date')
    || $self->ut_enum('taxable', [ 'Y', '' ])
  ;
  return $error if $error;

  $self->SUPER::check;
}

=item batch_import

Loads part_pkg_taxrate records from an external CSV file.  If there is
an error, returns the error, otherwise returns false. 

=cut 

sub batch_import {
  my $param = shift;

  my $fh = $param->{filehandle};
  my $format = $param->{'format'};

  my @fields;
  my $hook;
  if ( $format eq 'cch' ) {
    @fields = qw( city county state local geocode group groupdesc item
                  itemdesc provider customer taxtypetaxed taxcattaxed
                  taxable taxtype taxcat effdate rectype );

    $hook = sub { 
      my $hash = shift;

      unless ( $hash->{'rectype'} eq 'R' or $hash->{'rectype'} eq 'T' ) {
        delete($hash->{$_}) for (keys %$hash);
        return;
      }

      my %providers = ( '00' => 'Regulated LEC',
                        '01' => 'Regulated IXC',
                        '02' => 'Unregulated LEC',
                        '03' => 'Unregulated IXC',
                        '04' => 'ISP',
                        '05' => 'Wireless',
                      );

      my %customers = ( '00' => 'Residential',
                        '01' => 'Commercial',
                        '02' => 'Industrial',
                        '09' => 'Lifeline',
                        '10' => 'Senior Citizen',
                      );

      my $taxproduct =
        join(':', map{ $hash->{$_} } qw(group item provider customer ) );

      my %part_pkg_taxproduct = ( 'data_vendor' => 'cch', 
                                  'taxproduct' => $taxproduct,
                                );

      my $part_pkg_taxproduct = qsearchs( 'part_pkg_taxproduct', 
                                          { %part_pkg_taxproduct }
                                        );
      unless ($part_pkg_taxproduct) {
        $part_pkg_taxproduct{'description'} = 
          join(' : ', map{ $hash->{$_} } qw(groupdesc itemdesc),
                      $providers{$hash->{'provider'}} || 'Unknown',
                      $customers{$hash->{'customer'}} || 'Unknown',
              );
        $part_pkg_taxproduct = new FS::part_pkg_taxproduct \%part_pkg_taxproduct;
        my $error = $part_pkg_taxproduct->insert;
        return "Error inserting tax product (part_pkg_taxproduct): $error"
          if $error;

      }
      $hash->{'taxproductnum'} = $part_pkg_taxproduct->taxproductnum;

      delete($hash->{$_})
        for qw(group groupdesc item itemdesc provider customer rectype );

      my %map = ( 'taxclassnum'      => [ 'taxtype', 'taxcat' ],
                  'taxclassnumtaxed' => [ 'taxtypetaxed', 'taxcattaxed' ],
                );

      for my $item (keys %map) {
        my $tax_class =
          qsearchs( 'tax_class',
                    { data_vendor => 'cch',
                      'taxclass' => join(':', map($hash->{$_}, @{$map{$item}})),
                    }
                  );
        $hash->{$item} = $tax_class->taxclassnum
          if $tax_class;

        delete($hash->{$_}) foreach @{$map{$item}};
      }

      $hash->{'effdate'} = str2time($hash->{'effdate'});

      $hash->{'effdate'} = str2time($hash->{'effdate'});
      $hash->{'country'} = 'US'; # CA is available

      delete($hash->{'taxable'}) if ($hash->{'taxable'} eq 'N');

      '';
    };

  } elsif ( $format eq 'extended' ) {
    die "unimplemented\n";
    @fields = qw( );
    $hook = sub {};
  } else {
    die "unknown format $format";
  }

  eval "use Text::CSV_XS;";
  die $@ if $@;

  my $csv = new Text::CSV_XS;

  my $imported = 0;

  local $SIG{HUP} = 'IGNORE';
  local $SIG{INT} = 'IGNORE';
  local $SIG{QUIT} = 'IGNORE';
  local $SIG{TERM} = 'IGNORE';
  local $SIG{TSTP} = 'IGNORE';
  local $SIG{PIPE} = 'IGNORE';

  my $oldAutoCommit = $FS::UID::AutoCommit;
  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();
    };

    my @columns = $csv->fields();

    my %part_pkg_taxrate = ( 'data_vendor' => $format );
    foreach my $field ( @fields ) {
      $part_pkg_taxrate{$field} = shift @columns; 
    }
    my $error = &{$hook}(\%part_pkg_taxrate);
    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return $error;
    }
    next unless scalar(keys %part_pkg_taxrate);


    my $part_pkg_taxrate = new FS::part_pkg_taxrate( \%part_pkg_taxrate );
    $error = $part_pkg_taxrate->insert;

    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return "can't insert part_pkg_taxrate for $line: $error";
    }

    $imported++;
  }

  $dbh->commit or die $dbh->errstr if $oldAutoCommit;

  return "Empty file!" unless $imported;

  ''; #no error

}

=back

=head1 BUGS

=head1 SEE ALSO

L<FS::Record>, schema.html from the base documentation.

=cut

1;



--- NEW FILE: part_pkg_taxoverride.pm ---
package FS::part_pkg_taxoverride;

use strict;
use vars qw( @ISA );
use FS::Record;

@ISA = qw(FS::Record);

=head1 NAME

FS::part_pkg_taxoverride - Object methods for part_pkg_taxoverride records

=head1 SYNOPSIS

  use FS::part_pkg_taxoverride;

  $record = new FS::part_pkg_taxoverride \%hash;
  $record = new FS::part_pkg_taxoverride { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $error = $record->check;

=head1 DESCRIPTION

An FS::part_pkg_taxoverride object represents a manual mapping of a
package to tax rates.  FS::part_pkg_taxoverride inherits from FS::Record.
The following fields are currently supported:

=over 4

=item taxoverridenum

Primary key

=item pkgpart

The package definition id

=item taxnum

The tax rate definition id

=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new tax override.  To add the tax product to the database, see L<"insert">.

Note that this stores the hash reference, not a distinct copy of the hash it
points to.  You can ask the object for a copy with the I<hash> method.

=cut

sub table { 'part_pkg_taxoverride'; }

=item insert

Adds this record to the database.  If there is an error, returns the error,
otherwise returns false.

=cut

=item delete

Delete this record from the database.

=cut

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=cut

=item check

Checks all fields to make sure this is a valid tax product.  If there is
an error, returns the error, otherwise returns false.  Called by the insert
and replace methods.

=cut

sub check {
  my $self = shift;

  my $error = 
    $self->ut_numbern('taxoverridenum')
    || $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart')
    || $self->ut_foreign_key('taxnum', 'tax_rate', 'taxnum')
  ;
  return $error if $error;

  $self->SUPER::check;
}

=back

=cut

=head1 BUGS

=head1 SEE ALSO

L<FS::Record>, schema.html from the base documentation.

=cut

1;


Index: part_pkg.pm
===================================================================
RCS file: /home/cvs/cvsroot/freeside/FS/FS/part_pkg.pm,v
retrieving revision 1.61
retrieving revision 1.62
diff -u -d -r1.61 -r1.62
--- part_pkg.pm	24 Sep 2007 00:56:50 -0000	1.61
+++ part_pkg.pm	1 Apr 2008 00:54:42 -0000	1.62
@@ -14,6 +14,8 @@
 use FS::part_pkg_option;
 use FS::pkg_class;
 use FS::agent;
+use FS::part_pkg_taxoverride;
+use FS::part_pkg_taxproduct;
 
 @ISA = qw( FS::m2m_Common FS::Record ); # FS::option_Common ); # this can use option_Common
                                                 # when all the plandata bs is
@@ -726,6 +728,34 @@
   '';
 }
 
+=item part_pkg_taxoverride
+
+Returns all options as FS::part_pkg_taxoverride objects (see
+L<FS::part_pkg_taxoverride>).
+
+=cut
+
+sub part_pkg_taxoverride {
+  my $self = shift;
+  qsearch('part_pkg_taxoverride', { 'pkgpart' => $self->pkgpart } );
+}
+
+=item taxproduct_description
+
+Returns the description of the associated tax product for this package
+definition (see L<FS::part_pkg_taxproduct>).
+
+=cut
+
+sub taxproduct_description {
+  my $self = shift;
+  my $part_pkg_taxproduct =
+    qsearchs( 'part_pkg_taxproduct',
+              { 'taxproductnum' => $self->taxproductnum }
+            );
+  $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
+}
+
 =item _rebless
 
 Reblesses the object into the FS::part_pkg::PLAN class (if available), where

--- NEW FILE: cust_tax_location.pm ---
package FS::cust_tax_location;

use strict;
use vars qw( @ISA );
use FS::Record qw( qsearch qsearchs dbh );

@ISA = qw(FS::Record);

=head1 NAME

FS::cust_tax_location - Object methods for cust_tax_location records

=head1 SYNOPSIS

  use FS::cust_tax_location;

  $record = new FS::cust_tax_location \%hash;
  $record = new FS::cust_tax_location { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $error = $record->check;

=head1 DESCRIPTION

An FS::cust_tax_location object represents a mapping between a customer and
a tax location.  FS::cust_tax_location inherits from FS::Record.  The
following fields are currently supported:

=over 4

=item custlocationnum

primary key

=item data_vendor

a tax data vendor

=item zip 

=item state

=item plus4hi

the upper bound of the last 4 zip code digits

=item plus4lo

the lower bound of the last 4 zip code digits

=item default_location

'Y' when this record represents the default for zip

=item geocode - the foreign key into FS::part_pkg_tax_rate and FS::tax_rate


=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new cust_tax_location.  To add the cust_tax_location to the database,
see L<"insert">.

Note that this stores the hash reference, not a distinct copy of the hash it
points to.  You can ask the object for a copy with the I<hash> method.

=cut

sub table { 'cust_tax_location'; }

=item insert

Adds this record to the database.  If there is an error, returns the error,
otherwise returns false.

=cut

=item delete

Delete this record from the database.

=cut

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=cut

=item check

Checks all fields to make sure this is a valid cust_tax_location.  If there is
an error, returns the error, otherwise returns false.  Called by the insert
and replace methods.

=cut

sub check {
  my $self = shift;

  my $error = 
    $self->ut_numbern('custlocationnum')
    || $self->ut_text('data_vendor')
    || $self->ut_number('zip')
    || $self->ut_text('state')
    || $self->ut_number('plus4hi')
    || $self->ut_number('plus4lo')
    || $self->ut_enum('default', [ '', ' ', 'Y' ] )
    || $self->ut_number('geocode')
  ;
  return $error if $error;

  $self->SUPER::check;
}


sub batch_import {
  my $param = shift;

  my $fh = $param->{filehandle};
  my $format = $param->{'format'};

  my @fields;
  if ( $format eq 'cch' ) {
    @fields = qw( zip state plus4lo plus4hi geocode default );
  } elsif ( $format eq 'extended' ) {
    die "unimplemented\n";
    @fields = qw( );
  } else {
    die "unknown format $format";
  }

  eval "use Text::CSV_XS;";
  die $@ if $@;

  my $csv = new Text::CSV_XS;

  my $imported = 0;

  local $SIG{HUP} = 'IGNORE';
  local $SIG{INT} = 'IGNORE';
  local $SIG{QUIT} = 'IGNORE';
  local $SIG{TERM} = 'IGNORE';
  local $SIG{TSTP} = 'IGNORE';
  local $SIG{PIPE} = 'IGNORE';

  my $oldAutoCommit = $FS::UID::AutoCommit;
  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();
    };

    my @columns = $csv->fields();

    my %cust_tax_location = ( 'data_vendor' => $format );;
    foreach my $field ( @fields ) {
      $cust_tax_location{$field} = shift @columns; 
    }

    my $cust_tax_location = new FS::cust_tax_location( \%cust_tax_location );
    my $error = $cust_tax_location->insert;

    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return "can't insert cust_tax_location for $line: $error";
    }

    $imported++;
  }

  $dbh->commit or die $dbh->errstr if $oldAutoCommit;

  return "Empty file!" unless $imported;

  ''; #no error

}

=back

=head1 BUGS

The author should be informed of any you find.

=head1 SEE ALSO

L<FS::Record>, schema.html from the base documentation.

=cut

1;


--- NEW FILE: tax_class.pm ---
package FS::tax_class;

use strict;
use vars qw( @ISA );
use FS::UID qw(dbh);
use FS::Record qw( qsearch qsearchs );

@ISA = qw(FS::Record);

=head1 NAME

FS::tax_class - Object methods for tax_class records

=head1 SYNOPSIS

  use FS::tax_class;

  $record = new FS::tax_class \%hash;
  $record = new FS::tax_class { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $error = $record->check;

=head1 DESCRIPTION

An FS::tax_class object represents a tax class.  FS::tax_class
inherits from FS::Record.  The following fields are currently supported:

=over 4

=item taxclassnum

Primary key

=item data_vendor

Vendor of the tax data

=item taxclass

Tax class

=item description

Human readable description of the tax class

=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new tax class.  To add the tax class to the database, see L<"insert">.

Note that this stores the hash reference, not a distinct copy of the hash it
points to.  You can ask the object for a copy with the I<hash> method.

=cut

sub table { 'tax_class'; }

=item insert

Adds this record to the database.  If there is an error, returns the error,
otherwise returns false.

=cut

=item delete

Delete this record from the database.

=cut

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=cut

=item check

Checks all fields to make sure this is a valid tax class.  If there is
an error, returns the error, otherwise returns false.  Called by the insert
and replace methods.

=cut

sub check {
  my $self = shift;

  my $error = 
    $self->ut_numbern('taxclassnum')
    || $self->ut_text('taxclass')
    || $self->ut_textn('data_vendor')
    || $self->ut_textn('description')
  ;
  return $error if $error;

  $self->SUPER::check;
}

=item batch_import

Loads part_pkg_taxrate records from an external CSV file.  If there is
an error, returns the error, otherwise returns false. 

=cut 

sub batch_import {
  my $param = shift;

  my $fh = $param->{filehandle};
  my $format = $param->{'format'};

  my @fields;
  my $hook;
  my $endhook;
  my $data = {};
  my $imported = 0;

  if ( $format eq 'cch' ) {
    @fields = qw( table name pos number length value description );

    $hook = sub { 
      my $hash = shift;

      if ($hash->{'table'} eq 'DETAIL') {
        push @{$data->{'taxcat'}}, [ $hash->{'value'}, $hash->{'description'} ]
          if $hash->{'name'} eq 'TAXCAT';

        push @{$data->{'taxtype'}}, [ $hash->{'value'}, $hash->{'description'} ]
          if $hash->{'name'} eq 'TAXTYPE';
      }

      delete($hash->{$_})
        for qw( data_vendor table name pos number length value description );

      '';

    };

    $endhook = sub { 
      foreach my $type (@{$data->{'taxtype'}}) {
        foreach my $cat (@{$data->{'taxcat'}}) {
          my $tax_class =
            new FS::tax_class( { 'data_vendor' => 'cch',
                                 'taxclass'    => $type->[0].':'.$cat->[0],
                                 'description' => $type->[1].':'.$cat->[1],
                             } );
          my $error = $tax_class->insert;
          return $error if $error;
          $imported++;
        }
      }
      '';
    };

  } elsif ( $format eq 'extended' ) {
    die "unimplemented\n";
    @fields = qw( );
    $hook = sub {};
  } else {
    die "unknown format $format";
  }

  eval "use Text::CSV_XS;";
  die $@ if $@;

  my $csv = new Text::CSV_XS;

  local $SIG{HUP} = 'IGNORE';
  local $SIG{INT} = 'IGNORE';
  local $SIG{QUIT} = 'IGNORE';
  local $SIG{TERM} = 'IGNORE';
  local $SIG{TSTP} = 'IGNORE';
  local $SIG{PIPE} = 'IGNORE';

  my $oldAutoCommit = $FS::UID::AutoCommit;
  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();
    };

    my @columns = $csv->fields();

    my %tax_class = ( 'data_vendor' => $format );
    foreach my $field ( @fields ) {
      $tax_class{$field} = shift @columns; 
    }
    my $error = &{$hook}(\%tax_class);
    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return $error;
    }
    next unless scalar(keys %tax_class);

    my $tax_class = new FS::tax_class( \%tax_class );
    $error = $tax_class->insert;

    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return "can't insert tax_class for $line: $error";
    }

    $imported++;
  }

  my $error = &{$endhook}();
  if ( $error ) {
    $dbh->rollback if $oldAutoCommit;
    return "can't insert tax_class for $line: $error";
  }

  $dbh->commit or die $dbh->errstr if $oldAutoCommit;

  return "Empty file!" unless $imported;

  ''; #no error

}


=back

=head1 BUGS

=head1 SEE ALSO

L<FS::Record>, schema.html from the base documentation.

=cut

1;



Index: Schema.pm
===================================================================
RCS file: /home/cvs/cvsroot/freeside/FS/FS/Schema.pm,v
retrieving revision 1.80
retrieving revision 1.81
diff -u -d -r1.80 -r1.81
--- Schema.pm	20 Feb 2008 01:21:15 -0000	1.80
+++ Schema.pm	1 Apr 2008 00:54:41 -0000	1.81
@@ -303,6 +303,7 @@
   my @date_type  = ( 'int', 'NULL', ''     );
   my @perl_type = ( 'text', 'NULL', ''  ); 
   my @money_type = ( 'decimal',   '', '10,2' );
+  my @money_typen = ( 'decimal',   'NULL', '10,2' );
 
   my $username_len = 32; #usernamemax config file
 
@@ -665,6 +666,68 @@
       'index' => [ [ 'county' ], [ 'state' ], [ 'country' ] ],
     },
 
+    'tax_rate'    => {
+      'columns' => [
+        'taxnum',       'serial',     '',      '', '', '', 
+        'geocode',     'varchar', 'NULL', $char_d, '', '',#cch provides 10 char
+        'data_vendor', 'varchar', 'NULL', $char_d, '', '',#auto update source
+        'location',    'varchar', 'NULL', $char_d, '', '',#provided by tax authority
+        'taxclassnum', 'int',      '',      '', '', '', 
+        'effective_date', @date_type, '', '', 
+        'tax',        'real',  '',    '', '', '',        # tax %
+        'excessrate', 'real',  'NULL','', '', '',        # second tax %
+        'taxbase',    @money_typen, '', '',              # amount at first tax rate
+        'taxmax',     @money_typen, '', '',              # maximum about at both rates
+        'usetax',        'real',  'NULL',    '', '', '', # tax % when non-local
+        'useexcessrate', 'real',  'NULL',    '', '', '', # second tax % when non-local
+        'unittype',    'int',  'NULL', '', '', '',      # for fee
+        'fee',         'real', 'NULL', '', '', '',      # amount tax per unit
+        'excessfee',   'real', 'NULL', '', '', '',      # second amount tax per unit
+        'feebase',     'real', 'NULL', '', '', '',      # units taxed at first rate
+        'feemax',      'real', 'NULL', '', '', '',      # maximum number of unit taxed
+        'maxtype',     'int',  'NULL', '', '', '',      # indicator of how thresholds accumulate
+        'taxname', 'varchar',  'NULL', $char_d, '', '', # may appear on invoice
+        'taxauth',     'int',  'NULL', '', '', '',      # tax authority
+        'basetype',    'int',  'NULL', '', '', '', # indicator of basis for tax
+        'passtype',    'int',  'NULL', '', '', '', # indicator declaring how item should be shown
+        'passflag',    'char', 'NULL', 1, '', '',  # Y = required to list as line item, N = Prohibited
+        'setuptax',    'char', 'NULL', 1, '', '',  # Y = setup tax exempt
+        'recurtax',    'char', 'NULL', 1, '', '',  # Y = recur tax exempt
+        'manual',      'char', 'NULL', 1, '', '',  # Y = manually edited
+      ],
+      'primary_key' => 'taxnum',
+      'unique' => [],
+      'index' => [ ['taxclassnum'], ['data_vendor', 'geocode'] ],
+    },
+
+    'cust_tax_location' => { 
+      'columns' => [
+        'custlocationnum', 'serial',  '',     '', '', '', 
+        'data_vendor',     'varchar', 'NULL', $char_d, '', '', # update source
+        'zip',             'char',    '',     5,  '', '', 
+        'state',           'char',    '',     2,  '', '', 
+        'plus4hi',         'char',    '',     4,  '', '', 
+        'plus4lo',         'char',    '',     4,  '', '', 
+        'default_location','char',    'NULL', 1,  '', '', # Y = default for zip
+        'geocode',         'varchar', '',    20,  '', '', 
+      ],
+      'primary_key' => 'custlocationnum',
+      'unique' => [],
+      'index' => [ [ 'zip', 'plus4lo', 'plus4hi' ] ],
+    },
+
+    'tax_class' => { 
+      'columns' => [
+        'taxclassnum',  'serial',  '',            '', '', '',
+        'data_vendor',  'varchar', 'NULL',   $char_d, '', '',
+        'taxclass',     'varchar', '',       $char_d, '', '',          
+        'description',  'varchar', '',     2*$char_d, '', '',          
+      ],
+      'primary_key' => 'taxclassnum',
+      'unique' => [ [ 'data_vendor', 'taxclass' ] ],
+      'index' => [],
+    },
+
     'cust_pay_pending' => {
       'columns' => [
         'paypendingnum','serial',      '',  '', '', '',
@@ -933,6 +996,7 @@
         'disabled',      'char', 'NULL', 1, '', '', 
         'taxclass',      'varchar', 'NULL', $char_d, '', '', 
         'classnum',      'int',     'NULL', '', '', '', 
+        'taxproductnum', 'int',     'NULL', '', '', '', 
         'pay_weight',    'real',    'NULL', '', '', '',
         'credit_weight', 'real',    'NULL', '', '', '',
         'agentnum',      'int',     'NULL', '', '', '', 
@@ -953,6 +1017,51 @@
       'index'       => [],
     },
 
+    'part_pkg_taxproduct' => {
+      'columns' => [
+        'taxproductnum', 'serial',      '',        '', '', '',
+        'data_vendor',   'varchar', 'NULL',   $char_d, '', '', 
+        'taxproduct',    'varchar',     '',   $char_d, '', '', 
+        'description',   'varchar',     '', 2*$char_d, '', '', 
+      ],
+      'primary_key' => 'taxproductnum',
+      'unique'      => [ [ 'data_vendor', 'taxproduct' ] ],
+      'index'       => [],
+    },
+
+    'part_pkg_taxrate' => { 
+      'columns' => [
+        'pkgtaxratenum', 'serial',  '',     '',      '', '',
+        'data_vendor',   'varchar', 'NULL', $char_d, '', '', # update source
+        'geocode',       'varchar', 'NULL', $char_d, '', '', # cch provides 10
+        'taxproductnum', 'int',  '',     '',       '', '',          
+        'city',             'varchar', 'NULL', $char_d, '', '', # tax_location?
+        'county',           'varchar', 'NULL', $char_d, '', '', 
+        'state',            'varchar', 'NULL', $char_d, '', '', 
+        'local',            'varchar', 'NULL', $char_d, '', '', 
+        'country',          'char',    'NULL', 2,       '', '',
+        'taxclassnumtaxed', 'int',     'NULL', '',      '', '', 
+        'taxcattaxed',      'varchar', 'NULL', $char_d, '', '', 
+        'taxclassnum',      'int',     'NULL', '',      '', '', 
+        'effdate',          @date_type, '', '', 
+        'taxable',          'char',    'NULL', 1,       '', '', 
+      ],
+      'primary_key' => 'pkgtaxratenum',
+      'unique' => [],
+      'index' => [ [ 'data_vendor', 'geocode', 'taxproductnum' ] ],
+    },
+
+    'part_pkg_taxoverride' => { 
+      'columns' => [
+        'taxoverridenum', 'serial', '', '', '', '',
+        'pkgpart',        'serial', '', '', '', '',
+        'taxnum',         'serial', '', '', '', '',
+      ],
+      'primary_key' => 'taxoverridenum',
+      'unique' => [],
+      'index' => [ [ 'pkgpart' ], [ 'taxnum' ] ],
+    },
+
 #    'part_title' => {
 #      'columns' => [
 #        'titlenum',   'int',    '',   '',
@@ -1366,6 +1475,7 @@
         'routernum', 'serial', '', '', '', '', 
         'routername', 'varchar', '', $char_d, '', '', 
         'svcnum', 'int', 'NULL', '', '', '', 
+        'agentnum',   'int', 'NULL', '', '', '', 
       ],
       'primary_key' => 'routernum',
       'unique'      => [],
@@ -1389,6 +1499,7 @@
 	'routernum', 'int', '', '', '', '', 
         'ip_gateway', 'varchar', '', 15, '', '', 
         'ip_netmask', 'int', '', '', '', '', 
+        'agentnum',   'int', 'NULL', '', '', '', 
       ],
       'primary_key' => 'blocknum',
       'unique'      => [ [ 'blocknum', 'routernum' ] ],

--- NEW FILE: tax_rate.pm ---
package FS::tax_rate;

use strict;
use vars qw( @ISA @EXPORT_OK $conf $DEBUG $me
             %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
             %tax_passtypes
             @tax_rate %tax_rate $countyflag );
use Exporter;
use Date::Parse;
use Tie::IxHash;
use FS::Record qw( qsearchs qsearch dbh );
use FS::tax_class;

@ISA = qw( FS::Record );
@EXPORT_OK = qw( regionselector );

$DEBUG = 1;
$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

=head1 SYNOPSIS

  use FS::tax_rate;

  $record = new FS::tax_rate \%hash;
  $record = new FS::tax_rate { 'column' => 'value' };

  $error = $record->insert;

  $error = $new_record->replace($old_record);

  $error = $record->delete;

  $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.
FS::tax_rate inherits from FS::Record.  The following fields are
currently supported:

=over 4

=item taxnum

primary key (assigned automatically for new tax rates)

=item geocode

a geographic location code provided by a tax data vendor

=item data_vendor

the tax data vendor

=item location

a location code provided by a tax authority

=item taxclassnum

a foreign key into FS::tax_class - the type of tax
referenced but FS::part_pkg_taxrate

=item effective_date

the time after which the tax applies

=item tax

percentage

=item excessrate

second bracket percentage 

=item taxbase

the amount to which the tax applies (first bracket)

=item taxmax

a cap on the amount of tax if a cap exists

=item usetax

percentage on out of jurisdiction purchases

=item useexcessrate

second bracket percentage on out of jurisdiction purchases

=item unittype

one of the values in %tax_unittypes

=item fee

amount of tax per unit

=item excessfee

second bracket amount of tax per unit

=item feebase

the number of units to which the fee applies (first bracket)

=item feemax

the most units to which fees apply (first and second brackets)

=item maxtype

a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)

=item taxname

if defined, printed on invoices instead of "Tax"

=item taxauth

a value from %tax_authorities

=item basetype

a value from %tax_basetypes indicating the tax basis

=item passtype

a value from %tax_passtypes indicating how the tax should displayed to the customer

=item passflag

'Y', 'N', or blank indicating the tax can be passed to the customer

=item setuptax

if 'Y', this tax does not apply to setup fees

=item recurtax

if 'Y', this tax does not apply to recurring fees

=item manual

if 'Y', has been manually edited

=back

=head1 METHODS

=over 4

=item new HASHREF

Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.

=cut

sub table { 'tax_rate'; }

=item insert

Adds this tax rate to the database.  If there is an error, returns the error,
otherwise returns false.

=item delete

Deletes this tax rate from the database.  If there is an error, returns the
error, otherwise returns false.

=item replace OLD_RECORD

Replaces the OLD_RECORD with this one in the database.  If there is an error,
returns the error, otherwise returns false.

=item check

Checks all fields to make sure this is a valid tax rate.  If there is an error,
returns the error, otherwise returns false.  Called by the insert and replace
methods.

=cut

sub check {
  my $self = shift;

  foreach (qw( taxbase taxmax )) {
    $self->$_(0) unless $self->$_;
  }

  $self->ut_numbern('taxnum')
    || $self->ut_text('geocode')
    || $self->ut_textn('data_vendor')
    || $self->ut_textn('location')
    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
    || $self->ut_numbern('effective_date')
    || $self->ut_float('tax')
    || $self->ut_floatn('excessrate')
    || $self->ut_money('taxbase')
    || $self->ut_money('taxmax')
    || $self->ut_floatn('usetax')
    || $self->ut_floatn('useexcessrate')
    || $self->ut_numbern('unittype')
    || $self->ut_floatn('fee')
    || $self->ut_floatn('excessfee')
    || $self->ut_floatn('feemax')
    || $self->ut_numbern('maxtype')
    || $self->ut_textn('taxname')
    || $self->ut_numbern('taxauth')
    || $self->ut_numbern('basetype')
    || $self->ut_numbern('passtype')
    || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
    || $self->ut_enum('setuptax', [ '', 'Y' ] )
    || $self->ut_enum('recurtax', [ '', 'Y' ] )
    || $self->ut_enum('manual', [ '', 'Y' ] )
    || $self->SUPER::check
    ;

}

=item taxclass_description

Returns the human understandable value associated with the related
FS::tax_class.

=cut

sub taxclass_description {
  my $self = shift;
  my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
  $tax_class ? $tax_class->description : '';
}

=item unittype_name

Returns the human understandable value associated with the unittype column

=cut

%tax_unittypes = ( '0' => 'access line',
                   '1' => 'minute',
                   '2' => 'account',
);

sub unittype_name {
  my $self = shift;
  $tax_unittypes{$self->unittype};
}

=item maxtype_name

Returns the human understandable value associated with the maxtype column

=cut

%tax_maxtypes = ( '0' => 'receipts per invoice',
                  '1' => 'receipts per item',
                  '2' => 'total utility charges per utility tax year',
                  '3' => 'total charges per utility tax year',
                  '4' => 'receipts per access line',
                  '9' => 'monthly receipts per location',
);

sub maxtype_name {
  my $self = shift;
  $tax_maxtypes{$self->maxtype};
}

=item basetype_name

Returns the human understandable value associated with the basetype column

=cut

%tax_basetypes = ( '0'  => 'sale price',
                   '1'  => 'gross receipts',
                   '2'  => 'sales taxable telecom revenue',
                   '3'  => 'minutes carried',
                   '4'  => 'minutes billed',
                   '5'  => 'gross operating revenue',
                   '6'  => 'access line',
                   '7'  => 'account',
                   '8'  => 'gross revenue',
                   '9'  => 'portion gross receipts attributable to interstate service',
                   '10' => 'access line',
                   '11' => 'gross profits',
                   '12' => 'tariff rate',
                   '14' => 'account',
);

sub basetype_name {
  my $self = shift;
  $tax_basetypes{$self->basetype};
}

=item taxauth_name

Returns the human understandable value associated with the taxauth column

=cut

%tax_authorities = ( '0' => 'federal',
                     '1' => 'state',
                     '2' => 'county',
                     '3' => 'city',
                     '4' => 'local',
                     '5' => 'county administered by state',
                     '6' => 'city administered by state',
                     '7' => 'city administered by county',
                     '8' => 'local administered by state',
                     '9' => 'local administered by county',
);

sub taxauth_name {
  my $self = shift;
  $tax_authorities{$self->taxauth};
}

=item passtype_name

Returns the human understandable value associated with the passtype column

=cut

%tax_passtypes = ( '0' => 'separate tax line',
                   '1' => 'separate surcharge line',
                   '2' => 'surcharge not separated',
                   '3' => 'included in base rate',
);

sub passtype_name {
  my $self = shift;
  $tax_passtypes{$self->passtype};
}

=back

=head1 SUBROUTINES

=over 4

=item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]

=cut

sub regionselector {
  my ( $selected_county, $selected_state, $selected_country,
       $prefix, $onchange, $disabled ) = @_;

  $prefix = '' unless defined $prefix;

  $countyflag = 0;

#  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";
  }

  $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";
    }
  }

  $script_html .= <<END;
    }
    </SCRIPT>
END

  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 $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>);
  }
  $state_html .= '</SELECT>';

  $state_html .= '</SELECT>';

  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>';

  ($county_html, $state_html, $country_html);

}

sub batch_import {
  my $param = shift;

  my $fh = $param->{filehandle};
  my $format = $param->{'format'};

  my @fields;
  my $hook;
  if ( $format eq 'cch' ) {
    @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 );
    $hook = sub {
      my $hash = shift;

      $hash->{'effective_date'} = str2time($hash->{'effective_date'});

      my $taxclassid =
        join(':', map{ $hash->{$_} } qw(taxtype taxcat) );

      my %tax_class = ( 'data_vendor'  => 'cch', 
                        'taxclass' => $taxclassid,
                      );

      my $tax_class = qsearchs( 'tax_class', \%tax_class );
      return "Error inserting tax rate: no tax class $taxclassid"
        unless $tax_class;

      $hash->{'taxclassnum'} = $tax_class->taxclassnum;

      foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
        delete($hash->{$_});
      }

      my %passflagmap = ( '0' => '',
                          '1' => 'Y',
                          '2' => 'N',
                        );
      $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
        if exists $passflagmap{$hash->{'passflag'}};

      foreach (keys %$hash) {
        $hash->{$_} = substr($hash->{$_}, 0, 80)
          if length($hash->{$_}) > 80;
      }

    };

  } elsif ( $format eq 'extended' ) {
    die "unimplemented\n";
    @fields = qw( );
    $hook = sub {};
  } else {
    die "unknown format $format";
  }

  eval "use Text::CSV_XS;";
  die $@ if $@;

  my $csv = new Text::CSV_XS;

  my $imported = 0;

  local $SIG{HUP} = 'IGNORE';
  local $SIG{INT} = 'IGNORE';
  local $SIG{QUIT} = 'IGNORE';
  local $SIG{TERM} = 'IGNORE';
  local $SIG{TSTP} = 'IGNORE';
  local $SIG{PIPE} = 'IGNORE';

  my $oldAutoCommit = $FS::UID::AutoCommit;
  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);

    my @columns = $csv->fields();

    my %tax_rate = ( 'data_vendor' => $format );
    foreach my $field ( @fields ) {
      $tax_rate{$field} = shift @columns; 
    }
    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;

    if ( $error ) {
      $dbh->rollback if $oldAutoCommit;
      return "can't insert tax_rate for $line: $error";
    }

    $imported++;
  }

  $dbh->commit or die $dbh->errstr if $oldAutoCommit;

  return "Empty file!" unless $imported;

  ''; #no error

}

=back

=head1 BUGS

regionselector?  putting web ui components in here?  they should probably live
somewhere else...

=head1 SEE ALSO

L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
documentation.

=cut

1;




More information about the freeside-commits mailing list