rate tiers for vnes, RT#14903
authorivan <ivan>
Mon, 14 Nov 2011 04:31:31 +0000 (04:31 +0000)
committerivan <ivan>
Mon, 14 Nov 2011 04:31:31 +0000 (04:31 +0000)
18 files changed:
FS/FS.pm
FS/FS/Schema.pm
FS/FS/cdr.pm
FS/FS/cust_bill_pkg.pm
FS/FS/part_pkg/voip_inbound.pm
FS/FS/part_pkg/voip_tiered.pm [new file with mode: 0644]
FS/FS/rate_tier.pm [new file with mode: 0644]
FS/FS/rate_tier_detail.pm [new file with mode: 0644]
FS/MANIFEST
FS/t/rate_tier.t [new file with mode: 0644]
FS/t/rate_tier_detail.t [new file with mode: 0644]
httemplate/browse/rate_tier.html [new file with mode: 0644]
httemplate/edit/elements/edit.html
httemplate/edit/process/rate_tier.html [new file with mode: 0644]
httemplate/edit/rate_tier.html [new file with mode: 0644]
httemplate/elements/menu.html
httemplate/elements/rate_tier_detail.html [new file with mode: 0644]
httemplate/elements/tr-rate_tier_detail.html [new file with mode: 0644]

index 8645c56..e8f2cdc 100644 (file)
--- 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
index fcdb654..50b8b6d 100644 (file)
@@ -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',      '', '', '',
       ],
index 36721a8..850f797 100644 (file)
@@ -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 ) = @_;
 
index ab9d350..adc09d7 100644 (file)
@@ -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',
index 425d86b..7fb0a5d 100644 (file)
@@ -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 (file)
index 0000000..29e60d4
--- /dev/null
@@ -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 (file)
index 0000000..1ca457d
--- /dev/null
@@ -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 (file)
index 0000000..60896f4
--- /dev/null
@@ -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;
+
index 37f1a58..e983ea2 100644 (file)
@@ -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 (file)
index 0000000..d735bdb
--- /dev/null
@@ -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 (file)
index 0000000..eccd676
--- /dev/null
@@ -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 (file)
index 0000000..d840808
--- /dev/null
@@ -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>
index 6db54fd..4e896f2 100644 (file)
@@ -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 (file)
index 0000000..f29edbb
--- /dev/null
@@ -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 (file)
index 0000000..f9df4b5
--- /dev/null
@@ -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>
index b29b005..48270da 100644 (file)
@@ -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 (file)
index 0000000..ef1f38b
--- /dev/null
@@ -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 (file)
index 0000000..7b6f26b
--- /dev/null
@@ -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>