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 # allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing
12 use vars qw( $ignore_masked_payinfo $allow_closed_replace );
16 FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.
20 package FS::some_table;
22 @ISA = qw( FS::payinfo_Mixin FS::Record );
26 This is a mixin class for records that contain payinfo.
34 The following payment types (payby) are supported:
36 For Customers (cust_main):
37 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
38 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
39 'LECB' (Phone bill billing), 'BILL' (billing), 'COMP' (free), or
40 'PREPAY' (special billing type: applies a credit and sets billing type to I<BILL> - see L<FS::prepay_credit>)
42 For Refunds (cust_refund):
43 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
44 'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash),
45 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' (Manual electronic
46 check), 'CBAK' Chargeback, or 'COMP' (free)
49 For Payments (cust_pay):
50 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
51 'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card),
52 'CASH' (cash), 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK'
53 (Manual electronic check), 'PPAL' (PayPal)
54 'COMP' (free) is depricated as a payment type in cust_pay
60 Payment information (payinfo) can be one of the following types:
62 Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username)
63 prepayment identifier (see L<FS::prepay_credit>), PayPal transaction ID
68 my($self,$payinfo) = @_;
70 if ( defined($payinfo) ) {
71 $self->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set
72 $self->setfield('payinfo', $payinfo);
73 $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing
75 $self->getfield('payinfo');
81 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
85 #this prevents encrypting empty values on insert?
87 my($self,$paycvv) = @_;
88 # This is only allowed in cust_payby (formerly cust_main)
89 # It shouldn't be stored longer than necessary to run the first transaction
90 if ( defined($paycvv) ) {
91 $self->setfield('paycvv', $paycvv);
93 $self->getfield('paycvv');
102 my($self, $paymask) = @_;
104 if ( defined($paymask) ) {
105 $self->setfield('paymask', $paymask);
107 $self->getfield('paymask') || $self->mask_payinfo;
117 =item mask_payinfo [ PAYBY, PAYINFO ]
119 This method converts the payment info (credit card, bank account, etc.) into a
122 Optionally, an arbitrary payby and payinfo can be passed.
128 my $payby = scalar(@_) ? shift : $self->payby;
129 my $payinfo = scalar(@_) ? shift : $self->payinfo;
131 # Check to see if it's encrypted...
132 if ( ref($self) && $self->is_encrypted($payinfo) ) {
134 } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
135 return 'N/A (tokenized)'; #?
136 } else { # if not, mask it...
138 if ($payby eq 'CARD' || $payby eq 'DCRD') {
139 #|| $payby eq 'MCRD') {
140 #MCRD isn't a card in payinfo,
141 #its a record of an _offline_
146 # special handling for Local Isracards: always show last 4
147 if ( $payinfo =~ /^(\d{8,9})$/ ) {
149 return 'x'x(length($payinfo)-4).
150 substr($payinfo,(length($payinfo)-4));
154 my $conf = new FS::Conf;
155 my $mask_method = $conf->config('card_masking_method') || 'first6last4';
156 $mask_method =~ /^first(\d+)last(\d+)$/
157 or die "can't parse card_masking_method $mask_method";
158 my($first, $last) = ($1, $2);
160 return substr($payinfo,0,$first).
161 'x'x(length($payinfo)-$first-$last).
162 substr($payinfo,(length($payinfo)-$last));
164 } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
166 # Checks (Show last 2 @ bank)
167 my( $account, $aba ) = split('@', $payinfo );
168 return 'x'x(length($account)-2).
169 substr($account,(length($account)-2)).
170 ( length($aba) ? "@".$aba : '');
172 } elsif ($payby eq 'EDI') {
174 # These numbers have been seen anywhere from 8 to 30 digits, and
175 # possibly more. Lacking any better idea I'm going to mask all but
177 return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
179 } else { # Tie up loose ends
183 #die "shouldn't be reached";
188 Checks payby and payinfo.
195 FS::payby->can_payby($self->table, $self->payby)
196 or return "Illegal payby: ". $self->payby;
198 if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
200 # see parallel checks in cust_payby::check & cust_payby::check_payinfo_cardtype
201 if ( $self->tokenized ) {
202 $self->set('is_tokenized', 'Y'); #so we don't try to do it again
203 if ( $self->paymask =~ /^\d+x/ ) {
204 $self->set('paycardtype', cardtype($self->paymask));
206 $self->set('paycardtype', '') unless $self->paycardtype;
207 #return "paycardtype required ".
208 # "(can't derive from a token and no paymask w/prefix provided)";
211 $self->set('paycardtype', cardtype($self->payinfo));
214 if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
217 my $payinfo = $self->payinfo;
219 $self->payinfo($payinfo);
220 if ( $self->payinfo ) {
221 $self->payinfo =~ /^(\d{13,19}|\d{8,9})$/
222 or return "Illegal (mistyped?) credit card number (payinfo)";
224 validate($self->payinfo) or return "Illegal credit card number";
225 return "Unknown card type" if $self->paycardtype eq "Unknown";
227 $self->payinfo('N/A'); #??? re-masks card
233 if ( $self->payby eq 'CARD' && $self->paymask =~ /^\d+x/ ) {
234 # if we can't decrypt the card, at least detect the cardtype
235 $self->set('paycardtype', cardtype($self->paymask));
237 $self->set('paycardtype', '') unless $self->paycardtype;
238 # return "paycardtype required ".
239 # "(can't derive from a token and no paymask w/prefix provided)";
242 if ( $self->is_encrypted($self->payinfo) ) {
243 #something better? all it would cause is a decryption error anyway?
244 my $error = $self->ut_anything('payinfo');
245 return $error if $error;
247 my $error = $self->ut_textn('payinfo');
248 return $error if $error;
255 =item payby_payinfo_pretty [ LOCALE ]
257 Returns payment method and information (suitably masked, if applicable) as
258 a human-readable string, such as:
260 Card #54xxxxxxxxxxxx32
268 sub payby_payinfo_pretty {
271 my $lh = FS::L10N->get_handle($locale);
272 if ( $self->payby eq 'CARD' ) {
273 if ($self->paymask =~ /tokenized/) {
274 $lh->maketext('Tokenized Card');
276 $lh->maketext('Card #') . $self->paymask;
278 } elsif ( $self->payby eq 'CHEK' ) {
280 #false laziness w/view/cust_main/payment_history.html::translate_payinfo
281 my( $account, $aba ) = split('@', $self->paymask );
283 if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
284 my($branch, $routing) = ($1, $2);
285 $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
286 $routing, $branch, $account);
288 $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
291 } elsif ( $self->payby eq 'BILL' ) {
292 $lh->maketext('Check #') . $self->payinfo;
293 } elsif ( $self->payby eq 'PREP' ) {
294 $lh->maketext('Prepaid card #') . $self->payinfo;
295 } elsif ( $self->payby eq 'CASH' ) {
296 $lh->maketext('Cash') . ' ' . $self->payinfo;
297 } elsif ( $self->payby eq 'WEST' ) {
298 # does Western Union localize their name?
299 $lh->maketext('Western Union');
300 } elsif ( $self->payby eq 'MCRD' ) {
301 $lh->maketext('Manual credit card');
302 } elsif ( $self->payby eq 'MCHK' ) {
303 $lh->maketext('Manual electronic check');
304 } elsif ( $self->payby eq 'EDI' ) {
305 $lh->maketext('EDI') . ' ' . $self->paymask;
306 } elsif ( $self->payby eq 'PPAL' ) {
307 $lh->maketext('PayPal transaction#') . $self->order_number;
309 $self->payby. ' '. $self->payinfo;
313 =item payinfo_used [ PAYINFO ]
315 Returns 1 if there's an existing payment using this payinfo. This can be
316 used to set the 'recurring payment' flag required by some processors.
322 my $payinfo = shift || $self->payinfo;
324 'custnum' => $self->custnum,
325 'payby' => $self->payby,
329 if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
330 || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
338 For transactions that have both 'status' and 'failure_status', shows the
339 status in a single, display-friendly string.
346 'done' => 'Approved',
347 'expired' => 'Card Expired',
348 'stolen' => 'Lost/Stolen',
349 'pickup' => 'Pick Up Card',
350 'nsf' => 'Insufficient Funds',
351 'inactive' => 'Inactive Account',
352 'blacklisted' => 'Blacklisted',
353 'declined' => 'Declined',
354 'approved' => 'Approved',
356 if ( $self->failure_status ) {
357 return $status{$self->failure_status};
359 return $status{$self->status};
363 =item paydate_monthyear
365 Returns a two-element list consisting of the month and year of this customer's
366 paydate (credit card expiration date for CARD customers)
370 sub paydate_monthyear {
372 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
374 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
383 Returns the exact time in seconds corresponding to the payment method
384 expiration date. For CARD/DCRD customers this is the end of the month;
385 for others (COMP is the only other payby that uses paydate) it's the start.
386 Returns 0 if the paydate is empty or set to the far future.
392 my ($month, $year) = $self->paydate_monthyear;
393 return 0 if !$year or $year >= 2037;
394 if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
396 if ( $month == 13 ) {
400 return timelocal(0,0,0,1,$month-1,$year) - 1;
403 return timelocal(0,0,0,1,$month-1,$year);
407 =item paydate_epoch_sql
409 Class method. Returns an SQL expression to obtain the payment expiration date
410 as a number of seconds.
414 # Special expiration date behavior for non-CARD/DCRD customers has been
415 # carefully preserved. Do we really use that?
416 sub paydate_epoch_sql {
418 my $table = $class->table;
420 if ( driver_name eq 'Pg' ) {
421 $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
422 $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
424 elsif ( lc(driver_name) eq 'mysql' ) {
425 $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
426 $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
429 return "CASE WHEN $table.payby IN('CARD','DCRD')
435 =item upgrade_set_cardtype
437 Find all records with a credit card payment type and no paycardtype, and
438 replace them in order to set their paycardtype.
440 This method actually just starts a queue job.
444 sub upgrade_set_cardtype {
446 my $table = $class->table or die "upgrade_set_cardtype needs a table";
448 if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
449 my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
450 my $error = $job->insert($table);
451 die $error if $error;
452 FS::upgrade_journal->set_done("${table}__set_cardtype");
456 sub process_set_cardtype {
459 # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
460 # will do this. ignore any problems with the cards.
461 local $ignore_masked_payinfo = 1;
462 my $search = FS::Cursor->new({
464 extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
466 while (my $record = $search->fetch) {
467 my $error = $record->replace;
468 die $error if $error;
472 =item tokenized [ PAYINFO ]
474 Returns true if object payinfo is tokenized
476 Optionally, an arbitrary payby and payinfo can be passed.
482 my $payinfo = scalar(@_) ? shift : $self->payinfo;
483 return 0 unless $payinfo; #avoid uninitialized value error
484 $payinfo =~ /^99\d{14}$/;
493 L<FS::payby>, L<FS::Record>