summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorivan <ivan>2011-11-14 04:31:31 +0000
committerivan <ivan>2011-11-14 04:31:31 +0000
commit3db061f538c06804bd0a52b9ef8be3dc6b6db82e (patch)
tree7335808809768c4dcc6c8c85aebfa3ec365398ea
parent3892b13ea137969a2b4f880883960e58003cc1b3 (diff)
rate tiers for vnes, RT#14903
-rw-r--r--FS/FS.pm4
-rw-r--r--FS/FS/Schema.pm53
-rw-r--r--FS/FS/cdr.pm30
-rw-r--r--FS/FS/cust_bill_pkg.pm23
-rw-r--r--FS/FS/part_pkg/voip_inbound.pm55
-rw-r--r--FS/FS/part_pkg/voip_tiered.pm258
-rw-r--r--FS/FS/rate_tier.pm153
-rw-r--r--FS/FS/rate_tier_detail.pm139
-rw-r--r--FS/MANIFEST4
-rw-r--r--FS/t/rate_tier.t5
-rw-r--r--FS/t/rate_tier_detail.t5
-rw-r--r--httemplate/browse/rate_tier.html53
-rw-r--r--httemplate/edit/elements/edit.html2
-rw-r--r--httemplate/edit/process/rate_tier.html15
-rw-r--r--httemplate/edit/rate_tier.html54
-rw-r--r--httemplate/elements/menu.html4
-rw-r--r--httemplate/elements/rate_tier_detail.html66
-rw-r--r--httemplate/elements/tr-rate_tier_detail.html24
18 files changed, 893 insertions, 54 deletions
diff --git a/FS/FS.pm b/FS/FS.pm
index 8645c56..e8f2cdc 100644
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -250,6 +250,10 @@ L<FS::rate_prefix> - Rate region prefixes for call billing
L<FS::rate_detail> - Rate plan detail for call billing
+L<FS::rate_tier> - Rate tiers for call billing
+
+L<FS::rate_tier_details> - Rater tier details for call billing
+
L<FS::usage_class> - Usage class class
L<FS::agent> - Agent (reseller) class
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index fcdb654..50b8b6d 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -2685,6 +2685,30 @@ sub tables_hashref {
'index' => [],
},
+ #not really part of the above rate_ stuff (used with flat rate rather than
+ # rated billing), but could be eventually, and its a rate
+ 'rate_tier' => {
+ 'columns' => [
+ 'tiernum', 'serial', '', '', '', '',
+ 'tiername', 'varchar', '', $char_d, '', '',
+ ],
+ 'primary_key' => 'tiernum',
+ 'unique' => [ [ 'tiername'], ],
+ 'index' => [],
+ },
+
+ 'rate_tier_detail' => {
+ 'columns' => [
+ 'tierdetailnum', 'serial', '', '', '', '',
+ 'tiernum', 'int', '', '', '', '',
+ 'min_quan', 'int', '', '', '', '',
+ 'min_charge', 'decimal', '', '10,4', '', '',
+ ],
+ 'primary_key' => 'tierdetailnum',
+ 'unique' => [],
+ 'index' => [ ['tiernum'], ],
+ },
+
'usage_class' => {
'columns' => [
'classnum', 'serial', '', '', '', '',
@@ -2872,24 +2896,17 @@ sub tables_hashref {
'max_callers', 'int', 'NULL', '', '', '',
###
- # fields for unitel/RSLCOM/convergent that don't map well to asterisk
- # defaults
- # though these are now used elsewhere:
+ # old fields for unitel/RSLCOM/convergent that don't map to asterisk
+ # ones we adoped moved to "own fields" section below
# charged_party, upstream_price, rated_price, carrierid, cdrtypenum
###
- #cdr_type: Usage = 1, S&E = 7, OC&C = 8
- 'cdrtypenum', 'int', 'NULL', '', '', '',
-
- 'charged_party', 'varchar', 'NULL', $char_d, '', '',
-
'upstream_currency', 'char', 'NULL', 3, '', '',
'upstream_price', 'decimal', 'NULL', '10,4', '', '',
'upstream_rateplanid', 'int', 'NULL', '', '', '', #?
# how it was rated internally...
'ratedetailnum', 'int', 'NULL', '', '', '',
- 'rated_price', 'decimal', 'NULL', '10,4', '', '',
'distance', 'decimal', 'NULL', '', '', '',
'islocal', 'int', 'NULL', '', '', '', # '', '', 0, '' instead?
@@ -2900,16 +2917,24 @@ sub tables_hashref {
'description', 'varchar', 'NULL', $char_d, '', '',
'quantity', 'int', 'NULL', '', '', '',
- #cdr_carrier: Telstra =1, Optus = 2, RSL COM = 3
- 'carrierid', 'int', 'NULL', '', '', '',
-
'upstream_rateid', 'int', 'NULL', '', '', '',
###
#and now for our own fields
###
- # a svcnum... right..?
+ 'cdrtypenum', 'int', 'NULL', '', '', '',
+
+ 'charged_party', 'varchar', 'NULL', $char_d, '', '',
+
+ # how it was rated internally...
+ 'rated_price', 'decimal', 'NULL', '10,4', '', '',
+ 'rated_seconds', 'int', 'NULL', '', '', '',
+ 'rated_minutes', 'double precision', 'NULL', '', '', '',
+
+ 'carrierid', 'int', 'NULL', '', '', '',
+
+ # service it was matched to
'svcnum', 'int', 'NULL', '', '', '',
#NULL, done (or something)
@@ -2960,6 +2985,8 @@ sub tables_hashref {
'acctid', 'bigint', '', '', '', '',
'termpart', 'int', '', '', '', '',#future use see below
'rated_price', 'decimal', 'NULL', '10,4', '', '',
+ 'rated_seconds', 'int', 'NULL', '', '', '',
+ 'rated_minutes', 'double precision', 'NULL', '', '', '',
'status', 'varchar', 'NULL', 32, '', '',
'svcnum', 'int', 'NULL', '', '', '',
],
diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm
index 36721a8..850f797 100644
--- a/FS/FS/cdr.pm
+++ b/FS/FS/cdr.pm
@@ -401,13 +401,15 @@ error, otherwise returns false.
sub set_status_and_rated_price {
my($self, $status, $rated_price, $svcnum, %opt) = @_;
- if($opt{'inbound'}) {
+
+ if ($opt{'inbound'}) {
+
my $term = qsearchs('cdr_termination', {
acctid => $self->acctid,
termpart => 1 # inbound
});
my $error;
- if($term) {
+ if ( $term ) {
warn "replacing existing cdr status (".$self->acctid.")\n" if $term;
$error = $term->delete;
return $error if $error;
@@ -419,13 +421,19 @@ sub set_status_and_rated_price {
status => $status,
svcnum => $svcnum,
});
+ $term->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds});
+ $term->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes});
return $term->insert;
- }
- else {
+
+ } else {
+
$self->freesidestatus($status);
$self->rated_price($rated_price);
+ $self->rated_seconds($opt{rated_seconds}) if exists($opt{rated_seconds});
+ $self->rated_minutes($opt{rated_minutes}) if exists($opt{rated_minutes});
$self->svcnum($svcnum) if $svcnum;
return $self->replace();
+
}
}
@@ -642,6 +650,20 @@ sub export_formats {
return %export_formats;
}
+=item downstream_csv OPTION => VALUE ...
+
+Options:
+
+format
+
+charge
+
+seconds
+
+granularity
+
+=cut
+
sub downstream_csv {
my( $self, %opt ) = @_;
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index ab9d350..adc09d7 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -831,11 +831,21 @@ sub usage {
if ( $self->get('details') ) {
@values =
- map { $_->[2] }
- grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
+ map { ref($_) eq 'HASH'
+ ? $_->{'amount'}
+ : $_->[2]
+ }
+ grep { ref($_) && ( defined($classnum)
+ ? $classnum eq ( ref($_) eq 'HASH'
+ ? $_->{'classnum'}
+ : $_->[3]
+ )
+ : 1
+ )
+ }
@{ $self->get('details') };
- }else{
+ } else {
my $hashref = { 'billpkgnum' => $self->billpkgnum };
$hashref->{ 'classnum' } = $classnum if defined($classnum);
@@ -863,11 +873,14 @@ sub usage_classes {
my %seen = ();
foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
- $seen{ $detail->[3] } = 1;
+ $seen{ ref($detail) eq 'HASH'
+ ? $detail->{'classnum'}
+ : $detail->[3]
+ } = 1;
}
keys %seen;
- }else{
+ } else {
map { $_->classnum }
qsearch({ table => 'cust_bill_pkg_detail',
diff --git a/FS/FS/part_pkg/voip_inbound.pm b/FS/FS/part_pkg/voip_inbound.pm
index 425d86b..7fb0a5d 100644
--- a/FS/FS/part_pkg/voip_inbound.pm
+++ b/FS/FS/part_pkg/voip_inbound.pm
@@ -1,16 +1,15 @@
package FS::part_pkg::voip_inbound;
+use base qw( FS::part_pkg::recur_Common );
use strict;
-use vars qw(@ISA $DEBUG %info);
+use vars qw($DEBUG %info);
use Date::Format;
use Tie::IxHash;
+use Text::CSV_XS;
use FS::Conf;
use FS::Record qw(qsearchs qsearch);
-use FS::part_pkg::recur_Common;
use FS::cdr;
-use FS::part_pkg::recur_Common;
-
-@ISA = qw(FS::part_pkg::recur_Common);
+use FS::rate_detail;
$DEBUG = 0;
@@ -56,10 +55,6 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'default' => '+1',
},
- 'disable_tollfree' => { 'name' => 'Disable automatic toll-free processing',
- 'type' => 'checkbox',
- },
-
'use_amaflags' => { 'name' => 'Only charge for CDRs where the amaflags field is set to "2" ("BILL"/"BILLING").',
'type' => 'checkbox',
},
@@ -148,7 +143,6 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
FS::part_pkg::prorate_Mixin::fieldorder,
qw( min_charge min_included sec_granularity
default_prefix
- disable_tollfree
use_amaflags
use_carrierid
use_cdrtypenum ignore_cdrtypenum
@@ -160,7 +154,7 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
bill_every_call
)
],
- 'weight' => 40,
+ 'weight' => 42,
);
sub price_info {
@@ -205,25 +199,21 @@ sub calc_usage {
my $spool_cdr = $cust_pkg->cust_main->spool_cdr;
- my $included_min = ($self->option('min_included')
- && $self->option('min_included') > 0)
- ? $self->option('min_included') : 0;
my $charges = 0;
# my $downstream_cdr = '';
- my $disable_tollfree = $self->option('disable_tollfree');
- my $ignore_unrateable = $self->option('ignore_unrateable', 'Hush!');
- my $use_duration = $self->option('use_duration');
-
- my $output_format = $self->option('output_format', 'Hush!') || 'default';
+ my $included_min = $self->option('min_included', 1) || 0;
+ my $use_duration = $self->option('use_duration');
+ my $output_format = $self->option('output_format', 1) || 'default';
+ my $granularity = length($self->option('sec_granularity'))
+ ? $self->option('sec_granularity')
+ : 60;
#for check_chargable, so we don't keep looking up options inside the loop
my %opt_cache = ();
- eval "use Text::CSV_XS;";
- die $@ if $@;
my $csv = new Text::CSV_XS;
foreach my $cust_svc (
@@ -232,19 +222,25 @@ sub calc_usage {
my $svc_phone = $cust_svc->svc_x;
foreach my $cdr ( $svc_phone->get_cdrs(
- 'for_update' => 1,
- 'status' => '', # unprocessed only
- 'default_prefix' => $self->option('default_prefix'),
'inbound' => 1,
+ 'default_prefix' => $self->option('default_prefix'),
+ 'status' => '', # unprocessed only
+ 'for_update' => 1,
)
) {
+
+ my $reason = $self->check_chargable( $cdr,
+ 'option_cache' => \%opt_cache,
+ );
+ if ( $reason ) {
+ warn "not charging for CDR ($reason)\n" if $DEBUG;
+ next;
+ }
+
if ( $DEBUG > 1 ) {
warn "rating inbound CDR $cdr\n".
join('', map { " $_ => ". $cdr->{$_}. "\n" } keys %$cdr );
}
- my $granularity = length($self->option('sec_granularity'))
- ? $self->option('sec_granularity')
- : 60;
my $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
@@ -287,7 +283,7 @@ sub calc_usage {
detail => $call_details[0],
amount => $charge,
classnum => $cdr->calltypenum, #classnum
- phonenum => $self->phonenum,
+ #phonenum => $self->phonenum,
accountcode => $cdr->accountcode,
startdate => $cdr->startdate,
duration => $seconds,
@@ -348,8 +344,7 @@ sub check_chargable {
return "carrierid != $opt{'use_carrierid'}"
if length($opt{'use_carrierid'})
- && $cdr->carrierid ne $opt{'use_carrierid'} #ne otherwise 0 matches ''
- && ! $flags{'da_rewrote'};
+ && $cdr->carrierid ne $opt{'use_carrierid'}; #ne otherwise 0 matches ''
return "cdrtypenum != $opt{'use_cdrtypenum'}"
if length($opt{'use_cdrtypenum'})
diff --git a/FS/FS/part_pkg/voip_tiered.pm b/FS/FS/part_pkg/voip_tiered.pm
new file mode 100644
index 0000000..29e60d4
--- /dev/null
+++ b/FS/FS/part_pkg/voip_tiered.pm
@@ -0,0 +1,258 @@
+package FS::part_pkg::voip_tiered;
+use base qw( FS::part_pkg::voip_cdr );
+
+use strict;
+use vars qw( $DEBUG %info );
+use Tie::IxHash;
+use Date::Format;
+use Text::CSV_XS;
+use FS::Conf;
+use FS::Record qw(qsearchs); # qsearch);
+use FS::cdr;
+use FS::rate_tier;
+use FS::rate_detail;
+
+use Data::Dumper;
+
+$DEBUG = 0;
+
+tie my %cdr_inout, 'Tie::IxHash',
+ 'outbound' => 'Outbound',
+ 'inbound' => 'Inbound',
+ 'outbound_inbound' => 'Outbound and Inbound',
+;
+
+tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
+
+%info = (
+ 'name' => 'VoIP tiered rate pricing of CDRs',
+ 'shortname' => 'VoIP/telco CDR tiered rating',
+ 'inherit_fields' => [ 'voip_cdr', 'prorate_Mixin', 'global_Mixin' ],
+ 'fields' => {
+ 'tiernum' => { 'name' => 'Tier plan',
+ 'type' => 'select',
+ 'select_table' => 'rate_tier',
+ 'select_key' => 'tiernum',
+ 'select_label' => 'tiername',
+ },
+ 'cdr_inout' => { 'name'=> 'Call direction when using phone number matching',
+ 'type'=> 'select',
+ 'select_options' => \%cdr_inout,
+ },
+ 'min_included' => { 'name' => 'Minutes included',
+ },
+ 'sec_granularity' => { 'name' => 'Granularity',
+ 'type' => 'select',
+ 'select_options' => \%granularity,
+ },
+ 'rating_method' => { 'disabled' => 1 },
+ 'ratenum' => { 'disabled' => 1 },
+ 'intrastate_ratenum' => { 'disabled' => 1 },
+ 'min_charge' => { 'disabled' => 1 },
+ 'ignore_unrateable' => { 'disabled' => 1 },
+ 'domestic_prefix' => { 'disabled' => 1 },
+ 'international_prefix' => { 'disabled' => 1 },
+ 'disable_tollfree' => { 'disabled' => 1 },
+ 'noskip_src_length_accountcode_tollfree' => { 'disabled' => 1 },
+ 'accountcode_tollfree_ratenum' => { 'disabled' => 1 },
+ 'noskip_dst_length_accountcode_tollfree' => { 'disabled' => 1 },
+ },
+ 'fieldorder' => [qw(
+ recur_temporality
+ recur_method cutoff_day ),
+ FS::part_pkg::prorate_Mixin::fieldorder,
+ qw(
+ cdr_svc_method cdr_inout
+ tiernum
+ )
+ ],
+ 'weight' => 44,
+);
+
+sub calc_usage {
+ my $self = shift;
+ my($cust_pkg, $sdate, $details, $param ) = @_;
+
+ #my $last_bill = $cust_pkg->last_bill;
+ my $last_bill = $cust_pkg->get('last_bill'); #->last_bill falls back to setup
+
+ return 0
+ if $self->recur_temporality eq 'preceding'
+ && ( $last_bill eq '' || $last_bill == 0 );
+
+ my $included_min = $self->option('min_included', 1) || 0;
+ my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
+ my $cdr_inout = ($cdr_svc_method eq 'svc_phone.phonenum')
+ && $self->option('cdr_inout',1)
+ || 'outbound';
+ my $use_duration = $self->option('use_duration');
+ my $granularity = length($self->option('sec_granularity'))
+ ? $self->option('sec_granularity')
+ : 60;
+
+ #for check_chargable, so we don't keep looking up options inside the loop
+ my %opt_cache = ();
+
+ my($svc_table, $svc_field) = split('\.', $cdr_svc_method);
+
+ my %options = (
+ 'disable_src' => $self->option('disable_src'),
+ 'default_prefix' => $self->option('default_prefix'),
+ 'status' => '',
+ 'for_update' => 1,
+ ); # $last_bill, $$sdate )
+ $options{'by_svcnum'} = 1 if $svc_field eq 'svcnum';
+
+ ###
+ # pass one: find the total minutes/calls and store the CDRs
+ ###
+ my $total = 0;
+ my @cdrs = ();
+
+ my @cust_svc;
+ if( $self->option('bill_inactive_svcs',1) ) {
+ #XXX in this mode do we need to restrict the set of CDRs by date also?
+ @cust_svc = $cust_pkg->h_cust_svc($$sdate, $last_bill);
+ } else {
+ @cust_svc = $cust_pkg->cust_svc;
+ }
+ @cust_svc = grep { $_->part_svc->svcdb eq $svc_table } @cust_svc;
+
+ foreach my $cust_svc (@cust_svc) {
+
+ my $svc_x;
+ if( $self->option('bill_inactive_svcs',1) ) {
+ $svc_x = $cust_svc->h_svc_x($$sdate, $last_bill);
+ }
+ else {
+ $svc_x = $cust_svc->svc_x;
+ }
+
+ foreach my $pass (split('_', $cdr_inout)) {
+
+ $options{'inbound'} = ( $pass eq 'inbound' );
+
+ foreach my $cdr (
+ $svc_x->get_cdrs( %options )
+ ) {
+ if ( $DEBUG > 1 ) {
+ warn "rating CDR $cdr\n".
+ join('', map { " $_ => ". $cdr->{$_}. "\n" } keys %$cdr );
+ }
+
+ my $charge = '';
+ my $seconds = '';
+
+ $seconds = $use_duration ? $cdr->duration : $cdr->billsec;
+
+ $seconds += $granularity - ( $seconds % $granularity )
+ if $seconds # don't granular-ize 0 billsec calls (bills them)
+ && $granularity # 0 is per call
+ && $seconds % $granularity;
+ my $minutes = $granularity ? ($seconds / 60) : 1;
+
+ my $charge_min = $minutes;
+
+ $included_min -= $minutes;
+ if ( $included_min > 0 ) {
+ $charge_min = 0;
+ } else {
+ $charge_min = 0 - $included_min;
+ $included_min = 0;
+ }
+
+ $cdr->tmp_inout( $pass );
+ $cdr->tmp_rated_seconds( $seconds );
+ $cdr->tmp_rated_minutes( $charge_min );
+ $cdr->tmp_svcnum( $cust_svc->svcnum );
+ push @cdrs, $cdr;
+ $total += $charge_min;
+
+ } # $cdr
+
+ } # $pass
+
+ } # $cust_svc
+
+ ###
+ # pass two: find a tiered rate and do the rest
+ ###
+
+ my $rate_tier = qsearchs('rate_tier', { tiernum=>$self->option('tiernum') } )
+ or die "unknown tiernum ". $self->option('tiernum');
+ my $rate_tier_detail = $rate_tier->rate_tier_detail( $total )
+ or die "no base rate for tier? ($total)";
+ my $min_charge = $rate_tier_detail->min_charge;
+
+ my $output_format = $self->option('output_format', 'Hush!') || 'default';
+
+ my $csv = new Text::CSV_XS;
+
+ my $charges = 0;
+ my @invoice_details_sort;
+
+ foreach my $cdr (@cdrs) {
+
+ my $charge_min = $cdr->tmp_rated_minutes;
+
+ my $charge = sprintf('%.4f', ( $min_charge * $charge_min )
+ + 0.0000000001 ); #so 1.00005 rounds to 1.0001
+
+
+ if ( $charge > 0 ) {
+ $charges += $charge;
+
+ my $detail =
+ $cdr->downstream_csv( 'format' => $output_format,
+ 'charge' => $charge,
+ 'seconds' => ($use_duration ?
+ $cdr->duration :
+ $cdr->billsec),
+ 'granularity' => $granularity,
+ );
+
+ my $call_details =
+ { format => 'C',
+ detail => $detail,
+ amount => $charge,
+ #classnum => $cdr->calltypenum, #classnum
+ #phonenum => $phonenum, #XXX need this to sort on them
+ accountcode => $cdr->accountcode,
+ startdate => $cdr->startdate,
+ duration => $cdr->tmp_rated_seconds,
+ };
+
+ #warn " adding details on charge to invoice: [ ".
+ # join(', ', @{$call_details} ). " ]"
+ # if ( $DEBUG && ref($call_details) );
+ push @invoice_details_sort, [ $call_details, $cdr->calldate_unix ];
+ }
+
+ my $error = $cdr->set_status_and_rated_price(
+ 'done',
+ $charge,
+ $cdr->tmp_svcnum,
+ 'inbound' => ($cdr->tmp_inout eq 'inbound'),
+ 'rated_minutes' => $charge_min,
+ 'rated_seconds' => $cdr->tmp_rated_seconds,
+ );
+ die $error if $error;
+
+
+ }
+
+ my @sorted_invoice_details = sort { ${$a}[1] <=> ${$b}[1] } @invoice_details_sort;
+ foreach my $sorted_call_detail ( @sorted_invoice_details ) {
+ push @$details, ${$sorted_call_detail}[0];
+ }
+
+ unshift @$details, { format => 'C',
+ detail => FS::cdr::invoice_header($output_format),
+ }
+ if @$details;
+
+ $charges;
+}
+
+1;
+
diff --git a/FS/FS/rate_tier.pm b/FS/FS/rate_tier.pm
new file mode 100644
index 0000000..1ca457d
--- /dev/null
+++ b/FS/FS/rate_tier.pm
@@ -0,0 +1,153 @@
+package FS::rate_tier;
+use base qw( FS::o2m_Common FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+use FS::rate_tier_detail;
+
+=head1 NAME
+
+FS::rate_tier - Object methods for rate_tier records
+
+=head1 SYNOPSIS
+
+ use FS::rate_tier;
+
+ $record = new FS::rate_tier \%hash;
+ $record = new FS::rate_tier { 'column' => 'value' };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::rate_tier object represents a set of rate tiers. FS::rate_tier inherits
+ from FS::Record. The following fields are currently supported:
+
+=over 4
+
+=item tiernum
+
+primary key
+
+=item tiername
+
+tiername
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record. To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to. You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'rate_tier'; }
+
+=item insert
+
+Adds this record to the database. If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database. If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid record. If there is
+an error, returns the error, otherwise returns false. Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+ my $self = shift;
+
+ my $error =
+ $self->ut_numbern('tiernum')
+ || $self->ut_text('tiername')
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+=item rate_tier_detail QUANTITY
+
+=cut
+
+sub rate_tier_detail {
+ my $self = shift;
+
+ if ( defined($_[0]) && length($_[0]) ) {
+
+ my $quantity = shift;
+
+ qsearchs({
+ 'table' => 'rate_tier_detail',
+ 'hashref' => { 'tiernum' => $self->tiernum,
+ 'min_quan' => { op=>'<=', value=>$quantity },
+ },
+ 'order_by' => 'ORDER BY min_charge ASC LIMIT 1',
+ });
+
+ } else {
+
+ qsearch({
+ 'table' => 'rate_tier_detail',
+ 'hashref' => { 'tiernum' => $self->tiernum, },
+ 'order_by' => 'ORDER BY min_quan ASC',
+ });
+
+ }
+
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/rate_tier_detail.pm b/FS/FS/rate_tier_detail.pm
new file mode 100644
index 0000000..60896f4
--- /dev/null
+++ b/FS/FS/rate_tier_detail.pm
@@ -0,0 +1,139 @@
+package FS::rate_tier_detail;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record; # qw( qsearch qsearchs );
+use FS::rate_tier;
+
+=head1 NAME
+
+FS::rate_tier_detail - Object methods for rate_tier_detail records
+
+=head1 SYNOPSIS
+
+ use FS::rate_tier_detail;
+
+ $record = new FS::rate_tier_detail \%hash;
+ $record = new FS::rate_tier_detail { 'column' => 'value' };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::rate_tier_detail object represents rate tier pricing.
+FS::rate_tier_detail inherits from FS::Record. The following fields are
+currently supported:
+
+=over 4
+
+=item tierdetailnum
+
+primary key
+
+=item tiernum
+
+tiernum
+
+=item min_quan
+
+min_quan
+
+=item min_charge
+
+min_charge
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record. To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to. You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'rate_tier_detail'; }
+
+=item insert
+
+Adds this record to the database. If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database. If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid record. If there is
+an error, returns the error, otherwise returns false. Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+ my $self = shift;
+
+ my $min_quan = $self->min_quan;
+ $min_quan =~ s/[ ,]//g;
+ $self->min_quan($min_quan);
+
+ $self->min_quan(0) if $self->min_quan eq '';
+
+ my $error =
+ $self->ut_numbern('tierdetailnum')
+ || $self->ut_foreign_key('tiernum', 'rate_tier', 'tiernum')
+ || $self->ut_number('min_quan')
+ || $self->ut_textn('min_charge') #XXX money? but we use 4 decimal places
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 37f1a58..e983ea2 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -616,3 +616,7 @@ FS/export_nas.pm
t/export_nas.t
FS/legacy_cust_bill.pm
t/legacy_cust_bill.t
+FS/rate_tier.pm
+t/rate_tier.t
+FS/rate_tier_detail.pm
+t/rate_tier_detail.t
diff --git a/FS/t/rate_tier.t b/FS/t/rate_tier.t
new file mode 100644
index 0000000..d735bdb
--- /dev/null
+++ b/FS/t/rate_tier.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::rate_tier;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/rate_tier_detail.t b/FS/t/rate_tier_detail.t
new file mode 100644
index 0000000..eccd676
--- /dev/null
+++ b/FS/t/rate_tier_detail.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::rate_tier_detail;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/browse/rate_tier.html b/httemplate/browse/rate_tier.html
new file mode 100644
index 0000000..d840808
--- /dev/null
+++ b/httemplate/browse/rate_tier.html
@@ -0,0 +1,53 @@
+<% include( 'elements/browse.html',
+ 'title' => 'Tiering plans',
+ 'name_singular' => 'tiering plan',
+ 'menubar' => [ 'Add a new tier plan' =>
+ $p.'edit/rate_tier.html',
+ ],
+ 'query' => { 'table' => 'rate_tier', },
+ 'count_query' => 'SELECT COUNT(*) FROM rate_tier',
+ 'header' => [ 'Plan', 'Tiers', ],
+ 'fields' => [ 'tiername',
+ $details_sub,
+ ],
+ 'links' => [ $link, ],
+ #'disableable' => 1,
+ #'disabled_statuspos' => 1,
+ )
+%>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $link = [ "${p}edit/rate_tier.html?", 'tiernum' ];
+
+my $details_sub = sub {
+ my $rate_tier = shift;
+
+ [ [ { 'data' => 'Minimum quantity',
+ 'align' => 'center',
+ },
+ { 'data' => 'Charge per minute/call',
+ 'align' => 'center',
+ },
+ ],
+ map { my $rate_tier_detail = $_;
+ [
+ { 'data' => $rate_tier_detail->min_quan,
+ 'align' => 'right',
+ },
+ { 'data' => $money_char. $rate_tier_detail->min_charge,
+ 'align' => 'right',
+ },
+ ];
+ }
+ $rate_tier->rate_tier_detail
+ ];
+
+};
+
+</%init>
diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html
index 6db54fd..4e896f2 100644
--- a/httemplate/edit/elements/edit.html
+++ b/httemplate/edit/elements/edit.html
@@ -549,7 +549,7 @@ Example:
// only spawn if we're the last element... return if not
- var field_regex = /(\d+)(_[a-z]+)?$/;
+ var field_regex = /(\d+)(_[a-z_]+)?$/;
var match = field_regex.exec(what.name);
if ( !match ) {
alert(what.name + " didn't match for " + what);
diff --git a/httemplate/edit/process/rate_tier.html b/httemplate/edit/process/rate_tier.html
new file mode 100644
index 0000000..f29edbb
--- /dev/null
+++ b/httemplate/edit/process/rate_tier.html
@@ -0,0 +1,15 @@
+<% include( 'elements/process.html',
+ 'table' => 'rate_tier',
+ 'viewall_dir' => 'browse',
+ 'process_o2m' => {
+ 'table' => 'rate_tier_detail',
+ 'fields' => [qw( min_quan min_charge )],
+ },
+ )
+%>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
diff --git a/httemplate/edit/rate_tier.html b/httemplate/edit/rate_tier.html
new file mode 100644
index 0000000..f9df4b5
--- /dev/null
+++ b/httemplate/edit/rate_tier.html
@@ -0,0 +1,54 @@
+<% include( 'elements/edit.html',
+ 'table' => 'rate_tier',
+ 'name_singular' => 'tiering plan',
+ 'fields' => [
+ 'tiername',
+ #{ field=>'disabled', type=>'checkbox', value=>'Y' },
+ { 'field' => 'tierdetailnum',
+ 'type' => 'rate_tier_detail',
+ 'colspan' => 2,
+ 'o2m_table' => 'rate_tier_detail',
+ 'm2_label' => 'Tier',
+ 'm2_error_callback' => $m2_error_callback,
+ },
+ ],
+ 'labels' => { 'tiernum' => 'Plan #',
+ 'tiername' => 'Tiering plan',
+ 'tierdetailnum' => 'Tier',
+ },
+ 'viewall_dir' => 'browse',
+ )
+%>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $m2_error_callback = sub {
+ my($cgi, $object) = @_;
+
+ #process_o2m fields in process/rate_tier.html
+ my @fields = qw( min_quan min_charge );
+ my @gfields = ( '', map "_$_", @fields );
+
+ map {
+ if ( /^tierdetailnum(\d+)$/ ) {
+ my $num = $1;
+ if ( grep $cgi->param("tierdetailnum$num$_"), @gfields ) {
+ my $x = new FS::rate_tier_detail {
+ 'tierdetailnum' => scalar($cgi->param("tierdetailnum$num")),
+ map { $_ => scalar($cgi->param("tierdetailnum${num}_$_")) }
+ @fields,
+ };
+ $x;
+ } else {
+ ();
+ }
+ } else {
+ ();
+ }
+ }
+ $cgi->param;
+};
+
+</%init>
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index b29b005..48270da 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -25,7 +25,7 @@
% 'style' => 'color:#999999',
% 'actionlabel' => emt('About'),
% 'width' => 300,
-% 'height' => 360,
+% 'height' => 375,
% 'color' => '#7e0079',
% 'scrolling' => 'no',
% );
@@ -503,6 +503,8 @@ tie my %config_billing_rates, 'Tie::IxHash',
'Usage classes' => [ $fsurl.'browse/usage_class.html', 'Usage classes define groups of usage for taxation.' ],
'Time periods' => [ $fsurl.'browse/rate_time.html', 'Time periods define days and hours for rate plans' ],
'Edit rates with Excel' => [ $fsurl.'misc/rate_edit_excel.html', 'Download and edit rates with Excel, then upload changes.' ], #"Edit with Excel" ?
+ 'separator' => '', #its a separator!
+ 'Tiering plans' => [ $fsurl.'browse/rate_tier.html', 'Rating tiers' ],
;
tie my %config_billing, 'Tie::IxHash';
diff --git a/httemplate/elements/rate_tier_detail.html b/httemplate/elements/rate_tier_detail.html
new file mode 100644
index 0000000..ef1f38b
--- /dev/null
+++ b/httemplate/elements/rate_tier_detail.html
@@ -0,0 +1,66 @@
+% unless ( $opt{'js_only'} ) {
+
+ <INPUT TYPE="hidden" NAME="<%$name%>" ID="<%$id%>" VALUE="<% $curr_value %>">
+
+ <TABLE>
+ <TR>
+% foreach my $field ( @fields ) {
+%
+% my $value = $rate_tier_detail->get($field);
+
+ <TD>
+ <% $field eq 'min_charge' ? $money_char : '' %>
+ <INPUT TYPE = "text"
+ NAME = "<%$name%>_<%$field%>"
+ ID = "<%$id%>_<%$field%>"
+ SIZE = "<% $size{$field} || 15 %>"
+ STYLE = "text-align:right"
+ VALUE = "<% scalar($cgi->param($name."_$field"))
+ || $value |h %>"
+ <% $onchange %>
+ ><BR>
+ <FONT SIZE="-1"><% $label{$field} %></FONT>
+ </TD>
+% }
+ </TR>
+ </TABLE>
+
+% }
+<%init>
+
+my( %opt ) = @_;
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $name = $opt{'element_name'} || $opt{'field'} || 'tierdetailnum';
+my $id = $opt{'id'} || 'tierdetailnum';
+
+my $curr_value = $opt{'curr_value'} || $opt{'value'};
+
+my $onchange = '';
+if ( $opt{'onchange'} ) {
+ $onchange = $opt{'onchange'};
+ $onchange .= '(this)' unless $onchange =~ /\(\w*\);?$/;
+ $onchange =~ s/\(what\);/\(this\);/g; #ugh, terrible hack. all onchange
+ #callbacks should act the same
+ $onchange = 'onChange="'. $onchange. '"';
+}
+
+my $rate_tier_detail;
+if ( $curr_value ) {
+ $rate_tier_detail = qsearchs('rate_tier_detail', { 'tierdetailnum' => $curr_value } );
+} else {
+ $rate_tier_detail = new FS::rate_tier_detail {};
+}
+
+my %size = ( 'title' => 12 );
+
+tie my %label, 'Tie::IxHash',
+ 'min_quan' => 'Minimum quantity',
+ 'min_charge' => 'Charge per minute/call',
+;
+
+my @fields = keys %label;
+
+</%init>
diff --git a/httemplate/elements/tr-rate_tier_detail.html b/httemplate/elements/tr-rate_tier_detail.html
new file mode 100644
index 0000000..7b6f26b
--- /dev/null
+++ b/httemplate/elements/tr-rate_tier_detail.html
@@ -0,0 +1,24 @@
+% unless ( $opt{'js_only'} ) {
+
+ <% include('tr-td-label.html', %opt) %>
+ <TD <% $cell_style %>>
+
+% }
+%
+ <% include( '/elements/rate_tier_detail.html', %opt ) %>
+%
+% unless ( $opt{'js_only'} ) {
+
+ </TD>
+ </TR>
+
+% }
+<%init>
+
+my( %opt ) = @_;
+
+my $cell_style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : '';
+
+$opt{'label'} ||= 'Tier';
+
+</%init>