Merge branch 'FREESIDE_3_BRANCH' of git.freeside.biz:/home/git/freeside into FREESIDE...
authorIvan Kohler <ivan@freeside.biz>
Fri, 2 Sep 2016 00:00:22 +0000 (17:00 -0700)
committerIvan Kohler <ivan@freeside.biz>
Fri, 2 Sep 2016 00:00:22 +0000 (17:00 -0700)
40 files changed:
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI_XMLRPC.pm
FS/FS/Schema.pm
FS/FS/Upgrade.pm
FS/FS/cust_bill_pkg_tax_location.pm
FS/FS/cust_location.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_main/Packages.pm
FS/FS/cust_pay_pending.pm
FS/FS/cust_pkg.pm
FS/FS/part_event/Action/rt_ticket.pm [new file with mode: 0644]
FS/FS/part_event/Condition.pm
FS/FS/part_event/Condition/hasnt_pkg_class_cancelled.pm
FS/FS/part_event/Condition/hasnt_pkgpart_cancelled.pm
FS/FS/password_history.pm
bin/part_pkg-clone_fix_options [new file with mode: 0755]
bin/xmlrpc-customer_recurring [new file with mode: 0755]
conf/invoice_latex
fs_selfservice/FS-SelfService/SelfService.pm
httemplate/edit/cust_pay_pending.html
httemplate/edit/process/cust_pay_pending.html
httemplate/edit/process/part_event.html
httemplate/elements/menu.html
httemplate/elements/select-rt-queue.html
httemplate/elements/tr-freq.html
httemplate/elements/tr-select-rt-queue.html [new file with mode: 0644]
httemplate/search/cust_bill_pay_pkg.html
httemplate/search/cust_pay.html
httemplate/search/cust_pay_pending.html
httemplate/search/cust_pkg-date.html
httemplate/search/cust_pkg_churn.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/report_cust_bill_pay_pkg.html
httemplate/search/report_cust_pkg-date.html [new file with mode: 0755]
httemplate/search/sqlradius_usage.html
httemplate/search/svc_broadband.cgi
httemplate/view/cust_main/billing.html
httemplate/view/cust_main/payment_history/pending_payment.html

index 630346a..23fbf6c 100644 (file)
@@ -645,6 +645,29 @@ sub customer_info_short {
          };
 }
 
+sub customer_recurring {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my %return;
+
+  my $conf = new FS::Conf;
+
+  my $search = { 'custnum' => $custnum };
+  $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
+  my $cust_main = qsearchs('cust_main', $search )
+    or return { 'error' => "customer_info_short: unknown custnum $custnum" };
+
+  $return{'display_recurring'} = [ $cust_main->display_recurring ];
+
+  return { 'error'          => '',
+           'custnum'        => $custnum,
+           %return,
+         };
+}
+
 sub billing_history {
   my $p = shift;
 
index 97019d1..7a1fc3e 100644 (file)
@@ -111,6 +111,7 @@ sub ss2clientapi {
   'switch_acct'               => 'MyAccount/switch_acct',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
+  'customer_recurring'        => 'MyAccount/customer_recurring',
 
   'contact_passwd'            => 'MyAccount/contact/contact_passwd',
   'list_contacts'             => 'MyAccount/contact/list_contacts',
index 980cd21..b7ec7df 100644 (file)
@@ -1713,7 +1713,7 @@ sub tables_hashref {
     'cust_pay_pending' => {
       'columns' => [
         'paypendingnum','serial',      '',  '', '', '',
-        'custnum',      'int',         '',  '', '', '', 
+        'custnum',      'int',     'NULL',  '', '', '', 
         'paid',         @money_type,            '', '', 
         '_date',        @date_type,             '', '', 
         'payby',        'char',        '',   4, '', '', #CARD/BILL/COMP, should
index 5b27505..8b7d733 100644 (file)
@@ -329,6 +329,9 @@ sub upgrade_data {
 
   tie my %hash, 'Tie::IxHash', 
 
+    #fix whitespace - before cust_main
+    'cust_location' => [],
+
     #cust_main (remove paycvv from history)
     'cust_main' => [],
 
@@ -444,9 +447,6 @@ sub upgrade_data {
     #mark certain taxes as system-maintained,
     # and fix whitespace
     'cust_main_county' => [],
-
-    #fix whitespace
-    'cust_location' => [],
   ;
 
   \%hash;
index f16e930..0e51000 100644 (file)
@@ -341,7 +341,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 481ebb1..67a5e3e 100644 (file)
@@ -14,6 +14,12 @@ use FS::cust_main_county;
 use FS::part_export;
 use FS::GeocodeCache;
 
+# Essential fields. Can't be modified in place, will be considered in
+# deciding if a location is "new", and (because of that) can't have
+# leading/trailing whitespace.
+my @essential = (qw(custnum address1 address2 city county state zip country
+  location_number location_type location_kind disabled));
+
 $import = 0;
 
 $DEBUG = 0;
@@ -143,9 +149,6 @@ sub find_or_insert {
 
   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
 
-  my @essential = (qw(custnum address1 address2 city county state zip country
-    location_number location_type location_kind disabled));
-
   if ($conf->exists('cust_main-no_city_in_address')) {
     warn "Warning: passed city to find_or_insert when cust_main-no_city_in_address is configured, ignoring it"
       if $self->get('city');
@@ -346,9 +349,9 @@ 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));
+  # whitespace in essential fields leads to problems figuring out if a
+  # record is "new"; get rid of it.
+  $self->trim_whitespace(@essential);
 
   my $error = 
     $self->ut_numbern('locationnum')
@@ -907,7 +910,9 @@ sub _upgrade_data {
 
   # trim whitespace on records that need it
   local $allow_location_edit = 1;
-  foreach my $field (qw(city county state country district)) {
+  foreach my $field (@essential) {
+    next if $field eq 'custnum';
+    next if $field eq 'disabled';
     foreach my $location (qsearch({
       table => 'cust_location',
       extra_sql => " WHERE $field LIKE ' %' OR $field LIKE '% '"
index 8ebad53..5d35fc2 100644 (file)
@@ -5,7 +5,7 @@ use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Data::Dumper;
 use Business::CreditCard 0.35;
-use FS::UID qw( dbh );
+use FS::UID qw( dbh myconnect );
 use FS::Record qw( qsearch qsearchs );
 use FS::Misc qw( send_email );
 use FS::payby;
@@ -1731,6 +1731,7 @@ sub realtime_verify_bop {
   my $self = shift;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop');
 
   my %options = ();
   if (ref($_[0]) eq 'HASH') {
@@ -1836,29 +1837,14 @@ sub realtime_verify_bop {
   # run transaction(s)
   ###
 
-  warn "claiming mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
-  $self->select_for_update; #mutex ... just until we get our pending record in
-  warn "obtained mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
-
-  #the checks here are intended to catch concurrent payments
-  #double-form-submission prevention is taken care of in cust_pay_pending::check
-
-  #also check and make sure there aren't *other* pending payments for this cust
-
-  my @pending = qsearch('cust_pay_pending', {
-    'custnum' => $self->custnum,
-    'status'  => { op=>'!=', value=>'done' } 
-  });
-
-  return "A payment is already being processed for this customer (".
-         join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
-         "); verification transaction aborted."
-    if scalar(@pending);
-
-  #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
+  my $error;
+  my $transaction; #need this back so we can do _tokenize_card
+  # don't mutex the customer here, because they might be uncommitted. and
+  # this is only verification. it doesn't matter if they have other
+  # unfinished verifications.
 
   my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'           => $self->custnum,
+    'custnum_pending'   => 1,
     'paid'              => '1.00',
     '_date'             => '',
     'payby'             => $bop_method2payby{'CC'},
@@ -1875,220 +1861,254 @@ sub realtime_verify_bop {
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
 
-  warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n"
-    if $DEBUG > 1;
-  my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
-  return $cpp_new_err if $cpp_new_err;
+  IMMEDIATE: {
+    # open a separate handle for creating/updating the cust_pay_pending
+    # record
+    local $FS::UID::dbh = myconnect();
+    local $FS::UID::AutoCommit = 1;
+
+    # if this is an existing customer (and we can tell now because
+    # this is a fresh transaction), it's safe to assign their custnum
+    # to the cust_pay_pending record, and then the verification attempt
+    # will remain linked to them even if it fails.
+    if ( FS::cust_main->by_key($self->custnum) ) {
+      $cust_pay_pending->set('custnum', $self->custnum);
+    }
 
-  warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n"
-    if $DEBUG > 1;
-  warn Dumper($cust_pay_pending) if $DEBUG > 2;
+    warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n"
+      if $DEBUG > 1;
 
-  my $transaction = new $namespace( $payment_gateway->gateway_module,
-                                    $self->_bop_options(\%options),
-                                  );
+    # if this fails, just return; everything else will still allow the
+    # cust_pay_pending to have its custnum set later
+    my $cpp_new_err = $cust_pay_pending->insert;
+    return $cpp_new_err if $cpp_new_err;
 
-  $transaction->content(
-    'type'           => 'CC',
-    $self->_bop_auth(\%options),          
-    'action'         => 'Authorization Only',
-    'description'    => $options{'description'},
-    'amount'         => '1.00',
-    #'invoice_number' => $options{'invnum'},
-    'customer_id'    => $self->custnum,
-    %$bop_content,
-    'reference'      => $cust_pay_pending->paypendingnum, #for now
-    'callback_url'   => $payment_gateway->gateway_callback_url,
-    'cancel_url'     => $payment_gateway->gateway_cancel_url,
-    'email'          => $email,
-    %content, #after
-  );
+    warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n"
+      if $DEBUG > 1;
+    warn Dumper($cust_pay_pending) if $DEBUG > 2;
 
-  $cust_pay_pending->status('pending');
-  my $cpp_pending_err = $cust_pay_pending->replace;
-  return $cpp_pending_err if $cpp_pending_err;
+    $transaction = new $namespace( $payment_gateway->gateway_module,
+                                   $self->_bop_options(\%options),
+                                    );
 
-  warn Dumper($transaction) if $DEBUG > 2;
+    $transaction->content(
+      'type'           => 'CC',
+      $self->_bop_auth(\%options),          
+      'action'         => 'Authorization Only',
+      'description'    => $options{'description'},
+      'amount'         => '1.00',
+      #'invoice_number' => $options{'invnum'},
+      'customer_id'    => $self->custnum,
+      %$bop_content,
+      'reference'      => $cust_pay_pending->paypendingnum, #for now
+      'callback_url'   => $payment_gateway->gateway_callback_url,
+      'cancel_url'     => $payment_gateway->gateway_cancel_url,
+      'email'          => $email,
+      %content, #after
+    );
 
-  unless ( $BOP_TESTING ) {
-    $transaction->test_transaction(1)
-      if $conf->exists('business-onlinepayment-test_transaction');
-    $transaction->submit();
-  } else {
-    if ( $BOP_TESTING_SUCCESS ) {
-      $transaction->is_success(1);
-      $transaction->authorization('fake auth');
+    $cust_pay_pending->status('pending');
+    my $cpp_pending_err = $cust_pay_pending->replace;
+    return $cpp_pending_err if $cpp_pending_err;
+
+    warn Dumper($transaction) if $DEBUG > 2;
+
+    unless ( $BOP_TESTING ) {
+      $transaction->test_transaction(1)
+        if $conf->exists('business-onlinepayment-test_transaction');
+      $transaction->submit();
     } else {
-      $transaction->is_success(0);
-      $transaction->error_message('fake failure');
+      if ( $BOP_TESTING_SUCCESS ) {
+        $transaction->is_success(1);
+        $transaction->authorization('fake auth');
+      } else {
+        $transaction->is_success(0);
+        $transaction->error_message('fake failure');
+      }
     }
-  }
 
-  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop');
+    if ( $transaction->is_success() ) {
 
-  if ( $transaction->is_success() ) {
+      $cust_pay_pending->status('authorized');
+      my $cpp_authorized_err = $cust_pay_pending->replace;
+      return $cpp_authorized_err if $cpp_authorized_err;
 
-    $cust_pay_pending->status('authorized');
-    my $cpp_authorized_err = $cust_pay_pending->replace;
-    return $cpp_authorized_err if $cpp_authorized_err;
+      my $auth = $transaction->authorization;
+      my $ordernum = $transaction->can('order_number')
+                     ? $transaction->order_number
+                     : '';
 
-    my $auth = $transaction->authorization;
-    my $ordernum = $transaction->can('order_number')
-                   ? $transaction->order_number
-                   : '';
+      my $reverse = new $namespace( $payment_gateway->gateway_module,
+                                    $self->_bop_options(\%options),
+                                  );
 
-    my $reverse = new $namespace( $payment_gateway->gateway_module,
-                                  $self->_bop_options(\%options),
-                                );
+      $reverse->content( 'action'        => 'Reverse Authorization',
+                         $self->_bop_auth(\%options),          
 
-    $reverse->content( 'action'        => 'Reverse Authorization',
-                       $self->_bop_auth(\%options),          
+                         # B:OP
+                         'amount'        => '1.00',
+                         'authorization' => $transaction->authorization,
+                         'order_number'  => $ordernum,
 
-                       # B:OP
-                       'amount'        => '1.00',
-                       'authorization' => $transaction->authorization,
-                       'order_number'  => $ordernum,
+                         # vsecure
+                         'result_code'   => $transaction->result_code,
+                         'txn_date'      => $transaction->txn_date,
 
-                       # vsecure
-                       'result_code'   => $transaction->result_code,
-                       'txn_date'      => $transaction->txn_date,
+                         %content,
+                       );
+      $reverse->test_transaction(1)
+        if $conf->exists('business-onlinepayment-test_transaction');
+      $reverse->submit();
 
-                       %content,
-                     );
-    $reverse->test_transaction(1)
-      if $conf->exists('business-onlinepayment-test_transaction');
-    $reverse->submit();
+      if ( $reverse->is_success ) {
 
-    if ( $reverse->is_success ) {
+        $cust_pay_pending->status('done');
+        $cust_pay_pending->statustext('reversed');
+        my $cpp_reversed_err = $cust_pay_pending->replace;
+        return $cpp_reversed_err if $cpp_reversed_err;
 
-      $cust_pay_pending->status('done');
-      my $cpp_authorized_err = $cust_pay_pending->replace;
-      return $cpp_authorized_err if $cpp_authorized_err;
+      } else {
 
-    } else {
+        my $e = "Authorization successful but reversal failed, custnum #".
+                $self->custnum. ': '.  $reverse->result_code.
+                ": ". $reverse->error_message;
+        $log->warning($e);
+        warn $e;
+        return $e;
 
-      my $e = "Authorization successful but reversal failed, custnum #".
-              $self->custnum. ': '.  $reverse->result_code.
-              ": ". $reverse->error_message;
-      $log->warning($e);
-      warn $e;
-      return $e;
+      }
 
-    }
+      ### Address Verification ###
+      #
+      # Single-letter codes vary by cardtype.
+      #
+      # Erring on the side of accepting cards if avs is not available,
+      # only rejecting if avs occurred and there's been an explicit mismatch
+      #
+      # Charts below taken from vSecure documentation,
+      #    shows codes for Amex/Dscv/MC/Visa
+      #
+      # ACCEPTABLE AVS RESPONSES:
+      # Both Address and 5-digit postal code match Y A Y Y
+      # Both address and 9-digit postal code match Y A X Y
+      # United Kingdom â€“ Address and postal code match _ _ _ F
+      # International transaction â€“ Address and postal code match _ _ _ D/M
+      #
+      # ACCEPTABLE, BUT ISSUE A WARNING:
+      # Ineligible transaction; or message contains a content error _ _ _ E
+      # System unavailable; retry R U R R
+      # Information unavailable U W U U
+      # Issuer does not support AVS S U S S
+      # AVS is not applicable _ _ _ S
+      # Incompatible formats â€“ Not verified _ _ _ C
+      # Incompatible formats â€“ Address not verified; postal code matches _ _ _ P
+      # International transaction â€“ address not verified _ G _ G/I
+      #
+      # UNACCEPTABLE AVS RESPONSES:
+      # Only Address matches A Y A A
+      # Only 5-digit postal code matches Z Z Z Z
+      # Only 9-digit postal code matches Z Z W W
+      # Neither address nor postal code matches N N N N
+
+      if (my $avscode = uc($transaction->avs_code)) {
+
+        # map codes to accept/warn/reject
+        my $avs = {
+          'American Express card' => {
+            'A' => 'r',
+            'N' => 'r',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+          'Discover card' => {
+            'A' => 'a',
+            'G' => 'w',
+            'N' => 'r',
+            'U' => 'w',
+            'W' => 'w',
+            'Y' => 'r',
+            'Z' => 'r',
+          },
+          'MasterCard' => {
+            'A' => 'r',
+            'N' => 'r',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'W' => 'r',
+            'X' => 'a',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+          'VISA card' => {
+            'A' => 'r',
+            'C' => 'w',
+            'D' => 'a',
+            'E' => 'w',
+            'F' => 'a',
+            'G' => 'w',
+            'I' => 'w',
+            'M' => 'a',
+            'N' => 'r',
+            'P' => 'w',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'W' => 'r',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+        };
+        my $cardtype = cardtype($content{card_number});
+        if ($avs->{$cardtype}) {
+          my $avsact = $avs->{$cardtype}->{$avscode};
+          my $warning = '';
+          if ($avsact eq 'r') {
+            return "AVS code verification failed, cardtype $cardtype, code $avscode";
+          } elsif ($avsact eq 'w') {
+            $warning = "AVS did not occur, cardtype $cardtype, code $avscode";
+          } elsif (!$avsact) {
+            $warning = "AVS code unknown, cardtype $cardtype, code $avscode";
+          } # else $avsact eq 'a'
+          if ($warning) {
+            $log->warning($warning);
+            warn $warning;
+          }
+        } # else $cardtype avs handling not implemented
+      } # else !$transaction->avs_code
+
+    } else { # is not success
+
+      # status is 'done' not 'declined', as in _realtime_bop_result
+      $cust_pay_pending->status('done');
+      $error = $transaction->error_message || 'Unknown error';
+      $cust_pay_pending->statustext($error);
+      # could also record failure_status here,
+      #   but it's not supported by B::OP::vSecureProcessing...
+      #   need a B::OP module with (reverse) auth only to test it with
+      my $cpp_declined_err = $cust_pay_pending->replace;
+      return $cpp_declined_err if $cpp_declined_err;
 
-    ### Address Verification ###
-    #
-    # Single-letter codes vary by cardtype.
-    #
-    # Erring on the side of accepting cards if avs is not available,
-    # only rejecting if avs occurred and there's been an explicit mismatch
-    #
-    # Charts below taken from vSecure documentation,
-    #    shows codes for Amex/Dscv/MC/Visa
-    #
-    # ACCEPTABLE AVS RESPONSES:
-    # Both Address and 5-digit postal code match Y A Y Y
-    # Both address and 9-digit postal code match Y A X Y
-    # United Kingdom â€“ Address and postal code match _ _ _ F
-    # International transaction â€“ Address and postal code match _ _ _ D/M
-    #
-    # ACCEPTABLE, BUT ISSUE A WARNING:
-    # Ineligible transaction; or message contains a content error _ _ _ E
-    # System unavailable; retry R U R R
-    # Information unavailable U W U U
-    # Issuer does not support AVS S U S S
-    # AVS is not applicable _ _ _ S
-    # Incompatible formats â€“ Not verified _ _ _ C
-    # Incompatible formats â€“ Address not verified; postal code matches _ _ _ P
-    # International transaction â€“ address not verified _ G _ G/I
-    #
-    # UNACCEPTABLE AVS RESPONSES:
-    # Only Address matches A Y A A
-    # Only 5-digit postal code matches Z Z Z Z
-    # Only 9-digit postal code matches Z Z W W
-    # Neither address nor postal code matches N N N N
-
-    if (my $avscode = uc($transaction->avs_code)) {
-
-      # map codes to accept/warn/reject
-      my $avs = {
-        'American Express card' => {
-          'A' => 'r',
-          'N' => 'r',
-          'R' => 'w',
-          'S' => 'w',
-          'U' => 'w',
-          'Y' => 'a',
-          'Z' => 'r',
-        },
-        'Discover card' => {
-          'A' => 'a',
-          'G' => 'w',
-          'N' => 'r',
-          'U' => 'w',
-          'W' => 'w',
-          'Y' => 'r',
-          'Z' => 'r',
-        },
-        'MasterCard' => {
-          'A' => 'r',
-          'N' => 'r',
-          'R' => 'w',
-          'S' => 'w',
-          'U' => 'w',
-          'W' => 'r',
-          'X' => 'a',
-          'Y' => 'a',
-          'Z' => 'r',
-        },
-        'VISA card' => {
-          'A' => 'r',
-          'C' => 'w',
-          'D' => 'a',
-          'E' => 'w',
-          'F' => 'a',
-          'G' => 'w',
-          'I' => 'w',
-          'M' => 'a',
-          'N' => 'r',
-          'P' => 'w',
-          'R' => 'w',
-          'S' => 'w',
-          'U' => 'w',
-          'W' => 'r',
-          'Y' => 'a',
-          'Z' => 'r',
-        },
-      };
-      my $cardtype = cardtype($content{card_number});
-      if ($avs->{$cardtype}) {
-        my $avsact = $avs->{$cardtype}->{$avscode};
-        my $warning = '';
-        if ($avsact eq 'r') {
-          return "AVS code verification failed, cardtype $cardtype, code $avscode";
-        } elsif ($avsact eq 'w') {
-          $warning = "AVS did not occur, cardtype $cardtype, code $avscode";
-        } elsif (!$avsact) {
-          $warning = "AVS code unknown, cardtype $cardtype, code $avscode";
-        } # else $avsact eq 'a'
-        if ($warning) {
-          $log->warning($warning);
-          warn $warning;
-        }
-      } # else $cardtype avs handling not implemented
-    } # else !$transaction->avs_code
+    }
 
-  } else { # is not success
+  } # end of IMMEDIATE; we now have our $error and $transaction
 
-    # status is 'done' not 'declined', as in _realtime_bop_result
-    $cust_pay_pending->status('done');
-    $cust_pay_pending->statustext( $transaction->error_message || 'Unknown error' );
-    # could also record failure_status here,
-    #   but it's not supported by B::OP::vSecureProcessing...
-    #   need a B::OP module with (reverse) auth only to test it with
-    my $cpp_declined_err = $cust_pay_pending->replace;
-    return $cpp_declined_err if $cpp_declined_err;
+  ###
+  # Save the custnum (as part of the main transaction, so it can reference
+  # the cust_main)
+  ###
 
+  if (!$cust_pay_pending->custnum) {
+    $cust_pay_pending->set('custnum', $self->custnum);
+    my $set_custnum_err = $cust_pay_pending->replace;
+    if ($set_custnum_err) {
+      $log->error($set_custnum_err);
+      $error ||= $set_custnum_err;
+      # but if there was a real verification error also, return that one
+    }
   }
 
   ###
@@ -2113,7 +2133,9 @@ sub realtime_verify_bop {
   # result handling
   ###
 
-  $transaction->is_success() ? '' : $transaction->error_message();
+  # $error contains the transaction error_message, if is_success was false.
+  return $error;
 
 }
 
index 8f96f81..4d0eee7 100644 (file)
@@ -706,6 +706,104 @@ sub num_usage_pkgs {
   FS::Record->scalar_sql($sql, $self->custnum);
 }
 
+=item display_recurring
+
+Returns an array of hash references, one for each recurring freq
+on billable customer packages, with keys of freq, freq_pretty and amount
+(the amount that this customer will next be charged at the given frequency.)
+
+Results will be numerically sorted by freq.
+
+Only intended for display purposes, not used for actual billing.
+
+=cut
+
+sub display_recurring {
+  my $cust_main = shift;
+
+  my $sth = dbh->prepare("
+    SELECT DISTINCT freq FROM cust_pkg LEFT JOIN part_pkg USING (pkgpart)
+      WHERE freq IS NOT NULL AND freq != '0'
+        AND ( cancel IS NULL OR cancel = 0 )
+        AND custnum = ?
+  ") or die $DBI::errstr;
+
+  $sth->execute($cust_main->custnum) or die $sth->errstr;
+
+  #not really a numeric sort because freqs can actually be all sorts of things
+  # but good enough for the 99% cases of ordering monthly quarterly annually
+  my @freqs = sort { $a <=> $b } map { $_->[0] } @{ $sth->fetchall_arrayref };
+
+  $sth->finish;
+
+  my @out;
+
+  foreach my $freq (@freqs) {
+
+    my @cust_pkg = qsearch({
+      'table'     => 'cust_pkg',
+      'addl_from' => 'LEFT JOIN part_pkg USING (pkgpart)',
+      'hashref'   => { 'custnum' => $cust_main->custnum, },
+      'extra_sql' => 'AND ( cancel IS NULL OR cancel = 0 )
+                      AND freq = '. dbh->quote($freq),
+      'order_by'  => 'ORDER BY COALESCE(start_date,0), pkgnum', # to ensure old pkgs come before change_to_pkg
+    }) or next;
+
+    my $freq_pretty = $cust_pkg[0]->part_pkg->freq_pretty;
+
+    my $amount = 0;
+    my $skip_pkg = {};
+    foreach my $cust_pkg (@cust_pkg) {
+      my $part_pkg = $cust_pkg->part_pkg;
+      next if $cust_pkg->susp
+           && ! $cust_pkg->option('suspend_bill')
+           && ( ! $part_pkg->option('suspend_bill')
+                || $cust_pkg->option('no_suspend_bill')
+              );
+
+      #pkg change handling
+      next if $skip_pkg->{$cust_pkg->pkgnum};
+      if ($cust_pkg->change_to_pkgnum) {
+        #if change is on or before next bill date, use new pkg
+        next if $cust_pkg->expire <= $cust_pkg->bill;
+        #if change is after next bill date, use old (this) pkg
+        $skip_pkg->{$cust_pkg->change_to_pkgnum} = 1;
+      }
+
+      my $pkg_amount = 0;
+
+      #add recurring amounts for this package and its billing add-ons
+      foreach my $l_part_pkg ( $part_pkg->self_and_bill_linked ) {
+        $pkg_amount += $l_part_pkg->base_recur($cust_pkg);
+      }
+
+      #subtract amounts for any active discounts
+      #(there should only be one at the moment, otherwise this makes no sense)
+      foreach my $cust_pkg_discount ( $cust_pkg->cust_pkg_discount_active ) {
+        my $discount = $cust_pkg_discount->discount;
+        #and only one of these for each
+        $pkg_amount -= $discount->amount;
+        $pkg_amount -= $amount * $discount->percent/100;
+      }
+
+      $pkg_amount *= ( $cust_pkg->quantity || 1 );
+
+      $amount += $pkg_amount;
+
+    } #foreach $cust_pkg
+
+    next unless $amount;
+    push @out, {
+      'freq'        => $freq,
+      'freq_pretty' => $freq_pretty,
+      'amount'      => $amount,
+    };
+
+  } #foreach $freq
+
+  return @out;
+}
+
 =back
 
 =head1 BUGS
index 3a54e2d..4874802 100644 (file)
@@ -213,7 +213,7 @@ sub check {
 
   my $error = 
     $self->ut_numbern('paypendingnum')
-    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
+    || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
     || $self->ut_money('paid')
     || $self->ut_numbern('_date')
     || $self->ut_textn('payunique')
@@ -232,6 +232,10 @@ sub check {
   ;
   return $error if $error;
 
+  if (!$self->custnum and !$self->get('custnum_pending')) {
+    return 'custnum required';
+  }
+
   $self->_date(time) unless $self->_date;
 
   # UNIQUE index should catch this too, without race conditions, but this
@@ -451,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 101dd81..548d000 100644 (file)
@@ -6108,6 +6108,12 @@ sub _upgrade_data {  # class method
   }
 }
 
+# will autoload in v4+
+sub rt_field_charge {
+  my $self = shift;
+  qsearch('rt_field_charge',{ 'pkgnum' => $self->pkgnum });
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/part_event/Action/rt_ticket.pm b/FS/FS/part_event/Action/rt_ticket.pm
new file mode 100644 (file)
index 0000000..4a2b993
--- /dev/null
@@ -0,0 +1,100 @@
+package FS::part_event::Action::rt_ticket;
+
+use strict;
+use base qw( FS::part_event::Action );
+use FS::Record qw( qsearchs );
+use FS::msg_template;
+
+sub description { 'Open an RT ticket for the customer' }
+
+#need to be valid for msg_template substitution
+sub eventtable_hashref {
+    { 'cust_main'      => 1,
+      'cust_bill'      => 1,
+      'cust_pkg'       => 1,
+      'cust_pay'       => 1,
+      'svc_acct'       => 1,
+    };
+}
+
+sub option_fields {
+  (
+    'msgnum'    => { 'label'    => 'Template',
+                     'type'     => 'select-table',
+                     'table'    => 'msg_template',
+                     'name_col' => 'msgname',
+                     'hashref'  => { disabled => '' },
+                     'disable_empty' => 1,
+                   },
+    'queueid'   => { 'label' => 'Queue',
+                     'type'  => 'select-rt-queue',
+                   },
+    'requestor' => { 'label'   => 'Requestor',
+                     'type'    => 'select',
+                     'options' => [ 0, 1 ],
+                     'labels'  => {
+                       0 => 'Customer\'s invoice address',
+                       1 => 'Template From: address',
+                     },
+                   },
+
+  );
+}
+
+sub default_weight { 59; }
+
+sub do_action {
+
+  my( $self, $object ) = @_;
+
+  my $cust_main = $self->cust_main($object)
+    or die "Could not load cust_main";
+
+  my $msgnum = $self->option('msgnum');
+  my $msg_template = qsearchs('msg_template', { 'msgnum' => $msgnum } )
+    or die "Template $msgnum not found";
+
+  my $queueid = $self->option('queueid')
+    or die "No queue specified";
+
+  # technically this only works if create_ticket is implemented,
+  # and it is only implemented in RT_Internal,
+  # but we can let create_ticket throw that error
+  my $conf = new FS::Conf;
+  die "rt_ticket event - no ticket system configured"
+    unless $conf->config('ticket_system');
+  
+  FS::TicketSystem->init();
+
+  my %msg = $msg_template->prepare(
+    'cust_main' => $cust_main,
+    'object'    => $object,
+  );
+
+  my $subject = $msg{'subject'};
+  chomp($subject);
+
+  my $requestor = $self->option('requestor')
+                ? $msg_template->from_addr
+                : [ $cust_main->invoicing_list_emailonly ];
+
+  my $svcnum = ref($object) eq 'FS::svc_acct'
+             ? $object->svcnum
+             : undef;
+
+  my $err_or_ticket = FS::TicketSystem->create_ticket(
+    '', #session should already exist
+    'queue'     => $queueid,
+    'subject'   => $subject,
+    'requestor' => $requestor,
+    'message'   => $msg{'html_body'},
+    'mime_type' => 'text/html',
+    'custnum'   => $cust_main->custnum,
+    'svcnum'    => $svcnum,
+  );
+  die $err_or_ticket unless ref($err_or_ticket);
+  return '';
+
+}
+
+1;
index 36fbe9a..d1d5196 100644 (file)
@@ -312,7 +312,7 @@ sub option_age_from {
   } elsif ( $age =~ /^(\d+)d$/i ) {
     $mday -= $1;
   } elsif ( $age =~ /^(\d+)h$/i ) {
-    $hour -= $hour;
+    $hour -= $1;
   } else {
     die "unparsable age: $age";
   }
index 353e646..d54fb88 100644 (file)
@@ -21,9 +21,16 @@ sub option_fields {
                      'type'     => 'select-pkg_class',
                      'multiple' => 1,
                    },
-    'age'       => { 'label'      => 'Cancellation in last',
-                     'type'       => 'freq',
-                   },
+    'age_newest' => { 'label'      => 'Cancelled more than',
+                      'type'       => 'freq',
+                      'post_text'  => ' ago (blank for no limit)',
+                      'allow_blank' => 1,
+                    },
+    'age'        => { 'label'      => 'Cancelled less than',
+                      'type'       => 'freq',
+                      'post_text'  => ' ago (blank for no limit)',
+                      'allow_blank' => 1,
+                    },
   );
 }
 
@@ -32,11 +39,12 @@ sub condition {
 
   my $cust_main = $self->cust_main($object);
 
-  my $age = $self->option_age_from('age', $opt{'time'} );
+  my $oldest = length($self->option('age')) ? $self->option_age_from('age', $opt{'time'} ) : 0;
+  my $newest = $self->option_age_from('age_newest', $opt{'time'} );
+
+  my $pkgclass = $self->option('pkgclass') || {};
 
-  #XXX test
-  my $hashref = $self->option('pkgclass') || {};
-  ! grep { $hashref->{ $_->part_pkg->classnum } && $_->get('cancel') > $age }
+  ! grep { $pkgclass->{ $_->part_pkg->classnum } && ($_->get('cancel') > $oldest) && ($_->get('cancel') <= $newest) }
     $cust_main->cancelled_pkgs;
 }
 
index b4ff6c3..42845cb 100644 (file)
@@ -18,8 +18,15 @@ sub option_fields {
                       'type'     => 'select-part_pkg',
                       'multiple' => 1,
                     },
-    'age'        => { 'label'      => 'Cancellation in last',
+    'age_newest' => { 'label'      => 'Cancelled more than',
                       'type'       => 'freq',
+                      'post_text'  => ' ago (blank for no limit)',
+                      'allow_blank' => 1,
+                    },
+    'age'        => { 'label'      => 'Cancelled less than',
+                      'type'       => 'freq',
+                      'post_text'  => ' ago (blank for no limit)',
+                      'allow_blank' => 1,
                     },
   );
 }
@@ -29,10 +36,12 @@ sub condition {
 
   my $cust_main = $self->cust_main($object);
 
-  my $age = $self->option_age_from('age', $opt{'time'} );
+  my $oldest = length($self->option('age')) ? $self->option_age_from('age', $opt{'time'} ) : 0;
+  my $newest = $self->option_age_from('age_newest', $opt{'time'} );
 
   my $if_pkgpart = $self->option('if_pkgpart') || {};
-  ! grep { $if_pkgpart->{ $_->pkgpart } && $_->get('cancel') > $age }
+
+  ! grep { $if_pkgpart->{ $_->pkgpart } && ($_->get('cancel') > $oldest) && ($_->get('cancel') <= $newest) }
     $cust_main->cancelled_pkgs;
 
 }
index a34f616..13d1601 100644 (file)
@@ -161,9 +161,14 @@ sub password_equals {
 }
 
 sub _upgrade_schema {
+  my $class = shift;
+  # if the table doesn't exist yet then nothing needs to happen here
+  my $dbdef_table = $class->dbdef_table
+    or return;
+
   # clean up history records where linked_acct has gone away
   my @where;
-  for my $fk ( grep /__/, __PACKAGE__->dbdef_table->columns ) {
+  for my $fk ( grep /__/, $dbdef_table->columns ) {
     my ($table, $key) = split(/__/, $fk);
     push @where, "
       ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
diff --git a/bin/part_pkg-clone_fix_options b/bin/part_pkg-clone_fix_options
new file mode 100755 (executable)
index 0000000..4d8192b
--- /dev/null
@@ -0,0 +1,53 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Misc::Getopt;
+use FS::part_pkg;
+use FS::Record qw(qsearch dbh);
+
+our %opt;
+getopts('p:'); # pkgpart
+$FS::UID::AutoCommit = 0;
+
+sub usage {
+  die "Usage: part_pkg-clone_fix_options -p pkgpart[,pkgpart...] user\n\n";
+}
+
+my @pkgpart = split(',',$opt{p}) or usage();
+foreach my $base_pkgpart (@pkgpart) {
+  my $base_part_pkg = FS::part_pkg->by_key($base_pkgpart);
+  warn "Base package '".$base_part_pkg->pkg."'\n";
+  my @children = qsearch('part_pkg', { 'family_pkgpart' => $base_pkgpart });
+  next if !@children;
+  my $n_pkg = 0;
+  my $n_upd = 0;
+  my %base_options = $base_part_pkg->options;
+  my %report_classes = map { $_ => $base_options{$_} }
+                       grep /^report_option_/, keys %base_options;
+  if (!keys %report_classes) {
+    warn "No report classes.\n";
+    next;
+  }
+
+  foreach my $part_pkg (@children) {
+    my $pkgpart = $part_pkg->pkgpart;
+    next if $pkgpart == $base_pkgpart;
+    $n_pkg++;
+
+    # don't do this if it has report options already
+    my %options = $part_pkg->options;
+    if (grep /^report_option_/, keys %options) {
+      warn "#$pkgpart has report classes; skipped\n";
+    } else {
+      %options = ( %options, %report_classes );
+      my $error = $part_pkg->replace(options => \%options);
+      die "#$pkgpart: $error\n" if $error;
+      $n_upd++;
+    }
+  }
+  warn "Updated $n_upd / $n_pkg child packages.\n";
+}
+
+warn "Finished.\n";
+dbh->commit;
+
diff --git a/bin/xmlrpc-customer_recurring b/bin/xmlrpc-customer_recurring
new file mode 100755 (executable)
index 0000000..18dd3e8
--- /dev/null
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my( $email, $password ) = @ARGV;
+die "Usage: xmlrpc-customer_recurring email password\n"
+  unless $email && length($password);
+
+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 $list_result = $server->call(
+  'FS.ClientAPI_XMLRPC.customer_recurring',
+    'session_id'   => $login_result->{'session_id'},
+);
+die $list_result->{'error'}."\n" if $list_result->{'error'};
+
+print Dumper($list_result);
+
+1;
index c1d04d6..1cbed4a 100644 (file)
   { % First page\r
   }\r
   { % ... pages\r
-    \small{\thepage\ of \pageref{LastPage}}\r
+    \small{\thepage~[@-- emt('of') --@]~\pageref{LastPage}}\r
   }\r
 }\r
 \r
index ecbb6e9..32b2ded 100644 (file)
@@ -32,6 +32,7 @@ $socket .= '.'.$tag if defined $tag && length($tag);
   'switch_acct'               => 'MyAccount/switch_acct',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
+  'customer_recurring'        => 'MyAccount/customer_recurring',
 
   'contact_passwd'            => 'MyAccount/contact/contact_passwd',
   'list_contacts'             => 'MyAccount/contact/list_contacts',
@@ -479,6 +480,31 @@ first last company address1 address2 city county state zip country daytime night
 
 =back
 
+=item customer_recurring HASHREF
+
+Takes a hash reference as parameter with a single key B<session_id>
+or keys B<agent_session_id> and B<custnum>.
+
+Returns a hash reference with the keys error, custnum and display_recurring.
+
+display_recurring is an arrayref of hashrefs with the following keys:
+
+=over 4
+
+=item freq
+
+frequency of charge, in months unless units are specified
+
+=item freq_pretty
+
+frequency of charge, suitable for display
+
+=item amount
+
+amount charged at this frequency
+
+=back
+
 =item edit_info HASHREF
 
 Takes a hash reference as parameter with any of the following keys:
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 1bad6cf..0ff7d26 100644 (file)
@@ -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 bac6924..0293af8 100644 (file)
@@ -39,8 +39,8 @@
                                                  split(/\0/, $value)
                                            };
                                 } elsif ( $info->{'type'} eq 'freq' ) {
-                                  $value = '0' if !length($value);
-                                  $value .= $params->{$cgi_field.'_units'};
+                                  $value = '0' if !length($value) and !$info->{'allow_blank'};
+                                  $value .= $params->{$cgi_field.'_units'} if length($value);
                                 }
 
                                 #warn "value of $cgi_field is $value\n";
index 582dda6..cdb1d73 100644 (file)
@@ -274,7 +274,7 @@ $report_packages{'Suspension summary'} = [ $fsurl.'search/cust_pkg_susp.html', '
 $report_packages{'Customer packages with unconfigured services'} =  [ $fsurl.'search/cust_pkg.cgi?APKG_pkgnum', 'List packages which have provisionable services' ];
 $report_packages{'FCC Form 477'} =  [ $fsurl.'search/report_477.html' ]
   if $conf->exists('part_pkg-show_fcc_options');
-$report_packages{'Contract end dates'} = [ $fsurl.'search/cust_pkg-date.html?date=contract_end', 'Show packages by contract end date' ];
+$report_packages{'Contract end dates'} = [ $fsurl.'search/report_cust_pkg-date.html?date=contract_end', 'Show packages by contract end date' ];
 $report_packages{'Advanced package reports'} =  [ $fsurl.'search/report_cust_pkg.html', 'by agent, date range, status, package definition' ];
 
 tie my %report_inventory, 'Tie::IxHash',
index 4ae8bc9..2893365 100644 (file)
@@ -1,4 +1,4 @@
-<SELECT NAME="<% $opt{'name'} %>"<% $opt{'multiple'} ? ' MULTIPLE' : '' %>>
+<SELECT NAME="<% $opt{'name'} || $opt{'field'} %>"<% $opt{'multiple'} ? ' MULTIPLE' : '' %>>
 % while ( @fields ) {
 %   my $value = shift @fields;
 %   my $label = shift @fields;
index cb58bf6..795684c 100644 (file)
@@ -15,7 +15,7 @@
                 <% $freq eq $units ? 'SELECTED' : '' %>
         ><% $freq{$freq} %>
 %     }
-    </SELECT>
+    </SELECT><% $opt{'post_text'} || '' %>
 
   </TD>
 
diff --git a/httemplate/elements/tr-select-rt-queue.html b/httemplate/elements/tr-select-rt-queue.html
new file mode 100644 (file)
index 0000000..ac3689b
--- /dev/null
@@ -0,0 +1,7 @@
+
+<& 'tr-td-label.html', @_ &>
+<TD>
+<& 'select-rt-queue.html', @_ &>
+</TD>
+</TR>
+
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 942085c..5264626 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 1b93775..22a6740 100644 (file)
@@ -1,3 +1,17 @@
+<& elements/search.html,
+  'title'       => $title,
+  'name'        => 'packages',
+  'query'       => $query,
+  'count_query' => $count_query,
+  'header'      => \@header,
+  'fields'      => \@fields,
+  'sort_fields' => [],
+  'align'       => 'rrrl'. FS::UI::Web::cust_aligns(),
+  'color'       => \@color,
+  'style'       => \@style,
+  'links'       => \@links,
+  'cell_style'  => [ $date_color_sub ],
+&>
 <%init>
 my $curuser = $FS::CurrentUser::CurrentUser;
 die 'access denied' unless $curuser->access_right('List packages');
@@ -18,8 +32,6 @@ my $col = $cgi->param('date');
 die "invalid date column" unless $cols{$col};
 
 my $title = 'Packages by ' . lc($cols{$col}) . ' date';
-# second option on the cust_fields_avail list, plus email
-my $cust_fields = 'Cust# | Customer | Day phone | Night phone | Mobile phone | Invoicing email(s)';
 my @header = ( $cols{$col},
                emt('#'),
                emt('Quan.'),
@@ -31,35 +43,47 @@ my @fields = ( sub { time2str('%b %d %Y', $_[0]->$col) },
                'quantity',
                'pkg_label',
              );
-my @sort_fields = ( map '', @fields ); # should only ever sort by $col
+my @color = ( map '', @fields );
+my @style = ( map '', @fields );
+
+my $pkg_link = sub {
+  my $self = shift;
+  my $frag = 'cust_pkg'. $self->pkgnum;
+  [ "${p}view/cust_main.cgi?custnum=".$self->custnum.
+                           ";show=packages;fragment=$frag#cust_pkg",
+    'pkgnum'
+  ];
+};
+
+my @links = ( '', ($pkg_link) x 3 );
 
-push @header, FS::UI::Web::cust_header($cust_fields);
+push @header, FS::UI::Web::cust_header($cgi->param('cust_fields'));
 push @fields, \&FS::UI::Web::cust_fields;
+push @color,  FS::UI::Web::cust_colors();
+push @style,  FS::UI::Web::cust_styles();
+push @links,  FS::UI::Web::cust_links();
+
+my $agentnums_sql = $curuser->agentnums_sql('table' => 'cust_main');
+if ( $cgi->param('agentnum') =~ /^(\d+)$/ and $1 ) {
+  $agentnums_sql .= " AND agentnum = $1";
+}
 
 my $query = {
+  'select'    => join(',', 'cust_pkg.*', FS::UI::Web::cust_sql_fields() ),
   'table'     => 'cust_pkg',
   'addl_from' => FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg'),
   'hashref'   => {
     $col => { op => '!=', value => '' },
     'cancel' => '',
   },
-  'order_by' => "ORDER BY $col",
+  'extra_sql' => ' AND '.$agentnums_sql,
+  'order_by'  => "ORDER BY $col",
 };
 
 my $count_query =
-  "SELECT COUNT(*) FROM cust_pkg WHERE $col IS NOT NULL AND cancel IS NULL";
+  "SELECT COUNT(*) FROM cust_pkg JOIN cust_main USING (custnum) ".
+  "WHERE $col IS NOT NULL AND cancel IS NULL AND $agentnums_sql";
 
-my $pkg_link = sub {
-  my $self = shift;
-  my $frag = 'cust_pkg'. $self->pkgnum;
-  [ "${p}view/cust_main.cgi?custnum=".$self->custnum.
-                           ";show=packages;fragment=$frag#cust_pkg",
-    'pkgnum'
-  ];
-};
-
-my @links = ( '', ($pkg_link) x 3,
-  FS::UI::Web::cust_links() );
 
 my $date_color_sub = sub {
   my $self = shift;
@@ -76,15 +100,4 @@ my $date_color_sub = sub {
 };
 
 </%init>
-<& elements/search.html,
-  'title'       => $title,
-  'name'        => 'packages',
-  'query'       => $query,
-  'count_query' => $count_query,
-  'header'      => \@header,
-  'fields'      => \@fields,
-  'align'       => 'rrrl'. FS::UI::Web::cust_aligns(),
-  'links'       => \@links,
-  'cell_style'  => [ $date_color_sub ],
-&>
 
index 30962c9..4c7e7e8 100644 (file)
@@ -18,7 +18,7 @@
                                      emt('Susp.'),
                                      emt('Changed'),
                                      emt('Cancel'),
-                                     #emt('Reason'), # hard to do this right
+                                     @reason_header,
                                      FS::UI::Web::cust_header(
                                        $cgi->param('cust_fields')
                                      ),
@@ -45,6 +45,7 @@
                     ( map { time_or_blank($_) }
                       qw( setup last_bill bill susp change_date cancel ) ),
 
+                    @reason_fields,
                     \&FS::UI::Web::cust_fields,
                   ],
                   'sort_fields' => [
                     ('') x 3, # can't use at all
                     # use the plain SQL column names
                     qw( setup last_bill bill susp change_date cancel ),
+                    @reason_blank,
                     # cust_fields can take care of themselves
                   ],
                   'color' => [
                     ('') x 15,
+                    @reason_blank,
                     FS::UI::Web::cust_colors(),
                   ],
                   'style' => [ ('') x 15,
+                               @reason_blank,
                                FS::UI::Web::cust_styles() ],
                   'size'  => [ '', '', '', '', '-1' ],
-                  'align' => 'rrlcccrrlrrrrrr'. FS::UI::Web::cust_aligns(). 'r',
+                  'align' => 'rrlcccrrlrrrrrr'.$reason_align. FS::UI::Web::cust_aligns(). 'r',
                   'links' => [
                     $link,
                     $link,
                     $link,
                     ('') x 12,
+                    @reason_blank,
                     ( map { $_ ne 'Cust. Status' ? $clink : '' }
                           FS::UI::Web::cust_header(
                                                     $cgi->param('cust_fields')
@@ -184,4 +189,16 @@ sub time_or_blank {
    };
 }
 
+my (@reason_header,@reason_fields,@reason_blank);
+my $reason_align = '';
+if ($status eq 'cancel') {
+  push @reason_header, emt('Cancel Reason');
+  push @reason_fields, sub {
+    my $c = shift;
+    my $cust_pkg_reason = $c->last_cust_pkg_reason('cancel');
+    $cust_pkg_reason ? $cust_pkg_reason->reason->reason : '';
+  };
+  push @reason_blank, '';
+  $reason_align = 'l';
+}
 </%init>
index 4ed297d..cbda680 100755 (executable)
@@ -91,29 +91,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;
@@ -166,12 +167,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'),
   );
 };
@@ -211,7 +218,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, '';
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 beb0173..8b85324 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 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_cust_pkg-date.html b/httemplate/search/report_cust_pkg-date.html
new file mode 100755 (executable)
index 0000000..ceb9a9c
--- /dev/null
@@ -0,0 +1,38 @@
+<& /elements/header.html, mt($title) &>
+
+<FORM ACTION="cust_pkg-date.html" METHOD="GET">
+<INPUT TYPE="hidden" NAME="date" VALUE="<% $col %>">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+  <& /elements/tr-select-agent.html,
+                 'curr_value'    => scalar( $cgi->param('agentnum') ),
+                 'disable_empty' => 0,
+  &>
+
+  <& /elements/tr-select-cust-fields.html &>
+  
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List packages');
+
+# for the page title
+my %cols = (
+  'contract_end' => 'Contract end'
+);
+
+# or let the column be selected here?
+my $col = $cgi->param('date');
+die "invalid date column" unless $cols{$col};
+my $title = 'Packages by ' . lc($cols{$col}) . ' date';
+
+</%init>
index fcf6c10..561476e 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,
@@ -256,4 +257,24 @@ 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 8cdf29d..0e52d5f 100755 (executable)
@@ -10,6 +10,7 @@
                                  'Router',
                                  @tower_header,
                                  'IP Address',
+                                 @header_pkg,
                                  emt('Pkg. Status'),
                                  FS::UI::Web::cust_header($cgi->param('cust_fields')),
                                ],
@@ -21,6 +22,7 @@
                                  },
                                  @tower_fields,
                                  'ip_addr',
+                                 @fields_pkg,
                                  sub {
                                    $cust_pkg_cache{$_[0]->svcnum} ||= $_[0]->cust_svc->cust_pkg;
                                    return '' unless $cust_pkg_cache{$_[0]->svcnum};
                                  $link,
                                  '', #$link_router,
                                  (map '', @tower_fields),
-                                 $link,
+                                 $link, # ip_addr
+                                 @blank_pkg,
                                  '', # pkg status
                                  ( map { $_ ne 'Cust. Status' ? $link_cust : '' }
                                        FS::UI::Web::cust_header($cgi->param('cust_fields'))
                                  ),
                                ],
-              'align'       => 'rll'.('r' x @tower_fields).'rr'.
+              'align'       => 'rll'.('r' x @tower_fields).
+                                'r'. # ip_addr
+                                $align_pkg.
+                                'r'. # pkg status
                                 FS::UI::Web::cust_aligns(),
               'color'       => [ 
                                  '',
                                  '',
                                  '',
                                  (map '', @tower_fields),
-                                 '',
+                                 '', # ip_addr
+                                 @blank_pkg,
                                  sub {
                                    $cust_pkg_cache{$_[0]->svcnum} ||= $_[0]->cust_svc->cust_pkg;
                                    return '' unless $cust_pkg_cache{$_[0]->svcnum};
@@ -59,8 +66,9 @@
                                  '',
                                  '',
                                  (map '', @tower_fields),
-                                 '',
-                                 'b',
+                                 '',  # ip_addr
+                                 @blank_pkg,
+                                 'b', # pkg status
                                  FS::UI::Web::cust_styles(),
                                ],
           
@@ -129,4 +137,25 @@ $html_init .= ' | ' .
   $fsurl . 'search/svc_broadband-map.html?' . $cgi->query_string .
   '">' . emt('View a map of these services') . '</a>';
 
+my (@header_pkg,@fields_pkg,@blank_pkg);
+my $align_pkg = '';
+#false laziness with search/svc_acct.cgi
+$cgi->param('cust_pkg_fields') =~ /^([\w\,]*)$/ or die "bad cust_pkg_fields";
+my @pkg_fields = split(',', $1);
+foreach my $pkg_field ( @pkg_fields ) {
+  ( my $header = ucfirst($pkg_field) ) =~ s/_/ /; #:/
+  push @header_pkg, $header;
+
+  #not the most efficient to do it every field, but this is of niche use. so far
+  push @fields_pkg, sub { my $svc_x = shift;
+                          my $cust_pkg = $svc_x->cust_svc->cust_pkg or return '';
+                          my $value = $cust_pkg->get($pkg_field);#closures help alot
+                          $value ? time2str('%b %d %Y', $value ) : '';
+                        };
+
+  push @blank_pkg, '';
+  $align_pkg .= 'c';
+}
+
+
 </%init>
index 3d0983e..7be3201 100644 (file)
 % # customer base, and compare it to a graph of the overhead for generating this
 % # information.  (and optimize it better, we could get it more from SQL)
 % if ( $cust_main->num_ncancelled_pkgs < 54 ) {
-%   my $sth = dbh->prepare("
-%     SELECT DISTINCT freq FROM cust_pkg LEFT JOIN part_pkg USING (pkgpart)
-%       WHERE freq IS NOT NULL AND freq != '0'
-%         AND ( cancel IS NULL OR cancel = 0 )
-%         AND custnum = ?
-%   ") or die $DBI::errstr;
-% 
-%   $sth->execute($cust_main->custnum) or die $sth->errstr;
-
-%   #not really a numeric sort because freqs can actually be all sorts of things
-%   # but good enough for the 99% cases of ordering monthly quarterly annually
-%   my @freqs = sort { $a <=> $b } map { $_->[0] } @{ $sth->fetchall_arrayref };
-% 
-%   foreach my $freq (@freqs) {
-%     my @cust_pkg = qsearch({
-%       'table'     => 'cust_pkg',
-%       'addl_from' => 'LEFT JOIN part_pkg USING (pkgpart)',
-%       'hashref'   => { 'custnum' => $cust_main->custnum, },
-%       'extra_sql' => 'AND ( cancel IS NULL OR cancel = 0 )
-%                       AND freq = '. dbh->quote($freq),
-%       'order_by'  => 'ORDER BY COALESCE(start_date,0), pkgnum', # to ensure old pkgs come before change_to_pkg
-%     }) or next;
-% 
-%     my $freq_pretty = $cust_pkg[0]->part_pkg->freq_pretty;
-%
-%     my $amount = 0;
-%     my $skip_pkg = {};
-%     foreach my $cust_pkg (@cust_pkg) {
-%       my $part_pkg = $cust_pkg->part_pkg;
-%       next if $cust_pkg->susp
-%            && ! $cust_pkg->option('suspend_bill')
-%            && ( ! $part_pkg->option('suspend_bill')
-%                 || $cust_pkg->option('no_suspend_bill')
-%               );
-%
-%       #pkg change handling
-%       next if $skip_pkg->{$cust_pkg->pkgnum};
-%       if ($cust_pkg->change_to_pkgnum) {
-%         #if change is on or before next bill date, use new pkg
-%         next if $cust_pkg->expire <= $cust_pkg->bill;
-%         #if change is after next bill date, use old (this) pkg
-%         $skip_pkg->{$cust_pkg->change_to_pkgnum} = 1;
-%       }
-%
-%       my $pkg_amount = 0;
-%
-%       #add recurring amounts for this package and its billing add-ons
-%       foreach my $l_part_pkg ( $part_pkg->self_and_bill_linked ) {
-%         $pkg_amount += $l_part_pkg->base_recur($cust_pkg);
-%       }
-%
-%       #subtract amounts for any active discounts
-%       #(there should only be one at the moment, otherwise this makes no sense)
-%       foreach my $cust_pkg_discount ( $cust_pkg->cust_pkg_discount_active ) {
-%         my $discount = $cust_pkg_discount->discount;
-%         #and only one of these for each
-%         $pkg_amount -= $discount->amount;
-%         $pkg_amount -= $amount * $discount->percent/100;
-%       }
-%
-%       $pkg_amount *= ( $cust_pkg->quantity || 1 );
-%
-%       $amount += $pkg_amount;
-%
-%     }
-   
+%   foreach my $freq_info ($cust_main->display_recurring) {
       <TR>
-        <TD ALIGN="right"><% emt( ucfirst($freq_pretty). ' recurring' ) %></TD>
-        <TD BGCOLOR="#ffffff"><% $money_char. sprintf('%.2f', $amount) %></TD>
-        </TD>
+        <TD ALIGN="right"><% emt( ucfirst($freq_info->{'freq_pretty'}). ' recurring' ) %></TD>
+        <TD BGCOLOR="#ffffff"><% $money_char. sprintf('%.2f', $freq_info->{'amount'}) %></TD>
       </TR>
 %   }
-
 % }
 
 % if ( $conf->exists('cust_main-select-prorate_day') ) {
index 3114923..cf7ef7c 100644 (file)
@@ -12,6 +12,7 @@ my %statusaction = (
   'new'        => 'delete',
   'thirdparty' => 'delete',
   'pending'    => 'complete',
+  'authorized' => 'complete',
   'captured'   => 'capture',
 );