multiple payment options, RT#23741
authorIvan Kohler <ivan@freeside.biz>
Wed, 17 Jul 2013 16:03:56 +0000 (09:03 -0700)
committerIvan Kohler <ivan@freeside.biz>
Wed, 17 Jul 2013 16:03:56 +0000 (09:03 -0700)
FS/FS.pm
FS/FS/Schema.pm
FS/FS/Upgrade.pm
FS/FS/cust_main.pm
FS/FS/cust_payby.pm [new file with mode: 0644]
FS/MANIFEST
FS/t/cust_payby.t [new file with mode: 0644]

index 076f80b..a318a20 100644 (file)
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -338,6 +338,8 @@ L<FS::cust_main::Billing_Realtime> - Customer real-time billing class
 
 L<FS::cust_main::Packages> - Customer packages class
 
 
 L<FS::cust_main::Packages> - Customer packages class
 
+L<FS::cust_payby> - Customer payment information class
+
 L<FS::cust_location> - Customer location class
 
 L<FS::cust_main_Mixin> - Mixin class for records that contain fields from cust_main
 L<FS::cust_location> - Customer location class
 
 L<FS::cust_main_Mixin> - Mixin class for records that contain fields from cust_main
index 6df45e2..2b7db26 100644 (file)
@@ -1084,6 +1084,8 @@ sub tables_hashref {
         'ship_fax',      'varchar', 'NULL', 12, '', '', 
         'ship_mobile',   'varchar', 'NULL', 12, '', '', 
         'currency',         'char', 'NULL',  3, '', '',
         'ship_fax',      'varchar', 'NULL', 12, '', '', 
         'ship_mobile',   'varchar', 'NULL', 12, '', '', 
         'currency',         'char', 'NULL',  3, '', '',
+
+        #deprecated, info moved to cust_payby
         'payby',    'char', '',     4, '', '', 
         'payinfo',  'varchar', 'NULL', 512, '', '', 
         'paycvv',   'varchar', 'NULL', 512, '', '', 
         'payby',    'char', '',     4, '', '', 
         'payinfo',  'varchar', 'NULL', 512, '', '', 
         'paycvv',   'varchar', 'NULL', 512, '', '', 
@@ -1097,6 +1099,7 @@ sub tables_hashref {
         'paystate', 'varchar', 'NULL', $char_d, '', '', 
         'paytype',  'varchar', 'NULL', $char_d, '', '', 
         'payip',    'varchar', 'NULL', 15, '', '', 
         'paystate', 'varchar', 'NULL', $char_d, '', '', 
         'paytype',  'varchar', 'NULL', $char_d, '', '', 
         'payip',    'varchar', 'NULL', 15, '', '', 
+
         'geocode',  'varchar', 'NULL', 20,  '', '',
         'censustract', 'varchar', 'NULL', 20,  '', '', # 7 to save space?
         'censusyear', 'char', 'NULL', 4, '', '',
         'geocode',  'varchar', 'NULL', 20,  '', '',
         'censustract', 'varchar', 'NULL', 20,  '', '', # 7 to save space?
         'censusyear', 'char', 'NULL', 4, '', '',
@@ -1138,6 +1141,31 @@ sub tables_hashref {
                  ],
     },
 
                  ],
     },
 
+    'cust_payby' => {
+      'columns' => [
+        'custpaybynum', 'serial',     '',        '', '', '', 
+        'custnum',         'int',     '',        '', '', '',
+        'weight',          'int',     '',        '', '', '', 
+        'payby',          'char',     '',         4, '', '', 
+        'payinfo',     'varchar', 'NULL',       512, '', '', 
+        'paycvv',      'varchar', 'NULL',       512, '', '', 
+        'paymask',     'varchar', 'NULL',   $char_d, '', '', 
+        #'paydate',   @date_type, '', '', 
+        'paydate',     'varchar', 'NULL',        10, '', '', 
+        'paystart_month',  'int', 'NULL',        '', '', '', 
+        'paystart_year',   'int', 'NULL',        '', '', '', 
+        'payissue',    'varchar', 'NULL',         2, '', '', 
+        'payname',     'varchar', 'NULL', 2*$char_d, '', '', 
+        'paystate',    'varchar', 'NULL',   $char_d, '', '', 
+        'paytype',     'varchar', 'NULL',   $char_d, '', '', 
+        'payip',       'varchar', 'NULL',        15, '', '', 
+        'locationnum',     'int', 'NULL',        '', '', '',
+      ],
+      'primary_key' => 'custpaybynum',
+      'unique'      => [],
+      'index'       => [ [ 'custnum' ] ],
+    },
+
     'cust_recon' => {  # (some sort of not-well understood thing for OnPac)
       'columns' => [
         'reconid',      'serial',  '',          '', '', '', 
     'cust_recon' => {  # (some sort of not-well understood thing for OnPac)
       'columns' => [
         'reconid',      'serial',  '',          '', '', '', 
index cda3198..056b80b 100644 (file)
@@ -175,6 +175,9 @@ sub upgrade {
   local($FS::cust_main::ignore_banned_card) = 1;
   local($FS::cust_main::skip_fuzzyfiles) = 1;
 
   local($FS::cust_main::ignore_banned_card) = 1;
   local($FS::cust_main::skip_fuzzyfiles) = 1;
 
+  local($FS::cust_payby::ignore_expired_card) = 1;
+  local($FS::cust_payby::ignore_banned_card) = 1;
+
   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
   # kind of a weird spot for this, but it's better than duplicating
   # all this code in each class...
   # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK
   # kind of a weird spot for this, but it's better than duplicating
   # all this code in each class...
index 7c7c9e2..30d6fa0 100644 (file)
@@ -1791,6 +1791,8 @@ sub check {
   
   }
 
   
   }
 
+  ### start of stuff moved to cust_payby
+
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   #  or return "Illegal payby: ". $self->payby;
   #$self->payby($1);
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   #  or return "Illegal payby: ". $self->payby;
   #$self->payby($1);
@@ -2000,6 +2002,8 @@ sub check {
     $self->payname($1);
   }
 
     $self->payname($1);
   }
 
+  ### end of stuff moved to cust_payby
+
   return "Please select an invoicing locale"
     if ! $self->locale
     && ! $self->custnum
   return "Please select an invoicing locale"
     if ! $self->locale
     && ! $self->custnum
diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm
new file mode 100644 (file)
index 0000000..7bf8c76
--- /dev/null
@@ -0,0 +1,414 @@
+package FS::cust_payby;
+
+use strict;
+use base qw( FS::payinfo_Mixin FS::Record );
+use FS::UID;
+use FS::Record qw( qsearchs ); #qsearch;
+use FS::payby;
+use FS::cust_main;
+
+use vars qw( $conf $ignore_expired_card $ignore_banned_card  );
+
+$ignore_expired_card = 0;
+$ignore_banned_card = 0;
+
+install_callback FS::UID sub { 
+  $conf = new FS::Conf;
+  #yes, need it for stuff below (prolly should be cached)
+};
+
+=head1 NAME
+
+FS::cust_payby - Object methods for cust_payby records
+
+=head1 SYNOPSIS
+
+  use FS::cust_payby;
+
+  $record = new FS::cust_payby \%hash;
+  $record = new FS::cust_payby { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_payby object represents customer stored payment information.
+FS::cust_payby inherits from FS::Record.  The following fields are currently
+supported:
+
+=over 4
+
+=item custpaybynum
+
+primary key
+
+=item custnum
+
+custnum
+
+=item weight
+
+weight
+
+=item payby
+
+payby
+
+=item payinfo
+
+payinfo
+
+=item paycvv
+
+paycvv
+
+=item paymask
+
+paymask
+
+=item paydate
+
+paydate
+
+=item paystart_month
+
+paystart_month
+
+=item paystart_year
+
+paystart_year
+
+=item payissue
+
+payissue
+
+=item payname
+
+payname
+
+=item paystate
+
+paystate
+
+=item paytype
+
+paytype
+
+=item payip
+
+payip
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'cust_payby'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('custpaybynum')
+    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
+    || $self->ut_number('weight')
+    || $self->ut_('payby')
+    || $self->ut_textn('payinfo')
+    || $self->ut_textn('paycvv')
+    || $self->ut_textn('paymask')
+    || $self->ut_textn('paydate')
+    || $self->ut_numbern('paystart_month')
+    || $self->ut_numbern('paystart_year')
+    || $self->ut_textn('payissue')
+    || $self->ut_textn('payname')
+    || $self->ut_textn('paystate')
+    || $self->ut_textn('paytype')
+    || $self->ut_textn('payip')
+  ;
+  return $error if $error;
+
+
+  ### from cust_main
+
+
+  #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
+  #  or return "Illegal payby: ". $self->payby;
+  #$self->payby($1);
+  FS::payby->can_payby($self->table, $self->payby)
+    or return "Illegal payby: ". $self->payby;
+
+  $error =    $self->ut_numbern('paystart_month')
+           || $self->ut_numbern('paystart_year')
+           || $self->ut_numbern('payissue')
+           || $self->ut_textn('paytype')
+  ;
+  return $error if $error;
+
+  if ( $self->payip eq '' ) {
+    $self->payip('');
+  } else {
+    $error = $self->ut_ip('payip');
+    return $error if $error;
+  }
+
+  # If it is encrypted and the private key is not availaible then we can't
+  # check the credit card.
+  my $check_payinfo = ! $self->is_encrypted($self->payinfo);
+
+  # Need some kind of global flag to accept invalid cards, for testing
+  # on scrubbed data.
+  #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+  if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/\D//g;
+    $payinfo =~ /^(\d{13,16}|\d{8,9})$/
+      or return gettext('invalid_card'); # . ": ". $self->payinfo;
+    $payinfo = $1;
+    $self->payinfo($payinfo);
+    validate($payinfo)
+      or return gettext('invalid_card'); # . ": ". $self->payinfo;
+
+    return gettext('unknown_card_type')
+      if $self->payinfo !~ /^99\d{14}$/ #token
+      && cardtype($self->payinfo) eq "Unknown";
+
+    unless ( $ignore_banned_card ) {
+      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
+      if ( $ban ) {
+        if ( $ban->bantype eq 'warn' ) {
+          #or others depending on value of $ban->reason ?
+          return '_duplicate_card'.
+                 ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
+                 ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
+                 ' (ban# '. $ban->bannum. ')'
+            unless $self->override_ban_warn;
+        } else {
+          return 'Banned credit card: banned on '.
+                 time2str('%a %h %o at %r', $ban->_date).
+                 ' by '. $ban->otaker.
+                 ' (ban# '. $ban->bannum. ')';
+        }
+      }
+    }
+
+    if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
+      if ( cardtype($self->payinfo) eq 'American Express card' ) {
+        $self->paycvv =~ /^(\d{4})$/
+          or return "CVV2 (CID) for American Express cards is four digits.";
+        $self->paycvv($1);
+      } else {
+        $self->paycvv =~ /^(\d{3})$/
+          or return "CVV2 (CVC2/CID) is three digits.";
+        $self->paycvv($1);
+      }
+    } else {
+      $self->paycvv('');
+    }
+
+    my $cardtype = cardtype($payinfo);
+    if ( $cardtype =~ /^(Switch|Solo)$/i ) {
+
+      return "Start date or issue number is required for $cardtype cards"
+        unless $self->paystart_month && $self->paystart_year or $self->payissue;
+
+      return "Start month must be between 1 and 12"
+        if $self->paystart_month
+           and $self->paystart_month < 1 || $self->paystart_month > 12;
+
+      return "Start year must be 1990 or later"
+        if $self->paystart_year
+           and $self->paystart_year < 1990;
+
+      return "Issue number must be beween 1 and 99"
+        if $self->payissue
+          and $self->payissue < 1 || $self->payissue > 99;
+
+    } else {
+      $self->paystart_month('');
+      $self->paystart_year('');
+      $self->payissue('');
+    }
+
+  } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/[^\d\@\.]//g;
+    if ( $conf->config('echeck-country') eq 'CA' ) {
+      $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
+        or return 'invalid echeck account@branch.bank';
+      $payinfo = "$1\@$2.$3";
+    } elsif ( $conf->config('echeck-country') eq 'US' ) {
+      $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
+      $payinfo = "$1\@$2";
+    } else {
+      $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
+      $payinfo = "$1\@$2";
+    }
+    $self->payinfo($payinfo);
+    $self->paycvv('');
+
+    unless ( $ignore_banned_card ) {
+      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
+      if ( $ban ) {
+        if ( $ban->bantype eq 'warn' ) {
+          #or others depending on value of $ban->reason ?
+          return '_duplicate_ach' unless $self->override_ban_warn;
+        } else {
+          return 'Banned ACH account: banned on '.
+                 time2str('%a %h %o at %r', $ban->_date).
+                 ' by '. $ban->otaker.
+                 ' (ban# '. $ban->bannum. ')';
+        }
+      }
+    }
+
+  } elsif ( $self->payby eq 'LECB' ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/\D//g;
+    $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
+    $payinfo = $1;
+    $self->payinfo($payinfo);
+    $self->paycvv('');
+
+  } elsif ( $self->payby eq 'BILL' ) {
+
+    $error = $self->ut_textn('payinfo');
+    return "Illegal P.O. number: ". $self->payinfo if $error;
+    $self->paycvv('');
+
+  } elsif ( $self->payby eq 'COMP' ) {
+
+    my $curuser = $FS::CurrentUser::CurrentUser;
+    if (    ! $self->custnum
+         && ! $curuser->access_right('Complimentary customer')
+       )
+    {
+      return "You are not permitted to create complimentary accounts."
+    }
+
+    $error = $self->ut_textn('payinfo');
+    return "Illegal comp account issuer: ". $self->payinfo if $error;
+    $self->paycvv('');
+
+  } elsif ( $self->payby eq 'PREPAY' ) {
+
+    my $payinfo = $self->payinfo;
+    $payinfo =~ s/\W//g; #anything else would just confuse things
+    $self->payinfo($payinfo);
+    $error = $self->ut_alpha('payinfo');
+    return "Illegal prepayment identifier: ". $self->payinfo if $error;
+    return "Unknown prepayment identifier"
+      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
+    $self->paycvv('');
+
+  }
+
+  if ( $self->paydate eq '' || $self->paydate eq '-' ) {
+    return "Expiration date required"
+      # shouldn't payinfo_check do this?
+      unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/;
+    $self->paydate('');
+  } else {
+    my( $m, $y );
+    if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
+      ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+    } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $2, "19$1" );
+    } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $3, "20$2" );
+    } else {
+      return "Illegal expiration date: ". $self->paydate;
+    }
+    $m = sprintf('%02d',$m);
+    $self->paydate("$y-$m-01");
+    my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
+    return gettext('expired_card')
+      if #XXX !$import
+      #&&
+         !$ignore_expired_card 
+      && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
+  }
+
+  if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
+       ( ! $conf->exists('require_cardname')
+         || $self->payby !~ /^(CARD|DCRD)$/  ) 
+  ) {
+    $self->payname( $self->first. " ". $self->getfield('last') );
+  } else {
+    $self->payname =~ /^([\w \,\.\-\'\&]+)$/
+      or return gettext('illegal_name'). " payname: ". $self->payname;
+    $self->payname($1);
+  }
+
+  ###
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index a86683d..7e61868 100644 (file)
@@ -705,3 +705,5 @@ FS/currency_exchange.pm
 t/currency_exchange.t
 FS/part_pkg_currency.pm
 t/part_pkg_currency.t
 t/currency_exchange.t
 FS/part_pkg_currency.pm
 t/part_pkg_currency.t
+FS/cust_payby.pm
+t/cust_payby.t
diff --git a/FS/t/cust_payby.t b/FS/t/cust_payby.t
new file mode 100644 (file)
index 0000000..b5f7f51
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_payby;
+$loaded=1;
+print "ok 1\n";