[freeside-commits] branch FREESIDE_3_BRANCH updated. 881b82b9af1a264afbc98bb22adfcfb76fab74db

Mark Wells mark at 420.am
Tue Mar 31 11:54:04 PDT 2015


The branch, FREESIDE_3_BRANCH has been updated
       via  881b82b9af1a264afbc98bb22adfcfb76fab74db (commit)
      from  6e5cd05d1e71816cb2ef15d08ed54a170fe9a9bf (commit)

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

- Log -----------------------------------------------------------------
commit 881b82b9af1a264afbc98bb22adfcfb76fab74db
Author: Mark Wells <mark at freeside.biz>
Date:   Tue Mar 31 11:33:44 2015 -0700

    more flexible package suspend/unsuspend fees, #26828

diff --git a/FS/FS/FeeOrigin_Mixin.pm b/FS/FS/FeeOrigin_Mixin.pm
new file mode 100644
index 0000000..4eaf9b8
--- /dev/null
+++ b/FS/FS/FeeOrigin_Mixin.pm
@@ -0,0 +1,135 @@
+package FS::FeeOrigin_Mixin;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::part_fee;
+use FS::cust_bill_pkg;
+
+# is there a nicer idiom for this?
+our @subclasses = qw( FS::cust_event_fee FS::cust_pkg_reason_fee );
+use FS::cust_event_fee;
+use FS::cust_pkg_reason_fee;
+
+=head1 NAME
+
+FS::FeeOrigin_Mixin - Common interface for fee origin 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::FeeOrigin_Mixin object associates the timestamped event that triggered 
+a fee (which may be a billing event, or something else like a package
+suspension) to the resulting invoice line item (L<FS::cust_bill_pkg> object).
+The following fields are required:
+
+=over 4
+
+=item billpkgnum - key of the cust_bill_pkg record representing the fee 
+on an invoice.  This is 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>).
+
+=item nextbill - 'Y' if the fee should be charged on the customer's next bill,
+rather than causing a bill to be produced immediately.
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item by_cust CUSTNUM[, PARAMS]
+
+Finds all cust_event_fee records belonging to the customer CUSTNUM.
+
+PARAMS can be additional params to pass to qsearch; this really only works
+for 'hashref' and 'order_by'.
+
+=cut
+
+# invoke for all subclasses, and return the results as a flat list
+
+sub by_cust {
+  my $class = shift;
+  my @args = @_;
+  return map { $_->_by_cust(@args) } @subclasses;
+}
+
+=back
+
+=head1 INTERFACE
+
+=over 4
+
+=item _by_cust CUSTNUM[, PARAMS]
+
+The L</by_cust> search method. Each subclass must implement this.
+
+=item cust_bill
+
+If the fee origin generates a fee based on past invoices (for example, an
+invoice event that charges late fees), this method should return the
+L<FS::cust_bill> object that will be the basis for the fee. If this returns
+nothing, then then fee will be based on the rest of the invoice where it
+appears.
+
+=item cust_pkg
+
+If the fee origin generates a fee limited in scope to one package (for
+example, a package reconnection fee event), this method should return the
+L<FS::cust_pkg> object the fee applies to. If it's a percentage fee, this
+determines which charges it's a percentage of; otherwise it just affects the
+fee description appearing on the invoice.
+
+Currently not tested in combination with L</cust_bill>; be careful.
+
+=cut
+
+# stubs
+
+sub _by_cust { my $class = shift; die "'$class' must provide _by_cust method" }
+
+sub cust_bill { '' }
+
+sub cust_pkg { '' }
+
+# stubs; remove in 4.x
+
+sub part_fee {
+  my $self = shift;
+  FS::part_fee->by_key($self->feepart);
+}
+
+sub cust_bill_pkg {
+  my $self = shift;
+  $self->billpkgnum ? FS::cust_bill_pkg->by_key($self->billpkgnum) : '';
+}
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_event_fee>, L<FS::cust_pkg_reason_fee>, L<FS::cust_bill_pkg>, 
+L<FS::part_fee>
+
+=cut
+
+1;
+
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 8d132da..4cf0798 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -376,6 +376,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::svc_circuit;
   use FS::legacy_cust_history;
   use FS::quotation_pkg_tax;
+  use FS::cust_pkg_reason_fee;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 0294e9e..9f4670c 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1996,6 +1996,19 @@ sub tables_hashref {
       'index' => [ [ 'pkgnum' ], [ 'reasonnum' ], ['action'], [ 'usernum' ], ],
     },
 
+    'cust_pkg_reason_fee' => {
+      'columns' => [
+        'pkgreasonfeenum', 'serial', '', '', '', '',
+        'pkgreasonnum',       'int', '', '', '', '',
+        'billpkgnum',         'int', 'NULL', '', '', '',
+        'feepart',            'int', '', '', '', '',
+        'nextbill',          'char', 'NULL',  1, '', '',
+      ],
+      'primary_key'  => 'pkgreasonfeenum',
+      'unique' => [ [ 'billpkgnum' ], [ 'pkgreasonnum' ] ], # one-to-one link
+      'index'  => [ [ 'feepart' ] ],
+    },
+
     'cust_pkg_discount' => {
       'columns' => [
         'pkgdiscountnum', 'serial', '',        '', '', '',
@@ -4210,6 +4223,9 @@ sub tables_hashref {
         'unsuspend_pkgpart', 'int',  'NULL', '', '', '',
         'unsuspend_hold','char',    'NULL', 1, '', '',
         'unused_credit', 'char',    'NULL', 1, '', '',
+        'feepart',        'int', 'NULL', '', '', '',
+        'fee_on_unsuspend','char',  'NULL', 1, '', '',
+        'fee_hold',      'char',    'NULL', 1, '', '',
       ],
       'primary_key' => 'reasonnum',
       'unique' => [],
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index 0e333ae..70c7adc 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -300,13 +300,12 @@ sub insert {
     } # 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 ( my $fee_origin = $self->get('fee_origin') ) {
+    $fee_origin->set('billpkgnum' => $self->billpkgnum);
+    $error = $fee_origin->replace;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error updating cust_event_fee: $error";
+      return "error updating fee origin record: $error";
     }
   }
 
diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm
index f668633..c3c99af 100644
--- a/FS/FS/cust_event_fee.pm
+++ b/FS/FS/cust_event_fee.pm
@@ -1,10 +1,9 @@
 package FS::cust_event_fee;
 
 use strict;
-use base qw( FS::Record );
+use base qw( FS::Record FS::FeeOrigin_Mixin );
 use FS::Record qw( qsearch qsearchs );
 use FS::cust_event;
-use FS::part_fee;
 
 =head1 NAME
 
@@ -29,8 +28,8 @@ FS::cust_event_fee - Object methods for cust_event_fee records
 
 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:
+L<FS::cust_bill_pkg> object).  FS::cust_event_fee inherits from FS::Record 
+and FS::FeeOrigin_Mixin.  The following fields are currently supported:
 
 =over 4
 
@@ -87,9 +86,6 @@ and replace methods.
 
 =cut
 
-# the check method should currently be supplied - FS::Record contains some
-# data checking routines
-
 sub check {
   my $self = shift;
 
@@ -111,18 +107,14 @@ sub check {
 
 =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, cust_pkg, or cust_bill events; this will return 
-all of them.
+=item _by_cust CUSTNUM[, PARAMS]
 
-PARAMS can be additional params to pass to qsearch; this really only works
-for 'hashref' and 'order_by'.
+See L<FS::FeeOrigin_Mixin/by_cust>. This is the implementation for 
+event-triggered fees.
 
 =cut
 
-sub by_cust {
+sub _by_cust {
   my $class = shift;
   my $custnum = shift or return;
   my %params = @_;
@@ -169,23 +161,52 @@ sub by_cust {
   })
 }
 
-# stubs
+=item cust_bill
 
-sub cust_event {
+See L<FS::FeeOrigin_Mixin/cust_bill>. This version simply returns the event
+object if the event is an invoice event.
+
+=cut
+
+sub cust_bill {
   my $self = shift;
-  FS::cust_event->by_key($self->eventnum);
+  my $object = $self->cust_event->cust_X;
+  if ( $object->isa('FS::cust_bill') ) {
+    return $object;
+  } else {
+    return '';
+  }
 }
 
-sub part_fee {
+=item cust_pkg
+
+See L<FS::FeeOrigin_Mixin/cust_bill>. This version simply returns the event
+object if the event is a package event.
+
+=cut
+
+sub cust_pkg {
+  my $self = shift;
+  my $object = $self->cust_event->cust_X;
+  if ( $object->isa('FS::cust_pkg') ) {
+    return $object;
+  } else {
+    return '';
+  }
+}
+
+# stubs - remove in 4.x
+
+sub cust_event {
   my $self = shift;
-  FS::part_fee->by_key($self->feepart);
+  FS::cust_event->by_key($self->eventnum);
 }
 
 =head1 BUGS
 
 =head1 SEE ALSO
 
-L<FS::cust_event>, L<FS::part_fee>, L<FS::Record>
+L<FS::cust_event>, L<FS::FeeOrigin_Mixin>, L<FS::Record>
 
 =cut
 
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 37d8115..c107df9 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -22,7 +22,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::FeeOrigin_Mixin;
 use FS::Log;
 
 # 1 is mostly method/subroutine entry and options
@@ -575,17 +575,17 @@ sub bill {
     # process fees
     ###
 
-    my @pending_event_fees = FS::cust_event_fee->by_cust($self->custnum,
+    my @pending_fees = FS::FeeOrigin_Mixin->by_cust($self->custnum,
       hashref => { 'billpkgnum' => '' }
     );
-    warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n"
-      if @pending_event_fees and $DEBUG > 1;
+    warn "$me found pending fees:\n".Dumper(\@pending_fees)."\n"
+      if @pending_fees and $DEBUG > 1;
 
     # determine whether to generate an invoice
     my $generate_bill = scalar(@cust_bill_pkg) > 0;
 
-    foreach my $event_fee (@pending_event_fees) {
-      $generate_bill = 1 unless $event_fee->nextbill;
+    foreach my $fee (@pending_fees) {
+      $generate_bill = 1 unless $fee->nextbill;
     }
     
     # don't create an invoice with no line items, or where the only line 
@@ -594,38 +594,11 @@ sub bill {
 
     # calculate fees...
     my @fee_items;
-    foreach my $event_fee (@pending_event_fees) {
-      my $object = $event_fee->cust_event->cust_X;
-      my $part_fee = $event_fee->part_fee;
-      my $cust_bill;
-      if ( $object->isa('FS::cust_main')
-           or $object->isa('FS::cust_pkg')
-           or $object->isa('FS::cust_pay_batch') )
-      {
-        # 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} },
-        });
-
-        # If this is a package event, only apply the fee to line items 
-        # from that package.
-        if ($object->isa('FS::cust_pkg')) {
-          $cust_bill->set('cust_bill_pkg', 
-            [ grep  { $_->pkgnum == $object->pkgnum } @cust_bill_pkg ]
-          );
-        }
+    foreach my $fee_origin (@pending_fees) {
+      my $part_fee = $fee_origin->part_fee;
 
-      } elsif ( $object->isa('FS::cust_bill') ) {
-        # simple case: applying the fee to a previous invoice (late fee, 
-        # etc.)
-        $cust_bill = $object;
-      }
+      # check whether the fee is applicable before doing anything expensive:
+      #
       # 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.
@@ -636,10 +609,41 @@ sub bill {
       }
       # also skip if it's disabled
       next if $part_fee->disabled eq 'Y';
+
+      # Decide which invoice to base the fee on.
+      my $cust_bill = $fee_origin->cust_bill;
+      if (!$cust_bill) {
+        # Then link it to the current invoice. This isn't 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} },
+        });
+
+        # If the origin is for a specific package, then only apply the fee to
+        # line items from that package.
+        if ( my $cust_pkg = $fee_origin->cust_pkg ) {
+          my @charge_fee_on_item;
+          my $charge_fee_on_amount = 0;
+          foreach (@cust_bill_pkg) {
+            if ($_->pkgnum == $cust_pkg->pkgnum) {
+              push @charge_fee_on_item, $_;
+              $charge_fee_on_amount += $_->setup + $_->recur;
+            }
+          }
+          $cust_bill->set('cust_bill_pkg', \@charge_fee_on_item);
+          $cust_bill->set('charged', $charge_fee_on_amount);
+        }
+
+      } # $cust_bill is now set
       # calculate the fee
       my $fee_item = $part_fee->lineitem($cust_bill) or next;
       # link this so that we can clear the marker on inserting the line item
-      $fee_item->set('cust_event_fee', $event_fee);
+      $fee_item->set('fee_origin', $fee_origin);
       push @fee_items, $fee_item;
 
     }
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 5f2cd83..6274d3f 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -1368,6 +1368,7 @@ sub suspend {
       if $error;
   }
 
+  my $cust_pkg_reason;
   if ( $options{'reason'} ) {
     $error = $self->insert_reason( 'reason' => $options{'reason'},
                                    'action' => $date ? 'adjourn' : 'suspend',
@@ -1378,6 +1379,11 @@ sub suspend {
       dbh->rollback if $oldAutoCommit;
       return "Error inserting cust_pkg_reason: $error";
     }
+    $cust_pkg_reason = qsearchs('cust_pkg_reason', {
+        'date'    => $date ? $date : $suspend_time,
+        'action'  => $date ? 'A' : 'S',
+        'pkgnum'  => $self->pkgnum,
+    });
   }
 
   # if a reasonnum was passed, get the actual reason object so we can check
@@ -1458,6 +1464,27 @@ sub suspend {
       }
     }
 
+    # suspension fees: if there is a feepart, and it's not an unsuspend fee,
+    # and this is not a suspend-before-cancel
+    if ( $cust_pkg_reason ) {
+      my $reason_obj = $cust_pkg_reason->reason;
+      if ( $reason_obj->feepart and
+           ! $reason_obj->fee_on_unsuspend and
+           ! $options{'from_cancel'} ) {
+
+        # register the need to charge a fee, cust_main->bill will do the rest
+        warn "registering suspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n"
+          if $DEBUG;
+        my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({
+            'pkgreasonnum'  => $cust_pkg_reason->num,
+            'pkgnum'        => $self->pkgnum,
+            'feepart'       => $reason->feepart,
+            'nextbill'      => $reason->fee_hold,
+        });
+        $error ||= $cust_pkg_reason_fee->insert;
+      }
+    }
+
     my $conf = new FS::Conf;
     if ( $conf->config('suspend_email_admin') && !$options{'from_cancel'} ) {
  
@@ -1753,23 +1780,39 @@ sub unsuspend {
 
   my $unsusp_pkg;
 
-  if ( $reason && $reason->unsuspend_pkgpart ) {
-    my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
-      or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
-                  " not found.";
-    my $start_date = $self->cust_main->next_bill_date 
-      if $reason->unsuspend_hold;
-
-    if ( $part_pkg ) {
-      $unsusp_pkg = FS::cust_pkg->new({
-          'custnum'     => $self->custnum,
-          'pkgpart'     => $reason->unsuspend_pkgpart,
-          'start_date'  => $start_date,
-          'locationnum' => $self->locationnum,
-          # discount? probably not...
+  if ( $reason ) {
+    if ( $reason->unsuspend_pkgpart ) {
+      #warn "Suspend reason '".$reason->reason."' uses deprecated unsuspend_pkgpart feature.\n"; # in 4.x
+      my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
+        or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
+                    " not found.";
+      my $start_date = $self->cust_main->next_bill_date 
+        if $reason->unsuspend_hold;
+
+      if ( $part_pkg ) {
+        $unsusp_pkg = FS::cust_pkg->new({
+            'custnum'     => $self->custnum,
+            'pkgpart'     => $reason->unsuspend_pkgpart,
+            'start_date'  => $start_date,
+            'locationnum' => $self->locationnum,
+            # discount? probably not...
+        });
+
+        $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
+      }
+    }
+    # new way, using fees
+    if ( $reason->feepart and $reason->fee_on_unsuspend ) {
+      # register the need to charge a fee, cust_main->bill will do the rest
+      warn "registering unsuspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n"
+        if $DEBUG;
+      my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({
+          'pkgreasonnum'  => $cust_pkg_reason->num,
+          'pkgnum'        => $self->pkgnum,
+          'feepart'       => $reason->feepart,
+          'nextbill'      => $reason->fee_hold,
       });
-      
-      $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
+      $error ||= $cust_pkg_reason_fee->insert;
     }
 
     if ( $error ) {
diff --git a/FS/FS/cust_pkg_reason_fee.pm b/FS/FS/cust_pkg_reason_fee.pm
new file mode 100644
index 0000000..e5cc829
--- /dev/null
+++ b/FS/FS/cust_pkg_reason_fee.pm
@@ -0,0 +1,158 @@
+package FS::cust_pkg_reason_fee;
+
+use strict;
+use base qw( FS::Record FS::FeeOrigin_Mixin );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cust_pkg_reason_fee - Object methods for cust_pkg_reason_fee records
+
+=head1 SYNOPSIS
+
+  use FS::cust_pkg_reason_fee;
+
+  $record = new FS::cust_pkg_reason_fee \%hash;
+  $record = new FS::cust_pkg_reason_fee { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_pkg_reason_fee object links a package status change that charged
+a fee (an L<FS::cust_pkg_reason> object) to the resulting invoice line item.
+FS::cust_pkg_reason_fee inherits from FS::Record and FS::FeeOrigin_Mixin.  
+The following fields are currently supported:
+
+=over 4
+
+=item pkgreasonfeenum - primary key
+
+=item pkgreasonnum - key of the cust_pkg_reason object that triggered the fee.
+
+=item billpkgnum - key of the cust_bill_pkg record representing the fee on an
+invoice. This can be NULL if the fee is scheduled but hasn't been billed yet.
+
+=item feepart - key of the fee definition (L<FS::part_fee>).
+
+=item nextbill - 'Y' if the fee should be charged on the customer's next bill,
+rather than causing a bill to be produced immediately.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+=cut
+
+sub table { 'cust_pkg_reason_fee'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('pkgreasonfeenum')
+    || $self->ut_foreign_key('pkgreasonnum', 'cust_pkg_reason', 'num')
+    || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg', 'billpkgnum')
+    || $self->ut_foreign_key('feepart', 'part_fee', 'feepart')
+    || $self->ut_flag('nextbill')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item _by_cust CUSTNUM[, PARAMS]
+
+See L<FS::FeeOrigin_Mixin/by_cust>.
+
+=cut
+
+sub _by_cust {
+  my $class = shift;
+  my $custnum = shift or return;
+  my %params = @_;
+  $custnum =~ /^\d+$/ or die "bad custnum $custnum";
+    
+  my $where = ($params{hashref} && keys (%{ $params{hashref} }))
+              ? 'AND'
+              : 'WHERE';
+  qsearch({
+    table     => 'cust_pkg_reason_fee',
+    addl_from => 'JOIN cust_pkg_reason ON (cust_pkg_reason_fee.pkgreasonnum = cust_pkg_reason.num) ' .
+                 'JOIN cust_pkg USING (pkgnum) ',
+    extra_sql => "$where cust_pkg.custnum = $custnum",
+    %params
+  });
+}
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item cust_pkg
+
+Returns the package that triggered the fee.
+
+=cut
+
+sub cust_pkg {
+  my $self = shift;
+  $self->cust_pkg_reason->cust_pkg;
+}
+
+#stub - remove in 4.x
+sub cust_pkg_reason {
+  my $self = shift;
+  FS::cust_pkg_reason->by_key($self->pkgreasonnum);
+}
+
+=head1 SEE ALSO
+
+L<FS::FeeOrigin_Mixin>, L<FS::cust_pkg_reason>, L<part_fee>
+
+=cut
+
+1;
+
diff --git a/FS/FS/reason.pm b/FS/FS/reason.pm
index f28989a..21c82b1 100644
--- a/FS/FS/reason.pm
+++ b/FS/FS/reason.pm
@@ -50,7 +50,7 @@ FS::Record.  The following fields are currently supported:
 L<FS::part_pkg>) of a package to be ordered when the package is unsuspended.
 Typically this will be some kind of reactivation fee.  Attaching it to 
 a suspension reason allows the reactivation fee to be charged for some
-suspensions but not others.
+suspensions but not others. DEPRECATED.
 
 =item unsuspend_hold - 'Y' or ''.  If unsuspend_pkgpart is set, this tells
 whether to bill the unsuspend package immediately ('') or to wait until 
@@ -60,6 +60,15 @@ the customer's next invoice ('Y').
 If enabled, the customer will be credited for their remaining time on 
 suspension.
 
+=item feepart - for suspension reasons, the feepart of a fee to be
+charged when a package is suspended for this reason.
+
+=item fee_hold - 'Y' or ''. If feepart is set, tells whether to bill the fee
+immediately ('') or wait until the customer's next invoice ('Y').
+
+=item fee_on_unsuspend - If feepart is set, tells whether to charge the fee
+on suspension ('') or unsuspension ('Y').
+
 =back
 
 =head1 METHODS
@@ -121,10 +130,14 @@ sub check {
           || $self->ut_foreign_keyn('unsuspend_pkgpart', 'part_pkg', 'pkgpart')
           || $self->ut_flag('unsuspend_hold')
           || $self->ut_flag('unused_credit')
+          || $self->ut_foreign_keyn('feepart', 'part_fee', 'feepart')
+          || $self->ut_flag('fee_on_unsuspend')
+          || $self->ut_flag('fee_hold')
     ;
     return $error if $error;
   } else {
-    foreach (qw(unsuspend_pkgpart unsuspend_hold unused_credit)) {
+    foreach (qw(unsuspend_pkgpart unsuspend_hold unused_credit feepart
+                fee_on_unsuspend fee_hold)) {
       $self->set($_ => '');
     }
   }
@@ -180,7 +193,6 @@ sub new_or_existing {
   $reason;
 }
 
-
 =head1 BUGS
 
 =head1 SEE ALSO
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 54bbe1a..baf1135 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -793,3 +793,6 @@ FS/quotation_pkg_tax.pm
 t/quotation_pkg_tax.t
 FS/h_svc_circuit.pm
 FS/h_svc_circuit.t
+FS/FeeOrigin_Mixin.pm
+FS/cust_pkg_reason_fee.pm
+t/cust_pkg_reason_fee.t
diff --git a/FS/t/cust_pkg_reason_fee.t b/FS/t/cust_pkg_reason_fee.t
new file mode 100644
index 0000000..96cb79a
--- /dev/null
+++ b/FS/t/cust_pkg_reason_fee.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_pkg_reason_fee;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/browse/reason.html b/httemplate/browse/reason.html
index 5bb6a3e..8af88a9 100644
--- a/httemplate/browse/reason.html
+++ b/httemplate/browse/reason.html
@@ -65,7 +65,7 @@ my $align = 'rll';
 if ( $class eq 'S' ) {
   push @header,
     'Credit unused service',
-    'Unsuspension fee',
+    'Suspension fee',
   ;
   push @fields,
     sub {
@@ -78,17 +78,29 @@ if ( $class eq 'S' ) {
     },
     sub {
       my $reason = shift;
-      my $pkgpart = $reason->unsuspend_pkgpart or return '';
-      my $part_pkg = FS::part_pkg->by_key($pkgpart) or return '';
-      my $text = $part_pkg->pkg_comment;
-      my $href = $p."edit/part_pkg.cgi?$pkgpart";
-      $text = qq!<A HREF="$href">! . encode_entities($text) . "</A>".
-              "<FONT SIZE=-1>";
-      if ( $reason->unsuspend_hold ) {
-        $text .= ' (on next bill)'
+      my $feepart = $reason->feepart;
+      my ($href, $text, $detail);
+      if ( $feepart ) {
+        my $part_fee = FS::part_fee->by_key($feepart) or return '';
+        $text = $part_fee->itemdesc . ': ' . $part_fee->explanation;
+        $detail = $reason->fee_on_unsuspend ? 'unsuspension' : 'suspension';
+        if ( $reason->fee_hold ) {
+          $detail = "next bill after $detail";
+        }
+        $detail = "(on $detail)";
+        $href = $p."edit/part_fee.html?$feepart";
       } else {
-        $text .= ' (immediately)'
+        my $pkgpart = $reason->unsuspend_pkgpart;
+        my $part_pkg = FS::part_pkg->by_key($pkgpart) or return '';
+        $text = $part_pkg->pkg_comment;
+        $href = $p."edit/part_pkg.cgi?$pkgpart";
+        $detail = $reason->unsuspend_hold ?
+          '(on next bill after unsuspension)' : '(on unsuspension)';
       }
+      return '' unless length($text);
+
+      $text = qq!<A HREF="$href">! . encode_entities($text) . "</A> ".
+              "<FONT SIZE=-1>$detail</FONT>";
       $text .= '</FONT>';
     }
   ;
diff --git a/httemplate/edit/reason.html b/httemplate/edit/reason.html
index 3e6645e..30168d5 100644
--- a/httemplate/edit/reason.html
+++ b/httemplate/edit/reason.html
@@ -13,9 +13,12 @@
                 'reason'      => $classname . ' Reason',
  	        'disabled'    => 'Disabled',
                 'class'       => '',
-                'unsuspend_pkgpart' => 'Unsuspension fee',
-                'unsuspend_hold'    => 'Delay until next bill',
+                'feepart'     => 'Charge a suspension fee',
+                'fee_on_unsuspend'  => 'When a package is',
+                'fee_hold'          => 'Delay fee until next bill',
                 'unused_credit'     => 'Credit unused portion of service',
+                'unsuspend_pkgpart' => 'Order an unsuspension package',
+                'unsuspend_hold'    => 'Delay package until next bill',
               },
   'fields' => \@fields,
 &>
@@ -64,6 +67,28 @@ my @fields = (
 
 if ( $class eq 'S' ) {
   push @fields,
+    { 'field'     => 'unused_credit',
+      'type'      => 'checkbox',
+      'value'     => 'Y',
+    }, 
+    { 'type' => 'tablebreak-tr-title' },
+    { 'field'     => 'feepart',
+      'type'      => 'select-table',
+      'table'     => 'part_fee',
+      'hashref'   => { disabled => '' },
+      'name_col'  => 'itemdesc',
+      'value_col' => 'feepart',
+      'empty_label' => 'none',
+    },
+    { 'field'     => 'fee_on_unsuspend',
+      'type'      => 'select',
+      'options'   => [ '', 'Y' ],
+      'labels'    => { '' => 'suspended', 'Y' => 'unsuspended' },
+    },
+    { 'field'     => 'fee_hold',
+      'type'      => 'checkbox',
+      'value'     => 'Y',
+    },
     { 'field'     => 'unsuspend_pkgpart',
       'type'      => 'select-part_pkg',
       'hashref'   => { 'disabled' => '',
@@ -73,10 +98,6 @@ if ( $class eq 'S' ) {
       'type'      => 'checkbox',
       'value'     => 'Y',
     },
-    { 'field'     => 'unused_credit',
-      'type'      => 'checkbox',
-      'value'     => 'Y',
-    }, 
   ;
 }
 
diff --git a/httemplate/elements/tr-select-reason.html b/httemplate/elements/tr-select-reason.html
index 70f21c9..836dd9b 100755
--- a/httemplate/elements/tr-select-reason.html
+++ b/httemplate/elements/tr-select-reason.html
@@ -35,13 +35,17 @@ Example:
 % # - no redundant checking of ACLs or parameters
 % # - form fields are grouped for easy management
 % # - use the standard select-table widget instead of ad hoc crap
+<& /elements/xmlhttp.html,
+  url => $p . 'misc/xmlhttp-reason-hint.html',
+  subs => [ 'get_hint' ],
+&>
 <SCRIPT TYPE="text/javascript">
   function <% $id %>_changed() {
-    var hints = <% encode_json(\%all_hints) %>;
     var select_reason = document.getElementById('<% $id %>');
 
-    document.getElementById('<% $id %>_hint').innerHTML =
-      hints[select_reason.value] || '';
+    get_hint(select_reason.value, function(stuff) {
+      document.getElementById('<% $id %>_hint').innerHTML = stuff || '';
+    });
 
     // toggle submit button state
     var submit_button = document.getElementById(<% $opt{control_button} |js_string %>);
@@ -122,24 +126,45 @@ Example:
         field => $id.'_new_unused_credit',
         value => 'Y'
       &>
-      <& tr-select-part_pkg.html,
-        label   => 'Charge this fee when unsuspending',
-        field   => $id.'_new_unsuspend_pkgpart',
-        hashref => { disabled => '', freq => '0' },
+      <& tr-select-table.html,
+        label     => 'Charge a suspension fee',
+        field     => $id.'_new_feepart',
+        table     => 'part_fee',
+        hashref   => { disabled => '' },
+        name_col  => 'itemdesc',
+        value_col => 'feepart',
         empty_label => 'none',
       &>
+      <& tr-select.html,
+        label     => 'When this package is',
+        field     => $id.'_new_fee_on_unsuspend',
+        options   => [ '', 'Y' ],
+        labels    => { '' => 'suspended', 'Y' => 'unsuspended' },
+      &>
       <& tr-checkbox.html,
-        label => 'Hold unsuspension fee until the next bill',
-        field => $id.'_new_unsuspend_hold',
-        value => 'Y',
+        label     => 'Delay fee until the next bill',
+        field     => $id.'_new_fee_hold',
+        value     => 'Y',
       &>
+%# deprecated, but still accessible through the "Suspend Reasons" UI
+%#      <& tr-select-part_pkg.html,
+%#        label   => 'Charge this fee when unsuspending',
+%#        field   => $id.'_new_unsuspend_pkgpart',
+%#        hashref => { disabled => '', freq => '0' },
+%#        empty_label => 'none',
+%#      &>
+%#      <& tr-checkbox.html,
+%#        label => 'Hold unsuspension fee until the next bill',
+%#        field => $id.'_new_unsuspend_hold',
+%#        value => 'Y',
+%#      &>
 %   }
     </table>
   </td>
 </tr>
 % } # if the current user can add a reason
 
-% # container for hints
+% # container for hints (hints themselves come from xmlhttp-reason-hint)
 <TR>
   <TD COLSPAN=2 ALIGN="center" id="<% $id %>_hint" style="font-size:small">
   </TD>
@@ -190,43 +215,6 @@ my @reasons = qsearch({
   'extra_sql'       => " AND reason_type.class = '$class'",
 });
 
-my %all_hints;
-if ( $class eq 'S' ) {
-  my $conf = FS::Conf->new;
-  %all_hints = ( 0 => '', -1 => '' );
-  foreach my $reason (@reasons) {
-    my @hints;
-    if ( $reason->unsuspend_pkgpart ) {
-      my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart);
-      if ( $part_pkg ) {
-        if ( $part_pkg->option('setup_fee',1) > 0 and 
-             $part_pkg->option('recur_fee',1) == 0 ) {
-          # the usual case
-          push @hints,
-            mt('A [_1] unsuspension fee will apply.', 
-               ($conf->config('money_char') || '$') .
-               sprintf('%.2f', $part_pkg->option('setup_fee'))
-               );
-        } else {
-          # oddball cases--not really supported
-          push @hints,
-            mt('An unsuspension package will apply: [_1]',
-              $part_pkg->price_info
-              );
-        }
-      } else { #no $part_pkg
-        push @hints,
-          '<FONT COLOR="#ff0000">Unsuspend pkg #'.$reason->unsuspend_pkgpart.
-          ' not found.</FONT>';
-      }
-    }
-    if ( $reason->unused_credit ) {
-      push @hints, mt('The customer will be credited for unused time.');
-    }
-    $all_hints{ $reason->reasonnum } = join('<BR>', @hints);
-  }
-}
-
 my @post_options;
 if ( $curuser->access_right($add_access_right) ) {
   @post_options = ( -1 => 'Add new reason' );
diff --git a/httemplate/misc/process/elements/reason b/httemplate/misc/process/elements/reason
index ae92a75..f57f11f 100644
--- a/httemplate/misc/process/elements/reason
+++ b/httemplate/misc/process/elements/reason
@@ -8,7 +8,8 @@ my $error;
 if ($reasonnum == -1) {
   my $new_reason = FS::reason->new({
     map { $_ => scalar( $cgi->param("reasonnum_new_$_") ) }
-    qw( reason_type reason unsuspend_pkgpart unsuspend_hold unused_credit )
+    qw( reason_type reason unsuspend_pkgpart unsuspend_hold unused_credit
+        feepart fee_on_unsuspend fee_hold )
   }); # not sanitizing them here, but check() will do it
   $error = $new_reason->insert;
   $reasonnum = $new_reason->reasonnum;
diff --git a/httemplate/misc/xmlhttp-reason-hint.html b/httemplate/misc/xmlhttp-reason-hint.html
new file mode 100644
index 0000000..5d54788
--- /dev/null
+++ b/httemplate/misc/xmlhttp-reason-hint.html
@@ -0,0 +1,83 @@
+<%doc>
+Example:
+
+<& /elements/xmlhttp.html,
+  url => $p . 'misc/xmlhttp-reason-hint.html',
+  subs => [ 'get_hint' ]
+&>
+<script>
+var reasonnum = 101;
+get_hint( reasonnum, function(stuff) { alert(stuff); } )
+</script>
+
+Currently will provide hints for:
+1. suspension events (new-style reconnection fees, notification)
+2. unsuspend_pkgpart package info (older reconnection fees)
+3. crediting for unused time
+</%doc>
+<%init>
+my $sub = $cgi->param('sub');
+my ($reasonnum) = $cgi->param('arg');
+# arg is a reasonnum
+my $conf = FS::Conf->new;
+my $error = '';
+my @hints;
+if ( $reasonnum =~ /^\d+$/ ) {
+  my $reason = FS::reason->by_key($reasonnum);
+  if ( $reason ) {
+    # 1.
+    if ( $reason->feepart ) { # XXX
+      my $part_fee = FS::part_fee->by_key($reason->feepart);
+      my $when = '';
+      if ( $reason->fee_hold ) {
+        $when = 'on the next bill after ';
+      } else {
+        $when = 'upon ';
+      }
+      if ( $reason->fee_on_unsuspend ) {
+        $when .= 'unsuspension';
+      } else {
+        $when .= 'suspension';
+      }
+
+      my $fee_amt = $part_fee->explanation;
+      push @hints, mt('A fee of [_1] will be charged [_2].',
+                      $fee_amt, $when);
+    }
+    # 2.
+    if ( $reason->unsuspend_pkgpart ) {
+      my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart);
+      if ( $part_pkg ) {
+        if ( $part_pkg->option('setup_fee',1) > 0 and 
+             $part_pkg->option('recur_fee',1) == 0 ) {
+          # the usual case
+          push @hints,
+            mt('A [_1] unsuspension fee will apply.',
+               ($conf->config('money_char') || '$') .
+               sprintf('%.2f', $part_pkg->option('setup_fee'))
+               );
+        } else {
+          # oddball cases--not really supported
+          push @hints,
+            mt('An unsuspension package will apply: [_1]',
+              $part_pkg->price_info
+              );
+        }
+      } else { #no $part_pkg
+        push @hints,
+          '<FONT COLOR="#ff0000">Unsuspend pkg #'.$reason->unsuspend_pkgpart.
+          ' not found.</FONT>';
+      }
+    }
+    # 3.
+    if ( $reason->unused_credit ) {
+      push @hints, mt('The customer will be credited for unused time.');
+    }
+  } else {
+    warn "reasonnum $reasonnum not found; returning no hints\n";
+  }
+} else {
+  warn "reason-hint arg '$reasonnum' not a valid reasonnum\n";
+}
+</%init>
+<% join('<BR>', @hints) %>

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

Summary of changes:
 FS/FS/FeeOrigin_Mixin.pm                           |  135 +++++++++++++++++
 FS/FS/Mason.pm                                     |    1 +
 FS/FS/Schema.pm                                    |   16 ++
 FS/FS/cust_bill_pkg.pm                             |    9 +-
 FS/FS/cust_event_fee.pm                            |   63 +++++---
 FS/FS/cust_main/Billing.pm                         |   80 +++++-----
 FS/FS/cust_pkg.pm                                  |   75 ++++++++--
 FS/FS/cust_pkg_reason_fee.pm                       |  158 ++++++++++++++++++++
 FS/FS/reason.pm                                    |   18 ++-
 FS/MANIFEST                                        |    3 +
 ...art_pkg_usage_class.t => cust_pkg_reason_fee.t} |    2 +-
 httemplate/browse/reason.html                      |   32 ++--
 httemplate/edit/reason.html                        |   33 +++-
 httemplate/elements/tr-select-reason.html          |   84 +++++------
 httemplate/misc/process/elements/reason            |    3 +-
 httemplate/misc/xmlhttp-reason-hint.html           |   83 ++++++++++
 16 files changed, 646 insertions(+), 149 deletions(-)
 create mode 100644 FS/FS/FeeOrigin_Mixin.pm
 create mode 100644 FS/FS/cust_pkg_reason_fee.pm
 copy FS/t/{part_pkg_usage_class.t => cust_pkg_reason_fee.t} (99%)
 create mode 100644 httemplate/misc/xmlhttp-reason-hint.html




More information about the freeside-commits mailing list