przelewy24_payment 0.2.0 → 2.0.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 +5 -5
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +17 -18
- data/README.md +345 -139
- data/app/controllers/przelewy24_payment/webhooks_controller.rb +63 -0
- data/app/jobs/przelewy24_payment/application_job.rb +6 -0
- data/app/jobs/przelewy24_payment/charge_due_subscriptions_job.rb +13 -0
- data/app/models/przelewy24_payment/application_record.rb +7 -0
- data/app/models/przelewy24_payment/payment.rb +177 -0
- data/app/models/przelewy24_payment/refund.rb +33 -0
- data/app/models/przelewy24_payment/subscription.rb +147 -0
- data/config/routes.rb +6 -0
- data/lib/generators/przelewy24_payment/install/install_generator.rb +60 -5
- data/lib/generators/przelewy24_payment/install/templates/README +14 -0
- data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_payments.rb.tt +21 -0
- data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_refunds.rb.tt +14 -0
- data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_subscriptions.rb.tt +28 -0
- data/lib/generators/przelewy24_payment/install/templates/initializer.rb.tt +47 -0
- data/lib/przelewy24_payment/api/base.rb +17 -0
- data/lib/przelewy24_payment/api/blik.rb +41 -0
- data/lib/przelewy24_payment/api/cards.rb +26 -0
- data/lib/przelewy24_payment/api/payment_methods.rb +11 -0
- data/lib/przelewy24_payment/api/refunds.rb +25 -0
- data/lib/przelewy24_payment/api/transactions.rb +51 -0
- data/lib/przelewy24_payment/client.rb +66 -0
- data/lib/przelewy24_payment/configuration.rb +81 -0
- data/lib/przelewy24_payment/engine.rb +12 -1
- data/lib/przelewy24_payment/errors.rb +45 -0
- data/lib/przelewy24_payment/instrumentation.rb +16 -0
- data/lib/przelewy24_payment/money.rb +26 -0
- data/lib/przelewy24_payment/notification.rb +55 -0
- data/lib/przelewy24_payment/payable.rb +20 -0
- data/lib/przelewy24_payment/refund_notification.rb +44 -0
- data/lib/przelewy24_payment/signature.rb +64 -0
- data/lib/przelewy24_payment/test_helpers.rb +20 -0
- data/lib/przelewy24_payment/version.rb +3 -1
- data/lib/przelewy24_payment.rb +26 -187
- metadata +83 -24
- data/.gitignore +0 -1
- data/Gemfile +0 -4
- data/Rakefile +0 -2
- data/app/helpers/przelewy24_payment/payment_helper.rb +0 -5
- data/app/views/przelewy24_payment/_payment_form.html.haml +0 -28
- data/lib/generators/przelewy24_payment/install/USAGE +0 -8
- data/lib/generators/przelewy24_payment/install/templates/przelewy24_payment.rb +0 -16
- data/lib/przelewy24_payment/przelewy24_payment_controller.rb +0 -67
- data/lib/tasks/test_connection.rake +0 -18
- data/przelewy24_payment.gemspec +0 -19
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
# Schedule this job (cron, Solid Queue recurring tasks, GoodJob cron) e.g. hourly.
|
|
5
|
+
class ChargeDueSubscriptionsJob < ApplicationJob
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform
|
|
9
|
+
Subscription.charge_due!
|
|
10
|
+
Payment.expire_stale!
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
class Payment < ApplicationRecord
|
|
5
|
+
self.table_name = "przelewy24_payments"
|
|
6
|
+
|
|
7
|
+
# pending: registered, waiting for the customer or for a notification
|
|
8
|
+
# verified: confirmed by Przelewy24, money received
|
|
9
|
+
# failed: notification arrived but verification was rejected
|
|
10
|
+
# expired: abandoned attempt closed by .expire_stale!; a late notification
|
|
11
|
+
# can still settle it
|
|
12
|
+
STATUSES = %w[pending verified failed expired].freeze
|
|
13
|
+
|
|
14
|
+
belongs_to :payable, polymorphic: true
|
|
15
|
+
has_many :refunds, class_name: "Przelewy24Payment::Refund", dependent: :destroy
|
|
16
|
+
|
|
17
|
+
validates :session_id, presence: true, uniqueness: true
|
|
18
|
+
validates :amount, numericality: { only_integer: true, greater_than: 0 }
|
|
19
|
+
validates :currency, presence: true
|
|
20
|
+
validates :status, inclusion: { in: STATUSES }
|
|
21
|
+
|
|
22
|
+
scope :pending, -> { where(status: "pending") }
|
|
23
|
+
scope :verified, -> { where(status: "verified") }
|
|
24
|
+
scope :stale, ->(older_than: 1.day) { pending.where(created_at: ...older_than.ago) }
|
|
25
|
+
|
|
26
|
+
# Creates a payment with a fresh session id, registers it at Przelewy24
|
|
27
|
+
# and returns it with a token. Each payment attempt gets its own record.
|
|
28
|
+
# Closes abandoned attempts (customer left the gateway, no notification came).
|
|
29
|
+
# Safe to run from a recurring job; returns the number of closed payments.
|
|
30
|
+
def self.expire_stale!(older_than: 1.day)
|
|
31
|
+
stale(older_than: older_than).find_each.count(&:expire!)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds the pending payment for the next period of a subscription.
|
|
35
|
+
def self.create_for!(subscription)
|
|
36
|
+
create!(payable: subscription, amount: subscription.amount, currency: subscription.currency,
|
|
37
|
+
session_id: SecureRandom.alphanumeric(16).upcase)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raises AlreadyPaidError when the payable already has a verified payment,
|
|
41
|
+
# so a retry can never charge the customer twice.
|
|
42
|
+
def self.start!(payable:, amount:, currency:, email:, description:, return_url:, status_url: nil,
|
|
43
|
+
client: Przelewy24Payment.client, **extra)
|
|
44
|
+
raise AlreadyPaidError, "#{payable.class} ##{payable.id} already has a verified payment" if payable.przelewy24_paid?
|
|
45
|
+
|
|
46
|
+
payment = create!(payable: payable, amount: Money.to_minor(amount), currency: currency,
|
|
47
|
+
session_id: SecureRandom.alphanumeric(16).upcase)
|
|
48
|
+
token = client.transactions.register(
|
|
49
|
+
session_id: payment.session_id, amount: payment.amount, currency: currency,
|
|
50
|
+
description: description, email: email, url_return: return_url,
|
|
51
|
+
url_status: status_url || payment.default_status_url, **extra
|
|
52
|
+
)
|
|
53
|
+
payment.update!(token: token)
|
|
54
|
+
payment
|
|
55
|
+
rescue StandardError
|
|
56
|
+
payment&.update_columns(status: "failed")
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def amount_decimal = Money.from_minor(amount)
|
|
61
|
+
|
|
62
|
+
def subscription
|
|
63
|
+
payable if payable.is_a?(Subscription)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Closes an abandoned attempt. For a subscription this counts as a failed charge.
|
|
67
|
+
def expire!
|
|
68
|
+
with_lock do
|
|
69
|
+
next false unless status == "pending"
|
|
70
|
+
|
|
71
|
+
update!(status: "expired")
|
|
72
|
+
subscription&.record_failure!(reason: "payment_expired")
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# BLIK level 0: charges the registered transaction with the customer's code.
|
|
78
|
+
# For a subscription payment the alias and recurring settings default to the
|
|
79
|
+
# ones stored on the subscription. The result arrives through the webhook.
|
|
80
|
+
def charge_blik_code!(blik_code:, alias_value: nil, alias_label: nil, recurring: nil,
|
|
81
|
+
client: Przelewy24Payment.client)
|
|
82
|
+
sub = subscription
|
|
83
|
+
client.blik.charge_by_code(
|
|
84
|
+
token: token, blik_code: blik_code,
|
|
85
|
+
alias_value: alias_value || sub&.alias_value, alias_label: alias_label || sub&.alias_label,
|
|
86
|
+
recurring: recurring || sub&.recurring
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# BLIK 1-click with a stored alias.
|
|
91
|
+
def charge_blik_alias!(alias_value:, client: Przelewy24Payment.client)
|
|
92
|
+
client.blik.charge_by_alias(token: token, alias_value: alias_value)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Card charge by reference (registered with method_ref_id). Without 3-D Secure
|
|
96
|
+
# it is off-session; with `three_ds: true` it returns { order_id:, redirect_url: }.
|
|
97
|
+
def charge_card!(three_ds: false, client: Przelewy24Payment.client)
|
|
98
|
+
three_ds ? client.cards.charge_with_3ds(token: token) : client.cards.charge(token: token)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Registers and charges a subscription renewal without the customer.
|
|
102
|
+
def register_and_charge!(client: Przelewy24Payment.client)
|
|
103
|
+
sub = subscription
|
|
104
|
+
options = { method_ref_id: sub.method_ref }
|
|
105
|
+
options[:additional] = { PSU: sub.psu } if sub.payment_method == "blik" && sub.psu
|
|
106
|
+
self.token = client.transactions.register(
|
|
107
|
+
session_id: session_id, amount: amount, currency: currency, description: sub.description,
|
|
108
|
+
email: sub.email, url_return: sub.return_url, url_status: default_status_url, **options
|
|
109
|
+
)
|
|
110
|
+
save!
|
|
111
|
+
if sub.payment_method == "blik"
|
|
112
|
+
charge_blik_alias!(alias_value: sub.method_ref, client: client)
|
|
113
|
+
else
|
|
114
|
+
charge_card!(client: client)
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError
|
|
117
|
+
update_columns(status: "failed") if persisted?
|
|
118
|
+
raise
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def redirect_url = token && Przelewy24Payment.configuration.gateway_url(token)
|
|
122
|
+
def pending? = status == "pending"
|
|
123
|
+
def expired? = status == "expired"
|
|
124
|
+
def verified? = status == "verified"
|
|
125
|
+
def failed? = status == "failed"
|
|
126
|
+
|
|
127
|
+
def default_status_url
|
|
128
|
+
Przelewy24Payment::Engine.routes.url_helpers.status_webhooks_url(
|
|
129
|
+
**Przelewy24Payment.configuration.url_options
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Verifies the notification with Przelewy24 exactly once: row lock + status
|
|
134
|
+
# guard make duplicate deliveries harmless. Does not run callbacks, see
|
|
135
|
+
# #deliver_callbacks!. Returns :verified, :failed or :duplicate.
|
|
136
|
+
def settle!(notification, client: Przelewy24Payment.client)
|
|
137
|
+
with_lock do
|
|
138
|
+
next :duplicate unless status.in?(%w[pending expired])
|
|
139
|
+
|
|
140
|
+
update!(p24_order_id: notification.order_id, method_id: notification.method_id,
|
|
141
|
+
payload: notification.attributes.except("sign"))
|
|
142
|
+
ok = client.transactions.verify(session_id: session_id, order_id: notification.order_id,
|
|
143
|
+
amount: amount, currency: currency)
|
|
144
|
+
update!(status: ok ? "verified" : "failed", verified_at: (Time.current if ok))
|
|
145
|
+
if ok
|
|
146
|
+
subscription&.payment_verified!(self, client: client)
|
|
147
|
+
else
|
|
148
|
+
subscription&.record_failure!(reason: "verification_failed")
|
|
149
|
+
end
|
|
150
|
+
ok ? :verified : :failed
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def settled? = status.in?(%w[verified failed])
|
|
155
|
+
|
|
156
|
+
# Runs the configured callback for a settled payment until it succeeds once.
|
|
157
|
+
# The callback and the notified_at stamp share one transaction, so a raising
|
|
158
|
+
# callback leaves the payment un-notified and the next webhook delivery
|
|
159
|
+
# retries it. Callbacks therefore run at least once and must be idempotent
|
|
160
|
+
# for side effects outside the database (mails, jobs).
|
|
161
|
+
# Returns true when the callback ran, false when there was nothing to do.
|
|
162
|
+
def deliver_callbacks!
|
|
163
|
+
with_lock do
|
|
164
|
+
next false unless settled? && notified_at.nil?
|
|
165
|
+
|
|
166
|
+
config = Przelewy24Payment.configuration
|
|
167
|
+
callback = verified? ? config.on_payment_verified : config.on_payment_failed
|
|
168
|
+
callback&.call(self)
|
|
169
|
+
update!(notified_at: Time.current)
|
|
170
|
+
Instrumentation.instrument("payment_#{status}", payment: self)
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
ActiveSupport.run_load_hooks(:przelewy24_payment_payment, self)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
class Refund < ApplicationRecord
|
|
5
|
+
self.table_name = "przelewy24_refunds"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending completed failed].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :payment, class_name: "Przelewy24Payment::Payment"
|
|
10
|
+
|
|
11
|
+
validates :amount, numericality: { only_integer: true, greater_than: 0 }
|
|
12
|
+
validates :refunds_uuid, presence: true, uniqueness: true
|
|
13
|
+
validates :status, inclusion: { in: STATUSES }
|
|
14
|
+
|
|
15
|
+
# Requests a refund of a verified payment. Amount in decimal currency units.
|
|
16
|
+
def self.request!(payment:, amount:, description: nil, status_url: nil, client: Przelewy24Payment.client)
|
|
17
|
+
raise Error, "payment is not verified" unless payment.verified?
|
|
18
|
+
|
|
19
|
+
minor = Money.to_minor(amount)
|
|
20
|
+
request_id = SecureRandom.uuid
|
|
21
|
+
result = client.refunds.create(
|
|
22
|
+
request_id: request_id, order_id: payment.p24_order_id, session_id: payment.session_id,
|
|
23
|
+
amount: minor, description: description,
|
|
24
|
+
url_status: status_url || Przelewy24Payment::Engine.routes.url_helpers.refund_webhooks_url(
|
|
25
|
+
**Przelewy24Payment.configuration.url_options
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
create!(payment: payment, amount: minor, request_id: request_id, refunds_uuid: result.fetch("refundsUuid"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
ActiveSupport.run_load_hooks(:przelewy24_payment_refund, self)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
# Przelewy24 has no subscription object: it only lets you charge a stored
|
|
5
|
+
# BLIK alias or card reference without the customer present. This model keeps
|
|
6
|
+
# the schedule on your side and drives those charges.
|
|
7
|
+
#
|
|
8
|
+
# pending -> first payment not confirmed yet
|
|
9
|
+
# active -> charged every `frequency` while payments succeed
|
|
10
|
+
# past_due -> last charge failed, retried every config.retry_interval
|
|
11
|
+
# canceled -> no more charges (by you, or after too many failures)
|
|
12
|
+
class Subscription < ApplicationRecord
|
|
13
|
+
self.table_name = "przelewy24_subscriptions"
|
|
14
|
+
|
|
15
|
+
STATUSES = %w[pending active past_due canceled].freeze
|
|
16
|
+
METHODS = %w[blik card].freeze
|
|
17
|
+
FREQUENCY = /\A([1-9]\d{0,2})([DWMY])\z/
|
|
18
|
+
|
|
19
|
+
include Payable
|
|
20
|
+
|
|
21
|
+
belongs_to :owner, polymorphic: true
|
|
22
|
+
|
|
23
|
+
validates :payment_method, inclusion: { in: METHODS }
|
|
24
|
+
validates :status, inclusion: { in: STATUSES }
|
|
25
|
+
validates :frequency, format: { with: FREQUENCY }
|
|
26
|
+
validates :amount, numericality: { only_integer: true, greater_than: 0 }
|
|
27
|
+
validates :currency, :email, :return_url, presence: true
|
|
28
|
+
|
|
29
|
+
scope :active, -> { where(status: "active") }
|
|
30
|
+
scope :due, ->(at = Time.current) { where(status: %w[active past_due]).where(next_charge_at: ..at) }
|
|
31
|
+
|
|
32
|
+
# Starts a subscription with its first payment, made with the customer
|
|
33
|
+
# present. Redirect to the returned payment's redirect_url (card, or BLIK
|
|
34
|
+
# through the gateway) or complete a BLIK code payment with
|
|
35
|
+
# payment.charge_blik_code!(...) passing the same alias and `recurring`.
|
|
36
|
+
#
|
|
37
|
+
# For BLIK pass `recurring:` exactly as described in the Przelewy24 docs
|
|
38
|
+
# (type O/M/A, frequency, limits). The feature must be enabled by Przelewy24.
|
|
39
|
+
#
|
|
40
|
+
# psu: { IP:, userAgent: } of the customer; Przelewy24 requires it for BLIK
|
|
41
|
+
# and it is reused for later off-session charges.
|
|
42
|
+
def self.start!(owner:, payment_method:, amount:, currency:, email:, description:, frequency:, return_url:,
|
|
43
|
+
psu: nil, recurring: nil, alias_value: nil, alias_label: nil, client: Przelewy24Payment.client,
|
|
44
|
+
**payment_options)
|
|
45
|
+
is_blik = payment_method.to_s == "blik"
|
|
46
|
+
subscription = create!(owner: owner, payment_method: payment_method.to_s, amount: Money.to_minor(amount),
|
|
47
|
+
currency: currency, email: email, description: description, frequency: frequency,
|
|
48
|
+
return_url: return_url, psu: psu, recurring: recurring,
|
|
49
|
+
alias_value: alias_value || (SecureRandom.uuid if is_blik),
|
|
50
|
+
alias_label: alias_label || description.to_s.ljust(5, ".").first(35))
|
|
51
|
+
payment_options[:additional] = { PSU: psu } if psu
|
|
52
|
+
subscription.first_payment = Payment.start!(
|
|
53
|
+
payable: subscription, amount: amount, currency: currency, email: email, description: description,
|
|
54
|
+
return_url: return_url, client: client, **payment_options
|
|
55
|
+
)
|
|
56
|
+
subscription
|
|
57
|
+
rescue StandardError
|
|
58
|
+
subscription&.update_columns(status: "canceled", cancel_reason: "start_failed")
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_accessor :first_payment
|
|
63
|
+
|
|
64
|
+
# Charges the stored method for the next period. The result arrives through
|
|
65
|
+
# the normal status webhook, which renews the subscription.
|
|
66
|
+
# Returns the pending Payment, or nil when a charge is already in flight.
|
|
67
|
+
def charge!(client: Przelewy24Payment.client)
|
|
68
|
+
payment = with_lock do
|
|
69
|
+
next if canceled? || method_ref.blank? || przelewy24_payments.pending.exists?
|
|
70
|
+
|
|
71
|
+
Payment.create_for!(self)
|
|
72
|
+
end
|
|
73
|
+
return unless payment
|
|
74
|
+
|
|
75
|
+
payment.register_and_charge!(client: client)
|
|
76
|
+
payment
|
|
77
|
+
rescue Error => e
|
|
78
|
+
record_failure!(reason: e.message)
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Charges every due subscription. Run from a recurring job.
|
|
83
|
+
def self.charge_due!(client: Przelewy24Payment.client)
|
|
84
|
+
due.find_each.count { |subscription| subscription.charge!(client: client) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cancel!(reason: "canceled")
|
|
88
|
+
return false if canceled?
|
|
89
|
+
|
|
90
|
+
update!(status: "canceled", canceled_at: Time.current, cancel_reason: reason, next_charge_at: nil)
|
|
91
|
+
Instrumentation.instrument("subscription_canceled", subscription: self)
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def canceled? = status == "canceled"
|
|
96
|
+
def active? = status == "active"
|
|
97
|
+
def amount_decimal = Money.from_minor(amount)
|
|
98
|
+
|
|
99
|
+
# Called from Payment#settle! when a payment of this subscription is verified.
|
|
100
|
+
def payment_verified!(payment, client: Przelewy24Payment.client)
|
|
101
|
+
return if canceled?
|
|
102
|
+
|
|
103
|
+
first = pending?
|
|
104
|
+
self.method_ref = fetch_card_reference(payment, client) if payment_method == "card" && method_ref.blank?
|
|
105
|
+
self.method_ref = alias_value if payment_method == "blik" && method_ref.blank?
|
|
106
|
+
update!(status: "active", failed_attempts: 0, last_charged_at: Time.current,
|
|
107
|
+
next_charge_at: advance(Time.current))
|
|
108
|
+
Instrumentation.instrument(first ? "subscription_activated" : "subscription_renewed",
|
|
109
|
+
subscription: self, payment: payment)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Called when a payment of this subscription failed or expired.
|
|
113
|
+
def record_failure!(reason: nil)
|
|
114
|
+
return if canceled?
|
|
115
|
+
|
|
116
|
+
attempts = failed_attempts + 1
|
|
117
|
+
if pending? || attempts >= Przelewy24Payment.configuration.max_failed_attempts
|
|
118
|
+
return cancel!(reason: reason.presence || "payment_failed")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
update!(status: "past_due", failed_attempts: attempts,
|
|
122
|
+
next_charge_at: Przelewy24Payment.configuration.retry_interval.from_now)
|
|
123
|
+
Instrumentation.instrument("subscription_past_due", subscription: self, reason: reason)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def pending? = status == "pending"
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def advance(from)
|
|
131
|
+
count, unit = FREQUENCY.match(frequency).captures
|
|
132
|
+
count = count.to_i
|
|
133
|
+
case unit
|
|
134
|
+
when "D" then from + count.days
|
|
135
|
+
when "W" then from + count.weeks
|
|
136
|
+
when "M" then from + count.months
|
|
137
|
+
when "Y" then from + count.years
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fetch_card_reference(payment, client)
|
|
142
|
+
client.cards.reference_for(payment.p24_order_id)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ActiveSupport.run_load_hooks(:przelewy24_payment_subscription, self)
|
|
146
|
+
end
|
|
147
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -1,8 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
source_root File.expand_path('../templates', __FILE__)
|
|
1
|
+
# frozen_string_literal: true
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Przelewy24Payment
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Installs Przelewy24Payment: initializer, migrations and webhook routes."
|
|
13
|
+
|
|
14
|
+
class_option :mount_path, type: :string, default: "/przelewy24",
|
|
15
|
+
desc: "Path where the webhook engine is mounted"
|
|
16
|
+
class_option :skip_subscriptions, type: :boolean, default: false,
|
|
17
|
+
desc: "Do not generate the subscriptions migration"
|
|
18
|
+
|
|
19
|
+
def self.next_migration_number(dirname)
|
|
20
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def copy_initializer
|
|
24
|
+
template "initializer.rb.tt", "config/initializers/przelewy24_payment.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def copy_migrations
|
|
28
|
+
migration_template "create_przelewy24_payments.rb.tt", "db/migrate/create_przelewy24_payments.rb",
|
|
29
|
+
migration_version: migration_version
|
|
30
|
+
tables = options[:skip_subscriptions] ? %w[refunds] : %w[refunds subscriptions]
|
|
31
|
+
tables.each do |name|
|
|
32
|
+
sleep 1 # keep migration timestamps unique
|
|
33
|
+
migration_template "create_przelewy24_#{name}.rb.tt", "db/migrate/create_przelewy24_#{name}.rb",
|
|
34
|
+
migration_version: migration_version
|
|
35
|
+
end
|
|
36
|
+
end
|
|
7
37
|
|
|
38
|
+
def mount_engine
|
|
39
|
+
return if File.read(File.join(destination_root, "config/routes.rb")).include?("Przelewy24Payment::Engine")
|
|
40
|
+
|
|
41
|
+
route "mount Przelewy24Payment::Engine => #{mount_path.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show_readme
|
|
45
|
+
return unless behavior == :invoke
|
|
46
|
+
|
|
47
|
+
say File.read(File.join(self.class.source_root, "README")).gsub("{{mount_path}}", mount_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def mount_path
|
|
53
|
+
path = options[:mount_path].to_s
|
|
54
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
55
|
+
path.chomp("/").presence || "/"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def migration_version
|
|
59
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
8
63
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
Przelewy24Payment installed. Next steps:
|
|
3
|
+
|
|
4
|
+
1. Run `bin/rails db:migrate`.
|
|
5
|
+
2. Add credentials (`bin/rails credentials:edit`) or set the ENV variables:
|
|
6
|
+
PRZELEWY24_MERCHANT_ID, PRZELEWY24_POS_ID (optional), PRZELEWY24_API_KEY, PRZELEWY24_CRC_KEY.
|
|
7
|
+
3. Include Przelewy24Payment::Payable in your payable model (e.g. Order).
|
|
8
|
+
4. Start a payment:
|
|
9
|
+
payment = Przelewy24Payment::Payment.start!(payable: order, amount: order.price,
|
|
10
|
+
currency: "PLN", email: user.email, description: "Order #1", return_url: order_url(order))
|
|
11
|
+
redirect_to payment.redirect_url, allow_other_host: true
|
|
12
|
+
5. Status webhook URL (public, HTTPS in production):
|
|
13
|
+
POST {{mount_path}}/webhooks/status
|
|
14
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreatePrzelewy24Payments < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :przelewy24_payments do |t|
|
|
4
|
+
t.references :payable, polymorphic: true, null: false, index: true
|
|
5
|
+
t.string :session_id, null: false
|
|
6
|
+
t.string :token
|
|
7
|
+
t.integer :amount, null: false
|
|
8
|
+
t.string :currency, null: false
|
|
9
|
+
t.string :status, null: false, default: "pending"
|
|
10
|
+
t.bigint :p24_order_id
|
|
11
|
+
t.integer :method_id
|
|
12
|
+
t.json :payload
|
|
13
|
+
t.datetime :verified_at
|
|
14
|
+
t.datetime :notified_at
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :przelewy24_payments, :session_id, unique: true
|
|
19
|
+
add_index :przelewy24_payments, :p24_order_id
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreatePrzelewy24Refunds < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :przelewy24_refunds do |t|
|
|
4
|
+
t.references :payment, null: false, foreign_key: { to_table: :przelewy24_payments }
|
|
5
|
+
t.integer :amount, null: false
|
|
6
|
+
t.string :request_id, null: false
|
|
7
|
+
t.string :refunds_uuid, null: false
|
|
8
|
+
t.string :status, null: false, default: "pending"
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :przelewy24_refunds, :refunds_uuid, unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_subscriptions.rb.tt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class CreatePrzelewy24Subscriptions < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :przelewy24_subscriptions do |t|
|
|
4
|
+
t.references :owner, polymorphic: true, null: false, index: true
|
|
5
|
+
t.string :payment_method, null: false
|
|
6
|
+
t.string :status, null: false, default: "pending"
|
|
7
|
+
t.string :frequency, null: false
|
|
8
|
+
t.integer :amount, null: false
|
|
9
|
+
t.string :currency, null: false
|
|
10
|
+
t.string :email, null: false
|
|
11
|
+
t.string :description
|
|
12
|
+
t.string :return_url, null: false
|
|
13
|
+
t.string :alias_value
|
|
14
|
+
t.string :alias_label
|
|
15
|
+
t.string :method_ref
|
|
16
|
+
t.json :recurring
|
|
17
|
+
t.json :psu
|
|
18
|
+
t.integer :failed_attempts, null: false, default: 0
|
|
19
|
+
t.datetime :next_charge_at
|
|
20
|
+
t.datetime :last_charged_at
|
|
21
|
+
t.datetime :canceled_at
|
|
22
|
+
t.string :cancel_reason
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_index :przelewy24_subscriptions, %i[status next_charge_at]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Przelewy24Payment.configure do |config|
|
|
4
|
+
# :sandbox or :production
|
|
5
|
+
config.environment = Rails.env.production? ? :production : :sandbox
|
|
6
|
+
|
|
7
|
+
# Credentials: Rails credentials first, ENV as a fallback.
|
|
8
|
+
# bin/rails credentials:edit
|
|
9
|
+
# przelewy24:
|
|
10
|
+
# merchant_id: 12345
|
|
11
|
+
# pos_id: 12345 # optional, defaults to merchant_id
|
|
12
|
+
# api_key: "..." # REST API key from the Przelewy24 panel
|
|
13
|
+
# crc_key: "..." # CRC key of the shop
|
|
14
|
+
credentials = Rails.application.credentials.przelewy24 || {}
|
|
15
|
+
config.merchant_id = credentials[:merchant_id] || ENV["PRZELEWY24_MERCHANT_ID"]
|
|
16
|
+
config.pos_id = credentials[:pos_id] || ENV["PRZELEWY24_POS_ID"]
|
|
17
|
+
config.api_key = credentials[:api_key] || ENV["PRZELEWY24_API_KEY"]
|
|
18
|
+
config.crc_key = credentials[:crc_key] || ENV["PRZELEWY24_CRC_KEY"]
|
|
19
|
+
|
|
20
|
+
config.default_country = "PL"
|
|
21
|
+
config.default_language = "pl"
|
|
22
|
+
|
|
23
|
+
# Public host used to build the status webhook URL sent to Przelewy24.
|
|
24
|
+
# In development point it at a tunnel (ngrok, cloudflared).
|
|
25
|
+
config.url_options = {
|
|
26
|
+
host: ENV.fetch("APP_HOST", "localhost:3000"),
|
|
27
|
+
protocol: ENV.fetch("APP_PROTOCOL", Rails.env.production? ? "https" : "http")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Optional: only accept webhooks from Przelewy24 servers (IPs and CIDR ranges
|
|
31
|
+
# published in the Przelewy24 REST docs, section "Adresy IP serwerów").
|
|
32
|
+
# config.allowed_ips = %w[5.252.202.254 5.252.202.255 20.215.81.124 20.215.183.48/28
|
|
33
|
+
# 193.178.213.0/24 91.220.177.0/24 134.112.88.8/29]
|
|
34
|
+
|
|
35
|
+
# Controller the webhook endpoints inherit from. Keep it free of
|
|
36
|
+
# authentication filters; Przelewy24 calls it without a session.
|
|
37
|
+
# config.parent_controller = "ApplicationController"
|
|
38
|
+
|
|
39
|
+
# Subscriptions: failed charges before cancel, and the wait between retries.
|
|
40
|
+
# config.max_failed_attempts = 3
|
|
41
|
+
# config.retry_interval = 1.day
|
|
42
|
+
|
|
43
|
+
# Called once per payment, after Przelewy24 confirmed the transaction.
|
|
44
|
+
config.on_payment_verified = ->(payment) { payment.payable.try(:payment_verified!) }
|
|
45
|
+
config.on_payment_failed = ->(payment) { payment.payable.try(:payment_failed!) }
|
|
46
|
+
config.on_refund = ->(refund) { }
|
|
47
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
module Api
|
|
5
|
+
# BLIK on the merchant's own page. The transaction must be registered first
|
|
6
|
+
# (with additional.PSU) and its token passed here.
|
|
7
|
+
class Blik < Base
|
|
8
|
+
# BLIK level 0: the customer types the 6-digit code. Pass alias_value,
|
|
9
|
+
# alias_label (5..35 chars) and recurring to register an alias for later charges.
|
|
10
|
+
def charge_by_code(token:, blik_code:, alias_value: nil, alias_label: nil, recurring: nil)
|
|
11
|
+
body = { token: token, blikCode: blik_code, aliasValue: alias_value,
|
|
12
|
+
aliasLabel: alias_label, recurring: recurring }.compact
|
|
13
|
+
client.post("paymentMethod/blik/chargeByCode", body).fetch("data").fetch("orderId")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# BLIK 1-click / recurring charge with a stored alias. Raises
|
|
17
|
+
# AmbiguousAliasError (HTTP 409) when the alias maps to several bank apps;
|
|
18
|
+
# retry with charge_by_alternative_key using one of error.alternative_keys.
|
|
19
|
+
def charge_by_alias(token:, alias_value:)
|
|
20
|
+
charge(type: "alias", token: token, aliasValue: alias_value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def charge_by_alternative_key(token:, alternative_key:)
|
|
24
|
+
charge(type: "alternativeKey", token: token, alternativeKey: alternative_key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def aliases(email, custom: false)
|
|
28
|
+
path = "paymentMethod/blik/getAliasesByEmail/#{ERB::Util.url_encode(email)}"
|
|
29
|
+
client.get(custom ? "#{path}/custom" : path).fetch("data")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def charge(body)
|
|
35
|
+
client.post("paymentMethod/blik/chargeByAlias", body).fetch("data").fetch("orderId")
|
|
36
|
+
rescue ConflictError => e
|
|
37
|
+
raise AmbiguousAliasError.new(e.message, status: e.status, body: e.body)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przelewy24Payment
|
|
4
|
+
module Api
|
|
5
|
+
# Card one-click and recurring charges by card reference (refId). The
|
|
6
|
+
# reference must be passed as methodRefId when registering the transaction.
|
|
7
|
+
class Cards < Base
|
|
8
|
+
# Reference of the card used in a paid transaction (nil when unavailable).
|
|
9
|
+
def reference_for(order_id)
|
|
10
|
+
client.get("card/info/#{Integer(order_id)}").dig("data", "refId")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Charge without the customer present (recurring). Returns the order id.
|
|
14
|
+
def charge(token:)
|
|
15
|
+
client.post("card/charge", { token: token }).fetch("data").fetch("orderId")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One-click charge that may need 3-D Secure. Returns { order_id:, redirect_url: }
|
|
19
|
+
# where redirect_url is nil when no customer action is needed.
|
|
20
|
+
def charge_with_3ds(token:)
|
|
21
|
+
data = client.post("card/chargeWith3ds", { token: token }).fetch("data")
|
|
22
|
+
{ order_id: data["orderId"], redirect_url: data["redirectUrl"] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|