186614f5bad8cc36bb1287b43be8fb635f9c31ef
[freeside.git] / FS / FS / cust_pay.pm
1 package FS::cust_pay;
2
3 use strict;
4 use base qw( FS::otaker_Mixin FS::payinfo_transaction_Mixin FS::cust_main_Mixin
5              FS::Record );
6 use vars qw( $DEBUG $me $conf @encrypted_fields
7              $unsuspendauto $ignore_noapply 
8            );
9 use Date::Format;
10 use Business::CreditCard;
11 use Text::Template;
12 use FS::UID qw( getotaker );
13 use FS::Misc qw( send_email );
14 use FS::Record qw( dbh qsearch qsearchs );
15 use FS::CurrentUser;
16 use FS::payby;
17 use FS::cust_main_Mixin;
18 use FS::payinfo_transaction_Mixin;
19 use FS::cust_bill;
20 use FS::cust_bill_pay;
21 use FS::cust_pay_refund;
22 use FS::cust_main;
23 use FS::cust_pkg;
24 use FS::cust_pay_void;
25
26 $DEBUG = 0;
27
28 $me = '[FS::cust_pay]';
29
30 $ignore_noapply = 0;
31
32 #ask FS::UID to run this stuff for us later
33 FS::UID->install_callback( sub { 
34   $conf = new FS::Conf;
35   $unsuspendauto = $conf->exists('unsuspendauto');
36 } );
37
38 @encrypted_fields = ('payinfo');
39
40 =head1 NAME
41
42 FS::cust_pay - Object methods for cust_pay objects
43
44 =head1 SYNOPSIS
45
46   use FS::cust_pay;
47
48   $record = new FS::cust_pay \%hash;
49   $record = new FS::cust_pay { 'column' => 'value' };
50
51   $error = $record->insert;
52
53   $error = $new_record->replace($old_record);
54
55   $error = $record->delete;
56
57   $error = $record->check;
58
59 =head1 DESCRIPTION
60
61 An FS::cust_pay object represents a payment; the transfer of money from a
62 customer.  FS::cust_pay inherits from FS::Record.  The following fields are
63 currently supported:
64
65 =over 4
66
67 =item paynum
68
69 primary key (assigned automatically for new payments)
70
71 =item custnum
72
73 customer (see L<FS::cust_main>)
74
75 =item _date
76
77 specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
78 L<Time::Local> and L<Date::Parse> for conversion functions.
79
80 =item paid
81
82 Amount of this payment
83
84 =item usernum
85
86 order taker (see L<FS::access_user>)
87
88 =item payby
89
90 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
91
92 =item payinfo
93
94 Payment Information (See L<FS::payinfo_Mixin> for data format)
95
96 =item paymask
97
98 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
99
100 =item paybatch
101
102 text field for tracking card processing or other batch grouping
103
104 =item payunique
105
106 Optional unique identifer to prevent duplicate transactions.
107
108 =item closed
109
110 books closed flag, empty or `Y'
111
112 =item pkgnum
113
114 Desired pkgnum when using experimental package balances.
115
116 =back
117
118 =head1 METHODS
119
120 =over 4 
121
122 =item new HASHREF
123
124 Creates a new payment.  To add the payment to the databse, see L<"insert">.
125
126 =cut
127
128 sub table { 'cust_pay'; }
129 sub cust_linked { $_[0]->cust_main_custnum; } 
130 sub cust_unlinked_msg {
131   my $self = shift;
132   "WARNING: can't find cust_main.custnum ". $self->custnum.
133   ' (cust_pay.paynum '. $self->paynum. ')';
134 }
135
136 =item insert [ OPTION => VALUE ... ]
137
138 Adds this payment to the database.
139
140 For backwards-compatibility and convenience, if the additional field invnum
141 is defined, an FS::cust_bill_pay record for the full amount of the payment
142 will be created.  In this case, custnum is optional.
143
144 A hash of optional arguments may be passed.  Currently "manual" is supported.
145 If true, a payment receipt is sent instead of a statement when
146 'payment_receipt_email' configuration option is set.
147
148 =cut
149
150 sub insert {
151   my($self, %options) = @_;
152
153   local $SIG{HUP} = 'IGNORE';
154   local $SIG{INT} = 'IGNORE';
155   local $SIG{QUIT} = 'IGNORE';
156   local $SIG{TERM} = 'IGNORE';
157   local $SIG{TSTP} = 'IGNORE';
158   local $SIG{PIPE} = 'IGNORE';
159
160   my $oldAutoCommit = $FS::UID::AutoCommit;
161   local $FS::UID::AutoCommit = 0;
162   my $dbh = dbh;
163
164   my $cust_bill;
165   if ( $self->invnum ) {
166     $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
167       or do {
168         $dbh->rollback if $oldAutoCommit;
169         return "Unknown cust_bill.invnum: ". $self->invnum;
170       };
171     $self->custnum($cust_bill->custnum );
172   }
173
174   my $error = $self->check;
175   return $error if $error;
176
177   my $cust_main = $self->cust_main;
178   my $old_balance = $cust_main->balance;
179
180   $error = $self->SUPER::insert;
181   if ( $error ) {
182     $dbh->rollback if $oldAutoCommit;
183     return "error inserting cust_pay: $error";
184   }
185
186   if ( $self->invnum ) {
187     my $cust_bill_pay = new FS::cust_bill_pay {
188       'invnum' => $self->invnum,
189       'paynum' => $self->paynum,
190       'amount' => $self->paid,
191       '_date'  => $self->_date,
192     };
193     $error = $cust_bill_pay->insert(%options);
194     if ( $error ) {
195       if ( $ignore_noapply ) {
196         warn "warning: error inserting cust_bill_pay: $error ".
197              "(ignore_noapply flag set; inserting cust_pay record anyway)\n";
198       } else {
199         $dbh->rollback if $oldAutoCommit;
200         return "error inserting cust_bill_pay: $error";
201       }
202     }
203   }
204
205   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
206
207   #false laziness w/ cust_credit::insert
208   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
209     my @errors = $cust_main->unsuspend;
210     #return 
211     # side-fx with nested transactions?  upstack rolls back?
212     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
213          join(' / ', @errors)
214       if @errors;
215   }
216   #eslaf
217
218   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
219
220   #payment receipt
221   my $trigger = $conf->config('payment_receipt-trigger') || 'cust_pay';
222   if ( $trigger eq 'cust_pay' ) {
223     my $error = $self->send_receipt(
224       'manual'    => $options{'manual'},
225       'cust_bill' => $cust_bill,
226       'cust_main' => $cust_main,
227     );
228     warn "can't send payment receipt/statement: $error" if $error;
229   }
230
231   '';
232
233 }
234
235 =item void [ REASON ]
236
237 Voids this payment: deletes the payment and all associated applications and
238 adds a record of the voided payment to the FS::cust_pay_void table.
239
240 =cut
241
242 sub void {
243   my $self = shift;
244
245   local $SIG{HUP} = 'IGNORE';
246   local $SIG{INT} = 'IGNORE';
247   local $SIG{QUIT} = 'IGNORE';
248   local $SIG{TERM} = 'IGNORE';
249   local $SIG{TSTP} = 'IGNORE';
250   local $SIG{PIPE} = 'IGNORE';
251
252   my $oldAutoCommit = $FS::UID::AutoCommit;
253   local $FS::UID::AutoCommit = 0;
254   my $dbh = dbh;
255
256   my $cust_pay_void = new FS::cust_pay_void ( {
257     map { $_ => $self->get($_) } $self->fields
258   } );
259   $cust_pay_void->reason(shift) if scalar(@_);
260   my $error = $cust_pay_void->insert;
261   if ( $error ) {
262     $dbh->rollback if $oldAutoCommit;
263     return $error;
264   }
265
266   $error = $self->delete;
267   if ( $error ) {
268     $dbh->rollback if $oldAutoCommit;
269     return $error;
270   }
271
272   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
273
274   '';
275
276 }
277
278 =item delete
279
280 Unless the closed flag is set, deletes this payment and all associated
281 applications (see L<FS::cust_bill_pay> and L<FS::cust_pay_refund>).  In most
282 cases, you want to use the void method instead to leave a record of the
283 deleted payment.
284
285 =cut
286
287 # very similar to FS::cust_credit::delete
288 sub delete {
289   my $self = shift;
290   return "Can't delete closed payment" if $self->closed =~ /^Y/i;
291
292   local $SIG{HUP} = 'IGNORE';
293   local $SIG{INT} = 'IGNORE';
294   local $SIG{QUIT} = 'IGNORE';
295   local $SIG{TERM} = 'IGNORE';
296   local $SIG{TSTP} = 'IGNORE';
297   local $SIG{PIPE} = 'IGNORE';
298
299   my $oldAutoCommit = $FS::UID::AutoCommit;
300   local $FS::UID::AutoCommit = 0;
301   my $dbh = dbh;
302
303   foreach my $app ( $self->cust_bill_pay, $self->cust_pay_refund ) {
304     my $error = $app->delete;
305     if ( $error ) {
306       $dbh->rollback if $oldAutoCommit;
307       return $error;
308     }
309   }
310
311   my $error = $self->SUPER::delete(@_);
312   if ( $error ) {
313     $dbh->rollback if $oldAutoCommit;
314     return $error;
315   }
316
317   if (    $conf->exists('deletepayments')
318        && $conf->config('deletepayments') ne '' ) {
319
320     my $cust_main = $self->cust_main;
321
322     my $error = send_email(
323       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
324                                  #invoice_from??? well as good as any
325       'to'      => $conf->config('deletepayments'),
326       'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
327       'body'    => [
328         "This is an automatic message from your Freeside installation\n",
329         "informing you that the following payment has been deleted:\n",
330         "\n",
331         'paynum: '. $self->paynum. "\n",
332         'custnum: '. $self->custnum.
333           " (". $cust_main->last. ", ". $cust_main->first. ")\n",
334         'paid: $'. sprintf("%.2f", $self->paid). "\n",
335         'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
336         'payby: '. $self->payby. "\n",
337         'payinfo: '. $self->paymask. "\n",
338         'paybatch: '. $self->paybatch. "\n",
339       ],
340     );
341
342     if ( $error ) {
343       $dbh->rollback if $oldAutoCommit;
344       return "can't send payment deletion notification: $error";
345     }
346
347   }
348
349   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
350
351   '';
352
353 }
354
355 =item replace OLD_RECORD
356
357 You can, but probably shouldn't modify payments...
358
359 =cut
360
361 sub replace {
362   #return "Can't modify payment!"
363   my $self = shift;
364   return "Can't modify closed payment" if $self->closed =~ /^Y/i;
365   $self->SUPER::replace(@_);
366 }
367
368 =item check
369
370 Checks all fields to make sure this is a valid payment.  If there is an error,
371 returns the error, otherwise returns false.  Called by the insert method.
372
373 =cut
374
375 sub check {
376   my $self = shift;
377
378   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
379
380   my $error =
381     $self->ut_numbern('paynum')
382     || $self->ut_numbern('custnum')
383     || $self->ut_numbern('_date')
384     || $self->ut_money('paid')
385     || $self->ut_alphan('otaker')
386     || $self->ut_textn('paybatch')
387     || $self->ut_textn('payunique')
388     || $self->ut_enum('closed', [ '', 'Y' ])
389     || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
390     || $self->payinfo_check()
391   ;
392   return $error if $error;
393
394   return "paid must be > 0 " if $self->paid <= 0;
395
396   return "unknown cust_main.custnum: ". $self->custnum
397     unless $self->invnum
398            || qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
399
400   $self->_date(time) unless $self->_date;
401
402 #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it
403 #  # UNIQUE index should catch this too, without race conditions, but this
404 #  # should give a better error message the other 99.9% of the time...
405 #  if ( length($self->payunique)
406 #       && qsearchs('cust_pay', { 'payunique' => $self->payunique } ) ) {
407 #    #well, it *could* be a better error message
408 #    return "duplicate transaction".
409 #           " - a payment with unique identifer ". $self->payunique.
410 #           " already exists";
411 #  }
412
413   $self->SUPER::check;
414 }
415
416 =item send_receipt HASHREF | OPTION => VALUE ...
417
418 Sends a payment receipt for this payment..
419
420 Available options:
421
422 =over 4
423
424 =item manual
425
426 Flag indicating the payment is being made manually.
427
428 =item cust_bill
429
430 Invoice (FS::cust_bill) object.  If not specified, the most recent invoice
431 will be assumed.
432
433 =item cust_main
434
435 Customer (FS::cust_main) object (for efficiency).
436
437 =back
438
439 =cut
440
441 sub send_receipt {
442   my $self = shift;
443   my $opt = ref($_[0]) ? shift : { @_ };
444
445   my $cust_bill = $opt->{'cust_bill'};
446   my $cust_main = $opt->{'cust_main'} || $self->cust_main;
447
448   my $conf = new FS::Conf;
449
450   my @invoicing_list = $cust_main->invoicing_list_emailonly;
451   return '' unless @invoicing_list;
452
453   $cust_bill ||= ($cust_main->cust_bill)[-1]; #rather inefficient though?
454
455   if (    ( exists($opt->{'manual'}) && $opt->{'manual'} )
456        || ! $conf->exists('invoice_html_statement') # XXX msg_template
457        || ! $cust_bill
458      ) {
459
460     my $error = '';
461
462     if ( $conf->exists('payment_receipt_msgnum')
463          && $conf->config('payment_receipt_msgnum')
464        )
465     {
466       my $msg_template = 
467           FS::msg_template->by_key($conf->config('payment_receipt_msgnum'));
468       $error = $msg_template->send('cust_main'=> $cust_main, 'object'=> $self);
469
470     } elsif ( $conf->exists('payment_receipt_email') ) {
471       my $receipt_template = new Text::Template (
472         TYPE   => 'ARRAY',
473         SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
474       ) or do {
475         warn "can't create payment receipt template: $Text::Template::ERROR";
476         return '';
477       };
478
479       my $payby = $self->payby;
480       my $payinfo = $self->payinfo;
481       $payby =~ s/^BILL$/Check/ if $payinfo;
482       if ( $payby eq 'CARD' || $payby eq 'CHEK' ) {
483         $payinfo = $self->paymask
484       } else {
485         $payinfo = $self->decrypt($payinfo);
486       }
487       $payby =~ s/^CHEK$/Electronic check/;
488
489       my %fill_in = (
490         'date'         => time2str("%a %B %o, %Y", $self->_date),
491         'name'         => $cust_main->name,
492         'paynum'       => $self->paynum,
493         'paid'         => sprintf("%.2f", $self->paid),
494         'payby'        => ucfirst(lc($payby)),
495         'payinfo'      => $payinfo,
496         'balance'      => $cust_main->balance,
497         'company_name' => $conf->config('company_name', $cust_main->agentnum),
498       );
499
500       if ( $opt->{'cust_pkg'} ) {
501         $fill_in{'pkg'} = $opt->{'cust_pkg'}->part_pkg->pkg;
502         #setup date, other things?
503       }
504
505       $error = send_email(
506         'from'    => $conf->config('invoice_from', $cust_main->agentnum),
507                                    #invoice_from??? well as good as any
508         'to'      => \@invoicing_list,
509         'subject' => 'Payment receipt',
510         'body'    => [ $receipt_template->fill_in( HASH => \%fill_in ) ],
511       );
512
513     } else { # no payment_receipt_msgnum or payment_receipt_email
514
515       my $queue = new FS::queue {
516          'paynum' => $self->paynum,
517          'job'    => 'FS::cust_bill::queueable_email',
518       };
519
520       $queue->insert(
521         'invnum'   => $cust_bill->invnum,
522         'template' => 'statement',
523       );
524     }
525   
526     warn "send_receipt: $error\n" if $error;
527   } #$opt{manual} || no invoice_html_statement || customer has no invoices
528 }
529
530 =item cust_bill_pay
531
532 Returns all applications to invoices (see L<FS::cust_bill_pay>) for this
533 payment.
534
535 =cut
536
537 sub cust_bill_pay {
538   my $self = shift;
539   map { $_ } #return $self->num_cust_bill_pay unless wantarray;
540   sort {    $a->_date  <=> $b->_date
541          || $a->invnum <=> $b->invnum }
542     qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } )
543   ;
544 }
545
546 =item cust_pay_refund
547
548 Returns all applications of refunds (see L<FS::cust_pay_refund>) to this
549 payment.
550
551 =cut
552
553 sub cust_pay_refund {
554   my $self = shift;
555   map { $_ } #return $self->num_cust_pay_refund unless wantarray;
556   sort { $a->_date <=> $b->_date }
557     qsearch( 'cust_pay_refund', { 'paynum' => $self->paynum } )
558   ;
559 }
560
561
562 =item unapplied
563
564 Returns the amount of this payment that is still unapplied; which is
565 paid minus all payment applications (see L<FS::cust_bill_pay>) and refund
566 applications (see L<FS::cust_pay_refund>).
567
568 =cut
569
570 sub unapplied {
571   my $self = shift;
572   my $amount = $self->paid;
573   $amount -= $_->amount foreach ( $self->cust_bill_pay );
574   $amount -= $_->amount foreach ( $self->cust_pay_refund );
575   sprintf("%.2f", $amount );
576 }
577
578 =item unrefunded
579
580 Returns the amount of this payment that has not been refuned; which is
581 paid minus all  refund applications (see L<FS::cust_pay_refund>).
582
583 =cut
584
585 sub unrefunded {
586   my $self = shift;
587   my $amount = $self->paid;
588   $amount -= $_->amount foreach ( $self->cust_pay_refund );
589   sprintf("%.2f", $amount );
590 }
591
592 =item amount
593
594 Returns the "paid" field.
595
596 =cut
597
598 sub amount {
599   my $self = shift;
600   $self->paid();
601 }
602
603 =back
604
605 =head1 CLASS METHODS
606
607 =over 4
608
609 =item batch_insert CUST_PAY_OBJECT, ...
610
611 Class method which inserts multiple payments.  Takes a list of FS::cust_pay
612 objects.  Returns a list, each element representing the status of inserting the
613 corresponding payment - empty.  If there is an error inserting any payment, the
614 entire transaction is rolled back, i.e. all payments are inserted or none are.
615
616 For example:
617
618   my @errors = FS::cust_pay->batch_insert(@cust_pay);
619   my $num_errors = scalar(grep $_, @errors);
620   if ( $num_errors == 0 ) {
621     #success; all payments were inserted
622   } else {
623     #failure; no payments were inserted.
624   }
625
626 =cut
627
628 sub batch_insert {
629   my $self = shift; #class method
630
631   local $SIG{HUP} = 'IGNORE';
632   local $SIG{INT} = 'IGNORE';
633   local $SIG{QUIT} = 'IGNORE';
634   local $SIG{TERM} = 'IGNORE';
635   local $SIG{TSTP} = 'IGNORE';
636   local $SIG{PIPE} = 'IGNORE';
637
638   my $oldAutoCommit = $FS::UID::AutoCommit;
639   local $FS::UID::AutoCommit = 0;
640   my $dbh = dbh;
641
642   my $errors = 0;
643   
644   my @errors = map {
645     my $error = $_->insert( 'manual' => 1 );
646     if ( $error ) { 
647       $errors++;
648     } else {
649       $_->cust_main->apply_payments;
650     }
651     $error;
652   } @_;
653
654   if ( $errors ) {
655     $dbh->rollback if $oldAutoCommit;
656   } else {
657     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
658   }
659
660   @errors;
661
662 }
663
664 =item unapplied_sql
665
666 Returns an SQL fragment to retreive the unapplied amount.
667
668 =cut 
669
670 sub unapplied_sql {
671   my ($class, $start, $end) = @_;
672   my $bill_start   = $start ? "AND cust_bill_pay._date <= $start"   : '';
673   my $bill_end     = $end   ? "AND cust_bill_pay._date > $end"     : '';
674   my $refund_start = $start ? "AND cust_pay_refund._date <= $start" : '';
675   my $refund_end   = $end   ? "AND cust_pay_refund._date > $end"   : '';
676
677   "paid
678         - COALESCE( 
679                     ( SELECT SUM(amount) FROM cust_bill_pay
680                         WHERE cust_pay.paynum = cust_bill_pay.paynum
681                         $bill_start $bill_end )
682                     ,0
683                   )
684         - COALESCE(
685                     ( SELECT SUM(amount) FROM cust_pay_refund
686                         WHERE cust_pay.paynum = cust_pay_refund.paynum
687                         $refund_start $refund_end )
688                     ,0
689                   )
690   ";
691
692 }
693
694 # _upgrade_data
695 #
696 # Used by FS::Upgrade to migrate to a new database.
697
698 use FS::h_cust_pay;
699
700 sub _upgrade_data {  #class method
701   my ($class, %opts) = @_;
702
703   warn "$me upgrading $class\n" if $DEBUG;
704
705   ##
706   # otaker/ivan upgrade
707   ##
708
709   #not the most efficient, but hey, it only has to run once
710
711   my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
712               "  AND usernum IS NULL ".
713               "  AND 0 < ( SELECT COUNT(*) FROM cust_main                 ".
714               "              WHERE cust_main.custnum = cust_pay.custnum ) ";
715
716   my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
717
718   my $sth = dbh->prepare($count_sql) or die dbh->errstr;
719   $sth->execute or die $sth->errstr;
720   my $total = $sth->fetchrow_arrayref->[0];
721   #warn "$total cust_pay records to update\n"
722   #  if $DEBUG;
723   local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
724
725   my $count = 0;
726   my $lastprog = 0;
727
728   my @cust_pay = qsearch( {
729       'table'     => 'cust_pay',
730       'hashref'   => {},
731       'extra_sql' => $where,
732       'order_by'  => 'ORDER BY paynum',
733   } );
734
735   foreach my $cust_pay (@cust_pay) {
736
737     my $h_cust_pay = $cust_pay->h_search('insert');
738     if ( $h_cust_pay ) {
739       next if $cust_pay->otaker eq $h_cust_pay->history_user;
740       $cust_pay->otaker($h_cust_pay->history_user);
741     } else {
742       $cust_pay->otaker('legacy');
743     }
744
745     delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
746     my $error = $cust_pay->replace;
747
748     if ( $error ) {
749       warn " *** WARNING: Error updating order taker for payment paynum ".
750            $cust_pay->paynun. ": $error\n";
751       next;
752     }
753
754     $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
755
756     $count++;
757     if ( $DEBUG > 1 && $lastprog + 30 < time ) {
758       warn "$me $count/$total (". sprintf('%.2f',100*$count/$total). '%)'. "\n";
759       $lastprog = time;
760     }
761
762   }
763
764   ###
765   # payinfo N/A upgrade
766   ###
767
768   #XXX remove the 'N/A (tokenized)' part (or just this entire thing)
769
770   my @na_cust_pay = qsearch( {
771     'table'     => 'cust_pay',
772     'hashref'   => {}, #could be encrypted# { 'payinfo' => 'N/A' },
773     'extra_sql' => "WHERE ( payinfo = 'N/A' OR paymask = 'N/AA' OR paymask = 'N/A (tokenized)' ) AND payby IN ( 'CARD', 'CHEK' )",
774   } );
775
776   foreach my $na ( @na_cust_pay ) {
777
778     next unless $na->payinfo eq 'N/A';
779
780     my $cust_pay_pending =
781       qsearchs('cust_pay_pending', { 'paynum' => $na->paynum } );
782     unless ( $cust_pay_pending ) {
783       warn " *** WARNING: not-yet recoverable N/A card for payment ".
784            $na->paynum. " (no cust_pay_pending)\n";
785       next;
786     }
787     $na->$_($cust_pay_pending->$_) for qw( payinfo paymask );
788     my $error = $na->replace;
789     if ( $error ) {
790       warn " *** WARNING: Error updating payinfo for payment paynum ".
791            $na->paynun. ": $error\n";
792       next;
793     }
794
795   }
796
797   ###
798   # otaker->usernum upgrade
799   ###
800
801   delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
802   $class->_upgrade_otaker(%opts);
803   $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
804
805 }
806
807 =back
808
809 =head1 SUBROUTINES
810
811 =over 4 
812
813 =item batch_import HASHREF
814
815 Inserts new payments.
816
817 =cut
818
819 sub batch_import {
820   my $param = shift;
821
822   my $fh = $param->{filehandle};
823   my $agentnum = $param->{agentnum};
824   my $format = $param->{'format'};
825   my $paybatch = $param->{'paybatch'};
826
827   # here is the agent virtualization
828   my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
829
830   my @fields;
831   my $payby;
832   if ( $format eq 'simple' ) {
833     @fields = qw( custnum agent_custid paid payinfo );
834     $payby = 'BILL';
835   } elsif ( $format eq 'extended' ) {
836     die "unimplemented\n";
837     @fields = qw( );
838     $payby = 'BILL';
839   } else {
840     die "unknown format $format";
841   }
842
843   eval "use Text::CSV_XS;";
844   die $@ if $@;
845
846   my $csv = new Text::CSV_XS;
847
848   my $imported = 0;
849
850   local $SIG{HUP} = 'IGNORE';
851   local $SIG{INT} = 'IGNORE';
852   local $SIG{QUIT} = 'IGNORE';
853   local $SIG{TERM} = 'IGNORE';
854   local $SIG{TSTP} = 'IGNORE';
855   local $SIG{PIPE} = 'IGNORE';
856
857   my $oldAutoCommit = $FS::UID::AutoCommit;
858   local $FS::UID::AutoCommit = 0;
859   my $dbh = dbh;
860   
861   my $line;
862   while ( defined($line=<$fh>) ) {
863
864     $csv->parse($line) or do {
865       $dbh->rollback if $oldAutoCommit;
866       return "can't parse: ". $csv->error_input();
867     };
868
869     my @columns = $csv->fields();
870
871     my %cust_pay = (
872       payby    => $payby,
873       paybatch => $paybatch,
874     );
875
876     my $cust_main;
877     foreach my $field ( @fields ) {
878
879       if ( $field eq 'agent_custid'
880         && $agentnum
881         && $columns[0] =~ /\S+/ )
882       {
883
884         my $agent_custid = $columns[0];
885         my %hash = ( 'agent_custid' => $agent_custid,
886                      'agentnum'     => $agentnum,
887                    );
888
889         if ( $cust_pay{'custnum'} !~ /^\s*$/ ) {
890           $dbh->rollback if $oldAutoCommit;
891           return "can't specify custnum with agent_custid $agent_custid";
892         }
893
894         $cust_main = qsearchs({
895                                 'table'     => 'cust_main',
896                                 'hashref'   => \%hash,
897                                 'extra_sql' => $extra_sql,
898                              });
899
900         unless ( $cust_main ) {
901           $dbh->rollback if $oldAutoCommit;
902           return "can't find customer with agent_custid $agent_custid";
903         }
904
905         $field = 'custnum';
906         $columns[0] = $cust_main->custnum;
907       }
908
909       $cust_pay{$field} = shift @columns; 
910     }
911
912     my $cust_pay = new FS::cust_pay( \%cust_pay );
913     my $error = $cust_pay->insert;
914
915     if ( $error ) {
916       $dbh->rollback if $oldAutoCommit;
917       return "can't insert payment for $line: $error";
918     }
919
920     if ( $format eq 'simple' ) {
921       # include agentnum for less surprise?
922       $cust_main = qsearchs({
923                              'table'     => 'cust_main',
924                              'hashref'   => { 'custnum' => $cust_pay->custnum },
925                              'extra_sql' => $extra_sql,
926                            })
927         unless $cust_main;
928
929       unless ( $cust_main ) {
930         $dbh->rollback if $oldAutoCommit;
931         return "can't find customer to which payments apply at line: $line";
932       }
933
934       $error = $cust_main->apply_payments_and_credits;
935       if ( $error ) {
936         $dbh->rollback if $oldAutoCommit;
937         return "can't apply payments to customer for $line: $error";
938       }
939
940     }
941
942     $imported++;
943   }
944
945   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
946
947   return "Empty file!" unless $imported;
948
949   ''; #no error
950
951 }
952
953 =back
954
955 =head1 BUGS
956
957 Delete and replace methods.  
958
959 =head1 SEE ALSO
960
961 L<FS::cust_pay_pending>, L<FS::cust_bill_pay>, L<FS::cust_bill>, L<FS::Record>,
962 schema.html from the base documentation.
963
964 =cut
965
966 1;
967