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);
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->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set
71 $self->setfield('payinfo', $payinfo);
72 $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing
74 $self->getfield('payinfo');
80 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
84 #this prevents encrypting empty values on insert?
86 my($self,$paycvv) = @_;
87 # This is only allowed in cust_payby (formerly cust_main)
88 # It shouldn't be stored longer than necessary to run the first transaction
89 if ( defined($paycvv) ) {
90 $self->setfield('paycvv', $paycvv);
92 $self->getfield('paycvv');
101 my($self, $paymask) = @_;
103 if ( defined($paymask) ) {
104 $self->setfield('paymask', $paymask);
106 $self->getfield('paymask') || $self->mask_payinfo;
116 =item mask_payinfo [ PAYBY, PAYINFO ]
118 This method converts the payment info (credit card, bank account, etc.) into a
121 Optionally, an arbitrary payby and payinfo can be passed.
127 my $payby = scalar(@_) ? shift : $self->payby;
128 my $payinfo = scalar(@_) ? shift : $self->payinfo;
130 # Check to see if it's encrypted...
131 if ( ref($self) && $self->is_encrypted($payinfo) ) {
133 } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
134 return 'N/A (tokenized)'; #?
135 } else { # if not, mask it...
137 if ($payby eq 'CARD' || $payby eq 'DCRD') {
138 #|| $payby eq 'MCRD') {
139 #MCRD isn't a card in payinfo,
140 #its a record of an _offline_
145 # special handling for Local Isracards: always show last 4
146 if ( $payinfo =~ /^(\d{8,9})$/ ) {
148 return 'x'x(length($payinfo)-4).
149 substr($payinfo,(length($payinfo)-4));
153 my $conf = new FS::Conf;
154 my $mask_method = $conf->config('card_masking_method') || 'first6last4';
155 $mask_method =~ /^first(\d+)last(\d+)$/
156 or die "can't parse card_masking_method $mask_method";
157 my($first, $last) = ($1, $2);
159 return substr($payinfo,0,$first).
160 'x'x(length($payinfo)-$first-$last).
161 substr($payinfo,(length($payinfo)-$last));
163 } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
165 # Checks (Show last 2 @ bank)
166 my( $account, $aba ) = split('@', $payinfo );
167 return 'x'x(length($account)-2).
168 substr($account,(length($account)-2)).
169 ( length($aba) ? "@".$aba : '');
171 } elsif ($payby eq 'EDI') {
173 # These numbers have been seen anywhere from 8 to 30 digits, and
174 # possibly more. Lacking any better idea I'm going to mask all but
176 return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
178 } else { # Tie up loose ends
182 #die "shouldn't be reached";
187 Checks payby and payinfo.
194 FS::payby->can_payby($self->table, $self->payby)
195 or return "Illegal payby: ". $self->payby;
197 my $conf = new FS::Conf;
199 if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
201 my $payinfo = $self->payinfo;
202 my $cardtype = cardtype($payinfo);
203 $cardtype = 'Tokenized' if $self->tokenized;
204 $self->set('paycardtype', $cardtype);
206 if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
210 $self->payinfo($payinfo);
211 if ( $self->payinfo ) {
212 $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
213 or return "Illegal (mistyped?) credit card number (payinfo)";
215 validate($self->payinfo) or return "Illegal credit card number";
216 return "Unknown card type" if $cardtype eq "Unknown";
217 return "Card number not tokenized"
218 if $conf->exists('no_saved_cardnumbers') && !$self->tokenized;
220 $self->payinfo('N/A'); #??? re-masks card
224 if ( $self->payby eq 'CARD' and $self->paymask ) {
225 # if we can't decrypt the card, at least detect the cardtype
226 $self->set('paycardtype', cardtype($self->paymask));
228 $self->set('paycardtype', '');
230 if ( $self->is_encrypted($self->payinfo) ) {
231 #something better? all it would cause is a decryption error anyway?
232 my $error = $self->ut_anything('payinfo');
233 return $error if $error;
235 my $error = $self->ut_textn('payinfo');
236 return $error if $error;
243 =item payby_payinfo_pretty [ LOCALE ]
245 Returns payment method and information (suitably masked, if applicable) as
246 a human-readable string, such as:
248 Card #54xxxxxxxxxxxx32
256 sub payby_payinfo_pretty {
259 my $lh = FS::L10N->get_handle($locale);
260 if ( $self->payby eq 'CARD' ) {
261 if ($self->paymask =~ /tokenized/) {
262 $lh->maketext('Tokenized Card');
264 $lh->maketext('Card #') . $self->paymask;
266 } elsif ( $self->payby eq 'CHEK' ) {
268 #false laziness w/view/cust_main/payment_history.html::translate_payinfo
269 my( $account, $aba ) = split('@', $self->paymask );
271 if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
272 my($branch, $routing) = ($1, $2);
273 $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
274 $routing, $branch, $account);
276 $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
279 } elsif ( $self->payby eq 'BILL' ) {
280 $lh->maketext('Check #') . $self->payinfo;
281 } elsif ( $self->payby eq 'PREP' ) {
282 $lh->maketext('Prepaid card #') . $self->payinfo;
283 } elsif ( $self->payby eq 'CASH' ) {
284 $lh->maketext('Cash') . ' ' . $self->payinfo;
285 } elsif ( $self->payby eq 'WEST' ) {
286 # does Western Union localize their name?
287 $lh->maketext('Western Union');
288 } elsif ( $self->payby eq 'MCRD' ) {
289 $lh->maketext('Manual credit card');
290 } elsif ( $self->payby eq 'MCHK' ) {
291 $lh->maketext('Manual electronic check');
292 } elsif ( $self->payby eq 'EDI' ) {
293 $lh->maketext('EDI') . ' ' . $self->paymask;
294 } elsif ( $self->payby eq 'PPAL' ) {
295 $lh->maketext('PayPal transaction#') . $self->order_number;
297 $self->payby. ' '. $self->payinfo;
301 =item payinfo_used [ PAYINFO ]
303 Returns 1 if there's an existing payment using this payinfo. This can be
304 used to set the 'recurring payment' flag required by some processors.
310 my $payinfo = shift || $self->payinfo;
312 'custnum' => $self->custnum,
317 if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
318 || qsearch('cust_pay',
319 { %hash, 'paymask' => $self->mask_payinfo('CARD', $payinfo) } )
327 For transactions that have both 'status' and 'failure_status', shows the
328 status in a single, display-friendly string.
335 'done' => 'Approved',
336 'expired' => 'Card Expired',
337 'stolen' => 'Lost/Stolen',
338 'pickup' => 'Pick Up Card',
339 'nsf' => 'Insufficient Funds',
340 'inactive' => 'Inactive Account',
341 'blacklisted' => 'Blacklisted',
342 'declined' => 'Declined',
343 'approved' => 'Approved',
345 if ( $self->failure_status ) {
346 return $status{$self->failure_status};
348 return $status{$self->status};
352 =item paydate_monthyear
354 Returns a two-element list consisting of the month and year of this customer's
355 paydate (credit card expiration date for CARD customers)
359 sub paydate_monthyear {
361 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
363 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
372 Returns the exact time in seconds corresponding to the payment method
373 expiration date. For CARD/DCRD customers this is the end of the month;
374 for others (COMP is the only other payby that uses paydate) it's the start.
375 Returns 0 if the paydate is empty or set to the far future.
381 my ($month, $year) = $self->paydate_monthyear;
382 return 0 if !$year or $year >= 2037;
383 if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
385 if ( $month == 13 ) {
389 return timelocal(0,0,0,1,$month-1,$year) - 1;
392 return timelocal(0,0,0,1,$month-1,$year);
396 =item paydate_epoch_sql
398 Class method. Returns an SQL expression to obtain the payment expiration date
399 as a number of seconds.
403 # Special expiration date behavior for non-CARD/DCRD customers has been
404 # carefully preserved. Do we really use that?
405 sub paydate_epoch_sql {
407 my $table = $class->table;
409 if ( driver_name eq 'Pg' ) {
410 $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
411 $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
413 elsif ( lc(driver_name) eq 'mysql' ) {
414 $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
415 $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
418 return "CASE WHEN $table.payby IN('CARD','DCRD')
424 =item upgrade_set_cardtype
426 Find all records with a credit card payment type and no paycardtype, and
427 replace them in order to set their paycardtype.
429 This method actually just starts a queue job.
433 sub upgrade_set_cardtype {
435 my $table = $class->table or die "upgrade_set_cardtype needs a table";
437 if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
438 my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
439 my $error = $job->insert($table);
440 die $error if $error;
441 FS::upgrade_journal->set_done("${table}__set_cardtype");
445 sub process_set_cardtype {
448 # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
449 # will do this. ignore any problems with the cards.
450 local $ignore_masked_payinfo = 1;
451 my $search = FS::Cursor->new({
453 extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
455 while (my $record = $search->fetch) {
456 my $error = $record->replace;
457 die $error if $error;
461 =item tokenized [ PAYINFO ]
463 Returns true if object payinfo is tokenized
465 Optionally, an arbitrary payby and payinfo can be passed.
471 my $payinfo = scalar(@_) ? shift : $self->payinfo;
472 $payinfo =~ /^99\d{14}$/;
481 L<FS::payby>, L<FS::Record>