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 use Unicode::Truncate qw( truncate_egc );
230 use vars qw/ $VERSION $DEBUG /;
234 # =head1 INTERNAL METHODS
241 info_compat => '0.01',
242 module_version => $VERSION,
243 supported_types => [qw/ CC /],
244 supported_actions => {
246 'Normal Authorization',
247 'Authorization Only',
248 'Post Authorization',
251 'Reverse Authorization',
257 # =head2 set_defaults
259 # See L<Business::OnlinePayment/set_defaults>
266 $self->server('api.na.bambora.com');
269 # Create accessors for
270 $self->build_subs(qw/
288 # Dispatch to the appropriate handler based on the given action
292 my %action_dispatch_table = (
293 'normal authorization' => 'submit_normal_authorization',
294 'authorization only' => 'submit_authorization_only',
295 'post authorization' => 'submit_post_authorization',
296 'reverse authorization' => 'submit_reverse_authorization',
297 'void' => 'submit_void',
298 'credit' => 'submit_credit',
299 'tokenize' => 'submit_tokenize',
300 'recurring authorization' => 'submit_recurring_authorization',
301 'modify recurring authorization' => 'modify_recurring_authorization',
307 my $action = lc $self->{_content}->{action}
308 or croak 'submit() called with no action set';
310 my $method = $action_dispatch_table{$action};
312 unless ( $method && $self->can($method) ) {
313 warn $self->error_message( "Action is unsupported ($action)" );
314 return $self->is_success(0);
320 # =head2 submit_normal_authorization
322 # Complete a payment transaction by with an API POST to B</payments>
324 # See L<https://dev.na.bambora.com/docs/references/payment_APIs/v1-0-5>
328 sub submit_normal_authorization {
330 my $content = $self->{_content};
332 # Use epoch time as invoice_number, if none is specified
333 $content->{invoice_number} ||= time();
335 # Clarifying Bambora API and Business::OnlinePayment naming conflict
338 # - order_number: user supplied identifier for the order, displayed on reports
339 # - transaction_id: bambora supplied identifier for the order.
340 # this number must be referenced for future actions like voids,
343 # Business::OnlinePayment
344 # - invoice_number: contains the bambora order number
345 # - order_number: contains the bambora transaction id
348 order_number => $self->truncate( $content->{invoice_number}, 30 ),
349 amount => $content->{amount},
353 $content->{card_token}
354 || ( $content->{card_number} && $content->{card_number} =~ /^99\d{14}$/ )
356 # Process payment against a stored Payment Profile, whose
357 # customer_code is used as the card_token
359 my $card_token = $content->{card_token} || $content->{card_number};
361 unless ( $card_token =~ /^99\d{14}$/ ) {
362 $self->error_message(
363 "Invalid card_token($card_token): Expected 16-digit "
364 . " beginning with 99"
366 return $self->is_success(0);
369 $post{payment_method} = 'payment_profile';
371 $post{payment_profile} = {
372 customer_code => $card_token,
376 } elsif ( $content->{card_number} ) {
378 $post{payment_method} = 'card';
380 # Add card payment details to %post
381 $post{card} = $self->jhref_card;
382 return if $self->error_message;
384 # Add billing address to card
385 $post{billing} = $self->jhref_billing_address;
387 # Designate recurring payment label
388 $post{card}->{recurring_payment} = $content->{recurring_payment} ? 1 : 0;
390 # Direct API to issue a complete auth, instead of pre-auth
391 $post{card}->{complete} = 1;
394 croak 'unknown/unsupported payment method!';
397 my $action = lc $content->{action};
399 if ( $action eq 'normal authorization' ) {
400 # Perform complete authorization
401 $self->path('/v1/payments');
403 } elsif ( $action eq 'authorization only' ) {
404 # Perform pre-authorization
405 $self->path('/v1/payments');
407 # Set the 'complete' flag to false, directing API to perform pre-auth
408 if ( ref $post{payment_profile} ) {
409 $post{payment_profile}->{complete} = 0;
410 } elsif ( ref $post{card} ) {
411 $post{card}->{complete} = 0;
414 } elsif ( $action eq 'post authorization' ) {
415 # Complete a pre-authorization
417 croak 'post authorization cannot be completed - '.
418 'bambora transaction_id must be set as content order_number '.
419 'before using submit()'
420 unless $content->{order_number};
423 sprintf '/v1/payments/%s/completions',
424 $content->{order_number}
427 if ( ref $post{card} ) {
428 $post{card}->{complete} = 1
431 die "unsupported action $action";
434 # Parse %post into a JSON string, to be attached to the request POST body
435 my $post_body = encode_json( \%post );
440 post_body => $post_body,
445 my $response = $self->submit_api_request( $post_body );
447 # Any error messages will have been populated by submit_api_request
448 return unless $self->is_success;
450 # Populate transaction result values
451 $self->message_id( $response->{message_id} );
452 $self->authorization( $response->{auth_code} );
453 $self->order_number( $response->{id} );
454 $self->txn_date( $response->{created} );
455 $self->avs_code( $response->{card}{avs_result} );
456 $self->is_success( 1 );
461 # =head2 submit_authorization_only
463 # Capture a card authorization, but do not complete transaction
467 sub submit_authorization_only {
470 $self->submit_normal_authorization;
472 my $response = $self->response_decoded;
478 && $response->{type} ne 'PA'
481 # Bambora API uses nearly identical API calls for normal
482 # card transactions and pre-authorization. Sanity check
483 # that response reported a pre-authorization code
484 die "Expected API Respose type=PA, but type=$response->{type}! ".
485 "Pre-Authorization attempt may have charged card!";
489 # =head2 submit_post_authorization
491 # Complete a card pre-authorization
495 sub submit_post_authorization {
496 shift->submit_normal_authorization;
499 # =head2 submit_reverse_authorization
501 # Reverse a pre-authorization
505 sub submit_reverse_authorization {
511 # Process a return against a transaction for the given amount
517 my $content = $self->{_content};
519 for my $f (qw/ order_number amount/) {
520 unless ( $content->{$f} ) {
521 $self->error_message("Cannot process void - missing required content $f");
522 warn $self->error_message if $DEBUG;
524 return $self->is_success(0);
528 # The posted JSON string needs only contain the amount.
529 # The bambora order_number being voided is passed in the URL
531 amount => $content->{amount},
533 my $post_body = encode_json( \%post );
535 $self->path( sprintf '/v1/payments/%s/returns', $content->{order_number} );
540 post_body => $post_body,
544 my $response = $self->submit_api_request( $post_body );
545 return if $self->error_message;
547 $self->is_success(1);
552 # =head2 submit_tokenize
554 # Bambora tokenization is based on the Payment Profile feature of their API.
556 # The token created by this method represents the Bambora customer_code for the
557 # Payment Profile. The token resembles a credit card number. It is 16 digits
558 # long, beginning with 99. No valid card number can begin with the digits 99.
560 # This method creates the payment profile and reports the customer_code
565 sub submit_tokenize {
567 my $content = $self->{_content};
569 # Check if given card number is already a bambora customer_code
570 # under this module's token rules
571 croak "card_number is already tokenized"
572 if $content->{card_number} =~ /^99\d{14}$/;
575 customer_code => $self->generate_token,
576 card => $self->jhref_card,
577 billing => $self->jhref_billing_address,
581 # jhref_card may have generated an exception
582 return if $self->error_message;
584 $self->path('/v1/profiles');
586 my $post_body = encode_json( \%post );
591 post_body => $post_body,
596 my $response = $self->submit_api_request( $post_body );
599 response => $response,
600 is_success => $self->is_success,
601 error_message => $self->error_message,
604 return unless $self->is_success;
606 my $customer_code = $response->{customer_code};
607 if ( !$customer_code ) {
608 # Should not happen...
609 # API reported success codes, but
610 # customer_code value is missing
611 $self->error_message(
612 "Fatal error: API reported success, but did not return customer_code"
614 return $self->is_success(0);
617 if ( $customer_code ne $post{customer_code} ) {
618 # Should not happen...
619 # API reported success codes, but
620 # customer_code attached to created profiles does not match
621 # the token value we attempted to assign to the customer profile
622 $self->error_message(
623 "Fatal error: API failed to set payment profile customer_code value"
625 return $self->is_success(0);
628 $self->card_token( $customer_code );
633 # =head2 submit_api_request json_string [ POST | PUT ]
635 # Make the appropriate API request with the given JSON string
639 sub submit_api_request {
642 my $post_body = shift
643 or die 'submit_api_request() requires a json_string parameter';
645 # Default to using https_post, unless PUT has been specified
646 my $http_method = ( $_[0] && lc $_[0] eq 'put' ) ? 'https_put' : 'https_post';
648 my ($response_body, $response_code, %response_headers) = $self->$http_method(
650 headers => { $self->authorization_header },
651 'Content-Type' => 'application/json',
655 $self->server_response( $response_body );
660 eval{ $response = decode_json( $response_body ) };
664 response_body => $response_body,
665 response => $response,
666 response_code => $response_code,
667 # response_headers => \%response_headers,
671 # API should always return a JSON response
672 if ( $@ || !$response ) {
673 $self->error_message( $response_body || 'connection error' );
674 $self->is_success( 0 );
678 $self->response_decoded( $response );
680 if ( $response->{code} && $response->{code} != 1 ) {
681 # Response returned an error
683 $self->is_success( 0 );
684 $self->result_code( $response->{code} );
686 if ( $response->{message} =~ /decline/i ) {
687 $self->failure_status('declined');
690 return $self->error_message(
698 # Return the decoded json of the response back to handler
699 $self->is_success( 1 );
703 =head2 authorization_header
705 Bambora REST requests authenticate via a HTTP header of the format:
706 Authorization: Passcode Base64Encoded(merchant_id:passcode)
708 Returns a hash representing the authorization header derived from
709 the merchant id (login) and API passcode (password)
713 sub authorization_header {
715 my $content = $self->{_content};
717 my %authorization_header = (
718 Authorization => 'Passcode ' . MIME::Base64::encode_base64(
719 join( ':', $content->{login}, $content->{password} )
724 warn Dumper({ authorization_header => \%authorization_header })."\n";
727 %authorization_header;
730 # =head2 jhref_billing_address
732 # Return a hashref for inclusion into a json object
733 # representing the RequestBillingAddress for the API
737 sub jhref_billing_address {
740 $self->parse_province;
741 $self->parse_country;
742 $self->parse_phone_number;
744 my $content = $self->{_content};
747 name => $self->truncate( $content->{name}, 64 ),
748 address_line1 => $self->truncate( $content->{address}, 64 ),
749 city => $self->truncate( $content->{city}, 64 ),
750 province => $self->truncate( $content->{province}, 2 ),
751 country => $self->truncate( $content->{country}, 2 ),
752 postal_code => $self->truncate( $content->{zip}, 16 ),
753 phone_number => $self->truncate( $content->{phone_number}, 20 ),
754 email_address => $self->truncate( $content->{email}, 64 ),
760 # Return a hashref for inclusin into a json object
761 # representing Card for the API
763 # If necessary values are missing from %content, will set
764 # error_message and is_success
770 my $content = $self->{_content};
772 $self->parse_expiration;
774 $content->{owner} ||= $content->{name};
776 # Check required input
783 next if $content->{$f};
785 $self->error_message(
786 "Cannot parse card payment - missing required content $f"
791 error_message => $self->error_message,
796 $self->is_success( 0 );
801 number => $self->truncate( $content->{card_number}, 20 ),
802 name => $self->truncate( $content->{owner}, 64 ),
803 expiry_month => sprintf( '%02d', $content->{expiry_month} ),
804 expiry_year => sprintf( '%02d', $content->{expiry_year} ),
806 $content->{cvv2} ? ( cvd => $content->{cvv2} ) : (),
810 =head2 generate_token
812 Generate a 16-digit numeric token, beginning with the digits 99,
813 ending with a valid Luhn checksum, based on the current epoch time
821 # Pull the current time, to the micro-second from Time::HiRes
822 # Reverse the time string, so when trimed to 13 digits, the most
823 # significant digits, the microseconds, are preserved
825 # Collission testing:
826 # If a collission were to occur, two Bambora payment profiles would
827 # be created with the same customer_number token. This would result in
828 # both payment profiles declining transactions.
829 # I generated 1,000,000 tokens with this method in 18 seconds.
830 # and they were all unique. I think the risk of collission is minimal.
831 # If this did become a problem for somebody, a time delay could be added
832 # to this method to eliminate the change of collisions:
840 split //, sprintf '%.5f', Time::HiRes::time();
841 my $token = 99 . substr( $timestr, 0, 13 );
842 my @token = split //, $token;
844 # Generate Luhn checksum digit
846 for my $i ( 0..14 ) {
850 my $j = $token[$i]*2;
856 my $luhn = $sum % 10 ? 10 - ( $sum % 10 ) : 0;
857 return $token . $luhn;
860 # =head2 parse_country
862 # Country is expected to be set as an ISO-3166-1 2-letter country code
864 # Sets string to upper case.
866 # Dies unless country is a two-letter string.
868 # Could be extended to convert country names to their respective
869 # country codes, or validate country codes
871 # See: L<https://en.wikipedia.org/wiki/ISO_3166-1>
877 my $content = $self->{_content};
878 my $country = uc $content->{country};
880 if ( $country !~ /^[A-Z]{2}$/ ) {
881 croak sprintf 'country is not a 2 character string (%s)',
885 $content->{country} = $country;
888 # =head2 parse_expiration
890 # Split B::OP expiration field, which may be in the format
891 # MM/YY or MMYY, into separate expiry_month and expiry_year fields
893 # Will die if values are not numeric
897 sub parse_expiration {
899 my $content = $self->{_content};
900 my $expiration = $content->{expiration};
902 unless ( $expiration ) {
903 $content->{expiry_month} = undef;
904 $content->{expiry_year} = undef;
910 ? split( /\//, $expiration )
911 : unpack( 'A2 A2', $expiration )
914 croak 'card expiration must be in format MM/YY'
915 if $mm =~ /\D/ || $yy =~ /\D/;
918 $content->{expiry_month} = sprintf( '%02d', $mm ),
919 $content->{expiry_year} = sprintf ('%02d', $yy ),
923 # =head2 parse_phone_number
925 # Set value for field phone_number, from value in field phone
927 # Bambora API expects only digits in a phone number. Strips all non-digit
932 sub parse_phone_number {
934 my $content = $self->{_content};
936 my $phone = $content->{phone}
937 or return $content->{phone_number} = undef;
940 $content->{phone_number} = $phone;
943 # =head2 parse_province
945 # Set value for field province, from value in field state
947 # Outside the US/Canada, API expect province set to the string "--",
948 # otherwise expects a 2 character string. Value for province is
949 # formatted to upper case, and truncated to 2 characters.
955 my $content = $self->{_content};
956 my $country = uc $content->{country};
958 return $content->{province} = '--'
960 && ( $country eq 'US' || $country eq 'CA' );
962 $content->{province} = uc $content->{state};
965 =head2 truncate string, bytes
967 When given a string, truncate to given string length in a unicode safe way
972 my ( $self, $string, $bytes ) = @_;
974 # truncate_egc dies when asked to truncate undef
975 return $string unless $string;
977 truncate_egc( "$string", $bytes, '' );
980 =head2 https_put { headers => \%headers }, post_body
982 Implement a limited interface of https_get from Net::HTTPS::Any
983 for PUT instead of POST -- only implementing current use case of
984 submitting a JSON request body
986 Todo: Properly implement https_put in Net::HTTPS::Any
991 my ( $self, $args, $post_body ) = @_;
993 my $ua = LWP::UserAgent->new;
995 my %headers = %{ $args->{headers} } if ref $args->{headers};
996 for my $k ( keys %headers ) {
997 $ua->default_header( $k => $headers{$k} );
1000 my $url = $self->server().$self->path();
1001 my $res = $ua->put( $url, Content => $post_body );
1003 $self->build_subs(qw/ response_page response_code response_headers/);
1005 my @response_headers =
1006 map { $_ => $res->header( $_ ) }
1007 $res->header_field_names;
1009 $self->response_headers( {@response_headers} );
1010 $self->response_code( $res->code );
1011 $self->response_page( $res->decoded_content );
1013 ( $self->response_page, $self->response_code, @response_headers );
1018 Mitch Jackson <mitch@freeside.biz>
1020 =head1 ADVERTISEMENT
1022 Need a complete, open-source back-office and customer self-service solution?
1023 The Freeside software includes support for credit card and electronic check
1024 processing with IPPay and over 50 other gateways, invoicing, integrated
1025 trouble ticketing, and customer signup and self-service web interfaces.
1027 L<http://freeside.biz/freeside/>
1031 perl(1). L<Business::OnlinePayment>.