Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorAlex Brelsfoard <alex@freeside.biz>
Tue, 10 Feb 2015 05:11:07 +0000 (00:11 -0500)
committerAlex Brelsfoard <alex@freeside.biz>
Tue, 10 Feb 2015 05:11:07 +0000 (00:11 -0500)
80 files changed:
FS/FS/API.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI_XMLRPC.pm
FS/FS/Conf.pm
FS/FS/Daemon/Preforking.pm
FS/FS/Mason.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/Template_Mixin.pm
FS/FS/Upgrade.pm
FS/FS/contact.pm
FS/FS/cust_bill.pm
FS/FS/cust_bill_pkg_tax_location.pm
FS/FS/cust_contact.pm [new file with mode: 0644]
FS/FS/cust_main.pm
FS/FS/cust_main/API.pm
FS/FS/cust_pkg_discount.pm
FS/FS/msg_template.pm
FS/FS/o2m_Common.pm
FS/FS/part_event/Action/pkg_discount.pm [new file with mode: 0644]
FS/FS/part_event/Condition/cust_bill_has_service.pm
FS/FS/part_event/Condition/has_cust_tag.pm
FS/FS/part_event/Condition/has_referral_custnum.pm
FS/FS/part_event/Condition/has_referral_pkgpart.pm
FS/FS/part_event/Condition/nopostal.pm [new file with mode: 0644]
FS/FS/part_export/amazon_ec2.pm
FS/FS/part_export/cardfortress.pm
FS/FS/part_fee.pm
FS/FS/part_svc.pm
FS/FS/phone_avail.pm
FS/FS/pkg_category.pm
FS/FS/pkg_discount_Mixin.pm [new file with mode: 0644]
FS/FS/prospect_contact.pm [new file with mode: 0644]
FS/FS/prospect_main.pm
FS/FS/quotation.pm
FS/FS/quotation_pkg.pm
FS/FS/quotation_pkg_discount.pm
FS/FS/svc_phone.pm
FS/MANIFEST
FS/bin/freeside-cdrd
FS/t/cust_contact.t [new file with mode: 0644]
FS/t/pkg_discount_Mixin.t [new file with mode: 0644]
FS/t/prospect_contact.t [new file with mode: 0644]
bin/cust_bill-credit_ship2
conf/invoice_html
conf/invoice_latex
eg/table_template.pm
fs_selfservice/DEPLOY
fs_selfservice/FS-SelfService/SelfService.pm
fs_selfservice/FS-SelfService/cgi/select_cust.html [new file with mode: 0644]
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
httemplate/browse/discount.html
httemplate/docs/about.html
httemplate/docs/credits.html
httemplate/edit/cust_main-contacts.html
httemplate/edit/cust_main.cgi
httemplate/edit/cust_main/contacts_new.html
httemplate/edit/elements/edit.html
httemplate/edit/elements/part_svc_column.html
httemplate/edit/quick-charge.html
httemplate/elements/change_history_common.html
httemplate/elements/contact.html
httemplate/elements/masked_input_1.1.js [deleted file]
httemplate/elements/masked_input_1.3.js [new file with mode: 0644]
httemplate/elements/popup_link.html
httemplate/elements/selectlayers.html
httemplate/elements/tr-fixed.html
httemplate/elements/tr-input-mask.html
httemplate/elements/tr-select-contact.html
httemplate/elements/tr-select-cust_location.html
httemplate/misc/email-quotation.html
httemplate/search/contact.html
httemplate/search/cust_msg.html
httemplate/search/prospect_main.html
httemplate/view/cust_main/contacts_new.html
httemplate/view/prospect_main.html
init.d/freeside-init
ng_selfservice/index.php
ng_selfservice/process_login.php
ng_selfservice/process_select_cust.php [new file with mode: 0644]

index 629463c..dd172c1 100644 (file)
@@ -36,9 +36,10 @@ in plaintext.
 
 =over 4
 
-=item insert_payment
+=item insert_payment OPTION => VALUE, ...
 
-Adds a new payment to a customers account. Takes a hash reference as parameter with the following keys:
+Adds a new payment to a customers account. Takes a list of keys and values as
+paramters with the following keys:
 
 =over 5
 
@@ -60,9 +61,10 @@ Amount paid
 
 =item _date
 
-
 Option date for payment
 
+=back
+
 Example:
 
   my $result = FS::API->insert_payment(
@@ -82,8 +84,6 @@ Example:
     print "paynum ". $result->{'paynum'};
   }
 
-=back
-
 =cut
 
 #enter cash payment
@@ -133,9 +133,10 @@ sub _by_phonenum {
 
 }
 
-=item insert_credit
+=item insert_credit OPTION => VALUE, ...
 
-Adds a a credit to a customers account. Takes a hash reference as parameter with the following keys
+Adds a a credit to a customers account.  Takes a list of keys and values as
+parameters with the following keys
 
 =over 
 
@@ -155,6 +156,8 @@ Amount of the credit
 
 The date the credit will be posted
 
+=back
+
 Example:
 
   my $result = FS::API->insert_credit(
@@ -173,8 +176,6 @@ Example:
     print "crednum ". $result->{'crednum'};
   }
 
-=back
-
 =cut
 
 #Enter credit
@@ -206,9 +207,10 @@ sub insert_credit_phonenum {
 
 }
 
-=item insert_refund
+=item insert_refund OPTION => VALUE, ...
 
-Adds a a credit to a customers account. Takes a hash reference as parameter with the following keys: custnum,payby,refund
+Adds a a credit to a customers account.  Takes a list of keys and values as
+parmeters with the following keys: custnum, payby, refund
 
 Example:
 
@@ -270,9 +272,10 @@ sub insert_refund_phonenum {
 
 # long-term: package changes?
 
-=item new_customer
+=item new_customer OPTION => VALUE, ...
 
-Creates a new customer. Takes a hash reference as parameter with the following keys:
+Creates a new customer. Takes a list of keys and values as parameters with the
+following keys:
 
 =over 4
 
@@ -402,6 +405,7 @@ Agent specific customer number
 
 Referring customer number
 
+=back
 
 =cut
 
@@ -425,35 +429,39 @@ sub new_customer {
   $class->API_insert( %opt );
 }
 
-=back 
-
 =item update_customer
 
-Updates an existing customer. Takes a hash reference as parameter with the foll$
+Updates an existing customer. Passing an empty value clears that field, while
+NOT passing that key/value at all leaves it alone. Takes a list of keys and
+values as parameters with the following keys:
 
 =over 4
 
 =item secret
 
-API Secret
+API Secret (required)
+
+=item custnum
+
+Customer number (required)
 
 =item first
 
-first name (required)
+first name 
 
 =item last
 
-last name (required)
+last name 
 
 =item company
 
 Company name
 
-=item address1 (required)
+=item address1 
 
 Address line one
 
-=item city (required)
+=item city 
 
 City
 
@@ -461,11 +469,11 @@ City
 
 County
 
-=item state (required)
+=item state 
 
 State
 
-=item zip (required)
+=item zip 
 
 Zip or postal code
 
@@ -491,7 +499,9 @@ Mobile number
 
 =item invoicing_list
 
-comma-separated list of email addresses for email invoices. The special value '$
+Comma-separated list of email addresses for email invoices. The special value 
+'POST' is used to designate postal invoicing (it may be specified alone or in
+addition to email addresses),
 postal_invoicing
 Set to 1 to enable postal invoicing
 
@@ -501,7 +511,8 @@ CARD, DCRD, CHEK, DCHK, LECB, BILL, COMP or PREPAY
 
 =item payinfo
 
-Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid "pi$
+Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid 
+"pin" for PREPAY, purchase order number for BILL
 
 =item paycvv
 
@@ -520,13 +531,17 @@ Exact name on credit card for CARD/DCRD, bank name for CHEK/DCHK
 Referring customer number
 
 =item salesnum
+
 Sales person number
 
 =item agentnum
 
 Agent number
 
+=back
+
 =cut
+
 sub update_customer {
   my( $class, %opt ) = @_;
 
@@ -537,12 +552,10 @@ sub update_customer {
   FS::cust_main->API_update( %opt );
 }
 
-=back
-
+=item customer_info OPTION => VALUE, ...
 
-=item customer_info
-
-Returns general customer information. Takes a hash reference as parameter with the following keys: custnum and API secret 
+Returns general customer information. Takes a list of keys and values as
+parameters with the following keys: custnum, secret 
 
 =cut
 
@@ -560,9 +573,8 @@ sub customer_info {
 
 =item location_info
 
-Returns location specific information for the customer. Takes a hash reference as parameter with the following keys: custnum,secret
-
-=back
+Returns location specific information for the customer. Takes a list of keys
+and values as paramters with the following keys: custnum, secret
 
 =cut
 
@@ -586,6 +598,36 @@ sub location_info {
   return \%return;
 }
 
+=item bill_now OPTION => VALUE, ...
+
+Bills a single customer now, in the same fashion as the "Bill now" link in the
+UI.
+
+Returns a hash reference with a single key, 'error'.  If there is an error,
+the value contains the error, otherwise it is empty.
+
+=cut
+
+sub bill_now {
+  my( $class, %opt ) = @_;
+  my $conf = new FS::Conf;
+  return { 'error' => 'Incorrect shared secret' }
+    unless $opt{secret} eq $conf->config('api_shared_secret');
+
+  my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
+    or return { 'error' => 'Unknown custnum' };
+
+  my $error = $cust_main->bill_and_collect( 'fatal'      => 'return',
+                                            'retry'      => 1,
+                                            'check_freq' =>'1d',
+                                          );
+
+   return { 'error' => $error,
+          };
+
+}
+
+
 #Advertising sources?
 
 
index 8276d7e..86c7ac3 100644 (file)
@@ -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);
 
   }
index 62f61d6..952b199 100644 (file)
@@ -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',
index 9ba3f09..a1faecf 100644 (file)
@@ -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.',
                      ],
   },
 
index 98b4fa6..f3a39a6 100644 (file)
@@ -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();
index d3e45df..37e3ad2 100644 (file)
@@ -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 ) {
index f8282c0..92fb896 100644 (file)
@@ -876,6 +876,7 @@ sub qsearchs { # $result_record = &FS::Record:qsearchs('table',\%hash);
   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]) : ();
index d5ed1b7..133b6d8 100644 (file)
@@ -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, '', '', 
index e26592c..95d001e 100644 (file)
@@ -7,7 +7,7 @@ use vars qw( $DEBUG $me
            );
              # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
-use List::Util qw(sum);
+use List::Util qw(sum first);
 use Date::Format;
 use Date::Language;
 use Text::Template 1.20;
@@ -908,29 +908,6 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
-  my $taxtotal = 0;
-  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
-                      'subtotal'    => $taxtotal,   # adjusted below
-                      'tax_section' => 1,
-                    };
-  my $tax_weight = _pkg_category($tax_section->{description})
-                        ? _pkg_category($tax_section->{description})->weight
-                        : 0;
-  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
-  $tax_section->{'sort_weight'} = $tax_weight;
-
-  my $adjusttotal = 0;
-  my $adjust_section = {
-    'description'    => $self->mt('Credits, Payments, and Adjustments'),
-    'adjust_section' => 1,
-    'subtotal'       => 0,   # adjusted below
-  };
-  my $adjust_weight = _pkg_category($adjust_section->{description})
-                        ? _pkg_category($adjust_section->{description})->weight
-                        : 0;
-  $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
-  $adjust_section->{'sort_weight'} = $adjust_weight;
-
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
   my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
                      $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
@@ -971,6 +948,21 @@ sub print_generic {
     $previous_section = $default_section;
   }
 
+  my $adjust_section = {
+    'description'    => $self->mt('Credits, Payments, and Adjustments'),
+    'adjust_section' => 1,
+    'subtotal'       => 0,   # adjusted below
+  };
+  my $adjust_weight = _pkg_category($adjust_section->{description})
+                        ? _pkg_category($adjust_section->{description})->weight
+                        : 0;
+  $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
+  # Note: 'sort_weight' here is actually a flag telling whether there is an
+  # explicit package category for the adjust section. If so, certain behavior
+  # happens.
+  $adjust_section->{'sort_weight'} = $adjust_weight;
+
+
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
@@ -1220,6 +1212,26 @@ sub print_generic {
   warn "$me adding taxes\n"
     if $DEBUG > 1;
 
+  # create a tax section if we don't yet have one
+  my $tax_description = 'Taxes, Surcharges, and Fees';
+  my $tax_section = first { $_->{description} eq $tax_description } @sections;
+  if (!$tax_section) {
+    $tax_section = { 'description' => $tax_description };
+    push @sections, $tax_section if $multisection;
+  }
+  $tax_section->{tax_section} = 1; # mark this section as containing taxes
+  # if this is an existing tax section, we're merging the tax items into it.
+  # grab the taxtotal that's already there, strip the money symbol if any
+  my $taxtotal = $tax_section->{'subtotal'} || 0;
+  $taxtotal =~ s/^\Q$other_money_char\E//;
+
+  # this does nothing
+  #my $tax_weight = _pkg_category($tax_section->{description})
+  #                      ? _pkg_category($tax_section->{description})->weight
+  #                      : 0;
+  #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
+  #$tax_section->{'sort_weight'} = $tax_weight;
+
   my @items_tax = $self->_items_tax;
   foreach my $tax ( @items_tax ) {
 
@@ -1262,14 +1274,20 @@ sub print_generic {
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
     if ( $multisection ) {
-      $tax_section->{'subtotal'} = $other_money_char.
-                                   sprintf('%.2f', $taxtotal);
-      $tax_section->{'pretotal'} = 'New charges sub-total '.
-                                   $total->{'total_amount'};
-      if ( $taxtotal ) {
-        push @sections, $tax_section;
-        push @summary_subtotals, $tax_section;
+      if ( $taxtotal > 0 ) {
+        $tax_section->{'subtotal'} = $other_money_char.
+                                     sprintf('%.2f', $taxtotal);
+        $tax_section->{'pretotal'} = 'New charges sub-total '.
+                                     $total->{'total_amount'};
+        $tax_section->{'description'} = $self->mt($tax_description);
+
+        # append it if it's not already there
+        if ( !grep $tax_section, @sections ) {
+          push @sections, $tax_section;
+          push @summary_subtotals, $tax_section;
+        }
       }
+
     } else {
       unshift @total_items, $total;
     }
@@ -1285,7 +1303,6 @@ sub print_generic {
              $money_char. sprintf("%10.2f",$self->charged) ];
   push @buf,['',''];
 
-
   ###
   # Totals
   ###
@@ -1361,7 +1378,6 @@ sub print_generic {
         $total->{'total_item'} = &$escape_function($credit->{'description'});
         $credittotal += $credit->{'amount'};
         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
-        $adjusttotal += $credit->{'amount'};
         if ( $multisection ) {
           push @detail_items, {
             ext_description => [],
@@ -1395,7 +1411,6 @@ sub print_generic {
         $total->{'total_item'} = &$escape_function($payment->{'description'});
         $paymenttotal += $payment->{'amount'};
         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
-        $adjusttotal += $payment->{'amount'};
         if ( $multisection ) {
           push @detail_items, {
             ext_description => [],
@@ -1417,7 +1432,10 @@ sub print_generic {
     
       if ( $multisection ) {
         $adjust_section->{'subtotal'} = $other_money_char.
-                                        sprintf('%.2f', $adjusttotal);
+                                        sprintf('%.2f', $credittotal + $paymenttotal);
+
+        #why this? because {sort_weight} forces the adjust_section to appear
+        #in @extra_sections instead of @sections. obviously.
         push @sections, $adjust_section
           unless $adjust_section->{sort_weight};
         # do not summarize; adjustments there are shown according to 
@@ -2794,11 +2812,16 @@ equivalent to
 
 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
 
-The only OPTIONS accepted is 'section', which may point to a hashref 
-with a key named 'condensed', which may have a true value.  If it 
-does, this method tries to merge identical items into items with 
-'quantity' equal to the number of items (not the sum of their 
-separate quantities, for some reason).
+OPTIONS are passed through to _items_cust_bill_pkg, and should include
+'format' and 'escape_function' at minimum.
+
+To produce items for a specific invoice section, OPTIONS should include
+'section', a hashref containing 'category' and/or 'locationnum' keys.
+
+'section' may also contain a key named 'condensed'. If this is present
+and has a true value, _items_pkg will try to merge identical items into items
+with 'quantity' equal to the number of items (not the sum of their separate
+quantities, for some reason).
 
 =cut
 
@@ -2830,6 +2853,8 @@ sub _items_fee {
   my $self = shift;
   my %options = @_;
   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
+  my $escape_function = $options{escape_function};
+
   my @items;
   foreach my $cust_bill_pkg (@cust_bill_pkg) {
     # cache this, so we don't look it up again in every section
@@ -2864,13 +2889,19 @@ sub _items_fee {
     }
     foreach (sort keys(%base_invnums)) {
       next if $_ == $self->invnum;
+      # per convention, we must escape ext_description lines
       push @ext_desc,
-        $self->mt('from invoice \\#[_1] on [_2]', $_, $base_invnums{$_});
+        &{$escape_function}(
+          $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
+        );
     }
+    my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+    # but not escape the base description line
+
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
-        description => $part_fee->itemdesc_locale($self->cust_main->locale),
+        description => $desc,
         ext_description => \@ext_desc
         # sdate/edate?
       };
index 4719caa..d05b309 100644 (file)
@@ -312,6 +312,9 @@ sub upgrade_data {
     #cust_main (remove paycvv from history)
     'cust_main' => [],
 
+    #contact -> cust_contact / prospect_contact
+    'contact' => [],
+
     #msgcat
     'msgcat' => [],
 
index 3205df1..89bfb74 100644 (file)
@@ -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,10 +450,26 @@ 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);
+
+    my $pv = $self->get($pf);
+       $pv =~ s/\s//g;
+
+    #if new value is empty, delete old entry
+    if (!$pv) {
+      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 ||= new FS::contact_phone \%cp;
+
+    my %cpd = _parse_phonestring( $pv );
     $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
 
     my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
@@ -329,11 +519,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 +643,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 +679,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 +724,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 +825,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 +845,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 +888,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
 
index 888e88b..068d0d1 100644 (file)
@@ -1900,7 +1900,14 @@ sub print_csv {
   if ( lc($opt{'format'}) eq 'billco' ) {
 
     my $lineseq = 0;
-    foreach my $item ( $self->_items_pkg ) {
+    my %items_opt = ( format => 'template',
+                      escape_function => sub { shift } );
+    # I don't know what characters billco actually tolerates in spool entries.
+    # Text::CSV will take care of delimiters, though.
+
+    my @items = ( $self->_items_pkg(%items_opt),
+                  $self->_items_fee(%items_opt) );
+    foreach my $item (@items) {
 
       my $description = $item->{'description'};
       if ( $item->{'_is_discount'} and exists($item->{ext_description}[0]) ) {
index 468e6ae..2ffc273 100644 (file)
@@ -122,7 +122,7 @@ sub check {
     || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg', 'billpkgnum' )
     || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
     || $self->ut_enum('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
-    || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum' )
+    || $self->ut_number('pkgnum', 'cust_pkg', 'pkgnum' )
     || $self->ut_foreign_key('locationnum', 'cust_location', 'locationnum' )
     || $self->ut_money('amount')
     || $self->ut_foreign_key('taxable_billpkgnum', 'cust_bill_pkg', 'billpkgnum')
diff --git a/FS/FS/cust_contact.pm b/FS/FS/cust_contact.pm
new file mode 100644 (file)
index 0000000..6f899d8
--- /dev/null
@@ -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;
+
index d6f1a31..cd675f9 100644 (file)
@@ -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.
index 4a09b93..158b5cf 100644 (file)
@@ -159,11 +159,10 @@ sub API_insert {
 
 sub API_update {
 
 my( $class, %opt ) = @_;
+ my( $class, %opt ) = @_;
 
   my $conf = new FS::Conf;
 
-
   my $custnum = $opt{'custnum'}
     or return { 'error' => "no customer record" };
 
@@ -180,43 +179,47 @@ sub API_update {
         payby payinfo paydate paycvv payname
       ),
 
-  
-  my @invoicing_list = $opt{'invoicing_list'}
-                         ? split( /\s*\,\s*/, $opt{'invoicing_list'} )
-                         : ();
-  push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
-
-  my ($bill_hash, $ship_hash);
-  foreach my $f (FS::cust_main->location_fields) {
-    # avoid having to change this in front-end code
-    $bill_hash->{$f} = $opt{"bill_$f"} || $opt{$f};
-    $ship_hash->{$f} = $opt{"ship_$f"};
+  my @invoicing_list;
+  if ( exists $opt{'invoicing_list'} || exists $opt{'postal_invoicing'} ) {
+    @invoicing_list = split( /\s*\,\s*/, $opt{'invoicing_list'} );
+    push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
+  } else {
+    @invoicing_list = $cust_main->invoicing_list;
   }
 
-  my $bill_location = FS::cust_location->new($bill_hash);
-  my $ship_location;
-  # we don't have an equivalent of the "same" checkbox in selfservice^Wthis API
-  # so is there a ship address, and if so, is it different from the billing 
-  # address?
-  if ( length($ship_hash->{address1}) > 0 and
-          grep { $bill_hash->{$_} ne $ship_hash->{$_} } keys(%$ship_hash)
-         ) {
+  if ( exists( $opt{'address1'} ) ) {
+    my $bill_location = FS::cust_location->new({
+        map { $_ => $opt{$_} } @location_editable_fields
+    });
+    $bill_location->set('custnum' => $custnum);
+    my $error = $bill_location->find_or_insert;
+    die $error if $error;
 
-    $ship_location = FS::cust_location->new( $ship_hash );
-  
-  } else {
-    $ship_location = $bill_location;
+    # if this is unchanged from before, cust_main::replace will ignore it
+    $new->set('bill_location' => $bill_location);
   }
 
-  $new->set('bill_location' => $bill_location);
-  $new->set('ship_location' => $ship_location);
+  if ( exists($opt{'ship_address1'}) && length($opt{"ship_address1"}) > 0 ) {
+    my $ship_location = FS::cust_location->new({
+        map { $_ => $opt{"ship_$_"} } @location_editable_fields
+    });
+
+    $ship_location->set('custnum' => $custnum);
+    my $error = $ship_location->find_or_insert;
+    die $error if $error;
+
+   $new->set('ship_location' => $ship_location);
+
+   } elsif (exists($opt{'ship_address1'} ) && !grep { length($opt{"ship_$_"}) } @location_editable_fields ) {
+      my $ship_location = $new->bill_location;
+     $new->set('ship_location' => $ship_location);
+   }
 
   my $error = $new->replace( $cust_main, \@invoicing_list );
   return { 'error'   => $error } if $error;
-  
+
   return { 'error'   => '',
-         };
-  
+         };  
 }
 
 1;
index b74a231..5d0f85b 100644 (file)
@@ -1,5 +1,8 @@
 package FS::cust_pkg_discount;
-use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Record );
+use base qw( FS::otaker_Mixin
+             FS::cust_main_Mixin
+             FS::pkg_discount_Mixin
+             FS::Record );
 
 use strict;
 use FS::Record qw( dbh ); # qsearch qsearchs dbh );
@@ -82,52 +85,6 @@ sub table { 'cust_pkg_discount'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
-=cut
-
-sub insert {
-  #my( $self, %options ) = @_;
-  my $self = shift;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  if ( $self->discountnum == -1 ) {
-    my $discount = new FS::discount {
-      '_type'    => $self->_type,
-      'amount'   => $self->amount,
-      'percent'  => $self->percent,
-      'months'   => $self->months,
-      'setup'    => $self->setup,
-      #'linked'   => $self->linked,
-      'disabled' => 'Y',
-    };
-    my $error = $discount->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-    $self->discountnum($discount->discountnum);
-  }
-
-  my $error = $self->SUPER::insert; #(@_); #(%options);
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
 =item delete
 
 Delete this record from the database.
index f45fb2a..94d478f 100644 (file)
@@ -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
index 0e03b52..d237bef 100644 (file)
@@ -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  { my $self = shift; qsearch(  @_ ); }
+sub process_o2m_qsearchs { my $self = shift; qsearchs( @_ ); }
+
 sub _load_table {
   my( $self, $table ) = @_;
   eval "use FS::$table";
diff --git a/FS/FS/part_event/Action/pkg_discount.pm b/FS/FS/part_event/Action/pkg_discount.pm
new file mode 100644 (file)
index 0000000..04a3a0f
--- /dev/null
@@ -0,0 +1,97 @@
+package FS::part_event::Action::pkg_discount;
+
+use strict;
+use base qw( FS::part_event::Action );
+
+sub description { "Discount active customer packages"; }
+
+sub eventtable_hashref {
+  { 'cust_main' => 1 };
+}
+
+sub event_stage { 'pre-bill'; }
+
+sub option_fields {
+  (
+    'if_pkgpart'  => { 'label'    => 'Only packages',
+                       'type'     => 'select-table',
+                       'table'    => 'part_pkg',
+                       'name_col' => 'pkg',
+                       #can tweak after fixing discount bug with non-monthly recurring pkgs 
+                       'extra_sql' => q(AND freq NOT LIKE '0%' AND freq NOT LIKE '%d' AND freq NOT LIKE '%h' AND freq NOT LIKE '%w'), 
+                       'multiple' => 1,
+                     },
+    'discountnum' => { 'label'    => 'Discount',
+                       'type'     => 'select-table', #we don't handle the select-discount create a discount case
+                       'table'    => 'discount',
+                       'name_col' => 'description', #well, method
+                       'order_by' => 'ORDER BY discountnum', #requied because name_col is a method
+                       'hashref'  => { 'disabled' => '',
+                                       'months'   => { op=>'!=', value=>'0' },
+                                     },
+                       'disable_empty' => 1,
+                     },
+  );
+}
+
+#lots of false laziness with referral_pkg_discount
+#but also lots of doing it differently...and better???
+sub do_action {
+  my( $self, $object, $cust_event ) = @_;
+
+  my $cust_main = $self->cust_main($object);
+  my %if_pkgpart = map { $_=>1 } split(/\s*,\s*/, $self->option('if_pkgpart') );
+  my @cust_pkg = grep { $if_pkgpart{ $_->pkgpart } && $_->part_pkg->freq
+                          #can remove after fixing discount bug with non-monthly pkgs
+                          && ( $_->part_pkg->freq =~ /^\d+$/) } 
+                      $cust_main->active_pkgs;
+  return 'No qualifying packages' unless @cust_pkg;
+
+  my $gotit = 0;
+  foreach my $cust_pkg (@cust_pkg) {
+
+    my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active;
+
+    #our logic here only makes sense insomuch as you can't have multiple discounts
+    die "Unexpected multiple discounts, contact developers"
+      if scalar(@cust_pkg_discount) > 1;
+
+    my @my_cust_pkg_discount =
+      grep { $_->discountnum == $self->option('discountnum') } @cust_pkg_discount;
+
+    if ( @my_cust_pkg_discount ) { #reset the existing one instead
+
+      $gotit = 1;
+
+      #it's already got this discount and discount never expires--great, move on
+      next unless $cust_pkg_discount[0]->discount->months;
+       
+      #reset the discount
+      my $error = $cust_pkg_discount[0]->decrement_months_used( $cust_pkg_discount[0]->months_used );
+      die "Error extending discount: $error\n" if $error;
+
+    } elsif ( @cust_pkg_discount ) {
+
+      #can't currently discount an already discounted package,
+      #but maybe we can discount a different package
+      next;
+
+    } else { #normal case, create a new one
+
+      $gotit = 1;
+      my $cust_pkg_discount = new FS::cust_pkg_discount {
+        'pkgnum'      => $cust_pkg->pkgnum,
+        'discountnum' => $self->option('discountnum'),
+        'months_used' => 0
+      };
+      my $error = $cust_pkg_discount->insert;
+      die "Error discounting package: $error\n" if $error;
+
+    }
+  }
+
+  return $gotit ? '' : 'Discount not applied due to existing discounts';
+
+}
+
+1;
index 6e981ee..898b08d 100644 (file)
@@ -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;
 }
 
index cde9338..79bf2d3 100644 (file)
@@ -16,7 +16,6 @@ sub eventtable_hashref {
     };
 }
 
-#something like this
 sub option_fields {
   (
     'tagnum'  => { 'label'    => 'Customer tag',
index c505794..f8a2b82 100644 (file)
@@ -31,19 +31,22 @@ sub condition {
   my($self, $object, %opt) = @_;
 
   my $cust_main = $self->cust_main($object);
+  return 0 unless $cust_main; #sanity check
+  return 0 unless $cust_main->referral_custnum;
+
+  my $referring_cust_main = $cust_main->referral_custnum_cust_main;
+  return 0 unless $referring_cust_main; #sanity check;
+
+  #referring customer must sign up before referred customer
+  return 0 unless $cust_main->signupdate > $referring_cust_main->signupdate;
 
   if ( $self->option('active') ) {
-    return 0 unless $cust_main->referral_custnum;
     #check for no cust_main for referral_custnum? (deleted?)
-    return 0 unless $cust_main->referral_custnum_cust_main->status eq 'active';
-  } else {
-    return 0 unless $cust_main->referral_custnum; # ? 1 : 0;
+    return 0 unless $referring_cust_main->status eq 'active';
   }
 
   return 1 unless $self->option('check_bal');
 
-  my $referring_cust_main = $cust_main->referral_custnum_cust_main;
-
   #false laziness w/ balance_age_under
   my $under = $self->option('balance');
   $under = 0 unless length($under);
index 60ba7cc..7062f6c 100644 (file)
@@ -1,6 +1,7 @@
 package FS::part_event::Condition::has_referral_pkgpart;
 use base qw( FS::part_event::Condition );
 
+use FS::part_event::Condition::has_referral_custnum;
 #maybe i should be incorporated in has_referral_custnum
 
 use strict;
@@ -19,10 +20,10 @@ sub option_fields {
 sub condition {
   my($self, $object, %opt) = @_;
 
+  return 0 unless FS::part_event::Condition::has_referral_custnum::condition($self, $object, %opt);
+
   my $cust_main = $self->cust_main($object);
 
-  return 0 unless $cust_main->referral_custnum;
-  
   my $if_pkgpart = $self->option('if_pkgpart') || {};
   grep $if_pkgpart->{ $_->pkgpart },
     $cust_main->referral_custnum_cust_main->ncancelled_pkgs;
diff --git a/FS/FS/part_event/Condition/nopostal.pm b/FS/FS/part_event/Condition/nopostal.pm
new file mode 100644 (file)
index 0000000..b95cd5c
--- /dev/null
@@ -0,0 +1,26 @@
+package FS::part_event::Condition::nopostal;
+use base qw( FS::part_event::Condition );
+use strict;
+
+sub description {
+  'Customer does not receive a postal mail invoice';
+}
+
+sub condition {
+  my( $self, $object ) = @_;
+  my $cust_main = $self->cust_main($object);
+
+  scalar( grep { $_ eq 'POST' } $cust_main->invoicing_list ) ? 0 : 1;
+}
+
+sub condition_sql {
+  my( $self, $table ) = @_;
+
+  " NOT EXISTS( SELECT 1 FROM cust_main_invoice
+              WHERE cust_main_invoice.custnum = cust_main.custnum
+                AND cust_main_invoice.dest    = 'POST'
+          )
+  ";
+}
+
+1;
index 06e2c23..c1082a8 100644 (file)
@@ -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 +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 +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;
 
index 7ff7280..154f979 100644 (file)
@@ -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 +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 )
   );
index 370005c..ef14b4f 100644 (file)
@@ -2,11 +2,11 @@ package FS::part_fee;
 
 use strict;
 use base qw( FS::o2m_Common FS::Record );
-use vars qw( $DEBUG );
 use FS::Record qw( qsearch qsearchs );
 use FS::cust_bill_pkg_display;
 
-$DEBUG = 0;
+our $DEBUG = 0;
+our $default_class;
 
 =head1 NAME
 
@@ -50,6 +50,9 @@ the invoice
 =item disabled - 'Y' if the fee is disabled
 
 =item classnum - the L<FS::pkg_class> that the fee belongs to, for reporting
+and placement on multisection invoices. Unlike packages, fees I<must> be 
+assigned to a class; they will default to class named "Fees", which belongs 
+to the same invoice section that normally contains taxes.
 
 =item taxable - 'Y' if this fee should be considered a taxable sale.  
 Currently, taxable fees will be treated like they exist at the customer's
@@ -130,6 +133,13 @@ sub check {
   $self->set('amount', 0) unless $self->amount;
   $self->set('percent', 0) unless $self->percent;
 
+  $default_class ||= qsearchs('pkg_class', { classname => 'Fees' })
+    or die "default package fee class not found; run freeside-upgrade to continue.\n";
+
+  if (!$self->get('classnum')) {
+    $self->set('classnum', $default_class->classnum);
+  }
+
   my $error = 
     $self->ut_numbern('feepart')
     || $self->ut_textn('comment')
index 2748686..f56878a 100644 (file)
@@ -697,6 +697,8 @@ some components specified by "select-.*.html", and a bunch more...
 
 =item select_label - Used with select_table, this is the field name of labels
 
+=item select_allow_empty - Used with select_table, adds an empty option
+
 =back
 
 =cut
index 52bbdeb..ae8526c 100644 (file)
@@ -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;
index adfadd7..c2361cc 100644 (file)
@@ -3,7 +3,7 @@ use base qw( FS::category_Common );
 
 use strict;
 use vars qw( @ISA $me $DEBUG );
-use FS::Record qw( qsearch );
+use FS::Record qw( qsearch qsearchs );
 use FS::pkg_class;
 use FS::part_pkg;
 
@@ -145,6 +145,40 @@ sub _upgrade_data {
       $weight += 10;
     }
   }
+
+  # create default category for package fees
+  my $tax_category_name = 'Taxes, Surcharges, and Fees';
+  my $tax_category = qsearchs('pkg_category', 
+    { categoryname => $tax_category_name }
+  );
+  if (!$tax_category) {
+    $tax_category = FS::pkg_category->new({
+        categoryname => $tax_category_name,
+        weight       => 1000, # doesn't really matter
+    });
+    my $error = $tax_category->insert;
+    die "error creating tax category: $error\n" if $error;
+  }
+
+  my $fee_class_name = 'Fees'; # does not appear on invoice
+  my $fee_class = qsearchs('pkg_class', { classname => $fee_class_name });
+  if (!$fee_class) {
+    $fee_class = FS::pkg_class->new({
+        classname   => $fee_class_name,
+        categorynum => $tax_category->categorynum,
+    });
+    my $error = $fee_class->insert;
+    die "error creating fee class: $error\n" if $error;
+  }
+
+  # assign it to all fee defs that don't otherwise have a class
+  foreach my $part_fee (qsearch('part_fee', { classnum => '' })) {
+    $part_fee->set('classnum', $fee_class->classnum);
+    my $error = $part_fee->replace;
+    die "error assigning default class to fee def#".$part_fee->feepart .
+      ":$error\n" if $error;
+  }
+
   '';
 }
 
diff --git a/FS/FS/pkg_discount_Mixin.pm b/FS/FS/pkg_discount_Mixin.pm
new file mode 100644 (file)
index 0000000..c6fe008
--- /dev/null
@@ -0,0 +1,69 @@
+package FS::pkg_discount_Mixin;
+
+use strict;
+use NEXT;
+use FS::Record qw(dbh);
+
+=head1 NAME
+
+FS::pkg_discount_Mixin - mixin class for package-discount link objects.
+
+=head1 DESCRIPTION
+
+Implements some behavior that's common to cust_pkg_discount and 
+quotation_pkg_discount objects. The only required field is "discountnum",
+a foreign key to L<FS::discount>.
+
+=head1 METHODS
+
+=over 4
+
+=item insert
+
+Inserts the record. If the 'discountnum' field is -1, this will first create
+a discount using the contents of the '_type', 'amount', 'percent', 'months',
+and 'setup' field. The new discount will be disabled, since it's a one-off
+discount.
+
+=cut
+
+sub insert {
+  my $self = shift;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  
+  if ( $self->discountnum == -1 ) {
+    my $discount = new FS::discount {
+      '_type'    => $self->_type,
+      'amount'   => $self->amount,
+      'percent'  => $self->percent,
+      'months'   => $self->months,
+      'setup'    => $self->setup,
+      #'linked'   => $self->linked,
+      'disabled' => 'Y',
+    };
+    my $error = $discount->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error; 
+    } 
+    $self->set('discountnum', $discount->discountnum);
+  }
+
+  my $error = $self->NEXT::insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+  
+} 
+
+=back
+
+=cut
+
+1;
diff --git a/FS/FS/prospect_contact.pm b/FS/FS/prospect_contact.pm
new file mode 100644 (file)
index 0000000..6626132
--- /dev/null
@@ -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;
+
index b160343..81f71a9 100644 (file)
@@ -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;
index 38e7318..9cef3c1 100644 (file)
@@ -341,15 +341,23 @@ If there is an error, returns an error message, otherwise returns false.
 sub order {
   my $self = shift;
 
-  tie my %cust_pkg, 'Tie::RefHash',
-    map { FS::cust_pkg->new({ pkgpart  => $_->pkgpart,
-                              quantity => $_->quantity,
-                           })
-            => [] #services
-        }
-      $self->quotation_pkg ;
-
-  $self->cust_main->order_pkgs( \%cust_pkg );
+  tie my %all_cust_pkg, 'Tie::RefHash';
+  foreach my $quotation_pkg ($self->quotation_pkg) {
+    my $cust_pkg = FS::cust_pkg->new;
+    foreach (qw(pkgpart locationnum start_date contract_end quantity waive_setup)) {
+      $cust_pkg->set( $_, $quotation_pkg->get($_) );
+    }
+
+    # currently only one discount each
+    my ($pkg_discount) = $quotation_pkg->quotation_pkg_discount;
+    if ( $pkg_discount ) {
+      $cust_pkg->set('discountnum', $pkg_discount->discountnum);
+    }
+
+    $all_cust_pkg{$cust_pkg} = []; # no services
+  }
+
+  $self->cust_main->order_pkgs( \%all_cust_pkg );
 
 }
 
index 3813fb2..ea8f4e0 100644 (file)
@@ -103,8 +103,11 @@ otherwise returns false.
 
 =cut
 
+use Data::Dumper; #XXX DEBUG
 sub insert {
   my ($self, %options) = @_;
+  warn Dumper($self);
+  warn Dumper(\%options);
 
   my $dbh = dbh;
   my $oldAutoCommit = $FS::UID::AutoCommit;
@@ -251,6 +254,9 @@ sub estimate {
 
   # XXX the order of applying discounts is ill-defined, which matters
   # if there are percentage and amount discounts on the same package.
+  #
+  # but right now there can only be one discount on any package, so 
+  # it doesn't matter
   foreach my $pkg_discount ($self->quotation_pkg_discount) {
 
     my $discount = $pkg_discount->discount;
index 633308c..9fdae3e 100644 (file)
@@ -1,5 +1,6 @@
 package FS::quotation_pkg_discount;
-use base qw( FS::Record );
+
+use base qw( FS::pkg_discount_Mixin FS::Record );
 use FS::Maketext 'mt'; # XXX not really correct
 
 use strict;
@@ -78,27 +79,15 @@ sub table { 'quotation_pkg_discount'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
-=cut
-
-# the insert method can be inherited from FS::Record
-
 =item delete
 
 Delete this record from the database.
 
-=cut
-
-# the delete method can be inherited from FS::Record
-
 =item replace OLD_RECORD
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
-=cut
-
-# the replace method can be inherited from FS::Record
-
 =item check
 
 Checks all fields to make sure this is a valid quotation package discount.
index 06ce948..71a61ad 100644 (file)
@@ -196,6 +196,7 @@ sub table_info {
                          select_table => 'svc_domain',
                          select_key   => 'svcnum',
                          select_label => 'domain',
+                         select_allow_empty => 1,
                          disable_inventory => 1,
                        },
         'circuit_svcnum'   => { label             => 'Circuit',
index 6e36c33..b7d347b 100644 (file)
@@ -834,3 +834,9 @@ 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
+FS/pkg_discount_Mixin.pm
+t/pkg_discount_Mixin.t
index 45d5878..a3c67f9 100644 (file)
@@ -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 --git a/FS/t/cust_contact.t b/FS/t/cust_contact.t
new file mode 100644 (file)
index 0000000..0e9ea71
--- /dev/null
@@ -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/pkg_discount_Mixin.t b/FS/t/pkg_discount_Mixin.t
new file mode 100644 (file)
index 0000000..d811a92
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::pkg_discount_Mixin;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/prospect_contact.t b/FS/t/prospect_contact.t
new file mode 100644 (file)
index 0000000..dbb12e5
--- /dev/null
@@ -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";
index c4d5169..a9a899c 100755 (executable)
@@ -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 +217,28 @@ foreach my $cust_bill ( @cust_bill ) {
 #           " 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 ( $@ ) {
index e9b0bdf..06ee775 100644 (file)
                       &{$section->{description_generator}}($line);
             } else {
               my $class = 'invoice_desc_more';
-              if ( $line->{'ref'} and $line->{'ref'} ne $lastref ) {
+              if ( ($line->{'ref'} || 0) ne $lastref ) {
                 # then it's a new package (not a continuation)
                 $class = 'invoice_desc';
               }
               $OUT .= '<tr class="'.$class.'">
                        <td align="center">';
-              #if ( $line->{'ref'} ne $lastref ) {
-              #  $OUT .= $line->{'ref'};
-              #}
               $OUT .= '</td>
                        <td align="left">'. $line->{'description'}. '</td>';
               if ( $unitprices ) {
               $OUT .= '<td align="right">'. $line->{'amount'}. '</td>';
             }
             $OUT .= '</tr>';
-            $lastref = $line->{'ref'};
+            $lastref = $line->{'ref'} || 0;
             if ( @{$line->{'ext_description'} } ) {
               unless ( $section->{description_generator} ) {
                 $OUT .= '<tr class="invoice_extdesc"><td></td><td';
index 822afcb..40ec703 100644 (file)
         # Don't break-up small packages.\r
         my $rowbreak = @$ext_description < 5 ? '*' : '';\r
   \r
-        $OUT .= "\\hline\n" if ($line->{'ref'} && $line->{'ref'} ne $lastref);\r
+        $OUT .= "\\hline\n" if (($line->{'ref'} || 0) ne $lastref);\r
         if ($section->{description_generator}) {\r
           $OUT .= &{$section->{description_generator}}($line);\r
         } else {\r
           $OUT .= '\FSdesc'.\r
-                  '{}'. #'{' . ( $line->{'ref'} ne $lastref ? $line->{'ref'} : '' ) . '}'.\r
+                  '{}'.\r
                   '{' . $line->{'description'} . '}' ;\r
           if ( $unitprices and length($line->{'unit_amount'}) ) {\r
             # then show the unit amount and quantity\r
           }\r
           $OUT .= '{\\dollar' . $line->{'amount'} . "}${rowbreak}\n";\r
         }\r
-        $lastref = $line->{'ref'};\r
+        $lastref = $line->{'ref'} || 0;\r
 \r
         foreach my $ext_desc (@$ext_description) {\r
           if ($section->{extended_description_generator}) {\r
index 0a6f851..686bef6 100644 (file)
@@ -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
index bedb5ec..4e0f495 100755 (executable)
@@ -11,7 +11,8 @@ perl Makefile.PL && make && make install
 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
index f54a157..3aa60a0 100644 (file)
@@ -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 (file)
index 0000000..03b35c6
--- /dev/null
@@ -0,0 +1,51 @@
+<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 NAME="SelectCustomerForm" 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" ID="custnum" onChange="custnum_changed()">
+        <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" ID="submit" VALUE="Select customer" DISABLED></TD>
+  </TR>
+
+</TABLE>
+</FORM>
+
+<SCRIPT TYPE="text/javascript">
+
+function custnum_changed () {
+  var form = document.SelectCustomerForm;
+  if ( form.custnum.selectedIndex > 0 ) {
+    form.submit.disabled = false;
+  } else {
+    form.submit.disabled = true;
+  }
+}
+
+</SCRIPT>
+
+<%= $body_footer %>
index 9443a7d..2337fb5 100755 (executable)
@@ -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 ); 
 }
index d3cf873..9b2298a 100644 (file)
@@ -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',
                                   ],
index 80d9488..0f173f2 100644 (file)
@@ -56,7 +56,7 @@ GNU <b>Affero</b> General Public License.<BR>
 
 % 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>
 % }
 
index 158c5ba..b5ed451 100644 (file)
@@ -58,6 +58,7 @@ Charles A. Beasley<BR>
 Stephen Bechard<BR>
 Eric Bosrup<BR>
 Dickie Bradford<BR>
+Alex Brelsfoard<BR>
 Dave Burgess<BR>
 Joe Camadine<BR>
 Chris Cappuccio<BR>
@@ -91,6 +92,7 @@ Mack Nagashima<BR>
 David Peters<BR>
 Matt Peterson<BR>
 Luke Pfeifer<BR>
+Jonathan Prykop<BR>
 Ricardo Signes<BR>
 Steve Simitzis<BR>
 Stanislav Sinyagin<BR>
index 9f06546..3b7eb07 100644 (file)
@@ -11,6 +11,7 @@
        { 'field'             => 'contactnum',
          'type'              => 'contact',
          'colspan'           => 6,
+         'custnum'           => $custnum,
          'm2m_method'        => 'cust_contact',
          'm2m_dstcol'        => 'contactnum',   
          'm2_label'          => ' ', #'Contact',
index 353ae17..da87bfc 100755 (executable)
@@ -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?
index f59126a..0ab02b4 100644 (file)
@@ -11,6 +11,7 @@
        { 'field'             => 'contactnum',
          'type'              => 'contact',
          'colspan'           => 6,
+         'custnum'           => $opt{cust_main}->custnum,
          'm2m_method'        => 'cust_contact',
          'm2m_dstcol'        => 'contactnum',   
          'm2_label'          => 'Contact',
@@ -36,8 +37,8 @@ my $m2_error_callback = sub {
   my($cgi, $object) = @_;
 
   #process_o2m fields in process/cust_main-contacts.html
-  my @fields = qw( first last title comment );
-  my @gfields = ( '', map "_$_", @fields );
+  my $fields = FS::contact->cgi_contact_fields;
+  my @gfields = ( '', map "_$_", @$fields );
 
   map {
         if ( /^contactnum(\d+)$/ ) {
@@ -45,7 +46,7 @@ my $m2_error_callback = sub {
           if ( grep $cgi->param("contactnum$num$_"), @gfields ) {
             my $x = new FS::contact {
               'contactnum' => scalar($cgi->param("contactnum$num")),
-              map { $_ => scalar($cgi->param("contactnum${num}_$_")) } @fields,
+              map { $_ => scalar($cgi->param("contactnum${num}_$_")) } @$fields,
             };
             $x;
           } else {
index 9e506a7..4d5beee 100644 (file)
@@ -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->{$_}),
index 53cda85..2bb4f5e 100644 (file)
@@ -140,7 +140,8 @@ that field.
           'value_col'   => $def->{'select_key'},
           'order_by'    => dbdef->table($def->{'select_table'})->primary_key,
           'multiple'    => $def->{'multiple'},
-          'disable_empty' => 1,
+          'disable_empty' => $def->{'select_allow_empty'} ? undef : 1,
+          'empty_label' => $def->{'select_allow_empty'} ? ' ' : undef,
           'curr_value'  => $value,
           # these can be switched between multiple and singular,
           # so put the complete curr_value in an attribute
index 20a9ec7..58c1b0a 100644 (file)
@@ -106,7 +106,7 @@ function bill_now_changed (what) {
 
 <TABLE ID="QuickChargeTable" BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0 STYLE="background-color: #cccccc">
 
-% if ( $cust_pkg ) {
+% if ( $cust_pkg ) { #modify one-time charge
 
 <INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $cust_pkg->pkgnum %>">
 % my $field = '/elements/tr-input-text.html';
@@ -171,6 +171,7 @@ function bill_now_changed (what) {
       &>
 %   }
 
+%              unless ($billed) {
 <TR>
   <TD ALIGN="right"><% mt('Tax exempt') |h %> </TD>
   <TD><INPUT TYPE="checkbox" NAME="setuptax" VALUE="Y" <% $cgi->param('setuptax') ? 'CHECKED' : '' %>></TD>
@@ -179,6 +180,7 @@ function bill_now_changed (what) {
 <& /elements/tr-select-taxclass.html, 'curr_value' => $part_pkg->get('taxclass')  &>
 
 <& /elements/tr-select-taxproduct.html, 'label' => emt('Tax product'), 'onclick' => 'parent.taxproductmagic(this);', 'curr_value' => $part_pkg->get('taxproductnum')  &>
+%              }
 
 % } else { # new one-time charge
 
index 34ce70b..9e32bef 100644 (file)
@@ -172,6 +172,18 @@ my $svc_labelsub = sub {
   $label. ': <b>'. encode_entities($item->label($item->history_date)). '</b>';
 };
 
+my $discounts = {};
+my $discount_labelsub = sub {
+  my($item, $label) = @_;
+  my $dnum = $item->discountnum;
+  $discounts->{$dnum} ||= qsearchs({
+    'table'=>'discount',
+    'hashref'=>{'discountnum'=>$dnum}
+  });
+  my $d = $discounts->{$dnum};
+  $label . ': <b>' . encode_entities($d->description_short) . '<b>';
+};
+
 my %h_table_labelsub = (
   'h_cust_pkg'      => $pkg_labelsub,
   'h_svc_acct'      => $svc_labelsub,
@@ -183,6 +195,7 @@ my %h_table_labelsub = (
   'h_svc_external'  => $svc_labelsub,
   'h_svc_phone'     => $svc_labelsub,
   #'h_phone_device'
+  'h_cust_pkg_discount' => $discount_labelsub,
 );
 
 my $cust_pkg_date_format = '%b %o, %Y';
index 979c26b..ef74481 100644 (file)
@@ -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/masked_input_1.1.js b/httemplate/elements/masked_input_1.1.js
deleted file mode 100644 (file)
index 05efa77..0000000
+++ /dev/null
@@ -1,195 +0,0 @@
-/***********************************************************************
-                       Masked Input version 1.1
-************************************************************************
-Author: Kendall Conrad
-Home page: http://www.angelwatt.com/coding/masked_input.php
-Created:  2008-12-16
-Modified: 2010-04-14
-Description:
-License: This work is licensed under a Creative Commons Attribution-Share Alike
-  3.0 United States License http://creativecommons.org/licenses/by-sa/3.0/us/
-
-Argument pieces:
-- elm:        [req] text input node to apply the mask on
-- format:     [req] string format for the mask
-- allowed:    [opt, '0123456789'] string with chars allowed to be typed
-- sep:        [opt, '\/:-'] string of char(s) used as separators in mask
-- typeon:     [opt, '_YMDhms'] string of chars in mask that can be typed on
-- onbadkey:   [opt, null] function to run when user types a unallowed key
-- badkeywait: [opt, 0] used with onbadkey. Indicates how long (in ms) to lock
-  text input for onbadkey function to run
-***********************************************************************/
-function MaskedInput(args)
-{
-  if (args['elm'] === null || args['format'] === null) { return false; }
-  var el     = args['elm'],
-    format   = args['format'],
-    allowed  = args['allowed']    || '0123456789',
-    sep      = args['separator']  || '\/:-',
-    open     = args['typeon']     || '_YMDhms',
-    onbadkey = args['onbadkey']   || function(){},
-    badwait  = args['badkeywait'] || 0;
-  
-  var locked = false, hold = 0;
-  el.value = format;
-  // Assign events
-  el.onkeydown  = KeyHandlerDown;  //
-  el.onkeypress = KeyHandlerPress; // add event handlers to element
-  el.onkeyup    = KeyHandlerUp;    //
-
-  function GetKey(code)
-  {
-    code = code || window.event, ch = '';
-    var keyCode = code.which, evt = code.type;
-    if (keyCode == null) { keyCode = code.keyCode; }
-    if (keyCode === null) { return ''; } // no key, no play
-    // deal with special keys
-    switch (keyCode) {
-    case 8:  ch = 'bksp'; break;
-    case 46: // handle del and . both being 46
-      ch = (evt == 'keydown') ? 'del' : '.'; break;
-    case 16: ch = 'shift'; break;//shift
-    case 0:/*CRAP*/ case 9:/*TAB*/ case 13:/*ENTER*/
-      ch = 'etc'; break;
-    case 37: case 38: case 39: case 40: // arrow keys
-      ch = (!code.shiftKey &&
-           (code.charCode != 39 && code.charCode !== undefined)) ?
-        'etc' : String.fromCharCode(keyCode);
-      break;
-    // default to thinking it's a character or digit
-    default: ch = String.fromCharCode(keyCode);
-    }
-    return ch;
-  }
-  function KeyHandlerDown(e)
-  {
-    e = e || event;
-    if (locked) { return false; }
-    var key = GetKey(e);
-    if (el.value == '') { el.value = format; SetTextCursor(el,0); }
-    // Only do update for bksp del
-    if (key == 'bksp' || key == 'del') { Update(key); return false; }
-    else if (key == 'etc' || key == 'shift') { return true; }
-    else { return true; }    
-  }
-  function KeyHandlerPress(e)
-  {
-    e = e || event;
-    if (locked) { return false; }
-    var key = GetKey(e);
-    // Check if modifier key is being pressed; command
-    if (key=='etc' || e.metaKey || e.ctrlKey || e.altKey) { return true; }
-    if (key != 'bksp' && key != 'del' && key != 'etc' && key != 'shift') {
-      if (!GoodOnes(key)) { return false; }
-      return Update(key);
-    }
-    else { return false; }
-  }
-  function KeyHandlerUp(e) { hold = 0; }
-  function Update(key)
-  {
-    var p = GetTextCursor(el), c = el.value, val = '';
-    // Handle keys now
-    switch (true) {
-    case (allowed.indexOf(key) != -1):
-      if (++p > format.length) { return false; } // if text csor at end
-      // Handle cases where user places csor before separator
-      while (sep.indexOf(c.charAt(p-1)) != -1 && p <= format.length) { p++; }
-      val = c.substr(0, p-1) + key + c.substr(p);
-      // Move csor up a spot if next char is a separator char
-      if (allowed.indexOf(c.charAt(p)) == -1
-          && open.indexOf(c.charAt(p)) == -1) { p++; }
-      break;
-    case (key=='bksp'): // backspace
-      if (--p < 0) return false; // at start of field
-      // If previous char is a separator, move a little more
-      while (allowed.indexOf(c.charAt(p)) == -1
-             && open.indexOf(c.charAt(p)) == -1
-             && p > 1) { p--; }
-      val = c.substr(0, p) + format.substr(p,1) + c.substr(p+1);
-      break;
-    case (key=='del'): // forward delete
-      if (p >= c.length) { return false; } // at end of field
-      // If next char is a separator and not the end of the text field
-      while (sep.indexOf(c.charAt(p)) != -1
-             && c.charAt(p) != '') { p++; }
-      val = c.substr(0, p) + format.substr(p,1) + c.substr(p+1);
-      p++; // Move position forward
-      break;
-    case (key=='etc'): return true; // Catch other allowed chars
-    default: return false;   // Ignore the rest
-    }
-    el.value = '';        // blank it first (Firefox issue)
-    el.value = val;       // put updated value back in
-    SetTextCursor(el, p); // Set the text cursor
-    return false;
-  }
-  function GetTextCursor(node)
-  {
-    try {
-      if (node.selectionStart >= 0) { return node.selectionStart; }
-      else if (document.selection) {// IE
-        var ntxt = node.value; // getting starting text
-        var rng = document.selection.createRange();
-        rng.text = '|%|';
-        var start = node.value.indexOf('|%|');
-        rng.moveStart('character', -3);
-        rng.text = '';
-        // put starting text back in,
-        // fixes issue if all text was highlighted
-        node.value = ntxt;
-        return start;
-      } return -1;
-    } catch(e) { return false; }
-  }
-  function SetTextCursor(node, pos)
-  {
-    try {
-      if (node.selectionStart) {
-        node.focus();
-        node.setSelectionRange(pos,pos);
-      }
-      else if (node.createTextRange) { // IE
-        var rng = node.createTextRange();
-        rng.move('character', pos);
-        rng.select();
-      }
-    } catch(e) { return false; }
-  }
-  function GoodOnes(k)
-  {
-    if (allowed.indexOf(k) == -1 && k!='bksp' && k!='del' && k!='etc') {
-      var p = GetTextCursor(el); // Need to ensure cursor position not lost
-      locked = true; onbadkey();
-      // Hold lock long enough for onbadkey function to run
-      setTimeout(function(){locked=false; SetTextCursor(el,p);}, badwait);
-      return false;
-    } return true;
-  }
-  function resetField() {
-    el.value = format;
-  }
-  function setAllowed(a) {
-    allowed = a;
-    resetField();
-  }
-  function setFormat(f) {
-    format = f;
-    resetField();
-  }
-  function setSeparator(s) {
-    sep = s;
-    resetField();
-  }
-  function setTypeon(t) {
-    open = t;
-    resetField();
-  }
-  return {
-    resetField:resetField,
-    setAllowed:setAllowed,
-    setFormat:setFormat,
-    setSeparator:setSeparator,
-    setTypeon:setTypeon
-  }
-}
diff --git a/httemplate/elements/masked_input_1.3.js b/httemplate/elements/masked_input_1.3.js
new file mode 100644 (file)
index 0000000..54e38ac
--- /dev/null
@@ -0,0 +1,462 @@
+/**
+ * AW Masked Input
+ * @version 1.3
+ * @author Kendall Conrad
+ * @url http://www.angelwatt.com/coding/masked_input.php
+ * @created 2008-12-16
+ * @modified 2013-08-19
+ * @license This work is licensed under a Creative Commons
+ *  Attribution-Share Alike 3.0 United States License
+ *  http://creativecommons.org/licenses/by-sa/3.0/us/
+ *
+ * @param scope The object to attach MaskedInput to.
+ */
+(function(scope) {
+       'use strict';
+
+       /**
+        * MaskedInput takes many possible arguments described below.
+        * Note: req = required, opt = optional
+        * @param {object} args {
+        *  -elm [req] text input node to apply the mask on
+        *  -format [req] string format for the mask
+        *  -allowed [opt, '0123456789'] string with chars allowed to be typed
+        *  -sep [opt, '\/:-'] string of char(s) used as separators in mask
+        *  -typeon [opt, '_YMDhms'] string of chars in mask that can be typed on
+        *  -onfilled [opt, null] function to run when the format is filled in
+        *  -onbadkey [opt, null] function to run when user types a unallowed key
+        *  -badkeywait [opt, 0] used with onbadkey. Indicates how long (in ms)
+        *   to lock text input for onbadkey function to run
+        *  -preserve [opt, true] whether to preserve existing text in
+        *   field during init.
+        * }
+        * @returns MaskedInput
+        */
+       scope.MaskedInput = function(args) {
+               // Ensure passing in valid argument
+               if (!args || !args.elm || !args.format) {
+                       return null;
+               }
+               // Ensure use of 'new'
+               if (!(this instanceof scope.MaskedInput)) {
+                       return new scope.MaskedInput(args);
+               }
+               // Initialize variables
+               var self = this,
+                       el = args.elm,
+                       format = args.format,
+                       allowed = args.allowed || '0123456789',
+                       sep = args.separator || '\/:-',
+                       open = args.typeon || '_YMDhms',
+                       onbadkey = args.onbadkey || function() {},
+                       onfilled = args.onfilled || function() {},
+                       badwait = args.badkeywait || 0,
+                       preserve = args.hasOwnProperty('preserve') ? !!args.preserve : true,
+                       // ----
+                       enabled = true,
+                       locked = false,
+                       startText = format,
+               /**
+                * Add events to objects.
+                */
+               evtAdd = (function() {
+                       if (window.addEventListener) {
+                               return function(obj, type, fx, capture) {
+                                       obj.addEventListener(type, fx,
+                                                       (capture === undefined) ? false : capture);
+                               };
+                       }
+                       if (window.attachEvent) {
+                               return function(obj, type, fx) {
+                                       obj.attachEvent('on' + type, fx);
+                               };
+                       }
+                       return function(obj, type, fx) {
+                               obj['on' + type] = fx;
+                       };
+               }()),
+               /**
+                * Checks whether the format has been completely filled out.
+                * @return boolean if all typeon chars have been filled.
+                */
+               isFilled = function() {
+                       // Check if any typeon characters are left
+                       // Work from end of string as it's usually last filled
+                       for (var a = el.value.length - 1; a >= 0; a--) {
+                               // Check against each typeon character
+                               for (var c = 0, d = open.length; c < d; c++) {
+                                       // If one matches we don't need to check anymore
+                                       if (el.value[a] === open[c]) {
+                                               return false;
+                                       }
+                               }
+                       }
+                       return true;
+               },
+               /**
+                * Gets the current position of the text cursor in a text field.
+                * @param node a input or textarea HTML node.
+                * @return int text cursor position index, or -1 if there was a problem.
+                */
+               getTextCursor = function(node) {
+                       try {
+                               node.focus();
+                               if (node.selectionStart >= 0) {
+                                       return node.selectionStart;
+                               }
+                               if (document.selection) {// IE
+                                       var rng = document.selection.createRange();
+                                       return -rng.moveStart('character', -node.value.length);
+                               }
+                               return -1;
+                       }
+                       catch (e) {
+                               return -1;
+                       }
+               },
+               /**
+                * Sets the text cursor in a text field to a specific position.
+                * @param node a input or textarea HTML node.
+                * @param pos int of the position to be placed.
+                * @return boolean true is successful, false otherwise.
+                */
+               setTextCursor = function(node, pos) {
+                       try {
+                               if (node.selectionStart) {
+                                       node.focus();
+                                       node.setSelectionRange(pos, pos);
+                               }
+                               else if (node.createTextRange) { // IE
+                                       var rng = node.createTextRange();
+                                       rng.move('character', pos);
+                                       rng.select();
+                               }
+                       }
+                       catch (e) {
+                               return false;
+                       }
+                       return true;
+               },
+               /**
+                * Gets the keyboard input in usable way.
+                * @param code integer character code
+                * @return string representing character code
+                */
+               getKey = function(code) {
+                       code = code || window.event;
+                       var ch = '',
+                               keyCode = code.which,
+                               evt = code.type;
+                       if (keyCode === undefined || keyCode === null) {
+                               keyCode = code.keyCode;
+                       }
+                       // no key, no play
+                       if (keyCode === undefined || keyCode === null) {
+                               return '';
+                       }
+                       // deal with special keys
+                       switch (keyCode) {
+                               case 8:
+                                       ch = 'bksp';
+                                       break;
+                               case 46: // handle del and . both being 46
+                                       ch = (evt === 'keydown') ? 'del' : '.';
+                                       break;
+                               case 16:
+                                       ch = 'shift';
+                                       break;
+                               case 0: /*CRAP*/
+                               case 9: /*TAB*/
+                               case 13:/*ENTER*/
+                                       ch = 'etc';
+                                       break;
+                               case 37:
+                               case 38:
+                               case 39:
+                               case 40: // arrow keys
+                                       ch = (!code.shiftKey &&
+                                                       (code.charCode !== 39 && code.charCode !== undefined)) ?
+                                                       'etc' : String.fromCharCode(keyCode);
+                                       break;
+                                       // default to thinking it's a character or digit
+                               default:
+                                       ch = String.fromCharCode(keyCode);
+                                       break;
+                       }
+                       return ch;
+               },
+               /**
+                * Stop the event propogation chain.
+                * @param evt Event to stop
+                * @param ret boolean, used for IE to prevent default event
+                */
+               stopEvent = function(evt, ret) {
+                       // Stop default behavior the standard way
+                       if (evt.preventDefault) {
+                               evt.preventDefault();
+                       }
+                       // Then there's IE
+                       evt.returnValue = ret || false;
+               },
+               /**
+                * Updates the text field with the given key.
+                * @param key string keyboard input.
+                */
+               update = function(key) {
+                       var p = getTextCursor(el),
+                               c = el.value,
+                               val = '',
+                               cond = true;
+                       // Handle keys now
+                       switch (cond) {
+                               // Allowed characters
+                               case (allowed.indexOf(key) !== -1):
+                                       p = p + 1;
+                                       // if text cursor at end
+                                       if (p > format.length) {
+                                               return false;
+                                       }
+                                       // Handle cases where user places cursor before separator
+                                       while (sep.indexOf(c.charAt(p - 1)) !== -1 && p <= format.length) {
+                                               p = p + 1;
+                                       }
+                                       val = c.substr(0, p - 1) + key + c.substr(p);
+                                       // Move csor up a spot if next char is a separator char
+                                       if (allowed.indexOf(c.charAt(p)) === -1
+                                                       && open.indexOf(c.charAt(p)) === -1) {
+                                               p = p + 1;
+                                       }
+                                       break;
+                               case (key === 'bksp'): // backspace
+                                       p = p - 1;
+                                       // at start of field
+                                       if (p < 0) {
+                                               return false;
+                                       }
+                                       // If previous char is a separator, move a little more
+                                       while (allowed.indexOf(c.charAt(p)) === -1
+                                                       && open.indexOf(c.charAt(p)) === -1
+                                                       && p > 1) {
+                                               p = p - 1;
+                                       }
+                                       val = c.substr(0, p) + format.substr(p, 1) + c.substr(p + 1);
+                                       break;
+                               case (key === 'del'): // forward delete
+                                       // at end of field
+                                       if (p >= c.length) {
+                                               return false;
+                                       }
+                                       // If next char is a separator and not the end of the text field
+                                       while (sep.indexOf(c.charAt(p)) !== -1
+                                                       && c.charAt(p) !== '') {
+                                               p = p + 1;
+                                       }
+                                       val = c.substr(0, p) + format.substr(p, 1) + c.substr(p + 1);
+                                       p = p + 1; // Move position forward
+                                       break;
+                               case (key === 'etc'):
+                                       // Catch other allowed chars
+                                       return true;
+                               default:
+                                       return false; // Ignore the rest
+                       }
+                       el.value = ''; // blank it first (Firefox issue)
+                       el.value = val; // put updated value back in
+                       setTextCursor(el, p); // Set the text cursor
+                       return false;
+               },
+               /**
+                * Returns whether or not a given input is valid for the mask.
+                * @param k string of character to check.
+                * @return bool true if it's a valid character.
+                */
+               goodOnes = function(k) {
+                       // if not in allowed list, or invisible key action
+                       if (allowed.indexOf(k) === -1 && k !== 'bksp' && k !== 'del' && k !== 'etc') {
+                               // Need to ensure cursor position not lost
+                               var p = getTextCursor(el);
+                               locked = true;
+                               onbadkey(k);
+                               // Hold lock long enough for onbadkey function to run
+                               setTimeout(function() {
+                                       locked = false;
+                                       setTextCursor(el, p);
+                               }, badwait);
+                               return false;
+                       }
+                       return true;
+               },
+               /**
+                * Handles the key down events.
+                * @param e Event
+                */
+               keyHandlerDown = function(e) {
+                       if (!enabled) {
+                               return true;
+                       }
+                       if (locked) {
+                               stopEvent(e);
+                               return false;
+                       }
+                       e = e || event;
+                       var key = getKey(e);
+                       // Stop copy and paste
+                       if ((e.metaKey || e.ctrlKey) && (key === 'X' || key === 'V')) {
+                               stopEvent(e);
+                               return false;
+                       }
+                       // Allow for OS commands
+                       if (e.metaKey || e.ctrlKey) {
+                               return true;
+                       }
+                       if (el.value === '') {
+                               el.value = format;
+                               setTextCursor(el, 0);
+                       }
+                       // Only do update for bksp del
+                       if (key === 'bksp' || key === 'del') {
+                               update(key);
+                               stopEvent(e);
+                               return false;
+                       }
+                       return true;
+               },
+               /**
+                * Handles the key press events.
+                * @param e Event
+                */
+               keyHandlerPress = function(e) {
+                       if (!enabled) {
+                               return true;
+                       }
+                       if (locked) {
+                               stopEvent(e);
+                               return false;
+                       }
+                       e = e || event;
+                       var key = getKey(e);
+                       // Check if modifier key is being pressed; command
+                       if (key === 'etc' || e.metaKey || e.ctrlKey || e.altKey) {
+                               return true;
+                       }
+                       if (key !== 'bksp' && key !== 'del' && key !== 'shift') {
+                               if (!goodOnes(key)) {
+                                       stopEvent(e);
+                                       return false;
+                               }
+                               if (update(key)) {
+                                       if (isFilled()) {
+                                               onfilled();
+                                       }
+                                       stopEvent(e, true);
+                                       return true;
+                               }
+                               if (isFilled()) {
+                                       onfilled();
+                               }
+                               stopEvent(e);
+                               return false;
+                       }
+                       return false;
+               },
+               /**
+                * Initialize the object.
+                */
+               init = function() {
+                       // Check if an input or textarea tag was passed in
+                       if (!el.tagName || (el.tagName.toUpperCase() !== 'INPUT'
+                                       && el.tagName.toUpperCase() !== 'TEXTAREA')) {
+                               return null;
+                       }
+                       // Only place formatted text in field when not preserving
+                       // text or it's empty.
+                       if (!preserve || el.value === '') {
+                               el.value = format;
+                       }
+                       // Assign events
+                       evtAdd(el, 'keydown', function(e) {
+                               keyHandlerDown(e);
+                       });
+                       evtAdd(el, 'keypress', function(e) {
+                               keyHandlerPress(e);
+                       });
+                       // Let us set the initial text state when focused
+                       evtAdd(el, 'focus', function() {
+                               startText = el.value;
+                       });
+                       // Handle onChange event manually
+                       evtAdd(el, 'blur', function() {
+                               if (el.value !== startText && el.onchange) {
+                                       el.onchange();
+                               }
+                       });
+                       return self;
+               };
+
+               /**
+                * Resets the text field so just the format is present.
+                */
+               self.resetField = function() {
+                       el.value = format;
+               };
+
+               /**
+                * Set the allowed characters that can be used in the mask.
+                * @param a string of characters that can be used.
+                */
+               self.setAllowed = function(a) {
+                       allowed = a;
+                       self.resetField();
+               };
+
+               /**
+                * The format to be used in the mask.
+                * @param f string of the format.
+                */
+               self.setFormat = function(f) {
+                       format = f;
+                       self.resetField();
+               };
+
+               /**
+                * Set the characters to be used as separators.
+                * @param s string representing the separator characters.
+                */
+               self.setSeparator = function(s) {
+                       sep = s;
+                       self.resetField();
+               };
+
+               /**
+                * Set the characters that the user will be typing over.
+                * @param t string representing the characters that will be typed over.
+                */
+               self.setTypeon = function(t) {
+                       open = t;
+                       self.resetField();
+               };
+
+               /**
+                * Sets whether the mask is active.
+                */
+               self.setEnabled = function(enable) {
+                       enabled = enable;
+               };
+
+                /**
+                 * Local change for Freeside: sets the content of the field,
+                 * respecting formatting rules
+                 */
+               self.setValue = function(value) {
+                       self.resetField();
+                       setTextCursor(el, 0);
+                       var i = 0; // index in value
+                       while (i < value.length && !isFilled()) {
+                         update(value[i]);
+                         i++;
+                       }
+               }
+
+               return init();
+       };
+}(window));
index e5f8c61..2b6b187 100644 (file)
@@ -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,8 @@ Example:
     'aname'          => "target", # link NAME= value, useful for #targets
     'target'         => '_parent',
     'style'          => 'css-attribute:value',
-  } )
+  }
+  &>
 
 </%doc>
 % if ($params->{'action'} && $label) {
index cb1d2d6..785ee36 100644 (file)
@@ -121,7 +121,7 @@ Example:
 
         <OPTION VALUE="<% $option %>"
                 <% $option eq $selected ? ' SELECTED' : '' %>
-        ><% $options->{$option} %></OPTION>
+        ><% $options->{$option} |h %></OPTION>
 
 %     }
 
index 6904e3b..373c0ab 100644 (file)
@@ -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,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'});
index 19942b5..fdd2096 100644 (file)
@@ -1,52 +1,62 @@
 % if ( !$init ) {
-<script type="text/javascript" src="<%$p%>elements/masked_input_1.1.js">
+<script type="text/javascript" src="<%$p%>elements/masked_input_1.3.js">
 </script>
 % $init++;
 % }
 <& /elements/tr-input-text.html, id => $id, @_ &>
 <script type="text/javascript">
 <&| /elements/onload.js &>
-MaskedInput({
-  elm: document.getElementById('<%$id%>'),
+var el = document.getElementById('<%$id%>');
+el.MaskedInput = window.MaskedInput({
+  elm: el,
   format: '<% $opt{format} %>',
   <% $opt{allowed} ? "allowed: '$opt{allowed}'," : '' %>
   <% $opt{typeon}  ? "typeon:  '$opt{typeon}',"  : '' %>
 });
-document.getElementById('<%$id%>').value = <% $value |js_string %>;
+el.value = <% $value |js_string %>;
 % if ( $clipboard_hack ) {
-var t = document.getElementById('<% $id %>');
 var container = document.getElementById('<%$id%>_clipboard');
-var KeyHandlerDown = t.onkeydown
-t.onkeydown = function(e) {
-  if (typeof(e) == 'undefined') {
-    // ie8 hack
-    e = event;
-  }
+var KeyDownHandler = function(e) {
+  e = e || event; // IE8
   // intercept ctrl-c and ctrl-x
   // and cmd-c and cmd-x on mac
-  // when text is selected
   if ( ( e.ctrlKey || e.metaKey ) ) {
-    // do the dance
-    var separators = /[\\/:-]/g;
-    var s = t.value.substr(t.selectionStart, t.selectionEnd);
-    if ( s ) {
-      container.value = s.replace(separators, '');
-      container.previous = t;
-      container.focus();
-      container.select();
-      return true;
+    // grab contents of the field, strip out delimiters and copy to container,
+    // and select its contents so that the next "ctrl-c" copies it
+
+    el.select(); // just a visual hint to the user
+    var reject = /[^A-Za-z0-9]/g;
+    container.value = el.value.replace(reject, '');
+    container.focus();
+    container.select();
+    // don't confuse the maskedinput key handlers by letting them see this
+    if (e.stopImmediatePropagation) {
+      e.stopImmediatePropagation();
+    } else {
+      // IE8
+      e.returnValue = false;
+      e.cancelBubble = true;
     }
   }
-  return KeyHandlerDown.call(t, e);
 };
-container.onkeyup = function(e) {
-  if ( container.previous ) {
-    setTimeout(function() {
-      //container.previous.value = container.value;
-      container.previous.focus();
-    }, 10);
-  }
+var KeyUpHandler = function(e) {
+  e = e || event;
+  setTimeout( function() { el.focus() } , 10);
   return true;
+};
+var PasteHandler = function(e) {
+  setTimeout( function() {
+    el.MaskedInput.setValue(container.value);
+  }, 10);
+};
+if ( el.addEventListener ) {
+  el.addEventListener('keydown', KeyDownHandler);
+  container.addEventListener('keyup', KeyUpHandler);
+  container.addEventListener('paste', PasteHandler);
+} else if ( el.attachEvent ) {
+  el.attachEvent('onkeydown', KeyDownHandler);
+  container.attachEvent('onkeyup', KeyUpHandler);
+  container.attachEvent('onpaste', PasteHandler);
 }
 % } # clipboard hack
 </&>
index e37d26d..4eb8a98 100644 (file)
@@ -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(@prospect_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;
index abaaa5b..7a5b43b 100644 (file)
@@ -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)
index b93b80b..64e3691 100644 (file)
 %   }
 % }
 
-% 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 &>
index 1933493..c3667df 100644 (file)
@@ -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,
 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,22 +48,30 @@ if ( $cgi->param('selfservice_access') eq 'Y' ) {
 }
 
 my $extra_sql = '';
-if ( $cgi->param('link') ) {
+$link = $cgi->param('link');
+if ( $link ) {
 
-  my $coalesce = ', COALESCE( cust_main.company,';
   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.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 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";
@@ -62,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;
 
index 486c7b0..d5b865c 100644 (file)
@@ -47,7 +47,7 @@
                           ],
        'html_init'     => $html_init,
        'really_disable_download' => 1,
-       @_
+       @_ #why?
 &>
 <%init>
 #hmm...
@@ -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)";
index 4798f58..241918b 100644 (file)
@@ -12,9 +12,9 @@
                                   sub {
                                     my $pm = shift;
                                     [ map {
-                                            [ { 'data' => $_->line, }, ];
+                                            [ { 'data'=>$_->contact->line, }, ];
                                           }
-                                          $pm->contact
+                                          $pm->prospect_contact
                                     ];
                                   },
                                 ],
index f73483a..f0bc0b8 100644 (file)
@@ -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>
 %                      '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>
index 66abffc..a1f14a3 100644 (file)
   </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>
 %}
index 92e3fdf..705afc1 100644 (file)
@@ -160,6 +160,7 @@ case "$1" in
         then
           echo -n "Stopping (old) freeside-selfservice-server: "
           kill `cat /var/run/freeside-selfservice-server.$SELFSERVICE_USER.pid`
+          sleep 
           rm /var/run/freeside-selfservice-server.$SELFSERVICE_USER.pid
           echo "done."
         fi
@@ -168,9 +169,20 @@ case "$1" in
         for MACHINE in $SELFSERVICE_MACHINES; do
           if [ -e /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid ]
           then
-            echo -n "Stopping freeside-selfservice-server to $MACHINE: "
-            kill `cat /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid`
+            echo -n "Stopping freeside-selfservice-server to $MACHINE"
+            howlong=10
+            while [ $howlong -gt 0 ] && kill -0 `cat /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid` 2>/dev/null; do
+              echo -n '.'
+              kill `cat /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid`
+              sleep 1
+              howlong=$(( $howlong - 1 ))
+              if [ $howlong -eq 0 ]; then
+                echo -n 'forcefully.'
+                kill -9 `cat /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid`
+              fi
+            done
             echo "done."
+            rm /var/run/freeside-selfservice-server.$SELFSERVICE_USER.$MACHINE.pid
           fi
         done
 
index ec54686..540f1f8 100644 (file)
@@ -10,6 +10,7 @@ extract($login_info);
 $error = $_GET['error'];
 if ( $error ) {
   $username = $_GET['username'];
+  $email    = $_GET['email'];
   $domain   = $_GET['domain'];
 }
 
@@ -22,23 +23,23 @@ if ( $error ) {
 
 <TABLE BGCOLOR="#c0c0c0" BORDER=0 CELLSPACING=2 CELLPADDING=0>
 
-<TR>
-  <TH ALIGN="right">Username </TH>
-  <TD>
-    <INPUT TYPE="text" NAME="username" VALUE="<? echo htmlspecialchars($username); ?>"><? if ( $single_domain ) { echo '@'.$single_domain; } ?>
-  </TD>
-</TR>
-
 <? if ( $single_domain ) { ?>
 
+  <TR>
+    <TH ALIGN="right">Username </TH>
+    <TD>
+      <INPUT TYPE="text" NAME="username" VALUE="<? echo htmlspecialchars($username); ?>"><? if ( $single_domain ) { echo '@'.$single_domain; } ?>
+    </TD>
+<  /TR>
+
   <INPUT TYPE="hidden" NAME="domain" VALUE="<? echo $single_domain ?>">
 
 <? } else { ?>
 
   <TR>
-    <TH ALIGN="right">Domain </TH>
+    <TH ALIGN="right">Email address </TH>
     <TD>
-      <INPUT TYPE="text" NAME="domain" VALUE="<? echo htmlspecialchars($domain); ?>">
+      <INPUT TYPE="text" NAME="email" VALUE="<? echo htmlspecialchars($email); ?>">
     </TD>
   </TR>
 
index d2d0155..15b000b 100644 (file)
@@ -4,6 +4,7 @@ require('freeside.class.php');
 $freeside = new FreesideSelfService();
 
 $response = $freeside->login( array( 
+  'email'    => strtolower($_POST['email']),
   'username' => strtolower($_POST['username']),
   'domain'   => strtolower($_POST['domain']),
   'password' => $_POST['password'],
@@ -16,8 +17,9 @@ $error = $response['error'];
 if ( $error ) {
 
   header('Location:index.php?username='. urlencode($username).
-                            '&domain='.   urlencode($domain).
-                            '&error='.    urlencode($error)
+                           '&domain='.   urlencode($domain).
+                           '&email='.    urlencode($email).
+                           '&error='.    urlencode($error)
         );
   die();
 
@@ -29,12 +31,69 @@ $session_id = $response['session_id'];
 
 error_log("[login] logged into freeside with session_id=$session_id, setting cookie");
 
-// now what?  for now, always redirect to the main page.
+// now what?  for now, always redirect to the main page (or the select a
+// customer diversion).
 // eventually, other options?
 
 setcookie('session_id', $session_id);
 
-header("Location:main.php")
-#die();
+if ( $response['custnum'] || $response['svcnum'] ) {
+
+  header("Location:main.php");
+  die();
+
+} elseif ( $response['customers'] ) {
+var_dump($response['customers']);
+?>
+
+  <? $title ='Select customer'; include('elements/header.php'); ?>
+  <? include('elements/error.php'); ?>
+
+  <FORM NAME="SelectCustomerForm" ACTION="process_select_cust.php" METHOD=POST>
+  <INPUT TYPE="hidden" NAME="action" VALUE="switch_cust">
+
+  <TABLE BGCOLOR="#c0c0c0" BORDER=0 CELLSPACING=2 CELLPADDING=0>
+
+    <TR>
+      <TH ALIGN="right">Customer </TH>
+      <TD>
+        <SELECT NAME="custnum" ID="custnum" onChange="custnum_changed()">
+          <OPTION VALUE="">Select a customer
+          <? foreach ( $response['customers'] AS $custnum => $customer ) { ?>
+            <OPTION VALUE="<? echo $custnum ?>"><? echo htmlspecialchars( $customer ) ?>
+          <? } ?>
+        </SELECT>
+      </TD>
+    </TR>
+
+    <TR>
+      <TD COLSPAN=2 ALIGN="center"><INPUT TYPE="submit" ID="submit" VALUE="Select customer" DISABLED></TD>
+    </TR>
+
+  </TABLE>
+  </FORM>
+
+  <SCRIPT TYPE="text/javascript">
+
+  function custnum_changed () {
+    var form = document.SelectCustomerForm;
+    if ( form.custnum.selectedIndex > 0 ) {
+      form.submit.disabled = false;
+    } else {
+      form.submit.disabled = true;
+    }
+  }
+
+  </SCRIPT>
+
+  <? include('elements/footer.php'); ?>
+
+<?
+
+// } else {
+// 
+//   die 'login successful, but unrecognized info (no custnum, svcnum or customers)';
+  
+}
 
 ?>
diff --git a/ng_selfservice/process_select_cust.php b/ng_selfservice/process_select_cust.php
new file mode 100644 (file)
index 0000000..fe36121
--- /dev/null
@@ -0,0 +1,32 @@
+<?
+
+require('freeside.class.php');
+$freeside = new FreesideSelfService();
+
+$response = $freeside->switch_cust( array( 
+  'session_id' => $_COOKIE['session_id'],
+  'custnum'    => $_POST['custnum'],
+) );
+
+#error_log("[switch_cust] received response from freeside: $response");
+
+$error = $response['error'];
+
+if ( $error ) {
+
+  //this isn't well handled... but the only possible error is a session timeout?
+
+  header('Location:index.php?username='. urlencode($username).
+                           '&domain='.   urlencode($domain).
+                           '&email='.    urlencode($email).
+                           '&error='.    urlencode($error)
+        );
+  die();
+
+}
+
+// sucessful customer selection
+
+header("Location:main.php");
+
+?>