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
59 Dispatch to the appropriate handler based on the given action
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',
78 my $action = lc $self->{_content}->{action}
79 or croak 'submit() called with no action set';
81 my $method = $action_dispatch_table{$action};
83 unless ( $method && $self->can($method) ) {
84 warn $self->error_message( "Action is unsupported ($action)" );
85 return $self->is_success(0);
91 =head2 submit_normal_authorization
93 Complete a payment transaction by with an API POST to B</payments>
95 See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
99 sub submit_normal_authorization {
101 my $content = $self->{_content};
103 # Use epoch time as invoice_number, if none is specified
104 $content->{invoice_number} ||= time();
106 # Clarifying Bambora API and Business::OnlinePayment naming conflict
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,
114 # Business::OnlinePayment
115 # - invoice_number: contains the bambora order number
116 # - order_number: contains the bambora transaction id
119 order_number => $self->truncate( $content->{invoice_number}, 30 ),
120 amount => $content->{amount},
124 $content->{card_token}
125 || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
127 # Process payment against a stored Payment Profile, whose
128 # customer_code is used as the card_token
130 my $card_token = $content->{card_token} || $content->{card_number};
132 unless ( $card_token =~ /^99\d{14}$/ ) {
133 $self->error_message(
134 "Invalid card_token($card_token): Expected 16-digit "
135 . " beginning with 99"
137 return $self->is_success(0);
140 $post{payment_method} = 'payment_profile';
142 $post{payment_profile} = {
143 customer_code => $card_token,
147 } elsif ( $content->{card_number} ) {
149 $post{payment_method} = 'card';
151 # Add card payment details to %post
152 $post{card} = $self->jhref_card;
153 return if $self->error_message;
155 # Add billing address to card
156 $post{billing} = $self->jhref_billing_address;
158 # Designate recurring payment label
159 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
161 # Direct API to issue a complete auth, instead of pre-auth
162 $post{card}->{complete} = 1;
165 croak 'unknown/unsupported payment method!';
168 my $action = lc $content->{action};
170 if ( $action eq 'normal authorization' ) {
171 # Perform complete authorization
172 $self->path('/v1/payments');
174 } elsif ( $action eq 'authorization only' ) {
175 # Perform pre-authorization
176 $self->path('/v1/payments');
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;
185 } elsif ( $action eq 'post authorization' ) {
186 # Complete a pre-authorization
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};
194 sprintf '/v1/payments/%s/completions',
195 $content->{order_number}
198 if ( ref $post{card} ) {
199 $post{card}->{complete} = 1
202 die "unsupported action $action";
205 # Parse %post into a JSON string, to be attached to the request POST body
206 my $post_body = encode_json( \%post );
211 post_body => $post_body,
216 my $response = $self->submit_api_request( $post_body );
218 # Any error messages will have been populated by submit_api_request
219 return unless $self->is_success;
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 );
232 =head2 submit_authorization_only
234 Capture a card authorization, but do not complete transaction
238 sub submit_authorization_only {
241 $self->submit_normal_authorization;
243 my $response = $self->response_decoded;
249 && $response->{type} ne 'PA'
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!";
260 =head2 submit_post_authorization
262 Complete a card pre-authorization
266 sub submit_post_authorization {
267 shift->submit_normal_authorization;
270 =head2 submit_reverse_authorization
272 Reverse a pre-authorization
276 sub submit_reverse_authorization {
282 Process a return against a transaction for the given amount
288 my $content = $self->{_content};
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;
295 return $self->is_success(0);
299 # The posted JSON string needs only contain the amount.
300 # The bambora order_number being voided is passed in the URL
302 amount => $content->{amount},
304 my $post_body = encode_json( \%post );
306 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
311 post_body => $post_body,
315 my $response = $self->submit_api_request( $post_body );
316 return if $self->error_message;
318 $self->is_success(1);
323 =head2 submit_tokenize
325 Bambora tokenization is based on the Payment Profile feature of their API.
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.
331 This method creates the payment profile and reports the customer_code
336 sub submit_tokenize {
338 my $content = $self->{_content};
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}$/;
346 customer_code => $self->generate_token,
347 card => $self->jhref_card,
348 billing => $self->jhref_billing_address,
352 # jhref_card may have generated an exception
353 return if $self->error_message;
355 $self->path('/v1/profiles');
357 my $post_body = encode_json( \%post );
362 post_body => $post_body,
367 my $response = $self->submit_api_request( $post_body );
370 response => $response,
371 is_success => $self->is_success,
372 error_message => $self->error_message,
375 return unless $self->is_success;
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"
385 return $self->is_success(0);
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"
396 return $self->is_success(0);
399 $self->card_token( $customer_code );
406 =head2 submit_api_request json_string [ POST | PUT ]
408 Make the appropriate API request with the given JSON string
412 sub submit_api_request {
415 my $post_body = shift
416 or die 'submit_api_request() requires a json_string parameter';
418 # Default to using https_post, unless PUT has been specified
419 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
421 my ($response_body, $response_code, %response_headers) = $self->$http_method(
423 headers => { $self->authorization_header },
424 'Content-Type' => 'application/json',
428 $self->server_response( $response_body );
433 eval{ $response = decode_json( $response_body ) };
437 response_body => $response_body,
438 response => $response,
439 response_code => $response_code,
440 # response_headers => \%response_headers,
444 # API should always return a JSON response
445 if ( $@ || !$response ) {
446 $self->error_message( $response_body || 'connection error' );
447 $self->is_success( 0 );
451 $self->response_decoded( $response );
453 if ( $response->{code} && $response->{code} != 1 ) {
454 # Response returned an error
456 $self->is_success( 0 );
457 $self->result_code( $response->{code} );
459 if ( $response->{message} =~ /decline/i ) {
460 $self->failure_status('declined');
463 return $self->error_message(
471 # Return the decoded json of the response back to handler
472 $self->is_success( 1 );
476 =head2 authorization_header
478 Bambora REST requests authenticate via a HTTP header of the format:
479 Authorization: Passcode Base64Encoded(merchant_id:passcode)
481 Returns a hash representing the authorization header derived from
482 the merchant id (login) and API passcode (password)
486 sub authorization_header {
488 my $content = $self->{_content};
490 my %authorization_header = (
491 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
492 join( ':', $content->{login}, $content->{password} )
497 warn Dumper({ authorization_header => \%authorization_header })."\n";
500 %authorization_header;
503 =head2 jhref_billing_address
505 Return a hashref for inclusion into a json object
506 representing the RequestBillingAddress for the API
510 sub jhref_billing_address {
513 $self->parse_province;
515 $self->parse_phone_number;
517 my $content = $self->{_content};
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 ),
533 Return a hashref for inclusin into a json object
534 representing Card for the API
536 If necessary values are missing from %content, will set
537 error_message and is_success
543 my $content = $self->{_content};
545 $self->set_expiration;
547 $content->{owner} ||= $content->{name};
549 # Check required input
557 next if $content->{$f};
559 $self->error_message(
560 "Cannot parse card payment - missing required content $f"
565 error_message => $self->error_message,
570 $self->is_success( 0 );
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},
583 =head2 generate_token
585 Generate a 16-digit numeric token, beginning with the digits 99,
586 based on the current epoch time
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
600 my $time = Time::HiRes::time();
603 $time = substr($time, 0, 14 ); # Eventually time() will contain 15 digits
610 Country is expected to be set as an ISO-3166-1 2-letter country code
612 Sets string to upper case.
614 Dies unless country is a two-letter string.
616 Could be extended to convert country names to their respective
617 country codes, or validate country codes
619 See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
625 my $content = $self->{_content};
626 my $country = uc $content->{country};
628 if ( $country !~ /^[A-Z]{2}$/ ) {
629 croak sprintf 'country is not a 2 character string (%s)',
633 $content->{country} = $country;
636 =head2 set_expiration_month_year
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
641 Will die if values are not numeric
647 my $content = $self->{_content};
648 my $expiration = $content->{expiration};
650 unless ( $expiration ) {
651 $content->{expiry_month} = undef;
652 $content->{expiry_year} = undef;
658 ? split( /\//, $expiration )
659 : unpack( 'A2 A2', $expiration )
662 croak 'card expiration must be in format MM/YY'
663 if $mm =~ /\D/ || $yy =~ /\D/;
666 $content->{expiry_month} = sprintf( '%02d', $mm ),
667 $content->{expiry_year} = sprintf ('%02d', $yy ),
671 =head2 parse_phone_number
673 Set value for field phone_number, from value in field phone
675 Bambora API expects only digits in a phone number. Strips all non-digit
680 sub parse_phone_number {
682 my $content = $self->{_content};
684 my $phone = $content->{phone}
685 or return $content->{phone_number} = undef;
688 $content->{phone_number} = $phone;
691 =head2 parse_province
693 Set value for field province, from value in field state
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.
703 my $content = $self->{_content};
704 my $country = uc $content->{country};
706 return $content->{province} = '--'
708 && ( $country eq 'US' || $country eq 'CA' );
710 $content->{province} = uc $content->{state};
713 =head2 truncate string, bytes
715 When given a string, truncate to given string length in a unicode safe way
720 my ( $self, $string, $bytes ) = @_;
722 # truncate_egc dies when asked to truncate undef
723 return $string unless $string;
725 truncate_egc( "$string", $bytes, '' );
728 =head2 https_put { headers => \%headers }, post_body
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
734 Todo: Properly implement https_put in Net::HTTPS::Any
739 my ( $self, $args, $post_body ) = @_;
741 my $ua = LWP::UserAgent->new;
743 my %headers = %{ $args->{headers} } if ref $args->{headers};
744 for my $k ( keys %headers ) {
745 $ua->default_header( $k => $headers{$k} );
748 my $url = $self->server().$self->path();
749 my $res = $ua->put( $url, Content => $post_body );
751 $self->build_subs(qw/ response_page response_code response_headers/);
753 my @response_headers =
754 map { $_ => $res->header( $_ ) }
755 $res->header_field_names;
757 $self->response_headers( {@response_headers} );
758 $self->response_code( $res->code );
759 $self->response_page( $res->decoded_content );
761 ( $self->response_page, $self->response_code, @response_headers );