From 7d4ebaaed6a6a704c0a958f7e1305cf1d8560e82 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 2 Feb 2015 01:33:57 -0800 Subject: bill now via backoffice API, RT#33220 --- FS/FS/API.pm | 79 ++++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/FS/FS/API.pm b/FS/FS/API.pm index 629463c37..30efa9c39 100644 --- a/FS/FS/API.pm +++ b/FS/FS/API.pm @@ -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,8 +429,6 @@ sub new_customer { $class->API_insert( %opt ); } -=back - =item update_customer Updates an existing customer. Takes a hash reference as parameter with the foll$ @@ -520,13 +522,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 +543,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 +564,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 +589,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? -- cgit v1.2.1 From 91fc21ed01fec83bf1dbb7392d212acdffd4d44c Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 2 Feb 2015 01:34:57 -0800 Subject: comment --- httemplate/edit/quick-charge.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httemplate/edit/quick-charge.html b/httemplate/edit/quick-charge.html index dfaf404fa..1e1232dcd 100644 --- a/httemplate/edit/quick-charge.html +++ b/httemplate/edit/quick-charge.html @@ -106,7 +106,7 @@ function bill_now_changed (what) { -% if ( $cust_pkg ) { +% if ( $cust_pkg ) { #modify one-time charge % my $field = '/elements/tr-input-text.html'; -- cgit v1.2.1 From e9f799b79fca47ee24d2107a0e5c5114dc4e06d8 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Mon, 2 Feb 2015 18:50:47 -0600 Subject: RT#30248: Unable to remove phone number from contact --- FS/FS/contact.pm | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm index 3205df106..437fd1694 100644 --- a/FS/FS/contact.pm +++ b/FS/FS/contact.pm @@ -268,7 +268,7 @@ sub replace { return $error; } - foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) } + foreach my $pf ( grep { /^phonetypenum(\d+)$/ } keys %{ $self->hashref } ) { $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)"; my $phonetypenum = $1; @@ -276,8 +276,21 @@ sub replace { my %cp = ( 'contactnum' => $self->contactnum, 'phonetypenum' => $phonetypenum, ); - my $contact_phone = qsearchs('contact_phone', \%cp) - || new FS::contact_phone \%cp; + my $contact_phone = qsearchs('contact_phone', \%cp); + + # if new value is empty, delete old entry + if (!$self->get($pf)) { + if ($contact_phone) { + $error = $contact_phone->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + next; + } + + $contact_phone ||= new FS::contact_phone \%cp; my %cpd = _parse_phonestring( $self->get($pf) ); $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd; -- cgit v1.2.1 From 2177596bf13e9de77f09c7380d038200bd675f46 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Mon, 2 Feb 2015 22:02:53 -0600 Subject: RT#27425: Fixed/blank svc_phone domain --- FS/FS/part_svc.pm | 2 ++ FS/FS/svc_phone.pm | 1 + httemplate/edit/elements/part_svc_column.html | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/FS/FS/part_svc.pm b/FS/FS/part_svc.pm index 2748686cc..f56878acf 100644 --- a/FS/FS/part_svc.pm +++ b/FS/FS/part_svc.pm @@ -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 diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm index 06ce94848..71a61ad16 100644 --- a/FS/FS/svc_phone.pm +++ b/FS/FS/svc_phone.pm @@ -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', diff --git a/httemplate/edit/elements/part_svc_column.html b/httemplate/edit/elements/part_svc_column.html index 53cda859e..2bb4f5e41 100644 --- a/httemplate/edit/elements/part_svc_column.html +++ b/httemplate/edit/elements/part_svc_column.html @@ -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 -- cgit v1.2.1 From 475ae93877f1d834941f7b9adcc35ee84c5c22fa Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 2 Feb 2015 22:10:44 -0800 Subject: fix prospect contact search --- httemplate/search/contact.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/httemplate/search/contact.html b/httemplate/search/contact.html index 193349369..650307824 100644 --- a/httemplate/search/contact.html +++ b/httemplate/search/contact.html @@ -34,18 +34,19 @@ if ( $cgi->param('selfservice_access') eq 'Y' ) { my $extra_sql = ''; if ( $cgi->param('link') ) { - my $coalesce = ', COALESCE( cust_main.company,'; my $as = ') AS prospect_or_customer'; if ( $cgi->param('link') eq 'cust_main' ) { push @header, 'Customer'; - $select .= "$coalesce cust_main.first||' '||cust_main.last $as"; + $select .= + ", COALESCE( cust_main.company, cust_main.first||' '||cust_main.last $as"; $addl_from = ' LEFT JOIN cust_main USING ( custnum )'; $extra_sql = ' custnum IS NOT NULL '; $company_link = [ $p.'view/cust_main.cgi?', 'custnum' ]; } elsif ( $cgi->param('link') eq 'prospect_main' ) { push @header, 'Prospect'; - $select .= "$coalesce contact.first||' '||contact.last $as"; + $select .= + ", COALESCE( prospect_main.company, contact.first||' '||contact.last $as"; $addl_from = ' LEFT JOIN prospect_main USING ( prospectnum )'; $extra_sql = ' prospectnum IS NOT NULL '; $company_link = [ $p.'view/prospect_main.html?', 'prospectnum' ]; -- cgit v1.2.1 From d9edf24e9d3e1fd87a23359a7679ef6d6637c00d Mon Sep 17 00:00:00 2001 From: Alex Brelsfoard Date: Tue, 3 Feb 2015 07:03:07 -0500 Subject: RT #31482 making sure the tax class is not editable after the customer has been billed. --- httemplate/edit/quick-charge.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/httemplate/edit/quick-charge.html b/httemplate/edit/quick-charge.html index 83620a973..58c1b0a82 100644 --- a/httemplate/edit/quick-charge.html +++ b/httemplate/edit/quick-charge.html @@ -171,6 +171,7 @@ function bill_now_changed (what) { &> % } +% unless ($billed) { @@ -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 -- cgit v1.2.1 From 167dbdad01e2c1b62fd9be43cc05212e8c874a02 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Tue, 3 Feb 2015 07:14:45 -0800 Subject: contacts can be shared among customers / "duplicate contact emails", RT#27943 --- FS/FS/ClientAPI/MyAccount.pm | 70 ++++- FS/FS/ClientAPI_XMLRPC.pm | 1 + FS/FS/Conf.pm | 2 +- FS/FS/Mason.pm | 3 + FS/FS/Schema.pm | 59 +++- FS/FS/Upgrade.pm | 3 + FS/FS/contact.pm | 314 ++++++++++++++++++--- FS/FS/cust_contact.pm | 146 ++++++++++ FS/FS/cust_main.pm | 67 ++++- FS/FS/msg_template.pm | 40 +-- FS/FS/o2m_Common.pm | 7 +- FS/FS/prospect_contact.pm | 125 ++++++++ FS/FS/prospect_main.pm | 4 +- FS/MANIFEST | 4 + FS/t/cust_contact.t | 5 + FS/t/prospect_contact.t | 5 + fs_selfservice/FS-SelfService/SelfService.pm | 1 + fs_selfservice/FS-SelfService/cgi/select_cust.html | 38 +++ fs_selfservice/FS-SelfService/cgi/selfservice.cgi | 16 +- httemplate/edit/cust_main.cgi | 4 +- httemplate/edit/elements/edit.html | 4 + httemplate/elements/contact.html | 19 +- httemplate/elements/tr-select-contact.html | 10 +- httemplate/misc/email-quotation.html | 8 +- httemplate/search/contact.html | 60 ++-- httemplate/search/prospect_main.html | 4 +- httemplate/view/cust_main/contacts_new.html | 33 ++- httemplate/view/prospect_main.html | 5 +- 28 files changed, 915 insertions(+), 142 deletions(-) create mode 100644 FS/FS/cust_contact.pm create mode 100644 FS/FS/prospect_contact.pm create mode 100644 FS/t/cust_contact.t create mode 100644 FS/t/prospect_contact.t create mode 100644 fs_selfservice/FS-SelfService/cgi/select_cust.html diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 8276d7e4b..86c7ac324 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -46,6 +46,7 @@ use FS::payby; use FS::acct_rt_transaction; use FS::msg_template; use FS::contact; +use FS::cust_contact; $DEBUG = 1; $me = '[FS::ClientAPI::MyAccount]'; @@ -82,7 +83,7 @@ sub skin_info { #return { 'error' => $session } if $context eq 'error'; my $agentnum = ''; - if ( $context eq 'customer' ) { + if ( $context eq 'customer' && $custnum ) { my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?') or die dbh->errstr; @@ -237,7 +238,16 @@ sub login { return { error => 'Incorrect contact password.' } unless $contact->authenticate_password($p->{'password'}); - $session->{'custnum'} = $contact->custnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + if ( scalar(@cust_contact) == 1 ) { + $session->{'custnum'} = $cust_contact[0]->custnum; + } elsif ( scalar(@cust_contact) ) { + $session->{'customers'} = { map { $_->custnum => $_->cust_main->name } + @cust_contact + }; + } else { + return { error => 'No customer self-service access for contact' }; #?? + } } else { @@ -303,6 +313,7 @@ sub login { return { 'error' => '', 'session_id' => $session_id, + %$session, }; } @@ -336,6 +347,23 @@ sub switch_acct { } +sub switch_cust { + my $p = shift; + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + $session->{'custnum'} = $p->{'custnum'} + if exists $session->{'customers'}{ $p->{'custnum'} }; + + my $conf = new FS::Conf; + my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; + _cache->set( $p->{'session_id'}, $session, $timeout ); + + return { 'error' => '', + %{ customer_info( { session_id=>$p->{'session_id'} } ) }, + }; +} + sub payment_gateway { # internal use only # takes a cust_main and a cust_payby entry, returns the payment_gateway @@ -380,22 +408,23 @@ sub access_info { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; - my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) - or return { 'error' => "unknown custnum $custnum" }; + my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); $info->{'hide_payment_fields'} = [ map { - my $pg = payment_gateway($cust_main, $_); + my $pg = $cust_main && payment_gateway($cust_main, $_); $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment'; } @{ $info->{cust_paybys} } ]; $info->{'self_suspend_reason'} = - $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum); + $conf->config('selfservice-self_suspend_reason', + $cust_main ? $cust_main->agentnum : '' + ); $info->{'edit_ticket_subject'} = $conf->exists('ticket_system-selfservice_edit_subject') && - $cust_main->edit_subject; + $cust_main && $cust_main->edit_subject; $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600; @@ -432,7 +461,7 @@ sub customer_info { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info: unknown custnum $custnum" }; my $list_tickets = list_tickets($p); $return{'tickets'} = $list_tickets->{'tickets'}; @@ -536,7 +565,7 @@ sub customer_info_short { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info_short: unknown custnum $custnum" }; $return{display_custnum} = $cust_main->display_custnum; @@ -2916,7 +2945,12 @@ sub myaccount_passwd { #need to support the "ISP provides email that's used as a contact email" case #as well as we can. my $contact = FS::contact->by_selfservice_email($svc_acct->email); - if ( $contact && $contact->custnum == $custnum ) { + if ( $contact && qsearchs('cust_contact', { contactnum=> $contact->contactnum, + custnum => $custnum, + selfservice_access => 'Y', + } + ) + ) { #svc_acct was successful but this one returns an error? "shouldn't happen" $error ||= $contact->change_password($p->{'new_password'}); } @@ -2993,7 +3027,10 @@ sub reset_passwd { $contact = FS::contact->by_selfservice_email($p->{'email'}); - $cust_main = $contact->cust_main if $contact; + if ( $contact ) { + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1; + } #also look for an svc_acct, otherwise it would be super confusing @@ -3035,6 +3072,9 @@ sub reset_passwd { } + return { %$info, 'error' => 'Multi-customer contacts incompatible with customer-based verification' } + if ! $cust_main && $verification ne 'email'; + my %verify = ( 'email' => sub { 1; }, 'paymask' => sub { @@ -3157,7 +3197,9 @@ sub check_reset_passwd { my @contact_email = $contact->contact_email; return { 'error' => 'No contact email' } unless @contact_email; - $p->{'agentnum'} = $contact->cust_main->agentnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; my $info = skin_info($p); return { %$info, @@ -3207,7 +3249,9 @@ sub process_reset_passwd { $contact = qsearchs('contact', { 'contactnum' => $contactnum } ) or return { 'error' => "Contact not found" }; - $p->{'agentnum'} ||= $contact->cust_main->agentnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; $info ||= skin_info($p); } diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm index 62f61d6e5..952b19940 100644 --- a/FS/FS/ClientAPI_XMLRPC.pm +++ b/FS/FS/ClientAPI_XMLRPC.pm @@ -102,6 +102,7 @@ sub ss2clientapi { 'login' => 'MyAccount/login', 'logout' => 'MyAccount/logout', 'switch_acct' => 'MyAccount/switch_acct', + 'switch_cust' => 'MyAccount/switch_cust', 'customer_info' => 'MyAccount/customer_info', 'customer_info_short' => 'MyAccount/customer_info_short', 'billing_history' => 'MyAccount/billing_history', diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 029f1a1bf..2b959e661 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2996,7 +2996,7 @@ and customer address. Include units.', 'type' => 'select', 'select_hash' => [ '' => 'Password reset disabled', 'email' => 'Click on a link in email', - 'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code', + 'paymask,amount,zip' => 'Click on a link in email, and also verify with credit card (or bank account) last 4 digits, payment amount and zip code. Note: Do not use if you have multi-customer contacts, as they will be unable to reset their passwords.', ], }, diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index d3e45dfee..37e3ad243 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -396,6 +396,9 @@ if ( -e $addl_handler_use_file ) { use FS::circuit_provider; use FS::circuit_termination; use FS::svc_circuit; + use FS::cust_credit_source_bill_pkg; + use FS::prospect_contact; + use FS::cust_contact; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index d5ed1b718..133b6d81a 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1740,20 +1740,69 @@ sub tables_hashref { 'index' => [ ['disabled'] ], }, + 'cust_contact' => { + 'columns' => [ + 'custcontactnum', 'serial', '', '', '', '', + 'custnum', 'int', '', '', '', '', + 'contactnum', 'int', '', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', + 'comment', 'varchar', 'NULL', 255, '', '', + 'selfservice_access', 'char', 'NULL', 1, '', '', + ], + 'primary_key' => 'custcontactnum', + 'unique' => [ [ 'custnum', 'contactnum' ], ], + 'index' => [ [ 'custnum' ], [ 'contactnum' ], ], + 'foreign_keys' => [ + { columns => [ 'custnum' ], + table => 'cust_main', + }, + { columns => [ 'contactnum' ], + table => 'contact', + }, + { columns => [ 'classnum' ], + table => 'contact_class', + }, + ], + }, + + 'prospect_contact' => { + 'columns' => [ + 'prospectcontactnum', 'serial', '', '', '', '', + 'prospectnum', 'int', '', '', '', '', + 'contactnum', 'int', '', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', + 'comment', 'varchar', 'NULL', 255, '', '', + ], + 'primary_key' => 'prospectcontactnum', + 'unique' => [ [ 'prospectnum', 'contactnum' ], ], + 'index' => [ [ 'prospectnum' ], [ 'contactnum' ], ], + 'foreign_keys' => [ + { columns => [ 'prospectnum' ], + table => 'prospect_main', + }, + { columns => [ 'contactnum' ], + table => 'contact', + }, + { columns => [ 'classnum' ], + table => 'contact_class', + }, + ], + }, + 'contact' => { 'columns' => [ 'contactnum', 'serial', '', '', '', '', - 'prospectnum', 'int', 'NULL', '', '', '', - 'custnum', 'int', 'NULL', '', '', '', + 'prospectnum', 'int', 'NULL', '', '', '', #deprecated, now prospect_contact table + 'custnum', 'int', 'NULL', '', '', '', #deprecated, now cust_contact table 'locationnum', 'int', 'NULL', '', '', '', #not yet - 'classnum', 'int', 'NULL', '', '', '', + 'classnum', 'int', 'NULL', '', '', '', #deprecated, now prospect_contact or cust_contact # 'titlenum', 'int', 'NULL', '', '', '', #eg Mr. Mrs. Dr. Rev. 'last', 'varchar', '', $char_d, '', '', # 'middle', 'varchar', 'NULL', $char_d, '', '', 'first', 'varchar', '', $char_d, '', '', 'title', 'varchar', 'NULL', $char_d, '', '', #eg Head Bottle Washer - 'comment', 'varchar', 'NULL', 255, '', '', - 'selfservice_access', 'char', 'NULL', 1, '', '', + 'comment', 'varchar', 'NULL', 255, '', '', #depredated, now prospect_contact or cust_contact + 'selfservice_access', 'char', 'NULL', 1, '', '', #deprecated, now cust_contact '_password', 'varchar', 'NULL', $char_d, '', '', '_password_encoding', 'varchar', 'NULL', $char_d, '', '', 'disabled', 'char', 'NULL', 1, '', '', diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 4719caa22..d05b309c7 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -312,6 +312,9 @@ sub upgrade_data { #cust_main (remove paycvv from history) 'cust_main' => [], + #contact -> cust_contact / prospect_contact + 'contact' => [], + #msgcat 'msgcat' => [], diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm index 3205df106..07458c742 100644 --- a/FS/FS/contact.pm +++ b/FS/FS/contact.pm @@ -3,12 +3,15 @@ use base qw( FS::Record ); use strict; use vars qw( $skip_fuzzyfiles ); +use Carp; use Scalar::Util qw( blessed ); use FS::Record qw( qsearch qsearchs dbh ); use FS::contact_phone; use FS::contact_email; use FS::queue; use FS::phone_type; #for cgi_contact_fields +use FS::cust_contact; +use FS::prospect_contact; $skip_fuzzyfiles = 0; @@ -123,10 +126,88 @@ sub insert { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - my $error = $self->SUPER::insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; + #save off and blank values that move to cust_contact / prospect_contact now + my $prospectnum = $self->prospectnum; + $self->prospectnum(''); + my $custnum = $self->custnum; + $self->custnum(''); + + my %link_hash = (); + for (qw( classnum comment selfservice_access )) { + $link_hash{$_} = $self->get($_); + $self->$_(''); + } + + #look for an existing contact with this email address + my $existing_contact = ''; + if ( $self->get('emailaddress') =~ /\S/ ) { + + my %existing_contact = (); + + foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) { + + my $contact_email = qsearchs('contact_email', { emailaddress=>$email } ) + or next; + + my $contact = $contact_email->contact; + $existing_contact{ $contact->contactnum } = $contact; + + } + + if ( scalar( keys %existing_contact ) > 1 ) { + $dbh->rollback if $oldAutoCommit; + return 'Multiple email addresses specified '. + ' that already belong to separate contacts'; + } elsif ( scalar( keys %existing_contact ) ) { + ($existing_contact) = values %existing_contact; + } + + } + + if ( $existing_contact ) { + + $self->$_($existing_contact->$_()) + for qw( contactnum _password _password_encoding ); + $self->SUPER::replace($existing_contact); + + } else { + + my $error = $self->SUPER::insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } + + my $cust_contact = ''; + if ( $custnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'custnum' => $custnum, + ); + $cust_contact = qsearchs('cust_contact', \%hash ) + || new FS::cust_contact { %hash, %link_hash }; + my $error = $cust_contact->custcontactnum ? $cust_contact->replace + : $cust_contact->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $prospectnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'prospectnum' => $prospectnum, + ); + my $prospect_contact = qsearchs('prospect_contact', \%hash ) + || new FS::prospect_contact { %hash, %link_hash }; + my $error = + $prospect_contact->prospectcontactnum ? $prospect_contact->replace + : $prospect_contact->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ } @@ -134,12 +215,14 @@ sub insert { $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)"; my $phonetypenum = $1; - my $contact_phone = new FS::contact_phone { - 'contactnum' => $self->contactnum, - 'phonetypenum' => $phonetypenum, - _parse_phonestring( $self->get($pf) ), - }; - $error = $contact_phone->insert; + my %hash = ( 'contactnum' => $self->contactnum, + 'phonetypenum' => $phonetypenum, + ); + my $contact_phone = + qsearchs('contact_phone', \%hash) + || new FS::contact_phone { %hash, _parse_phonestring($self->get($pf)) }; + my $error = $contact_phone->contactphonenum ? $contact_phone->replace + : $contact_phone->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -149,17 +232,18 @@ sub insert { if ( $self->get('emailaddress') =~ /\S/ ) { foreach my $email ( split(/\s*,\s*/, $self->get('emailaddress') ) ) { - - my $contact_email = new FS::contact_email { + my %hash = ( 'contactnum' => $self->contactnum, 'emailaddress' => $email, - }; - $error = $contact_email->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; + ); + unless ( qsearchs('contact_email', \%hash) ) { + my $contact_email = new FS::contact_email \%hash; + my $error = $contact_email->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } - } } @@ -167,14 +251,17 @@ sub insert { unless ( $skip_fuzzyfiles ) { #unless ( $import || $skip_fuzzyfiles ) { #warn " queueing fuzzyfiles update\n" # if $DEBUG > 1; - $error = $self->queue_fuzzyfiles_update; + my $error = $self->queue_fuzzyfiles_update; if ( $error ) { $dbh->rollback if $oldAutoCommit; return "updating fuzzy search cache: $error"; } } - if ( $self->selfservice_access ) { + if ( $link_hash{'selfservice_access'} eq 'R' + or ( $link_hash{'selfservice_access'} && $cust_contact ) + ) + { my $error = $self->send_reset_email( queue=>1 ); if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -208,6 +295,44 @@ sub delete { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #got a prospetnum or custnum? delete the prospect_contact or cust_contact link + + if ( $self->prospectnum ) { + my $prospect_contact = qsearchs('prospect_contact', { + 'contactnum' => $self->contactnum, + 'prospectnum' => $self->prospectnum, + }); + my $error = $prospect_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $self->custnum ) { + my $cust_contact = qsearchs('cust_contact', { + 'contactnum' => $self->contactnum, + 'custnum' => $self->custnum, + }); + my $error = $cust_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + # then, proceed with deletion only if the contact isn't attached to any other + # prospects or customers + + #inefficient, but how many prospects/customers can a single contact be + # attached too? (and is removing them from one a common operation?) + if ( $self->prospect_contact || $self->cust_contact ) { + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + return ''; + } + + #proceed with deletion + foreach my $cust_pkg ( $self->cust_pkg ) { $cust_pkg->contactnum(''); my $error = $cust_pkg->replace; @@ -262,13 +387,62 @@ sub replace { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #save off and blank values that move to cust_contact / prospect_contact now + my $prospectnum = $self->prospectnum; + $self->prospectnum(''); + my $custnum = $self->custnum; + $self->custnum(''); + + my %link_hash = (); + for (qw( classnum comment selfservice_access )) { + $link_hash{$_} = $self->get($_); + $self->$_(''); + } + my $error = $self->SUPER::replace($old); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } - foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) } + my $cust_contact = ''; + if ( $custnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'custnum' => $custnum, + ); + my $error; + if ( $cust_contact = qsearchs('cust_contact', \%hash ) ) { + $cust_contact->$_($link_hash{$_}) for keys %link_hash; + $error = $cust_contact->replace; + } else { + $cust_contact = new FS::cust_contact { %hash, %link_hash }; + $error = $cust_contact->insert; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ( $prospectnum ) { + my %hash = ( 'contactnum' => $self->contactnum, + 'prospectnum' => $prospectnum, + ); + my $error; + if ( my $prospect_contact = qsearchs('prospect_contact', \%hash ) ) { + $prospect_contact->$_($link_hash{$_}) for keys %link_hash; + $error = $prospect_contact->replace; + } else { + my $prospect_contact = new FS::prospect_contact { %hash, %link_hash }; + $error = $prospect_contact->insert; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + foreach my $pf ( grep { /^phonetypenum(\d+)$/ } keys %{ $self->hashref } ) { $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)"; my $phonetypenum = $1; @@ -276,8 +450,19 @@ sub replace { my %cp = ( 'contactnum' => $self->contactnum, 'phonetypenum' => $phonetypenum, ); - my $contact_phone = qsearchs('contact_phone', \%cp) - || new FS::contact_phone \%cp; + my $contact_phone = qsearchs('contact_phone', \%cp); + + #if new value is empty, delete old entry + if (!$self->get($pf)) { + if ($contact_phone) { + $error = $contact_phone->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + next; + } my %cpd = _parse_phonestring( $self->get($pf) ); $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd; @@ -329,11 +514,14 @@ sub replace { } } - if ( ( $old->selfservice_access eq '' && $self->selfservice_access - && ! $self->_password - ) - || $self->_resend() - ) + if ( $cust_contact and ( + ( $cust_contact->selfservice_access eq '' + && $link_hash{selfservice_access} + && ! length($self->_password) + ) + || $cust_contact->_resend() + ) + ) { my $error = $self->send_reset_email( queue=>1 ); if ( $error ) { @@ -450,7 +638,6 @@ sub check { ; return $error if $error; - return "No prospect or customer!" unless $self->prospectnum || $self->custnum; return "Prospect and customer!" if $self->prospectnum && $self->custnum; return "One of first name, last name, or title must have a value" @@ -487,17 +674,35 @@ sub firstlast { $self->first . ' ' . $self->last; } -=item contact_classname - -Returns the name of this contact's class (see L). - -=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, L and +#L). +# +#=cut +# +#sub contact_classname { +# my( $self, $prospect_or_cust ) = @_; +# +# my $link = ''; +# if ( ref($prospect_or_cust) eq 'FS::prospect_main' ) { +# $link = qsearchs('prospect_contact', { +# 'contactnum' => $self->contactnum, +# 'prospectnum' => $prospect_or_cust->prospectnum, +# }); +# } elsif ( ref($prospect_or_cust) eq 'FS::cust_main' ) { +# $link = qsearchs('cust_contact', { +# 'contactnum' => $self->contactnum, +# 'custnum' => $prospect_or_cust->custnum, +# }); +# } else { +# croak "$prospect_or_cust is not an FS::prospect_main or FS::cust_main object"; +# } +# +# my $contact_class = $link->contact_class or return ''; +# $contact_class->classname; +#} =item by_selfservice_email EMAILADDRESS @@ -514,8 +719,7 @@ sub by_selfservice_email { 'table' => 'contact_email', 'addl_from' => ' LEFT JOIN contact USING ( contactnum ) ', 'hashref' => { 'emailaddress' => $email, }, - 'extra_sql' => " AND selfservice_access = 'Y' ". - " AND ( disabled IS NULL OR disabled = '' )", + 'extra_sql' => " AND ( disabled IS NULL OR disabled = '' )", }) or return ''; $contact_email->contact; @@ -616,10 +820,12 @@ sub send_reset_email { my $conf = new FS::Conf; - my $cust_main = $self->cust_main - or die "no customer"; #reset a password for a prospect contact? someday + my $cust_main = ''; + my @cust_contact = grep $_->selfservice_access, $self->cust_contact; + $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1; - my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum); + my $agentnum = $cust_main ? $cust_main->agentnum : ''; + my $msgnum = $conf->config('selfservice-password_reset_msgnum', $agentnum); #die "selfservice-password_reset_msgnum unset" unless $msgnum; return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum; my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } ); @@ -634,7 +840,7 @@ sub send_reset_email { my $queue = new FS::queue { 'job' => 'FS::Misc::process_send_email', - 'custnum' => $cust_main->custnum, + 'custnum' => $cust_main ? $cust_main->custnum : '', }; $queue->insert( $msg_template->prepare( %msg_template ) ); @@ -677,7 +883,21 @@ sub cgi_contact_fields { } -use FS::phone_type; +use FS::upgrade_journal; +sub _upgrade_data { #class method + my ($class, %opts) = @_; + + unless ( FS::upgrade_journal->is_done('contact__DUPEMAIL') ) { + + foreach my $contact (qsearch('contact', {})) { + my $error = $contact->replace; + die $error if $error; + } + + FS::upgrade_journal->set_done('contact__DUPEMAIL'); + } + +} =back diff --git a/FS/FS/cust_contact.pm b/FS/FS/cust_contact.pm new file mode 100644 index 000000000..6f899d83f --- /dev/null +++ b/FS/FS/cust_contact.pm @@ -0,0 +1,146 @@ +package FS::cust_contact; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::cust_contact - Object methods for cust_contact records + +=head1 SYNOPSIS + + use FS::cust_contact; + + $record = new FS::cust_contact \%hash; + $record = new FS::cust_contact { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_contact object represents a contact's attachment to a specific +customer. FS::cust_contact inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item custcontactnum + +primary key + +=item custnum + +custnum + +=item contactnum + +contactnum + +=item classnum + +classnum + +=item comment + +comment + +=item selfservice_access + +empty or Y + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I 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). + +=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, L, L + +=cut + +1; + diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index d6f1a3176..cd675f9d4 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -71,7 +71,7 @@ use FS::agent_payment_gateway; use FS::banned_pay; use FS::cust_main_note; use FS::cust_attachment; -use FS::contact; +use FS::cust_contact; use FS::Locales; use FS::upgrade_journal; use FS::sales; @@ -529,11 +529,23 @@ sub insert { return $error; } - my @contact = $prospect_main->contact; + foreach my $prospect_contact ( $prospect_main->prospect_contact ) { + my $cust_contact = new FS::cust_contact { + 'custnum' => $self->custnum, + map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment ) + }; + my $error = $cust_contact->insert + || $prospect_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my @cust_location = $prospect_main->cust_location; my @qual = $prospect_main->qual; - foreach my $r ( @contact, @cust_location, @qual ) { + foreach my $r ( @cust_location, @qual ) { $r->prospectnum(''); $r->custnum($self->custnum); my $error = $r->replace; @@ -1915,14 +1927,13 @@ sub cust_location { =item cust_contact -Returns all contacts (see L) for this customer. +Returns all contact associations (see L) for this customer. =cut -#already used :/ sub contact { sub cust_contact { my $self = shift; - qsearch('contact', { 'custnum' => $self->custnum } ); + qsearch('cust_contact', { 'custnum' => $self->custnum } ); } =item cust_payby @@ -3656,9 +3667,11 @@ sub service_contact { my $classnum = $self->scalar_sql( 'SELECT classnum FROM contact_class WHERE classname = \'Service\'' ) || 0; #if it's zero, qsearchs will return nothing - $self->{service_contact} = qsearchs('contact', { - 'classnum' => $classnum, 'custnum' => $self->custnum - }) || undef; + my $cust_contact = qsearchs('cust_contact', { + 'classnum' => $classnum, + 'custnum' => $self->custnum, + }); + $self->{service_contact} = $cust_contact->contact if $cust_contact; } $self->{service_contact}; } @@ -4614,6 +4627,42 @@ sub _agent_plandata { } +sub process_o2m_qsearch { + my $self = shift; + my $table = shift; + return qsearch($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #4343'; + + qsearch({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + +sub process_o2m_qsearchs { + my $self = shift; + my $table = shift; + return qsearchs($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #2121'; + + qsearchs({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ] Subroutine (not a method), designed to be called from the queue. diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index f45fb2aef..94d478f6f 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -278,16 +278,17 @@ A hash reference of additional substitutions sub prepare { my( $self, %opt ) = @_; - my $cust_main = $opt{'cust_main'} or die 'cust_main required'; + my $cust_main = $opt{'cust_main'}; # or die 'cust_main required'; my $object = $opt{'object'} or die 'object required'; # localization - my $locale = $cust_main->locale || ''; + my $locale = $cust_main && $cust_main->locale || ''; warn "no locale for cust#".$cust_main->custnum."; using default content\n" - if $DEBUG and !$locale; - my $content = $self->content($cust_main->locale); - warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n" - if($DEBUG); + if $DEBUG and $cust_main && !$locale; + my $content = $self->content($locale); + + warn "preparing template '".$self->msgname."\n" + if $DEBUG; my $subs = $self->substitutions; @@ -295,7 +296,8 @@ sub prepare { # create substitution table ### my %hash; - my @objects = ($cust_main); + my @objects = (); + push @objects, $cust_main if $cust_main; my @prefixes = (''); my $svc; if( ref $object ) { @@ -385,20 +387,22 @@ sub prepare { my @to; if ( exists($opt{'to'}) ) { @to = split(/\s*,\s*/, $opt{'to'}); - } - else { + } elsif ( $cust_main ) { @to = $cust_main->invoicing_list_emailonly; + } else { + die 'no To: address or cust_main object specified'; } - # no warning when preparing with no destination my $from_addr = $self->from_addr; if ( !$from_addr ) { + + my $agentnum = $cust_main ? $cust_main->agentnum : ''; + if ( $opt{'from_config'} ) { - $from_addr = scalar( $conf->config($opt{'from_config'}, - $cust_main->agentnum) ); + $from_addr = $conf->config($opt{'from_config'}, $agentnum); } - $from_addr ||= $conf->invoice_from_full($cust_main->agentnum); + $from_addr ||= $conf->invoice_from_full($agentnum); } # my @cust_msg = (); # if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) { @@ -416,11 +420,11 @@ sub prepare { ->format( HTML::TreeBuilder->new_from_content($body) ) ); ( - 'custnum' => $cust_main->custnum, - 'msgnum' => $self->msgnum, - 'from' => $from_addr, - 'to' => \@to, - 'bcc' => $self->bcc_addr || undef, + 'custnum' => ( $cust_main ? $cust_main->custnum : ''), + 'msgnum' => $self->msgnum, + 'from' => $from_addr, + 'to' => \@to, + 'bcc' => $self->bcc_addr || undef, 'subject' => $subject, 'html_body' => $body, 'text_body' => $text_body diff --git a/FS/FS/o2m_Common.pm b/FS/FS/o2m_Common.pm index 0e03b52ee..4848649d3 100644 --- a/FS/FS/o2m_Common.pm +++ b/FS/FS/o2m_Common.pm @@ -87,7 +87,7 @@ sub process_o2m { foreach my $del_obj ( grep { ! $edits{$_->$table_pkey()} } - qsearch( $table, $hashref ) + $self->process_o2m_qsearch( $table, $hashref ) ) { my $error = $del_obj->delete; if ( $error ) { @@ -97,7 +97,7 @@ sub process_o2m { } foreach my $pkey_value ( keys %edits ) { - my $old_obj = qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } ), + my $old_obj = $self->process_o2m_qsearchs( $table, { %$hashref, $table_pkey => $pkey_value } ); my $add_param = $edits{$pkey_value}; my %hash = ( $table_pkey => $pkey_value, map { $_ => $opt{'params'}->{$add_param."_$_"} } @@ -131,6 +131,9 @@ sub process_o2m { ''; } +sub process_o2m_qsearch { shift->qsearch( @_ ); } +sub process_o2m_qsearchs { shift->qsearchs( @_ ); } + sub _load_table { my( $self, $table ) = @_; eval "use FS::$table"; diff --git a/FS/FS/prospect_contact.pm b/FS/FS/prospect_contact.pm new file mode 100644 index 000000000..6626132dd --- /dev/null +++ b/FS/FS/prospect_contact.pm @@ -0,0 +1,125 @@ +package FS::prospect_contact; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::prospect_contact - Object methods for prospect_contact records + +=head1 SYNOPSIS + + use FS::prospect_contact; + + $record = new FS::prospect_contact \%hash; + $record = new FS::prospect_contact { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::prospect_contact object represents a contact's attachment to a specific +prospect. FS::prospect_contact inherits from FS::Record. The following fields +are currently supported: + +=over 4 + +=item prospectcontactnum + +primary key + +=item prospectnum + +prospectnum + +=item contactnum + +contactnum + +=item classnum + +classnum + +=item comment + +comment + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I 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, L, L + +=cut + +1; + diff --git a/FS/FS/prospect_main.pm b/FS/FS/prospect_main.pm index b160343de..81f71a996 100644 --- a/FS/FS/prospect_main.pm +++ b/FS/FS/prospect_main.pm @@ -269,7 +269,7 @@ sub name { my $self = shift; return $self->company if $self->company; - my $contact = ($self->contact)[0]; #first contact? good enough for now + my $contact = ($self->prospect_contact)[0]->contact; #first contact? good enough for now return $contact->line if $contact; 'Prospect #'. $self->prospectnum; @@ -314,7 +314,7 @@ sub convert_cust_main { my @cust_location = $self->cust_location; #the interface only allows one, so we're just gonna go with that for now - my @contact = $self->contact; + my @contact = map $_->contact, $self->prospect_contact; #XXX define one contact type as "billing", then we could pick just that one my @invoicing_list = map $_->emailaddress, map $_->contact_email, @contact; diff --git a/FS/MANIFEST b/FS/MANIFEST index 6e36c3344..e5e29b4c9 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -834,3 +834,7 @@ FS/svc_circuit.pm t/svc_circuit.t FS/cust_credit_source_bill_pkg.pm t/cust_credit_source_bill_pkg.t +FS/prospect_contact.pm +t/prospect_contact.t +FS/cust_contact.pm +t/cust_contact.t diff --git a/FS/t/cust_contact.t b/FS/t/cust_contact.t new file mode 100644 index 000000000..0e9ea7100 --- /dev/null +++ b/FS/t/cust_contact.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_contact; +$loaded=1; +print "ok 1\n"; diff --git a/FS/t/prospect_contact.t b/FS/t/prospect_contact.t new file mode 100644 index 000000000..dbb12e510 --- /dev/null +++ b/FS/t/prospect_contact.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::prospect_contact; +$loaded=1; +print "ok 1\n"; diff --git a/fs_selfservice/FS-SelfService/SelfService.pm b/fs_selfservice/FS-SelfService/SelfService.pm index f54a1571e..3aa60a0c0 100644 --- a/fs_selfservice/FS-SelfService/SelfService.pm +++ b/fs_selfservice/FS-SelfService/SelfService.pm @@ -30,6 +30,7 @@ $socket .= '.'.$tag if defined $tag && length($tag); 'login' => 'MyAccount/login', 'logout' => 'MyAccount/logout', 'switch_acct' => 'MyAccount/switch_acct', + 'switch_cust' => 'MyAccount/switch_cust', 'customer_info' => 'MyAccount/customer_info', 'customer_info_short' => 'MyAccount/customer_info_short', 'billing_history' => 'MyAccount/billing_history', diff --git a/fs_selfservice/FS-SelfService/cgi/select_cust.html b/fs_selfservice/FS-SelfService/cgi/select_cust.html new file mode 100644 index 000000000..7ab55db45 --- /dev/null +++ b/fs_selfservice/FS-SelfService/cgi/select_cust.html @@ -0,0 +1,38 @@ + + + Select customer + <%= $head %> + + + <%= $body_header %> + +Select customer

+<%= $error %> + +<%= $selfurl =~ s/\?.*//; ''; %> +
+ + + +
<% mt('Tax exempt') |h %> param('setuptax') ? 'CHECKED' : '' %>>
+ + + + + + + + + + +
Customer + +
+ + +<%= $body_footer %> diff --git a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi index 9443a7de7..2337fb51e 100755 --- a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi +++ b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi @@ -81,6 +81,7 @@ my @actions = ( qw( process_change_password customer_suspend_pkg process_suspend_pkg + switch_cust )); my @nologin_actions = (qw( @@ -204,6 +205,12 @@ unless ( $nologin_actions{$action} ) { # at this point $session_id is a real session + if ( ! $login_rv->{'custnum'} && ! $login_rv->{'svcnum'} && $login_rv->{'customers'} ) { + #select a customer if we're a multi-contact customer + do_template('select_cust', { %$login_rv } ); + exit; + } + } warn "calling $action sub\n" @@ -212,6 +219,7 @@ $FS::SelfService::DEBUG = $DEBUG; my $result = eval "&$action();"; die $@ if $@; +use Data::Dumper; warn Dumper($result) if $DEBUG; if ( $result->{error} && ( $result->{error} eq "Can't resume session" @@ -237,7 +245,13 @@ do_template($action, { #-- -use Data::Dumper; +sub switch_cust { + $action = 'myaccount'; + FS::SelfService::switch_cust( 'session_id' => $session_id, + 'custnum' => scalar($cgi->param('custnum')), + ); +} + sub myaccount { customer_info( 'session_id' => $session_id ); } diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi index 353ae1799..da87bfca7 100755 --- a/httemplate/edit/cust_main.cgi +++ b/httemplate/edit/cust_main.cgi @@ -325,8 +325,8 @@ if ( $cgi->param('error') ) { $cust_main->company( $prospect_main->company ); #first contact? -> name - my @contacts = $prospect_main->contact; - my $contact = $contacts[0]; + my @prospect_contacts = $prospect_main->prospect_contact; + my $contact = $prospect_contacts[0]->contact; $cust_main->first( $contact->first ); $cust_main->set( 'last', $contact->get('last') ); #contact phone numbers? diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html index 9e506a731..4d5beee71 100644 --- a/httemplate/edit/elements/edit.html +++ b/httemplate/edit/elements/edit.html @@ -334,6 +334,10 @@ Example: % #any? % 'colspan' => $f->{'colspan'}, % 'required' => $f->{'required'}, +% +% #contact +% 'custnum' => $f->{'custnum'}, +% 'prospectnum' => $f->{'prospectnum'}, % ); % % $include_common{$_} = $f->{$_} foreach grep exists($f->{$_}), diff --git a/httemplate/elements/contact.html b/httemplate/elements/contact.html index 979c26b49..ef74481c0 100644 --- a/httemplate/elements/contact.html +++ b/httemplate/elements/contact.html @@ -9,7 +9,7 @@ - - - + + Username + + + +< /TR> + - Domain + Email address - + -- cgit v1.2.1 From d0ba0fe7d87171e79f0cf38b073b9d454e868b68 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Fri, 6 Feb 2015 16:53:29 -0800 Subject: contacts can be shared among customers / "duplicate contact emails", ng_selfservice login, RT#27943 --- fs_selfservice/FS-SelfService/cgi/select_cust.html | 19 +++++- ng_selfservice/process_login.php | 69 ++++++++++++++++++++-- ng_selfservice/process_select_cust.php | 32 ++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 ng_selfservice/process_select_cust.php diff --git a/fs_selfservice/FS-SelfService/cgi/select_cust.html b/fs_selfservice/FS-SelfService/cgi/select_cust.html index 7ab55db45..03b35c675 100644 --- a/fs_selfservice/FS-SelfService/cgi/select_cust.html +++ b/fs_selfservice/FS-SelfService/cgi/select_cust.html @@ -10,7 +10,7 @@ <%= $error %> <%= $selfurl =~ s/\?.*//; ''; %> -
+ @@ -19,7 +19,7 @@ Customer -
+ + <%= $body_footer %> diff --git a/ng_selfservice/process_login.php b/ng_selfservice/process_login.php index d2d01550d..15b000b14 100644 --- a/ng_selfservice/process_login.php +++ b/ng_selfservice/process_login.php @@ -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']); +?> + + + + +
+ + + + + + + + + + + + + +
Customer + +
+
+ + + + + + diff --git a/ng_selfservice/process_select_cust.php b/ng_selfservice/process_select_cust.php new file mode 100644 index 000000000..fe3612157 --- /dev/null +++ b/ng_selfservice/process_select_cust.php @@ -0,0 +1,32 @@ +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"); + +?> -- cgit v1.2.1 From bc14f5756d854400ed4a5c8019089491c5a418df Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Fri, 6 Feb 2015 18:22:44 -0800 Subject: fix unlikely self-XSS from your own package def names --- httemplate/elements/selectlayers.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httemplate/elements/selectlayers.html b/httemplate/elements/selectlayers.html index cb1d2d619..785ee369e 100644 --- a/httemplate/elements/selectlayers.html +++ b/httemplate/elements/selectlayers.html @@ -121,7 +121,7 @@ Example: + ><% $options->{$option} |h %> % } -- cgit v1.2.1 From 4a9c7f72502c7d39e04c6d810b98000d3caab715 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Sun, 8 Feb 2015 13:44:20 -0800 Subject: credit --- httemplate/docs/credits.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/httemplate/docs/credits.html b/httemplate/docs/credits.html index 158c5ba2d..b5ed451bc 100644 --- a/httemplate/docs/credits.html +++ b/httemplate/docs/credits.html @@ -58,6 +58,7 @@ Charles A. Beasley
Stephen Bechard
Eric Bosrup
Dickie Bradford
+Alex Brelsfoard
Dave Burgess
Joe Camadine
Chris Cappuccio
@@ -91,6 +92,7 @@ Mack Nagashima
David Peters
Matt Peterson
Luke Pfeifer
+Jonathan Prykop
Ricardo Signes
Steve Simitzis
Stanislav Sinyagin
-- cgit v1.2.1 From bb3b0a3d1854fdf8e6de9038cea3f73ac4f9d817 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Sun, 8 Feb 2015 14:18:46 -0800 Subject: paste into masked input fields, #26012 --- httemplate/elements/masked_input_1.1.js | 195 -------------- httemplate/elements/masked_input_1.3.js | 462 ++++++++++++++++++++++++++++++++ httemplate/elements/tr-input-mask.html | 68 +++-- 3 files changed, 501 insertions(+), 224 deletions(-) delete mode 100644 httemplate/elements/masked_input_1.1.js create mode 100644 httemplate/elements/masked_input_1.3.js diff --git a/httemplate/elements/masked_input_1.1.js b/httemplate/elements/masked_input_1.1.js deleted file mode 100644 index 05efa779b..000000000 --- a/httemplate/elements/masked_input_1.1.js +++ /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 index 000000000..54e38ac86 --- /dev/null +++ b/httemplate/elements/masked_input_1.3.js @@ -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)); diff --git a/httemplate/elements/tr-input-mask.html b/httemplate/elements/tr-input-mask.html index 19942b58b..fdd20962d 100644 --- a/httemplate/elements/tr-input-mask.html +++ b/httemplate/elements/tr-input-mask.html @@ -1,52 +1,62 @@ % if ( !$init ) { - % $init++; % } <& /elements/tr-input-text.html, id => $id, @_ &>