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
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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-----