summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2014-02-27 14:04:52 -0800
committerMark Wells <mark@freeside.biz>2014-02-27 15:08:21 -0800
commita0e00fa0547e99893c735ab3dbdacdb2bb054f5a (patch)
tree2784c6564ab363606a606fbbac56539006bb16a1
parent55190e4a18ff318cf2a0ac2eb6abaf7a3b95e087 (diff)
package fees and usage-based fees, #27687, #25899
-rw-r--r--FS/FS/Mason.pm1
-rw-r--r--FS/FS/Schema.pm20
-rw-r--r--FS/FS/cust_event_fee.pm12
-rw-r--r--FS/FS/cust_main/Billing.pm13
-rw-r--r--FS/FS/part_event/Action/pkg_fee.pm16
-rw-r--r--FS/FS/part_fee.pm105
-rw-r--r--FS/FS/part_fee_usage.pm130
-rw-r--r--FS/MANIFEST2
-rw-r--r--FS/t/part_fee_usage.t5
-rw-r--r--httemplate/edit/part_fee.html44
-rwxr-xr-xhttemplate/edit/process/part_fee.html17
-rw-r--r--httemplate/elements/tr-part_fee_usage.html29
12 files changed, 337 insertions, 57 deletions
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 7bf5446..cefdeaa 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -377,6 +377,7 @@ if ( -e $addl_handler_use_file ) {
use FS::part_fee;
use FS::cust_bill_pkg_fee;
use FS::part_fee_msgcat;
+ use FS::part_fee_usage;
# Sammath Naur
if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 795b97f..78cac4a 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -3185,6 +3185,26 @@ sub tables_hashref {
],
},
+ 'part_fee_usage' => {
+ 'columns' => [
+ 'feepartusagenum','serial', '', '', '', '',
+ 'feepart', 'int', '', '', '', '',
+ 'classnum', 'int', '', '', '', '',
+ 'amount', @money_type, '', '',
+ 'percent', 'decimal', '', '7,4', '', '',
+ ],
+ 'primary_key' => 'feepartusagenum',
+ 'unique' => [ [ 'feepart', 'classnum' ] ],
+ 'index' => [],
+ 'foreign_keys' => [
+ { columns => [ 'feepart' ],
+ table => 'part_fee',
+ },
+ { columns => [ 'classnum' ],
+ table => 'pkg_class',
+ },
+ ],
+ },
'part_pkg_link' => {
'columns' => [
diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm
index d924485..181640d 100644
--- a/FS/FS/cust_event_fee.pm
+++ b/FS/FS/cust_event_fee.pm
@@ -112,7 +112,8 @@ sub check {
=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.
+fee events can be cust_main, cust_pkg, or cust_bill events; this will return
+all of them.
PARAMS can be additional params to pass to qsearch; this really only works
for 'hashref' and 'order_by'.
@@ -145,6 +146,15 @@ sub by_cust {
extra_sql => "$where eventtable = 'cust_bill' ".
"AND cust_bill.custnum = $custnum",
%params
+ }),
+ qsearch({
+ table => 'cust_event_fee',
+ addl_from => 'JOIN cust_event USING (eventnum) ' .
+ 'JOIN part_event USING (eventpart) ' .
+ 'JOIN cust_pkg ON (cust_event.tablenum = cust_pkg.pkgnum)',
+ extra_sql => "$where eventtable = 'cust_pkg' ".
+ "AND cust_pkg.custnum = $custnum",
+ %params
})
}
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index a7e7d19..8d38992 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -564,7 +564,7 @@ sub bill {
my $object = $event_fee->cust_event->cust_X;
my $part_fee = $event_fee->part_fee;
my $cust_bill;
- if ( $object->isa('FS::cust_main') ) {
+ if ( $object->isa('FS::cust_main') or $object->isa('FS::cust_pkg') ) {
# 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
@@ -575,6 +575,15 @@ sub bill {
'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 ]
+ );
+ }
+
} elsif ( $object->isa('FS::cust_bill') ) {
# simple case: applying the fee to a previous invoice (late fee,
# etc.)
@@ -591,7 +600,7 @@ sub bill {
# 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);
+ 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);
push @fee_items, $fee_item;
diff --git a/FS/FS/part_event/Action/pkg_fee.pm b/FS/FS/part_event/Action/pkg_fee.pm
new file mode 100644
index 0000000..7e409a5
--- /dev/null
+++ b/FS/FS/part_event/Action/pkg_fee.pm
@@ -0,0 +1,16 @@
+package FS::part_event::Action::pkg_fee;
+
+use strict;
+use base qw( FS::part_event::Action::Mixin::fee );
+
+sub description { 'Charge a fee when this package is billed'; }
+
+sub eventtable_hashref {
+ { 'cust_pkg' => 1 };
+}
+
+sub hold_until_bill { 1 }
+
+# Functionally identical to cust_fee.
+
+1;
diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm
index b0e5473..186fb34 100644
--- a/FS/FS/part_fee.pm
+++ b/FS/FS/part_fee.pm
@@ -146,23 +146,20 @@ sub check {
|| $self->ut_moneyn('minimum')
|| $self->ut_moneyn('maximum')
|| $self->ut_flag('limit_credit')
- || $self->ut_enum('basis', [ '', 'charged', 'owed' ])
+ || $self->ut_enum('basis', [ 'charged', 'owed', 'usage' ])
|| $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', '');
}
+ if ( $self->get('basis') eq 'usage' ) {
+ # to avoid confusion, don't also allow charging a percentage
+ $self->set('percent', 0);
+ }
+
$self->SUPER::check;
}
@@ -178,7 +175,7 @@ sub explanation {
my $money_char = FS::Conf->new->config('money_char') || '$';
my $money = $money_char . '%.2f';
my $percent = '%.1f%%';
- my $string;
+ my $string = '';
if ( $self->amount > 0 ) {
$string = sprintf($money, $self->amount);
}
@@ -193,7 +190,14 @@ sub explanation {
} elsif ( $self->basis('owed') ) {
$string .= 'unpaid invoice balance';
}
+ } elsif ( $self->basis eq 'usage' ) {
+ if ( $string ) {
+ $string .= " plus \n";
+ }
+ # append per-class descriptions
+ $string .= join("\n", map { $_->explanation } $self->part_fee_usage);
}
+
if ( $self->minimum or $self->maximum or $self->limit_credit ) {
$string .= "\nbut";
if ( $self->minimum ) {
@@ -244,37 +248,68 @@ sub lineitem {
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"
+ my $basis = $self->basis;
- # calculate the fee on an already-inserted past invoice. This may have
- # payments or credits, so if basis = owed, we need to consider those.
+ # $total_base: the total charged/owed on the invoice
+ # %item_base: billpkgnum => fraction of base amount
+ if ( $cust_bill->invnum ) {
+
+ # calculate the fee on an already-inserted past invoice. This may have
+ # payments or credits, so if basis = owed, we need to consider those.
+ @items = $cust_bill->cust_bill_pkg;
+ if ( $basis ne 'usage' ) {
+
+ $total_base = $cust_bill->$basis; # "charged", "owed"
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)
+ }
+ } 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)
+ @items = @{ $cust_bill->get('cust_bill_pkg') };
+ if ( $basis ne 'usage' ) {
$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 ( $basis eq 'usage' ) {
+
+ my %part_fee_usage = map { $_->classnum => $_ } $self->part_fee_usage;
+
+ foreach my $item (@items) { # cust_bill_pkg objects
+ my $usage_fee = 0;
+ $item->regularize_details;
+ my $details;
+ if ( $item->billpkgnum ) {
+ $details = [
+ qsearch('cust_bill_pkg_detail', { billpkgnum => $item->billpkgnum })
+ ];
+ } else {
+ $details = $item->get('details') || [];
+ }
+ foreach my $d (@$details) {
+ # if there's a usage fee defined for this class...
+ next if $d->amount eq '' # not a real usage detail
+ or $d->amount == 0 # zero charge, probably shouldn't charge fee
+ ;
+ my $p = $part_fee_usage{$d->classnum} or next;
+ $usage_fee += ($d->amount * $p->percent / 100)
+ + $p->amount;
+ # we'd create detail records here if we were doing that
+ }
+ # bypass @item_base entirely
+ push @item_fee, $usage_fee;
+ $amount += $usage_fee;
+ }
+
+ } # if $basis eq 'usage'
+
if ( $self->minimum ne '' and $amount < $self->minimum ) {
warn "Applying mininum fee\n" if $DEBUG;
$amount = $self->minimum;
@@ -365,25 +400,25 @@ sub lineitem {
}
}
}
- # and add them to the cust_bill_pkg
+ }
+ if ( @item_fee ) {
+ # add allocation records 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,
+ base_invnum => $cust_bill->invnum, # may be null
amount => $item_fee[$i],
base_cust_bill_pkg => $items[$i], # late resolve
});
}
}
- } else { # if !@item_base
+ } else { # if !@item_fee
# 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,
+ base_invnum => $cust_bill->invnum, # may be null
amount => $amount,
});
}
diff --git a/FS/FS/part_fee_usage.pm b/FS/FS/part_fee_usage.pm
new file mode 100644
index 0000000..a1b85ae
--- /dev/null
+++ b/FS/FS/part_fee_usage.pm
@@ -0,0 +1,130 @@
+package FS::part_fee_usage;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
+
+=head1 NAME
+
+FS::part_fee_usage - Object methods for part_fee_usage records
+
+=head1 SYNOPSIS
+
+ use FS::part_fee_usage;
+
+ $record = new FS::part_fee_usage \%hash;
+ $record = new FS::part_fee_usage { 'column' => 'value' };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_fee_usage object is the part of a processing fee definition
+(L<FS::part_fee>) that applies to a specific telephone usage class
+(L<FS::usage_class>). FS::part_fee_usage inherits from
+FS::Record. The following fields are currently supported:
+
+=over 4
+
+=item feepartusagenum - primary key
+
+=item feepart - foreign key to L<FS::part_pkg>
+
+=item classnum - foreign key to L<FS::usage_class>
+
+=item amount - fixed amount to charge per usage record
+
+=item percent - percentage of rated price to charge per usage record
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+sub table { 'part_fee_usage'; }
+
+sub check {
+ my $self = shift;
+
+ $self->set('amount', 0) unless ($self->amount || 0) > 0;
+ $self->set('percent', 0) unless ($self->percent || 0) > 0;
+
+ my $error =
+ $self->ut_numbern('feepartusagenum')
+ || $self->ut_foreign_key('feepart', 'part_fee', 'feepart')
+ || $self->ut_foreign_key('classnum', 'usage_class', 'classnum')
+ || $self->ut_money('amount')
+ || $self->ut_float('percent')
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+# silently discard records with percent = 0 and amount = 0
+
+sub insert {
+ my $self = shift;
+ if ( $self->amount > 0 or $self->percent > 0 ) {
+ return $self->SUPER::insert;
+ }
+ '';
+}
+
+sub replace {
+ my ($new, $old) = @_;
+ $old ||= $new->replace_old;
+ if ( $new->amount > 0 or $new->percent > 0 ) {
+ return $new->SUPER::replace($old);
+ } elsif ( $old->feepartusagenum ) {
+ return $old->delete;
+ }
+ '';
+}
+
+=item explanation
+
+Returns a string describing how this fee is calculated.
+
+=cut
+
+sub explanation {
+ my $self = shift;
+ my $string = '';
+ my $money = (FS::Conf->new->config('money_char') || '$') . '%.2f';
+ my $percent = '%.1f%%';
+ 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 rated charge';
+ }
+ $string .= ' per '. $self->usage_class->classname . ' call';
+
+ return $string;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 129ee64..7ba2226 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -766,3 +766,5 @@ FS/cust_bill_pkg_fee.pm
t/cust_bill_pkg_fee.t
FS/part_fee_msgcat.pm
t/part_fee_msgcat.t
+FS/part_fee_usage.pm
+FS/part_fee_usage.t
diff --git a/FS/t/part_fee_usage.t b/FS/t/part_fee_usage.t
new file mode 100644
index 0000000..cb7fb22
--- /dev/null
+++ b/FS/t/part_fee_usage.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_fee_usage;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html
index b1044c9..e057a75 100644
--- a/httemplate/edit/part_fee.html
+++ b/httemplate/edit/part_fee.html
@@ -14,13 +14,11 @@
'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',
- 'nextbill' => 'Hold until the customer\'s next invoice',
%locale_labels
},
'fields' => \@fields,
@@ -56,8 +54,8 @@ 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, type => 'hidden' },
+ { field => 'feepartmsgnum'. $n. '_locale', type => 'hidden', value => $_ },
{ field => 'feepartmsgnum'. $n. '_itemdesc', type => 'text', size => 40 },
;
$locale_labels{ 'feepartmsgnum'.$n.'_itemdesc' } =
@@ -65,6 +63,19 @@ foreach (@locales) {
$n++;
}
+$n = 0;
+my %layer_fields = (
+ 'charged' => [
+ 'percent' => { label => 'Fraction of invoice total', type => 'percentage', },
+ ],
+ 'owed' => [
+ 'percent' => { label => 'Fraction of balance', type => 'percentage', },
+ ],
+ 'usage' => [
+ 'usage' => { type => 'part_fee_usage' }
+ ],
+);
+
my @fields = (
{ field => 'itemdesc', type => 'text', size => 40, },
@@ -87,11 +98,6 @@ my @fields = (
value => 'Y',
},
- { field => 'nextbill',
- type => 'checkbox',
- value => 'Y',
- },
-
{ field => 'setuprecur',
type => 'select',
options => [ 'setup', 'recur' ],
@@ -101,15 +107,23 @@ my @fields = (
{ 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', },
+ type => 'selectlayers',
+ options => [ 'charged', 'owed', 'usage' ],
+ labels => { 'charged' => 'amount charged',
+ 'owed' => 'balance due',
+ 'usage' => 'usage charges' },
+ layer_fields => \%layer_fields,
+ layer_values_callback => sub {
+ my ($cgi, $obj) = @_;
+ {
+ 'charged' => { percent => $obj->percent },
+ 'owed' => { percent => $obj->percent },
+ 'usage' => { usage => [ $obj->part_fee_usage ] },
+ }
+ },
},
-
{ field => 'minimum', type => 'money', },
{ field => 'maximum', type => 'money', },
{ field => 'limit_credit',
diff --git a/httemplate/edit/process/part_fee.html b/httemplate/edit/process/part_fee.html
index 25656e9..6d17863 100755
--- a/httemplate/edit/process/part_fee.html
+++ b/httemplate/edit/process/part_fee.html
@@ -4,10 +4,19 @@
'agent_virt' => 1,
'agent_null_right' => 'Edit global fee definitions',
'viewall_dir' => 'browse',
- 'process_o2m' => {
- 'table' => 'part_fee_msgcat',
- 'fields' => [ 'locale', 'itemdesc' ],
- },
+ 'process_o2m' => [
+ {
+ 'table' => 'part_fee_msgcat',
+ 'fields' => [ 'locale', 'itemdesc' ],
+ },
+ {
+ 'table' => 'part_fee_usage',
+ 'fields' => [ 'classnum',
+ 'amount',
+ 'percent'
+ ],
+ },
+ ],
&>
<%init>
diff --git a/httemplate/elements/tr-part_fee_usage.html b/httemplate/elements/tr-part_fee_usage.html
new file mode 100644
index 0000000..00f4e12
--- /dev/null
+++ b/httemplate/elements/tr-part_fee_usage.html
@@ -0,0 +1,29 @@
+% my $n = 0;
+% foreach my $class (@classes) {
+% my $pre = "feepartusagenum$n";
+% my $x = $part_fee_usage{$class->classnum} || FS::part_fee_usage->new({});
+<tr>
+ <td align="right">
+ <input type="hidden" name="<%$pre%>" value="<% $x->partfeeusagenum %>">
+ <input type="hidden" name="<%$pre%>_classnum" value="<% $class->classnum %>">
+ <% $class->classname %>:</td>
+ <td>
+ <%$money_char%><input size=4 name="<%$pre%>_amount" \
+ value="<% sprintf("%.2f", $x->amount) %>">
+ </td>
+ <td>per call<b> + </b></td>
+ <td>
+ <input size=4 name="<%$pre%>_percent" \
+ value="<% sprintf("%.1f", $x->percent) %>">%
+ </td>
+</tr>
+% $n++;
+% }
+<%init>
+my %opt = @_;
+my $value = $opt{'curr_value'} || $opt{'value'};
+# values is an arrayref of part_fee_usage objects
+my %part_fee_usage = map { $_->classnum => $_ } @$value;
+my @classes = qsearch('usage_class', { disabled => '' });
+my $money_char = FS::Conf->new->config('money_char') || '$';
+</%init>