add CASH and WEST payment types (payments only, not cust_main.payby)
[freeside.git] / FS / FS / cust_pay.pm
1 package FS::cust_pay;
2
3 use strict;
4 use vars qw( @ISA $conf $unsuspendauto $ignore_noapply );
5 use Date::Format;
6 use Business::CreditCard;
7 use Text::Template;
8 use FS::Misc qw(send_email);
9 use FS::Record qw( dbh qsearch qsearchs );
10 use FS::cust_main_Mixin;
11 use FS::cust_bill;
12 use FS::cust_bill_pay;
13 use FS::cust_pay_refund;
14 use FS::cust_main;
15 use FS::cust_pay_void;
16
17 @ISA = qw( FS::cust_main_Mixin FS::Record );
18
19 $ignore_noapply = 0;
20
21 #ask FS::UID to run this stuff for us later
22 FS::UID->install_callback( sub { 
23   $conf = new FS::Conf;
24   $unsuspendauto = $conf->exists('unsuspendauto');
25 } );
26
27 =head1 NAME
28
29 FS::cust_pay - Object methods for cust_pay objects
30
31 =head1 SYNOPSIS
32
33   use FS::cust_pay;
34
35   $record = new FS::cust_pay \%hash;
36   $record = new FS::cust_pay { 'column' => 'value' };
37
38   $error = $record->insert;
39
40   $error = $new_record->replace($old_record);
41
42   $error = $record->delete;
43
44   $error = $record->check;
45
46 =head1 DESCRIPTION
47
48 An FS::cust_pay object represents a payment; the transfer of money from a
49 customer.  FS::cust_pay inherits from FS::Record.  The following fields are
50 currently supported:
51
52 =over 4
53
54 =item paynum - primary key (assigned automatically for new payments)
55
56 =item custnum - customer (see L<FS::cust_main>)
57
58 =item paid - Amount of this payment
59
60 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
61 L<Time::Local> and L<Date::Parse> for conversion functions.
62
63 =item payby - `CARD' (credit cards), `CHEK' (electronic check/ACH),
64 `LECB' (phone bill billing), `BILL' (billing), `PREP` (prepaid card),
65 `CASH' (cash), `WEST' (Western Union) or `COMP' (free)
66
67 =item payinfo - card number, check #, or comp issuer (4-8 lowercase alphanumerics; think username), respectively
68
69 =item paybatch - text field for tracking card processing
70
71 =item closed - books closed flag, empty or `Y'
72
73 =back
74
75 =head1 METHODS
76
77 =over 4 
78
79 =item new HASHREF
80
81 Creates a new payment.  To add the payment to the databse, see L<"insert">.
82
83 =cut
84
85 sub table { 'cust_pay'; }
86 sub cust_linked { $_[0]->cust_main_custnum; } 
87 sub cust_unlinked_msg {
88   my $self = shift;
89   "WARNING: can't find cust_main.custnum ". $self->custnum.
90   ' (cust_pay.paynum '. $self->paynum. ')';
91 }
92
93 =item insert
94
95 Adds this payment to the database.
96
97 For backwards-compatibility and convenience, if the additional field invnum
98 is defined, an FS::cust_bill_pay record for the full amount of the payment
99 will be created.  In this case, custnum is optional.
100
101 =cut
102
103 sub insert {
104   my $self = shift;
105
106   local $SIG{HUP} = 'IGNORE';
107   local $SIG{INT} = 'IGNORE';
108   local $SIG{QUIT} = 'IGNORE';
109   local $SIG{TERM} = 'IGNORE';
110   local $SIG{TSTP} = 'IGNORE';
111   local $SIG{PIPE} = 'IGNORE';
112
113   my $oldAutoCommit = $FS::UID::AutoCommit;
114   local $FS::UID::AutoCommit = 0;
115   my $dbh = dbh;
116
117   if ( $self->invnum ) {
118     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
119       or do {
120         $dbh->rollback if $oldAutoCommit;
121         return "Unknown cust_bill.invnum: ". $self->invnum;
122       };
123     $self->custnum($cust_bill->custnum );
124   }
125
126
127   my $error = $self->check;
128   return $error if $error;
129
130   my $cust_main = $self->cust_main;
131   my $old_balance = $cust_main->balance;
132
133   $error = $self->SUPER::insert;
134   if ( $error ) {
135     $dbh->rollback if $oldAutoCommit;
136     return "error inserting $self: $error";
137   }
138
139   if ( $self->invnum ) {
140     my $cust_bill_pay = new FS::cust_bill_pay {
141       'invnum' => $self->invnum,
142       'paynum' => $self->paynum,
143       'amount' => $self->paid,
144       '_date'  => $self->_date,
145     };
146     $error = $cust_bill_pay->insert;
147     if ( $error ) {
148       if ( $ignore_noapply ) {
149         warn "warning: error inserting $cust_bill_pay: $error ".
150              "(ignore_noapply flag set; inserting cust_pay record anyway)\n";
151       } else {
152         $dbh->rollback if $oldAutoCommit;
153         return "error inserting $cust_bill_pay: $error";
154       }
155     }
156   }
157
158   if ( $self->paybatch =~ /^webui-/ ) {
159     my @cust_pay = qsearch('cust_pay', {
160       'custnum' => $self->custnum,
161       'paybatch' => $self->paybatch,
162     } );
163     if ( scalar(@cust_pay) > 1 ) {
164       $dbh->rollback if $oldAutoCommit;
165       return "a payment with webui token ". $self->paybatch. " already exists";
166     }
167   }
168
169   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
170
171   #false laziness w/ cust_credit::insert
172   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
173     my @errors = $cust_main->unsuspend;
174     #return 
175     # side-fx with nested transactions?  upstack rolls back?
176     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
177          join(' / ', @errors)
178       if @errors;
179   }
180   #eslaf
181
182   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
183
184   #my $cust_main = $self->cust_main;
185   if ( $conf->exists('payment_receipt_email')
186        && grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list
187   ) {
188
189     my $receipt_template = new Text::Template (
190       TYPE   => 'ARRAY',
191       SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
192     ) or do {
193       warn "can't create payment receipt template: $Text::Template::ERROR";
194       return '';
195     };
196
197     my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list;
198
199     my $payby = $self->payby;
200     my $payinfo = $self->payinfo;
201     $payby =~ s/^BILL$/Check/ if $payinfo;
202     $payinfo = $self->payinfo_masked if $payby eq 'CARD' || $payby eq 'CHEK';
203     $payby =~ s/^CHEK$/Electronic check/;
204
205     my $error = send_email(
206       'from'    => $conf->config('invoice_from'), #??? well as good as any
207       'to'      => \@invoicing_list,
208       'subject' => 'Payment receipt',
209       'body'    => [ $receipt_template->fill_in( HASH => {
210                        'date'    => time2str("%a %B %o, %Y", $self->_date),
211                        'name'    => $cust_main->name,
212                        'paynum'  => $self->paynum,
213                        'paid'    => sprintf("%.2f", $self->paid),
214                        'payby'   => ucfirst(lc($payby)),
215                        'payinfo' => $payinfo,
216                        'balance' => $cust_main->balance,
217                    } ) ],
218     );
219     if ( $error ) {
220       warn "can't send payment receipt: $error";
221     }
222
223   }
224
225   '';
226
227 }
228
229 =item void [ REASON ]
230
231 Voids this payment: deletes the payment and all associated applications and
232 adds a record of the voided payment to the FS::cust_pay_void table.
233
234 =cut
235
236 sub void {
237   my $self = shift;
238
239   local $SIG{HUP} = 'IGNORE';
240   local $SIG{INT} = 'IGNORE';
241   local $SIG{QUIT} = 'IGNORE';
242   local $SIG{TERM} = 'IGNORE';
243   local $SIG{TSTP} = 'IGNORE';
244   local $SIG{PIPE} = 'IGNORE';
245
246   my $oldAutoCommit = $FS::UID::AutoCommit;
247   local $FS::UID::AutoCommit = 0;
248   my $dbh = dbh;
249
250   my $cust_pay_void = new FS::cust_pay_void ( {
251     map { $_ => $self->get($_) } $self->fields
252   } );
253   $cust_pay_void->reason(shift) if scalar(@_);
254   my $error = $cust_pay_void->insert;
255   if ( $error ) {
256     $dbh->rollback if $oldAutoCommit;
257     return $error;
258   }
259
260   $error = $self->delete;
261   if ( $error ) {
262     $dbh->rollback if $oldAutoCommit;
263     return $error;
264   }
265
266   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
267
268   '';
269
270 }
271
272 =item delete
273
274 Deletes this payment and all associated applications (see L<FS::cust_bill_pay>),
275 unless the closed flag is set.  In most cases, you want to use the void
276 method instead to leave a record of the deleted payment.
277
278 =cut
279
280 sub delete {
281   my $self = shift;
282   return "Can't delete closed payment" if $self->closed =~ /^Y/i;
283
284   local $SIG{HUP} = 'IGNORE';
285   local $SIG{INT} = 'IGNORE';
286   local $SIG{QUIT} = 'IGNORE';
287   local $SIG{TERM} = 'IGNORE';
288   local $SIG{TSTP} = 'IGNORE';
289   local $SIG{PIPE} = 'IGNORE';
290
291   my $oldAutoCommit = $FS::UID::AutoCommit;
292   local $FS::UID::AutoCommit = 0;
293   my $dbh = dbh;
294
295   foreach my $app ( $self->cust_bill_pay, $self->cust_pay_refund ) {
296     my $error = $app->delete;
297     if ( $error ) {
298       $dbh->rollback if $oldAutoCommit;
299       return $error;
300     }
301   }
302
303   my $error = $self->SUPER::delete(@_);
304   if ( $error ) {
305     $dbh->rollback if $oldAutoCommit;
306     return $error;
307   }
308
309   if ( $conf->config('deletepayments') ne '' ) {
310
311     my $cust_main = $self->cust_main;
312
313     my $error = send_email(
314       'from'    => $conf->config('invoice_from'), #??? well as good as any
315       'to'      => $conf->config('deletepayments'),
316       'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
317       'body'    => [
318         "This is an automatic message from your Freeside installation\n",
319         "informing you that the following payment has been deleted:\n",
320         "\n",
321         'paynum: '. $self->paynum. "\n",
322         'custnum: '. $self->custnum.
323           " (". $cust_main->last. ", ". $cust_main->first. ")\n",
324         'paid: $'. sprintf("%.2f", $self->paid). "\n",
325         'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
326         'payby: '. $self->payby. "\n",
327         'payinfo: '. $self->payinfo. "\n",
328         'paybatch: '. $self->paybatch. "\n",
329       ],
330     );
331
332     if ( $error ) {
333       $dbh->rollback if $oldAutoCommit;
334       return "can't send payment deletion notification: $error";
335     }
336
337   }
338
339   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
340
341   '';
342
343 }
344
345 =item replace OLD_RECORD
346
347 You probably shouldn't modify payments...
348
349 =item check
350
351 Checks all fields to make sure this is a valid payment.  If there is an error,
352 returns the error, otherwise returns false.  Called by the insert method.
353
354 =cut
355
356 sub check {
357   my $self = shift;
358
359   my $error =
360     $self->ut_numbern('paynum')
361     || $self->ut_numbern('custnum')
362     || $self->ut_money('paid')
363     || $self->ut_numbern('_date')
364     || $self->ut_textn('paybatch')
365     || $self->ut_enum('closed', [ '', 'Y' ])
366   ;
367   return $error if $error;
368
369   return "paid must be > 0 " if $self->paid <= 0;
370
371   return "unknown cust_main.custnum: ". $self->custnum
372     unless $self->invnum
373            || qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
374
375   $self->_date(time) unless $self->_date;
376
377   $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREP|CASH|WEST)$/
378     or return "Illegal payby";
379   $self->payby($1);
380
381   #false laziness with cust_refund::check
382   if ( $self->payby eq 'CARD' ) {
383     my $payinfo = $self->payinfo;
384     $payinfo =~ s/\D//g;
385     $self->payinfo($payinfo);
386     if ( $self->payinfo ) {
387       $self->payinfo =~ /^(\d{13,16})$/
388         or return "Illegal (mistyped?) credit card number (payinfo)";
389       $self->payinfo($1);
390       validate($self->payinfo) or return "Illegal credit card number";
391       return "Unknown card type" if cardtype($self->payinfo) eq "Unknown";
392     } else {
393       $self->payinfo('N/A');
394     }
395
396   } else {
397     $error = $self->ut_textn('payinfo');
398     return $error if $error;
399   }
400
401   $self->SUPER::check;
402 }
403
404 =item batch_insert CUST_PAY_OBJECT, ...
405
406 Class method which inserts multiple payments.  Takes a list of FS::cust_pay
407 objects.  Returns a list, each element representing the status of inserting the
408 corresponding payment - empty.  If there is an error inserting any payment, the
409 entire transaction is rolled back, i.e. all payments are inserted or none are.
410
411 For example:
412
413   my @errors = FS::cust_pay->batch_insert(@cust_pay);
414   my $num_errors = scalar(grep $_, @errors);
415   if ( $num_errors == 0 ) {
416     #success; all payments were inserted
417   } else {
418     #failure; no payments were inserted.
419   }
420
421 =cut
422
423 sub batch_insert {
424   my $self = shift; #class method
425
426   local $SIG{HUP} = 'IGNORE';
427   local $SIG{INT} = 'IGNORE';
428   local $SIG{QUIT} = 'IGNORE';
429   local $SIG{TERM} = 'IGNORE';
430   local $SIG{TSTP} = 'IGNORE';
431   local $SIG{PIPE} = 'IGNORE';
432
433   my $oldAutoCommit = $FS::UID::AutoCommit;
434   local $FS::UID::AutoCommit = 0;
435   my $dbh = dbh;
436
437   my $errors = 0;
438   
439   my @errors = map {
440     my $error = $_->insert;
441     if ( $error ) { 
442       $errors++;
443     } else {
444       $_->cust_main->apply_payments;
445     }
446     $error;
447   } @_;
448
449   if ( $errors ) {
450     $dbh->rollback if $oldAutoCommit;
451   } else {
452     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
453   }
454
455   @errors;
456
457 }
458
459 =item cust_bill_pay
460
461 Returns all applications to invoices (see L<FS::cust_bill_pay>) for this
462 payment.
463
464 =cut
465
466 sub cust_bill_pay {
467   my $self = shift;
468   sort {    $a->_date  <=> $b->_date
469          || $a->invnum <=> $b->invnum }
470     qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } )
471   ;
472 }
473
474 =item cust_pay_refund
475
476 Returns all applications of refunds (see L<FS::cust_pay_refund>) to this
477 payment.
478
479 =cut
480
481 sub cust_pay_refund {
482   my $self = shift;
483   sort { $a->_date <=> $b->_date }
484     qsearch( 'cust_pay_refund', { 'paynum' => $self->paynum } )
485   ;
486 }
487
488
489 =item unapplied
490
491 Returns the amount of this payment that is still unapplied; which is
492 paid minus all payment applications (see L<FS::cust_bill_pay>) and refund
493 applications (see L<FS::cust_pay_refund>).
494
495 =cut
496
497 sub unapplied {
498   my $self = shift;
499   my $amount = $self->paid;
500   $amount -= $_->amount foreach ( $self->cust_bill_pay );
501   $amount -= $_->amount foreach ( $self->cust_pay_refund );
502   sprintf("%.2f", $amount );
503 }
504
505 =item unrefunded
506
507 Returns the amount of this payment that has not been refuned; which is
508 paid minus all  refund applications (see L<FS::cust_pay_refund>).
509
510 =cut
511
512 sub unrefunded {
513   my $self = shift;
514   my $amount = $self->paid;
515   $amount -= $_->amount foreach ( $self->cust_pay_refund );
516   sprintf("%.2f", $amount );
517 }
518
519
520 =item cust_main
521
522 Returns the parent customer object (see L<FS::cust_main>).
523
524 =cut
525
526 sub cust_main {
527   my $self = shift;
528   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
529 }
530
531 =item payinfo_masked
532
533 Returns a "masked" payinfo field with all but the last four characters replaced
534 by 'x'es.  Useful for displaying credit cards.
535
536 =cut
537
538 sub payinfo_masked {
539   my $self = shift;
540   #some false laziness w/cust_main::paymask
541   if ( $self->payby eq 'CARD' ) {
542     my $payinfo = $self->payinfo;
543     'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
544   } elsif ( $self->payby eq 'CHEK' ) {
545     my( $account, $aba ) = split('@', $self->payinfo );
546     'x'x(length($account)-2). substr($account,(length($account)-2)). "@". $aba;
547   } else {
548     $self->payinfo;
549   }
550 }
551
552 =back
553
554 =head1 BUGS
555
556 Delete and replace methods.  payinfo_masked false laziness with cust_main.pm
557 and cust_refund.pm
558
559 =head1 SEE ALSO
560
561 L<FS::cust_bill_pay>, L<FS::cust_bill>, L<FS::Record>, schema.html from the
562 base documentation.
563
564 =cut
565
566 1;
567