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.
Files changed (48) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE.txt +17 -18
  4. data/README.md +345 -139
  5. data/app/controllers/przelewy24_payment/webhooks_controller.rb +63 -0
  6. data/app/jobs/przelewy24_payment/application_job.rb +6 -0
  7. data/app/jobs/przelewy24_payment/charge_due_subscriptions_job.rb +13 -0
  8. data/app/models/przelewy24_payment/application_record.rb +7 -0
  9. data/app/models/przelewy24_payment/payment.rb +177 -0
  10. data/app/models/przelewy24_payment/refund.rb +33 -0
  11. data/app/models/przelewy24_payment/subscription.rb +147 -0
  12. data/config/routes.rb +6 -0
  13. data/lib/generators/przelewy24_payment/install/install_generator.rb +60 -5
  14. data/lib/generators/przelewy24_payment/install/templates/README +14 -0
  15. data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_payments.rb.tt +21 -0
  16. data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_refunds.rb.tt +14 -0
  17. data/lib/generators/przelewy24_payment/install/templates/create_przelewy24_subscriptions.rb.tt +28 -0
  18. data/lib/generators/przelewy24_payment/install/templates/initializer.rb.tt +47 -0
  19. data/lib/przelewy24_payment/api/base.rb +17 -0
  20. data/lib/przelewy24_payment/api/blik.rb +41 -0
  21. data/lib/przelewy24_payment/api/cards.rb +26 -0
  22. data/lib/przelewy24_payment/api/payment_methods.rb +11 -0
  23. data/lib/przelewy24_payment/api/refunds.rb +25 -0
  24. data/lib/przelewy24_payment/api/transactions.rb +51 -0
  25. data/lib/przelewy24_payment/client.rb +66 -0
  26. data/lib/przelewy24_payment/configuration.rb +81 -0
  27. data/lib/przelewy24_payment/engine.rb +12 -1
  28. data/lib/przelewy24_payment/errors.rb +45 -0
  29. data/lib/przelewy24_payment/instrumentation.rb +16 -0
  30. data/lib/przelewy24_payment/money.rb +26 -0
  31. data/lib/przelewy24_payment/notification.rb +55 -0
  32. data/lib/przelewy24_payment/payable.rb +20 -0
  33. data/lib/przelewy24_payment/refund_notification.rb +44 -0
  34. data/lib/przelewy24_payment/signature.rb +64 -0
  35. data/lib/przelewy24_payment/test_helpers.rb +20 -0
  36. data/lib/przelewy24_payment/version.rb +3 -1
  37. data/lib/przelewy24_payment.rb +26 -187
  38. metadata +83 -24
  39. data/.gitignore +0 -1
  40. data/Gemfile +0 -4
  41. data/Rakefile +0 -2
  42. data/app/helpers/przelewy24_payment/payment_helper.rb +0 -5
  43. data/app/views/przelewy24_payment/_payment_form.html.haml +0 -28
  44. data/lib/generators/przelewy24_payment/install/USAGE +0 -8
  45. data/lib/generators/przelewy24_payment/install/templates/przelewy24_payment.rb +0 -16
  46. data/lib/przelewy24_payment/przelewy24_payment_controller.rb +0 -67
  47. data/lib/tasks/test_connection.rake +0 -18
  48. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przelewy24Payment
4
+ class ApplicationRecord < ::ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Przelewy24Payment::Engine.routes.draw do
4
+ post "webhooks/status", to: "webhooks#status", as: :status_webhooks
5
+ post "webhooks/refund", to: "webhooks#refund", as: :refund_webhooks
6
+ end
@@ -1,8 +1,63 @@
1
- class Przelewy24Payment::InstallGenerator < Rails::Generators::Base
2
- source_root File.expand_path('../templates', __FILE__)
1
+ # frozen_string_literal: true
3
2
 
4
- def add_config
5
- template "przelewy24_payment.rb", "config/initializers/przelewy24_payment.rb"
6
- end
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
@@ -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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przelewy24Payment
4
+ module Api
5
+ class Base
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :client
13
+
14
+ def config = client.configuration
15
+ end
16
+ end
17
+ 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