New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adds preliminary support for Klarna #1059
Changes from 37 commits
dc22957
226c14c
9a3c9a8
01b41b9
9529a0f
5f5ea74
a5bbde4
f53f4f6
4fc3ab7
0f3fbd1
7959954
1a38ed0
97c28f8
19b7283
1c3909a
510645a
3a19e38
162c413
3320f6c
bed5868
892cd20
bb03426
141296e
a252f16
289a231
d3cf30f
feca1c7
fb3aff9
1f865ed
4d9b9cd
0fbf607
3dde048
9653ff3
320b3c7
57b5241
beb6887
d877732
116cf51
5560cf1
71cd3ce
9a3ce57
d521276
09de314
9d99fc1
95d5880
cae9e8b
2c57418
b53abb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
require File.dirname(__FILE__) + '/klarna/helper.rb' | ||
require File.dirname(__FILE__) + '/klarna/notification.rb' | ||
require 'digest' | ||
|
||
module ActiveMerchant #:nodoc: | ||
module Billing #:nodoc: | ||
module Integrations #:nodoc: | ||
module Klarna | ||
mattr_accessor :service_url | ||
self.service_url = 'https://api.hostedcheckout.io/api/v1/checkout' | ||
|
||
def self.notification(post_body, options = {}) | ||
Notification.new(post_body, options) | ||
end | ||
|
||
def self.return(query_string, options = {}) | ||
Return.new(query_string, options) | ||
end | ||
|
||
def self.cart_items_payload(fields, cart_items) | ||
check_required_fields!(fields) | ||
|
||
payload = fields['purchase_country'].to_s + | ||
fields['purchase_currency'].to_s + | ||
fields['locale'].to_s | ||
|
||
cart_items.each do |item| | ||
payload << item[:type].to_s + | ||
item[:reference].to_s + | ||
item[:quantity].to_s + | ||
item[:unit_price].to_s + | ||
item.fetch(:discount_rate, nil).to_s | ||
end | ||
|
||
payload << fields['merchant_id'].to_s + | ||
fields['merchant_terms_uri'].to_s + | ||
fields['merchant_checkout_uri'].to_s + | ||
fields['merchant_base_uri'].to_s + | ||
fields['merchant_confirmation_uri'].to_s | ||
|
||
payload | ||
end | ||
|
||
def self.sign(fields, cart_items, shared_secret) | ||
payload = cart_items_payload(fields, cart_items) | ||
|
||
digest(payload, shared_secret) | ||
end | ||
|
||
def self.digest(payload, shared_secret) | ||
Digest::SHA256.base64digest(payload + shared_secret.to_s) | ||
end | ||
|
||
private | ||
|
||
def self.check_required_fields!(fields) | ||
%w(purchase_country | ||
purchase_currency | ||
locale | ||
merchant_id | ||
merchant_terms_uri | ||
merchant_checkout_uri | ||
merchant_base_uri | ||
merchant_confirmation_uri).each do |required_field| | ||
raise ArgumentError, "Missing required field #{required_field}" if fields[required_field].nil? | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
module ActiveMerchant #:nodoc: | ||
module Billing #:nodoc: | ||
module Integrations #:nodoc: | ||
module Klarna | ||
class Helper < ActiveMerchant::Billing::Integrations::Helper | ||
mapping :currency, 'purchase_currency' | ||
mapping :return_url, 'merchant_confirmation_uri' | ||
mapping :notify_url, 'merchant_push_uri' | ||
mapping :cancel_return_url, ['merchant_terms_uri', 'merchant_checkout_uri', 'merchant_base_uri'] | ||
mapping :account, 'merchant_id' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there is no mapping for customer or billing address? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It isn’t necessary since I used Would you prefer I used mappings? I find them to be clumsy and indirect in comparison with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's where I'm getting confused. You're using the details from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m following up with Klarna on this issue, but here’s what I’ve got: Here’s an example HTML form that I copy/pasted from a working forward.html.erb page: https://gist.github.com/edward/115f37ba310a2b6f91bb Here’s a doc covering the fields that we send Klarna’s HPP: https://hpp-staging-eu.herokuapp.com/api/doc#!/checkout/POST--version-checkout---format-_post_0 Here’s an example form-maker thing that lets you construct a Klarna-valid form and send it to their checkout: https://hpp-staging-eu.herokuapp.com/example/checkout-form/advanced – I’m unable to make it pass an email, phone number, etc. to Klarna. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it blow up on their end when we try? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the customer info is all valid, there is no effect. If the customer info indicates that there’s a customer trying to purchase something from an unsupported country, it blows up. |
||
mapping :customer, email: 'shipping_address_email' | ||
|
||
def initialize(order, account, options = {}) | ||
super | ||
@shared_secret = options[:credential2] | ||
|
||
add_field('platform_type', application_id) | ||
add_field('test_mode', test?) | ||
end | ||
|
||
def line_item(item) | ||
@line_items ||= [] | ||
@line_items << item | ||
|
||
i = @line_items.size - 1 | ||
|
||
add_field("cart_item-#{i}_type", item.fetch(:type, '')) | ||
add_field("cart_item-#{i}_reference", item.fetch(:reference, '')) | ||
add_field("cart_item-#{i}_name", item.fetch(:name, '')) | ||
add_field("cart_item-#{i}_quantity", item.fetch(:quantity, '')) | ||
add_field("cart_item-#{i}_unit_price", item.fetch(:unit_price, '')) | ||
add_field("cart_item-#{i}_discount_rate", item.fetch(:discount_rate, '')) | ||
add_field("cart_item-#{i}_tax_rate", item.fetch(:tax_rate, '')) | ||
|
||
@fields | ||
end | ||
|
||
def billing_address(billing_fields) | ||
country = billing_fields[:country] | ||
|
||
add_field('purchase_country', country) | ||
add_field('locale', guess_locale_based_on_country(country)) | ||
end | ||
|
||
def shipping_address(shipping_fields) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could these be done via the standard mappings? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’ll look again – I felt this was more readable so I went this way first |
||
add_field('shipping_address_given_name', shipping_fields[:first_name]) | ||
add_field('shipping_address_family_name', shipping_fields[:last_name]) | ||
|
||
street_address = [shipping_fields[:address1], shipping_fields[:address2]].join(', ') | ||
add_field('shipping_address_street_address', street_address) | ||
|
||
add_field('shipping_address_postal_code', shipping_fields[:zip]) | ||
add_field('shipping_address_city', shipping_fields[:city]) | ||
add_field('shipping_address_country', shipping_fields[:country]) | ||
add_field('shipping_address_phone', shipping_fields[:phone]) | ||
end | ||
|
||
def form_fields | ||
sign_fields | ||
|
||
super | ||
end | ||
|
||
def sign_fields | ||
merchant_digest = Klarna.sign(@fields, @line_items, @shared_secret) | ||
add_field('merchant_digest', merchant_digest) | ||
end | ||
|
||
private | ||
|
||
def street_address | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not used? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks |
||
[address1, address2].compact.join(', ') | ||
end | ||
|
||
def guess_locale_based_on_country(country_code) | ||
case country_code | ||
when /no/i | ||
"nb-no" | ||
when /fi/i | ||
"fi-fi" | ||
when /se/i | ||
"sv-se" | ||
else | ||
raise StandardError, "Unable to guess locale based on country #{country_code}" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about just defaulting to something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd still prefer a sane default vs an exception There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you suggest a default or an approach to determining one? This really is exceptional behaviour here – would we not want to fail fast on something like this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Umm, which country has the biggest population? ¯(°_o)/¯ idunnolol There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It really is an exceptional case, but I can set it to 'sv-se' so as to not bail out. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're currently setting There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we make a reasonable guess using geoip? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You’re right – that’s my mistake; I’ll change this to actually be the country where the purchase is happening. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @bslobodin no, guessing locales is a super tricky thing. I really do feel like this is an exceptional case. I’d rather set it to be 'sv-se' since the worst-case scenario there is that it will at least work, but might make the buyer guess some fields. I’m going to see if I can also set this locale to be 'uk-en' or 'us-en' and see what happens. These docs show an English-language screen but this doc does not mention English as being a supported locale. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried setting it to values like 'us-en', 'uk-en', 'se-en' and the HPP just crashes. I’m asking Klarna. |
||
end | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
require 'net/http' | ||
require 'json' | ||
|
||
module ActiveMerchant #:nodoc: | ||
module Billing #:nodoc: | ||
module Integrations #:nodoc: | ||
module Klarna | ||
class Notification < ActiveMerchant::Billing::Integrations::Notification | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This still needs to be complete, or removed if Klarna doesn't support Notifications. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct. This is still a WIP; confirming this behaviour now – just wanted to get the rest of it sorted first. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’ve filled this in. Yay notifications! |
||
def initialize(post, options = {}) | ||
super | ||
@shared_secret = @options[:credential2] | ||
end | ||
|
||
def complete? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this used anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, in Shopify’s OffSitePaymentGateway There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked and couldn't find any? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My bad – I was thinking of something else. I’ll remove this. Should we change the template generated by AM to not provide this method stub? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I see where it’s used now – it was in the default generated test suite. Since it’s pretty much the closest thing I have to an expected API, I filled it in. I’ll update this and the test. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah gotcha, maybe leave it then if it's part of some convention. |
||
status == 'Complete' | ||
end | ||
|
||
def item_id | ||
params["reservation"] | ||
end | ||
|
||
def transaction_id | ||
params["reference"] | ||
end | ||
|
||
def received_at | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this used anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, in the generated test suite. (Same answer for the rest of these comments. I’ll remove them.) Should we remove this from the generated test suite? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that you mention it, I don't know what the right thing to do is. I know we don't use some of these, but perhaps others have grown to expect them. I'm still leaning towards smallest possible footprint for initial implementation and if someone needs these in the future, PRs are welcomed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m cool with removing them, but I feel like if we’re going to do that, then we should update the generators, or else we’re just perpetuating this issue and making the next gateway implementor’s job more complex. @odorcicd what do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I spoke with Denis in person and his recommendation was to let it be and leave it in. Should we decide to remove it, it should be removed from the generators too, but we should consult with the Spree guys who are also contributors to AM. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
params["completed_at"] | ||
end | ||
|
||
def payer_email | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this used anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure – was filling in the generated template and was asking myself the same question. Is there a particular API to which I should be writing to or should I just stick to what Shopify needs? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMHO it makes sense to only have methods that are called, if other users of AM find a need to extract these from the notification, that can be done in a separate PR, but at least then we'll know it's being used somewhere. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool. We should probably remove these from the generated test suite to streamline implementations. I’ll make a note. |
||
params["billing_address"]["email"] | ||
end | ||
|
||
def receiver_email | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this used anywhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Same as others) |
||
params["shipping_address"]["email"] | ||
end | ||
|
||
def currency | ||
params["purchase_currency"] | ||
end | ||
|
||
def gross | ||
amount = Float(gross_cents) / 100 | ||
sprintf("%.2f", amount) | ||
end | ||
|
||
def gross_cents | ||
params["cart"]["total_price_including_tax"] | ||
end | ||
|
||
def status | ||
case params['status'] | ||
when 'checkout_complete' | ||
'Complete' | ||
else | ||
params['status'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We generally expect this to return one of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I felt odd about writing this too. I figured that since I don’t actually know what statuses map to Pending or Failed, I shouldn’t guess here. |
||
end | ||
end | ||
|
||
def acknowledge(authcode = nil) | ||
Verifier.new(@options[:authorization_header], @raw, @shared_secret).verify | ||
end | ||
|
||
private | ||
|
||
def parse(post) | ||
@raw = post.to_s | ||
@params = JSON.parse(post) | ||
end | ||
|
||
class Verifier | ||
attr_reader :header, :payload, :digest, :shared_secret | ||
def initialize(header, payload, shared_secret) | ||
@header, @payload, @shared_secret = header, payload, shared_secret | ||
|
||
@digest = extract_digest | ||
end | ||
|
||
def verify | ||
digest_matches? | ||
end | ||
|
||
private | ||
|
||
def extract_digest | ||
match = header.match(/^Klarna (?<digest>.+)$/) | ||
match && match[:digest] | ||
end | ||
|
||
def digest_matches? | ||
Klarna.digest(payload, shared_secret) == digest | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,6 +10,8 @@ | |
end | ||
|
||
require 'test/unit' | ||
require 'minitest/autorun' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like I should probably remove this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this staying, and if so, what are the implications for the other bits in AM? |
||
|
||
require 'money' | ||
require 'mocha/version' | ||
if(Mocha::VERSION.split(".")[1].to_i < 12) | ||
|
@@ -61,7 +63,6 @@ class SimpleTestGateway < ActiveMerchant::Billing::Gateway | |
class SubclassGateway < SimpleTestGateway | ||
end | ||
|
||
|
||
module ActiveMerchant | ||
module Assertions | ||
AssertionClass = RUBY_VERSION > '1.9' ? MiniTest::Assertion : Test::Unit::AssertionFailedError | ||
|
@@ -72,7 +73,7 @@ def assert_field(field, value) | |
end | ||
end | ||
|
||
# Allows the testing of you to check for negative assertions: | ||
# Allows testing of negative assertions: | ||
# | ||
# # Instead of | ||
# assert !something_that_is_false | ||
|
@@ -91,7 +92,7 @@ def assert_false(boolean, message = nil) | |
end | ||
end | ||
|
||
# A handy little assertion to check for a successful response: | ||
# An assertion of a successful response: | ||
# | ||
# # Instead of | ||
# assert response.success? | ||
|
@@ -231,7 +232,6 @@ def symbolize_keys(hash) | |
end | ||
|
||
module ActionViewHelperTestHelper | ||
|
||
def self.included(base) | ||
base.send(:include, ActiveMerchant::Billing::Integrations::ActionViewHelper) | ||
base.send(:include, ActionView::Helpers::FormHelper) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These used to be aligned?