X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=ed16e1b9e21cc5e5e719cc0db8eb334a9c6cded6;hb=74cb9e1c3974d8899bf9745564d0dfce5875454c;hp=e7b34459c6b8fdcd4299a27fab0a428b20d3759f;hpb=5da73ac30a52234cc126ead03cddaf5a4e131019;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index e7b34459c..ed16e1b9e 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -9,14 +9,12 @@ use Safe; use Carp; use Exporter; use Scalar::Util qw( blessed ); -use Time::Local qw(timelocal_nocheck); +use Time::Local qw(timelocal); use Data::Dumper; use Tie::IxHash; use Digest::MD5 qw(md5_base64); use Date::Format; -use Date::Parse; #use Date::Manip; -use File::Slurp qw( slurp ); use File::Temp qw( tempfile ); use String::Approx qw(amatch); use Business::CreditCard 0.28; @@ -25,10 +23,14 @@ use FS::UID qw( getotaker dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef ); use FS::Misc qw( generate_email send_email generate_ps do_print ); use FS::Msgcat qw(gettext); +use FS::payby; use FS::cust_pkg; use FS::cust_svc; use FS::cust_bill; use FS::cust_bill_pkg; +use FS::cust_bill_pkg_display; +use FS::cust_bill_pkg_tax_location; +use FS::cust_bill_pkg_tax_rate_location; use FS::cust_pay; use FS::cust_pay_pending; use FS::cust_pay_void; @@ -37,6 +39,12 @@ use FS::cust_credit; use FS::cust_refund; use FS::part_referral; use FS::cust_main_county; +use FS::cust_location; +use FS::cust_main_exemption; +use FS::tax_rate; +use FS::tax_rate_location; +use FS::cust_tax_location; +use FS::part_pkg_taxrate; use FS::agent; use FS::cust_main_invoice; use FS::cust_credit_bill; @@ -71,6 +79,8 @@ $skip_fuzzyfiles = 0; $ignore_expired_card = 0; @encrypted_fields = ('payinfo', 'paycvv'); +sub nohistory_fields { ('paycvv'); } + @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings'); #ask FS::UID to run this stuff for us later @@ -133,99 +143,181 @@ FS::Record. The following fields are currently supported: =over 4 -=item custnum - primary key (assigned automatically for new customers) +=item custnum + +Primary key (assigned automatically for new customers) + +=item agentnum + +Agent (see L) + +=item refnum + +Advertising source (see L) + +=item first -=item agentnum - agent (see L) +First name -=item refnum - Advertising source (see L) +=item last -=item first - name +Last name -=item last - name +=item ss -=item ss - social security number (optional) +Cocial security number (optional) -=item company - (optional) +=item company + +(optional) =item address1 -=item address2 - (optional) +=item address2 + +(optional) =item city -=item county - (optional, see L) +=item county + +(optional, see L) + +=item state -=item state - (see L) +(see L) =item zip -=item country - (see L) +=item country + +(see L) + +=item daytime + +phone (optional) + +=item night -=item daytime - phone (optional) +phone (optional) -=item night - phone (optional) +=item fax -=item fax - phone (optional) +phone (optional) -=item ship_first - name +=item ship_first -=item ship_last - name +Shipping first name -=item ship_company - (optional) +=item ship_last + +Shipping last name + +=item ship_company + +(optional) =item ship_address1 -=item ship_address2 - (optional) +=item ship_address2 + +(optional) =item ship_city -=item ship_county - (optional, see L) +=item ship_county -=item ship_state - (see L) +(optional, see L) + +=item ship_state + +(see L) =item ship_zip -=item ship_country - (see L) +=item ship_country + +(see L) + +=item ship_daytime -=item ship_daytime - phone (optional) +phone (optional) -=item ship_night - phone (optional) +=item ship_night -=item ship_fax - phone (optional) +phone (optional) -=item payby - Payment Type (See L for valid payby values) +=item ship_fax -=item payinfo - Payment Information (See L for data format) +phone (optional) -=item paymask - Masked payinfo (See L for how this works) +=item payby + +Payment Type (See L for valid payby values) + +=item payinfo + +Payment Information (See L for data format) + +=item paymask + +Masked payinfo (See L for how this works) =item paycvv Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card -=item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy +=item paydate + +Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy + +=item paystart_month -=item paystart_month - start date month (maestro/solo cards only) +Start date month (maestro/solo cards only) -=item paystart_year - start date year (maestro/solo cards only) +=item paystart_year -=item payissue - issue number (maestro/solo cards only) +Start date year (maestro/solo cards only) -=item payname - name on card or billing name +=item payissue -=item payip - IP address from which payment information was received +Issue number (maestro/solo cards only) -=item tax - tax exempt, empty or `Y' +=item payname -=item otaker - order taker (assigned automatically, see L) +Name on card or billing name -=item comments - comments (optional) +=item payip -=item referral_custnum - referring customer number +IP address from which payment information was received -=item spool_cdr - Enable individual CDR spooling, empty or `Y' +=item tax -=item squelch_cdr - Discourage individual CDR printing, empty or `Y' +Tax exempt, empty or `Y' + +=item otaker + +Order taker (assigned automatically, see L) + +=item comments + +Comments (optional) + +=item referral_custnum + +Referring customer number + +=item spool_cdr + +Enable individual CDR spooling, empty or `Y' + +=item dundate + +A suggestion to events (see L) to delay until this unix timestamp + +=item squelch_cdr + +Discourage individual CDR printing, empty or `Y' =back @@ -272,7 +364,7 @@ invoicing_list destination to the newly-created svc_acct. Here's an example: $cust_main->insert( {}, [ $email, 'POST' ] ); -Currently available options are: I and I. +Currently available options are: I, I and I. If I is set, all provisioning jobs will have a dependancy on the supplied jobnum (they will not run until the specific job completes). @@ -283,6 +375,9 @@ The I option is deprecated. If I is set true, no provisioning jobs (exports) are scheduled. (You can schedule them later with the B method.) +The I option can be set to an arrayref of tax names. +FS::cust_main_exemption records will be created and inserted. + =cut sub insert { @@ -306,7 +401,7 @@ sub insert { my $dbh = dbh; my $prepay_identifier = ''; - my( $amount, $seconds ) = ( 0, 0 ); + my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = (0, 0, 0, 0, 0); my $payby = ''; if ( $self->payby eq 'PREPAY' ) { @@ -317,7 +412,13 @@ sub insert { warn " looking up prepaid card $prepay_identifier\n" if $DEBUG > 1; - my $error = $self->get_prepay($prepay_identifier, \$amount, \$seconds); + my $error = $self->get_prepay( $prepay_identifier, + 'amount_ref' => \$amount, + 'seconds_ref' => \$seconds, + 'upbytes_ref' => \$upbytes, + 'downbytes_ref' => \$downbytes, + 'totalbytes_ref' => \$totalbytes, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; #return "error applying prepaid card (transaction rolled back): $error"; @@ -339,6 +440,9 @@ sub insert { $self->signupdate(time) unless $self->signupdate; + $self->auto_agent_custid() + if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid; + my $error = $self->SUPER::insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -359,6 +463,24 @@ sub insert { $self->invoicing_list( $invoicing_list ); } + warn " setting cust_main_exemption\n" + if $DEBUG > 1; + + my $tax_exemption = delete $options{'tax_exemption'}; + if ( $tax_exemption ) { + foreach my $taxname ( @$tax_exemption ) { + my $cust_main_exemption = new FS::cust_main_exemption { + 'custnum' => $self->custnum, + 'taxname' => $taxname, + }; + my $error = $cust_main_exemption->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_main_exemption (transaction rolled back): $error"; + } + } + } + if ( $conf->config('cust_main-skeleton_tables') && $conf->config('cust_main-skeleton_custnum') ) { @@ -376,7 +498,13 @@ sub insert { warn " ordering packages\n" if $DEBUG > 1; - $error = $self->order_pkgs($cust_pkgs, \$seconds, %options); + $error = $self->order_pkgs( $cust_pkgs, + %options, + 'seconds_ref' => \$seconds, + 'upbytes_ref' => \$upbytes, + 'downbytes_ref' => \$downbytes, + 'totalbytes_ref' => \$totalbytes, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -386,6 +514,10 @@ sub insert { $dbh->rollback if $oldAutoCommit; return "No svc_acct record to apply pre-paid time"; } + if ( $upbytes || $downbytes || $totalbytes ) { + $dbh->rollback if $oldAutoCommit; + return "No svc_acct record to apply pre-paid data"; + } if ( $amount ) { warn " inserting initial $payby payment of $amount\n" @@ -415,6 +547,35 @@ sub insert { } +use File::CounterFile; +sub auto_agent_custid { + my $self = shift; + + my $format = $conf->config('cust_main-auto_agent_custid'); + my $agent_custid; + if ( $format eq '1YMMXXXXXXXX' ) { + + my $counter = new File::CounterFile 'cust_main.agent_custid'; + $counter->lock; + + my $ym = 100000000000 + time2str('%y%m00000000', time); + if ( $ym > $counter->value ) { + $counter->{'value'} = $agent_custid = $ym; + $counter->{'updated'} = 1; + } else { + $agent_custid = $counter->inc; + } + + $counter->unlock; + + } else { + die "Unknown cust_main-auto_agent_custid format: $format"; + } + + $self->agent_custid($agent_custid); + +} + sub start_copy_skel { my $self = shift; @@ -542,12 +703,129 @@ sub _copy_skel { } -=item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ] +=item order_pkg HASHREF | OPTION => VALUE ... + +Orders a single package. + +Options may be passed as a list of key/value pairs or as a hash reference. +Options are: + +=over 4 + +=item cust_pkg + +FS::cust_pkg object + +=item cust_location + +Optional FS::cust_location object + +=item svcs + +Optional arryaref of FS::svc_* service objects. + +=item depend_jobnum + +If this option is set to a job queue jobnum (see L), all provisioning +jobs will have a dependancy on the supplied job (they will not run until the +specific job completes). This can be used to defer provisioning until some +action completes (such as running the customer's credit card successfully). + +=item ticket_subject + +Optional subject for a ticket created and attached to this customer + +=item ticket_subject + +Optional queue name for ticket additions + +=back + +=cut + +sub order_pkg { + my $self = shift; + my $opt = ref($_[0]) ? shift : { @_ }; + + warn "$me order_pkg called with options ". + join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n" + if $DEBUG; + + my $cust_pkg = $opt->{'cust_pkg'}; + my $svcs = $opt->{'svcs'} || []; + + my %svc_options = (); + $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'} + if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'}; + + my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () } + qw( ticket_subject ticket_queue ); + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + if ( $opt->{'cust_location'} && + ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) { + my $error = $opt->{'cust_location'}->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_location (transaction rolled back): $error"; + } + $cust_pkg->locationnum($opt->{'cust_location'}->locationnum); + } + + $cust_pkg->custnum( $self->custnum ); + + my $error = $cust_pkg->insert( %insert_params ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_pkg (transaction rolled back): $error"; + } + + foreach my $svc_something ( @{ $opt->{'svcs'} } ) { + if ( $svc_something->svcnum ) { + my $old_cust_svc = $svc_something->cust_svc; + my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash }; + $new_cust_svc->pkgnum( $cust_pkg->pkgnum); + $error = $new_cust_svc->replace($old_cust_svc); + } else { + $svc_something->pkgnum( $cust_pkg->pkgnum ); + if ( $svc_something->isa('FS::svc_acct') ) { + foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } } + qw( seconds upbytes downbytes totalbytes ) ) { + $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } ); + ${ $opt->{$_.'_ref'} } = 0; + } + } + $error = $svc_something->insert(%svc_options); + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting svc_ (transaction rolled back): $error"; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; #no error + +} + +#deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ] +=item order_pkgs HASHREF [ , OPTION => VALUE ... ] -Like the insert method on an existing record, this method orders a package -and included services atomicaly. Pass a Tie::RefHash data structure to this -method containing FS::cust_pkg and FS::svc_I objects. There should -be a better explanation of this, but until then, here's an example: +Like the insert method on an existing record, this method orders multiple +packages and included services atomicaly. Pass a Tie::RefHash data structure +to this method containing FS::cust_pkg and FS::svc_I objects. +There should be a better explanation of this, but until then, here's an +example: use Tie::RefHash; tie %hash, 'Tie::RefHash'; #this part is important @@ -555,12 +833,13 @@ be a better explanation of this, but until then, here's an example: $cust_pkg => [ $svc_acct ], ... ); - $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 ); + $cust_main->order_pkgs( \%hash, 'noexport'=>1 ); Services can be new, in which case they are inserted, or existing unaudited services, in which case they are linked to the newly-created package. -Currently available options are: I and I. +Currently available options are: I, I, I, +I, I, and I. If I is set, all provisioning jobs will have a dependancy on the supplied jobnum (they will not run until the specific job completes). @@ -573,16 +852,19 @@ the B method for each cust_pkg object. Using the B method on the cust_main object is not recommended, as existing services will also be reexported.) +If I, I, I, or I is +provided, the scalars (provided by references) will be incremented by the +values of the prepaid card.` + =cut sub order_pkgs { my $self = shift; my $cust_pkgs = shift; - my $seconds = shift; + my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated my %options = @_; - my %svc_options = (); - $svc_options{'depend_jobnum'} = $options{'depend_jobnum'} - if exists $options{'depend_jobnum'}; + $seconds_ref ||= $options{'seconds_ref'}; + warn "$me order_pkgs called with options ". join(', ', map { "$_: $options{$_}" } keys %options ). "\n" if $DEBUG; @@ -601,32 +883,20 @@ sub order_pkgs { local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'}; foreach my $cust_pkg ( keys %$cust_pkgs ) { - $cust_pkg->custnum( $self->custnum ); - my $error = $cust_pkg->insert; + + my $error = $self->order_pkg( + 'cust_pkg' => $cust_pkg, + 'svcs' => $cust_pkgs->{$cust_pkg}, + 'seconds_ref' => $seconds_ref, + map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref + depend_jobnum + ) + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "inserting cust_pkg (transaction rolled back): $error"; - } - foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) { - if ( $svc_something->svcnum ) { - my $old_cust_svc = $svc_something->cust_svc; - my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash }; - $new_cust_svc->pkgnum( $cust_pkg->pkgnum); - $error = $new_cust_svc->replace($old_cust_svc); - } else { - $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(%svc_options); - } - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - #return "inserting svc_ (transaction rolled back): $error"; - return $error; - } + return $error; } + } $dbh->commit or die $dbh->errstr if $oldAutoCommit; @@ -640,13 +910,14 @@ L), specified either by I or as an FS::prepay_credit object. If there is an error, returns the error, otherwise returns false. -Optionally, four scalar references can be passed as well. They will have their -values filled in with the amount, number of seconds, and number of upload and -download bytes applied by this prepaid -card. +Optionally, five scalar references can be passed as well. They will have their +values filled in with the amount, number of seconds, and number of upload, +download, and total bytes applied by this prepaid card. =cut +#the ref bullshit here should be refactored like get_prepay. MyAccount.pm is +#the only place that uses these args sub recharge_prepay { my( $self, $prepay_credit, $amountref, $secondsref, $upbytesref, $downbytesref, $totalbytesref ) = @_; @@ -664,8 +935,13 @@ sub recharge_prepay { my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 ); - my $error = $self->get_prepay($prepay_credit, \$amount, - \$seconds, \$upbytes, \$downbytes, \$totalbytes) + my $error = $self->get_prepay( $prepay_credit, + 'amount_ref' => \$amount, + 'seconds_ref' => \$seconds, + 'upbytes_ref' => \$upbytes, + 'downbytes_ref' => \$downbytes, + 'totalbytes_ref' => \$totalbytes, + ) || $self->increment_seconds($seconds) || $self->increment_upbytes($upbytes) || $self->increment_downbytes($downbytes) @@ -692,13 +968,13 @@ sub recharge_prepay { } -=item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ , AMOUNTREF, SECONDSREF +=item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , OPTION => VALUE ... ] Looks up and deletes a prepaid card (see L), specified either by I or as an FS::prepay_credit object. -References to I and I scalars should be passed as arguments -and will be incremented by the values of the prepaid card. +Available options are: I, I, I, I, and I. The scalars (provided by references) will be +incremented by the values of the prepaid card. If the prepaid card specifies an I (see L), it is used to check or set this customer's I. @@ -709,8 +985,7 @@ If there is an error, returns the error, otherwise returns false. sub get_prepay { - my( $self, $prepay_credit, $amountref, $secondsref, - $upref, $downref, $totalref) = @_; + my( $self, $prepay_credit, %opt ) = @_; local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -755,11 +1030,8 @@ sub get_prepay { return "removing prepay_credit (transaction rolled back): $error"; } - $$amountref += $prepay_credit->amount; - $$secondsref += $prepay_credit->seconds; - $$upref += $prepay_credit->upbytes; - $$downref += $prepay_credit->downbytes; - $$totalref += $prepay_credit->totalbytes; + ${ $opt{$_.'_ref'} } += $prepay_credit->$_() + for grep $opt{$_.'_ref'}, qw( amount seconds upbytes downbytes totalbytes ); $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; @@ -1045,6 +1317,16 @@ sub delete { } } + foreach my $cust_main_exemption ( + qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } ) + ) { + my $error = $cust_main_exemption->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my $error = $self->SUPER::delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -1056,7 +1338,8 @@ sub delete { } -=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] +=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ] + Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. @@ -1068,6 +1351,11 @@ check_invoicing_list first. Here's an example: $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] ); +Currently available options are: I. + +The I option can be set to an arrayref of tax names. +FS::cust_main_exemption records will be deleted and inserted as appropriate. + =cut sub replace { @@ -1114,7 +1402,7 @@ sub replace { return $error; } - if ( @param ) { # INVOICING_LIST_ARYREF + if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF my $invoicing_list = shift @param; $error = $self->check_invoicing_list( $invoicing_list ); if ( $error ) { @@ -1124,6 +1412,40 @@ sub replace { $self->invoicing_list( $invoicing_list ); } + my %options = @param; + + my $tax_exemption = delete $options{'tax_exemption'}; + if ( $tax_exemption ) { + + my %cust_main_exemption = + map { $_->taxname => $_ } + qsearch('cust_main_exemption', { 'custnum' => $old->custnum } ); + + foreach my $taxname ( @$tax_exemption ) { + + next if delete $cust_main_exemption{$taxname}; + + my $cust_main_exemption = new FS::cust_main_exemption { + 'custnum' => $self->custnum, + 'taxname' => $taxname, + }; + my $error = $cust_main_exemption->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_main_exemption (transaction rolled back): $error"; + } + } + + foreach my $cust_main_exemption ( values %cust_main_exemption ) { + my $error = $cust_main_exemption->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "deleting cust_main_exemption (transaction rolled back): $error"; + } + } + + } + if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ && grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) { # card/check/lec info has changed, want to retry realtime_ invoice events @@ -1228,7 +1550,9 @@ sub check { || $self->ut_textn('stateid') || $self->ut_textn('stateid_state') || $self->ut_textn('invoice_terms') + || $self->ut_alphan('geocode') ; + #barf. need message catalogs. i18n. etc. $error .= "Please select an advertising source." if $error =~ /^Illegal or empty \(numeric\) refnum: /; @@ -1535,7 +1859,7 @@ sub check { $self->payname($1); } - foreach my $flag (qw( tax spool_cdr squelch_cdr )) { + foreach my $flag (qw( tax spool_cdr squelch_cdr archived )) { $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag(); $self->$flag($1); } @@ -1572,7 +1896,7 @@ sub has_ship_address { scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields ); } -=item all_pkgs +=item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ] Returns all packages (see L) for this customer. @@ -1580,14 +1904,15 @@ Returns all packages (see L) for this customer. sub all_pkgs { my $self = shift; + my $extra_qsearch = ref($_[0]) ? shift : {}; - return $self->num_pkgs unless wantarray; + return $self->num_pkgs unless wantarray || keys(%$extra_qsearch); my @cust_pkg = (); if ( $self->{'_pkgnum'} ) { @cust_pkg = values %{ $self->{'_pkgnum'}->cache }; } else { - @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum }); + @cust_pkg = $self->_cust_pkg($extra_qsearch); } sort sort_packages @cust_pkg; @@ -1603,7 +1928,18 @@ sub cust_pkg { shift->all_pkgs(@_); } -=item ncancelled_pkgs +=item cust_location + +Returns all locations (see L) for this customer. + +=cut + +sub cust_location { + my $self = shift; + qsearch('cust_location', { 'custnum' => $self->custnum } ); +} + +=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ] Returns all non-cancelled packages (see L) for this customer. @@ -1611,6 +1947,7 @@ Returns all non-cancelled packages (see L) for this customer. sub ncancelled_pkgs { my $self = shift; + my $extra_qsearch = ref($_[0]) ? shift : {}; return $self->num_ncancelled_pkgs unless wantarray; @@ -1629,33 +1966,56 @@ sub ncancelled_pkgs { $self->custnum. "\n" if $DEBUG > 1; - @cust_pkg = - qsearch( 'cust_pkg', { - 'custnum' => $self->custnum, - 'cancel' => '', - }); - push @cust_pkg, - qsearch( 'cust_pkg', { - 'custnum' => $self->custnum, - 'cancel' => 0, - }); + $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) '; + + @cust_pkg = $self->_cust_pkg($extra_qsearch); + } sort sort_packages @cust_pkg; } +sub _cust_pkg { + my $self = shift; + my $extra_qsearch = ref($_[0]) ? shift : {}; + + $extra_qsearch->{'select'} ||= '*'; + $extra_qsearch->{'select'} .= + ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum ) + AS _num_cust_svc'; + + map { + $_->{'_num_cust_svc'} = $_->get('_num_cust_svc'); + $_; + } + qsearch({ + %$extra_qsearch, + 'table' => 'cust_pkg', + 'hashref' => { 'custnum' => $self->custnum }, + }); + +} + # This should be generalized to use config options to determine order. sub sort_packages { - if ( $a->get('cancel') and $b->get('cancel') ) { - $a->pkgnum <=> $b->pkgnum; - } elsif ( $a->get('cancel') or $b->get('cancel') ) { + + if ( $a->get('cancel') xor $b->get('cancel') ) { return -1 if $b->get('cancel'); return 1 if $a->get('cancel'); + #shouldn't get here... return 0; } else { - $a->pkgnum <=> $b->pkgnum; + my $a_num_cust_svc = $a->num_cust_svc; + my $b_num_cust_svc = $b->num_cust_svc; + return 0 if !$a_num_cust_svc && !$b_num_cust_svc; + return -1 if $a_num_cust_svc && !$b_num_cust_svc; + return 1 if !$a_num_cust_svc && $b_num_cust_svc; + my @a_cust_svc = $a->cust_svc; + my @b_cust_svc = $b->cust_svc; + $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label; } + } =item suspended_pkgs @@ -1950,60 +2310,87 @@ Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in opt sub bill_and_collect { my( $self, %options ) = @_; - ### - # cancel packages - ### - #$options{actual_time} not $options{time} because freeside-daily -d is for #pre-printing invoices - my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} } - $self->ncancelled_pkgs; + $self->cancel_expired_pkgs( $options{actual_time} ); + $self->suspend_adjourned_pkgs( $options{actual_time} ); + + my $error = $self->bill( %options ); + warn "Error billing, custnum ". $self->custnum. ": $error" if $error; + + $self->apply_payments_and_credits; + + unless ( $conf->exists('cancelled_cust-noevents') + && ! $self->num_ncancelled_pkgs + ) { + + $error = $self->collect( %options ); + warn "Error collecting, custnum". $self->custnum. ": $error" if $error; + + } + +} + +sub cancel_expired_pkgs { + my ( $self, $time ) = @_; + + my @cancel_pkgs = $self->ncancelled_pkgs( { + 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time " + } ); foreach my $cust_pkg ( @cancel_pkgs ) { - my $error = $cust_pkg->cancel; + my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); + my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, + 'reason_otaker' => $cpr->otaker + ) + : () + ); warn "Error cancelling expired pkg ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" if $error; } - ### - # suspend packages - ### +} - #$options{actual_time} not $options{time} because freeside-daily -d is for - #pre-printing invoices - my @susp_pkgs = - grep { ! $_->susp - && ( ( $_->part_pkg->is_prepaid - && $_->bill - && $_->bill < $options{actual_time} - ) - || ( $_->adjourn - && $_->adjourn <= $options{actual_time} - ) - ) +sub suspend_adjourned_pkgs { + my ( $self, $time ) = @_; + + my @susp_pkgs = $self->ncancelled_pkgs( { + 'extra_sql' => + " AND ( susp IS NULL OR susp = 0 ) + AND ( ( bill IS NOT NULL AND bill != 0 AND bill < $time ) + OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time ) + ) + ", + } ); + + #only because there's no SQL test for is_prepaid :/ + @susp_pkgs = + grep { ( $_->part_pkg->is_prepaid + && $_->bill + && $_->bill < $time + ) + || ( $_->adjourn + && $_->adjourn <= $time + ) + } - $self->ncancelled_pkgs; + @susp_pkgs; foreach my $cust_pkg ( @susp_pkgs ) { - my $error = $cust_pkg->suspend; + my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn') + if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T); + my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum, + 'reason_otaker' => $cpr->otaker + ) + : () + ); + warn "Error suspending package ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" if $error; } - ### - # bill and collect - ### - - my $error = $self->bill( %options ); - warn "Error billing, custnum ". $self->custnum. ": $error" if $error; - - $self->apply_payments_and_credits; - - $error = $self->collect( %options ); - warn "Error collecting, custnum". $self->custnum. ": $error" if $error; - } =item bill OPTIONS @@ -2050,6 +2437,7 @@ sub bill { if $DEBUG; my $time = $options{'time'} || time; + my $invoice_time = $options{'invoice_time'} || $time; #put below somehow? local $SIG{HUP} = 'IGNORE'; @@ -2066,7 +2454,6 @@ sub bill { $self->select_for_update; #mutex my @cust_bill_pkg = (); - my @appended_cust_bill_pkg = (); ### # find the packages which are due for billing, find out how much they are @@ -2074,16 +2461,10 @@ sub bill { ### my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 ); - my %tax; my %taxlisthash; - my %taxname; my @precommit_hooks = (); - my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } ); - foreach my $cust_pkg (@cust_pkgs) { - - #NO!! next if $cust_pkg->cancel; - next if $cust_pkg->getfield('cancel'); + foreach my $cust_pkg ( $self->ncancelled_pkgs ) { warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1; @@ -2105,7 +2486,6 @@ sub bill { 'cust_pkg' => $cust_pkg, 'precommit_hooks' => \@precommit_hooks, 'line_items' => \@cust_bill_pkg, - 'appended_line_items' => \@appended_cust_bill_pkg, 'setup' => \$total_setup, 'recur' => \$total_recur, 'tax_matrix' => \%taxlisthash, @@ -2121,116 +2501,147 @@ sub bill { } #foreach my $cust_pkg - push @cust_bill_pkg, @appended_cust_bill_pkg; - unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items #but do commit any package date cycling that happened $dbh->commit or die $dbh->errstr if $oldAutoCommit; return ''; } - my $postal_pkg = $self->charge_postal_fee(); - if ( $postal_pkg && !ref( $postal_pkg ) ) { - $dbh->rollback if $oldAutoCommit; - return "can't charge postal invoice fee for customer ". - $self->custnum. ": $postal_pkg"; - } - if ( $postal_pkg ) { - foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) { - my $error = - $self->_make_lines( 'part_pkg' => $part_pkg, - 'cust_pkg' => $postal_pkg, - 'precommit_hooks' => \@precommit_hooks, - 'line_items' => \@cust_bill_pkg, - 'appended_line_items' => \@appended_cust_bill_pkg, - 'setup' => \$total_setup, - 'recur' => \$total_recur, - 'tax_matrix' => \%taxlisthash, - 'time' => $time, - 'options' => \%options, - ); - if ($error) { - $dbh->rollback if $oldAutoCommit; - return $error; + if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) || + !$conf->exists('postal_invoice-recurring_only') + ) + { + + my $postal_pkg = $self->charge_postal_fee(); + if ( $postal_pkg && !ref( $postal_pkg ) ) { + + $dbh->rollback if $oldAutoCommit; + return "can't charge postal invoice fee for customer ". + $self->custnum. ": $postal_pkg"; + + } elsif ( $postal_pkg ) { + + foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) { + my $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $postal_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => \@cust_bill_pkg, + 'setup' => \$total_setup, + 'recur' => \$total_recur, + 'tax_matrix' => \%taxlisthash, + 'time' => $time, + 'options' => \%options, + ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } + } + } warn "having a look at the taxes we found...\n" if $DEBUG > 2; + + # keys are tax names (as printed on invoices / itemdesc ) + # values are listrefs of taxlisthash keys (internal identifiers) + my %taxname = (); + + # keys are taxlisthash keys (internal identifiers) + # values are (cumulative) amounts + my %tax = (); + + # keys are taxlisthash keys (internal identifiers) + # values are listrefs of cust_bill_pkg_tax_location hashrefs + my %tax_location = (); + + # keys are taxlisthash keys (internal identifiers) + # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs + my %tax_rate_location = (); + foreach my $tax ( keys %taxlisthash ) { my $tax_object = shift @{ $taxlisthash{$tax} }; warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2; - my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } ); - unless (ref($listref_or_error)) { + warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2; + my $hashref_or_error = + $tax_object->taxline( $taxlisthash{$tax}, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time + ); + unless ( ref($hashref_or_error) ) { $dbh->rollback if $oldAutoCommit; - return $listref_or_error; + return $hashref_or_error; } unshift @{ $taxlisthash{$tax} }, $tax_object; - warn "adding ". $listref_or_error->[1]. - " as ". $listref_or_error->[0]. "\n" - if $DEBUG > 2; - $tax{ $tax_object->taxname } += $listref_or_error->[1]; - if ( $taxname{ $listref_or_error->[0] } ) { - push @{ $taxname{ $listref_or_error->[0] } }, $tax_object->taxname; - }else{ - $taxname{ $listref_or_error->[0] } = [ $tax_object->taxname ]; + my $name = $hashref_or_error->{'name'}; + my $amount = $hashref_or_error->{'amount'}; + + #warn "adding $amount as $name\n"; + $taxname{ $name } ||= []; + push @{ $taxname{ $name } }, $tax; + + $tax{ $tax } += $amount; + + $tax_location{ $tax } ||= []; + if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) { + push @{ $tax_location{ $tax } }, + { + 'taxnum' => $tax_object->taxnum, + 'taxtype' => ref($tax_object), + 'pkgnum' => $tax_object->get('pkgnum'), + 'locationnum' => $tax_object->get('locationnum'), + 'amount' => sprintf('%.2f', $amount ), + }; } - + + $tax_rate_location{ $tax } ||= []; + if ( ref($tax_object) eq 'FS::tax_rate' ) { + my $taxratelocationnum = + $tax_object->tax_rate_location->taxratelocationnum; + push @{ $tax_rate_location{ $tax } }, + { + 'taxnum' => $tax_object->taxnum, + 'taxtype' => ref($tax_object), + 'amount' => sprintf('%.2f', $amount ), + 'locationtaxid' => $tax_object->location, + 'taxratelocationnum' => $taxratelocationnum, + }; + } + } - #some taxes are taxed - my %totlisthash; - - warn "finding taxed taxes...\n" if $DEBUG > 2; + #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit + my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg; foreach my $tax ( keys %taxlisthash ) { - my $tax_object = shift @{ $taxlisthash{$tax} }; - warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" - if $DEBUG > 2; - next unless $tax_object->can('tax_on_tax'); + foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) { + next unless ref($_) eq 'FS::cust_bill_pkg'; - foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { - my $totname = ref( $tot ). ' '. $tot->taxnum; - - warn "checking $totname which we call ". $tot->taxname. " as applicable\n" - if $DEBUG > 2; - next unless exists( $taxlisthash{ $totname } ); # only increase - # existing taxes - warn "adding $totname to taxed taxes\n" if $DEBUG > 2; - if ( exists( $totlisthash{ $totname } ) ) { - push @{ $totlisthash{ $totname } }, $tax{ $tax_object->taxname }; - }else{ - $totlisthash{ $totname } = [ $tot, $tax{ $tax_object->taxname } ]; - } + push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, + splice( @{ $_->_cust_tax_exempt_pkg } ); } } - warn "having a look at taxed taxes...\n" if $DEBUG > 2; - foreach my $tax ( keys %totlisthash ) { - my $tax_object = shift @{ $totlisthash{$tax} }; - warn "found previously found taxed tax ". $tax_object->taxname. "\n" - if $DEBUG > 2; - my $listref_or_error = $tax_object->taxline( @{ $totlisthash{$tax} } ); - unless (ref($listref_or_error)) { - $dbh->rollback if $oldAutoCommit; - return $listref_or_error; - } - - warn "adding taxed tax amount ". $listref_or_error->[1]. - " as ". $tax_object->taxname. "\n" - if $DEBUG; - $tax{ $tax_object->taxname } += $listref_or_error->[1]; - } - #consolidate and create tax line items warn "consolidating and generating...\n" if $DEBUG > 2; foreach my $taxname ( keys %taxname ) { my $tax = 0; my %seen = (); + my @cust_bill_pkg_tax_location = (); + my @cust_bill_pkg_tax_rate_location = (); warn "adding $taxname\n" if $DEBUG > 1; foreach my $taxitem ( @{ $taxname{$taxname} } ) { - $tax += $tax{$taxitem} unless $seen{$taxitem}; + next if $seen{$taxitem}++; warn "adding $tax{$taxitem}\n" if $DEBUG > 1; + $tax += $tax{$taxitem}; + push @cust_bill_pkg_tax_location, + map { new FS::cust_bill_pkg_tax_location $_ } + @{ $tax_location{ $taxitem } }; + push @cust_bill_pkg_tax_rate_location, + map { new FS::cust_bill_pkg_tax_rate_location $_ } + @{ $tax_rate_location{ $taxitem } }; } next unless $tax; @@ -2244,6 +2655,8 @@ sub bill { 'sdate' => '', 'edate' => '', 'itemdesc' => $taxname, + 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location, + 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location, }; } @@ -2253,7 +2666,7 @@ sub bill { #create the new invoice my $cust_bill = new FS::cust_bill ( { 'custnum' => $self->custnum, - '_date' => ( $options{'invoice_time'} || $time ), + '_date' => ( $invoice_time ), 'charged' => $charged, } ); my $error = $cust_bill->insert; @@ -2294,13 +2707,11 @@ sub _make_lines { my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified"; my $precommit_hooks = $params{precommit_hooks} or die "no package specified"; my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified"; - my $appended_cust_bill_pkg = $params{appended_line_items} - or die "no appended line buffer specified"; my $total_setup = $params{setup} or die "no setup accumulator specified"; my $total_recur = $params{recur} or die "no recur accumulator specified"; my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified"; my $time = $params{'time'} or die "no time specified"; - my (%options) = %{$params{options}}; #hmmm only for 'resetup' + my (%options) = %{$params{options}}; my $dbh = dbh; my $real_pkgpart = $cust_pkg->pkgpart; @@ -2352,9 +2763,13 @@ sub _make_lines { my $recur = 0; my $unitrecur = 0; my $sdate; - if ( $part_pkg->getfield('freq') ne '0' && - ! $cust_pkg->getfield('susp') && - ( $cust_pkg->getfield('bill') || 0 ) <= $time + if ( ! $cust_pkg->getfield('susp') and + ( $part_pkg->getfield('freq') ne '0' && + ( $cust_pkg->getfield('bill') || 0 ) <= $time + ) + || ( $part_pkg->plan eq 'voip_cdr' + && $part_pkg->option('bill_every_call') + ) ) { # XXX should this be a package event? probably. events are called @@ -2371,41 +2786,33 @@ sub _make_lines { $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; #over two params! lets at least switch to a hashref for the rest... - my %param = ( 'precommit_hooks' => $precommit_hooks, ); + my $increment_next_bill = ( $part_pkg->freq ne '0' + && ( $cust_pkg->getfield('bill') || 0 ) <= $time + ); + my %param = ( 'precommit_hooks' => $precommit_hooks, + 'increment_next_bill' => $increment_next_bill, + ); $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) }; return "$@ running calc_recur for $cust_pkg\n" if ( $@ ); + if ( $increment_next_bill ) { + + my $next_bill = $part_pkg->add_freq($sdate); + return "unparsable frequency: ". $part_pkg->freq + if $next_bill == -1; - #change this bit to use Date::Manip? CAREFUL with timezones (see - # mailing list archive) - my ($sec,$min,$hour,$mday,$mon,$year) = - (localtime($sdate) )[0,1,2,3,4,5]; - - #pro-rating magic - if $recur_prog fiddles $sdate, want to use that - # 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 ( $part_pkg->freq =~ /^\d+$/ ) { - $mon += $part_pkg->freq; - until ( $mon < 12 ) { $mon -= 12; $year++; } - } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) { - my $weeks = $1; - $mday += $weeks * 7; - } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) { - my $days = $1; - $mday += $days; - } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) { - my $hours = $1; - $hour += $hours; - } else { - return "unparsable frequency: ". $part_pkg->freq; + #pro-rating magic - if $recur_prog fiddled $sdate, want to use that + # only for figuring next bill date, nothing else, so, reset $sdate again + # here + $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill; + $cust_pkg->last_bill($sdate); + + $cust_pkg->setfield('bill', $next_bill ); + } - $cust_pkg->setfield('bill', - timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); } @@ -2446,6 +2853,14 @@ sub _make_lines { warn " charges (setup=$setup, recur=$recur); adding line items\n" if $DEBUG > 1; + + my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I'); + if ( $DEBUG > 1 ) { + warn " adding customer package invoice detail: $_\n" + foreach @cust_pkg_detail; + } + push @details, @cust_pkg_detail; + my $cust_bill_pkg = new FS::cust_bill_pkg { 'pkgnum' => $cust_pkg->pkgnum, 'setup' => $setup, @@ -2453,13 +2868,19 @@ sub _make_lines { 'recur' => $recur, 'unitrecur' => $unitrecur, 'quantity' => $cust_pkg->quantity, - 'sdate' => $sdate, - 'edate' => $cust_pkg->bill, 'details' => \@details, }; + + if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) { + $cust_bill_pkg->sdate( $hash{last_bill} ); + $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1 + } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) { + $cust_bill_pkg->sdate( $sdate ); + $cust_bill_pkg->edate( $cust_pkg->bill ); + } + $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) unless $part_pkg->pkgpart == $real_pkgpart; - push @$cust_bill_pkgs, $cust_bill_pkg; $$total_setup += $setup; $$total_recur += $recur; @@ -2468,143 +2889,222 @@ sub _make_lines { # handle taxes ### - unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { - - $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg); + my $error = + $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}); + return $error if $error; - } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' + push @$cust_bill_pkgs, $cust_bill_pkg; } #if $setup != 0 || $recur != 0 } #if $line_items - if ( $part_pkg->can('append_cust_bill_pkgs') ) { - my %param = ( 'precommit_hooks' => $precommit_hooks, ); - my ($more_cust_bill_pkgs) = - eval { $part_pkg->append_cust_bill_pkgs( $cust_pkg, \$sdate, \%param ) }; + ''; - return "$@ running append_cust_bill_pkgs for $cust_pkg\n" - if ( $@ ); - return "$more_cust_bill_pkgs" - unless ( ref($more_cust_bill_pkgs) ); +} + +sub _handle_taxes { + my $self = shift; + my $part_pkg = shift; + my $taxlisthash = shift; + my $cust_bill_pkg = shift; + my $cust_pkg = shift; + my $invoice_time = shift; - foreach my $cust_bill_pkg ( @{$more_cust_bill_pkgs} ) { + my %cust_bill_pkg = (); + my %taxes = (); + + my @classes; + #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; + push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + push @classes, 'setup' if $cust_bill_pkg->setup; + push @classes, 'recur' if $cust_bill_pkg->recur; - $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) - unless $part_pkg->pkgpart == $real_pkgpart; - push @$appended_cust_bill_pkg, $cust_bill_pkg; + if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) { + + if ( $conf->exists('enable_taxproducts') + && ( scalar($part_pkg->part_pkg_taxoverride) + || $part_pkg->has_taxproduct + ) + ) + { - unless ($cust_bill_pkg->duplicate) { - $$total_setup += $cust_bill_pkg->setup; - $$total_recur += $cust_bill_pkg->recur; + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + return "fatal: Can't (yet) use tax-pkg_address with taxproducts"; + } - ### - # handle taxes - ### + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes( $part_pkg, $class ); + return $err_or_ref unless ref($err_or_ref); + $taxes{$class} = $err_or_ref; + } - unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { + unless (exists $taxes{''}) { + my $err_or_ref = $self->_gather_taxes( $part_pkg, '' ); + return $err_or_ref unless ref($err_or_ref); + $taxes{''} = $err_or_ref; + } - $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg); + } else { - } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' + my @loc_keys = qw( state county country ); + my %taxhash; + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + my $cust_location = $cust_pkg->cust_location; + %taxhash = map { $_ => $cust_location->$_() } @loc_keys; + } else { + my $prefix = + ( $conf->exists('tax-ship_address') && length($self->ship_last) ) + ? 'ship_' + : ''; + %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys; } - } - } -} + $taxhash{'taxclass'} = $part_pkg->taxclass; -sub _handle_taxes { - my $self = shift; - my $part_pkg = shift; - my $taxlisthash = shift; - my $cust_bill_pkg = shift; + my @taxes = qsearch( 'cust_main_county', \%taxhash ); - my @taxes = (); - my @taxoverrides = $part_pkg->part_pkg_taxoverride; - - my $prefix = - ( $conf->exists('tax-ship_address') && length($self->ship_last) ) - ? 'ship_' - : ''; + my %taxhash_elim = %taxhash; - if ( $conf->exists('enable_taxproducts') - && (scalar(@taxoverrides) || $part_pkg->taxproductnum ) - ) - { + my @elim = qw( taxclass county state ); + while ( !scalar(@taxes) && scalar(@elim) ) { + $taxhash_elim{ shift(@elim) } = ''; + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + } - my @taxclassnums = (); - my $geocode = $self->geocode('cch'); + @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) } + @taxes + if $self->cust_main_exemption; #just to be safe - if ( scalar( @taxoverrides ) ) { - @taxclassnums = map { $_->taxclassnum } @taxoverrides; - }elsif ( $part_pkg->taxproductnum ) { - @taxclassnums = map { $_->taxclassnum } - $part_pkg->part_pkg_taxrate('cch', $geocode); - } + if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { + foreach (@taxes) { + $_->set('pkgnum', $cust_pkg->pkgnum ); + $_->set('locationnum', $cust_pkg->locationnum ); + } + } - my $extra_sql = - "AND (". - join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; + $taxes{''} = [ @taxes ]; + $taxes{'setup'} = [ @taxes ]; + $taxes{'recur'} = [ @taxes ]; + $taxes{$_} = [ @taxes ] foreach (@classes); - @taxes = qsearch({ 'table' => 'tax_rate', - 'hashref' => { 'geocode' => $geocode, }, - 'extra_sql' => $extra_sql, - }) - if scalar(@taxclassnums); + # # maybe eliminate this entirely, along with all the 0% records + # unless ( @taxes ) { + # return + # "fatal: can't find tax rate for state/county/country/taxclass ". + # join('/', map $taxhash{$_}, qw(state county country taxclass) ); + # } + } #if $conf->exists('enable_taxproducts') ... - }else{ + } + + my @display = (); + if ( $conf->exists('separate_usage') ) { + my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!'); + my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!'); + push @display, new FS::cust_bill_pkg_display { type => 'S' }; + push @display, new FS::cust_bill_pkg_display { type => 'R' }; + push @display, new FS::cust_bill_pkg_display { type => 'U', + section => $section + }; + if ($section && $summary) { + $display[2]->post_total('Y'); + push @display, new FS::cust_bill_pkg_display { type => 'U', + summary => 'Y', + } + } + } + $cust_bill_pkg->set('display', \@display); - my %taxhash = map { $_ => $self->get("$prefix$_") } - qw( state county country ); + my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; + foreach my $key (keys %tax_cust_bill_pkg) { + my @taxes = @{ $taxes{$key} || [] }; + my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; - $taxhash{'taxclass'} = $part_pkg->taxclass; + my %localtaxlisthash = (); + foreach my $tax ( @taxes ) { - @taxes = qsearch( 'cust_main_county', \%taxhash ); + my $taxname = ref( $tax ). ' '. $tax->taxnum; +# $taxname .= ' pkgnum'. $cust_pkg->pkgnum. +# ' locationnum'. $cust_pkg->locationnum +# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum; - unless ( @taxes ) { - $taxhash{'taxclass'} = ''; - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } + $taxlisthash->{ $taxname } ||= [ $tax ]; + push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; + + $localtaxlisthash{ $taxname } ||= [ $tax ]; + push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg; - #one more try at a whole-country tax rate - unless ( @taxes ) { - $taxhash{$_} = '' foreach qw( state county ); - @taxes = qsearch( 'cust_main_county', \%taxhash ); } - } #if $conf->exists('enable_taxproducts') + warn "finding taxed taxes...\n" if $DEBUG > 2; + foreach my $tax ( keys %localtaxlisthash ) { + my $tax_object = shift @{ $localtaxlisthash{$tax} }; + warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" + if $DEBUG > 2; + next unless $tax_object->can('tax_on_tax'); + + foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { + my $totname = ref( $tot ). ' '. $tot->taxnum; + + warn "checking $totname which we call ". $tot->taxname. " as applicable\n" + if $DEBUG > 2; + next unless exists( $localtaxlisthash{ $totname } ); # only increase + # existing taxes + warn "adding $totname to taxed taxes\n" if $DEBUG > 2; + my $hashref_or_error = + $tax_object->taxline( $localtaxlisthash{$tax}, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time, + ); + return $hashref_or_error + unless ref($hashref_or_error); + + $taxlisthash->{ $totname } ||= [ $tot ]; + push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount}; - # maybe eliminate this entirely, along with all the 0% records - unless ( @taxes ) { - my $error; - if ( $conf->exists('enable_taxproducts') ) { - $error = - "fatal: can't find tax rate for zip/taxproduct/pkgpart ". - join('/', ( map $self->get("$prefix$_"), - qw(zip) - ), - $part_pkg->taxproduct_description, - $part_pkg->pkgpart ). "\n"; - } else { - $error = - "fatal: can't find tax rate for state/county/country/taxclass ". - join('/', ( map $self->get("$prefix$_"), - qw(state county country) - ), - $part_pkg->taxclass ). "\n"; + } } - return $error; + } - foreach my $tax ( @taxes ) { - my $taxname = ref( $tax ). ' '. $tax->taxnum; - if ( exists( $taxlisthash->{ $taxname } ) ) { - push @{ $taxlisthash->{ $taxname } }, $cust_bill_pkg; - }else{ - $taxlisthash->{ $taxname } = [ $tax, $cust_bill_pkg ]; - } + ''; +} + +sub _gather_taxes { + my $self = shift; + my $part_pkg = shift; + my $class = shift; + + my @taxes = (); + my $geocode = $self->geocode('cch'); + + my @taxclassnums = map { $_->taxclassnum } + $part_pkg->part_pkg_taxoverride($class); + + unless (@taxclassnums) { + @taxclassnums = map { $_->taxclassnum } + $part_pkg->part_pkg_taxrate('cch', $geocode, $class); } + warn "Found taxclassnum values of ". join(',', @taxclassnums) + if $DEBUG; + + my $extra_sql = + "AND (". + join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; + + @taxes = qsearch({ 'table' => 'tax_rate', + 'hashref' => { 'geocode' => $geocode, }, + 'extra_sql' => $extra_sql, + }) + if scalar(@taxclassnums); + + warn "Found taxes ". + join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" + if $DEBUG; + + [ @taxes ]; } @@ -2775,6 +3275,11 @@ Only return events for the specified eventtable (by default, events of all event Explicitly pass the objects to be tested (typically used with eventtable). +=item testonly + +Set to true to return the objects, but not actually insert them into the +database. + =back =cut @@ -2805,7 +3310,8 @@ sub due_cust_event { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - $self->select_for_update; #mutex + $self->select_for_update #mutex + unless $opt{testonly}; ### # 1: find possible events (initial search) @@ -2920,14 +3426,16 @@ sub due_cust_event { # 3: insert ## - foreach my $cust_event ( @cust_event ) { + unless( $opt{testonly} ) { + foreach my $cust_event ( @cust_event ) { - my $error = $cust_event->insert(); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } + my $error = $cust_event->insert(); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } } $dbh->commit or die $dbh->errstr if $oldAutoCommit; @@ -3033,6 +3541,10 @@ sub retry_realtime { } +# some horrid false laziness here to avoid refactor fallout +# eventually realtime realtime_bop and realtime_refund_bop should go +# away and be replaced by _new_realtime_bop and _new_realtime_refund_bop + =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ] Runs a realtime credit card, ACH (electronic check) or phone bill transaction @@ -3066,7 +3578,12 @@ I is a unique identifier for this payment. =cut sub realtime_bop { - my( $self, $method, $amount, %options ) = @_; + my $self = shift; + + return $self->_new_realtime_bop(@_) + if $self->_new_bop_required(); + + my( $method, $amount, %options ) = @_; if ( $DEBUG ) { warn "$me realtime_bop: $method $amount\n"; warn " $_ => $options{$_}\n" foreach keys %options; @@ -3100,24 +3617,35 @@ sub realtime_bop { return "Banned credit card" if $ban; ### - # select a gateway + # set taxclass and trans_is_recur based on invnum if there is one ### my $taxclass = ''; + my $trans_is_recur = 0; if ( $options{'invnum'} ) { + my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } ); die "invnum ". $options{'invnum'}. " not found" unless $cust_bill; - my @taxclasses = - map { $_->part_pkg->taxclass } + + my @part_pkg = + map { $_->part_pkg } grep { $_ } map { $_->cust_pkg } $cust_bill->cust_bill_pkg; - unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are - #different taxclasses - $taxclass = $taxclasses[0]; - } + + my @taxclasses = map $_->taxclass, @part_pkg; + $taxclass = $taxclasses[0] + unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are + #different taxclasses + $trans_is_recur = 1 + if grep { $_->freq ne '0' } @part_pkg; + } + ### + # select a gateway + ### + #look for an agent gateway override first my $cardtype; if ( $method eq 'CC' ) { @@ -3245,16 +3773,15 @@ sub realtime_bop { : $self->payissue; $content{issue_number} = $payissue if $payissue; - $content{recurring_billing} = 'YES' - if qsearch('cust_pay', { 'custnum' => $self->custnum, - 'payby' => 'CARD', - 'payinfo' => $payinfo, - } ) - || qsearch('cust_pay', { 'custnum' => $self->custnum, - 'payby' => 'CARD', - 'paymask' => $self->mask_payinfo('CARD', $payinfo), - } ); - + if ( $self->_bop_recurring_billing( 'payinfo' => $payinfo, + 'trans_is_recur' => $trans_is_recur, + ) + ) + { + $content{recurring_billing} = 'YES'; + $content{acct_code} = 'rebill' + if $conf->exists('credit_card-recurring_billing_acct_code'); + } } elsif ( $method eq 'ECHECK' ) { ( $content{account_number}, $content{routing_code} ) = @@ -3313,15 +3840,16 @@ sub realtime_bop { #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 : '' ), + 'custnum' => $self->custnum, + #'invnum' => $options{'invnum'}, + 'paid' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + 'payinfo' => $payinfo, + 'paydate' => $paydate, + 'recurring_billing' => $content{recurring_billing}, + 'status' => 'new', + 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ), }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -3356,7 +3884,7 @@ sub realtime_bop { 'country' => ( exists($options{'country'}) ? $options{'country'} : $self->country ), - 'referer' => 'http://cleanwhisker.420.am/', + 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/ 'email' => $email, 'phone' => $self->daytime || $self->night, %content, #after @@ -3512,6 +4040,7 @@ sub realtime_bop { $cust_pay_pending->status('done'); $cust_pay_pending->statustext('captured'); + $cust_pay_pending->paynum($cust_pay->paynum); my $cpp_done_err = $cust_pay_pending->replace; if ( $cpp_done_err ) { @@ -3579,7 +4108,7 @@ sub realtime_bop { my $templ_hash = { error => $transaction->error_message }; my $error = send_email( - 'from' => $conf->config('invoice_from'), + 'from' => $conf->config('invoice_from', $self->agentnum ), 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], 'subject' => 'Your payment could not be processed', 'body' => [ $template->fill_in(HASH => $templ_hash) ], @@ -3606,118 +4135,33 @@ sub realtime_bop { } -=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"; - } +sub _bop_recurring_billing { + my( $self, %opt ) = @_; - my %method2payby = ( - 'CC' => 'CARD', - 'ECHECK' => 'CHEK', - 'LEC' => 'LECB', - ); + my $method = $conf->config('credit_card-recurring_billing_flag'); - #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); + if ( $method eq 'transaction_is_recur' ) { - my $paybatch = 'FakeProcessor:54:32'; + return 1 if $opt{'trans_is_recur'}; - 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}); + } else { - my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () ); + my %hash = ( 'custnum' => $self->custnum, + 'payby' => 'CARD', + ); - 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; - } - } + return 1 + if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } ) + || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD', + $opt{'payinfo'} ) + } ); - if ( $options{'paynum_ref'} ) { - ${ $options{'paynum_ref'} } = $cust_pay->paynum; } - return ''; #no error - -} - -=item default_payment_gateway - -=cut - -sub default_payment_gateway { - my( $self, $method ) = @_; - - die "Real-time processing not enabled\n" - unless $conf->exists('business-onlinepayment'); - - #load up config - my $bop_config = 'business-onlinepayment'; - $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'; - pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; - die "No real-time processor is enabled - ". - "did you set the business-onlinepayment configuration value?\n" - unless $processor; + return 0; - ( $processor, $login, $password, $action, @bop_options ) } -=item remove_cvv - -Removes the I field from the database directly. - -If there is an error, returns the error, otherwise returns false. - -=cut - -sub remove_cvv { - my $self = shift; - my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?") - or return dbh->errstr; - $sth->execute($self->custnum) - or return $sth->errstr; - $self->paycvv(''); - ''; -} =item realtime_refund_bop METHOD [ OPTION => VALUE ... ] @@ -3758,15 +4202,1336 @@ gateway is attempted. #some false laziness w/realtime_bop, not enough to make it worth merging #but some useful small subs should be pulled out sub realtime_refund_bop { - my( $self, $method, %options ) = @_; + my $self = shift; + + return $self->_new_realtime_refund_bop(@_) + if $self->_new_bop_required(); + + my( $method, %options ) = @_; + if ( $DEBUG ) { + warn "$me realtime_refund_bop: $method refund\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + eval "use Business::OnlinePayment"; + die $@ if $@; + + ### + # look up the original payment and optionally a gateway for that payment + ### + + my $cust_pay = ''; + my $amount = $options{'amount'}; + + my( $processor, $login, $password, @bop_options ) ; + my( $auth, $order_number ) = ( '', '', '' ); + + if ( $options{'paynum'} ) { + + warn " paynum: $options{paynum}\n" if $DEBUG > 1; + $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } ) + or return "Unknown paynum $options{'paynum'}"; + $amount ||= $cust_pay->paid; + + $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/ + or return "Can't parse paybatch for paynum $options{'paynum'}: ". + $cust_pay->paybatch; + my $gatewaynum = ''; + ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 ); + + if ( $gatewaynum ) { #gateway for the payment to be refunded + + my $payment_gateway = + qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } ); + die "payment gateway $gatewaynum not found" + unless $payment_gateway; + + $processor = $payment_gateway->gateway_module; + $login = $payment_gateway->gateway_username; + $password = $payment_gateway->gateway_password; + @bop_options = $payment_gateway->options; + + } else { #try the default gateway + + my( $conf_processor, $unused_action ); + ( $conf_processor, $login, $password, $unused_action, @bop_options ) = + $self->default_payment_gateway($method); + + return "processor of payment $options{'paynum'} $processor does not". + " match default processor $conf_processor" + unless $processor eq $conf_processor; + + } + + + } else { # didn't specify a paynum, so look for agent gateway overrides + # like a normal transaction + + my $cardtype; + if ( $method eq 'CC' ) { + $cardtype = cardtype($self->payinfo); + } elsif ( $method eq 'ECHECK' ) { + $cardtype = 'ACH'; + } else { + $cardtype = $method; + } + my $override = + qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, + cardtype => $cardtype, + taxclass => '', } ) + || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, + cardtype => '', + taxclass => '', } ); + + if ( $override ) { #use a payment gateway override + + my $payment_gateway = $override->payment_gateway; + + $processor = $payment_gateway->gateway_module; + $login = $payment_gateway->gateway_username; + $password = $payment_gateway->gateway_password; + #$action = $payment_gateway->gateway_action; + @bop_options = $payment_gateway->options; + + } else { #use the standard settings from the config + + my $unused_action; + ( $processor, $login, $password, $unused_action, @bop_options ) = + $self->default_payment_gateway($method); + + } + + } + return "neither amount nor paynum specified" unless $amount; + + my %content = ( + 'type' => $method, + 'login' => $login, + 'password' => $password, + 'order_number' => $order_number, + 'amount' => $amount, + 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/ + ); + $content{authorization} = $auth + if length($auth); #echeck/ACH transactions have an order # but no auth + #(at least with authorize.net) + + my $disable_void_after; + if ($conf->exists('disable_void_after') + && $conf->config('disable_void_after') =~ /^(\d+)$/) { + $disable_void_after = $1; + } + + #first try void if applicable + if ( $cust_pay && $cust_pay->paid == $amount + && ( + ( not defined($disable_void_after) ) + || ( time < ($cust_pay->_date + $disable_void_after ) ) + ) + ) { + warn " attempting void\n" if $DEBUG > 1; + my $void = new Business::OnlinePayment( $processor, @bop_options ); + $void->content( 'action' => 'void', %content ); + $void->submit(); + if ( $void->is_success ) { + my $error = $cust_pay->void($options{'reason'}); + if ( $error ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH voided but database not updated - '. + "error voiding payment: $error"; + warn $e; + return $e; + } + warn " void successful\n" if $DEBUG > 1; + return ''; + } + } + + warn " void unsuccessful, trying refund\n" + if $DEBUG > 1; + + #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 = $self->invoicing_list_emailonly; + if ( $conf->exists('emailinvoiceautoalways') + || $conf->exists('emailinvoiceauto') && ! @invoicing_list + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + + my $email = ($conf->exists('business-onlinepayment-email-override')) + ? $conf->config('business-onlinepayment-email-override') + : $invoicing_list[0]; + + my $payip = exists($options{'payip'}) + ? $options{'payip'} + : $self->payip; + $content{customer_ip} = $payip + if length($payip); + + my $payinfo = ''; + if ( $method eq 'CC' ) { + + if ( $cust_pay ) { + $content{card_number} = $payinfo = $cust_pay->payinfo; + (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate) + =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ && + ($content{expiration} = "$2/$1"); # where available + } else { + $content{card_number} = $payinfo = $self->payinfo; + (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate) + =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + } + + } elsif ( $method eq 'ECHECK' ) { + + if ( $cust_pay ) { + $payinfo = $cust_pay->payinfo; + } else { + $payinfo = $self->payinfo; + } + ( $content{account_number}, $content{routing_code} )= split('@', $payinfo ); + $content{bank_name} = $self->payname; + $content{account_type} = 'CHECKING'; + $content{account_name} = $payname; + $content{customer_org} = $self->company ? 'B' : 'I'; + $content{customer_ssn} = $self->ss; + } elsif ( $method eq 'LEC' ) { + $content{phone} = $payinfo = $self->payinfo; + } + + #then try refund + my $refund = new Business::OnlinePayment( $processor, @bop_options ); + my %sub_content = $refund->content( + 'action' => 'credit', + '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, + 'email' => $email, + 'phone' => $self->daytime || $self->night, + %content, #after + ); + warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content ) + if $DEBUG > 1; + $refund->submit(); + + return "$processor error: ". $refund->error_message + unless $refund->is_success(); + + my %method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', + ); + + my $paybatch = "$processor:". $refund->authorization; + $paybatch .= ':'. $refund->order_number + if $refund->can('order_number') && $refund->order_number; + + while ( $cust_pay && $cust_pay->unapplied < $amount ) { + my @cust_bill_pay = $cust_pay->cust_bill_pay; + last unless @cust_bill_pay; + my $cust_bill_pay = pop @cust_bill_pay; + my $error = $cust_bill_pay->delete; + last if $error; + } + + my $cust_refund = new FS::cust_refund ( { + 'custnum' => $self->custnum, + 'paynum' => $options{'paynum'}, + 'refund' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + 'payinfo' => $payinfo, + 'paybatch' => $paybatch, + 'reason' => $options{'reason'} || 'card or ACH refund', + } ); + my $error = $cust_refund->insert; + if ( $error ) { + $cust_refund->paynum(''); #try again with no specific paynum + my $error2 = $cust_refund->insert; + if ( $error2 ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH refunded but database not updated - '. + "error inserting refund ($processor): $error2". + " (previously tried insert with paynum #$options{'paynum'}" . + ": $error )"; + warn $e; + return $e; + } + } + + ''; #no error + +} + +# does the configuration indicate the new bop routines are required? + +sub _new_bop_required { + my $self = shift; + + my $botpp = 'Business::OnlineThirdPartyPayment'; + + return 1 + if ( $conf->config('business-onlinepayment-namespace') eq $botpp || + scalar( grep { $_->gateway_namespace eq $botpp } + qsearch( 'payment_gateway', { 'disabled' => '' } ) + ) + ) + ; + + ''; +} + + +=item realtime_collect [ OPTION => VALUE ... ] + +Runs a realtime credit card, ACH (electronic check) or phone bill transaction +via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime +gateway. See L and +L for supported gateways. + +On failure returns an error message. + +Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url. + +Available options are: I, I, I, I, I, I, I, I + +I is one of: I, I and I. If none is specified +then it is deduced from the customer record. + +If no I is specified, then the customer balance is used. + +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 successful) 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. + +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. + +I is a session identifier associated with this payment. + +I allows payment capture to unlock export jobs + +=cut + +sub realtime_collect { + my( $self, %options ) = @_; + + if ( $DEBUG ) { + warn "$me realtime_collect:\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + $options{amount} = $self->balance unless exists( $options{amount} ); + $options{method} = FS::payby->payby2bop($self->payby) + unless exists( $options{method} ); + + return $self->realtime_bop({%options}); + +} + +=item _realtime_bop { [ ARG => VALUE ... ] } + +Runs a realtime credit card, ACH (electronic check) or phone bill transaction +via a Business::OnlinePayment realtime gateway. See +L for supported gateways. + +Required arguments in the hashref are I, and I + +Available methods are: I, I and I + +Available optional arguments are: I, I, I, 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 successful) 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. + +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. + +I is a session identifier associated with this payment. + +I allows payment capture to unlock export jobs + +(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too) + +=cut + +# some helper routines +sub _payment_gateway { + my ($self, $options) = @_; + + $options->{payment_gateway} = $self->agent->payment_gateway( %$options ) + unless exists($options->{payment_gateway}); + + $options->{payment_gateway}; +} + +sub _bop_auth { + my ($self, $options) = @_; + + ( + 'login' => $options->{payment_gateway}->gateway_username, + 'password' => $options->{payment_gateway}->gateway_password, + ); +} + +sub _bop_options { + my ($self, $options) = @_; + + $options->{payment_gateway}->gatewaynum + ? $options->{payment_gateway}->options + : @{ $options->{payment_gateway}->get('options') }; +} + +sub _bop_defaults { + my ($self, $options) = @_; + + $options->{description} ||= 'Internet services'; + $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} ); + $options->{invnum} ||= ''; + $options->{payname} = $self->payname unless exists( $options->{payname} ); +} + +sub _bop_content { + my ($self, $options) = @_; + my %content = (); + + $content{address} = exists($options->{'address1'}) + ? $options->{'address1'} + : $self->address1; + my $address2 = exists($options->{'address2'}) + ? $options->{'address2'} + : $self->address2; + $content{address} .= ", ". $address2 if length($address2); + + my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip; + $content{customer_ip} = $payip if length($payip); + + $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') ); + + $content{payfirst} = $self->getfield('first'); + $content{paylast} = $self->getfield('last'); + + $content{account_name} = "$content{payfirst} $content{paylast}" + if $options->{method} eq 'ECHECK'; + + $content{name} = $options->{payname}; + $content{name} = $content{account_name} if exists($content{account_name}); + + $content{city} = exists($options->{city}) + ? $options->{city} + : $self->city; + $content{state} = exists($options->{state}) + ? $options->{state} + : $self->state; + $content{zip} = exists($options->{zip}) + ? $options->{'zip'} + : $self->zip; + $content{country} = exists($options->{country}) + ? $options->{country} + : $self->country; + $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/ + $content{phone} = $self->daytime || $self->night; + + (%content); +} + +my %bop_method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', +); + +sub _new_realtime_bop { + my $self = shift; + + my %options = (); + if (ref($_[0]) eq 'HASH') { + %options = %{$_[0]}; + } else { + my ( $method, $amount ) = ( shift, shift ); + %options = @_; + $options{method} = $method; + $options{amount} = $amount; + } + + if ( $DEBUG ) { + warn "$me realtime_bop (new): $options{method} $options{amount}\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + return $self->fake_bop(%options) if $options{'fake'}; + + $self->_bop_defaults(\%options); + + ### + # set trans_is_recur based on invnum if there is one + ### + + my $trans_is_recur = 0; + if ( $options{'invnum'} ) { + + my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } ); + die "invnum ". $options{'invnum'}. " not found" unless $cust_bill; + + my @part_pkg = + map { $_->part_pkg } + grep { $_ } + map { $_->cust_pkg } + $cust_bill->cust_bill_pkg; + + $trans_is_recur = 1 + if grep { $_->freq ne '0' } @part_pkg; + + } + + ### + # select a gateway + ### + + my $payment_gateway = $self->_payment_gateway( \%options ); + my $namespace = $payment_gateway->gateway_namespace; + + eval "use $namespace"; + die $@ if $@; + + ### + # check for banned credit card/ACH + ### + + my $ban = qsearchs('banned_pay', { + 'payby' => $bop_method2payby{$options{method}}, + 'payinfo' => md5_base64($options{payinfo}), + } ); + return "Banned credit card" if $ban; + + ### + # massage data + ### + + my (%bop_content) = $self->_bop_content(\%options); + + if ( $options{method} ne 'ECHECK' ) { + $options{payname} =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ + or return "Illegal payname $options{payname}"; + ($bop_content{payfirst}, $bop_content{paylast}) = ($1, $2); + } + + my @invoicing_list = $self->invoicing_list_emailonly; + if ( $conf->exists('emailinvoiceautoalways') + || $conf->exists('emailinvoiceauto') && ! @invoicing_list + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + + my $email = ($conf->exists('business-onlinepayment-email-override')) + ? $conf->config('business-onlinepayment-email-override') + : $invoicing_list[0]; + + my $paydate = ''; + my %content = (); + if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) { + + $content{card_number} = $options{payinfo}; + $paydate = exists($options{'paydate'}) + ? $options{'paydate'} + : $self->paydate; + $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + + my $paycvv = exists($options{'paycvv'}) + ? $options{'paycvv'} + : $self->paycvv; + $content{cvv2} = $paycvv + if length($paycvv); + + my $paystart_month = exists($options{'paystart_month'}) + ? $options{'paystart_month'} + : $self->paystart_month; + + my $paystart_year = exists($options{'paystart_year'}) + ? $options{'paystart_year'} + : $self->paystart_year; + + $content{card_start} = "$paystart_month/$paystart_year" + if $paystart_month && $paystart_year; + + my $payissue = exists($options{'payissue'}) + ? $options{'payissue'} + : $self->payissue; + $content{issue_number} = $payissue if $payissue; + + if ( $self->_bop_recurring_billing( 'payinfo' => $options{'payinfo'}, + 'trans_is_recur' => $trans_is_recur, + ) + ) + { + $content{recurring_billing} = 'YES'; + $content{acct_code} = 'rebill' + if $conf->exists('credit_card-recurring_billing_acct_code'); + } + + } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){ + ( $content{account_number}, $content{routing_code} ) = + split('@', $options{payinfo}); + $content{bank_name} = $options{payname}; + $content{bank_state} = exists($options{'paystate'}) + ? $options{'paystate'} + : $self->getfield('paystate'); + $content{account_type} = exists($options{'paytype'}) + ? uc($options{'paytype'}) || 'CHECKING' + : uc($self->getfield('paytype')) || 'CHECKING'; + $content{customer_org} = $self->company ? 'B' : 'I'; + $content{state_id} = exists($options{'stateid'}) + ? $options{'stateid'} + : $self->getfield('stateid'); + $content{state_id_state} = exists($options{'stateid_state'}) + ? $options{'stateid_state'} + : $self->getfield('stateid_state'); + $content{customer_ssn} = exists($options{'ss'}) + ? $options{'ss'} + : $self->ss; + } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) { + $content{phone} = $options{payinfo}; + } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { + #move along + } else { + #die an evil death + } + + ### + # 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; $options{method} transaction aborted." + if $self->balance < $balance; + #&& $self->balance < $options{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 ). + "); $options{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' => $options{amount}, + '_date' => '', + 'payby' => $bop_method2payby{$options{method}}, + 'payinfo' => $options{payinfo}, + 'paydate' => $paydate, + 'recurring_billing' => $content{recurring_billing}, + 'status' => 'new', + 'gatewaynum' => $payment_gateway->gatewaynum || '', + 'session_id' => $options{session_id} || '', + 'jobnum' => $options{depend_jobnum} || '', + }; + $cust_pay_pending->payunique( $options{payunique} ) + if defined($options{payunique}) && 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*/, $payment_gateway->gateway_action ); + + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $transaction->content( + 'type' => $options{method}, + $self->_bop_auth(\%options), + 'action' => $action1, + 'description' => $options{'description'}, + 'amount' => $options{amount}, + #'invoice_number' => $options{'invnum'}, + 'customer_id' => $self->custnum, + %bop_content, + 'reference' => $cust_pay_pending->paypendingnum, #for now + 'email' => $email, + %content, #after + ); + + $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() && $namespace eq 'Business::OnlineThirdPartyPayment' ) { + + return { reference => $cust_pay_pending->paypendingnum, + map { $_ => $transaction->$_ } qw ( popup_url collectitems ) }; + + } elsif ( $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 + : ''; + + my $capture = + new Business::OnlinePayment( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + my %capture = ( + %content, + type => $options{method}, + action => $action2, + $self->_bop_auth(\%options), + order_number => $ordernum, + amount => $options{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 successful but capture failed, custnum #". + $self->custnum. ': '. $capture->result_code. + ": ". $capture->error_message; + warn $e; + return $e; + } + + } + + ### + # remove paycvv after initial transaction + ### + + #false laziness w/misc/process/payment.cgi - check both to make sure working + # correctly + if ( defined $self->dbdef_table->column('paycvv') + && length($self->paycvv) + && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save') + ) { + my $error = $self->remove_cvv; + if ( $error ) { + warn "WARNING: error removing cvv: $error\n"; + } + } + + ### + # result handling + ### + + $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options ); + +} + +=item fake_bop + +=cut + +sub fake_bop { + my $self = shift; + + my %options = (); + if (ref($_[0]) eq 'HASH') { + %options = %{$_[0]}; + } else { + my ( $method, $amount ) = ( shift, shift ); + %options = @_; + $options{method} = $method; + $options{amount} = $amount; + } + + if ( $options{'fake_failure'} ) { + return "Error: No error; test failure requested with fake_failure"; + } + + #my $paybatch = ''; + #if ( $payment_gateway->gatewaynum ) { # 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' => $options{amount}, + '_date' => '', + 'payby' => $bop_method2payby{$options{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 _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ] +# +# Wraps up processing of a realtime credit card, ACH (electronic check) or +# phone bill transaction. + +sub _realtime_bop_result { + my( $self, $cust_pay_pending, $transaction, %options ) = @_; + if ( $DEBUG ) { + warn "$me _realtime_bop_result: pending transaction ". + $cust_pay_pending->paypendingnum. "\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + my $payment_gateway = $options{payment_gateway} + or return "no payment gateway in arguments to _realtime_bop_result"; + + $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; + + if ( $transaction->is_success() ) { + + my $paybatch = ''; + if ( $payment_gateway->gatewaynum ) { # agent override + $paybatch = $payment_gateway->gatewaynum. '-'; + } + + $paybatch .= $payment_gateway->gateway_module. ":". + $transaction->authorization; + + $paybatch .= ':'. $transaction->order_number + if $transaction->can('order_number') + && length($transaction->order_number); + + my $cust_pay = new FS::cust_pay ( { + 'custnum' => $self->custnum, + 'invnum' => $options{'invnum'}, + 'paid' => $cust_pay_pending->paid, + '_date' => '', + 'payby' => $cust_pay_pending->payby, + #'payinfo' => $payinfo, + 'paybatch' => $paybatch, + 'paydate' => $cust_pay_pending->paydate, + } ); + #doesn't hurt to know, even though the dup check is in cust_pay_pending now + $cust_pay->payunique( $options{payunique} ) + if defined($options{payunique}) && 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 ) { + $cust_pay->invnum(''); #try again with no specific invnum + my $error2 = $cust_pay->insert( $options{'manual'} ? + ( 'manual' => 1 ) : () + ); + if ( $error2 ) { + # gah. but at least we have a record of the state we had to abort in + # from cust_pay_pending now. + my $e = "WARNING: $options{method} captured but payment not recorded -". + " error inserting payment (". $payment_gateway->gateway_module. + "): $error2". + " (previously tried insert with invnum #$options{'invnum'}" . + ": $error ) - pending payment saved as paypendingnum ". + $cust_pay_pending->paypendingnum. "\n"; + warn $e; + return $e; + } + } + + my $jobnum = $cust_pay_pending->jobnum; + if ( $jobnum ) { + my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } ); + + unless ( $placeholder ) { + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; + my $e = "WARNING: $options{method} captured but job $jobnum not ". + "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n"; + warn $e; + return $e; + } + + $error = $placeholder->delete; + + if ( $error ) { + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; + my $e = "WARNING: $options{method} captured but could not delete ". + "job $jobnum for paypendingnum ". + $cust_pay_pending->paypendingnum. ": $error\n"; + warn $e; + return $e; + } + + } + + if ( $options{'paynum_ref'} ) { + ${ $options{'paynum_ref'} } = $cust_pay->paynum; + } + + $cust_pay_pending->status('done'); + $cust_pay_pending->statustext('captured'); + $cust_pay_pending->paynum($cust_pay->paynum); + my $cpp_done_err = $cust_pay_pending->replace; + + if ( $cpp_done_err ) { + + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; + my $e = "WARNING: $options{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 { + + my $perror = $payment_gateway->gateway_module. " error: ". + $transaction->error_message; + + my $jobnum = $cust_pay_pending->jobnum; + if ( $jobnum ) { + my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } ); + + if ( $placeholder ) { + my $error = $placeholder->depended_delete; + $error ||= $placeholder->delete; + warn "error removing provisioning jobs after declined paypendingnum ". + $cust_pay_pending->paypendingnum. "\n"; + } else { + my $e = "error finding job $jobnum for declined paypendingnum ". + $cust_pay_pending->paypendingnum. "\n"; + warn $e; + } + + } + + unless ( $transaction->error_message ) { + + my $t_response; + if ( $transaction->can('response_page') ) { + $t_response = { + 'page' => ( $transaction->can('response_page') + ? $transaction->response_page + : '' + ), + 'code' => ( $transaction->can('response_code') + ? $transaction->response_code + : '' + ), + 'headers' => ( $transaction->can('response_headers') + ? $transaction->response_headers + : '' + ), + }; + } else { + $t_response .= + "No additional debugging information available for ". + $payment_gateway->gateway_module; + } + + $perror .= "No error_message returned from ". + $payment_gateway->gateway_module. " -- ". + ( ref($t_response) ? Dumper($t_response) : $t_response ); + + } + + if ( !$options{'quiet'} && !$realtime_bop_decline_quiet + && $conf->exists('emaildecline') + && grep { $_ ne 'POST' } $self->invoicing_list + && ! grep { $transaction->error_message =~ /$_/ } + $conf->config('emaildecline-exclude') + ) { + 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', $self->agentnum ), + '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; + + } + + $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: $options{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 realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ] + +Verifies successful third party processing of a realtime credit card, +ACH (electronic check) or phone bill transaction via a +Business::OnlineThirdPartyPayment realtime gateway. See +L for supported gateways. + +Available options are: I, I, I, I, I + +The additional options 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 successful) 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. + +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. + +Returns a hashref containing elements bill_error (which will be undefined +upon success) and session_id of any associated session. + +=cut + +sub realtime_botpp_capture { + my( $self, $cust_pay_pending, %options ) = @_; + if ( $DEBUG ) { + warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + eval "use Business::OnlineThirdPartyPayment"; + die $@ if $@; + + ### + # select the gateway + ### + + my $method = FS::payby->payby2bop($cust_pay_pending->payby); + + my $payment_gateway = $cust_pay_pending->gatewaynum + ? qsearchs( 'payment_gateway', + { gatewaynum => $cust_pay_pending->gatewaynum } + ) + : $self->agent->payment_gateway( 'method' => $method, + # 'invnum' => $cust_pay_pending->invnum, + # 'payinfo' => $cust_pay_pending->payinfo, + ); + + $options{payment_gateway} = $payment_gateway; # for the helper subs + + ### + # massage data + ### + + my @invoicing_list = $self->invoicing_list_emailonly; + if ( $conf->exists('emailinvoiceautoalways') + || $conf->exists('emailinvoiceauto') && ! @invoicing_list + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + + my $email = ($conf->exists('business-onlinepayment-email-override')) + ? $conf->config('business-onlinepayment-email-override') + : $invoicing_list[0]; + + my %content = (); + + $content{email_customer} = + ( $conf->exists('business-onlinepayment-email_customer') + || $conf->exists('business-onlinepayment-email-override') ); + + ### + # run transaction(s) + ### + + my $transaction = + new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $transaction->reference({ %options }); + + $transaction->content( + 'type' => $method, + $self->_bop_auth(\%options), + 'action' => 'Post Authorization', + 'description' => $options{'description'}, + 'amount' => $cust_pay_pending->paid, + #'invoice_number' => $options{'invnum'}, + 'customer_id' => $self->custnum, + 'referer' => 'http://cleanwhisker.420.am/', + 'reference' => $cust_pay_pending->paypendingnum, + 'email' => $email, + 'phone' => $self->daytime || $self->night, + %content, #after + # plus whatever is required for bogus capture avoidance + ); + + $transaction->submit(); + + my $error = + $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options ); + + { + bill_error => $error, + session_id => $cust_pay_pending->session_id, + } + +} + +=item default_payment_gateway DEPRECATED -- use agent->payment_gateway + +=cut + +sub default_payment_gateway { + my( $self, $method ) = @_; + + die "Real-time processing not enabled\n" + unless $conf->exists('business-onlinepayment'); + + #warn "default_payment_gateway deprecated -- use agent->payment_gateway\n"; + + #load up config + my $bop_config = 'business-onlinepayment'; + $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'; + pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; + die "No real-time processor is enabled - ". + "did you set the business-onlinepayment configuration value?\n" + unless $processor; + + ( $processor, $login, $password, $action, @bop_options ) +} + +=item remove_cvv + +Removes the I field from the database directly. + +If there is an error, returns the error, otherwise returns false. + +=cut + +sub remove_cvv { + my $self = shift; + my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?") + or return dbh->errstr; + $sth->execute($self->custnum) + or return $sth->errstr; + $self->paycvv(''); + ''; +} + +=item _new_realtime_refund_bop METHOD [ OPTION => VALUE ... ] + +Refunds 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, I + +Most gateways require a reference to an original payment transaction to refund, +so you probably need to specify a I. + +I defaults to the original amount of the payment if not specified. + +I specifies a reason for the refund. + +I specifies the expiration date for a credit card overriding the +value from the customer record or the payment record. Specified as yyyy-mm-dd + +Implementation note: If I is unspecified or equal to the amount of the +orignal payment, first an attempt is made to "void" the transaction via +the gateway (to cancel a not-yet settled transaction) and then if that fails, +the normal attempt is made to "refund" ("credit") the transaction via the +gateway is attempted. + +#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. + +#If an I is specified, this payment (if successful) is applied to the +#specified invoice. If you don't specify an I you might want to +#call the B method. + +=cut + +#some false laziness w/realtime_bop, not enough to make it worth merging +#but some useful small subs should be pulled out +sub _new_realtime_refund_bop { + my $self = shift; + + my %options = (); + if (ref($_[0]) ne 'HASH') { + %options = %{$_[0]}; + } else { + my $method = shift; + %options = @_; + $options{method} = $method; + } + if ( $DEBUG ) { - warn "$me realtime_refund_bop: $method refund\n"; + warn "$me realtime_refund_bop (new): $options{method} refund\n"; warn " $_ => $options{$_}\n" foreach keys %options; } - eval "use Business::OnlinePayment"; - die $@ if $@; - ### # look up the original payment and optionally a gateway for that payment ### @@ -3774,7 +5539,7 @@ sub realtime_refund_bop { my $cust_pay = ''; my $amount = $options{'amount'}; - my( $processor, $login, $password, @bop_options ) ; + my( $processor, $login, $password, @bop_options, $namespace ) ; my( $auth, $order_number ) = ( '', '', '' ); if ( $options{'paynum'} ) { @@ -3800,13 +5565,22 @@ sub realtime_refund_bop { $processor = $payment_gateway->gateway_module; $login = $payment_gateway->gateway_username; $password = $payment_gateway->gateway_password; + $namespace = $payment_gateway->gateway_namespace; @bop_options = $payment_gateway->options; } else { #try the default gateway - my( $conf_processor, $unused_action ); - ( $conf_processor, $login, $password, $unused_action, @bop_options ) = - $self->default_payment_gateway($method); + my $conf_processor; + my $payment_gateway = + $self->agent->payment_gateway('method' => $options{method}); + + ( $conf_processor, $login, $password, $namespace ) = + map { my $method = "gateway_$_"; $payment_gateway->$method } + qw( module username password namespace ); + + @bop_options = $payment_gateway->gatewaynum + ? $payment_gateway->options + : @{ $payment_gateway->get('options') }; return "processor of payment $options{'paynum'} $processor does not". " match default processor $conf_processor" @@ -3817,51 +5591,32 @@ sub realtime_refund_bop { } else { # didn't specify a paynum, so look for agent gateway overrides # like a normal transaction - - my $cardtype; - if ( $method eq 'CC' ) { - $cardtype = cardtype($self->payinfo); - } elsif ( $method eq 'ECHECK' ) { - $cardtype = 'ACH'; - } else { - $cardtype = $method; - } - my $override = - qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => $cardtype, - taxclass => '', } ) - || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => '', - taxclass => '', } ); - - if ( $override ) { #use a payment gateway override - my $payment_gateway = $override->payment_gateway; - - $processor = $payment_gateway->gateway_module; - $login = $payment_gateway->gateway_username; - $password = $payment_gateway->gateway_password; - #$action = $payment_gateway->gateway_action; - @bop_options = $payment_gateway->options; - - } else { #use the standard settings from the config - - my $unused_action; - ( $processor, $login, $password, $unused_action, @bop_options ) = - $self->default_payment_gateway($method); + my $payment_gateway = + $self->agent->payment_gateway( 'method' => $options{method}, + #'payinfo' => $payinfo, + ); + my( $processor, $login, $password, $namespace ) = + map { my $method = "gateway_$_"; $payment_gateway->$method } + qw( module username password namespace ); - } + my @bop_options = $payment_gateway->gatewaynum + ? $payment_gateway->options + : @{ $payment_gateway->get('options') }; } return "neither amount nor paynum specified" unless $amount; + eval "use $namespace"; + die $@ if $@; + my %content = ( - 'type' => $method, + 'type' => $options{method}, 'login' => $login, 'password' => $password, 'order_number' => $order_number, 'amount' => $amount, - 'referer' => 'http://cleanwhisker.420.am/', + 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/ ); $content{authorization} = $auth if length($auth); #echeck/ACH transactions have an order # but no auth @@ -3906,7 +5661,7 @@ sub realtime_refund_bop { $address .= ", ". $self->address2 if $self->address2; my($payname, $payfirst, $paylast); - if ( $self->payname && $method ne 'ECHECK' ) { + if ( $self->payname && $options{method} ne 'ECHECK' ) { $payname = $self->payname; $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ or return "Illegal payname $payname"; @@ -3935,7 +5690,7 @@ sub realtime_refund_bop { if length($payip); my $payinfo = ''; - if ( $method eq 'CC' ) { + if ( $options{method} eq 'CC' ) { if ( $cust_pay ) { $content{card_number} = $payinfo = $cust_pay->payinfo; @@ -3949,7 +5704,7 @@ sub realtime_refund_bop { $content{expiration} = "$2/$1"; } - } elsif ( $method eq 'ECHECK' ) { + } elsif ( $options{method} eq 'ECHECK' ) { if ( $cust_pay ) { $payinfo = $cust_pay->payinfo; @@ -3962,7 +5717,7 @@ sub realtime_refund_bop { $content{account_name} = $payname; $content{customer_org} = $self->company ? 'B' : 'I'; $content{customer_ssn} = $self->ss; - } elsif ( $method eq 'LEC' ) { + } elsif ( $options{method} eq 'LEC' ) { $content{phone} = $payinfo = $self->payinfo; } @@ -3990,12 +5745,6 @@ sub realtime_refund_bop { return "$processor error: ". $refund->error_message unless $refund->is_success(); - my %method2payby = ( - 'CC' => 'CARD', - 'ECHECK' => 'CHEK', - 'LEC' => 'LECB', - ); - my $paybatch = "$processor:". $refund->authorization; $paybatch .= ':'. $refund->order_number if $refund->can('order_number') && $refund->order_number; @@ -4013,7 +5762,7 @@ sub realtime_refund_bop { 'paynum' => $options{'paynum'}, 'refund' => $amount, '_date' => '', - 'payby' => $method2payby{$method}, + 'payby' => $bop_method2payby{$options{method}}, 'payinfo' => $payinfo, 'paybatch' => $paybatch, 'reason' => $options{'reason'} || 'card or ACH refund', @@ -4138,7 +5887,9 @@ sub batch_card { die $error; } - my $unapplied = $self->total_credited + $self->total_unapplied_payments + $self->in_transit_payments; + my $unapplied = $self->total_unapplied_credits + + $self->total_unapplied_payments + + $self->in_transit_payments; foreach my $cust_bill ($self->open_cust_bill) { #$dbh->commit or die $dbh->errstr if $oldAutoCommit; my $cust_bill_pay_batch = new FS::cust_bill_pay_batch { @@ -4165,39 +5916,6 @@ sub batch_card { ''; } -=item total_owed - -Returns the total owed for this customer on all invoices -(see L). - -=cut - -sub total_owed { - my $self = shift; - $self->total_owed_date(2145859200); #12/31/2037 -} - -=item total_owed_date TIME - -Returns the total owed for this customer on all invoices with date earlier than -TIME. TIME is specified as a UNIX timestamp; see L). Also -see L and L for conversion functions. - -=cut - -sub total_owed_date { - my $self = shift; - my $time = shift; - my $total_bill = 0; - foreach my $cust_bill ( - grep { $_->_date <= $time } - qsearch('cust_bill', { 'custnum' => $self->custnum, } ) - ) { - $total_bill += $cust_bill->owed; - } - sprintf( "%.2f", $total_bill ); -} - =item apply_payments_and_credits Applies unapplied payments and credits. @@ -4267,7 +5985,7 @@ sub apply_credits { $self->select_for_update; #mutex - unless ( $self->total_credited ) { + unless ( $self->total_unapplied_credits ) { $dbh->commit or die $dbh->errstr if $oldAutoCommit; return 0; } @@ -4308,11 +6026,11 @@ sub apply_credits { } - my $total_credited = $self->total_credited; + my $total_unapplied_credits = $self->total_unapplied_credits; $dbh->commit or die $dbh->errstr if $oldAutoCommit; - return $total_credited; + return $total_unapplied_credits; } =item apply_payments @@ -4344,11 +6062,13 @@ sub apply_payments { #return 0 unless - my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 } - qsearch('cust_pay', { 'custnum' => $self->custnum } ) ); + my @payments = sort { $b->_date <=> $a->_date } + grep { $_->unapplied > 0 } + $self->cust_pay; - my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 } - qsearch('cust_bill', { 'custnum' => $self->custnum } ) ); + my @invoices = sort { $a->_date <=> $b->_date} + grep { $_->owed > 0 } + $self->cust_bill; my $payment; @@ -4387,21 +6107,89 @@ sub apply_payments { return $total_unapplied_payments; } -=item total_credited +=item total_owed + +Returns the total owed for this customer on all invoices +(see L). + +=cut + +sub total_owed { + my $self = shift; + $self->total_owed_date(2145859200); #12/31/2037 +} + +=item total_owed_date TIME + +Returns the total owed for this customer on all invoices with date earlier than +TIME. TIME is specified as a UNIX timestamp; see L). Also +see L and L for conversion functions. + +=cut + +sub total_owed_date { + my $self = shift; + my $time = shift; + +# my $custnum = $self->custnum; +# +# my $owed_sql = FS::cust_bill->owed_sql; +# +# my $sql = " +# SELECT SUM($owed_sql) FROM cust_bill +# WHERE custnum = $custnum +# AND _date <= $time +# "; +# +# my $sth = dbh->prepare($sql) or die dbh->errstr; +# $sth->execute() or die $sth->errstr; +# +# return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] ); + + my $total_bill = 0; + foreach my $cust_bill ( + grep { $_->_date <= $time } + qsearch('cust_bill', { 'custnum' => $self->custnum, } ) + ) { + $total_bill += $cust_bill->owed; + } + sprintf( "%.2f", $total_bill ); + +} + +=item total_paid + +Returns the total amount of all payments. + +=cut + +sub total_paid { + my $self = shift; + my $total = 0; + $total += $_->paid foreach $self->cust_pay; + sprintf( "%.2f", $total ); +} + +=item total_unapplied_credits Returns the total outstanding credit (see L) for this customer. See L. +=item total_credited + +Old name for total_unapplied_credits. Don't use. + =cut sub total_credited { + #carp "total_credited deprecated, use total_unapplied_credits"; + shift->total_unapplied_credits(@_); +} + +sub total_unapplied_credits { my $self = shift; my $total_credit = 0; - foreach my $cust_credit ( qsearch('cust_credit', { - 'custnum' => $self->custnum, - } ) ) { - $total_credit += $cust_credit->credited; - } + $total_credit += $_->credited foreach $self->cust_credit; sprintf( "%.2f", $total_credit ); } @@ -4415,11 +6203,7 @@ See L. sub total_unapplied_payments { my $self = shift; my $total_unapplied = 0; - foreach my $cust_pay ( qsearch('cust_pay', { - 'custnum' => $self->custnum, - } ) ) { - $total_unapplied += $cust_pay->unapplied; - } + $total_unapplied += $_->unapplied foreach $self->cust_pay; sprintf( "%.2f", $total_unapplied ); } @@ -4433,18 +6217,14 @@ customer. See L. sub total_unapplied_refunds { my $self = shift; my $total_unapplied = 0; - foreach my $cust_refund ( qsearch('cust_refund', { - 'custnum' => $self->custnum, - } ) ) { - $total_unapplied += $cust_refund->unapplied; - } + $total_unapplied += $_->unapplied foreach $self->cust_refund; sprintf( "%.2f", $total_unapplied ); } =item balance Returns the balance for this customer (total_owed plus total_unrefunded, minus -total_credited minus total_unapplied_payments). +total_unapplied_credits minus total_unapplied_payments). =cut @@ -4453,7 +6233,7 @@ sub balance { sprintf( "%.2f", $self->total_owed + $self->total_unapplied_refunds - - $self->total_credited + - $self->total_unapplied_credits - $self->total_unapplied_payments ); } @@ -4474,7 +6254,7 @@ sub balance_date { sprintf( "%.2f", $self->total_owed_date($time) + $self->total_unapplied_refunds - - $self->total_credited + - $self->total_unapplied_credits - $self->total_unapplied_payments ); } @@ -4499,7 +6279,87 @@ sub in_transit_payments { $in_transit_payments += $cust_pay_batch->amount; } } - sprintf( "%.2f", $in_transit_payments ); + sprintf( "%.2f", $in_transit_payments ); +} + +=item payment_info + +Returns a hash of useful information for making a payment. + +=over 4 + +=item balance + +Current balance. + +=item payby + +'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand), +'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand), +'LECB' (Phone bill billing), 'BILL' (billing), or 'COMP' (free). + +=back + +For credit card transactions: + +=over 4 + +=item card_type 1 + +=item payname + +Exact name on card + +=back + +For electronic check transactions: + +=over 4 + +=item stateid_state + +=back + +=cut + +sub payment_info { + my $self = shift; + + my %return = (); + + $return{balance} = $self->balance; + + $return{payname} = $self->payname + || ( $self->first. ' '. $self->get('last') ); + + $return{$_} = $self->get($_) for qw(address1 address2 city state zip); + + $return{payby} = $self->payby; + $return{stateid_state} = $self->stateid_state; + + if ( $self->payby =~ /^(CARD|DCRD)$/ ) { + $return{card_type} = cardtype($self->payinfo); + $return{payinfo} = $self->paymask; + + @return{'month', 'year'} = $self->paydate_monthyear; + + } + + if ( $self->payby =~ /^(CHEK|DCHK)$/ ) { + my ($payinfo1, $payinfo2) = split '@', $self->paymask; + $return{payinfo1} = $payinfo1; + $return{payinfo2} = $payinfo2; + $return{paytype} = $self->paytype; + $return{paystate} = $self->paystate; + + } + + #doubleclick protection + my $_date = time; + $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32; + + %return; + } =item paydate_monthyear @@ -4520,6 +6380,28 @@ sub paydate_monthyear { } } +=item tax_exemption TAXNAME + +=cut + +sub tax_exemption { + my( $self, $taxname ) = @_; + + qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum, + 'taxname' => $taxname, + }, + ); +} + +=item cust_main_exemption + +=cut + +sub cust_main_exemption { + my $self = shift; + qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } ); +} + =item invoicing_list [ ARRAYREF ] If an arguement is given, sets these email addresses as invoice recipients @@ -4762,21 +6644,47 @@ sub referring_cust_main { qsearchs('cust_main', { 'custnum' => $self->referral_custnum } ); } -=item credit AMOUNT, REASON +=item credit AMOUNT, REASON [ , OPTION => VALUE ... ] Applies a credit to this customer. If there is an error, returns the error, otherwise returns false. +REASON can be a text string, an FS::reason object, or a scalar reference to +a reasonnum. If a text string, it will be automatically inserted as a new +reason, and a 'reason_type' option must be passed to indicate the +FS::reason_type for the new reason. + +An I option may be passed to set the credit's I field. + +Any other options are passed to FS::cust_credit::insert. + =cut sub credit { my( $self, $amount, $reason, %options ) = @_; + my $cust_credit = new FS::cust_credit { 'custnum' => $self->custnum, 'amount' => $amount, - 'reason' => $reason, }; + + if ( ref($reason) ) { + + if ( ref($reason) eq 'SCALAR' ) { + $cust_credit->reasonnum( $$reason ); + } else { + $cust_credit->reasonnum( $reason->reasonnum ); + } + + } else { + $cust_credit->set('reason', $reason) + } + + $cust_credit->addlinfo( delete $options{'addlinfo'} ) + if exists($options{'addlinfo'}); + $cust_credit->insert(%options); + } =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ] @@ -4788,21 +6696,27 @@ the error, otherwise returns false. sub charge { my $self = shift; - my ( $amount, $quantity, $pkg, $comment, $taxclass, $additional, $classnum ); + my ( $amount, $quantity, $pkg, $comment, $classnum, $additional ); + my ( $setuptax, $taxclass ); #internal taxes + my ( $taxproduct, $override ); #vendor (CCH) taxes if ( ref( $_[0] ) ) { $amount = $_[0]->{amount}; $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge'; $comment = exists($_[0]->{comment}) ? $_[0]->{comment} : '$'. sprintf("%.2f",$amount); + $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : ''; $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : ''; $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : ''; $additional = $_[0]->{additional}; + $taxproduct = $_[0]->{taxproductnum}; + $override = { '' => $_[0]->{tax_override} }; }else{ $amount = shift; $quantity = 1; $pkg = @_ ? shift : 'One-time charge'; $comment = @_ ? shift : '$'. sprintf("%.2f",$amount); + $setuptax = ''; $taxclass = @_ ? shift : ''; $additional = []; } @@ -4819,13 +6733,15 @@ sub charge { my $dbh = dbh; my $part_pkg = new FS::part_pkg ( { - 'pkg' => $pkg, - 'comment' => $comment, - 'plan' => 'flat', - 'freq' => 0, - 'disabled' => 'Y', - 'classnum' => $classnum ? $classnum : '', - 'taxclass' => $taxclass, + 'pkg' => $pkg, + 'comment' => $comment, + 'plan' => 'flat', + 'freq' => 0, + 'disabled' => 'Y', + 'classnum' => $classnum ? $classnum : '', + 'setuptax' => $setuptax, + 'taxclass' => $taxclass, + 'taxproductnum' => $taxproduct, } ); my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) } @@ -4835,7 +6751,9 @@ sub charge { 'setup_fee' => $amount, ); - my $error = $part_pkg->insert( options => \%options ); + my $error = $part_pkg->insert( options => \%options, + tax_overrides => $override, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -4917,7 +6835,14 @@ customer. sub open_cust_bill { my $self = shift; - grep { $_->owed > 0 } $self->cust_bill; + + qsearch({ + 'table' => 'cust_bill', + 'hashref' => { 'custnum' => $self->custnum, }, + 'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0', + 'order_by' => 'ORDER BY _date ASC', + }); + } =item cust_credit @@ -4964,10 +6889,45 @@ Returns all batched payments (see L) for this customer. sub cust_pay_batch { my $self = shift; - sort { $a->_date <=> $b->_date } + sort { $a->paybatchnum <=> $b->paybatchnum } qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } ) } +=item cust_pay_pending + +Returns all pending payments (see L) for this customer +(without status "done"). + +=cut + +sub cust_pay_pending { + my $self = shift; + return $self->num_cust_pay_pending unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_pay_pending', { + 'custnum' => $self->custnum, + 'status' => { op=>'!=', value=>'done' }, + }, + ); +} + +=item num_cust_pay_pending + +Returns the number of pending payments (see L) for this +customer (without status "done"). Also called automatically when the +cust_pay_pending method is used in a scalar context. + +=cut + +sub num_cust_pay_pending { + my $self = shift; + my $sql = " SELECT COUNT(*) FROM cust_pay_pending ". + " WHERE custnum = ? AND status != 'done' "; + my $sth = dbh->prepare($sql) or die dbh->errstr; + $sth->execute($self->custnum) or die $sth->errstr; + $sth->fetchrow_arrayref->[0]; +} + =item cust_refund Returns all the refunds (see L) for this customer. @@ -4980,6 +6940,22 @@ sub cust_refund { qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) } +=item display_custnum + +Returns the displayed customer number for this customer: agent_custid if +cust_main-default_agent_custid is set and it has a value, custnum otherwise. + +=cut + +sub display_custnum { + my $self = shift; + if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){ + return $self->agent_custid; + } else { + return $self->custnum; + } +} + =item name Returns a name string for this customer, either "Company (Last, First)" or @@ -5012,6 +6988,35 @@ sub ship_name { } } +=item name_short + +Returns a name string for this customer, either "Company" or "First Last". + +=cut + +sub name_short { + my $self = shift; + $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast; +} + +=item ship_name_short + +Returns a name string for this (service/shipping) contact, either "Company" +or "First Last". + +=cut + +sub ship_name_short { + my $self = shift; + if ( $self->get('ship_last') ) { + $self->ship_company !~ /^\s*$/ + ? $self->ship_company + : $self->ship_contact_firstlast; + } else { + $self->name_company_or_firstlast; + } +} + =item contact Returns this customer's full (billing) contact name only, "Last, First" @@ -5036,6 +7041,30 @@ sub ship_contact { : $self->contact; } +=item contact_firstlast + +Returns this customers full (billing) contact name only, "First Last". + +=cut + +sub contact_firstlast { + my $self = shift; + $self->first. ' '. $self->get('last'); +} + +=item ship_contact_firstlast + +Returns this customer's full (shipping) contact name only, "First Last". + +=cut + +sub ship_contact_firstlast { + my $self = shift; + $self->get('ship_last') + ? $self->first. ' '. $self->get('ship_last') + : $self->contact_firstlast; +} + =item country_full Returns this customer's full country name @@ -5057,6 +7086,9 @@ Currently this only makes sense for "CCH" as DATA_VENDOR. sub geocode { my ($self, $data_vendor) = (shift, shift); #always cch for now + my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode + return $geocode if $geocode; + my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) ) ? 'ship_' : ''; @@ -5067,16 +7099,16 @@ sub geocode { #CCH specific location stuff my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'"; - my $geocode = ''; - my $cust_tax_location = - qsearchs( { - 'table' => 'cust_tax_location', - 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor }, - 'extra_sql' => $extra_sql, - } - ); - $geocode = $cust_tax_location->geocode - if $cust_tax_location; + my @cust_tax_location = + qsearch( { + 'table' => 'cust_tax_location', + 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor }, + 'extra_sql' => $extra_sql, + 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends + } + ); + $geocode = $cust_tax_location[0]->geocode + if scalar(@cust_tax_location); $geocode; } @@ -5166,22 +7198,24 @@ sub tickets { my $num = $conf->config('cust_main-max_tickets') || 10; my @tickets = (); - unless ( $conf->config('ticket_system-custom_priority_field') ) { + if ( $conf->config('ticket_system') ) { + unless ( $conf->config('ticket_system-custom_priority_field') ) { - @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) }; + @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) }; - } else { + } else { - foreach my $priority ( - $conf->config('ticket_system-custom_priority_field-values'), '' - ) { - last if scalar(@tickets) >= $num; - push @tickets, - @{ FS::TicketSystem->customer_tickets( $self->custnum, - $num - scalar(@tickets), - $priority, - ) - }; + foreach my $priority ( + $conf->config('ticket_system-custom_priority_field-values'), '' + ) { + last if scalar(@tickets) >= $num; + push @tickets, + @{ FS::TicketSystem->customer_tickets( $self->custnum, + $num - scalar(@tickets), + $priority, + ) + }; + } } } (@tickets); @@ -5343,7 +7377,7 @@ sub balance_sql { " Returns an SQL fragment to retreive the balance for this customer, only considering invoices with date earlier than START_TIME, and optionally not -later than END_TIME (total_owed_date minus total_credited minus +later than END_TIME (total_owed_date minus total_unapplied_credits minus total_unapplied_payments). Times are specified as SQL fragments or numeric @@ -5867,22 +7901,28 @@ sub smart_search { # custnum search (also try agent_custid), with some tweaking options if your # legacy cust "numbers" have letters - } elsif ( $search =~ /^\s*(\d+)\s*$/ + } + + if ( $search =~ /^\s*(\d+)\s*$/ || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+' && $search =~ /^\s*(\w\w?\d+)\s*$/ ) ) { - push @cust_main, qsearch( { - 'table' => 'cust_main', - 'hashref' => { 'custnum' => $1, %options }, - 'extra_sql' => " AND $agentnums_sql", #agent virtualization - } ); + my $num = $1; + + if ( $num <= 2147483647 ) { #need a bigint custnum? wow. + push @cust_main, qsearch( { + 'table' => 'cust_main', + 'hashref' => { 'custnum' => $num, %options }, + 'extra_sql' => " AND $agentnums_sql", #agent virtualization + } ); + } push @cust_main, qsearch( { 'table' => 'cust_main', - 'hashref' => { 'agent_custid' => $1, %options }, + 'hashref' => { 'agent_custid' => $num, %options }, 'extra_sql' => " AND $agentnums_sql", #agent virtualization } ); @@ -6216,322 +8256,6 @@ sub append_fuzzyfiles { 1; } -=item process_batch_import - -Load a batch import as a queued JSRPC job - -=cut - -use Storable qw(thaw); -use Data::Dumper; -use MIME::Base64; -sub process_batch_import { - my $job = shift; - - my $param = thaw(decode_base64(shift)); - warn Dumper($param) if $DEBUG; - - my $files = $param->{'uploaded_files'} - or die "No files provided.\n"; - - my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files; - - my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/'; - my $file = $dir. $files{'file'}; - - my $type; - if ( $file =~ /\.(\w+)$/i ) { - $type = lc($1); - } else { - #or error out??? - warn "can't parse file type from filename $file; defaulting to CSV"; - $type = 'csv'; - } - - my $error = - FS::cust_main::batch_import( { - job => $job, - file => $file, - type => $type, - custbatch => $param->{custbatch}, - agentnum => $param->{'agentnum'}, - refnum => $param->{'refnum'}, - pkgpart => $param->{'pkgpart'}, - #'fields' => [qw( cust_pkg.setup dayphone first last address1 address2 - # city state zip comments )], - 'format' => $param->{'format'}, - } ); - - unlink $file; - - die "$error\n" if $error; - -} - -=item batch_import - -=cut - -#some false laziness w/cdr.pm now -sub batch_import { - my $param = shift; - - my $job = $param->{job}; - - my $filename = $param->{file}; - my $type = $param->{type} || 'csv'; - - my $custbatch = $param->{custbatch}; - - my $agentnum = $param->{agentnum}; - my $refnum = $param->{refnum}; - my $pkgpart = $param->{pkgpart}; - - my $format = $param->{'format'}; - - my @fields; - my $payby; - if ( $format eq 'simple' ) { - @fields = qw( cust_pkg.setup dayphone first last - address1 address2 city state zip comments ); - $payby = 'BILL'; - } elsif ( $format eq 'extended' ) { - @fields = qw( agent_custid refnum - last first address1 address2 city state zip country - daytime night - ship_last ship_first 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'; - } 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"; - } - - my $count; - my $parser; - my @buffer = (); - if ( $type eq 'csv' ) { - - eval "use Text::CSV_XS;"; - die $@ if $@; - - $parser = new Text::CSV_XS; - - @buffer = split(/\r?\n/, slurp($filename) ); - $count = scalar(@buffer); - - } elsif ( $type eq 'xls' ) { - - eval "use Spreadsheet::ParseExcel;"; - die $@ if $@; - - my $excel = new Spreadsheet::ParseExcel::Workbook->Parse($filename); - $parser = $excel->{Worksheet}[0]; #first sheet - - $count = $parser->{MaxRow} || $parser->{MinRow}; - $count++; - - } else { - die "Unknown file type $type\n"; - } - - #my $columns; - - 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; - - my $line; - my $row = 0; - my( $last, $min_sec ) = ( time, 5 ); #progressbar foo - while (1) { - - my @columns = (); - if ( $type eq 'csv' ) { - - last unless scalar(@buffer); - $line = shift(@buffer); - - $parser->parse($line) or do { - $dbh->rollback if $oldAutoCommit; - return "can't parse: ". $parser->error_input(); - }; - @columns = $parser->fields(); - - } elsif ( $type eq 'xls' ) { - - last if $row > ($parser->{MaxRow} || $parser->{MinRow}); - - my @row = @{ $parser->{Cells}[$row] }; - @columns = map $_->{Val}, @row; - - #my $z = 'A'; - #warn $z++. ": $_\n" for @columns; - - } else { - die "Unknown file type $type\n"; - } - - #warn join('-',@columns); - - my %cust_main = ( - custbatch => $custbatch, - agentnum => $agentnum, - refnum => $refnum, - country => $conf->config('countrydefault') || 'US', - payby => $payby, #default - paydate => '12/2037', #default - ); - my $billtime = time; - my %cust_pkg = ( pkgpart => $pkgpart ); - my %svc_acct = (); - foreach my $field ( @fields ) { - - if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) { - - #$cust_pkg{$1} = str2time( shift @$columns ); - if ( $1 eq 'pkgpart' ) { - $cust_pkg{$1} = shift @columns; - } elsif ( $1 eq 'setup' ) { - $billtime = str2time(shift @columns); - } else { - $cust_pkg{$1} = str2time( shift @columns ); - } - - } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) { - - $svc_acct{$1} = shift @columns; - - } else { - - #refnum interception - if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) { - - my $referral = $columns[0]; - my %hash = ( 'referral' => $referral, - 'agentnum' => $agentnum, - 'disabled' => '', - ); - - my $part_referral = qsearchs('part_referral', \%hash ) - || new FS::part_referral \%hash; - - unless ( $part_referral->refnum ) { - my $error = $part_referral->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't auto-insert advertising source: $referral: $error"; - } - } - - $columns[0] = $part_referral->refnum; - } - - #$cust_main{$field} = shift @$columns; - $cust_main{$field} = shift @columns; - } - } - - $cust_main{'payby'} = 'CARD' - if defined $cust_main{'payinfo'} - && length $cust_main{'payinfo'}; - - my $invoicing_list = $cust_main{'invoicing_list'} - ? [ delete $cust_main{'invoicing_list'} ] - : []; - - my $cust_main = new FS::cust_main ( \%cust_main ); - - use Tie::RefHash; - tie my %hash, 'Tie::RefHash'; #this part is important - - if ( $cust_pkg{'pkgpart'} ) { - my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ); - - my @svc_acct = (); - if ( $svc_acct{'username'} ) { - my $part_pkg = $cust_pkg->part_pkg; - unless ( $part_pkg ) { - $dbh->rollback if $oldAutoCommit; - return "unknown pkgpart: ". $cust_pkg{'pkgpart'}; - } - $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' ); - push @svc_acct, new FS::svc_acct ( \%svc_acct ) - } - - $hash{$cust_pkg} = \@svc_acct; - } - - my $error = $cust_main->insert( \%hash, $invoicing_list ); - - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't insert customer". ( $line ? " for $line" : '' ). ": $error"; - } - - if ( $format eq 'simple' ) { - - #false laziness w/bill.cgi - $error = $cust_main->bill( 'time' => $billtime ); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't bill customer for $line: $error"; - } - - $error = $cust_main->apply_payments_and_credits; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't bill customer for $line: $error"; - } - - $error = $cust_main->collect(); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't collect customer for $line: $error"; - } - - } - - $row++; - - if ( $job && time - $min_sec > $last ) { #progress bar - $job->update_statustext( int(100 * $row / $count) ); - $last = time; - } - - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit;; - - return "Empty file!" unless $row; - - ''; #no error - -} - =item batch_charge =cut @@ -6645,18 +8369,19 @@ I<$expdate> - the expiration of the customer payment in seconds from epoch =cut sub notify { - my ($customer, $template, %options) = @_; + my ($self, $template, %options) = @_; return unless $conf->exists($template); - my $from = $conf->config('invoice_from') if $conf->exists('invoice_from'); + my $from = $conf->config('invoice_from', $self->agentnum) + if $conf->exists('invoice_from', $self->agentnum); $from = $options{from} if exists($options{from}); - my $to = join(',', $customer->invoicing_list_emailonly); + my $to = join(',', $self->invoicing_list_emailonly); $to = $options{to} if exists($options{to}); - my $subject = "Notice from " . $conf->config('company_name') - if $conf->exists('company_name'); + my $subject = "Notice from " . $conf->config('company_name', $self->agentnum) + if $conf->exists('company_name', $self->agentnum); $subject = $options{subject} if exists($options{subject}); my $notify_template = new Text::Template (TYPE => 'ARRAY', @@ -6667,16 +8392,17 @@ sub notify { $notify_template->compile() or die "can't compile template: Text::Template::ERROR"; - $FS::notify_template::_template::company_name = $conf->config('company_name'); + $FS::notify_template::_template::company_name = + $conf->config('company_name', $self->agentnum); $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; - $FS::notify_template::_template::payinfo = $customer->mask_payinfo; - my $payby = $customer->payby; + join("\n", $conf->config('company_address', $self->agentnum) ). "\n"; + + my $paydate = $self->paydate || '2037-12-31'; + $FS::notify_template::_template::first = $self->first; + $FS::notify_template::_template::last = $self->last; + $FS::notify_template::_template::company = $self->company; + $FS::notify_template::_template::payinfo = $self->mask_payinfo; + my $payby = $self->payby; my ($payyear,$paymonth,$payday) = split (/-/,$paydate); my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear); @@ -6776,10 +8502,10 @@ sub generate_letter { ); if ( length($retadd) ) { $letter_data{returnaddress} = $retadd; - } elsif ( grep /\S/, $conf->config('company_address') ) { + } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) { $letter_data{returnaddress} = join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg, - $conf->config('company_address') + $conf->config('company_address', $self->agentnum) ); } else { $letter_data{returnaddress} = '~'; @@ -6788,9 +8514,9 @@ sub generate_letter { $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc"; - $letter_data{company_name} = $conf->config('company_name'); + $letter_data{company_name} = $conf->config('company_name', $self->agentnum); - my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc; + my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc; my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX', DIR => $dir, SUFFIX => '.tex', @@ -6838,6 +8564,8 @@ sub print { do_print [ $self->print_ps($template) ]; } +#these three subs should just go away once agent stuff is all config overrides + sub agent_template { my $self = shift; $self->_agent_plandata('agent_templatename'); @@ -6878,9 +8606,13 @@ sub _agent_plandata { AND peo_agentnum.optionname = 'agentnum' 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 - AND peo_cust_bill_age.optionname = 'cust_bill_age' + LEFT JOIN part_event_condition + ON ( part_event.eventpart = part_event_condition.eventpart + AND part_event_condition.conditionname = 'cust_bill_age' + ) + LEFT JOIN part_event_condition_option + ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum + AND part_event_condition_option.optionname = 'age' ) }, #'hashref' => { 'optionname' => $option }, @@ -6890,11 +8622,11 @@ sub _agent_plandata { " AND action = 'cust_bill_send_agent' ". " AND ( disabled IS NULL OR disabled != 'Y' ) ". " AND peo_agentnum.optionname = 'agentnum' ". - " AND agentnum IS NULL OR agentnum = $agentnum ". + " AND ( agentnum IS NULL OR agentnum = $agentnum ) ". " ORDER BY - CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age' + CASE WHEN part_event_condition_option.optionname IS NULL THEN -1 - ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue'). + ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue'). " END , part_event.weight". " LIMIT 1" @@ -6920,6 +8652,15 @@ sub queued_bill { ); } +sub _upgrade_data { #class method + my ($class, %opts) = @_; + + my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL'; + my $sth = dbh->prepare($sql) or die dbh->errstr; + $sth->execute or die $sth->errstr; + +} + =back =head1 BUGS