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 /;
22 =head1 INTERNAL METHODS
29 info_compat => '0.01',
30 module_version => $VERSION,
31 supported_types => [qw/ CC /],
32 supported_actions => {
34 'Normal Authorization',
39 'Reverse Authorization',
47 See L<Business::OnlinePayment/set_defaults>
54 $self->server('api.na.bambora.com');
57 # Create accessors for
76 Dispatch to the appropriate handler based on the given action
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',
95 my $action = lc $self->{_content}->{action}
96 or croak 'submit() called with no action set';
98 my $method = $action_dispatch_table{$action};
100 unless ( $method && $self->can($method) ) {
101 warn $self->error_message( "Action is unsupported ($action)" );
102 return $self->is_success(0);
108 =head2 submit_normal_authorization
110 Complete a payment transaction by with an API POST to B</payments>
112 See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
116 sub submit_normal_authorization {
118 my $content = $self->{_content};
120 # Use epoch time as invoice_number, if none is specified
121 $content->{invoice_number} ||= time();
123 # Clarifying Bambora API and Business::OnlinePayment naming conflict
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,
131 # Business::OnlinePayment
132 # - invoice_number: contains the bambora order number
133 # - order_number: contains the bambora transaction id
136 order_number => $self->truncate( $content->{invoice_number}, 30 ),
137 amount => $content->{amount},
141 $content->{card_token}
142 || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
144 # Process payment against a stored Payment Profile, whose
145 # customer_code is used as the card_token
147 my $card_token = $content->{card_token} || $content->{card_number};
149 unless ( $card_token =~ /^99\d{14}$/ ) {
150 $self->error_message(
151 "Invalid card_token($card_token): Expected 16-digit "
152 . " beginning with 99"
154 return $self->is_success(0);
157 $post{payment_method} = 'payment_profile';
159 $post{payment_profile} = {
160 customer_code => $card_token,
164 } elsif ( $content->{card_number} ) {
166 $post{payment_method} = 'card';
168 # Add card payment details to %post
169 $post{card} = $self->jhref_card;
170 return if $self->error_message;
172 # Add billing address to card
173 $post{billing} = $self->jhref_billing_address;
175 # Designate recurring payment label
176 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
178 # Direct API to issue a complete auth, instead of pre-auth
179 $post{card}->{complete} = 1;
182 croak 'unknown/unsupported payment method!';
185 my $action = lc $content->{action};
187 if ( $action eq 'normal authorization' ) {
188 # Perform complete authorization
189 $self->path('/v1/payments');
191 } elsif ( $action eq 'authorization only' ) {
192 # Perform pre-authorization
193 $self->path('/v1/payments');
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;
202 } elsif ( $action eq 'post authorization' ) {
203 # Complete a pre-authorization
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};
211 sprintf '/v1/payments/%s/completions',
212 $content->{order_number}
215 if ( ref $post{card} ) {
216 $post{card}->{complete} = 1
219 die "unsupported action $action";
222 # Parse %post into a JSON string, to be attached to the request POST body
223 my $post_body = encode_json( \%post );
228 post_body => $post_body,
233 my $response = $self->submit_api_request( $post_body );
235 # Any error messages will have been populated by submit_api_request
236 return unless $self->is_success;
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 );
249 =head2 submit_authorization_only
251 Capture a card authorization, but do not complete transaction
255 sub submit_authorization_only {
258 $self->submit_normal_authorization;
260 my $response = $self->response_decoded;
266 && $response->{type} ne 'PA'
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!";
277 =head2 submit_post_authorization
279 Complete a card pre-authorization
283 sub submit_post_authorization {
284 shift->submit_normal_authorization;
287 =head2 submit_reverse_authorization
289 Reverse a pre-authorization
293 sub submit_reverse_authorization {
299 Process a return against a transaction for the given amount
305 my $content = $self->{_content};
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;
312 return $self->is_success(0);
316 # The posted JSON string needs only contain the amount.
317 # The bambora order_number being voided is passed in the URL
319 amount => $content->{amount},
321 my $post_body = encode_json( \%post );
323 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
328 post_body => $post_body,
332 my $response = $self->submit_api_request( $post_body );
333 return if $self->error_message;
335 $self->is_success(1);
340 =head2 submit_tokenize
342 Bambora tokenization is based on the Payment Profile feature of their API.
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.
348 This method creates the payment profile and reports the customer_code
353 sub submit_tokenize {
355 my $content = $self->{_content};
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}$/;
363 customer_code => $self->generate_token,
364 card => $self->jhref_card,
365 billing => $self->jhref_billing_address,
369 # jhref_card may have generated an exception
370 return if $self->error_message;
372 $self->path('/v1/profiles');
374 my $post_body = encode_json( \%post );
379 post_body => $post_body,
384 my $response = $self->submit_api_request( $post_body );
387 response => $response,
388 is_success => $self->is_success,
389 error_message => $self->error_message,
392 return unless $self->is_success;
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"
402 return $self->is_success(0);
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"
413 return $self->is_success(0);
416 $self->card_token( $customer_code );
423 =head2 submit_api_request json_string [ POST | PUT ]
425 Make the appropriate API request with the given JSON string
429 sub submit_api_request {
432 my $post_body = shift
433 or die 'submit_api_request() requires a json_string parameter';
435 # Default to using https_post, unless PUT has been specified
436 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
438 my ($response_body, $response_code, %response_headers) = $self->$http_method(
440 headers => { $self->authorization_header },
441 'Content-Type' => 'application/json',
445 $self->server_response( $response_body );
450 eval{ $response = decode_json( $response_body ) };
454 response_body => $response_body,
455 response => $response,
456 response_code => $response_code,
457 # response_headers => \%response_headers,
461 # API should always return a JSON response
462 if ( $@ || !$response ) {
463 $self->error_message( $response_body || 'connection error' );
464 $self->is_success( 0 );
468 $self->response_decoded( $response );
470 if ( $response->{code} && $response->{code} != 1 ) {
471 # Response returned an error
473 $self->is_success( 0 );
474 $self->result_code( $response->{code} );
476 if ( $response->{message} =~ /decline/i ) {
477 $self->failure_status('declined');
480 return $self->error_message(
488 # Return the decoded json of the response back to handler
489 $self->is_success( 1 );
493 =head2 authorization_header
495 Bambora REST requests authenticate via a HTTP header of the format:
496 Authorization: Passcode Base64Encoded(merchant_id:passcode)
498 Returns a hash representing the authorization header derived from
499 the merchant id (login) and API passcode (password)
503 sub authorization_header {
505 my $content = $self->{_content};
507 my %authorization_header = (
508 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
509 join( ':', $content->{login}, $content->{password} )
514 warn Dumper({ authorization_header => \%authorization_header })."\n";
517 %authorization_header;
520 =head2 jhref_billing_address
522 Return a hashref for inclusion into a json object
523 representing the RequestBillingAddress for the API
527 sub jhref_billing_address {
530 $self->parse_province;
532 $self->parse_phone_number;
534 my $content = $self->{_content};
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 ),
550 Return a hashref for inclusin into a json object
551 representing Card for the API
553 If necessary values are missing from %content, will set
554 error_message and is_success
560 my $content = $self->{_content};
562 $self->set_expiration;
564 $content->{owner} ||= $content->{name};
566 # Check required input
573 next if $content->{$f};
575 $self->error_message(
576 "Cannot parse card payment - missing required content $f"
581 error_message => $self->error_message,
586 $self->is_success( 0 );
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} ),
596 $content->{cvv2} ? ( cvd => $content->{cvv2} ) : (),
600 =head2 generate_token
602 Generate a 16-digit numeric token, beginning with the digits 99,
603 based on the current epoch time
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
618 # Pull the current time, to the micro-second from Time::HiRes
619 # Reverse the time string, so when trimed to 13 digits, the most
620 # significant digits, the microseconds, are preserved
622 # Collission testing:
623 # If a collission were to occur, two Bambora payment profiles would
624 # be created with the same customer_number token. This would result in
625 # both payment profiles declining transactions.
626 # I generated 1,000,000 tokens with this method in 18 seconds.
627 # and they were all unique. I think the risk of collission is minimal.
628 # If this did become a problem for somebody, a time delay could be added
629 # to this method to eliminate the change of collisions:
637 split //, sprintf '%.5f', Time::HiRes::time();
638 my $token = 99 . substr( $timestr, 0, 13 );
639 my @token = split //, $token;
641 # Generate Luhn checksum digit
643 for my $i ( 0..14 ) {
647 my $j = $token[$i]*2;
653 my $luhn = $sum % 10 ? 10 - ( $sum % 10 ) : 0;
654 return $token . $luhn;
661 my $content = $self->{_content};
662 my $country = uc $content->{country};
664 if ( $country !~ /^[A-Z]{2}$/ ) {
665 croak sprintf 'country is not a 2 character string (%s)',
669 $content->{country} = $country;
672 =head2 set_expiration_month_year
674 Split B::OP expiration field, which may be in the format
675 MM/YY or MMYY, into separate expiry_month and expiry_year fields
677 Will die if values are not numeric
683 my $content = $self->{_content};
684 my $expiration = $content->{expiration};
686 unless ( $expiration ) {
687 $content->{expiry_month} = undef;
688 $content->{expiry_year} = undef;
694 ? split( /\//, $expiration )
695 : unpack( 'A2 A2', $expiration )
698 croak 'card expiration must be in format MM/YY'
699 if $mm =~ /\D/ || $yy =~ /\D/;
702 $content->{expiry_month} = sprintf( '%02d', $mm ),
703 $content->{expiry_year} = sprintf ('%02d', $yy ),
707 =head2 parse_phone_number
709 Set value for field phone_number, from value in field phone
711 Bambora API expects only digits in a phone number. Strips all non-digit
716 sub parse_phone_number {
718 my $content = $self->{_content};
720 my $phone = $content->{phone}
721 or return $content->{phone_number} = undef;
724 $content->{phone_number} = $phone;
727 =head2 parse_province
729 Set value for field province, from value in field state
731 Outside the US/Canada, API expect province set to the string "--",
732 otherwise expects a 2 character string. Value for province is
733 formatted to upper case, and truncated to 2 characters.
739 my $content = $self->{_content};
740 my $country = uc $content->{country};
742 return $content->{province} = '--'
744 && ( $country eq 'US' || $country eq 'CA' );
746 $content->{province} = uc $content->{state};
749 =head2 truncate string, bytes
751 When given a string, truncate to given string length in a unicode safe way
756 my ( $self, $string, $bytes ) = @_;
758 # truncate_egc dies when asked to truncate undef
759 return $string unless $string;
761 truncate_egc( "$string", $bytes, '' );
764 =head2 https_put { headers => \%headers }, post_body
766 Implement a limited interface of https_get from Net::HTTPS::Any
767 for PUT instead of POST -- only implementing current use case of
768 submitting a JSON request body
770 Todo: Properly implement https_put in Net::HTTPS::Any
775 my ( $self, $args, $post_body ) = @_;
777 my $ua = LWP::UserAgent->new;
779 my %headers = %{ $args->{headers} } if ref $args->{headers};
780 for my $k ( keys %headers ) {
781 $ua->default_header( $k => $headers{$k} );
784 my $url = $self->server().$self->path();
785 my $res = $ua->put( $url, Content => $post_body );
787 $self->build_subs(qw/ response_page response_code response_headers/);
789 my @response_headers =
790 map { $_ => $res->header( $_ ) }
791 $res->header_field_names;
793 $self->response_headers( {@response_headers} );
794 $self->response_code( $res->code );
795 $self->response_page( $res->decoded_content );
797 ( $self->response_page, $self->response_code, @response_headers );