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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5e1ff99da9824d739c402761ed91dc9ce7bdccc560d6c5dbb90b6c30165e8938
|
|
4
|
+
data.tar.gz: ef0293add94df86faba164c06f1c89dde456051546e4eab6e36814089f2a3f3e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 857fe5ebeebdf6361f6a9c0463c42c31e353b0a8c4aff520a1e859861fa427d70698d136b293e52e7ffaa85c0217a2fc92dabefd23b8035d08c1e43a577dcfc6
|
|
7
|
+
data.tar.gz: cec84320121d5e938c06ebb1af0ca12b197a572bd9d39e47e28c4a41a984275da8c421be6f71a0428c67d2fe7dcdcc03f4b65882bb1460108c00e6e6dc00f487
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-minitest
|
|
3
|
+
- rubocop-rake
|
|
4
|
+
|
|
5
|
+
AllCops:
|
|
6
|
+
TargetRubyVersion: 3.2
|
|
7
|
+
NewCops: enable
|
|
8
|
+
SuggestExtensions: false
|
|
9
|
+
Exclude:
|
|
10
|
+
- "bin/**/*"
|
|
11
|
+
- "public/**/*"
|
|
12
|
+
- "log/**/*"
|
|
13
|
+
- "tmp/**/*"
|
|
14
|
+
- "vendor/**/*"
|
|
15
|
+
# Rails-generated scaffolding; follows rails-omakase, not this gem's style.
|
|
16
|
+
- "test/dummy/**/*"
|
|
17
|
+
- "db/schema.rb"
|
|
18
|
+
- "db/migrate/**/*"
|
|
19
|
+
|
|
20
|
+
Layout/LineLength:
|
|
21
|
+
Max: 120
|
|
22
|
+
|
|
23
|
+
Metrics/AbcSize:
|
|
24
|
+
Max: 20
|
|
25
|
+
|
|
26
|
+
Metrics/MethodLength:
|
|
27
|
+
Max: 16
|
|
28
|
+
|
|
29
|
+
Metrics/BlockLength:
|
|
30
|
+
Max: 30
|
|
31
|
+
|
|
32
|
+
# A public API's signature is its documentation: `generate_payment(amount, **)`
|
|
33
|
+
# tells a caller nothing about what it accepts.
|
|
34
|
+
Style/ArgumentsForwarding:
|
|
35
|
+
Enabled: false
|
|
36
|
+
|
|
37
|
+
Style/Documentation:
|
|
38
|
+
Enabled: false
|
|
39
|
+
|
|
40
|
+
# A test that reads back several fields of one payload is one behaviour, not
|
|
41
|
+
# four; splitting it would obscure what it checks.
|
|
42
|
+
Minitest/MultipleAssertions:
|
|
43
|
+
Max: 5
|
|
44
|
+
|
|
45
|
+
Style/StringLiterals:
|
|
46
|
+
Enabled: true
|
|
47
|
+
EnforcedStyle: double_quotes
|
|
48
|
+
|
|
49
|
+
Style/StringLiteralsInInterpolation:
|
|
50
|
+
Enabled: true
|
|
51
|
+
EnforcedStyle: double_quotes
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.7
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Swoosh
|
|
2
|
+
|
|
3
|
+
We're building and maintaining a gem for Swedish Swish payments. Its goals:
|
|
4
|
+
|
|
5
|
+
1. A nice and ergonomic API for creating and querying payments through Swish
|
|
6
|
+
2. First-class Rails support via railties etc
|
|
7
|
+
3. Other ruby frameworks and servers such as Sinatra or Hanami should be able to use it
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
|
|
11
|
+
- Use minitest for tests
|
|
12
|
+
- Tests should be exercised both for the gem itself and its usage through a Rails app
|
|
13
|
+
- Never commit code, I'll do that
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
## [0.2.0] - 2026-09-21
|
|
2
|
+
|
|
3
|
+
- Drop the `http` gem and reach Swish with `net/http` from the standard library. Swoosh now has no
|
|
4
|
+
runtime dependencies.
|
|
5
|
+
- **Verify Swish's server certificate.** The SSL context the gem built set no `verify_mode`, which
|
|
6
|
+
OpenSSL reads as `VERIFY_NONE`, and pushed the bundled DigiCert root into the *client* chain sent
|
|
7
|
+
to Swish rather than into a trust store. The server was therefore never authenticated and the
|
|
8
|
+
merchant certificate would have been handed to anything that answered. `root_ca_path` is now the
|
|
9
|
+
`ca_file` it is documented to be, under `VERIFY_PEER`.
|
|
10
|
+
- Replace `Certificates#ssl_context` with `Certificates#configure_ssl(http)`, and `Main#ssl_context`
|
|
11
|
+
with `Main#connection(uri)`: Net::HTTP builds its own context rather than accepting one.
|
|
12
|
+
`ssl_context` also mutated the memoized `ca_certs` array on every call.
|
|
13
|
+
- Connection failures now raise `Net::HTTP`'s own errors (`Errno::ECONNREFUSED`, `Net::OpenTimeout`,
|
|
14
|
+
`SocketError`) rather than `HTTP::ConnectionError`. Requests inherit Net::HTTP's 60 second open and
|
|
15
|
+
read timeouts, where http.rb applied none.
|
|
16
|
+
- Add `Swoosh.cancel_payment, which withdraws a `CREATED` payment request so an abandoned checkout
|
|
17
|
+
stops occupying the payer's three minutes. Cancelling also drops the stored m-commerce token.
|
|
18
|
+
- Raise `Swoosh::PaymentNotCancellable` (RP07, the payer accepted first) and
|
|
19
|
+
`Swoosh::PaymentAlreadyCancelled` (RP08, a second cancel) rather than one `RequestError`: Swish
|
|
20
|
+
reports both as a 422 differing only by a code, and they call for opposite responses.
|
|
21
|
+
- Pick the error class from Swish's errorCode as well as the status, via `ResponseError.for`. An
|
|
22
|
+
unrecognised code still raises `RequestError`, so a code Swish adds later stays rescuable.
|
|
23
|
+
- Add `TokenStore#delete`, used when a payment can no longer be paid. A store that predates it and
|
|
24
|
+
answers only `read`/`write` is skipped rather than raising.
|
|
25
|
+
|
|
26
|
+
- `generate_payment` returns a `Swoosh::Payment` carrying the id and the m-commerce token instead of
|
|
27
|
+
the response body, which was empty on success.
|
|
28
|
+
- Add `Swoosh.find_payment`, `Payment#app_switch_url`, `Payment#qr_code` and status predicates.
|
|
29
|
+
- Raise `Swoosh::RequestError` / `Swoosh::ServerError` on 4xx/5xx, carrying Swish's errorCode,
|
|
30
|
+
with `Swoosh::PaymentNotFound` for 404 so a reconciliation sweep can skip what will never resolve.
|
|
31
|
+
- Add `Swoosh::Callback`, a plain module any framework can include, and `Swoosh::Callback::Controller`,
|
|
32
|
+
the Rails concern that supplies only `request.body.read`.
|
|
33
|
+
- Add an opt-in token store (`Rails.cache` by default) for the m-commerce token, which Swish issues once.
|
|
34
|
+
- Add `swoosh/test` with callback payload builders and WebMock stubs for host applications.
|
|
35
|
+
|
|
36
|
+
- Build the payment payload from arguments instead of hardcoded Swish test values. `generate_payment`
|
|
37
|
+
now takes `amount` positionally and the rest as keywords, and omits absent fields rather than
|
|
38
|
+
sending null.
|
|
39
|
+
- Add `payee_alias`, `callback_url` and `currency` configuration. `payee_alias` is per application
|
|
40
|
+
with a per-call override; `callback_url` is per call, with an optional configured default.
|
|
41
|
+
|
|
42
|
+
- Find certificates by name: `swish_test.p12` / `swish_production.p12` in a configurable `cert_dir`.
|
|
43
|
+
Staging falls back to the certificates bundled with the gem; production raises rather than falling back.
|
|
44
|
+
- Add `Swoosh::Configuration` and `Swoosh.configure`, so the core no longer depends on Rails.
|
|
45
|
+
- Rework the railtie around `config.swoosh.{environment,cert_dir,cert_password,root_ca_path}`, defaulting
|
|
46
|
+
to staging outside `Rails.env.production?`.
|
|
47
|
+
- Add a dummy Rails application in `test/dummy` and a second suite that drives the gem through it.
|
|
48
|
+
`rake` now runs `test:gem` (Rails absent) and `test:rails` in separate processes.
|
|
49
|
+
|
|
50
|
+
- Require Ruby >= 3.2; develop and test against Ruby 4.0.7.
|
|
51
|
+
- Bump `http` to ~> 6.0, and the development dependencies (rake, rubocop, rubocop-minitest, minitest).
|
|
52
|
+
- Replace `pry` with `debug`.
|
|
53
|
+
- Add `vcr` + `webmock`, and record the payment-request flow against the Swish staging playground (MSS)
|
|
54
|
+
into `test/cassettes/`, so the suite exercises a real 201 response offline.
|
|
55
|
+
- Load `rubocop-minitest` and `rubocop-rake` as RuboCop plugins (they were installed but never enabled).
|
|
56
|
+
- Refresh the bundled Swish test certificates. The previous merchant certificates expired in 2022 and
|
|
57
|
+
were encrypted with `pbeWithSHA1And40BitRC2-CBC`, which OpenSSL 3 refuses to parse without the legacy
|
|
58
|
+
provider. The current bundle is valid until 2027-09-11 and uses PBES2/PBKDF2/AES-256-CBC.
|
|
59
|
+
- Replace the `Swish_TLS_RootCA.pem` root CA with DigiCert Global Root G2. Both `mss.cpc.getswish.net`
|
|
60
|
+
and `cpc.getswish.net` now chain to G2; the previous DigiCert Global Root CA no longer verifies them.
|
|
61
|
+
- Add the Swish technical supplier test certificate (`Swish_TechnicalSupplier_TestCertificate_9870474641`).
|
|
62
|
+
- Fix `Main#generate_payment` to accept the `message` argument it passes on to `#data`.
|
|
63
|
+
- Make the certificate directory, filename and password injectable via `Main.new`.
|
|
64
|
+
- Drop the dead `Main#client` method (it referenced an undefined `Client` and `@private_key`).
|
|
65
|
+
|
|
66
|
+
## [0.1.0] - 2021-10-22
|
|
67
|
+
|
|
68
|
+
- Initial release
|
data/Gemfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
# Specify your gem's dependencies in swoosh.gemspec
|
|
6
|
+
gemspec
|
|
7
|
+
|
|
8
|
+
gem "debug", "~> 1.11"
|
|
9
|
+
gem "minitest", "~> 6.0"
|
|
10
|
+
gem "puma", "~> 7.0"
|
|
11
|
+
gem "rails", "~> 8.1"
|
|
12
|
+
gem "rake", "~> 13.4"
|
|
13
|
+
gem "rubocop", "~> 1.91"
|
|
14
|
+
gem "rubocop-minitest", "~> 0.40"
|
|
15
|
+
gem "rubocop-rake", "~> 0.7"
|
|
16
|
+
gem "vcr", "~> 6.4"
|
|
17
|
+
gem "webmock", "~> 3.26"
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Johan Halse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Swoosh
|
|
2
|
+
|
|
3
|
+
Swish payments for Ruby, with first-class Rails support.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "swoosh"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Certificates
|
|
12
|
+
|
|
13
|
+
Swish authenticates merchants with a mutual-TLS client certificate issued by your
|
|
14
|
+
bank. Swoosh finds it **by name**, in a directory you choose:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
config/certs/
|
|
18
|
+
├── swish_test.p12 # staging (Swish calls this environment MSS)
|
|
19
|
+
└── swish_production.p12 # production
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Drop the bundle your bank issued into that directory under the name matching the
|
|
23
|
+
environment it belongs to. Nothing else to configure.
|
|
24
|
+
|
|
25
|
+
Two conveniences worth knowing:
|
|
26
|
+
|
|
27
|
+
- **Staging needs no setup at all.** If no `swish_test.p12` is present, Swoosh
|
|
28
|
+
falls back to the Swish test certificates bundled with the gem, so a fresh app
|
|
29
|
+
can talk to the staging playground immediately.
|
|
30
|
+
- **Production never falls back.** A missing `swish_production.p12` raises
|
|
31
|
+
`Swoosh::CertificateError` naming the path it looked in, rather than quietly
|
|
32
|
+
running against staging.
|
|
33
|
+
|
|
34
|
+
The DigiCert root CA that signs the Swish endpoints ships with the gem, so you
|
|
35
|
+
don't need to supply one. Override it with `root_ca_path` if that ever changes.
|
|
36
|
+
It is the trust anchor Swoosh verifies *Swish* by, under `VERIFY_PEER` -- the
|
|
37
|
+
merchant certificate authenticates you to them, and this authenticates them to
|
|
38
|
+
you. Point it at the wrong root and the handshake fails rather than falling back
|
|
39
|
+
to the system store.
|
|
40
|
+
|
|
41
|
+
## Rails
|
|
42
|
+
|
|
43
|
+
The railtie reads `config.swoosh.*`. Every setting is optional:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# config/environments/development.rb
|
|
47
|
+
Rails.application.configure do
|
|
48
|
+
# Development and test default to :test (staging). Switch to :production when
|
|
49
|
+
# you want to exercise real certificates by hand.
|
|
50
|
+
config.swoosh.environment = :production
|
|
51
|
+
|
|
52
|
+
config.swoosh.cert_dir = Rails.root.join("config/certs")
|
|
53
|
+
config.swoosh.cert_password = ENV["SWISH_CERT_PASSWORD"]
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Setting | Default |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `environment` | `:production` in `Rails.env.production?`, otherwise `:test` |
|
|
60
|
+
| `cert_dir` | `Rails.root.join("config/certs")` |
|
|
61
|
+
| `cert_password` | `"swish"` |
|
|
62
|
+
| `root_ca_path` | the DigiCert root bundled with the gem |
|
|
63
|
+
| `payee_alias` | none -- your Swish merchant number |
|
|
64
|
+
| `callback_url` | none -- see below |
|
|
65
|
+
| `currency` | `"SEK"` |
|
|
66
|
+
| `token_store` | `Rails.cache`; `nil` disables |
|
|
67
|
+
| `token_ttl` | 300 seconds |
|
|
68
|
+
|
|
69
|
+
`environment` accepts `:test`, `:staging` (an alias for `:test`) and
|
|
70
|
+
`:production`, as symbols or strings. Anything else raises
|
|
71
|
+
`Swoosh::ConfigurationError` at boot.
|
|
72
|
+
|
|
73
|
+
## Creating a payment
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
payment = Swoosh.generate_payment(
|
|
77
|
+
199,
|
|
78
|
+
callback_url: swish_callbacks_url,
|
|
79
|
+
payee_payment_reference: order.ocr, # your matching key, e.g. "ABC123"
|
|
80
|
+
message: "Order #{order.number}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
order.update!(swish_payment_id: payment.id) # persist before you render anything
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`amount` is the only positional argument. Everything else is a keyword:
|
|
87
|
+
|
|
88
|
+
| Keyword | |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `callback_url` | HTTPS URL Swish posts the result to. Required. |
|
|
91
|
+
| `payee_alias` | overrides the configured merchant number |
|
|
92
|
+
| `message` | shown to the payer |
|
|
93
|
+
| `payer_alias` | the payer's number. **Omit it** for the Swish-app flow, which is what issues a token |
|
|
94
|
+
| `payee_payment_reference` | your own reference: `a-z A-Z 0-9 -_.+*/`, 1-36 characters |
|
|
95
|
+
| `payer_ssn`, `age_limit` | passed through to Swish when given |
|
|
96
|
+
| `currency` | defaults to `SEK` |
|
|
97
|
+
|
|
98
|
+
Anything left out is omitted from the request rather than sent as null, which
|
|
99
|
+
matters for `payer_alias`: sending it null breaks the Swish-app flow.
|
|
100
|
+
|
|
101
|
+
A 4xx or 5xx raises `Swoosh::RequestError` / `Swoosh::ServerError`, carrying
|
|
102
|
+
Swish's own code:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
rescue Swoosh::RequestError => e
|
|
106
|
+
e.status # => 422
|
|
107
|
+
e.error_code # => "BE18"
|
|
108
|
+
e.error_message # => "Payer alias is invalid"
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### payee_alias vs callback_url
|
|
113
|
+
|
|
114
|
+
Your merchant number is the same everywhere, so configure it once. A call can
|
|
115
|
+
still override it if you bill through more than one merchant.
|
|
116
|
+
|
|
117
|
+
The callback is different: one application usually has several kinds of payment
|
|
118
|
+
that want different endpoints, so **`callback_url` is per call**. Configure
|
|
119
|
+
`callback_url` only if every payment in the app shares one endpoint -- leave it
|
|
120
|
+
unset and Swoosh requires each call to name one.
|
|
121
|
+
|
|
122
|
+
## Presenting the payment
|
|
123
|
+
|
|
124
|
+
Both flows use the same token, so you decide at render time, not request time:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# same device -- hand the payer to the Swish app
|
|
128
|
+
redirect_to payment.app_switch_url(return_url: order_url(order))
|
|
129
|
+
|
|
130
|
+
# other device -- show a QR code
|
|
131
|
+
send_data payment.qr_code(size: 300), type: "image/png"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`qr_code` accepts `size:` (minimum 300, which Swish enforces), `format:` (`png`,
|
|
135
|
+
`jpg`, `svg`), `border:` and `transparent:`. It is served from Swish's public QR
|
|
136
|
+
host, so it needs no certificate.
|
|
137
|
+
|
|
138
|
+
Supply `payer_alias` instead and Swish notifies that number directly; no token is
|
|
139
|
+
issued and both methods above raise.
|
|
140
|
+
|
|
141
|
+
The `return_url` is a **UX return only**. It tells you nothing about whether the
|
|
142
|
+
payment succeeded, and in-app browsers drop it routinely.
|
|
143
|
+
|
|
144
|
+
## Receiving the callback
|
|
145
|
+
|
|
146
|
+
Swish POSTs the payment to your `callback_url` when it settles. **Swish does not
|
|
147
|
+
sign these**, so anyone who guesses a payment id can post one. Verify before you
|
|
148
|
+
act:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class Swish::CallbacksController < ApplicationController
|
|
152
|
+
include Swoosh::Callback::Controller
|
|
153
|
+
skip_forgery_protection
|
|
154
|
+
|
|
155
|
+
def create
|
|
156
|
+
payment = swoosh_verified_payment # re-fetched from Swish over mTLS
|
|
157
|
+
Order.find_by!(swish_payment_id: payment.id).settle! if payment.paid?
|
|
158
|
+
head :ok
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| | |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `swoosh_callback` | the POSTed body, parsed. Unauthenticated -- fine for logging |
|
|
166
|
+
| `swoosh_verified_payment` | asks Swish directly. Act on this one |
|
|
167
|
+
|
|
168
|
+
Outside Rails, include the plain module and say where the body comes from:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
class CallbackHandler
|
|
172
|
+
include Swoosh::Callback
|
|
173
|
+
|
|
174
|
+
def initialize(body) = @body = body
|
|
175
|
+
def swoosh_callback_body = @body
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`Swoosh::Callback::Controller` is only that module plus `request.body.read`.
|
|
180
|
+
|
|
181
|
+
## Cancelling
|
|
182
|
+
|
|
183
|
+
A payment request the payer never answers occupies the full three minutes.
|
|
184
|
+
Cancel it and the payer's Swish app stops offering it immediately:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
Swoosh.cancel_payment(order.swish_payment_id) # => Payment, status CANCELLED
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Only a `CREATED` payment can be cancelled, which makes this an inherently racy
|
|
191
|
+
call: on a page people abandon by paying, the payer often accepts somewhere
|
|
192
|
+
between your decision to cancel and the request landing. Swish reports both
|
|
193
|
+
outcomes as a 422 differing only by a code in the body, so Swoosh gives them
|
|
194
|
+
separate classes:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
begin
|
|
198
|
+
Swoosh.cancel_payment(order.swish_payment_id)
|
|
199
|
+
rescue Swoosh::PaymentAlreadyCancelled
|
|
200
|
+
# RP08. Already where you wanted it -- usually nothing to do.
|
|
201
|
+
rescue Swoosh::PaymentNotCancellable
|
|
202
|
+
# RP07. The payer got there first. This order may be PAID.
|
|
203
|
+
order.settle!(Swoosh.find_payment(order.swish_payment_id))
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**A failed cancel is never licence to treat an order as abandoned.** `RP07`
|
|
208
|
+
means the payment left `CREATED`, and the overwhelmingly likely reason is that
|
|
209
|
+
it was paid. Poll before you decide anything.
|
|
210
|
+
|
|
211
|
+
One wrinkle worth knowing, because it looks like a bug: a *successful* cancel
|
|
212
|
+
comes back carrying `errorCode` `"RP08"` while its status is `CANCELLED`. Swish
|
|
213
|
+
populates that field on more than failures, so read `status` -- or
|
|
214
|
+
`payment.cancelled?` -- rather than treating a present `error_code` as trouble.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
payment = Swoosh.cancel_payment(id)
|
|
218
|
+
payment.cancelled? # => true
|
|
219
|
+
payment.error? # => false -- ERROR is a different status
|
|
220
|
+
payment.error_code # => "RP08"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Cancelling also drops any stored m-commerce token, so `Swoosh.token_for` won't
|
|
224
|
+
hand back something that still renders a QR nobody can pay.
|
|
225
|
+
|
|
226
|
+
## Polling
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
payment = Swoosh.find_payment(order.swish_payment_id)
|
|
230
|
+
payment.paid? # also declined? cancelled? error?
|
|
231
|
+
payment.pending? # still CREATED
|
|
232
|
+
payment.error_code # e.g. "TM01" when the payer ran out of time
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Swish delivers each callback exactly once and never retries.** A deploy or a
|
|
236
|
+
brief 502 loses it permanently, so polling is not optional:
|
|
237
|
+
|
|
238
|
+
- Your waiting page should poll **your** app, reading your own database. Never
|
|
239
|
+
block a request on Swish.
|
|
240
|
+
- A background job sweeps anything still `CREATED` after ~3 minutes and calls
|
|
241
|
+
`find_payment`. That closes the gap.
|
|
242
|
+
|
|
243
|
+
Since the callback controller also ends at a verified `Payment`, both paths run
|
|
244
|
+
the same settling code -- make it idempotent once.
|
|
245
|
+
|
|
246
|
+
Statuses are `CREATED`, `PAID`, `DECLINED`, `ERROR`, `CANCELLED`. A payer has
|
|
247
|
+
three minutes to accept; after that Swish reports `ERROR` with code `TM01`.
|
|
248
|
+
|
|
249
|
+
### Reconciliation
|
|
250
|
+
|
|
251
|
+
Swoosh deliberately ships no sweep: it would need your database and your
|
|
252
|
+
scheduler. It gives you `find_payment` and error classes precise enough to build
|
|
253
|
+
one in a dozen lines.
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
class ReconcileSwishPayments
|
|
257
|
+
def call
|
|
258
|
+
Order.awaiting_swish.where(created_at: ..3.minutes.ago).find_each do |order|
|
|
259
|
+
settle(order)
|
|
260
|
+
rescue Swoosh::PaymentNotFound
|
|
261
|
+
order.record_swish_lookup_miss! # do NOT treat as "never happened"
|
|
262
|
+
rescue Swoosh::ServerError
|
|
263
|
+
next # transient; next sweep picks it up
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def settle(order)
|
|
270
|
+
payment = Swoosh.find_payment(order.swish_payment_id)
|
|
271
|
+
order.settle!(payment) if payment.terminal?
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Rescue per row, not per batch: one bad row shouldn't abort the sweep.
|
|
277
|
+
|
|
278
|
+
**A 404 is not proof the payment doesn't exist.** The integration guide defines
|
|
279
|
+
it as "the Payment request was not found, *or it was not created by the
|
|
280
|
+
merchant*" -- so polling a real, possibly paid payment with the wrong merchant
|
|
281
|
+
certificate returns exactly the same `Swoosh::PaymentNotFound`.
|
|
282
|
+
|
|
283
|
+
Retrying won't fix either case, but the right response differs, and you can't
|
|
284
|
+
tell them apart from the response alone. So don't write a 404 off as a payment
|
|
285
|
+
that never happened. Record it and alert when the rate climbs: a handful usually
|
|
286
|
+
means bad rows, while a spike almost always means a certificate or environment
|
|
287
|
+
mismatch, where every one of those payments is real.
|
|
288
|
+
|
|
289
|
+
`ServerError` (5xx) is separated from `RequestError` (4xx) so you retry the
|
|
290
|
+
failures worth retrying and nothing else.
|
|
291
|
+
|
|
292
|
+
### Payments don't expire out from under you
|
|
293
|
+
|
|
294
|
+
The three-minute limit is the payer's deadline to accept, not a retention
|
|
295
|
+
window. A payment request stays queryable long after it settles -- Swish rejects
|
|
296
|
+
an original as too old for refunds only past 13 months. Age alone will not turn a
|
|
297
|
+
poll into a 404.
|
|
298
|
+
|
|
299
|
+
### Staging settles payments for you
|
|
300
|
+
|
|
301
|
+
MSS moves a payment to `PAID` on its own, with no payer involved. Convenient for
|
|
302
|
+
exercising the happy path, but it means staging never shows you `DECLINED`, and
|
|
303
|
+
never shows you the `ERROR`/`TM01` timeout that a real unanswered payment
|
|
304
|
+
produces. Don't read "it went `PAID` in staging" as proof your flow handles the
|
|
305
|
+
other four statuses.
|
|
306
|
+
|
|
307
|
+
## What to persist
|
|
308
|
+
|
|
309
|
+
Just the id:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
add_column :orders, :swish_payment_id, :string
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Everything else comes back from `find_payment`. The one exception is the
|
|
316
|
+
m-commerce token, which Swish returns once and never again -- so if you need it
|
|
317
|
+
in a later request (a reload, an AJAX-rendered QR), Swoosh can keep it for you:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
Swoosh.token_for(order.swish_payment_id) # => token, or nil
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`nil` means "create a fresh payment request", never an error -- whether the token
|
|
324
|
+
expired, was never stored, or was dropped because you cancelled the payment. In
|
|
325
|
+
Rails this is backed by `Rails.cache` by default with a 5 minute TTL, since the
|
|
326
|
+
token dies with the payment window anyway. Set `config.swoosh.token_store = nil` to turn it off.
|
|
327
|
+
Losing a token costs the payer one extra tap; nothing about it is load-bearing.
|
|
328
|
+
|
|
329
|
+
Statuses are deliberately **not** cached -- caching a `CREATED` would make your
|
|
330
|
+
poller report stale results for a payment that has already settled.
|
|
331
|
+
|
|
332
|
+
## Dependencies
|
|
333
|
+
|
|
334
|
+
None. Swoosh talks to Swish with `net/http` from the standard library, so adding
|
|
335
|
+
it to an application pulls in nothing else.
|
|
336
|
+
|
|
337
|
+
## Without Rails
|
|
338
|
+
|
|
339
|
+
Nothing in the core depends on Rails, so Sinatra, Hanami and plain Ruby work the
|
|
340
|
+
same way:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
Swoosh.configure do |config|
|
|
344
|
+
config.environment = :production
|
|
345
|
+
config.cert_dir = "config/certs"
|
|
346
|
+
config.cert_password = ENV["SWISH_CERT_PASSWORD"]
|
|
347
|
+
config.payee_alias = "1231181189"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
Swoosh.generate_payment(100, callback_url: "https://example.com/swish", message: "Kaffe")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Development
|
|
354
|
+
|
|
355
|
+
$ bin/setup
|
|
356
|
+
$ bundle exec rake
|
|
357
|
+
|
|
358
|
+
`rake` runs two suites in separate processes: `rake test:gem` exercises the gem
|
|
359
|
+
with Rails absent from the process, and `rake test:rails` drives it through the
|
|
360
|
+
dummy application in `test/dummy`.
|
|
361
|
+
|
|
362
|
+
HTTP is recorded with VCR against the Swish staging playground. To re-record,
|
|
363
|
+
delete the cassette in `test/cassettes` and run the suite again.
|
|
364
|
+
|
|
365
|
+
### The certificate canaries
|
|
366
|
+
|
|
367
|
+
Four tests in `test/swoosh_test.rb` compare the bundled certificates against the
|
|
368
|
+
real clock, and they are meant to. They fail 30 days before a certificate lapses,
|
|
369
|
+
because a suite that stays green on an expired bundle would ship a staging
|
|
370
|
+
fallback that cannot complete a TLS handshake.
|
|
371
|
+
|
|
372
|
+
Don't freeze or travel time around them. When one fires, the message tells you
|
|
373
|
+
what to renew and where from. Nothing else in the suite depends on the clock.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
[MIT](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
# Two suites, deliberately in separate processes:
|
|
7
|
+
#
|
|
8
|
+
# test:gem - the gem on its own, with Rails absent from the process, which
|
|
9
|
+
# is what keeps Sinatra/Hanami/plain Ruby usage honest.
|
|
10
|
+
# test:rails - the same gem driven through the dummy application in test/dummy.
|
|
11
|
+
namespace :test do
|
|
12
|
+
Rake::TestTask.new(:gem) do |t|
|
|
13
|
+
t.libs << "test" << "lib"
|
|
14
|
+
t.test_files = FileList["test/*_test.rb"]
|
|
15
|
+
t.warning = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Rake::TestTask.new(:rails) do |t|
|
|
19
|
+
t.libs << "test" << "lib"
|
|
20
|
+
t.test_files = FileList["test/rails/*_test.rb"]
|
|
21
|
+
t.warning = false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Run the gem suite and the Rails suite"
|
|
26
|
+
task test: ["test:gem", "test:rails"]
|
|
27
|
+
|
|
28
|
+
require "rubocop/rake_task"
|
|
29
|
+
|
|
30
|
+
RuboCop::RakeTask.new
|
|
31
|
+
|
|
32
|
+
task default: %i[test rubocop]
|
data/bin/console
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "swoosh"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
# Drop a `binding.break` anywhere in lib/ and run this console to step through it.
|
|
11
|
+
require "debug"
|
|
12
|
+
|
|
13
|
+
require "irb"
|
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE REQUEST-----
|
|
2
|
+
MIIEpzCCAo8CAQAwTDELMAkGA1UEBhMCU0UxEzARBgNVBAgMClNvbWUtU3RhdGUx
|
|
3
|
+
EzARBgNVBAoMCjU1NjA5OTc5ODIxEzARBgNVBAMMCjEyMzQ2NzkzMDQwggIiMA0G
|
|
4
|
+
CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDOic6GesD7N6i2T7STxWEHbiYjucuy
|
|
5
|
+
22WQ3qF2GdB4VmwAUH/Bv7w1tA7FLqqVQSIzLfPAXUSWsGlbmyIu2bBXpSqrT7km
|
|
6
|
+
ng/qjgTVRQnBxPoAD4xh5Y3vmYwMvTLGa3Cllq/4PIrlr486trHbWNxDs4sgp2KZ
|
|
7
|
+
bDaSfGbTBD7jksKZvQZDVvxBKtp3v9Ul7A6PuftYGrAI2MDEeQiWboV++I5e6TtI
|
|
8
|
+
r9aLBrmHZOP9q3X8vBIvJagp4Q73aBrT1oQG51Ob2Ppmtz8Kxwje1+YSmZ2YIl7h
|
|
9
|
+
E3PJjCyxf8pR6VkdZfkkpDKvqn6QHLCGRG9qSIGRu1xlUmP8Q6FdnM+BPBlt3Rhl
|
|
10
|
+
mdsf/+Z2HrqBjLG6fZxYqEr0Jg0xdgYNFjgp29FJwgLIrVD6RCWQd0oYlk/GtKG5
|
|
11
|
+
OfERPY+5ur7RGG+YH6ren1WPkI0RItNLb9BF1KWBwPPyQmUBysMaR94sDYpDNrF+
|
|
12
|
+
HF/F+sNXAowTYHir0159s988IkGl9SSU6Vy6CgmFG6sd7a8XA/XqrUfM5KwGIyOd
|
|
13
|
+
PIm1lROGcL8LMhpXOcukMlOZuUCuXb0yMvKzEgaFANNO5CgPTdF1R4GXy09LjdlS
|
|
14
|
+
9lJbaFGdkGAP4DbT8Kw6nOHtpga0NWtw2CuoQGAwl19oqr9Wqv9agZ40qjC/33sJ
|
|
15
|
+
6YEg830kOV3AyQIDAQABoBYwFAYJKoZIhvcNAQkHMQcMBXN3aXNoMA0GCSqGSIb3
|
|
16
|
+
DQEBCwUAA4ICAQC/q0jw4Wm5uKMhVdsvY1eCfRwUphaNXujhSYpLMsIW0jVvp1LW
|
|
17
|
+
jNcWnc+mBY4U5hGLzRGz7SfMYGyqfshBObj0mZIknP7nw0tpMkRY9RzSvTgEhxIp
|
|
18
|
+
AFv0WpwuwE+UDRugPRxfQe/9Zp2ijIQ0LE/KUKz8X6wyjS30quqBCTHOc7VecUln
|
|
19
|
+
IdYO4RCcTqQSPFtOwgs+JQRHAY0JbUsOS+3JIvLi0dipYdRyPIDWdHjGVV/AMCcQ
|
|
20
|
+
8ReHYQJqEG3iebHYTTDjXU97wh6OAk5vKZKzZk7p9AyR1kbT1hWyi1SzpMZc/ff9
|
|
21
|
+
pAWBzWvwcAvmn0UJE7wAFKChVfQdwINrr0OUXn1pQ0oenqxbKWhsrHUDS88i9BBl
|
|
22
|
+
sewPl3MyEax8Vh6BArSM6KFW4wKZShO2GOdMJAOi+LY/g/osiamlbv6FmIZAIGNe
|
|
23
|
+
4/X2vKXnDbaNbEkmFaMkhz5jj9uN3BbYNHq9QgzGgkOx3SBJIm84TIBf4kHDoI/l
|
|
24
|
+
10smpu2DLW2g5UvNZ7gfqJkXcbsq1VANq95u5evJykjAQyGL5pOr8oDPTAQEzw49
|
|
25
|
+
t2cK2N5PyPU9lDXC5ZOek+oOa14FObqGe34W9a2umzZBHuRrYY2fSiOPEB0kmbHN
|
|
26
|
+
GhF6ugSWfvQ5oxglIlhwOdpSn3Q1v3fXon50uOAKq2QbYprdeRyBdVAu5A==
|
|
27
|
+
-----END CERTIFICATE REQUEST-----
|