Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / ClientAPI / MyAccount.pm
index 3b18947..77a4683 100644 (file)
@@ -14,6 +14,7 @@ use Business::CreditCard;
 use HTML::Entities;
 use Text::CSV_XS;
 use Spreadsheet::WriteExcel;
+use OLE::Storage_Lite;
 use FS::UI::Web::small_custview qw(small_custview); #less doh
 use FS::UI::Web;
 use FS::UI::bytecount qw( display_bytecount );
@@ -27,6 +28,7 @@ use FS::TicketSystem;
 use FS::ClientAPI_SessionCache;
 use FS::cust_svc;
 use FS::svc_acct;
+use FS::svc_forward;
 use FS::svc_domain;
 use FS::svc_phone;
 use FS::svc_external;
@@ -37,26 +39,26 @@ use FS::cust_main;
 use FS::cust_bill;
 use FS::legacy_cust_bill;
 use FS::cust_main_county;
+use FS::part_pkg;
 use FS::cust_pkg;
 use FS::payby;
 use FS::acct_rt_transaction;
 use FS::msg_template;
 
-$DEBUG = 0;
+$DEBUG = 1;
 $me = '[FS::ClientAPI::MyAccount]';
 
-use vars qw( @cust_main_editable_fields );
+use vars qw( @cust_main_editable_fields @location_editable_fields );
 @cust_main_editable_fields = qw(
-  first last company address1 address2 city
-    county state zip country
-    daytime night fax mobile
-  ship_first ship_last ship_company ship_address1 ship_address2 ship_city
-    ship_state ship_zip ship_country
-    ship_daytime ship_night ship_fax ship_mobile
+  first last company daytime night fax mobile
   locale
   payby payinfo payname paystart_month paystart_year payissue payip
   ss paytype paystate stateid stateid_state
 );
+@location_editable_fields = qw(
+  address1 address2 city county state zip country
+);
+
 
 BEGIN { #preload to reduce time customer_info takes
   if ( $FS::TicketSystem::system ) {
@@ -114,13 +116,14 @@ sub skin_info {
       ( map { $_ => scalar( $conf->config($_, $agentnum) ) }
         qw( company_name date_format ) ),
       ( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
-        qw( body_bgcolor box_bgcolor
+        qw( body_bgcolor box_bgcolor stripe1_bgcolor stripe2_bgcolor
             text_color link_color vlink_color hlink_color alink_color
             font title_color title_align title_size menu_bgcolor menu_fontsize
           )
       ),
+      'menu_disable' => [ $conf->config('selfservice-menu_disable',$agentnum) ],
       ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) }
-        qw( menu_skipblanks menu_skipheadings menu_nounderline )
+        qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo )
       ),
       ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) }
         qw( title_left_image title_right_image
@@ -130,6 +133,36 @@ 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 ) ),
+      'menu' => join("\n", $conf->config("ng_selfservice-menu", $agentnum ) ) ||
+                'main.php Home
+
+                 services.php Services
+                 services.php My Services
+                 services_new.php Order a new service
+
+                 personal.php Profile
+                 personal.php Personal Information
+                 password.php Change Password
+
+                 payment.php Payments
+                 payment_cc.php Credit Card Payment
+                 payment_ach.php Electronic Check Payment
+                 payment_paypal.php PayPal Payment
+                 payment_webpay.php Webpay Payments
+
+                 usage.php Usage
+                 usage_data.php Data usage
+                 usage_cdr.php Call usage
+
+                 tickets.php Help Desk
+                 tickets.php Open Tickets
+                 tickets_resolved.php Resolved Tickets
+                 ticket_create.php Create a new ticket
+
+                 docs.php FAQs
+
+                 logout.php Logout
+                ',
     };
 
     _cache->set("skin_info_cache_agent$agentnum", $skin_info_cache_agent);
@@ -150,12 +183,25 @@ sub login_info {
     %{ skin_info($p) },
     'phone_login'  => $conf->exists('selfservice_server-phone_login'),
     'single_domain'=> scalar($conf->config('selfservice_server-single_domain')),
+    'banner_url'       => scalar($conf->config('selfservice-login_banner_url')),
+    'banner_image_md5' => 
+      md5_hex($conf->config_binary('selfservice-login_banner_image')),
   );
 
   return \%info;
 
 }
 
+sub login_banner_image {
+  my $p = shift;
+  my $conf = new FS::Conf;
+  my $image = $conf->config_binary('selfservice-login_banner_image');
+  return { 
+    'md5'   => md5_hex($image),
+    'image' => $image,
+  };
+}
+
 #false laziness w/FS::ClientAPI::passwd::passwd
 sub login {
   my $p = shift;
@@ -333,6 +379,8 @@ sub access_info {
       $conf->exists('ticket_system-selfservice_edit_subject') && 
       $cust_main->edit_subject;
 
+  $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600;
+
   return { %$info,
            'custnum'       => $custnum,
            'access_pkgnum' => $session->{'pkgnum'},
@@ -349,51 +397,29 @@ sub customer_info {
   my %return;
 
   my $conf = new FS::Conf;
-  if ($conf->exists('cust_main-require_address2')) {
-    $return{'require_address2'} = '1';
-  }else{
-    $return{'require_address2'} = '';
-  }
+  $return{'require_address2'} = $conf->exists('cust_main-require_address2');
 
-  if ( $FS::TicketSystem::system ) {
-    warn "$me customer_info: initializing ticket system\n" if $DEBUG;
-    FS::TicketSystem->init();
-  }
+#  if ( $FS::TicketSystem::system ) {
+#    warn "$me customer_info: initializing ticket system\n" if $DEBUG;
+#    FS::TicketSystem->init();
+#  }
  
   if ( $custnum ) { #customer record
 
+    %return = ( %return, %{ customer_info_short($p) } );
+
+    #redundant with customer_info_short, but we need it for several things below
     my $search = { 'custnum' => $custnum };
     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
     my $cust_main = qsearchs('cust_main', $search )
       or return { 'error' => "unknown custnum $custnum" };
 
-    if ( $session->{'pkgnum'} ) { 
-      $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
-    } else {
-      $return{balance} = $cust_main->balance;
-    }
-
-    my @tickets = $cust_main->tickets;
-    # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html
-    if ( FS::TicketSystem->selfservice_priority ) {
-      my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1;
-      $return{tickets} = [ 
-        sort { 
-          (
-            ($a->{'_selfservice_priority'} eq '') <=>
-            ($b->{'_selfservice_priority'} eq '')
-          ) ||
-          ( $dir * 
-            ($b->{'_selfservice_priority'} <=> $a->{'_selfservice_priority'}) 
-          )
-        } @tickets
-      ];
-    }
-    else {
-      $return{tickets} = \@tickets;
-    }
+    my $list_tickets = list_tickets($p);
+    $return{'tickets'} = $list_tickets->{'tickets'};
 
-    unless ( $session->{'pkgnum'} ) {
+    if ( $session->{'pkgnum'} ) {
+      #XXX open invoices in the pkg-balances case
+    } else {
       my @open = map {
                        {
                          invnum => $_->invnum,
@@ -402,35 +428,37 @@ sub customer_info {
                        };
                      } $cust_main->open_cust_bill;
       $return{open_invoices} = \@open;
+
+      my $sql = 'SELECT MAX(_date) FROM cust_bill WHERE custnum = ?';
+      my $sth = dbh->prepare($sql) or die  dbh->errstr;
+      $sth->execute($custnum)      or die $sth->errstr;
+      $return{'last_invoice_date'} = $sth->fetchrow_arrayref->[0];
+      $return{'last_invoice_date_pretty'} =
+        time2str('%m/%d/%Y', $return{'last_invoice_date'} );
     }
 
+    #customer_info_short always has nobalance on..
     $return{small_custview} =
       small_custview( $cust_main,
-                      scalar($conf->config('countrydefault')),
+                      $return{countrydefault},
                       ( $session->{'pkgnum'} ? 1 : 0 ), #nobalance
                     );
 
-    $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
+    $return{has_ship_address} = $cust_main->has_ship_address;
+    $return{status} = $cust_main->status;
+    $return{statuscolor} = $cust_main->statuscolor;
 
-    for (@cust_main_editable_fields) {
-      $return{$_} = $cust_main->get($_);
+    # compatibility: some places in selfservice use this to determine
+    # if there's a ship address
+    if ( $return{has_ship_address} ) {
+      $return{ship_last}  = $cust_main->last;
+      $return{ship_first} = $cust_main->first;
     }
 
-    if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
-      $return{payinfo} = $cust_main->paymask;
-      @return{'month', 'year'} = $cust_main->paydate_monthyear;
-    }
-
-    $return{'invoicing_list'} =
-      join(', ', grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list );
-    $return{'postal_invoicing'} =
-      0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
-
     if (scalar($conf->config('support_packages'))) {
       my @support_services = ();
       foreach ($cust_main->support_services) {
-        my $seconds = $_->svc_x->seconds;
+        my $seconds = $_->svc_x->seconds || 0;
         my $time_remaining = (($seconds < 0) ? '-' : '' ).
                              int(abs($seconds)/3600)."h".
                              sprintf("%02d",(abs($seconds)%3600)/60)."m";
@@ -453,12 +481,6 @@ sub customer_info {
       $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
     }
 
-    if ( $session->{'svcnum'} ) {
-      my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $session->{'svcnum'} });
-      $return{'svc_label'} = ($cust_svc->label)[1] if $cust_svc;
-      $return{'svcnum'} = $session->{'svcnum'};
-    }
-
   } elsif ( $session->{'svcnum'} ) { #no customer record
 
     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
@@ -471,8 +493,8 @@ sub customer_info {
 
   }
 
-  return { 'error'          => '',
-           'custnum'        => $custnum,
+  return { 'error'   => '',
+           'custnum' => $custnum,
            %return,
          };
 
@@ -495,14 +517,28 @@ sub customer_info_short {
     my $cust_main = qsearchs('cust_main', $search )
       or return { 'error' => "unknown custnum $custnum" };
 
+    $return{display_custnum} = $cust_main->display_custnum;
+
+    if ( $session->{'pkgnum'} ) { 
+      $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
+      #next_bill_date from cust_pkg?
+    } else {
+      $return{balance} = $cust_main->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)';
+    }
+
+    $return{countrydefault} = scalar($conf->config('countrydefault'));
+
     $return{small_custview} =
       small_custview( $cust_main,
-                      scalar($conf->config('countrydefault')),
+                      $return{countrydefault},
                       1, ##nobalance
                     );
 
     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
 
     $return{payby} = $cust_main->payby;
 
@@ -510,16 +546,22 @@ sub customer_info_short {
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
     }
-    
+    #maybe a little more expensive, but it should be cached by now
+    for (@location_editable_fields) {
+      $return{$_} = $cust_main->bill_location->get($_);
+      $return{'ship_'.$_} = $cust_main->ship_location->get($_);
+    }
     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)$/ } $cust_main->invoicing_list );
-    #$return{'postal_invoicing'} =
-    #  0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
+      join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
+    $return{'postal_invoicing'} =
+      0 < ( grep { $_ eq 'POST' } @invoicing_list );
 
     if ( $session->{'svcnum'} ) {
       my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $session->{'svcnum'} });
@@ -544,6 +586,105 @@ sub customer_info_short {
          };
 }
 
+sub billing_history {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  return { 'error' => 'No customer' } unless $custnum;
+
+  my $search = { 'custnum' => $custnum };
+  $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
+  my $cust_main = qsearchs('cust_main', $search )
+    or return { 'error' => "unknown custnum $custnum" };
+
+  my %return = ();
+
+  if ( $session->{'pkgnum'} ) { 
+    #$return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
+    #next_bill_date from cust_pkg?
+    return { 'error' => 'No history for package' };
+  }
+
+  $return{balance} = $cust_main->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 {
+
+    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 \%return;
+
+}
+
 sub edit_info {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -556,9 +697,32 @@ sub edit_info {
     or return { 'error' => "unknown custnum $custnum" };
 
   my $new = new FS::cust_main { $cust_main->hash };
+
   $new->set( $_ => $p->{$_} )
     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
 
+  if ( exists($p->{address1}) ) {
+    my $bill_location = FS::cust_location->new({
+        map { $_ => $p->{$_} } @location_editable_fields
+    });
+    # if this is unchanged from before, cust_main::replace will ignore it
+    $new->set('bill_location' => $bill_location);
+  }
+
+  if ( exists($p->{ship_address1}) ) {
+    my $ship_location = FS::cust_location->new({
+        map { $_ => $p->{"ship_$_"} } @location_editable_fields
+    });
+    if ( !grep { length($p->{"ship_$_"}) } @location_editable_fields ) {
+      # Selfservice unfortunately tries to indicate "same as billing 
+      # address" by sending all fields empty.  Did this ever work?
+      $ship_location = $cust_main->bill_location;
+    }
+    $new->set('ship_location' => $ship_location);
+  }
+  # 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})$/
@@ -584,7 +748,7 @@ sub edit_info {
     $p->{'payinfo1'} =~ /^([\dx]+)$/
       or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
     my $payinfo1 = $1;
-     $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by -require-bank-branch?
+     $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ?
       or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
     my $payinfo2 = $1;
     $payinfo = $payinfo1. '@'. $payinfo2;
@@ -668,7 +832,7 @@ sub payment_info {
 
       'save_unchecked' => $conf->exists('selfservice-save_unchecked'),
 
-      'credit_card_surcharge_percentage' => $conf->config('credit-card-surcharge-percentage'),
+      'credit_card_surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')),
     };
 
   }
@@ -696,7 +860,8 @@ sub payment_info {
   $return{payname} = $cust_main->payname
                      || ( $cust_main->first. ' '. $cust_main->get('last') );
 
-  $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
+  $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;
@@ -715,7 +880,7 @@ sub payment_info {
     $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') ) {
@@ -752,6 +917,21 @@ sub validate_payment {
   my $amount = $1;
   return { error => 'Amount must be greater than 0' } unless $amount > 0;
 
+  #false laziness w/tr-amount_fee.html, but we don't want selfservice users
+  #changing the hidden form values
+  my $conf = new FS::Conf;
+  my $fee_display = $conf->config('selfservice_process-display') || 'add';
+  my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
+  my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
+  if ( $fee_display eq 'add'
+         and $fee_pkgpart
+         and ! $fee_skip_first || scalar($cust_main->cust_pay)
+     )
+  {
+    my $fee_pkg = qsearchs('part_pkg', { pkgpart=>$fee_pkgpart } );
+    $amount = sprintf('%.2f', $amount + $fee_pkg->option('setup_fee') );
+  }
+
   $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
     or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
   my $discount_term = $1;
@@ -789,7 +969,7 @@ sub validate_payment {
    
     $payinfo = $p->{'payinfo'};
 
-    #more intelligent mathing will be needed here if you change
+    #more intelligent matching will be needed here if you change
     #card_masking_method and don't remove existing paymasks
     $payinfo = $cust_main->payinfo
       if $cust_main->paymask eq $payinfo;
@@ -838,6 +1018,8 @@ sub validate_payment {
     'card_type'      => $card_type,
     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
+    'month'          => $p->{'month'},
+    'year'           => $p->{'year'},
     'payname'        => $payname,
     'paybatch'       => $paybatch, #this doesn't actually do anything
     'paycvv'         => $paycvv,
@@ -862,7 +1044,9 @@ sub store_payment {
   _cache->set( 'payment_'.$p->{'session_id'}, $validate, $timeout );
 
   +{ map { $_=>$validate->{$_} }
-      qw( card_type paymask payname paydate_pretty amount )
+      qw( card_type paymask payname paydate_pretty month year amount
+          address1 address2 city state zip country
+        )
   };
 
 }
@@ -907,15 +1091,42 @@ sub do_process_payment {
   );
   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 ($validate->{'payby'} eq 'CARD' || $validate->{'payby'} eq 'DCRD') {
+    if ($payby eq 'CARD' || $payby eq 'DCRD') {
       $new->set( $_ => $validate->{$_} )
-        foreach qw( payname paystart_month paystart_year payissue payip
-                    address1 address2 city state zip country );
+        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
@@ -1043,6 +1254,50 @@ sub realtime_collect {
   return { 'error' => '', amount => $amount, %$error };
 }
 
+sub start_thirdparty {
+  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 = FS::cust_main->by_key($custnum);
+  
+  my $amount = $p->{'amount'}
+    or return { error => 'no amount' };
+
+  my $result = $cust_main->create_payment(
+    'method'      => $p->{'method'},
+    'amount'      => $p->{'amount'},
+    'pkgnum'      => $session->{'pkgnum'},
+    'session_id'  => $p->{'session_id'},
+  );
+  
+  if ( ref($result) ) { # hashref or error
+    return $result;
+  } else {
+    return { error => $result };
+  }
+}
+
+sub finish_thirdparty {
+  my $p = shift;
+  my $session_id = delete $p->{'session_id'};
+  my $session = _cache->get($session_id)
+    or return { 'error' => "Can't resume session" };
+  my $custnum = $session->{'custnum'};
+  my $cust_main = FS::cust_main->by_key($custnum);
+
+  if ( $p->{_cancel} ) {
+    # customer backed out of making a payment
+    return $cust_main->cancel_payment( $session_id );
+  }
+  my $result = $cust_main->execute_payment( $session_id, %$p );
+  if ( ref($result) ) {
+    return $result;
+  } else {
+    return { error => $result };
+  }
+}
+
 sub process_payment_order_pkg {
   my $p = shift;
 
@@ -1140,7 +1395,10 @@ sub invoice_pdf {
 
   return { 'error'       => '',
            'invnum'      => $invnum,
-           'invoice_pdf' => $cust_bill->print_pdf( { unsquelch_cdr => 1 } ),
+           'invoice_pdf' => $cust_bill->print_pdf({
+                              'unsquelch_cdr' => 1,
+                              'locale'        => $p->{'locale'},
+                            }),
          };
 
 }
@@ -1154,10 +1412,15 @@ sub legacy_invoice {
 
   my $legacyinvnum = $p->{'legacyinvnum'};
 
-  my $legacy_cust_bill = qsearchs('legacy_cust_bill', {
+  my %hash = (
     'legacyinvnum' => $legacyinvnum,
     'custnum'      => $custnum,
-  }) or return { 'error' => "Can't find legacyinvnum" };
+  );
+
+  my $legacy_cust_bill =
+         qsearchs('legacy_cust_bill', { %hash, 'locale' => $p->{'locale'} } )
+      || qsearchs('legacy_cust_bill', \%hash )
+    or return { 'error' => "Can't find legacyinvnum" };
 
   #my %return;
 
@@ -1240,16 +1503,7 @@ sub list_invoices {
 
   my @legacy_cust_bill = $cust_main->legacy_cust_bill;
 
-  my @cust_bill = $cust_main->cust_bill;
-
-  my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass');
-  if ( $hide_taxclass ) {
-    @cust_bill = grep { my @cust_bill_pkg = $_->cust_bill_pkg;
-                        my @part_pkg= grep $_, map $_->part_pkg, @cust_bill_pkg;
-                        grep { $_->taxclass ne $hide_taxclass } @part_pkg;
-                      }
-                   @cust_bill;
-  }
+  my @cust_bill = grep ! $_->hide, $cust_main->cust_bill;
 
   my $balance = 0;
 
@@ -1362,9 +1616,14 @@ sub list_pkgs {
                           my $primary_cust_svc = $_->primary_cust_svc;
                           +{ $_->hash,
                             $_->part_pkg->hash,
-                            status => $_->status,
+                            pkg_label   => $_->pkg_locale,
+                            status      => $_->status,
+                            statuscolor => $_->statuscolor,
                             part_svc =>
-                              [ map $_->hashref, $_->available_part_svc ],
+                              [ map { $_->hashref }
+                                  grep { $_->selfservice_access ne 'hidden' }
+                                    $_->available_part_svc
+                              ],
                             cust_svc => 
                               [ map { my $ref = { $_->hash,
                                                   label => [ $_->label ],
@@ -1378,7 +1637,9 @@ sub list_pkgs {
                                       $ref->{svchash}->{svcpart} =  $_->part_svc->svcpart
                                         if $_->part_svc->svcdb eq 'svc_phone'; # hack
                                       $ref;
-                                    } $_->cust_svc
+                                    }
+                                  grep { $_->part_svc->selfservice_access ne 'hidden' }
+                                    $_->cust_svc
                               ],
                             primary_cust_svc =>
                               $primary_cust_svc
@@ -1393,6 +1654,7 @@ sub list_pkgs {
                   ],
     'small_custview' =>
       small_custview( $cust_main, $conf->config('countrydefault') ),
+    'date_format' => $conf->config('date_format') || '%m/%d/%Y',
   };
 
 }
@@ -1415,13 +1677,27 @@ sub list_svcs {
   }
 
   my @cust_svc = ();
+  my @cust_pkg_usage = ();
   #foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) {
   foreach my $cust_pkg ( $p->{'ncancelled'} 
                          ? $cust_main->ncancelled_pkgs
                          : $cust_main->unsuspended_pkgs ) {
     next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
     push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
+    push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
   }
+
+  @cust_svc = grep { $_->part_svc->selfservice_access ne 'hidden' } @cust_svc;
+  my %usage_pools;
+  foreach (@cust_pkg_usage) {
+    my $part = $_->part_pkg_usage;
+    my $tag = $part->description . ($part->shared ? 1 : 0);
+    my $row = $usage_pools{$tag} 
+          ||= [ $part->description, 0, 0, $part->shared ? 1 : 0 ];
+    $row->[1] += $_->minutes; # minutes remaining
+    $row->[2] += $part->minutes; # minutes total
+  }
+
   if ( $p->{'svcdb'} ) {
     my $svcdb = ref($p->{'svcdb'}) eq 'HASH'
                   ? $p->{'svcdb'}
@@ -1445,16 +1721,20 @@ sub list_svcs {
       map { 
             my $svc_x = $_->svc_x;
             my($label, $value) = $_->label;
-            my $svcdb = $_->part_svc->svcdb;
+            my $part_svc = $_->part_svc;
+            my $svcdb = $part_svc->svcdb;
             my $cust_pkg = $_->cust_pkg;
             my $part_pkg = $cust_pkg->part_pkg;
 
             my %hash = (
-              'svcnum'     => $_->svcnum,
-              'svcdb'      => $svcdb,
-              'label'      => $label,
-              'value'      => $value,
-              'pkg_status' => $cust_pkg->status,
+              'svcnum'         => $_->svcnum,
+              'display_svcnum' => $_->display_svcnum,
+              'svcdb'          => $svcdb,
+              'label'          => $label,
+              'value'          => $value,
+              'pkg_label'      => $cust_pkg->pkg_locale,
+              'pkg_status'     => $cust_pkg->status,
+              'readonly'       => ($part_svc->selfservice_access eq 'readonly'),
             );
 
             if ( $svcdb eq 'svc_acct' ) {
@@ -1488,7 +1768,34 @@ sub list_svcs {
               } else {
                 $hash{'name'} = $cust_main->name;
               }
+            } elsif ( $svcdb eq 'svc_phone' ) {
+              # could potentially show lots of things...
+              $hash{'outbound'} = 1;
+              $hash{'inbound'}  = 0;
+              if ( $part_pkg->plan eq 'voip_inbound' ) {
+                $hash{'outbound'} = 0;
+                $hash{'inbound'}  = 1;
+              } elsif ( $part_pkg->option('selfservice_inbound_format')
+                    or  $conf->config('selfservice-default_inbound_cdr_format')
+              ) {
+                $hash{'inbound'}  = 1;
+              }
+              foreach (qw(inbound outbound)) {
+                # hmm...we can't filter by status here, because there might
+                # not be cdr_terminations at all.  have to go by date.
+                # find all since the last bill date.
+                # XXX cdr types?  we are going to need them.
+                if ( $hash{$_} ) {
+                  my $sum_cdr = $svc_x->sum_cdrs(
+                    'inbound' => ( $_ eq 'inbound' ? 1 : 0 ),
+                    'begin'   => ($cust_pkg->last_bill || 0),
+                    'nonzero' => 1,
+                  );
+                  $hash{$_} = $sum_cdr->hashref;
+                }
+              }
             }
+
             # elsif ( $svcdb eq 'svc_phone' || $svcdb eq 'svc_port' ) {
             #  %hash = (
             #    %hash,
@@ -1499,12 +1806,18 @@ sub list_svcs {
           }
           @cust_svc
     ],
+    'usage_pools' => [
+      map { $usage_pools{$_} }
+      sort { $a cmp $b }
+      keys %usage_pools
+    ],
   };
 
 }
 
 sub _customer_svc_x {
-  my($custnum, $svcnum, $table) = @_;
+  my($custnum, $svcnum, $table) = (shift, shift, shift);
+  my $hashref = ref($svcnum) ? $svcnum : { 'svcnum' => $svcnum };
 
   $custnum =~ /^(\d+)$/ or die "illegal custnum";
   my $search = " AND custnum = $1";
@@ -1515,7 +1828,7 @@ sub _customer_svc_x {
     'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
                    'LEFT JOIN cust_pkg  USING ( pkgnum  ) ',#.
                    #'LEFT JOIN cust_main USING ( custnum ) ',
-    'hashref'   => { 'svcnum' => $svcnum, },
+    'hashref'   => $hashref,
     'extra_sql' => $search, #important
   } );
 
@@ -1537,6 +1850,111 @@ sub svc_status_html {
 
 }
 
+sub svc_status_hash {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  #XXX only svc_acct for now
+  my $svc_x = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct')
+    or return { 'error' => "Service not found" };
+
+  my ( $html, $hashref ) = $svc_x->export_getstatus;
+  return $hashref;
+
+}
+
+sub set_svc_status_hash    { _svc_method_X(shift, 'export_setstatus') }
+sub set_svc_status_listadd { _svc_method_X(shift, 'export_setstatus_listadd') }
+sub set_svc_status_listdel { _svc_method_X(shift, 'export_setstatus_listdel') }
+sub set_svc_status_vacationadd { _svc_method_X(shift, 'export_setstatus_vacationadd') }
+sub set_svc_status_vacationdel { _svc_method_X(shift, 'export_setstatus_vacationdel') }
+
+sub _svc_method_X {
+  my( $p, $method ) = @_;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  #XXX only svc_acct for now
+  my $svc_x = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct')
+    or return { 'error' => "Service not found" };
+
+  warn "$method ". join(' / ', map "$_=>".$p->{$_}, keys %$p )
+    if $DEBUG;
+  my $error = $svc_x->$method($p); #$p? returns error?
+  return { 'error' => $error } if $error;
+
+  return {}; #? { 'error' => '' }
+
+}
+
+sub acct_forward_info {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $svc_forward = _customer_svc_x( $custnum,
+                                     { 'srcsvc' => $p->{'svcnum'} },
+                                     'svc_forward',
+                                   )
+    or return { 'error' => '',
+                'dst'   => '',
+              };
+
+  return { 'error' => '',
+           'dst'   => $svc_forward->dst || $svc_forward->dstsvc_acct->email,
+         };
+
+}
+
+sub process_acct_forward {
+  my $p = shift;
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my $old = _customer_svc_x( $custnum,
+                             { 'srcsvc' => $p->{'svcnum'} },
+                             'svc_forward',
+                           );
+
+  if ( $p->{'dst'} eq '' ) {
+    if ( $old ) {
+      my $error = $old->delete;
+      return { 'error' => $error };
+    }
+    return { 'error' => '' };
+  }
+
+  my $new = new FS::svc_forward { 'srcsvc' => $p->{'svcnum'},
+                                  'dst'    => $p->{'dst'},
+                                };
+
+  my $error;
+  if ( $old ) {
+    $new->svcnum($old->svcnum);
+    my $cust_svc = $old->cust_svc;
+    $new->svcpart($old->svcpart);
+    $new->pkgnuym($old->pkgnum);
+    $error = $new->replace($old);
+  } else {
+    my $conf = new FS::Conf;
+    $new->svcpart($conf->config('selfservice-svc_forward_svcpart'));
+
+    my $svc_acct = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct' )
+      or return { 'error' => 'No service' }; #how would we even get here?
+
+    $new->pkgnum( $svc_acct->cust_svc->pkgnum );
+
+    $error = $new->insert;
+  }
+
+  return { 'error' => $error };
+
+}
+
 sub list_dsl_devices {
   my $p = shift;
 
@@ -1650,9 +2068,12 @@ sub list_support_usage {
 }
 
 sub _list_cdr_usage {
-  my($svc_phone, $begin, $end) = @_;
-  map [ $_->downstream_csv('format' => 'default', 'keeparray' => 1) ], #XXX config for format
-                       $svc_phone->get_cdrs( 'begin'=>$begin, 'end'=>$end, );
+  # XXX CDR type support...
+  # XXX any way to do a paged search on this?
+  # we have to return the results all at once...
+  my($svc_phone, $begin, $end, %opt) = @_;
+  map [ $_->downstream_csv(%opt, 'keeparray' => 1) ],
+    $svc_phone->get_cdrs( 'begin'=>$begin, 'end'=>$end, %opt );
 }
 
 sub list_cdr_usage {
@@ -1677,12 +2098,29 @@ sub _usage_details {
   return { 'error' => 'No service selected in list_svc_usage' } 
     unless $svc_x;
 
-  my $header = $svcdb eq 'svc_phone'
-                 ? [ split(',', FS::cdr::invoice_header('default') ) ]  #XXX
-                 : [];
-
   my $cust_pkg = $svc_x->cust_svc->cust_pkg;
   my $freq     = $cust_pkg->part_pkg->freq;
+  my %callback_opt;
+  my $header = [];
+  if ( $svcdb eq 'svc_phone' ) {
+    my $conf = FS::Conf->new;
+    my $format = '';
+    if ( $p->{inbound} ) {
+      $format = $cust_pkg->part_pkg->option('selfservice_inbound_format') 
+                || $conf->config('selfservice-default_inbound_cdr_format')
+                || 'source_default';
+      $callback_opt{inbound} = 1;
+    } else {
+      $format = $cust_pkg->part_pkg->option('selfservice_format')
+                || $conf->config('selfservice-default_cdr_format')
+                || 'default';
+    }
+
+    $callback_opt{format} = $format;
+    $callback_opt{use_clid} = 1;
+    $header = [ split(',', FS::cdr::invoice_header($format) ) ];
+  }
+
   my $start    = $cust_pkg->setup;
   #my $end      = $cust_pkg->bill; # or time?
   my $end      = time;
@@ -1692,7 +2130,12 @@ sub _usage_details {
     $p->{ending}    = $end;
   }
 
-  my (@usage) = &$callback($svc_x, $p->{beginning}, $p->{ending});
+  die "illegal beginning" if $p->{beginning} !~ /^\d*$/;
+  die "illegal ending"    if $p->{ending}    !~ /^\d*$/;
+
+  my (@usage) = &$callback($svc_x, $p->{beginning}, $p->{ending}, 
+    %callback_opt
+  );
 
   #kinda false laziness with FS::cust_main::bill, but perhaps
   #we should really change this bit to DateTime and DateTime::Duration
@@ -1733,6 +2176,7 @@ sub _usage_details {
     'svcnum'    => $p->{svcnum},
     'beginning' => $p->{beginning},
     'ending'    => $p->{ending},
+    'inbound'   => $p->{inbound},
     'previous'  => ($previous > $start) ? $previous : $start,
     'next'      => ($next < $end) ? $next : $end,
     'header'    => $header,
@@ -2362,7 +2806,7 @@ sub myaccount_passwd {
       unless $svc_acct->check_password($p->{'old_password'});
   }
 
-  $svc_acct->_password($p->{'new_password'});
+  $svc_acct->set_password($p->{'new_password'});
   my $error = $svc_acct->replace();
 
   my($label, $value) = $svc_acct->cust_svc->label;
@@ -2503,7 +2947,7 @@ sub process_reset_passwd {
   my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } )
     or return { 'error' => "Service not found" };
 
-  $svc_acct->_password($p->{'new_password'});
+  $svc_acct->set_password($p->{'new_password'});
   my $error = $svc_acct->replace();
 
   my($label, $value) = $svc_acct->cust_svc->label;
@@ -2515,13 +2959,59 @@ sub process_reset_passwd {
 
 }
 
+sub list_tickets {
+  my $p = shift;
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  my @tickets = ();
+  if ( $session->{'pkgnum'} ) { 
+
+    #tickets for specific service with pkg-balances on
+    my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
+                                          'pkgnum'  => $session->{'pkgnum'} })
+                     or return { 'error' => 'unknown package' };
+    foreach my $cust_svc ( $cust_pkg->cust_svc ) {
+      push @tickets, $cust_svc->tickets( $p->{status} );
+    }
+
+  } else {
+
+    my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+      or return { 'error' => "unknown custnum $custnum" };
+
+    @tickets = $cust_main->tickets( $p->{status} );
+  }
+
+  # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html
+  if ( $FS::TicketSystem::system && FS::TicketSystem->selfservice_priority ) {
+    my $conf = new FS::Conf;
+    my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1;
+    +{ tickets => [ 
+         sort { 
+           (
+             ($a->{'_selfservice_priority'} eq '') <=>
+             ($b->{'_selfservice_priority'} eq '')
+           ) ||
+           ( $dir * 
+             ($b->{'_selfservice_priority'} <=> $a->{'_selfservice_priority'})
+           )
+         } @tickets
+       ]
+    };
+  } else {
+    +{ tickets => \@tickets };
+  }
+
+}
+
 sub create_ticket {
   my $p = shift;
   my($context, $session, $custnum) = _custoragent_session_custnum($p);
   return { 'error' => $session } if $context eq 'error';
 
-  warn "$me create_ticket: initializing ticket system\n" if $DEBUG;
-  FS::TicketSystem->init();
+#  warn "$me create_ticket: initializing ticket system\n" if $DEBUG;
+#  FS::TicketSystem->init();
 
   my $conf = new FS::Conf;
   my $queue = $p->{'queue'}
@@ -2636,10 +3126,10 @@ sub get_ticket {
   my($context, $session, $custnum) = _custoragent_session_custnum($p);
   return { 'error' => $session } if $context eq 'error';
 
-  warn "$me get_ticket: initializing ticket system\n" if $DEBUG;
-  FS::TicketSystem->init();
-  return { 'error' => 'get_ticket configuration error' }
-    if $FS::TicketSystem::system ne 'RT_Internal';
+#  warn "$me get_ticket: initializing ticket system\n" if $DEBUG;
+#  FS::TicketSystem->init();
+#  return { 'error' => 'get_ticket configuration error' }
+#    if $FS::TicketSystem::system ne 'RT_Internal';
 
   # check existence and ownership as part of this
   warn "$me get_ticket: fetching ticket\n" if $DEBUG;
@@ -2711,8 +3201,8 @@ sub adjust_ticket_priority {
   my($context, $session, $custnum) = _custoragent_session_custnum($p);
   return { 'error' => $session } if $context eq 'error';
 
-  warn "$me adjust_ticket_priority: initializing ticket system\n" if $DEBUG;
-  FS::TicketSystem->init;
+#  warn "$me adjust_ticket_priority: initializing ticket system\n" if $DEBUG;
+#  FS::TicketSystem->init;
   my $ss_priority = FS::TicketSystem->selfservice_priority;
 
   return { 'error' => 'adjust_ticket_priority configuration error' }