[freeside-commits] branch master updated. 30e2dfd524a3f52445cbca6bc2cd1962dce7eb04

Mark Wells mark at 420.am
Tue Feb 18 22:04:42 PST 2014


The branch, master has been updated
       via  30e2dfd524a3f52445cbca6bc2cd1962dce7eb04 (commit)
      from  3c7fd3e7d40f2c946b2fcf63c63d595c82fcae22 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 30e2dfd524a3f52445cbca6bc2cd1962dce7eb04
Author: Mark Wells <mark at freeside.biz>
Date:   Tue Feb 18 22:04:12 2014 -0800

    non-package fees, phase 1, #25899

diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index 9de9eac..4b165eb 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -356,6 +356,9 @@ tie my %rights, 'Tie::IxHash',
 
     'Bulk edit package definitions',
 
+    'Edit fee definitions',
+    { rightname=>'Edit global fee definitions', global=>1 },
+
     'Edit billing events',
     { rightname=>'Edit global billing events', global=>1 },
 
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 73d7556..7bf5446 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -373,6 +373,10 @@ if ( -e $addl_handler_use_file ) {
   use FS::pbx_device;
   use FS::extension_device;
   use FS::cust_main_credit_limit;
+  use FS::cust_event_fee;
+  use FS::part_fee;
+  use FS::cust_bill_pkg_fee;
+  use FS::part_fee_msgcat;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index bd58698..bf516b2 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -937,6 +937,29 @@ sub tables_hashref {
                         ],
     },
 
+    'cust_event_fee' => {
+      'columns' => [
+        'eventfeenum', 'serial', '', '', '', '',
+        'eventnum',       'int', '', '', '', '',
+        'billpkgnum',     'int', 'NULL', '', '', '',
+        'feepart',        'int', '', '', '', '',
+      ],
+      'primary_key'  => 'eventfeenum', # I'd rather just use eventnum
+      'unique' => [ [ 'billpkgnum' ], [ 'eventnum' ] ], # one-to-one link
+      'index'  => [ [ 'feepart' ] ],
+      'foreign_keys' => [
+                          { columns => [ 'eventnum' ],
+                            table   => 'cust_event',
+                          },
+                          { columns => [ 'billpkgnum' ],
+                            table   => 'cust_bill_pkg',
+                          },
+                          { columns => [ 'feepart' ],
+                            table   => 'part_fee',
+                          },
+                        ],
+    },
+
     'cust_bill_pkg' => {
       'columns' => [
         'billpkgnum',          'serial',     '',      '', '', '', 
@@ -959,6 +982,7 @@ sub tables_hashref {
         'freq',               'varchar', 'NULL', $char_d, '', '',
         'quantity',               'int', 'NULL',      '', '', '',
         'hidden',                'char', 'NULL',       1, '', '',
+        'feepart',                'int', 'NULL',      '', '', '',
       ],
       'primary_key'  => 'billpkgnum',
       'unique'       => [],
@@ -975,6 +999,9 @@ sub tables_hashref {
                             table      => 'part_pkg',
                             references => [ 'pkgpart' ],
                           },
+                          { columns    => [ 'feepart' ],
+                            table      => 'part_fee',
+                          },
                         ],
     },
 
@@ -1017,7 +1044,7 @@ sub tables_hashref {
 
     'cust_bill_pkg_display' => {
       'columns' => [
-        'billpkgdisplaynum', 'serial', '', '', '', '', 
+        'billpkgdisplaynum', 'serial', '', '', '', '',
         'billpkgnum', 'int', '', '', '', '', 
         'section',  'varchar', 'NULL', $char_d, '', '', 
         #'unitsetup', @money_typen, '', '',     #override the linked real one?
@@ -1036,6 +1063,35 @@ sub tables_hashref {
                         ],
     },
 
+    'cust_bill_pkg_fee' => {
+      'columns' => [
+        'billpkgfeenum',    'serial', '', '', '', '',
+        'billpkgnum',          'int', '', '', '', '',
+        'base_invnum',       'int', '', '', '', '',
+        'base_billpkgnum',   'int', 'NULL', '', '', '',
+        'amount',        @money_type,         '', '',
+      ],
+      'primary_key' => 'billpkgfeenum',
+      'unique'      => [],
+      'index'       => [ ['billpkgnum'],
+                         ['base_invnum'],
+                         ['base_billpkgnum'],
+                       ],
+      'foreign_keys' => [
+                          { columns     => [ 'billpkgnum' ],
+                            table       => 'cust_bill_pkg',
+                          },
+                          { columns     => [ 'base_billpkgnum' ],
+                            table       => 'cust_bill_pkg',
+                            references  => [ 'billpkgnum' ],
+                          },
+                          { columns     => [ 'base_invnum' ],
+                            table       => 'cust_bill',
+                            references  => [ 'invnum' ],
+                          },
+                        ],
+    },
+
     'cust_bill_pkg_tax_location' => {
       'columns' => [
         'billpkgtaxlocationnum', 'serial',     '',      '', '', '',
@@ -1060,9 +1116,9 @@ sub tables_hashref {
                           { columns    => [ 'billpkgnum' ],
                             table      => 'cust_bill_pkg',
                           },
-                          { columns    => [ 'pkgnum' ],
-                            table      => 'cust_pkg',
-                          },
+                          #{ columns    => [ 'pkgnum' ],
+                          #  table      => 'cust_pkg',
+                          #}, # taxes can apply to fees
                           { columns    => [ 'locationnum' ],
                             table      => 'cust_location',
                           },
@@ -3072,6 +3128,63 @@ sub tables_hashref {
                         ],
     },
 
+    'part_fee' => {
+      'columns' => [
+        'feepart',       'serial',    '',   '', '', '',
+        'itemdesc',      'varchar',   '',   $char_d,   '', '',
+        'comment',       'varchar', 'NULL', 2*$char_d, '', '',
+        'disabled',      'char',    'NULL',  1, '', '',
+        'classnum',      'int',     'NULL', '', '', '',
+        'taxclass',      'varchar', 'NULL', $char_d, '', '',
+        'taxproductnum', 'int',     'NULL', '', '', '',
+        'pay_weight',    'real',    'NULL', '', '', '',
+        'credit_weight', 'real',    'NULL', '', '', '',
+        'agentnum',      'int',     'NULL', '', '', '',
+        'amount',   @money_type,                '', '', 
+        'percent',     'decimal',    '', '7,4', '', '',
+        'basis',         'varchar',  '',    16, '', '',
+        'minimum',    @money_typen,             '', '',
+        'maximum',    @money_typen,             '', '',
+        'limit_credit',  'char',    'NULL',  1, '', '',
+        'setuprecur',    'char',     '',     5, '', '',
+        'taxable',       'char',    'NULL',  1, '', '',
+      ],
+      'primary_key'  => 'feepart',
+      'unique'       => [],
+      'index'        => [ [ 'disabled' ], [ 'classnum' ], [ 'agentnum' ]
+                        ],
+      'foreign_keys' => [
+                          { columns    => [ 'classnum' ],
+                            table      => 'pkg_class',
+                          },
+                          { columns    => [ 'taxproductnum' ],
+                            table      => 'part_pkg_taxproduct',
+                          },
+                          { columns    => [ 'agentnum' ],
+                            table      => 'agent',
+                          },
+                        ],
+    },
+
+    'part_fee_msgcat' => {
+      'columns' => [
+        'feepartmsgnum',  'serial',     '',        '', '', '',
+        'feepart',           'int',     '',        '', '', '',
+        'locale',        'varchar',     '',        16, '', '',
+        'itemdesc',      'varchar',     '',   $char_d, '', '', #longer/no limit?
+        'comment',       'varchar', 'NULL', 2*$char_d, '', '', #longer/no limit?
+      ],
+      'primary_key'  => 'feepartmsgnum',
+      'unique'       => [ [ 'feepart', 'locale' ] ],
+      'index'        => [],
+      'foreign_keys' => [
+                          { columns    => [ 'feepart' ],
+                            table      => 'part_fee',
+                          },
+                        ],
+    },
+
+
     'part_pkg_link' => {
       'columns' => [
         'pkglinknum',  'serial',   '',      '', '', '',
diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm
index 569d98c..bf857a9 100644
--- a/FS/FS/TemplateItem_Mixin.pm
+++ b/FS/FS/TemplateItem_Mixin.pm
@@ -62,7 +62,9 @@ sub desc {
 
   if ( $self->pkgnum > 0 ) {
     $self->itemdesc || $self->part_pkg->pkg_locale($locale);
-  } else {
+  } elsif ( $self->feepart ) {
+    $self->part_fee->itemdesc_locale($locale);
+  } else { # by the process of elimination it must be a tax
     my $desc = $self->itemdesc || 'Tax';
     $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
     $desc;
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index b234d6f..a943921 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -11,6 +11,7 @@ use FS::cust_pkg;
 use FS::cust_bill_pkg_detail;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pkg_discount;
+use FS::cust_bill_pkg_fee;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
 use FS::cust_tax_exempt_pkg;
@@ -46,8 +47,8 @@ FS::cust_bill_pkg - Object methods for cust_bill_pkg records
 =head1 DESCRIPTION
 
 An FS::cust_bill_pkg object represents an invoice line item.
-FS::cust_bill_pkg inherits from FS::Record.  The following fields are currently
-supported:
+FS::cust_bill_pkg inherits from FS::Record.  The following fields are
+currently supported:
 
 =over 4
 
@@ -220,8 +221,7 @@ sub insert {
         # XXX if we ever do tax-on-tax for these, this will have to change
         # since pkgnum will be zero
         $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
-        $link->set('locationnum', 
-          $taxable_cust_bill_pkg->cust_pkg->tax_locationnum);
+        $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum);
         $link->set('taxable_cust_bill_pkg', '');
       }
 
@@ -256,6 +256,52 @@ sub insert {
     }
   }
 
+  my $fee_links = $self->get('cust_bill_pkg_fee');
+  if ( $fee_links ) {
+    foreach my $link ( @$fee_links ) {
+      # very similar to cust_bill_pkg_tax_location, for obvious reasons
+      next if $link->billpkgfeenum; # don't try to double-insert
+
+      my $target = $link->get('cust_bill_pkg'); # the line item of the fee
+      my $base = $link->get('base_cust_bill_pkg'); # line item it was based on
+
+      if ( $target and $target->billpkgnum ) {
+        $link->set('billpkgnum', $target->billpkgnum);
+        # base_invnum => null indicates that the fee is based on its own
+        # invoice
+        $link->set('base_invnum', $target->invnum) unless $link->base_invnum;
+        $link->set('cust_bill_pkg', '');
+      }
+
+      if ( $base and $base->billpkgnum ) {
+        $link->set('base_billpkgnum', $base->billpkgnum);
+        $link->set('base_cust_bill_pkg', '');
+      } elsif ( $base ) {
+        # it's based on a line item that's not yet inserted
+        my $link_array = $base->get('cust_bill_pkg_fee') || [];
+        push @$link_array, $link;
+        $base->set('cust_bill_pkg_fee' => $link_array);
+        next; # don't insert the link yet
+      }
+
+      $error = $link->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "error inserting cust_bill_pkg_fee: $error";
+      }
+    } # foreach my $link
+  }
+
+  my $cust_event_fee = $self->get('cust_event_fee');
+  if ( $cust_event_fee ) {
+    $cust_event_fee->set('billpkgnum' => $self->billpkgnum);
+    $error = $cust_event_fee->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error updating cust_event_fee: $error";
+    }
+  }
+
   my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
   if ( $cust_tax_adjustment ) {
     $cust_tax_adjustment->billpkgnum($self->billpkgnum);
@@ -903,6 +949,25 @@ sub credited {
   $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
 }
 
+=item tax_locationnum
+
+Returns the L<FS::cust_location> number that this line item is in for tax
+purposes.  For package sales, it's the package tax location; for fees, 
+it's the customer's default service location.
+
+=cut
+
+sub tax_locationnum {
+  my $self = shift;
+  if ( $self->pkgnum ) { # normal sales
+    return $self->cust_pkg->tax_locationnum;
+  } elsif ( $self->feepart ) { # fees
+    return $self->cust_bill->cust_main->ship_locationnum;
+  } else { # taxes
+    return '';
+  }
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -926,9 +991,10 @@ sub usage_sql { $usage_sql }
 # this makes owed_sql, etc. much more concise
 sub charged_sql {
   my ($class, $start, $end, %opt) = @_;
+  my $setuprecur = $opt{setuprecur} || '';
   my $charged = 
-    $opt{setuprecur} =~ /^s/ ? 'cust_bill_pkg.setup' :
-    $opt{setuprecur} =~ /^r/ ? 'cust_bill_pkg.recur' :
+    $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
+    $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
     'cust_bill_pkg.setup + cust_bill_pkg.recur';
 
   if ($opt{no_usage} and $charged =~ /recur/) { 
@@ -964,10 +1030,9 @@ sub paid_sql {
   my ($class, $start, $end, %opt) = @_;
   my $s = $start ? "AND cust_pay._date <= $start" : '';
   my $e = $end   ? "AND cust_pay._date >  $end"   : '';
-  my $setuprecur = 
-    $opt{setuprecur} =~ /^s/ ? 'setup' :
-    $opt{setuprecur} =~ /^r/ ? 'recur' :
-    '';
+  my $setuprecur = $opt{setuprecur} || '';
+  $setuprecur = 'setup' if $setuprecur =~ /^s/;
+  $setuprecur = 'recur' if $setuprecur =~ /^r/;
   $setuprecur &&= "AND setuprecur = '$setuprecur'";
 
   my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
@@ -993,10 +1058,9 @@ sub credited_sql {
   my ($class, $start, $end, %opt) = @_;
   my $s = $start ? "AND cust_credit._date <= $start" : '';
   my $e = $end   ? "AND cust_credit._date >  $end"   : '';
-  my $setuprecur = 
-    $opt{setuprecur} =~ /^s/ ? 'setup' :
-    $opt{setuprecur} =~ /^r/ ? 'recur' :
-    '';
+  my $setuprecur = $opt{setuprecur} || '';
+  $setuprecur = 'setup' if $setuprecur =~ /^s/;
+  $setuprecur = 'recur' if $setuprecur =~ /^r/;
   $setuprecur &&= "AND setuprecur = '$setuprecur'";
 
   my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
diff --git a/FS/FS/cust_bill_pkg_fee.pm b/FS/FS/cust_bill_pkg_fee.pm
new file mode 100644
index 0000000..8ea73c9
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_fee.pm
@@ -0,0 +1,91 @@
+package FS::cust_bill_pkg_fee;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cust_bill_pkg_fee - Object methods for cust_bill_pkg_fee records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_fee;
+
+  $record = new FS::cust_bill_pkg_fee \%hash;
+  $record = new FS::cust_bill_pkg_fee { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_fee object records the origin of a fee.  
+.  FS::cust_bill_pkg_fee inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item billpkgfeenum - primary key
+
+=item billpkgnum - the billpkgnum of the fee line item
+
+=item base_invnum - the invoice number (L<FS::cust_bill>) that caused 
+(this portion of) the fee to be charged.
+
+=item base_billpkgnum - the invoice line item (L<FS::cust_bill_pkg>) that
+caused (this portion of) the fee to be charged.  May be null.
+
+=item amount - the fee amount
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+sub table { 'cust_bill_pkg_fee'; }
+
+# seeing as these methods are not defined in this module I object to having
+# perldoc noise for them
+
+=item check
+
+Checks all fields to make sure this is a valid example.  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('billpkgfeenum')
+    || $self->ut_number('billpkgnum')
+    || $self->ut_foreign_key('origin_invnum', 'cust_bill', 'invnum')
+    || $self->ut_foreign_keyn('origin_billpkgnum', 'cust_bill_pkg', 'billpkgnum')
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm
new file mode 100644
index 0000000..78794fd
--- /dev/null
+++ b/FS/FS/cust_event_fee.pm
@@ -0,0 +1,158 @@
+package FS::cust_event_fee;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cust_event_fee - Object methods for cust_event_fee records
+
+=head1 SYNOPSIS
+
+  use FS::cust_event_fee;
+
+  $record = new FS::cust_event_fee \%hash;
+  $record = new FS::cust_event_fee { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_event_fee object links a billing event that charged a fee
+(an L<FS::cust_event>) to the resulting invoice line item (an 
+L<FS::cust_bill_pkg> object).  FS::cust_event_fee inherits from FS::Record.  
+The following fields are currently supported:
+
+=over 4
+
+=item eventfeenum - primary key
+
+=item eventnum - key of the cust_event record that required the fee to be 
+created.  This is a unique column; there's no reason for a single event 
+instance to create more than one fee.
+
+=item billpkgnum - key of the cust_bill_pkg record representing the fee 
+on an invoice.  This is also a unique column but can be NULL to indicate
+a fee that hasn't been billed yet.  In that case it will be billed the next
+time billing runs for the customer.
+
+=item feepart - key of the fee definition (L<FS::part_fee>).
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new event-fee link.  To add the record to the database, 
+see L<"insert">.
+
+=cut
+
+sub table { 'cust_event_fee'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=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 example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('eventfeenum')
+    || $self->ut_foreign_key('eventnum', 'cust_event', 'eventnum')
+    || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg', 'billpkgnum')
+    || $self->ut_foreign_key('feepart', 'part_fee', 'feepart')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item by_cust CUSTNUM[, PARAMS]
+
+Finds all cust_event_fee records belonging to the customer CUSTNUM.  Currently
+fee events can be cust_main or cust_bill events; this will return both.
+
+PARAMS can be additional params to pass to qsearch; this really only works
+for 'hashref' and 'order_by'.
+
+=cut
+
+sub by_cust {
+  my $class = shift;
+  my $custnum = shift or return;
+  my %params = @_;
+  $custnum =~ /^\d+$/ or die "bad custnum $custnum";
+
+  # silliness
+  my $where = ($params{hashref} && keys (%{ $params{hashref} }))
+              ? 'AND'
+              : 'WHERE';
+  qsearch({
+    table     => 'cust_event_fee',
+    addl_from => 'JOIN cust_event USING (eventnum) ' .
+                 'JOIN part_event USING (eventpart) ',
+    extra_sql => "$where eventtable = 'cust_main' ".
+                 "AND cust_event.tablenum = $custnum",
+    %params
+  }),
+  qsearch({
+    table     => 'cust_event_fee',
+    addl_from => 'JOIN cust_event USING (eventnum) ' .
+                 'JOIN part_event USING (eventpart) ' .
+                 'JOIN cust_bill ON (cust_event.tablenum = cust_bill.invnum)',
+    extra_sql => "$where eventtable = 'cust_bill' ".
+                 "AND cust_bill.custnum = $custnum",
+    %params
+  })
+}
+
+                  
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_event>, L<FS::part_fee>, L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index b8a71d4..6bd82d1 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -21,6 +21,7 @@ use FS::cust_bill_pkg_tax_rate_location;
 use FS::part_event;
 use FS::part_event_condition;
 use FS::pkg_category;
+use FS::cust_event_fee;
 use FS::Log;
 
 # 1 is mostly method/subroutine entry and options
@@ -538,6 +539,75 @@ sub bill {
            #.Dumper(\@cust_bill_pkg)."\n"
       if $DEBUG > 2;
 
+    ###
+    # process fees
+    ###
+
+    my @pending_event_fees = FS::cust_event_fee->by_cust($self->custnum,
+      hashref => { 'billpkgnum' => '' }
+    );
+    warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n"
+      if @pending_event_fees;
+
+    my @fee_items;
+    foreach my $event_fee (@pending_event_fees) {
+      my $object = $event_fee->cust_event->cust_X;
+      my $cust_bill;
+      if ( $object->isa('FS::cust_main') ) {
+        # Not the real cust_bill object that will be inserted--in particular
+        # there are no taxes yet.  If you want to charge a fee on the total 
+        # invoice amount including taxes, you have to put the fee on the next
+        # invoice.
+        $cust_bill = FS::cust_bill->new({
+            'custnum'       => $self->custnum,
+            'cust_bill_pkg' => \@cust_bill_pkg,
+            'charged'       => ${ $total_setup{$pass} } +
+                               ${ $total_recur{$pass} },
+        });
+      } elsif ( $object->isa('FS::cust_bill') ) {
+        # simple case: applying the fee to a previous invoice (late fee, 
+        # etc.)
+        $cust_bill = $object;
+      }
+      my $part_fee = $event_fee->part_fee;
+      # if the fee def belongs to a different agent, don't charge the fee.
+      # event conditions should prevent this, but just in case they don't,
+      # skip the fee.
+      if ( $part_fee->agentnum and $part_fee->agentnum != $self->agentnum ) {
+        warn "tried to charge fee#".$part_fee->feepart .
+             " on customer#".$self->custnum." from a different agent.\n";
+        next;
+      }
+      # also skip if it's disabled
+      next if $part_fee->disabled eq 'Y';
+      # calculate the fee
+      my $fee_item = $event_fee->part_fee->lineitem($cust_bill);
+      # link this so that we can clear the marker on inserting the line item
+      $fee_item->set('cust_event_fee', $event_fee);
+      push @fee_items, $fee_item;
+    }
+    foreach my $fee_item (@fee_items) {
+
+      push @cust_bill_pkg, $fee_item;
+      ${ $total_setup{$pass} } += $fee_item->setup;
+      ${ $total_recur{$pass} } += $fee_item->recur;
+
+      my $part_fee = $fee_item->part_fee;
+      my $fee_location = $self->ship_location; # I think?
+
+      my $error = $self->_handle_taxes(
+        $part_fee,
+        $taxlisthash{$pass},
+        $fee_item,
+        $fee_location,
+        $options{invoice_time},
+        {} # no options
+      );
+      return $error if $error;
+
+    }
+
+    # XXX implementation of fees is supposed to make this go away...
     if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
            !$conf->exists('postal_invoice-recurring_only')
        )
@@ -633,14 +703,12 @@ sub bill {
 
     my @cust_bill = $self->cust_bill;
     my $balance = $self->balance;
-    my $previous_balance = scalar(@cust_bill)
-                             ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
-                             : 0;
-
-    $previous_balance += $cust_bill[$#cust_bill]->charged
-      if scalar(@cust_bill);
-    #my $balance_adjustments =
-    #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+    my $previous_bill = $cust_bill[-1] if @cust_bill;
+    my $previous_balance = 0;
+    if ( $previous_bill ) {
+      $previous_balance = $previous_bill->billing_balance 
+                        + $previous_bill->charged;
+    }
 
     warn "creating the new invoice\n" if $DEBUG;
     #create the new invoice
@@ -935,6 +1003,7 @@ sub _make_lines {
 
   my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
   my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
+  my $cust_location = $cust_pkg->tax_location;
   my $precommit_hooks = $params{precommit_hooks} or die "no precommit_hooks specified";
   my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
   my $total_setup = $params{setup} or die "no setup accumulator specified";
@@ -1250,18 +1319,15 @@ sub _make_lines {
       # handle taxes
       ###
 
-      #unless ( $discount_show_always ) { # oh, for god's sake
       my $error = $self->_handle_taxes(
         $part_pkg,
         $taxlisthash,
         $cust_bill_pkg,
-        $cust_pkg,
+        $cust_location,
         $options{invoice_time},
-        $real_pkgpart,
         \%options # I have serious objections to this
       );
       return $error if $error;
-      #}
 
       $cust_bill_pkg->set_display(
         part_pkg     => $part_pkg,
@@ -1357,12 +1423,12 @@ sub _transfer_balance {
   return @transfers;
 }
 
-=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ]
+=item _handle_taxes PART_ITEM TAXLISTHASH CUST_BILL_PKG CUST_LOCATION TIME [ OPTIONS ]
 
 This is _handle_taxes.  It's called once for each cust_bill_pkg generated
-from _make_lines, along with the part_pkg, cust_pkg, invoice time, the 
-non-overridden pkgpart, a flag indicating whether the package is being
-canceled, and a partridge in a pear tree.
+from _make_lines, along with the part_pkg (or part_fee), cust_location,
+invoice time, a flag indicating whether the package is being canceled, and a 
+partridge in a pear tree.
 
 The most important argument is 'taxlisthash'.  This is shared across the 
 entire invoice.  It looks like this:
@@ -1382,23 +1448,20 @@ happen until calculate_taxes, though.
 
 sub _handle_taxes {
   my $self = shift;
-  my $part_pkg = shift;
+  my $part_item = shift;
   my $taxlisthash = shift;
   my $cust_bill_pkg = shift;
-  my $cust_pkg = shift;
+  my $location = shift;
   my $invoice_time = shift;
-  my $real_pkgpart = shift;
   my $options = shift;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my $location = $cust_pkg->tax_location;
-
   return if ( $self->payby eq 'COMP' ); #dubious
 
   if ( $conf->exists('enable_taxproducts')
-       && ( scalar($part_pkg->part_pkg_taxoverride)
-            || $part_pkg->has_taxproduct
+       && ( scalar($part_item->part_pkg_taxoverride)
+            || $part_item->has_taxproduct
           )
      )
     {
@@ -1423,13 +1486,13 @@ sub _handle_taxes {
     if ( !$exempt ) {
 
       foreach my $class (@classes) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
+        my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
         return $err_or_ref unless ref($err_or_ref);
         $taxes{$class} = $err_or_ref;
       }
 
       unless (exists $taxes{''}) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
+        my $err_or_ref = $self->_gather_taxes($part_item, '', $location);
         return $err_or_ref unless ref($err_or_ref);
         $taxes{''} = $err_or_ref;
       }
@@ -1505,7 +1568,7 @@ sub _handle_taxes {
     my @loc_keys = qw( district city county state country );
     my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
-    $taxhash{'taxclass'} = $part_pkg->taxclass;
+    $taxhash{'taxclass'} = $part_item->taxclass;
 
     warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
 
@@ -1538,44 +1601,28 @@ sub _handle_taxes {
   '';
 }
 
+=item _gather_taxes PART_ITEM CLASS CUST_LOCATION
+
+Internal method used with vendor-provided tax tables.  PART_ITEM is a part_pkg
+or part_fee (which will define the tax eligibility of the product), CLASS is
+'setup', 'recur', null, or a C<usage_class> number, and CUST_LOCATION is the 
+location where the service was provided (or billed, depending on 
+configuration).  Returns an arrayref of L<FS::tax_rate> objects that 
+can apply to this line item.
+
+=cut
+
 sub _gather_taxes {
   my $self = shift;
-  my $part_pkg = shift;
+  my $part_item = shift;
   my $class = shift;
-  my $cust_pkg = shift;
+  my $location = shift;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my $geocode = $cust_pkg->tax_location->geocode('cch');
-
-  my @taxes = ();
-
-  my @taxclassnums = map { $_->taxclassnum }
-                     $part_pkg->part_pkg_taxoverride($class);
-
-  unless (@taxclassnums) {
-    @taxclassnums = map { $_->taxclassnum }
-                    grep { $_->taxable eq 'Y' }
-                    $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
-  }
-  warn "Found taxclassnum values of ". join(',', @taxclassnums)
-    if $DEBUG;
-
-  my $extra_sql =
-    "AND (".
-    join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
-
-  @taxes = qsearch({ 'table' => 'tax_rate',
-                     'hashref' => { 'geocode' => $geocode, },
-                     'extra_sql' => $extra_sql,
-                  })
-    if scalar(@taxclassnums);
-
-  warn "Found taxes ".
-       join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" 
-   if $DEBUG;
+  my $geocode = $location->geocode('cch');
 
-  [ @taxes ];
+  [ $part_item->tax_rates('cch', $geocode, $class) ]
 
 }
 
@@ -2424,6 +2471,7 @@ sub apply_payments {
         _handle_taxes
           (vendor-only) _gather_taxes
       _omit_zero_value_bundles
+      _handle_taxes (for fees)
       calculate_taxes
 
     apply_payments_and_credits
diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm
index 5c1be7b..654e567 100644
--- a/FS/FS/cust_main_county.pm
+++ b/FS/FS/cust_main_county.pm
@@ -316,6 +316,11 @@ sub taxline {
 
     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
     my $part_pkg  = $cust_bill_pkg->part_pkg;
+    my $part_fee  = $cust_bill_pkg->part_fee;
+
+    my $locationnum = $cust_pkg
+                      ? $cust_pkg->locationnum
+                      : $cust_main->bill_locationnum;
 
     my @new_exemptions;
     my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
@@ -341,8 +346,13 @@ sub taxline {
 
     }
 
-    if ( ($part_pkg->setuptax eq 'Y' or $self->setuptax eq 'Y')
-        and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) {
+    my $setup_exempt = ( ($part_fee and not $part_fee->taxable)
+                      or ($part_pkg and $part_pkg->setuptax)
+                      or $self->setuptax );
+
+    if ( $setup_exempt
+        and $cust_bill_pkg->setup > 0
+        and $taxable_charged > 0 ) {
 
       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
           amount => $cust_bill_pkg->setup,
@@ -351,8 +361,14 @@ sub taxline {
       $taxable_charged -= $cust_bill_pkg->setup;
 
     }
-    if ( ($part_pkg->recurtax eq 'Y' or $self->recurtax eq 'Y')
-        and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) {
+
+    my $recur_exempt = ( ($part_fee and not $part_fee->taxable)
+                      or ($part_pkg and $part_pkg->recurtax)
+                      or $self->recurtax );
+
+    if ( $recur_exempt
+        and $cust_bill_pkg->recur > 0
+        and $taxable_charged > 0 ) {
 
       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
           amount => $cust_bill_pkg->recur,
@@ -494,7 +510,7 @@ sub taxline {
         'taxtype'     => ref($self),
         'cents'       => $this_tax_cents,
         'pkgnum'      => $cust_bill_pkg->pkgnum,
-        'locationnum' => $cust_bill_pkg->cust_pkg->tax_locationnum,
+        'locationnum' => $locationnum,
         'taxable_cust_bill_pkg' => $cust_bill_pkg,
         'tax_cust_bill_pkg'     => $tax_item,
     });
diff --git a/FS/FS/part_event/Action/Mixin/fee.pm b/FS/FS/part_event/Action/Mixin/fee.pm
new file mode 100644
index 0000000..8eb86fa
--- /dev/null
+++ b/FS/FS/part_event/Action/Mixin/fee.pm
@@ -0,0 +1,45 @@
+package FS::part_event::Action::Mixin::fee;
+
+use strict;
+use base qw( FS::part_event::Action );
+
+sub event_stage { 'pre-bill'; }
+
+sub option_fields {
+  (
+    'feepart'  => { label     => 'Fee definition',
+                    type      => 'select-table', #select-part_fee XXX
+                    table     => 'part_fee',
+                    hashref   => { disabled => '' },
+                    name_col  => 'itemdesc',
+                    value_col => 'feepart',
+                    disable_empty => 1,
+                  },
+  );
+}
+
+sub default_weight { 10; }
+
+sub do_action {
+  my( $self, $cust_object, $cust_event ) = @_;
+
+  die "no fee definition selected for event '".$self->event."'\n"
+    unless $self->option('feepart');
+
+  # mark the event so that the fee will be charged
+  # the logic for calculating the fee amount is in FS::part_fee
+  # the logic for attaching it to the base invoice/line items is in 
+  # FS::cust_bill_pkg
+  my $cust_event_fee = FS::cust_event_fee->new({
+      'eventnum'    => $cust_event->eventnum,
+      'feepart'     => $self->option('feepart'),
+      'billpkgnum'  => '',
+  });
+
+  my $error = $cust_event_fee->insert;
+  die $error if $error;
+
+  '';
+}
+
+1;
diff --git a/FS/FS/part_event/Action/cust_bill_fee.pm b/FS/FS/part_event/Action/cust_bill_fee.pm
new file mode 100644
index 0000000..fc185e4
--- /dev/null
+++ b/FS/FS/part_event/Action/cust_bill_fee.pm
@@ -0,0 +1,12 @@
+package FS::part_event::Action::cust_bill_fee;
+
+use strict;
+use base qw( FS::part_event::Action::Mixin::fee );
+
+sub description { 'Charge a fee based on this invoice'; }
+
+sub eventtable_hashref {
+    { 'cust_bill' => 1 };
+}
+
+1;
diff --git a/FS/FS/part_event/Action/cust_fee.pm b/FS/FS/part_event/Action/cust_fee.pm
new file mode 100644
index 0000000..a6f1078
--- /dev/null
+++ b/FS/FS/part_event/Action/cust_fee.pm
@@ -0,0 +1,16 @@
+package FS::part_event::Action::cust_fee;
+
+use strict;
+use base qw( FS::part_event::Action::Mixin::fee );
+
+sub description { 'Charge a fee based on the customer\'s current invoice'; }
+
+sub eventtable_hashref {
+    { 'cust_main' => 1 };
+}
+
+# Otherwise identical to cust_bill_fee.  We only have a separate event 
+# because it behaves differently as an invoice event than as a customer
+# event, and needs a different description.
+
+1;
diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm
new file mode 100644
index 0000000..67da245
--- /dev/null
+++ b/FS/FS/part_fee.pm
@@ -0,0 +1,428 @@
+package FS::part_fee;
+
+use strict;
+use base qw( FS::o2m_Common FS::Record );
+use vars qw( $DEBUG );
+use FS::Record qw( qsearch qsearchs );
+
+$DEBUG = 1;
+
+=head1 NAME
+
+FS::part_fee - Object methods for part_fee records
+
+=head1 SYNOPSIS
+
+  use FS::part_fee;
+
+  $record = new FS::part_fee \%hash;
+  $record = new FS::part_fee { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_fee object represents the definition of a fee
+
+Fees are like packages, but instead of being ordered and then billed on a 
+cycle, they are created by the operation of events and added to a single
+invoice.  The fee definition specifies the fee's description, how the amount
+is calculated (a flat fee or a percentage of the customer's balance), and 
+how to classify the fee for tax and reporting purposes.
+
+FS::part_fee inherits from FS::Record.  The following fields are currently 
+supported:
+
+=over 4
+
+=item feepart - primary key
+
+=item comment - a description of the fee for employee use, not shown on 
+the invoice
+
+=item disabled - 'Y' if the fee is disabled
+
+=item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
+
+=item taxable - 'Y' if this fee should be considered a taxable sale.  
+Currently, taxable fees will be treated like they exist at the customer's
+default service location.
+
+=item taxclass - the tax class the fee belongs to, as a string, for the 
+internal tax system
+
+=item taxproductnum - the tax product family the fee belongs to, for the 
+external tax system in use, if any
+
+=item pay_weight - Weight (relative to credit_weight and other package/fee 
+definitions) that controls payment application to specific line items.
+
+=item credit_weight - Weight that controls credit application to specific
+line items.
+
+=item agentnum - the agent (L<FS::agent>) who uses this fee definition.
+
+=item amount - the flat fee to charge, as a decimal amount
+
+=item percent - the percentage of the base to charge (out of 100).  If both
+this and "amount" are specified, the fee will be the sum of the two.
+
+=item basis - the method for calculating the base: currently one of "charged",
+"owed", or null.
+
+=item minimum - the minimum fee that should be charged
+
+=item maximum - the maximum fee that should be charged
+
+=item limit_credit - 'Y' to set the maximum fee at the customer's credit 
+balance, if any.
+
+=item setuprecur - whether the fee should be classified as 'setup' or 
+'recur', for reporting purposes.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new fee definition.  To add the record to the database, see 
+L<"insert">.
+
+=cut
+
+sub table { 'part_fee'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=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 example.  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('feepart')
+    || $self->ut_textn('comment')
+    || $self->ut_flag('disabled')
+    || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
+    || $self->ut_flag('taxable')
+    || $self->ut_textn('taxclass')
+    || $self->ut_numbern('taxproductnum')
+    || $self->ut_floatn('pay_weight')
+    || $self->ut_floatn('credit_weight')
+    || $self->ut_agentnum_acl('agentnum',
+                              [ 'Edit global package definitions' ])
+    || $self->ut_moneyn('amount')
+    || $self->ut_floatn('percent')
+    || $self->ut_moneyn('minimum')
+    || $self->ut_moneyn('maximum')
+    || $self->ut_flag('limit_credit')
+    || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
+    || $self->ut_enum('setuprecur', [ 'setup', 'recur' ])
+  ;
+  return $error if $error;
+
+  return "For a percentage fee, the basis must be set"
+    if $self->get('percent') > 0 and $self->get('basis') eq '';
+
+  if ( ! $self->get('percent') and ! $self->get('limit_credit') ) {
+    # then it makes no sense to apply minimum/maximum
+    $self->set('minimum', '');
+    $self->set('maximum', '');
+  }
+  if ( $self->get('limit_credit') ) {
+    $self->set('maximum', '');
+  }
+
+  $self->SUPER::check;
+}
+
+=item explanation
+
+Returns a string describing how this fee is calculated.
+
+=cut
+
+sub explanation {
+  my $self = shift;
+  # XXX customer currency
+  my $money_char = FS::Conf->new->config('money_char') || '$';
+  my $money = $money_char . '%.2f';
+  my $percent = '%.1f%%';
+  my $string;
+  if ( $self->amount > 0 ) {
+    $string = sprintf($money, $self->amount);
+  }
+  if ( $self->percent > 0 ) {
+    if ( $string ) {
+      $string .= " plus ";
+    }
+    $string .= sprintf($percent, $self->percent);
+    $string .= ' of the ';
+    if ( $self->basis eq 'charged' ) {
+      $string .= 'invoice amount';
+    } elsif ( $self->basis('owed') ) {
+      $string .= 'unpaid invoice balance';
+    }
+  }
+  if ( $self->minimum or $self->maximum or $self->limit_credit ) {
+    $string .= "\nbut";
+    if ( $self->minimum ) {
+      $string .= ' at least '.sprintf($money, $self->minimum);
+    }
+    if ( $self->maximum ) {
+      $string .= ' and' if $self->minimum;
+      $string .= ' at most '.sprintf($money, $self->maximum);
+    }
+    if ( $self->limit_credit ) {
+      if ( $self->maximum ) {
+        $string .= ", or the customer's credit balance, whichever is less.";
+      } else {
+        $string .= ' and' if $self->minimum;
+        $string .= " not more than the customer's credit balance";
+      }
+    }
+  }
+  return $string;
+}
+
+=item lineitem INVOICE
+
+Given INVOICE (an L<FS::cust_bill>), returns an L<FS::cust_bill_pkg> object 
+representing the invoice line item for the fee, with linked 
+L<FS::cust_bill_pkg_fee> record(s) allocating the fee to the invoice or 
+its line items, as appropriate.
+
+=cut
+
+sub lineitem {
+  my $self = shift;
+  my $cust_bill = shift;
+
+  my $amount = 0 + $self->get('amount');
+  my $total_base;  # sum of base line items
+  my @items;       # base line items (cust_bill_pkg records)
+  my @item_base;   # charged/owed of that item (sequential w/ @items)
+  my @item_fee;    # fee amount of that item (sequential w/ @items)
+  my @cust_bill_pkg_fee; # link record
+
+  warn "Calculating fee: ".$self->itemdesc." on ".
+    ($cust_bill->invnum ? "invoice #".$cust_bill->invnum : "current invoice").
+    "\n" if $DEBUG;
+  if ( $self->percent > 0 and $self->basis ne '' ) {
+    warn $self->percent . "% of amount ".$self->basis.")\n"
+      if $DEBUG;
+
+    # $total_base: the total charged/owed on the invoice
+    # %item_base: billpkgnum => fraction of base amount
+    if ( $cust_bill->invnum ) {
+      my $basis = $self->basis;
+      $total_base = $cust_bill->$basis; # "charged", "owed"
+
+      # calculate the fee on an already-inserted past invoice.  This may have 
+      # payments or credits, so if basis = owed, we need to consider those.
+      my $basis_sql = $basis.'_sql';
+      my $sql = 'SELECT ' . FS::cust_bill_pkg->$basis_sql .
+                ' FROM cust_bill_pkg WHERE billpkgnum = ?';
+      @items = $cust_bill->cust_bill_pkg;
+      @item_base = map { FS::Record->scalar_sql($sql, $_->billpkgnum) }
+                    @items;
+    } else {
+      # the fee applies to _this_ invoice.  It has no payments or credits, so
+      # "charged" and "owed" basis are both just the invoice amount, and 
+      # the line item amounts (setup + recur)
+      $total_base = $cust_bill->charged;
+      @items = @{ $cust_bill->get('cust_bill_pkg') };
+      @item_base = map { $_->setup + $_->recur }
+                    @items;
+    }
+
+    $amount += $total_base * $self->percent / 100;
+  }
+
+  if ( $self->minimum ne '' and $amount < $self->minimum ) {
+    warn "Applying mininum fee\n" if $DEBUG;
+    $amount = $self->minimum;
+  }
+
+  my $maximum = $self->maximum;
+  if ( $self->limit_credit ) {
+    my $balance = $cust_bill->cust_main;
+    if ( $balance >= 0 ) {
+      $maximum = 0;
+    } elsif ( -1 * $balance < $maximum ) {
+      $maximum = -1 * $balance;
+    }
+  }
+  if ( $maximum ne '' and $amount > $maximum ) {
+    warn "Applying maximum fee\n" if $DEBUG;
+    $amount = $maximum;
+  }
+
+  # at this point, if the fee is zero, return nothing
+  return if $amount < 0.005;
+  $amount = sprintf('%.2f', $amount);
+
+  my $cust_bill_pkg = FS::cust_bill_pkg->new({
+      feepart     => $self->feepart,
+      pkgnum      => 0,
+      # no sdate/edate, right?
+      setup       => 0,
+      recur       => 0,
+  });
+  $cust_bill_pkg->set( $self->setuprecur, $amount );
+  
+  if ( $self->classnum ) {
+    my $pkg_category = $self->pkg_class->pkg_category;
+    $cust_bill_pkg->set('section' => $pkg_category->categoryname)
+      if $pkg_category;
+  }
+
+  # if this is a percentage fee and has line item fractions,
+  # adjust them to be proportional and to add up correctly.
+  if ( @item_base ) {
+    my $cents = $amount * 100;
+    # not necessarily the same as percent
+    my $multiplier = $amount / $total_base;
+    for (my $i = 0; $i < scalar(@items); $i++) {
+      my $fee = sprintf('%.2f', $item_base[$i] * $multiplier);
+      $item_fee[$i] = $fee;
+      $cents -= $fee * 100;
+    }
+    # correct rounding error
+    while ($cents >= 0.5 or $cents < -0.5) {
+      foreach my $fee (@item_fee) {
+        if ( $cents >= 0.5 ) {
+          $fee += 0.01;
+          $cents--;
+        } elsif ( $cents < -0.5 ) {
+          $fee -= 0.01;
+          $cents++;
+        }
+      }
+    }
+    # and add them to the cust_bill_pkg
+    for (my $i = 0; $i < scalar(@items); $i++) {
+      if ( $item_fee[$i] > 0 ) {
+        push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
+            cust_bill_pkg   => $cust_bill_pkg,
+            base_invnum     => $cust_bill->invnum,
+            amount          => $item_fee[$i],
+            base_cust_bill_pkg => $items[$i], # late resolve
+        });
+      }
+    }
+  } else { # if !@item_base
+    # then this isn't a proportional fee, so it just applies to the 
+    # entire invoice.
+    # (if it's the current invoice, $cust_bill->invnum is null and that 
+    # will be fixed later)
+    push @cust_bill_pkg_fee, FS::cust_bill_pkg_fee->new({
+        cust_bill_pkg   => $cust_bill_pkg,
+        base_invnum     => $cust_bill->invnum,
+        amount          => $amount,
+    });
+  }
+
+  # cust_bill_pkg::insert will handle this
+  $cust_bill_pkg->set('cust_bill_pkg_fee', \@cust_bill_pkg_fee);
+  # avoid misbehavior by usage() and some other things
+  $cust_bill_pkg->set('details', []);
+
+  return $cust_bill_pkg;
+}
+
+=item itemdesc_locale LOCALE
+
+Returns a customer-viewable description of this fee for the given locale,
+from the part_fee_msgcat table.  If the locale is empty or no localized fee
+description exists, returns part_fee.itemdesc.
+
+=cut
+
+sub itemdesc_locale {
+  my ( $self, $locale ) = @_;
+  return $self->itemdesc unless $locale;
+  my $part_fee_msgcat = qsearchs('part_fee_msgcat', {
+    feepart => $self->feepart,
+    locale  => $locale,
+  }) or return $self->itemdesc;
+  $part_fee_msgcat->itemdesc;
+}
+
+=item tax_rates DATA_PROVIDER, GEOCODE
+
+Returns the external taxes (L<FS::tax_rate> objects) that apply to this
+fee, in the location specified by GEOCODE.
+
+=cut
+
+sub tax_rates {
+  my $self = shift;
+  my ($vendor, $geocode) = @_;
+  return unless $self->taxproductnum;
+  my $taxproduct = FS::part_pkg_taxproduct->by_key($self->taxproductnum);
+  # cch stuff
+  my @taxclassnums = map { $_->taxclassnum }
+                     $taxproduct->part_pkg_taxrate($geocode);
+  return unless @taxclassnums;
+
+  warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
+  if $DEBUG;
+  my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
+  my @taxes = qsearch({ 'table'     => 'tax_rate',
+      'hashref'   => { 'geocode'     => $geocode,
+        'data_vendor' => $vendor },
+      'extra_sql' => $extra_sql,
+    });
+  warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
+  if $DEBUG;
+
+  return @taxes;
+}
+
+sub part_pkg_taxoverride {} # we don't do overrides here
+
+sub has_taxproduct {
+  my $self = shift;
+  return ($self->taxproductnum ? 1 : 0);
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_fee_msgcat.pm b/FS/FS/part_fee_msgcat.pm
new file mode 100644
index 0000000..e60651e
--- /dev/null
+++ b/FS/FS/part_fee_msgcat.pm
@@ -0,0 +1,127 @@
+package FS::part_fee_msgcat;
+use base qw( FS::Record );
+
+use strict;
+use FS::Locales;
+
+=head1 NAME
+
+FS::part_fee_msgcat - Object methods for part_fee_msgcat records
+
+=head1 SYNOPSIS
+
+  use FS::part_fee_msgcat;
+
+  $record = new FS::part_fee_msgcat \%hash;
+  $record = new FS::part_fee_msgcat { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_fee_msgcat object represents localized labels of a fee
+definition.  FS::part_fee_msgcat inherits from FS::Record.  The following
+fields are currently supported:
+
+=over 4
+
+=item feepartmsgnum
+
+primary key
+
+=item feepart - Fee definition (L<FS::part_fee>)
+
+=item locale - locale string
+
+=item itemdesc - Localized fee name (customer-viewable)
+
+=item comment - Localized fee comment (non-customer-viewable), optional
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record 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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'part_fee_msgcat'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=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
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid record.  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('feepartmsgnum')
+    || $self->ut_foreign_key('feepart', 'part_fee', 'feepart')
+    || $self->ut_enum('locale', [ FS::Locales->locales ] )
+    || $self->ut_text('itemdesc')
+    || $self->ut_textn('comment')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+Exactly duplicates part_pkg_msgcat.pm.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm
index 8f8287b..e872232 100644
--- a/FS/FS/part_pkg.pm
+++ b/FS/FS/part_pkg.pm
@@ -1463,74 +1463,40 @@ sub taxproduct_description {
   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
 }
 
-=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
 
-Returns the package to taxrate m2m records for this package in the location
-specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
-CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
-(see L<FS::usage_class>).
+=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
 
-=cut
+Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
+package in the location specified by GEOCODE, for usage class CLASS (one of
+'setup', 'recur', null, or a C<usage_class> number).
 
-sub _expand_cch_taxproductnum {
-  my $self = shift;
-  my $class = shift;
-  my $part_pkg_taxproduct = $self->taxproduct($class);
-
-  my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
-                         ? ( split ':', $part_pkg_taxproduct->taxproduct )
-                         : ()
-                     );
-  $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
-  my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
-                      OR taxproduct = '$a:$b:$c:'
-                      OR taxproduct = '$a:$b:".":$d'
-                      OR taxproduct = '$a:$b:".":' )";
-  map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
-                                       'hashref'   => { 'data_vendor'=>'cch' },
-                                       'extra_sql' => $extra_sql,
-                                   } );
-                                     
-}
+=cut
 
-sub part_pkg_taxrate {
+sub tax_rates {
   my $self = shift;
-  my ($data_vendor, $geocode, $class) = @_;
-
-  my $dbh = dbh;
-  my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
-                  dbh->quote($data_vendor);
-  
-  # CCH oddness in m2m
-  $extra_sql .= ' AND ('.
-    join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
-                 qw(10 5 2)
-        ).
-    ')';
-  # much more CCH oddness in m2m -- this is kludgy
-  my @tpnums = $self->_expand_cch_taxproductnum($class);
-  if (scalar(@tpnums)) {
-    $extra_sql .= ' AND ('.
-                            join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
-                       ')';
-  } else {
-    $extra_sql .= ' AND ( 0 = 1 )';
+  my ($vendor, $geocode, $class) = @_;
+  my @taxclassnums = map { $_->taxclassnum } 
+                     $self->part_pkg_taxoverride($class);
+  if (!@taxclassnums) {
+    my $part_pkg_taxproduct = $self->taxproduct($class);
+    @taxclassnums = map { $_->taxclassnum }
+                    grep { $_->taxable eq 'Y' } # why do we need this?
+                    $part_pkg_taxproduct->part_pkg_taxrate($geocode);
   }
+  return unless @taxclassnums;
+
+  warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
+      if $DEBUG;
+  my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
+  my @taxes = qsearch({ 'table'     => 'tax_rate',
+                        'hashref'   => { 'geocode'     => $geocode,
+                                         'data_vendor' => $vendor },
+                        'extra_sql' => $extra_sql,
+                      });
+  warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
+      if $DEBUG;
 
-  my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
-  my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
-  my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
-
-  # should qsearch preface columns with the table to facilitate joins?
-  qsearch( { 'table'     => 'part_pkg_taxrate',
-             'select'    => $select,
-             'hashref'   => { # 'data_vendor'   => $data_vendor,
-                              # 'taxproductnum' => $self->taxproductnum,
-                            },
-             'addl_from' => $addl_from,
-             'extra_sql' => $extra_sql,
-             'order_by'  => $order_by,
-         } );
+  return @taxes;
 }
 
 =item part_pkg_discount
diff --git a/FS/FS/part_pkg_taxproduct.pm b/FS/FS/part_pkg_taxproduct.pm
index 56e63b6..ddea1da 100644
--- a/FS/FS/part_pkg_taxproduct.pm
+++ b/FS/FS/part_pkg_taxproduct.pm
@@ -2,7 +2,7 @@ package FS::part_pkg_taxproduct;
 
 use strict;
 use vars qw( @ISA $delete_kludge );
-use FS::Record qw( qsearch );
+use FS::Record qw( qsearch dbh );
 
 @ISA = qw(FS::Record);
 $delete_kludge = 0;
@@ -123,12 +123,86 @@ sub check {
   $self->SUPER::check;
 }
 
+=item part_pkg_taxrate GEOCODE
+
+Returns the L<FS::part_pkg_taxrate> records (tax definitions) that can apply 
+to this tax product category in the location identified by GEOCODE.
+
+=cut
+
+# actually only returns one arbitrary record for each taxclassnum, making 
+# it useful only for retrieving the taxclassnums
+
+sub part_pkg_taxrate {
+  my $self = shift;
+  my $data_vendor = $self->data_vendor; # because duh
+  my $geocode = shift;
+
+  my $dbh = dbh;
+
+  # CCH oddness in m2m
+  my $extra_sql .= "AND part_pkg_taxrate.data_vendor = '$data_vendor' ".
+                   "AND (".
+    join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
+                 qw(10 5 2)
+        ).
+    ')';
+  # much more CCH oddness in m2m -- this is kludgy
+  my $tpnums = join(',',
+    map { $_->taxproductnum }
+    $self->expand_cch_taxproduct
+  );
+  $extra_sql .= "AND taxproductnum IN($tpnums)";
+
+  my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
+  my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
+  my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
+
+  # should qsearch preface columns with the table to facilitate joins?
+  qsearch( { 'table'     => 'part_pkg_taxrate',
+             'select'    => $select,
+             'hashref'   => { 'taxable' => 'Y' },
+             'addl_from' => $addl_from,
+             'extra_sql' => $extra_sql,
+             'order_by'  => $order_by,
+         } );
+}
+
+=item expand_cch_taxproduct
+
+Returns the full set of part_pkg_taxproduct records that are "implied" by 
+this one.
+
+=cut
+
+sub expand_cch_taxproduct {
+  my $self = shift;
+  my $class = shift;
+
+  my ($a,$b,$c,$d) = split ':', $self->taxproduct;
+  $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
+  my $taxproducts = join(',',
+    "'${a}:${b}:${c}:${d}'",
+    "'${a}:${b}:${c}:'",
+    "'${a}:${b}::${d}'",
+    "'${a}:${b}::'"
+  );
+  qsearch( {
+      'table'     => 'part_pkg_taxproduct',
+      'hashref'   => { 'data_vendor'=>'cch' },
+      'extra_sql' => "AND taxproduct IN($taxproducts)",
+  } );
+}
+
+
 =back
 
 =cut
 
 =head1 BUGS
 
+Confusingly named.  It has nothing to do with part_pkg.
+
 =head1 SEE ALSO
 
 L<FS::Record>, schema.html from the base documentation.
diff --git a/FS/MANIFEST b/FS/MANIFEST
index a0a71c9..129ee64 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -758,3 +758,11 @@ FS/extension_device.pm
 t/extension_device.t
 FS/cust_main_credit_limit.pm
 t/cust_main_credit_limit.t
+FS/cust_event_fee.pm
+t/cust_event_fee.t
+FS/part_fee.pm
+t/part_fee.t
+FS/cust_bill_pkg_fee.pm
+t/cust_bill_pkg_fee.t
+FS/part_fee_msgcat.pm
+t/part_fee_msgcat.t
diff --git a/FS/t/cust_bill_pkg_fee.t b/FS/t/cust_bill_pkg_fee.t
new file mode 100644
index 0000000..c7cf0a0
--- /dev/null
+++ b/FS/t/cust_bill_pkg_fee.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_fee;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_event_fee.t b/FS/t/cust_event_fee.t
new file mode 100644
index 0000000..882b1df
--- /dev/null
+++ b/FS/t/cust_event_fee.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_event_fee;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_fee.t b/FS/t/part_fee.t
new file mode 100644
index 0000000..b4192a4
--- /dev/null
+++ b/FS/t/part_fee.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_fee;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_fee_msgcat.t b/FS/t/part_fee_msgcat.t
new file mode 100644
index 0000000..f7e8ca8
--- /dev/null
+++ b/FS/t/part_fee_msgcat.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_fee_msgcat;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/browse/part_fee.html b/httemplate/browse/part_fee.html
new file mode 100644
index 0000000..0370fe0
--- /dev/null
+++ b/httemplate/browse/part_fee.html
@@ -0,0 +1,67 @@
+<& elements/browse.html,
+  'title'         => 'Fee definitions',
+  'name_singular' => 'fee definition',
+  'query'         => $query,
+  'count_query'   => $count_query,
+  'header'        => [  '#',
+                        'Description',
+                        'Comment',
+                        'Class',
+                        'Amount',
+                        'Tax status',
+                     ],
+  'fields'        => [  'feepart',
+                        'itemdesc',
+                        'comment',
+                        'classname',
+                        $sub_amount,
+                        $sub_tax,
+                     ],
+  disableable     => 1,
+  disabled_statuspos => 3,
+  agent_pos       => 6,
+  agent_virt      => 1,
+  agent_null_right=> 'Edit global fee definitions',
+  links           => [  '',
+                        $link,
+                        $link,
+                     ],
+  align           => 'cllccc',
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit fee definitions');
+my $acl_edit_global = $curuser->access_right('Edit global fee definitions');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $query = {
+  'select'    => 'part_fee.*,'.
+                 '(select classname from pkg_class '.
+                 'where pkg_class.classnum = part_fee.classnum) AS classname',
+  'table'     => 'part_fee',  
+};
+my $count_query = "SELECT COUNT(*) FROM part_fee";
+
+my $sub_amount = sub {
+  my $obj = shift;
+  my $string = $obj->explanation;
+  $string =~ s/\n/<br>/sg;
+  $string;
+};
+
+my $sub_tax = sub {
+  my $obj = shift;
+  if ( $obj->taxable ) {
+    return $obj->taxclass || 'taxable';
+  } elsif ( $obj->taxproductnum ) {
+    return join('<br>', 
+      split(/\s*:\s*/, $obj->part_pkg_taxproduct->description)
+    );
+  } else {
+    return 'exempt';
+  }
+};
+
+my $link = [ $p.'edit/part_fee.html?', 'feepart' ];
+</%init>
diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html
new file mode 100644
index 0000000..dada233
--- /dev/null
+++ b/httemplate/edit/part_fee.html
@@ -0,0 +1,141 @@
+<& elements/edit.html,
+  'name_singular' => 'fee definition',
+  'table'         => 'part_fee',
+  'labels'        => {
+    'feepart'       => 'Fee definition',
+    'itemdesc'      => 'Description',
+    'comment'       => 'Comment (customer-hidden)',
+    'classnum'      => 'Package class',
+    'taxable'       => 'This fee is taxable',
+    'disabled'      => 'Disable this fee',
+    'taxclass'      => 'Tax class name',
+    'taxproductnum' => 'Tax product',
+    'pay_weight'    => 'Payment weight',
+    'credit_weight' => 'Credit weight',
+    'agentnum'      => 'Agent',
+    'amount'        => 'Flat fee amount',
+    'percent'       => 'Percentage of invoice amount',
+    'basis'         => 'Based on',
+    'setuprecur'    => 'Report this fee as',
+    'minimum'       => 'Minimum fee',
+    'maximum'       => 'Maximum fee',
+    'limit_credit'  => 'Limit to customer credit balance',
+    %locale_labels
+  },
+  'fields'        => \@fields,
+  'edit_callback'   => $edit_callback,
+  'error_callback'  => $error_callback,
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit fee definitions');
+my $acl_edit_global = $curuser->access_right('Edit global fee definitions');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $conf = FS::Conf->new;
+my @tax_fields;
+if ( $conf->exists('enable_taxproducts') ) {
+  @tax_fields = (
+    { field => 'taxproductnum', type => 'select-taxproduct' }
+  );
+} else {
+  @tax_fields = (
+    { field => 'taxable', type => 'checkbox', value => 'Y' },
+  );
+  push (
+    { field => 'taxclass', type => 'select-taxclass' },
+  ) if $conf->exists('enable_taxclasses');
+}
+
+my $default_locale = $conf->config('locale') || 'en_US';
+my @locales = grep {$_ ne $default_locale} $conf->config('available-locales');
+# duplicates edit/part_pkg.cgi, yuck
+my $n = 0;
+my (@locale_fields, %locale_labels);
+foreach (@locales) {
+  push @locale_fields,
+    { field => 'feepartmsgnum'. $n,               type => 'hidden' },
+    { field => 'feepartmsgnum'. $n. '_locale',    type => 'hidden' },
+    { field => 'feepartmsgnum'. $n. '_itemdesc',  type => 'text', size => 40 },
+  ;
+  $locale_labels{ 'feepartmsgnum'.$n.'_itemdesc' } =
+    'Description—' . FS::Locales->description($_);
+  $n++;
+}
+
+my @fields = (
+
+  { field   => 'itemdesc',  type    => 'text', size    => 40, },
+  @locale_fields,
+
+  { field   => 'comment',   type    => 'text', size    => 40, },
+
+  { field   => 'agentnum',
+    type    => 'select-agent',
+    disable_empty => !$acl_edit_global,
+    empty_label   => '(global)',
+  },
+
+  { field   => 'classnum',
+    type    => 'select-pkg_class',
+  },
+
+  { field   => 'disabled',
+    type    => 'checkbox',
+    value   => 'Y',
+  },
+
+  { field   => 'setuprecur',
+    type    => 'select',
+    options => [ 'setup', 'recur' ],
+    labels  => { 'setup'  => 'a setup fee',
+                 'recur'  => 'a recurring charge' },
+  },
+
+  { type => 'justtitle', value => 'Fee calculation' },
+  { field   => 'amount',  type    => 'money', },
+  { field   => 'percent', type    => 'percentage', },
+
+  { field   => 'basis',
+    type    => 'select',
+    options => [ 'charged', 'owed' ],
+    labels  => { 'charged' => 'amount charged',
+                 'owed'    => 'balance due', },
+  },
+
+  { field   => 'minimum', type    => 'money', },
+  { field   => 'maximum', type    => 'money', },
+  { field   => 'limit_credit',
+    type    => 'checkbox',
+    value   => 'Y' },
+
+  { type => 'justtitle', value => 'Taxation' },
+
+  @tax_fields,
+);
+
+my $edit_callback = sub {
+  my ($cgi, $obj, $fields, $opt) = @_;
+  my %existing_locales;
+  if ( $obj->feepart ) {
+    %existing_locales = map { $_->locale => $_ } $obj->part_fee_msgcat;
+  }
+  my $n = 0;
+  foreach (@locales) {
+    $obj->set('feepartmsgnum'.$n.'_locale', $_);
+    # load the existing itemdescs
+    if ( my $msgcat = $existing_locales{$_} ) {
+      $obj->set('feepartmsgnum'.$n, $msgcat->feepartmsgnum);
+      $obj->set('feepartmsgnum'.$n.'_itemdesc', $msgcat->itemdesc);
+    } 
+    # then override that with the CGI param if there is one
+    if ( my $itemdesc = $cgi->param('feepartmsgnum'.$n.'_itemdesc') ) {
+      $obj->set('feepartmsgnum'.$n.'_itemdesc', $itemdesc);
+    }
+    $n++;
+  }
+};
+
+my $error_callback = $edit_callback;
+</%init>
diff --git a/httemplate/edit/process/part_fee.html b/httemplate/edit/process/part_fee.html
new file mode 100755
index 0000000..25656e9
--- /dev/null
+++ b/httemplate/edit/process/part_fee.html
@@ -0,0 +1,20 @@
+<& elements/process.html,
+  'debug'             => 1,
+  'table'             => 'part_fee',
+  'agent_virt'        => 1,
+  'agent_null_right'  => 'Edit global fee definitions',
+  'viewall_dir'       => 'browse',
+  'process_o2m'       => {
+                            'table'   => 'part_fee_msgcat',
+                            'fields'  => [ 'locale', 'itemdesc' ],
+                         },
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit fee definitions');
+my $acl_edit_global = $curuser->access_right('Edit global fee definitions');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+</%init>
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index fb84e75..cd4fb39 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -584,6 +584,10 @@ if ( $curuser->access_right('Configuration') ) {
   $config_pkg{'Package report classes'} =  [ $fsurl.'browse/part_pkg_report_option.html', 'Package classes define optional groups of packages for reporting only.' ];
   #eo package grouping sub-menu
 
+  if ( $curuser->access_right([ 'Edit fee definitions',
+                                'Edit global fee definitions' ]) ) {
+    $config_pkg{'Fees'} = [ $fsurl.'browse/part_fee.html', '' ];
+  }
   $config_pkg{'Discounts'} = [ $fsurl.'browse/discount.html', '' ];
   $config_pkg{'Discount classes'} = [ $fsurl.'browse/discount_class.html', '' ];
   $config_pkg{'Cancel/Suspend Reasons'} = [ \%config_pkg_reason, '' ];

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/AccessRight.pm                     |    3 +
 FS/FS/Mason.pm                           |    4 +
 FS/FS/Schema.pm                          |  121 ++++++++-
 FS/FS/TemplateItem_Mixin.pm              |    4 +-
 FS/FS/cust_bill_pkg.pm                   |   92 ++++++-
 FS/FS/cust_bill_pkg_fee.pm               |   91 +++++++
 FS/FS/cust_event_fee.pm                  |  158 +++++++++++
 FS/FS/cust_main/Billing.pm               |  162 ++++++++----
 FS/FS/cust_main_county.pm                |   26 ++-
 FS/FS/part_event/Action/Mixin/fee.pm     |   45 +++
 FS/FS/part_event/Action/cust_bill_fee.pm |   12 +
 FS/FS/part_event/Action/cust_fee.pm      |   16 ++
 FS/FS/part_fee.pm                        |  428 ++++++++++++++++++++++++++++++
 FS/FS/part_fee_msgcat.pm                 |  127 +++++++++
 FS/FS/part_pkg.pm                        |   88 ++-----
 FS/FS/part_pkg_taxproduct.pm             |   76 ++++++-
 FS/MANIFEST                              |    8 +
 FS/t/cust_bill_pkg_fee.t                 |    5 +
 FS/t/cust_event_fee.t                    |    5 +
 FS/t/part_fee.t                          |    5 +
 FS/t/part_fee_msgcat.t                   |    5 +
 httemplate/browse/part_fee.html          |   67 +++++
 httemplate/edit/part_fee.html            |  141 ++++++++++
 httemplate/edit/process/part_fee.html    |   20 ++
 httemplate/elements/menu.html            |    4 +
 25 files changed, 1570 insertions(+), 143 deletions(-)
 create mode 100644 FS/FS/cust_bill_pkg_fee.pm
 create mode 100644 FS/FS/cust_event_fee.pm
 create mode 100644 FS/FS/part_event/Action/Mixin/fee.pm
 create mode 100644 FS/FS/part_event/Action/cust_bill_fee.pm
 create mode 100644 FS/FS/part_event/Action/cust_fee.pm
 create mode 100644 FS/FS/part_fee.pm
 create mode 100644 FS/FS/part_fee_msgcat.pm
 create mode 100644 FS/t/cust_bill_pkg_fee.t
 create mode 100644 FS/t/cust_event_fee.t
 create mode 100644 FS/t/part_fee.t
 create mode 100644 FS/t/part_fee_msgcat.t
 create mode 100644 httemplate/browse/part_fee.html
 create mode 100644 httemplate/edit/part_fee.html
 create mode 100755 httemplate/edit/process/part_fee.html




More information about the freeside-commits mailing list