Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / ClientAPI / MyAccount.pm
index 610754c..7e1720d 100644 (file)
@@ -23,7 +23,7 @@ use FS::Conf;
 #use FS::UID qw(dbh);
 use FS::Record qw(qsearch qsearchs dbh);
 use FS::Msgcat qw(gettext);
-use FS::Misc qw(card_types);
+use FS::Misc qw(card_types money_pretty);
 use FS::Misc::DateTime qw(parse_datetime);
 use FS::TicketSystem;
 use FS::ClientAPI_SessionCache;
@@ -48,8 +48,11 @@ use FS::msg_template;
 use FS::contact;
 use FS::cust_contact;
 use FS::cust_location;
+use FS::cust_payby;
 
-use FS::ClientAPI::MyAccount::quotation; # just for code organization
+# for code organization
+use FS::ClientAPI::MyAccount::contact;
+use FS::ClientAPI::MyAccount::quotation;
 
 $DEBUG = 0;
 $me = '[FS::ClientAPI::MyAccount]';
@@ -58,8 +61,7 @@ use vars qw( @cust_main_editable_fields @location_editable_fields );
 @cust_main_editable_fields = qw(
   first last company daytime night fax mobile
   locale
-  payby payinfo payname paystart_month paystart_year payissue payip
-  ss paytype paystate stateid stateid_state
+  ss stateid stateid_state
 );
 @location_editable_fields = qw(
   address1 address2 city county state zip country
@@ -130,7 +132,7 @@ sub skin_info {
       ),
       'menu_disable' => [ $conf->config('selfservice-menu_disable',$agentnum) ],
       ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) }
-        qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo )
+        qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo enable_payment_without_balance )
       ),
       ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) }
         qw( title_left_image title_right_image
@@ -242,6 +244,8 @@ sub login {
     return { error => 'Incorrect contact password.' }
       unless $contact->authenticate_password($p->{'password'});
 
+    $session->{'contactnum'} = $contact->contactnum;
+
     my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
     if ( scalar(@cust_contact) == 1 ) {
       $session->{'custnum'} = $cust_contact[0]->custnum;
@@ -608,6 +612,7 @@ sub customer_info_short {
         $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} )
                                 : '(none)';
     }
+    $return{balance_pretty} = money_pretty($return{balance});
 
     $return{countrydefault} = scalar($conf->config('countrydefault'));
 
@@ -621,8 +626,6 @@ sub customer_info_short {
     $return{'last'} = $cust_main->get('last');
     $return{name}   = $cust_main->first. ' '. $cust_main->get('last');
 
-    $return{payby} = $cust_main->payby;
-
     #none of these are terribly expensive if we want 'em...
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
@@ -635,11 +638,6 @@ sub customer_info_short {
         if $cust_main->ship_locationnum;
     }
  
-    if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
-      $return{payinfo} = $cust_main->paymask;
-      @return{'month', 'year'} = $cust_main->paydate_monthyear;
-    }
-    
     my @invoicing_list = $cust_main->invoicing_list;
     $return{'invoicing_list'} =
       join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
@@ -663,6 +661,11 @@ sub customer_info_short {
 
   }
 
+  # this is here because this routine is called by both fs_ and ng_ main pages, where it appears
+  # it is not customer-specific, though it is only shown to authenticated customers
+  # it is not currently agent-specific, though at some point it might be
+  $return{'announcement'} = join(' ',$conf->config('selfservice-announcement')) || '';
+
   return { 'error'          => '',
            'custnum'        => $custnum,
            %return,
@@ -691,6 +694,7 @@ sub billing_history {
   }
 
   $return{balance} = $cust_main->balance;
+  $return{balance_pretty} = money_pretty($return{balance});
   $return{next_bill_date} = $cust_main->next_bill_date;
   $return{next_bill_date_pretty} =
     $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} )
@@ -749,55 +753,8 @@ sub edit_info {
   # but if it hasn't been passed in at all, leave ship_location alone--
   # DON'T change it to match bill_location.
 
-  my $payby = '';
-  if (exists($p->{'payby'})) {
-    $p->{'payby'} =~ /^([A-Z]{4})$/
-      or return { 'error' => "illegal_payby " . $p->{'payby'} };
-    $payby = $1;
-  }
-
   my $conf = new FS::Conf;
 
-  if ( $payby =~ /^(CARD|DCRD)$/ ) {
-
-    $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01');
-
-    if ( $new->payinfo eq $cust_main->paymask ) {
-      $new->payinfo($cust_main->payinfo);
-      $new->paycvv( $p->{'paycvv'} || $cust_main->paycvv );
-    } else {
-      $new->payinfo($p->{'payinfo'});
-      return { 'error' => 'CVV2 is required' }
-        if ! $p->{'paycvv'} && $conf->exists('selfservice-onfile_require_cvv');
-      $new->paycvv( $p->{'paycvv'} )
-    }
-
-    $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
-
-  } elsif ( $payby =~ /^(CHEK|DCHK)$/ ) {
-
-    my $payinfo;
-    $p->{'payinfo1'} =~ /^([\dx]+)$/
-      or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
-    my $payinfo1 = $1;
-     $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ?
-      or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
-    my $payinfo2 = $1;
-    $payinfo = $payinfo1. '@'. $payinfo2;
-
-    $new->payinfo( ($payinfo eq $cust_main->paymask)
-                     ? $cust_main->payinfo
-                     : $payinfo
-                 );
-
-    $new->set( 'payby' => $p->{'auto'} ? 'CHEK' : 'DCHK' );
-
-  } elsif ( $payby =~ /^(BILL)$/ ) {
-    #no-op
-  } elsif ( $payby ) {  #notyet ready
-    return { 'error' => "unknown payby $payby" };
-  }
-
   my @invoicing_list;
   if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) {
     #false laziness with httemplate/edit/process/cust_main.cgi
@@ -854,7 +811,7 @@ sub payment_info {
       'require_cvv'        => $conf->exists('selfservice-require_cvv'),
       'onfile_require_cvv' => $conf->exists('selfservice-onfile_require_cvv'),
 
-      'paytypes' => [ @FS::cust_main::paytypes ],
+      'paytypes' => [ FS::cust_payby::paytypes ],
 
       'paybys' => [ $conf->config('signup_server-payby') ],
       'cust_paybys' => \@cust_paybys,
@@ -893,31 +850,30 @@ sub payment_info {
 
   $return{balance} = $cust_main->balance; #XXX pkg-balances?
 
-  $return{payname} = $cust_main->payname
-                     || ( $cust_main->first. ' '. $cust_main->get('last') );
-
   $return{$_} = $cust_main->bill_location->get($_) 
     for qw(address1 address2 city state zip);
 
-  $return{payby} = $cust_main->payby;
-  $return{stateid_state} = $cust_main->stateid_state;
-
-  if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
-    $return{card_type} = cardtype($cust_main->payinfo);
-    $return{payinfo} = $cust_main->paymask;
-
-    @return{'month', 'year'} = $cust_main->paydate_monthyear;
-
-  }
-
-  if ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
-    my ($payinfo1, $payinfo2) = split '@', $cust_main->paymask;
-    $return{payinfo1} = $payinfo1;
-    $return{payinfo2} = $payinfo2;
-    $return{paytype}  = $cust_main->paytype;
-    $return{paystate} = $cust_main->paystate;
-    $return{payname}  = $cust_main->payname;   # override 'first/last name' default from above, if any.  Is instution-name here.  (#15819)
-  }
+  #XXX look for stored cust_payby info
+  #
+  # $return{payname} = $cust_main->payname
+  #                    || ( $cust_main->first. ' '. $cust_main->get('last') );
+  #
+  #if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
+  #  $return{card_type} = cardtype($cust_main->payinfo);
+  #  $return{payinfo} = $cust_main->paymask;
+  #
+  #  @return{'month', 'year'} = $cust_main->paydate_monthyear;
+  #
+  #}
+  #
+  #if ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
+  #  my ($payinfo1, $payinfo2) = split '@', $cust_main->paymask;
+  #  $return{payinfo1} = $payinfo1;
+  #  $return{payinfo2} = $payinfo2;
+  #  $return{paytype}  = $cust_main->paytype;
+  #  $return{paystate} = $cust_main->paystate;
+  #  $return{payname}  = $cust_main->payname;  # override 'first/last name' default from above, if any.  Is instution-name here.  (#15819)
+  #}
 
   if ( $conf->config('prepayment_discounts-credit_type') ) {
     #need to eval?
@@ -1135,37 +1091,6 @@ sub do_process_payment {
 
   my $payby = delete $validate->{'payby'};
 
-  my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount,
-    'quiet'       => 1,
-    'manual'      => 1,
-    'selfservice' => 1,
-    'paynum_ref'  => \$paynum,
-    %$validate,
-  );
-  return { 'error' => $error } if $error;
-
-  #no error, so order the fee package if applicable...
-  my $conf = new FS::Conf;
-  my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
-  my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
-  
-  if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) {
-
-    my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart };
-
-    $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg );
-    return { 'error' => "payment processed successfully, but error ordering fee: $error" }
-      if $error;
-
-    #and generate an invoice for it now too
-    $error = $cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
-    return { 'error' => "payment processed and fee ordered sucessfully, but error billing fee: $error" }
-      if $error;
-
-  }
-
-  $cust_main->apply_payments;
-
   if ( $validate->{'save'} ) {
     my $new = new FS::cust_main { $cust_main->hash };
     if ($payby eq 'CARD' || $payby eq 'DCRD') {
@@ -1186,7 +1111,7 @@ sub do_process_payment {
                     stateid stateid_state );
       $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' );
     }
-    $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} );
+    $new->payinfo( $validate->{'payinfo'} ); #to properly set paymask
     $new->set( 'paydate' => $validate->{'paydate'} );
     my $error = $new->replace($cust_main);
     if ( $error ) {
@@ -1194,18 +1119,48 @@ sub do_process_payment {
       #return { 'error' => $error };
       #XXX just warn verosely for now so i can figure out how these happen in
       # the first place, eventually should redirect them to the "change
-      #address" page but indicate the payment did process??
+      #address" page but indicate if the payment processed?
       delete($validate->{'payinfo'}); #don't want to log this!
       warn "WARNING: error changing customer info when processing payment (not returning to customer as a processing error): $error\n".
            "NEW: ". Dumper($new)."\n".
            "OLD: ". Dumper($cust_main)."\n".
            "PACKET: ". Dumper($validate)."\n";
-    #} else {
-      #not needed...
-      #$cust_main = $new;
+    } else {
+      $cust_main = $new;
     }
   }
 
+  my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount,
+    'quiet'       => 1,
+    'manual'      => 1,
+    'selfservice' => 1,
+    'paynum_ref'  => \$paynum,
+    %$validate,
+  );
+  return { 'error' => $error } if $error;
+
+  #no error, so order the fee package if applicable...
+  my $conf = new FS::Conf;
+  my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
+  my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
+  
+  if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) {
+
+    my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart };
+
+    $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg );
+    return { 'error' => "payment processed successfully, but error ordering fee: $error" }
+      if $error;
+
+    #and generate an invoice for it now too
+    $error = $cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
+    return { 'error' => "payment processed and fee ordered sucessfully, but error billing fee: $error" }
+      if $error;
+
+  }
+
+  $cust_main->apply_payments;
+
   my $cust_pay = '';
   my $receipt_html = '';
   if ($paynum) {
@@ -1539,7 +1494,6 @@ sub invoice_logo {
          };
 }
 
-
 sub list_invoices {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -1599,6 +1553,79 @@ sub list_invoices {
           };
 }
 
+sub list_payby {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+    or return { 'error' => "unknown custnum $custnum" };
+
+  return {
+    'payby' => [ map {
+                       my $cust_payby = $_;
+                       +{
+                          map { $_ => $cust_payby->$_ }
+                            qw( custpaybynum weight payby paymask paydate
+                                payname paystate paytype
+                              )
+                        };
+                     }
+                   $cust_main->cust_payby
+               ],
+  };
+}
+
+sub insert_payby {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  #XXX payinfo1 + payinfo2 for CHEK?
+  #or take the opportunity to use separate, more well- named fields?
+  # my $payinfo;
+  # $p->{'payinfo1'} =~ /^([\dx]+)$/
+  #   or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
+  # my $payinfo1 = $1;
+  #  $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ?
+  #   or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
+  # my $payinfo2 = $1;
+  # $payinfo = $payinfo1. '@'. $payinfo2;
+
+  my $cust_payby = new FS::cust_payby {
+    'custnum' => $custnum,
+    map { $_ => $p->{$_} } qw( weight payby payinfo paycvv paydate payname
+                               paystate paytype payip 
+                             ),
+  };
+
+  my $error = $cust_payby->insert;
+  if ( $error ) {
+    return { 'error' => $error };
+  } else {
+    return { 'custpaybynum' => $cust_payby->custpaybynum };
+  }
+  
+}
+
+sub delete_payby {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $cust_payby = qsearchs('cust_payby', {
+                              'custnum'      => $custnum,
+                              'custpaybynum' => $p->{'custpaybynum'},
+                           })
+    or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} };
+
+  return { 'error' => $cust_payby->delete };
+
+}
+
 sub cancel {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -2418,7 +2445,7 @@ sub change_pkg {
 
   if ( $conf->exists('signup_server-realtime') ) {
 
-    my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_credit'=>1 );
+    my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_invoice_void'=>1 );
 
     if ($bill_error) {
       $err_or_cust_pkg->suspend;
@@ -2492,35 +2519,36 @@ sub order_recharge {
 sub _do_bop_realtime {
   my ($cust_main, $status, %opt) = @_;
 
-    my $old_balance = $cust_main->balance;
-
-    my $bill_error =    $cust_main->bill
-                     || $cust_main->apply_payments_and_credits;
-
-    $bill_error ||= $cust_main->realtime_collect('selfservice' => 1)
-      if $cust_main->payby =~ /^(CARD|CHEK)$/;
-
-    if (    $cust_main->balance > $old_balance
-         && $cust_main->balance > 0
-         && ( $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/
-                || $status eq 'suspended'
-            )
-       )
-    {
-      unless ( $opt{'no_credit'} ) {
-        #this makes sense.  credit is "un-doing" the invoice
-        my $conf = new FS::Conf;
-        $cust_main->credit( sprintf("%.2f", $cust_main->balance-$old_balance ),
-                            'self-service decline',
-                            reason_type=>$conf->config('signup_credit_type'),
-                          );
-        $cust_main->apply_credits( 'order' => 'newest' );
+  my $old_balance = $cust_main->balance;
+
+  my @cust_bill;
+  my $bill_error = $cust_main->bill(
+    'return_bill'   => \@cust_bill,
+  );
+
+  $bill_error ||= $cust_main->apply_payments_and_credits;
+
+  $bill_error ||= $cust_main->realtime_collect('selfservice' => 1);
+
+  if (    $cust_main->balance > $old_balance
+       && $cust_main->balance > 0
+       && ( $cust_main->has_cust_payby_auto || $status eq 'suspended' )
+     )
+  {
+    unless ( $opt{'no_invoice_void'} ) {
+
+      #this used to apply a credit, but now we can void invoices...
+      foreach my $cust_bill (@cust_bill) {
+        my $voiderror = $cust_bill->void('automatic payment failed');
+        warn "Error voiding cust bill after decline: $voiderror" if $voiderror;
       }
 
-      return { 'error' => '_decline', 'bill_error' => $bill_error };
     }
 
-    '';
+    return { 'error' => '_decline', 'bill_error' => $bill_error };
+  }
+
+  '';
 }
 
 sub renew_info {
@@ -2967,13 +2995,15 @@ sub myaccount_passwd {
         )
     && ! $svc_acct->check_password($p->{'old_password'});
 
+    # should move password length checks into is_password_allowed
   $error = 'Password too short.'
     if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6);
   $error = 'Password too long.'
     if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8);
 
-  $svc_acct->set_password($p->{'new_password'});
-  $error ||= $svc_acct->replace();
+  $error ||= $svc_acct->is_password_allowed($p->{'new_password'})
+         ||  $svc_acct->set_password($p->{'new_password'})
+         ||  $svc_acct->replace();
 
   #regular pw change in self-service should change contact pw too, otherwise its
   #way too confusing.  hell its confusing they're separate at all, but alas.
@@ -2999,53 +3029,6 @@ sub myaccount_passwd {
 
 }
 
-#  sub contact_passwd {
-#    my $p = shift;
-#    my($context, $session, $custnum) = _custoragent_session_custnum($p);
-#    return { 'error' => $session } if $context eq 'error';
-#  
-#    return { 'error' => 'Not logged in as a contact.' }
-#      unless $session->{'contactnum'};
-#  
-#    return { 'error' => "New passwords don't match." }
-#      if $p->{'new_password'} ne $p->{'new_password2'};
-#  
-#    return { 'error' => 'Enter new password' }
-#      unless length($p->{'new_password'});
-#  
-#    #my $search = { 'custnum' => $custnum };
-#    #$search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
-#    $custnum =~ /^(\d+)$/ or die "illegal custnum";
-#    my $search = " AND selfservice_access IS NOT NULL ".
-#                 " AND selfservice_access = 'Y' ".
-#                 " AND ( disabled IS NULL OR disabled = '' )".
-#                 " AND custnum IS NOT NULL AND custnum = $1";
-#    $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
-#  
-#    my $contact = qsearchs( {
-#      'table'     => 'contact',
-#      'addl_from' => 'LEFT JOIN cust_main USING ( custnum ) ',
-#      'hashref'   => { 'contactnum' => $session->{'contactnum'}, },
-#      'extra_sql' => $search, #important
-#    } )
-#      or return { 'error' => "Email not found" }; #?  how did we get logged in?
-#                                                  # deleted since then?
-#  
-#    my $error = '';
-#  
-#    # use these svc_acct length restrictions??
-#    my $conf = new FS::Conf;
-#    $error = 'Password too short.'
-#      if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6);
-#    $error = 'Password too long.'
-#      if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8);
-#  
-#    $error ||= $contact->change_password($p->{'new_password'});
-#  
-#    return { 'error' => $error, };
-#  
-#  }
-
 sub reset_passwd {
   my $p = shift;
 
@@ -3072,7 +3055,7 @@ sub reset_passwd {
     my($username, $domain) = split('@', $p->{'email'});
     my $svc_domain = qsearchs('svc_domain', { 'domain' => $domain } );
     if ( $svc_domain ) {
-      $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'},
+      $svc_acct = qsearchs('svc_acct', { 'username' => $username,
                                          'domsvc'   => $svc_domain->svcnum  }
                           );
       if ( $svc_acct ) {
@@ -3160,7 +3143,7 @@ sub reset_passwd {
 
     my $reset_session = {
       'svcnum'   => $svc_acct->svcnum,
-      'agentnum' =>
+      'agentnum' => $svc_acct->cust_main->agentnum,
     };
 
     my $timeout = '1 hour'; #?
@@ -3299,8 +3282,9 @@ sub process_reset_passwd {
 
   if ( $svc_acct ) {
 
-    $svc_acct->set_password($p->{'new_password'});
-    my $error = $svc_acct->replace();
+    my $error ||= $svc_acct->is_password_allowed($p->{'new_password'})
+              ||  $svc_acct->set_password($p->{'new_password'})
+              ||  $svc_acct->replace();
 
     return { %$info, 'error' => $error } if $error;