1 package FS::payinfo_Mixin;
4 use Business::CreditCard;
6 use FS::Record qw(qsearch);
7 use FS::UID qw(driver_name);
9 use Time::Local qw(timelocal);
11 use vars qw( $ignore_masked_payinfo $allow_closed_replace );
15 FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.
19 package FS::some_table;
21 @ISA = qw( FS::payinfo_Mixin FS::Record );
25 This is a mixin class for records that contain payinfo.
33 The following payment types (payby) are supported:
35 For Customers (cust_main):
36 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
37 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
38 'LECB' (Phone bill billing), 'BILL' (billing), 'COMP' (free), or
39 'PREPAY' (special billing type: applies a credit and sets billing type to I<BILL> - see L<FS::prepay_credit>)
41 For Refunds (cust_refund):
42 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
43 'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash),
44 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' (Manual electronic
45 check), 'CBAK' Chargeback, or 'COMP' (free)
48 For Payments (cust_pay):
49 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
50 'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card),
51 'CASH' (cash), 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK'
52 (Manual electronic check), 'PPAL' (PayPal)
53 'COMP' (free) is depricated as a payment type in cust_pay
59 Payment information (payinfo) can be one of the following types:
61 Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username)
62 prepayment identifier (see L<FS::prepay_credit>), PayPal transaction ID
67 my($self,$payinfo) = @_;
69 if ( defined($payinfo) ) {
70 $self->setfield('payinfo', $payinfo);
71 $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #token
73 $self->getfield('payinfo');
79 Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
83 #this prevents encrypting empty values on insert?
85 my($self,$paycvv) = @_;
86 # This is only allowed in cust_payby (formerly cust_main)
87 # It shouldn't be stored longer than necessary to run the first transaction
88 if ( defined($paycvv) ) {
89 $self->setfield('paycvv', $paycvv);
91 $self->getfield('paycvv');
100 my($self, $paymask) = @_;
102 if ( defined($paymask) ) {
103 $self->setfield('paymask', $paymask);
105 $self->getfield('paymask') || $self->mask_payinfo;
115 =item mask_payinfo [ PAYBY, PAYINFO ]
117 This method converts the payment info (credit card, bank account, etc.) into a
120 Optionally, an arbitrary payby and payinfo can be passed.
126 my $payby = scalar(@_) ? shift : $self->payby;
127 my $payinfo = scalar(@_) ? shift : $self->payinfo;
129 # Check to see if it's encrypted...
130 if ( ref($self) && $self->is_encrypted($payinfo) ) {
132 } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token
133 return 'N/A (tokenized)'; #?
134 } else { # if not, mask it...
136 if ($payby eq 'CARD' || $payby eq 'DCRD') {
137 #|| $payby eq 'MCRD') {
138 #MCRD isn't a card in payinfo,
139 #its a record of an _offline_
144 # special handling for Local Isracards: always show last 4
145 if ( $payinfo =~ /^(\d{8,9})$/ ) {
147 return 'x'x(length($payinfo)-4).
148 substr($payinfo,(length($payinfo)-4));
152 my $conf = new FS::Conf;
153 my $mask_method = $conf->config('card_masking_method') || 'first6last4';
154 $mask_method =~ /^first(\d+)last(\d+)$/
155 or die "can't parse card_masking_method $mask_method";
156 my($first, $last) = ($1, $2);
158 return substr($payinfo,0,$first).
159 'x'x(length($payinfo)-$first-$last).
160 substr($payinfo,(length($payinfo)-$last));
162 } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
164 # Checks (Show last 2 @ bank)
165 my( $account, $aba ) = split('@', $payinfo );
166 return 'x'x(length($account)-2).
167 substr($account,(length($account)-2)).
168 ( length($aba) ? "@".$aba : '');
170 } elsif ($payby eq 'EDI') {
172 # These numbers have been seen anywhere from 8 to 30 digits, and
173 # possibly more. Lacking any better idea I'm going to mask all but
175 return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
177 } else { # Tie up loose ends
181 #die "shouldn't be reached";
186 Checks payby and payinfo.
193 FS::payby->can_payby($self->table, $self->payby)
194 or return "Illegal payby: ". $self->payby;
196 if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
198 my $payinfo = $self->payinfo;
199 my $cardtype = cardtype($payinfo);
200 $cardtype = 'Tokenized' if $payinfo =~ /^99\d{14}$/;
201 $self->set('paycardtype', $cardtype);
203 if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
207 $self->payinfo($payinfo);
208 if ( $self->payinfo ) {
209 $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
210 or return "Illegal (mistyped?) credit card number (payinfo)";
212 validate($self->payinfo) or return "Illegal credit card number";
213 return "Unknown card type" if $cardtype eq "Unknown";
215 $self->payinfo('N/A'); #???
219 if ( $self->payby eq 'CARD' and $self->paymask ) {
220 # if we can't decrypt the card, at least detect the cardtype
221 $self->set('paycardtype', cardtype($self->paymask));
223 $self->set('paycardtype', '');
225 if ( $self->is_encrypted($self->payinfo) ) {
226 #something better? all it would cause is a decryption error anyway?
227 my $error = $self->ut_anything('payinfo');
228 return $error if $error;
230 my $error = $self->ut_textn('payinfo');
231 return $error if $error;
237 =item payby_payinfo_pretty [ LOCALE ]
239 Returns payment method and information (suitably masked, if applicable) as
240 a human-readable string, such as:
242 Card #54xxxxxxxxxxxx32
250 sub payby_payinfo_pretty {
253 my $lh = FS::L10N->get_handle($locale);
254 if ( $self->payby eq 'CARD' ) {
255 if ($self->paymask =~ /tokenized/) {
256 $lh->maketext('Tokenized Card');
258 $lh->maketext('Card #') . $self->paymask;
260 } elsif ( $self->payby eq 'CHEK' ) {
262 #false laziness w/view/cust_main/payment_history.html::translate_payinfo
263 my( $account, $aba ) = split('@', $self->paymask );
265 if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
266 my($branch, $routing) = ($1, $2);
267 $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
268 $routing, $branch, $account);
270 $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
273 } elsif ( $self->payby eq 'BILL' ) {
274 $lh->maketext('Check #') . $self->payinfo;
275 } elsif ( $self->payby eq 'PREP' ) {
276 $lh->maketext('Prepaid card #') . $self->payinfo;
277 } elsif ( $self->payby eq 'CASH' ) {
278 $lh->maketext('Cash') . ' ' . $self->payinfo;
279 } elsif ( $self->payby eq 'WEST' ) {
280 # does Western Union localize their name?
281 $lh->maketext('Western Union');
282 } elsif ( $self->payby eq 'MCRD' ) {
283 $lh->maketext('Manual credit card');
284 } elsif ( $self->payby eq 'MCHK' ) {
285 $lh->maketext('Manual electronic check');
286 } elsif ( $self->payby eq 'EDI' ) {
287 $lh->maketext('EDI') . ' ' . $self->paymask;
288 } elsif ( $self->payby eq 'PPAL' ) {
289 $lh->maketext('PayPal transaction#') . $self->order_number;
291 $self->payby. ' '. $self->payinfo;
295 =item payinfo_used [ PAYINFO ]
297 Returns 1 if there's an existing payment using this payinfo. This can be
298 used to set the 'recurring payment' flag required by some processors.
304 my $payinfo = shift || $self->payinfo;
306 'custnum' => $self->custnum,
307 'payby' => $self->payby,
311 if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
312 || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
320 For transactions that have both 'status' and 'failure_status', shows the
321 status in a single, display-friendly string.
328 'done' => 'Approved',
329 'expired' => 'Card Expired',
330 'stolen' => 'Lost/Stolen',
331 'pickup' => 'Pick Up Card',
332 'nsf' => 'Insufficient Funds',
333 'inactive' => 'Inactive Account',
334 'blacklisted' => 'Blacklisted',
335 'declined' => 'Declined',
336 'approved' => 'Approved',
338 if ( $self->failure_status ) {
339 return $status{$self->failure_status};
341 return $status{$self->status};
345 =item paydate_monthyear
347 Returns a two-element list consisting of the month and year of this customer's
348 paydate (credit card expiration date for CARD customers)
352 sub paydate_monthyear {
354 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
356 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
365 Returns the exact time in seconds corresponding to the payment method
366 expiration date. For CARD/DCRD customers this is the end of the month;
367 for others (COMP is the only other payby that uses paydate) it's the start.
368 Returns 0 if the paydate is empty or set to the far future.
374 my ($month, $year) = $self->paydate_monthyear;
375 return 0 if !$year or $year >= 2037;
376 if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
378 if ( $month == 13 ) {
382 return timelocal(0,0,0,1,$month-1,$year) - 1;
385 return timelocal(0,0,0,1,$month-1,$year);
389 =item paydate_epoch_sql
391 Class method. Returns an SQL expression to obtain the payment expiration date
392 as a number of seconds.
396 # Special expiration date behavior for non-CARD/DCRD customers has been
397 # carefully preserved. Do we really use that?
398 sub paydate_epoch_sql {
400 my $table = $class->table;
402 if ( driver_name eq 'Pg' ) {
403 $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
404 $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
406 elsif ( lc(driver_name) eq 'mysql' ) {
407 $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
408 $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
411 return "CASE WHEN $table.payby IN('CARD','DCRD')
417 =item upgrade_set_cardtype
419 Find all records with a credit card payment type and no paycardtype, and
420 replace them in order to set their paycardtype.
422 This method actually just starts a queue job.
426 sub upgrade_set_cardtype {
428 my $table = $class->table or die "upgrade_set_cardtype needs a table";
430 if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
431 my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
432 my $error = $job->insert($table);
433 die $error if $error;
434 FS::upgrade_journal->set_done("${table}__set_cardtype");
438 sub process_set_cardtype {
441 # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
442 # will do this. ignore any problems with the cards.
443 local $ignore_masked_payinfo = 1;
444 my $search = FS::Cursor->new({
446 extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
448 while (my $record = $search->fetch) {
449 my $error = $record->replace;
450 die $error if $error;
460 L<FS::payby>, L<FS::Record>