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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/MIT-LICENSE +21 -0
- data/README.md +248 -0
- data/app/controllers/pay/webhooks/abacatepay_controller.rb +104 -0
- data/lib/generators/pay_abacatepay/install/install_generator.rb +21 -0
- data/lib/generators/pay_abacatepay/install/templates/create_pay_abacatepay_processed_webhooks.rb.tt +15 -0
- data/lib/pay/abacatepay/charge.rb +77 -0
- data/lib/pay/abacatepay/customer.rb +136 -0
- data/lib/pay/abacatepay/engine.rb +33 -0
- data/lib/pay/abacatepay/payment_method.rb +21 -0
- data/lib/pay/abacatepay/processed_webhook.rb +19 -0
- data/lib/pay/abacatepay/subscription.rb +88 -0
- data/lib/pay/abacatepay/version.rb +5 -0
- data/lib/pay/abacatepay/webhooks/checkout_completed.rb +52 -0
- data/lib/pay/abacatepay/webhooks/checkout_disputed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/checkout_lost.rb +11 -0
- data/lib/pay/abacatepay/webhooks/checkout_refunded.rb +38 -0
- data/lib/pay/abacatepay/webhooks/event.rb +95 -0
- data/lib/pay/abacatepay/webhooks/payout_completed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/payout_failed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/subscription_cancelled.rb +34 -0
- data/lib/pay/abacatepay/webhooks/subscription_completed.rb +53 -0
- data/lib/pay/abacatepay/webhooks/subscription_renewed.rb +45 -0
- data/lib/pay/abacatepay/webhooks/subscription_trial_started.rb +13 -0
- data/lib/pay/abacatepay/webhooks/transfer_completed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/transfer_failed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/transparent_completed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/transparent_disputed.rb +11 -0
- data/lib/pay/abacatepay/webhooks/transparent_lost.rb +11 -0
- data/lib/pay/abacatepay/webhooks/transparent_refunded.rb +11 -0
- data/lib/pay/abacatepay/webhooks.rb +23 -0
- data/lib/pay/abacatepay.rb +75 -0
- data/lib/pay-abacatepay.rb +1 -0
- 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
|
data/lib/generators/pay_abacatepay/install/templates/create_pay_abacatepay_processed_webhooks.rb.tt
ADDED
|
@@ -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
|