1 package FS::cust_pay_batch;
4 use vars qw( @ISA $DEBUG );
5 use Carp qw( confess );
6 use Business::CreditCard 0.28;
7 use FS::Record qw(dbh qsearch qsearchs);
11 use Storable qw( thaw );
12 use MIME::Base64 qw( decode_base64 );
15 @ISA = qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
17 # 1 is mostly method/subroutine entry and options
18 # 2 traces progress of some operations
19 # 3 is even more information including possibly sensitive data
22 #@encrypted_fields = ('payinfo');
23 sub nohistory_fields { ('payinfo'); }
27 FS::cust_pay_batch - Object methods for batch cards
31 use FS::cust_pay_batch;
33 $record = new FS::cust_pay_batch \%hash;
34 $record = new FS::cust_pay_batch { 'column' => 'value' };
36 $error = $record->insert;
38 $error = $new_record->replace($old_record);
40 $error = $record->delete;
42 $error = $record->check;
44 #deprecated# $error = $record->retriable;
48 An FS::cust_pay_batch object represents a credit card transaction ready to be
49 batched (sent to a processor). FS::cust_pay_batch inherits from FS::Record.
50 Typically called by the collect method of an FS::cust_main object. The
51 following fields are currently supported:
55 =item paybatchnum - primary key (automatically assigned)
57 =item batchnum - indentifies group in batch
59 =item payby - CARD/CHEK/LECB/BILL/COMP
63 =item exp - card expiration
67 =item invnum - invoice
69 =item custnum - customer
71 =item payname - name on card
89 =item status - 'Approved' or 'Declined'
91 =item error_message - the error returned by the gateway if any
101 Creates a new record. To add the record to the database, see L<"insert">.
103 Note that this stores the hash reference, not a distinct copy of the hash it
104 points to. You can ask the object for a copy with the I<hash> method.
108 sub table { 'cust_pay_batch'; }
112 Adds this record to the database. If there is an error, returns the error,
113 otherwise returns false.
117 Delete this record from the database. If there is an error, returns the error,
118 otherwise returns false.
120 =item replace OLD_RECORD
122 Replaces the OLD_RECORD with this one in the database. If there is an error,
123 returns the error, otherwise returns false.
127 Checks all fields to make sure this is a valid transaction. If there is
128 an error, returns the error, otherwise returns false. Called by the insert
136 my $conf = new FS::Conf;
139 $self->ut_numbern('paybatchnum')
140 || $self->ut_numbern('trancode') #deprecated
141 || $self->ut_money('amount')
142 || $self->ut_number('invnum')
143 || $self->ut_number('custnum')
144 || $self->ut_text('address1')
145 || $self->ut_textn('address2')
146 || ($conf->exists('cust_main-no_city_in_address')
147 ? $self->ut_textn('city')
148 : $self->ut_text('city'))
149 || $self->ut_textn('state')
152 return $error if $error;
154 $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
155 $self->setfield('last',$1);
157 $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
160 $error = $self->payinfo_check();
161 return $error if $error;
163 if ( $self->exp eq '' ) {
164 return "Expiration date required"
165 unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
168 if ( $self->exp =~ /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/ ) {
169 $self->exp("$1-$2-$3");
170 } elsif ( $self->exp =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
171 if ( length($2) == 4 ) {
172 $self->exp("$2-$1-01");
173 } elsif ( $2 > 98 ) { #should pry change to check for "this year"
174 $self->exp("19$2-$1-01");
176 $self->exp("20$2-$1-01");
179 return "Illegal expiration date";
183 if ( $self->payname eq '' ) {
184 $self->payname( $self->first. " ". $self->getfield('last') );
186 $self->payname =~ /^([\w \,\.\-\']+)$/
187 or return "Illegal billing name";
191 #we have lots of old zips in there... don't hork up batch results cause of em
192 $self->zip =~ /^\s*(\w[\w\-\s]{2,8}\w)\s*$/
193 or return "Illegal zip: ". $self->zip;
196 $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
199 #$error = $self->ut_zip('zip', $self->country);
200 #return $error if $error;
202 #check invnum, custnum, ?
209 Returns the customer (see L<FS::cust_main>) for this batched credit card
216 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
221 Returns the credit card expiration date in MMYY format. If this is a
222 CHEK payment, returns an empty string.
228 if ( $self->payby eq 'CARD' ) {
229 $self->get('exp') =~ /^(\d{4})-(\d{2})-(\d{2})$/;
230 return sprintf('%02u%02u', $2, ($1 % 100));
239 Returns the payment batch this payment belongs to (L<FS::pay_batch>).
245 FS::pay_batch->by_key($self->batchnum);
248 #you know what, screw this in the new world of events. we should be able to
249 #get the event defs to retry (remove once.pm condition, add every.pm) without
250 #mucking about with statuses of previous cust_event records. right?
254 #Marks the corresponding event (see L<FS::cust_bill_event>) for this batched
255 #credit card payment as retriable. Useful if the corresponding financial
256 #institution account was declined for temporary reasons and/or a manual
259 #Implementation details: For the named customer's invoice, changes the
260 #statustext of the 'done' (without statustext) event to 'retriable.'
266 confess "deprecated method cust_pay_batch->retriable called; try removing ".
267 "the once condition and adding an every condition?";
271 local $SIG{HUP} = 'IGNORE'; #Hmm
272 local $SIG{INT} = 'IGNORE';
273 local $SIG{QUIT} = 'IGNORE';
274 local $SIG{TERM} = 'IGNORE';
275 local $SIG{TSTP} = 'IGNORE';
276 local $SIG{PIPE} = 'IGNORE';
278 my $oldAutoCommit = $FS::UID::AutoCommit;
279 local $FS::UID::AutoCommit = 0;
282 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
283 or return "event $self->eventnum references nonexistant invoice $self->invnum";
285 warn "cust_pay_batch->retriable working with self of " . $self->paybatchnum . " and invnum of " . $self->invnum;
286 my @cust_bill_event =
287 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
289 $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
290 && $_->status eq 'done'
293 $cust_bill->cust_bill_event;
294 # complain loudly if scalar(@cust_bill_event) > 1 ?
295 my $error = $cust_bill_event[0]->retriable;
297 # gah, even with transactions.
298 $dbh->commit if $oldAutoCommit; #well.
299 return "error marking invoice event retriable: $error";
304 =item approve OPTIONS
306 Approve this payment. This will replace the existing record with the
307 same paybatchnum, set its status to 'Approved', and generate a payment
308 record (L<FS::cust_pay>). This should only be called from the batch
311 OPTIONS may contain "gatewaynum", "processor", "auth", and "order_number".
316 # to break up the Big Wall of Code that is import_results
319 my $paybatchnum = $new->paybatchnum;
320 my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
321 or return "cannot approve, paybatchnum $paybatchnum not found";
322 # leave these restrictions in place until TD EFT is converted over
324 return "cannot approve paybatchnum $paybatchnum, already resolved ('".$old->status."')"
326 $new->status('Approved');
327 my $error = $new->replace($old);
329 return "error approving paybatchnum $paybatchnum: $error\n";
332 return if $new->paycode eq "C";
334 my $cust_pay = new FS::cust_pay ( {
335 'custnum' => $new->custnum,
336 'payby' => $new->payby,
337 'payinfo' => $new->payinfo || $old->payinfo,
338 'paymask' => $new->mask_payinfo,
339 'paid' => $new->paid,
340 '_date' => $new->_date,
341 'usernum' => $new->usernum,
342 'batchnum' => $new->batchnum,
343 'gatewaynum' => $opt{'gatewaynum'},
344 'processor' => $opt{'processor'},
345 'auth' => $opt{'auth'},
346 'order_number' => $opt{'order_number'}
349 $error = $cust_pay->insert;
351 return "error inserting payment for paybatchnum $paybatchnum: $error\n";
353 $cust_pay->cust_main->apply_payments;
357 =item decline [ REASON ]
359 Decline this payment. This will replace the existing record with the
360 same paybatchnum, set its status to 'Declined', and run collection events
361 as appropriate. This should only be called from the batch import process.
363 REASON is a string description of the decline reason, defaulting to
370 my $reason = shift || 'Returned payment';
371 #my $conf = new FS::Conf;
373 my $paybatchnum = $new->paybatchnum;
374 my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
375 or return "cannot decline, paybatchnum $paybatchnum not found";
376 if ( $old->status ) {
377 # Handle the case where payments are rejected after the batch has been
378 # approved. FS::pay_batch::import_results won't allow results to be
379 # imported to a closed batch unless batch-manual_approval is enabled,
380 # so we don't check it here.
381 # if ( $conf->exists('batch-manual_approval') and
382 if ( lc($old->status) eq 'approved' ) {
384 my $cust_pay = qsearchs('cust_pay', {
385 custnum => $new->custnum,
386 batchnum => $new->batchnum
388 # these should all be migrated over, but if it's not found, look for
389 # batchnum in the 'paybatch' field also
390 $cust_pay ||= qsearchs('cust_pay', {
391 custnum => $new->custnum,
392 paybatch => $new->batchnum
395 # should never happen...
396 return "failed to revoke paybatchnum $paybatchnum, payment not found";
398 $cust_pay->void($reason);
401 # normal case: refuse to do anything
402 return "cannot decline paybatchnum $paybatchnum, already resolved ('".$old->status."')";
405 $new->status('Declined');
406 $new->error_message($reason);
407 my $error = $new->replace($old);
409 return "error declining paybatchnum $paybatchnum: $error\n";
411 my $due_cust_event = $new->cust_main->due_cust_event(
412 'eventtable' => 'cust_pay_batch',
413 'objects' => [ $new ],
415 if ( !ref($due_cust_event) ) {
416 return $due_cust_event;
418 # XXX breaks transaction integrity
419 foreach my $cust_event (@$due_cust_event) {
420 next unless $cust_event->test_conditions;
421 if ( my $error = $cust_event->do_event() ) {
428 =item request_item [ OPTIONS ]
430 Returns a L<Business::BatchPayment::Item> object for this batch payment
431 entry. This can be submitted to a processor.
433 OPTIONS can be a list of key/values to append to the attributes. The most
434 useful case of this is "process_date" to set a processing date based on the
435 date the batch is being submitted.
443 eval "use Business::BatchPayment;";
444 die "couldn't load Business::BatchPayment: $@" if $@;
446 my $cust_main = $self->cust_main;
447 my $location = $cust_main->bill_location;
448 my $pay_batch = $self->pay_batch;
451 $payment{payment_type} = FS::payby->payby2bop( $pay_batch->payby );
452 if ( $payment{payment_type} eq 'CC' ) {
453 $payment{card_number} = $self->payinfo,
454 $payment{expiration} = $self->expmmyy,
455 } elsif ( $payment{payment_type} eq 'ECHECK' ) {
456 $self->payinfo =~ /(\d+)@(\d+)/; # or else what?
457 $payment{account_number} = $1;
458 $payment{routing_code} = $2;
459 $payment{account_type} = $cust_main->paytype;
460 # XXX what if this isn't their regular payment method?
462 die "unsupported BatchPayment method: ".$pay_batch->payby;
466 if ( $cust_main->status =~ /^active|suspended|ordered$/ ) {
467 if ( $self->payinfo_used ) {
468 $recurring = 'S'; # subsequent
470 $recurring = 'F'; # first use
473 $recurring = 'N'; # non-recurring
476 Business::BatchPayment->create(Item =>
479 tid => $self->paybatchnum,
480 amount => $self->amount,
483 customer_id => $self->custnum,
484 first_name => $cust_main->first,
485 last_name => $cust_main->last,
486 company => $cust_main->company,
487 address => $location->address1,
488 ( map { $_ => $location->$_ } qw(address2 city state country zip) ),
490 invoice_number => $self->invnum,
491 recurring_billing => $recurring,
496 =item process_unbatch_and_delete
498 L</unbatch_and_delete> run as a queued job, accepts I<$job> and I<$param>.
502 sub process_unbatch_and_delete {
503 my ($job, $param) = @_;
504 $param = thaw(decode_base64($param));
505 my $self = qsearchs('cust_pay_batch',{ 'paybatchnum' => scalar($param->{'paybatchnum'}) })
506 or die 'Could not find paybatchnum ' . $param->{'paybatchnum'};
507 my $error = $self->unbatch_and_delete;
508 die $error if $error;
512 =item unbatch_and_delete
514 May only be called on a record with an empty status and an associated
515 L<FS::pay_batch> with a status of 'O' (not yet in transit.) Deletes all associated
516 records from L<FS::cust_bill_pay_batch> and then deletes this record.
517 If there is an error, returns the error, otherwise returns false.
521 sub unbatch_and_delete {
524 return 'Cannot unbatch a cust_pay_batch with status ' . $self->status
527 my $pay_batch = qsearchs('pay_batch',{ 'batchnum' => $self->batchnum })
528 or return 'Cannot find associated pay_batch record';
530 return 'Cannot unbatch from a pay_batch with status ' . $pay_batch->status
531 if $pay_batch->status ne 'O';
533 local $SIG{HUP} = 'IGNORE';
534 local $SIG{INT} = 'IGNORE';
535 local $SIG{QUIT} = 'IGNORE';
536 local $SIG{TERM} = 'IGNORE';
537 local $SIG{TSTP} = 'IGNORE';
538 local $SIG{PIPE} = 'IGNORE';
540 my $oldAutoCommit = $FS::UID::AutoCommit;
541 local $FS::UID::AutoCommit = 0;
544 # have not generated actual payments yet, so should be safe to delete
545 foreach my $cust_bill_pay_batch (
546 qsearch('cust_bill_pay_batch',{ 'paybatchnum' => $self->paybatchnum })
548 my $error = $cust_bill_pay_batch->delete;
550 $dbh->rollback if $oldAutoCommit;
555 my $error = $self->delete;
557 $dbh->rollback if $oldAutoCommit;
561 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
570 There should probably be a configuration file with a list of allowed credit
575 L<FS::cust_main>, L<FS::Record>