Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Wed, 26 Feb 2014 00:23:14 +0000 (16:23 -0800)
committerIvan Kohler <ivan@freeside.biz>
Wed, 26 Feb 2014 00:23:14 +0000 (16:23 -0800)
44 files changed:
FS/FS/AccessRight.pm
FS/FS/Mason.pm
FS/FS/Report/Table.pm
FS/FS/Schema.pm
FS/FS/TemplateItem_Mixin.pm
FS/FS/Template_Mixin.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_bill_pkg_fee.pm [new file with mode: 0644]
FS/FS/cust_credit.pm
FS/FS/cust_event_fee.pm [new file with mode: 0644]
FS/FS/cust_main/Billing.pm
FS/FS/cust_main_county.pm
FS/FS/option_Common.pm
FS/FS/part_event/Action/Mixin/fee.pm [new file with mode: 0644]
FS/FS/part_event/Action/cust_bill_fee.pm [new file with mode: 0644]
FS/FS/part_event/Action/cust_fee.pm [new file with mode: 0644]
FS/FS/part_event/Action/fee.pm
FS/FS/part_export.pm
FS/FS/part_fee.pm [new file with mode: 0644]
FS/FS/part_fee_msgcat.pm [new file with mode: 0644]
FS/FS/part_pkg.pm
FS/FS/part_pkg_taxproduct.pm
FS/FS/pay_batch/RBC.pm
FS/FS/tax_rate.pm
FS/MANIFEST
FS/t/cust_bill_pkg_fee.t [new file with mode: 0644]
FS/t/cust_event_fee.t [new file with mode: 0644]
FS/t/part_fee.t [new file with mode: 0644]
FS/t/part_fee_msgcat.t [new file with mode: 0644]
bin/cdr-voipswitch.import
bin/test_scrub_sql
httemplate/browse/part_fee.html [new file with mode: 0644]
httemplate/edit/credit-cust_bill_pkg.html
httemplate/edit/part_fee.html [new file with mode: 0644]
httemplate/edit/process/part_fee.html [new file with mode: 0755]
httemplate/elements/menu.html
httemplate/misc/xmlhttp-calculate_taxes.html
httemplate/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html
httemplate/pref/pref-process.html
httemplate/pref/pref.html
httemplate/search/cust_bill_pkg.cgi
httemplate/view/cust_main/packages.html
httemplate/view/cust_main/packages/hidden.html [new file with mode: 0644]
httemplate/view/cust_main/packages/section.html

index 9de9eac..4b165eb 100644 (file)
@@ -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 },
 
index 73d7556..7bf5446 100644 (file)
@@ -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 ) {
index 7f59384..17b12ae 100644 (file)
@@ -141,7 +141,7 @@ sub payments {
 sub credits {
   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
   $self->scalar_sql("
-    SELECT SUM(amount)
+    SELECT SUM(cust_credit.amount)
       FROM cust_credit
         LEFT JOIN cust_main USING ( custnum )
       WHERE ". $self->in_time_period_and_agent($speriod, $eperiod, $agentnum).
@@ -390,9 +390,6 @@ unspecified, defaults to all three.
 'use_override': for line items generated by an add-on package, use the class
 of the add-on rather than the base package.
 
-'freq': limit to packages with this frequency.  Currently uses the part_pkg 
-frequency, so term discounted packages may give odd results.
-
 'distribute': for non-monthly recurring charges, ignore the invoice 
 date.  Instead, consider the line item's starting/ending dates.  Determine 
 the fraction of the line item duration that falls within the specified 
@@ -421,7 +418,8 @@ my $cust_bill_pkg_join = '
     LEFT JOIN cust_main USING ( custnum )
     LEFT JOIN cust_pkg USING ( pkgnum )
     LEFT JOIN part_pkg USING ( pkgpart )
-    LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart';
+    LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
+    LEFT JOIN part_fee USING ( feepart )';
 
 sub cust_bill_pkg_setup {
   my $self = shift;
@@ -434,7 +432,7 @@ sub cust_bill_pkg_setup {
   $agentnum ||= $opt{'agentnum'};
 
   my @where = (
-    'pkgnum != 0',
+    '(pkgnum != 0 OR feepart IS NOT NULL)',
     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
     $self->with_report_option(%opt),
     $self->in_time_period_and_agent($speriod, $eperiod, $agentnum),
@@ -461,7 +459,7 @@ sub cust_bill_pkg_recur {
   my $cust_bill_pkg = $opt{'project'} ? 'v_cust_bill_pkg' : 'cust_bill_pkg';
 
   my @where = (
-    'pkgnum != 0',
+    '(pkgnum != 0 OR feepart IS NOT NULL)',
     $self->with_classnum($opt{'classnum'}, $opt{'use_override'}),
     $self->with_report_option(%opt),
   );
@@ -476,13 +474,14 @@ sub cust_bill_pkg_recur {
     $item_usage = 'usage'; #already calculated
   }
   else {
-    $item_usage = '( SELECT COALESCE(SUM(amount),0)
+    $item_usage = '( SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
       FROM cust_bill_pkg_detail
       WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum )';
   }
   my $recur_fraction = '';
 
   if ( $opt{'distribute'} ) {
+    $where[0] = 'pkgnum != 0'; # specifically exclude fees
     push @where, "cust_main.agentnum = $agentnum" if $agentnum;
     push @where,
       "$cust_bill_pkg.sdate <  $eperiod",
@@ -521,7 +520,8 @@ Arguments as for C<cust_bill_pkg>, plus:
 sub cust_bill_pkg_detail {
   my( $self, $speriod, $eperiod, $agentnum, %opt ) = @_;
 
-  my @where = ( "cust_bill_pkg.pkgnum != 0" );
+  my @where = 
+    ( "(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart IS NOT NULL)" );
 
   push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'};
 
@@ -536,7 +536,9 @@ sub cust_bill_pkg_detail {
     ;
 
   if ( $opt{'distribute'} ) {
-    # then limit according to the usage time, not the billing date
+    # exclude fees
+    $where[0] = 'cust_bill_pkg.pkgnum != 0';
+    # and limit according to the usage time, not the billing date
     push @where, $self->in_time_period_and_agent($speriod, $eperiod, $agentnum,
       'cust_bill_pkg_detail.startdate'
     );
@@ -547,7 +549,7 @@ sub cust_bill_pkg_detail {
     );
   }
 
-  my $total_sql = " SELECT SUM(amount) ";
+  my $total_sql = " SELECT SUM(cust_bill_pkg_detail.amount) ";
 
   $total_sql .=
     " / CASE COUNT(cust_pkg.*) WHEN 0 THEN 1 ELSE COUNT(cust_pkg.*) END "
@@ -561,6 +563,7 @@ sub cust_bill_pkg_detail {
         LEFT JOIN cust_pkg ON cust_bill_pkg.pkgnum = cust_pkg.pkgnum
         LEFT JOIN part_pkg USING ( pkgpart )
         LEFT JOIN part_pkg AS override ON pkgpart_override = override.pkgpart
+        LEFT JOIN part_fee USING ( feepart )
       WHERE ".join( ' AND ', grep $_, @where );
 
   $self->scalar_sql($total_sql);
@@ -683,14 +686,14 @@ sub with_classnum {
   @$classnum = grep /^\d+$/, @$classnum;
   my $in = 'IN ('. join(',', @$classnum). ')';
 
-  if ( $use_override ) {
-    "(
+  my $expr = "
          ( COALESCE(part_pkg.classnum, 0) $in AND pkgpart_override IS NULL)
-      OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL )
-    )";
-  } else {
-    "COALESCE(part_pkg.classnum, 0) $in";
+      OR ( COALESCE(part_fee.classnum, 0) $in AND feepart IS NOT NULL )";
+  if ( $use_override ) {
+    $expr .= "
+      OR ( COALESCE(override.classnum, 0) $in AND pkgpart_override IS NOT NULL )";
   }
+  "( $expr )";
 }
 
 sub with_usageclass {
@@ -834,7 +837,8 @@ sub init_projection {
       # sdate/edate overlapping the ROI, for performance
       "INSERT INTO v_cust_bill_pkg ( 
         SELECT cust_bill_pkg.*,
-          (SELECT COALESCE(SUM(amount),0) FROM cust_bill_pkg_detail 
+          (SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
+          FROM cust_bill_pkg_detail 
           WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum),
           cust_bill._date,
           cust_pkg.expire
index bd58698..bf516b2 100644 (file)
@@ -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',   '',      '', '', '',
index 569d98c..bf857a9 100644 (file)
@@ -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;
index c4c2d7f..131a236 100644 (file)
@@ -2452,6 +2452,8 @@ sub _items_cust_bill_pkg {
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
           if $DEBUG > 1;
+        # quotation_pkgs are never fees, so don't worry about the case where
+        # part_pkg is undefined
 
         if ( $cust_bill_pkg->setup != 0 ) {
           my $description = $desc;
@@ -2471,7 +2473,7 @@ sub _items_cust_bill_pkg {
           };
         }
 
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+      } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
           if $DEBUG > 1;
@@ -2739,29 +2741,21 @@ sub _items_cust_bill_pkg {
 
         } # recurring or usage with recurring charge
 
-      } else { #pkgnum tax or one-shot line item (??)
+      } else { # taxes and fees
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
           if $DEBUG > 1;
 
-        if ( $cust_bill_pkg->setup != 0 ) {
-          push @b, {
-            'description' => $desc,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          push @b, {
-            'description' => "$desc (".
-                             $self->time2str_local('short', $cust_bill_pkg->sdate). ' - '.
-                             $self->time2str_local('short', $cust_bill_pkg->edate). ')',
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-          };
-        }
+        # items of this kind should normally not have sdate/edate.
+        push @b, {
+          'description' => $desc,
+          'amount'      => sprintf('%.2f', $cust_bill_pkg->setup 
+                                           + $cust_bill_pkg->recur)
+        };
 
-      }
+      } # if quotation / package line item / other line item
 
-    }
+    } # foreach $display
 
     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
                                 && $conf->exists('discount-show-always'));
index b234d6f..066ddf1 100644 (file)
@@ -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,50 @@ 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 '';
+  }
+}
+
+sub tax_location {
+  my $self = shift;
+  FS::cust_location->by_key($self->tax_locationnum);
+}
+
+=item part_X
+
+Returns the L<FS::part_pkg> or L<FS::part_fee> object that defines this
+charge.  If called on a tax line, returns nothing.
+
+=cut
+
+sub part_X {
+  my $self = shift;
+  if ( $self->override_pkgpart ) {
+    return FS::part_pkg->by_key($self->override_pkgpart);
+  } elsif ( $self->pkgnum ) {
+    return $self->cust_pkg->part_pkg;
+  } elsif ( $self->feepart ) {
+    return $self->part_fee;
+  } else {
+    return;
+  }
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -926,9 +1016,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 +1055,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 +1083,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 (file)
index 0000000..8ea73c9
--- /dev/null
@@ -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;
+
index 7ae6c97..1890845 100644 (file)
@@ -910,14 +910,9 @@ sub credit_lineitems {
 
     # recalculate taxes with new amounts
     $taxlisthash{$invnum} ||= {};
-    my $part_pkg = $cust_bill_pkg->part_pkg;
-    $cust_main->_handle_taxes( $part_pkg,
-                               $taxlisthash{$invnum},
-                               $cust_bill_pkg,
-                               $cust_bill_pkg->cust_pkg,
-                               $cust_bill_pkg->cust_bill->_date, #invoice time
-                               $cust_bill_pkg->cust_pkg->pkgpart,
-                             );
+    my $part_pkg = $cust_bill_pkg->part_pkg
+      if $cust_bill_pkg->pkgpart_override;
+    $cust_main->_handle_taxes( $taxlisthash{$invnum}, $cust_bill_pkg );
   }
 
   ###
@@ -1013,12 +1008,12 @@ sub credit_lineitems {
 
       # we still have to deal with the possibility that the tax links don't
       # cover the whole amount of tax because of an incomplete upgrade...
-      if ($amount > 0) {
+      if ($amount > 0.005) {
         $cust_credit_bill{$invnum} += $amount;
         push @{ $cust_credit_bill_pkg{$invnum} },
           new FS::cust_credit_bill_pkg {
             'billpkgnum' => $tax_item->billpkgnum,
-            'amount'     => $amount,
+            'amount'     => sprintf('%.2f', $amount),
             'setuprecur' => 'setup',
           };
 
diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm
new file mode 100644 (file)
index 0000000..78794fd
--- /dev/null
@@ -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;
+
index b8a71d4..f4c30ce 100644 (file)
@@ -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,72 @@ 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(
+        $taxlisthash{$pass},
+        $fee_item,
+        location => $fee_location
+      );
+      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 +700,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 +1000,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 +1316,8 @@ 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,
-        $options{invoice_time},
-        $real_pkgpart,
-        \%options # I have serious objections to this
-      );
+      my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg );
       return $error if $error;
-      #}
 
       $cust_bill_pkg->set_display(
         part_pkg     => $part_pkg,
@@ -1357,15 +1413,13 @@ sub _transfer_balance {
   return @transfers;
 }
 
-=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ]
+=item handle_taxes TAXLISTHASH CUST_BILL_PKG [ 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.
 
-The most important argument is 'taxlisthash'.  This is shared across th
-entire invoice.  It looks like this:
+TAXLISTHASH is a hashref shared across the entire invoice.  It looks lik
+this:
 {
   'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
   'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
@@ -1378,27 +1432,35 @@ That "..." is a list of FS::cust_bill_pkg objects that will be fed to
 the 'taxline' method to calculate the amount of the tax.  This doesn't
 happen until calculate_taxes, though.
 
+OPTIONS may include:
+- part_item: a part_pkg or part_fee object to be used as the package/fee 
+  definition.
+- location: a cust_location to be used as the billing location.
+
+If not supplied, part_item will be inferred from the pkgnum or feepart of the
+cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and 
+the customer's default service location).
+
 =cut
 
 sub _handle_taxes {
   my $self = shift;
-  my $part_pkg = shift;
   my $taxlisthash = shift;
   my $cust_bill_pkg = shift;
-  my $cust_pkg = shift;
-  my $invoice_time = shift;
-  my $real_pkgpart = shift;
-  my $options = shift;
+  my %options = @_;
 
-  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+  # at this point I realize that we have enough information to infer all this
+  # stuff, instead of passing around giant honking argument lists
+  my $location = $options{location} || $cust_bill_pkg->tax_location;
+  my $part_item = $options{part_item} || $cust_bill_pkg->part_X;
 
-  my $location = $cust_pkg->tax_location;
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   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
           )
      )
     {
@@ -1410,9 +1472,8 @@ sub _handle_taxes {
     my @classes;
     #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
     push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-    # debatable
-    push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
-    push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+    push @classes, 'setup' if $cust_bill_pkg->setup;
+    push @classes, 'recur' if $cust_bill_pkg->recur;
 
     my $exempt = $conf->exists('cust_class-tax_exempt')
                    ? ( $self->cust_class ? $self->cust_class->tax : '' )
@@ -1423,13 +1484,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;
       }
@@ -1480,10 +1541,7 @@ sub _handle_taxes {
           warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
           # calculate the tax amount that the tax_on_tax will apply to
           my $hashref_or_error = 
-            $tax_object->taxline( $localtaxlisthash{$tax},
-                                  'custnum'      => $self->custnum,
-                                  'invoice_time' => $invoice_time,
-                                );
+            $tax_object->taxline( $localtaxlisthash{$tax} );
           return $hashref_or_error
             unless ref($hashref_or_error);
           
@@ -1505,7 +1563,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 +1596,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 +2466,7 @@ sub apply_payments {
         _handle_taxes
           (vendor-only) _gather_taxes
       _omit_zero_value_bundles
+      _handle_taxes (for fees)
       calculate_taxes
 
     apply_payments_and_credits
index 5c1be7b..654e567 100644 (file)
@@ -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,
     });
index c1dda22..74adbed 100644 (file)
@@ -134,13 +134,7 @@ sub delete {
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
-
-  my $error = $self->SUPER::delete;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-  
   my $pkey = $self->primary_key;
   #my $option_table = $self->option_table;
 
@@ -152,6 +146,12 @@ sub delete {
     }
   }
 
+  my $error = $self->SUPER::delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
diff --git a/FS/FS/part_event/Action/Mixin/fee.pm b/FS/FS/part_event/Action/Mixin/fee.pm
new file mode 100644 (file)
index 0000000..8eb86fa
--- /dev/null
@@ -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 (file)
index 0000000..fc185e4
--- /dev/null
@@ -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 (file)
index 0000000..a6f1078
--- /dev/null
@@ -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;
index c2b4673..f1d5891 100644 (file)
@@ -1,5 +1,7 @@
 package FS::part_event::Action::fee;
 
+# DEPRECATED; will most likely be removed in 4.x
+
 use strict;
 use base qw( FS::part_event::Action );
 
@@ -53,11 +55,9 @@ sub _calc_fee {
       my $part_pkg = FS::part_pkg->new({
           taxclass => $self->option('taxclass')
       });
-      my $error = $cust_main->_handle_taxes(
-        FS::part_pkg->new({ taxclass => ($self->option('taxclass') || '') }),
-        $taxlisthash,
-        $charge,
-        FS::cust_pkg->new({custnum => $cust_main->custnum}),
+      my $error = $cust_main->_handle_taxes( $taxlisthash, $charge,
+        location  => $cust_main->ship_location,
+        part_item => $part_pkg,
       );
       if ( $error ) {
         warn "error estimating taxes for breakage charge: custnum ".$cust_main->custnum."\n";
index 8e10ea7..9d261f0 100644 (file)
@@ -161,6 +161,10 @@ sub delete {
     'link_table'    => 'export_nas',
     'target_table'  => 'nas',
     'params'        => [],
+  ) || $self->process_m2m(
+    'link_table'    => 'export_svc',
+    'target_table'  => 'part_svc',
+    'params'        => [],
   ) || $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm
new file mode 100644 (file)
index 0000000..9605d61
--- /dev/null
@@ -0,0 +1,464 @@
+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;
+
+  $self->set('amount', 0) unless $self->amount;
+
+  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.
+
+If the fee is going to be charged on the upcoming invoice (credit card 
+processing fees, postal invoice fees), INVOICE should be an uninserted
+L<FS::cust_bill> object where the 'cust_bill_pkg' property is an arrayref
+of the non-fee line items that will appear on the invoice.
+
+=cut
+
+sub lineitem {
+  my $self = shift;
+  my $cust_bill = shift;
+  my $cust_main = $cust_bill->cust_main;
+
+  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->balance;
+    if ( $balance >= 0 ) {
+      warn "Credit balance is zero, so fee is zero" if $DEBUG;
+      return; # don't bother doing estimated tax, etc.
+    } elsif ( -1 * $balance < $maximum ) {
+      $maximum = -1 * $balance;
+    }
+  }
+  if ( $maximum ne '' ) {
+    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,
+  });
+
+  if ( $maximum and $self->taxable ) {
+    warn "Estimating taxes on fee.\n";
+    # then we need to estimate tax to respect the maximum
+    # XXX currently doesn't work with external (tax_rate) taxes
+    # or batch taxes, obviously
+    my $taxlisthash = {};
+    my $error = $cust_main->_handle_taxes(
+      $taxlisthash,
+      $cust_bill_pkg,
+      location => $cust_main->ship_location
+    );
+    my $total_rate = 0;
+    # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
+    my @taxes = map { $_->[0] } values %$taxlisthash;
+    foreach (@taxes) {
+      $total_rate += $_->tax;
+    }
+    if ($total_rate > 0) {
+      my $max_cents = $maximum * 100;
+      my $charge_cents = sprintf('%0.f', $max_cents * 100/(100 + $total_rate));
+      $maximum = sprintf('%.2f', $charge_cents / 100.00);
+      $amount = $maximum if $amount > $maximum;
+    }
+  } # if $maximum and $self->taxable
+
+  # set the amount that we'll charge
+  $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 (file)
index 0000000..e60651e
--- /dev/null
@@ -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;
+
index 8f8287b..e872232 100644 (file)
@@ -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
index 56e63b6..ddea1da 100644 (file)
@@ -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.
index 6ee5771..7536266 100644 (file)
@@ -93,6 +93,15 @@ $name = 'RBC';
   row => sub {
     my ($cust_pay_batch, $pay_batch) = @_;
     my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
+    my($bankno, $branch);
+    if ( $aba =~ /^0(\d{3})(\d{5})$/ ) { # standard format for Canadian bank ID
+      ($bankno, $branch) = ( $1, $2 );
+    } elsif ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #how we store branches
+      ($branch, $bankno) = ( $1, $2 );
+    } else {
+      die "invalid branch/routing number '$aba'\n";
+    }
+
     $i++;
     sprintf("%06u", $i).
     'D'.
@@ -101,8 +110,9 @@ $name = 'RBC';
     ' '.
     sprintf("%-19s", $cust_pay_batch->paybatchnum).
     '00'.
-    sprintf("%09u", $aba).
-    sprintf("%-18s", $account).
+    sprintf("%04u", $bankno).
+    sprintf("%05u", $branch).
+    sprintf("%-18u", $account).
     ' '.
     sprintf("%010.0f",$cust_pay_batch->amount*100).
     '      '.
@@ -129,7 +139,7 @@ $name = 'RBC';
     'Z'.
     'TRL'.
     sprintf("%10s", $client_num).
-    ' ' x 20 .
+    '0' x 20 .
     sprintf("%06u", $batchcount).
     sprintf("%014.0f", $batchtotal*100).
     '00' .
index 3d37677..4516004 100644 (file)
@@ -371,7 +371,7 @@ sub passtype_name {
   $tax_passtypes{$self->passtype};
 }
 
-=item taxline TAXABLES, [ OPTIONSHASH ]
+=item taxline TAXABLES
 
 Returns a listref of a name and an amount of tax calculated for the list
 of packages/amounts referenced by TAXABLES.  If an error occurs, a message
@@ -381,13 +381,13 @@ is returned as a scalar.
 
 sub taxline {
   my $self = shift;
+  # this used to accept a hash of options but none of them did anything
+  # so it's been removed.
 
   my $taxables;
-  my %opt = ();
 
   if (ref($_[0]) eq 'ARRAY') {
     $taxables = shift;
-    %opt = @_;
   }else{
     $taxables = [ @_ ];
     #exemptions would be broken in this case
index a0a71c9..129ee64 100644 (file)
@@ -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 (file)
index 0000000..c7cf0a0
--- /dev/null
@@ -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 (file)
index 0000000..882b1df
--- /dev/null
@@ -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 (file)
index 0000000..b4192a4
--- /dev/null
@@ -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 (file)
index 0000000..f7e8ca8
--- /dev/null
@@ -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";
index a8ab9d9..69d0636 100644 (file)
@@ -122,7 +122,7 @@ my $row;
 while ( $row = $sth->fetchrow_hashref ) {
 
   my $ip = $row->{ip_number};
-  if ( $ip =~ /^([\d\.]+)\/([\d\.]*)$/ ) {
+  if ( $ip =~ /^([\d\.]+)\/([\d\.]*)/ ) {
     $ip = $1;
     #$nat_ip = $2;
   }
@@ -144,7 +144,7 @@ while ( $row = $sth->fetchrow_hashref ) {
       uniqueid                => $row->{id_cc},
       orig_call_id            => $row->{clid},
       billsec                 => $row->{effective_duration},
-      lastdata                => $row->{dtmf},
+      #lastdata                => $row->{dtmf},
       disposition             => $row->{call_data},
 
       cdrbatchnum   => $cdrbatchnum,
index fb26fe9..fe66805 100755 (executable)
 
 foreach my $table (qw(
   part_export_option
-  payment_gateway
   payment_gateway_option
+  payment_gateway
   agent_payment_gateway
-  queue
   queue_arg
+  queue
   cust_pay_batch
 )) {
   print "DELETE FROM $table;\n";
diff --git a/httemplate/browse/part_fee.html b/httemplate/browse/part_fee.html
new file mode 100644 (file)
index 0000000..482c692
--- /dev/null
@@ -0,0 +1,71 @@
+<& 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',
+  menubar         => \@menubar,
+&>
+<%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' ];
+
+my @menubar = ( 'Add a new fee definition',
+                $p.'edit/part_fee.html' );
+</%init>
index a5ecb69..40faddc 100644 (file)
@@ -269,7 +269,8 @@ my @cust_bill_pkg = qsearch({
   'select'    => 'cust_bill_pkg.*',
   'table'     => 'cust_bill_pkg',
   'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
-  'extra_sql' => "WHERE custnum = $custnum AND pkgnum != 0",
+  'extra_sql' => "WHERE custnum = $custnum ".
+                 "AND (pkgnum != 0 or feepart IS NOT NULL)",
   'order_by'  => 'ORDER BY invnum ASC, billpkgnum ASC',
 });
 
diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html
new file mode 100644 (file)
index 0000000..dada233
--- /dev/null
@@ -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&mdash;' . 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 (executable)
index 0000000..25656e9
--- /dev/null
@@ -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>
index fb84e75..cd4fb39 100644 (file)
@@ -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, '' ];
index ed7bd01..2bb1f4c 100644 (file)
@@ -62,14 +62,7 @@ if ( $sub eq 'calculate_taxes' ) {
 
     my $taxlisthash = {};
     foreach my $cust_bill_pkg (values %cust_bill_pkg) {
-      my $part_pkg = $cust_bill_pkg->part_pkg;
-      $cust_main->_handle_taxes( $part_pkg,
-                                 $taxlisthash,
-                                 $cust_bill_pkg,
-                                 $cust_bill_pkg->cust_pkg,
-                                 $cust_bill_pkg->cust_bill->_date,
-                                 $cust_bill_pkg->cust_pkg->pkgpart,
-                               );
+      $cust_main->_handle_taxes( $taxlisthash, $cust_bill_pkg );
     }
     my $listref_or_error = 
       $cust_main->calculate_taxes( [ values %cust_bill_pkg ], $taxlisthash, [ values %cust_bill_pkg ]->[0]->cust_bill->_date );
index c0db3e2..4558682 100644 (file)
@@ -62,15 +62,7 @@ if ( $sub eq 'calculate_taxes' ) {
 
       push @cust_bill_pkg, $cust_bill_pkg;
 
-      my $part_pkg = $cust_bill_pkg->part_pkg;
-      $cust_main->_handle_taxes( $part_pkg,
-                                 $taxlisthash,
-                                 $cust_bill_pkg,
-                                 $cust_bill_pkg->cust_pkg,
-                                 $cust_bill_pkg->cust_bill->_date,
-                                 $cust_bill_pkg->cust_pkg->pkgpart,
-                               );
-
+      $cust_main->_handle_taxes( $taxlisthash, $cust_bill_pkg );
     }
 
     if ( @cust_bill_pkg ) {
@@ -89,7 +81,10 @@ if ( $sub eq 'calculate_taxes' ) {
       foreach my $taxline ( @$listref_or_error ) {
         my $amount = $taxline->setup;
         my $desc = $taxline->desc;
-        foreach my $location ( @{$taxline->cust_bill_pkg_tax_location}, @{$taxline->cust_bill_pkg_tax_rate_location} ) {
+        foreach my $location (
+          @{$taxline->get('cust_bill_pkg_tax_location')},
+          @{$taxline->get('cust_bill_pkg_tax_rate_location')} )
+        {
           my $taxlocnum = $location->locationnum || '';
           my $taxratelocnum = $location->taxratelocationnum || '';
           $location->cust_bill_pkg_desc($taxline->desc); #ugh @ that kludge
index 7848b72..6d4f89a 100644 (file)
@@ -59,6 +59,7 @@ unless ( $error ) { # if ($access_user) {
                       snom-ip snom-username snom-password
                       vonage-fromnumber vonage-username vonage-password
                       cust_pkg-display_times
+                      hide_package_changes
                       show_pkgnum show_confitem_counts export_getsettings
                       show_db_profile save_db_profile save_tmp_typesetting
                       height width availHeight availWidth colorDepth
index ccfeecd..eaa7d32 100644 (file)
@@ -78,6 +78,22 @@ Interface
     </TD>
   </TR>
 
+  <TR>
+    <TH ALIGN="right">Hide package changes: </TH>
+    <TD>
+    <& /elements/select.html,
+      field   => 'hide_package_changes',
+      options => [ '', 'location', 'all' ],
+      labels  => { ''         => 'never',
+                   'location' => 'location changes',
+                   'all'      => 'all package changes',
+                 },
+      curr_value => ($cgi->param('hide_package_changes') 
+                      || $curuser->option('hide_package_changes')),
+    &>
+    </TD>
+  </TR>
+
 % my $history_order = $curuser->option('history_order') || 'oldest';
   <TR>
     <TH ALIGN="right">Customer history sort order: </TH>
index 6b7a5e6..440ab15 100644 (file)
@@ -137,9 +137,9 @@ Filtering parameters:
 - use_override: Apply "classnum" and "taxclass" filtering based on the 
   override (bundle) pkgpart, rather than always using the true pkgpart.
 
-- nottax: Limit to items that are not taxes (pkgnum > 0).
+- nottax: Limit to items that are not taxes (pkgnum > 0 or feepart > 0).
 
-- istax: Limit to items that are taxes (pkgnum == 0).
+- istax: Limit to items that are taxes (pkgnum == 0 and feepart = null).
 
 - taxnum: Limit to items whose tax definition matches this taxnum.
   With "nottax" that means items that are subject to that tax;
@@ -305,7 +305,8 @@ if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
 # we want the package and its definition if available
 my $join_pkg = 
 ' LEFT JOIN cust_pkg      USING (pkgnum) 
-  LEFT JOIN part_pkg      USING (pkgpart)';
+  LEFT JOIN part_pkg      USING (pkgpart)
+  LEFT JOIN part_fee      USING (feepart)';
 
 my $part_pkg = 'part_pkg';
 # "Separate sub-packages from parents"
@@ -319,12 +320,16 @@ if ( $use_override ) {
   $part_pkg = 'override';
 }
 push @select, "$part_pkg.pkgpart", "$part_pkg.pkg";
-push @select, "$part_pkg.taxclass" if $conf->exists('enable_taxclasses');
+push @select, "COALESCE($part_pkg.taxclass, part_fee.taxclass) AS taxclass"
+  if $conf->exists('enable_taxclasses');
 
 # the non-tax case
 if ( $cgi->param('nottax') ) {
 
-  push @where, 'cust_bill_pkg.pkgnum > 0';
+  push @select, "part_fee.itemdesc";
+
+  push @where,
+    '(cust_bill_pkg.pkgnum > 0 OR cust_bill_pkg.feepart IS NOT NULL)';
 
   my @tax_where; # will go into a subquery
   my @exempt_where; # will also go into a subquery
@@ -335,7 +340,7 @@ if ( $cgi->param('nottax') ) {
   # N: classnum
   if ( grep { $_ eq 'classnum' } $cgi->param ) {
     my @classnums = grep /^\d*$/, $cgi->param('classnum');
-    push @where, "COALESCE($part_pkg.classnum, 0) IN ( ".
+    push @where, "COALESCE(part_fee.classnum, $part_pkg.classnum, 0) IN ( ".
                      join(',', @classnums ).
                  ' )'
       if @classnums;
@@ -360,7 +365,7 @@ if ( $cgi->param('nottax') ) {
     # effective taxclass, not the real one
     push @tax_where, 'cust_main_county.taxclass IS NULL'
   } elsif ( $cgi->param('taxclass') ) {
-    push @tax_where, "$part_pkg.taxclass IN (" .
+    push @tax_where, "COALESCE(part_fee.taxclass, $part_pkg.taxclass) IN (" .
                  join(', ', map {dbh->quote($_)} $cgi->param('taxclass') ).
                  ')';
   }
@@ -681,7 +686,7 @@ if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
     'paid'            => ($cgi->param('paid') ? 1 : 0),
     'classnum'        => scalar($cgi->param('classnum'))
   );
-  $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_pkg.classnum, 0) )";
+  $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_fee.classnum, part_pkg.classnum, 0) )";
 
   my $extra_sql = $subsearch->{extra_sql};
   $extra_sql =~ s/^WHERE//;
index 566ab29..746e0c7 100755 (executable)
@@ -22,8 +22,62 @@ table.usage {
 .row0 { background-color: #eeeeee; }
 .row1 { background-color: #ffffff; }
 
-</STYLE>
+table.hiddenrows {
+  width: 80%;
+  margin-left: 100px;
+  border: 1px solid #7E0079;
+  background-color: #cccccc;
+}
+
+.hiddenrows td {
+  text-align: center;
+}
 
+.rolldown_button {
+  min-width: 80px;
+  margin-left: 100px;
+  min-height: 20px;
+  background-color: #efefef;
+  border: 1px solid #7e0079;
+  z-index: 1;
+  text-align: center;
+}
+</STYLE>
+% # activate rolldown buttons for hidden package blocks
+<SCRIPT TYPE="text/javascript">
+function toggle_rolldown() {
+  var up_arrow = <% decode_entities('&#x2b06') |js_string %>;
+  var dn_arrow = <% decode_entities('&#x2b07') |js_string %>;
+  var pkgnum = this.id.replace('rolldown_', '');
+  var hidden = document.getElementById('cust_pkg'+pkgnum+'_block');
+  if (hidden.style.display == 'none') {
+    hidden.style.display = '';
+    this.textContent = this.textContent.replace(dn_arrow, up_arrow);
+  } else {
+    hidden.style.display = 'none';
+    this.textContent = this.textContent.replace(up_arrow, dn_arrow);
+  }
+}
+<&| /elements/onload.js &>
+var el;
+% if ( $cgi->param('fragment') =~ /^cust_pkg(\d+)$/ ) {
+% # IE-specific hack, but also unhide the row if it's in a hidden block
+el = document.getElementById('cust_pkg<% $1 %>');
+% }
+var all_buttons = document.getElementsByClassName('rolldown_button');
+for (var i = 0; i < all_buttons.length; i++) {
+  all_buttons[i].onclick = toggle_rolldown;
+  var block_id = all_buttons[i].id.replace('rolldown_', '');
+  if ( el && document.getElementById('cust_pkg'+block_id+'_block')
+                     .contains(el)
+     ) {
+    // then toggle it now
+    all_buttons[i].click();
+  }
+}
+if ( el ) el.scrollIntoView(true);
+</&>
+</SCRIPT>
 % unless ( $opt{no_links} ) {
 
 %   my $s = 0;
@@ -124,7 +178,7 @@ table.usage {
 % if ( $conf->exists('cust_pkg-group_by_location') ) {
 <& locations.html,
     'cust_main'     => $cust_main,
-    'packages'      => $packages,
+    'packages'      => \@packages,
     %opt,
  &>
 % }
@@ -133,7 +187,7 @@ table.usage {
 <& /elements/table-grid.html &>
 <& packages/section.html,
     'cust_main'     => $cust_main,
-    'packages'      => $packages,
+    'packages'      => \@packages,
     %opt,
  &>
 </TABLE>
@@ -141,15 +195,6 @@ table.usage {
     </TD>
   </TR>
 
-% if ( $cgi->param('fragment') =~ /^cust_pkg(\d+)$/ ) {
-  <SCRIPT>
-    // IE-specific hack.  other browsers listen to #fragments
-    // is this even working?  or is the #target redirection just working cause
-    // we set the URL params differently?
-    var el = document.getElementById( 'cust_pkg<% $1 %>' );
-    if ( el ) el.scrollIntoView(true);
-  </SCRIPT>
-% }
 </TABLE>
 <%init>
 
@@ -159,94 +204,113 @@ my $conf = new FS::Conf;
 
 my $curuser = $FS::CurrentUser::CurrentUser;
 
-my( $packages, $num_old_packages ) = get_packages($cust_main, $conf);
-
 my $countrydefault = scalar($conf->config('countrydefault')) || 'US';
-#subroutines
-
-sub get_packages {
-  my $cust_main = shift or return undef;
-  my $conf = shift;
-
-  my $method;
-  if (  $cgi->param('showcancelledpackages') eq '0' #see if it was set by me
-     || ( $conf->exists('hidecancelledpackages')
-           && ! $cgi->param('showcancelledpackages') )
-     )
-  {
-    $method = 'ncancelled_pkgs';
-  } else {
-    $method = 'all_pkgs';
-  }
 
-  my $cust_pkg_fields =
-    join(', ', map { "cust_pkg.$_ AS $_"          } fields('cust_pkg') );
+my $hide_changed = $curuser->option('hide_package_changes');
 
-  my $part_pkg_fields =
-    join(', ', map { "part_pkg.$_ AS part_pkg_$_" } fields('part_pkg') );
+my $hide_cancelled = 0;
+if (  $cgi->param('showcancelledpackages') eq '0' #see if it was set by me
+   || ( $conf->exists('hidecancelledpackages')
+         && ! $cgi->param('showcancelledpackages') )
+   )
+{
+  $hide_cancelled = 1;
+}
 
-  my $group_by =
-    join(', ', map "cust_pkg.$_", fields('cust_pkg') ). ', '.
-    join(', ', map "part_pkg.$_", fields('part_pkg') );
+my $cust_pkg_fields =
+  join(', ', map { "cust_pkg.$_ AS $_"          } fields('cust_pkg') );
 
-  my $num_svcs = '( SELECT COUNT(*) FROM cust_svc '.
-                 '    WHERE cust_svc.pkgnum = cust_pkg.pkgnum ) AS num_svcs';
+my $part_pkg_fields =
+  join(', ', map { "part_pkg.$_ AS part_pkg_$_" } fields('part_pkg') );
 
-  my @packages = $cust_main->$method( {
-    'select'    => "$cust_pkg_fields, $part_pkg_fields, $num_svcs",
-    'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )',
-  } );
-  my $num_old_packages = scalar(@packages);
+my $group_by =
+  join(', ', map "cust_pkg.$_", fields('cust_pkg') ). ', '.
+  join(', ', map "part_pkg.$_", fields('part_pkg') );
 
-  my %change_to_from; # target pkgnum => current cust_pkg, for future changes
+my $num_svcs = '( SELECT COUNT(*) FROM cust_svc '.
+               '    WHERE cust_svc.pkgnum = cust_pkg.pkgnum ) AS num_svcs';
 
-  foreach my $cust_pkg ( @packages ) {
-    my %hash = $cust_pkg->hash;
-    my %part_pkg = map  { /^part_pkg_(.+)$/ or die; ( $1 => $hash{$_} ); }
-                   grep { /^part_pkg_/ } keys %hash;
-    $cust_pkg->{'_pkgpart'} = new FS::part_pkg \%part_pkg;
-    if ( $cust_pkg->change_to_pkgnum ) {
-      $change_to_from{$cust_pkg->change_to_pkgnum} = $cust_pkg;
-    }
+# don't exclude cancelled packages at this stage
+my @packages = $cust_main->all_pkgs( {
+  'select'    => "$cust_pkg_fields, $part_pkg_fields, $num_svcs",
+  'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )',
+} );
+
+my %change_to_from; # target pkgnum => current cust_pkg, for future changes
+my %changed_from; # old pkgnum => new cust_pkg, for past changes
+
+foreach my $cust_pkg ( @packages ) {
+  my %hash = $cust_pkg->hash;
+  my %part_pkg = map  { /^part_pkg_(.+)$/ or die; ( $1 => $hash{$_} ); }
+                 grep { /^part_pkg_/ } keys %hash;
+  $cust_pkg->{'_pkgpart'} = new FS::part_pkg \%part_pkg;
+  if ( $cust_pkg->change_to_pkgnum ) {
+    $change_to_from{$cust_pkg->change_to_pkgnum} = $cust_pkg;
   }
+  if ( $cust_pkg->change_pkgnum ) {
+    $changed_from{$cust_pkg->change_pkgnum} = $cust_pkg;
+  }
+}
 
-  if ( keys %change_to_from ) {
-    my @not_future_packages;
-    foreach my $cust_pkg (@packages) {
-      if ( exists( $change_to_from{$cust_pkg->pkgnum} ) ) {
-        my $change_from = $change_to_from{ $cust_pkg->pkgnum };
-        $cust_pkg->set('change_from_pkg', $change_from);
-        $change_from->set('change_to_pkg', $cust_pkg);
-      } else {
-        push @not_future_packages, $cust_pkg;
+# filter out hidden package changes
+if ( keys %change_to_from or keys %changed_from ) {
+  my @displayable_packages;
+  foreach my $cust_pkg (@packages) {
+    if ( exists( $change_to_from{$cust_pkg->pkgnum} ) ) {
+      # $cust_pkg is an ordered, not-yet-active package change target
+      my $change_from = $change_to_from{ $cust_pkg->pkgnum };
+      $cust_pkg->set('change_from_pkg', $change_from);
+      $change_from->set('change_to_pkg', $cust_pkg);
+    } elsif ( exists( $changed_from{$cust_pkg->pkgnum} ) ) {
+      # $cust_pkg is a canceled package changed into another packge
+      my $changed_to = $changed_from{$cust_pkg->pkgnum};
+      if ( ( $hide_changed eq 'all' ) or
+           ( $hide_changed eq 'location' 
+              and $changed_to->pkgpart  == $cust_pkg->pkgpart
+              and $changed_to->refnum   == $cust_pkg->refnum
+              and $changed_to->quantity == $cust_pkg->quantity )
+      ) {
+        # then we're hiding it
+        $cust_pkg->set('changed_to_pkg', $changed_to);
+        $changed_to->set('changed_from_pkg', $cust_pkg);
+      } else { # show it anyway
+        push @displayable_packages, $cust_pkg;
       }
+    } else {
+      push @displayable_packages, $cust_pkg;
     }
-    @packages = @not_future_packages;
   }
+  @packages = @displayable_packages;
+}
 
-  unless ( $cgi->param('showoldpackages') ) {
-    my $years = $conf->config('cust_main-packages-years') || 2;
-    my $then = time - $years * 31556926; #60*60*24*365.2422 is close enough
-
-    my %hide = ( 'cancelled'       => 'cancel',
-                 'one-time charge' => 'setup',
-               );
-  
-    @packages =
-      grep { !exists($hide{$_->status}) or $_->get($hide{$_->status}) > $then
-             or $_->num_svcs #don't hide packages w/services
-           }
-           @packages;
-  }
+# filter all cancelled packages if the user wants
+if ( $hide_cancelled ) {
+  @packages = grep { !$_->get('cancel') } @packages;
+}
+
+# filter out 'old' packages
+my $num_old_packages = scalar(@packages);
 
-  $num_old_packages -= scalar(@packages);
-  
-  # don't include supplemental packages in this list; they'll be found from
-  # their main packages
-  # (as will change-target packages)
-  @packages = grep !$_->main_pkgnum, @packages;
+unless ( $cgi->param('showoldpackages') ) {
+  my $years = $conf->config('cust_main-packages-years') || 2;
+  my $then = time - $years * 31556926; #60*60*24*365.2422 is close enough
 
-  ( \@packages, $num_old_packages );
+  my %hide = ( 'cancelled'       => 'cancel',
+               'one-time charge' => 'setup',
+             );
+
+  @packages =
+    grep { !exists($hide{$_->status}) or $_->get($hide{$_->status}) > $then
+           or $_->num_svcs #don't hide packages w/services
+         }
+         @packages;
 }
 
+$num_old_packages -= scalar(@packages);
+
+# don't include supplemental packages in this list; they'll be found from
+# their main packages
+# (as will change-target packages)
+@packages = grep !$_->main_pkgnum, @packages;
+
 </%init>
diff --git a/httemplate/view/cust_main/packages/hidden.html b/httemplate/view/cust_main/packages/hidden.html
new file mode 100644 (file)
index 0000000..e3bd0fa
--- /dev/null
@@ -0,0 +1,55 @@
+% if (!$iopt{noframe}) {
+%   # then start the block here, and assign a suitable ID (cust_pkgX_block)
+<TABLE CLASS="hiddenrows" STYLE="display: none" ID="<% $id %>_block">
+% }
+  <TR ID="<% $id %>">
+    <TD>
+      <A NAME="<% $id %>"/>
+      <% $pkgnum %>
+    </TD>
+    <TD>
+%     if ( $pkgpart_change and $location_change ) {
+      Package type and location change
+%     } elsif ( $pkgpart_change ) {
+      Package type change
+%     } elsif ( $location_change ) {
+      Location change
+%     } # or else what?
+      <B><% time2str('%b %o, %Y', $cust_pkg->get('cancel')) %></B>
+    </TD><TD>
+%     if ( $pkgpart_change ) {
+      from <B><% $part_pkg->pkg |h %></B></A> - <% $part_pkg->custom_comment |h %>
+%     }
+%     if ( $pkgpart_change and $location_change ) {
+      <BR>
+%     }
+%     if ( $location_change ) {
+      from <I><% $cust_pkg->location_label %></I>
+%     }
+    </TD>
+  </TR>
+% if ( $cust_pkg->get('changed_from_pkg') ) {
+<& hidden.html, $cust_pkg->get('changed_from_pkg'),
+                %iopt,
+                'next_pkg' => $cust_pkg,
+                'noframe' => 1
+&>
+% }
+% if ( !$iopt{noframe} ) {
+</TABLE>
+% }
+<%init>
+my $cust_pkg = shift;
+my $part_pkg = $cust_pkg->part_pkg;
+my %iopt = @_;
+my $next = delete($iopt{'next_pkg'});
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $pkgnum = $curuser->option('show_pkgnum') ? $cust_pkg->pkgnum.': ' : '';
+
+my $id = "cust_pkg".$cust_pkg->pkgnum;
+
+my $pkgpart_change = ($next->pkgpart != $cust_pkg->pkgpart);
+my $location_change = ($next->locationnum != $cust_pkg->locationnum);
+my $both_change = $pkgpart_change && $location_change;
+
+</%init>
index 152ccaa..730bb2c 100755 (executable)
     </TD>
     <& services.html, %iopt &>
   </TR>
+% # insert hidden predecessors to this package, if any
+% # and a rolldown button to show them
+% # (we'll make it do something later)
+% if ( $cust_pkg->get('changed_from_pkg') ) {
+  <TR CLASS="row<% $row % 2 %>">
+    <TD COLSPAN=4>
+    <BUTTON CLASS="rolldown_button"
+            ID="rolldown_<% $cust_pkg->change_pkgnum %>">
+      History &#x2b07;
+    </BUTTON>
+% # the hidden block here has ID="cust_pkgX" where X is the first pkgnum
+% # it contains.
+    <& hidden.html, $cust_pkg->get('changed_from_pkg'),
+       %iopt,
+       'next_pkg' => $cust_pkg,
+    &>
+    </TD>
+  </TR>
+% }
 % $row++;
 % # show the change target, if there is one
 % if ( $cust_pkg->change_to_pkg ) {