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 unless ( $method && $self->can($method) ) {
83 warn $self->error_message( "Action is unsupported ($action)" );
84 return $self->is_success(0);
90 =head2 submit_normal_authorization
92 Complete a payment transaction by with an API POST to B</payments>
94 See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
98 sub submit_normal_authorization {
100 my $content = $self->{_content};
102 # Use epoch time as invoice_number, if none is specified
103 $content->{invoice_number} ||= time();
105 # Clarifying Bambora API and Business::OnlinePayment naming conflict
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,
113 # Business::OnlinePayment
114 # - invoice_number: contains the bambora order number
115 # - order_number: contains the bambora transaction id
118 order_number => $self->truncate( $content->{invoice_number}, 30 ),
119 amount => $content->{amount},
123 $content->{card_token}
124 || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
126 # Process payment against a stored Payment Profile, whose
127 # customer_code is used as the card_token
129 my $card_token = $content->{card_token} || $content->{card_number};
131 unless ( $card_token =~ /^99\d{14}$/ ) {
132 $self->error_message(
133 "Invalid card_token($card_token): Expected 16-digit "
134 . " beginning with 99"
136 return $self->is_success(0);
139 $post{payment_method} = 'payment_profile';
141 $post{payment_profile} = {
142 customer_code => $card_token,
146 } elsif ( $content->{card_number} ) {
148 $post{payment_method} = 'card';
150 # Add card payment details to %post
151 $post{card} = $self->jhref_card;
152 return if $self->error_message;
154 # Add billing address to card
155 $post{billing} = $self->jhref_billing_address;
157 # Designate recurring payment label
158 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
160 # Direct API to issue a complete auth, instead of pre-auth
161 $post{card}->{complete} = 1;
164 croak 'unknown/unsupported payment method!';
167 my $action = lc $content->{action};
169 if ( $action eq 'normal authorization' ) {
170 # Perform complete authorization
171 $self->path('/v1/payments');
173 } elsif ( $action eq 'authorization only' ) {
174 # Perform pre-authorization
175 $self->path('/v1/payments');
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;
184 } elsif ( $action eq 'post authorization' ) {
185 # Complete a pre-authorization
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};
193 sprintf '/v1/payments/%s/completions',
194 $content->{order_number}
197 if ( ref $post{card} ) {
198 $post{card}->{complete} = 1
201 die "unsupported action $action";
204 # Parse %post into a JSON string, to be attached to the request POST body
205 my $post_body = encode_json( \%post );
210 post_body => $post_body,
215 my $response = $self->submit_api_request( $post_body );
217 # Any error messages will have been populated by submit_api_request
218 return unless $self->is_success;
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 );
231 =head2 submit_authorization_only
233 Capture a card authorization, but do not complete transaction
237 sub submit_authorization_only {
240 $self->submit_normal_authorization;
242 my $response = $self->response_decoded;
248 && $response->{type} ne 'PA'
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!";
259 =head2 submit_post_authorization
261 Complete a card pre-authorization
265 sub submit_post_authorization {
266 shift->submit_normal_authorization;
269 =head2 submit_reverse_authorization
271 Reverse a pre-authorization
275 sub submit_reverse_authorization {
281 Process a return against a transaction for the given amount
287 my $content = $self->{_content};
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;
294 return $self->is_success(0);
298 # The posted JSON string needs only contain the amount.
299 # The bambora order_number being voided is passed in the URL
301 amount => $content->{amount},
303 my $post_body = encode_json( \%post );
305 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
310 post_body => $post_body,
314 my $response = $self->submit_api_request( $post_body );
315 return if $self->error_message;
317 $self->is_success(1);
322 =head2 submit_tokenize
324 Bambora tokenization is based on the Payment Profile feature of their API.
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.
330 This method creates the payment profile and reports the customer_code
335 sub submit_tokenize {
337 my $content = $self->{_content};
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}$/;
345 customer_code => $self->generate_token,
346 card => $self->jhref_card,
347 billing => $self->jhref_billing_address,
351 # jhref_card may have generated an exception
352 return if $self->error_message;
354 $self->path('/v1/profiles');
356 my $post_body = encode_json( \%post );
361 post_body => $post_body,
366 my $response = $self->submit_api_request( $post_body );
369 response => $response,
370 is_success => $self->is_success,
371 error_message => $self->error_message,
374 return unless $self->is_success;
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"
384 return $self->is_success(0);
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"
395 return $self->is_success(0);
398 $self->card_token( $customer_code );
405 =head2 submit_api_request json_string [ POST | PUT ]
407 Make the appropriate API request with the given JSON string
411 sub submit_api_request {
414 my $post_body = shift
415 or die 'submit_api_request() requires a json_string parameter';
417 # Default to using https_post, unless PUT has been specified
418 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
420 my ($response_body, $response_code, %response_headers) = $self->$http_method(
422 headers => { $self->authorization_header },
423 'Content-Type' => 'application/json',
427 $self->server_response( $response_body );
432 eval{ $response = decode_json( $response_body ) };
436 response_body => $response_body,
437 response => $response,
438 response_code => $response_code,
439 # response_headers => \%response_headers,
443 # API should always return a JSON response
444 if ( $@ || !$response ) {
445 $self->error_message( $response_body || 'connection error' );
446 $self->is_success( 0 );
450 $self->response_decoded( $response );
452 if ( $response->{code} && $response->{code} != 1 ) {
453 # Response returned an error
455 $self->is_success( 0 );
456 $self->result_code( $response->{code} );
458 return $self->error_message(
466 # Return the decoded json of the response back to handler
467 $self->is_success( 1 );
471 =head2 authorization_header
473 Bambora REST requests authenticate via a HTTP header of the format:
474 Authorization: Passcode Base64Encoded(merchant_id:passcode)
476 Returns a hash representing the authorization header derived from
477 the merchant id (login) and API passcode (password)
481 sub authorization_header {
483 my $content = $self->{_content};
485 my %authorization_header = (
486 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
487 join( ':', $content->{login}, $content->{password} )
492 warn Dumper({ authorization_header => \%authorization_header })."\n";
495 %authorization_header;
498 =head2 jhref_billing_address
500 Return a hashref for inclusion into a json object
501 representing the RequestBillingAddress for the API
505 sub jhref_billing_address {
508 $self->parse_province;
510 $self->parse_phone_number;
512 my $content = $self->{_content};
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 ),
528 Return a hashref for inclusin into a json object
529 representing Card for the API
531 If necessary values are missing from %content, will set
532 error_message and is_success
538 my $content = $self->{_content};
540 $self->set_expiration;
542 # Check required input
550 next if $content->{$f};
552 $self->error_message(
553 "Cannot parse card payment - missing required content $f"
556 warn $self->error_message if $DEBUG;
557 $self->is_success( 0 );
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},
571 =head2 generate_token
573 Generate a 16-digit numeric token, beginning with the digits 99,
574 based on the current epoch time
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
588 my $time = Time::HiRes::time();
591 $time = substr($time, 0, 14 ); # Eventually time() will contain 15 digits
598 Country is expected to be set as an ISO-3166-1 2-letter country code
600 Sets string to upper case.
602 Dies unless country is a two-letter string.
604 Could be extended to convert country names to their respective
605 country codes, or validate country codes
607 See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
613 my $content = $self->{_content};
614 my $country = uc $content->{country};
616 if ( $country !~ /^[A-Z]{2}$/ ) {
617 croak sprintf 'country is not a 2 character string (%s)',
621 $content->{country} = $country;
624 =head2 set_expiration_month_year
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
629 Will die if values are not numeric
635 my $content = $self->{_content};
636 my $expiration = $content->{expiration};
638 unless ( $expiration ) {
639 $content->{expiry_month} = undef;
640 $content->{expiry_year} = undef;
646 ? split( /\//, $expiration )
647 : unpack( 'A2 A2', $expiration )
650 croak 'card expiration must be in format MM/YY'
651 if $mm =~ /\D/ || $yy =~ /\D/;
654 $content->{expiry_month} = sprintf( '%02d', $mm ),
655 $content->{expiry_year} = sprintf ('%02d', $yy ),
659 =head2 parse_phone_number
661 Set value for field phone_number, from value in field phone
663 Bambora API expects only digits in a phone number. Strips all non-digit
668 sub parse_phone_number {
670 my $content = $self->{_content};
672 my $phone = $content->{phone}
673 or return $content->{phone_number} = undef;
676 $content->{phone_number} = $phone;
679 =head2 parse_province
681 Set value for field province, from value in field state
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.
691 my $content = $self->{_content};
692 my $country = uc $content->{country};
694 return $content->{province} = '--'
696 && ( $country eq 'US' || $country eq 'CA' );
698 $content->{province} = uc $content->{state};
701 =head2 truncate string, bytes
703 When given a string, truncate to given string length in a unicode safe way
708 my ( $self, $string, $bytes ) = @_;
710 # truncate_egc dies when asked to truncate undef
711 return $string unless $string;
713 truncate_egc( "$string", $bytes, '' );
716 =head2 https_put { headers => \%headers }, post_body
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
722 Todo: Properly implement https_put in Net::HTTPS::Any
727 my ( $self, $args, $post_body ) = @_;
729 my $ua = LWP::UserAgent->new;
731 my %headers = %{ $args->{headers} } if ref $args->{headers};
732 for my $k ( keys %headers ) {
733 $ua->default_header( $k => $headers{$k} );
736 my $url = $self->server().$self->path();
737 my $res = $ua->put( $url, Content => $post_body );
739 $self->build_subs(qw/ response_page response_code response_headers/);
741 my @response_headers =
742 map { $_ => $res->header( $_ ) }
743 $res->header_field_names;
745 $self->response_headers( {@response_headers} );
746 $self->response_code( $res->code );
747 $self->response_page( $res->decoded_content );
749 ( $self->response_page, $self->response_code, @response_headers );