Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Thu, 4 Aug 2016 00:52:34 +0000 (17:52 -0700)
committerIvan Kohler <ivan@freeside.biz>
Thu, 4 Aug 2016 00:52:34 +0000 (17:52 -0700)
119 files changed:
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI_XMLRPC.pm
FS/FS/Conf.pm
FS/FS/Log.pm
FS/FS/Mason.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/TaxEngine/internal.pm
FS/FS/Upgrade.pm
FS/FS/access_right.pm
FS/FS/commission_rate.pm [new file with mode: 0644]
FS/FS/commission_schedule.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_tax_location.pm
FS/FS/cust_credit.pm
FS/FS/cust_location.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_main_county.pm
FS/FS/cust_pay_pending.pm
FS/FS/cust_pkg_reason.pm
FS/FS/log.pm
FS/FS/log_context.pm
FS/FS/msg_template.pm
FS/FS/part_event/Action/bill_agent_credit_schedule.pm [new file with mode: 0644]
FS/FS/payinfo_Mixin.pm
FS/FS/svc_Common.pm
FS/MANIFEST
FS/bin/freeside-cdrrewrited
FS/t/commission_rate.t [new file with mode: 0644]
FS/t/commission_schedule.t [new file with mode: 0644]
bin/xmlrpc-insert_payby [new file with mode: 0755]
bin/xmlrpc-update_payby [new file with mode: 0755]
debian/control
fs_selfservice/FS-SelfService/SelfService.pm
httemplate/browse/commission_schedule.html [new file with mode: 0644]
httemplate/browse/log_email.html
httemplate/edit/commission_schedule.html [new file with mode: 0644]
httemplate/edit/cust_main.cgi
httemplate/edit/cust_pay_pending.html
httemplate/edit/elements/edit.html
httemplate/edit/log_email.html
httemplate/edit/part_event.html
httemplate/edit/process/bulk-cust_main_county.html
httemplate/edit/process/bulk-cust_svc-pkgnum.html
httemplate/edit/process/cgp_rule-simplified.html
httemplate/edit/process/change-cust_pkg.html
httemplate/edit/process/commission_schedule.html [new file with mode: 0644]
httemplate/edit/process/credit-cust_bill_pkg.html
httemplate/edit/process/cust_credit-pkgnum.html
httemplate/edit/process/cust_credit.cgi
httemplate/edit/process/cust_location-censustract.html
httemplate/edit/process/cust_location.cgi
httemplate/edit/process/cust_main.cgi
httemplate/edit/process/cust_main_attach.cgi
httemplate/edit/process/cust_main_county-add.cgi
httemplate/edit/process/cust_main_county-expand.cgi
httemplate/edit/process/cust_main_note.cgi
httemplate/edit/process/cust_pay-no_auto_apply.cgi
httemplate/edit/process/cust_pay-pkgnum.html
httemplate/edit/process/cust_pay.cgi
httemplate/edit/process/cust_pay_pending.html
httemplate/edit/process/cust_pkg_detail.html
httemplate/edit/process/cust_pkg_discount.html
httemplate/edit/process/cust_pkg_quantity.html
httemplate/edit/process/cust_pkg_salesnum.html
httemplate/edit/process/cust_refund.cgi
httemplate/edit/process/cust_tax_adjustment.html
httemplate/edit/process/detach-cust_pkg.html
httemplate/edit/process/domain_record.cgi
httemplate/edit/process/elements/ApplicationCommon.html
httemplate/edit/process/elements/process.html
httemplate/edit/process/quick-charge.cgi
httemplate/edit/process/quotation_pkg_detail.html
httemplate/elements/commission_rate.html [new file with mode: 0644]
httemplate/elements/header-full.html
httemplate/elements/header-popup.html
httemplate/elements/menu.html
httemplate/elements/selectlayersx.html [new file with mode: 0644]
httemplate/elements/topreload.js [new file with mode: 0644]
httemplate/elements/tr-select-reason.html
httemplate/elements/tr-selectlayersx.html [new file with mode: 0644]
httemplate/misc/change_pkg_date.html [new file with mode: 0755]
httemplate/misc/change_pkg_start.html [deleted file]
httemplate/misc/cust_main-cancel.cgi
httemplate/misc/cust_main-suspend.cgi
httemplate/misc/cust_main-unsuspend.cgi
httemplate/misc/delete-addr_range.html
httemplate/misc/delete-rate_detail.html
httemplate/misc/did_order_confirmed.html
httemplate/misc/disable-cust_location.cgi
httemplate/misc/disable-msg_template.cgi
httemplate/misc/process/bulk_pkg_increment_bill.cgi
httemplate/misc/process/cancel_pkg.html
httemplate/misc/process/change_pkg_contact.html
httemplate/misc/process/change_pkg_date.html [new file with mode: 0755]
httemplate/misc/process/change_pkg_start.html [deleted file]
httemplate/misc/process/cust_bill-promised_date.html
httemplate/misc/process/delay_susp_pkg.html
httemplate/misc/process/enable_or_disable_tax.html
httemplate/misc/process/nms-add_iface.html
httemplate/misc/process/nms-add_router.html
httemplate/misc/process/recharge_svc.html
httemplate/misc/process/unhold_pkg.html
httemplate/misc/process/void-cust_bill.html
httemplate/misc/reason-merge.html
httemplate/misc/void-cust_credit.html
httemplate/search/cust_bill_pay_pkg.html
httemplate/search/cust_pay.html
httemplate/search/cust_pay_pending.html
httemplate/search/elements/cust_pay_or_refund.html
httemplate/search/elements/report_cust_pay_or_refund.html
httemplate/search/elements/search.html
httemplate/search/log.html
httemplate/search/report_cust_bill_pay_pkg.html
httemplate/search/report_sqlradius_usage-custnum.html [new file with mode: 0644]
httemplate/search/report_sqlradius_usage.html
httemplate/search/sqlradius_usage.html
httemplate/view/cust_main/menu.html
httemplate/view/cust_main/packages/status.html
httemplate/view/cust_main/payment_history/pending_payment.html

index d767e91..685821b 100644 (file)
@@ -1627,6 +1627,34 @@ sub insert_payby {
   
 }
 
+sub update_payby {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $cust_payby = qsearchs('cust_payby', {
+                              'custnum'      => $custnum,
+                              'custpaybynum' => $p->{'custpaybynum'},
+                           })
+    or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} };
+
+  foreach my $field (
+    qw( weight payby payinfo paycvv paydate payname paystate paytype payip )
+  ) {
+    next unless exists($p->{$field});
+    $cust_payby->set($field,$p->{$field});
+  }
+
+  my $error = $cust_payby->replace;
+  if ( $error ) {
+    return { 'error' => $error };
+  } else {
+    return { 'custpaybynum' => $cust_payby->custpaybynum };
+  }
+  
+}
+
 sub verify_payby {
   my $p = shift;
 
index 622f3df..08c6c2d 100644 (file)
@@ -129,6 +129,7 @@ sub ss2clientapi {
   'list_invoices'             => 'MyAccount/list_invoices', #?
   'list_payby'                => 'MyAccount/list_payby',
   'insert_payby'              => 'MyAccount/insert_payby',
+  'update_payby'              => 'MyAccount/update_payby',
   'delete_payby'              => 'MyAccount/delete_payby',
   'cancel'                    => 'MyAccount/cancel',        #add to ss cgi!
   'payment_info'              => 'MyAccount/payment_info',
index 94b8839..1b50006 100644 (file)
@@ -4950,6 +4950,13 @@ and customer address. Include units.',
 #  },
 
   {
+    'key'         => 'cdr-skip_duplicate_rewrite',
+    'section'     => 'telephony',
+    'description' => 'Use the freeside-cdrrewrited daemon to prevent billing CDRs with a src, dst and calldate identical to an existing CDR',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'cdr-charged_party_rewrite',
     'section'     => 'telephony',
     'description' => 'Do charged party rewriting in the freeside-cdrrewrited daemon; useful if CDRs are being dropped off directly in the database and require special charged_party processing such as cdr-charged_party-accountcode or cdr-charged_party-truncate*.',
index 2fd0020..aed1f39 100644 (file)
@@ -5,13 +5,20 @@ use FS::Record qw(qsearch qsearchs);
 use FS::Conf;
 use FS::Log::Output;
 use FS::log;
-use vars qw(@STACK @LEVELS);
+use vars qw(@STACK %LEVELS);
 
 # override the stringification of @_ with something more sensible.
 BEGIN {
-  @LEVELS = qw(debug info notice warning error critical alert emergency);
+  # subset of Log::Dispatch levels
+  %LEVELS = (
+    0 => 'debug',
+    1 => 'info',
+    3 => 'warning',
+    4 => 'error',
+    5 => 'critical'
+  );
 
-  foreach my $l (@LEVELS) {
+  foreach my $l (values %LEVELS) {
     my $sub = sub {
       my $self = shift;
       $self->log( level => $l, message => @_ );
@@ -100,4 +107,24 @@ sub DESTROY {
   splice(@STACK, $self->{'index'}, 1); # delete the stack entry
 }
 
+=item levelnums
+
+Subroutine.  Returns ordered list of level nums.
+
+=cut
+
+sub levelnums {
+  sort keys %LEVELS;
+}
+
+=item levelmap
+
+Subroutine.  Returns ordered map of level num => level name.
+
+=cut
+
+sub levelmap {
+  map { $_ => $LEVELS{$_} } levelnums;
+}
+
 1;
index 1008fd5..245bdea 100644 (file)
@@ -413,6 +413,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::olt_site;
   use FS::access_user_page_pref;
   use FS::part_svc_msgcat;
+  use FS::commission_schedule;
+  use FS::commission_rate;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index 7f76d99..c3d3973 100644 (file)
@@ -2,6 +2,7 @@ package FS::Record;
 use base qw( Exporter );
 
 use strict;
+use charnames ':full';
 use vars qw( $AUTOLOAD
              %virtual_fields_cache %fk_method_cache $fk_table_cache
              $money_char $lat_lower $lon_upper
@@ -2913,6 +2914,10 @@ sub ut_coord {
   my $coord = $self->getfield($field);
   my $neg = $coord =~ s/^(-)//;
 
+  # ignore degree symbol at the end,
+  #   but not otherwise supporting degree/minutes/seconds symbols
+  $coord =~ s/\N{DEGREE SIGN}\s*$//;
+
   my ($d, $m, $s) = (0, 0, 0);
 
   if (
@@ -3220,6 +3225,22 @@ sub ut_agentnum_acl {
 
 }
 
+=item trim_whitespace FIELD[, FIELD ... ]
+
+Strip leading and trailing spaces from the value in the named FIELD(s).
+
+=cut
+
+sub trim_whitespace {
+  my $self = shift;
+  foreach my $field (@_) {
+    my $value = $self->get($field);
+    $value =~ s/^\s+//;
+    $value =~ s/\s+$//;
+    $self->set($field, $value);
+  }
+}
+
 =item fields [ TABLE ]
 
 This is a wrapper for real_fields.  Code that called
index ac58510..8661c4b 100644 (file)
@@ -1361,6 +1361,7 @@ sub tables_hashref {
         'commission_agentnum', 'int', 'NULL', '', '', '', #
         'commission_salesnum', 'int', 'NULL', '', '', '', #
         'commission_pkgnum',   'int', 'NULL', '', '', '', #
+        'commission_invnum',   'int', 'NULL', '', '', '',
         'credbatch',    'varchar', 'NULL', $char_d, '', '',
       ],
       'primary_key'  => 'crednum',
@@ -1396,6 +1397,10 @@ sub tables_hashref {
                             table      => 'cust_pkg',
                             references => [ 'pkgnum' ],
                           },
+                          { columns    => [ 'commission_invnum' ],
+                            table      => 'cust_bill',
+                            references => [ 'invnum' ],
+                          },
                         ],
     },
 
@@ -1417,6 +1422,7 @@ sub tables_hashref {
         'commission_agentnum', 'int', 'NULL', '', '', '',
         'commission_salesnum', 'int', 'NULL', '', '', '',
         'commission_pkgnum',   'int', 'NULL', '', '', '',
+        'commission_invnum',   'int', 'NULL', '', '', '',
         #void fields
         'void_date',  @date_type,                  '', '', 
         'void_reason', 'varchar', 'NULL', $char_d, '', '', 
@@ -1456,6 +1462,10 @@ sub tables_hashref {
                             table      => 'cust_pkg',
                             references => [ 'pkgnum' ],
                           },
+                          { columns    => [ 'commission_invnum' ],
+                            table      => 'cust_bill',
+                            references => [ 'invnum' ],
+                          },
                           { columns    => [ 'void_reasonnum' ],
                             table      => 'reason',
                             references => [ 'reasonnum' ],
@@ -7438,6 +7448,36 @@ sub tables_hashref {
                         ],
     },
 
+    'commission_schedule' => {
+      'columns' => [
+        'schedulenum',    'serial',     '',      '', '', '',
+        'schedulename',  'varchar',     '', $char_d, '', '',
+        'reasonnum',         'int', 'NULL',      '', '', '',
+        'basis',         'varchar', 'NULL',      32, '', '',
+      ],
+      'primary_key'  => 'schedulenum',
+      'unique'       => [],
+      'index'        => [],
+    },
+
+    'commission_rate' => {
+      'columns' => [
+        'commissionratenum', 'serial',     '',      '', '', '',
+        'schedulenum',       'int',     '',      '', '', '',
+        'cycle',             'int',     '',      '', '', '',
+        'amount',            @money_type,          '', '', 
+        'percent',           'decimal','',   '7,4', '', '',
+      ],
+      'primary_key'  => 'commissionratenum',
+      'unique'       => [ [ 'schedulenum', 'cycle', ] ],
+      'index'        => [],
+      'foreign_keys' => [
+                          { columns => [ 'schedulenum' ],
+                            table   => 'commission_schedule',
+                          },
+                        ],
+    },
     # name type nullability length default local
 
     #'new_table' => {
index db7010c..3e3e7e5 100644 (file)
@@ -28,8 +28,10 @@ sub add_sale {
 
   push @{ $self->{items} }, $cust_bill_pkg;
 
-  my @loc_keys = qw( district city county state country );
-  my %taxhash = map { $_ => $location->get($_) } @loc_keys;
+  my %taxhash = map { $_ => $location->get($_) }
+                qw( district county state country );
+  # city names in cust_main_county are uppercase
+  $taxhash{'city'} = uc($location->get('city'));
 
   $taxhash{'taxclass'} = $part_item->taxclass;
 
index 6f14cd2..3faf47e 100644 (file)
@@ -352,6 +352,9 @@ sub upgrade_data {
 
   tie my %hash, 'Tie::IxHash', 
 
+    #remap log levels
+       'log' => [],
+
     #cust_main (remove paycvv from history, locations, cust_payby, etc)
     'cust_main' => [],
 
@@ -478,8 +481,12 @@ sub upgrade_data {
     #populate tax statuses
     'tax_status' => [],
 
-    #mark certain taxes as system-maintained
+    #mark certain taxes as system-maintained,
+    # and fix whitespace
     'cust_main_county' => [],
+
+    #fix whitespace
+    'cust_location' => [],
   ;
 
   \%hash;
index 0ee0aa0..13a826f 100644 (file)
@@ -253,7 +253,9 @@ sub _upgrade_data { # class method
     'Generate quotation' => 'Disable quotation',
     'Add on-the-fly void credit reason' => 'Add on-the-fly void reason',
     '_ALL' => 'Employee preference telephony integration',
-    'Edit customer package dates' => 'Change package start date', #4.x
+    'Edit customer package dates' => [ 'Change package start date', #4.x
+                                       'Change package contract end date',
+                                     ],
     'Resend invoices' => 'Print and mail invoices',
   );
 
diff --git a/FS/FS/commission_rate.pm b/FS/FS/commission_rate.pm
new file mode 100644 (file)
index 0000000..dcb596d
--- /dev/null
@@ -0,0 +1,116 @@
+package FS::commission_rate;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::commission_rate - Object methods for commission_rate records
+
+=head1 SYNOPSIS
+
+  use FS::commission_rate;
+
+  $record = new FS::commission_rate \%hash;
+  $record = new FS::commission_rate { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::commission_rate object represents a commission rate (a percentage or a
+flat amount) that will be paid on a customer's N-th invoice. The sequence of
+commissions that will be paid on consecutive invoices is the parent object,
+L<FS::commission_schedule>.
+
+FS::commission_rate inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item commissionratenum - primary key
+
+=item schedulenum - L<FS::commission_schedule> foreign key
+
+=item cycle - the ordinal of the billing cycle this commission will apply
+to. cycle = 1 applies to the customer's first invoice, cycle = 2 to the
+second, etc.
+
+=item amount - the flat amount to pay per invoice in commission
+
+=item percent - the percentage of the invoice amount to pay in 
+commission
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new commission rate.  To add it to the database, see L<"insert">.
+
+=cut
+
+sub table { 'commission_rate'; }
+
+=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 commission rate.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  $self->set('amount', '0.00')
+    if $self->get('amount') eq '';
+  $self->set('percent', '0')
+    if $self->get('percent') eq '';
+
+  my $error = 
+    $self->ut_numbern('commissionratenum')
+    || $self->ut_number('schedulenum')
+    || $self->ut_number('cycle')
+    || $self->ut_money('amount')
+    || $self->ut_decimal('percent')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/commission_schedule.pm b/FS/FS/commission_schedule.pm
new file mode 100644 (file)
index 0000000..375386c
--- /dev/null
@@ -0,0 +1,235 @@
+package FS::commission_schedule;
+use base qw( FS::o2m_Common FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+use FS::commission_rate;
+use Tie::IxHash;
+
+tie our %basis_options, 'Tie::IxHash', (
+  setuprecur    => 'Total sales',
+  setup         => 'One-time and setup charges',
+  recur         => 'Recurring charges',
+  setup_cost    => 'Setup costs',
+  recur_cost    => 'Recurring costs',
+  setup_margin  => 'Setup charges minus costs',
+  recur_margin_permonth => 'Monthly recurring charges minus costs',
+);
+
+=head1 NAME
+
+FS::commission_schedule - Object methods for commission_schedule records
+
+=head1 SYNOPSIS
+
+  use FS::commission_schedule;
+
+  $record = new FS::commission_schedule \%hash;
+  $record = new FS::commission_schedule { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::commission_schedule object represents a bundle of one or more
+commission rates for invoices. FS::commission_schedule inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item schedulenum - primary key
+
+=item schedulename - descriptive name
+
+=item reasonnum - the credit reason (L<FS::reason>) that will be assigned
+to these commission credits
+
+=item basis - for percentage credits, which component of the invoice charges
+the percentage will be calculated on:
+- setuprecur (total charges)
+- setup
+- recur
+- setup_cost
+- recur_cost
+- setup_margin (setup - setup_cost)
+- recur_margin_permonth ((recur - recur_cost) / freq)
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new commission schedule.  To add the object to the database, see
+L<"insert">.
+
+=cut
+
+sub table { 'commission_schedule'; }
+
+=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.
+
+=cut
+
+sub delete {
+  my $self = shift;
+  # don't allow the schedule to be removed if it's still linked to events
+  if ($self->part_event) {
+    return 'This schedule is still in use.'; # UI should be smarter
+  }
+  $self->process_o2m(
+    'table'   => 'commission_rate',
+    'params'  => [],
+  ) || $self->delete;
+}
+
+=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 record.  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('schedulenum')
+    || $self->ut_text('schedulename')
+    || $self->ut_number('reasonnum')
+    || $self->ut_enum('basis', [ keys %basis_options ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item part_event
+
+Returns a list of billing events (L<FS::part_event> objects) that pay
+commission on this schedule.
+
+=cut
+
+sub part_event {
+  my $self = shift;
+  map { $_->part_event }
+    qsearch('part_event_option', {
+      optionname  => 'schedulenum',
+      optionvalue => $self->schedulenum,
+    }
+  );
+}
+
+=item calc_credit INVOICE
+
+Takes an L<FS::cust_bill> object and calculates credit on this schedule.
+Returns the amount to credit. If there's no rate defined for this invoice,
+returns nothing.
+
+=cut
+
+# Some false laziness w/ FS::part_event::Action::Mixin::credit_bill.
+# this is a little different in that we calculate the credit on the whole
+# invoice.
+
+sub calc_credit {
+  my $self = shift;
+  my $cust_bill = shift;
+  die "cust_bill record required" if !$cust_bill or !$cust_bill->custnum;
+  # count invoices before or including this one
+  my $cycle = FS::cust_bill->count('custnum = ? AND _date <= ?',
+    $cust_bill->custnum,
+    $cust_bill->_date
+  );
+  my $rate = qsearchs('commission_rate', {
+    schedulenum => $self->schedulenum,
+    cycle       => $cycle,
+  });
+  # we might do something with a rate that applies "after the end of the
+  # schedule" (cycle = 0 or something) so that this can do commissions with
+  # no end date. add that here if there's a need.
+  return unless $rate;
+
+  my $amount;
+  if ( $rate->percent ) {
+    my $what = $self->basis;
+    my $cost = ($what =~ /_cost/ ? 1 : 0);
+    my $margin = ($what =~ /_margin/ ? 1 : 0);
+    my %part_pkg_cache;
+    foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) {
+
+      my $charge = 0;
+      next if !$cust_bill_pkg->pkgnum; # exclude taxes and fees
+
+      my $cust_pkg = $cust_bill_pkg->cust_pkg;
+      if ( $margin or $cost ) {
+        # look up package costs only if we need them
+        my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
+        my $part_pkg   = $part_pkg_cache{$pkgpart}
+                     ||= FS::part_pkg->by_key($pkgpart);
+
+        if ( $cost ) {
+          $charge = $part_pkg->get($what);
+        } else { # $margin
+          $charge = $part_pkg->$what($cust_pkg);
+        }
+
+        $charge = ($charge || 0) * ($cust_pkg->quantity || 1);
+
+      } else {
+
+        if ( $what eq 'setup' ) {
+          $charge = $cust_bill_pkg->get('setup');
+        } elsif ( $what eq 'recur' ) {
+          $charge = $cust_bill_pkg->get('recur');
+        } elsif ( $what eq 'setuprecur' ) {
+          $charge = $cust_bill_pkg->get('setup') +
+                    $cust_bill_pkg->get('recur');
+        }
+      }
+
+      $amount += ($charge * $rate->percent / 100);
+
+    }
+  } # if $rate->percent
+
+  if ( $rate->amount ) {
+    $amount += $rate->amount;
+  }
+
+  $amount = sprintf('%.2f', $amount + 0.005);
+  return $amount;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, L<FS::part_event>, L<FS::commission_rate>
+
+=cut
+
+1;
+
index 9a1f22a..7c67c2d 100644 (file)
@@ -338,7 +338,7 @@ sub upgrade_taxable_billpkgnum {
         } #for $i
       } else {
         # the more complicated case
-        $log->warn("mismatched charges and tax links in pkg#$pkgnum",
+        $log->warning("mismatched charges and tax links in pkg#$pkgnum",
           object => $cust_bill);
         my $tax_amount = sum(map {$_->amount} @tax_links);
         # remove all tax link records and recreate them to be 1:1 with 
index 8546372..e4b1fc0 100644 (file)
@@ -315,6 +315,7 @@ sub check {
     || $self->ut_foreign_keyn('commission_agentnum',  'agent', 'agentnum')
     || $self->ut_foreign_keyn('commission_salesnum',  'sales', 'salesnum')
     || $self->ut_foreign_keyn('commission_pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('commission_invnum', 'cust_bill', 'invnum')
   ;
   return $error if $error;
 
index 9040098..fdc2cf8 100644 (file)
@@ -2,7 +2,7 @@ package FS::cust_location;
 use base qw( FS::geocode_Mixin FS::Record );
 
 use strict;
-use vars qw( $import $DEBUG $conf $label_prefix );
+use vars qw( $import $DEBUG $conf $label_prefix $allow_location_edit );
 use Data::Dumper;
 use Date::Format qw( time2str );
 use FS::UID qw( dbh driver_name );
@@ -171,6 +171,10 @@ sub find_or_insert {
   delete $nonempty{'locationnum'};
 
   my %hash = map { $_ => $self->get($_) } @essential;
+  foreach (values %hash) {
+    s/^\s+//;
+    s/\s+$//;
+  }
   my @matches = qsearch('cust_location', \%hash);
 
   # we no longer reject matches for having different values in nonessential
@@ -292,7 +296,7 @@ sub replace {
   # it's a prospect location, then there are no active packages, no billing
   # history, no taxes, and in general no reason to keep the old location
   # around.
-  if ( $self->custnum ) {
+  if ( !$allow_location_edit and $self->custnum ) {
     foreach (qw(address1 address2 city state zip country)) {
       if ( $self->$_ ne $old->$_ ) {
         return "can't change cust_location field $_";
@@ -347,6 +351,10 @@ sub check {
 
   return '' if $self->disabled; # so that disabling locations never fails
 
+  # maybe should just do all fields in the table?
+  # or in every table?
+  $self->trim_whitespace(qw(district city county state country));
+
   my $error = 
     $self->ut_numbern('locationnum')
     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
@@ -887,6 +895,35 @@ sub process_standardize {
   close $log;
 }
 
+sub _upgrade_data {
+  my $class = shift;
+
+  # are we going to need to update tax districts?
+  my $use_districts = $conf->config('tax_district_method') ? 1 : 0;
+
+  # trim whitespace on records that need it
+  local $allow_location_edit = 1;
+  foreach my $field (qw(city county state country district)) {
+    foreach my $location (qsearch({
+      table => 'cust_location',
+      extra_sql => " WHERE $field LIKE ' %' OR $field LIKE '% '"
+    })) {
+      my $error = $location->replace;
+      die "$error (fixing whitespace in $field, locationnum ".$location->locationnum.')'
+        if $error;
+
+      if ( $use_districts ) {
+        my $queue = new FS::queue {
+          'job' => 'FS::geocode_Mixin::process_district_update'
+        };
+        $error = $queue->insert( 'FS::cust_location' => $location->locationnum );
+        die $error if $error;
+      }
+    } # foreach $location
+  } # foreach $field
+  '';
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
index 0fc2cb7..3e4a438 100644 (file)
@@ -355,6 +355,35 @@ sub _bop_content {
   \%content;
 }
 
+sub _tokenize_card {
+  my ($self,$transaction,$payinfo,$log) = @_;
+
+  if ( $transaction->can('card_token') 
+       and $transaction->card_token 
+       and $payinfo !~ /^99\d{14}$/ #not already tokenized
+  ) {
+
+    my @cust_payby = $self->cust_payby('CARD','DCRD');
+    @cust_payby = grep { $payinfo == $_->payinfo } @cust_payby;
+    if (@cust_payby > 1) {
+      $log->error('Multiple matching card numbers for cust '.$self->custnum.', could not tokenize card');
+    } elsif (@cust_payby) {
+      my $cust_payby = $cust_payby[0];
+      $cust_payby->payinfo($transaction->card_token);
+      my $error = $cust_payby->replace;
+      if ( $error ) {
+        $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error);
+      } else {
+        $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum);
+      }
+    } else {
+      $log->debug('No matching card numbers for cust '.$self->custnum.', could not tokenize card');
+    }
+
+  }
+
+}
+
 my %bop_method2payby = (
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
@@ -369,6 +398,8 @@ sub realtime_bop {
     unless $FS::UID::AutoCommit;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_bop');
  
   my %options = ();
   if (ref($_[0]) eq 'HASH') {
@@ -774,18 +805,7 @@ sub realtime_bop {
   # Tokenize
   ###
 
-
-  if ( $transaction->can('card_token') && $transaction->card_token ) {
-
-    if ( $options{'payinfo'} eq $self->payinfo ) {
-      $self->payinfo($transaction->card_token);
-      my $error = $self->replace;
-      if ( $error ) {
-        warn "WARNING: error storing token: $error, but proceeding anyway\n";
-      }
-    }
-
-  }
+  $self->_tokenize_card($transaction,$options{'payinfo'},$log);
 
   ###
   # result handling
@@ -1950,6 +1970,7 @@ sub realtime_verify_bop {
     if ( $reverse->is_success ) {
 
       $cust_pay_pending->status('done');
+      $cust_pay_pending->statustext('reversed');
       my $cpp_authorized_err = $cust_pay_pending->replace;
       return $cpp_authorized_err if $cpp_authorized_err;
 
@@ -2083,19 +2104,7 @@ sub realtime_verify_bop {
   # Tokenize
   ###
 
-  if ( $transaction->can('card_token') && $transaction->card_token ) {
-
-    if ( $options{'payinfo'} eq $self->payinfo ) {
-      $self->payinfo($transaction->card_token);
-      my $error = $self->replace;
-      if ( $error ) {
-        my $warning = "WARNING: error storing token: $error, but proceeding anyway\n";
-        $log->warning($warning);
-        warn $warning;
-      }
-    }
-
-  }
+  $self->_tokenize_card($transaction,$options{'payinfo'},$log);
 
   ###
   # result handling
index 3c355e8..a1233d0 100644 (file)
@@ -122,6 +122,9 @@ methods.
 sub check {
   my $self = shift;
 
+  $self->trim_whitespace(qw(district city county state country));
+  $self->set('city', uc($self->get('city'))); # also county?
+
   $self->exempt_amount(0) unless $self->exempt_amount;
 
   $self->ut_numbern('taxnum')
@@ -701,6 +704,49 @@ sub _upgrade_data {
     }
     FS::upgrade_journal->set_done($journal);
   }
+  # trim whitespace and convert to uppercase in the 'city' field.
+  foreach my $record (qsearch({
+    table => 'cust_main_county',
+    extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)",
+  })) {
+    # any with-trailing-space records probably duplicate other records
+    # from the same city, and if we just fix the record in place, we'll
+    # create an exact duplicate.
+    # so find the record this one would duplicate, and merge them.
+    $record->check; # trims whitespace
+    my %match = map { $_ => $record->get($_) }
+      qw(city county state country district taxname taxclass);
+    my $other = qsearchs('cust_main_county', \%match);
+    if ($other) {
+      my $new_taxnum = $other->taxnum;
+      my $old_taxnum = $record->taxnum;
+      if ($other->tax != $record->tax or
+          $other->exempt_amount != $record->exempt_amount) {
+        # don't assume these are the same.
+        warn "Found duplicate taxes (#$new_taxnum and #$old_taxnum) but they have different rates and can't be merged.\n";
+      } else {
+        warn "Merging tax #$old_taxnum into #$new_taxnum\n";
+        foreach my $table (qw(
+          cust_bill_pkg_tax_location
+          cust_bill_pkg_tax_location_void
+          cust_tax_exempt_pkg
+          cust_tax_exempt_pkg_void
+        )) {
+          foreach my $row (qsearch($table, { 'taxnum' => $old_taxnum })) {
+            $row->set('taxnum' => $new_taxnum);
+            my $error = $row->replace;
+            die $error if $error;
+          }
+        }
+        my $error = $record->delete;
+        die $error if $error;
+      }
+    } else {
+      # else there is no record this one duplicates, so just fix it
+      my $error = $record->replace;
+      die $error if $error;
+    }
+  } # foreach $record
   '';
 }
 
index dfb07b8..3a8322e 100644 (file)
@@ -455,6 +455,26 @@ sub decline {
   $self->replace;
 }
 
+=item reverse [ STATUSTEXT ]
+
+Sets the status of this pending payment to "done" (with statustext
+"reversed (manual)" unless otherwise specified).
+
+Currently only used when resolving pending payments manually.
+
+=cut
+
+# almost complete false laziness with decline,
+# but want to avoid confusion, in case any additional steps/defaults are ever added to either
+sub reverse {
+  my $self = shift;
+  my $statustext = shift || "reversed (manual)";
+
+  $self->status('done');
+  $self->statustext($statustext);
+  $self->replace;
+}
+
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
index 29b4b0a..a632ab4 100644 (file)
@@ -3,7 +3,7 @@ use base qw( FS::otaker_Mixin FS::Record );
 
 use strict;
 use vars qw( $ignore_empty_action );
-use FS::Record qw( qsearch ); #qsearchs );
+use FS::Record qw( qsearch qsearchs );
 use FS::upgrade_journal;
 
 $ignore_empty_action = 0;
index 1d4df73..d432ee3 100644 (file)
@@ -6,6 +6,8 @@ use FS::Record qw( qsearch qsearchs dbdef );
 use FS::UID qw( dbh driver_name );
 use FS::log_context;
 use FS::log_email;
+use FS::upgrade_journal;
+use Tie::IxHash;
 
 =head1 NAME
 
@@ -115,7 +117,7 @@ sub insert {
       'msgtype'       => 'admin',
       'to'            => $log_email->to_addr,
       'substitutions' => {
-        'loglevel'   => $FS::Log::LEVELS[$self->level], # which has hopefully been loaded...
+        'loglevel'   => $FS::Log::LEVELS{$self->level}, # which has hopefully been loaded...
         'logcontext' => $log_email->context, # use the one that triggered the email
         'logmessage' => $self->message,
       },
@@ -383,6 +385,49 @@ sub search {
   };
 }
 
+sub _upgrade_data {
+  my ($class, %opts) = @_;
+
+  return if FS::upgrade_journal->is_done('log__remap_levels');
+
+  tie my %levelmap, 'Tie::IxHash', 
+    2 => 1, #notice -> info
+    6 => 5, #alert -> critical
+    7 => 5, #emergency -> critical
+  ;
+
+  # this method should never autocommit
+  # should have been set in upgrade, but just in case...
+  local $FS::UID::AutoCommit = 0;
+
+  # in practice, only debug/info/warning/error appear to have been used,
+  #   so this probably won't do anything, but just in case
+  foreach my $old (keys %levelmap) {
+    # FS::log has no replace method
+    my $sql = 'UPDATE log SET level=' . dbh->quote($levelmap{$old}) . ' WHERE level=' . dbh->quote($old);
+    warn $sql unless $opts{'quiet'};
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute() or die $sth->errstr;
+    $sth->finish();
+  }
+
+  foreach my $log_email (
+    qsearch('log_email',{ 'min_level' => 2 }),
+    qsearch('log_email',{ 'min_level' => 6 }),
+    qsearch('log_email',{ 'min_level' => 7 }),
+  ) {
+    $log_email->min_level($levelmap{$log_email->min_level});
+    my $error = $log_email->replace;
+    if ($error) {
+      dbh->rollback;
+      die $error;
+    }
+  }
+
+  FS::upgrade_journal->set_done('log__remap_levels');
+
+}
+
 =back
 
 =head1 BUGS
index 83414a6..37befb5 100644 (file)
@@ -5,10 +5,10 @@ use base qw( FS::Record );
 use FS::Record qw( qsearch qsearchs );
 
 my @contexts = ( qw(
-  test
   bill_and_collect
   FS::cust_main::Billing::bill_and_collect
   FS::cust_main::Billing::bill
+  FS::cust_main::Billing_Realtime::realtime_bop
   FS::cust_main::Billing_Realtime::realtime_verify_bop
   FS::pay_batch::import_from_gateway
   FS::part_pkg
@@ -23,6 +23,7 @@ my @contexts = ( qw(
   upgrade_taxable_billpkgnum
   freeside-paymentech-upload
   freeside-paymentech-download
+  test
 ) );
 
 =head1 NAME
index b890717..0a16724 100644 (file)
@@ -804,6 +804,59 @@ sub _upgrade_data {
   ###
   $self->_populate_initial_data;
 
+  ###
+  # Move welcome_msgnum to an export
+  ###
+
+  #upgrade_journal loaded by _populate_initial_data
+  unless (FS::upgrade_journal->is_done('msg_template__welcome_export')) {
+    if (my $msgnum = $conf->config('welcome_msgnum')) {
+      eval "use FS::part_export;";
+      die $@ if $@;
+      eval "use FS::part_svc;";
+      die $@ if $@;
+      eval "use FS::export_svc;";
+      die $@ if $@;
+      #create the export
+      my $part_export = new FS::part_export {
+        'exportname' => 'Welcome Email',
+        'exporttype' => 'send_email'
+      };
+      my $error = $part_export->insert({
+        'to_customer' => 1,
+        'insert_template' => $msgnum,
+        # replicate blank options that would be generated by UI,
+        # to avoid unexpected results from not having them exist
+        'to_address'  => '',
+        'replace_template' => 0,
+        'suspend_template' => 0,
+        'unsuspend_template' => 0,
+        'delete_template' => 0,
+      });
+      die $error if $error;
+      #attach it to part_svcs
+      my @welcome_exclude_svcparts = $conf->config('svc_acct_welcome_exclude');
+      foreach my $part_svc (
+        qsearch('part_svc',{ 'svcdb' => 'svc_acct', 'disabled' => '' })
+      ) {
+        next if grep { $_ eq $part_svc->svcpart } @welcome_exclude_svcparts;
+        my $export_svc = new FS::export_svc {
+          'exportnum' => $part_export->exportnum,
+          'svcpart'   => $part_svc->svcpart,
+        };
+        $error = $export_svc->insert;
+        die $error if $error;
+      }
+      #remove the old confs
+      $error = $conf->delete('welcome_msgnum');
+      die $error if $error;
+      $error = $conf->delete('svc_acct_welcome_exclude');
+      die $error if $error;
+    }
+    FS::upgrade_journal->set_done('msg_template__welcome_export');
+  }
+
+
   ### Fix dump-email_to (needs to happen after _populate_initial_data)
   if ($conf->config('dump-email_to')) {
     # anyone who still uses dump-email_to should have just had this created
diff --git a/FS/FS/part_event/Action/bill_agent_credit_schedule.pm b/FS/FS/part_event/Action/bill_agent_credit_schedule.pm
new file mode 100644 (file)
index 0000000..31189a2
--- /dev/null
@@ -0,0 +1,76 @@
+package FS::part_event::Action::bill_agent_credit_schedule;
+
+use base qw( FS::part_event::Action );
+use FS::Conf;
+use FS::cust_credit;
+use FS::commission_schedule;
+use Date::Format qw(time2str);
+
+use strict;
+
+sub description { 'Credit the agent based on a commission schedule' }
+
+sub option_fields {
+  'schedulenum' => { 'label'        => 'Schedule',
+                     'type'         => 'select-table',
+                     'table'        => 'commission_schedule',
+                     'name_col'     => 'schedulename',
+                     'disable_empty'=> 1,
+                   },
+}
+
+sub eventtable_hashref {
+  { 'cust_bill' => 1 };
+}
+
+our $date_format;
+
+sub do_action {
+  my( $self, $cust_bill, $cust_event ) = @_;
+
+  $date_format ||= FS::Conf->new->config('date_format') || '%x';
+
+  my $cust_main = $self->cust_main($cust_bill);
+  my $agent = $cust_main->agent;
+  return "No customer record for agent ". $agent->agent
+    unless $agent->agent_custnum;
+
+  my $agent_cust_main = $agent->agent_cust_main;
+
+  my $schedulenum = $self->option('schedulenum')
+    or return "no commission schedule selected";
+  my $schedule = FS::commission_schedule->by_key($schedulenum)
+    or return "commission schedule #$schedulenum not found";
+    # commission_schedule::delete tries to prevent this, but just in case
+
+  my $amount = $schedule->calc_credit($cust_bill)
+    or return;
+
+  my $reasonnum = $schedule->reasonnum;
+
+  #XXX shouldn't do this here, it's a localization problem.
+  # credits with commission_invnum should know how to display it as part
+  # of invoice rendering.
+  my $desc = 'from invoice #'. $cust_bill->display_invnum .
+             ' ('. time2str($date_format, $cust_bill->_date) . ')';
+             # could also show custnum and pkgnums here?
+  my $cust_credit = FS::cust_credit->new({
+    'custnum'             => $agent_cust_main->custnum,
+    'reasonnum'           => $reasonnum,
+    'amount'              => $amount,
+    'eventnum'            => $cust_event->eventnum,
+    'addlinfo'            => $desc,
+    'commission_agentnum' => $cust_main->agentnum,
+    'commission_invnum'   => $cust_bill->invnum,
+  });
+  my $error = $cust_credit->insert;
+  die "Error crediting customer ". $agent_cust_main->custnum.
+      " for agent commission: $error"
+    if $error;
+
+  #return $warning; # currently don't get warnings here
+  return;
+
+}
+
+1;
index 4f26e8c..5f7ce35 100644 (file)
@@ -420,15 +420,30 @@ sub paydate_epoch_sql {
 Find all records with a credit card payment type and no paycardtype, and
 replace them in order to set their paycardtype.
 
+This method actually just starts a queue job.
+
 =cut
 
 sub upgrade_set_cardtype {
   my $class = shift;
+  my $table = $class->table or die "upgrade_set_cardtype needs a table";
+
+  if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
+    my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
+    my $error = $job->insert($table);
+    die $error if $error;
+    FS::upgrade_journal->set_done("${table}__set_cardtype");
+  }
+}
+
+sub process_set_cardtype {
+  my $table = shift;
+
   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
   # will do this. ignore any problems with the cards.
   local $ignore_masked_payinfo = 1;
   my $search = FS::Cursor->new({
-    table     => $class->table,
+    table     => $table,
     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
   });
   while (my $record = $search->fetch) {
index 1dd9ffb..f2456a5 100644 (file)
@@ -1481,8 +1481,12 @@ sub search {
   }
 
   #svcnum
-  if ( $params->{'svcnum'} =~ /^(\d+)$/ ) {
-    push @where, "svcnum = $1";
+  if ( $params->{'svcnum'} ) {
+    my @svcnum = ref( $params->{'svcnum'} )
+                 ? @{ $params->{'svcnum'} }
+                 : $params->{'svcnum'};
+    @svcnum = grep /^\d+$/, @svcnum;
+    push @where, 'svcnum IN ('. join(',', @svcnum) . ')' if @svcnum;
   }
 
   # svcpart
index 83359f1..4184b9c 100644 (file)
@@ -870,3 +870,7 @@ FS/webservice_log.pm
 t/webservice_log.t
 FS/access_user_page_pref.pm
 t/access_user_page_pref.t
+FS/commission_schedule.pm
+t/commission_schedule.t
+FS/commission_rate.pm
+t/commission_rate.t
index 0087590..1745d67 100644 (file)
@@ -4,7 +4,7 @@ use strict;
 use vars qw( $conf );
 use FS::Daemon ':all'; #daemonize1 drop_root daemonize2 myexit logfile sig*
 use FS::UID qw( adminsuidsetup );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
 #use FS::cdr;
 #use FS::cust_pkg;
 #use FS::queue;
@@ -24,12 +24,12 @@ daemonize2();
 
 $conf = new FS::Conf;
 
-die "not running; cdr-asterisk_forward_rewrite, cdr-charged_party_rewrite ".
-    " and cdr-taqua-accountcode_rewrite conf options are all off\n"
+die "not running; relevant conf options are all off\n"
   unless _shouldrun();
 
 #--
 
+#used for taqua
 my %sessionnum_unmatch = ();
 my $sessionnum_retry = 4 * 60 * 60; # 4 hours
 my $sessionnum_giveup = 4 * 24 * 60 * 60; # 4 days
@@ -45,20 +45,25 @@ while (1) {
   # instead of just doing this search like normal CDRs
 
   #hmm :/
+  #used only by taqua, should have no effect otherwise
   my @recent = grep { ($sessionnum_unmatch{$_} + $sessionnum_retry) > time }
                  keys %sessionnum_unmatch;
   my $extra_sql = scalar(@recent)
                     ? ' AND acctid NOT IN ('. join(',', @recent). ') '
                     : '';
 
+  #order matters for removing dupes--only the first is preserved
+  $extra_sql .= ' ORDER BY acctid '
+    if $conf->exists('cdr-skip_duplicate_rewrite');
+
   my $found = 0;
-  my %skip = ();
+  my %skip = (); #used only by taqua
   my %warning = ();
 
   foreach my $cdr ( 
     qsearch( {
       'table'     => 'cdr',
-      'extra_sql' => 'FOR UPDATE',
+      'extra_sql' => 'FOR UPDATE', #XXX overwritten by opt below...would fixing this break anything?
       'hashref'   => {},
       'extra_sql' => 'WHERE freesidestatus IS NULL '.
                      ' AND freesiderewritestatus IS NULL '.
@@ -67,11 +72,27 @@ while (1) {
     } )
   ) {
 
-    next if $skip{$cdr->acctid};
+    next if $skip{$cdr->acctid}; #used only by taqua
 
     $found = 1;
     my @status = ();
 
+    if ($conf->exists('cdr-skip_duplicate_rewrite')) {
+      #qsearch can't handle timestamp type of calldate
+      my $sth = dbh->prepare(
+        'SELECT 1 FROM cdr WHERE src=? AND dst=? AND calldate=? AND acctid < ? LIMIT 1'
+      ) or die dbh->errstr;
+      $sth->execute($cdr->src,$cdr->dst,$cdr->calldate,$cdr->acctid) or die $sth->errstr;
+      my $isdup = $sth->fetchrow_hashref;
+      $sth->finish;
+      if ($isdup) {
+        #we only act on this cdr, not touching previous dupes
+        #if a dupe somehow creeped in previously, too late to fix it
+        $cdr->freesidestatus('done'); #prevent it from being billed
+        push(@status,'duplicate');
+      }
+    }
+
     if ( $conf->exists('cdr-asterisk_forward_rewrite')
          && $cdr->dstchannel =~ /^Local\/(\d+)/i && $1 ne $cdr->dst
        )
@@ -240,6 +261,7 @@ sub _shouldrun {
   || $conf->exists('cdr-taqua-accountcode_rewrite')
   || $conf->exists('cdr-taqua-callerid_rewrite')
   || $conf->exists('cdr-intl_to_domestic_rewrite')
+  || $conf->exists('cdr-skip_duplicate_rewrite')
   || 0
   ;
 }
@@ -263,6 +285,11 @@ of the following config options are enabled:
 
 =over 4
 
+=item cdr-skip_duplicate_rewrite
+
+Marks as 'done' (prevents billing for) any CDRs with 
+a src, dst and calldate identical to an existing CDR
+
 =item cdr-asterisk_australia_rewrite
 
 Classifies Australian numbers as domestic, mobile, tollfree, international, or
diff --git a/FS/t/commission_rate.t b/FS/t/commission_rate.t
new file mode 100644 (file)
index 0000000..fb5f43c
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::commission_rate;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/commission_schedule.t b/FS/t/commission_schedule.t
new file mode 100644 (file)
index 0000000..bbe6b42
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::commission_schedule;
+$loaded=1;
+print "ok 1\n";
diff --git a/bin/xmlrpc-insert_payby b/bin/xmlrpc-insert_payby
new file mode 100755 (executable)
index 0000000..9815d05
--- /dev/null
@@ -0,0 +1,52 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+use Getopt::Long;
+
+my( $email, $password ) = @ARGV;
+die "Usage: xmlrpc-insert_payby email password
+       [-w weight -b payby -i payinfo -c paycvv -d paydate -n payname -s paystate -t paytype -p payip]\n"
+  unless $email && length($password);
+
+my %opts;
+GetOptions(
+  "by=s"     => \$opts{'payby'},
+  "cvv=s"    => \$opts{'paycvv'},
+  "date=s"   => \$opts{'paydate'},
+  "info=s"   => \$opts{'payinfo'},
+  "name=s"   => \$opts{'payname'},
+  "payip=s"  => \$opts{'payip'},
+  "state=s"  => \$opts{'paystate'},
+  "type=s"   => \$opts{'paytype'},
+  "weight=i" => \$opts{'weight'},
+);
+
+foreach my $key (keys %opts) {
+  delete($opts{$key}) unless defined($opts{$key});
+}
+
+my $uri = new URI 'http://localhost:8080/';
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $login_result = $server->call(
+  'FS.ClientAPI_XMLRPC.login',
+    'email'    => $email,
+    'password' => $password,
+);
+die $login_result->{'error'}."\n" if $login_result->{'error'};
+
+my $call_result = $server->call(
+  'FS.ClientAPI_XMLRPC.insert_payby',
+    'session_id'   => $login_result->{'session_id'},
+    %opts,
+);
+die $call_result->{'error'}."\n" if $call_result->{'error'};
+
+print Dumper($call_result);
+print "Successfully inserted\n";
+
+1;
diff --git a/bin/xmlrpc-update_payby b/bin/xmlrpc-update_payby
new file mode 100755 (executable)
index 0000000..75a1a8d
--- /dev/null
@@ -0,0 +1,53 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+use Getopt::Long;
+
+my( $email, $password, $custpaybynum ) = @ARGV;
+die "Usage: xmlrpc-update_payby email password custpaybynum
+       [-w weight -b payby -i payinfo -c paycvv -d paydate -n payname -s paystate -t paytype -p payip]\n"
+  unless $email && length($password) && $custpaybynum;
+
+my %opts;
+GetOptions(
+  "by=s"     => \$opts{'payby'},
+  "cvv=s"    => \$opts{'paycvv'},
+  "date=s"   => \$opts{'paydate'},
+  "info=s"   => \$opts{'payinfo'},
+  "name=s"   => \$opts{'payname'},
+  "payip=s"  => \$opts{'payip'},
+  "state=s"  => \$opts{'paystate'},
+  "type=s"   => \$opts{'paytype'},
+  "weight=i" => \$opts{'weight'},
+);
+
+foreach my $key (keys %opts) {
+  delete($opts{$key}) unless defined($opts{$key});
+}
+
+my $uri = new URI 'http://localhost:8080/';
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $login_result = $server->call(
+  'FS.ClientAPI_XMLRPC.login',
+    'email'    => $email,
+    'password' => $password,
+);
+die $login_result->{'error'}."\n" if $login_result->{'error'};
+
+my $call_result = $server->call(
+  'FS.ClientAPI_XMLRPC.update_payby',
+    'session_id'   => $login_result->{'session_id'},
+    'custpaybynum' => $custpaybynum,
+    %opts,
+);
+die $call_result->{'error'}."\n" if $call_result->{'error'};
+
+print Dumper($call_result);
+print "Successfully updated\n";
+
+1;
index 24ddea3..4de8fee 100644 (file)
@@ -28,7 +28,7 @@ Description: Billing and trouble ticketing for service providers
 Package: freeside-lib
 Architecture: all
 Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,latex-xcolor,
- libbusiness-creditcard-perl,libcache-cache-perl,
+ libbusiness-creditcard-perl (>= 0.36),libcache-cache-perl,
  libcache-simple-timedexpiry-perl,libchart-perl,libclass-container-perl,
  libclass-data-inheritable-perl,libclass-returnvalue-perl,libcolor-scheme-perl,
  libcompress-zlib-perl,libconvert-binhex-perl,libcrypt-passwdmd5-perl,
index bc54b1e..3be4ebd 100644 (file)
@@ -50,6 +50,7 @@ $socket .= '.'.$tag if defined $tag && length($tag);
   'list_invoices'             => 'MyAccount/list_invoices', #?
   'list_payby'                => 'MyAccount/list_payby',
   'insert_payby'              => 'MyAccount/insert_payby',
+  'update_payby'              => 'MyAccount/update_payby',
   'delete_payby'              => 'MyAccount/delete_payby', 
   'cancel'                    => 'MyAccount/cancel',        #add to ss cgi!
   'payment_info'              => 'MyAccount/payment_info',
@@ -682,6 +683,16 @@ Optional IP address from which payment was submitted
 If there is an error, returns a hash reference with a single key, B<error>,
 otherwise returns a hash reference with a single key, B<custpaybynum>.
 
+=item update_payby HASHREF
+
+Updates stored payment information.  Takes a hash reference with the same
+keys as insert_payby, as well as B<custpaybynum> to specify which record
+to update.  All keys except B<session_id> and B<custpaybynum> are optional;
+if omitted, the previous values in the record will be preserved.
+
+If there is an error, returns a hash reference with a single key, B<error>,
+otherwise returns a hash reference with a single key, B<custpaybynum>.
+
 =item delete_payby HASHREF
 
 Removes stored payment information.  Takes a hash reference with two keys,
diff --git a/httemplate/browse/commission_schedule.html b/httemplate/browse/commission_schedule.html
new file mode 100644 (file)
index 0000000..5a4f984
--- /dev/null
@@ -0,0 +1,70 @@
+<& elements/browse.html,
+  'title'       => "Commission schedules",
+  'name'        => "commission schedules",
+  'menubar'     => [ 'Add a new schedule' =>
+                        $p.'edit/commission_schedule.html'
+                   ],
+  'query'       => { 'table'     => 'commission_schedule', },
+  'count_query' => 'SELECT COUNT(*) FROM commission_schedule',
+  'header'      => [ '#',
+                     'Name',
+                     'Rates',
+                   ],
+  'fields'      => [ 'schedulenum',
+                     'schedulename',
+                     $rates_sub,
+                  ],
+  'links'       => [ $link,
+                     $link,
+                     '',
+                   ],
+  'disable_total' => 1,
+&>
+<%init>
+
+my $money_char = FS::Conf->new->config('money_char') || '$';
+
+my $ordinal_sub = sub {
+  # correct from 1 to 12...
+  my $num = shift;
+  $num == 1 ? '1st' :
+  $num == 2 ? '2nd' :
+  $num == 3 ? '3rd' :
+  $num . 'th'
+};
+
+my $rates_sub = sub {
+  my $schedule = shift;
+  my @rates = sort { $a->cycle <=> $b->cycle } $schedule->commission_rate;
+  my @data;
+  my $basis = emt(lc( $FS::commission_schedule::basis_options{$schedule->basis} ));
+  foreach my $rate (@rates) {
+    my $desc = '';
+    if ( $rate->amount > 0 ) {
+      $desc = $money_char . sprintf('%.2f', $rate->amount);
+    }
+    if ( $rate->percent > 0 ) {
+      $desc .= ' + ' if $desc;
+      $desc .= $rate->percent . '% ' . emt('of') . ' ' . $basis;
+    }
+    next if !$desc;
+    $desc = &$ordinal_sub($rate->cycle) . ' ' . emt('invoice') .
+             ':&nbsp;' . $desc;
+
+    push @data,
+    [
+      {
+        'data'  => $desc,
+        'align' => 'right',
+      }
+    ];
+  }
+  \@data;
+};
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $link = [ $p.'edit/commission_schedule.html?', 'schedulenum' ];
+
+</%init>
index 0f64dd4..007ea6f 100644 (file)
@@ -21,7 +21,7 @@
                       ],
      'fields'      => [ 'logemailnum',
                         sub { $_[0]->context || '(all)' },
-                        sub { $FS::Log::LEVELS[$_[0]->min_level] },
+                        sub { $FS::Log::LEVELS{$_[0]->min_level} },
                         'msgname',
                         'to_addr',
                         $actions,
diff --git a/httemplate/edit/commission_schedule.html b/httemplate/edit/commission_schedule.html
new file mode 100644 (file)
index 0000000..c76a361
--- /dev/null
@@ -0,0 +1,53 @@
+<& elements/edit.html,
+     name_singular => 'schedule',
+     table         => 'commission_schedule',
+     viewall_dir   => 'browse',
+     fields        => [ 'schedulename',
+                        { field             => 'reasonnum',
+                          type              => 'select-reason',
+                          reason_class      => 'R',
+                        },
+                        { field             => 'basis',
+                          type              => 'select',
+                          options           => [ keys %FS::commission_schedule::basis_options ],
+                          labels            => { %FS::commission_schedule::basis_options },
+                        },
+                        { type => 'tablebreak-tr-title', value => 'Billing cycles' },
+                        { field             => 'commissionratenum',
+                          type              => 'commission_rate',
+                          o2m_table         => 'commission_rate',
+                          m2_label          => ' ',
+                          m2_error_callback => $m2_error_callback,
+                          colspan => 2,
+                        },
+                      ],
+     labels        => { 'schedulenum'       => '',
+                        'schedulename'      => 'Name',
+                        'basis'             => 'Based on',
+                        'commissionratenum' => '',
+                      },
+&>
+<%init>
+
+my $m2_error_callback = sub {
+  my ($cgi, $object) = @_;
+
+  my @rates;
+  foreach my $k ( grep /^commissionratenum\d+/, $cgi->param ) {
+    my $num = $cgi->param($k);
+    my $cycle = $cgi->param($k.'_cycle');
+    my $amount = $cgi->param($k.'_amount');
+    my $percent = $cgi->param($k.'_percent');
+    if ($cycle > 0) {
+      push @rates, FS::commission_rate->new({
+        'commissionratenum' => $num,
+        'cycle'             => $cycle,
+        'amount'            => $amount,
+        'percent'           => $percent,
+      });
+    }
+  }
+  @rates;
+};
+
+</%init>
index 39cddc0..b314d2d 100755 (executable)
@@ -203,12 +203,19 @@ if ( $cgi->param('error') ) {
   my %locations;
   for my $pre (qw(bill ship)) {
     my %hash;
-    foreach ( FS::cust_main->location_fields ) {
-      $hash{$_} = scalar($cgi->param($pre.'_'.$_));
+    foreach my $locfield ( FS::cust_main->location_fields ) {
+      # don't search on lat/long, string values can cause qsearchs to die
+      next if grep {$_ eq $locfield} qw(latitude longitude);
+      $hash{$locfield} = scalar($cgi->param($pre.'_'.$locfield));
     }
     $hash{'custnum'} = $cgi->param('custnum');
     $locations{$pre} = qsearchs('cust_location', \%hash)
                        || FS::cust_location->new( \%hash );
+    # now set lat/long, for redisplay of entered values
+    foreach my $locfield ( qw(latitude longitude) ) {
+      my $locvalue = scalar($cgi->param($pre.'_'.$locfield));
+      $locations{$pre}->set($locfield,$locvalue);
+    }
   }
   if ( $same ) {
     $locations{ship} = $locations{bill};
index 0056bb9..7d480f3 100644 (file)
@@ -4,6 +4,10 @@
 
     <CENTER><FONT SIZE="+1"><B>Are you sure you want to delete this pending payment?</B></FONT></CENTER>
 
+% } elsif (( $action eq 'complete' ) and $authorized) {
+
+    <CENTER><FONT SIZE="+1"><B>Payment was authorized but not captured.  Contact <% $cust_pay_pending->processor || 'the payment gateway' %> to establish the final disposition of this transaction.</B></FONT></CENTER>
+
 % } elsif ( $action eq 'complete' ) {
 
     <CENTER><FONT SIZE="+1"><B>No response was received from <% $cust_pay_pending->processor || 'the payment gateway' %> for this transaction.  Check <% $cust_pay_pending->processor || 'the payment gateway' %>'s reporting and determine if this transaction completed successfully.</B></FONT></CENTER>
 
 % } else {
 
-%#   if ( $action eq 'complete' ) {
-
     <INPUT TYPE="hidden" NAME="action" VALUE="">
 
     <TR>
         <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'insert_cust_pay'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/tick.png" ALT=""-->Yes, transaction completed sucessfully.</BUTTON>
       </TD>
 
-%     if ( $action eq 'complete' ) {
+%   if ( $action eq 'complete' ) {
         <TD>&nbsp;&nbsp;&nbsp;</TD>
+%     if ($authorized) {
+        <TD ALIGN="center">
+          <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'reverse'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was reversed</BUTTON>
+        </TD>
+%     } else {
         <TD ALIGN="center">
           <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'decline'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was declined</BUTTON>
         </TD>
+%     }
         <TD>&nbsp;&nbsp;&nbsp;</TD>
         <TD ALIGN="center">
           <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'delete'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was not received</BUTTON>
         </TD>
-      </TR>
 %   }
 
+    </TR>
+
     <TR><TD COLSPAN=5></TD></TR>
 
     <TR>
@@ -156,6 +165,8 @@ my $cust_pay_pending =
   })
   or die 'unknown paypendingnum';
 
+my $authorized = ($cust_pay_pending->status eq 'authorized') ? 1 : 0;
+
 my $conf = new FS::Conf;
 
 my $money_char = $conf->config('money_char') || '$';
index bbc9797..8dd15dc 100644 (file)
@@ -650,7 +650,7 @@ Example:
           var newrow =  <% include(@layer_opt, html_only=>1) |js_string %>;
 
 %         #until the rest have html/js_only
-%         if ( $type eq 'selectlayers' || $type =~ /^select-cgp_rule_/ ) {
+%         if ( ($type eq 'selectlayers') || ($type eq 'selectlayersx') || ($type =~ /^select-cgp_rule_/) ) {
             var newfunc = <% include(@layer_opt, js_only=>1) |js_string %>;
 %         } else {
             var newfunc = '';
index 0c98046..b79aba9 100644 (file)
@@ -16,8 +16,8 @@
                             },
                             { 'field' => 'min_level',
                               'type'  => 'select',
-                              'options' => [ 0..7 ],
-                              'labels' => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 },
+                              'options' => [ &FS::Log::levelnums ],
+                              'labels' => { &FS::Log::levelmap },
                               'curr_value' => scalar($cgi->param('min_level')),
                             },
                             'to_addr',
index 47b8c1a..c8072e9 100644 (file)
@@ -31,7 +31,7 @@
                               value   => 'Event Conditions',
                             },
                             { field   => 'conditionname',
-                              type    => 'selectlayers',
+                              type    => 'selectlayersx',
                               options => [ keys %all_conditions ],
                               labels  => \%condition_labels,
                               onchange => 'condition_changed(what);',
@@ -51,7 +51,7 @@
                               value   => 'Event Action',
                             },
                             { field   => 'action',
-                              type     => 'selectlayers',
+                              type     => 'selectlayersx',
                               options  => [ keys %all_actions ],
                               labels   => \%action_labels,
                               onchange => 'action_changed(what);',
index b7ff40f..b5a0258 100644 (file)
@@ -12,7 +12,7 @@
   <% include('/elements/header-popup.html', "Taxes ${action}ed") %>
 
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 
   </BODY>
index f5cf7dd..3c27306 100644 (file)
@@ -7,7 +7,7 @@
 
     <% header(emt("Services moved")) %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index 60769d4..24515d5 100644 (file)
@@ -4,7 +4,7 @@
 % } else { #success XXX better msg talking about vacation vs. redirect all
   <% include('/elements/header-popup.html', 'Rule updated') %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 
   </BODY>
index 308ea8f..54cafbf 100644 (file)
@@ -5,7 +5,7 @@
 
     <% header(emt("Package changed")) %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
diff --git a/httemplate/edit/process/commission_schedule.html b/httemplate/edit/process/commission_schedule.html
new file mode 100644 (file)
index 0000000..50e0371
--- /dev/null
@@ -0,0 +1,36 @@
+<& elements/process.html,
+  'table'       => 'commission_schedule',
+  'viewall_dir' => 'browse',
+  'process_o2m' => {
+   'table'  => 'commission_rate',
+   'fields' => [qw( cycle amount percent )],
+  },
+  'precheck_callback' => $precheck,
+  'debug' => 1,
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $precheck = sub {
+  my $cgi = shift;
+  $cgi->param('reasonnum') =~ /^(-?\d+)$/ or die "Illegal reasonnum";
+
+  my ($reasonnum, $error) = $m->comp('/misc/process/elements/reason');
+  if (!$reasonnum) {
+    $error ||= 'Reason required'
+  }
+  $cgi->param('reasonnum', $reasonnum) unless $error;
+
+  # remove rate entries with no cycle selected
+  foreach my $k (grep /^commissionratenum\d+$/, $cgi->param) {
+    if (! $cgi->param($k.'_cycle') ) {
+      $cgi->delete($k);
+    }
+  }
+
+  $error;
+};
+
+</%init>
index 75900bd..12b68c0 100644 (file)
@@ -3,7 +3,7 @@
 %} else {
 <& /elements/header-popup.html, 'Credit successful' &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY></HTML>
 % }
index 8941cbc..56f7989 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <% header(emt('Credit package changed')) %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
 
     </BODY></HTML>
index 39c6f19..5d30287 100755 (executable)
@@ -16,7 +16,7 @@
 %  
 <% header(emt('Credit successful')) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 
   </BODY></HTML>
index bc9cd4f..6edaca3 100644 (file)
@@ -5,7 +5,7 @@
 
     <% header("Census tract changed") %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index fd1b874..3a23881 100644 (file)
@@ -5,7 +5,7 @@
 
     <% header("Location changed") %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index 04516e9..74f8f23 100755 (executable)
@@ -1,5 +1,15 @@
 % if ( $error ) {
 %   $cgi->param('error', $error);
+%   # workaround for create_uri_query's mangling of unicode characters,
+%   # false laziness with FS::Record::ut_coord
+%   use charnames ':full';
+%   for my $pre (qw(bill ship)) {
+%     foreach (qw( latitude longitude)) {
+%       my $coord = $cgi->param($pre.'_'.$_);
+%       $coord =~ s/\N{DEGREE SIGN}\s*$//;
+%       $cgi->param($pre.'_'.$_, $coord);
+%     }
+%   }
 %   my $query = $m->scomp('/elements/create_uri_query', 'secure'=>1);
 <% $cgi->redirect(popurl(2). "cust_main.cgi?$query" ) %>
 %
index 09c18ad..5695002 100644 (file)
@@ -9,7 +9,7 @@
 % $act = 'deleted' if($attachnum and $delete);
 <% header('Attachment ' . $act ) %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
     </BODY></HTML>
 % }
index fc8956b..fcc138f 100755 (executable)
@@ -1,7 +1,7 @@
 <% include('/elements/header-popup.html', 'Addition successful' ) %>
 
 <SCRIPT TYPE="text/javascript">
-  window.top.location.reload();
+  topreload();
 </SCRIPT>
 
 </BODY>
index a108276..42e4673 100755 (executable)
@@ -1,7 +1,7 @@
 <% include('/elements/header-popup.html', 'Addition successful' ) %>
 
 <SCRIPT TYPE="text/javascript">
-  window.top.location.reload();
+  topreload();
 </SCRIPT>
 
 </BODY>
index 53e616a..bb52db8 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <% header('Note ' . ($notenum ? 'updated' : 'added') ) %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
     </BODY></HTML>
 % }
index ccbd2d7..4a5ee84 100644 (file)
@@ -15,7 +15,7 @@ Requires 'Apply payment' acl.
 <P STYLE="font-weight: bold;"><% emt($message) %></P>
 <P><% emt('Please wait while the page reloads.') %></P>
 <SCRIPT TYPE="text/javascript">
-window.top.location.reload();
+topreload();
 </SCRIPT>
 
 % }
index d9a92a1..cefe970 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <% header(emt('Payment package changed')) %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
 
     </BODY></HTML>
index 9e5f3d3..15b26f9 100755 (executable)
@@ -14,7 +14,7 @@
 %    
 <% header(emt('Payment entered')) %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
 
     </BODY></HTML>
index 1bad6cf..80bd14a 100644 (file)
@@ -3,7 +3,7 @@
   <FONT SIZE="+1" COLOR="#ff0000">Error: <% $error |h %></FONT>
 % } else {
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
 % }
 </BODY>
@@ -59,6 +59,15 @@ if ( $action eq 'delete' ) {
     $title = 'Pending payment completed (decline)';
   }
 
+} elsif ( $action eq 'reverse' ) {
+
+  $error = $cust_pay_pending->reverse;
+  if ( $error ) {
+    $title = 'Error reversing pending payment';
+  } else {
+    $title = 'Pending payment completed (reverse)';
+  }
+
 } else {
 
   die "unknown action $action";
index 132ff63..25fabd9 100644 (file)
@@ -6,7 +6,7 @@
 % } else {
 <% header($action) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY></HTML>
 % }
index 143611e..9635463 100644 (file)
@@ -5,7 +5,7 @@
 
     <% header("Discount applied") %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index fb26572..b605955 100644 (file)
@@ -5,7 +5,7 @@
 
     <& /elements/header-popup.html, "Quantity changed" &>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index aab3741..c1cb268 100644 (file)
@@ -5,7 +5,7 @@
 
     <& /elements/header-popup.html, "Sales Person changed" &>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index 8977ced..d4236bc 100755 (executable)
@@ -7,7 +7,7 @@
 %
 <% header('Refund entered') %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
 
     </BODY></HTML>
index 204b5b9..fe23275 100644 (file)
@@ -4,7 +4,7 @@
 % } else {
 <% header("Tax adjustment added") %>
   <SCRIPT TYPE="text/javascript">
-    //window.top.location.reload();
+    //topreload();
     parent.cClick();
   </SCRIPT>
   </BODY></HTML>
index 782ffa5..34c5805 100644 (file)
@@ -5,7 +5,7 @@
 
     <% header(emt("Package detached")) %>
       <SCRIPT TYPE="text/javascript">
-        window.top.location.reload();
+        topreload();
       </SCRIPT>
     </BODY>
     </HTML>
index 8369f71..9d869d5 100755 (executable)
@@ -3,7 +3,7 @@
 %} elsif ( $recnum ) { #editing
 <% header('Nameservice record changed') %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY></HTML>
 %} else { #adding
index a73b1bc..67fa891 100644 (file)
@@ -26,7 +26,7 @@ Examples:
 %} else {
 <% header("$src_thing application$to sucessful") %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
   </HTML>
index 60aaf74..76722c9 100644 (file)
@@ -188,7 +188,7 @@ process();
   <% include('/elements/header-popup.html', $opt{'popup_reload'} ) %>
 
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 
   </BODY>
index d1b8e10..00d17c8 100644 (file)
@@ -1,4 +1,13 @@
+% if ( $error ) {
+%   $cgi->param('error', $error );
 <% $cgi->redirect($redirect) %>
+% } else {
+<% header(emt($message)) %>
+  <SCRIPT TYPE="text/javascript">
+    topreload();
+  </SCRIPT>
+  </BODY></HTML>
+% }
 <%init>
 
 my $curuser = $FS::CurrentUser::CurrentUser;
index b836bae..5728832 100644 (file)
@@ -6,7 +6,7 @@
 % } else {
 <% header($action) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY></HTML>
 % }
diff --git a/httemplate/elements/commission_rate.html b/httemplate/elements/commission_rate.html
new file mode 100644 (file)
index 0000000..071ebb1
--- /dev/null
@@ -0,0 +1,68 @@
+% unless ( $opt{'js_only'} ) {
+
+  <INPUT TYPE="hidden" NAME="<%$name%>" ID="<%$id%>" VALUE="<% $curr_value %>">
+
+      <& select.html,
+        field         => "${name}_cycle",
+        options       => [ '', 1 .. 12 ],
+        option_labels => {
+          ''  => '',
+          1   => '1st',
+          2   => '2nd',
+          3   => '3rd',
+          map { $_ => $_.'th' } 4 .. 12
+        },
+        onchange      => $onchange,
+        curr_value    => $commission_rate->get("cycle"),
+      &>
+      <B><% $money_char %></B>
+      <& input-text.html,
+        field         => "${name}_amount",
+        size          => 8,
+        curr_value    => $commission_rate->get("amount")
+                         || '0.00',
+        'text-align'  => 'right'
+      &>
+      <B> + </B>
+      <& input-text.html,
+        field         => "${name}_percent",
+        size          => 8,
+        curr_value    => $commission_rate->get("percent")
+                         || '0',
+        'text-align'  => 'right'
+      &><B>%</B>
+% }
+<%init>
+
+my( %opt ) = @_;
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $name = $opt{'field'} || 'commissionratenum';
+my $id = $opt{'id'} || 'commissionratenum';
+
+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 $commission_rate;
+if ( $curr_value ) {
+  $commission_rate = qsearchs('commission_rate', { 'commissionratenum' => $curr_value } );
+} else {
+  $commission_rate = new FS::commission_rate {};
+}
+
+foreach my $field (qw( amount percent cycle)) {
+  my $value = $cgi->param("${name}_${field}");
+  $commission_rate->set($field, $value) if $value;
+}
+
+</%init>
index 07595a5..850eaed 100644 (file)
@@ -53,7 +53,7 @@ Example:
 %   }
     <% include('init_overlib.html') |n %>
     <% include('rs_init_object.html') |n %>
-
+    <script type="text/javascript" src="<% $fsurl %>elements/topreload.js"></script>
     <% $head |n %>
 
 %# announce our base path, and the Mason comp path of this page
index 6c0f80b..839a636 100644 (file)
@@ -34,6 +34,7 @@ Example:
       <SCRIPT SRC="<% $fsurl %>elements/printtofit.js"></SCRIPT>
 %     }
 %   }
+    <SCRIPT SRC="<% $fsurl %>elements/topreload.js"></SCRIPT>
     <% $head |n %>
   </HEAD>
   <BODY <% $etc |n %>>
index 0f98bc9..88c1df3 100644 (file)
@@ -672,7 +672,10 @@ $config_cust{'Note classes'} = [ $fsurl.'browse/cust_note_class.html', 'Note cla
 tie my %config_agent, 'Tie::IxHash',
   'Agent types' => [ $fsurl.'browse/agent_type.cgi', 'Agent types define groups of package definitions that you can then assign to particular agents' ],
   'Agents'      => [ $fsurl.'browse/agent.cgi', 'Agents are resellers of your service. Agents may be limited to a subset of your full offerings (via their type)' ],
-  'Agent payment gateways'         => [ $fsurl.'browse/payment_gateway.html', 'Credit card and electronic check processors for agent overrides' ];
+  'Agent payment gateways'         => [ $fsurl.'browse/payment_gateway.html', 'Credit card and electronic check processors for agent overrides' ],
+  'separator' => '',
+  'Commission schedules' => [ $fsurl.'browse/commission_schedule.html',
+    'Commission schedules for consecutive billing periods' ],
 ;
 
 tie my %config_sales, 'Tie::IxHash',
diff --git a/httemplate/elements/selectlayersx.html b/httemplate/elements/selectlayersx.html
new file mode 100644 (file)
index 0000000..41f3cb0
--- /dev/null
@@ -0,0 +1,248 @@
+<%doc>
+
+Example:
+
+  include( '/elements/selectlayers.html',
+    'field'        => $key, # SELECT element NAME (passed as form field)
+                            # also used as ID and a unique key for layers and
+                            # functions
+    'curr_value'   => $selected_layer,
+    'options'      => [ 'option1', 'option2' ],
+    'labels'       => { 'option1' => 'Option 1 Label',
+                        'option2' => 'Option 2 Label',
+                      },
+
+    #XXX put this handling it its own selectlayers-fields.html element?
+    'layer_prefix' => 'prefix_', #optional prefix for fieldnames
+    'layer_fields' => { 'layer'  => [ 'fieldname',
+                                      { label => 'fieldname2',
+                                        type  => 'text', #implemented:
+                                                         # text, money, fixed,
+                                                         # hidden, checkbox,
+                                                         # checkbox-multiple,
+                                                         # select, select-agent,
+                                                         # select-pkg_class,
+                                                         # select-part_referral,
+                                                         # select-taxclass,
+                                                         # select-table,
+                                                         #XXX tbd:
+                                                         # more?
+                                      },
+                                      ...
+                                    ],
+                        'layer2' => [ 'l2fieldname',
+                                      ...
+                                    ],
+                      },
+
+    #current values for layer fields above
+    'layer_values' => { 'layer'  => { 'fieldname'  => 'current_value',
+                                      'fieldname2' => 'field2value',
+                                      ...
+                                    },
+                        'layer2' => { 'l2fieldname' => 'l2value',
+                                      ...
+                                    },
+                        ...
+                      },
+
+    #or manual control, instead of layer_fields and layer_values above
+    #called with args: my( $layer, $layer_fields, $layer_values, $layer_prefix )
+    'layer_callback' => 
+
+    'html_between  => '', #optional HTML displayed between the SELECT and the
+                          #layers, scalar or coderef ('field' passed as a param)
+    'onchange'     => '', #javascript code run when the SELECT changes
+                          # ("what" is the element)
+    'js_only'      => 0, #set true to return only the JS portions
+    'html_only'    => 0, #set true to return only the HTML portions
+    'select_only'  => 0, #set true to return only the <SELECT> HTML
+    'layers_only'  => 0, #set true to return only the layers <DIV> HTML
+  )
+
+</%doc>
+% unless ( grep $opt{$_}, qw(html_only js_only select_only layers_only) ) {
+<SCRIPT TYPE="text/javascript">
+% }
+% unless ( grep $opt{$_}, qw(html_only select_only layers_only) ) {
+
+%   unless ($selectlayersx_init) {
+
+var selectlayerx_info = {};
+
+function selectlayersx_changed (field) {
+
+  var what = document.getElementById(field);
+  selectlayerx_info[field]['onchange'](what);
+
+  var selectedlayer = what.options[what.selectedIndex].value;
+  for (i=0; i < selectlayerx_info[field]['layers'].length; i++) {
+    var iterlayer = selectlayerx_info[field]['layers'][i];
+    var iterobj   = document.getElementById(field+'d'+iterlayer);
+    if (selectedlayer == iterlayer) {
+      iterobj.style.display = "";
+      iterobj.style.zIndex  = 1;
+    } else {
+      iterobj.style.display = "none";
+      iterobj.style.zIndex  = 0;
+    }
+  }
+
+}
+
+%     $selectlayersx_init = 1;
+%   } #selectlayersx_init
+
+selectlayerx_info['<% $key %>'] = {};
+selectlayerx_info['<% $key %>']['onchange'] = function (what) { <% $opt{'onchange'} %> };
+selectlayerx_info['<% $key %>']['layers']   = <% encode_json(\@layers) %>;
+
+
+% } #unless html_only/select_only/layers_only
+% unless ( grep $opt{$_}, qw(html_only js_only select_only layers_only) ) {
+</SCRIPT>
+% }
+%
+% unless ( grep $opt{$_}, qw(js_only layers_only) ) {
+
+    <SELECT NAME          = "<% $key %>"
+            ID            = "<% $key %>"
+            previousValue = "<% $selected %>"
+            previousText  = "<% $options{$selected} %>"
+            onChange="selectlayersx_changed('<% $key %>')"
+    >
+
+%     foreach my $option ( keys %$options ) {
+
+        <OPTION VALUE="<% $option %>"
+                <% $option eq $selected ? ' SELECTED' : '' %>
+        ><% $options->{$option} |h %></OPTION>
+
+%     }
+
+    </SELECT>
+
+% }
+% unless ( grep $opt{$_}, qw(js_only select_only layers_only) ) {
+
+<% ref($between) ? &{$between}($key) : $between %>
+
+% }
+%
+% unless ( grep $opt{$_}, qw(js_only select_only) ) {
+
+%   foreach my $layer ( @layers ) {
+%     my $selected_layer;
+%     $selected_layer = $selected;
+
+      <DIV ID="<% $key %>d<% $layer %>"
+           STYLE="<% $selected_layer eq $layer
+                       ? 'display: block; z-index: 1'
+                       : 'display: none; z-index: 0'
+                  %>"
+      >
+
+        <% &{$layer_callback}($layer, $layer_fields, $layer_values, $layer_prefix) %>
+
+      </DIV>
+
+%   }
+
+% }
+<%once>
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+my $date_noinit = 0;
+
+</%once>
+<%shared>
+
+my $selectlayersx_init = 0;
+
+</%shared>
+<%init>
+
+my %opt = @_;
+
+#use Data::Dumper;
+#warn Dumper(%opt);
+
+my $key = $opt{field}; # || 'generate_one' #?
+
+tie my %options, 'Tie::IxHash',
+   map { $_ => $opt{'labels'}->{$_} }
+       @{ $opt{'options'} }; #just arrayref for now
+
+my $between = exists($opt{html_between}) ? $opt{html_between} : '';
+my $options = \%options;
+
+my @layers = ();
+@layers = keys %options;
+
+my $selected = exists($opt{curr_value}) ? $opt{curr_value} : '';
+
+#XXX eek.  also eek $layer_fields in the layer_callback() call...
+my $layer_fields = $opt{layer_fields};
+my $layer_values = $opt{layer_values};
+my $layer_prefix = $opt{layer_prefix};
+
+my $layer_callback = $opt{layer_callback} || \&layer_callback;
+
+sub layer_callback {
+  my( $layer, $layer_fields, $layer_values, $layer_prefix ) = @_;
+
+  return  '' unless $layer && exists $layer_fields->{$layer};
+  tie my %fields, 'Tie::IxHash', @{ $layer_fields->{$layer} };
+
+  #XXX this should become an element itself... (false laziness w/edit.html)
+  # but at least all the elements inside are the shared mason elements now
+
+  return '' unless keys %fields;
+  my $html = "<TABLE>";
+
+  foreach my $field ( keys %fields ) {
+
+    my $lf = ref($fields{$field})
+               ? $fields{$field}
+               : { 'label'=>$fields{$field} };
+
+    my $value = $layer_values->{$layer}{$field};
+
+    my $type = $lf->{type} || 'text';
+
+    my $include = $type;
+
+    if ( $include eq 'date' ) {
+      # several important differences from other tr-*
+      $html .= include( '/elements/tr-input-date-field.html',
+        {
+          'name'  => "$layer_prefix$field",
+          'value' => $value,
+          'label' => $lf->{label},
+          'format'=> $lf->{format},
+          'noinit'=> $date_noinit,
+        }
+      );
+      $date_noinit = 1;
+    }
+    else {
+      $include = "input-$include" if $include =~ /^(text|money|percentage)$/;
+      $include = "tr-$include" unless $include eq 'hidden';
+      $html .= include( "/elements/$include.html",
+                        %$lf,
+                        'field'      => "$layer_prefix$field",
+                        'id'         => "$layer_prefix$field", #separate?
+                        #don't want field0_label0...?
+                        'label_id'   => $layer_prefix.$field."_label",
+
+                        'value'      => ( $lf->{'value'} || $value ), #hmm.
+                        'curr_value' => $value,
+                    );
+    }
+  } #foreach $field
+  $html .= '</TABLE>';
+  return $html;
+}
+
+</%init>
diff --git a/httemplate/elements/topreload.js b/httemplate/elements/topreload.js
new file mode 100644 (file)
index 0000000..a66703b
--- /dev/null
@@ -0,0 +1,5 @@
+window.topreload = function() {
+  if (window != window.top) {
+    window.top.location.reload();
+  }
+}
index 97466f1..9a43022 100755 (executable)
@@ -188,9 +188,8 @@ my $class = $opt{'reason_class'};
 my $init_reason;
 if ( $opt{'cgi'} ) {
   $init_reason = $opt{'cgi'}->param($name);
-} else {
-  $init_reason = $opt{'curr_value'};
 }
+$init_reason ||= $opt{'curr_value'};
 
 my $id = $opt{'id'} || $name;
 $id =~ s/\./_/g; # for edit/part_event
diff --git a/httemplate/elements/tr-selectlayersx.html b/httemplate/elements/tr-selectlayersx.html
new file mode 100644 (file)
index 0000000..ca7a360
--- /dev/null
@@ -0,0 +1,25 @@
+% unless ( $opt{js_only} ) {
+
+  <% include('tr-td-label.html', @_ ) %>
+
+    <TD <% $style %>>
+
+% }
+
+      <% include('selectlayersx.html', @_ ) %>
+
+% unless ( $opt{js_only} ) {
+
+    </TD>
+
+  </TR>
+
+% }
+
+<%init>
+
+my %opt = @_;
+
+my $style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : '';
+
+</%init>
diff --git a/httemplate/misc/change_pkg_date.html b/httemplate/misc/change_pkg_date.html
new file mode 100755 (executable)
index 0000000..642a5b8
--- /dev/null
@@ -0,0 +1,112 @@
+<& /elements/header-popup.html, mt($title) &>
+
+<& /elements/error.html &>
+
+% # only slightly different from unhold_pkg.
+<FORM NAME="MyForm" ACTION="process/change_pkg_date.html" METHOD=POST>
+<INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>">
+<INPUT TYPE="hidden" NAME="field" VALUE="<% $field %>">
+
+<BR>
+<% emt(($isstart ? 'Start billing' : 'Set contract end for').' [_1]', $part_pkg->pkg_comment(cust_pkg => $cust_pkg)) %>
+<UL STYLE="padding-left: 3ex; list-style: none; background-color: #cccccc">
+<LI>
+  <& /elements/radio.html,
+    field => 'when',
+    id    => 'when_now',
+    value => 'now',
+    curr_value => $when,
+  &>
+  <label for="when_now"><% emt($isstart ? 'Now' : 'Never') %></label>
+</LI>
+% if ( $next_bill_date ) {
+<LI>
+  <& /elements/radio.html,
+    field => 'when',
+    id    => 'when_next_bill_date',
+    value => 'next_bill_date',
+    curr_value => $when,
+  &>
+  <label for="when_next_bill_date">
+    <% emt('On the next bill date: [_1]', 
+      time2str($date_format, $next_bill_date) ) %>
+  </label>
+</LI>
+% }
+<LI>
+<& /elements/radio.html,
+  field => 'when',
+  id    => 'when_date',
+  value => 'date',
+  curr_value => $when,
+&>
+<label for="when_date"> <% emt('On this date:') %> </label>
+<& /elements/input-date-field.html,
+  { name  => 'date_value',
+    value => $cgi->param('date_value') || $cust_pkg->get($field),
+  }
+&>
+</LI>
+</UL>
+<INPUT TYPE="submit" NAME="submit" VALUE="<% emt('Set '.($isstart ? 'start date' : 'contract end')) %>">
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+my $field = $cgi->param('field');
+
+my ($acl, $isstart);
+if ($field eq 'start_date') {
+  $acl = 'Change package start date';
+  $isstart = 1;
+} elsif ($field eq 'contract_end') {
+  $acl = 'Change package contract end date';
+} else {
+  die "Unknown date field";
+}
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right($acl);
+
+my $pkgnum;
+if ( $cgi->param('pkgnum') =~ /^(\d+)$/ ) {
+  $pkgnum = $1;
+} else {
+  die "illegal query ". $cgi->keywords;
+}
+
+my $conf = new FS::Conf;
+my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+
+my $title = $isstart ? 'Start billing package' : 'Change contract end';
+
+my $cust_pkg = qsearchs({
+  table     => 'cust_pkg',
+  addl_from => ' JOIN cust_main USING (custnum) ',
+  hashref   => { 'pkgnum' => $pkgnum },
+  extra_sql => ' AND '. $curuser->agentnums_sql,
+}) or die "Unknown pkgnum: $pkgnum";
+
+my $next_bill_date = $cust_pkg->cust_main->next_bill_date;
+
+my $part_pkg = $cust_pkg->part_pkg;
+
+# defaults:
+# sticky on error, then the existing date if any, then the customer's
+# next bill date, and if none of those, default to now
+my $when = $cgi->param('when');
+
+if (!$when) {
+  if ($cust_pkg->get($field)) {
+    $when = 'date';
+  } elsif ($next_bill_date) {
+    $when = 'next_bill_date';
+  } else {
+    $when = 'now';
+  }
+}
+</%init>
diff --git a/httemplate/misc/change_pkg_start.html b/httemplate/misc/change_pkg_start.html
deleted file mode 100755 (executable)
index 5a890c8..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-<& /elements/header-popup.html, mt($title) &>
-
-<& /elements/error.html &>
-
-% # only slightly different from unhold_pkg.
-<FORM NAME="MyForm" ACTION="process/change_pkg_start.html" METHOD=POST>
-<INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>">
-
-<BR>
-<% emt('Start billing [_1]', $part_pkg->pkg_comment(cust_pkg => $cust_pkg)) %>
-<UL STYLE="padding-left: 3ex; list-style: none; background-color: #cccccc">
-<LI>
-  <& /elements/radio.html,
-    field => 'when',
-    id    => 'when_now',
-    value => 'now',
-    curr_value => $when,
-  &>
-  <label for="when_now"><% emt('Immediately') %></label>
-</LI>
-% if ( $next_bill_date ) {
-<LI>
-  <& /elements/radio.html,
-    field => 'when',
-    id    => 'when_next_bill_date',
-    value => 'next_bill_date',
-    curr_value => $when,
-  &>
-  <label for="when_next_bill_date">
-    <% emt('On the next bill date: [_1]', 
-      time2str($date_format, $next_bill_date) ) %>
-  </label>
-</LI>
-% }
-<LI>
-<& /elements/radio.html,
-  field => 'when',
-  id    => 'when_date',
-  value => 'date',
-  curr_value => $when,
-&>
-<label for="when_date"> <% emt('On this date:') %> </label>
-<& /elements/input-date-field.html,
-  { name  => 'start_date',
-    value => $cgi->param('start_date') || $cust_pkg->start_date,
-  }
-&>
-</LI>
-</UL>
-<INPUT TYPE="submit" NAME="submit" VALUE="<% emt('Set start date') %>">
-
-</FORM>
-</BODY>
-</HTML>
-
-<%init>
-
-my $curuser = $FS::CurrentUser::CurrentUser;
-die "access denied"
-  unless $curuser->access_right('Change package start date');
-
-my $pkgnum;
-if ( $cgi->param('pkgnum') =~ /^(\d+)$/ ) {
-  $pkgnum = $1;
-} else {
-  die "illegal query ". $cgi->keywords;
-}
-
-my $conf = new FS::Conf;
-my $date_format = $conf->config('date_format') || '%m/%d/%Y';
-
-my $title = 'Start billing package';
-
-my $cust_pkg = qsearchs({
-  table     => 'cust_pkg',
-  addl_from => ' JOIN cust_main USING (custnum) ',
-  hashref   => { 'pkgnum' => $pkgnum },
-  extra_sql => ' AND '. $curuser->agentnums_sql,
-}) or die "Unknown pkgnum: $pkgnum";
-
-my $next_bill_date = $cust_pkg->cust_main->next_bill_date;
-
-my $part_pkg = $cust_pkg->part_pkg;
-
-# defaults:
-# sticky on error, then the existing start date if any, then the customer's
-# next bill date, and if none of those, default to now
-my $when = $cgi->param('when');
-
-if (!$when) {
-  if ($cust_pkg->start_date) {
-    $when = 'date';
-  } elsif ($next_bill_date) {
-    $when = 'next_bill_date';
-  } else {
-    $when = 'now';
-  }
-}
-</%init>
index f6fd1e9..73c4deb 100755 (executable)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html, mt("Customer cancelled") &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index 7a501d6..e81e2b4 100755 (executable)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html, mt("Customer suspended") &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index e8ac8d3..99ec70a 100755 (executable)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html, mt("Customer unsuspended") &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index c6310e9..239332d 100644 (file)
@@ -3,7 +3,7 @@
 % } else {
 <& /elements/header-popup.html, "Address range deleted" &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 </BODY>
 </HTML>
index 30856a7..b4d31b3 100755 (executable)
@@ -3,7 +3,7 @@
 % } else {
 <% header('Rate deleted') %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
     </BODY></HTML>
 % }
index 53dbb2f..3cc121c 100644 (file)
@@ -14,7 +14,7 @@ die "access denied"
 my $action = $1;
 my $header = '';
 my $popup = '';
-my $js = 'window.top.location.reload();';
+my $js = 'topreload();';
 
 $cgi->param('ordernum') =~ /^(\d+)$/ or die 'illegal ordernum';
 my $ordernum = $1;
index ee7ba1d..677f0b8 100755 (executable)
@@ -1,6 +1,6 @@
 <% header("Location disabled") %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 </BODY>
 </HTML>
index 1eb4d25..565eb2c 100644 (file)
@@ -3,7 +3,7 @@
 % } else {
 <& /elements/header-popup.html, "Template ${actioned}" &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 </BODY>
 </HTML>
index 0d8417b..8da8495 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <% header('Packages Adjusted') %>
     <SCRIPT TYPE="text/javascript">
-      window.top.location.reload();
+      topreload();
     </SCRIPT>
     </BODY></HTML>
 % }
index eb3b2ef..46ba06a 100755 (executable)
@@ -1,6 +1,6 @@
 <% header(emt("Package $past_method")) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index 2795c11..5bf8962 100644 (file)
@@ -1,6 +1,6 @@
 <% header(emt("Package contact $past_method")) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
diff --git a/httemplate/misc/process/change_pkg_date.html b/httemplate/misc/process/change_pkg_date.html
new file mode 100755 (executable)
index 0000000..3084ec5
--- /dev/null
@@ -0,0 +1,67 @@
+<& /elements/header-popup.html &>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY>
+</HTML>
+<%init>
+
+my $field = $cgi->param('field');
+
+my ($acl, $isstart);
+if ($field eq 'start_date') {
+  $acl = 'Change package start date';
+  $isstart = 1;
+} elsif ($field eq 'contract_end') {
+  $acl = 'Change package contract end date';
+} else {
+  die "Unknown date field";
+}
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right($acl);
+
+$cgi->param('pkgnum') =~ /^(\d+)$/
+  or die "illegal pkgnum";
+my $pkgnum = $1;
+
+my $cust_pkg = qsearchs({
+  table     => 'cust_pkg',
+  addl_from => ' JOIN cust_main USING (custnum) ',
+  hashref   => { 'pkgnum' => $pkgnum },
+  extra_sql => ' AND '. $curuser->agentnums_sql,
+}) or die "Unknown pkgnum: $pkgnum";
+
+my $cust_main = $cust_pkg->cust_main;
+
+my $error;
+my $date_value;
+if ( $cgi->param('when') eq 'now' ) {
+  # blank start means start it the next time billing runs ("Now")
+  # blank contract end means it never ends ("Never")
+  $date_value = '';
+} elsif ( $cgi->param('when') eq 'next_bill_date' ) {
+  $date_value = $cust_main->next_bill_date;
+} elsif ( $cgi->param('when') eq 'date' ) {
+  $date_value = parse_datetime($cgi->param('date_value'));
+}
+
+if ( $isstart && $cust_pkg->setup ) {
+  # shouldn't happen
+  $error = 'This package has already started billing.';
+} else {
+  local $FS::UID::AutoCommit = 0;
+  foreach my $pkg ($cust_pkg, $cust_pkg->supplemental_pkgs) {
+    last if $error;
+    $pkg->set($field, $date_value);
+    $error ||= $pkg->replace;
+  }
+  $error ? dbh->rollback : dbh->commit;
+}
+
+if ( $error ) {
+  $cgi->param('error', $error);
+  print $cgi->redirect($fsurl.'misc/change_pkg_date.html?', $cgi->query_string);
+}
+</%init>
diff --git a/httemplate/misc/process/change_pkg_start.html b/httemplate/misc/process/change_pkg_start.html
deleted file mode 100755 (executable)
index 17a8518..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-<& /elements/header-popup.html &>
-  <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
-  </SCRIPT>
-  </BODY>
-</HTML>
-<%init>
-
-my $curuser = $FS::CurrentUser::CurrentUser;
-die "access denied"
-  unless $curuser->access_right('Change package start date');
-
-$cgi->param('pkgnum') =~ /^(\d+)$/
-  or die "illegal pkgnum";
-my $pkgnum = $1;
-
-my $cust_pkg = qsearchs({
-  table     => 'cust_pkg',
-  addl_from => ' JOIN cust_main USING (custnum) ',
-  hashref   => { 'pkgnum' => $pkgnum },
-  extra_sql => ' AND '. $curuser->agentnums_sql,
-}) or die "Unknown pkgnum: $pkgnum";
-
-my $cust_main = $cust_pkg->cust_main;
-
-my $error;
-my $start_date;
-if ( $cgi->param('when') eq 'now' ) {
-  # start it the next time billing runs
-  $start_date = '';
-} elsif ( $cgi->param('when') eq 'next_bill_date' ) {
-  $start_date = $cust_main->next_bill_date;
-} elsif ( $cgi->param('when') eq 'date' ) {
-  $start_date = parse_datetime($cgi->param('start_date'));
-}
-
-if ( $cust_pkg->setup ) {
-  # shouldn't happen
-  $error = 'This package has already started billing.';
-} else {
-  local $FS::UID::AutoCommit = 0;
-  foreach my $pkg ($cust_pkg, $cust_pkg->supplemental_pkgs) {
-    $pkg->set('start_date', $start_date);
-    $error ||= $pkg->replace;
-  }
-  $error ? dbh->rollback : dbh->commit;
-}
-
-if ( $error ) {
-  $cgi->param('error', $error);
-  print $cgi->redirect($fsurl.'misc/change_pkg_start.html?', $cgi->query_string);
-}
-</%init>
index 721a763..f390609 100644 (file)
@@ -1,4 +1,4 @@
-<SCRIPT TYPE="text/javascript">window.top.location.reload()</SCRIPT>
+<SCRIPT TYPE="text/javascript">topreload()</SCRIPT>
 <%init>
 # XXX ACL?
 die "access denied"
index 675da04..15a3c96 100755 (executable)
@@ -1,6 +1,6 @@
 <% header($msg) %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index 9b7324b..8a7a559 100755 (executable)
@@ -4,7 +4,7 @@
   <% include('/elements/header-popup.html', $title) %>
 
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 
   </BODY>
index 79e6856..cbd0fc0 100644 (file)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html, 'Interface added' &>
 <SCRIPT TYPE="text/javascript">
-  window.top.location.reload();
+  topreload();
 </SCRIPT>
 </BODY></HTML>
 <%init>
index c3b42a8..a4c3423 100644 (file)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html, 'Router added' &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
 </BODY></HTML>
 <%init>
index 2d49f6b..88a1f7f 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <% header("Package recharged") %>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY></HTML>
 %}
index 6940480..7e54262 100755 (executable)
@@ -1,6 +1,6 @@
 <& /elements/header-popup.html &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index 7773b0b..32a2fc5 100755 (executable)
@@ -4,7 +4,7 @@
 %} else {
 <& /elements/header-popup.html, 'Invoice voided' &>
 <SCRIPT TYPE="text/javascript">
-  window.top.location.reload();
+  topreload();
 </SCRIPT>
 </BODY></HTML>
 %}
index 14f5ebb..b7e4df4 100644 (file)
@@ -1,7 +1,7 @@
 % if ($success) {
 <% include('/elements/header-popup.html', 'Reason Merge Success') %>
 <SCRIPT>
-window.top.location.reload()
+topreload()
 </SCRIPT>
 % } else {
 <% include('/elements/header-popup.html', 'Merge Reasons') %>
index 1e71f00..81ba31d 100755 (executable)
@@ -1,7 +1,7 @@
 %if ( $success ) {
 <& /elements/header-popup.html, mt("Credit voided") &>
   <SCRIPT TYPE="text/javascript">
-    window.top.location.reload();
+    topreload();
   </SCRIPT>
   </BODY>
 </HTML>
index 7c231a6..e2ffd12 100644 (file)
@@ -14,7 +14,7 @@
 
                    #payment
                    'Date',
-                   'Order Number',
+                   @on_header,
                    'By',
 
                    #application
@@ -44,7 +44,7 @@
                            ? cardtype($cust_pay->paymask) : '';
                        },
                    sub { time2str('%b %d %Y', shift->get('cust_pay_date') ) },
-                   sub { shift->cust_bill_pay->cust_pay->order_number },
+                   @on_field,
                    sub { shift->cust_bill_pay->cust_pay->otaker },
 
                    sub { sprintf($money_char.'%.2f', shift->amount ) },
@@ -66,7 +66,7 @@
                    '', #payinfo/paymask
                    '', #cardtype
                    'cust_pay_date',
-                   '', #order_number
+                   @on_null, #order_number
                    '', #'otaker',
                    '', #amount
                    '', #line item description
@@ -83,7 +83,7 @@
                    '',
                    '',
                    '',
-                   '',
+                   @on_null,
                    '',
                    '',
                    '',
                          FS::UI::Web::cust_header()
                    ),
                ],
-               'align' => 'rcrlrrlrlll',
-#original value before cardtype & package were added
-#why are there 13 cols?
-#'rcrrlrlllrrcl'.
+               'align' => 'rcrlr'.
+                          $on_align.
+                          'lrlll'.
                           $post_desc_align.
                           'rr'.
                           FS::UI::Web::cust_aligns(),
                               '',
                               '',
                               '',
-                              '',
+                              @on_null,
                               '',
                               '',
                               '',
                               '',
                               '',
                               '',
-                              '',
+                              @on_null,
                               '',
                               '',
                               '',
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
+my @on_header = ();
+my @on_field  = ();
+my @on_null   = ();
+my $on_align  = '';
+if ($cgi->param('show_order_number')) {
+  @on_header = ('Order Number');
+  @on_field = (sub { shift->cust_bill_pay->cust_pay->order_number });
+  @on_null  = ('');
+  $on_align = 'r';
+}
+
 my $conf = new FS::Conf;
 
 my %payby = FS::payby->payby2shortname;
index 536ab29..e466f6a 100755 (executable)
@@ -4,5 +4,4 @@
                 'name_singular' => emt('payment'),
                 'name_verb'     => emt('paid'),
                 'show_card_type' => 1,
-                'show_order_number' => 1,
 &>
index 8662d19..697bdbb 100755 (executable)
@@ -17,7 +17,7 @@
 my %statusaction = (
   'new'        => 'delete',
   'pending'    => 'complete',
-  #'authorized' => '',
+  'authorized' => 'complete',
   'captured'   => 'capture',
   #'declined'   => '',
   #wouldn't need to take action on a done state#'done'
index 03aaedd..1b1be5f 100755 (executable)
@@ -74,6 +74,7 @@ my %cardtype_of = (
   'Amex'      => q['American Express card'],
   'Discover'  => q['Discover card'],
   'Maestro'   => q['Switch', 'Solo', 'Laser'],
+  'Tokenized' => q['Tokenized'],
 );  
 </%shared>
 <%init>
@@ -100,29 +101,30 @@ my $title = '';
 $title = 'Unapplied ' if $unapplied;
 $title .= "\u$name_singular Search Results";
 
-my $link = '';
-if (    ( $curuser->access_right('View invoices') #remove in 2.5 (2.7?)
-          || ($curuser->access_right('View payments') && $table =~ /^cust_pay/)
-          || ($curuser->access_right('View refunds') && $table eq 'cust_refund')
-        )
-     && ! $opt{'disable_link'}
-   )
-{
-
-  my $key;
-  my $q = '';
-  if ( $table eq 'cust_pay_void' ) {
-    $key = 'paynum';
-    $q .= 'void=1;';
-  } elsif ( $table eq /^cust_(\w+)$/ ) {
-    $key = $1.'num';
-  }
-  
-  if ( $key ) {
-    $q .= "$key=";
-    $link = [ "${p}view/$table.html?$q", $key ]
-  }
-}
+###NOT USED???
+#my $link = '';
+#if (    ( $curuser->access_right('View invoices') #remove in 2.5 (2.7?)
+#          || ($curuser->access_right('View payments') && $table =~ /^cust_pay/)
+#          || ($curuser->access_right('View refunds') && $table eq 'cust_refund')
+#        )
+#     && ! $opt{'disable_link'}
+#   )
+#{
+#
+#  my $key;
+#  my $q = '';
+#  if ( $table eq 'cust_pay_void' ) {
+#    $key = 'paynum';
+#    $q .= 'void=1;';
+#  } elsif ( $table eq /^cust_(\w+)$/ ) {
+#    $key = $1.'num';
+#  }
+#  
+#  if ( $key ) {
+#    $q .= "$key=";
+#    $link = [ "${p}view/$table.html?$q", $key ]
+#  }
+#}
 
 my $cust_link = sub {
   my $cust_thing = shift;
@@ -175,12 +177,18 @@ if ( $opt{'pre_header'} ) {
   push @sort_fields, @{ $opt{'pre_fields'} };
 }
 
-my $sub_receipt = sub {
+my $sub_receipt = $opt{'disable_link'} ? '' : sub {
   my $obj = shift;
   my $objnum = $obj->primary_key . '=' . $obj->get($obj->primary_key);
+  my $table = $obj->table;
+  my $void = '';
+  if ($table eq 'cust_pay_void') {
+    $table = 'cust_pay';
+    $void = ';void=1';
+  }
 
   include('/elements/popup_link_onclick.html',
-    'action'  => $p.'view/cust_pay.html?link=popup;'.$objnum,
+    'action'  => $p.'view/'.$table.'.html?link=popup;'.$objnum.$void,
     'actionlabel' => emt('Payment Receipt'),
   );
 };
@@ -218,7 +226,7 @@ push @links, '';
 push @fields, sub { time2str('%b %d %Y', shift->_date ) };
 push @sort_fields, '_date';
 
-if ($opt{'show_order_number'}) {
+if ($cgi->param('show_order_number')) {
   push @header, emt('Order Number');
   $align .= 'r';
   push @links, '';
@@ -326,18 +334,9 @@ if ( $cgi->param('magic') ) {
 
         if ( $subtype ) {
 
-          if ( $subtype eq 'Tokenized' ) {
-
-            $payby_search .= " AND substring($table.payinfo from 1 for 2 ) = '99' ";
-            # XXX should store the cardtype as 'Tokenized' in this case?
-
-          } else {
-
-            my $in_cardtype = $cardtype_of{$subtype}
-              or die "unknown card type $subtype";
-            $payby_search .= " AND $table.paycardtype IN($in_cardtype)";
-
-          }
+          my $in_cardtype = $cardtype_of{$subtype}
+            or die "unknown card type $subtype";
+          $payby_search .= " AND $table.paycardtype IN($in_cardtype)";
 
         }
 
@@ -499,8 +498,6 @@ if ( $cgi->param('magic') ) {
     'addl_from' => $addl_from,
   };
 
-warn Dumper \$sql_query;
-
 } else {
 
   #hmm... is this still used?
index 730db68..806746a 100644 (file)
@@ -151,6 +151,12 @@ Examples:
                 'value' => 1,
   &>
 
+  <& /elements/tr-checkbox.html,
+                'label' => emt('Include order number'),
+                'field' => 'show_order_number',
+                'value' => 1,
+  &>
+
 </TABLE>
 
 % }
index a279f53..b6ee7b3 100644 (file)
@@ -135,8 +135,11 @@ Example:
 
     # sort, link & display properties for fields
 
-    'sort_fields' => [], #optional list of field names or SQL expressions for
-                         # sorts
+    'sort_fields' => [], #optional list of field names or SQL expressions for sorts
+
+    'order_by_sql' => {              #to keep complex SQL expressions out of cgi order_by value,
+      'fieldname' => 'sql snippet',  #  maps fields/sort_fields values to sql snippets
+    }
    
     #listref - each item is the empty string,
     #          or a listref of link and method name to append,
@@ -406,6 +409,12 @@ $order_by = $cgi->param('order_by') if $cgi->param('order_by');
 my $header = [ map { ref($_) ? $_->{'label'} : $_ } @{$opt{header}} ];
 my $rows;
 
+my ($order_by_key,$order_by_desc) = ($order_by =~ /^\s*(.*?)(\s+DESC)?\s*$/i);
+$opt{'order_by_sql'} ||= {};
+$order_by_desc ||= '';
+$order_by = $opt{'order_by_sql'}{$order_by_key} . $order_by_desc
+  if $opt{'order_by_sql'}{$order_by_key};
+
 if ( ref $query ) {
   my @query;
   if (ref($query) eq 'HASH') {
index 111200f..5b330f8 100644 (file)
@@ -81,15 +81,15 @@ a:visited {text-decoration: none}
   <TD>Level
     <& /elements/select.html,
       field => 'min_level',
-      options => [ 0..7 ],
-      labels => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 },
+      options => [ &FS::Log::levelnums ],
+      labels => { &FS::Log::levelmap },
       curr_value => $cgi->param('min_level'),
     &>
      to
     <& /elements/select.html,
       field => 'max_level',
-      options => [ 0..7 ],
-      labels => { map {$_ => $FS::Log::LEVELS[$_]} 0..7 },
+      options => [ &FS::Log::levelnums ],
+      labels => { &FS::Log::levelmap },
       curr_value => $cgi->param('max_level'),
     &>
   </TD>
@@ -128,7 +128,7 @@ a:visited {text-decoration: none}
 <%once>
 my $date_sub = sub { time2str('%Y-%m-%d %T', $_[0]->_date) };
 
-my $level_sub = sub { $FS::Log::LEVELS[$_[0]->level] };
+my $level_sub = sub { $FS::Log::LEVELS{$_[0]->level} };
 
 my $context_sub = sub {
   my $log = shift;
@@ -191,18 +191,15 @@ my $object_link_sub = sub {
   }
 };
 
-my @colors = (
-  '404040', #debug
-  '0000aa', #info
-  '00aa00', #notice
-  'aa0066', #warning
-  '000000', #error
-  'aa0000', #critical
-  'ff0000', #alert
-  'ff0000', #emergency
+my %colors = (
+  0 => '404040', #debug, gray
+  1 => '000000', #info, black
+  3 => '0000aa', #warning, blue
+  4 => 'aa0066', #error, purple
+  5 => 'ff0000', #critical, red
 );
 
-my $color_sub = sub { $colors[ $_[0]->level ]; };
+my $color_sub = sub { $colors{ $_[0]->level }; };
 
 my @contexts = ('', sort FS::log_context->contexts);
 </%once>
@@ -212,10 +209,10 @@ die "access denied"
   unless $curuser->access_right([ 'View system logs', 'Configuration' ]);
 
 my @menubar = ();
-push @menubar, qq(<A HREF="${fsurl}browse/log_email.html" STYLE="text-decoration: underline;">Configure conditions for sending email when logging</A>),
+push @menubar, qq(<A HREF="${fsurl}browse/log_email.html" STYLE="text-decoration: underline;">Configure conditions for sending email when logging</A>);
 
 $cgi->param('min_level', 0) unless defined($cgi->param('min_level'));
-$cgi->param('max_level', 7) unless defined($cgi->param('max_level'));
+$cgi->param('max_level', 5) unless defined($cgi->param('max_level'));
 
 my %search = ();
 $search{'date'} = [ FS::UI::Web::parse_beginning_ending($cgi) ];
index 2347bab..bdcd154 100644 (file)
      field   => 'paid',
 &>
 
+  <& /elements/tr-checkbox.html,
+                'label' => emt('Display order number'),
+                'field' => 'show_order_number',
+                'value' => 1,
+                'cell_style' => 'font-weight: normal', #for consistency
+  &>
+
 <!--
 <TR>
   <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="nottax" VALUE="Y" onClick="nottax_changed(this)" onChange="nottax_change(thid)"></TD>
diff --git a/httemplate/search/report_sqlradius_usage-custnum.html b/httemplate/search/report_sqlradius_usage-custnum.html
new file mode 100644 (file)
index 0000000..a71012d
--- /dev/null
@@ -0,0 +1,71 @@
+<& /elements/header-popup.html, mt($title) &>
+
+<FORM ACTION="sqlradius_usage.html" METHOD="GET" TARGET="_top">
+
+<& /elements/hidden.html,
+  'field' => 'custnum',
+  'value' => $custnum,
+&>
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+% if ( scalar(@exports) == 1 ) {
+<tr><td>
+<& /elements/hidden.html,
+  'field'         => 'exportnum',
+  'value'         => $exports[0]->exportnum,
+&>
+</td></tr>
+% } else {
+<& /elements/tr-select-table.html,
+  'label'         => 'Export', # kind of non-indicative...
+  'table'         => 'part_export',
+  'name_col'      => 'label',
+  'value_col'     => 'exportnum',
+  'records'       => \@exports,
+  'disable_empty' => 1,
+&>
+% }
+<& /elements/tr-input-beginning_ending.html &>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right('Usage: RADIUS sessions');
+  # yes?
+
+my $title = 'Data Usage Report';
+my $custnum;
+if ($cgi->keywords) {
+  ($custnum) = $cgi->keywords;
+} else {
+  $custnum = $cgi->param('custnum');
+}
+$custnum =~ /^(\d+)$/
+  or die "illegal custnum $custnum";
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $curuser->agentnums_sql,
+});
+# get all exports that apply to this customer's services--should be fast, as
+# everything here is indexed
+my @exports = qsearch({
+  'table'     => 'part_export',
+  'select'    => 'DISTINCT part_export.*',
+  'addl_from' => ' JOIN export_svc USING (exportnum)
+                   JOIN cust_svc USING (svcpart)
+                   JOIN cust_pkg USING (pkgnum) ',
+  'extra_sql' => ' WHERE cust_pkg.custnum = '.$custnum,
+});
+@exports = grep { $_->can('usage_sessions') } @exports;
+
+</%init>
index e818fb5..89b6084 100644 (file)
@@ -1,3 +1,4 @@
+%# some overlap with report_sqlradius_usage_custnum.html
 <& /elements/header.html, mt($title) &>
 
 <FORM ACTION="sqlradius_usage.html" METHOD="GET">
index 29ef4c0..08f9b6b 100644 (file)
@@ -39,6 +39,7 @@
                      @svc_fields,
                      @svc_usage,
                    ],
+  'order_by_sql' => $order_by_sql,
   'links'       => [ #( map { $_ ne 'Cust. Status' ? $link_cust : '' }
                      #  FS::UI::Web::cust_header() ),
                      $link_cust,
@@ -59,8 +60,8 @@
 
 my %opt = @_;
 
-die "access denied" unless
-  $FS::CurrentUser::CurrentUser->access_right('List services');
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied" unless $curuser->access_right('List services');
 
 my $title = 'Data Usage Report - '; 
 my $agentnum;
@@ -92,6 +93,40 @@ if ( $ending == 4294967295 ) {
   $title .= time2str('%h %o %Y', $ending);
 }
 
+# can also show a specific customer / service. the main query will handle
+# agent restrictions, but we need a list of the services to ask the export
+# for usage data.
+my ($cust_main, @svc_x);
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  $cust_main = qsearchs( {
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $1 },
+    'extra_sql' => ' AND '. $curuser->agentnums_sql,
+  });
+  die "Customer not found!" unless $cust_main;
+  # then only report on this agent
+  $agentnum = $cust_main->agentnum;
+  @include_agents = ();
+  # and announce that we're doing it
+  $title .= ' - ' . $cust_main->name_short;
+
+  # yes, we'll query the database once for each service the customer has,
+  # even non-radacct'd services. probably less bad than a single query that
+  # pulls records for every service for every customer.
+  foreach my $cust_pkg ($cust_main->all_pkgs) {
+    foreach my $cust_svc ($cust_pkg->cust_svc) {
+      push @svc_x, $cust_svc->svc_x;
+    }
+  }
+}
+foreach ($cgi->param('svcnum')) {
+  if (/^(\d+)$/) {
+    my $cust_svc = FS::cust_svc->by_key($1)
+      or die "service #$1 not found."; # or continue?
+    push @svc_x, $cust_svc->svc_x;
+  }
+}
+
 my $export;
 my %usage_by_username;
 if ( exists($opt{usage_by_username}) ) {
@@ -109,16 +144,28 @@ if ( exists($opt{usage_by_username}) ) {
     or die "exportnum ".$export->exportnum." is type ".$export->exporttype.
            ", not sqlradius";
 
-  my $usage = $export->usage_sessions( {
+  my %usage_param = (
       stoptime_start  => $beginning,
       stoptime_end    => $ending,
       summarize       => 1
-  );
-  # arrayref of hashrefs of
+  );
+  # usage_sessions() returns an arrayref of hashrefs of
   # (username, acctsessiontime, acctinputoctets, acctoutputoctets)
   # (XXX needs to include 'realm' for sqlradius_withdomain)
-  # rearrange to be indexed by username.
+  my $usage;
+  if ( @svc_x ) {
+    # then query once per service
+    $usage = [];
+    foreach my $svc ( @svc_x ) {
+      $usage_param{'svc'} = $svc;
+      push @$usage, @{ $export->usage_sessions(\%usage_param) };
+    }
+  } else {
+    # one query, get everyone's data
+    my $usage = $export->usage_sessions(\%usage_param);
+  }
 
+  # rearrange to be indexed by username.
   foreach (@$usage) {
     my $username = $_->{'username'};
     my @row = (
@@ -171,10 +218,22 @@ my @svc_fields = @{ $svc_fields{$svcdb} };
 my %search_hash = ( 'agentnum' => $agentnum,
                     'exportnum' => $export->exportnum );
 
+if ($cust_main) {
+  $search_hash{'custnum'} = $cust_main->custnum;
+}
+if (@svc_x) {
+  $search_hash{'svcnum'} = [ map { $_->get('svcnum') } @svc_x ];
+}
+
 my $sql_query = $class->search(\%search_hash);
 $sql_query->{'select'}    .= ', part_pkg.pkg';
 $sql_query->{'addl_from'} .= ' LEFT JOIN part_pkg USING (pkgpart)';
 
+if ( @svc_x ) {
+  my $svcnums = join(',', map { $_->get('svcnum') } @svc_x);
+  $sql_query->{'extra_sql'} .= ' AND svcnum IN('.$svcnums.')';
+}
+
 my $link_svc = [ $p.'view/cust_svc.cgi?', 'svcnum' ];
 
 my $link_cust = [ $p.'view/cust_main.cgi?', 'custnum' ];
@@ -182,9 +241,10 @@ my $link_cust = [ $p.'view/cust_main.cgi?', 'custnum' ];
 # columns between the customer name and the usage fields
 my $skip_cols = 1 + scalar(@svc_header);
 
+my $num_rows = FS::Record->scalar_sql($sql_query->{count_query});
 my @footer = (
   '',
-  FS::Record->scalar_sql($sql_query->{count_query}) . ' services',
+  emt('[quant,_1,service]', $num_rows), 
   ('') x $skip_cols,
   map {
     my $i = $_;
@@ -198,4 +258,23 @@ sub bytes_to_gb {
   $_[0] ?  sprintf('%.3f', $_[0] / (1024*1024*1024.0)) : '';
 }
 
+my $conf = new FS::Conf;
+my $order_by_sql = {
+  'name'            => "CASE WHEN cust_main.company IS NOT NULL
+                                  AND cust_main.company != ''
+                             THEN CONCAT(cust_main.company,' (',cust_main.last,', ',cust_main.first,')')
+                             ELSE CONCAT(cust_main.last,', ',cust_main.first)
+                        END",
+  'display_custnum' => $conf->exists('cust_main-default_agent_custid')
+                       ? "CASE WHEN cust_main.agent_custid IS NOT NULL
+                                    AND cust_main.agent_custid != ''
+                                    AND cust_main.agent_custid ". regexp_sql. " '^[0-9]+\$'
+                               THEN CAST(cust_main.agent_custid AS BIGINT)
+                               ELSE cust_main.custnum
+                          END"
+                       : "custnum",
+};
+
+#warn Dumper \%usage_by_username;
+
 </%init>
index 9e194f4..7c7303b 100644 (file)
@@ -391,8 +391,17 @@ my @menu = (
       url   => "search/report_svc_acct.html?custnum=$custnum",
     },
     {
+      label => 'View data usage',
+      popup => "search/report_sqlradius_usage-custnum.html?$custnum",
+      acl   => 'Usage: RADIUS sessions',
+      actionlabel => 'Data usage report',
+      width   => 480,
+      height  => 245,
+    },
+    {
       label => 'View CDRs',
       url   => "search/report_cdr.html?custnum=$custnum",
+      # XXX should have a condition that the customer has any CDR packages
     },
   ],
   [
index 97011c3..3f629e1 100644 (file)
       <TR>
         <TD COLSPAN=<%$opt{colspan}%>>
           <FONT SIZE=-1>
+%           if ( !$cust_pkg->change_to_pkgnum # because on a technical level, change won't propagate,
+%                                             # and there's not really a use case worth making that work
+%                 and $part_pkg->freq # technically possible to have contract_end w/o freq, but nonsensical
+%                 and $curuser->access_right('Change package contract end date')
+%           ) {
+                (&nbsp;<% pkg_change_contract_end_link($cust_pkg) %>&nbsp;)
+                <BR>
+%           }
 %           if ( $cust_pkg->change_to_pkgnum ) {
 %               # then you can modify the package change
 %               if ( $curuser->access_right('Change customer package') ) {
           <% pkg_status_row_if($cust_pkg, emt('Start billing'), 'start_date', %opt) %>
           <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
 
-%         if ( !$opt{no_links}
-%               and !$change_from
-%               and !$supplemental # can be changed from its main package
-%               and $curuser->access_right('Change package start date') )
-%         {
-
-        <TR>
-          <TD COLSPAN=<%$opt{colspan}%>>
-            <FONT SIZE=-1>
-            (&nbsp;<% pkg_change_start_link($cust_pkg) %>&nbsp;)
-            </FONT>
-          </TD>
-        </TR>
-%         }
-          
 %       } 
 %
 %     } else { #setup
         <TR>
           <TD COLSPAN=<%$opt{colspan}%>>
             <FONT SIZE=-1>
+
+% #change date links
+%           if ( !$change_from and !$supplemental ) {
+%             my $has_date_links = 0;
+%             if ( !$cust_pkg->get('setup')
+%                   and $curuser->access_right('Change package start date')
+%             ) {
+            (&nbsp;<% pkg_change_start_link($cust_pkg) %>&nbsp;)
+%               $has_date_links = 1;
+%             }
+%             if ( !$cust_pkg->change_to_pkgnum # because on a technical level, change won't propagate,
+%                                               # and there's not really a use case worth making that work
+%                   and $curuser->access_right('Change package contract end date')
+%             ) {
+            (&nbsp;<% pkg_change_contract_end_link($cust_pkg) %>&nbsp;)
+%               $has_date_links = 1;
+%             }
+%             if ($has_date_links) {
+            <BR>
+%             }
+%           }
+
 % # action links
 %           if ( $change_from ) {
 %               # nothing
@@ -745,7 +760,7 @@ sub pkg_change_later_link {
 sub pkg_change_start_link {
   my $cust_pkg = shift;
   include( '/elements/popup_link-cust_pkg.html',
-    'action'      => $p . 'misc/change_pkg_start.html',
+    'action'      => $p . 'misc/change_pkg_date.html?field=start_date',
     'label'       => emt('Set start date'),
     'actionlabel' => emt('Set start of billing for'),
     'cust_pkg'    => $cust_pkg,
@@ -754,6 +769,18 @@ sub pkg_change_start_link {
   )
 }
 
+sub pkg_change_contract_end_link {
+  my $cust_pkg = shift;
+  include( '/elements/popup_link-cust_pkg.html',
+    'action'      => $p . 'misc/change_pkg_date.html?field=contract_end',
+    'label'       => emt('Set contract end'),
+    'actionlabel' => emt('Set contract end for'),
+    'cust_pkg'    => $cust_pkg,
+    'width'       => 510,
+    'height'      => 310,
+  )
+}
+
 sub svc_recharge_link {
   include( '/elements/popup_link-cust_svc.html',
              'action'      => $p. 'misc/recharge_svc.html',
index 3114923..cf7ef7c 100644 (file)
@@ -12,6 +12,7 @@ my %statusaction = (
   'new'        => 'delete',
   'thirdparty' => 'delete',
   'pending'    => 'complete',
+  'authorized' => 'complete',
   'captured'   => 'capture',
 );