RT# 79737 - Added ability to us a cc surcharge of a flat fee.
[freeside.git] / FS / FS / ClientAPI / MyAccount.pm
index c89b7bb..e4fef95 100644 (file)
@@ -11,7 +11,7 @@ use Digest::SHA qw(sha512_hex);
 use Date::Format;
 use Time::Duration;
 use Time::Local qw(timelocal_nocheck);
-use Business::CreditCard;
+use Business::CreditCard 0.35;
 use HTML::Entities;
 use Text::CSV_XS;
 use Spreadsheet::WriteExcel;
@@ -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
@@ -140,6 +142,7 @@ sub skin_info {
       'logo' => scalar($conf->config_binary('logo.png', $agentnum )),
       ( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) }
         qw( head body_header body_footer company_address ) ),
+      'money_char' => $conf->config("money_char") || '$',
       'menu' => join("\n", $conf->config("ng_selfservice-menu", $agentnum ) ) ||
                 'main.php Home
 
@@ -234,6 +237,13 @@ sub login {
 
     $svc_x = $svc_phone;
 
+  } elsif ( $p->{'domain'} eq 'ip_mac' ) {
+
+      my $svc_broadband = qsearchs( 'svc_broadband', { 'mac_addr' => $p->{'username'} } );
+      return { error => 'IP address not found' }
+        unless $svc_broadband;
+      $svc_x = $svc_broadband;
+
   } elsif ( $p->{email}
               && (my $contact = FS::contact->by_selfservice_email($p->{email}))
           )
@@ -241,6 +251,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;
@@ -259,16 +271,39 @@ sub login {
     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
       or return { error => 'Domain '. $p->{'domain'}. ' not found' };
 
-    my $svc_acct = qsearchs( 'svc_acct', { 'username'  => $p->{'username'},
-                                           'domsvc'    => $svc_domain->svcnum, }
-                           );
-    return { error => 'User not found.' } unless $svc_acct;
+    my @svc_acct = qsearch( 'svc_acct', { 'username'  => $p->{'username'},
+                                          'domsvc'    => $svc_domain->svcnum, }
+                          );
 
-    if($conf->exists('selfservice_server-login_svcpart')) {
-       my @svcpart = $conf->config('selfservice_server-login_svcpart');
-       my $svcpart = $svc_acct->cust_svc->svcpart;
-       return { error => 'Invalid user.' } 
-           unless grep($_ eq $svcpart, @svcpart);
+    if ( $conf->exists('selfservice_server-login_svcpart') ) {
+      my @svcpart = $conf->config('selfservice_server-login_svcpart');
+      @svc_acct = grep { my $svcpart = $_->cust_svc->svcpart;
+                         scalar( grep( $_ eq $svcpart, @svcpart ) );
+                       }
+                    @svc_acct;
+    }
+
+    if ( $conf->exists('selfservice_server-primary_only') ) {
+        @svc_acct =
+          grep {
+            my $cust_svc = $_->cust_svc;
+            $cust_svc->cust_pkg->part_pkg->svcpart([qw( svc_acct svc_phone )])
+              == $cust_svc->svcpart
+          }
+          @svc_acct;
+    }
+
+    return { error => 'User not found.' } unless @svc_acct;
+
+    return { error => 'Multiple users.' } if scalar(@svc_acct) > 1;
+
+    my $svc_acct = $svc_acct[0];
+
+    if ( $conf->exists('selfservice_server-login_svcpart') ) {
+      my @svcpart = $conf->config('selfservice_server-login_svcpart');
+      my $svcpart = $svc_acct->cust_svc->svcpart;
+      return { error => 'Invalid user.' } 
+        unless grep($_ eq $svcpart, @svcpart);
     }
 
     return { error => 'Incorrect password.' }
@@ -373,20 +408,12 @@ sub payment_gateway {
   my $conf = new FS::Conf;
   my $cust_main = shift;
   my $cust_payby = shift;
-  my $gatewaynum = $conf->config('selfservice-payment_gateway');
-  if ( $gatewaynum ) {
-    my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
-    die "configured gatewaynum $gatewaynum not found!" if !$pg;
-    return $pg;
-  }
-  else {
-    return '' if ! FS::payby->realtime($cust_payby);
-    my $pg = $cust_main->agent->payment_gateway(
-      'method'  => FS::payby->payby2bop($cust_payby),
-      'nofatal' => 1
-    );
-    return $pg;
-  }
+  return '' if ! FS::payby->realtime($cust_payby);
+  my $pg = $cust_main->agent->payment_gateway(
+    'method'  => FS::payby->payby2bop($cust_payby),
+    'nofatal' => 1
+  );
+  return $pg;
 }
 
 sub access_info {
@@ -472,11 +499,13 @@ sub customer_info {
     if ( $session->{'pkgnum'} ) {
       #XXX open invoices in the pkg-balances case
     } else {
+      $return{'money_char'} = $conf->config("money_char") || '$';
       my @open = map {
                        {
-                         invnum => $_->invnum,
-                         date   => time2str("%b %o, %Y", $_->_date),
-                         owed   => $_->owed,
+                         invnum     => $_->invnum,
+                         date       => time2str("%b %o, %Y", $_->_date),
+                         owed       => $_->owed,
+                         charged    => $_->charged,
                        };
                      } $cust_main->open_cust_bill;
       $return{open_invoices} = \@open;
@@ -571,6 +600,7 @@ sub customer_info_short {
       or return { 'error' => "customer_info_short: unknown custnum $custnum" };
 
     $return{display_custnum} = $cust_main->display_custnum;
+    $return{max_invnum}      = $cust_main->max_invnum;
 
     if ( $session->{'pkgnum'} ) { 
       $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
@@ -582,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'));
 
@@ -595,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($_);
@@ -609,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 );
@@ -637,6 +661,34 @@ 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,
+         };
+}
+
+sub customer_recurring {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my %return;
+
+  my $conf = new FS::Conf;
+
+  my $search = { 'custnum' => $custnum };
+  $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
+  my $cust_main = qsearchs('cust_main', $search )
+    or return { 'error' => "customer_info_short: unknown custnum $custnum" };
+
+  $return{'display_recurring'} = [ $cust_main->display_recurring ];
+
   return { 'error'          => '',
            'custnum'        => $custnum,
            %return,
@@ -665,78 +717,22 @@ 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} )
                             : '(none)';
 
-  my @history = ();
-
   my $conf = new FS::Conf;
 
-  if ( $conf->exists('selfservice-billing_history-line_items') ) {
-
-    foreach my $cust_bill ( $cust_main->cust_bill ) {
-
-      push @history, {
-        'type'        => 'Line item',
-        'description' => $_->desc( $cust_main->locale ).
-                           ( $_->sdate && $_->edate
-                               ? ' '. time2str('%d-%b-%Y', $_->sdate).
-                                 ' To '. time2str('%d-%b-%Y', $_->edate)
-                               : ''
-                           ),
-        'amount'      => sprintf('%.2f', $_->setup + $_->recur ),
-        'date'        => $cust_bill->_date,
-        'date_pretty' =>  time2str('%m/%d/%Y', $cust_bill->_date ),
-      }
-        foreach $cust_bill->cust_bill_pkg;
-
-    }
-
-  } else {
+  $return{'history'} = [
+    $cust_main->payment_history(
+      'line_items' => $conf->exists('selfservice-billing_history-line_items'),
+      'reverse_sort' => 1,
+    )
+  ];
 
-    push @history, {
-                     'type'        => 'Invoice',
-                     'description' => 'Invoice #'. $_->display_invnum,
-                     'amount'      => sprintf('%.2f', $_->charged ),
-                     'date'        => $_->_date,
-                     'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
-                   }
-      foreach $cust_main->cust_bill;
-
-  }
-
-  push @history, {
-                   'type'        => 'Payment',
-                   'description' => 'Payment', #XXX type
-                   'amount'      => sprintf('%.2f', 0 - $_->paid ),
-                   'date'        => $_->_date,
-                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
-                 }
-    foreach $cust_main->cust_pay;
-
-  push @history, {
-                   'type'        => 'Credit',
-                   'description' => 'Credit', #more info?
-                   'amount'      => sprintf('%.2f', 0 -$_->amount ),
-                   'date'        => $_->_date,
-                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
-                 }
-    foreach $cust_main->cust_credit;
-
-  push @history, {
-                   'type'        => 'Refund',
-                   'description' => 'Refund', #more info?  type, like payment?
-                   'amount'      => $_->refund,
-                   'date'        => $_->_date,
-                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
-                 }
-    foreach $cust_main->cust_refund;
-
-  @history = sort { $b->{'date'} <=> $a->{'date'} } @history;
-
-  $return{'history'} = \@history;
+  $return{'money_char'} = $conf->config("money_char") || '$',
 
   return \%return;
 
@@ -753,6 +749,11 @@ sub edit_info {
   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
     or return { 'error' => "unknown custnum $custnum" };
 
+  my $conf = new FS::Conf;
+  if (($p->{payby} eq "CHEK" || $p->{payby} eq "DCHEK") && $conf->exists('selfservice-ACH_info_readonly')) {
+    return { 'error' => "You do not have authority to add a bank account" };
+  }
+
   my $new = new FS::cust_main { $cust_main->hash };
 
   $new->set( $_ => $p->{$_} )
@@ -780,55 +781,6 @@ 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);
-    } else {
-      $new->payinfo($p->{'payinfo'});
-    }
-
-    $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
-
-    if ( $conf->exists('selfservice-onfile_require_cvv') ){
-      return { 'error' => 'CVV2 is required' } unless $p->{'paycvv'};
-    }
-
-  } 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
@@ -885,7 +837,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,
@@ -898,8 +850,8 @@ sub payment_info {
       'show_paystate' => $conf->exists('show_bankstate'),
 
       'save_unchecked' => $conf->exists('selfservice-save_unchecked'),
+      'ach_read_only' => $conf->exists('selfservice-ACH_info_readonly'),
 
-      'credit_card_surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')),
     };
 
   }
@@ -910,6 +862,8 @@ sub payment_info {
 
   my %return = %$payment_info;
 
+  delete $return{'cust_main_county'} if $p->{'omit_cust_main_county'};
+
   my $custnum = $session->{'custnum'};
 
   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
@@ -924,30 +878,36 @@ 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;
+  # look for stored cust_payby info
+  #   only if we've been given a clear payment_payby (to avoid payname conflicts)
+  if ($p->{'payment_payby'} =~ /^(CARD|CHEK)$/) {
+    my @search_payby = ($p->{'payment_payby'} eq 'CARD') ? ('CARD','DCRD') : ('CHEK','DCHK');
+    my ($cust_payby) = $cust_main->cust_payby(@search_payby);
+    if ($cust_payby) {
+      $return{payname} = $cust_payby->payname
+                         || ( $cust_main->first. ' '. $cust_main->get('last') );
+      $return{custpaybynum} = $cust_payby->custpaybynum;
 
-  if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
-    $return{card_type} = cardtype($cust_main->payinfo);
-    $return{payinfo} = $cust_main->paymask;
+      if ( $cust_payby->payby =~ /^(CARD|DCRD)$/ ) {
+        $return{card_type} = cardtype($cust_payby->payinfo);
+        $return{payinfo} = $cust_payby->paymask;
 
-    @return{'month', 'year'} = $cust_main->paydate_monthyear;
+        @return{'month', 'year'} = $cust_payby->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 ( $cust_payby->payby =~ /^(CHEK|DCHK)$/ ) {
+        my ($payinfo1, $payinfo2) = split '@', $cust_payby->paymask;
+        $return{payinfo1} = $payinfo1;
+        $return{payinfo2} = $payinfo2;
+        $return{paytype}  = $cust_payby->paytype;
+        $return{paystate} = $cust_payby->paystate;
+        $return{payname}  = $cust_payby->payname;      # override 'first/last name' default from above, if any.  Is instution-name here.  (#15819)
+      }
+    }
   }
 
   if ( $conf->config('prepayment_discounts-credit_type') ) {
@@ -960,6 +920,9 @@ sub payment_info {
   $return{payunique} = "webui-MyAccount-$_date-$$-". rand() * 2**32; #new
   $return{paybatch} = $return{payunique};  #back compat
 
+  $return{credit_card_surcharge_percentage} = $conf->config('credit-card-surcharge-percentage', $cust_main->agentnum);
+  $return{credit_card_surcharge_flatfee} = $conf->config('credit-card-surcharge-flatfee', $cust_main->agentnum);
+
   return { 'error' => '',
            %return,
          };
@@ -1026,6 +989,7 @@ sub validate_payment {
   #false laziness w/process/payment.cgi
   my $payinfo;
   my $paycvv = '';
+  my $replace_cust_payby;
   if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) {
   
     $p->{'payinfo1'} =~ /^([\dx]+)$/
@@ -1036,8 +1000,19 @@ sub validate_payment {
     my $payinfo2 = $1;
     $payinfo = $payinfo1. '@'. $payinfo2;
 
-    $payinfo = $cust_main->payinfo
-      if $cust_main->paymask eq $payinfo;
+    my $achonfile = 0;
+    foreach my $cust_payby ($cust_main->cust_payby('CHEK','DCHK')) {
+      if ( $cust_payby->paymask eq $payinfo ) {
+        $payinfo = $cust_payby->payinfo;
+        $replace_cust_payby = $cust_payby;
+        $achonfile = 1;
+        last;
+      }
+    }
+
+    if ($conf->exists('selfservice-ACH_info_readonly') && !$achonfile) {
+      return { 'error' => "You are not allowed to change your payment information." };
+    }
    
   } elsif ( $payby eq 'CARD' || $payby eq 'DCRD' ) {
    
@@ -1047,20 +1022,24 @@ sub validate_payment {
 
     #more intelligent matching will be needed here if you change
     #card_masking_method and don't remove existing paymasks
-    if ( $cust_main->paymask eq $payinfo ) {
-      $payinfo = $cust_main->payinfo;
-      $onfile = 1;
+    foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) {
+      if ( $cust_payby->paymask eq $payinfo ) {
+        $payinfo = $cust_payby->payinfo;
+        $replace_cust_payby = $cust_payby;
+        $onfile = 1;
+        last;
+      }
     }
 
     $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,16}|\d{8,9})$/
+    $payinfo =~ /^(\d{13,19}|\d{8,9})$/
       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
     $payinfo = $1;
 
     validate($payinfo)
       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
     return { 'error' => gettext('unknown_card_type') }
-      if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown";
+      if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown";
 
     if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
       if ( cardtype($payinfo) eq 'American Express card' ) {
@@ -1088,6 +1067,8 @@ sub validate_payment {
     'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ],
   );
 
+  my %replace = ( 'replace' => $replace_cust_payby, );
+
   my $card_type = '';
   $card_type = cardtype($payinfo) if $payby eq 'CARD';
 
@@ -1096,7 +1077,7 @@ sub validate_payment {
     'amount'         => sprintf('%.2f', $amount),
     'payby'          => $payby,
     'payinfo'        => $payinfo,
-    'paymask'        => $cust_main->mask_payinfo( $payby, $payinfo ),
+    'paymask'        => FS::payinfo_Mixin->mask_payinfo( $payby, $payinfo ),
     'card_type'      => $card_type,
     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
@@ -1109,6 +1090,7 @@ sub validate_payment {
     'payname'        => $payname,
     'discount_term'  => $discount_term,
     'pkgnum'         => $session->{'pkgnum'},
+    %replace,
     map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} },
                              qw( save auto ),
                            )
@@ -1166,6 +1148,50 @@ sub do_process_payment {
 
   my $payby = delete $validate->{'payby'};
 
+  if ( $validate->{'save'} ) {
+
+    my %saveopt;
+    foreach my $field ( qw( auto payinfo paymask payname payip ) ) {
+      $saveopt{$field} = $validate->{$field};
+    }
+
+    if ( $payby eq 'CARD' ) {
+      my $bill_location = FS::cust_location->new({
+          map { $_ => $validate->{$_} } 
+          qw(address1 address2 city state country zip)
+      });
+      $saveopt{'bill_location'} = $bill_location;
+      foreach my $field ( qw( paydate paystart_month paystart_year payissue ) ) {
+        $saveopt{$field} = $validate->{$field};
+      }
+    } else {
+      # stateid/stateid_state won't be saved, might be broken as of 4.x
+      foreach my $field ( qw( paytype paystate ) ) {
+        $saveopt{$field} = $validate->{$field};
+      }
+    }
+
+    my $error = $cust_main->save_cust_payby(
+      'payment_payby' => $payby,
+      'replace'       => $validate->{'replace'}, # cust_payby object to replace
+      %saveopt
+    );
+
+    if ( $error ) {
+      #no, this causes customers to process their payments again
+      #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 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".
+           "PAYBY: $payby\n".
+           "SAVEOPT: ".Dumper(\%saveopt)."\n".
+           "CUST_MAIN: ". Dumper($cust_main)."\n".
+           "PACKET: ". Dumper($validate)."\n";
+    }
+  }
+
   my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount,
     'quiet'       => 1,
     'manual'      => 1,
@@ -1190,53 +1216,13 @@ sub do_process_payment {
 
     #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" }
+    return { 'error' => "payment processed and fee ordered successfully, 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') {
-      $new->set( $_ => $validate->{$_} )
-        foreach qw( payname paystart_month paystart_year payissue payip );
-      $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' );
-
-      my $bill_location = FS::cust_location->new({
-          map { $_ => $validate->{$_} } 
-          qw(address1 address2 city state country zip)
-      }); # county?
-      $new->set('bill_location' => $bill_location);
-      # but don't allow the service address to change this way.
-
-    } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') {
-      $new->set( $_ => $validate->{$_} )
-        foreach qw( payname payip paytype paystate
-                    stateid stateid_state );
-      $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' );
-    }
-    $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} );
-    $new->set( 'paydate' => $validate->{'paydate'} );
-    my $error = $new->replace($cust_main);
-    if ( $error ) {
-      #no, this causes customers to process their payments again
-      #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??
-      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;
-    }
-  }
-
   my $cust_pay = '';
   my $receipt_html = '';
   if ($paynum) {
@@ -1570,7 +1556,6 @@ sub invoice_logo {
          };
 }
 
-
 sub list_invoices {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -1588,25 +1573,31 @@ sub list_invoices {
   my @cust_bill = grep ! $_->hide, $cust_main->cust_bill;
 
   my $balance = 0;
+  my $invoices = [
+    map {
+      #not super efficient, we also run cust_bill_pay/cust_credited inside owed
+      my @payments_and_credits = sort {$b->_date <=> $a->_date} ($_->cust_bill_pay,$_->cust_credited);
+      my $owed = $_->owed;
+      $balance += $owed;
+      +{ 'invnum'       => $_->invnum,
+         '_date'        => $_->_date,
+         'date'         => time2str("%b %o, %Y", $_->_date),
+         'date_short'   => time2str("%m-%d-%Y",  $_->_date),
+         'previous'     => sprintf('%.2f', ($_->previous)[0]),
+         'charged'      => sprintf('%.2f', $_->charged),
+         'owed'         => sprintf('%.2f', $owed),
+         'balance'      => sprintf('%.2f', $balance),
+         'lastpay'      => @payments_and_credits 
+                           ? time2str("%b %o, %Y", $payments_and_credits[0]->_date)
+                           : '',
+      }
+    } @cust_bill
+  ];
 
   return  { 'error'       => '',
             'balance'     => $cust_main->balance,
-            'invoices'    => [
-              map {
-                    my $owed = $_->owed;
-                    $balance += $owed;
-                    +{ 'invnum'       => $_->invnum,
-                       '_date'        => $_->_date,
-                       'date'         => time2str("%b %o, %Y", $_->_date),
-                       'date_short'   => time2str("%m-%d-%Y",  $_->_date),
-                       'previous'     => sprintf('%.2f', ($_->previous)[0]),
-                       'charged'      => sprintf('%.2f', $_->charged),
-                       'owed'         => sprintf('%.2f', $owed),
-                       'balance'      => sprintf('%.2f', $balance),
-                     }
-                  }
-                  @cust_bill
-            ],
+            'money_char'  => $conf->config("money_char") || '$',
+            'invoices'    => $invoices,
             'legacy_invoices' => [
               map {
                     +{ 'legacyinvnum' => $_->legacyinvnum,
@@ -1624,6 +1615,175 @@ sub list_invoices {
           };
 }
 
+sub list_payments {
+  my $p = shift;
+  my $session = _cache->get($p->{'session_id'})
+    or return { 'error' => "Can't resume session" }; #better error message
+
+  my $custnum = $session->{'custnum'};
+
+  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+    or return { 'error' => "unknown custnum $custnum" };
+
+  return  { 'error'       => '',
+            'balance'     => $cust_main->balance,
+            'money_char'  => FS::Conf->new->config("money_char") || '$',
+            'payments'    => [ map $_->SSAPI_getinfo, $cust_main->cust_pay ],
+          };
+}
+
+sub payment_receipt {
+  my $p = shift;
+  my $session = _cache->get($p->{'session_id'})
+    or return { 'error' => "Can't resume session" }; #better error message
+
+  my $custnum = $session->{'custnum'};
+
+  my $cust_pay = qsearchs('cust_pay', { 'custnum' => $custnum,
+                                        'paynum'  => $p->{'paynum'},
+                                      }
+                         )
+    or return { 'error' => "unknown payment ". $p->{'paynum'} };
+
+  return { 
+           'error' => '',
+           %{ $cust_pay->SSAPI_getinfo },
+         };
+}
+
+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?
+   if ($p->{'payby'} eq 'CHEK') {
+     $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;
+     $p->{'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 update_payby {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  if ($p->{'payby'} eq 'CHEK') {
+     $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;
+     $p->{'payinfo'} = $payinfo1. '@'. $payinfo2;
+   }
+
+  my $cust_payby = qsearchs('cust_payby', {
+                              'custnum'      => $custnum,
+                              'custpaybynum' => $p->{'custpaybynum'},
+                           })
+    or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} };
+
+  foreach my $field (
+    qw( weight payby payinfo paycvv paydate payname paystate paytype payip )
+  ) {
+    next unless exists($p->{$field});
+    $cust_payby->set($field,$p->{$field});
+  }
+
+  my $error = $cust_payby->replace;
+  if ( $error ) {
+    return { 'error' => $error };
+  } else {
+    return { 'custpaybynum' => $cust_payby->custpaybynum };
+  }
+  
+}
+
+sub verify_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->verify };
+  
+}
+
+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'} };
+
+  my $conf = new FS::Conf;
+  if (($cust_payby->payby eq "DCHK" || $cust_payby->payby eq "CHEK") && $conf->exists('selfservice-ACH_info_readonly')) {
+    return { 'error' => "Sorry you do not have permission to delete bank information." };
+  }
+  else {
+    return { 'error' => $cust_payby->delete };
+  }
+}
+
 sub cancel {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -1642,6 +1802,30 @@ sub cancel {
 
 }
 
+sub pkg_info {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $pkg = qsearchs({
+    'table'     => 'cust_pkg',
+    'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )',
+    'hashref'   => {
+                      'custnum' => $custnum,
+                      'pkgnum'  => $p->{'pkgnum'},
+                   },
+  })
+    or return {'error' => 'unknown pkg num $pkgnum'};
+
+  return {
+        pkg_label => $pkg->pkg,
+        pkgpart   => $pkg->pkgpart,
+        classnum  => $pkg->classnum,
+  };
+
+}
+
 sub list_pkgs {
   my $p = shift;
 
@@ -1800,6 +1984,7 @@ sub list_svcs {
   #              @svc_x;
 
   my @svcs; # stuff to return to the client
+  my %bytes_used_total; # for _used columns only
   foreach my $cust_svc (@cust_svc) {
     my $svc_x = $cust_svc->svc_x;
     my($label, $value) = $cust_svc->label;
@@ -1821,6 +2006,24 @@ 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)
+      );
+      $bytes_used_total{'seconds_used'} += $hash{'seconds_used'};
+      $bytes_used_total{'upbytes_used'} += $up_used;
+      $bytes_used_total{'downbytes_used'} += $down_used;
+      $bytes_used_total{'totalbytes_used'} += $up_used + $down_used;
+    }
+
     if ( $svcdb eq 'svc_acct' ) {
       foreach (qw(username email finger seconds)) {
         $hash{$_} = $svc_x->$_;
@@ -1893,12 +2096,19 @@ sub list_svcs {
     push @svcs, \%hash;
   } # foreach $cust_svc
 
+  foreach my $field (keys %bytes_used_total) {
+    if ($field =~ /bytes/) {
+      $bytes_used_total{$field} = display_bytecount($bytes_used_total{$field});
+    }
+  }
+
   return { 
     'svcnum'   => $session->{'svcnum'},
     'custnum'  => $custnum,
     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
     'view_usage_nodomain' => $conf->exists('selfservice-view_usage_nodomain'),
     'svcs'     => \@svcs,
+    'bytes_used_total' => \%bytes_used_total,
     'usage_pools' => [
       map { $usage_pools{$_} }
       sort { $a cmp $b }
@@ -2391,7 +2601,7 @@ sub order_pkg {
   my $conf = new FS::Conf;
   if ( $conf->exists('signup_server-realtime') ) {
 
-    my $bill_error = _do_bop_realtime( $cust_main, $status );
+    my $bill_error = _do_bop_realtime( $cust_main, $status, 'collect'=>$p->{run_bill_events} );
 
     if ($bill_error) {
       $cust_pkg->cancel('quiet'=>1);
@@ -2437,13 +2647,17 @@ sub change_pkg {
   my $err_or_cust_pkg = $cust_pkg->change( 'pkgpart'  => $p->{'pkgpart'},
                                            'quantity' => $p->{'quantity'} || 1,
                                          );
+  
+  my $new_pkg = qsearchs('part_pkg', { 'pkgpart' => $p->{pkgpart} } )
+    or return { 'error' => "unknown package $p->{pkgpart}" };
 
   return { error=>$err_or_cust_pkg, pkgnum=>$cust_pkg->pkgnum }
     unless ref($err_or_cust_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;
@@ -2456,7 +2670,7 @@ sub change_pkg {
     $err_or_cust_pkg->reexport;
   }
 
-  return { error => '', pkgnum => $cust_pkg->pkgnum };
+  return { error => '', pkg => $new_pkg->pkg, pkgnum => $err_or_cust_pkg->pkgnum };
 
 }
 
@@ -2517,35 +2731,42 @@ 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 };
+  }
+
+  if ( $opt{'collect'} ) {
+    my $collect_error = $cust_main->collect();
+    return { 'error' => '_decline', 'bill_error' => $collect_error }
+     if $collect_error; #?
+  }
+
+  '';
 }
 
 sub renew_info {
@@ -2651,19 +2872,18 @@ sub cancel_pkg {
     or return { 'error' => "Can't resume session" }; #better error message
 
   my $custnum = $session->{'custnum'};
-
   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
     or return { 'error' => "unknown custnum $custnum" };
 
   my $pkgnum = $p->{'pkgnum'};
-
   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
                                         'pkgnum'  => $pkgnum,   } )
     or return { 'error' => "unknown pkgnum $pkgnum" };
 
-  my $error = $cust_pkg->cancel('quiet' => 1);
+  my $error = $cust_pkg->cancel( 'quiet' => 1,
+                                 'date'  => $p->{'date'},
+                               );
   return { 'error' => $error };
-
 }
 
 sub provision_phone {
@@ -2732,6 +2952,21 @@ sub provision_phone {
   { 'bulkdid' => [ @bulkdid ], 'svc' => $error->{'svc'} }
 }
 
+sub provision_pbx {
+  my $p = shift;
+  warn "provision_pbx called\n"
+    if $DEBUG;
+
+  warn "provision_pbx calling _provision\n"
+    if $DEBUG;
+  _provision( 'FS::svc_pbx',
+              [qw(id title max_extensions max_simultaneous ip_addr)],
+              [qw(id title max_extensions max_simultaneous ip_addr)],
+              $p,
+              @_
+            );
+}
+
 sub provision_acct {
   my $p = shift;
   warn "provision_acct called\n"
@@ -2770,6 +3005,15 @@ sub provision_external {
             );
 }
 
+sub provision_forward {
+  my $p = shift;
+  _provision( 'FS::svc_forward',
+              ['srcsvc','src','dstsvc','dst'],
+              [],
+              $p,
+            );
+}
+
 sub _provision {
   my( $class, $fields, $return_fields, $p ) = splice(@_, 0, 4);
   warn "_provision called for $class\n"
@@ -2797,6 +3041,9 @@ sub _provision {
   my $part_svc = qsearchs('part_svc', { 'svcpart' => $p->{'svcpart'} } )
     or return { 'error' => "unknown svcpart $p->{'svcpart'}" };
 
+  return { error=> 'svcpart '. $p->{'svcpart'}. " is not a $class definition" }
+    if $class ne 'FS::'. $part_svc->svcdb;
+
   warn "creating $class record\n"
     if $DEBUG;
   my $svc_x = $class->new( {
@@ -2892,6 +3139,10 @@ sub part_svc_info {
         }
   }
 
+  if ($ret->{'svcdb'} eq 'svc_forward') {
+    $ret->{'forward_emails'} = {$cust_pkg->forward_emails()};
+  }
+
   $ret;
 }
 
@@ -2961,13 +3212,9 @@ sub myaccount_passwd {
         )
     && ! $svc_acct->check_password($p->{'old_password'});
 
-  $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.
@@ -2981,6 +3228,8 @@ sub myaccount_passwd {
                            )
   ) {
     #svc_acct was successful but this one returns an error?  "shouldn't happen"
+    #don't recheck is_password_allowed here; if the svc_acct password was
+    #legal, that's good enough
     $error ||= $contact->change_password($p->{'new_password'});
   }
 
@@ -2993,53 +3242,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;
 
@@ -3066,7 +3268,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 ) {
@@ -3154,7 +3356,7 @@ sub reset_passwd {
 
     my $reset_session = {
       'svcnum'   => $svc_acct->svcnum,
-      'agentnum' =>
+      'agentnum' => $svc_acct->cust_main->agentnum,
     };
 
     my $timeout = '1 hour'; #?
@@ -3293,8 +3495,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;
 
@@ -3308,7 +3511,8 @@ sub process_reset_passwd {
 
   if ( $contact ) {
 
-    my $error = $contact->change_password($p->{'new_password'});
+    my $error = $contact->is_password_allowed($p->{'new_password'})
+            ||  $contact->change_password($p->{'new_password'});
 
     return { %$info, 'error' => $error }; # if $error;
 
@@ -3321,6 +3525,45 @@ sub process_reset_passwd {
 
 }
 
+sub validate_passwd {
+  my $p = shift;
+
+  my %result;
+  %result = ( 'fieldid' => $p->{'fieldid'} )
+    if $p->{'fieldid'} =~ /^\w+$/;
+
+  return { %result, 'password_invalid' => 'Enter new password' }
+    unless length($p->{'check_password'});
+
+  my $svc_acct;
+  if ($p->{'svcnum'}) {
+    # false laziness with myaccount_passwd
+    my($context, $session, $custnum) = _custoragent_session_custnum($p);
+    return { %result, 'error' => $session } if $context eq 'error';
+
+    $custnum =~ /^(\d+)$/ or die "illegal custnum";
+    my $search = " AND custnum = $1";
+    $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
+
+    $svc_acct = qsearchs( {
+      'table'     => 'svc_acct',
+      'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
+                     'LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
+                     'LEFT JOIN cust_main USING ( custnum ) ',
+      'hashref'   => { 'svcnum' => $p->{'svcnum'}, },
+      'extra_sql' => $search, #important
+    } )
+      or return { %result, 'error' => "Service not found" };
+    # end false laziness
+  }
+
+  $svc_acct ||= new FS::svc_acct {};
+
+  my $error = $svc_acct->is_password_allowed($p->{'check_password'});
+  return { %result, 'password_invalid' => $error } if $error;
+  return { %result, 'password_valid' => 1 };
+}
+
 sub list_tickets {
   my $p = shift;
   my($context, $session, $custnum) = _custoragent_session_custnum($p);
@@ -3347,6 +3590,11 @@ sub list_tickets {
 
   # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html
   if ( $FS::TicketSystem::system && FS::TicketSystem->selfservice_priority ) {
+
+    @tickets = grep { $_->{'_selfservice_priority'}
+                        !~ /^\s*(closed?|resolved?|done)\s*/i }
+                 @tickets;
+
     my $conf = new FS::Conf;
     my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1;
     +{ tickets => [