In content, owner is optional if name is specified
[Business-OnlinePayment-Bambora.git] / lib / Business / OnlinePayment / Bambora.pm
1 package Business::OnlinePayment::Bambora;
2 use strict;
3 use warnings;
4 use base qw/ Business::OnlinePayment::HTTPS /;
5 use feature 'unicode_strings';
6
7 use Carp qw( croak );
8 use Cpanel::JSON::XS;
9 use Data::Dumper;
10     $Data::Dumper::Sortkeys = 1;
11     $Data::Dumper::Indent   = 1;
12 use LWP::UserAgent;
13 use MIME::Base64;
14 use Time::HiRes;
15 use Unicode::Truncate qw( truncate_egc );
16 use URI::Escape;
17
18 use vars qw/ $VERSION $DEBUG /;
19 $VERSION = '0.1';
20 $DEBUG   = 1;
21
22 if ( $DEBUG ) {
23     $Data::Dumper::Sortkeys = 1;
24 }
25
26 =head1 INTERNAL METHODS
27
28 =head2 set_defaults
29
30 See L<Business::OnlinePayment/set_defaults>
31
32 =cut
33
34 sub set_defaults {
35   my $self = shift;
36
37   $self->server('api.na.bambora.com');
38   $self->port('443');
39
40   # Create accessors for
41   $self->build_subs(qw/
42     card_token
43     expiry_month
44     expiry_year
45     failure_status
46     invoice_number
47     message_id
48     payment_method
49     phone_number
50     province
51     recurring_payment
52     response_decoded
53     txn_date
54   /);
55 }
56
57 =head2 submit
58
59 Dispatch to the appropriate handler based on the given action
60
61 =cut
62
63 my %action_dispatch_table = (
64   'normal authorization'           => 'submit_normal_authorization',
65   'authorization only'             => 'submit_authorization_only',
66   'post authorization'             => 'submit_post_authorization',
67   'reverse authorization'          => 'submit_reverse_authorization',
68   'void'                           => 'submit_void',
69   'credit'                         => 'submit_credit',
70   'tokenize'                       => 'submit_tokenize',
71   'recurring authorization'        => 'submit_recurring_authorization',
72   'modify recurring authorization' => 'modify_recurring_authorization',
73 );
74
75 sub submit {
76   my $self = shift;
77
78   my $action = lc $self->{_content}->{action}
79     or croak 'submit() called with no action set';
80
81   my $method = $action_dispatch_table{$action};
82
83   unless ( $method && $self->can($method) ) {
84     warn $self->error_message( "Action is unsupported ($action)" );
85     return $self->is_success(0);
86   }
87
88   $self->$method(@_);
89 }
90
91 =head2 submit_normal_authorization
92
93 Complete a payment transaction by with an API POST to B</payments>
94
95 See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
96
97 =cut
98
99 sub submit_normal_authorization {
100   my $self = shift;
101   my $content = $self->{_content};
102
103   # Use epoch time as invoice_number, if none is specified
104   $content->{invoice_number} ||= time();
105
106   # Clarifying Bambora API and Business::OnlinePayment naming conflict
107   #
108   # Bambora API:
109   # - order_number: user supplied identifier for the order, displayed on reports
110   # - transaction_id: bambora supplied identifier for the order.
111   #     this number must be referenced for future actions like voids,
112   #     auth captures, etc
113   #
114   # Business::OnlinePayment
115   # - invoice_number: contains the bambora order number
116   # - order_number: contains the bambora transaction id
117
118   my %post = (
119     order_number => $self->truncate( $content->{invoice_number}, 30 ),
120     amount       => $content->{amount},
121   );
122
123   if (
124     $content->{card_token}
125     || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
126   ) {
127     # Process payment against a stored Payment Profile, whose
128     # customer_code is used as the card_token
129
130     my $card_token = $content->{card_token} || $content->{card_number};
131
132     unless ( $card_token =~ /^99\d{14}$/ ) {
133       $self->error_message(
134         "Invalid card_token($card_token): Expected 16-digit "
135         . " beginning with 99"
136       );
137       return $self->is_success(0);
138     }
139
140     $post{payment_method} = 'payment_profile';
141
142     $post{payment_profile} = {
143       customer_code => $card_token,
144       card_id => 1,
145     };
146
147   } elsif ( $content->{card_number} ) {
148
149     $post{payment_method} = 'card';
150
151     # Add card payment details to %post
152     $post{card} = $self->jhref_card;
153     return if $self->error_message;
154
155     # Add billing address to card
156     $post{billing} = $self->jhref_billing_address;
157
158     # Designate recurring payment label
159     $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
160
161     # Direct API to issue a complete auth, instead of pre-auth
162     $post{card}->{complete} = 1;
163
164   } else {
165     croak 'unknown/unsupported payment method!';
166   }
167
168   my $action = lc $content->{action};
169
170   if ( $action eq 'normal authorization' ) {
171     # Perform complete authorization
172     $self->path('/v1/payments');
173
174   } elsif ( $action eq 'authorization only' ) {
175     # Perform pre-authorization
176     $self->path('/v1/payments');
177
178     # Set the 'complete' flag to false, directing API to perform pre-auth
179     if ( ref $post{payment_profile} ) {
180       $post{payment_profile}->{complete} = 0;
181     } elsif ( ref $post{card} ) {
182       $post{card}->{complete} = 0;
183     }
184
185   } elsif ( $action eq 'post authorization' ) {
186     # Complete a pre-authorization
187
188     croak 'post authorization cannot be completed - '.
189           'bambora transaction_id must be set as content order_number '.
190           'before using submit()'
191               unless $content->{order_number};
192
193     $self->path(
194       sprintf '/v1/payments/%s/completions',
195         $content->{order_number}
196     );
197
198     if ( ref $post{card} ) {
199       $post{card}->{complete} = 1
200     }
201   } else {
202     die "unsupported action $action";
203   }
204
205   # Parse %post into a JSON string, to be attached to the request POST body
206   my $post_body = encode_json( \%post );
207     
208   if ( $DEBUG ) {
209     warn Dumper({
210       path      => $self->path,
211       post_body => $post_body,
212       post_href => \%post,
213     });
214   }
215
216   my $response = $self->submit_api_request( $post_body );
217
218   # Any error messages will have been populated by submit_api_request
219   return unless $self->is_success;
220
221   # Populate transaction result values
222   $self->message_id( $response->{message_id} );
223   $self->authorization( $response->{auth_code} );
224   $self->order_number( $response->{id} );
225   $self->txn_date( $response->{created} );
226   $self->avs_code( $response->{card}{avs_result} );
227   $self->is_success( 1 );
228
229   $response;
230 }
231
232 =head2 submit_authorization_only
233
234 Capture a card authorization, but do not complete transaction
235
236 =cut
237
238 sub submit_authorization_only {
239   my $self = shift;
240
241   $self->submit_normal_authorization;
242
243   my $response = $self->response_decoded;
244
245   if (
246     $self->is_success
247     && (
248       ref $response
249       && $response->{type} ne 'PA'
250     )
251   ) {
252     # Bambora API uses nearly identical API calls for normal
253     # card transactions and pre-authorization. Sanity check
254     # that response reported a pre-authorization code
255     die "Expected API Respose type=PA, but type=$response->{type}! ".
256         "Pre-Authorization attempt may have charged card!";
257   }
258 }
259
260 =head2 submit_post_authorization
261
262 Complete a card pre-authorization
263
264 =cut
265
266 sub submit_post_authorization {
267   shift->submit_normal_authorization;
268 }
269
270 =head2 submit_reverse_authorization
271
272 Reverse a pre-authorization
273
274 =cut
275
276 sub submit_reverse_authorization {
277   shift->submit_void;
278 }
279
280 =head2 submit_void
281
282 Process a return against a transaction for the given amount
283
284 =cut
285
286 sub submit_void {
287   my $self = shift;
288   my $content = $self->{_content};
289
290   for my $f (qw/ order_number amount/) {
291     unless ( $content->{$f} ) {
292       $self->error_message("Cannot process void - missing required content $f");
293       warn $self->error_message if $DEBUG;
294
295       return $self->is_success(0);
296     }
297   }
298
299   # The posted JSON string needs only contain the amount.
300   # The bambora order_number being voided is passed in the URL
301   my %post = (
302     amount => $content->{amount},
303   );
304   my $post_body = encode_json( \%post );
305
306   $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
307   if ( $DEBUG ) {
308     warn Dumper({
309       path => $self->path,
310       post => \%post,
311       post_body => $post_body,
312     });
313   }
314
315   my $response = $self->submit_api_request( $post_body );
316   return if $self->error_message;
317
318   $self->is_success(1);
319
320   $response;
321 }
322
323 =head2 submit_tokenize
324
325 Bambora tokenization is based on the Payment Profile feature of their API.
326
327 The token created by this method represents the Bambora customer_code for the
328 Payment Profile.  The token resembles a credit card number.  It is 16 digits
329 long, beginning with 99.  No valid card number can begin with the digits 99.
330
331 This method creates the payment profile and reports the customer_code
332 as the card_token
333
334 =cut
335
336 sub submit_tokenize {
337   my $self = shift;
338   my $content = $self->{_content};
339
340   # Check if given card number is already a bambora customer_code
341   # under this module's token rules
342   croak "card_number is already tokenized"
343     if $content->{card_number} =~ /^99\d{14}$/;
344
345   my %post = (
346     customer_code => $self->generate_token,
347     card          => $self->jhref_card,
348     billing       => $self->jhref_billing_address,
349     validate      => 0,
350   );
351
352   # jhref_card may have generated an exception
353   return if $self->error_message;
354
355   $self->path('/v1/profiles');
356
357   my $post_body = encode_json( \%post );
358
359   if ( $DEBUG ) {
360     warn Dumper({
361       path      => $self->path,
362       post_body => $post_body,
363       post_href => \%post,
364     });
365   }
366
367   my $response = $self->submit_api_request( $post_body );
368   if ( $DEBUG ) {
369     warn Dumper({
370       response => $response,
371       is_success => $self->is_success,
372       error_message => $self->error_message,
373     });
374   }
375   return unless $self->is_success;
376
377   my $customer_code = $response->{customer_code};
378   if ( !$customer_code ) {
379     # Should not happen...
380     # API reported success codes, but
381     # customer_code value is missing
382     $self->error_message(
383       "Fatal error: API reported success, but did not return customer_code"
384     );
385     return $self->is_success(0);
386   }
387
388   if ( $customer_code ne $post{customer_code} ) {
389     # Should not happen...
390     # API reported success codes, but
391     # customer_code attached to created profiles does not match
392     # the token value we attempted to assign to the customer profile
393     $self->error_message(
394       "Fatal error: API failed to set payment profile customer_code value"
395     );
396     return $self->is_success(0);
397   }
398
399   $self->card_token( $customer_code );
400
401   return $response;
402 }
403
404
405
406 =head2 submit_api_request json_string [ POST | PUT ]
407
408 Make the appropriate API request with the given JSON string
409
410 =cut
411
412 sub submit_api_request {
413   my $self = shift;
414
415   my $post_body = shift
416     or die 'submit_api_request() requires a json_string parameter';
417
418   # Default to using https_post, unless PUT has been specified
419   my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
420
421   my ($response_body, $response_code, %response_headers) = $self->$http_method(
422     {
423       headers => { $self->authorization_header },
424       'Content-Type' => 'application/json',
425     },
426     $post_body,
427   );
428   $self->server_response( $response_body );
429
430   my $response;
431   {
432     local $@;
433     eval{ $response = decode_json( $response_body ) };
434
435     if ( $DEBUG ) {
436       warn Dumper({
437         response_body    => $response_body,
438         response         => $response,
439         response_code    => $response_code,
440         # response_headers => \%response_headers,
441       });
442     }
443
444     # API should always return a JSON response
445     if ( $@ || !$response ) {
446       $self->error_message( $response_body || 'connection error' );
447       $self->is_success( 0 );
448       return;
449     }
450   }
451   $self->response_decoded( $response );
452
453   if ( $response->{code} && $response->{code} != 1 ) {
454     # Response returned an error
455
456     $self->is_success( 0 );
457     $self->result_code( $response->{code} );
458
459     if ( $response->{message} =~ /decline/i ) {
460       $self->failure_status('declined');
461     }
462
463     return $self->error_message(
464       sprintf '%s %s',
465         $response->{code},
466         $response->{message}
467     );
468   }
469
470   # Success
471   # Return the decoded json of the response back to handler
472   $self->is_success( 1 );
473   return $response;
474 }
475
476 =head2 authorization_header
477
478 Bambora REST requests authenticate via a HTTP header of the format:
479 Authorization: Passcode Base64Encoded(merchant_id:passcode)
480
481 Returns a hash representing the authorization header derived from
482 the merchant id (login) and API passcode (password)
483
484 =cut
485
486 sub authorization_header {
487   my $self = shift;
488   my $content = $self->{_content};
489
490   my %authorization_header = (
491     Authorization => 'Passcode ' . MIME::Base64::encode_base64(
492       join( ':', $content->{login}, $content->{password} )
493     )
494   );
495
496   if ( $DEBUG ) {
497     warn Dumper({ authorization_header => \%authorization_header })."\n";
498   }
499
500   %authorization_header;
501 }
502
503 =head2 jhref_billing_address
504
505 Return a hashref for inclusion into a json object
506 representing the RequestBillingAddress for the API
507
508 =cut
509
510 sub jhref_billing_address {
511   my $self = shift;
512
513   $self->parse_province;
514   $self->set_country;
515   $self->parse_phone_number;
516
517   my $content = $self->{_content};
518
519   return +{
520     name          => $self->truncate( $content->{name}, 64 ),
521     address_line1 => $self->truncate( $content->{address}, 64 ),
522     city          => $self->truncate( $content->{city}, 64 ),
523     province      => $self->truncate( $content->{province}, 2 ),
524     country       => $self->truncate( $content->{country}, 2 ),
525     postal_code   => $self->truncate( $content->{zip}, 16 ),
526     phone_number  => $self->truncate( $content->{phone_number}, 20 ),
527     email_address => $self->truncate( $content->{email}, 64 ),
528   };
529 }
530
531 =head2 jhref_card
532
533 Return a hashref for inclusin into a json object
534 representing Card for the API
535
536 If necessary values are missing from %content, will set
537 error_message and is_success
538
539 =cut
540
541 sub jhref_card {
542   my $self = shift;
543   my $content = $self->{_content};
544
545   $self->set_expiration;
546
547   $content->{owner} ||= $content->{name};
548
549   # Check required input
550   for my $f (qw/
551     card_number
552     owner
553     expiry_month
554     expiry_year
555     cvv2
556   /) {
557     next if $content->{$f};
558
559     $self->error_message(
560       "Cannot parse card payment - missing required content $f"
561     );
562
563     if ( $DEBUG ) {
564       warn Dumper({
565         error_message => $self->error_message,
566         content => $content,
567       });
568     }
569
570     $self->is_success( 0 );
571     return {};
572   }
573
574   return +{
575     number       => $self->truncate( $content->{card_number}, 20 ),
576     name         => $self->truncate( $content->{owner}, 64 ),
577     expiry_month => sprintf( '%02d', $content->{expiry_month} ),
578     expiry_year  => sprintf( '%02d', $content->{expiry_year} ),
579     cvd          => $content->{cvv2},
580   }
581 }
582
583 =head2 generate_token
584
585 Generate a 16-digit numeric token, beginning with the digits 99,
586 based on the current epoch time
587
588 Implementation note:
589
590 If this module is somehow used to tokenize multiple cardholders within
591 the same microsecond, these cardholders will be assigned the same
592 customer_code.  In the unlikely event this does happen, the Bambora system
593 will decline to process cards for either of the profiles with a duplicate
594 customer_code.
595
596 =cut
597
598 sub generate_token {
599   my $self = shift;
600   my $time = Time::HiRes::time();
601
602   $time =~ s/\D//g;
603   $time = substr($time, 0, 14 ); # Eventually time() will contain 15 digits
604
605   "99$time";
606 }
607
608 =head2 set_country
609
610 Country is expected to be set as an ISO-3166-1 2-letter country code
611
612 Sets string to upper case.
613
614 Dies unless country is a two-letter string.
615
616 Could be extended to convert country names to their respective
617 country codes, or validate country codes
618
619 See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
620
621 =cut
622
623 sub set_country {
624   my $self = shift;
625   my $content = $self->{_content};
626   my $country = uc $content->{country};
627
628   if ( $country !~ /^[A-Z]{2}$/ ) {
629     croak sprintf 'country is not a 2 character string (%s)',
630       $country || 'undef';
631   };
632
633   $content->{country} = $country;
634 }
635
636 =head2 set_expiration_month_year
637
638 Split B::OP expiration field, which may be in the format
639 MM/YY or MMYY, into separate expiry_month and expiry_year fields
640
641 Will die if values are not numeric
642
643 =cut
644
645 sub set_expiration {
646   my $self = shift;
647   my $content = $self->{_content};
648   my $expiration = $content->{expiration};
649
650   unless ( $expiration ) {
651     $content->{expiry_month} = undef;
652     $content->{expiry_year}  = undef;
653     return;
654   }
655
656   my ( $mm, $yy ) = (
657     $expiration =~ /\//
658     ? split( /\//, $expiration )
659     : unpack( 'A2 A2', $expiration )
660   );
661
662   croak 'card expiration must be in format MM/YY'
663     if $mm =~ /\D/ || $yy =~ /\D/;
664
665   return (
666     $content->{expiry_month} = sprintf( '%02d', $mm ),
667     $content->{expiry_year}  = sprintf ('%02d', $yy ),
668   );
669 }
670
671 =head2 parse_phone_number
672
673 Set value for field phone_number, from value in field phone
674
675 Bambora API expects only digits in a phone number. Strips all non-digit
676 characters
677
678 =cut
679
680 sub parse_phone_number {
681   my $self = shift;
682   my $content = $self->{_content};
683
684   my $phone = $content->{phone}
685     or return $content->{phone_number} = undef;
686
687   $phone =~ s/\D//g;
688   $content->{phone_number} = $phone;
689 }
690
691 =head2 parse_province
692
693 Set value for field province, from value in field state
694
695 Outside the US/Canada, API expect province set to the string "--",
696 otherwise expects a 2 character string.  Value for province is
697 formatted to upper case, and truncated to 2 characters.
698
699 =cut
700
701 sub parse_province {
702   my $self = shift;
703   my $content = $self->{_content};
704   my $country = uc $content->{country};
705
706   return $content->{province} = '--'
707     unless $country
708        && ( $country eq 'US' || $country eq 'CA' );
709
710   $content->{province} = uc $content->{state};
711 }
712
713 =head2 truncate string, bytes
714
715 When given a string, truncate to given string length in a unicode safe way
716
717 =cut
718
719 sub truncate {
720   my ( $self, $string, $bytes ) = @_;
721
722   # truncate_egc dies when asked to truncate undef
723   return $string unless $string;
724
725   truncate_egc( "$string", $bytes, '' );
726 }
727
728 =head2 https_put { headers => \%headers }, post_body
729
730 Implement a limited interface of https_get from Net::HTTPS::Any
731 for PUT instead of POST -- only implementing current use case of
732 submitting a JSON request body
733
734 Todo: Properly implement https_put in Net::HTTPS::Any
735
736 =cut
737
738 sub https_put {
739   my ( $self, $args, $post_body ) = @_;
740
741   my $ua = LWP::UserAgent->new;
742
743   my %headers = %{ $args->{headers} } if ref $args->{headers};
744   for my $k ( keys %headers ) {
745     $ua->default_header( $k => $headers{$k} );
746   }
747
748   my $url = $self->server().$self->path();
749   my $res = $ua->put( $url, Content => $post_body );
750
751   $self->build_subs(qw/ response_page response_code response_headers/);
752
753   my @response_headers =
754     map { $_ => $res->header( $_ ) }
755     $res->header_field_names;
756
757   $self->response_headers( {@response_headers} );
758   $self->response_code( $res->code );
759   $self->response_page( $res->decoded_content );
760
761   ( $self->response_page, $self->response_code, @response_headers );
762 }
763
764 1;