From: Mark Wells Date: Thu, 27 Feb 2014 21:06:56 +0000 (-0800) Subject: package fees and usage-based fees, #27687, #25899 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=e1c6b4af716fecad943bf282b50c0d459b986720 package fees and usage-based fees, #27687, #25899 --- diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index f160d3ffb..e4e41173e 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -356,6 +356,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 1e060c7d0..b8321618e 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -2195,6 +2195,18 @@ sub tables_hashref { 'index' => [], }, + 'part_fee_usage' => { + 'columns' => [ + 'feepartusagenum','serial', '', '', '', '', + 'feepart', 'int', '', '', '', '', + 'classnum', 'int', '', '', '', '', + 'amount', @money_type, '', '', + 'percent', 'decimal', '', '7,4', '', '', + ], + 'primary_key' => 'feepartusagenum', + 'unique' => [ [ 'feepart', 'classnum' ] ], + 'index' => [], + }, 'part_pkg_link' => { 'columns' => [ diff --git a/FS/FS/cust_event_fee.pm b/FS/FS/cust_event_fee.pm index 29d7c5c1f..ad78b0e14 100644 --- a/FS/FS/cust_event_fee.pm +++ b/FS/FS/cust_event_fee.pm @@ -114,7 +114,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'. @@ -147,6 +148,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 5ce94fe1c..a15c990c9 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 000000000..7e409a556 --- /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 cd895ebc7..fe6325040 100644 --- a/FS/FS/part_fee.pm +++ b/FS/FS/part_fee.pm @@ -7,6 +7,7 @@ use FS::Record qw( qsearch qsearchs ); use FS::pkg_class; use FS::part_pkg_taxproduct; use FS::agent; +use FS::part_fee_usage; $DEBUG = 0; @@ -149,23 +150,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; } @@ -181,7 +179,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); } @@ -196,7 +194,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 ) { @@ -247,37 +252,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; + + # $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. + # 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; @@ -368,25 +404,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, }); } @@ -483,6 +519,11 @@ sub part_fee_msgcat { qsearch( 'part_fee_msgcat', { feepart => $self->feepart } ); } +sub part_fee_usage { + my $self = shift; + qsearch( 'part_fee_usage', { feepart => $self->feepart } ); +} + =back =head1 BUGS diff --git a/FS/FS/part_fee_usage.pm b/FS/FS/part_fee_usage.pm new file mode 100644 index 000000000..701c8c5f9 --- /dev/null +++ b/FS/FS/part_fee_usage.pm @@ -0,0 +1,144 @@ +package FS::part_fee_usage; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); +use FS::part_fee; +use FS::usage_class; +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) that applies to a specific telephone usage class +(L). 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 + +=item classnum - foreign key to L + +=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; +} + +# stubs, remove under 4.x + +sub part_fee { + my $self = shift; + FS::part_fee->by_key($self->feepart); +} + +sub usage_class { + my $self = shift; + FS::usage_class->by_key($self->classnum); +} + +=back + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index 1cc68cceb..c453f872f 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -726,3 +726,5 @@ t/cust_bill_pkg_fee.t FS/part_fee_msgcat.pm t/part_fee_msgcat.t +FS/part_fee_usage.pm +t/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 000000000..cb7fb220e --- /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 dada23360..e057a752d 100644 --- a/httemplate/edit/part_fee.html +++ b/httemplate/edit/part_fee.html @@ -14,7 +14,6 @@ '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', @@ -55,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' } = @@ -64,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, }, @@ -95,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 25656e9b0..6d1786300 100644 --- 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 000000000..00f4e122a --- /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({}); + + + + + <% $class->classname %>: + + <%$money_char%>amount) %>"> + + per call + + + percent) %>">% + + +% $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') || '$'; +