4 use base qw( FS::otaker_Mixin FS::payinfo_transaction_Mixin FS::cust_main_Mixin
6 use vars qw( $DEBUG $me $conf @encrypted_fields
7 $unsuspendauto $ignore_noapply
10 use Business::CreditCard;
12 use FS::UID qw( getotaker );
13 use FS::Record qw( dbh qsearch qsearchs );
16 use FS::cust_main_Mixin;
17 use FS::payinfo_transaction_Mixin;
19 use FS::cust_bill_pay;
20 use FS::cust_pay_refund;
23 use FS::cust_pay_void;
24 use FS::upgrade_journal;
28 $me = '[FS::cust_pay]';
32 #ask FS::UID to run this stuff for us later
33 FS::UID->install_callback( sub {
35 $unsuspendauto = $conf->exists('unsuspendauto');
38 @encrypted_fields = ('payinfo');
42 FS::cust_pay - Object methods for cust_pay objects
48 $record = new FS::cust_pay \%hash;
49 $record = new FS::cust_pay { 'column' => 'value' };
51 $error = $record->insert;
53 $error = $new_record->replace($old_record);
55 $error = $record->delete;
57 $error = $record->check;
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
69 primary key (assigned automatically for new payments)
73 customer (see L<FS::cust_main>)
77 specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
78 L<Time::Local> and L<Date::Parse> for conversion functions.
82 Amount of this payment
86 order taker (see L<FS::access_user>)
90 Payment Type (See L<FS::payinfo_Mixin> for valid values)
94 Payment Information (See L<FS::payinfo_Mixin> for data format)
98 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
102 obsolete text field for tracking card processing or other batch grouping
106 Optional unique identifer to prevent duplicate transactions.
110 books closed flag, empty or `Y'
114 Desired pkgnum when using experimental package balances.
118 The bank where the payment was deposited.
122 The name of the depositor.
126 The deposit account number.
134 The number of the batch this payment came from (see L<FS::pay_batch>),
135 or null if it was processed through a realtime gateway or entered manually.
139 The number of the realtime or batch gateway L<FS::payment_gateway>) this
140 payment was processed through. Null if it was entered manually or processed
141 by the "system default" gateway, which doesn't have a number.
145 The name of the processor module (Business::OnlinePayment, ::BatchPayment,
146 or ::OnlineThirdPartyPayment subclass) used for this payment. Slightly
147 redundant with C<gatewaynum>.
151 The authorization number returned by the credit card network.
155 The transaction ID returned by the gateway, if any. This is usually what
156 you would use to initiate a void or refund of the payment.
166 Creates a new payment. To add the payment to the databse, see L<"insert">.
170 sub table { 'cust_pay'; }
171 sub cust_linked { $_[0]->cust_main_custnum; }
172 sub cust_unlinked_msg {
174 "WARNING: can't find cust_main.custnum ". $self->custnum.
175 ' (cust_pay.paynum '. $self->paynum. ')';
178 =item insert [ OPTION => VALUE ... ]
180 Adds this payment to the database.
182 For backwards-compatibility and convenience, if the additional field invnum
183 is defined, an FS::cust_bill_pay record for the full amount of the payment
184 will be created. In this case, custnum is optional.
186 If the additional field discount_term is defined then a prepayment discount
187 is taken for that length of time. It is an error for the customer to owe
188 after this payment is made.
190 A hash of optional arguments may be passed. Currently "manual" is supported.
191 If true, a payment receipt is sent instead of a statement when
192 'payment_receipt_email' configuration option is set.
197 my($self, %options) = @_;
199 local $SIG{HUP} = 'IGNORE';
200 local $SIG{INT} = 'IGNORE';
201 local $SIG{QUIT} = 'IGNORE';
202 local $SIG{TERM} = 'IGNORE';
203 local $SIG{TSTP} = 'IGNORE';
204 local $SIG{PIPE} = 'IGNORE';
206 my $oldAutoCommit = $FS::UID::AutoCommit;
207 local $FS::UID::AutoCommit = 0;
211 if ( $self->invnum ) {
212 $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
214 $dbh->rollback if $oldAutoCommit;
215 return "Unknown cust_bill.invnum: ". $self->invnum;
217 $self->custnum($cust_bill->custnum );
220 my $error = $self->check;
221 return $error if $error;
223 my $cust_main = $self->cust_main;
224 my $old_balance = $cust_main->balance;
226 $error = $self->SUPER::insert;
228 $dbh->rollback if $oldAutoCommit;
229 return "error inserting cust_pay: $error";
232 if ( my $credit_type = $conf->config('prepayment_discounts-credit_type') ) {
233 if ( my $months = $self->discount_term ) {
234 # XXX this should be moved out somewhere, but discount_term_values
236 my ($cust_bill) = ($cust_main->cust_bill)[-1]; # most recent invoice
237 return "can't accept prepayment for an unbilled customer" if !$cust_bill;
239 # %billing_pkgs contains this customer's active monthly packages.
240 # Recurring fees for those packages will be credited and then rebilled
241 # for the full discount term. Other packages on the last invoice
242 # (canceled, non-monthly recurring, or one-time charges) will be
244 my %billing_pkgs = map { $_->pkgnum => $_ }
245 grep { $_->part_pkg->freq eq '1' }
246 $cust_main->billing_pkgs;
247 my $credit = 0; # sum of recurring charges from that invoice
248 my $last_bill_date = 0; # the real bill date
249 foreach my $item ( $cust_bill->cust_bill_pkg ) {
250 next if !exists($billing_pkgs{$item->pkgnum}); # skip inactive packages
251 $credit += $item->recur;
252 $last_bill_date = $item->cust_pkg->last_bill
253 if defined($item->cust_pkg)
254 and $item->cust_pkg->last_bill > $last_bill_date
257 my $cust_credit = new FS::cust_credit {
258 'custnum' => $self->custnum,
259 'amount' => sprintf('%.2f', $credit),
260 'reason' => 'customer chose to prepay for discount',
262 $error = $cust_credit->insert('reason_type' => $credit_type);
264 $dbh->rollback if $oldAutoCommit;
265 return "error inserting prepayment credit: $error";
269 # bill for the entire term
270 $_->bill($_->last_bill) foreach (values %billing_pkgs);
271 $error = $cust_main->bill(
272 # no recurring_only, we want unbilled packages with start dates to
274 'no_usage_reset' => 1,
275 'time' => $last_bill_date, # not $cust_bill->_date
276 'pkg_list' => [ values %billing_pkgs ],
277 'freq_override' => $months,
280 $dbh->rollback if $oldAutoCommit;
281 return "error inserting cust_pay: $error";
283 $error = $cust_main->apply_payments_and_credits;
285 $dbh->rollback if $oldAutoCommit;
286 return "error inserting cust_pay: $error";
288 my $new_balance = $cust_main->balance;
289 if ($new_balance > 0) {
290 $dbh->rollback if $oldAutoCommit;
291 return "balance after prepay discount attempt: $new_balance";
293 # user friendly: override the "apply only to this invoice" mode
300 if ( $self->invnum ) {
301 my $cust_bill_pay = new FS::cust_bill_pay {
302 'invnum' => $self->invnum,
303 'paynum' => $self->paynum,
304 'amount' => $self->paid,
305 '_date' => $self->_date,
307 $error = $cust_bill_pay->insert(%options);
309 if ( $ignore_noapply ) {
310 warn "warning: error inserting cust_bill_pay: $error ".
311 "(ignore_noapply flag set; inserting cust_pay record anyway)\n";
313 $dbh->rollback if $oldAutoCommit;
314 return "error inserting cust_bill_pay: $error";
319 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
321 #false laziness w/ cust_credit::insert
322 if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
323 my @errors = $cust_main->unsuspend;
325 # side-fx with nested transactions? upstack rolls back?
326 warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
332 #bill setup fees for voip_cdr bill_every_call packages
333 #some false laziness w/search in freeside-cdrd
335 'LEFT JOIN part_pkg USING ( pkgpart ) '.
336 "LEFT JOIN part_pkg_option
337 ON ( cust_pkg.pkgpart = part_pkg_option.pkgpart
338 AND part_pkg_option.optionname = 'bill_every_call' )";
340 my $extra_sql = " AND plan = 'voip_cdr' AND optionvalue = '1' ".
341 " AND ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 ) ";
343 my @cust_pkg = qsearch({
344 'table' => 'cust_pkg',
345 'addl_from' => $addl_from,
346 'hashref' => { 'custnum' => $self->custnum,
350 'extra_sql' => $extra_sql,
354 warn "voip_cdr bill_every_call packages found; billing customer\n";
355 my $bill_error = $self->cust_main->bill_and_collect( 'fatal' => 'return' );
357 warn "WARNING: Error billing customer: $bill_error\n";
360 #end of billing setup fees for voip_cdr bill_every_call packages
362 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
365 my $trigger = $conf->config('payment_receipt-trigger',
366 $self->cust_main->agentnum) || 'cust_pay';
367 if ( $trigger eq 'cust_pay' ) {
368 my $error = $self->send_receipt(
369 'manual' => $options{'manual'},
370 'cust_bill' => $cust_bill,
371 'cust_main' => $cust_main,
373 warn "can't send payment receipt/statement: $error" if $error;
380 =item void [ REASON ]
382 Voids this payment: deletes the payment and all associated applications and
383 adds a record of the voided payment to the FS::cust_pay_void table.
390 local $SIG{HUP} = 'IGNORE';
391 local $SIG{INT} = 'IGNORE';
392 local $SIG{QUIT} = 'IGNORE';
393 local $SIG{TERM} = 'IGNORE';
394 local $SIG{TSTP} = 'IGNORE';
395 local $SIG{PIPE} = 'IGNORE';
397 my $oldAutoCommit = $FS::UID::AutoCommit;
398 local $FS::UID::AutoCommit = 0;
401 my $cust_pay_void = new FS::cust_pay_void ( {
402 map { $_ => $self->get($_) } $self->fields
404 $cust_pay_void->reason(shift) if scalar(@_);
405 my $error = $cust_pay_void->insert;
407 $dbh->rollback if $oldAutoCommit;
411 $error = $self->delete;
413 $dbh->rollback if $oldAutoCommit;
417 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
425 Unless the closed flag is set, deletes this payment and all associated
426 applications (see L<FS::cust_bill_pay> and L<FS::cust_pay_refund>). In most
427 cases, you want to use the void method instead to leave a record of the
432 # very similar to FS::cust_credit::delete
435 return "Can't delete closed payment" if $self->closed =~ /^Y/i;
437 local $SIG{HUP} = 'IGNORE';
438 local $SIG{INT} = 'IGNORE';
439 local $SIG{QUIT} = 'IGNORE';
440 local $SIG{TERM} = 'IGNORE';
441 local $SIG{TSTP} = 'IGNORE';
442 local $SIG{PIPE} = 'IGNORE';
444 my $oldAutoCommit = $FS::UID::AutoCommit;
445 local $FS::UID::AutoCommit = 0;
448 foreach my $app ( $self->cust_bill_pay, $self->cust_pay_refund ) {
449 my $error = $app->delete;
451 $dbh->rollback if $oldAutoCommit;
456 my $error = $self->SUPER::delete(@_);
458 $dbh->rollback if $oldAutoCommit;
462 if ( $conf->exists('deletepayments')
463 && $conf->config('deletepayments') ne '' ) {
465 my $cust_main = $self->cust_main;
467 my $error = send_email(
468 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
469 #invoice_from??? well as good as any
470 'to' => $conf->config('deletepayments'),
471 'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
473 "This is an automatic message from your Freeside installation\n",
474 "informing you that the following payment has been deleted:\n",
476 'paynum: '. $self->paynum. "\n",
477 'custnum: '. $self->custnum.
478 " (". $cust_main->last. ", ". $cust_main->first. ")\n",
479 'paid: $'. sprintf("%.2f", $self->paid). "\n",
480 'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
481 'payby: '. $self->payby. "\n",
482 'payinfo: '. $self->paymask. "\n",
483 'paybatch: '. $self->paybatch. "\n",
488 $dbh->rollback if $oldAutoCommit;
489 return "can't send payment deletion notification: $error";
494 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
500 =item replace [ OLD_RECORD ]
502 You can, but probably shouldn't modify payments...
504 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
505 supplied, replaces this record. If there is an error, returns the error,
506 otherwise returns false.
512 return "Can't modify closed payment" if $self->closed =~ /^Y/i;
513 $self->SUPER::replace(@_);
518 Checks all fields to make sure this is a valid payment. If there is an error,
519 returns the error, otherwise returns false. Called by the insert method.
526 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
529 $self->ut_numbern('paynum')
530 || $self->ut_numbern('custnum')
531 || $self->ut_numbern('_date')
532 || $self->ut_money('paid')
533 || $self->ut_alphan('otaker')
534 || $self->ut_textn('paybatch')
535 || $self->ut_textn('payunique')
536 || $self->ut_enum('closed', [ '', 'Y' ])
537 || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
538 || $self->ut_textn('bank')
539 || $self->ut_alphan('depositor')
540 || $self->ut_numbern('account')
541 || $self->ut_numbern('teller')
542 || $self->ut_foreign_keyn('batchnum', 'pay_batch', 'batchnum')
543 || $self->payinfo_check()
545 return $error if $error;
547 return "paid must be > 0 " if $self->paid <= 0;
549 return "unknown cust_main.custnum: ". $self->custnum
551 || qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
553 $self->_date(time) unless $self->_date;
555 return "invalid discount_term"
556 if ($self->discount_term && $self->discount_term < 2);
558 if ( $self->payby eq 'CASH' and $conf->exists('require_cash_deposit_info') ) {
559 foreach (qw(bank depositor account teller)) {
560 return "$_ required" if $self->get($_) eq '';
564 #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it
565 # # UNIQUE index should catch this too, without race conditions, but this
566 # # should give a better error message the other 99.9% of the time...
567 # if ( length($self->payunique)
568 # && qsearchs('cust_pay', { 'payunique' => $self->payunique } ) ) {
569 # #well, it *could* be a better error message
570 # return "duplicate transaction".
571 # " - a payment with unique identifer ". $self->payunique.
578 =item send_receipt HASHREF | OPTION => VALUE ...
580 Sends a payment receipt for this payment..
588 Flag indicating the payment is being made manually.
592 Invoice (FS::cust_bill) object. If not specified, the most recent invoice
597 Customer (FS::cust_main) object (for efficiency).
605 my $opt = ref($_[0]) ? shift : { @_ };
607 my $cust_bill = $opt->{'cust_bill'};
608 my $cust_main = $opt->{'cust_main'} || $self->cust_main;
610 my $conf = new FS::Conf;
612 return '' unless $conf->config_bool('payment_receipt', $cust_main->agentnum);
614 my @invoicing_list = $cust_main->invoicing_list_emailonly;
615 return '' unless @invoicing_list;
617 $cust_bill ||= ($cust_main->cust_bill)[-1]; #rather inefficient though?
621 if ( ( exists($opt->{'manual'}) && $opt->{'manual'} )
622 #|| ! $conf->exists('invoice_html_statement')
626 my $msgnum = $conf->config('payment_receipt_msgnum', $cust_main->agentnum);
629 my $queue = new FS::queue {
630 'job' => 'FS::Misc::process_send_email',
631 'paynum' => $self->paynum,
632 'custnum' => $cust_main->custnum,
634 $error = $queue->insert(
635 FS::msg_template->by_key($msgnum)->prepare(
636 'cust_main' => $cust_main,
638 'from_config' => 'payment_receipt_from',
642 } elsif ( $conf->exists('payment_receipt_email') ) {
644 my $receipt_template = new Text::Template (
646 SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
648 warn "can't create payment receipt template: $Text::Template::ERROR";
652 my $payby = $self->payby;
653 my $payinfo = $self->payinfo;
654 $payby =~ s/^BILL$/Check/ if $payinfo;
655 if ( $payby eq 'CARD' || $payby eq 'CHEK' ) {
656 $payinfo = $self->paymask
658 $payinfo = $self->decrypt($payinfo);
660 $payby =~ s/^CHEK$/Electronic check/;
663 'date' => time2str("%a %B %o, %Y", $self->_date),
664 'name' => $cust_main->name,
665 'paynum' => $self->paynum,
666 'paid' => sprintf("%.2f", $self->paid),
667 'payby' => ucfirst(lc($payby)),
668 'payinfo' => $payinfo,
669 'balance' => $cust_main->balance,
670 'company_name' => $conf->config('company_name', $cust_main->agentnum),
673 if ( $opt->{'cust_pkg'} ) {
674 $fill_in{'pkg'} = $opt->{'cust_pkg'}->part_pkg->pkg;
675 #setup date, other things?
678 my $queue = new FS::queue {
679 'job' => 'FS::Misc::process_send_generated_email',
680 'paynum' => $self->paynum,
681 'custnum' => $cust_main->custnum,
683 $error = $queue->insert(
684 'from' => $conf->config('invoice_from', $cust_main->agentnum),
685 #invoice_from??? well as good as any
686 'to' => \@invoicing_list,
687 'subject' => 'Payment receipt',
688 'body' => [ $receipt_template->fill_in( HASH => \%fill_in ) ],
693 warn "payment_receipt is on, but no payment_receipt_msgnum\n";
697 } elsif ( ! $cust_main->invoice_noemail ) { #not manual
699 my $queue = new FS::queue {
700 'job' => 'FS::cust_bill::queueable_email',
701 'paynum' => $self->paynum,
702 'custnum' => $cust_main->custnum,
705 $error = $queue->insert(
706 'invnum' => $cust_bill->invnum,
707 'template' => 'statement',
708 'notice_name' => 'Statement',
714 warn "send_receipt: $error\n" if $error;
719 Returns all applications to invoices (see L<FS::cust_bill_pay>) for this
726 map { $_ } #return $self->num_cust_bill_pay unless wantarray;
727 sort { $a->_date <=> $b->_date
728 || $a->invnum <=> $b->invnum }
729 qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } )
733 =item cust_pay_refund
735 Returns all applications of refunds (see L<FS::cust_pay_refund>) to this
740 sub cust_pay_refund {
742 map { $_ } #return $self->num_cust_pay_refund unless wantarray;
743 sort { $a->_date <=> $b->_date }
744 qsearch( 'cust_pay_refund', { 'paynum' => $self->paynum } )
751 Returns the amount of this payment that is still unapplied; which is
752 paid minus all payment applications (see L<FS::cust_bill_pay>) and refund
753 applications (see L<FS::cust_pay_refund>).
759 my $amount = $self->paid;
760 $amount -= $_->amount foreach ( $self->cust_bill_pay );
761 $amount -= $_->amount foreach ( $self->cust_pay_refund );
762 sprintf("%.2f", $amount );
767 Returns the amount of this payment that has not been refuned; which is
768 paid minus all refund applications (see L<FS::cust_pay_refund>).
774 my $amount = $self->paid;
775 $amount -= $_->amount foreach ( $self->cust_pay_refund );
776 sprintf("%.2f", $amount );
781 Returns the "paid" field.
796 =item batch_insert CUST_PAY_OBJECT, ...
798 Class method which inserts multiple payments. Takes a list of FS::cust_pay
799 objects. Returns a list, each element representing the status of inserting the
800 corresponding payment - empty. If there is an error inserting any payment, the
801 entire transaction is rolled back, i.e. all payments are inserted or none are.
803 FS::cust_pay objects may have the pseudo-field 'apply_to', containing a
804 reference to an array of (uninserted) FS::cust_bill_pay objects. If so,
805 those objects will be inserted with the paynum of the payment, and for
806 each one, an error message or an empty string will be inserted into the
811 my @errors = FS::cust_pay->batch_insert(@cust_pay);
812 my $num_errors = scalar(grep $_, @errors);
813 if ( $num_errors == 0 ) {
814 #success; all payments were inserted
816 #failure; no payments were inserted.
822 my $self = shift; #class method
824 local $SIG{HUP} = 'IGNORE';
825 local $SIG{INT} = 'IGNORE';
826 local $SIG{QUIT} = 'IGNORE';
827 local $SIG{TERM} = 'IGNORE';
828 local $SIG{TSTP} = 'IGNORE';
829 local $SIG{PIPE} = 'IGNORE';
831 my $oldAutoCommit = $FS::UID::AutoCommit;
832 local $FS::UID::AutoCommit = 0;
838 foreach my $cust_pay (@_) {
839 my $error = $cust_pay->insert( 'manual' => 1 );
840 push @errors, $error;
841 $num_errors++ if $error;
843 if ( ref($cust_pay->get('apply_to')) eq 'ARRAY' ) {
845 foreach my $cust_bill_pay ( @{ $cust_pay->apply_to } ) {
846 if ( $error ) { # insert placeholders if cust_pay wasn't inserted
850 $cust_bill_pay->set('paynum', $cust_pay->paynum);
851 my $apply_error = $cust_bill_pay->insert;
852 push @errors, $apply_error || '';
853 $num_errors++ if $apply_error;
857 } elsif ( !$error ) { #normal case: apply payments as usual
858 $cust_pay->cust_main->apply_payments;
864 $dbh->rollback if $oldAutoCommit;
866 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
875 Returns an SQL fragment to retreive the unapplied amount.
880 my ($class, $start, $end) = @_;
881 my $bill_start = $start ? "AND cust_bill_pay._date <= $start" : '';
882 my $bill_end = $end ? "AND cust_bill_pay._date > $end" : '';
883 my $refund_start = $start ? "AND cust_pay_refund._date <= $start" : '';
884 my $refund_end = $end ? "AND cust_pay_refund._date > $end" : '';
888 ( SELECT SUM(amount) FROM cust_bill_pay
889 WHERE cust_pay.paynum = cust_bill_pay.paynum
890 $bill_start $bill_end )
894 ( SELECT SUM(amount) FROM cust_pay_refund
895 WHERE cust_pay.paynum = cust_pay_refund.paynum
896 $refund_start $refund_end )
905 # Used by FS::Upgrade to migrate to a new database.
909 sub _upgrade_data { #class method
910 my ($class, %opts) = @_;
912 warn "$me upgrading $class\n" if $DEBUG;
914 local $FS::payinfo_Mixin::ignore_masked_payinfo = 1;
917 # otaker/ivan upgrade
920 unless ( FS::upgrade_journal->is_done('cust_pay__otaker_ivan') ) {
922 #not the most efficient, but hey, it only has to run once
924 my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
925 " AND usernum IS NULL ".
926 " AND 0 < ( SELECT COUNT(*) FROM cust_main ".
927 " WHERE cust_main.custnum = cust_pay.custnum ) ";
929 my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
931 my $sth = dbh->prepare($count_sql) or die dbh->errstr;
932 $sth->execute or die $sth->errstr;
933 my $total = $sth->fetchrow_arrayref->[0];
934 #warn "$total cust_pay records to update\n"
936 local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
941 my @cust_pay = qsearch( {
942 'table' => 'cust_pay',
944 'extra_sql' => $where,
945 'order_by' => 'ORDER BY paynum',
948 foreach my $cust_pay (@cust_pay) {
950 my $h_cust_pay = $cust_pay->h_search('insert');
952 next if $cust_pay->otaker eq $h_cust_pay->history_user;
953 #$cust_pay->otaker($h_cust_pay->history_user);
954 $cust_pay->set('otaker', $h_cust_pay->history_user);
956 $cust_pay->set('otaker', 'legacy');
959 delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
960 my $error = $cust_pay->replace;
963 warn " *** WARNING: Error updating order taker for payment paynum ".
964 $cust_pay->paynun. ": $error\n";
968 $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
971 if ( $DEBUG > 1 && $lastprog + 30 < time ) {
972 warn "$me $count/$total (".sprintf('%.2f',100*$count/$total). '%)'."\n";
978 FS::upgrade_journal->set_done('cust_pay__otaker_ivan');
982 # payinfo N/A upgrade
985 unless ( FS::upgrade_journal->is_done('cust_pay__payinfo_na') ) {
987 #XXX remove the 'N/A (tokenized)' part (or just this entire thing)
989 my @na_cust_pay = qsearch( {
990 'table' => 'cust_pay',
991 'hashref' => {}, #could be encrypted# { 'payinfo' => 'N/A' },
992 'extra_sql' => "WHERE ( payinfo = 'N/A' OR paymask = 'N/AA' OR paymask = 'N/A (tokenized)' ) AND payby IN ( 'CARD', 'CHEK' )",
995 foreach my $na ( @na_cust_pay ) {
997 next unless $na->payinfo eq 'N/A';
999 my $cust_pay_pending =
1000 qsearchs('cust_pay_pending', { 'paynum' => $na->paynum } );
1001 unless ( $cust_pay_pending ) {
1002 warn " *** WARNING: not-yet recoverable N/A card for payment ".
1003 $na->paynum. " (no cust_pay_pending)\n";
1006 $na->$_($cust_pay_pending->$_) for qw( payinfo paymask );
1007 my $error = $na->replace;
1009 warn " *** WARNING: Error updating payinfo for payment paynum ".
1010 $na->paynun. ": $error\n";
1016 FS::upgrade_journal->set_done('cust_pay__payinfo_na');
1020 # otaker->usernum upgrade
1023 delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
1024 $class->_upgrade_otaker(%opts);
1025 $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
1028 # migrate batchnums from the misused 'paybatch' field to 'batchnum'
1030 my @cust_pay = qsearch( {
1031 'table' => 'cust_pay',
1032 'addl_from' => ' JOIN pay_batch ON cust_pay.paybatch = CAST(pay_batch.batchnum AS text) ',
1034 foreach my $cust_pay (@cust_pay) {
1035 $cust_pay->set('batchnum' => $cust_pay->paybatch);
1036 $cust_pay->set('paybatch' => '');
1037 my $error = $cust_pay->replace;
1038 warn "error setting batchnum on cust_pay #".$cust_pay->paynum.":\n $error"
1043 # migrate gateway info from the misused 'paybatch' field
1046 # not only cust_pay, but also voided and refunded payments
1047 if (!FS::upgrade_journal->is_done('cust_pay__parse_paybatch_1')) {
1048 local $FS::Record::nowarn_classload=1;
1049 # really inefficient, but again, only has to run once
1050 foreach my $table (qw(cust_pay cust_pay_void cust_refund)) {
1051 my $and_batchnum_is_null =
1052 ( $table =~ /^cust_pay/ ? ' AND batchnum IS NULL' : '' );
1053 foreach my $object ( qsearch({
1055 extra_sql => "WHERE payby IN('CARD','CHEK') ".
1056 "AND (paybatch IS NOT NULL ".
1057 "OR (paybatch IS NULL AND auth IS NULL
1058 $and_batchnum_is_null ) )",
1061 if ( $object->paybatch eq '' ) {
1062 # repair for a previous upgrade that didn't save 'auth'
1063 my $pkey = $object->primary_key;
1064 # find the last history record that had a paybatch value
1066 table => "h_$table",
1068 $pkey => $object->$pkey,
1069 paybatch => { op=>'!=', value=>''},
1070 history_action => 'replace_old',
1072 order_by => 'ORDER BY history_date DESC LIMIT 1',
1075 warn "couldn't find paybatch history record for $table ".$object->$pkey."\n";
1078 # if the paybatch didn't have an auth string, then it's fine
1079 $h->paybatch =~ /:(\w+):/ or next;
1080 # set paybatch to what it was in that record
1081 $object->set('paybatch', $h->paybatch)
1082 # and then upgrade it like the old records
1085 my $parsed = $object->_parse_paybatch;
1086 if (keys %$parsed) {
1087 $object->set($_ => $parsed->{$_}) foreach keys %$parsed;
1088 $object->set('auth' => $parsed->{authorization});
1089 $object->set('paybatch', '');
1090 my $error = $object->replace;
1091 warn "error parsing CARD/CHEK paybatch fields on $object #".
1092 $object->get($object->primary_key).":\n $error\n"
1097 FS::upgrade_journal->set_done('cust_pay__parse_paybatch_1');
1107 =item batch_import HASHREF
1109 Inserts new payments.
1116 my $fh = $param->{filehandle};
1117 my $agentnum = $param->{agentnum};
1118 my $format = $param->{'format'};
1119 my $paybatch = $param->{'paybatch'};
1121 # here is the agent virtualization
1122 my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
1126 if ( $format eq 'simple' ) {
1127 @fields = qw( custnum agent_custid paid payinfo );
1129 } elsif ( $format eq 'extended' ) {
1130 die "unimplemented\n";
1134 die "unknown format $format";
1137 eval "use Text::CSV_XS;";
1140 my $csv = new Text::CSV_XS;
1144 local $SIG{HUP} = 'IGNORE';
1145 local $SIG{INT} = 'IGNORE';
1146 local $SIG{QUIT} = 'IGNORE';
1147 local $SIG{TERM} = 'IGNORE';
1148 local $SIG{TSTP} = 'IGNORE';
1149 local $SIG{PIPE} = 'IGNORE';
1151 my $oldAutoCommit = $FS::UID::AutoCommit;
1152 local $FS::UID::AutoCommit = 0;
1156 while ( defined($line=<$fh>) ) {
1158 $csv->parse($line) or do {
1159 $dbh->rollback if $oldAutoCommit;
1160 return "can't parse: ". $csv->error_input();
1163 my @columns = $csv->fields();
1167 paybatch => $paybatch,
1171 foreach my $field ( @fields ) {
1173 if ( $field eq 'agent_custid'
1175 && $columns[0] =~ /\S+/ )
1178 my $agent_custid = $columns[0];
1179 my %hash = ( 'agent_custid' => $agent_custid,
1180 'agentnum' => $agentnum,
1183 if ( $cust_pay{'custnum'} !~ /^\s*$/ ) {
1184 $dbh->rollback if $oldAutoCommit;
1185 return "can't specify custnum with agent_custid $agent_custid";
1188 $cust_main = qsearchs({
1189 'table' => 'cust_main',
1190 'hashref' => \%hash,
1191 'extra_sql' => $extra_sql,
1194 unless ( $cust_main ) {
1195 $dbh->rollback if $oldAutoCommit;
1196 return "can't find customer with agent_custid $agent_custid";
1200 $columns[0] = $cust_main->custnum;
1203 $cust_pay{$field} = shift @columns;
1206 my $cust_pay = new FS::cust_pay( \%cust_pay );
1207 my $error = $cust_pay->insert;
1210 $dbh->rollback if $oldAutoCommit;
1211 return "can't insert payment for $line: $error";
1214 if ( $format eq 'simple' ) {
1215 # include agentnum for less surprise?
1216 $cust_main = qsearchs({
1217 'table' => 'cust_main',
1218 'hashref' => { 'custnum' => $cust_pay->custnum },
1219 'extra_sql' => $extra_sql,
1223 unless ( $cust_main ) {
1224 $dbh->rollback if $oldAutoCommit;
1225 return "can't find customer to which payments apply at line: $line";
1228 $error = $cust_main->apply_payments_and_credits;
1230 $dbh->rollback if $oldAutoCommit;
1231 return "can't apply payments to customer for $line: $error";
1239 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1241 return "Empty file!" unless $imported;
1251 Delete and replace methods.
1255 L<FS::cust_pay_pending>, L<FS::cust_bill_pay>, L<FS::cust_bill>, L<FS::Record>,
1256 schema.html from the base documentation.