Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_pay_batch.pm
1 package FS::cust_pay_batch;
2
3 use strict;
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);
8 use FS::payinfo_Mixin;
9 use FS::cust_main;
10 use FS::cust_bill;
11
12 @ISA = qw( FS::payinfo_Mixin FS::Record );
13
14 # 1 is mostly method/subroutine entry and options
15 # 2 traces progress of some operations
16 # 3 is even more information including possibly sensitive data
17 $DEBUG = 0;
18
19 =head1 NAME
20
21 FS::cust_pay_batch - Object methods for batch cards
22
23 =head1 SYNOPSIS
24
25   use FS::cust_pay_batch;
26
27   $record = new FS::cust_pay_batch \%hash;
28   $record = new FS::cust_pay_batch { 'column' => 'value' };
29
30   $error = $record->insert;
31
32   $error = $new_record->replace($old_record);
33
34   $error = $record->delete;
35
36   $error = $record->check;
37
38   #deprecated# $error = $record->retriable;
39
40 =head1 DESCRIPTION
41
42 An FS::cust_pay_batch object represents a credit card transaction ready to be
43 batched (sent to a processor).  FS::cust_pay_batch inherits from FS::Record.  
44 Typically called by the collect method of an FS::cust_main object.  The
45 following fields are currently supported:
46
47 =over 4
48
49 =item paybatchnum - primary key (automatically assigned)
50
51 =item batchnum - indentifies group in batch
52
53 =item payby - CARD/CHEK/LECB/BILL/COMP
54
55 =item payinfo
56
57 =item exp - card expiration 
58
59 =item amount 
60
61 =item invnum - invoice
62
63 =item custnum - customer 
64
65 =item payname - name on card 
66
67 =item first - name 
68
69 =item last - name 
70
71 =item address1 
72
73 =item address2 
74
75 =item city 
76
77 =item state 
78
79 =item zip 
80
81 =item country 
82
83 =item status
84
85 =back
86
87 =head1 METHODS
88
89 =over 4
90
91 =item new HASHREF
92
93 Creates a new record.  To add the record to the database, see L<"insert">.
94
95 Note that this stores the hash reference, not a distinct copy of the hash it
96 points to.  You can ask the object for a copy with the I<hash> method.
97
98 =cut
99
100 sub table { 'cust_pay_batch'; }
101
102 =item insert
103
104 Adds this record to the database.  If there is an error, returns the error,
105 otherwise returns false.
106
107 =item delete
108
109 Delete this record from the database.  If there is an error, returns the error,
110 otherwise returns false.
111
112 =item replace OLD_RECORD
113
114 Replaces the OLD_RECORD with this one in the database.  If there is an error,
115 returns the error, otherwise returns false.
116
117 =item check
118
119 Checks all fields to make sure this is a valid transaction.  If there is
120 an error, returns the error, otherwise returns false.  Called by the insert
121 and replace methods.
122
123 =cut
124
125 sub check {
126   my $self = shift;
127
128   my $error = 
129       $self->ut_numbern('paybatchnum')
130     || $self->ut_numbern('trancode') #deprecated
131     || $self->ut_money('amount')
132     || $self->ut_number('invnum')
133     || $self->ut_number('custnum')
134     || $self->ut_text('address1')
135     || $self->ut_textn('address2')
136     || $self->ut_text('city')
137     || $self->ut_textn('state')
138   ;
139
140   return $error if $error;
141
142   $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
143   $self->setfield('last',$1);
144
145   $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
146   $self->first($1);
147
148   $error = $self->payinfo_check();
149   return $error if $error;
150
151   if ( $self->exp eq '' ) {
152     return "Expiration date required"
153       unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
154     $self->exp('');
155   } else {
156     if ( $self->exp =~ /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/ ) {
157       $self->exp("$1-$2-$3");
158     } elsif ( $self->exp =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
159       if ( length($2) == 4 ) {
160         $self->exp("$2-$1-01");
161       } elsif ( $2 > 98 ) { #should pry change to check for "this year"
162         $self->exp("19$2-$1-01");
163       } else {
164         $self->exp("20$2-$1-01");
165       }
166     } else {
167       return "Illegal expiration date";
168     }
169   }
170
171   if ( $self->payname eq '' ) {
172     $self->payname( $self->first. " ". $self->getfield('last') );
173   } else {
174     $self->payname =~ /^([\w \,\.\-\']+)$/
175       or return "Illegal billing name";
176     $self->payname($1);
177   }
178
179   #we have lots of old zips in there... don't hork up batch results cause of em
180   $self->zip =~ /^\s*(\w[\w\-\s]{2,8}\w)\s*$/
181     or return "Illegal zip: ". $self->zip;
182   $self->zip($1);
183
184   $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
185   $self->country($1);
186
187   #$error = $self->ut_zip('zip', $self->country);
188   #return $error if $error;
189
190   #check invnum, custnum, ?
191
192   $self->SUPER::check;
193 }
194
195 =item cust_main
196
197 Returns the customer (see L<FS::cust_main>) for this batched credit card
198 payment.
199
200 =cut
201
202 sub cust_main {
203   my $self = shift;
204   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
205 }
206
207 =item expmmyy
208
209 Returns the credit card expiration date in MMYY format.  If this is a 
210 CHEK payment, returns an empty string.
211
212 =cut
213
214 sub expmmyy {
215   my $self = shift;
216   if ( $self->payby eq 'CARD' ) {
217     $self->get('exp') =~ /^(\d{4})-(\d{2})-(\d{2})$/;
218     return sprintf('%02u%02u', $2, ($1 % 100));
219   }
220   else {
221     return '';
222   }
223 }
224
225 =item pay_batch
226
227 Returns the payment batch this payment belongs to (L<FS::pay_batch).
228
229 =cut
230
231 sub pay_batch {
232   my $self = shift;
233   FS::pay_batch->by_key($self->batchnum);
234 }
235
236 #you know what, screw this in the new world of events.  we should be able to
237 #get the event defs to retry (remove once.pm condition, add every.pm) without
238 #mucking about with statuses of previous cust_event records.  right?
239 #
240 #=item retriable
241 #
242 #Marks the corresponding event (see L<FS::cust_bill_event>) for this batched
243 #credit card payment as retriable.  Useful if the corresponding financial
244 #institution account was declined for temporary reasons and/or a manual 
245 #retry is desired.
246 #
247 #Implementation details: For the named customer's invoice, changes the
248 #statustext of the 'done' (without statustext) event to 'retriable.'
249 #
250 #=cut
251
252 sub retriable {
253
254   confess "deprecated method cust_pay_batch->retriable called; try removing ".
255           "the once condition and adding an every condition?";
256
257   my $self = shift;
258
259   local $SIG{HUP} = 'IGNORE';        #Hmm
260   local $SIG{INT} = 'IGNORE';
261   local $SIG{QUIT} = 'IGNORE';
262   local $SIG{TERM} = 'IGNORE';
263   local $SIG{TSTP} = 'IGNORE';
264   local $SIG{PIPE} = 'IGNORE';
265
266   my $oldAutoCommit = $FS::UID::AutoCommit;
267   local $FS::UID::AutoCommit = 0;
268   my $dbh = dbh;
269
270   my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
271     or return "event $self->eventnum references nonexistant invoice $self->invnum";
272
273   warn "cust_pay_batch->retriable working with self of " . $self->paybatchnum . " and invnum of " . $self->invnum;
274   my @cust_bill_event =
275     sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
276       grep {
277         $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
278           && $_->status eq 'done'
279           && ! $_->statustext
280         }
281       $cust_bill->cust_bill_event;
282   # complain loudly if scalar(@cust_bill_event) > 1 ?
283   my $error = $cust_bill_event[0]->retriable;
284   if ($error ) {
285     # gah, even with transactions.
286     $dbh->commit if $oldAutoCommit; #well.
287     return "error marking invoice event retriable: $error";
288   }
289   '';
290 }
291
292 =item approve PAYBATCH
293
294 Approve this payment.  This will replace the existing record with the 
295 same paybatchnum, set its status to 'Approved', and generate a payment 
296 record (L<FS::cust_pay>).  This should only be called from the batch 
297 import process.
298
299 =cut
300
301 sub approve {
302   # to break up the Big Wall of Code that is import_results
303   my $new = shift;
304   my $paybatch = shift;
305   my $paybatchnum = $new->paybatchnum;
306   my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
307     or return "paybatchnum $paybatchnum not found";
308   # leave these restrictions in place until TD EFT is converted over
309   # to B::BP
310   return "paybatchnum $paybatchnum already resolved ('".$old->status."')" 
311     if $old->status;
312   $new->status('Approved');
313   my $error = $new->replace($old);
314   if ( $error ) {
315     return "error updating status of paybatchnum $paybatchnum: $error\n";
316   }
317   my $cust_pay = new FS::cust_pay ( {
318       'custnum'   => $new->custnum,
319       'payby'     => $new->payby,
320       'paybatch'  => $paybatch,
321       'payinfo'   => $new->payinfo || $old->payinfo,
322       'paid'      => $new->paid,
323       '_date'     => $new->_date,
324       'usernum'   => $new->usernum,
325     } );
326   $error = $cust_pay->insert;
327   if ( $error ) {
328     return "error inserting payment for paybatchnum $paybatchnum: $error\n";
329   }
330   $cust_pay->cust_main->apply_payments;
331   return;
332 }
333
334 =item decline [ REASON ]
335
336 Decline this payment.  This will replace the existing record with the 
337 same paybatchnum, set its status to 'Declined', and run collection events
338 as appropriate.  This should only be called from the batch import process.
339
340 REASON is a string description of the decline reason, defaulting to 
341 'Returned payment'.
342
343 =cut
344
345 sub decline {
346   my $new = shift;
347   my $reason = shift || 'Returned payment';
348   #my $conf = new FS::Conf;
349
350   my $paybatchnum = $new->paybatchnum;
351   my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
352     or return "paybatchnum $paybatchnum not found";
353   if ( $old->status ) {
354     # Handle the case where payments are rejected after the batch has been 
355     # approved.  FS::pay_batch::import_results won't allow results to be 
356     # imported to a closed batch unless batch-manual_approval is enabled, 
357     # so we don't check it here.
358 #    if ( $conf->exists('batch-manual_approval') and
359     if ( lc($old->status) eq 'approved' ) {
360       # Void the payment
361       my $cust_pay = qsearchs('cust_pay', { 
362           custnum  => $new->custnum,
363           paybatch => $new->batchnum
364         });
365       if ( !$cust_pay ) {
366         # should never happen...
367         return "failed to revoke paybatchnum $paybatchnum, payment not found";
368       }
369       $cust_pay->void($reason);
370     }
371     else {
372       # normal case: refuse to do anything
373       return "paybatchnum $paybatchnum already resolved ('".$old->status."')";
374     }
375   } # !$old->status
376   $new->status('Declined');
377   my $error = $new->replace($old);
378   if ( $error ) {
379     return "error updating status of paybatchnum $paybatchnum: $error\n";
380   }
381   my $due_cust_event = $new->cust_main->due_cust_event(
382     'eventtable'  => 'cust_pay_batch',
383     'objects'     => [ $new ],
384   );
385   if ( !ref($due_cust_event) ) {
386     return $due_cust_event;
387   }
388   # XXX breaks transaction integrity
389   foreach my $cust_event (@$due_cust_event) {
390     next unless $cust_event->test_conditions;
391     if ( my $error = $cust_event->do_event() ) {
392       return $error;
393     }
394   }
395   return;
396 }
397
398 =item request_item [ OPTIONS ]
399
400 Returns a L<Business::BatchPayment::Item> object for this batch payment
401 entry.  This can be submitted to a processor.
402
403 OPTIONS can be a list of key/values to append to the attributes.  The most
404 useful case of this is "process_date" to set a processing date based on the
405 date the batch is being submitted.
406
407 =cut
408
409 sub request_item {
410   local $@;
411   my $self = shift;
412
413   eval "use Business::BatchPayment;";
414   die "couldn't load Business::BatchPayment: $@" if $@;
415
416   my $cust_main = $self->cust_main;
417   my $location = $cust_main->bill_location;
418   my $pay_batch = $self->pay_batch;
419
420   my %payment;
421   $payment{payment_type} = FS::payby->payby2bop( $pay_batch->payby );
422   if ( $payment{payment_type} eq 'CC' ) {
423     $payment{card_number} = $self->payinfo,
424     $payment{expiration}  = $self->expmmyy,
425   } elsif ( $payment{payment_type} eq 'ECHECK' ) {
426     $self->payinfo =~ /(\d+)@(\d+)/; # or else what?
427     $payment{account_number} = $1;
428     $payment{routing_code} = $2;
429     $payment{account_type} = $cust_main->paytype;
430     # XXX what if this isn't their regular payment method?
431   } else {
432     die "unsupported BatchPayment method: ".$pay_batch->payby;
433   }
434
435   Business::BatchPayment->create(Item =>
436     # required
437     action      => 'payment',
438     tid         => $self->paybatchnum,
439     amount      => $self->amount,
440
441     # customer info
442     customer_id => $self->custnum,
443     first_name  => $cust_main->first,
444     last_name   => $cust_main->last,
445     company     => $cust_main->company,
446     address     => $location->address1,
447     ( map { $_ => $location->$_ } qw(address2 city state country zip) ),
448     
449     invoice_number  => $self->invnum,
450     %payment,
451   );
452 }
453
454 =back
455
456 =head1 BUGS
457
458 There should probably be a configuration file with a list of allowed credit
459 card types.
460
461 =head1 SEE ALSO
462
463 L<FS::cust_main>, L<FS::Record>
464
465 =cut
466
467 1;
468