pay-abacatepay 0.1.0.pre.2 → 0.1.0.pre.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +35 -4
- data/app/models/pay/abacatepay/customer.rb +61 -6
- data/app/models/pay/abacatepay/subscription.rb +14 -0
- data/lib/pay/abacatepay/frequency.rb +20 -0
- data/lib/pay/abacatepay/version.rb +1 -1
- data/lib/pay/abacatepay/webhooks/event.rb +1 -8
- data/lib/pay/abacatepay.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27f07627d9715f378edc2d9897c43f472dc1894da4ea045e2819f6487251cc36
|
|
4
|
+
data.tar.gz: b01ce9998ff6a3e72d736ac7f2ae7c4f963c26120936aa64906480bdb03e5c43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a97b59da5e68065a83573fe1b11696542f270e785350fff201928c586fd80b65bd5c0f2ac56ebbd096c33846e177a0c7558ec68de72d6d2de813c940409968fc
|
|
7
|
+
data.tar.gz: 962f4aad6948fbedcdf9548cb580d08e17c803de98e3c15f3b89e3b7e8476cf3de3e24664b252d3a9a81a5d914a87b5076b11366ecd3e5de9bec5aac4d6c6f9b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.0.pre.3]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Pay::Abacatepay::Customer#subscribe(name:, plan:, methods:, quantity:, external_id:, metadata:, cycle:)` — creates an AbacatePay subscription and persists a `Pay::Abacatepay::Subscription` eagerly. Status is mapped from the API response: `PENDING` → `"incomplete"` (the typical case, reconciled to `"active"` by the `subscription.completed` webhook on the same `processor_id`); `PAID` → `"active"` already on return. `methods` accepts either an array or a single string (normalized); `quantity` defaults to `1` and must be a positive integer.
|
|
9
|
+
- `Pay::Abacatepay::Frequency` module with `INTERVALS`, `to_interval`, `valid?` — single source of truth shared by `Webhooks::Event#interval` and `Customer#subscribe`.
|
|
10
|
+
- `Pay::Abacatepay::Subscription#checkout_url` (via `store_accessor :data, :checkout_url`) so callers can redirect the payer to the first-payment URL after `subscribe`.
|
|
11
|
+
- Optional `coupons:` kwarg on `Customer#charge`, forwarded to the hosted checkout create call (omitted from the request body when blank).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `Webhooks::Event#interval` now delegates to `Pay::Abacatepay::Frequency.to_interval` (constant `FREQUENCY_TO_INTERVAL` removed from `Event`).
|
|
15
|
+
|
|
16
|
+
### Notes
|
|
17
|
+
- `Customer#subscribe(metadata:)` stores values locally only — the upstream SDK's `SubscriptionClient#create` does not include `metadata` in the request body yet. Pending upstream PR.
|
|
18
|
+
- Trial periods unsupported: passing `trial_period_days:` or `trial_end:` raises `Pay::Abacatepay::Error` (fail-fast — silently dropping the trial would charge the customer immediately).
|
|
19
|
+
|
|
20
|
+
## [0.0.0]
|
|
21
|
+
|
|
5
22
|
### Added
|
|
6
23
|
- Initial scaffold with Rails engine structure
|
|
7
24
|
- `Pay::AbacatePay::Customer` with `api_record`, `api_record_attributes`, `update_api_record`
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ AbacatePay processor for the [Pay gem](https://github.com/pay-rails/pay) (Rails
|
|
|
12
12
|
- [x] Customer creation
|
|
13
13
|
- [x] One-time charges — hosted checkout via `Customer#charge` + `checkout.*` webhooks
|
|
14
14
|
- [ ] Transparent PIX (QR Code inline) — planned (Fase 5)
|
|
15
|
-
- [x] Subscriptions — webhook-driven lifecycle + cancel (gaps below)
|
|
15
|
+
- [x] Subscriptions — `Customer#subscribe` + webhook-driven lifecycle + cancel (gaps below)
|
|
16
16
|
- [x] Webhooks — infrastructure + subscription and checkout handlers
|
|
17
17
|
- [ ] Chargeback/dispute handling — planned (Fase 5)
|
|
18
18
|
- [ ] Payment methods — planned
|
|
@@ -88,7 +88,8 @@ result = user.payment_processor.charge(
|
|
|
88
88
|
methods: ["PIX", "CARD"], # defaults shown
|
|
89
89
|
return_url: "https://app.example.com/cart",
|
|
90
90
|
completion_url: "https://app.example.com/thanks",
|
|
91
|
-
external_id: "order-1234"
|
|
91
|
+
external_id: "order-1234", # optional, for your reconciliation
|
|
92
|
+
coupons: ["LANCAMENTO"] # optional promo codes; omitted when blank
|
|
92
93
|
)
|
|
93
94
|
|
|
94
95
|
redirect_to result.url # send the user to AbacatePay
|
|
@@ -96,6 +97,8 @@ result.id # "chk_xxx" — also result.charge.
|
|
|
96
97
|
result.charge # Pay::Abacatepay::Charge, status: "pending"
|
|
97
98
|
```
|
|
98
99
|
|
|
100
|
+
Coupon codes passed via `coupons:` are forwarded to `/checkouts/create`; AbacatePay validates them (existence/eligibility). When the argument is blank, the key is omitted from the request body entirely.
|
|
101
|
+
|
|
99
102
|
### Product on-the-fly
|
|
100
103
|
|
|
101
104
|
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.
|
|
@@ -148,10 +151,35 @@ bin/rails db:migrate
|
|
|
148
151
|
|
|
149
152
|
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
153
|
|
|
154
|
+
### Creating a subscription
|
|
155
|
+
|
|
156
|
+
`Customer#subscribe` creates the subscription on AbacatePay and returns a `Pay::Abacatepay::Subscription` persisted with the status mapped from AbacatePay's response. In the typical flow the subscription comes back as `PENDING` (mapped to `"incomplete"`); redirect the payer to `subscription.checkout_url` to complete the first payment, and the `subscription.completed` webhook then flips the local status to `"active"` and fills in `current_period_*`. If AbacatePay responds with `PAID` (e.g., a saved payment method settled immediately), `subscribe` returns the subscription already as `"active"`.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
subscription = user.payment_processor.subscribe(
|
|
160
|
+
name: "Pro",
|
|
161
|
+
plan: "prod_xxx", # AbacatePay product_id (must exist with cycle set)
|
|
162
|
+
cycle: "MONTHLY", # optional; sets an optimistic current_period_end
|
|
163
|
+
methods: ["PIX", "CARD"], # defaults shown; a single string is also accepted and normalized
|
|
164
|
+
quantity: 1, # default; positive integer
|
|
165
|
+
external_id: "order-1234", # optional
|
|
166
|
+
metadata: {release_id: order.id} # local-only, not yet sent to AbacatePay
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
redirect_to subscription.checkout_url # first payment (when status is "incomplete")
|
|
170
|
+
subscription.processor_id # "subs_xxx"
|
|
171
|
+
subscription.status # usually "incomplete" → flipped to "active" by webhook; can be "active" already if the API returned PAID
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`plan` is the AbacatePay `product_id`. Cycle is a Product property, not a Subscription property — pass `cycle:` here purely so the gem can compute `current_period_end` immediately; otherwise it stays `nil` until the webhook arrives.
|
|
175
|
+
|
|
176
|
+
Trial periods are unsupported: passing `trial_period_days:` or `trial_end:` raises `Pay::Abacatepay::Error`. AbacatePay simply has no trial primitive, and silently dropping it would charge the customer immediately — fail-fast surfaces the divergence.
|
|
177
|
+
|
|
151
178
|
### Supported operations
|
|
152
179
|
|
|
153
180
|
| Operation | Support |
|
|
154
181
|
|---|---|
|
|
182
|
+
| `Customer#subscribe` (create subscription from code) | yes (PIX/CARD; no trial; `cycle:` and `quantity:` optional) |
|
|
155
183
|
| Webhook-driven `Pay::Subscription` create/update | yes |
|
|
156
184
|
| `Pay::Charge` creation per renewal | yes, idempotent via `processor_id = data.payment.id` |
|
|
157
185
|
| `#cancel_now!` (immediate cancellation) | yes (calls `POST /v2/subscriptions/cancel` directly — SDK does not cover) |
|
|
@@ -162,10 +190,13 @@ The migration creates `pay_abacatepay_processed_webhooks`, a permanent table wit
|
|
|
162
190
|
AbacatePay's API is narrower than Stripe's, so several `Pay::Subscription` affordances are intentionally not implemented:
|
|
163
191
|
|
|
164
192
|
- **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
|
|
193
|
+
- **No trial periods.** Passing `trial_period_days:` or `trial_end:` to `Customer#subscribe` raises `Pay::Abacatepay::Error`.
|
|
194
|
+
- **Cycle is a Product property.** Pass `cycle:` to `subscribe` for an immediate `current_period_end`; otherwise it stays `nil` until the `subscription.completed` webhook arrives.
|
|
195
|
+
- **`metadata` not yet sent to AbacatePay.** `Customer#subscribe(metadata:)` stores values locally on `Pay::Subscription#metadata` only — the SDK's `SubscriptionClient#create` doesn't include `metadata` in the request body yet. Pending upstream PR.
|
|
196
|
+
- **No plan swap.** `#swap` raises `NotImplementedError`. Cancel and create a new subscription instead. (AbacatePay added `POST /v2/subscriptions/change-plan` on 2026-05-04 — implementation pending.)
|
|
166
197
|
- **No resume.** `#resume` raises `NotImplementedError`. Cancelled subscriptions cannot be reactivated.
|
|
167
198
|
- **No quantity changes.** `#change_quantity` raises `NotImplementedError`.
|
|
168
|
-
- **No `past_due` state.**
|
|
199
|
+
- **No `past_due` state.** `#past_due?` always returns `false`. (AbacatePay added `subscription.payment_failed` on 2026-04-29 — handler implementation pending.)
|
|
169
200
|
- **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
201
|
- **`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
202
|
|
|
@@ -44,7 +44,7 @@ module Pay
|
|
|
44
44
|
|
|
45
45
|
CheckoutResult = Struct.new(:id, :url, :charge, keyword_init: true)
|
|
46
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)
|
|
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, coupons: nil)
|
|
48
48
|
api_record unless processor_id?
|
|
49
49
|
|
|
50
50
|
product_id ||= create_one_time_product(amount, product_name)
|
|
@@ -54,7 +54,8 @@ module Pay
|
|
|
54
54
|
methods: methods,
|
|
55
55
|
return_url: return_url,
|
|
56
56
|
completion_url: completion_url,
|
|
57
|
-
external_id: external_id
|
|
57
|
+
external_id: external_id,
|
|
58
|
+
coupons: coupons
|
|
58
59
|
)
|
|
59
60
|
created = ::AbacatePay.checkouts.create(resource)
|
|
60
61
|
|
|
@@ -74,8 +75,33 @@ module Pay
|
|
|
74
75
|
raise Pay::Abacatepay::Error, e.message
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
def subscribe(name: Pay.default_product_name, plan:
|
|
78
|
-
|
|
78
|
+
def subscribe(name: Pay.default_product_name, plan: nil, methods: ["PIX", "CARD"], quantity: 1, external_id: nil, metadata: nil, cycle: nil, **options)
|
|
79
|
+
methods = Array(methods).reject(&:blank?)
|
|
80
|
+
validate_subscribe_args!(plan: plan, methods: methods, quantity: quantity, cycle: cycle, options: options)
|
|
81
|
+
api_record unless processor_id?
|
|
82
|
+
|
|
83
|
+
resource = build_subscription_resource(plan: plan, methods: methods, external_id: external_id, quantity: quantity)
|
|
84
|
+
created = ::AbacatePay.subscriptions.create(resource)
|
|
85
|
+
|
|
86
|
+
period_start = Time.current
|
|
87
|
+
period_end = cycle ? period_start + Pay::Abacatepay::Frequency.to_interval(cycle) : nil
|
|
88
|
+
|
|
89
|
+
pay_subscription = Pay::Abacatepay::Subscription.find_or_initialize_by(customer: self, processor_id: created.id)
|
|
90
|
+
pay_subscription.assign_attributes(
|
|
91
|
+
name: name,
|
|
92
|
+
processor_plan: plan,
|
|
93
|
+
quantity: quantity,
|
|
94
|
+
status: Pay::Abacatepay::Subscription::API_STATUS_MAP[created.status&.to_s&.upcase] || "incomplete",
|
|
95
|
+
current_period_start: period_start,
|
|
96
|
+
current_period_end: period_end,
|
|
97
|
+
checkout_url: created.url
|
|
98
|
+
)
|
|
99
|
+
pay_subscription.metadata = metadata if metadata.present?
|
|
100
|
+
pay_subscription.save!
|
|
101
|
+
|
|
102
|
+
pay_subscription
|
|
103
|
+
rescue ::AbacatePay::Error => e
|
|
104
|
+
raise Pay::Abacatepay::Error, e.message
|
|
79
105
|
end
|
|
80
106
|
|
|
81
107
|
def add_payment_method(payment_method_id, default: false)
|
|
@@ -84,6 +110,34 @@ module Pay
|
|
|
84
110
|
|
|
85
111
|
private
|
|
86
112
|
|
|
113
|
+
def validate_subscribe_args!(plan:, methods:, quantity:, cycle:, options:)
|
|
114
|
+
raise Pay::Abacatepay::Error, "plan is required (AbacatePay product_id)" if plan.blank?
|
|
115
|
+
raise Pay::Abacatepay::Error, "methods cannot be empty" if methods.empty?
|
|
116
|
+
|
|
117
|
+
unless quantity.is_a?(Integer) && quantity.positive?
|
|
118
|
+
raise Pay::Abacatepay::Error, "quantity must be a positive integer (got #{quantity.inspect})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if cycle && !Pay::Abacatepay::Frequency.valid?(cycle)
|
|
122
|
+
raise Pay::Abacatepay::Error,
|
|
123
|
+
"invalid cycle: #{cycle.inspect}. Use one of: #{Pay::Abacatepay::Frequency::INTERVALS.keys.join(", ")}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if options.key?(:trial_period_days) || options.key?(:trial_end)
|
|
127
|
+
raise Pay::Abacatepay::Error,
|
|
128
|
+
"AbacatePay does not support trial periods; remove trial_period_days/trial_end"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_subscription_resource(plan:, methods:, external_id:, quantity:)
|
|
133
|
+
::AbacatePay::Resources::Subscriptions.new(
|
|
134
|
+
methods: methods,
|
|
135
|
+
externalId: external_id,
|
|
136
|
+
customer: {id: processor_id},
|
|
137
|
+
products: [{externalId: plan, quantity: quantity}]
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
87
141
|
def create_one_time_product(amount, product_name)
|
|
88
142
|
resource = ::AbacatePay::Resources::Products.new(
|
|
89
143
|
external_id: "pay-abacatepay-#{SecureRandom.hex(8)}",
|
|
@@ -95,14 +149,15 @@ module Pay
|
|
|
95
149
|
created.id
|
|
96
150
|
end
|
|
97
151
|
|
|
98
|
-
def build_checkout_resource(product_id:, quantity:, methods:, return_url:, completion_url:, external_id:)
|
|
152
|
+
def build_checkout_resource(product_id:, quantity:, methods:, return_url:, completion_url:, external_id:, coupons: nil)
|
|
99
153
|
::AbacatePay::Resources::Checkouts.new(
|
|
100
154
|
frequency: "ONE_TIME",
|
|
101
155
|
methods: methods,
|
|
102
156
|
metadata: {returnUrl: return_url, completionUrl: completion_url}.compact,
|
|
103
157
|
products: [{externalId: product_id, quantity: quantity}],
|
|
104
158
|
customer: {id: processor_id},
|
|
105
|
-
externalId: external_id
|
|
159
|
+
externalId: external_id,
|
|
160
|
+
coupons: coupons.presence
|
|
106
161
|
)
|
|
107
162
|
end
|
|
108
163
|
|
|
@@ -7,6 +7,20 @@ module Pay
|
|
|
7
7
|
"subscription.cancelled" => "canceled"
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
|
+
# Maps AbacatePay API checkout-like statuses (Resources::Subscriptions
|
|
11
|
+
# reuses Enums::Checkouts::Statuses) to local Pay::Subscription statuses.
|
|
12
|
+
# PENDING is the default on creation: the first payment hasn't settled
|
|
13
|
+
# yet, mirroring Stripe's "incomplete".
|
|
14
|
+
API_STATUS_MAP = {
|
|
15
|
+
"PENDING" => "incomplete",
|
|
16
|
+
"PAID" => "active",
|
|
17
|
+
"CANCELLED" => "canceled",
|
|
18
|
+
"EXPIRED" => "canceled",
|
|
19
|
+
"REFUNDED" => "canceled"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
store_accessor :data, :checkout_url
|
|
23
|
+
|
|
10
24
|
def self.sync(processor_id, event: nil)
|
|
11
25
|
return if processor_id.blank?
|
|
12
26
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module Abacatepay
|
|
3
|
+
module Frequency
|
|
4
|
+
INTERVALS = {
|
|
5
|
+
"WEEKLY" => 1.week,
|
|
6
|
+
"MONTHLY" => 1.month,
|
|
7
|
+
"SEMIANNUALLY" => 6.months,
|
|
8
|
+
"ANNUALLY" => 1.year
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.to_interval(freq)
|
|
12
|
+
INTERVALS[freq]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.valid?(freq)
|
|
16
|
+
INTERVALS.key?(freq)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -2,13 +2,6 @@ module Pay
|
|
|
2
2
|
module Abacatepay
|
|
3
3
|
module Webhooks
|
|
4
4
|
class Event
|
|
5
|
-
FREQUENCY_TO_INTERVAL = {
|
|
6
|
-
"WEEKLY" => 1.week,
|
|
7
|
-
"MONTHLY" => 1.month,
|
|
8
|
-
"SEMIANNUALLY" => 6.months,
|
|
9
|
-
"ANNUALLY" => 1.year
|
|
10
|
-
}.freeze
|
|
11
|
-
|
|
12
5
|
def initialize(raw)
|
|
13
6
|
@raw = raw.is_a?(Hash) ? raw : {}
|
|
14
7
|
end
|
|
@@ -80,7 +73,7 @@ module Pay
|
|
|
80
73
|
def product_id = data.dig("checkout", "items", 0, "id")
|
|
81
74
|
|
|
82
75
|
def interval
|
|
83
|
-
|
|
76
|
+
Pay::Abacatepay::Frequency.to_interval(frequency)
|
|
84
77
|
end
|
|
85
78
|
|
|
86
79
|
private
|
data/lib/pay/abacatepay.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Pay
|
|
|
16
16
|
# live in app/models/pay/abacatepay/ — Zeitwerk autoloads them from the host
|
|
17
17
|
# app, which is required so STI subclasses get reloaded together with their
|
|
18
18
|
# superclass (Pay::Customer/Charge/...) during dev reloads.
|
|
19
|
+
autoload :Frequency, "pay/abacatepay/frequency"
|
|
19
20
|
autoload :Webhooks, "pay/abacatepay/webhooks"
|
|
20
21
|
|
|
21
22
|
# Enabled when the processor is registered and the SDK constant is present.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pay-abacatepay
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.pre.
|
|
4
|
+
version: 0.1.0.pre.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Moreira
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/pay-abacatepay.rb
|
|
60
60
|
- lib/pay/abacatepay.rb
|
|
61
61
|
- lib/pay/abacatepay/engine.rb
|
|
62
|
+
- lib/pay/abacatepay/frequency.rb
|
|
62
63
|
- lib/pay/abacatepay/version.rb
|
|
63
64
|
- lib/pay/abacatepay/webhooks.rb
|
|
64
65
|
- lib/pay/abacatepay/webhooks/checkout_completed.rb
|