dpay 0.1.0 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +100 -4
  4. data/lib/dpay/blik/blik_alias_type.rb +2 -2
  5. data/lib/dpay/blik/blik_service.rb +0 -9
  6. data/lib/dpay/card/card_service.rb +21 -5
  7. data/lib/dpay/client.rb +3 -1
  8. data/lib/dpay/errors.rb +13 -6
  9. data/lib/dpay/internal/checksum_calculator.rb +27 -2
  10. data/lib/dpay/internal/error_mapper.rb +11 -2
  11. data/lib/dpay/internal/php.rb +6 -0
  12. data/lib/dpay/ipn/ipn_event.rb +2 -0
  13. data/lib/dpay/ipn/ipn_type.rb +1 -0
  14. data/lib/dpay/payment/payment_service.rb +4 -4
  15. data/lib/dpay/payment/register_payment_request.rb +92 -13
  16. data/lib/dpay/payment/registered_payment.rb +19 -0
  17. data/lib/dpay/payment/return_urls.rb +3 -1
  18. data/lib/dpay/payment/transaction_type.rb +1 -3
  19. data/lib/dpay/recurring/recurring_registration.rb +162 -0
  20. data/lib/dpay/recurring/recurring_registration_info.rb +45 -0
  21. data/lib/dpay/recurring/recurring_retry_result.rb +43 -0
  22. data/lib/dpay/recurring/recurring_service.rb +59 -0
  23. data/lib/dpay/recurring/recurring_status.rb +42 -0
  24. data/lib/dpay/refund/refund_service.rb +10 -4
  25. data/lib/dpay/version.rb +1 -1
  26. data/lib/dpay/webhook/event_page.rb +25 -0
  27. data/lib/dpay/webhook/event_service.rb +76 -0
  28. data/lib/dpay/webhook/webhook_event.rb +42 -0
  29. data/lib/dpay/webhook/webhook_event_type.rb +43 -0
  30. data/lib/dpay/webhook/webhook_target.rb +45 -0
  31. data/lib/dpay/webhook/webhook_verifier.rb +110 -0
  32. data/lib/dpay.rb +11 -3
  33. data/sig/dpay/blik/blik_alias_type.rbs +0 -1
  34. data/sig/dpay/blik/blik_service.rbs +0 -1
  35. data/sig/dpay/card/card_service.rbs +1 -1
  36. data/sig/dpay/client.rbs +2 -0
  37. data/sig/dpay/errors.rbs +6 -2
  38. data/sig/dpay/internal/checksum_calculator.rbs +8 -1
  39. data/sig/dpay/internal/error_mapper.rbs +1 -0
  40. data/sig/dpay/internal/php.rbs +2 -0
  41. data/sig/dpay/ipn/ipn_event.rbs +2 -0
  42. data/sig/dpay/ipn/ipn_type.rbs +1 -0
  43. data/sig/dpay/payment/register_payment_request.rbs +13 -1
  44. data/sig/dpay/payment/registered_payment.rbs +6 -0
  45. data/sig/dpay/payment/return_urls.rbs +2 -2
  46. data/sig/dpay/payment/transaction_type.rbs +0 -2
  47. data/sig/dpay/recurring/recurring_registration.rbs +45 -0
  48. data/sig/dpay/{blik/blik_recurring_registration_info.rbs → recurring/recurring_registration_info.rbs} +13 -3
  49. data/sig/dpay/recurring/recurring_retry_result.rbs +24 -0
  50. data/sig/dpay/recurring/recurring_service.rbs +15 -0
  51. data/sig/dpay/recurring/recurring_status.rbs +27 -0
  52. data/sig/dpay/refund/refund_service.rbs +2 -2
  53. data/sig/dpay/webhook/event_page.rbs +14 -0
  54. data/sig/dpay/webhook/event_service.rbs +21 -0
  55. data/sig/dpay/webhook/webhook_event.rbs +24 -0
  56. data/sig/dpay/webhook/webhook_event_type.rbs +23 -0
  57. data/sig/dpay/webhook/webhook_target.rbs +15 -0
  58. data/sig/dpay/webhook/webhook_verifier.rbs +37 -0
  59. metadata +26 -10
  60. data/lib/dpay/blik/blik_recurring_registration.rb +0 -74
  61. data/lib/dpay/blik/blik_recurring_registration_info.rb +0 -26
  62. data/lib/dpay/blik/blik_recurring_status.rb +0 -28
  63. data/sig/dpay/blik/blik_recurring_registration.rbs +0 -24
  64. data/sig/dpay/blik/blik_recurring_status.rbs +0 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1f660bb376f9c3a692b0fcd3b243384fb4265a826afb4bce089928687c2ac7e
4
- data.tar.gz: ac17460ac4601eb85224609d373a45870eed71a38f129fdf94098fc1d31361d7
3
+ metadata.gz: 96829708b670a52f5790c2e96db83946582206a72dd1c81cbd3a1744306b7fe9
4
+ data.tar.gz: 9928ed9b7eb9c6b6da934ad204e6c7afb1aff3d44b12088b33382c74144cb39c
5
5
  SHA512:
6
- metadata.gz: 3ac8bf1585647900f6723835332ededbaa0b3b6e2901ffe9c23e55fa6688cc448d7c3d4eb7bcc62bcaf4a08f9b4728ddf103a20094801cbd06ce4a3f69e28a1c
7
- data.tar.gz: 493b7bf534857df4fc6dcbebbba508ad58b1ea91770bfaf424b67bd02728229f79ab9354532ccca9be078c49d3ad0edfd62c71a08680ace6672e199080038abd
6
+ metadata.gz: fc7432ce768eeaf7c9795af7177bbdc4b4036bbbcb0c14c390c58c6ad078f40c9889eb8997580f1d44b26ae26df35d28380dba208a81172c3471f094c29977d3
7
+ data.tar.gz: 04d7f864c82d9f4a29600a61edebc77673c7b2429900018d514d23683f7e9e326b990950298ac17dc76e71f176d2a90e6fbb1d3d6f24a4d4dcdeeaa2455c288a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ projekt stosuje [Semantic Versioning](https://semver.org/lang/pl/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - wydanie razem z wdrożeniem API dpay
11
+
12
+ Wersja wymaga API dpay z tym samym wydaniem (wspólne API płatności cyklicznych, suma kontrolna capture
13
+ i anulowania kart). Zmiany łamiące zgodność są oznaczone jako **BREAKING**.
14
+
15
+ ### Added
16
+
17
+ - `client.recurring` (`DPay::RecurringService`): `status`, `retry` i `cancel` płatności cyklicznej
18
+ (`/api/v1_0/payments/recurring/*`), modele `DPay::RecurringStatus`, `DPay::RecurringRegistrationInfo`,
19
+ `DPay::RecurringRetryResult`.
20
+ - `RegisterPaymentRequest#with_recurring_registration(DPay::RecurringRegistration)` - rejestracja płatności
21
+ cyklicznej (modele O, A i M, `terms_url` wymagany) z kodem BLIK klienta.
22
+ - `RegisterPaymentRequest#with_recurring_alias` - obciążenie zapisanej płatności cyklicznej bez kodu BLIK;
23
+ alias wchodzi do sumy kontrolnej. `#with_client_context` - opcjonalne IP i przeglądarka klienta przy obciążeniu.
24
+ - Webhooki: `DPay::WebhookVerifier.construct_event` i `.verify` (Standard Webhooks, podpis `v1`, tolerancja czasu,
25
+ kilka podpisów i sekretów w czasie rotacji; nagłówki z `Hash` z kluczami `String` lub `Symbol` w dowolnej
26
+ wielkości liter albo w formacie Rack `HTTP_WEBHOOK_ID`), `DPay::WebhookEvent`, `DPay::WebhookEventType`.
27
+ - `client.events` (`DPay::EventService`): historia zdarzeń z filtrami, `list` i `iterate` po stronach
28
+ (`DPay::EventPage`).
29
+ - `DPay::WebhookTarget` - własny adres zdarzeń w rejestracji płatności (`#with_webhook`), zwrocie
30
+ (`refunds.create`) i capture karty (`cards.capture`); `RegisterPaymentRequest#with_reference`.
31
+ - `DPay::ApiError#reason`, `DPay::PaymentRejectedError#error_description` (kod błędu także
32
+ z `additionalInfo.error`), `DPay::RegisteredPayment#recurring_alias` i `#recurring_methods`.
33
+ - `spec/fixtures/api_vectors.json` - wspólne wektory sum kontrolnych i podpisów webhooków wszystkich SDK dpay.
34
+
35
+ ### Changed
36
+
37
+ - **BREAKING** `cards.capture` i `cards.cancel` wysyłają `service` i sumę
38
+ `sha256(operacja|service|transaction_id|amount|hash)` - API odrzuca je bez sumy (401).
39
+ - **BREAKING** `DPay::ReturnUrls`: adres IPN jest opcjonalny (`ipn` może być `nil`); bez niego `url_ipn`
40
+ nie jest wysyłany, a IPN nie przychodzi (wynik przychodzi webhookiem).
41
+ - Kod błędu API (`DPay::ApiError#error_code`) pochodzi z pola `code` (np. `CHECKSUM_REQUIRED`,
42
+ `WEBHOOK_URL_INVALID`), potem z `errorcode`.
43
+ - `ChecksumCalculator#ordered_body` przyjmuje całe body i spłaszcza obiekty zagnieżdżone (np. `webhook`).
44
+
45
+ ### Removed
46
+
47
+ - **BREAKING** `BlikService#recurring_status`, `DPay::BlikRecurringRegistration`, `DPay::BlikRecurringStatus`,
48
+ `DPay::BlikRecurringRegistrationInfo` i `RegisterPaymentRequest#with_register_blik_recurring_alias` - API
49
+ usunęło te endpointy i pole; użyj `client.recurring` i `#with_recurring_registration`.
50
+ - **BREAKING** `BlikAliasType::PAYID` (aliasy OneClick są tylko `UID`), `TransactionType::BLIK_RECURRING`
51
+ i `TransactionType::BIZUM_DIRECT` (API odrzuca je kodem 422).
52
+
53
+ ### Deprecated
54
+
55
+ - `IpnType::CAPTURE`, `IpnEvent#capture?` i `#capture_payment_id` - dpay nie wysyła już IPN typu `capture`;
56
+ użyj zdarzenia `payment.captured`.
57
+
10
58
  ## [0.1.0] - 2026-07-23
11
59
 
12
60
  ### Added
data/README.md CHANGED
@@ -46,9 +46,84 @@ redirect_to payment.redirect_url if payment.redirect_url
46
46
 
47
47
  `DPay::Money.pln(1050)` przyjmuje kwotę w groszach (najmniejszej jednostce), czyli 10,50 PLN.
48
48
  `RegisterPaymentRequest` to budowniczy: każda metoda `with_*` zwraca `self`, więc wywołania można łączyć w łańcuch.
49
+ Adres IPN w `ReturnUrls` jest opcjonalny - bez niego IPN nie przychodzi, a wynik płatności dostaniesz webhookiem.
50
+
51
+ ## Płatności cykliczne
52
+
53
+ Rejestracja idzie razem z płatnością kodem BLIK klienta (kwota `0` - sama zgoda, więcej - opłata inicjalna).
54
+ Kolejne obciążenia wysyła Twój serwer, bez kodu.
55
+
56
+ ```ruby
57
+ urls = DPay::ReturnUrls.new("https://twojsklep.pl/sukces", "https://twojsklep.pl/blad")
58
+
59
+ registration = dpay.payments.register(
60
+ DPay::RegisterPaymentRequest
61
+ .create(DPay::Money.pln(0), DPay::TransactionType::TRANSFERS, urls)
62
+ .with_blik_code(kod_blik, request.user_agent, request.remote_ip)
63
+ .with_recurring_registration(
64
+ DPay::RecurringRegistration
65
+ .create("Abonament Premium", DPay::RecurringRegistration::MODEL_O, "https://twojsklep.pl/regulamin")
66
+ .with_alias("SUB-1234")
67
+ )
68
+ )
69
+ registration.recurring_alias # => "SUB-1234"
70
+
71
+ charge = dpay.payments.register(
72
+ DPay::RegisterPaymentRequest
73
+ .create(DPay::Money.pln(4999), DPay::TransactionType::TRANSFERS, urls)
74
+ .with_recurring_alias("SUB-1234")
75
+ .with_description("Abonament Premium 10/2026")
76
+ )
77
+
78
+ status = dpay.recurring.status("SUB-1234") # status.status: ACTIVE, INACTIVE, UNREGISTERED, EXPIRED, DECLINED
79
+ dpay.recurring.retry(charge.transaction_id) # po odmowie, np. INSUFFICIENT_FUNDS
80
+ dpay.recurring.cancel("SUB-1234", "Rezygnacja klienta")
81
+ ```
82
+
83
+ Model `A` (stała kwota) wymaga `with_frequency`, `with_limit_amt`, `with_tot_limit_amt` (kwoty w groszach),
84
+ `with_expiration_date` i `with_init_date`, model `M` przyjmuje je opcjonalnie, a model `O` ich nie dopuszcza.
85
+ Obciążenie wiąże alias z sumą kontrolną, a anulowanie ma własną sumę - SDK liczy obie.
86
+ Limity API: `status` do 60, `retry` i `cancel` do 30 zapytań na minutę (licznik wspólny z resztą API
87
+ płatności z tego adresu IP) - nie odpytuj statusu w pętli, wynik przychodzi webhookiem.
88
+
89
+ ## Webhooki
90
+
91
+ Zdarzenia (`payment.succeeded`, `refund.failed`, `recurring_payment.canceled` i inne) są podpisane
92
+ (Standard Webhooks). Weryfikuj je na surowym body, przed parsowaniem JSON:
93
+
94
+ ```ruby
95
+ begin
96
+ event = DPay::WebhookVerifier.construct_event(
97
+ request.raw_post,
98
+ request.headers,
99
+ ENV.fetch("DPAY_WEBHOOK_SECRET") # sekret endpointu z panelu (whsec_...); w czasie rotacji tablica sekretów
100
+ )
101
+ rescue DPay::SignatureVerificationError
102
+ return head :bad_request
103
+ end
104
+
105
+ payment = event.object if event.type == DPay::WebhookEventType::PAYMENT_SUCCEEDED # Hash, kwoty w groszach
106
+
107
+ head :ok
108
+ ```
109
+
110
+ Nagłówki podajesz jako `Hash` (albo obiekt z `#each` zwracającym nazwę i wartość) z kluczami `String` lub `Symbol`
111
+ w dowolnej wielkości liter (`"webhook-id"`, `"Webhook-Id"`, `:webhook_id`) albo w formacie Rack (`"HTTP_WEBHOOK_ID"`),
112
+ więc zadziała zarówno `request.headers` w Rails, jak i `request.env` w Rack i Sinatrze. Wartość nagłówka może być
113
+ tablicą - liczy się pierwszy element. Znacznik czasu może odbiegać od zegara serwera najwyżej o 300 sekund
114
+ (`DPay::WebhookVerifier::DEFAULT_TOLERANCE`, inną wartość podasz czwartym argumentem). Sama weryfikacja bez
115
+ parsowania: `DPay::WebhookVerifier.verify` z tymi samymi argumentami.
116
+
117
+ Deduplikuj zdarzenia po `event.id`. Historię zdarzeń (np. po awarii endpointu) pobierzesz przez
118
+ `dpay.events.iterate(types: ["payment.succeeded"]) { |event| ... }` albo stronami przez `dpay.events.list`.
119
+
120
+ Własny adres zdarzeń jednej płatności: `.with_webhook(DPay::WebhookTarget.create("https://twojsklep.pl/webhooks"))`
121
+ (podpisywany sekretem webhooków serwisu), a Twój identyfikator zamówienia w zdarzeniach: `.with_reference("order-1234")`.
49
122
 
50
123
  ## Obsługa IPN
51
124
 
125
+ IPN przychodzi tylko wtedy, gdy podasz adres IPN w `ReturnUrls`.
126
+
52
127
  Brama traktuje jako potwierdzenie dokładnie treść odpowiedzi, nie kod HTTP. Musi to być dokładnie napis
53
128
  `"OK"` (dostępny jako stała `DPay::IpnEvent::ACK`) - jakikolwiek inna treść w body oznacza dla dpay.pl,
54
129
  że powiadomienie nie zostało obsłużone i zostanie wysłane ponownie.
@@ -60,7 +135,7 @@ rescue DPay::SignatureVerificationError
60
135
  return render plain: "Invalid signature", status: :bad_request
61
136
  end
62
137
 
63
- mark_order_as_paid(event.id, event.amount) if event.transfer? || event.capture?
138
+ mark_order_as_paid(event.id, event.amount) if event.transfer?
64
139
 
65
140
  render plain: DPay::IpnEvent::ACK
66
141
  ```
@@ -75,6 +150,14 @@ zamiast ufać wyłącznie faktowi otrzymania powiadomienia.
75
150
  dpay.refunds.create("identyfikator-transakcji")
76
151
  dpay.refunds.create("identyfikator-transakcji", DPay::Money.pln(500), "reklamacja")
77
152
 
153
+ # Odpowiedź oznacza przyjęcie zwrotu - wynik przychodzi zdarzeniem refund.succeeded / refund.failed
154
+ dpay.refunds.create(
155
+ "identyfikator-transakcji",
156
+ DPay::Money.pln(500),
157
+ nil,
158
+ DPay::WebhookTarget.create("https://twojsklep.pl/webhooks/zwroty", %w[refund.succeeded refund.failed])
159
+ )
160
+
78
161
  availability = dpay.refunds.check_availability("identyfikator-transakcji")
79
162
  availability.available?
80
163
  ```
@@ -119,6 +202,18 @@ offer = result.dcc_offer if result.dcc_offer?
119
202
 
120
203
  Klucz publiczny jest rotowany - pobieraj go przed każdą próbą płatności, nie przechowuj go u siebie.
121
204
 
205
+ Po preautoryzacji (`pre_auth`) pobierasz środki przez `capture` albo zwalniasz je przez `cancel`. Oba wywołania
206
+ SDK podpisuje sumą kontrolną operacji; `capture` przyjmuje też własny adres zdarzenia `payment.captured`:
207
+
208
+ ```ruby
209
+ dpay.cards.capture(
210
+ "identyfikator-transakcji",
211
+ DPay::Money.pln(2999),
212
+ DPay::WebhookTarget.create("https://twojsklep.pl/webhooks", ["payment.captured"])
213
+ )
214
+ dpay.cards.cancel("identyfikator-transakcji") # bez kwoty: cała nieprzechwycona reszta
215
+ ```
216
+
122
217
  ## Obsługa błędów
123
218
 
124
219
  Wszystkie wyjątki SDK dołączają moduł `DPay::Error`, więc `rescue DPay::Error` łapie każdy z nich.
@@ -130,7 +225,8 @@ rescue DPay::InvalidRequestError => e
130
225
  e.field_errors
131
226
  rescue DPay::ApiError => e
132
227
  e.http_status
133
- e.error_code
228
+ e.error_code # np. CHECKSUM_REQUIRED, WEBHOOK_URL_INVALID
229
+ e.reason # szczegół kodu, np. "https_required" przy WEBHOOK_URL_INVALID
134
230
  rescue DPay::TransportError
135
231
  # błąd sieci - status płatności jest nieznany, sprawdź go przez payments.details()
136
232
  rescue DPay::Error => e
@@ -146,9 +242,9 @@ end
146
242
  | `DPay::NotFoundError` | HTTP 404 |
147
243
  | `DPay::RateLimitError` | HTTP 429, dodatkowo `retry_after`, `limit`, `remaining` |
148
244
  | `DPay::ApiServerError` | HTTP 5xx |
149
- | `DPay::PaymentRejectedError` | rejestracja płatności odrzucona mimo HTTP 200 |
245
+ | `DPay::PaymentRejectedError` | rejestracja płatności odrzucona mimo HTTP 200, dodatkowo `transaction_id`, `error_description` |
150
246
  | `DPay::CardPaymentError` | płatność kartą odrzucona mimo HTTP 200 |
151
- | `DPay::SignatureVerificationError` | niepoprawny lub brakujący podpis IPN |
247
+ | `DPay::SignatureVerificationError` | niepoprawny lub brakujący podpis IPN albo webhooka |
152
248
  | `DPay::TransportError` | błąd sieci lub transportu HTTP |
153
249
  | `DPay::InvalidArgumentError` | niepoprawny argument przekazany do SDK |
154
250
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module DPay
4
4
  module BlikAliasType
5
+ # BLIK OneClick alias. Recurring payments (PAYID) are handled by Client#recurring.
5
6
  UID = "UID"
6
- PAYID = "PAYID"
7
7
 
8
- ALL = [UID, PAYID].freeze
8
+ ALL = [UID].freeze
9
9
 
10
10
  def self.valid?(value)
11
11
  ALL.include?(value)
@@ -23,15 +23,6 @@ module DPay
23
23
  nil
24
24
  end
25
25
 
26
- def recurring_status(alias_value)
27
- body = { "service" => @api.service, "alias_value" => alias_value }
28
- body["checksum"] = @api.checksum.secret_second(@api.service, [alias_value])
29
-
30
- data = @api.post_json(Internal::BaseUrls::API_PAYMENTS, "/api/v1_0/payments/blik/recurring/status", body)
31
-
32
- BlikRecurringStatus.from_api(payload(data))
33
- end
34
-
35
26
  private
36
27
 
37
28
  def alias_body(alias_value, alias_type, reason = nil)
@@ -18,12 +18,28 @@ module DPay
18
18
  post(transaction_id, "/pay/card-pre-auth", request.to_body)
19
19
  end
20
20
 
21
- def capture(transaction_id, amount)
22
- post(transaction_id, "/capture", { "amount" => amount.to_decimal.to_f })
23
- end
24
-
21
+ # Captures a pre-authorised amount (partial captures allowed up to the authorisation). Signed with
22
+ # sha256(capture|service|transaction_id|amount|hash). Optional webhook target for "payment.captured".
23
+ def capture(transaction_id, amount, webhook = nil)
24
+ # @type var body: Hash[String, untyped]
25
+ body = { "service" => @api.service, "amount" => amount.to_decimal.to_f }
26
+ unless webhook.nil?
27
+ webhook.assert_events_allowed(WebhookEventType::CAPTURE, "a card capture")
28
+ body["webhook"] = webhook.to_h
29
+ end
30
+ body["checksum"] = @api.checksum.operation("capture", @api.service, transaction_id, amount.to_decimal)
31
+
32
+ post(transaction_id, "/capture", body)
33
+ end
34
+
35
+ # Cancels the pre-authorisation, the whole uncaptured remainder without an amount. Signed with
36
+ # sha256(cancellation|service|transaction_id|amount|hash) - empty amount segment without an amount.
25
37
  def cancel(transaction_id, amount = nil)
26
- post(transaction_id, "/cancellation", amount.nil? ? {} : { "amount" => amount.to_decimal.to_f })
38
+ body = { "service" => @api.service }
39
+ body["amount"] = amount.to_decimal.to_f unless amount.nil?
40
+ body["checksum"] = @api.checksum.operation("cancellation", @api.service, transaction_id, amount&.to_decimal)
41
+
42
+ post(transaction_id, "/cancellation", body)
27
43
  end
28
44
 
29
45
  def google_pay(transaction_id, request)
data/lib/dpay/client.rb CHANGED
@@ -4,7 +4,7 @@ module DPay
4
4
  class Client
5
5
  VERSION = DPay::VERSION
6
6
 
7
- attr_reader :config, :payments, :refunds, :banks, :blik, :cards, :payouts
7
+ attr_reader :config, :payments, :refunds, :banks, :blik, :cards, :payouts, :recurring, :events
8
8
 
9
9
  def initialize(service:, secret_hash:, timeout: Config::DEFAULT_TIMEOUT, http_client: nil, base_urls: nil)
10
10
  @config = Config.new(
@@ -20,6 +20,8 @@ module DPay
20
20
  @blik = BlikService.new(api)
21
21
  @cards = CardService.new(api)
22
22
  @payouts = PayoutService.new(api)
23
+ @recurring = RecurringService.new(api)
24
+ @events = EventService.new(api)
23
25
  freeze
24
26
  end
25
27
  end
data/lib/dpay/errors.rb CHANGED
@@ -22,14 +22,16 @@ module DPay
22
22
  class ApiError < StandardError
23
23
  include Error
24
24
 
25
- attr_reader :http_status, :error_code, :field_errors, :raw_body
25
+ # reason: detail next to the error code, e.g. "https_required" for WEBHOOK_URL_INVALID.
26
+ attr_reader :http_status, :error_code, :field_errors, :raw_body, :reason
26
27
 
27
- def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "")
28
+ def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "", reason = nil)
28
29
  super(message)
29
30
  @http_status = http_status
30
31
  @error_code = error_code
31
32
  @field_errors = field_errors
32
33
  @raw_body = raw_body
34
+ @reason = reason
33
35
  end
34
36
  end
35
37
 
@@ -51,19 +53,24 @@ module DPay
51
53
  end
52
54
 
53
55
  class PaymentRejectedError < ApiError
54
- attr_reader :transaction_id
56
+ # error_description: the provider's description of the decline, when it sent one.
57
+ attr_reader :transaction_id, :error_description
55
58
 
56
59
  def self.from_api(data)
57
60
  message = %w[message msg].filter_map { |key| data[key] if data[key].is_a?(String) }.first || "Payment rejected"
58
- error_code = data["errorcode"].is_a?(String) ? data["errorcode"] : nil
61
+ additional = data["additionalInfo"].is_a?(Hash) ? data["additionalInfo"] : {}
62
+ error_code = [data["errorcode"], additional["error"]].find { |code| code.is_a?(String) }
59
63
  transaction_id = data["transactionId"].nil? ? nil : Internal::PHP.strval(data["transactionId"])
64
+ description = additional["error_description"].is_a?(String) ? additional["error_description"] : nil
60
65
 
61
- new(message, 200, error_code, {}, Internal::PHP.json_encode(data), transaction_id)
66
+ new(message, 200, error_code, {}, Internal::PHP.json_encode(data), transaction_id, description)
62
67
  end
63
68
 
64
- def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "", transaction_id = nil)
69
+ def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "", transaction_id = nil,
70
+ error_description = nil)
65
71
  super(message, http_status, error_code, field_errors, raw_body)
66
72
  @transaction_id = transaction_id
73
+ @error_description = error_description
67
74
  end
68
75
  end
69
76
 
@@ -5,22 +5,47 @@ require "digest"
5
5
  module DPay
6
6
  module Internal
7
7
  class ChecksumCalculator
8
+ CHECKSUM_KEY = "checksum"
9
+
8
10
  def initialize(secret_hash)
9
11
  @secret_hash = secret_hash
10
12
  freeze
11
13
  end
12
14
 
15
+ # sha256(service|secret_hash|field1|field2|...) - payment registration, BLIK aliases, recurring payments, events.
13
16
  def secret_second(service, fields)
14
17
  parts = [service, @secret_hash] + fields.map { |field| PHP.strval(field) }
15
18
 
16
19
  Digest::SHA256.hexdigest(parts.join("|"))
17
20
  end
18
21
 
19
- def ordered_body(values)
20
- joined = values.map { |value| PHP.strval(value) }.join("|")
22
+ # sha256(value1|value2|...|secret_hash) over the request body in the order it is sent (PBL API: refunds,
23
+ # transaction details, banks, payouts). Takes the whole body (Hash) or its values (Array). The "checksum" key
24
+ # is skipped, nested objects (e.g. "webhook") contribute their leaf values in order, nil and false give an empty
25
+ # segment and true gives "1" - the way the API casts JSON values to strings.
26
+ def ordered_body(body)
27
+ values = body.is_a?(Hash) ? body.reject { |key, _| key.to_s == CHECKSUM_KEY }.values : body
28
+ joined = values.flat_map { |value| leaves(value) }.map { |leaf| PHP.strval(leaf) }.join("|")
21
29
 
22
30
  Digest::SHA256.hexdigest("#{joined}|#{@secret_hash}")
23
31
  end
32
+
33
+ # sha256(operation|service|transaction_id|amount|secret_hash) - Cards API capture and cancellation. The
34
+ # operation name keeps a capture checksum from authorising a cancellation; without an amount the segment
35
+ # stays empty.
36
+ def operation(operation, service, transaction_id, amount)
37
+ Digest::SHA256.hexdigest([operation, service, transaction_id, amount || "", @secret_hash].join("|"))
38
+ end
39
+
40
+ private
41
+
42
+ def leaves(value)
43
+ case value
44
+ when Hash then value.values.flat_map { |item| leaves(item) }
45
+ when Array then value.flat_map { |item| leaves(item) }
46
+ else [value]
47
+ end
48
+ end
24
49
  end
25
50
  end
26
51
  end
@@ -17,12 +17,21 @@ module DPay
17
17
  error_class(response.status).new(
18
18
  message,
19
19
  response.status,
20
- data["errorcode"].is_a?(String) ? data["errorcode"] : nil,
20
+ extract_code(data),
21
21
  normalize_field_errors(data["errors"]),
22
- response.body
22
+ response.body,
23
+ data["reason"].is_a?(String) ? data["reason"] : nil
23
24
  )
24
25
  end
25
26
 
27
+ # "code" (Cards API, webhooks, Connect: CHECKSUM_REQUIRED, WEBHOOK_URL_INVALID, ...), then legacy "errorcode".
28
+ def extract_code(data)
29
+ return data["code"] if data["code"].is_a?(String)
30
+ return data["errorcode"] if data["errorcode"].is_a?(String)
31
+
32
+ nil
33
+ end
34
+
26
35
  def error_class(status)
27
36
  case status
28
37
  when 401 then AuthenticationError
@@ -7,9 +7,15 @@ module DPay
7
7
  module PHP
8
8
  SAFE_INTEGRAL_FLOAT = 2**53
9
9
  NON_UNRESERVED = /[^A-Za-z0-9\-_.~]/n
10
+ TRIM_EDGES = /\A[ \t\n\r\x00\x0B]+|[ \t\n\r\x00\x0B]+\z/
10
11
 
11
12
  module_function
12
13
 
14
+ # PHP trim(): strips " \t\n\r\0\x0B" from both ends (String#strip also strips "\f").
15
+ def trim(value)
16
+ value.gsub(TRIM_EDGES, "")
17
+ end
18
+
13
19
  def strval(value)
14
20
  case value
15
21
  when nil, false then ""
@@ -39,6 +39,7 @@ module DPay
39
39
  scalar("custom")
40
40
  end
41
41
 
42
+ # @deprecated dpay no longer sends capture IPNs - use the "payment.captured" webhook event.
42
43
  def capture_payment_id
43
44
  scalar("capture_payment_id")
44
45
  end
@@ -55,6 +56,7 @@ module DPay
55
56
  type == IpnType::TRANSFER
56
57
  end
57
58
 
59
+ # @deprecated dpay no longer sends capture IPNs - use the "payment.captured" webhook event.
58
60
  def capture?
59
61
  type == IpnType::CAPTURE
60
62
  end
@@ -3,6 +3,7 @@
3
3
  module DPay
4
4
  module IpnType
5
5
  TRANSFER = "transfer"
6
+ # @deprecated dpay no longer sends capture IPNs - use the "payment.captured" webhook event.
6
7
  CAPTURE = "capture"
7
8
  DCB = "dcb"
8
9
 
@@ -8,10 +8,10 @@ module DPay
8
8
 
9
9
  def register(request)
10
10
  body = request.to_body(@api.service)
11
- body["checksum"] = @api.checksum.secret_second(
12
- @api.service,
13
- [body["value"], body["url_success"], body["url_fail"], body["url_ipn"]]
14
- )
11
+ fields = [body["value"], body["url_success"], body["url_fail"], body["url_ipn"]]
12
+ # A recurring charge binds the checksum to the customer's alias
13
+ fields << body["recurring_alias"] unless body["recurring_alias"].nil?
14
+ body["checksum"] = @api.checksum.secret_second(@api.service, fields)
15
15
 
16
16
  data = @api.post_json(Internal::BaseUrls::API_PAYMENTS, "/api/v1_0/payments/register", body)
17
17
  raise PaymentRejectedError.from_api(data) if rejected?(data)
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "resolv"
4
+
3
5
  module DPay
4
6
  class RegisterPaymentRequest
5
7
  BLIK_CODE = /\A\d{6}\z/
6
8
  PARTNER_PLATFORM = /\A[A-Z0-9]{1,64}\z/
7
9
  HTTP_URL = %r{\Ahttps?://[^\s]+\z}
10
+ CONTROL_CHARACTERS = /[\x00-\x1F\x7F]/
11
+ RECURRING_ALIAS_MAX = 128
12
+ REFERENCE_MAX = 64
8
13
  TOGGLES = { "creditcard" => :@credit_card, "paysafecard" => :@paysafecard, "blik" => :@blik,
9
14
  "installment" => :@installment, "paypal" => :@paypal, "nobanks" => :@no_banks }.freeze
15
+ RECURRING_CONFLICTS = %w[blik_alias register_blik_alias register_card_recurring card_recurring_alias].freeze
10
16
 
11
17
  attr_reader :amount
12
18
 
@@ -21,6 +27,7 @@ module DPay
21
27
  @transaction_type = transaction_type
22
28
  @urls = urls
23
29
  @optional = {}
30
+ @recurring_registration = nil
24
31
  end
25
32
 
26
33
  def with_description(description)
@@ -103,9 +110,10 @@ module DPay
103
110
  end
104
111
 
105
112
  def with_blik_alias(alias_value, user_agent, user_ip)
106
- if @optional.key?("blik_code") || @optional.key?("register_blik_alias") ||
107
- @optional.key?("register_blik_recurring_alias")
108
- raise InvalidArgumentError, "blik_alias cannot be combined with blik_code or alias registration"
113
+ if %w[blik_code register_blik_alias recurring_alias].any? { |key| @optional.key?(key) } ||
114
+ !@recurring_registration.nil?
115
+ raise InvalidArgumentError,
116
+ "blik_alias cannot be combined with blik_code, alias registration or recurring payments"
109
117
  end
110
118
 
111
119
  set("blik_alias", alias_value)
@@ -121,12 +129,47 @@ module DPay
121
129
  set("register_blik_alias", registration.to_h)
122
130
  end
123
131
 
124
- def with_register_blik_recurring_alias(registration)
125
- if @optional.key?("blik_alias")
126
- raise InvalidArgumentError, "register_blik_recurring_alias cannot be combined with blik_alias"
132
+ # Registers a recurring payment together with this payment. Requires the customer's BLIK code (with_blik_code)
133
+ # and transactionType "transfers"; the amount may be 0 (consent only) or an initial fee.
134
+ def with_recurring_registration(registration)
135
+ @recurring_registration = registration
136
+ self
137
+ end
138
+
139
+ # Charges a registered recurring payment server-to-server (no BLIK code): transactionType "transfers", amount
140
+ # above 0. The alias is appended to the checksum, binding the charge to that customer.
141
+ def with_recurring_alias(alias_value)
142
+ unless alias_value.is_a?(String) && !alias_value.empty? && alias_value.bytesize <= RECURRING_ALIAS_MAX
143
+ raise InvalidArgumentError, "Recurring alias must be 1-128 characters"
127
144
  end
128
145
 
129
- set("register_blik_recurring_alias", registration.to_h)
146
+ set("recurring_alias", alias_value)
147
+ end
148
+
149
+ # Payer's user agent and IP for a recurring charge (optional there; BLIK code and alias payments set them in
150
+ # with_blik_code / with_blik_alias).
151
+ def with_client_context(user_agent, user_ip)
152
+ raise InvalidArgumentError, %(Invalid user IP "#{user_ip}") unless ip_address?(user_ip)
153
+
154
+ set("user_agent", user_agent)
155
+ set("user_ip", user_ip)
156
+ end
157
+
158
+ # Sends this payment's events (and later events of its refunds and recurring payment) also to this URL, signed
159
+ # with the service's webhook secret. Not part of the checksum.
160
+ def with_webhook(webhook)
161
+ webhook.assert_events_allowed(WebhookEventType::PAYMENT_REGISTRATION, "a payment registration")
162
+ set("webhook", webhook.to_h)
163
+ end
164
+
165
+ # Your reference of the payment (max 64 characters), returned as references.merchant in webhooks.
166
+ def with_reference(reference)
167
+ trimmed = reference.is_a?(String) ? Internal::PHP.trim(reference) : ""
168
+ if trimmed.empty? || trimmed.length > REFERENCE_MAX || CONTROL_CHARACTERS.match?(trimmed)
169
+ raise InvalidArgumentError, "Reference must be 1-64 characters without control characters"
170
+ end
171
+
172
+ set("reference", trimmed)
130
173
  end
131
174
 
132
175
  def with_alias_ipn_url(url)
@@ -194,14 +237,17 @@ module DPay
194
237
  end
195
238
 
196
239
  def to_body(service)
240
+ assert_recurring_combination
241
+
242
+ # @type var body: Hash[String, untyped]
197
243
  body = {
198
244
  "service" => service,
199
245
  "value" => @amount.to_decimal,
200
246
  "transactionType" => @transaction_type,
201
247
  "url_success" => @urls.success,
202
- "url_fail" => @urls.fail,
203
- "url_ipn" => @urls.ipn
248
+ "url_fail" => @urls.fail
204
249
  }
250
+ body["url_ipn"] = @urls.ipn unless @urls.ipn.nil?
205
251
 
206
252
  append(body, "description")
207
253
  append(body, "custom")
@@ -209,10 +255,12 @@ module DPay
209
255
  append(body, "accept_tos")
210
256
  append(body, "channel")
211
257
  append_toggles(body)
212
- %w[phone_number currency_code partner_platform user_agent user_ip blik_code blik_alias register_blik_alias
213
- register_blik_recurring_alias alias_ipn_url no_delay register_card_recurring card_recurring_alias
214
- authorize_only card_recurring_operation payout billing_address shipping_address device_info products
215
- efaktura invoice].each { |key| append(body, key) }
258
+ %w[phone_number currency_code partner_platform user_agent user_ip blik_code blik_alias
259
+ register_blik_alias].each { |key| append(body, key) }
260
+ body["recurring_registration"] = @recurring_registration.to_h unless @recurring_registration.nil?
261
+ %w[recurring_alias alias_ipn_url no_delay register_card_recurring card_recurring_alias authorize_only
262
+ card_recurring_operation payout billing_address shipping_address device_info products efaktura invoice
263
+ webhook reference].each { |key| append(body, key) }
216
264
 
217
265
  body
218
266
  end
@@ -242,5 +290,36 @@ module DPay
242
290
  body[key] = flag ? 1 : 0 unless flag.nil?
243
291
  end
244
292
  end
293
+
294
+ def ip_address?(value)
295
+ value.is_a?(String) && !value.include?("%") &&
296
+ (Resolv::IPv4::Regex.match?(value) || Resolv::IPv6::Regex.match?(value))
297
+ end
298
+
299
+ # Checked when the body is built, like the API: a registration needs the BLIK code and excludes "channel",
300
+ # a charge needs an amount above 0 and excludes "blik_code"; both exclude the other alias kinds.
301
+ def assert_recurring_combination
302
+ charge = !@optional["recurring_alias"].nil?
303
+ return if @recurring_registration.nil? && !charge
304
+
305
+ if !@recurring_registration.nil? && charge
306
+ raise InvalidArgumentError, "recurring_registration cannot be combined with recurring_alias"
307
+ end
308
+ unless @transaction_type == TransactionType::TRANSFERS
309
+ raise InvalidArgumentError, %(Recurring payments require transactionType "transfers")
310
+ end
311
+
312
+ assert_recurring_requirements(charge)
313
+ conflict = (RECURRING_CONFLICTS + [charge ? "blik_code" : "channel"]).find { |key| !@optional[key].nil? }
314
+ raise InvalidArgumentError, "#{conflict} cannot be combined with a recurring payment" unless conflict.nil?
315
+ end
316
+
317
+ def assert_recurring_requirements(charge)
318
+ if charge
319
+ raise InvalidArgumentError, "A recurring charge requires an amount above 0" unless @amount.minor.positive?
320
+ elsif @optional["blik_code"].nil?
321
+ raise InvalidArgumentError, "recurring_registration requires the customer's BLIK code (with_blik_code)"
322
+ end
323
+ end
245
324
  end
246
325
  end