X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=09c56474cb9839a9940c4c839090a8907b4f6401;hp=d6e4bc1e3024d9f56abe93025171ff55caef1fae;hb=a1295d0682aa81a408abe06fcaa7c14440f6a2e2;hpb=eb9d5b215af1fbe867b75c12328126f650f9fb06 diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index d6e4bc1e3..09c56474c 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -4,12 +4,13 @@ use strict; use vars qw( @ISA $conf $Debug $import ); use Safe; use Carp; -use Time::Local; +use Time::Local qw(timelocal_nocheck); use Date::Format; #use Date::Manip; use Business::CreditCard; use FS::UID qw( getotaker dbh ); use FS::Record qw( qsearchs qsearch dbdef ); +use FS::Misc qw( send_email ); use FS::cust_pkg; use FS::cust_bill; use FS::cust_bill_pkg; @@ -38,7 +39,8 @@ $Debug = 0; $import = 0; #ask FS::UID to run this stuff for us later -$FS::UID::callback{'FS::cust_main'} = sub { +#$FS::UID::callback{'FS::cust_main'} = sub { +install_callback FS::UID sub { $conf = new FS::Conf; #yes, need it for stuff below (prolly should be cached) }; @@ -158,7 +160,7 @@ FS::Record. The following fields are currently supported: =item ship_fax - phone (optional) -=item payby - `CARD' (credit cards), `CHEK' (electronic check), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L and sets billing type to BILL) +=item payby - I (credit card - automatic), I (credit card - on-demand), I (electronic check - automatic), I (electronic check - on-demand), I (Phone bill billing), I (billing), I (free), or I (special billing type: applies a credit - see L and sets billing type to I) =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L) @@ -172,6 +174,8 @@ FS::Record. The following fields are currently supported: =item comments - comments (optional) +=item referral_custnum - referring customer number + =back =head1 METHODS @@ -274,26 +278,10 @@ sub insert { } # packages - foreach my $cust_pkg ( keys %$cust_pkgs ) { - $cust_pkg->custnum( $self->custnum ); - $error = $cust_pkg->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "inserting cust_pkg (transaction rolled back): $error"; - } - foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) { - $svc_something->pkgnum( $cust_pkg->pkgnum ); - if ( $seconds && $svc_something->isa('FS::svc_acct') ) { - $svc_something->seconds( $svc_something->seconds + $seconds ); - $seconds = 0; - } - $error = $svc_something->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - #return "inserting svc_ (transaction rolled back): $error"; - return $error; - } - } + $error = $self->order_pkgs($cust_pkgs, \$seconds); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; } if ( $seconds ) { @@ -336,6 +324,54 @@ sub insert { } +=item order_pkgs + +document me. like ->insert(%cust_pkg) on an existing record + +=cut + +sub order_pkgs { + my $self = shift; + my $cust_pkgs = shift; + my $seconds = shift; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + foreach my $cust_pkg ( keys %$cust_pkgs ) { + $cust_pkg->custnum( $self->custnum ); + my $error = $cust_pkg->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_pkg (transaction rolled back): $error"; + } + foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) { + $svc_something->pkgnum( $cust_pkg->pkgnum ); + if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) { + $svc_something->seconds( $svc_something->seconds + $$seconds ); + $$seconds = 0; + } + $error = $svc_something->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + #return "inserting svc_ (transaction rolled back): $error"; + return $error; + } + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error +} + =item delete NEW_CUSTNUM This deletes the customer. If there is an error, returns the error, otherwise @@ -482,14 +518,15 @@ sub replace { $self->invoicing_list( $invoicing_list ); } - if ( $self->payby =~ /^(CARD|CHEK)$/ && + if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ && grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) { # card/check info has changed, want to retry realtime_card invoice events #false laziness w/collect foreach my $cust_bill_event ( grep { #$_->part_bill_event->plan eq 'realtime-card' - $_->part_bill_event->eventcode eq '$cust_bill->realtime_card();' + $_->part_bill_event->eventcode =~ + /^\$cust_bill\->realtime_(card|ach|lec)\(\);$/ && $_->status eq 'done' && $_->statustext } @@ -588,13 +625,13 @@ sub check { # bad idea to disable, causes billing to fail because of no tax rates later # unless ( $import ) { - unless ( qsearchs('cust_main_county', { + unless ( qsearch('cust_main_county', { 'country' => $self->country, 'state' => '', } ) ) { return "Unknown state/county/country: ". $self->state. "/". $self->county. "/". $self->country - unless qsearchs('cust_main_county',{ + unless qsearch('cust_main_county',{ 'state' => $self->state, 'county' => $self->county, 'country' => $self->country, @@ -664,11 +701,11 @@ sub check { } } - $self->payby =~ /^(CARD|CHEK|BILL|COMP|PREPAY)$/ + $self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY)$/ or return "Illegal payby: ". $self->payby; $self->payby($1); - if ( $self->payby eq 'CARD' ) { + if ( $self->payby eq 'CARD' || $self->payby eq 'DCRD' ) { my $payinfo = $self->payinfo; $payinfo =~ s/\D//g; @@ -681,14 +718,22 @@ sub check { return gettext('unknown_card_type') if cardtype($self->payinfo) eq "Unknown"; - } elsif ( $self->payby eq 'CHEK' ) { + } elsif ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' ) { my $payinfo = $self->payinfo; - $payinfo =~ s/[\D\@]//g; + $payinfo =~ s/[^\d\@]//g; $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba'; $payinfo = "$1\@$2"; $self->payinfo($payinfo); + } elsif ( $self->payby eq 'LECB' ) { + + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; + $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number'; + $payinfo = $1; + $self->payinfo($payinfo); + } elsif ( $self->payby eq 'BILL' ) { $error = $self->ut_textn('payinfo'); @@ -713,7 +758,7 @@ sub check { if ( $self->paydate eq '' || $self->paydate eq '-' ) { return "Expriation date required" - unless $self->payby =~ /^(BILL|PREPAY|CHEK)$/; + unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/; $self->paydate(''); } else { $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ @@ -726,7 +771,9 @@ sub check { } if ( $self->payname eq '' && $self->payby ne 'CHEK' && - ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) { + ( ! $conf->exists('require_cardname') + || $self->payby !~ /^(CARD|DCRD)$/ ) + ) { $self->payname( $self->first. " ". $self->getfield('last') ); } else { $self->payname =~ /^([\w \,\.\-\']+)$/ @@ -936,6 +983,8 @@ sub bill { my %hash = $cust_pkg->hash; my $old_cust_pkg = new FS::cust_pkg \%hash; + my @details = (); + # bill setup my $setup = 0; unless ( $cust_pkg->setup ) { @@ -946,6 +995,7 @@ sub bill { ": $setup_prog"; }; $setup_prog = $1; + $setup_prog = '0' if $setup_prog =~ /^\s*$/; #my $cpt = new Safe; ##$cpt->permit(); #what is necessary? @@ -966,7 +1016,7 @@ sub bill { my $sdate; if ( $part_pkg->getfield('freq') > 0 && ! $cust_pkg->getfield('susp') && - ( $cust_pkg->getfield('bill') || 0 ) < $time + ( $cust_pkg->getfield('bill') || 0 ) <= $time ) { my $recur_prog = $part_pkg->getfield('recur'); $recur_prog =~ /^(.*)$/ or do { @@ -975,6 +1025,7 @@ sub bill { ": $recur_prog"; }; $recur_prog = $1; + $recur_prog = '0' if $recur_prog =~ /^\s*$/; # shared with $recur_prog $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; @@ -998,11 +1049,13 @@ sub bill { # only for figuring next bill date, nothing else, so, reset $sdate again # here $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + $cust_pkg->last_bill($sdate) + if $cust_pkg->dbdef_table->column('last_bill'); $mon += $part_pkg->freq; until ( $mon < 12 ) { $mon -= 12; $year++; } $cust_pkg->setfield('bill', - timelocal($sec,$min,$hour,$mday,$mon,$year)); + timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); $cust_pkg_mod_flag = 1; } @@ -1029,11 +1082,12 @@ sub bill { } if ( $setup > 0 || $recur > 0 ) { my $cust_bill_pkg = new FS::cust_bill_pkg ({ - 'pkgnum' => $cust_pkg->pkgnum, - 'setup' => $setup, - 'recur' => $recur, - 'sdate' => $sdate, - 'edate' => $cust_pkg->bill, + 'pkgnum' => $cust_pkg->pkgnum, + 'setup' => $setup, + 'recur' => $recur, + 'sdate' => $sdate, + 'edate' => $cust_pkg->bill, + 'details' => \@details, }); push @cust_bill_pkg, $cust_bill_pkg; $total_setup += $setup; @@ -1047,26 +1101,25 @@ sub bill { || $self->payby eq 'COMP' || $taxable_charged == 0 ) { - my $cust_main_county = - qsearchs('cust_main_county',{ + my $cust_main_county = qsearchs('cust_main_county',{ 'state' => $self->state, 'county' => $self->county, 'country' => $self->country, 'taxclass' => $part_pkg->taxclass, - } ) - or qsearchs('cust_main_county',{ + } ); + $cust_main_county ||= qsearchs('cust_main_county',{ 'state' => $self->state, 'county' => $self->county, 'country' => $self->country, 'taxclass' => '', - } ) - or do { - $dbh->rollback if $oldAutoCommit; - return - "fatal: can't find tax rate for state/county/country/taxclass ". - join('/', ( map $self->$_(), qw(state county country) ), - $part_pkg->taxclass ). "\n"; - }; + } ); + unless ( $cust_main_county ) { + $dbh->rollback if $oldAutoCommit; + return + "fatal: can't find tax rate for state/county/country/taxclass ". + join('/', ( map $self->$_(), qw(state county country) ), + $part_pkg->taxclass ). "\n"; + } if ( $cust_main_county->exempt_amount ) { my ($mon,$year) = (localtime($sdate) )[4,5]; @@ -1196,8 +1249,9 @@ sub bill { (Attempt to) collect money for this customer's outstanding invoices (see L). Usually used after the bill method. -Depending on the value of `payby', this may print an invoice (`BILL'), charge -a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP'). +Depending on the value of `payby', this may print or email an invoice (I, +I, or I), charge a credit card (I), charge via electronic +check/ACH (I), or just add any necessary (pseudo-)payment (I). Most actions are now triggered by invoice events; see L and the invoice events web interface. @@ -1325,7 +1379,8 @@ sub collect { my $cust_bill_event = new FS::cust_bill_event { 'invnum' => $cust_bill->invnum, 'eventpart' => $part_bill_event->eventpart, - '_date' => $invoice_time, + #'_date' => $invoice_time, + '_date' => time, 'status' => $status, 'statustext' => $statustext, }; @@ -1354,6 +1409,230 @@ sub collect { } +=item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ] + +Runs a realtime credit card, ACH (electronic check) or phone bill transaction +via a Business::OnlinePayment realtime gateway. See +L for supported gateways. + +Available methods are: I, I and I + +Available options are: I, I, I + +The additional options I, I, I, I, I, +I, I and I are also available. Any of these options, +if set, will override the value from the customer record. + +I is a free-text field passed to the gateway. It defaults to +"Internet services". + +If an I is specified, this payment (if sucessful) is applied to the +specified invoice. If you don't specify an I you might want to +call the B method. + +I can be set true to surpress email decline notices. + +(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too) + +=cut + +sub realtime_bop { + my( $self, $method, $amount, %options ) = @_; + $options{'description'} ||= 'Internet services'; + + #pre-requisites + die "Real-time processing not enabled\n" + unless $conf->exists('business-onlinepayment'); + eval "use Business::OnlinePayment"; + die $@ if $@; + + #overrides + $self->set( $_ => $options{$_} ) + foreach grep { exists($options{$_}) } + qw( payname address1 address2 city state zip payinfo paydate ); + + #load up config + my $bop_config = 'business-onlinepayment'; + $bop_config .= '-ach' + if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach'); + my ( $processor, $login, $password, $action, @bop_options ) = + $conf->config($bop_config); + $action ||= 'normal authorization'; + pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; + + #massage data + + my $address = $self->address1; + $address .= ", ". $self->address2 if $self->address2; + + my($payname, $payfirst, $paylast); + if ( $self->payname && $method ne 'ECHECK' ) { + $payname = $self->payname; + $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ + or return "Illegal payname $payname"; + ($payfirst, $paylast) = ($1, $2); + } else { + $payfirst = $self->getfield('first'); + $paylast = $self->getfield('last'); + $payname = "$payfirst $paylast"; + } + + my @invoicing_list = grep { $_ ne 'POST' } $self->invoicing_list; + if ( $conf->exists('emailinvoiceauto') + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + my $email = $invoicing_list[0]; + + my %content; + if ( $method eq 'CC' ) { + $content{card_number} = $self->payinfo; + $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + } elsif ( $method eq 'ECHECK' ) { + my($account_number,$routing_code) = $self->payinfo; + ( $content{account_number}, $content{routing_code} ) = + split('@', $self->payinfo); + $content{bank_name} = $self->payname; + } elsif ( $method eq 'LEC' ) { + $content{phone} = $self->payinfo; + } + + #transaction(s) + + my( $action1, $action2 ) = split(/\s*\,\s*/, $action ); + + my $transaction = + new Business::OnlinePayment( $processor, @bop_options ); + $transaction->content( + 'type' => $method, + 'login' => $login, + 'password' => $password, + 'action' => $action1, + 'description' => $options{'description'}, + 'amount' => $amount, + 'invoice_number' => $options{'invnum'}, + 'customer_id' => $self->custnum, + 'last_name' => $paylast, + 'first_name' => $payfirst, + 'name' => $payname, + 'address' => $address, + 'city' => $self->city, + 'state' => $self->state, + 'zip' => $self->zip, + 'country' => $self->country, + 'referer' => 'http://cleanwhisker.420.am/', + 'email' => $email, + 'phone' => $self->daytime || $self->night, + %content, #after + ); + $transaction->submit(); + + if ( $transaction->is_success() && $action2 ) { + my $auth = $transaction->authorization; + my $ordernum = $transaction->can('order_number') + ? $transaction->order_number + : ''; + + my $capture = + new Business::OnlinePayment( $processor, @bop_options ); + + my %capture = ( + %content, + type => $method, + action => $action2, + login => $login, + password => $password, + order_number => $ordernum, + amount => $amount, + authorization => $auth, + description => $options{'description'}, + ); + + foreach my $field (qw( authorization_source_code returned_ACI transaction_identifier validation_code + transaction_sequence_num local_transaction_date + local_transaction_time AVS_result_code )) { + $capture{$field} = $transaction->$field() if $transaction->can($field); + } + + $capture->content( %capture ); + + $capture->submit(); + + unless ( $capture->is_success ) { + my $e = "Authorization sucessful but capture failed, custnum #". + $self->custnum. ': '. $capture->result_code. + ": ". $capture->error_message; + warn $e; + return $e; + } + + } + + #result handling + if ( $transaction->is_success() ) { + + my %method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', + ); + + my $cust_pay = new FS::cust_pay ( { + 'custnum' => $self->custnum, + 'invnum' => $options{'invnum'}, + 'paid' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + 'payinfo' => $self->payinfo, + 'paybatch' => "$processor:". $transaction->authorization, + } ); + my $error = $cust_pay->insert; + if ( $error ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH debited but database not updated - '. + 'error applying payment, invnum #' . $self->invnum. + " ($processor): $error"; + warn $e; + return $e; + } else { + return ''; + } + + } else { + + my $perror = "$processor error: ". $transaction->error_message; + + if ( !$options{'quiet'} && $conf->exists('emaildecline') + && grep { $_ ne 'POST' } $self->invoicing_list + ) { + my @templ = $conf->config('declinetemplate'); + my $template = new Text::Template ( + TYPE => 'ARRAY', + SOURCE => [ map "$_\n", @templ ], + ) or return "($perror) can't create template: $Text::Template::ERROR"; + $template->compile() + or return "($perror) can't compile template: $Text::Template::ERROR"; + + my $templ_hash = { error => $transaction->error_message }; + + my $error = send_email( + 'from' => $conf->config('invoice_from'), + 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], + 'subject' => 'Your payment could not be processed', + 'body' => [ $template->fill_in(HASH => $templ_hash) ], + ); + + $perror .= " (also received error sending decline notification: $error)" + if $error; + + } + + return $perror; + } + +} + =item total_owed Returns the total owed for this customer on all invoices