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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +51 -0
  3. data/.tool-versions +1 -0
  4. data/AGENTS.md +13 -0
  5. data/CHANGELOG.md +68 -0
  6. data/Gemfile +17 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +377 -0
  9. data/Rakefile +32 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/certs/Swish_Merchant_TestCertificate_1234679304.csr +27 -0
  13. data/certs/Swish_Merchant_TestCertificate_1234679304.key +52 -0
  14. data/certs/Swish_Merchant_TestCertificate_1234679304.p12 +0 -0
  15. data/certs/Swish_Merchant_TestCertificate_1234679304.pem +98 -0
  16. data/certs/Swish_Merchant_TestSigningCertificate_1234679304.csr +27 -0
  17. data/certs/Swish_Merchant_TestSigningCertificate_1234679304.key +52 -0
  18. data/certs/Swish_Merchant_TestSigningCertificate_1234679304.p12 +0 -0
  19. data/certs/Swish_Merchant_TestSigningCertificate_1234679304.pem +98 -0
  20. data/certs/Swish_TLS_RootCA.pem +22 -0
  21. data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.csr +27 -0
  22. data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.key +52 -0
  23. data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.p12 +0 -0
  24. data/certs/Swish_TechnicalSupplier_TestCertificate_9870474641.pem +98 -0
  25. data/lib/swoosh/callback/controller.rb +31 -0
  26. data/lib/swoosh/callback.rb +35 -0
  27. data/lib/swoosh/certificates.rb +92 -0
  28. data/lib/swoosh/configuration.rb +54 -0
  29. data/lib/swoosh/errors.rb +100 -0
  30. data/lib/swoosh/payment.rb +101 -0
  31. data/lib/swoosh/payment_request.rb +53 -0
  32. data/lib/swoosh/qr_code.rb +36 -0
  33. data/lib/swoosh/railtie.rb +41 -0
  34. data/lib/swoosh/test.rb +116 -0
  35. data/lib/swoosh/token_store.rb +66 -0
  36. data/lib/swoosh/version.rb +5 -0
  37. data/lib/swoosh.rb +172 -0
  38. 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swoosh
4
+ VERSION = "0.2.0"
5
+ end