sort customer locations in package list, RT#41119, RT#39822
[freeside.git] / FS / FS / cust_main.pm
index 0c109ce..6d5d1d3 100644 (file)
@@ -28,10 +28,11 @@ use Date::Format;
 #use Date::Manip;
 use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
+use List::Util qw(min);
 use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Cursor;
-use FS::Misc qw( generate_ps do_print money_pretty );
+use FS::Misc qw( generate_ps do_print money_pretty card_types );
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
 use FS::TicketSystem;
@@ -468,7 +469,8 @@ sub insert {
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
-  my $error = $self->SUPER::insert;
+  my $error =  $self->check_payinfo_cardtype
+            || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     #return "inserting cust_main record (transaction rolled back): $error";
@@ -1354,6 +1356,14 @@ sub replace {
          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
+  if (    $self->payby =~ /^(CARD|DCRD)$/
+       && $old->payinfo ne $self->payinfo
+       && $old->paymask ne $self->paymask )
+  {
+    my $error = $self->check_payinfo_cardtype;
+    return $error if $error;
+  }
+
   return "Invoicing locale is required"
     if $old->locale
     && ! $self->locale
@@ -2092,6 +2102,25 @@ sub check {
   $self->SUPER::check;
 }
 
+sub check_payinfo_cardtype {
+  my $self = shift;
+
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
+
+  my $payinfo = $self->payinfo;
+  $payinfo =~ s/\D//g;
+
+  return '' if $payinfo =~ /^99\d{14}$/; #token
+
+  my %bop_card_types = map { $_=>1 } values %{ card_types() };
+  my $cardtype = cardtype($payinfo);
+
+  return "$cardtype not accepted" unless $bop_card_types{$cardtype};
+
+  '';
+
+}
+
 =item replace_check
 
 Additional checks for replace only.
@@ -2154,8 +2183,13 @@ Returns all locations (see L<FS::cust_location>) for this customer.
 
 sub cust_location {
   my $self = shift;
-  qsearch('cust_location', { 'custnum'     => $self->custnum,
-                             'prospectnum' => '' } );
+  qsearch({
+    'table'   => 'cust_location',
+    'hashref' => { 'custnum'     => $self->custnum,
+                   'prospectnum' => '',
+                 },
+    'order_by' => 'ORDER BY country, LOWER(state), LOWER(city), LOWER(county), LOWER(address1), LOWER(address2)',
+  });
 }
 
 =item cust_contact
@@ -2901,62 +2935,34 @@ sub payment_info {
 
 =item paydate_epoch
 
-Returns the exact time in seconds corresponding to the payment method 
-expiration date.  For CARD/DCRD customers this is the end of the month;
-for others (COMP is the only other payby that uses paydate) it's the start.
-Returns 0 if the paydate is empty or set to the far future.
+Returns the next payment expiration date for this customer. If they have no
+payment methods that will expire, returns 0.
 
 =cut
 
 sub paydate_epoch {
   my $self = shift;
-  my ($month, $year) = $self->paydate_monthyear;
-  return 0 if !$year or $year >= 2037;
-  if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
-    $month++;
-    if ( $month == 13 ) {
-      $month = 1;
-      $year++;
-    }
-    return timelocal(0,0,0,1,$month-1,$year) - 1;
-  }
-  else {
-    return timelocal(0,0,0,1,$month-1,$year);
-  }
+  # filter out the ones that individually return 0, but then return 0 if
+  # there are no results
+  my @epochs = grep { $_ > 0 } map { $_->paydate_epoch } $self->cust_payby;
+  min( @epochs ) || 0;
 }
 
 =item paydate_epoch_sql
 
-Class method.  Returns an SQL expression to obtain the payment expiration date
-as a number of seconds.
+Returns an SQL expression to get the next payment expiration date for a
+customer. Returns 2143260000 (2037-12-01) if there are no payment expiration
+dates, so that it's safe to test for "will it expire before date X" for any
+date up to then.
 
 =cut
 
-# Special expiration date behavior for non-CARD/DCRD customers has been 
-# carefully preserved.  Do we really use that?
 sub paydate_epoch_sql {
   my $class = shift;
-  my $table = shift || 'cust_main';
-  my ($case1, $case2);
-  if ( driver_name eq 'Pg' ) {
-    $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
-    $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
-  }
-  elsif ( lc(driver_name) eq 'mysql' ) {
-    $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
-    $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
-  }
-  else { return '' }
-  return "CASE WHEN $table.payby IN('CARD','DCRD') 
-  THEN ($case1)
-  ELSE ($case2)
-  END"
+  my $paydate = FS::cust_payby->paydate_epoch_sql;
+  "(SELECT COALESCE(MIN($paydate), 2143260000) FROM cust_payby WHERE cust_payby.custnum = cust_main.custnum)";
 }
 
-=item tax_exemption TAXNAME
-
-=cut
-
 sub tax_exemption {
   my( $self, $taxname ) = @_;
 
@@ -3591,9 +3597,12 @@ Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 sub cust_credit {
   my $self = shift;
-  map { $_ } #return $self->num_cust_credit unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
+
+  #return $self->num_cust_credit unless wantarray;
+
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
 
 =item cust_credit_pkgnum
@@ -4407,8 +4416,10 @@ sub payment_history {
 Saves a new cust_payby for this customer, replacing an existing entry only
 in select circumstances.  Does not validate input.
 
-If auto is specified, marks this as the customer's primary method (weight 1) 
-and changes existing primary methods for that payby to secondary methods (weight 2.)
+If auto is specified, marks this as the customer's primary method, or the 
+specified weight.  Existing payment methods have their weight incremented as
+appropriate.
+
 If bill_location is specified with auto, also sets location in cust_main.
 
 Will not insert complete duplicates of existing records, or records in which the
@@ -4420,39 +4431,77 @@ blanks when replacing.
 
 Accepts the following named parameters:
 
-payment_payby - either CARD or CHEK
+=over 4
+
+=item payment_payby
+
+either CARD or CHEK
+
+=item auto
+
+save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+
+=item weight
+
+optional, set higher than 1 for secondary, etc.
+
+=item payinfo
+
+required
+
+=item paymask
+
+optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+
+=item payname
+
+required
+
+=item payip
+
+optional, will be preserved when replacing
+
+=item paydate
+
+CARD only, required
 
-auto - save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+=item bill_location
 
-payinfo - required
+CARD only, required, FS::cust_location object
 
-paymask - optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+=item paystart_month
 
-payname - required
+CARD only, optional, will be preserved when replacing
 
-payip - optional, will be preserved when replacing
+=item paystart_year
 
-paydate - CARD only, required
+CARD only, optional, will be preserved when replacing
 
-bill_location - CARD only, required, FS::cust_location object
+=item payissue
 
-paystart_month - CARD only, optional, will be preserved when replacing
+CARD only, optional, will be preserved when replacing
 
-paystart_year - CARD only, optional, will be preserved when replacing
+=item paycvv
+
+CARD only, only used if conf cvv-save is set appropriately
 
-payissue - CARD only, optional, will be preserved when replacing
+=item paytype
 
-paycvv - CARD only, only used if conf cvv-save is set appropriately
+CHEK only
 
-paytype - CHEK only
+=item paystate
 
-paystate - CHEK only
+CHEK only
+
+=back
 
 =cut
 
 #The code for this option is in place, but it's not currently used
 #
-# replace - existing cust_payby object to be replaced (must match custnum)
+# =item replace
+#
+# existing cust_payby object to be replaced (must match custnum)
 
 # stateid/stateid_state/ss are not currently supported in cust_payby,
 # might not even work properly in 4.x, but will need to work here if ever added
@@ -4483,8 +4532,7 @@ sub save_cust_payby {
     @check_existing = qw( CHEK DCHK );
   }
 
-  # every automatic payment type added here will be marked primary
-  $new->set( 'weight' => $opt{'auto'} ? 1 : '' );
+  $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' );
 
   # basic fields
   $new->payinfo($opt{'payinfo'}); # sets default paymask, but not if it's already tokenized
@@ -4578,7 +4626,7 @@ PAYBYLOOP:
       # if we got this far, we're definitely replacing
       $old = $cust_payby;
       last PAYBYLOOP;
-    }
+    } #PAYBYLOOP
   }
 
   if ($old) {
@@ -4621,7 +4669,8 @@ PAYBYLOOP:
       last unless $cust_payby->payby !~ /^D/;
       last if $cust_payby->weight > 1;
       next if $new->custpaybynum eq $cust_payby->custpaybynum;
-      $cust_payby->set( 'weight' => 2 );
+      next if $cust_payby->weight < ($opt{'weight'} || 1);
+      $cust_payby->weight( $cust_payby->weight + 1 );
       my $error = $cust_payby->replace;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
@@ -5428,34 +5477,14 @@ sub _upgrade_data { #class method
   local($skip_fuzzyfiles) = 1;
   local($import) = 1; #prevent automatic geocoding (need its own variable?)
 
-  FS::cust_main::Location->_upgrade_data(%opts);
-
-  unless ( FS::upgrade_journal->is_done('cust_main__trimspaces') ) {
-
-    foreach my $cust_main ( qsearch({
-      'table'     => 'cust_main', 
-      'hashref'   => {},
-      'extra_sql' => 'WHERE '.
-                       join(' OR ',
-                         map "$_ LIKE ' %' OR $_ LIKE '% ' OR $_ LIKE '%  %'",
-                           qw( first last company )
-                       ),
-    }) ) {
-      my $error = $cust_main->replace;
-      die $error if $error;
-    }
-
-    FS::upgrade_journal->set_done('cust_main__trimspaces');
-
-  }
-
   unless ( FS::upgrade_journal->is_done('cust_main__cust_payby') ) {
 
     #we don't want to decrypt them, just stuff them as-is into cust_payby
     local(@encrypted_fields) = ();
 
     local($FS::cust_payby::ignore_expired_card) = 1;
-    local($FS::cust_payby::ignore_banned_card) = 1;
+    local($FS::cust_payby::ignore_banned_card)  = 1;
+    local($FS::cust_payby::ignore_cardtype)     = 1;
 
     my @payfields = qw( payby payinfo paycvv paymask
                         paydate paystart_month paystart_year payissue
@@ -5514,6 +5543,27 @@ sub _upgrade_data { #class method
     FS::upgrade_journal->set_done('cust_main__cust_payby');
   }
 
+  FS::cust_main::Location->_upgrade_data(%opts);
+
+  unless ( FS::upgrade_journal->is_done('cust_main__trimspaces') ) {
+
+    foreach my $cust_main ( qsearch({
+      'table'     => 'cust_main', 
+      'hashref'   => {},
+      'extra_sql' => 'WHERE '.
+                       join(' OR ',
+                         map "$_ LIKE ' %' OR $_ LIKE '% ' OR $_ LIKE '%  %'",
+                           qw( first last company )
+                       ),
+    }) ) {
+      my $error = $cust_main->replace;
+      die $error if $error;
+    }
+
+    FS::upgrade_journal->set_done('cust_main__trimspaces');
+
+  }
+
   $class->_upgrade_otaker(%opts);
 
 }