swoosh 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +51 -0
- data/.tool-versions +1 -0
- data/AGENTS.md +13 -0
- data/CHANGELOG.md +68 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +377 -0
- data/Rakefile +32 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/certs/Swish_Merchant_TestCertificate_1234679304.csr +27 -0
- data/certs/Swish_Merchant_TestCertificate_1234679304.key +52 -0
- data/certs/Swish_Merchant_TestCertificate_1234679304.p12 +0 -0
- data/certs/Swish_Merchant_TestCertificate_1234679304.pem +98 -0
- data/certs/Swish_Merchant_TestSigningCertificate_1234679304.csr +27 -0
- data/certs/Swish_Merchant_TestSigningCertificate_1234679304.key +52 -0
- data/certs/Swish_Merchant_TestSigningCertificate_1234679304.p12 +0 -0
- data/certs/Swish_Merchant_TestSigningCertificate_1234679304.pem +98 -0
- data/certs/Swish_TLS_RootCA.pem +22 -0
- data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.csr +27 -0
- data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.key +52 -0
- data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.p12 +0 -0
- data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.pem +98 -0
- data/lib/swoosh/callback/controller.rb +31 -0
- data/lib/swoosh/callback.rb +35 -0
- data/lib/swoosh/certificates.rb +92 -0
- data/lib/swoosh/configuration.rb +54 -0
- data/lib/swoosh/errors.rb +100 -0
- data/lib/swoosh/payment.rb +101 -0
- data/lib/swoosh/payment_request.rb +53 -0
- data/lib/swoosh/qr_code.rb +36 -0
- data/lib/swoosh/railtie.rb +41 -0
- data/lib/swoosh/test.rb +116 -0
- data/lib/swoosh/token_store.rb +66 -0
- data/lib/swoosh/version.rb +5 -0
- data/lib/swoosh.rb +172 -0
- metadata +86 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Swoosh
|
|
6
|
+
# Resolves and loads the certificates for one environment.
|
|
7
|
+
#
|
|
8
|
+
# Drop your bank-issued bundle into the configured directory named after the
|
|
9
|
+
# environment it belongs to:
|
|
10
|
+
#
|
|
11
|
+
# config/certs/swish_production.p12
|
|
12
|
+
# config/certs/swish_test.p12
|
|
13
|
+
#
|
|
14
|
+
# In :test we fall back to the Swish test certificates bundled with the gem,
|
|
15
|
+
# so a fresh app can talk to the Swish staging playground with no setup at
|
|
16
|
+
# all. :production never falls back -- a missing bundle there is an error.
|
|
17
|
+
class Certificates
|
|
18
|
+
BUNDLED_DIR = File.expand_path("../../certs", __dir__)
|
|
19
|
+
BUNDLED_TEST_CERT = File.join(BUNDLED_DIR, "Swish_Merchant_TestCertificate_1234679304.p12")
|
|
20
|
+
BUNDLED_ROOT_CA = File.join(BUNDLED_DIR, "Swish_TLS_RootCA.pem")
|
|
21
|
+
|
|
22
|
+
def initialize(configuration)
|
|
23
|
+
@configuration = configuration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cert
|
|
27
|
+
@cert ||= OpenSSL::PKCS12.new(File.read(cert_path), @configuration.cert_password)
|
|
28
|
+
rescue OpenSSL::PKCS12::PKCS12Error => e
|
|
29
|
+
raise CertificateError, "Could not open #{cert_path} (wrong cert_password?): #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def root_ca
|
|
33
|
+
@root_ca ||= OpenSSL::X509::Certificate.new(File.read(root_ca_path))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Net::HTTP builds its own SSL context internally rather than accepting one,
|
|
37
|
+
# so the pieces go onto the connection:
|
|
38
|
+
#
|
|
39
|
+
# cert / key the merchant certificate Swish authenticates us by
|
|
40
|
+
# extra_chain_cert the Nordea intermediates that vouch for it, which Swish
|
|
41
|
+
# needs because it does not hold them itself
|
|
42
|
+
# ca_file the root we verify *Swish* by -- the other direction
|
|
43
|
+
#
|
|
44
|
+
# Both directions matter. Sending the merchant credential to whatever
|
|
45
|
+
# answers on the far end, unverified, would be worse than not sending it.
|
|
46
|
+
def configure_ssl(http)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
http.cert = cert.certificate
|
|
49
|
+
http.key = cert.key
|
|
50
|
+
http.extra_chain_cert = cert.ca_certs
|
|
51
|
+
http.ca_file = root_ca_path
|
|
52
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
53
|
+
http
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# <cert_dir>/swish_test.p12 or <cert_dir>/swish_production.p12
|
|
57
|
+
def filename
|
|
58
|
+
"swish_#{@configuration.environment}.p12"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cert_path
|
|
62
|
+
@cert_path ||= resolve_cert_path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def root_ca_path
|
|
66
|
+
@configuration.root_ca_path&.to_s || BUNDLED_ROOT_CA
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def resolve_cert_path
|
|
72
|
+
configured = configured_cert_path
|
|
73
|
+
|
|
74
|
+
return configured if configured && File.exist?(configured)
|
|
75
|
+
return BUNDLED_TEST_CERT unless @configuration.production?
|
|
76
|
+
|
|
77
|
+
raise CertificateError, missing_certificate_message(configured)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def configured_cert_path
|
|
81
|
+
dir = @configuration.cert_dir
|
|
82
|
+
File.join(dir.to_s, filename) if dir
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def missing_certificate_message(configured)
|
|
86
|
+
return "No Swish production certificate found: set `cert_dir` and place #{filename} in it." unless configured
|
|
87
|
+
|
|
88
|
+
"No Swish production certificate at #{configured}. Place your bank-issued bundle there, " \
|
|
89
|
+
"or point `cert_dir` somewhere else."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swoosh
|
|
4
|
+
# Framework-agnostic configuration. The Rails railtie maps `config.swoosh.*`
|
|
5
|
+
# onto this; Sinatra/Hanami/plain Ruby can use `Swoosh.configure` directly.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Swish calls the staging environment "MSS". We accept :staging as an alias
|
|
8
|
+
# so `config.swoosh.environment = :staging` reads naturally in an app.
|
|
9
|
+
ENVIRONMENTS = { test: :test, staging: :test, production: :production }.freeze
|
|
10
|
+
|
|
11
|
+
DEFAULT_CERT_PASSWORD = "swish"
|
|
12
|
+
DEFAULT_CURRENCY = "SEK"
|
|
13
|
+
|
|
14
|
+
# A payment request has to be accepted within three minutes, after which the
|
|
15
|
+
# token is worthless, so there is no reason to keep one longer.
|
|
16
|
+
DEFAULT_TOKEN_TTL = 300
|
|
17
|
+
|
|
18
|
+
attr_reader :environment
|
|
19
|
+
attr_accessor :cert_dir, :cert_password, :root_ca_path,
|
|
20
|
+
:payee_alias, :callback_url, :currency,
|
|
21
|
+
:token_store, :token_ttl
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@environment = :test
|
|
25
|
+
@cert_dir = nil
|
|
26
|
+
@cert_password = DEFAULT_CERT_PASSWORD
|
|
27
|
+
@root_ca_path = nil
|
|
28
|
+
|
|
29
|
+
# The merchant number is the same for a whole codebase, so it belongs in
|
|
30
|
+
# configuration. callback_url has no default on purpose: leave it unset
|
|
31
|
+
# and every call must name one, which is usually what you want.
|
|
32
|
+
@payee_alias = nil
|
|
33
|
+
@callback_url = nil
|
|
34
|
+
@currency = DEFAULT_CURRENCY
|
|
35
|
+
|
|
36
|
+
# Opt-in. The railtie points this at Rails.cache; nil just disables the
|
|
37
|
+
# lookup, it never breaks a payment.
|
|
38
|
+
@token_store = nil
|
|
39
|
+
@token_ttl = DEFAULT_TOKEN_TTL
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def environment=(value)
|
|
43
|
+
key = value.to_s.downcase.to_sym
|
|
44
|
+
@environment = ENVIRONMENTS.fetch(key) do
|
|
45
|
+
raise ConfigurationError,
|
|
46
|
+
"Unknown Swoosh environment #{value.inspect}. Valid values: #{ENVIRONMENTS.keys.join(", ")}."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def production?
|
|
51
|
+
environment == :production
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Swoosh
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
class CertificateError < Error; end
|
|
9
|
+
|
|
10
|
+
# Swish answered, but not with success. A 422 carries a JSON array of
|
|
11
|
+
# {errorCode, errorMessage, additionalInformation}; other statuses may not.
|
|
12
|
+
class ResponseError < Error
|
|
13
|
+
attr_reader :status, :errors, :body
|
|
14
|
+
|
|
15
|
+
def initialize(status:, body:)
|
|
16
|
+
@status = status
|
|
17
|
+
@body = body
|
|
18
|
+
@errors = parse(body)
|
|
19
|
+
super(build_message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def error_code = errors.first&.fetch("errorCode", nil)
|
|
23
|
+
def error_message = errors.first&.fetch("errorMessage", nil)
|
|
24
|
+
|
|
25
|
+
# Builds the most specific error the response justifies. Swish reports
|
|
26
|
+
# several quite different problems as the same 422, so status alone is not
|
|
27
|
+
# enough to pick a class -- the errorCode in the body decides.
|
|
28
|
+
def self.for(status:, body:)
|
|
29
|
+
subclass_for(status, body).new(status: status, body: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.subclass_for(status, body)
|
|
33
|
+
return ServerError if status >= 500
|
|
34
|
+
return PaymentNotFound if status == 404
|
|
35
|
+
|
|
36
|
+
ERROR_CLASSES_BY_CODE.fetch(error_code_in(body), RequestError)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.error_code_in(body)
|
|
40
|
+
parsed = JSON.parse(body.to_s)
|
|
41
|
+
parsed = parsed.first if parsed.is_a?(Array)
|
|
42
|
+
parsed["errorCode"] if parsed.is_a?(Hash)
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse(body)
|
|
50
|
+
parsed = JSON.parse(body.to_s)
|
|
51
|
+
parsed.is_a?(Array) ? parsed : [parsed]
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_message
|
|
57
|
+
return "Swish responded #{status}: #{body.to_s[0, 200]}" if errors.empty?
|
|
58
|
+
|
|
59
|
+
described = errors.map { |e| "#{e["errorCode"]} #{e["errorMessage"]}".strip }.join(", ")
|
|
60
|
+
"Swish responded #{status}: #{described}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 4xx -- the request was wrong. A bug, or a payer number that can't be used.
|
|
65
|
+
class RequestError < ResponseError; end
|
|
66
|
+
|
|
67
|
+
# 404 -- Swish has no such payment *for this certificate*. Per the integration
|
|
68
|
+
# guide it means "not found, or it was not created by the merchant", so it is
|
|
69
|
+
# not proof the payment doesn't exist: the same 404 comes back for a real,
|
|
70
|
+
# possibly paid payment polled with the wrong merchant certificate.
|
|
71
|
+
#
|
|
72
|
+
# Retrying won't help, but treating one as "this payment never happened" is
|
|
73
|
+
# only safe once you know the certificate is right. In bulk, this almost
|
|
74
|
+
# always means a configuration mismatch rather than N missing payments.
|
|
75
|
+
class PaymentNotFound < RequestError; end
|
|
76
|
+
|
|
77
|
+
# 422 RP07 -- the payment is no longer CREATED, so there is nothing to
|
|
78
|
+
# withdraw. This is the race worth designing for rather than an edge case: the
|
|
79
|
+
# payer accepted somewhere between your decision to cancel and the PATCH
|
|
80
|
+
# landing, which on a checkout page people abandon by paying is common.
|
|
81
|
+
#
|
|
82
|
+
# The payment may well be PAID, so a failed cancel is never licence to treat
|
|
83
|
+
# the order as abandoned. Poll before you decide anything.
|
|
84
|
+
class PaymentNotCancellable < RequestError; end
|
|
85
|
+
|
|
86
|
+
# 422 RP08 -- already cancelled. The state you asked for already holds, so
|
|
87
|
+
# this is the one cancel failure that is usually safe to rescue and ignore.
|
|
88
|
+
class PaymentAlreadyCancelled < RequestError; end
|
|
89
|
+
|
|
90
|
+
# 5xx -- Swish had a problem. Worth retrying.
|
|
91
|
+
class ServerError < ResponseError; end
|
|
92
|
+
|
|
93
|
+
# Declared once the classes exist, so the mapping reads as a single table.
|
|
94
|
+
# An unrecognised code falls back to RequestError rather than raising, so a
|
|
95
|
+
# code Swish adds later degrades to something still rescuable.
|
|
96
|
+
ERROR_CLASSES_BY_CODE = {
|
|
97
|
+
"RP07" => PaymentNotCancellable,
|
|
98
|
+
"RP08" => PaymentAlreadyCancelled
|
|
99
|
+
}.freeze
|
|
100
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Swoosh
|
|
7
|
+
# A Swish payment request, however you came by it: the response to creating
|
|
8
|
+
# one, a poll, or a callback body.
|
|
9
|
+
#
|
|
10
|
+
# Only #id and #token can't be re-derived -- everything else comes back from
|
|
11
|
+
# Swoosh.find_payment.
|
|
12
|
+
class Payment
|
|
13
|
+
# Swish stops changing a payment once it reaches one of these.
|
|
14
|
+
TERMINAL_STATUSES = %w[PAID DECLINED ERROR CANCELLED].freeze
|
|
15
|
+
CREATED = "CREATED"
|
|
16
|
+
|
|
17
|
+
ATTRIBUTES = {
|
|
18
|
+
id: "id",
|
|
19
|
+
status: "status",
|
|
20
|
+
amount: "amount",
|
|
21
|
+
currency: "currency",
|
|
22
|
+
message: "message",
|
|
23
|
+
callback_url: "callbackUrl",
|
|
24
|
+
payee_alias: "payeeAlias",
|
|
25
|
+
payer_alias: "payerAlias",
|
|
26
|
+
payee_payment_reference: "payeePaymentReference",
|
|
27
|
+
payment_reference: "paymentReference",
|
|
28
|
+
date_created: "dateCreated",
|
|
29
|
+
date_paid: "datePaid",
|
|
30
|
+
error_code: "errorCode",
|
|
31
|
+
error_message: "errorMessage"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :token, :attributes
|
|
35
|
+
|
|
36
|
+
# Swish only ever hands the m-commerce token back in the 201 response
|
|
37
|
+
# header, so it has to be carried alongside rather than read from the body.
|
|
38
|
+
def initialize(attributes = {}, token: nil)
|
|
39
|
+
@attributes = attributes.transform_keys(&:to_s)
|
|
40
|
+
@token = token
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.from_json(json, token: nil)
|
|
44
|
+
new(json.nil? || json.empty? ? {} : JSON.parse(json), token: token)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The body Swish POSTs to your callback URL is a Payment Request object.
|
|
48
|
+
def self.from_callback(json)
|
|
49
|
+
from_json(json)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ATTRIBUTES.each do |name, key|
|
|
53
|
+
define_method(name) { @attributes[key] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def paid? = status == "PAID"
|
|
57
|
+
def declined? = status == "DECLINED"
|
|
58
|
+
def cancelled? = status == "CANCELLED"
|
|
59
|
+
def error? = status == "ERROR"
|
|
60
|
+
|
|
61
|
+
def terminal? = TERMINAL_STATUSES.include?(status)
|
|
62
|
+
def pending? = !terminal?
|
|
63
|
+
|
|
64
|
+
# Present for m-commerce (no payer_alias), absent for e-commerce.
|
|
65
|
+
def token? = !token.nil?
|
|
66
|
+
|
|
67
|
+
# Opens the Swish app with this payment preloaded. Same-device flow.
|
|
68
|
+
# return_url is where Swish (or BankID) sends the payer afterwards; it is a
|
|
69
|
+
# UX return only, and never evidence that the payment succeeded.
|
|
70
|
+
def app_switch_url(return_url:)
|
|
71
|
+
require_token!(:app_switch_url)
|
|
72
|
+
|
|
73
|
+
"swish://paymentrequest?token=#{token}&callbackurl=#{CGI.escape(return_url.to_s)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Image bytes for the other-device flow. Served from Swish's public QR host.
|
|
77
|
+
def qr_code(**options)
|
|
78
|
+
require_token!(:qr_code)
|
|
79
|
+
|
|
80
|
+
QrCode.generate(token, **options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_h = @attributes.dup
|
|
84
|
+
|
|
85
|
+
def ==(other)
|
|
86
|
+
other.is_a?(Payment) && other.attributes == attributes && other.token == token
|
|
87
|
+
end
|
|
88
|
+
alias eql? ==
|
|
89
|
+
|
|
90
|
+
def hash = [attributes, token].hash
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def require_token!(method)
|
|
95
|
+
return if token?
|
|
96
|
+
|
|
97
|
+
raise Error, "##{method} needs an m-commerce token. Omit payer_alias when creating the " \
|
|
98
|
+
"payment so Swish issues one, or use Swoosh.token_for(id) to recover it."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swoosh
|
|
4
|
+
# Builds the body of a Swish payment request.
|
|
5
|
+
#
|
|
6
|
+
# payee_alias (your merchant number) and callback_url fall back to the
|
|
7
|
+
# configuration when a call doesn't name them; everything else is per-call,
|
|
8
|
+
# because it describes this payment rather than this application.
|
|
9
|
+
class PaymentRequest
|
|
10
|
+
# Swish rejects a payload carrying keys it doesn't expect, so anything left
|
|
11
|
+
# nil is dropped rather than sent as null.
|
|
12
|
+
def initialize(configuration:, amount:, **options)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
@amount = amount
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
payeeAlias: payee_alias,
|
|
21
|
+
callbackUrl: callback_url,
|
|
22
|
+
amount: @amount,
|
|
23
|
+
currency: currency,
|
|
24
|
+
message: @options[:message],
|
|
25
|
+
payerAlias: @options[:payer_alias],
|
|
26
|
+
payeePaymentReference: @options[:payee_payment_reference],
|
|
27
|
+
payerSSN: @options[:payer_ssn],
|
|
28
|
+
ageLimit: @options[:age_limit]
|
|
29
|
+
}.compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def payee_alias
|
|
33
|
+
@options[:payee_alias] || @configuration.payee_alias ||
|
|
34
|
+
raise(ConfigurationError, missing_message("payee_alias", "your Swish merchant number"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def callback_url
|
|
38
|
+
@options[:callback_url] || @configuration.callback_url ||
|
|
39
|
+
raise(ConfigurationError, missing_message("callback_url", "the HTTPS URL Swish posts the result to"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def currency
|
|
43
|
+
@options[:currency] || @configuration.currency
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def missing_message(name, description)
|
|
49
|
+
"Swoosh needs a #{name} (#{description}). Pass #{name}: to the call, " \
|
|
50
|
+
"or set config.swoosh.#{name} once for the whole application."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Swoosh
|
|
8
|
+
# Swish generates payment QR codes from a separate, public host -- no client
|
|
9
|
+
# certificate involved, which is why this doesn't go through the mTLS client.
|
|
10
|
+
module QrCode
|
|
11
|
+
ENDPOINT = "https://mpc.getswish.net/qrg-swish/api/v1/commerce"
|
|
12
|
+
FORMATS = %w[png jpg svg].freeze
|
|
13
|
+
# Swish rejects anything smaller with a 400, for every format.
|
|
14
|
+
MIN_SIZE = 300
|
|
15
|
+
DEFAULT_SIZE = 300
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns the image bytes. Render with `send_data qr, type: "image/png"`.
|
|
20
|
+
def generate(token, size: DEFAULT_SIZE, format: "png", border: 0, transparent: false)
|
|
21
|
+
raise ArgumentError, "Unknown QR format #{format.inspect}. Use #{FORMATS.join(", ")}." unless
|
|
22
|
+
FORMATS.include?(format.to_s)
|
|
23
|
+
raise ArgumentError, "Swish requires a QR size of at least #{MIN_SIZE} (got #{size})." if size < MIN_SIZE
|
|
24
|
+
|
|
25
|
+
response = Net::HTTP.post(
|
|
26
|
+
URI.parse(ENDPOINT),
|
|
27
|
+
JSON.generate({ token: token, size: size, format: format.to_s, border: border, transparent: transparent }),
|
|
28
|
+
"Content-Type" => "application/json"
|
|
29
|
+
)
|
|
30
|
+
raise ResponseError.new(status: response.code.to_i, body: response.body.to_s) unless
|
|
31
|
+
response.is_a?(Net::HTTPSuccess)
|
|
32
|
+
|
|
33
|
+
response.body
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require_relative "callback/controller"
|
|
5
|
+
|
|
6
|
+
module Swoosh
|
|
7
|
+
# Maps `config.swoosh.*` from the host app onto Swoosh::Configuration.
|
|
8
|
+
#
|
|
9
|
+
# # config/environments/development.rb
|
|
10
|
+
# config.swoosh.environment = :staging # default outside production
|
|
11
|
+
# config.swoosh.cert_dir = Rails.root.join("config/certs")
|
|
12
|
+
# config.swoosh.cert_password = ENV["SWISH_CERT_PASSWORD"]
|
|
13
|
+
# config.swoosh.payee_alias = "1231181189" # your merchant number
|
|
14
|
+
class Railtie < Rails::Railtie
|
|
15
|
+
# Passed through only when the app actually set them, so Configuration keeps
|
|
16
|
+
# owning the defaults.
|
|
17
|
+
OPTIONAL_SETTINGS = %i[cert_password root_ca_path payee_alias callback_url currency
|
|
18
|
+
token_store token_ttl].freeze
|
|
19
|
+
|
|
20
|
+
config.swoosh = ActiveSupport::OrderedOptions.new
|
|
21
|
+
|
|
22
|
+
initializer "swoosh.configure" do |app|
|
|
23
|
+
options = app.config.swoosh
|
|
24
|
+
|
|
25
|
+
Swoosh.configure do |swoosh|
|
|
26
|
+
swoosh.environment = options[:environment] || default_environment
|
|
27
|
+
swoosh.cert_dir = options[:cert_dir] || app.root.join("config/certs")
|
|
28
|
+
swoosh.token_store = options.key?(:token_store) ? options[:token_store] : ::Rails.cache
|
|
29
|
+
OPTIONAL_SETTINGS.each do |setting|
|
|
30
|
+
swoosh.public_send(:"#{setting}=", options[setting]) if options.key?(setting)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Development and test talk to the Swish staging playground unless the app
|
|
36
|
+
# says otherwise, so only a real production boot reaches for real money.
|
|
37
|
+
def default_environment
|
|
38
|
+
Rails.env.production? ? :production : :test
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/swoosh/test.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Not loaded with the gem -- require "swoosh/test" from your test helper.
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "../swoosh"
|
|
6
|
+
|
|
7
|
+
module Swoosh
|
|
8
|
+
# Builders and stubs for exercising Swish flows without touching the network.
|
|
9
|
+
module Test
|
|
10
|
+
CALLBACK_DEFAULTS = {
|
|
11
|
+
"payeePaymentReference" => "ABC123",
|
|
12
|
+
"paymentReference" => "A58D39A2632D488EA8F00AA88251336B",
|
|
13
|
+
"callbackUrl" => "https://example.com/swish/callbacks",
|
|
14
|
+
"payerAlias" => "4671234768",
|
|
15
|
+
"payeeAlias" => "1231181189",
|
|
16
|
+
"amount" => 100.0,
|
|
17
|
+
"currency" => "SEK",
|
|
18
|
+
"message" => "",
|
|
19
|
+
"status" => "PAID",
|
|
20
|
+
"dateCreated" => "2026-09-21T08:26:45.746Z",
|
|
21
|
+
"datePaid" => "2026-09-21T08:27:01.000Z",
|
|
22
|
+
"errorCode" => nil,
|
|
23
|
+
"errorMessage" => nil
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Swish's refusals, verbatim from the staging playground. RP07 is the payer
|
|
27
|
+
# winning the race (the payment is PAID); RP08 is a second cancel.
|
|
28
|
+
CANCEL_REFUSALS = {
|
|
29
|
+
"RP07" => "The payment request can not be cancelled.",
|
|
30
|
+
"RP08" => "The payment request has been cancelled."
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# The body Swish POSTs to your callback URL, shaped like the real thing.
|
|
36
|
+
def callback_payload(id:, status: "PAID", **overrides)
|
|
37
|
+
CALLBACK_DEFAULTS
|
|
38
|
+
.merge("id" => id, "status" => status)
|
|
39
|
+
.merge(error_defaults(status))
|
|
40
|
+
.merge(overrides.transform_keys(&:to_s))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def callback_json(...) = JSON.generate(callback_payload(...))
|
|
44
|
+
|
|
45
|
+
def payment(id: SecureRandom.uuid.delete("-").upcase, status: "CREATED", token: nil, **overrides)
|
|
46
|
+
Payment.new(callback_payload(id: id, status: status, **overrides), token: token)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Requires webmock. Stubs the mTLS GET that find_payment and
|
|
50
|
+
# swoosh_verified_payment make.
|
|
51
|
+
def stub_find_payment(id, status: "PAID", **overrides)
|
|
52
|
+
require "webmock"
|
|
53
|
+
WebMock::API.stub_request(:get, %r{/api/v1/paymentrequests/#{id}\z})
|
|
54
|
+
.to_return(
|
|
55
|
+
status: 200,
|
|
56
|
+
body: callback_json(id: id, status: status, **overrides),
|
|
57
|
+
headers: { "Content-Type" => "application/json" }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stubs creating a payment, including the token header when one is wanted.
|
|
62
|
+
def stub_generate_payment(token: "37ca502428ff40c1b17310787165236f")
|
|
63
|
+
require "webmock"
|
|
64
|
+
headers = { "Content-Length" => "0" }
|
|
65
|
+
headers["PaymentRequestToken"] = token if token
|
|
66
|
+
WebMock::API.stub_request(:put, %r{/api/v2/paymentrequests/})
|
|
67
|
+
.to_return(status: 201, body: "", headers: headers)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Stubs the PATCH that cancel_payment makes. Swish answers with the payment
|
|
71
|
+
# in its new state, so this returns a CANCELLED one by default.
|
|
72
|
+
def stub_cancel_payment(id, status: "CANCELLED", **overrides)
|
|
73
|
+
require "webmock"
|
|
74
|
+
WebMock::API.stub_request(:patch, %r{/api/v1/paymentrequests/#{id}\z})
|
|
75
|
+
.to_return(
|
|
76
|
+
status: 200,
|
|
77
|
+
body: callback_json(id: id, status: status, **overrides),
|
|
78
|
+
headers: { "Content-Type" => "application/json" }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Stubs a cancel Swish refuses. Defaults to RP07, the race worth testing:
|
|
83
|
+
# the payer accepted before your PATCH landed.
|
|
84
|
+
def stub_cancel_payment_refused(id, code: "RP07")
|
|
85
|
+
require "webmock"
|
|
86
|
+
WebMock::API.stub_request(:patch, %r{/api/v1/paymentrequests/#{id}\z})
|
|
87
|
+
.to_return(
|
|
88
|
+
status: 422,
|
|
89
|
+
body: JSON.generate(
|
|
90
|
+
[{ "errorCode" => code, "errorMessage" => CANCEL_REFUSALS.fetch(code),
|
|
91
|
+
"additionalInformation" => nil }]
|
|
92
|
+
),
|
|
93
|
+
headers: { "Content-Type" => "application/json" }
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stub_qr_code(body: "\x89PNG\r\n\x1a\n")
|
|
98
|
+
require "webmock"
|
|
99
|
+
WebMock::API.stub_request(:post, Swoosh::QrCode::ENDPOINT)
|
|
100
|
+
.to_return(status: 200, body: body, headers: { "Content-Type" => "image/png" })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Swish fills errorCode on more than just ERROR: a successfully cancelled
|
|
104
|
+
# payment carries RP08 while its status is CANCELLED, not ERROR.
|
|
105
|
+
def error_defaults(status)
|
|
106
|
+
case status
|
|
107
|
+
when "ERROR"
|
|
108
|
+
{ "errorCode" => "TM01", "errorMessage" => "Swish timed out before the payment was started" }
|
|
109
|
+
when "CANCELLED"
|
|
110
|
+
{ "errorCode" => "RP08", "errorMessage" => CANCEL_REFUSALS.fetch("RP08") }
|
|
111
|
+
else
|
|
112
|
+
{}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Swoosh
|
|
4
|
+
# Optional, best-effort storage for the m-commerce token, which Swish hands
|
|
5
|
+
# back exactly once and never again.
|
|
6
|
+
#
|
|
7
|
+
# Backed by anything with ActiveSupport::Cache's read/write signature, so
|
|
8
|
+
# `Rails.cache` drops straight in; `delete` is used when present and skipped
|
|
9
|
+
# when not. A miss is normal and must never raise: the caller creates a fresh
|
|
10
|
+
# payment request instead. Losing a token costs the payer one extra tap, and
|
|
11
|
+
# it expires with the payment window anyway.
|
|
12
|
+
class TokenStore
|
|
13
|
+
PREFIX = "swoosh:token:"
|
|
14
|
+
|
|
15
|
+
# A cache that blips must not take a payment down with it. A cache that was
|
|
16
|
+
# wired up wrong should be loud, though, so these come straight back out
|
|
17
|
+
# rather than looking like a miss.
|
|
18
|
+
PROGRAMMING_ERRORS = [NameError, NoMethodError, TypeError, ArgumentError].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(backend, ttl:)
|
|
21
|
+
@backend = backend
|
|
22
|
+
@ttl = ttl
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write(payment_id, token)
|
|
26
|
+
return if @backend.nil? || token.nil?
|
|
27
|
+
|
|
28
|
+
@backend.write(key(payment_id), token, expires_in: @ttl)
|
|
29
|
+
token
|
|
30
|
+
rescue *PROGRAMMING_ERRORS
|
|
31
|
+
raise
|
|
32
|
+
rescue StandardError
|
|
33
|
+
token
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Called when a payment can no longer be paid. Best-effort like the rest:
|
|
37
|
+
# the TTL would clear it anyway, so a backend that refuses is not a failure.
|
|
38
|
+
#
|
|
39
|
+
# `delete` is the one method a store may leave out. It arrived after read
|
|
40
|
+
# and write, so a hand-rolled store written against the older contract
|
|
41
|
+
# doesn't have it, and a missing one must not turn a completed cancel into
|
|
42
|
+
# an exception -- unlike read and write, whose absence is a real miswiring.
|
|
43
|
+
def delete(payment_id)
|
|
44
|
+
return if @backend.nil? || !@backend.respond_to?(:delete)
|
|
45
|
+
|
|
46
|
+
@backend.delete(key(payment_id))
|
|
47
|
+
nil
|
|
48
|
+
rescue *PROGRAMMING_ERRORS
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(payment_id)
|
|
55
|
+
return nil if @backend.nil?
|
|
56
|
+
|
|
57
|
+
@backend.read(key(payment_id))
|
|
58
|
+
rescue *PROGRAMMING_ERRORS
|
|
59
|
+
raise
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def key(payment_id) = "#{PREFIX}#{payment_id}"
|
|
65
|
+
end
|
|
66
|
+
end
|