[freeside-commits] branch master updated. aeb90ade381fc3d5477db0334048c2af623fccfe

Ivan ivan at 420.am
Tue Feb 3 07:18:53 PST 2015


The branch, master has been updated
       via  aeb90ade381fc3d5477db0334048c2af623fccfe (commit)
       via  167dbdad01e2c1b62fd9be43cc05212e8c874a02 (commit)
       via  475ae93877f1d834941f7b9adcc35ee84c5c22fa (commit)
      from  d9edf24e9d3e1fd87a23359a7679ef6d6637c00d (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit aeb90ade381fc3d5477db0334048c2af623fccfe
Merge: 167dbda d9edf24
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Feb 3 07:18:45 2015 -0800

    contacts can be shared among customers / "duplicate contact emails", RT#27943

diff --cc FS/FS/Daemon/Preforking.pm
index 98b4fa6,98b4fa6..f3a39a6
--- a/FS/FS/Daemon/Preforking.pm
+++ b/FS/FS/Daemon/Preforking.pm
@@@ -96,6 -96,6 +96,7 @@@ sub daemon_run 
    #parent doesn't need to hold a DB connection open
    dbh->disconnect;
    undef $FS::UID::dbh;
++  undef $RT::Handle;
  
    server_spawn(MAX_PROCESSES);
    POE::Kernel->run();
diff --cc FS/FS/Record.pm
index f8282c0,f8282c0..92fb896
--- a/FS/FS/Record.pm
+++ b/FS/FS/Record.pm
@@@ -876,6 -876,6 +876,7 @@@ sub qsearchs { # $result_record = &FS::
    my $table = $_[0];
    my(@result) = qsearch(@_);
    cluck "warning: Multiple records in scalar search ($table)"
++        #.join(' / ', map "$_=>".$_[1]->{$_}, keys %{ $_[1] } )
      if scalar(@result) > 1;
    #should warn more vehemently if the search was on a primary key?
    scalar(@result) ? ($result[0]) : ();
diff --cc FS/FS/part_event/Condition/cust_bill_has_service.pm
index 6e981ee,6e981ee..898b08d
--- a/FS/FS/part_event/Condition/cust_bill_has_service.pm
+++ b/FS/FS/part_event/Condition/cust_bill_has_service.pm
@@@ -44,13 -44,13 +44,13 @@@ sub condition_sql 
    my $servicenums =
      $class->condition_sql_option_option_integer('has_service');
  
--  my $sql = qq| 0 < ( SELECT COUNT(cs.svcpart)
++  my $sql = " 0 < ( SELECT COUNT(cs.svcpart)
       FROM cust_bill_pkg cbp, cust_svc cs
      WHERE cbp.invnum = cust_bill.invnum
        AND cs.pkgnum = cbp.pkgnum
        AND cs.svcpart IN $servicenums
    )
--  |;
++  ";
    return $sql;
  }
  
diff --cc FS/FS/part_event/Condition/has_cust_tag.pm
index cde9338,cde9338..79bf2d3
--- a/FS/FS/part_event/Condition/has_cust_tag.pm
+++ b/FS/FS/part_event/Condition/has_cust_tag.pm
@@@ -16,7 -16,7 +16,6 @@@ sub eventtable_hashref 
      };
  }
  
--#something like this
  sub option_fields {
    (
      'tagnum'  => { 'label'    => 'Customer tag',
diff --cc FS/FS/part_export/amazon_ec2.pm
index 06e2c23,06e2c23..c1082a8
--- a/FS/FS/part_export/amazon_ec2.pm
+++ b/FS/FS/part_export/amazon_ec2.pm
@@@ -8,10 -8,10 +8,12 @@@ use FS::Record qw( qsearchs )
  use FS::svc_external;
  
  tie my %options, 'Tie::IxHash',
--  'access_key' => { label => 'AWS access key', },
--  'secret_key' => { label => 'AWS secret key', },
--  'ami'        => { label => 'AMI', 'default' => 'ami-ff46a796', },
--  'keyname'    => { label => 'Keypair name', },
++  'access_key'   => { label => 'AWS access key', },
++  'secret_key'   => { label => 'AWS secret key', },
++  'ami'          => { label => 'AMI', 'default' => 'ami-ff46a796', },
++  'keyname'      => { label => 'Keypair name', },
++  'region'       => { label => 'Region', },
++  'InstanceType' => { label => 'Instance Type', },
    #option to turn off (or on) ip address allocation
  ;
  
@@@ -38,6 -38,6 +40,7 @@@ sub _export_insert 
      $svc_external->svcnum,
      $self->option('ami'),
      $self->option('keyname'),
++    $self->option('InstanceType'),
    );
    ref($err_or_queue) ? '' : $err_or_queue;
  }
@@@ -96,31 -96,31 +99,35 @@@ sub amazon_ec2_queue 
    };
    $queue->insert( $self->option('access_key'),
                    $self->option('secret_key'),
++                  $self->option('region'),
                    @_
                  )
      or $queue;
  }
  
  sub amazon_ec2_new {
--  my( $access_key, $secret_key, @rest ) = @_;
++  my( $access_key, $secret_key, $region, @rest ) = @_;
  
    eval 'use Net::Amazon::EC2;';
    die $@ if $@;
  
    my $ec2 = new Net::Amazon::EC2 'AWSAccessKeyId'  => $access_key,
--                                 'SecretAccessKey' => $secret_key;
--
++                                 'SecretAccessKey' => $secret_key,
++                                 'region'          => $region || 'us-east-1',
++                                ;
    ( $ec2, @rest );
  }
  
  sub amazon_ec2_insert { #subroutine, not method
--  my( $ec2, $svcnum, $ami, $keyname ) = amazon_ec2_new(@_);
--
--  my $reservation_info = $ec2->run_instances( 'ImageId'  => $ami,
--                                              'KeyName'  => $keyname,
--                                              'MinCount' => 1,
--                                              'MaxCount' => 1,
--                                            );
++  my( $ec2, $svcnum, $ami, $keyname, $InstanceType ) = amazon_ec2_new(@_);
++
++  my $reservation_info = $ec2->run_instances(
++    'ImageId'      => $ami,
++    'KeyName'      => $keyname,
++    'InstanceType' => $InstanceType || 'm1.small',
++    'MinCount'     => 1,
++    'MaxCount'     => 1,
++  );
  
    my $instance_id = $reservation_info->instances_set->[0]->instance_id;
  
diff --cc FS/FS/part_export/cardfortress.pm
index 7ff7280,7ff7280..154f979
--- a/FS/FS/part_export/cardfortress.pm
+++ b/FS/FS/part_export/cardfortress.pm
@@@ -28,6 -28,6 +28,7 @@@ sub _export_insert 
    my $ssh = Net::OpenSSH->new( $self->machine,
                                 default_stdin_fh => $def_in );
  
++  #capture2 and return STDERR, its probably useful if there's a problem
    my $private_key = $ssh->capture(
      { 'stdin_data' => $svc_acct->_password. "\n" },
      '/usr/local/bin/merchant_create', map $svc_acct->$_, qw( username finger )
@@@ -67,6 -67,6 +68,7 @@@ sub _export_delete 
    my $ssh = Net::OpenSSH->new( $self->machine,
                                 default_stdin_fh => $def_in );
  
++  #capture2 and return STDERR, its probably useful if there's a problem
    my $unused_output = $ssh->capture(
      '/usr/local/bin/merchant_disable', map $svc_acct->$_, qw( username )
    );
diff --cc FS/FS/phone_avail.pm
index 52bbdeb,52bbdeb..ae8526c
--- a/FS/FS/phone_avail.pm
+++ b/FS/FS/phone_avail.pm
@@@ -283,8 -283,8 +283,8 @@@ sub _upgrade_data 
    my $sth = dbh->prepare(
      'UPDATE phone_avail SET svcnum = NULL
         WHERE svcnum IS NOT NULL
--         AND 0 = ( SELECT COUNT(*) FROM svc_phone
--                     WHERE phone_avail.svcnum = svc_phone.svcnum )'
++         AND NOT EXISTS ( SELECT 1 FROM svc_phone
++                            WHERE phone_avail.svcnum = svc_phone.svcnum )'
    ) or die dbh->errstr;
  
    $sth->execute or die $sth->errstr;
diff --cc FS/bin/freeside-cdrd
index 45d5878,45d5878..a3c67f9
--- a/FS/bin/freeside-cdrd
+++ b/FS/bin/freeside-cdrd
@@@ -120,10 -120,10 +120,10 @@@ while (1) 
  sub _shouldrun {
  
    my $extra_sql =
--    ' AND 0 < ( SELECT COUNT(*) FROM cust_pkg
--                  WHERE cust_pkg.pkgpart = part_pkg.pkgpart
--                    AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
--              )
++    ' AND EXISTS ( SELECT 1 FROM cust_pkg
++                     WHERE cust_pkg.pkgpart = part_pkg.pkgpart
++                       AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
++                 )
      ';
  
    my @part_pkg =
diff --cc bin/cust_bill-credit_ship2
index c4d5169,c4d5169..a9a899c
--- a/bin/cust_bill-credit_ship2
+++ b/bin/cust_bill-credit_ship2
@@@ -193,16 -193,16 +193,17 @@@ foreach my $cust_bill ( @cust_bill ) 
    my $cur_cr = 0;
    $cur_cr += $_->amount foreach $cust_bill->cust_credited;
    $cur_cr = '' if $cur_cr == 0;
++
++  next if $cur_cr > 0 && $opt_k;
++
    if ( $opt_p ) {
      #print $cust_bill->invnum. ','. $cust_bill->custnum. ",$tax,$credit,$cr_percent%\n";
++#    print $cust_bill->invnum. ','. $cust_bill->custnum. ',"'.
++#          $cust_bill->cust_main->name. '",'. "$tax,$credit,$cur_cr\n";
      print $cust_bill->invnum. ','. $cust_bill->custnum. ',"'.
--          $cust_bill->cust_main->name. '",'. "$tax,$credit,$cur_cr\n";
++          $cust_bill->cust_main->name. '",'. "$tax,$credit\n";
    }
  
--  next if $cur_cr > 0 && $opt_k;
--
--#COMMENTING OUT ALL DANGEROUS STUFF
--#
  #  if ( $opt_m && ! $opt_r ) {
  #
  #    my $msg_template = qsearchs('msg_template', { 'msgnum' => $opt_m } )
@@@ -216,28 -216,28 +217,28 @@@
  #           " custnum ". $cust_bill->custnum. ": $error\n";
  #    }
  #  }
--#
--#  if ( $opt_c ) {
--#    my $cust_credit = new FS::cust_credit {
--#      'custnum'   => $cust_main->custnum,
--#      'amount'    => $credit,
--#      'reasonnum' => $opt_c,
--#    };
--#    my $error = $cust_credit->insert;
--#    if ( $error ) {
--#      warn "error inserting credit: $error\n";
--#    }
--#    my $cust_credit_bill = new FS::cust_credit_bill {
--#      'crednum' => $cust_credit->crednum,
--#      'invnum'  => $cust_bill->invnum,
--#      'amount'  => $credit,
--#    };
--#    my $aerror = $cust_credit_bill->insert;
--#    if ( $aerror ) {
--#      warn "error applying credit to invnum ". $cust_bill->invnum. ": $aerror\n";
--#    }
--#  }
--#
++
++  if ( $opt_c ) {
++    my $cust_credit = new FS::cust_credit {
++      'custnum'   => $cust_main->custnum,
++      'amount'    => $credit,
++      'reasonnum' => $opt_c,
++    };
++    my $error = $cust_credit->insert;
++    if ( $error ) {
++      warn "error inserting credit: $error\n";
++    }
++    my $cust_credit_bill = new FS::cust_credit_bill {
++      'crednum' => $cust_credit->crednum,
++      'invnum'  => $cust_bill->invnum,
++      'amount'  => $credit,
++    };
++    my $aerror = $cust_credit_bill->insert;
++    if ( $aerror ) {
++      warn "error applying credit to invnum ". $cust_bill->invnum. ": $aerror\n";
++    }
++  }
++
  #  if ( $opt_e && ! $opt_r ) {
  #    eval { $cust_bill->email };
  #    if ( $@ ) {
diff --cc eg/table_template.pm
index 0a6f851,0a6f851..686bef6
--- a/eg/table_template.pm
+++ b/eg/table_template.pm
@@@ -1,7 -1,7 +1,7 @@@
  package FS::table_name;
++use base qw( FS::Record );
  
  use strict;
--use base qw( FS::Record );
  use FS::Record qw( qsearch qsearchs );
  
  =head1 NAME
diff --cc fs_selfservice/DEPLOY
index bedb5ec,bedb5ec..4e0f495
--- a/fs_selfservice/DEPLOY
+++ b/fs_selfservice/DEPLOY
@@@ -11,7 -11,7 +11,8 @@@ perl Makefile.PL && make && make instal
  cd ..
  
  #( cd ..; make deploy; cd fs_selfservice )
--( cd ..; make clean; make configure-rt; make install-perl-modules; /etc/init.d/freeside restart; cd fs_selfservice )
++#( cd ..; make clean; make configure-rt; make install-perl-modules; /etc/init.d/freeside restart; cd fs_selfservice )
++( cd ..; make clean; make configure-rt; make install-perl-modules; make deploy; cd fs_selfservice )
  
  #cp /home/ivan/freeside/fs_selfservice/FS-SelfService/cgi/* /var/www/MyAccount
  #chown freeside /var/www/MyAccount/*.cgi
diff --cc httemplate/browse/discount.html
index d3cf873,d3cf873..9b2298a
--- a/httemplate/browse/discount.html
+++ b/httemplate/browse/discount.html
@@@ -8,8 -8,8 +8,9 @@@
                   'count_query' => 'SELECT COUNT(*) FROM discount',
                   'disableable' => 1,
                   'disabled_statuspos' => 1,
--                 'header'      => [ 'Name', 'Class', 'Discount', ],
++                 'header'      => [ 'Name', 'Comment', 'Class', 'Discount', ],
                   'fields'      => [ 'name',
++                                    'comment',
                                      'classname',
                                      'description',
                                    ],
diff --cc httemplate/docs/about.html
index 80d9488,80d9488..0f173f2
--- a/httemplate/docs/about.html
+++ b/httemplate/docs/about.html
@@@ -56,7 -56,7 +56,7 @@@ GNU <b>Affero</b> General Public Licens
  
  % unless ( $agentnum ) {
    <CENTER>
--  <FONT SIZE="-3">"" - R. Hunter</FONT>
++  <FONT SIZE="-3">"Half the world's a desert / Cannibals eat human brains for dessert" - D. Zero</FONT>
    </CENTER>
  % }
  
diff --cc httemplate/edit/cust_main-contacts.html
index 9f06546,9f06546..3b7eb07
--- a/httemplate/edit/cust_main-contacts.html
+++ b/httemplate/edit/cust_main-contacts.html
@@@ -11,6 -11,6 +11,7 @@@
         { 'field'             => 'contactnum',
           'type'              => 'contact',
           'colspan'           => 6,
++         'custnum'           => $custnum,
           'm2m_method'        => 'cust_contact',
           'm2m_dstcol'        => 'contactnum',   
           'm2_label'          => ' ', #'Contact',
diff --cc httemplate/elements/popup_link.html
index e5f8c61,e5f8c61..2b6b187
--- a/httemplate/elements/popup_link.html
+++ b/httemplate/elements/popup_link.html
@@@ -2,9 -2,9 +2,9 @@@
  
  Example:
  
--  include('/elements/init_overlib.html')
++  <& /elements/init_overlib.html &>
  
--  include( '/elements/popup_link.html', { #hashref or a list, either way is fine
++  <& /elements/popup_link.html', { #hashref or a list, either way is fine
  
      #required
      'action'         => 'content.html', # uri for content of popup
@@@ -23,7 -23,7 +23,8 @@@
      'aname'          => "target", # link NAME= value, useful for #targets
      'target'         => '_parent',
      'style'          => 'css-attribute:value',
--  } )
++  }
++  &>
  
  </%doc>
  % if ($params->{'action'} && $label) {
diff --cc httemplate/elements/tr-fixed.html
index 6904e3b,6904e3b..373c0ab
--- a/httemplate/elements/tr-fixed.html
+++ b/httemplate/elements/tr-fixed.html
@@@ -1,6 -1,6 +1,6 @@@
  <% include('tr-td-label.html', @_ ) %>
  
--  <TD BGCOLOR="#dddddd" <% $style %>><% $value %></TD>
++  <TD BGCOLOR="#dddddd" <% $style %> <% $colspan %>><% $value %></TD>
  
  </TR>
  
@@@ -10,7 -10,7 +10,9 @@@
  
  my %opt = @_;
  
--my $style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : '';
++my $style = $opt{'cell_style'} ? ' STYLE="'. $opt{'cell_style'}. '" ' : '';
++
++my $colspan = $opt{'colspan'} ? ' COLSPAN="'. $opt{'colspan'}. '" ' : '';
  
  my $value = $opt{'formatted_value'} || $opt{'curr_value'} || $opt{'value'};
  $value = $opt{'prefix'} . $value if defined($opt{'prefix'});
diff --cc httemplate/elements/tr-select-cust_location.html
index abaaa5b,abaaa5b..7a5b43b
--- a/httemplate/elements/tr-select-cust_location.html
+++ b/httemplate/elements/tr-select-cust_location.html
@@@ -287,6 -287,6 +287,8 @@@ if ( $locationnum && $locationnum > 0 
  $cust_location->coord_auto('Y');
  
  my $location_sort = sub {
++  #enabled w/label_prefix _location #    $a->locationname cmp $b->locationname
++                                    # or 
          $a->country   cmp $b->country
    or lc($a->city)     cmp lc($b->city)
    or lc($a->address1) cmp lc($b->address1)
diff --cc httemplate/search/cust_msg.html
index 486c7b0,486c7b0..d5b865c
--- a/httemplate/search/cust_msg.html
+++ b/httemplate/search/cust_msg.html
@@@ -47,7 -47,7 +47,7 @@@
                            ],
         'html_init'     => $html_init,
         'really_disable_download' => 1,
--       @_
++       @_ #why?
  &>
  <%init>
  #hmm...
@@@ -71,7 -71,7 +71,7 @@@ if ( $cgi->param('msgtype') =~ /^(\w+)$
    push @where, "msgtype = '$1'";
  }
  if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
--  push @where, "custnum = $1";
++  push @where, "cust_msg.custnum = $1";
  }
  my ($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, '');
  push @where, "(_date >= $beginning AND _date <= $ending)";

commit 167dbdad01e2c1b62fd9be43cc05212e8c874a02
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Feb 3 07:14:45 2015 -0800

    contacts can be shared among customers / "duplicate contact emails", RT#27943

diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index 8276d7e..86c7ac3 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -46,6 +46,7 @@ use FS::payby;
 use FS::acct_rt_transaction;
 use FS::msg_template;
 use FS::contact;
+use FS::cust_contact;
 
 $DEBUG = 1;
 $me = '[FS::ClientAPI::MyAccount]';
@@ -82,7 +83,7 @@ sub skin_info {
   #return { 'error' => $session } if $context eq 'error';
 
   my $agentnum = '';
-  if ( $context eq 'customer' ) {
+  if ( $context eq 'customer' && $custnum ) {
 
     my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?')
       or die dbh->errstr;
@@ -237,7 +238,16 @@ sub login {
     return { error => 'Incorrect contact password.' }
       unless $contact->authenticate_password($p->{'password'});
 
-    $session->{'custnum'} = $contact->custnum;
+    my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
+    if ( scalar(@cust_contact) == 1 ) {
+      $session->{'custnum'} = $cust_contact[0]->custnum;
+    } elsif ( scalar(@cust_contact) ) {
+      $session->{'customers'} = { map { $_->custnum => $_->cust_main->name }
+                                    @cust_contact
+                                };
+    } else {
+      return { error => 'No customer self-service access for contact' }; #??
+    }
 
   } else {
 
@@ -303,6 +313,7 @@ sub login {
 
   return { 'error'      => '',
            'session_id' => $session_id,
+           %$session,
          };
 }
 
@@ -336,6 +347,23 @@ sub switch_acct {
 
 }
 
+sub switch_cust {
+  my $p = shift;
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  $session->{'custnum'} = $p->{'custnum'}
+    if exists $session->{'customers'}{ $p->{'custnum'} };
+
+  my $conf = new FS::Conf;
+  my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
+  _cache->set( $p->{'session_id'}, $session, $timeout );
+
+  return { 'error'      => '',
+           %{ customer_info( { session_id=>$p->{'session_id'} } ) },
+         };
+}
+
 sub payment_gateway {
   # internal use only
   # takes a cust_main and a cust_payby entry, returns the payment_gateway
@@ -380,22 +408,23 @@ sub access_info {
   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" };
+  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } );
 
   $info->{'hide_payment_fields'} = [ 
     map { 
-      my $pg = payment_gateway($cust_main, $_);
+      my $pg = $cust_main && payment_gateway($cust_main, $_);
       $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
     } @{ $info->{cust_paybys} }
   ];
 
   $info->{'self_suspend_reason'} = 
-      $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum);
+      $conf->config('selfservice-self_suspend_reason',
+                      $cust_main ? $cust_main->agentnum : ''
+                   );
 
   $info->{'edit_ticket_subject'} =
       $conf->exists('ticket_system-selfservice_edit_subject') && 
-      $cust_main->edit_subject;
+      $cust_main && $cust_main->edit_subject;
 
   $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600;
 
@@ -432,7 +461,7 @@ sub customer_info {
     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" };
+      or return { 'error' => "customer_info: unknown custnum $custnum" };
 
     my $list_tickets = list_tickets($p);
     $return{'tickets'} = $list_tickets->{'tickets'};
@@ -536,7 +565,7 @@ sub customer_info_short {
     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" };
+      or return { 'error' => "customer_info_short: unknown custnum $custnum" };
 
     $return{display_custnum} = $cust_main->display_custnum;
 
@@ -2916,7 +2945,12 @@ sub myaccount_passwd {
   #need to support the "ISP provides email that's used as a contact email" case
   #as well as we can.
   my $contact = FS::contact->by_selfservice_email($svc_acct->email);
-  if ( $contact && $contact->custnum == $custnum ) {
+  if ( $contact && qsearchs('cust_contact', { contactnum=> $contact->contactnum,
+                                              custnum   => $custnum,
+                                              selfservice_access => 'Y',
+                                            }
+                           )
+  ) {
     #svc_acct was successful but this one returns an error?  "shouldn't happen"
     $error ||= $contact->change_password($p->{'new_password'});
   }
@@ -2993,7 +3027,10 @@ sub reset_passwd {
   
     $contact = FS::contact->by_selfservice_email($p->{'email'});
 
-    $cust_main = $contact->cust_main if $contact;
+    if ( $contact ) {
+      my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
+      $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
+    }
 
     #also look for an svc_acct, otherwise it would be super confusing
 
@@ -3035,6 +3072,9 @@ sub reset_passwd {
 
   }
 
+  return { %$info, 'error' => 'Multi-customer contacts incompatible with customer-based verification' }
+    if ! $cust_main && $verification ne 'email';
+
   my %verify = (
     'email'   => sub { 1; },
     'paymask' => sub { 
@@ -3157,7 +3197,9 @@ sub check_reset_passwd {
     my @contact_email = $contact->contact_email;
     return { 'error' => 'No contact email' } unless @contact_email;
 
-    $p->{'agentnum'} = $contact->cust_main->agentnum;
+    my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
+    $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum
+      if scalar(@cust_contact) == 1;
     my $info = skin_info($p);
 
     return { %$info,
@@ -3207,7 +3249,9 @@ sub process_reset_passwd {
     $contact = qsearchs('contact', { 'contactnum' => $contactnum } )
       or return { 'error' => "Contact not found" };
 
-    $p->{'agentnum'} ||= $contact->cust_main->agentnum;
+    my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
+    $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum
+      if scalar(@cust_contact) == 1;
     $info ||= skin_info($p);
 
   }
diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm
index 62f61d6..952b199 100644
--- a/FS/FS/ClientAPI_XMLRPC.pm
+++ b/FS/FS/ClientAPI_XMLRPC.pm
@@ -102,6 +102,7 @@ sub ss2clientapi {
   'login'                     => 'MyAccount/login',
   'logout'                    => 'MyAccount/logout',
   'switch_acct'               => 'MyAccount/switch_acct',
+  'switch_cust'               => 'MyAccount/switch_cust',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
   'billing_history'           => 'MyAccount/billing_history',
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 029f1a1..2b959e6 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2996,7 +2996,7 @@ and customer address. Include units.',
     'type'        => 'select',
     'select_hash' => [ '' => 'Password reset disabled',
                        'email' => 'Click on a link in email',
-                       'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code',
+                       'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code.  Note: Do not use if you have multi-customer contacts, as they will be unable to reset their passwords.',
                      ],
   },
 
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index d3e45df..37e3ad2 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -396,6 +396,9 @@ if ( -e $addl_handler_use_file ) {
   use FS::circuit_provider;
   use FS::circuit_termination;
   use FS::svc_circuit;
+  use FS::cust_credit_source_bill_pkg;
+  use FS::prospect_contact;
+  use FS::cust_contact;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index d5ed1b7..133b6d8 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1740,20 +1740,69 @@ sub tables_hashref {
       'index' => [ ['disabled'] ],
     },
 
+    'cust_contact' => {
+      'columns' => [
+        'custcontactnum',     'serial',     '',  '', '', '',
+        'custnum',               'int',     '',  '', '', '',
+        'contactnum',            'int',     '',  '', '', '',
+        'classnum',              'int', 'NULL',  '', '', '',
+        'comment',           'varchar', 'NULL', 255, '', '',
+        'selfservice_access',   'char', 'NULL',   1, '', '',
+      ],
+      'primary_key'  => 'custcontactnum',
+      'unique'       => [ [ 'custnum', 'contactnum' ], ],
+      'index'        => [ [ 'custnum' ], [ 'contactnum' ], ],
+      'foreign_keys' => [
+                          { columns    => [ 'custnum' ],
+                            table      => 'cust_main',
+                          },
+                          { columns    => [ 'contactnum' ],
+                            table      => 'contact',
+                          },
+                          { columns    => [ 'classnum' ],
+                            table      => 'contact_class',
+                          },
+                        ],
+    },
+
+    'prospect_contact' => {
+      'columns' => [
+        'prospectcontactnum', 'serial',     '',  '', '', '',
+        'prospectnum',       'int',     '',  '', '', '',
+        'contactnum',        'int',     '',  '', '', '',
+        'classnum',          'int', 'NULL',  '', '', '',
+        'comment',       'varchar', 'NULL', 255, '', '',
+      ],
+      'primary_key'  => 'prospectcontactnum',
+      'unique'       => [ [ 'prospectnum', 'contactnum' ], ],
+      'index'        => [ [ 'prospectnum' ], [ 'contactnum' ], ],
+      'foreign_keys' => [
+                          { columns    => [ 'prospectnum' ],
+                            table      => 'prospect_main',
+                          },
+                          { columns    => [ 'contactnum' ],
+                            table      => 'contact',
+                          },
+                          { columns    => [ 'classnum' ],
+                            table      => 'contact_class',
+                          },
+                        ],
+    },
+
     'contact' => {
       'columns' => [
         'contactnum', 'serial',     '',      '', '', '',
-        'prospectnum',   'int', 'NULL',      '', '', '',
-        'custnum',       'int', 'NULL',      '', '', '',
+        'prospectnum',   'int', 'NULL',      '', '', '', #deprecated, now prospect_contact table
+        'custnum',       'int', 'NULL',      '', '', '', #deprecated, now cust_contact table
         'locationnum',   'int', 'NULL',      '', '', '', #not yet
-        'classnum',      'int', 'NULL',      '', '', '',
+        'classnum',      'int', 'NULL',      '', '', '', #deprecated, now prospect_contact or cust_contact
 #        'titlenum',      'int', 'NULL',      '', '', '', #eg Mr. Mrs. Dr. Rev.
         'last',      'varchar',     '', $char_d, '', '', 
 #        'middle',    'varchar', 'NULL', $char_d, '', '', 
         'first',     'varchar',     '', $char_d, '', '', 
         'title',     'varchar', 'NULL', $char_d, '', '', #eg Head Bottle Washer
-        'comment',   'varchar', 'NULL',     255, '', '', 
-        'selfservice_access',    'char', 'NULL',       1, '', '',
+        'comment',   'varchar', 'NULL',     255, '', '',  #depredated, now prospect_contact or cust_contact
+        'selfservice_access',    'char', 'NULL',       1, '', '', #deprecated, now cust_contact
         '_password',          'varchar', 'NULL', $char_d, '', '',
         '_password_encoding', 'varchar', 'NULL', $char_d, '', '',
         'disabled',              'char', 'NULL',       1, '', '', 
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index 4719caa..d05b309 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -312,6 +312,9 @@ sub upgrade_data {
     #cust_main (remove paycvv from history)
     'cust_main' => [],
 
+    #contact -> cust_contact / prospect_contact
+    'contact' => [],
+
     #msgcat
     'msgcat' => [],
 
diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm
index 3205df1..07458c7 100644
--- a/FS/FS/contact.pm
+++ b/FS/FS/contact.pm
@@ -3,12 +3,15 @@ use base qw( FS::Record );
 
 use strict;
 use vars qw( $skip_fuzzyfiles );
+use Carp;
 use Scalar::Util qw( blessed );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::contact_phone;
 use FS::contact_email;
 use FS::queue;
 use FS::phone_type; #for cgi_contact_fields
+use FS::cust_contact;
+use FS::prospect_contact;
 
 $skip_fuzzyfiles = 0;
 
@@ -123,10 +126,88 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $self->SUPER::insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
+  #save off and blank values that move to cust_contact / prospect_contact now
+  my $prospectnum = $self->prospectnum;
+  $self->prospectnum('');
+  my $custnum = $self->custnum;
+  $self->custnum('');
+
+  my %link_hash = ();
+  for (qw( classnum comment selfservice_access )) {
+    $link_hash{$_} = $self->get($_);
+    $self->$_('');
+  }
+
+  #look for an existing contact with this email address
+  my $existing_contact = '';
+  if ( $self->get('emailaddress') =~ /\S/ ) {
+  
+    my %existing_contact = ();
+
+    foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
+ 
+      my $contact_email = qsearchs('contact_email', { emailaddress=>$email } )
+        or next;
+
+      my $contact = $contact_email->contact;
+      $existing_contact{ $contact->contactnum } = $contact;
+
+    }
+
+    if ( scalar( keys %existing_contact ) > 1 ) {
+      $dbh->rollback if $oldAutoCommit;
+      return 'Multiple email addresses specified '.
+             ' that already belong to separate contacts';
+    } elsif ( scalar( keys %existing_contact ) ) {
+      ($existing_contact) = values %existing_contact;
+    }
+
+  }
+
+  if ( $existing_contact ) {
+
+    $self->$_($existing_contact->$_())
+      for qw( contactnum _password _password_encoding );
+    $self->SUPER::replace($existing_contact);
+
+  } else {
+
+    my $error = $self->SUPER::insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
+  my $cust_contact = '';
+  if ( $custnum ) {
+    my %hash = ( 'contactnum' => $self->contactnum,
+                 'custnum'    => $custnum,
+               );
+    $cust_contact =  qsearchs('cust_contact', \%hash )
+                  || new FS::cust_contact { %hash, %link_hash };
+    my $error = $cust_contact->custcontactnum ? $cust_contact->replace
+                                              : $cust_contact->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  if ( $prospectnum ) {
+    my %hash = ( 'contactnum'  => $self->contactnum,
+                 'prospectnum' => $prospectnum,
+               );
+    my $prospect_contact =  qsearchs('prospect_contact', \%hash )
+                         || new FS::prospect_contact { %hash, %link_hash };
+    my $error =
+      $prospect_contact->prospectcontactnum ? $prospect_contact->replace
+                                            : $prospect_contact->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
   }
 
   foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
@@ -134,12 +215,14 @@ sub insert {
     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
     my $phonetypenum = $1;
 
-    my $contact_phone = new FS::contact_phone {
-      'contactnum' => $self->contactnum,
-      'phonetypenum' => $phonetypenum,
-      _parse_phonestring( $self->get($pf) ),
-    };
-    $error = $contact_phone->insert;
+    my %hash = ( 'contactnum'   => $self->contactnum,
+                 'phonetypenum' => $phonetypenum,
+               );
+    my $contact_phone =
+      qsearchs('contact_phone', \%hash)
+        || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) };
+    my $error = $contact_phone->contactphonenum ? $contact_phone->replace
+                                                : $contact_phone->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
@@ -149,17 +232,18 @@ sub insert {
   if ( $self->get('emailaddress') =~ /\S/ ) {
 
     foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) {
- 
-      my $contact_email = new FS::contact_email {
+      my %hash = (
         'contactnum'   => $self->contactnum,
         'emailaddress' => $email,
-      };
-      $error = $contact_email->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
+      );
+      unless ( qsearchs('contact_email', \%hash) ) {
+        my $contact_email = new FS::contact_email \%hash;
+        my $error = $contact_email->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
       }
-
     }
 
   }
@@ -167,14 +251,17 @@ sub insert {
   unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) {
     #warn "  queueing fuzzyfiles update\n"
     #  if $DEBUG > 1;
-    $error = $self->queue_fuzzyfiles_update;
+    my $error = $self->queue_fuzzyfiles_update;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "updating fuzzy search cache: $error";
     }
   }
 
-  if ( $self->selfservice_access ) {
+  if (      $link_hash{'selfservice_access'} eq 'R'
+       or ( $link_hash{'selfservice_access'} && $cust_contact )
+     )
+  {
     my $error = $self->send_reset_email( queue=>1 );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -208,6 +295,44 @@ sub delete {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  #got a prospetnum or custnum? delete the prospect_contact or cust_contact link
+
+  if ( $self->prospectnum ) {
+    my $prospect_contact = qsearchs('prospect_contact', {
+                             'contactnum'  => $self->contactnum,
+                             'prospectnum' => $self->prospectnum,
+                           });
+    my $error = $prospect_contact->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  if ( $self->custnum ) {
+    my $cust_contact = qsearchs('cust_contact', {
+                         'contactnum'  => $self->contactnum,
+                         'custnum' => $self->custnum,
+                       });
+    my $error = $cust_contact->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  # then, proceed with deletion only if the contact isn't attached to any other
+  # prospects or customers
+
+  #inefficient, but how many prospects/customers can a single contact be
+  # attached too?  (and is removing them from one a common operation?)
+  if ( $self->prospect_contact || $self->cust_contact ) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  #proceed with deletion
+
   foreach my $cust_pkg ( $self->cust_pkg ) {
     $cust_pkg->contactnum('');
     my $error = $cust_pkg->replace;
@@ -262,13 +387,62 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  #save off and blank values that move to cust_contact / prospect_contact now
+  my $prospectnum = $self->prospectnum;
+  $self->prospectnum('');
+  my $custnum = $self->custnum;
+  $self->custnum('');
+
+  my %link_hash = ();
+  for (qw( classnum comment selfservice_access )) {
+    $link_hash{$_} = $self->get($_);
+    $self->$_('');
+  }
+
   my $error = $self->SUPER::replace($old);
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
-  foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) }
+  my $cust_contact = '';
+  if ( $custnum ) {
+    my %hash = ( 'contactnum' => $self->contactnum,
+                 'custnum'    => $custnum,
+               );
+    my $error;
+    if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) {
+      $cust_contact->$_($link_hash{$_}) for keys %link_hash;
+      $error = $cust_contact->replace;
+    } else {
+      $cust_contact = new FS::cust_contact { %hash, %link_hash };
+      $error = $cust_contact->insert;
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  if ( $prospectnum ) {
+    my %hash = ( 'contactnum'  => $self->contactnum,
+                 'prospectnum' => $prospectnum,
+               );
+    my $error;
+    if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) {
+      $prospect_contact->$_($link_hash{$_}) for keys %link_hash;
+      $error = $prospect_contact->replace;
+    } else {
+      my $prospect_contact = new FS::prospect_contact { %hash, %link_hash };
+      $error = $prospect_contact->insert;
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  foreach my $pf ( grep { /^phonetypenum(\d+)$/ }
                         keys %{ $self->hashref } ) {
     $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
     my $phonetypenum = $1;
@@ -276,8 +450,19 @@ sub replace {
     my %cp = ( 'contactnum'   => $self->contactnum,
                'phonetypenum' => $phonetypenum,
              );
-    my $contact_phone = qsearchs('contact_phone', \%cp)
-                        || new FS::contact_phone   \%cp;
+    my $contact_phone = qsearchs('contact_phone', \%cp);
+
+    #if new value is empty, delete old entry
+    if (!$self->get($pf)) {
+      if ($contact_phone) {
+        $error = $contact_phone->delete;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+      }
+      next;
+    }
 
     my %cpd = _parse_phonestring( $self->get($pf) );
     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
@@ -329,11 +514,14 @@ sub replace {
     }
   }
 
-  if (    ( $old->selfservice_access eq '' && $self->selfservice_access
-              && ! $self->_password
-          )
-       || $self->_resend()
-     )
+  if ( $cust_contact and (
+                              (      $cust_contact->selfservice_access eq ''
+                                  && $link_hash{selfservice_access}
+                                  && ! length($self->_password)
+                              )
+                           || $cust_contact->_resend()
+                         )
+    )
   {
     my $error = $self->send_reset_email( queue=>1 );
     if ( $error ) {
@@ -450,7 +638,6 @@ sub check {
   ;
   return $error if $error;
 
-  return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
 
   return "One of first name, last name, or title must have a value"
@@ -487,17 +674,35 @@ sub firstlast {
   $self->first . ' ' . $self->last;
 }
 
-=item contact_classname
-
-Returns the name of this contact's class (see L<FS::contact_class>).
-
-=cut
-
-sub contact_classname {
-  my $self = shift;
-  my $contact_class = $self->contact_class or return '';
-  $contact_class->classname;
-}
+#=item contact_classname PROSPECT_OBJ | CUST_MAIN_OBJ
+#
+#Returns the name of this contact's class for the specified prospect or
+#customer (see L<FS::prospect_contact>, L<FS::cust_contact> and
+#L<FS::contact_class>).
+#
+#=cut
+#
+#sub contact_classname {
+#  my( $self, $prospect_or_cust ) = @_;
+#
+#  my $link = '';
+#  if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) {
+#    $link = qsearchs('prospect_contact', {
+#              'contactnum'  => $self->contactnum,
+#              'prospectnum' => $prospect_or_cust->prospectnum,
+#            });
+#  } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) {
+#    $link = qsearchs('cust_contact', {
+#              'contactnum'  => $self->contactnum,
+#              'custnum'     => $prospect_or_cust->custnum,
+#            });
+#  } else {
+#    croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object";
+#  }
+#
+#  my $contact_class = $link->contact_class or return '';
+#  $contact_class->classname;
+#}
 
 =item by_selfservice_email EMAILADDRESS
 
@@ -514,8 +719,7 @@ sub by_selfservice_email {
     'table'     => 'contact_email',
     'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ',
     'hashref'   => { 'emailaddress' => $email, },
-    'extra_sql' => " AND selfservice_access = 'Y' ".
-                   " AND ( disabled IS NULL OR disabled = '' )",
+    'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )",
   }) or return '';
 
   $contact_email->contact;
@@ -616,10 +820,12 @@ sub send_reset_email {
 
   my $conf = new FS::Conf;
 
-  my $cust_main = $self->cust_main
-    or die "no customer"; #reset a password for a prospect contact?  someday
+  my $cust_main = '';
+  my @cust_contact = grep $_->selfservice_access, $self->cust_contact;
+  $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
 
-  my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum);
+  my $agentnum = $cust_main ? $cust_main->agentnum : '';
+  my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum);
   #die "selfservice-password_reset_msgnum unset" unless $msgnum;
   return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum;
   my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
@@ -634,7 +840,7 @@ sub send_reset_email {
 
     my $queue = new FS::queue {
       'job'     => 'FS::Misc::process_send_email',
-      'custnum' => $cust_main->custnum,
+      'custnum' => $cust_main ? $cust_main->custnum : '',
     };
     $queue->insert( $msg_template->prepare( %msg_template ) );
 
@@ -677,7 +883,21 @@ sub cgi_contact_fields {
 
 }
 
-use FS::phone_type;
+use FS::upgrade_journal;
+sub _upgrade_data { #class method
+  my ($class, %opts) = @_;
+
+  unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) {
+
+    foreach my $contact (qsearch('contact', {})) {
+      my $error = $contact->replace;
+      die $error if $error;
+    }
+
+    FS::upgrade_journal->set_done('contact__DUPEMAIL');
+  }
+
+}
 
 =back
 
diff --git a/FS/FS/cust_contact.pm b/FS/FS/cust_contact.pm
new file mode 100644
index 0000000..6f899d8
--- /dev/null
+++ b/FS/FS/cust_contact.pm
@@ -0,0 +1,146 @@
+package FS::cust_contact;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cust_contact - Object methods for cust_contact records
+
+=head1 SYNOPSIS
+
+  use FS::cust_contact;
+
+  $record = new FS::cust_contact \%hash;
+  $record = new FS::cust_contact { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_contact object represents a contact's attachment to a specific
+customer.  FS::cust_contact inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item custcontactnum
+
+primary key
+
+=item custnum
+
+custnum
+
+=item contactnum
+
+contactnum
+
+=item classnum
+
+classnum
+
+=item comment
+
+comment
+
+=item selfservice_access
+
+empty or Y
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'cust_contact'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  if ( $self->selfservice_access eq 'R' ) {
+    $self->selfservice_access('Y');
+    $self->_resend('Y');
+  }
+
+  my $error = 
+    $self->ut_numbern('custcontactnum')
+    || $self->ut_number('custnum')
+    || $self->ut_number('contactnum')
+    || $self->ut_numbern('classnum')
+    || $self->ut_textn('comment')
+    || $self->ut_enum('selfservice_access', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item contact_classname
+
+Returns the name of this contact's class (see L<FS::contact_class>).
+
+=cut
+
+sub contact_classname {
+  my $self = shift;
+  my $contact_class = $self->contact_class or return '';
+  $contact_class->classname;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::contact>, L<FS::cust_main>, L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index d6f1a31..cd675f9 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -71,7 +71,7 @@ use FS::agent_payment_gateway;
 use FS::banned_pay;
 use FS::cust_main_note;
 use FS::cust_attachment;
-use FS::contact;
+use FS::cust_contact;
 use FS::Locales;
 use FS::upgrade_journal;
 use FS::sales;
@@ -529,11 +529,23 @@ sub insert {
       return $error;
     }
 
-    my @contact = $prospect_main->contact;
+    foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
+      my $cust_contact = new FS::cust_contact {
+        'custnum' => $self->custnum,
+        map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment )
+      };
+      my $error =  $cust_contact->insert
+                || $prospect_contact->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
     my @cust_location = $prospect_main->cust_location;
     my @qual = $prospect_main->qual;
 
-    foreach my $r ( @contact, @cust_location, @qual ) {
+    foreach my $r ( @cust_location, @qual ) {
       $r->prospectnum('');
       $r->custnum($self->custnum);
       my $error = $r->replace;
@@ -1915,14 +1927,13 @@ sub cust_location {
 
 =item cust_contact
 
-Returns all contacts (see L<FS::contact>) for this customer.
+Returns all contact associations (see L<FS::cust_contact>) for this customer.
 
 =cut
 
-#already used :/ sub contact {
 sub cust_contact {
   my $self = shift;
-  qsearch('contact', { 'custnum' => $self->custnum } );
+  qsearch('cust_contact', { 'custnum' => $self->custnum } );
 }
 
 =item cust_payby
@@ -3656,9 +3667,11 @@ sub service_contact {
     my $classnum = $self->scalar_sql(
       'SELECT classnum FROM contact_class WHERE classname = \'Service\''
     ) || 0; #if it's zero, qsearchs will return nothing
-    $self->{service_contact} = qsearchs('contact', { 
-        'classnum' => $classnum, 'custnum' => $self->custnum
-      }) || undef;
+    my $cust_contact = qsearchs('cust_contact', { 
+        'classnum' => $classnum,
+        'custnum'  => $self->custnum,
+    });
+    $self->{service_contact} = $cust_contact->contact if $cust_contact;
   }
   $self->{service_contact};
 }
@@ -4614,6 +4627,42 @@ sub _agent_plandata {
 
 }
 
+sub process_o2m_qsearch {
+  my $self = shift;
+  my $table = shift;
+  return qsearch($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #4343';
+
+  qsearch({ 'table'     => 'contact',
+            'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+            'hashref'   => \%hash,
+            'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                           " cust_contact.custnum = $custnum "
+         });                
+}
+
+sub process_o2m_qsearchs {
+  my $self = shift;
+  my $table = shift;
+  return qsearchs($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #2121';
+
+  qsearchs({ 'table'     => 'contact',
+             'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+             'hashref'   => \%hash,
+             'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                            " cust_contact.custnum = $custnum "
+          });                
+}
+
 =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
 
 Subroutine (not a method), designed to be called from the queue.
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index f45fb2a..94d478f 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -278,16 +278,17 @@ A hash reference of additional substitutions
 sub prepare {
   my( $self, %opt ) = @_;
 
-  my $cust_main = $opt{'cust_main'} or die 'cust_main required';
+  my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
   my $object = $opt{'object'} or die 'object required';
 
   # localization
-  my $locale = $cust_main->locale || '';
+  my $locale = $cust_main && $cust_main->locale || '';
   warn "no locale for cust#".$cust_main->custnum."; using default content\n"
-    if $DEBUG and !$locale;
-  my $content = $self->content($cust_main->locale);
-  warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n"
-    if($DEBUG);
+    if $DEBUG and $cust_main && !$locale;
+  my $content = $self->content($locale);
+
+  warn "preparing template '".$self->msgname."\n"
+    if $DEBUG;
 
   my $subs = $self->substitutions;
 
@@ -295,7 +296,8 @@ sub prepare {
   # create substitution table
   ###  
   my %hash;
-  my @objects = ($cust_main);
+  my @objects = ();
+  push @objects, $cust_main if $cust_main;
   my @prefixes = ('');
   my $svc;
   if( ref $object ) {
@@ -385,20 +387,22 @@ sub prepare {
   my @to;
   if ( exists($opt{'to'}) ) {
     @to = split(/\s*,\s*/, $opt{'to'});
-  }
-  else {
+  } elsif ( $cust_main ) {
     @to = $cust_main->invoicing_list_emailonly;
+  } else {
+    die 'no To: address or cust_main object specified';
   }
-  # no warning when preparing with no destination
 
   my $from_addr = $self->from_addr;
 
   if ( !$from_addr ) {
+
+    my $agentnum = $cust_main ? $cust_main->agentnum : '';
+
     if ( $opt{'from_config'} ) {
-      $from_addr = scalar( $conf->config($opt{'from_config'}, 
-                                         $cust_main->agentnum) );
+      $from_addr = $conf->config($opt{'from_config'}, $agentnum);
     }
-    $from_addr ||= $conf->invoice_from_full($cust_main->agentnum);
+    $from_addr ||= $conf->invoice_from_full($agentnum);
   }
 #  my @cust_msg = ();
 #  if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
@@ -416,11 +420,11 @@ sub prepare {
                       ->format( HTML::TreeBuilder->new_from_content($body) )
                   );
   (
-    'custnum' => $cust_main->custnum,
-    'msgnum'  => $self->msgnum,
-    'from' => $from_addr,
-    'to'   => \@to,
-    'bcc'  => $self->bcc_addr || undef,
+    'custnum'   => ( $cust_main ? $cust_main->custnum : ''),
+    'msgnum'    => $self->msgnum,
+    'from'      => $from_addr,
+    'to'        => \@to,
+    'bcc'       => $self->bcc_addr || undef,
     'subject'   => $subject,
     'html_body' => $body,
     'text_body' => $text_body
diff --git a/FS/FS/o2m_Common.pm b/FS/FS/o2m_Common.pm
index 0e03b52..4848649 100644
--- a/FS/FS/o2m_Common.pm
+++ b/FS/FS/o2m_Common.pm
@@ -87,7 +87,7 @@ sub process_o2m {
 
   foreach my $del_obj (
     grep { ! $edits{$_->$table_pkey()} }
-         qsearch( $table, $hashref )
+         $self->process_o2m_qsearch( $table, $hashref )
   ) {
     my $error = $del_obj->delete;
     if ( $error ) {
@@ -97,7 +97,7 @@ sub process_o2m {
   }
 
   foreach my $pkey_value ( keys %edits ) {
-    my $old_obj = qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } ),
+    my $old_obj = $self->process_o2m_qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } );
     my $add_param = $edits{$pkey_value};
     my %hash = ( $table_pkey => $pkey_value,
                  map { $_ => $opt{'params'}->{$add_param."_$_"} }
@@ -131,6 +131,9 @@ sub process_o2m {
   '';
 }
 
+sub process_o2m_qsearch  { shift->qsearch( @_  ); }
+sub process_o2m_qsearchs { shift->qsearchs( @_ ); }
+
 sub _load_table {
   my( $self, $table ) = @_;
   eval "use FS::$table";
diff --git a/FS/FS/prospect_contact.pm b/FS/FS/prospect_contact.pm
new file mode 100644
index 0000000..6626132
--- /dev/null
+++ b/FS/FS/prospect_contact.pm
@@ -0,0 +1,125 @@
+package FS::prospect_contact;
+use base qw( FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::prospect_contact - Object methods for prospect_contact records
+
+=head1 SYNOPSIS
+
+  use FS::prospect_contact;
+
+  $record = new FS::prospect_contact \%hash;
+  $record = new FS::prospect_contact { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::prospect_contact object represents a contact's attachment to a specific
+prospect.  FS::prospect_contact inherits from FS::Record.  The following fields
+are currently supported:
+
+=over 4
+
+=item prospectcontactnum
+
+primary key
+
+=item prospectnum
+
+prospectnum
+
+=item contactnum
+
+contactnum
+
+=item classnum
+
+classnum
+
+=item comment
+
+comment
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'prospect_contact'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('prospectcontactnum')
+    || $self->ut_number('prospectnum')
+    || $self->ut_number('contactnum')
+    || $self->ut_numbern('classnum')
+    || $self->ut_textn('comment')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::contact>, L<FS::prospect_main>, L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/prospect_main.pm b/FS/FS/prospect_main.pm
index b160343..81f71a9 100644
--- a/FS/FS/prospect_main.pm
+++ b/FS/FS/prospect_main.pm
@@ -269,7 +269,7 @@ sub name {
   my $self = shift;
   return $self->company if $self->company;
 
-  my $contact = ($self->contact)[0]; #first contact?  good enough for now
+  my $contact = ($self->prospect_contact)[0]->contact; #first contact?  good enough for now
   return $contact->line if $contact;
 
   'Prospect #'. $self->prospectnum;
@@ -314,7 +314,7 @@ sub convert_cust_main {
   my @cust_location = $self->cust_location;
   #the interface only allows one, so we're just gonna go with that for now
 
-  my @contact = $self->contact;
+  my @contact = map $_->contact, $self->prospect_contact;
 
   #XXX define one contact type as "billing", then we could pick just that one
   my @invoicing_list = map $_->emailaddress, map $_->contact_email, @contact;
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 6e36c33..e5e29b4 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -834,3 +834,7 @@ FS/svc_circuit.pm
 t/svc_circuit.t
 FS/cust_credit_source_bill_pkg.pm
 t/cust_credit_source_bill_pkg.t
+FS/prospect_contact.pm
+t/prospect_contact.t
+FS/cust_contact.pm
+t/cust_contact.t
diff --git a/FS/t/cust_contact.t b/FS/t/cust_contact.t
new file mode 100644
index 0000000..0e9ea71
--- /dev/null
+++ b/FS/t/cust_contact.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_contact;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/prospect_contact.t b/FS/t/prospect_contact.t
new file mode 100644
index 0000000..dbb12e5
--- /dev/null
+++ b/FS/t/prospect_contact.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::prospect_contact;
+$loaded=1;
+print "ok 1\n";
diff --git a/fs_selfservice/FS-SelfService/SelfService.pm b/fs_selfservice/FS-SelfService/SelfService.pm
index f54a157..3aa60a0 100644
--- a/fs_selfservice/FS-SelfService/SelfService.pm
+++ b/fs_selfservice/FS-SelfService/SelfService.pm
@@ -30,6 +30,7 @@ $socket .= '.'.$tag if defined $tag && length($tag);
   'login'                     => 'MyAccount/login',
   'logout'                    => 'MyAccount/logout',
   'switch_acct'               => 'MyAccount/switch_acct',
+  'switch_cust'               => 'MyAccount/switch_cust',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
   'billing_history'           => 'MyAccount/billing_history',
diff --git a/fs_selfservice/FS-SelfService/cgi/select_cust.html b/fs_selfservice/FS-SelfService/cgi/select_cust.html
new file mode 100644
index 0000000..7ab55db
--- /dev/null
+++ b/fs_selfservice/FS-SelfService/cgi/select_cust.html
@@ -0,0 +1,38 @@
+<HTML>
+  <HEAD>
+    <TITLE>Select customer</TITLE>
+    <%= $head %>
+  </HEAD>
+  <BODY BGCOLOR="<%= $body_bgcolor || '#eeeeee' %>">
+  <%= $body_header %>
+
+<FONT SIZE=5>Select customer</FONT><BR><BR>
+<FONT SIZE="+1" COLOR="#ff0000"><%= $error %></FONT>
+
+<%= $selfurl =~ s/\?.*//; ''; %>
+<FORM ACTION="<%= $selfurl %>" METHOD=POST>
+<INPUT TYPE="hidden" NAME="action" VALUE="switch_cust">
+<INPUT TYPE="hidden" NAME="agentnum" VALUE="<%= $agentnum %>">
+
+<TABLE BGCOLOR="<%= $box_bgcolor || '#c0c0c0' %>" BORDER=0 CELLSPACING=2 CELLPADDING=0>
+
+  <TR>
+    <TH ALIGN="right">Customer </TH>
+    <TD>
+      <SELECT NAME="custnum">
+        <OPTION VALUE="">Select a customer
+<%=     $OUT .= qq(<OPTION VALUE="$_">). encode_entities( $customers{$_} )
+          foreach keys %customers;
+%>
+      </SELECT>
+    </TD>
+  </TR>
+
+  <TR>
+    <TD COLSPAN=2 ALIGN="center"><INPUT TYPE="submit" VALUE="Select customer"></TD>
+  </TR>
+
+</TABLE>
+</FORM>
+
+<%= $body_footer %>
diff --git a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
index 9443a7d..2337fb5 100755
--- a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
+++ b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
@@ -81,6 +81,7 @@ my @actions = ( qw(
   process_change_password
   customer_suspend_pkg
   process_suspend_pkg
+  switch_cust
 ));
 
 my @nologin_actions = (qw(
@@ -204,6 +205,12 @@ unless ( $nologin_actions{$action} ) {
 
   # at this point $session_id is a real session
 
+  if ( ! $login_rv->{'custnum'} && ! $login_rv->{'svcnum'} && $login_rv->{'customers'} ) {
+    #select a customer if we're a multi-contact customer
+    do_template('select_cust', { %$login_rv } );
+    exit;
+  }
+
 }
 
 warn "calling $action sub\n"
@@ -212,6 +219,7 @@ $FS::SelfService::DEBUG = $DEBUG;
 my $result = eval "&$action();";
 die $@ if $@;
 
+use Data::Dumper;
 warn Dumper($result) if $DEBUG;
 
 if ( $result->{error} && ( $result->{error} eq "Can't resume session"
@@ -237,7 +245,13 @@ do_template($action, {
 
 #--
 
-use Data::Dumper;
+sub switch_cust {
+  $action = 'myaccount';
+  FS::SelfService::switch_cust( 'session_id' => $session_id,
+                                'custnum'    => scalar($cgi->param('custnum')),
+                              );
+}
+
 sub myaccount { 
   customer_info( 'session_id' => $session_id ); 
 }
diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi
index 353ae17..da87bfc 100755
--- a/httemplate/edit/cust_main.cgi
+++ b/httemplate/edit/cust_main.cgi
@@ -325,8 +325,8 @@ if ( $cgi->param('error') ) {
     $cust_main->company(  $prospect_main->company  );
 
     #first contact? -> name
-    my @contacts = $prospect_main->contact;
-    my $contact = $contacts[0];
+    my @prospect_contacts = $prospect_main->prospect_contact;
+    my $contact = $prospect_contacts[0]->contact;
     $cust_main->first( $contact->first );
     $cust_main->set( 'last', $contact->get('last') );
     #contact phone numbers?
diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html
index 9e506a7..4d5beee 100644
--- a/httemplate/edit/elements/edit.html
+++ b/httemplate/edit/elements/edit.html
@@ -334,6 +334,10 @@ Example:
 %     #any?
 %     'colspan'       => $f->{'colspan'},
 %     'required'      => $f->{'required'},
+%
+%     #contact
+%     'custnum'     => $f->{'custnum'},
+%     'prospectnum' => $f->{'prospectnum'},
 %   );
 %
 %   $include_common{$_} = $f->{$_} foreach grep exists($f->{$_}),
diff --git a/httemplate/elements/contact.html b/httemplate/elements/contact.html
index 979c26b..ef74481 100644
--- a/httemplate/elements/contact.html
+++ b/httemplate/elements/contact.html
@@ -9,7 +9,7 @@
           <SELECT NAME="<%$name%>_classnum" <% $onchange %>>
             <OPTION VALUE="">
 %           my $classnum = scalar($cgi->param($name.'_classnum'))
-%                            || $contact->classnum;
+%                            || $X_contact->classnum;
 %           foreach my $contact_class (@contact_class) {
               <OPTION VALUE="<% $contact_class->classnum %>"
                  <% ($contact_class->classnum == $classnum) ? 'SELECTED' : '' %>
@@ -40,6 +40,8 @@
 %         }
 %       } elsif ( $field eq 'emailaddress' ) {
 %         $value = join(', ', map $_->emailaddress, $contact->contact_email);
+%       } elsif ( $field eq 'selfservice_access' || $field eq 'comment' ) {
+%         $value = $X_contact->get($field);
 %       } else {
 %         $value = $contact->get($field);
 %       }
@@ -100,10 +102,25 @@ if ( $opt{'onchange'} ) {
 my @contact_class = qsearch('contact_class', { 'disabled' => '' });
 
 my $contact;
+my $X_contact;
 if ( $curr_value ) {
   $contact = qsearchs('contact', { 'contactnum' => $curr_value } );
+  if ( $opt{'custnum'} ) {
+    $X_contact = qsearchs('cust_contact', {
+                            'contactnum' => $curr_value,
+                            'custnum'    => $opt{'custnum'},
+                 });
+  } elsif ( $opt{'prospectnum'} ) {
+    $X_contact = qsearchs('prospect_contact', {
+                   'contactnum'  => $curr_value,
+                   'prospectnum' => $opt{'prospectnum'},
+                 });
+  } else {
+    die 'neither custnum nor prospectnum specified';
+  }
 } else {
   $contact = new FS::contact {};
+  $X_contact = new FS::cust_contact; #arbitrary, it could be prospect_contact
 }
 
 my %size = ( 'title' => 12 );
diff --git a/httemplate/elements/tr-select-contact.html b/httemplate/elements/tr-select-contact.html
index e37d26d..0bfa893 100644
--- a/httemplate/elements/tr-select-contact.html
+++ b/httemplate/elements/tr-select-contact.html
@@ -138,8 +138,8 @@ if ( $cgi->param('error') ) {
   if ( length($opt{'curr_value'}) ) {
     $contactnum = $opt{'curr_value'};
   } elsif ($prospect_main) {
-    my @cust_contact = $prospect_main->contact;
-    $contactnum = $cust_contact[0]->contactnum if scalar(@cust_contact)==1;
+    my @prospect_contact = $prospect_main->prospect_contact;
+    $contactnum = $prospect_contact[0]->contactnum if scalar(@cust_contact)==1;
   } else { #$cust_main
     $cgi->param('contactnum') =~ /^(\-?\d*)$/ or die "illegal contactnum";
     $contactnum = $1;
@@ -176,8 +176,10 @@ my $contact_sort = sub {
 };
 
 my @contact;
-push @contact, $cust_main->cust_contact if $cust_main;
-push @contact, $prospect_main->contact if $prospect_main;
+push @contact, map $_->contact, $cust_main->cust_contact
+  if $cust_main;
+push @contact, map $_->contact, $prospect_main->prospect_contact
+  if $prospect_main;
 push @contact, $contact
   if !$cust_main && $contact && $contact->contactnum > 0
   && ! grep { $_->contactnum == $contact->contactnum } @contact;
diff --git a/httemplate/misc/email-quotation.html b/httemplate/misc/email-quotation.html
index b93b80b..64e3691 100644
--- a/httemplate/misc/email-quotation.html
+++ b/httemplate/misc/email-quotation.html
@@ -16,9 +16,11 @@
 %   }
 % }
 
-% my @contact = $quotation->custnum ? $quotation->cust_main->cust_contact
-%                                   : $quotation->prospect_main->contact;
-% foreach my $contact ( @contact ) {
+% my @X_contact = $quotation->custnum
+%                   ? $quotation->cust_main->cust_contact
+%                   : $quotation->prospect_main->prospect_contact;
+% foreach my $X_contact ( @X_contact ) {
+%    my $contact = $X_contact->contact;
 %    foreach my $contact_email ( $contact->contact_email ) {
 %      $emails++;
        <& .emailrow, $contact_email->emailaddress, $contact->firstlast &>
diff --git a/httemplate/search/contact.html b/httemplate/search/contact.html
index 6503078..c3667df 100644
--- a/httemplate/search/contact.html
+++ b/httemplate/search/contact.html
@@ -1,13 +1,13 @@
 <& elements/search.html,
   title         => 'Contacts',
   name_singular => 'contact',
-  query         => { select    => $select,
+  query         => { select    => join(', ', @select),
                      table     => 'contact',
                      addl_from => $addl_from,
                      hashref   => \%hash,
                      extra_sql => $extra_sql,
                    },
-  count_query   => "SELECT COUNT(*) FROM contact $extra_sql", #XXX
+  count_query   => "SELECT COUNT(*) FROM contact $addl_from $extra_sql", #XXX
   header        => \@header,
   fields        => \@fields,
   links         => \@links,
@@ -17,13 +17,29 @@
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('List contacts');
 
-my $select = 'contact.*';
+my @select = 'contact.contactnum AS contact_contactnum'; #if we select it as bare contactnum, the multi-customer listings go away
+push @select, map "contact.$_", qw( first last title );
 my %hash = ();
 my $addl_from = '';
 
-my @header = ( 'First', 'Last', 'Title', );
-my @fields = ( 'first', 'last', 'title', );
-my @links = ( '', '', '' );
+my $link; #for closure in this sub, we'll define it later
+my $contact_classname_sub = sub {
+  my $contact = shift;
+  my %hash = ( 'contactnum' => $contact->contact_contactnum );
+  my $X_contact;
+  if ( $link eq 'cust_main' ) {
+    $X_contact = qsearchs('cust_contact', { %hash, 'custnum' => $contact->custnum } );
+  } elsif ( $link eq 'prospect_main' ) {
+    $X_contact = qsearchs('prospect_contact', { %hash, 'prospectnum' => $contact->prospectnum } );
+  } else {
+    die 'guru meditation #5555';
+  }
+  $X_contact->contact_classname;
+};
+
+my @header = ( 'First', 'Last', 'Title', 'Type' );
+my @fields = ( 'first', 'last', 'title', $contact_classname_sub );
+my @links = ( '', '', '', '', );
 
 my $company_link = '';
 
@@ -32,23 +48,30 @@ if ( $cgi->param('selfservice_access') eq 'Y' ) {
 }
 
 my $extra_sql = '';
-if ( $cgi->param('link') ) {
+$link = $cgi->param('link');
+if ( $link ) {
 
   my $as       = ') AS prospect_or_customer';
 
-  if ( $cgi->param('link') eq 'cust_main' ) {
+  if ( $link eq 'cust_main' ) {
     push @header, 'Customer';
-    $select .=
-      ", COALESCE( cust_main.company, cust_main.first||' '||cust_main.last $as";
-    $addl_from = ' LEFT JOIN cust_main USING ( custnum )';
-    $extra_sql = ' custnum IS NOT NULL ';
+    push @select,
+       "COALESCE( cust_main.company, cust_main.first||' '||cust_main.last $as",
+       map "cust_contact.$_", qw( custnum classnum comment selfservice_access );
+    $addl_from =
+      ' LEFT JOIN cust_contact USING ( contactnum ) '.
+      ' LEFT JOIN cust_main ON ( cust_contact.custnum = cust_main.custnum )';
+    $extra_sql = ' cust_contact.custnum IS NOT NULL ';
     $company_link  = [ $p.'view/cust_main.cgi?', 'custnum' ];
-  } elsif ( $cgi->param('link') eq 'prospect_main' ) {
+  } elsif ( $link eq 'prospect_main' ) {
     push @header, 'Prospect';
-    $select .=
-     ", COALESCE( prospect_main.company, contact.first||'  '||contact.last $as";
-    $addl_from = ' LEFT JOIN prospect_main USING ( prospectnum )';
-    $extra_sql = ' prospectnum IS NOT NULL ';
+    push @select,
+      "COALESCE( prospect_main.company, contact.first||'  '||contact.last $as",
+      map "prospect_contact.$_", qw( prospectnum classnum comment );
+    $addl_from =
+      ' LEFT JOIN prospect_contact USING ( contactnum ) '.
+      ' LEFT JOIN prospect_main ON ( prospect_contact.prospectnum = prospect_main.prospectnum )';
+    $extra_sql = ' prospect_contact.prospectnum IS NOT NULL ';
     $company_link  = [ $p.'view/prospect_main.html?', 'prospectnum' ];
   } else {
     die "don't know how to report on contacts linked to specified table";
@@ -63,6 +86,9 @@ if ( $cgi->param('link') ) {
 push @header, 'Self-service';
 push @fields, 'selfservice_access';
 
+push @header, 'Comment';
+push @fields, 'comment';
+
 $extra_sql = (keys(%hash) ? ' AND ' : ' WHERE '). $extra_sql
  if $extra_sql;
 
diff --git a/httemplate/search/prospect_main.html b/httemplate/search/prospect_main.html
index 4798f58..241918b 100644
--- a/httemplate/search/prospect_main.html
+++ b/httemplate/search/prospect_main.html
@@ -12,9 +12,9 @@
                                   sub {
                                     my $pm = shift;
                                     [ map {
-                                            [ { 'data' => $_->line, }, ];
+                                            [ { 'data'=>$_->contact->line, }, ];
                                           }
-                                          $pm->contact
+                                          $pm->prospect_contact
                                     ];
                                   },
                                 ],
diff --git a/httemplate/view/cust_main/contacts_new.html b/httemplate/view/cust_main/contacts_new.html
index f73483a..f0bc0b8 100644
--- a/httemplate/view/cust_main/contacts_new.html
+++ b/httemplate/view/cust_main/contacts_new.html
@@ -6,26 +6,31 @@
 % my $bgcolor1 = '#eeeeee';
 %     my $bgcolor2 = '#ffffff';
 %     my $bgcolor = $bgcolor2;
+% my $th = '<TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc">';
 <TR>
-  <TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc">Type</TH>
-  <TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc">Contact</TH>
-  <TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc">Email</TH>
-  <TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc">Self-service</TH>
+  <%$th%>Type</TH>
+  <%$th%>Contact</TH>
+  <%$th%>Email</TH>
+  <%$th%>Self-service</TH>
 % foreach my $phone_type (@phone_type) {
-    <TH CLASS="grid" ALIGN="left" BGCOLOR="#cccccc"><% $phone_type->typename |h %> phone</TD>
+    <%$th%><% $phone_type->typename |h %></TH>
 % }
+  <%$th%>Comment</TH>
 </TR>
 
-%   foreach my $contact ( @contacts ) {
+%   foreach my $cust_contact ( @cust_contacts ) {
+%     my $contact = $cust_contact->contact;
+%     my $td = qq(<TD CLASS="grid" BGCOLOR="$bgcolor">);
+
       <TR>
-        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $contact->contact_classname |h %></TD>
-        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $contact->line |h %></TD>
+        <%$td%><% $cust_contact->contact_classname |h %></TD>
+        <%$td%><% $contact->line |h %></TD>
 
 %       my @contact_email = $contact->contact_email;
-        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% join(', ', map $_->emailaddress, @contact_email) %></TD>
+        <%$td%><% join(', ', map $_->emailaddress, @contact_email) %></TD>
 
-        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
-%         if ( $contact->selfservice_access ) {
+        <%$td%>
+%         if ( $cust_contact->selfservice_access ) {
             Enabled
 %#            <FONT SIZE="-1"><A HREF="XXX">disable</A>
 %#                            <A HREF="XXX">re-email</A></FONT>
@@ -41,9 +46,11 @@
 %                      'contactnum'   => $contact->contactnum,
 %                      'phonetypenum' => $phone_type->phonetypenum,
 %                   });
-          <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $contact_phone ? $contact_phone->phonenum_pretty : '' |h %></TD>
+          <%$td%><% $contact_phone ? $contact_phone->phonenum_pretty : '' |h %></TD>
 %       }
 
+        <%$td%><% $cust_contact->comment |h %></TD>
+
       </TR>
 
 %     if ( $bgcolor eq $bgcolor1 ) {
@@ -63,6 +70,6 @@ my @phone_type = qsearch({table=>'phone_type', order_by=>'weight'});
 my( $cust_main ) = @_;
 #my $conf = new FS::Conf;
 
-my @contacts = $cust_main->cust_contact;
+my @cust_contacts = $cust_main->cust_contact;
 
 </%init>
diff --git a/httemplate/view/prospect_main.html b/httemplate/view/prospect_main.html
index 66abffc..a1f14a3 100644
--- a/httemplate/view/prospect_main.html
+++ b/httemplate/view/prospect_main.html
@@ -39,9 +39,10 @@
   </TR>
 % }
 
-% foreach my $contact ( $prospect_main->contact ) {
+% foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
+%   my $contact = $prospect_contact->contact;
     <TR>
-      <TD ALIGN="right"><% $contact->contact_classname %> Contact</TD>
+      <TD ALIGN="right"><% $prospect_contact->contact_classname %> Contact</TD>
       <TD BGCOLOR="#FFFFFF"><% $contact->line %></TD>
     </TR>
 %}

commit 475ae93877f1d834941f7b9adcc35ee84c5c22fa
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Mon Feb 2 22:10:44 2015 -0800

    fix prospect contact search

diff --git a/httemplate/search/contact.html b/httemplate/search/contact.html
index 1933493..6503078 100644
--- a/httemplate/search/contact.html
+++ b/httemplate/search/contact.html
@@ -34,18 +34,19 @@ if ( $cgi->param('selfservice_access') eq 'Y' ) {
 my $extra_sql = '';
 if ( $cgi->param('link') ) {
 
-  my $coalesce = ', COALESCE( cust_main.company,';
   my $as       = ') AS prospect_or_customer';
 
   if ( $cgi->param('link') eq 'cust_main' ) {
     push @header, 'Customer';
-    $select .= "$coalesce cust_main.first||' '||cust_main.last $as";
+    $select .=
+      ", COALESCE( cust_main.company, cust_main.first||' '||cust_main.last $as";
     $addl_from = ' LEFT JOIN cust_main USING ( custnum )';
     $extra_sql = ' custnum IS NOT NULL ';
     $company_link  = [ $p.'view/cust_main.cgi?', 'custnum' ];
   } elsif ( $cgi->param('link') eq 'prospect_main' ) {
     push @header, 'Prospect';
-    $select .= "$coalesce contact.first||'  '||contact.last $as";
+    $select .=
+     ", COALESCE( prospect_main.company, contact.first||'  '||contact.last $as";
     $addl_from = ' LEFT JOIN prospect_main USING ( prospectnum )';
     $extra_sql = ' prospectnum IS NOT NULL ';
     $company_link  = [ $p.'view/prospect_main.html?', 'prospectnum' ];

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/ClientAPI/MyAccount.pm                       |   70 ++++-
 FS/FS/ClientAPI_XMLRPC.pm                          |    1 +
 FS/FS/Conf.pm                                      |    2 +-
 FS/FS/Daemon/Preforking.pm                         |    1 +
 FS/FS/Mason.pm                                     |    3 +
 FS/FS/Record.pm                                    |    1 +
 FS/FS/Schema.pm                                    |   59 +++-
 FS/FS/Upgrade.pm                                   |    3 +
 FS/FS/contact.pm                                   |  299 +++++++++++++++++---
 FS/FS/{cust_tax_adjustment.pm => cust_contact.pm}  |   73 ++---
 FS/FS/cust_main.pm                                 |   67 ++++-
 FS/FS/msg_template.pm                              |   40 +--
 FS/FS/o2m_Common.pm                                |    7 +-
 .../part_event/Condition/cust_bill_has_service.pm  |    4 +-
 FS/FS/part_event/Condition/has_cust_tag.pm         |    1 -
 FS/FS/part_export/amazon_ec2.pm                    |   35 ++-
 FS/FS/part_export/cardfortress.pm                  |    2 +
 FS/FS/phone_avail.pm                               |    4 +-
 .../{clientapi_session.pm => prospect_contact.pm}  |   63 +++--
 FS/FS/prospect_main.pm                             |    4 +-
 FS/MANIFEST                                        |    4 +
 FS/bin/freeside-cdrd                               |    8 +-
 FS/t/{AccessRight.t => cust_contact.t}             |    2 +-
 FS/t/{AccessRight.t => prospect_contact.t}         |    2 +-
 bin/cust_bill-credit_ship2                         |   55 ++--
 eg/table_template.pm                               |    2 +-
 fs_selfservice/DEPLOY                              |    3 +-
 fs_selfservice/FS-SelfService/SelfService.pm       |    1 +
 fs_selfservice/FS-SelfService/cgi/select_cust.html |   38 +++
 fs_selfservice/FS-SelfService/cgi/selfservice.cgi  |   16 +-
 httemplate/browse/discount.html                    |    3 +-
 httemplate/docs/about.html                         |    2 +-
 httemplate/edit/cust_main-contacts.html            |    1 +
 httemplate/edit/cust_main.cgi                      |    4 +-
 httemplate/edit/elements/edit.html                 |    4 +
 httemplate/elements/contact.html                   |   19 +-
 httemplate/elements/popup_link.html                |    7 +-
 httemplate/elements/tr-fixed.html                  |    6 +-
 httemplate/elements/tr-select-contact.html         |   10 +-
 httemplate/elements/tr-select-cust_location.html   |    2 +
 httemplate/misc/email-quotation.html               |    8 +-
 httemplate/search/contact.html                     |   59 ++--
 httemplate/search/cust_msg.html                    |    4 +-
 httemplate/search/prospect_main.html               |    4 +-
 httemplate/view/cust_main/contacts_new.html        |   33 ++-
 httemplate/view/prospect_main.html                 |    5 +-
 46 files changed, 777 insertions(+), 264 deletions(-)
 copy FS/FS/{cust_tax_adjustment.pm => cust_contact.pm} (56%)
 copy FS/FS/{clientapi_session.pm => prospect_contact.pm} (59%)
 copy FS/t/{AccessRight.t => cust_contact.t} (82%)
 copy FS/t/{AccessRight.t => prospect_contact.t} (79%)
 create mode 100644 fs_selfservice/FS-SelfService/cgi/select_cust.html




More information about the freeside-commits mailing list