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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +100 -4
- data/lib/dpay/blik/blik_alias_type.rb +2 -2
- data/lib/dpay/blik/blik_service.rb +0 -9
- data/lib/dpay/card/card_service.rb +21 -5
- data/lib/dpay/client.rb +3 -1
- data/lib/dpay/errors.rb +13 -6
- data/lib/dpay/internal/checksum_calculator.rb +27 -2
- data/lib/dpay/internal/error_mapper.rb +11 -2
- data/lib/dpay/internal/php.rb +6 -0
- data/lib/dpay/ipn/ipn_event.rb +2 -0
- data/lib/dpay/ipn/ipn_type.rb +1 -0
- data/lib/dpay/payment/payment_service.rb +4 -4
- data/lib/dpay/payment/register_payment_request.rb +92 -13
- data/lib/dpay/payment/registered_payment.rb +19 -0
- data/lib/dpay/payment/return_urls.rb +3 -1
- data/lib/dpay/payment/transaction_type.rb +1 -3
- data/lib/dpay/recurring/recurring_registration.rb +162 -0
- data/lib/dpay/recurring/recurring_registration_info.rb +45 -0
- data/lib/dpay/recurring/recurring_retry_result.rb +43 -0
- data/lib/dpay/recurring/recurring_service.rb +59 -0
- data/lib/dpay/recurring/recurring_status.rb +42 -0
- data/lib/dpay/refund/refund_service.rb +10 -4
- data/lib/dpay/version.rb +1 -1
- data/lib/dpay/webhook/event_page.rb +25 -0
- data/lib/dpay/webhook/event_service.rb +76 -0
- data/lib/dpay/webhook/webhook_event.rb +42 -0
- data/lib/dpay/webhook/webhook_event_type.rb +43 -0
- data/lib/dpay/webhook/webhook_target.rb +45 -0
- data/lib/dpay/webhook/webhook_verifier.rb +110 -0
- data/lib/dpay.rb +11 -3
- data/sig/dpay/blik/blik_alias_type.rbs +0 -1
- data/sig/dpay/blik/blik_service.rbs +0 -1
- data/sig/dpay/card/card_service.rbs +1 -1
- data/sig/dpay/client.rbs +2 -0
- data/sig/dpay/errors.rbs +6 -2
- data/sig/dpay/internal/checksum_calculator.rbs +8 -1
- data/sig/dpay/internal/error_mapper.rbs +1 -0
- data/sig/dpay/internal/php.rbs +2 -0
- data/sig/dpay/ipn/ipn_event.rbs +2 -0
- data/sig/dpay/ipn/ipn_type.rbs +1 -0
- data/sig/dpay/payment/register_payment_request.rbs +13 -1
- data/sig/dpay/payment/registered_payment.rbs +6 -0
- data/sig/dpay/payment/return_urls.rbs +2 -2
- data/sig/dpay/payment/transaction_type.rbs +0 -2
- data/sig/dpay/recurring/recurring_registration.rbs +45 -0
- data/sig/dpay/{blik/blik_recurring_registration_info.rbs → recurring/recurring_registration_info.rbs} +13 -3
- data/sig/dpay/recurring/recurring_retry_result.rbs +24 -0
- data/sig/dpay/recurring/recurring_service.rbs +15 -0
- data/sig/dpay/recurring/recurring_status.rbs +27 -0
- data/sig/dpay/refund/refund_service.rbs +2 -2
- data/sig/dpay/webhook/event_page.rbs +14 -0
- data/sig/dpay/webhook/event_service.rbs +21 -0
- data/sig/dpay/webhook/webhook_event.rbs +24 -0
- data/sig/dpay/webhook/webhook_event_type.rbs +23 -0
- data/sig/dpay/webhook/webhook_target.rbs +15 -0
- data/sig/dpay/webhook/webhook_verifier.rbs +37 -0
- metadata +26 -10
- data/lib/dpay/blik/blik_recurring_registration.rb +0 -74
- data/lib/dpay/blik/blik_recurring_registration_info.rb +0 -26
- data/lib/dpay/blik/blik_recurring_status.rb +0 -28
- data/sig/dpay/blik/blik_recurring_registration.rbs +0 -24
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
12
|
-
|
|
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["
|
|
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
|
@@ -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
|