From 92b6628c08e4478e48b6f250320a3e3e93262ec2 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Tue, 31 Mar 2015 11:53:29 -0700 Subject: [PATCH] more flexible package suspend/unsuspend fees, #26828 --- FS/FS/FeeOrigin_Mixin.pm | 135 +++++++++++++++++++++++++ FS/FS/Mason.pm | 1 + FS/FS/Schema.pm | 26 +++++ FS/FS/cust_bill_pkg.pm | 9 +- FS/FS/cust_event_fee.pm | 57 ++++++++--- 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 + FS/t/cust_pkg_reason_fee.t | 5 + 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, 659 insertions(+), 143 deletions(-) create mode 100644 FS/FS/FeeOrigin_Mixin.pm create mode 100644 FS/FS/cust_pkg_reason_fee.pm create mode 100644 FS/t/cust_pkg_reason_fee.t create mode 100644 httemplate/misc/xmlhttp-reason-hint.html diff --git a/FS/FS/FeeOrigin_Mixin.pm b/FS/FS/FeeOrigin_Mixin.pm new file mode 100644 index 000000000..4eaf9b8d0 --- /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 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). + +=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 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 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 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; 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, L, L, +L + +=cut + +1; + diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 2cabf851d..8f7f73916 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -400,6 +400,7 @@ if ( -e $addl_handler_use_file ) { use FS::cust_contact; 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 a048d3e24..fc56d901b 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -2851,6 +2851,29 @@ sub tables_hashref { ], }, + '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' ] ], + 'foreign_keys' => [ + { columns => [ 'pkgreasonnum' ], + table => 'cust_pkg_reason', + references => [ 'num' ], + }, + { columns => [ 'feepart' ], + table => 'part_fee', + }, + # can't link billpkgnum, because of voids + ], + }, + 'cust_pkg_discount' => { 'columns' => [ 'pkgdiscountnum', 'serial', '', '', '', '', @@ -5984,6 +6007,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 7257a9bb8..aa25f8c4b 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -295,13 +295,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 e88dcc4b7..375a533e9 100644 --- a/FS/FS/cust_event_fee.pm +++ b/FS/FS/cust_event_fee.pm @@ -1,7 +1,7 @@ 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 ); =head1 NAME @@ -27,8 +27,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) to the resulting invoice line item (an -L object). FS::cust_event_fee inherits from FS::Record. -The following fields are currently supported: +L object). FS::cust_event_fee inherits from FS::Record +and FS::FeeOrigin_Mixin. The following fields are currently supported: =over 4 @@ -85,9 +85,6 @@ and replace methods. =cut -# the check method should currently be supplied - FS::Record contains some -# data checking routines - sub check { my $self = shift; @@ -109,18 +106,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. 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 = @_; @@ -167,13 +160,45 @@ sub by_cust { }) } - +=item cust_bill + +See L. This version simply returns the event +object if the event is an invoice event. + +=cut + +sub cust_bill { + my $self = shift; + my $object = $self->cust_event->cust_X; + if ( $object->isa('FS::cust_bill') ) { + return $object; + } else { + return ''; + } +} + +=item cust_pkg + +See L. 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 ''; + } +} =head1 BUGS =head1 SEE ALSO -L, L, L +L, L, L =cut diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 87499a91a..9bfab96ef 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -21,7 +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::FeeOrigin_Mixin; use FS::Log; use FS::TaxEngine; @@ -601,17 +601,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 @@ -620,38 +620,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. @@ -662,10 +635,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 0a1d0020e..4def528b8 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -1334,6 +1334,7 @@ sub suspend { if $error; } + my $cust_pkg_reason; if ( $options{'reason'} ) { $error = $self->insert_reason( 'reason' => $options{'reason'}, 'action' => $date ? 'adjourn' : 'suspend', @@ -1344,6 +1345,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 @@ -1424,6 +1430,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'} ) { @@ -1721,23 +1748,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 000000000..e5cc82910 --- /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 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). + +=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. + +=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, L, L + +=cut + +1; + diff --git a/FS/FS/reason.pm b/FS/FS/reason.pm index 9c34dd98a..6f4bf62d9 100644 --- a/FS/FS/reason.pm +++ b/FS/FS/reason.pm @@ -50,7 +50,7 @@ FS::Record. The following fields are currently supported: L) 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($_ => ''); } } @@ -192,7 +205,6 @@ sub new_or_existing { $reason; } - =head1 BUGS =head1 SEE ALSO diff --git a/FS/MANIFEST b/FS/MANIFEST index ca532ee0c..575184ced 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -842,3 +842,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 000000000..96cb79a7a --- /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 5bb6a3e0c..8af88a950 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!! . encode_entities($text) . "". - ""; - 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!! . encode_entities($text) . " ". + "$detail"; $text .= ''; } ; diff --git a/httemplate/edit/reason.html b/httemplate/edit/reason.html index 3e6645ec8..30168d551 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 356597553..125874694 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' ], +&> + +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 + +<%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, + 'Unsuspend pkg #'.$reason->unsuspend_pkgpart. + ' not found.'; + } + } + # 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"; +} + +<% join('
', @hints) %> -- 2.11.0