pay-abacatepay 0.1.0.pre.1

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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +248 -0
  5. data/app/controllers/pay/webhooks/abacatepay_controller.rb +104 -0
  6. data/lib/generators/pay_abacatepay/install/install_generator.rb +21 -0
  7. data/lib/generators/pay_abacatepay/install/templates/create_pay_abacatepay_processed_webhooks.rb.tt +15 -0
  8. data/lib/pay/abacatepay/charge.rb +77 -0
  9. data/lib/pay/abacatepay/customer.rb +136 -0
  10. data/lib/pay/abacatepay/engine.rb +33 -0
  11. data/lib/pay/abacatepay/payment_method.rb +21 -0
  12. data/lib/pay/abacatepay/processed_webhook.rb +19 -0
  13. data/lib/pay/abacatepay/subscription.rb +88 -0
  14. data/lib/pay/abacatepay/version.rb +5 -0
  15. data/lib/pay/abacatepay/webhooks/checkout_completed.rb +52 -0
  16. data/lib/pay/abacatepay/webhooks/checkout_disputed.rb +11 -0
  17. data/lib/pay/abacatepay/webhooks/checkout_lost.rb +11 -0
  18. data/lib/pay/abacatepay/webhooks/checkout_refunded.rb +38 -0
  19. data/lib/pay/abacatepay/webhooks/event.rb +95 -0
  20. data/lib/pay/abacatepay/webhooks/payout_completed.rb +11 -0
  21. data/lib/pay/abacatepay/webhooks/payout_failed.rb +11 -0
  22. data/lib/pay/abacatepay/webhooks/subscription_cancelled.rb +34 -0
  23. data/lib/pay/abacatepay/webhooks/subscription_completed.rb +53 -0
  24. data/lib/pay/abacatepay/webhooks/subscription_renewed.rb +45 -0
  25. data/lib/pay/abacatepay/webhooks/subscription_trial_started.rb +13 -0
  26. data/lib/pay/abacatepay/webhooks/transfer_completed.rb +11 -0
  27. data/lib/pay/abacatepay/webhooks/transfer_failed.rb +11 -0
  28. data/lib/pay/abacatepay/webhooks/transparent_completed.rb +11 -0
  29. data/lib/pay/abacatepay/webhooks/transparent_disputed.rb +11 -0
  30. data/lib/pay/abacatepay/webhooks/transparent_lost.rb +11 -0
  31. data/lib/pay/abacatepay/webhooks/transparent_refunded.rb +11 -0
  32. data/lib/pay/abacatepay/webhooks.rb +23 -0
  33. data/lib/pay/abacatepay.rb +75 -0
  34. data/lib/pay-abacatepay.rb +1 -0
  35. metadata +105 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e2ab67254ae39892abe3501a42c66727a625849a722fb619264986e9956194f9
4
+ data.tar.gz: 12af82bfd3ec2b1caf6fbcc980cbfa42bca571af77abdb2e6b1d15d1ed58e2a3
5
+ SHA512:
6
+ metadata.gz: fe3d6ac447067fa8ea24479b282456f4de5a46c01acf556c3b17a1f826c00bc752bb3a948cb87b51f5e3fc967fb700221e51524a674782ae1b7dea61bee9617c
7
+ data.tar.gz: 56e74d4a3213b2036dd02dbb57cf72e2e8e78bc5d3a85a845a05e9f544a6b4e8500edb83ca6009898bc35ded6b02b945249853c5160bef61539d3c6e6b8a231d
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial scaffold with Rails engine structure
7
+ - `Pay::AbacatePay::Customer` with `api_record`, `api_record_attributes`, `update_api_record`
8
+ - Document validation (`document`/`cpf`/`cnpj`) on billable model
9
+ - Configuration via Rails credentials or environment variables
10
+ - Integration with official `abacatepay-ruby` SDK as HTTP layer
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Daniel Moreira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # pay-abacatepay
2
+
3
+ AbacatePay processor for the [Pay gem](https://github.com/pay-rails/pay) (Rails payments engine).
4
+
5
+ > [!WARNING]
6
+ > This gem is a work in progress and is **not ready for production use**. Public API may break until 1.0.
7
+
8
+ > This gem is not affiliated with AbacatePay. It is a community-maintained adapter.
9
+
10
+ ## Status
11
+
12
+ - [x] Customer creation
13
+ - [x] One-time charges — hosted checkout via `Customer#charge` + `checkout.*` webhooks
14
+ - [ ] Transparent PIX (QR Code inline) — planned (Fase 5)
15
+ - [x] Subscriptions — webhook-driven lifecycle + cancel (gaps below)
16
+ - [x] Webhooks — infrastructure + subscription and checkout handlers
17
+ - [ ] Chargeback/dispute handling — planned (Fase 5)
18
+ - [ ] Payment methods — planned
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ bundle add pay-abacatepay
24
+ ```
25
+
26
+ Make sure the `pay` gem is installed and mounted: https://github.com/pay-rails/pay/blob/main/docs/1_installation.md
27
+
28
+ ## Configuration
29
+
30
+ ### Rails credentials
31
+
32
+ ```bash
33
+ rails credentials:edit --environment=development
34
+ ```
35
+
36
+ ```yaml
37
+ abacatepay:
38
+ api_key: abc_dev_xxxxx
39
+ ```
40
+
41
+ ### Environment variables
42
+
43
+ - `ABACATEPAY_API_KEY` — required
44
+ - `ABACATEPAY_WEBHOOK_SECRET` — optional, reserved for webhooks (not yet implemented)
45
+
46
+ ### Environment (sandbox vs production)
47
+
48
+ The AbacatePay API version is inferred from the **token prefix**:
49
+
50
+ | Token prefix | API version | Typical use |
51
+ |---|---|---|
52
+ | `abc_dev_*` | v2 | sandbox |
53
+ | `abc_live_*` | v2 | production |
54
+ | other | v1 | legacy |
55
+
56
+ There is no environment switch to configure — give the gem the right token and it routes correctly.
57
+
58
+ ## Customer
59
+
60
+ To create an AbacatePay customer the billable model **must** expose a document (CPF or CNPJ). The gem checks, in order, `document`, `cpf`, then `cnpj`. Non-digit characters are stripped before being sent to the API.
61
+
62
+ ```ruby
63
+ class User < ApplicationRecord
64
+ pay_customer default_payment_processor: :abacatepay
65
+ end
66
+
67
+ user = User.create!(email: "user@example.com", name: "Daniel", document: "123.456.789-01")
68
+ user.payment_processor.customer # creates a customer on AbacatePay, stores processor_id
69
+ ```
70
+
71
+ If the document is missing or blank, a `Pay::AbacatePay::Error` is raised before any HTTP request.
72
+
73
+ ### Idempotency
74
+
75
+ AbacatePay's `POST /v2/customers/create` is idempotent by `taxId`: submitting the same document returns the existing customer with HTTP 200. The adapter stores whichever id the API returns — no client-side deduplication is needed.
76
+
77
+ ### Updates
78
+
79
+ AbacatePay does not expose a customer update endpoint. `update_api_record` is a **no-op with a warning**. If you rename a user, the AbacatePay record will not reflect it until the API grows `PATCH /v2/customers`.
80
+
81
+ ## One-time charges
82
+
83
+ `Customer#charge` creates an AbacatePay **hosted checkout** and returns a struct you can redirect the payer to. A pending `Pay::Abacatepay::Charge` is persisted immediately so the app can render "payment in progress" UI and reconcile against the webhook later.
84
+
85
+ ```ruby
86
+ result = user.payment_processor.charge(
87
+ 5000, # amount in cents
88
+ methods: ["PIX", "CARD"], # defaults shown
89
+ return_url: "https://app.example.com/cart",
90
+ completion_url: "https://app.example.com/thanks",
91
+ external_id: "order-1234" # optional, for your reconciliation
92
+ )
93
+
94
+ redirect_to result.url # send the user to AbacatePay
95
+ result.id # "chk_xxx" — also result.charge.processor_id
96
+ result.charge # Pay::Abacatepay::Charge, status: "pending"
97
+ ```
98
+
99
+ ### Product on-the-fly
100
+
101
+ AbacatePay's v2 `/checkouts/create` expects pre-registered products in `items[]`. When `product_id:` is omitted, the gem creates an ephemeral product via `POST /products/create` with `name: "Cobrança avulsa"` (overridable via `product_name:`). This costs an extra API call but keeps the host app's code free of product bookkeeping. Pass `product_id:` to skip this step when you manage products yourself.
102
+
103
+ ### Completion flow
104
+
105
+ Once the payer completes the checkout, AbacatePay delivers a `checkout.completed` webhook. The handler:
106
+
107
+ 1. Skips the event if `data.checkout.frequency != "ONE_TIME"` — subscription payments are handled by `subscription.renewed` (see [Subscriptions](#subscriptions)).
108
+ 2. Locates the `Pay::Customer` by `processor_id` (no auto-creation — the customer must already exist from the app signup flow).
109
+ 3. Updates the pending `Pay::Abacatepay::Charge` (same `processor_id` as `result.id`) to `status: "paid"`, filling in `amount_refunded`, `application_fee_amount`, and `created_at`. If the checkout originated outside `Customer#charge`, a new charge is created instead.
110
+
111
+ ### Refunds
112
+
113
+ AbacatePay **does not expose a programmatic refund endpoint** (confirmed in SDK v0.2.0 and in the public API docs as of April 2026). Calling `Pay::Abacatepay::Charge#refund!` raises `Pay::Abacatepay::Error` with a message pointing you to the dashboard.
114
+
115
+ ```
116
+ AbacatePay does not expose a refund endpoint. Process the refund in the
117
+ AbacatePay dashboard; the checkout.refunded webhook will update this
118
+ Pay::Charge automatically.
119
+ ```
120
+
121
+ When the refund is issued in the dashboard, AbacatePay delivers `checkout.refunded`; the gem updates `amount_refunded` and `status: "refunded"` on the matching charge. If the charge is not found (refund for a checkout the app never registered), the handler logs a warning and no-ops.
122
+
123
+ ### Status mapping
124
+
125
+ `Pay::Abacatepay::Charge` stores status in `data` via `store_accessor`. The mapping is intentionally narrow:
126
+
127
+ | AbacatePay | `Pay::Abacatepay::Charge#status` |
128
+ |---|---|
129
+ | `PENDING` | `"pending"` |
130
+ | `PAID` | `"paid"` |
131
+ | `REFUNDED` | `"refunded"` |
132
+ | `DISPUTED` | `"disputed"` (see Fase 5) |
133
+ | `EXPIRED` | *(no charge is created — the payment never succeeded)* |
134
+ | `CANCELLED` | *(no charge is created)* |
135
+
136
+ ## Subscriptions
137
+
138
+ Subscriptions are managed primarily through webhooks: when AbacatePay delivers `subscription.completed`, `subscription.renewed`, or `subscription.cancelled`, the gem creates or updates the corresponding `Pay::Subscription` and, for paid events, the matching `Pay::Charge` (with `data.payment.id` as `processor_id`).
139
+
140
+ ### Install the dedup migration
141
+
142
+ Before deploying, run the generator and migrate:
143
+
144
+ ```bash
145
+ bin/rails generate pay_abacatepay:install:migrations
146
+ bin/rails db:migrate
147
+ ```
148
+
149
+ The migration creates `pay_abacatepay_processed_webhooks`, a permanent table with a unique `(event_type, event_id)` index. It protects against double-processing on AbacatePay retries — `Pay::Webhook` records are destroyed after processing, so without this table a retry that arrives after the original ACK would create a duplicate `Pay::Charge`.
150
+
151
+ ### Supported operations
152
+
153
+ | Operation | Support |
154
+ |---|---|
155
+ | Webhook-driven `Pay::Subscription` create/update | yes |
156
+ | `Pay::Charge` creation per renewal | yes, idempotent via `processor_id = data.payment.id` |
157
+ | `#cancel_now!` (immediate cancellation) | yes (calls `POST /v2/subscriptions/cancel` directly — SDK does not cover) |
158
+ | `#cancel` | delegates to `#cancel_now!` with a `Rails.logger.warn`; see gap below |
159
+
160
+ ### Known gaps
161
+
162
+ AbacatePay's API is narrower than Stripe's, so several `Pay::Subscription` affordances are intentionally not implemented:
163
+
164
+ - **No cancel-at-period-end.** AbacatePay cancels immediately. `#cancel` delegates to `#cancel_now!` and logs a warning so code paths that assume Stripe-like grace periods notice the divergence.
165
+ - **No plan swap.** `#swap` raises `NotImplementedError`. Cancel and create a new subscription instead.
166
+ - **No resume.** `#resume` raises `NotImplementedError`. Cancelled subscriptions cannot be reactivated.
167
+ - **No quantity changes.** `#change_quantity` raises `NotImplementedError`.
168
+ - **No `past_due` state.** AbacatePay does not emit payment-failure events, so `#past_due?` always returns `false`.
169
+ - **No `Subscriptions.retrieve` / `Subscriptions.cancel` in the SDK (v0.2.x).** Both calls are made via the SDK's Faraday client directly. Filed upstream.
170
+ - **`subscription.trial_started`.** Handler is registered but raises `NotImplementedError` — the event is not listed in `AbacatePay::Enums::Webhooks::EventTypes`, so we fail-loud until it is confirmed.
171
+
172
+ ### Webhook idempotency
173
+
174
+ Each event has a permanent `id` (e.g. `log_abc123xyz`). The handler wraps its side effects in `Pay::Abacatepay::ProcessedWebhook.process!(event_type:, event_id:)`, which relies on the unique index to short-circuit retries. A second delivery of the same event returns `:already_processed` and produces no side effects.
175
+
176
+ ## Webhooks
177
+
178
+ The gem mounts `POST /pay/webhooks/abacatepay` on the Pay engine (so the full URL is whatever `Pay.routes_path` resolves to — `/pay/webhooks/abacatepay` by default). Point AbacatePay's dashboard webhook at that path on your public host.
179
+
180
+ ### Secret and signature
181
+
182
+ Each webhook created in AbacatePay's dashboard has its own `secret`. Expose it to the gem via Rails credentials or environment:
183
+
184
+ ```yaml
185
+ # config/credentials.yml.enc
186
+ abacatepay:
187
+ webhook_secret: wsec_xxxxx
188
+ ```
189
+
190
+ Or set `ABACATEPAY_WEBHOOK_SECRET` in the environment.
191
+
192
+ Every incoming request is verified with **HMAC-SHA256** over the raw request body. The expected header is `X-Webhook-Signature`. Verification happens before any parsing or persistence; the gem delegates to the official SDK's `AbacatePay::Webhooks.verify!`.
193
+
194
+ ### Response codes
195
+
196
+ | Scenario | Status |
197
+ |---|---|
198
+ | Valid signature + known event | `200 OK` |
199
+ | Valid signature + unknown event type | `200 OK` (ignored, no record) |
200
+ | Duplicate delivery (same `data.id`, same type) while a previous copy is still queued | `200 OK` (dedup, no double-processing) |
201
+ | Missing or invalid `X-Webhook-Signature` | `401 Unauthorized` |
202
+ | Malformed JSON | `400 Bad Request` |
203
+
204
+ Note: Idempotency is scoped to the window between reception and processing (`Pay::Webhook` records are destroyed by `Pay::Webhooks::ProcessJob#process!`). AbacatePay retries that arrive after a successful handler run will be re-processed; handlers must therefore be individually idempotent — or upgrade this strategy in a later phase.
205
+
206
+ ### Supported events
207
+
208
+ | Event | Handler | Status |
209
+ |---|---|---|
210
+ | `checkout.completed` | `Pay::Abacatepay::Webhooks::CheckoutCompleted` | active (one-time only; subscription payments skipped) |
211
+ | `checkout.refunded` | `Pay::Abacatepay::Webhooks::CheckoutRefunded` | active |
212
+ | `checkout.disputed` | `Pay::Abacatepay::Webhooks::CheckoutDisputed` | stub (Fase 5) |
213
+ | `checkout.lost` | `Pay::Abacatepay::Webhooks::CheckoutLost` | stub (Fase 5) |
214
+ | `transparent.completed` | `Pay::Abacatepay::Webhooks::TransparentCompleted` | stub (Fase 4) |
215
+ | `transparent.refunded` | `Pay::Abacatepay::Webhooks::TransparentRefunded` | stub (Fase 4) |
216
+ | `transparent.disputed` | `Pay::Abacatepay::Webhooks::TransparentDisputed` | stub (Fase 5) |
217
+ | `transparent.lost` | `Pay::Abacatepay::Webhooks::TransparentLost` | stub (Fase 5) |
218
+ | `subscription.completed` | `Pay::Abacatepay::Webhooks::SubscriptionCompleted` | active |
219
+ | `subscription.cancelled` | `Pay::Abacatepay::Webhooks::SubscriptionCancelled` | active |
220
+ | `subscription.renewed` | `Pay::Abacatepay::Webhooks::SubscriptionRenewed` | active |
221
+ | `subscription.trial_started` | `Pay::Abacatepay::Webhooks::SubscriptionTrialStarted` | raises `NotImplementedError` (see gap) |
222
+ | `payout.completed` | `Pay::Abacatepay::Webhooks::PayoutCompleted` | stub |
223
+ | `payout.failed` | `Pay::Abacatepay::Webhooks::PayoutFailed` | stub |
224
+ | `transfer.completed` | `Pay::Abacatepay::Webhooks::TransferCompleted` | stub |
225
+ | `transfer.failed` | `Pay::Abacatepay::Webhooks::TransferFailed` | stub |
226
+
227
+ To override or extend a handler from your own app, subscribe after the gem registers its defaults:
228
+
229
+ ```ruby
230
+ # config/initializers/pay.rb
231
+ Pay::Webhooks.configure do |events|
232
+ events.subscribe "abacatepay.subscription.renewed", ->(event) { MyJob.perform_later(event) }
233
+ end
234
+ ```
235
+
236
+ ## Development
237
+
238
+ ```bash
239
+ bin/setup
240
+ bundle exec rake test
241
+ bundle exec standardrb
242
+ ```
243
+
244
+ Tests run against an in-memory SQLite database inside `test/dummy`, using `webmock` for HTTP stubs.
245
+
246
+ ## License
247
+
248
+ Released under the [MIT License](MIT-LICENSE).
@@ -0,0 +1,104 @@
1
+ module Pay
2
+ module Webhooks
3
+ class AbacatepayController < ActionController::API
4
+ # Authenticates a webhook delivery per the official AbacatePay scheme
5
+ # (https://docs.abacatepay.com/pages/webhooks/security).
6
+ #
7
+ # AbacatePay combines two complementary mechanisms — neither alone is
8
+ # sufficient, but **either** is enough to mark a delivery as genuine for
9
+ # this gem (we accept the strongest available):
10
+ #
11
+ # 1. **`webhookSecret` query parameter** — AbacatePay appends the
12
+ # per-webhook secret you configured in the dashboard to the URL:
13
+ # `?webhookSecret=...`. This authenticates the **origin** because
14
+ # only AbacatePay knows your secret. Compared against
15
+ # `Pay::Abacatepay.webhook_secret`.
16
+ #
17
+ # 2. **`X-Webhook-Signature` header (HMAC-SHA256, base64)** — protects
18
+ # **body integrity**. Computed by AbacatePay over the raw body
19
+ # using their fixed `PUBLIC_KEY` (in the SDK as a constant). The
20
+ # key is public, so this alone does NOT prove origin — it only
21
+ # proves the body wasn't tampered with in transit.
22
+ #
23
+ # Sandbox compatibility: the current AbacatePay sandbox occasionally
24
+ # delivers `webhookSecret` inside the JSON body instead of the URL.
25
+ # We accept that as a third path (compared against the same configured
26
+ # secret) so dev/staging environments work without painel reconfiguration.
27
+ #
28
+ # The request is rejected with 401 if none of the three pass.
29
+ def create
30
+ payload = request.body.read
31
+
32
+ unless authenticated?(payload)
33
+ return head(:unauthorized)
34
+ end
35
+
36
+ event_hash = JSON.parse(payload)
37
+ event_type = event_hash["event"] || event_hash["type"]
38
+ event_id = event_hash["id"] || event_hash.dig("data", "id")
39
+
40
+ return head(:ok) if already_recorded?(event_type, event_id)
41
+
42
+ queue_event(event_type, event_hash)
43
+ head :ok
44
+ rescue JSON::ParserError
45
+ head :bad_request
46
+ end
47
+
48
+ private
49
+
50
+ def authenticated?(payload)
51
+ configured_secret = Pay::Abacatepay.webhook_secret
52
+ return false if configured_secret.blank?
53
+
54
+ # 1. Query parameter (official scheme)
55
+ query_secret = request.query_parameters["webhookSecret"]
56
+ if query_secret.present?
57
+ return secrets_match?(query_secret, configured_secret)
58
+ end
59
+
60
+ # 2. HMAC-SHA256 base64 in X-Webhook-Signature header.
61
+ # Uses AbacatePay's fixed PUBLIC_KEY (NOT the per-webhook secret).
62
+ if (signature = request.headers["X-Webhook-Signature"]).present?
63
+ return ::AbacatePay::Webhooks.valid?(payload: payload, signature: signature)
64
+ end
65
+
66
+ # 3. Sandbox compatibility: webhookSecret inside JSON body
67
+ body_secret = extract_body_secret(payload)
68
+ return false if body_secret.blank?
69
+
70
+ secrets_match?(body_secret, configured_secret)
71
+ end
72
+
73
+ def secrets_match?(received, expected)
74
+ ActiveSupport::SecurityUtils.secure_compare(received.to_s, expected.to_s)
75
+ end
76
+
77
+ def extract_body_secret(payload)
78
+ parsed = JSON.parse(payload)
79
+ parsed["webhookSecret"] || parsed.dig("webhook_secret")
80
+ rescue JSON::ParserError
81
+ nil
82
+ end
83
+
84
+ def already_recorded?(event_type, event_id)
85
+ return false if event_id.blank?
86
+
87
+ Pay::Webhook
88
+ .where(processor: "abacatepay", event_type: event_type)
89
+ .find_each
90
+ .any? { |w| w.event.is_a?(Hash) && (w.event["id"] == event_id.to_s || w.event.dig("data", "id") == event_id.to_s) }
91
+ end
92
+
93
+ def queue_event(event_type, event_hash)
94
+ unless Pay::Webhooks.delegator.listening?("abacatepay.#{event_type}")
95
+ Rails.logger.debug { "[pay-abacatepay] no listener for abacatepay.#{event_type}; ignoring" }
96
+ return
97
+ end
98
+
99
+ record = Pay::Webhook.create!(processor: :abacatepay, event_type: event_type, event: event_hash)
100
+ Pay::Webhooks::ProcessJob.perform_later(record)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module PayAbacatepay
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Copies pay-abacatepay migrations into the host application."
12
+
13
+ def copy_migrations
14
+ migration_template(
15
+ "create_pay_abacatepay_processed_webhooks.rb.tt",
16
+ "db/migrate/create_pay_abacatepay_processed_webhooks.rb"
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ class CreatePayAbacatepayProcessedWebhooks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :pay_abacatepay_processed_webhooks do |t|
4
+ t.string :event_type, null: false
5
+ t.string :event_id, null: false
6
+ t.datetime :processed_at, null: false
7
+ t.datetime :created_at, null: false
8
+ end
9
+
10
+ add_index :pay_abacatepay_processed_webhooks,
11
+ [:event_type, :event_id],
12
+ unique: true,
13
+ name: "index_pay_abacatepay_processed_webhooks_unique"
14
+ end
15
+ end
@@ -0,0 +1,77 @@
1
+ module Pay
2
+ module Abacatepay
3
+ class Charge < Pay::Charge
4
+ # AbacatePay checkout status → Pay::Charge status (stored in data).
5
+ # PENDING is created eagerly by Customer#charge so the app can render
6
+ # "payment in progress" UI before the webhook arrives.
7
+ # EXPIRED and CANCELLED never become a Pay::Charge — the payment never
8
+ # succeeded and there is nothing to track.
9
+ STATUS_MAP = {
10
+ "PENDING" => "pending",
11
+ "PAID" => "paid",
12
+ "REFUNDED" => "refunded",
13
+ "DISPUTED" => "disputed"
14
+ }.freeze
15
+
16
+ store_accessor :data, :status
17
+ store_accessor :data, :checkout_url
18
+ store_accessor :data, :payment_method
19
+
20
+ def self.sync(charge_id, object: nil)
21
+ object ||= ::AbacatePay.checkouts.get(charge_id)
22
+
23
+ customer_id = extract_customer_id(object)
24
+ if customer_id.blank?
25
+ Rails.logger.debug("[pay-abacatepay] checkout #{charge_id} has no customer; skipping sync")
26
+ return
27
+ end
28
+
29
+ pay_customer = Pay::Customer.find_by(processor: "abacatepay", processor_id: customer_id)
30
+ if pay_customer.nil?
31
+ Rails.logger.debug("[pay-abacatepay] Pay::Customer #{customer_id} not found while syncing checkout #{charge_id}")
32
+ return
33
+ end
34
+
35
+ charge = Pay::Abacatepay::Charge.find_or_initialize_by(
36
+ customer: pay_customer,
37
+ processor_id: charge_id
38
+ )
39
+ charge.amount = object.amount if object.respond_to?(:amount) && object.amount
40
+ charge.currency ||= "BRL"
41
+ charge.status = STATUS_MAP[object.status] || charge.status || "pending" if object.respond_to?(:status)
42
+ charge.checkout_url = object.url if object.respond_to?(:url) && object.url
43
+ charge.save!
44
+ charge
45
+ rescue ::AbacatePay::Error => e
46
+ raise Pay::Abacatepay::Error, e.message
47
+ end
48
+
49
+ def api_record
50
+ ::AbacatePay.checkouts.get(processor_id)
51
+ rescue ::AbacatePay::Error => e
52
+ raise Pay::Abacatepay::Error, e.message
53
+ end
54
+
55
+ def refund!(_amount_to_refund = nil)
56
+ raise Pay::Abacatepay::Error,
57
+ "AbacatePay does not expose a refund endpoint. " \
58
+ "Process the refund in the AbacatePay dashboard; the checkout.refunded " \
59
+ "webhook will update this Pay::Charge automatically."
60
+ end
61
+
62
+ def charged_back?
63
+ status == "disputed"
64
+ end
65
+
66
+ def self.extract_customer_id(object)
67
+ return object.dig("customer", "id") if object.is_a?(Hash)
68
+ customer = object.respond_to?(:customer) ? object.customer : nil
69
+ return nil if customer.nil?
70
+ customer.respond_to?(:id) ? customer.id : customer["id"]
71
+ end
72
+ private_class_method :extract_customer_id
73
+ end
74
+ end
75
+ end
76
+
77
+ ActiveSupport.run_load_hooks :pay_abacatepay_charge, Pay::Abacatepay::Charge
@@ -0,0 +1,136 @@
1
+ module Pay
2
+ module Abacatepay
3
+ class Customer < Pay::Customer
4
+ has_many :charges, dependent: :destroy, class_name: "Pay::Abacatepay::Charge"
5
+ has_many :subscriptions, dependent: :destroy, class_name: "Pay::Abacatepay::Subscription"
6
+ has_many :payment_methods, dependent: :destroy, class_name: "Pay::Abacatepay::PaymentMethod"
7
+ has_one :default_payment_method, -> { where(default: true) }, class_name: "Pay::Abacatepay::PaymentMethod"
8
+
9
+ def api_record_attributes
10
+ {
11
+ name: customer_name,
12
+ email: email,
13
+ cellphone: owner.try(:cellphone) || owner.try(:phone),
14
+ tax_id: extract_document
15
+ }
16
+ end
17
+
18
+ def api_record
19
+ with_lock do
20
+ if processor_id?
21
+ ::AbacatePay.customers.get(processor_id)
22
+ else
23
+ attrs = api_record_attributes
24
+ resource = build_customer_resource(attrs)
25
+ created = ::AbacatePay.customers.create(resource)
26
+ update!(processor_id: created.id)
27
+ created
28
+ end
29
+ end
30
+ rescue ::AbacatePay::Error => e
31
+ raise Pay::Abacatepay::Error, e.message
32
+ end
33
+
34
+ # AbacatePay's API does not expose an update endpoint for customers
35
+ # (POST /v2/customers/create and DELETE are the only mutations).
36
+ # TODO: Remove this no-op once the API adds PATCH/PUT support.
37
+ def update_api_record(**attributes)
38
+ logger = defined?(Rails) ? Rails.logger : nil
39
+ logger&.warn(
40
+ "[pay-abacatepay] AbacatePay does not support customer updates; update_api_record is a no-op."
41
+ )
42
+ nil
43
+ end
44
+
45
+ CheckoutResult = Struct.new(:id, :url, :charge, keyword_init: true)
46
+
47
+ def charge(amount, product_id: nil, methods: ["PIX", "CARD"], return_url: nil, completion_url: nil, external_id: nil, product_name: "Cobrança avulsa", metadata: nil)
48
+ api_record unless processor_id?
49
+
50
+ product_id ||= create_one_time_product(amount, product_name)
51
+ resource = build_checkout_resource(
52
+ product_id: product_id,
53
+ quantity: 1,
54
+ methods: methods,
55
+ return_url: return_url,
56
+ completion_url: completion_url,
57
+ external_id: external_id
58
+ )
59
+ created = ::AbacatePay.checkouts.create(resource)
60
+
61
+ pay_charge = Pay::Abacatepay::Charge.find_or_initialize_by(
62
+ customer: self,
63
+ processor_id: created.id
64
+ )
65
+ pay_charge.amount = amount
66
+ pay_charge.currency ||= "BRL"
67
+ pay_charge.status = "pending"
68
+ pay_charge.checkout_url = created.url
69
+ pay_charge.metadata = metadata if metadata.present?
70
+ pay_charge.save!
71
+
72
+ CheckoutResult.new(id: created.id, url: created.url, charge: pay_charge)
73
+ rescue ::AbacatePay::Error => e
74
+ raise Pay::Abacatepay::Error, e.message
75
+ end
76
+
77
+ def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
78
+ raise NotImplementedError, "Pay::Abacatepay::Customer#subscribe is planned for a future release"
79
+ end
80
+
81
+ def add_payment_method(payment_method_id, default: false)
82
+ raise NotImplementedError, "Pay::Abacatepay::Customer#add_payment_method is planned for a future release"
83
+ end
84
+
85
+ private
86
+
87
+ def create_one_time_product(amount, product_name)
88
+ resource = ::AbacatePay::Resources::Products.new(
89
+ external_id: "pay-abacatepay-#{SecureRandom.hex(8)}",
90
+ name: product_name,
91
+ price: amount,
92
+ currency: "BRL"
93
+ )
94
+ created = ::AbacatePay.products.create(resource)
95
+ created.id
96
+ end
97
+
98
+ def build_checkout_resource(product_id:, quantity:, methods:, return_url:, completion_url:, external_id:)
99
+ ::AbacatePay::Resources::Checkouts.new(
100
+ frequency: "ONE_TIME",
101
+ methods: methods,
102
+ metadata: {returnUrl: return_url, completionUrl: completion_url}.compact,
103
+ products: [{externalId: product_id, quantity: quantity}],
104
+ customer: {id: processor_id},
105
+ externalId: external_id
106
+ )
107
+ end
108
+
109
+ def build_customer_resource(attrs)
110
+ ::AbacatePay::Resources::Customers.new(
111
+ metadata: ::AbacatePay::Resources::Customers::Metadata.new(
112
+ name: attrs[:name],
113
+ email: attrs[:email],
114
+ cellphone: attrs[:cellphone],
115
+ tax_id: attrs[:tax_id]
116
+ )
117
+ )
118
+ end
119
+
120
+ def extract_document
121
+ raw = %i[document cpf cnpj].filter_map { |m| owner.send(m) if owner.respond_to?(m) }.find(&:present?)
122
+
123
+ unless %i[document cpf cnpj].any? { |m| owner.respond_to?(m) }
124
+ raise Pay::Abacatepay::Error,
125
+ "#{owner.class} must respond to :document, :cpf, or :cnpj for AbacatePay"
126
+ end
127
+
128
+ raise Pay::Abacatepay::Error, "document is required for AbacatePay customers" if raw.blank?
129
+
130
+ raw.to_s.gsub(/\D/, "")
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ ActiveSupport.run_load_hooks :pay_abacatepay_customer, Pay::Abacatepay::Customer
@@ -0,0 +1,33 @@
1
+ require "rails/engine"
2
+
3
+ module Pay
4
+ module Abacatepay
5
+ class Engine < ::Rails::Engine
6
+ engine_name "pay_abacatepay"
7
+
8
+ initializer "pay_abacatepay.register_processor" do
9
+ Pay.enabled_processors << :abacatepay unless Pay.enabled_processors.include?(:abacatepay)
10
+ end
11
+
12
+ initializer "pay_abacatepay.attributes" do
13
+ ActiveSupport.on_load(:active_record) do
14
+ include Pay::Attributes
15
+ end
16
+ end
17
+
18
+ initializer "pay_abacatepay.routes" do
19
+ Pay::Engine.routes.append do
20
+ post "webhooks/abacatepay", to: "pay/webhooks/abacatepay#create"
21
+ end
22
+ end
23
+
24
+ initializer "pay_abacatepay.webhooks", after: "pay_abacatepay.register_processor" do
25
+ Pay::Abacatepay.configure_webhooks
26
+ end
27
+
28
+ config.to_prepare do
29
+ Pay::Abacatepay.setup if Pay::Abacatepay.enabled? && Pay::Abacatepay.api_key
30
+ end
31
+ end
32
+ end
33
+ end