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
@@ -38,5 +38,24 @@ module DPay
38
38
 
39
39
  additional["card_recurring_alias"]
40
40
  end
41
+
42
+ # Alias of the recurring payment registered with this payment (with_recurring_registration).
43
+ def recurring_alias
44
+ value = recurring_registration["alias"]
45
+ value.is_a?(String) ? value : nil
46
+ end
47
+
48
+ def recurring_methods
49
+ names = recurring_registration["methods"]
50
+ names.is_a?(Array) ? names.grep(String) : []
51
+ end
52
+
53
+ private
54
+
55
+ def recurring_registration
56
+ additional = @raw["additionalInfo"]
57
+ registration = additional.is_a?(Hash) ? additional["recurring_registration"] : nil
58
+ registration.is_a?(Hash) ? registration : {}
59
+ end
41
60
  end
42
61
  end
@@ -6,8 +6,10 @@ module DPay
6
6
 
7
7
  attr_reader :success, :fail, :ipn
8
8
 
9
- def initialize(success, fail, ipn)
9
+ # ipn is optional: without it no IPN is sent and "url_ipn" is left out (the outcome comes as a webhook).
10
+ def initialize(success, fail, ipn = nil)
10
11
  { "success" => success, "fail" => fail, "ipn" => ipn }.each do |name, url|
12
+ next if name == "ipn" && url.nil?
11
13
  raise InvalidArgumentError, %(Invalid #{name} URL "#{url}") unless url.is_a?(String) && HTTP_URL.match?(url)
12
14
  end
13
15
 
@@ -6,11 +6,9 @@ module DPay
6
6
  DCB_GATEWAY = "dcb_gateway"
7
7
  CARD_AUTH = "card_auth"
8
8
  MB_WAY_DIRECT = "mb_way_direct"
9
- BIZUM_DIRECT = "bizum_direct"
10
- BLIK_RECURRING = "blik_recurring"
11
9
  CARD_RECURRING = "card_recurring"
12
10
 
13
- ALL = [TRANSFERS, DCB_GATEWAY, CARD_AUTH, MB_WAY_DIRECT, BIZUM_DIRECT, BLIK_RECURRING, CARD_RECURRING].freeze
11
+ ALL = [TRANSFERS, DCB_GATEWAY, CARD_AUTH, MB_WAY_DIRECT, CARD_RECURRING].freeze
14
12
 
15
13
  def self.valid?(value)
16
14
  ALL.include?(value)
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # The "recurring_registration" object: registers a recurring payment (today BLIK, alias PAYID) together with a
5
+ # payment that carries the customer's BLIK code. Models:
6
+ # - O (open): no frequency or limits, the merchant charges any amount within its active ranges (max 2000 PLN);
7
+ # - A (automatic): fixed amount, frequency, total limit, start and expiry date - all required;
8
+ # - M (manual): every charge is confirmed by the customer in the banking app; frequency and limits optional.
9
+ class RecurringRegistration
10
+ MODEL_A = "A"
11
+ MODEL_M = "M"
12
+ MODEL_O = "O"
13
+ MODELS = [MODEL_A, MODEL_M, MODEL_O].freeze
14
+
15
+ METHOD_BLIK = "blik"
16
+ METHODS = [METHOD_BLIK].freeze
17
+
18
+ FREQUENCY = /\A[1-9][0-9]{0,2}[DWMY]\z/
19
+ DATE = /\A\d{4}-\d{2}-\d{2}\z/
20
+ HTTP_URL = %r{\Ahttps?://[^\s]+\z}
21
+ TERMS_URL_MAX = 2048
22
+ ALIAS_MAX = 128
23
+ MODEL_O_FORBIDDEN = %w[frequency limit_amt tot_limit_amt is_limit_amt_fixed].freeze
24
+ MODEL_A_REQUIRED = %w[frequency limit_amt tot_limit_amt expiration_date init_date].freeze
25
+ OPTIONAL_ORDER = %w[frequency limit_amt tot_limit_amt is_limit_amt_fixed expiration_date init_date methods].freeze
26
+
27
+ attr_reader :model
28
+
29
+ # terms_url: the merchant's terms the customer accepted (consent evidence).
30
+ def self.create(label, model, terms_url)
31
+ new(label, model, terms_url)
32
+ end
33
+
34
+ def initialize(label, model, terms_url)
35
+ unless label.is_a?(String) && !label.empty? && label.length <= 50
36
+ raise InvalidArgumentError, "Recurring payment label must be 1-50 characters"
37
+ end
38
+ raise InvalidArgumentError, %(Invalid recurring model "#{model}") unless MODELS.include?(model)
39
+ unless terms_url.is_a?(String) && terms_url.bytesize <= TERMS_URL_MAX && HTTP_URL.match?(terms_url)
40
+ raise InvalidArgumentError, %(Invalid terms URL "#{terms_url}")
41
+ end
42
+
43
+ @label = label
44
+ @model = model
45
+ @terms_url = terms_url
46
+ @optional = {}
47
+ end
48
+
49
+ # Your own alias of the recurring payment (max 128 characters); without it dpay assigns one.
50
+ def with_alias(alias_value)
51
+ unless alias_value.is_a?(String) && !alias_value.empty? && alias_value.bytesize <= ALIAS_MAX
52
+ raise InvalidArgumentError, "Recurring alias must be 1-128 characters"
53
+ end
54
+
55
+ set("alias", alias_value)
56
+ end
57
+
58
+ def with_terms_version(terms_version)
59
+ unless terms_version.is_a?(String) && !terms_version.empty? && terms_version.length <= 64
60
+ raise InvalidArgumentError, "Terms version must be 1-64 characters"
61
+ end
62
+
63
+ set("terms_version", terms_version)
64
+ end
65
+
66
+ # Payment methods of the recurring payment, today only "blik".
67
+ def with_methods(names)
68
+ unless names.is_a?(Array) && !names.empty? && names.uniq.size == names.size
69
+ raise InvalidArgumentError, "Methods must be a non-empty list of distinct methods"
70
+ end
71
+
72
+ unsupported = names.reject { |name| METHODS.include?(name) }
73
+ raise InvalidArgumentError, %(Unsupported recurring method "#{unsupported.first}") unless unsupported.empty?
74
+
75
+ set("methods", names.dup)
76
+ end
77
+
78
+ # Frequency like "1M", "2W", "14D", "1Y" (1-999 days, weeks, months or years).
79
+ def with_frequency(frequency)
80
+ unless frequency.is_a?(String) && FREQUENCY.match?(frequency)
81
+ raise InvalidArgumentError, %(Invalid recurring frequency "#{frequency}")
82
+ end
83
+
84
+ set("frequency", frequency)
85
+ end
86
+
87
+ # Single payment limit in minor units (grosz).
88
+ def with_limit_amt(limit_amt)
89
+ set("limit_amt", assert_positive(limit_amt, "limit_amt"))
90
+ end
91
+
92
+ # Total limit of all payments in minor units (grosz).
93
+ def with_tot_limit_amt(tot_limit_amt)
94
+ set("tot_limit_amt", assert_positive(tot_limit_amt, "tot_limit_amt"))
95
+ end
96
+
97
+ def with_limit_amt_fixed(fixed)
98
+ raise InvalidArgumentError, "is_limit_amt_fixed must be true or false" unless [true, false].include?(fixed)
99
+
100
+ set("is_limit_amt_fixed", fixed)
101
+ end
102
+
103
+ # YYYY-MM-DD, after today and at most 10 years ahead.
104
+ def with_expiration_date(expiration_date)
105
+ set("expiration_date", assert_date(expiration_date))
106
+ end
107
+
108
+ # YYYY-MM-DD, the first charge date (today or later).
109
+ def with_init_date(init_date)
110
+ set("init_date", assert_date(init_date))
111
+ end
112
+
113
+ # Key order: label, alias, model, frequency, limit_amt, tot_limit_amt, is_limit_amt_fixed, expiration_date,
114
+ # init_date, methods, terms_url, terms_version. Raises InvalidArgumentError when the terms break the model rules.
115
+ def to_h
116
+ assert_model_rules
117
+
118
+ data = { "label" => @label }
119
+ data["alias"] = @optional["alias"] if @optional.key?("alias")
120
+ data["model"] = @model
121
+ OPTIONAL_ORDER.each { |key| data[key] = @optional[key] if @optional.key?(key) }
122
+ data["terms_url"] = @terms_url
123
+ data["terms_version"] = @optional["terms_version"] if @optional.key?("terms_version")
124
+ data
125
+ end
126
+
127
+ private
128
+
129
+ def set(key, value)
130
+ @optional[key] = value
131
+ self
132
+ end
133
+
134
+ def assert_model_rules
135
+ if @model == MODEL_O
136
+ forbidden = MODEL_O_FORBIDDEN.find { |key| @optional.key?(key) }
137
+ raise InvalidArgumentError, "#{forbidden} is not allowed in recurring model O" unless forbidden.nil?
138
+ end
139
+ return unless @model == MODEL_A
140
+
141
+ missing = MODEL_A_REQUIRED.find { |key| !@optional.key?(key) }
142
+ raise InvalidArgumentError, "#{missing} is required in recurring model A" unless missing.nil?
143
+ return unless @optional["is_limit_amt_fixed"] == false
144
+
145
+ raise InvalidArgumentError, "Recurring model A requires a fixed amount (is_limit_amt_fixed = true)"
146
+ end
147
+
148
+ def assert_positive(value, field)
149
+ raise InvalidArgumentError, "#{field} must be at least 1 (minor units)" unless value.is_a?(Integer) && value >= 1
150
+
151
+ value
152
+ end
153
+
154
+ def assert_date(date)
155
+ unless date.is_a?(String) && DATE.match?(date)
156
+ raise InvalidArgumentError, %(Date "#{date}" must be in YYYY-MM-DD format)
157
+ end
158
+
159
+ date
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Terms of a registered recurring payment, as returned by the status endpoint. Amounts in minor units (grosz).
5
+ class RecurringRegistrationInfo
6
+ DIGITS = /\A\d+\z/
7
+
8
+ # transaction_id: transactionId of the registering payment. registered_at: ISO 8601 with offset.
9
+ attr_reader :transaction_id, :label, :model, :frequency, :limit_amt, :tot_limit_amt, :limit_amt_fixed,
10
+ :init_date, :terms_url, :terms_version, :registered_at, :raw
11
+
12
+ def self.from_api(data)
13
+ new(data)
14
+ end
15
+
16
+ def initialize(data)
17
+ @raw = data
18
+ @transaction_id = string(data["transaction_id"])
19
+ @label = string(data["label"])
20
+ @model = string(data["model"])
21
+ @frequency = string(data["frequency"])
22
+ @limit_amt = integer(data["limit_amt"])
23
+ @tot_limit_amt = integer(data["tot_limit_amt"])
24
+ fixed = data["is_limit_amt_fixed"]
25
+ @limit_amt_fixed = [true, false].include?(fixed) ? fixed : nil
26
+ @init_date = string(data["init_date"])
27
+ @terms_url = string(data["terms_url"])
28
+ @terms_version = string(data["terms_version"])
29
+ @registered_at = string(data["registered_at"])
30
+ freeze
31
+ end
32
+
33
+ private
34
+
35
+ def string(value)
36
+ value.is_a?(String) ? value : nil
37
+ end
38
+
39
+ def integer(value)
40
+ return value if value.is_a?(Integer)
41
+
42
+ value.is_a?(String) && DIGITS.match?(value) ? value.to_i : nil
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Result of retrying a declined recurring charge. "pending" - the retry went to the bank, the outcome comes like for
5
+ # a charge (webhook, IPN, status); "failed" - the bank declined it at once (error code in #error_code).
6
+ class RecurringRetryResult
7
+ STATUS_PENDING = "pending"
8
+ STATUS_FAILED = "failed"
9
+ STATUS_SUCCESS = "success"
10
+
11
+ # count: which retry this was (1-3).
12
+ attr_reader :transaction_id, :status, :count, :error_code, :error_description, :raw
13
+
14
+ def self.from_api(data)
15
+ new(data)
16
+ end
17
+
18
+ def initialize(data)
19
+ @raw = data
20
+ details = data["retry"].is_a?(Hash) ? data["retry"] : {}
21
+ @transaction_id = Internal::Coerce.string(data["transactionId"]) || ""
22
+ @status = string(details["status"])
23
+ @count = details["count"].is_a?(Integer) ? details["count"] : nil
24
+ @error_code = string(details["error"])
25
+ @error_description = string(details["error_description"])
26
+ freeze
27
+ end
28
+
29
+ def pending?
30
+ @status == STATUS_PENDING
31
+ end
32
+
33
+ def failed?
34
+ @status == STATUS_FAILED
35
+ end
36
+
37
+ private
38
+
39
+ def string(value)
40
+ value.is_a?(String) ? value : nil
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Recurring payments shared by payment methods (today BLIK). Registration and charges go through
5
+ # payments.register with RegisterPaymentRequest#with_recurring_registration and #with_recurring_alias.
6
+ class RecurringService
7
+ CANCEL = "cancel"
8
+
9
+ def initialize(api)
10
+ @api = api
11
+ end
12
+
13
+ # Current status and terms of the recurring payment registered for this service.
14
+ def status(alias_value)
15
+ body = {
16
+ "service" => @api.service,
17
+ "alias" => alias_value,
18
+ "checksum" => @api.checksum.secret_second(@api.service, [alias_value])
19
+ }
20
+
21
+ RecurringStatus.from_api(payload(post("/api/v1_0/payments/recurring/status", body)))
22
+ end
23
+
24
+ # Retries a declined recurring charge (transaction_id of the charge) with the same BLIK transaction - up to
25
+ # 3 times within 5 minutes, only after declines the customer can fix, e.g. INSUFFICIENT_FUNDS. Not retryable:
26
+ # InvalidRequestError with the reason in field_errors["retry"] (e.g. DECLINE_NOT_RETRYABLE, RETRY_LIMIT_REACHED).
27
+ def retry(transaction_id)
28
+ body = {
29
+ "service" => @api.service,
30
+ "transaction_id" => transaction_id,
31
+ "checksum" => @api.checksum.secret_second(@api.service, [transaction_id])
32
+ }
33
+
34
+ RecurringRetryResult.from_api(payload(post("/api/v1_0/payments/recurring/retry", body)))
35
+ end
36
+
37
+ # Cancels the recurring payment (for BLIK: unregisters the alias at BLIK); later charges with the alias are
38
+ # rejected. The checksum ends with the operation name, so a status checksum cannot cancel. Returns the new
39
+ # status (UNREGISTERED).
40
+ def cancel(alias_value, reason = nil)
41
+ body = { "service" => @api.service, "alias" => alias_value }
42
+ body["reason"] = reason unless reason.nil?
43
+ body["checksum"] = @api.checksum.secret_second(@api.service, [alias_value, CANCEL])
44
+
45
+ status = payload(post("/api/v1_0/payments/recurring/cancel", body))["status"]
46
+ status.is_a?(String) ? status : RecurringStatus::UNREGISTERED
47
+ end
48
+
49
+ private
50
+
51
+ def post(path, body)
52
+ @api.post_json(Internal::BaseUrls::API_PAYMENTS, path, body)
53
+ end
54
+
55
+ def payload(data)
56
+ data["data"].is_a?(Hash) ? data["data"] : {}
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ class RecurringStatus
5
+ ACTIVE = "ACTIVE"
6
+ INACTIVE = "INACTIVE"
7
+ UNREGISTERED = "UNREGISTERED"
8
+ EXPIRED = "EXPIRED"
9
+ DECLINED = "DECLINED"
10
+
11
+ ALL = [ACTIVE, INACTIVE, UNREGISTERED, EXPIRED, DECLINED].freeze
12
+
13
+ # payment_method: the "method" field, e.g. "blik" (renamed so it does not shadow Object#method).
14
+ # status: ACTIVE, INACTIVE (waiting for the customer), UNREGISTERED, EXPIRED, DECLINED or nil.
15
+ attr_reader :alias, :payment_method, :status, :expiration_date, :registration, :raw
16
+
17
+ def self.from_api(data)
18
+ new(data)
19
+ end
20
+
21
+ def initialize(data)
22
+ @raw = data
23
+ @alias = string(data["alias"]) || ""
24
+ @payment_method = string(data["method"])
25
+ @status = string(data["status"])
26
+ @expiration_date = string(data["expiration_date"])
27
+ registration = data["registration"]
28
+ @registration = registration.is_a?(Hash) ? RecurringRegistrationInfo.from_api(registration) : nil
29
+ freeze
30
+ end
31
+
32
+ def active?
33
+ @status == ACTIVE
34
+ end
35
+
36
+ private
37
+
38
+ def string(value)
39
+ value.is_a?(String) ? value : nil
40
+ end
41
+ end
42
+ end
@@ -8,8 +8,12 @@ module DPay
8
8
  @api = api
9
9
  end
10
10
 
11
- def create(transaction_id, amount = nil, reason = nil)
12
- body = signed_body(transaction_id, amount, reason)
11
+ # Orders a refund (partial with an amount, the rest of the payment without). A successful response means the
12
+ # refund was accepted - its outcome comes as a "refund.succeeded" / "refund.failed" event. The optional webhook
13
+ # target receives the events of this refund and is part of the checksum.
14
+ def create(transaction_id, amount = nil, reason = nil, webhook = nil)
15
+ webhook&.assert_events_allowed(WebhookEventType::REFUND, "a refund")
16
+ body = signed_body(transaction_id, amount, reason, webhook)
13
17
 
14
18
  Refund.from_api(@api.post_json(Internal::BaseUrls::PANEL, "/api/v1/pbl/refund", body))
15
19
  end
@@ -34,11 +38,13 @@ module DPay
34
38
  status == 401 && data["message"] != "Unauthorized request"
35
39
  end
36
40
 
37
- def signed_body(transaction_id, amount, reason)
41
+ def signed_body(transaction_id, amount, reason, webhook = nil)
42
+ # @type var body: Hash[String, untyped]
38
43
  body = { "service" => @api.service, "transaction_id" => transaction_id }
39
44
  body["value"] = amount.to_decimal unless amount.nil?
40
45
  body["reason"] = reason unless reason.nil?
41
- body["checksum"] = @api.checksum.ordered_body(body.values)
46
+ body["webhook"] = webhook.to_h unless webhook.nil?
47
+ body["checksum"] = @api.checksum.ordered_body(body)
42
48
 
43
49
  body
44
50
  end
data/lib/dpay/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DPay
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ class EventPage
5
+ # data: events, newest first. Re-encoded by the API - do not verify webhook signatures on them.
6
+ attr_reader :data, :next_starting_after, :raw
7
+
8
+ def self.from_api(response)
9
+ new(response)
10
+ end
11
+
12
+ def initialize(response)
13
+ @raw = response
14
+ @data = Internal::Coerce.list(response["data"]).map { |event| WebhookEvent.from_api(event) }.freeze
15
+ @has_more = response["has_more"] == true
16
+ next_starting_after = response["next_starting_after"]
17
+ @next_starting_after = next_starting_after.is_a?(String) ? next_starting_after : nil
18
+ freeze
19
+ end
20
+
21
+ def has_more? # rubocop:disable Naming/PredicatePrefix
22
+ @has_more
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Events API: the event history of the service (the same envelopes as webhooks), newest first. Use it to catch up
5
+ # after an outage of your webhook endpoint.
6
+ class EventService
7
+ PATH = "/api/v1_0/events"
8
+ STARTING_AFTER = /\Aevt_[0-9a-z]{26}\z/
9
+ DATE_FIELDS = %w[created_from created_to].freeze
10
+ LIMIT = (1..100)
11
+
12
+ def initialize(api)
13
+ @api = api
14
+ end
15
+
16
+ # params (String or Symbol keys): types, created_from, created_to, starting_after, limit. timestamp: Unix time
17
+ # for the checksum (defaults to now; the API accepts +/- 300 s).
18
+ def list(params = {}, timestamp = nil)
19
+ options = params.transform_keys(&:to_s)
20
+ timestamp = Time.now.to_i if timestamp.nil?
21
+ raise InvalidArgumentError, "timestamp must be an Integer (Unix time)" unless timestamp.is_a?(Integer)
22
+
23
+ body = { "service" => @api.service, "timestamp" => timestamp }
24
+ append_types(body, options["types"])
25
+ DATE_FIELDS.each { |field| body[field] = options[field] unless options[field].nil? }
26
+ append_starting_after(body, options["starting_after"])
27
+ append_limit(body, options["limit"])
28
+ body["checksum"] = @api.checksum.secret_second(@api.service, [timestamp.to_s])
29
+
30
+ EventPage.from_api(@api.post_json(Internal::BaseUrls::API_PAYMENTS, PATH, body))
31
+ end
32
+
33
+ # Iterates over all matching events page by page (newest first). Without a block returns an Enumerator.
34
+ def iterate(params = {}, &block)
35
+ return enum_for(:iterate, params) if block.nil?
36
+
37
+ options = params.transform_keys(&:to_s)
38
+ loop do
39
+ page = list(options)
40
+ page.data.each(&block)
41
+ options["starting_after"] = page.next_starting_after
42
+ break unless page.has_more? && !options["starting_after"].nil?
43
+ end
44
+
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def append_types(body, types)
51
+ return if types.nil?
52
+ unless types.is_a?(Array) && !types.empty? && types.uniq.size == types.size
53
+ raise InvalidArgumentError, "Event types must be a non-empty list of distinct types"
54
+ end
55
+
56
+ WebhookEventType.assert_allowed(types, WebhookEventType::MERCHANT, "the Events API")
57
+ body["types"] = types.dup
58
+ end
59
+
60
+ def append_starting_after(body, starting_after)
61
+ return if starting_after.nil?
62
+ unless starting_after.is_a?(String) && STARTING_AFTER.match?(starting_after)
63
+ raise InvalidArgumentError, "starting_after must be an event id (evt_...)"
64
+ end
65
+
66
+ body["starting_after"] = starting_after
67
+ end
68
+
69
+ def append_limit(body, limit)
70
+ return if limit.nil?
71
+ raise InvalidArgumentError, "limit must be between 1 and 100" unless limit.is_a?(Integer) && LIMIT.cover?(limit)
72
+
73
+ body["limit"] = limit
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Webhook event envelope: {id, type, api_version, created, livemode, service, [merchant_ref], data: {object}}.
5
+ # data.object stays a Hash - payment, refund, recurring_payment or payout, amounts in minor units.
6
+ class WebhookEvent
7
+ # created: event time in UTC ("YYYY-MM-DDTHH:MM:SSZ") - not the delivery time, do not use it against replays.
8
+ # service: nil for account events (payouts) and test events. merchant_ref: only in events sent to a dpay Connect
9
+ # partner. object_type: "payment", "refund", "recurring_payment", "payout" or "webhook_endpoint".
10
+ attr_reader :id, :type, :api_version, :created, :service, :merchant_ref, :object, :object_type, :raw
11
+
12
+ def self.from_api(data)
13
+ new(data)
14
+ end
15
+
16
+ def initialize(data)
17
+ @raw = data
18
+ @id = string(data["id"]) || ""
19
+ @type = string(data["type"]) || ""
20
+ @api_version = string(data["api_version"])
21
+ @created = string(data["created"])
22
+ @livemode = data["livemode"] != false
23
+ @service = string(data["service"])
24
+ @merchant_ref = string(data["merchant_ref"])
25
+ envelope = data["data"]
26
+ object = envelope.is_a?(Hash) ? envelope["object"] : nil
27
+ @object = object.is_a?(Hash) ? object : {}
28
+ @object_type = string(@object["object"])
29
+ freeze
30
+ end
31
+
32
+ def livemode?
33
+ @livemode
34
+ end
35
+
36
+ private
37
+
38
+ def string(value)
39
+ value.is_a?(String) ? value : nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module WebhookEventType
5
+ PAYMENT_SUCCEEDED = "payment.succeeded"
6
+ PAYMENT_FAILED = "payment.failed"
7
+ PAYMENT_CAPTURED = "payment.captured"
8
+ REFUND_SUCCEEDED = "refund.succeeded"
9
+ REFUND_FAILED = "refund.failed"
10
+ RECURRING_PAYMENT_ACTIVATED = "recurring_payment.activated"
11
+ RECURRING_PAYMENT_CANCELED = "recurring_payment.canceled"
12
+ RECURRING_PAYMENT_EXPIRED = "recurring_payment.expired"
13
+ RECURRING_PAYMENT_DECLINED = "recurring_payment.declined"
14
+ PAYOUT_PAID = "payout.paid"
15
+ PAYOUT_FAILED = "payout.failed"
16
+ WEBHOOK_TEST = "webhook.test"
17
+
18
+ # Events allowed in the "webhook" object of a payment registration.
19
+ PAYMENT_REGISTRATION = [
20
+ PAYMENT_SUCCEEDED, PAYMENT_FAILED, PAYMENT_CAPTURED, REFUND_SUCCEEDED, REFUND_FAILED,
21
+ RECURRING_PAYMENT_ACTIVATED, RECURRING_PAYMENT_CANCELED, RECURRING_PAYMENT_EXPIRED, RECURRING_PAYMENT_DECLINED
22
+ ].freeze
23
+
24
+ # Events a merchant endpoint can subscribe to and the Events API can filter on.
25
+ MERCHANT = (PAYMENT_REGISTRATION + [PAYOUT_PAID, PAYOUT_FAILED]).freeze
26
+
27
+ # Events allowed in the "webhook" object of a refund.
28
+ REFUND = [REFUND_SUCCEEDED, REFUND_FAILED].freeze
29
+
30
+ # Events allowed in the "webhook" object of a card capture.
31
+ CAPTURE = [PAYMENT_CAPTURED].freeze
32
+
33
+ def self.assert_allowed(events, allowed, context)
34
+ events.each do |event|
35
+ next if allowed.include?(event)
36
+
37
+ raise InvalidArgumentError, %(Event "#{event}" is not allowed in the webhook object of #{context})
38
+ end
39
+
40
+ nil
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ # Per-request webhook address (the "webhook" object of a payment registration, refund or card capture). Events of
5
+ # this payment go to this URL, signed with the service's webhook secret, on top of the endpoints set in the panel.
6
+ class WebhookTarget
7
+ HTTPS_URL = %r{\Ahttps://[^\s]+\z}i
8
+ URL_MAX = 500
9
+
10
+ attr_reader :url, :events
11
+
12
+ # events: empty = every event the request allows.
13
+ def self.create(url, events = [])
14
+ new(url, events)
15
+ end
16
+
17
+ def initialize(url, events = [])
18
+ raise InvalidArgumentError, "Webhook URL must be at most 500 characters" if url.to_s.bytesize > URL_MAX
19
+ unless url.is_a?(String) && HTTPS_URL.match?(url)
20
+ raise InvalidArgumentError, %(Webhook URL "#{url}" must be a valid https:// URL)
21
+ end
22
+ unless events.is_a?(Array) && events.uniq.size == events.size
23
+ raise InvalidArgumentError, "Webhook events must be distinct"
24
+ end
25
+
26
+ WebhookEventType.assert_allowed(events, WebhookEventType::MERCHANT, "a request")
27
+
28
+ @url = url
29
+ @events = events.dup.freeze
30
+ freeze
31
+ end
32
+
33
+ def assert_events_allowed(allowed, context)
34
+ WebhookEventType.assert_allowed(@events, allowed, context)
35
+ end
36
+
37
+ # "url" first, then "events" - the refund checksum hashes the values in this order.
38
+ def to_h
39
+ # @type var data: Hash[String, untyped]
40
+ data = { "url" => @url }
41
+ data["events"] = @events.dup unless @events.empty?
42
+ data
43
+ end
44
+ end
45
+ end