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;
21 use FS::cust_pay_void;
23 @ISA = qw( FS::payinfo_transaction_Mixin FS::cust_main_Mixin FS::Record );
27 $me = '[FS::cust_pay]';
31 #ask FS::UID to run this stuff for us later
32 FS::UID->install_callback( sub {
34 $unsuspendauto = $conf->exists('unsuspendauto');
37 @encrypted_fields = ('payinfo');
41 FS::cust_pay - Object methods for cust_pay objects
47 $record = new FS::cust_pay \%hash;
48 $record = new FS::cust_pay { 'column' => 'value' };
50 $error = $record->insert;
52 $error = $new_record->replace($old_record);
54 $error = $record->delete;
56 $error = $record->check;
60 An FS::cust_pay object represents a payment; the transfer of money from a
61 customer. FS::cust_pay inherits from FS::Record. The following fields are
68 primary key (assigned automatically for new payments)
72 customer (see L<FS::cust_main>)
76 specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
77 L<Time::Local> and L<Date::Parse> for conversion functions.
81 Amount of this payment
85 order taker (assigned automatically, see L<FS::UID>)
89 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
93 Payment Information (See L<FS::payinfo_Mixin> for data format)
97 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
101 text field for tracking card processing or other batch grouping
105 Optional unique identifer to prevent duplicate transactions.
109 books closed flag, empty or `Y'
113 Desired pkgnum when using experimental package balances.
123 Creates a new payment. To add the payment to the databse, see L<"insert">.
127 sub table { 'cust_pay'; }
128 sub cust_linked { $_[0]->cust_main_custnum; }
129 sub cust_unlinked_msg {
131 "WARNING: can't find cust_main.custnum ". $self->custnum.
132 ' (cust_pay.paynum '. $self->paynum. ')';
137 Adds this payment to the database.
139 For backwards-compatibility and convenience, if the additional field invnum
140 is defined, an FS::cust_bill_pay record for the full amount of the payment
141 will be created. In this case, custnum is optional. An hash of optional
142 arguments may be passed. Currently "manual" is supported. If true, a
143 payment receipt is sent instead of a statement when 'payment_receipt_email'
144 configuration option is set.
149 my ($self, %options) = @_;
151 local $SIG{HUP} = 'IGNORE';
152 local $SIG{INT} = 'IGNORE';
153 local $SIG{QUIT} = 'IGNORE';
154 local $SIG{TERM} = 'IGNORE';
155 local $SIG{TSTP} = 'IGNORE';
156 local $SIG{PIPE} = 'IGNORE';
158 my $oldAutoCommit = $FS::UID::AutoCommit;
159 local $FS::UID::AutoCommit = 0;
163 if ( $self->invnum ) {
164 $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
166 $dbh->rollback if $oldAutoCommit;
167 return "Unknown cust_bill.invnum: ". $self->invnum;
169 $self->custnum($cust_bill->custnum );
173 my $error = $self->check;
174 return $error if $error;
176 my $cust_main = $self->cust_main;
177 my $old_balance = $cust_main->balance;
179 $error = $self->SUPER::insert;
181 $dbh->rollback if $oldAutoCommit;
182 return "error inserting $self: $error";
185 if ( $self->invnum ) {
186 my $cust_bill_pay = new FS::cust_bill_pay {
187 'invnum' => $self->invnum,
188 'paynum' => $self->paynum,
189 'amount' => $self->paid,
190 '_date' => $self->_date,
192 $error = $cust_bill_pay->insert;
194 if ( $ignore_noapply ) {
195 warn "warning: error inserting $cust_bill_pay: $error ".
196 "(ignore_noapply flag set; inserting cust_pay record anyway)\n";
198 $dbh->rollback if $oldAutoCommit;
199 return "error inserting $cust_bill_pay: $error";
204 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
206 #false laziness w/ cust_credit::insert
207 if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
208 my @errors = $cust_main->unsuspend;
210 # side-fx with nested transactions? upstack rolls back?
211 warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
217 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
219 #my $cust_main = $self->cust_main;
220 if ( $conf->exists('payment_receipt_email')
221 && grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list
224 $cust_bill ||= ($cust_main->cust_bill)[-1]; #rather inefficient though?
227 if ( ( exists($options{'manual'}) && $options{'manual'} )
228 || ! $conf->exists('invoice_html_statement')
232 my $receipt_template = new Text::Template (
234 SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
236 warn "can't create payment receipt template: $Text::Template::ERROR";
240 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ }
241 $cust_main->invoicing_list;
243 my $payby = $self->payby;
244 my $payinfo = $self->payinfo;
245 $payby =~ s/^BILL$/Check/ if $payinfo;
246 if ( $payby eq 'CARD' || $payby eq 'CHEK' ) {
247 $payinfo = $self->paymask
249 $payinfo = $self->decrypt($payinfo);
251 $payby =~ s/^CHEK$/Electronic check/;
254 'from' => $conf->config('invoice_from', $cust_main->agentnum),
255 #invoice_from??? well as good as any
256 'to' => \@invoicing_list,
257 'subject' => 'Payment receipt',
258 'body' => [ $receipt_template->fill_in( HASH => {
259 'date' => time2str("%a %B %o, %Y", $self->_date),
260 'name' => $cust_main->name,
261 'paynum' => $self->paynum,
262 'paid' => sprintf("%.2f", $self->paid),
263 'payby' => ucfirst(lc($payby)),
264 'payinfo' => $payinfo,
265 'balance' => $cust_main->balance,
266 'company_name' => $conf->config('company_name'),
272 my $queue = new FS::queue {
273 'paynum' => $self->paynum,
274 'job' => 'FS::cust_bill::queueable_email',
276 $error = $queue->insert(
277 'invnum' => $cust_bill->invnum,
278 'template' => 'statement',
284 warn "can't send payment receipt/statement: $error";
293 =item void [ REASON ]
295 Voids this payment: deletes the payment and all associated applications and
296 adds a record of the voided payment to the FS::cust_pay_void table.
303 local $SIG{HUP} = 'IGNORE';
304 local $SIG{INT} = 'IGNORE';
305 local $SIG{QUIT} = 'IGNORE';
306 local $SIG{TERM} = 'IGNORE';
307 local $SIG{TSTP} = 'IGNORE';
308 local $SIG{PIPE} = 'IGNORE';
310 my $oldAutoCommit = $FS::UID::AutoCommit;
311 local $FS::UID::AutoCommit = 0;
314 my $cust_pay_void = new FS::cust_pay_void ( {
315 map { $_ => $self->get($_) } $self->fields
317 $cust_pay_void->reason(shift) if scalar(@_);
318 my $error = $cust_pay_void->insert;
320 $dbh->rollback if $oldAutoCommit;
324 $error = $self->delete;
326 $dbh->rollback if $oldAutoCommit;
330 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
338 Unless the closed flag is set, deletes this payment and all associated
339 applications (see L<FS::cust_bill_pay> and L<FS::cust_pay_refund>). In most
340 cases, you want to use the void method instead to leave a record of the
345 # very similar to FS::cust_credit::delete
348 return "Can't delete closed payment" if $self->closed =~ /^Y/i;
350 local $SIG{HUP} = 'IGNORE';
351 local $SIG{INT} = 'IGNORE';
352 local $SIG{QUIT} = 'IGNORE';
353 local $SIG{TERM} = 'IGNORE';
354 local $SIG{TSTP} = 'IGNORE';
355 local $SIG{PIPE} = 'IGNORE';
357 my $oldAutoCommit = $FS::UID::AutoCommit;
358 local $FS::UID::AutoCommit = 0;
361 foreach my $app ( $self->cust_bill_pay, $self->cust_pay_refund ) {
362 my $error = $app->delete;
364 $dbh->rollback if $oldAutoCommit;
369 my $error = $self->SUPER::delete(@_);
371 $dbh->rollback if $oldAutoCommit;
375 if ( $conf->exists('deletepayments')
376 && $conf->config('deletepayments') ne '' ) {
378 my $cust_main = $self->cust_main;
380 my $error = send_email(
381 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
382 #invoice_from??? well as good as any
383 'to' => $conf->config('deletepayments'),
384 'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
386 "This is an automatic message from your Freeside installation\n",
387 "informing you that the following payment has been deleted:\n",
389 'paynum: '. $self->paynum. "\n",
390 'custnum: '. $self->custnum.
391 " (". $cust_main->last. ", ". $cust_main->first. ")\n",
392 'paid: $'. sprintf("%.2f", $self->paid). "\n",
393 'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
394 'payby: '. $self->payby. "\n",
395 'payinfo: '. $self->paymask. "\n",
396 'paybatch: '. $self->paybatch. "\n",
401 $dbh->rollback if $oldAutoCommit;
402 return "can't send payment deletion notification: $error";
407 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
413 =item replace OLD_RECORD
415 You can, but probably shouldn't modify payments...
420 #return "Can't modify payment!"
422 return "Can't modify closed payment" if $self->closed =~ /^Y/i;
423 $self->SUPER::replace(@_);
428 Checks all fields to make sure this is a valid payment. If there is an error,
429 returns the error, otherwise returns false. Called by the insert method.
436 $self->otaker(getotaker) unless ($self->otaker);
439 $self->ut_numbern('paynum')
440 || $self->ut_numbern('custnum')
441 || $self->ut_numbern('_date')
442 || $self->ut_money('paid')
443 || $self->ut_alpha('otaker')
444 || $self->ut_textn('paybatch')
445 || $self->ut_textn('payunique')
446 || $self->ut_enum('closed', [ '', 'Y' ])
447 || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
448 || $self->payinfo_check()
450 return $error if $error;
452 return "paid must be > 0 " if $self->paid <= 0;
454 return "unknown cust_main.custnum: ". $self->custnum
456 || qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
458 $self->_date(time) unless $self->_date;
460 #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it
461 # # UNIQUE index should catch this too, without race conditions, but this
462 # # should give a better error message the other 99.9% of the time...
463 # if ( length($self->payunique)
464 # && qsearchs('cust_pay', { 'payunique' => $self->payunique } ) ) {
465 # #well, it *could* be a better error message
466 # return "duplicate transaction".
467 # " - a payment with unique identifer ". $self->payunique.
474 =item batch_insert CUST_PAY_OBJECT, ...
476 Class method which inserts multiple payments. Takes a list of FS::cust_pay
477 objects. Returns a list, each element representing the status of inserting the
478 corresponding payment - empty. If there is an error inserting any payment, the
479 entire transaction is rolled back, i.e. all payments are inserted or none are.
483 my @errors = FS::cust_pay->batch_insert(@cust_pay);
484 my $num_errors = scalar(grep $_, @errors);
485 if ( $num_errors == 0 ) {
486 #success; all payments were inserted
488 #failure; no payments were inserted.
494 my $self = shift; #class method
496 local $SIG{HUP} = 'IGNORE';
497 local $SIG{INT} = 'IGNORE';
498 local $SIG{QUIT} = 'IGNORE';
499 local $SIG{TERM} = 'IGNORE';
500 local $SIG{TSTP} = 'IGNORE';
501 local $SIG{PIPE} = 'IGNORE';
503 my $oldAutoCommit = $FS::UID::AutoCommit;
504 local $FS::UID::AutoCommit = 0;
510 my $error = $_->insert( 'manual' => 1 );
514 $_->cust_main->apply_payments;
520 $dbh->rollback if $oldAutoCommit;
522 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
531 Returns all applications to invoices (see L<FS::cust_bill_pay>) for this
538 sort { $a->_date <=> $b->_date
539 || $a->invnum <=> $b->invnum }
540 qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } )
544 =item cust_pay_refund
546 Returns all applications of refunds (see L<FS::cust_pay_refund>) to this
551 sub cust_pay_refund {
553 sort { $a->_date <=> $b->_date }
554 qsearch( 'cust_pay_refund', { 'paynum' => $self->paynum } )
561 Returns the amount of this payment that is still unapplied; which is
562 paid minus all payment applications (see L<FS::cust_bill_pay>) and refund
563 applications (see L<FS::cust_pay_refund>).
569 my $amount = $self->paid;
570 $amount -= $_->amount foreach ( $self->cust_bill_pay );
571 $amount -= $_->amount foreach ( $self->cust_pay_refund );
572 sprintf("%.2f", $amount );
577 Returns the amount of this payment that has not been refuned; which is
578 paid minus all refund applications (see L<FS::cust_pay_refund>).
584 my $amount = $self->paid;
585 $amount -= $_->amount foreach ( $self->cust_pay_refund );
586 sprintf("%.2f", $amount );
591 Returns the "paid" field.
608 Returns an SQL fragment to retreive the unapplied amount.
617 ( SELECT SUM(amount) FROM cust_bill_pay
618 WHERE cust_pay.paynum = cust_bill_pay.paynum )
622 ( SELECT SUM(amount) FROM cust_pay_refund
623 WHERE cust_pay.paynum = cust_pay_refund.paynum )
632 # Used by FS::Upgrade to migrate to a new database.
636 sub _upgrade_data { #class method
637 my ($class, %opts) = @_;
639 warn "$me upgrading $class\n" if $DEBUG;
641 #not the most efficient, but hey, it only has to run once
643 my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
644 " AND 0 < ( SELECT COUNT(*) FROM cust_main ".
645 " WHERE cust_main.custnum = cust_pay.custnum ) ";
647 my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
649 my $sth = dbh->prepare($count_sql) or die dbh->errstr;
650 $sth->execute or die $sth->errstr;
651 my $total = $sth->fetchrow_arrayref->[0];
652 #warn "$total cust_pay records to update\n"
654 local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
659 my @cust_pay = qsearch( {
660 'table' => 'cust_pay',
662 'extra_sql' => $where,
663 'order_by' => 'ORDER BY paynum',
666 foreach my $cust_pay (@cust_pay) {
668 my $h_cust_pay = $cust_pay->h_search('insert');
670 next if $cust_pay->otaker eq $h_cust_pay->history_user;
671 $cust_pay->otaker($h_cust_pay->history_user);
673 $cust_pay->otaker('legacy');
676 delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
677 my $error = $cust_pay->replace;
680 warn " *** WARNING: Error updating order taker for payment paynum ".
681 $cust_pay->paynun. ": $error\n";
685 $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
688 if ( $DEBUG > 1 && $lastprog + 30 < time ) {
689 warn "$me $count/$total (". sprintf('%.2f',100*$count/$total). '%)'. "\n";
703 =item batch_import HASHREF
705 Inserts new payments.
712 my $fh = $param->{filehandle};
713 my $agentnum = $param->{agentnum};
714 my $format = $param->{'format'};
715 my $paybatch = $param->{'paybatch'};
717 # here is the agent virtualization
718 my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
722 if ( $format eq 'simple' ) {
723 @fields = qw( custnum agent_custid paid payinfo );
725 } elsif ( $format eq 'extended' ) {
726 die "unimplemented\n";
730 die "unknown format $format";
733 eval "use Text::CSV_XS;";
736 my $csv = new Text::CSV_XS;
740 local $SIG{HUP} = 'IGNORE';
741 local $SIG{INT} = 'IGNORE';
742 local $SIG{QUIT} = 'IGNORE';
743 local $SIG{TERM} = 'IGNORE';
744 local $SIG{TSTP} = 'IGNORE';
745 local $SIG{PIPE} = 'IGNORE';
747 my $oldAutoCommit = $FS::UID::AutoCommit;
748 local $FS::UID::AutoCommit = 0;
752 while ( defined($line=<$fh>) ) {
754 $csv->parse($line) or do {
755 $dbh->rollback if $oldAutoCommit;
756 return "can't parse: ". $csv->error_input();
759 my @columns = $csv->fields();
763 paybatch => $paybatch,
767 foreach my $field ( @fields ) {
769 if ( $field eq 'agent_custid'
771 && $columns[0] =~ /\S+/ )
774 my $agent_custid = $columns[0];
775 my %hash = ( 'agent_custid' => $agent_custid,
776 'agentnum' => $agentnum,
779 if ( $cust_pay{'custnum'} !~ /^\s*$/ ) {
780 $dbh->rollback if $oldAutoCommit;
781 return "can't specify custnum with agent_custid $agent_custid";
784 $cust_main = qsearchs({
785 'table' => 'cust_main',
787 'extra_sql' => $extra_sql,
790 unless ( $cust_main ) {
791 $dbh->rollback if $oldAutoCommit;
792 return "can't find customer with agent_custid $agent_custid";
796 $columns[0] = $cust_main->custnum;
799 $cust_pay{$field} = shift @columns;
802 my $cust_pay = new FS::cust_pay( \%cust_pay );
803 my $error = $cust_pay->insert;
806 $dbh->rollback if $oldAutoCommit;
807 return "can't insert payment for $line: $error";
810 if ( $format eq 'simple' ) {
811 # include agentnum for less surprise?
812 $cust_main = qsearchs({
813 'table' => 'cust_main',
814 'hashref' => { 'custnum' => $cust_pay->custnum },
815 'extra_sql' => $extra_sql,
819 unless ( $cust_main ) {
820 $dbh->rollback if $oldAutoCommit;
821 return "can't find customer to which payments apply at line: $line";
824 $error = $cust_main->apply_payments_and_credits;
826 $dbh->rollback if $oldAutoCommit;
827 return "can't apply payments to customer for $line: $error";
835 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
837 return "Empty file!" unless $imported;
847 Delete and replace methods.
851 L<FS::cust_pay_pending>, L<FS::cust_bill_pay>, L<FS::cust_bill>, L<FS::Record>,
852 schema.html from the base documentation.