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