X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=952aa73c2c38658c79466bd35755f569e78d24ea;hb=378bd3dcbe67b0ea00ab65aca2c59ff777137469;hp=bdc181014a34d03de4cbe8dd45cce760d50ba206;hpb=d780e6f463091cd02532ea4750e9484cc26ce9a8;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index bdc181014..952aa73c2 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -19,7 +19,7 @@ use String::Approx qw(amatch); use Business::CreditCard 0.28; use Locale::Country; use Data::Dumper; -use FS::UID qw( getotaker dbh ); +use FS::UID qw( getotaker dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef ); use FS::Misc qw( send_email generate_ps do_print ); use FS::Msgcat qw(gettext); @@ -28,6 +28,7 @@ use FS::cust_svc; use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_pay; +use FS::cust_pay_pending; use FS::cust_pay_void; use FS::cust_pay_batch; use FS::cust_credit; @@ -1290,58 +1291,60 @@ sub check { } - my @addfields = qw( - last first company address1 address2 city county state zip - country daytime night fax - ); + if ( $self->has_ship_address + && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") } + $self->addr_fields ) + ) + { + my $error = + $self->ut_name('ship_last') + || $self->ut_name('ship_first') + || $self->ut_textn('ship_company') + || $self->ut_text('ship_address1') + || $self->ut_textn('ship_address2') + || $self->ut_text('ship_city') + || $self->ut_textn('ship_county') + || $self->ut_textn('ship_state') + || $self->ut_country('ship_country') + ; + return $error if $error; - if ( defined $self->dbdef_table->column('ship_last') ) { - if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") } - @addfields ) - && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields ) - ) - { - my $error = - $self->ut_name('ship_last') - || $self->ut_name('ship_first') - || $self->ut_textn('ship_company') - || $self->ut_text('ship_address1') - || $self->ut_textn('ship_address2') - || $self->ut_text('ship_city') - || $self->ut_textn('ship_county') - || $self->ut_textn('ship_state') - || $self->ut_country('ship_country') - ; - return $error if $error; + #false laziness with above + unless ( qsearchs('cust_main_county', { + 'country' => $self->ship_country, + 'state' => '', + } ) ) { + return "Unknown ship_state/ship_county/ship_country: ". + $self->ship_state. "/". $self->ship_county. "/". $self->ship_country + unless qsearch('cust_main_county',{ + 'state' => $self->ship_state, + 'county' => $self->ship_county, + 'country' => $self->ship_country, + } ); + } + #eofalse - #false laziness with above - unless ( qsearchs('cust_main_county', { - 'country' => $self->ship_country, - 'state' => '', - } ) ) { - return "Unknown ship_state/ship_county/ship_country: ". - $self->ship_state. "/". $self->ship_county. "/". $self->ship_country - unless qsearch('cust_main_county',{ - 'state' => $self->ship_state, - 'county' => $self->ship_county, - 'country' => $self->ship_country, - } ); - } - #eofalse - - $error = - $self->ut_phonen('ship_daytime', $self->ship_country) - || $self->ut_phonen('ship_night', $self->ship_country) - || $self->ut_phonen('ship_fax', $self->ship_country) - || $self->ut_zip('ship_zip', $self->ship_country) - ; - return $error if $error; + $error = + $self->ut_phonen('ship_daytime', $self->ship_country) + || $self->ut_phonen('ship_night', $self->ship_country) + || $self->ut_phonen('ship_fax', $self->ship_country) + || $self->ut_zip('ship_zip', $self->ship_country) + ; + return $error if $error; + + return "Unit # is required." + if $self->ship_address2 =~ /^\s*$/ + && $conf->exists('cust_main-require_address2'); + + } else { # ship_ info eq billing info, so don't store dup info in database + + $self->setfield("ship_$_", '') + foreach $self->addr_fields; + + return "Unit # is required." + if $self->address2 =~ /^\s*$/ + && $conf->exists('cust_main-require_address2'); - } else { # ship_ info eq billing info, so don't store dup info in database - $self->setfield("ship_$_", '') - foreach qw( last first company address1 address2 city county state zip - country daytime night fax ); - } } #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/ @@ -1542,6 +1545,30 @@ sub check { $self->SUPER::check; } +=item addr_fields + +Returns a list of fields which have ship_ duplicates. + +=cut + +sub addr_fields { + qw( last first company + address1 address2 city county state zip country + daytime night fax + ); +} + +=item has_ship_address + +Returns true if this customer record has a separate shipping address. + +=cut + +sub has_ship_address { + my $self = shift; + scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields ); +} + =item all_pkgs Returns all packages (see L) for this customer. @@ -1681,7 +1708,8 @@ sub num_ncancelled_pkgs { } sub num_pkgs { - my( $self, $sql ) = @_; + my( $self ) = shift; + my $sql = scalar(@_) ? shift : ''; $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i; my $sth = dbh->prepare( "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql" @@ -2731,7 +2759,8 @@ sub due_cust_event { if $DEBUG; # > 1; warn " invalid conditions not eliminated with condition_sql:\n". - join('', map " $_: ".$unsat{$_}."\n", keys %unsat ); + join('', map " $_: ".$unsat{$_}."\n", keys %unsat ) + if $DEBUG; # > 1; ## # 3: insert @@ -2818,6 +2847,7 @@ sub retry_realtime { my @cust_event = qsearchs({ 'table' => 'cust_event', + 'select' => 'cust_event.*', 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join", 'hashref' => { 'status' => 'done' }, 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ". @@ -2857,7 +2887,7 @@ L for supported gateways. Available methods are: I, I and I -Available options are: I, I, I +Available options are: I, I, I, I, I The additional options I, I, I, I, I, I, I and I are also available. Any of these options, @@ -2872,6 +2902,11 @@ call the B method. I can be set true to surpress email decline notices. +I can be set to a scalar reference. It will be filled in with the +resulting paynum, if any. + +I is a unique identifier for this payment. + (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too) =cut @@ -2885,6 +2920,8 @@ sub realtime_bop { $options{'description'} ||= 'Internet services'; + return $self->fake_bop($method, $amount, %options) if $options{'fake'}; + eval "use Business::OnlinePayment"; die $@ if $@; @@ -3018,6 +3055,10 @@ sub realtime_bop { $content{invoice_number} = $options{'invnum'} if exists($options{'invnum'}) && length($options{'invnum'}); + $content{email_customer} = + ( $conf->exists('business-onlinepayment-email_customer') + || $conf->exists('business-onlinepayment-email-override') ); + my $paydate = ''; if ( $method eq 'CC' ) { @@ -3031,7 +3072,7 @@ sub realtime_bop { my $paycvv = exists($options{'paycvv'}) ? $options{'paycvv'} : $self->paycvv; - $content{cvv2} = $self->paycvv + $content{cvv2} = $paycvv if length($paycvv); my $paystart_month = exists($options{'paystart_month'}) @@ -3090,6 +3131,49 @@ sub realtime_bop { # run transaction(s) ### + my $balance = exists( $options{'balance'} ) + ? $options{'balance'} + : $self->balance; + + $self->select_for_update; #mutex ... just until we get our pending record in + + #the checks here are intended to catch concurrent payments + #double-form-submission prevention is taken care of in cust_pay_pending::check + + #check the balance + return "The customer's balance has changed; $method transaction aborted." + if $self->balance < $balance; + #&& $self->balance < $amount; #might as well anyway? + + #also check and make sure there aren't *other* pending payments for this cust + + my @pending = qsearch('cust_pay_pending', { + 'custnum' => $self->custnum, + 'status' => { op=>'!=', value=>'done' } + }); + return "A payment is already being processed for this customer (". + join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ). + "); $method transaction aborted." + if scalar(@pending); + + #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out + + my $cust_pay_pending = new FS::cust_pay_pending { + 'custnum' => $self->custnum, + #'invnum' => $options{'invnum'}, + 'paid' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + 'payinfo' => $payinfo, + 'paydate' => $paydate, + 'status' => 'new', + 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ), + }; + $cust_pay_pending->payunique( $options{payunique} ) + if length($options{payunique}); + my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted + return $cpp_new_err if $cpp_new_err; + my( $action1, $action2 ) = split(/\s*\,\s*/, $action ); my $transaction = new Business::OnlinePayment( $processor, @bop_options ); @@ -3123,9 +3207,33 @@ sub realtime_bop { 'phone' => $self->daytime || $self->night, %content, #after ); - $transaction->submit(); + + $cust_pay_pending->status('pending'); + my $cpp_pending_err = $cust_pay_pending->replace; + return $cpp_pending_err if $cpp_pending_err; + + #config? + my $BOP_TESTING = 0; + my $BOP_TESTING_SUCCESS = 1; + + unless ( $BOP_TESTING ) { + $transaction->submit(); + } else { + if ( $BOP_TESTING_SUCCESS ) { + $transaction->is_success(1); + $transaction->authorization('fake auth'); + } else { + $transaction->is_success(0); + $transaction->error_message('fake failure'); + } + } if ( $transaction->is_success() && $action2 ) { + + $cust_pay_pending->status('authorized'); + my $cpp_authorized_err = $cust_pay_pending->replace; + return $cpp_authorized_err if $cpp_authorized_err; + my $auth = $transaction->authorization; my $ordernum = $transaction->can('order_number') ? $transaction->order_number @@ -3167,6 +3275,10 @@ sub realtime_bop { } + $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined'); + my $cpp_captured_err = $cust_pay_pending->replace; + return $cpp_captured_err if $cpp_captured_err; + ### # remove paycvv after initial transaction ### @@ -3189,12 +3301,6 @@ sub realtime_bop { if ( $transaction->is_success() ) { - my %method2payby = ( - 'CC' => 'CARD', - 'ECHECK' => 'CHEK', - 'LEC' => 'LECB', - ); - my $paybatch = ''; if ( $payment_gateway ) { # agent override $paybatch = $payment_gateway->gatewaynum. '-'; @@ -3210,14 +3316,21 @@ sub realtime_bop { 'custnum' => $self->custnum, 'invnum' => $options{'invnum'}, 'paid' => $amount, - '_date' => '', + '_date' => '', 'payby' => $method2payby{$method}, 'payinfo' => $payinfo, 'paybatch' => $paybatch, 'paydate' => $paydate, } ); + #doesn't hurt to know, even though the dup check is in cust_pay_pending now $cust_pay->payunique( $options{payunique} ) if length($options{payunique}); + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction + my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () ); if ( $error ) { @@ -3226,16 +3339,41 @@ sub realtime_bop { ( 'manual' => 1 ) : () ); if ( $error2 ) { - # gah, even with transactions. - my $e = 'WARNING: Card/ACH debited but database not updated - '. + # gah. but at least we have a record of the state we had to abort in + # from cust_pay_pending now. + my $e = "WARNING: $method captured but payment not recorded - ". "error inserting payment ($processor): $error2". " (previously tried insert with invnum #$options{'invnum'}" . - ": $error )"; + ": $error ) - pending payment saved as paypendingnum ". + $cust_pay_pending->paypendingnum. "\n"; warn $e; return $e; } } - return ''; #no error + + if ( $options{'paynum_ref'} ) { + ${ $options{'paynum_ref'} } = $cust_pay->paynum; + } + + $cust_pay_pending->status('done'); + $cust_pay_pending->statustext('captured'); + my $cpp_done_err = $cust_pay_pending->replace; + + if ( $cpp_done_err ) { + + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; + my $e = "WARNING: $method captured but payment not recorded - ". + "error updating status for paypendingnum ". + $cust_pay_pending->paypendingnum. ": $cpp_done_err \n"; + warn $e; + return $e; + + } else { + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + return ''; #no error + + } } else { @@ -3296,12 +3434,93 @@ sub realtime_bop { if $error; } - + + $cust_pay_pending->status('done'); + $cust_pay_pending->statustext("declined: $perror"); + my $cpp_done_err = $cust_pay_pending->replace; + if ( $cpp_done_err ) { + my $e = "WARNING: $method declined but pending payment not resolved - ". + "error updating status for paypendingnum ". + $cust_pay_pending->paypendingnum. ": $cpp_done_err \n"; + warn $e; + $perror = "$e ($perror)"; + } + return $perror; } } +=item fake_bop + +=cut + +sub fake_bop { + my( $self, $method, $amount, %options ) = @_; + + if ( $options{'fake_failure'} ) { + return "Error: No error; test failure requested with fake_failure"; + } + + my %method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', + ); + + #my $paybatch = ''; + #if ( $payment_gateway ) { # agent override + # $paybatch = $payment_gateway->gatewaynum. '-'; + #} + # + #$paybatch .= "$processor:". $transaction->authorization; + # + #$paybatch .= ':'. $transaction->order_number + # if $transaction->can('order_number') + # && length($transaction->order_number); + + my $paybatch = 'FakeProcessor:54:32'; + + my $cust_pay = new FS::cust_pay ( { + 'custnum' => $self->custnum, + 'invnum' => $options{'invnum'}, + 'paid' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + #'payinfo' => $payinfo, + 'payinfo' => '4111111111111111', + 'paybatch' => $paybatch, + #'paydate' => $paydate, + 'paydate' => '2012-05-01', + } ); + $cust_pay->payunique( $options{payunique} ) if length($options{payunique}); + + my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () ); + + if ( $error ) { + $cust_pay->invnum(''); #try again with no specific invnum + my $error2 = $cust_pay->insert( $options{'manual'} ? + ( 'manual' => 1 ) : () + ); + if ( $error2 ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH debited but database not updated - '. + "error inserting (fake!) payment: $error2". + " (previously tried insert with invnum #$options{'invnum'}" . + ": $error )"; + warn $e; + return $e; + } + } + + if ( $options{'paynum_ref'} ) { + ${ $options{'paynum_ref'} } = $cust_pay->paynum; + } + + return ''; #no error + +} + =item default_payment_gateway =cut @@ -3315,7 +3534,7 @@ sub default_payment_gateway { #load up config my $bop_config = 'business-onlinepayment'; $bop_config .= '-ach' - if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach'); + if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach'); my ( $processor, $login, $password, $action, @bop_options ) = $conf->config($bop_config); $action ||= 'normal authorization'; @@ -3696,6 +3915,8 @@ sub batch_card { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #this needs to handle mysql as well as Pg, like svc_acct.pm + #(make it into a common function if folks need to do batching with mysql) $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE") or return "Cannot lock pay_batch: " . $dbh->errstr; @@ -4394,13 +4615,13 @@ otherwise returns false. =cut sub credit { - my( $self, $amount, $reason ) = @_; + my( $self, $amount, $reason, %options ) = @_; my $cust_credit = new FS::cust_credit { 'custnum' => $self->custnum, 'amount' => $amount, 'reason' => $reason, }; - $cust_credit->insert; + $cust_credit->insert(%options); } =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ] @@ -5068,6 +5289,9 @@ sub smart_search { } } elsif ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search + # (also try agent_custid) + # (regex needs tweaking if your + # legacy cust numbers have letters) push @cust_main, qsearch( { 'table' => 'cust_main', @@ -5075,6 +5299,12 @@ sub smart_search { 'extra_sql' => " AND $agentnums_sql", #agent virtualization } ); + push @cust_main, qsearch( { + 'table' => 'cust_main', + 'hashref' => { 'agent_custid' => $1, %options }, + 'extra_sql' => " AND $agentnums_sql", #agent virtualization + } ); + } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) { my($company, $last, $first) = ( $1, $2, $3 ); @@ -5372,6 +5602,18 @@ sub batch_import { svc_acct.username svc_acct._password ); $payby = 'BILL'; + } elsif ( $format eq 'extended-plus_company' ) { + @fields = qw( agent_custid refnum + last first company address1 address2 city state zip country + daytime night + ship_last ship_first ship_company ship_address1 ship_address2 + ship_city ship_state ship_zip ship_country + payinfo paycvv paydate + invoicing_list + cust_pkg.pkgpart + svc_acct.username svc_acct._password + ); + $payby = 'BILL'; } else { die "unknown format $format"; } @@ -5670,7 +5912,11 @@ sub notify { $notify_template->compile() or die "can't compile template: Text::Template::ERROR"; - my $paydate = $customer->paydate; + $FS::notify_template::_template::company_name = $conf->config('company_name'); + $FS::notify_template::_template::company_address = + join("\n", $conf->config('company_address') ). "\n"; + + my $paydate = $customer->paydate || '2037-12-31'; $FS::notify_template::_template::first = $customer->first; $FS::notify_template::_template::last = $customer->last; $FS::notify_template::_template::company = $customer->company; @@ -5723,7 +5969,7 @@ I<$payby> - a description of the method of payment for the customer # would be nice to use FS::payby::shortname I<$payinfo> - the masked account information used to collect for this customer I<$expdate> - the expiration of the customer payment method in seconds from epoch -I<$returnaddress> - the return address defaults to invoice_latexreturnaddress +I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address =cut @@ -5745,8 +5991,8 @@ sub generate_letter { my %letter_data = map { $_ => $self->$_ } $self->fields; $letter_data{payinfo} = $self->mask_payinfo; - #my $paydate = $self->paydate || '2037-12'; - my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12'; + #my $paydate = $self->paydate || '2037-12-31'; + my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31'; my $payby = $self->payby; my ($payyear,$paymonth,$payday) = split (/-/,$paydate); @@ -5773,12 +6019,22 @@ sub generate_letter { my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress', $self->agent_template) ); - - $letter_data{returnaddress} = length($retadd) ? $retadd : '~'; + if ( length($retadd) ) { + $letter_data{returnaddress} = $retadd; + } elsif ( grep /\S/, $conf->config('company_address') ) { + $letter_data{returnaddress} = + join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg, + $conf->config('company_address') + ); + } else { + $letter_data{returnaddress} = '~'; + } } $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc"; + $letter_data{company_name} = $conf->config('company_name'); + my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc; my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX', DIR => $dir, @@ -5842,9 +6098,20 @@ sub _agent_plandata { #yuck. this whole thing needs to be reconciled better with 1.9's idea of #agent-specific Conf + + use FS::part_event::Condition; my $agentnum = $self->agentnum; + my $regexp = ''; + if ( driver_name =~ /^Pg/i ) { + $regexp = '~'; + } elsif ( driver_name =~ /^mysql/i ) { + $regexp = 'REGEXP'; + } else { + die "don't know how to use regular expressions in ". driver_name. " databases"; + } + my $part_event_option = qsearchs({ 'select' => 'part_event_option.*', @@ -5854,7 +6121,7 @@ sub _agent_plandata { LEFT JOIN part_event_option AS peo_agentnum ON ( part_event.eventpart = peo_agentnum.eventpart AND peo_agentnum.optionname = 'agentnum' - AND peo_agentnum.optionvalue ~ '(^|,)}. $agentnum. q{(,|$)' + AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)' ) LEFT JOIN part_event_option AS peo_cust_bill_age ON ( part_event.eventpart = peo_cust_bill_age.eventpart @@ -5872,13 +6139,8 @@ sub _agent_plandata { " ORDER BY CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age' THEN -1 - ELSE EXTRACT( EPOCH FROM - REPLACE( peo_cust_bill_age.optionvalue, - 'm', - 'mon' - )::interval - ) - END + ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue'). + " END , part_event.weight". " LIMIT 1" });