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