1 package Business::OnlinePayment::Bambora;
4 use base qw/ Business::OnlinePayment::HTTPS /;
5 use feature 'unicode_strings';
10 $Data::Dumper::Sortkeys = 1;
11 $Data::Dumper::Indent = 1;
15 use Unicode::Truncate qw( truncate_egc );
18 use vars qw/ $VERSION $DEBUG /;
23 $Data::Dumper::Sortkeys = 1;
26 =head1 INTERNAL METHODS
30 See L<Business::OnlinePayment/set_defaults>
37 $self->server('api.na.bambora.com');
40 # Create accessors for
58 Dispatch to the appropriate handler based on the given action
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',
77 my $action = lc $self->{_content}->{action}
78 or croak 'submit() called with no action set';
80 my $method = $action_dispatch_table{$action};
82 $self->submit_action_unsupported()
84 && $self->can($method);
89 =head2 submit_normal_authorization
91 Complete a payment transaction by with an API POST to B</payments>
93 See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
97 sub submit_normal_authorization {
99 my $content = $self->{_content};
101 # Use epoch time as invoice_number, if none is specified
102 $content->{invoice_number} ||= time();
104 # Clarifying Bambora API and Business::OnlinePayment naming conflict
107 # - order_number: user supplied identifier for the order, displayed on reports
108 # - transaction_id: bambora supplied identifier for the order.
109 # this number must be referenced for future actions like voids,
112 # Business::OnlinePayment
113 # - invoice_number: contains the bambora order number
114 # - order_number: contains the bambora transaction id
117 order_number => $self->truncate( $content->{invoice_number}, 30 ),
118 amount => $content->{amount},
119 billing => $self->jhref_billing_address,
123 if ( $content->{card_number} ) {
124 $post{payment_method} = 'card';
126 # Add card payment details to %post
127 $post{card} = $self->jhref_card;
128 return if $self->error_message;
130 # Designate recurring payment label
131 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
133 # Direct API to issue a complete auth, instead of pre-auth
134 $post{card}->{complete} = 1;
137 # number => $self->truncate( $content->{card_number}, 20 ),
138 # name => $self->truncate( $content->{owner}, 64 ),
139 # expiry_month => sprintf( '%02d', $content->{expiry_month} ),
140 # expiry_year => sprintf( '%02d', $content->{expiry_year} ),
141 # cvd => $content->{cvv2},
142 # recurring_payment => $content->{recurring_payment} ? 1 : 0,
147 croak 'unknown/unsupported payment method!';
150 my $action = lc $content->{action};
152 if ( $action eq 'normal authorization' ) {
153 # Perform complete authorization
154 $self->path('/v1/payments');
156 } elsif ( $action eq 'authorization only' ) {
157 # Perform pre-authorization
158 $self->path('/v1/payments');
159 $post{card}->{complete} = 0;
161 } elsif ( $action eq 'post authorization' ) {
162 # Complete a pre-authorization
164 croak 'post authorization cannot be completed - '.
165 'bambora transaction_id must be set as content order_number '.
166 'before using submit()'
167 unless $content->{order_number};
170 sprintf '/v1/payments/%s/completions',
171 $content->{order_number}
174 if ( ref $post{card} ) {
175 $post{card}->{complete} = 1
178 die "unsupported action $action";
181 # Parse %post into a JSON string, to be attached to the request POST body
182 my $post_body = encode_json( \%post );
187 post_body => $post_body,
192 my $response = $self->submit_api_request( $post_body );
194 # Any error messages will have been populated by submit_api_request
195 return unless $self->is_success;
197 # Populate transaction result values
198 $self->message_id( $response->{message_id} );
199 $self->authorization( $response->{auth_code} );
200 $self->order_number( $response->{id} );
201 $self->txn_date( $response->{created} );
202 $self->avs_code( $response->{card}{avs_result} );
203 $self->is_success( 1 );
208 =head2 submit_authorization_only
210 Capture a card authorization, but do not complete transaction
214 sub submit_authorization_only {
217 $self->submit_normal_authorization;
219 my $response = $self->response_decoded;
225 && $response->{type} ne 'PA'
228 # Bambora API uses nearly identical API calls for normal
229 # card transactions and pre-authorization. Sanity check
230 # that response reported a pre-authorization code
231 die "Expected API Respose type=PA, but type=$response->{type}! ".
232 "Pre-Authorization attempt may have charged card!";
236 =head2 submit_post_authorization
238 Complete a card pre-authorization
242 sub submit_post_authorization {
243 shift->submit_normal_authorization;
246 =head2 submit_reverse_authorization
248 Reverse a pre-authorization
252 sub submit_reverse_authorization {
258 Process a return against a transaction for the given amount
264 my $content = $self->{_content};
266 for my $f (qw/ order_number amount/) {
267 unless ( $content->{$f} ) {
268 $self->error_message("Cannot process void - missing required content $f");
269 warn $self->error_message if $DEBUG;
271 return $self->is_success(0);
275 # The posted JSON string needs only contain the amount.
276 # The bambora order_number being voided is passed in the URL
278 amount => $content->{amount},
280 my $post_body = encode_json( \%post );
285 post_body => $post_body,
288 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
290 my $response = $self->submit_api_request( $post_body );
293 =head2 submit_tokenize
295 Bambora tokenization is based on the Payment Profile feature of their API.
297 The token created by this method represnets the Bambora customer_code for the
298 Payment Profile. The token resembles a credit card number. It is 16 digits
299 long, beginning with 99. No valid card number can begin with the digits 99.
301 This method creates the payment profile, then replaces the customer_code
302 generated by Bambora with the card number resembling token.
306 sub submit_tokenize {
308 my $content = $self->{_content};
310 # Check if given card number is already a bambora customer_code
311 # under this module's token rules
312 croak "card_number is already tokenized"
313 if $content->{card_number} =~ /^99\d{14}$/;
316 customer_code => $self->generate_token,
317 card => $self->jhref_card,
318 billing => $self->jhref_billing_address,
322 # jhref_card may have generated an exception
323 return if $self->error_message;
325 $self->path('/v1/profiles');
327 my $post_body = encode_json( \%post );
332 post_body => $post_body,
337 my $response = $self->submit_api_request( $post_body );
340 response => $response,
341 is_success => $self->is_success,
342 error_message => $self->error_message,
345 return unless $self->is_success;
347 my $customer_code = $response->{customer_code};
348 if ( !$customer_code ) {
349 # Should not happen...
350 # API reported success codes, but
351 # customer_code value is missing
352 $self->error_message(
353 "Fatal error: API reported success, but did not return customer_code"
355 return $self->is_success(0);
358 if ( $customer_code ne $post{customer_code} ) {
359 # Should not happen...
360 # API reported success codes, but
361 # customer_code attached to created profiles does not match
362 # the token value we attempted to assign to the customer profile
363 $self->error_message(
364 "Fatal error: API failed to set payment profile customer_code value"
366 return $self->is_success(0);
369 $self->card_token( $customer_code );
376 =head2 submit_api_request json_string [ POST | PUT ]
378 Make the appropriate API request with the given JSON string
382 sub submit_api_request {
385 my $post_body = shift
386 or die 'submit_api_request() requires a json_string parameter';
388 # Default to using https_post, unless PUT has been specified
389 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
391 my ($response_body, $response_code, %response_headers) = $self->$http_method(
393 headers => { $self->authorization_header },
394 'Content-Type' => 'application/json',
398 $self->server_response( $response_body );
403 eval{ $response = decode_json( $response_body ) };
407 response_body => $response_body,
408 response => $response,
409 response_code => $response_code,
410 # response_headers => \%response_headers,
414 # API should always return a JSON response
415 if ( $@ || !$response ) {
416 $self->error_message( $response_body || 'connection error' );
417 $self->is_success( 0 );
421 $self->response_decoded( $response );
423 # Response returned an error
424 if ( $response->{code} && $response->{code} != 1 ) {
425 $self->is_success( 0 );
426 $self->result_code( $response->{code} );
428 return $self->error_message(
436 # Return the decoded json of the response back to handler
437 $self->is_success( 1 );
441 =head2 submit_action_unsupported
443 Croak with the error message Action $action unsupported
447 sub submit_action_unsupported {
448 croak sprintf 'Action %s unsupported', shift->{_content}{action}
451 =head2 authorization_header
453 Bambora REST requests authenticate via a HTTP header of the format:
454 Authorization: Passcode Base64Encoded(merchant_id:passcode)
456 Returns a hash representing the authorization header derived from
457 the merchant id (login) and API passcode (password)
461 sub authorization_header {
463 my $content = $self->{_content};
465 my %authorization_header = (
466 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
467 join( ':', $content->{login}, $content->{password} )
472 warn Dumper({ authorization_header => \%authorization_header })."\n";
475 %authorization_header;
478 =head2 jhref_billing_address
480 Return a hashref for inclusion into a json object
481 representing the RequestBillingAddress for the API
485 sub jhref_billing_address {
490 $self->set_phone_number;
492 my $content = $self->{_content};
495 name => $self->truncate( $content->{name}, 64 ),
496 address_line1 => $self->truncate( $content->{address}, 64 ),
497 city => $self->truncate( $content->{city}, 64 ),
498 province => $self->truncate( $content->{province}, 2 ),
499 country => $self->truncate( $content->{country}, 2 ),
500 postal_code => $self->truncate( $content->{zip}, 16 ),
501 phone_number => $self->truncate( $content->{phone_number}, 20 ),
502 email_address => $self->truncate( $content->{email}, 64 ),
508 Return a hashref for inclusin into a json object
509 representing Card for the API
511 If necessary values are missing from %content, will set
512 error_message and is_success
518 my $content = $self->{_content};
520 $self->set_expiration;
522 # Check required input
530 next if $content->{$f};
532 $self->error_message(
533 "Cannot parse card payment - missing required content $f"
536 warn $self->error_message if $DEBUG;
537 $self->is_success( 0 );
543 number => $self->truncate( $content->{card_number}, 20 ),
544 name => $self->truncate( $content->{owner}, 64 ),
545 expiry_month => sprintf( '%02d', $content->{expiry_month} ),
546 expiry_year => sprintf( '%02d', $content->{expiry_year} ),
547 cvd => $content->{cvv2},
551 =head2 generate_token
553 Generate a 16-digit numeric token, beginning with the digits 99,
554 based on the current epoch time
558 If this module is somehow used to tokenize multiple cardholders within
559 the same microsecond, these cardholders will be assigned the same
560 customer_code. In the unlikely event this does happen, the Bambora system
561 will decline to process cards for either of the profiles with a duplicate
568 my $time = Time::HiRes::time();
571 $time = substr($time, 0, 14 ); # Eventually time() will contain 15 digits
578 Country is expected to be set as an ISO-3166-1 2-letter country code
580 Sets string to upper case.
582 Dies unless country is a two-letter string.
584 Could be extended to convert country names to their respective
585 country codes, or validate country codes
587 See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
593 my $content = $self->{_content};
594 my $country = uc $content->{country};
596 if ( $country !~ /^[A-Z]{2}$/ ) {
597 croak sprintf 'country is not a 2 character string (%s)',
601 $content->{country} = $country;
604 =head2 set_expiration_month_year
606 Split B::OP expiration field, which may be in the format
607 MM/YY or MMYY, into separate expiry_month and expiry_year fields
609 Will die if values are not numeric
615 my $content = $self->{_content};
616 my $expiration = $content->{expiration};
618 unless ( $expiration ) {
619 $content->{expiry_month} = undef;
620 $content->{expiry_year} = undef;
626 ? split( /\//, $expiration )
627 : unpack( 'A2 A2', $expiration )
630 croak 'card expiration must be in format MM/YY'
631 if $mm =~ /\D/ || $yy =~ /\D/;
634 $content->{expiry_month} = sprintf( '%02d', $mm ),
635 $content->{expiry_year} = sprintf ('%02d', $yy ),
639 =head2 set_payment_method
641 Set payment_method value to one of the following strings
654 sub set_payment_method {
655 # todo - determine correct payment method
656 warn "set_payment_method() STUB FUNCTION ALWAYS RETURNS card!\n";
657 shift->{_content}->{payment_method} = 'card';
660 =head2 set_phone_number
662 Set value for field phone_number, from value in field phone
664 Bambora API expects only digits in a phone number. Strips all non-digit
669 sub set_phone_number {
671 my $content = $self->{_content};
673 my $phone = $content->{phone}
674 or return $content->{phone_number} = undef;
677 $content->{phone_number} = $phone;
682 Set value for field province, from value in field state
684 Outside the US/Canada, API expect province set to the string "--",
685 otherwise expects a 2 character string. Value for province is
686 formatted to upper case, and truncated to 2 characters.
692 my $content = $self->{_content};
693 my $country = uc $content->{country};
695 return $content->{province} = '--'
697 && ( $country eq 'US' || $country eq 'CA' );
699 $content->{province} = uc $content->{state};
702 =head2 truncate string, bytes
704 When given a string, truncate to given string length in a unicode safe way
709 my ( $self, $string, $bytes ) = @_;
711 # truncate_egc dies when asked to truncate undef
712 return $string unless $string;
714 truncate_egc( "$string", $bytes, '' );
717 =head2 https_put { headers => \%headers }, post_body
719 Implement a limited interface of https_get from Net::HTTPS::Any
720 for PUT instead of POST -- only implementing current use case of
721 submitting a JSON request body
723 Todo: Properly implement https_put in Net::HTTPS::Any
728 my ( $self, $args, $post_body ) = @_;
730 my $ua = LWP::UserAgent->new;
732 my %headers = %{ $args->{headers} } if ref $args->{headers};
733 for my $k ( keys %headers ) {
734 $ua->default_header( $k => $headers{$k} );
737 my $url = $self->server().$self->path();
738 my $res = $ua->put( $url, Content => $post_body );
740 $self->build_subs(qw/ response_page response_code response_headers/);
742 my @response_headers =
743 map { $_ => $res->header( $_ ) }
744 $res->header_field_names;
746 $self->response_headers( {@response_headers} );
747 $self->response_code( $res->code );
748 $self->response_page( $res->decoded_content );
750 ( $self->response_page, $self->response_code, @response_headers );