1 package Business::OnlinePayment::Bambora;
7 Business::OnlinePayment::Bambora - Bambora backend for Business::OnlinePayment
11 =head2 Card Transaction
13 use Business::OnlinePayment
15 my $tr = Business::OnlinePayment->new('Bambora');
17 login => $BAMBORA_MERCHANT_ID,
18 password => $BAMBORA_API_KEY,
20 action => 'Normal Authorization',
23 owner => 'Business OnlinePayment',
24 name => 'Mitch Jackson',
25 address => '1407 Graymalkin Lane',
31 invoice_number => time(),
32 card_number => '4030000010001234',
35 phone => '415-462-1624',
36 email => 'mitch@freeside.biz',
41 if ( $tr->is_success ) {
42 print "Card processed successfully: ".$tr->authorization."\n";
44 print "Card was rejected: ".$tr->error_message."\n";
49 use Business::OnlinePayment
51 my $tr = Business::OnlinePayment->new('Bambora');
53 login => $BAMBORA_MERCHANT_ID,
54 password => $BAMBORA_API_KEY,
58 owner => 'Business OnlinePayment',
59 name => 'Mitch Jackson',
60 address => '1407 Graymalkin Lane',
66 invoice_number => time(),
67 card_number => '4030000010001234',
70 phone => '415-462-1624',
71 email => 'mitch@freeside.biz',
76 if ( $tr->is_success ) {
77 print "Card tokenized successfully: ".$tr->card_token."\n";
79 print "Card was rejected: ".$tr->error_message."\n";
82 my $tr_token = Business::OnlinePayment->new('Bambora');
84 login => $BAMBORA_MERCHANT_ID,
85 password => $BAMBORA_API_KEY,
87 action => 'Normal Authorization',
89 card_token => $card_token,
95 if ( $tr_token->is_success ) {
96 print "Card processed successfully: ".$tr_token->authorization."\n";
98 print "Card was rejected: ".$tr_token->error_message."\n";
101 =head1 SUPPORTED TRANSACTION TYPES
103 =head2 CC, Visa, Mastercard, American Express, Discover
105 Content required: type, login, password, action, amount, card_number, expiration
109 For detailed information see L<Business::OnlinePayment>
111 =head1 METHODS AND FUNCTIONS
113 See L<Business::OnlinePayment> for the complete list. The following methods
114 either override the methods inherited from L<Business::OnlinePayment> or
115 provide additional functions
119 Returns the response error code
123 Returns the response error description text
127 Returns the complete response from the Bambora API call
129 =head2 response_decoded
131 Returns hashref containing the decoded JSON response from the Bambora API call
133 =head1 Handling of content(%content) data:
137 The following actions are valid
141 Reverse Authorization
147 =head1 Settings Bambora parameters from content(%content)
149 The following rules are applied to map data from %content into
150 a Bambora API request
152 Bambora Business::OnlinePayment-%content
153 -----------------------------------------------------
154 order_number invoice_number
157 transaction_id order_number
158 customer_code card_token
160 card:number card_number
161 card:name owner OR name
162 card:expiry_month expiration
163 card:expiry_year expiration
167 billing:address_line1 address
169 billing:province state
170 billing:country country
171 billing:postal_code zip
172 billing:phone_number phone
173 billing:email_address email
175 =head1 Bambora Authentication
177 This module generates HTTP Authorization headers based on your
178 Bambora API Access Pascode. You must generate an API Access Passcode
179 within the Bambora merchant portal under the menu headings
180 Administration > Account Settings > Order Settings
182 If you intend to use tokenization, you must also copy the same
183 API Access Passcode to the configuration page found at
184 Configuration > Payment Profile Configuration
186 =head1 Tokenization Implementation
188 Many use tokenization is achieved via the Bambora Payment Profile feature
190 The token created by this module represents the Bambora customer_code identifier
191 for Payment Profile records
193 This module does not support advanced management of the Payment Profile,
194 such as storing multiple cards onto a single profile, or updating the
195 stored profile detail
197 Recommending configuration settings in your Bambora merchant portal:
198 ( as of the time of this module's writing )
200 Main Manu > Configuration > Payment Profile Configuration
203 - Uncheck "Requre unique order numbers"
204 - Uncheck "Do not allow profile to be created with billing information
205 duplicated from an existing profile"
208 - Select: API Access Passcode (requierd for this API)
209 - The API Access Passcode will be your "password" using this module
212 - Uncheck "Do not allow profile to be created with card data duplicated
213 from an existing profile""
217 use base qw/ Business::OnlinePayment::HTTPS /;
218 use feature 'unicode_strings';
219 use Carp qw( croak );
220 use Cpanel::JSON::XS;
222 $Data::Dumper::Sortkeys = 1;
223 $Data::Dumper::Indent = 1;
227 $Net::HTTPS::SSL_SOCKET_CLASS = 'Net::SSL'; # Crypt::SSLeay
229 use Unicode::Truncate qw( truncate_egc );
232 use vars qw/ $VERSION $DEBUG /;
236 # =head1 INTERNAL METHODS
243 info_compat => '0.01',
244 module_version => $VERSION,
245 supported_types => [qw/ CC /],
246 supported_actions => {
248 'Normal Authorization',
249 'Authorization Only',
250 'Post Authorization',
253 'Reverse Authorization',
259 # =head2 set_defaults
261 # See L<Business::OnlinePayment/set_defaults>
268 $self->server('api.na.bambora.com');
271 # Create accessors for
272 $self->build_subs(qw/
290 # Dispatch to the appropriate handler based on the given action
294 my %action_dispatch_table = (
295 'normal authorization' => 'submit_normal_authorization',
296 'authorization only' => 'submit_authorization_only',
297 'post authorization' => 'submit_post_authorization',
298 'reverse authorization' => 'submit_reverse_authorization',
299 'void' => 'submit_void',
300 'credit' => 'submit_credit',
301 'tokenize' => 'submit_tokenize',
302 'recurring authorization' => 'submit_recurring_authorization',
303 'modify recurring authorization' => 'modify_recurring_authorization',
309 my $action = lc $self->{_content}->{action}
310 or croak 'submit() called with no action set';
312 my $method = $action_dispatch_table{$action};
314 unless ( $method && $self->can($method) ) {
315 warn $self->error_message( "Action is unsupported ($action)" );
316 return $self->is_success(0);
322 # =head2 submit_normal_authorization
324 # Complete a payment transaction by with an API POST to B</payments>
326 # See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
330 sub submit_normal_authorization {
332 my $content = $self->{_content};
334 # Use epoch time as invoice_number, if none is specified
335 $content->{invoice_number} ||= time();
337 # Clarifying Bambora API and Business::OnlinePayment naming conflict
340 # - order_number: user supplied identifier for the order, displayed on reports
341 # - transaction_id: bambora supplied identifier for the order.
342 # this number must be referenced for future actions like voids,
345 # Business::OnlinePayment
346 # - invoice_number: contains the bambora order number
347 # - order_number: contains the bambora transaction id
350 order_number => $self->truncate( $content->{invoice_number}, 30 ),
351 amount => $content->{amount},
355 $content->{card_token}
356 || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
358 # Process payment against a stored Payment Profile, whose
359 # customer_code is used as the card_token
361 my $card_token = $content->{card_token} || $content->{card_number};
363 unless ( $card_token =~ /^99\d{14}$/ ) {
364 $self->error_message(
365 "Invalid card_token($card_token): Expected 16-digit "
366 . " beginning with 99"
368 return $self->is_success(0);
371 $post{payment_method} = 'payment_profile';
373 $post{payment_profile} = {
374 customer_code => $card_token,
378 } elsif ( $content->{card_number} ) {
380 $post{payment_method} = 'card';
382 # Add card payment details to %post
383 $post{card} = $self->jhref_card;
384 return if $self->error_message;
386 # Add billing address to card
387 $post{billing} = $self->jhref_billing_address;
389 # Designate recurring payment label
390 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
392 # Direct API to issue a complete auth, instead of pre-auth
393 $post{card}->{complete} = 1;
396 croak 'unknown/unsupported payment method!';
399 my $action = lc $content->{action};
401 if ( $action eq 'normal authorization' ) {
402 # Perform complete authorization
403 $self->path('/v1/payments');
405 } elsif ( $action eq 'authorization only' ) {
406 # Perform pre-authorization
407 $self->path('/v1/payments');
409 # Set the 'complete' flag to false, directing API to perform pre-auth
410 if ( ref $post{payment_profile} ) {
411 $post{payment_profile}->{complete} = 0;
412 } elsif ( ref $post{card} ) {
413 $post{card}->{complete} = 0;
416 } elsif ( $action eq 'post authorization' ) {
417 # Complete a pre-authorization
419 croak 'post authorization cannot be completed - '.
420 'bambora transaction_id must be set as content order_number '.
421 'before using submit()'
422 unless $content->{order_number};
425 sprintf '/v1/payments/%s/completions',
426 $content->{order_number}
429 if ( ref $post{card} ) {
430 $post{card}->{complete} = 1
433 die "unsupported action $action";
436 # Parse %post into a JSON string, to be attached to the request POST body
437 my $post_body = encode_json( \%post );
442 post_body => $post_body,
447 my $response = $self->submit_api_request( $post_body );
449 # Any error messages will have been populated by submit_api_request
450 return unless $self->is_success;
452 # Populate transaction result values
453 $self->message_id( $response->{message_id} );
454 $self->authorization( $response->{auth_code} );
455 $self->order_number( $response->{id} );
456 $self->txn_date( $response->{created} );
457 $self->avs_code( $response->{card}{avs_result} );
458 $self->is_success( 1 );
463 # =head2 submit_authorization_only
465 # Capture a card authorization, but do not complete transaction
469 sub submit_authorization_only {
472 $self->submit_normal_authorization;
474 my $response = $self->response_decoded;
480 && $response->{type} ne 'PA'
483 # Bambora API uses nearly identical API calls for normal
484 # card transactions and pre-authorization. Sanity check
485 # that response reported a pre-authorization code
486 die "Expected API Respose type=PA, but type=$response->{type}! ".
487 "Pre-Authorization attempt may have charged card!";
491 # =head2 submit_post_authorization
493 # Complete a card pre-authorization
497 sub submit_post_authorization {
498 shift->submit_normal_authorization;
501 # =head2 submit_reverse_authorization
503 # Reverse a pre-authorization
507 sub submit_reverse_authorization {
513 # Process a return against a transaction for the given amount
519 my $content = $self->{_content};
521 for my $f (qw/ order_number amount/) {
522 unless ( $content->{$f} ) {
523 $self->error_message("Cannot process void - missing required content $f");
524 warn $self->error_message if $DEBUG;
526 return $self->is_success(0);
530 # The posted JSON string needs only contain the amount.
531 # The bambora order_number being voided is passed in the URL
533 amount => $content->{amount},
535 my $post_body = encode_json( \%post );
537 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
542 post_body => $post_body,
546 my $response = $self->submit_api_request( $post_body );
547 return if $self->error_message;
549 $self->is_success(1);
554 # =head2 submit_tokenize
556 # Bambora tokenization is based on the Payment Profile feature of their API.
558 # The token created by this method represents the Bambora customer_code for the
559 # Payment Profile. The token resembles a credit card number. It is 16 digits
560 # long, beginning with 99. No valid card number can begin with the digits 99.
562 # This method creates the payment profile and reports the customer_code
567 sub submit_tokenize {
569 my $content = $self->{_content};
571 # Check if given card number is already a bambora customer_code
572 # under this module's token rules
573 croak "card_number is already tokenized"
574 if $content->{card_number} =~ /^99\d{14}$/;
577 customer_code => $self->generate_token,
578 card => $self->jhref_card,
579 billing => $self->jhref_billing_address,
583 # jhref_card may have generated an exception
584 return if $self->error_message;
586 $self->path('/v1/profiles');
588 my $post_body = encode_json( \%post );
593 post_body => $post_body,
598 my $response = $self->submit_api_request( $post_body );
601 response => $response,
602 is_success => $self->is_success,
603 error_message => $self->error_message,
606 return unless $self->is_success;
608 my $customer_code = $response->{customer_code};
609 if ( !$customer_code ) {
610 # Should not happen...
611 # API reported success codes, but
612 # customer_code value is missing
613 $self->error_message(
614 "Fatal error: API reported success, but did not return customer_code"
616 return $self->is_success(0);
619 if ( $customer_code ne $post{customer_code} ) {
620 # Should not happen...
621 # API reported success codes, but
622 # customer_code attached to created profiles does not match
623 # the token value we attempted to assign to the customer profile
624 $self->error_message(
625 "Fatal error: API failed to set payment profile customer_code value"
627 return $self->is_success(0);
630 $self->card_token( $customer_code );
635 # =head2 submit_api_request json_string [ POST | PUT ]
637 # Make the appropriate API request with the given JSON string
641 sub submit_api_request {
644 my $post_body = shift
645 or die 'submit_api_request() requires a json_string parameter';
647 # Default to using https_post, unless PUT has been specified
648 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
650 my ($response_body, $response_code, %response_headers) = $self->$http_method(
652 headers => { $self->authorization_header },
653 'Content-Type' => 'application/json',
657 $self->server_response( $response_body );
662 eval{ $response = decode_json( $response_body ) };
666 response_body => $response_body,
667 response => $response,
668 response_code => $response_code,
669 # response_headers => \%response_headers,
673 # API should always return a JSON response
674 if ( $@ || !$response ) {
675 $self->error_message( $response_body || 'connection error' );
676 $self->is_success( 0 );
680 $self->response_decoded( $response );
682 if ( $response->{code} && $response->{code} != 1 ) {
683 # Response returned an error
685 $self->is_success( 0 );
686 $self->result_code( $response->{code} );
688 if ( $response->{message} =~ /decline/i ) {
689 $self->failure_status('declined');
692 return $self->error_message(
700 # Return the decoded json of the response back to handler
701 $self->is_success( 1 );
705 =head2 authorization_header
707 Bambora REST requests authenticate via a HTTP header of the format:
708 Authorization: Passcode Base64Encoded(merchant_id:passcode)
710 Returns a hash representing the authorization header derived from
711 the merchant id (login) and API passcode (password)
715 sub authorization_header {
717 my $content = $self->{_content};
719 my %authorization_header = (
720 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
721 join( ':', $content->{login}, $content->{password} )
726 warn Dumper({ authorization_header => \%authorization_header })."\n";
729 %authorization_header;
732 # =head2 jhref_billing_address
734 # Return a hashref for inclusion into a json object
735 # representing the RequestBillingAddress for the API
739 sub jhref_billing_address {
742 $self->parse_province;
743 $self->parse_country;
744 $self->parse_phone_number;
746 my $content = $self->{_content};
749 name => $self->truncate( $content->{name}, 64 ),
750 address_line1 => $self->truncate( $content->{address}, 64 ),
751 city => $self->truncate( $content->{city}, 64 ),
752 province => $self->truncate( $content->{province}, 2 ),
753 country => $self->truncate( $content->{country}, 2 ),
754 postal_code => $self->truncate( $content->{zip}, 16 ),
755 phone_number => $self->truncate( $content->{phone_number}, 20 ),
756 email_address => $self->truncate( $content->{email}, 64 ),
762 # Return a hashref for inclusin into a json object
763 # representing Card for the API
765 # If necessary values are missing from %content, will set
766 # error_message and is_success
772 my $content = $self->{_content};
774 $self->parse_expiration;
776 $content->{owner} ||= $content->{name};
778 # Check required input
785 next if $content->{$f};
787 $self->error_message(
788 "Cannot parse card payment - missing required content $f"
793 error_message => $self->error_message,
798 $self->is_success( 0 );
803 number => $self->truncate( $content->{card_number}, 20 ),
804 name => $self->truncate( $content->{owner}, 64 ),
805 expiry_month => sprintf( '%02d', $content->{expiry_month} ),
806 expiry_year => sprintf( '%02d', $content->{expiry_year} ),
808 $content->{cvv2} ? ( cvd => $content->{cvv2} ) : (),
812 =head2 generate_token
814 Generate a 16-digit numeric token, beginning with the digits 99,
815 ending with a valid Luhn checksum, based on the current epoch time
823 # Pull the current time, to the micro-second from Time::HiRes
824 # Reverse the time string, so when trimed to 13 digits, the most
825 # significant digits, the microseconds, are preserved
827 # Collission testing:
828 # If a collission were to occur, two Bambora payment profiles would
829 # be created with the same customer_number token. This would result in
830 # both payment profiles declining transactions.
831 # I generated 1,000,000 tokens with this method in 18 seconds.
832 # and they were all unique. I think the risk of collission is minimal.
833 # If this did become a problem for somebody, a time delay could be added
834 # to this method to eliminate the change of collisions:
842 split //, sprintf '%.5f', Time::HiRes::time();
843 my $token = 99 . substr( $timestr, 0, 13 );
844 my @token = split //, $token;
846 # Generate Luhn checksum digit
848 for my $i ( 0..14 ) {
852 my $j = $token[$i]*2;
858 my $luhn = $sum % 10 ? 10 - ( $sum % 10 ) : 0;
859 return $token . $luhn;
862 # =head2 parse_country
864 # Country is expected to be set as an ISO-3166-1 2-letter country code
866 # Sets string to upper case.
868 # Dies unless country is a two-letter string.
870 # Could be extended to convert country names to their respective
871 # country codes, or validate country codes
873 # See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
879 my $content = $self->{_content};
880 my $country = uc $content->{country};
882 if ( $country !~ /^[A-Z]{2}$/ ) {
883 croak sprintf 'country is not a 2 character string (%s)',
887 $content->{country} = $country;
890 # =head2 parse_expiration
892 # Split B::OP expiration field, which may be in the format
893 # MM/YY or MMYY, into separate expiry_month and expiry_year fields
895 # Will die if values are not numeric
899 sub parse_expiration {
901 my $content = $self->{_content};
902 my $expiration = $content->{expiration};
904 unless ( $expiration ) {
905 $content->{expiry_month} = undef;
906 $content->{expiry_year} = undef;
912 ? split( /\//, $expiration )
913 : unpack( 'A2 A2', $expiration )
916 croak 'card expiration must be in format MM/YY'
917 if $mm =~ /\D/ || $yy =~ /\D/;
920 $content->{expiry_month} = sprintf( '%02d', $mm ),
921 $content->{expiry_year} = sprintf ('%02d', $yy ),
925 # =head2 parse_phone_number
927 # Set value for field phone_number, from value in field phone
929 # Bambora API expects only digits in a phone number. Strips all non-digit
934 sub parse_phone_number {
936 my $content = $self->{_content};
938 my $phone = $content->{phone}
939 or return $content->{phone_number} = undef;
942 $content->{phone_number} = $phone;
945 # =head2 parse_province
947 # Set value for field province, from value in field state
949 # Outside the US/Canada, API expect province set to the string "--",
950 # otherwise expects a 2 character string. Value for province is
951 # formatted to upper case, and truncated to 2 characters.
957 my $content = $self->{_content};
958 my $country = uc $content->{country};
960 return $content->{province} = '--'
962 && ( $country eq 'US' || $country eq 'CA' );
964 $content->{province} = uc $content->{state};
967 =head2 truncate string, bytes
969 When given a string, truncate to given string length in a unicode safe way
974 my ( $self, $string, $bytes ) = @_;
976 # truncate_egc dies when asked to truncate undef
977 return $string unless $string;
979 truncate_egc( "$string", $bytes, '' );
982 =head2 https_put { headers => \%headers }, post_body
984 Implement a limited interface of https_get from Net::HTTPS::Any
985 for PUT instead of POST -- only implementing current use case of
986 submitting a JSON request body
988 Todo: Properly implement https_put in Net::HTTPS::Any
993 my ( $self, $args, $post_body ) = @_;
995 my $ua = LWP::UserAgent->new;
997 my %headers = %{ $args->{headers} } if ref $args->{headers};
998 for my $k ( keys %headers ) {
999 $ua->default_header( $k => $headers{$k} );
1002 my $url = $self->server().$self->path();
1003 my $res = $ua->put( $url, Content => $post_body );
1005 $self->build_subs(qw/ response_page response_code response_headers/);
1007 my @response_headers =
1008 map { $_ => $res->header( $_ ) }
1009 $res->header_field_names;
1011 $self->response_headers( {@response_headers} );
1012 $self->response_code( $res->code );
1013 $self->response_page( $res->decoded_content );
1015 ( $self->response_page, $self->response_code, @response_headers );
1020 Mitch Jackson <mitch@freeside.biz>
1022 =head1 ADVERTISEMENT
1024 Need a complete, open-source back-office and customer self-service solution?
1025 The Freeside software includes support for credit card and electronic check
1026 processing with IPPay and over 50 other gateways, invoicing, integrated
1027 trouble ticketing, and customer signup and self-service web interfaces.
1029 L<http://freeside.biz/freeside/>
1033 perl(1). L<Business::OnlinePayment>.