4 use vars qw( @ISA $DEBUG $me $conf @encrypted_fields
5 $unsuspendauto $ignore_noapply
8 use Business::CreditCard;
10 use FS::UID qw( getotaker );
11 use FS::Misc qw( send_email );
12 use FS::Record qw( dbh qsearch qsearchs );
14 use FS::cust_main_Mixin;
15 use FS::payinfo_transaction_Mixin;
17 use FS::cust_bill_pay;
18 use FS::cust_pay_refund;
20 use FS::cust_pay_void;
22 @ISA = qw( FS::payinfo_transaction_Mixin FS::cust_main_Mixin FS::Record );
26 $me = '[FS::cust_pay]';
30 #ask FS::UID to run this stuff for us later
31 FS::UID->install_callback( sub {
33 $unsuspendauto = $conf->exists('unsuspendauto');
36 @encrypted_fields = ('payinfo');
40 FS::cust_pay - Object methods for cust_pay objects
46 $record = new FS::cust_pay \%hash;
47 $record = new FS::cust_pay { 'column' => 'value' };
49 $error = $record->insert;
51 $error = $new_record->replace($old_record);
53 $error = $record->delete;
55 $error = $record->check;
59 An FS::cust_pay object represents a payment; the transfer of money from a
60 customer. FS::cust_pay inherits from FS::Record. The following fields are
65 =item paynum - primary key (assigned automatically for new payments)
67 =item custnum - customer (see L<FS::cust_main>)
69 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
70 L<Time::Local> and L<Date::Parse> for conversion functions.
72 =item paid - Amount of this payment
74 =item otaker - order taker (assigned automatically, see L<FS::UID>)
76 =item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
78 =item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
80 =item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
82 =item paybatch - text field for tracking card processing or other batch grouping
84 =item payunique - Optional unique identifer to prevent duplicate transactions.
86 =item closed - books closed flag, empty or `Y'
96 Creates a new payment. To add the payment to the databse, see L<"insert">.
100 sub table { 'cust_pay'; }
101 sub cust_linked { $_[0]->cust_main_custnum; }
102 sub cust_unlinked_msg {
104 "WARNING: can't find cust_main.custnum ". $self->custnum.
105 ' (cust_pay.paynum '. $self->paynum. ')';
110 Adds this payment to the database.
112 For backwards-compatibility and convenience, if the additional field invnum
113 is defined, an FS::cust_bill_pay record for the full amount of the payment
114 will be created. In this case, custnum is optional. An hash of optional
115 arguments may be passed. Currently "manual" is supported. If true, a
116 payment receipt is sent instead of a statement when 'payment_receipt_email'
117 configuration option is set.
122 my ($self, %options) = @_;
124 local $SIG{HUP} = 'IGNORE';
125 local $SIG{INT} = 'IGNORE';
126 local $SIG{QUIT} = 'IGNORE';
127 local $SIG{TERM} = 'IGNORE';
128 local $SIG{TSTP} = 'IGNORE';
129 local $SIG{PIPE} = 'IGNORE';
131 my $oldAutoCommit = $FS::UID::AutoCommit;
132 local $FS::UID::AutoCommit = 0;
136 if ( $self->invnum ) {
137 $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
139 $dbh->rollback if $oldAutoCommit;
140 return "Unknown cust_bill.invnum: ". $self->invnum;
142 $self->custnum($cust_bill->custnum );
146 my $error = $self->check;
147 return $error if $error;
149 my $cust_main = $self->cust_main;
150 my $old_balance = $cust_main->balance;
152 $error = $self->SUPER::insert;
154 $dbh->rollback if $oldAutoCommit;
155 return "error inserting $self: $error";
158 if ( $self->invnum ) {
159 my $cust_bill_pay = new FS::cust_bill_pay {
160 'invnum' => $self->invnum,
161 'paynum' => $self->paynum,
162 'amount' => $self->paid,
163 '_date' => $self->_date,
165 $error = $cust_bill_pay->insert;
167 if ( $ignore_noapply ) {
168 warn "warning: error inserting $cust_bill_pay: $error ".
169 "(ignore_noapply flag set; inserting cust_pay record anyway)\n";
171 $dbh->rollback if $oldAutoCommit;
172 return "error inserting $cust_bill_pay: $error";
177 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
179 #false laziness w/ cust_credit::insert
180 if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
181 my @errors = $cust_main->unsuspend;
183 # side-fx with nested transactions? upstack rolls back?
184 warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
190 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
192 #my $cust_main = $self->cust_main;
193 if ( $conf->exists('payment_receipt_email')
194 && grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list
197 $cust_bill ||= ($cust_main->cust_bill)[-1]; #rather inefficient though?
200 if ( ( exists($options{'manual'}) && $options{'manual'} )
201 || ! $conf->exists('invoice_html_statement')
205 my $receipt_template = new Text::Template (
207 SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
209 warn "can't create payment receipt template: $Text::Template::ERROR";
213 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
214 $cust_main->invoicing_list;
216 my $payby = $self->payby;
217 my $payinfo = $self->payinfo;
218 $payby =~ s/^BILL$/Check/ if $payinfo;
219 if ( $payby eq 'CARD' || $payby eq 'CHEK' ) {
220 $payinfo = $self->paymask
222 $payinfo = $self->decrypt($payinfo);
224 $payby =~ s/^CHEK$/Electronic check/;
227 'from' => $conf->config('invoice_from', $cust_main->agentnum),
228 #invoice_from??? well as good as any
229 'to' => \@invoicing_list,
230 'subject' => 'Payment receipt',
231 'body' => [ $receipt_template->fill_in( HASH => {
232 'date' => time2str("%a %B %o, %Y", $self->_date),
233 'name' => $cust_main->name,
234 'paynum' => $self->paynum,
235 'paid' => sprintf("%.2f", $self->paid),
236 'payby' => ucfirst(lc($payby)),
237 'payinfo' => $payinfo,
238 'balance' => $cust_main->balance,
239 'company_name' => $conf->config('company_name'),
245 my $queue = new FS::queue {
246 'paynum' => $self->paynum,
247 'job' => 'FS::cust_bill::queueable_email',
249 $error = $queue->insert(
250 'invnum' => $cust_bill->invnum,
251 'template' => 'statement',
257 warn "can't send payment receipt/statement: $error";
266 =item void [ REASON ]
268 Voids this payment: deletes the payment and all associated applications and
269 adds a record of the voided payment to the FS::cust_pay_void table.
276 local $SIG{HUP} = 'IGNORE';
277 local $SIG{INT} = 'IGNORE';
278 local $SIG{QUIT} = 'IGNORE';
279 local $SIG{TERM} = 'IGNORE';
280 local $SIG{TSTP} = 'IGNORE';
281 local $SIG{PIPE} = 'IGNORE';
283 my $oldAutoCommit = $FS::UID::AutoCommit;
284 local $FS::UID::AutoCommit = 0;
287 my $cust_pay_void = new FS::cust_pay_void ( {
288 map { $_ => $self->get($_) } $self->fields
290 $cust_pay_void->reason(shift) if scalar(@_);
291 my $error = $cust_pay_void->insert;
293 $dbh->rollback if $oldAutoCommit;
297 $error = $self->delete;
299 $dbh->rollback if $oldAutoCommit;
303 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
311 Unless the closed flag is set, deletes this payment and all associated
312 applications (see L<FS::cust_bill_pay> and L<FS::cust_pay_refund>). In most
313 cases, you want to use the void method instead to leave a record of the
318 # very similar to FS::cust_credit::delete
321 return "Can't delete closed payment" if $self->closed =~ /^Y/i;
323 local $SIG{HUP} = 'IGNORE';
324 local $SIG{INT} = 'IGNORE';
325 local $SIG{QUIT} = 'IGNORE';
326 local $SIG{TERM} = 'IGNORE';
327 local $SIG{TSTP} = 'IGNORE';
328 local $SIG{PIPE} = 'IGNORE';
330 my $oldAutoCommit = $FS::UID::AutoCommit;
331 local $FS::UID::AutoCommit = 0;
334 foreach my $app ( $self->cust_bill_pay, $self->cust_pay_refund ) {
335 my $error = $app->delete;
337 $dbh->rollback if $oldAutoCommit;
342 my $error = $self->SUPER::delete(@_);
344 $dbh->rollback if $oldAutoCommit;
348 if ( $conf->exists('deletepayments')
349 && $conf->config('deletepayments') ne '' ) {
351 my $cust_main = $self->cust_main;
353 my $error = send_email(
354 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
355 #invoice_from??? well as good as any
356 'to' => $conf->config('deletepayments'),
357 'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
359 "This is an automatic message from your Freeside installation\n",
360 "informing you that the following payment has been deleted:\n",
362 'paynum: '. $self->paynum. "\n",
363 'custnum: '. $self->custnum.
364 " (". $cust_main->last. ", ". $cust_main->first. ")\n",
365 'paid: $'. sprintf("%.2f", $self->paid). "\n",
366 'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
367 'payby: '. $self->payby. "\n",
368 'payinfo: '. $self->paymask. "\n",
369 'paybatch: '. $self->paybatch. "\n",
374 $dbh->rollback if $oldAutoCommit;
375 return "can't send payment deletion notification: $error";
380 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
386 =item replace OLD_RECORD
388 You can, but probably shouldn't modify payments...
393 #return "Can't modify payment!"
395 return "Can't modify closed payment" if $self->closed =~ /^Y/i;
396 $self->SUPER::replace(@_);
401 Checks all fields to make sure this is a valid payment. If there is an error,
402 returns the error, otherwise returns false. Called by the insert method.
409 $self->otaker(getotaker) unless ($self->otaker);
412 $self->ut_numbern('paynum')
413 || $self->ut_numbern('custnum')
414 || $self->ut_numbern('_date')
415 || $self->ut_money('paid')
416 || $self->ut_alpha('otaker')
417 || $self->ut_textn('paybatch')
418 || $self->ut_textn('payunique')
419 || $self->ut_enum('closed', [ '', 'Y' ])
420 || $self->payinfo_check()
422 return $error if $error;
424 return "paid must be > 0 " if $self->paid <= 0;
426 return "unknown cust_main.custnum: ". $self->custnum
428 || qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
430 $self->_date(time) unless $self->_date;
432 #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it
433 # # UNIQUE index should catch this too, without race conditions, but this
434 # # should give a better error message the other 99.9% of the time...
435 # if ( length($self->payunique)
436 # && qsearchs('cust_pay', { 'payunique' => $self->payunique } ) ) {
437 # #well, it *could* be a better error message
438 # return "duplicate transaction".
439 # " - a payment with unique identifer ". $self->payunique.
446 =item batch_insert CUST_PAY_OBJECT, ...
448 Class method which inserts multiple payments. Takes a list of FS::cust_pay
449 objects. Returns a list, each element representing the status of inserting the
450 corresponding payment - empty. If there is an error inserting any payment, the
451 entire transaction is rolled back, i.e. all payments are inserted or none are.
455 my @errors = FS::cust_pay->batch_insert(@cust_pay);
456 my $num_errors = scalar(grep $_, @errors);
457 if ( $num_errors == 0 ) {
458 #success; all payments were inserted
460 #failure; no payments were inserted.
466 my $self = shift; #class method
468 local $SIG{HUP} = 'IGNORE';
469 local $SIG{INT} = 'IGNORE';
470 local $SIG{QUIT} = 'IGNORE';
471 local $SIG{TERM} = 'IGNORE';
472 local $SIG{TSTP} = 'IGNORE';
473 local $SIG{PIPE} = 'IGNORE';
475 my $oldAutoCommit = $FS::UID::AutoCommit;
476 local $FS::UID::AutoCommit = 0;
482 my $error = $_->insert( 'manual' => 1 );
486 $_->cust_main->apply_payments;
492 $dbh->rollback if $oldAutoCommit;
494 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
503 Returns all applications to invoices (see L<FS::cust_bill_pay>) for this
510 sort { $a->_date <=> $b->_date
511 || $a->invnum <=> $b->invnum }
512 qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } )
516 =item cust_pay_refund
518 Returns all applications of refunds (see L<FS::cust_pay_refund>) to this
523 sub cust_pay_refund {
525 sort { $a->_date <=> $b->_date }
526 qsearch( 'cust_pay_refund', { 'paynum' => $self->paynum } )
533 Returns the amount of this payment that is still unapplied; which is
534 paid minus all payment applications (see L<FS::cust_bill_pay>) and refund
535 applications (see L<FS::cust_pay_refund>).
541 my $amount = $self->paid;
542 $amount -= $_->amount foreach ( $self->cust_bill_pay );
543 $amount -= $_->amount foreach ( $self->cust_pay_refund );
544 sprintf("%.2f", $amount );
549 Returns the amount of this payment that has not been refuned; which is
550 paid minus all refund applications (see L<FS::cust_pay_refund>).
556 my $amount = $self->paid;
557 $amount -= $_->amount foreach ( $self->cust_pay_refund );
558 sprintf("%.2f", $amount );
563 Returns the "paid" field.
580 Returns an SQL fragment to retreive the unapplied amount.
589 ( SELECT SUM(amount) FROM cust_bill_pay
590 WHERE cust_pay.paynum = cust_bill_pay.paynum )
594 ( SELECT SUM(amount) FROM cust_pay_refund
595 WHERE cust_pay.paynum = cust_pay_refund.paynum )
604 # Used by FS::Upgrade to migrate to a new database.
608 sub _upgrade_data { #class method
609 my ($class, %opts) = @_;
611 warn "$me upgrading $class\n" if $DEBUG;
613 #not the most efficient, but hey, it only has to run once
615 my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
616 " AND 0 < ( SELECT COUNT(*) FROM cust_main ".
617 " WHERE cust_main.custnum = cust_pay.custnum ) ";
619 my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
621 my $sth = dbh->prepare($count_sql) or die dbh->errstr;
622 $sth->execute or die $sth->errstr;
623 my $total = $sth->fetchrow_arrayref->[0];
624 #warn "$total cust_pay records to update\n"
626 local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
631 my @cust_pay = qsearch( {
632 'table' => 'cust_pay',
634 'extra_sql' => $where,
635 'order_by' => 'ORDER BY paynum',
638 foreach my $cust_pay (@cust_pay) {
640 my $h_cust_pay = $cust_pay->h_search('insert');
642 next if $cust_pay->otaker eq $h_cust_pay->history_user;
643 $cust_pay->otaker($h_cust_pay->history_user);
645 $cust_pay->otaker('legacy');
648 delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
649 my $error = $cust_pay->replace;
652 warn " *** WARNING: Error updating order taker for payment paynum ".
653 $cust_pay->paynun. ": $error\n";
657 $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
660 if ( $DEBUG > 1 && $lastprog + 30 < time ) {
661 warn "$me $count/$total (". sprintf('%.2f',100*$count/$total). '%)'. "\n";
675 =item batch_import HASHREF
677 Inserts new payments.
684 my $fh = $param->{filehandle};
685 my $agentnum = $param->{agentnum};
686 my $format = $param->{'format'};
687 my $paybatch = $param->{'paybatch'};
689 # here is the agent virtualization
690 my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
694 if ( $format eq 'simple' ) {
695 @fields = qw( custnum agent_custid paid payinfo );
697 } elsif ( $format eq 'extended' ) {
698 die "unimplemented\n";
702 die "unknown format $format";
705 eval "use Text::CSV_XS;";
708 my $csv = new Text::CSV_XS;
712 local $SIG{HUP} = 'IGNORE';
713 local $SIG{INT} = 'IGNORE';
714 local $SIG{QUIT} = 'IGNORE';
715 local $SIG{TERM} = 'IGNORE';
716 local $SIG{TSTP} = 'IGNORE';
717 local $SIG{PIPE} = 'IGNORE';
719 my $oldAutoCommit = $FS::UID::AutoCommit;
720 local $FS::UID::AutoCommit = 0;
724 while ( defined($line=<$fh>) ) {
726 $csv->parse($line) or do {
727 $dbh->rollback if $oldAutoCommit;
728 return "can't parse: ". $csv->error_input();
731 my @columns = $csv->fields();
735 paybatch => $paybatch,
739 foreach my $field ( @fields ) {
741 if ( $field eq 'agent_custid'
743 && $columns[0] =~ /\S+/ )
746 my $agent_custid = $columns[0];
747 my %hash = ( 'agent_custid' => $agent_custid,
748 'agentnum' => $agentnum,
751 if ( $cust_pay{'custnum'} !~ /^\s*$/ ) {
752 $dbh->rollback if $oldAutoCommit;
753 return "can't specify custnum with agent_custid $agent_custid";
756 $cust_main = qsearchs({
757 'table' => 'cust_main',
759 'extra_sql' => $extra_sql,
762 unless ( $cust_main ) {
763 $dbh->rollback if $oldAutoCommit;
764 return "can't find customer with agent_custid $agent_custid";
768 $columns[0] = $cust_main->custnum;
771 $cust_pay{$field} = shift @columns;
774 my $cust_pay = new FS::cust_pay( \%cust_pay );
775 my $error = $cust_pay->insert;
778 $dbh->rollback if $oldAutoCommit;
779 return "can't insert payment for $line: $error";
782 if ( $format eq 'simple' ) {
783 # include agentnum for less surprise?
784 $cust_main = qsearchs({
785 'table' => 'cust_main',
786 'hashref' => { 'custnum' => $cust_pay->custnum },
787 'extra_sql' => $extra_sql,
791 unless ( $cust_main ) {
792 $dbh->rollback if $oldAutoCommit;
793 return "can't find customer to which payments apply at line: $line";
796 $error = $cust_main->apply_payments_and_credits;
798 $dbh->rollback if $oldAutoCommit;
799 return "can't apply payments to customer for $line: $error";
807 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
809 return "Empty file!" unless $imported;
819 Delete and replace methods.
823 L<FS::cust_pay_pending>, L<FS::cust_bill_pay>, L<FS::cust_bill>, L<FS::Record>,
824 schema.html from the base documentation.