Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Sat, 4 Jun 2016 00:23:02 +0000 (17:23 -0700)
committerIvan Kohler <ivan@freeside.biz>
Sat, 4 Jun 2016 00:23:02 +0000 (17:23 -0700)
36 files changed:
FS/FS/AccessRight.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ConfDefaults.pm
FS/FS/Test.pm
FS/FS/UI/Web.pm
FS/FS/Upgrade.pm
FS/FS/cdr.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_pay_pending.pm
FS/FS/cust_payby.pm
FS/FS/cust_pkg.pm
FS/FS/cust_svc.pm
FS/FS/detail_format/sum_duration_accountcode.pm [new file with mode: 0644]
FS/FS/log_context.pm
FS/FS/part_pkg/prorate_Mixin.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/part_pkg/voip_inbound.pm
FS/FS/part_pkg/voip_sqlradacct.pm
FS/FS/part_pkg/voip_tiered.pm
FS/FS/password_history.pm
FS/bin/freeside-ipifony-download
FS/t/suite/06-prorate_defer_bill.t [new file with mode: 0755]
FS/t/suite/07-pkg_change_location.t [new file with mode: 0755]
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
fs_selfservice/FS-SelfService/cgi/view_usage.html
httemplate/edit/process/quotation_pkg_detail.html
httemplate/edit/quotation_pkg_detail.html
httemplate/elements/cust_payby.html
httemplate/elements/header-full.html
httemplate/elements/notify-tickets.html [new file with mode: 0644]
httemplate/elements/tr-select-cust-part_pkg.html
httemplate/misc/process/payment.cgi
rt/etc/initialdata
rt/lib/RT/Search/UnrepliedTickets.pm [new file with mode: 0644]
rt/share/html/Search/UnrepliedTickets.html [new file with mode: 0755]

index 7096db5..89e50aa 100644 (file)
@@ -153,7 +153,7 @@ tie my %rights, 'Tie::IxHash',
     'Make appointment',
     'View package definition costs', #NEWNEW
     'Change package start date',
-    'Add/remove package contract end date',
+    'Change package contract end date',
   ],
   
   ###
index ecac223..9863065 100644 (file)
@@ -1856,6 +1856,20 @@ sub list_svcs {
 
     # would it make sense to put this in a svc_* method?
 
+    if (!$hide_usage and grep(/^$svcdb$/, qw(svc_acct svc_broadband)) and $part_svc->part_export_usage) {
+      my $last_bill = $cust_pkg->last_bill || 0;
+      my $now = time;
+      my $up_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctInputOctets');
+      my $down_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctOutputOctets');
+      %hash = (
+        %hash,
+        'seconds_used'    => $cust_svc->seconds_since_sqlradacct($last_bill,$now),
+        'upbytes_used'    => display_bytecount($up_used),
+        'downbytes_used'  => display_bytecount($down_used),
+        'totalbytes_used' => display_bytecount($up_used + $down_used)
+      );
+    }
+
     if ( $svcdb eq 'svc_acct' ) {
       foreach (qw(username email finger seconds)) {
         $hash{$_} = $svc_x->$_;
index 4c37175..2fa8344 100644 (file)
@@ -56,29 +56,23 @@ sub cust_fields_avail { (
   'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s)' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s)',
 
-  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type' =>
-    'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type',
-
-  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' =>
-    'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type | Current Balance',
+  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Current Balance' =>
+    'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Current Balance',
 
   'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s)' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s)',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type' =>
-    'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type',
-
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' =>
-    'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+    'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' =>
-    'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
+  'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+    'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance' =>
-    'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance',
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance' =>
+    'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance | Advertising Source' =>
-    'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance | Advertising Source',
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance | Advertising Source' =>
+    'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance | Advertising Source',
 
   'Invoicing email(s)' => 'Invoicing email(s)',
   'Cust# | Invoicing email(s)' => 'custnum | Invoicing email(s)',
index 9854b94..9c77417 100644 (file)
@@ -235,4 +235,34 @@ sub qsearchs {
   FS::Record::qsearchs(@_);
 }
 
+=item new_customer FIRSTNAME
+
+Returns an L<FS::cust_main> object full of default test data, ready to be inserted.
+This doesn't insert the customer, because you might want to change some things first.
+FIRSTNAME is recommended so you know which test the customer was used for.
+
+=cut
+
+sub new_customer {
+  my $self = shift;
+  my $first = shift || 'No Name';
+  my $location = FS::cust_location->new({
+      address1  => '123 Example Street',
+      city      => 'Sacramento',
+      state     => 'CA',
+      country   => 'US',
+      zip       => '94901',
+  });
+  my $cust = FS::cust_main->new({
+      agentnum      => 1,
+      refnum        => 1,
+      last          => 'Customer',
+      first         => $first,
+      invoice_email => 'newcustomer@fake.freeside.biz',
+      bill_location => $location,
+      ship_location => $location,
+  });
+  $cust;
+}
+
 1; # End of FS::Test
index e07e682..04aeda1 100644 (file)
@@ -343,7 +343,8 @@ sub cust_header {
     '(service) Latitude'       => 'ship_latitude',
     '(service) Longitude'      => 'ship_longitude',
     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
-    'Payment Type'             => 'cust_payby',
+# FS::Upgrade::upgrade_config removes this from existing cust-fields settings
+#    'Payment Type'             => 'cust_payby',
     'Current Balance'          => 'current_balance',
     'Agent Cust#'              => 'agent_custid',
     'Advertising Source'       => 'referral',
@@ -447,8 +448,6 @@ sub cust_sql_fields {
   foreach my $field (qw(daytime night mobile fax )) {
     push @fields, $field if (grep { $_ eq $field } @cust_fields);
   }
-  push @fields, "payby AS cust_payby"
-    if grep { $_ eq 'cust_payby' } @cust_fields;
   push @fields, 'agent_custid';
 
   my @extra_fields = ();
index eb2587b..a374d39 100644 (file)
@@ -170,6 +170,14 @@ If you need to continue using the old Form 477 report, turn on the
     $conf->delete('unsuspendauto');
   }
 
+  if ($conf->config('cust-fields') =~ / \| Payment Type/) {
+    my $cust_fields = $conf->config('cust-fields');
+    # so we can potentially use 'Payment Types' or somesuch in the future
+    $cust_fields =~ s/ \| Payment Type( \|)/$1/;
+    $cust_fields =~ s/ \| Payment Type$//;
+    $conf->set('cust-fields',$cust_fields);
+  }
+
   enable_banned_pay_pad() unless length($conf->config('banned_pay-pad'));
 
 }
@@ -523,7 +531,9 @@ sub upgrade_schema_data {
     'cust_bill_pkg_detail' => [],
     #add necessary columns to RT schema
     'TicketSystem' => [],
-
+    #remove possible dangling records
+    'password_history' => [],
+    'cust_pay_pending' => [],
   ;
 
   \%hash;
index b3cceb4..a2b9a8c 100644 (file)
@@ -927,8 +927,10 @@ sub rate_prefix {
 
       # by default, set the included minutes for this region/time to
       # what's in the rate_detail
-      $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
-        unless exists $included_min->{$regionnum}{$ratetimenum};
+      if (!exists( $included_min->{$regionnum}{$ratetimenum} )) {
+        $included_min->{$regionnum}{$ratetimenum} =
+          ($rate_detail->min_included * $cust_pkg->quantity || 1);
+      }
 
       if ( $included_min->{$regionnum}{$ratetimenum} >= $minutes ) {
         $charge_sec = 0;
@@ -1262,6 +1264,10 @@ my %export_names = (
     'name'           => 'Number of calls, one line per service',
     'invoice_header' => 'Caller,Rate,Messages,Price',
   },
+  'sum_duration' => {
+    'name'           => 'Summary, one line per service',
+    'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
+  },
   'sum_duration_prefix' => {
     'name'           => 'Summary, one line per destination prefix',
     'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
@@ -1270,6 +1276,10 @@ my %export_names = (
     'name'           => 'Summary, one line per usage class',
     'invoice_header' => 'Caller,Class,Calls,Price',
   },
+  'sum_duration_accountcode' => {
+    'name'           => 'Summary, one line per accountcode',
+    'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
+  },
 );
 
 my %export_formats = ();
index 0d89ff4..cb5181d 100644 (file)
@@ -2338,6 +2338,8 @@ Removes the I<paycvv> field from the database directly.
 
 If there is an error, returns the error, otherwise returns false.
 
+DEPRECATED.  Use L</remove_cvv_from_cust_payby> instead.
+
 =cut
 
 sub remove_cvv {
@@ -4524,6 +4526,33 @@ PAYBYLOOP:
 
 }
 
+=item remove_cvv_from_cust_payby PAYINFO
+
+Removes paycvv from associated cust_payby with matching PAYINFO.
+
+=cut
+
+sub remove_cvv_from_cust_payby {
+  my ($self,$payinfo) = @_;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  foreach my $cust_payby ( qsearch('cust_payby',{ custnum => $self->custnum }) ) {
+    next unless $cust_payby->payinfo eq $payinfo; # can't qsearch on payinfo
+    $cust_payby->paycvv('');
+    my $error = $cust_payby->replace;
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+}
+
 =back
 
 =head1 CLASS METHODS
index cf4b16e..9fea1bb 100644 (file)
@@ -510,11 +510,8 @@ sub realtime_bop {
       $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
       $content{expiration} = "$2/$1";
 
-      my $paycvv = exists($options{'paycvv'})
-                     ? $options{'paycvv'}
-                     : $self->paycvv;
-      $content{cvv2} = $paycvv
-        if length($paycvv);
+      $content{cvv2} = $options{'paycvv'}
+        if length($options{'paycvv'});
 
       my $paystart_month = exists($options{'paystart_month'})
                              ? $options{'paystart_month'}
@@ -764,10 +761,10 @@ sub realtime_bop {
   ###
 
   # compare to FS::cust_main::save_cust_payby - check both to make sure working correctly
-  if ( length($self->paycvv)
+  if ( length($options{'paycvv'})
        && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
   ) {
-    my $error = $self->remove_cvv;
+    my $error = $self->remove_cvv_from_cust_payby($options{payinfo});
     if ( $error ) {
       warn "WARNING: error removing cvv: $error\n";
     }
@@ -1790,11 +1787,8 @@ sub realtime_verify_bop {
       $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
       $content{expiration} = "$2/$1";
 
-      my $paycvv = exists($options{'paycvv'})
-                     ? $options{'paycvv'}
-                     : $self->paycvv;
-      $content{cvv2} = $paycvv
-        if length($paycvv);
+      $content{cvv2} = $options{'paycvv'}
+        if length($options{'paycvv'});
 
       my $paystart_month = exists($options{'paystart_month'})
                              ? $options{'paystart_month'}
@@ -1918,6 +1912,8 @@ sub realtime_verify_bop {
     }
   }
 
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop');
+
   if ( $transaction->is_success() ) {
 
     $cust_pay_pending->status('authorized');
@@ -1962,11 +1958,114 @@ sub realtime_verify_bop {
       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
@@ -1990,7 +2089,9 @@ sub realtime_verify_bop {
       $self->payinfo($transaction->card_token);
       my $error = $self->replace;
       if ( $error ) {
-        warn "WARNING: error storing token: $error, but proceeding anyway\n";
+        my $warning = "WARNING: error storing token: $error, but proceeding anyway\n";
+        $log->warning($warning);
+        warn $warning;
       }
     }
 
index 1a54203..dfb07b8 100644 (file)
@@ -470,6 +470,19 @@ sub _upgrade_data {  #class method
 
 }
 
+sub _upgrade_schema {
+  my ($class, %opts) = @_;
+
+  # fix records where jobnum points to a nonexistent queue job
+  my $sql = 'UPDATE cust_pay_pending SET jobnum = NULL
+    WHERE NOT EXISTS (
+      SELECT 1 FROM queue WHERE queue.jobnum = cust_pay_pending.jobnum
+    )';
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+  '';
+}
+
 =back
 
 =head1 BUGS
index fd75567..623a44e 100644 (file)
@@ -217,14 +217,14 @@ sub replace {
     $self->payinfo($new_account.'@'.$new_aba);
   }
 
-  # don't preserve paycvv if it was passed blank and payinfo changed
-  unless ( $self->payby =~ /^(CARD|DCRD)$/
-       && $old->payinfo ne $self->payinfo
-       && $old->paymask ne $self->paymask
-       && $self->paycvv =~ /^\s*$/ )
-  {
-    if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) {
+  # only unmask paycvv if payinfo stayed the same
+  if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
+    if ( $old->payinfo eq $self->payinfo
+         && $old->paymask eq $self->paymask
+    ) {
       $self->paycvv($old->paycvv);
+    } else {
+      $self->paycvv('');
     }
   }
 
index 1cc8235..d15eb89 100644 (file)
@@ -2537,6 +2537,16 @@ sub change_later {
     return "start_date $date is in the past";
   }
 
+  # If the user entered a new location, set it up now.
+  if ( $opt->{'cust_location'} ) {
+    $error = $opt->{'cust_location'}->find_or_insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "creating location record: $error";
+    }
+    $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
+  }
+
   if ( $self->change_to_pkgnum ) {
     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
     my $new_pkgpart = $opt->{'pkgpart'}
index 9d9ecdd..3f73483 100644 (file)
@@ -823,13 +823,12 @@ sub seconds_since { 'internal session db deprecated'; };
 
 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
 
-See L<FS::svc_acct/seconds_since_sqlradacct>.  Equivalent to
-$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient.  Meaningless
-for records where B<svcdb> is not "svc_acct".
+Equivalent to $cust_svc->svc_x->seconds_since_sqlradacct, but 
+more efficient.  Meaningless for records where B<svcdb> is not 
+svc_acct or svc_broadband.
 
 =cut
 
-#note: implementation here, POD in FS::svc_acct
 sub seconds_since_sqlradacct {
   my($self, $start, $end) = @_;
 
@@ -968,12 +967,11 @@ sub seconds_since_sqlradacct {
 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
 
 See L<FS::svc_acct/attribute_since_sqlradacct>.  Equivalent to
-$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.  Meaningless
-for records where B<svcdb> is not "svc_acct".
+$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.
+Meaningless for records where B<svcdb> is not svc_acct or svc_broadband.
 
 =cut
 
-#note: implementation here, POD in FS::svc_acct
 #(false laziness w/seconds_since_sqlradacct above)
 sub attribute_since_sqlradacct {
   my($self, $start, $end, $attrib) = @_;
diff --git a/FS/FS/detail_format/sum_duration_accountcode.pm b/FS/FS/detail_format/sum_duration_accountcode.pm
new file mode 100644 (file)
index 0000000..d181d47
--- /dev/null
@@ -0,0 +1,69 @@
+package FS::detail_format::sum_duration_accountcode;
+
+use strict;
+use vars qw( $DEBUG );
+use base qw(FS::detail_format);
+
+$DEBUG = 0;
+
+my $me = '[sum_duration_accountcode]';
+
+sub name { 'Summary, one line per accountcode' };
+
+sub header_detail {
+  'Account code,Calls,Duration,Price';
+}
+
+sub append {
+  my $self = shift;
+  my $codes = ($self->{codes} ||= {});
+  my $acctids = ($self->{acctids} ||= []);
+  foreach my $cdr (@_) {
+    my $accountcode = $cdr->accountcode || 'other';
+
+    my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
+    my $subtotal = $codes->{$accountcode}
+               ||= { count => 0, duration => 0, amount => 0.0 };
+    $subtotal->{count}++;
+    $subtotal->{duration} += $object->rated_seconds;
+    $subtotal->{amount} += $object->rated_price
+      if $object->freesidestatus ne 'no-charge';
+
+    push @$acctids, $cdr->acctid;
+  }
+}
+
+sub finish {
+  my $self = shift;
+  my $codes = $self->{codes};
+  foreach my $accountcode (sort { $a cmp $b } keys %$codes) {
+
+    warn "processing $accountcode\n" if $DEBUG;
+
+    my $subtotal = $codes->{$accountcode};
+
+    $self->csv->combine(
+      $accountcode,
+      $subtotal->{count},
+      sprintf('%.01f min', $subtotal->{duration}/60),
+      $self->money_char . sprintf('%.02f', $subtotal->{amount})
+    );
+
+    warn "adding detail: ".$self->csv->string."\n" if $DEBUG;
+
+    push @{ $self->{buffer} }, FS::cust_bill_pkg_detail->new({
+        amount      => $subtotal->{amount},
+        format      => 'C',
+        classnum    => '', #ignored in this format
+        duration    => $subtotal->{duration},
+        phonenum    => '', # not divided up per service
+        accountcode => $accountcode,
+        startdate   => '',
+        regionname  => '',
+        detail      => $self->csv->string,
+        acctid      => $self->{acctids},
+    });
+  } #foreach $accountcode
+}
+
+1;
index 9dba582..ab1b0c3 100644 (file)
@@ -9,6 +9,7 @@ my @contexts = ( qw(
   bill_and_collect
   FS::cust_main::Billing::bill_and_collect
   FS::cust_main::Billing::bill
+  FS::cust_main::Billing_Realtime::realtime_verify_bop
   FS::pay_batch::import_from_gateway
   FS::Misc::Geo::standardize_uscensus
   Cron::bill
index 26fdc35..beae6d8 100644 (file)
@@ -191,22 +191,35 @@ sub prorate_setup {
   my $self = shift;
   my ($cust_pkg, $sdate) = @_;
   my @cutoff_days = $self->cutoff_day($cust_pkg);
-  if ( ! $cust_pkg->bill
-      and $self->option('prorate_defer_bill',1)
-      and @cutoff_days
-  ) {
-    my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
-    # If today is the cutoff day, set the next bill and setup both to 
-    # midnight today, so that the customer will be billed normally for a 
-    # month starting today.
-    if ( $mnow - $mstart < 86400 ) {
-      $cust_pkg->setup($mstart);
-      $cust_pkg->bill($mstart);
+  if ( @cutoff_days and $self->option('prorate_defer_bill', 1) ) {
+    if ( $cust_pkg->setup ) {
+      # Setup date is already set. Then we're being called indirectly via calc_prorate
+      # to calculate the deferred setup fee. Allow that to happen normally.
+      return 0;
+    } else {
+      # We're going to set the setup date (so that the deferred billing knows when
+      # the package started) and suppress charging the setup fee.
+      if ( $cust_pkg->bill ) {
+        # For some reason (probably user override), the bill date has been set even
+        # though the package isn't billing yet. Start billing as though that was the
+        # start date.
+        $sdate = $cust_pkg->bill;
+        $cust_pkg->setup($cust_pkg->bill);
+      }
+      # Now figure the start and end of the period that contains the start date.
+      my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
+      # If today is the cutoff day, set the next bill and setup both to 
+      # midnight today, so that the customer will be billed normally for a 
+      # month starting today.
+      if ( $mnow - $mstart < 86400 ) {
+        $cust_pkg->setup($mstart);
+        $cust_pkg->bill($mstart);
+      }
+      else {
+        $cust_pkg->bill($mend);
+      }
+      return 1;
     }
-    else {
-      $cust_pkg->bill($mend);
-    }
-    return 1;
   }
   return 0;
 }
index be2d15b..24c4cf0 100644 (file)
@@ -401,8 +401,10 @@ sub calc_usage {
   my $included_min = $self->option('min_included', 1) || 0;
     #single price rating
     #or region group
+  $included_min *= ($cust_pkg->quantity || 1);
 
   my $included_calls = $self->option('calls_included', 1) || 0;
+  $included_calls *= ($cust_pkg->quantity || 1);
 
   my $cdr_svc_method    = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
   my $rating_method     = $self->option('rating_method') || 'prefix';
@@ -664,7 +666,8 @@ sub reset_usage {
                 FS::cust_pkg_usage->new({
                     'pkgnum'        => $cust_pkg->pkgnum,
                     'pkgusagepart'  => $part,
-                    'minutes'       => $part_pkg_usage->minutes,
+                    'minutes'       => $part_pkg_usage->minutes *
+                                        ($cust_pkg->quantity || 1),
                 });
     foreach my $cdr_usage (
       qsearch('cdr_cust_pkg_usage', {'cdrusagenum' => $usage->cdrusagenum})
index 052bb7f..81f2765 100644 (file)
@@ -214,6 +214,7 @@ sub calc_usage {
 #  my $downstream_cdr = '';
 
   my $included_min  = $self->option('min_included', 1) || 0;
+  $included_min *= ($cust_pkg->quantity || 1);
   my $use_duration  = $self->option('use_duration');
   my $output_format = $self->option('output_format', 1) || 'default';
 
index a205f9f..299d5c1 100644 (file)
@@ -131,7 +131,8 @@ sub calc_recur {
       # find the price and add detail to the invoice
       ###
 
-      $included_min{$regionnum} = $rate_detail->min_included
+      $included_min{$regionnum} =
+          ($rate_detail->min_included * $cust_pkg->quantity || 1)
         unless exists $included_min{$regionnum};
 
       my $granularity = $rate_detail->sec_granularity;
index d8d74c1..0ad0ff6 100644 (file)
@@ -81,6 +81,7 @@ sub calc_usage {
     && ( $last_bill eq '' || $last_bill == 0 );
 
   my $included_min    = $self->option('min_included', 1) || 0;
+  $included_min *= ($cust_pkg->quantity || 1);
   my $cdr_svc_method  = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
   my $cdr_inout       = ($cdr_svc_method eq 'svc_phone.phonenum')
                           && $self->option('cdr_inout',1)
index dd527b9..a34f616 100644 (file)
@@ -160,6 +160,29 @@ sub password_equals {
 
 }
 
+sub _upgrade_schema {
+  # clean up history records where linked_acct has gone away
+  my @where;
+  for my $fk ( grep /__/, __PACKAGE__->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) )";
+  }
+  my @recs = qsearch({
+      'table'     => 'password_history',
+      'extra_sql' => ' WHERE ' . join(' AND ', @where),
+  });
+  my $error;
+  if (@recs) {
+    warn "Removing unattached password_history records (".scalar(@recs).").\n";
+    foreach my $password_history (@recs) {
+      $error = $password_history->delete;
+      die $error if $error;
+    }
+  }
+  '';
+}
+
 =back
 
 =head1 BUGS
index ee1f4bd..10faa74 100644 (file)
@@ -13,7 +13,7 @@ use File::Copy qw(copy);
 use Text::CSV;
 
 my %opt;
-getopts('vqa:P:C:e:', \%opt);
+getopts('vqNa:P:C:e:', \%opt);
 
 # Product codes that are subject to flat rate E911 charges.  For these 
 # products, the'quantity' field represents the number of lines.
@@ -32,6 +32,7 @@ sub HELP_MESSAGE { '
       freeside-ipifony-download 
         [ -v ]
         [ -q ]
+        [ -N ]
         [ -a archivedir ]
         [ -P port ]
         [ -C category ]
@@ -192,7 +193,8 @@ FILE: foreach my $filename (@$files) {
     if ( $next_bill_date ) {
       my ($bill_month, $bill_year) = (localtime($next_bill_date))[4, 5];
       my ($this_month, $this_year) = (localtime(time))[4, 5];
-      if ( $this_month == $bill_month and $this_year == $bill_year ) {
+      if ( $opt{N} or 
+           $this_month == $bill_month and $this_year == $bill_year ) {
         $cust_main->set('charge_date', $next_bill_date);
       }
     }
@@ -296,6 +298,7 @@ freeside-ipifony-download - Download and import invoice items from IPifony.
       freeside-ipifony-download 
         [ -v ]
         [ -q ]
+        [ -N ]
         [ -a archivedir ]
         [ -P port ]
         [ -C category ]
@@ -312,12 +315,19 @@ have an authorization key to connect as that user.
 
 I<hostname>: the SFTP server.
 
+I<path>: the path on the server to the working directory. The working
+directory is the one containing the "ready/" and "done/" subdirectories.
+
 =head1 OPTIONAL PARAMETERS
 
 -v: Be verbose.
 
 -q: Include the quantity and unit price in the charge description.
 
+-N: Always bill the charges on the customer's next bill date, if they have
+one. Otherwise, charges will be billed on the next bill date only if it's
+within the current calendar month.
+
 -a I<archivedir>: Save a copy of the downloaded file to I<archivedir>.
 
 -P I<port>: Connect to that TCP port.
diff --git a/FS/t/suite/06-prorate_defer_bill.t b/FS/t/suite/06-prorate_defer_bill.t
new file mode 100755 (executable)
index 0000000..e14b8ec
--- /dev/null
@@ -0,0 +1,92 @@
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Tests the prorate_defer_bill behavior when a package is started on the cutoff day,
+and when it's started later in the month.
+
+Correct: The package started on the cutoff day should be charged a setup fee and a
+full period. The package started later in the month should be charged a setup fee,
+a full period, and the partial period.
+
+=cut
+
+use strict;
+use Test::More tests => 11;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_main;
+use FS::cust_pkg;
+use FS::Conf;
+my $FS= FS::Test->new;
+
+my $error;
+
+my $old_part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 2 });
+my $part_pkg = $old_part_pkg->clone;
+BAIL_OUT("existing pkgpart 2 is not a prorated monthly package")
+  unless $part_pkg->freq eq '1' and $part_pkg->plan eq 'prorate';
+$error = $part_pkg->insert(
+  options => {  $old_part_pkg->options,
+                'prorate_defer_bill' => 1,
+                'cutoff_day' => 1,
+                'setup_fee'  => 100,
+                'recur_fee'  => 30,
+              }
+);
+BAIL_OUT("can't configure package: $error") if $error;
+
+my $cust = $FS->new_customer('Prorate defer');
+$error = $cust->insert;
+BAIL_OUT("can't create test customer: $error") if $error;
+
+my @pkgs;
+foreach my $start_day (1, 11) {
+  diag("prorate package starting on day $start_day");
+  # Create and bill the first package.
+  my $date = str2time("2016-04-$start_day");
+  set_fixed_time($date);
+  my $pkg = FS::cust_pkg->new({ pkgpart => $part_pkg->pkgpart });
+  $error = $cust->order_pkg({ 'cust_pkg' => $pkg });
+  BAIL_OUT("can't order package: $error") if $error;
+
+  # bill the customer on the order date
+  $error = $cust->bill_and_collect;
+  $pkg = $pkg->replace_old;
+  push @pkgs, $pkg;
+  my ($cust_bill_pkg) = $pkg->cust_bill_pkg;
+  if ( $start_day == 1 ) {
+    # then it should bill immediately
+    ok($cust_bill_pkg, "package was billed") or next;
+    ok($cust_bill_pkg->setup == 100, "setup fee was charged");
+    ok($cust_bill_pkg->recur == 30, "one month was charged");
+  } elsif ( $start_day == 11 ) {
+    # then not
+    ok(!$cust_bill_pkg, "package billing was deferred");
+    ok($pkg->setup == $date, "package setup date was set");
+  }
+}
+diag("first of month billing...");
+my $date = str2time('2016-05-01');
+set_fixed_time($date);
+my @bill;
+$error = $cust->bill_and_collect(return_bill => \@bill);
+# examine the invoice...
+my $cust_bill = $bill[0] or BAIL_OUT("neither package was billed");
+for my $pkg ($pkgs[0]) {
+  diag("package started day 1:");
+  my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg;
+  ok($cust_bill_pkg, "was billed") or next;
+  ok($cust_bill_pkg->setup == 0, "no setup fee was charged");
+  ok($cust_bill_pkg->recur == 30, "one month was charged");
+}
+for my $pkg ($pkgs[1]) {
+  diag("package started day 11:");
+  my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg;
+  ok($cust_bill_pkg, "was billed") or next;
+  ok($cust_bill_pkg->setup == 100, "setup fee was charged");
+  ok($cust_bill_pkg->recur == 50, "twenty days + one month was charged");
+}
+
diff --git a/FS/t/suite/07-pkg_change_location.t b/FS/t/suite/07-pkg_change_location.t
new file mode 100755 (executable)
index 0000000..6744f78
--- /dev/null
@@ -0,0 +1,82 @@
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Test scheduling a package location change through the UI, then billing
+on the day of the scheduled change.
+
+=cut
+
+use Test::More tests => 6;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_pkg;
+my $FS = FS::Test->new;
+my $error;
+
+# set up a customer with an active package
+my $cust = $FS->new_customer('Future location change');
+$error = $cust->insert;
+my $pkg = FS::cust_pkg->new({pkgpart => 2});
+$error ||= $cust->order_pkg({ cust_pkg => $pkg });
+my $date = str2time('2016-04-01');
+set_fixed_time($date);
+$error ||= $cust->bill_and_collect;
+BAIL_OUT($error) if $error;
+
+# get the form
+my %args = ( pkgnum  => $pkg->pkgnum,
+             pkgpart => $pkg->pkgpart,
+             locationnum => -1);
+$FS->post('/misc/change_pkg.cgi', %args);
+my $form = $FS->form('OrderPkgForm');
+
+# Schedule the package change two days from now.
+$date += 86400*2;
+my $date_str = time2str('%x', $date);
+
+my %params = (
+  start_date              => $date_str,
+  delay                   => 1,
+  address1                => int(rand(1000)) . ' Changed Street',
+  city                    => 'New City',
+  state                   => 'CA',
+  zip                     => '90001',
+  country                 => 'US',
+);
+
+diag "requesting location change to $params{address1}";
+
+foreach (keys %params) {
+  $form->value($_, $params{$_});
+}
+$FS->post($form);
+ok( $FS->error eq '' , 'form posted' );
+if ( ok( $FS->page =~ m[location.reload], 'location change accepted' )) {
+  #nothing
+} else {
+  $FS->post($FS->redirect);
+  BAIL_OUT( $FS->error);
+}
+# check that the package change is set
+$pkg = $pkg->replace_old;
+my $new_pkgnum = $pkg->change_to_pkgnum;
+ok( $new_pkgnum, 'package change is scheduled' );
+
+# run it and check that the package change happened
+diag("billing customer on $date_str");
+set_fixed_time($date);
+my $error = $cust->bill_and_collect;
+BAIL_OUT($error) if $error;
+
+$pkg = $pkg->replace_old;
+ok($pkg->get('cancel'), "old package is canceled");
+my $new_pkg = $FS->qsearchs('cust_pkg', { pkgnum => $new_pkgnum });
+ok($new_pkg->setup, "new package is active");
+ok($new_pkg->cust_location->address1 eq $params{'address1'}, "new location is correct")
+  or diag $new_pkg->cust_location->address1;
+
+1;
+
index 1054e6a..2407361 100755 (executable)
@@ -998,7 +998,7 @@ sub delete_svc {
 sub view_usage {
   my $res = list_svcs(
     'session_id'  => $session_id,
-    'svcdb'       => [ 'svc_acct', 'svc_phone', 'svc_port', 'svc_pbx' ],
+    'svcdb'       => [ 'svc_acct', 'svc_broadband', 'svc_phone', 'svc_port', 'svc_pbx' ],
     'ncancelled'  => 1,
   );
   if ($res->{hide_usage}) {
index 4099a57..07ccfed 100644 (file)
@@ -1,21 +1,27 @@
 <%= $url = "$selfurl?action=";
     %by_pkg_label = (); # not used yet, but I'm sure it will be...
-    @svc_acct = ();
     @svc_phone = ();
     @svc_port = ();
     @svc_pbx = ();
-
-    foreach (@svcs) {
-      $by_pkg_label{ $_->{pkg_label} } ||= [];
-      push @{ $by_pkg_label{ $_->{pkg_label} } }, $_;
-      if ( $_->{svcdb} eq 'svc_acct' ) {
-        push @svc_acct, $_;
-      } elsif ( $_->{svcdb} eq 'svc_phone' ) {
-        push @svc_phone, $_;
-      } elsif ( $_->{svcdb} eq 'svc_port' ) {
-        push @svc_port, $_;
-      } elsif ( $_->{svcdb} eq 'svc_pbx' ) {
-        push @svc_pbx, $_;
+    @bytes_svcs = (); # contains svc_acct and svc_broadband
+    @bytes_cols = qw(seconds_used seconds upbytes_used upbytes downbytes_used downbytes totalbytes_used totalbytes);
+    %bytes_show = map { $_ => 0 } @bytes_cols;
+
+    foreach my $svc (@svcs) {
+      $by_pkg_label{ $svc->{pkg_label} } ||= [];
+      push @{ $by_pkg_label{ $svc->{pkg_label} } }, $svc;
+
+      if (( $svc->{svcdb} eq 'svc_acct' ) || ( $svc->{svcdb} eq 'svc_broadband' )) {
+        foreach my $field (@bytes_cols) {
+          $bytes_show{$field} = 1 if length($svc->{$field}) or (($field !~ /_used$/) && $svc->{'recharge_'.$field});
+        }
+        push @bytes_svcs, $svc;
+      } elsif ( $svc->{svcdb} eq 'svc_phone' ) {
+        push @svc_phone, $svc;
+      } elsif ( $svc->{svcdb} eq 'svc_port' ) {
+        push @svc_port, $svc;
+      } elsif ( $svc->{svcdb} eq 'svc_pbx' ) {
+        push @svc_pbx, $svc;
       }
     }
     '';
   $OUT .= qq!<FONT SIZE="+1" COLOR="#ff0000">$error</FONT><BR><BR>!;
 } ''; %>
 
-<%= if ( @svc_acct ) {
-      $OUT.= '<TABLE BGCOLOR="#cccccc">
-                <TR>
-                  <TH ALIGN="left">Account</TH>
-                  <TH ALIGN="right">Time remaining</TH>
-                  <TH ALIGN="right">Upload remaining</TH>
-                  <TH ALIGN="right">Download remaining</TH>
-                  <TH ALIGN="right">Total remaining</TH>
-                </TR>';
-    } else {
-      $OUT .= '';
+<%= if ( @bytes_svcs ) {
+      $OUT .= '<TABLE BGCOLOR="#cccccc"><TR>';
+      $OUT .= '<TH ALIGN="left">Account</TH>';
+      $OUT .= '<TH ALIGN="right">Time used</TH>' if $bytes_show{'seconds_used'};
+      $OUT .= '<TH ALIGN="right">Time remaining</TH>' if $bytes_show{'seconds'};
+      $OUT .= '<TH ALIGN="right">Upload used</TH>' if $bytes_show{'upbytes_used'};
+      $OUT .= '<TH ALIGN="right">Upload remaining</TH>' if $bytes_show{'upbytes'};
+      $OUT .= '<TH ALIGN="right">Download used</TH>' if $bytes_show{'downbytes_used'};
+      $OUT .= '<TH ALIGN="right">Download remaining</TH>' if $bytes_show{'downbytes'};
+      $OUT .= '<TH ALIGN="right">Total used</TH>' if $bytes_show{'totalbytes_used'};
+      $OUT .= '<TH ALIGN="right">Total remaining</TH>' if $bytes_show{'totalbytes'};
+      $OUT .= '</TR>';
     }
 %>
 
-<%= foreach my $svc ( @svc_acct ) {
-      my $link = "${url}view_usage_details;".
-        "svcnum=$svc->{'svcnum'};beginning=0;ending=0";
+<%= foreach my $svc ( @bytes_svcs ) {
+    my $link = "${url}view_usage_details;".
+      "svcnum=$svc->{'svcnum'};beginning=0;ending=0";
     my $username = $svc->{'value'};
     $username =~ s/@.*?$//g if $view_usage_nodomain;
-  $OUT .= '<TR><TD>';
-    $OUT .= qq!<A HREF="$link">!. $svc->{'label'}. ': '. $username .'</A>';
-    $OUT .= '</TD><TD ALIGN="right">';
-    $OUT .= $svc->{'seconds'};
-    $OUT .= '</TD><TD ALIGN="right">';
-    $OUT .=  $svc->{'upbytes'};
-    $OUT .= '</TD><TD ALIGN="right">';
-    $OUT .= $svc->{'downbytes'};
-    $OUT .= '</TD><TD ALIGN="right">';
-    $OUT .= $svc->{'totalbytes'};
-  $OUT .= '</TD></TR>';
+    $OUT .= '<TR>';
+    $OUT .= '<TD>' . qq!<A HREF="$link">!. $svc->{'label'}. ': '. $username .'</A></TD>';
+    foreach my $field (@bytes_cols) {
+      $OUT .= '<TD ALIGN="right">' . $svc->{$field} . '</TD>' if $bytes_show{$field};
+    }
     if ( $svc->{'recharge_amount'} ) {
       my $link = "${url}process_order_recharge;".
                  "svcnum=$svc->{'svcnum'}";
-    $OUT .= '<TR><TD ALIGN="right">';
+      $OUT .= '<TR><TD ALIGN="right">';
       $OUT .= qq!<A HREF="$link">!.'Recharge for $';
       $OUT .= $svc->{'recharge_amount'} . '</A> with';
-      $OUT .= '</TD><TD ALIGN="right">';
-      $OUT .= $svc->{'recharge_seconds'} if $svc->{'recharge_seconds'};
-      $OUT .= '</TD><TD ALIGN="right">';
-      $OUT .=  $svc->{'recharge_upbytes'} if $svc->{'recharge_upbytes'};
-      $OUT .= '</TD><TD ALIGN="right">';
-      $OUT .= $svc->{'recharge_downbytes'} if $svc->{'recharge_downbytes'};
-      $OUT .= '</TD><TD ALIGN="right">';
-      $OUT .= $svc->{'recharge_totalbytes'} if $svc->{'recharge_totalbytes'};
-    $OUT .= '</TD></TR>';
+      $OUT .= '</TD>';
+      foreach my $field ( qw(seconds upbytes downbytes totalbytes) ) {
+        $OUT .= '<TD></TD>' if $bytes_show{$field.'_used'};
+        if ($bytes_show{$field}) {
+          $OUT .= '<TD ALIGN="right">';
+          $OUT .= $svc->{'recharge_'.$field} if $svc->{'recharge_'.$field};
+          $OUT .= '</TD>';
+        }
+      }
+      $OUT .= '</TR>';
     }
   }
 %>
 
-<%= scalar(@svc_acct) ? '</TABLE><BR><BR>' : '' %>
+<%= scalar(@bytes_svcs) ? '</TABLE><BR><BR>' : '' %>
 
 <%= if ( @svc_phone or @svc_pbx ) {
       %any = ();
index 9e4ac32..b836bae 100644 (file)
@@ -26,8 +26,16 @@ my $quotation_pkg = qsearchs({
                  'LEFT JOIN cust_main USING ( custnum )',
   'hashref'   => { 'quotationpkgnum' => $pkgnum },
   'extra_sql' => ' AND '. $curuser->agentnums_sql,
+})
+|| qsearchs({
+  'table'     => 'quotation_pkg',
+  'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'.
+                 'LEFT JOIN prospect_main USING ( prospectnum )',
+  'hashref'   => { 'quotationpkgnum' => $pkgnum },
+  'extra_sql' => ' AND '. $curuser->agentnums_sql,
 });
 
+
 my @orig_details = $quotation_pkg->details();
 
 my $action = 'Quotation details'.
index 036bffd..9aa50ec 100644 (file)
@@ -61,6 +61,13 @@ my $quotation_pkg = qsearchs({
                  'LEFT JOIN cust_main USING ( custnum )',
   'hashref'   => { 'quotationpkgnum' => $pkgnum },
   'extra_sql' => ' AND '. $curuser->agentnums_sql,
+})
+|| qsearchs({
+  'table'     => 'quotation_pkg',
+  'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'.
+                 'LEFT JOIN prospect_main USING ( prospectnum )',
+  'hashref'   => { 'quotationpkgnum' => $pkgnum },
+  'extra_sql' => ' AND '. $curuser->agentnums_sql,
 });
 
 my $part_pkg = $quotation_pkg->part_pkg;
index c7d4549..60e6eb8 100644 (file)
@@ -68,7 +68,7 @@
                ID        = "<%$id%>_paycvv"
                SIZE      = 2
                MAXLENGTH = 4
-               VALUE     = "<% scalar($cgi->param($name.'_paycvv')) %>"
+               VALUE     = "<% scalar($cgi->param($name.'_paycvv')) || ('*' x length($cust_payby->paycvv)) %>"
                onChange  = "<% $onchange %>"
         >
         <BR><FONT SIZE="-1"><% mt('CVV2') |h %>&nbsp;(<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('<%$p%>docs/cvv2.html', 480, 275, 'cvv2_popup' ), CAPTION, 'CVV2 Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)</FONT>
index 699f82c..db38eaf 100644 (file)
@@ -67,6 +67,9 @@ Example:
         <td align=left BGCOLOR="#ffffff"> <!-- valign="top" -->
           <font size=6><% $company_name || 'ExampleCo' %></font>
         </td>
+        <td align="right" BGCOLOR="#ffffff">
+          <& notify-tickets.html &>
+        </td>
         <td align=right valign=top BGCOLOR="#ffffff"><FONT SIZE="-1">Logged in as <b><% $FS::CurrentUser::CurrentUser->username |h %>&nbsp;</b> <FONT SIZE="-2"><a href="<%$fsurl%>loginout/logout.html">logout</a></FONT><br></FONT><FONT SIZE="-2"><a href="<%$fsurl%>pref/pref.html" STYLE="color: #000000">Preferences</a>
 %         if ( $conf->config("ticket_system")
 %              && FS::TicketSystem->access_right(\%session, 'ModifySelf') ) {
diff --git a/httemplate/elements/notify-tickets.html b/httemplate/elements/notify-tickets.html
new file mode 100644 (file)
index 0000000..faf998e
--- /dev/null
@@ -0,0 +1,36 @@
+% if ($enabled) {
+<style>
+.dot {
+  border-radius: 50%;
+  border: 1px solid black;
+  width: 1ex;
+  height: 1ex;
+  display: inline-block;
+}
+</style>
+<div style="font-weight: bold; vertical-align: bottom; text-align: left">
+%   if ( $UnrepliedTickets->Count > 0 ) {
+  <a href="<% $fsurl %>rt/Search/UnrepliedTickets.html">
+    <div class="dot" style="background-color: green"></div>
+    <% emt('New activity on [quant,_1,ticket]', $UnrepliedTickets->Count) %>
+  </a>
+%   } else {
+  <% emt('No new activity on tickets') %>
+%   }
+</div>
+% }
+<%init>
+use Class::Load 'load_class';
+
+my $enabled = $FS::TicketSystem::system eq 'RT_Internal';
+my $UnrepliedTickets;
+if ($enabled) {
+  my $class = 'RT::Search::UnrepliedTickets';
+  load_class($class);
+  my $session = FS::TicketSystem->session;
+  my $CurrentUser = $session->{CurrentUser};
+  $UnrepliedTickets = RT::Tickets->new($CurrentUser);
+  my $search = $class->new(TicketsObj => $UnrepliedTickets);
+  $search->Prepare;
+}
+</%init>
index 6244b6c..f4af405 100644 (file)
@@ -86,7 +86,7 @@
   </TR>
 
 % } else { # so that the rest of the page works correctly
-  <INPUT TYPE="hidden" ID="classnum" NAME="classnum" VALUE="-1`">
+  <INPUT TYPE="hidden" ID="classnum" NAME="classnum" VALUE="-1">
 % }
 
 <TR>
index d232fe7..7768f92 100644 (file)
@@ -86,7 +86,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
 
   $payinfo = $cust_payby->payinfo;
   $paymask = $cust_payby->paymask;
-  $paycvv = '';
+  $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it
   ( $month, $year ) = $cust_payby->paydate_mon_year;
   $payname = $cust_payby->payname;
 
index 96255b5..825e650 100644 (file)
     { Name        => 'Open Tickets',                                      # loc
       Description => 'Open tickets on correspondence',                    # loc
       ExecModule  => 'AutoOpen' },
-    { Name        => 'Open Inactive Tickets',                             # loc
-      Description => 'Open inactive tickets',                             # loc
-      ExecModule  => 'AutoOpenInactive' },
     { Name        => 'Extract Subject Tag',                               # loc
       Description => 'Extract tags from a Transaction\'s subject and add them to the Ticket\'s subject.', # loc
       ExecModule  => 'ExtractSubjectTag' },
@@ -801,9 +798,9 @@ Hour:         { $SubscriptionObj->SubValue('Hour') }
 #       ScripCondition => 'On Correspond',
 #       ScripAction    => 'Notify Requestors And Ccs',
 #       Template       => 'Correspondence in HTML' },
-    {  Description    => 'On Correspond Open Inactive Tickets',
+    {  Description    => 'On Correspond Open Tickets',
        ScripCondition => 'On Correspond',
-       ScripAction    => 'Open Inactive Tickets',
+       ScripAction    => 'Open Tickets',
        Template       => 'Blank' },
     {  Description    => 'On Create Autoreply To Requestors',
        ScripCondition => 'On Create',
@@ -947,7 +944,17 @@ Hour:         { $SubscriptionObj->SubValue('Hour') }
   'on correspond' => {
     'notify requestors and ccs' => { 'correspondence' => 1 },
     'notify other recipients'   => { 'correspondence' => 1 },
-  }
+    # RT 4.2
+    # superseded by "notify owner and adminccs"
+    'notify adminccs'           => { 'admin correspondence' => 1 },
+    # the new way, but doesn't work right vs. "open tickets"
+    'open inactive tickets'              => { 'blank' => 1 },
+  },
+  'on create' => {
+    # RT 4.2
+    # superseded by "notify owner and adminccs"
+    'notify adminccs'           => { 'transaction' => 1 },
+  },
 );
 
 # -*- perl -*-
diff --git a/rt/lib/RT/Search/UnrepliedTickets.pm b/rt/lib/RT/Search/UnrepliedTickets.pm
new file mode 100644 (file)
index 0000000..a996901
--- /dev/null
@@ -0,0 +1,62 @@
+=head1 NAME
+
+  RT::Search::UnrepliedTickets
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+Find all unresolved tickets owned by the current user where the last correspondence
+from a requestor (or ticket creation) is more recent than the last
+correspondence from a non-requestor (if there is any).
+
+=head1 METHODS
+
+=cut
+
+package RT::Search::UnrepliedTickets;
+
+use strict;
+use warnings;
+use base qw(RT::Search);
+
+
+sub Describe  {
+  my $self = shift;
+  return ($self->loc("Tickets awaiting a reply"));
+}
+
+sub Prepare  {
+  my $self = shift;
+
+  my $TicketsObj = $self->TicketsObj;
+  $TicketsObj->Limit(
+    FIELD => 'Owner',
+    VALUE => $TicketsObj->CurrentUser->id
+  );
+  $TicketsObj->Limit(
+    FIELD => 'Status',
+    OPERATOR => '!=',
+    VALUE => 'resolved'
+  );
+  my $txn_alias = $TicketsObj->JoinTransactions;
+  $TicketsObj->Limit(
+    ALIAS => $txn_alias,
+    FIELD => 'Created',
+    OPERATOR => '>',
+    VALUE => 'COALESCE(main.Told,\'1970-01-01\')',
+    QUOTEVALUE => 0,
+  );
+  $TicketsObj->Limit(
+    ALIAS => $txn_alias,
+    FIELD => 'Type',
+    OPERATOR => 'IN',
+    VALUE => [ 'Correspond', 'Create' ],
+  );
+
+  return(1);
+}
+
+RT::Base->_ImportOverlays();
+
+1;
diff --git a/rt/share/html/Search/UnrepliedTickets.html b/rt/share/html/Search/UnrepliedTickets.html
new file mode 100755 (executable)
index 0000000..37f94e0
--- /dev/null
@@ -0,0 +1,156 @@
+%# false laziness with Results.html; basically this is the same thing but with
+%# a hardcoded RT::Tickets object instead of a Query param
+
+<& /Elements/Header, Title => $title,
+    Refresh => $refresh,
+    LinkRel => \%link_rel &>
+
+% $m->callback( ARGSRef => \%ARGS, Format => \$Format, CallbackName => 'BeforeResults' );
+
+<& /Elements/CollectionList, 
+    Class => 'RT::Tickets',
+    Collection => $session{tickets},
+    TotalFound => $ticketcount,
+    AllowSorting => 1,
+    OrderBy => $OrderBy,
+    Order => $Order,
+    Rows => $Rows,
+    Page => $Page,
+    Format => $Format,
+    BaseURL => $BaseURL,
+    SavedSearchId => $ARGS{'SavedSearchId'},
+    SavedChartSearchId => $ARGS{'SavedChartSearchId'},
+    PassArguments => [qw(Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId)],
+&>
+% $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
+
+% my %hiddens = (Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
+<div align="right" class="refresh">
+<form method="get" action="<%RT->Config->Get('WebPath')%>/Search/UnrepliedTickets.html">
+% foreach my $key (keys(%hiddens)) {
+<input type="hidden" class="hidden" name="<%$key%>" value="<% defined($hiddens{$key})?$hiddens{$key}:'' %>" />
+% }
+<& /Elements/Refresh, Name => 'TicketsRefreshInterval', Default => $session{'tickets_refresh_interval'}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
+<input type="submit" class="button" value="<&|/l&>Change</&>" />
+</form>
+</div>
+<%INIT>
+$m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );
+
+# Read from user preferences
+my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {};
+
+# These variables are what define a search_hash; this is also
+# where we give sane defaults.
+$Format      ||= $prefs->{'Format'} || RT->Config->Get('DefaultSearchResultFormat');
+$Order       ||= $prefs->{'Order'} || RT->Config->Get('DefaultSearchResultOrder');
+$OrderBy     ||= $prefs->{'OrderBy'} || RT->Config->Get('DefaultSearchResultOrderBy');
+
+# In this case the search UI isn't available, so trust the defaults.
+
+# Some forms pass in "RowsPerPage" rather than "Rows"
+# We call it RowsPerPage everywhere else.
+
+if ( defined $prefs->{'RowsPerPage'} ) {
+    $Rows = $prefs->{'RowsPerPage'};
+} else {
+    $Rows = 50;
+}
+$Page = 1 unless $Page && $Page > 0;
+
+use RT::Search::UnrepliedTickets;
+
+$session{'i'}++;
+$session{'tickets'} = RT::Tickets->new($session{'CurrentUser'}) ;
+my $search = RT::Search::UnrepliedTickets->new( TicketsObj => $session{'tickets'} );
+$search->Prepare;
+
+if ($OrderBy =~ /\|/) {
+    # Multiple Sorts
+    my @OrderBy = split /\|/,$OrderBy;
+    my @Order = split /\|/,$Order;
+    $session{'tickets'}->OrderByCols(
+        map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0
+        .. $#OrderBy ) );; 
+} else {
+    $session{'tickets'}->OrderBy(FIELD => $OrderBy, ORDER => $Order); 
+}
+$session{'tickets'}->RowsPerPage( $Rows ) if $Rows;
+$session{'tickets'}->GotoPage( $Page - 1 );
+
+# use this to set a CSRF token applying to the search, so that the user can come
+# back to this page without triggering a referrer check
+$session{'CurrentSearchHash'} = {
+    Format      => $Format,
+    Page        => $Page,
+    Order       => $Order,
+    OrderBy     => $OrderBy,
+    RowsPerPage => $Rows
+};
+
+
+my $ticketcount = $session{tickets}->CountAll();
+my $title = loc('New activity on [quant,_1,ticket,tickets]', $ticketcount);
+
+# pass this through on pagination links
+my $QueryString = "?".$m->comp('/Elements/QueryString',
+                               Format => $Format,
+                               Rows => $Rows,
+                               OrderBy => $OrderBy,
+                               Order => $Order,
+                               Page => $Page);
+
+if ($ARGS{'TicketsRefreshInterval'}) {
+    $session{'tickets_refresh_interval'} = $ARGS{'TicketsRefreshInterval'};
+}
+
+my $refresh = $session{'tickets_refresh_interval'}
+    || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
+
+# Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh
+if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) {
+    my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentSearchHash'} );
+    $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
+        . "Search/UnrepliedTickets.html?CSRF_Token="
+            . $token;
+}
+
+my %link_rel;
+my $genpage = sub {
+    return $m->comp(
+        '/Elements/QueryString',
+        Format  => $Format,
+        Rows    => $Rows,
+        OrderBy => $OrderBy,
+        Order   => $Order,
+        Page    => shift(@_),
+    );
+};
+
+if ( RT->Config->Get('SearchResultsAutoRedirect') && $ticketcount == 1 &&
+    $session{tickets}->First ) {
+# $ticketcount is not always precise unless $UseSQLForACLChecks is set to true,
+# check $session{tickets}->First here is to make sure the ticket is there.
+    RT::Interface::Web::Redirect( RT->Config->Get('WebURL')
+            ."Ticket/Display.html?id=". $session{tickets}->First->id );
+}
+
+my $BaseURL = RT->Config->Get('WebPath')."/Search/UnrepliedTickets.html?";
+$link_rel{first} = $BaseURL . $genpage->(1)         if $Page > 1;
+$link_rel{prev}  = $BaseURL . $genpage->($Page - 1) if $Page > 1;
+$link_rel{next}  = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $ticketcount;
+$link_rel{last}  = $BaseURL . $genpage->(POSIX::ceil($ticketcount/$Rows)) if $Rows and ($Page * $Rows) < $ticketcount;
+</%INIT>
+<%CLEANUP>
+$session{'tickets'}->PrepForSerialization();
+</%CLEANUP>
+<%ARGS>
+$HideResults => 0
+$Rows => undef
+$Page => 1
+$OrderBy => undef
+$Order => undef
+$SavedSearchId => undef
+$SavedChartSearchId => undef
+$Format => undef
+</%ARGS>