pay-abacatepay 0.1.0.pre.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2ab67254ae39892abe3501a42c66727a625849a722fb619264986e9956194f9
4
- data.tar.gz: 12af82bfd3ec2b1caf6fbcc980cbfa42bca571af77abdb2e6b1d15d1ed58e2a3
3
+ metadata.gz: 27f07627d9715f378edc2d9897c43f472dc1894da4ea045e2819f6487251cc36
4
+ data.tar.gz: b01ce9998ff6a3e72d736ac7f2ae7c4f963c26120936aa64906480bdb03e5c43
5
5
  SHA512:
6
- metadata.gz: fe3d6ac447067fa8ea24479b282456f4de5a46c01acf556c3b17a1f826c00bc752bb3a948cb87b51f5e3fc967fb700221e51524a674782ae1b7dea61bee9617c
7
- data.tar.gz: 56e74d4a3213b2036dd02dbb57cf72e2e8e78bc5d3a85a845a05e9f544a6b4e8500edb83ca6009898bc35ded6b02b945249853c5160bef61539d3c6e6b8a231d
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" # optional, for your reconciliation
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 plan swap.** `#swap` raises `NotImplementedError`. Cancel and create a new subscription instead.
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.** AbacatePay does not emit payment-failure events, so `#past_due?` always returns `false`.
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: Pay.default_plan_name, **options)
78
- raise NotImplementedError, "Pay::Abacatepay::Customer#subscribe is planned for a future release"
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
@@ -1,5 +1,5 @@
1
1
  module Pay
2
2
  module Abacatepay
3
- VERSION = "0.1.0.pre.1"
3
+ VERSION = "0.1.0.pre.3"
4
4
  end
5
5
  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
- FREQUENCY_TO_INTERVAL[frequency]
76
+ Pay::Abacatepay::Frequency.to_interval(frequency)
84
77
  end
85
78
 
86
79
  private
@@ -12,11 +12,11 @@ module Pay
12
12
 
13
13
  extend Pay::Env
14
14
 
15
- autoload :Charge, "pay/abacatepay/charge"
16
- autoload :Customer, "pay/abacatepay/customer"
17
- autoload :PaymentMethod, "pay/abacatepay/payment_method"
18
- autoload :ProcessedWebhook, "pay/abacatepay/processed_webhook"
19
- autoload :Subscription, "pay/abacatepay/subscription"
15
+ # AR models (Charge, Customer, PaymentMethod, ProcessedWebhook, Subscription)
16
+ # live in app/models/pay/abacatepay/ — Zeitwerk autoloads them from the host
17
+ # app, which is required so STI subclasses get reloaded together with their
18
+ # superclass (Pay::Customer/Charge/...) during dev reloads.
19
+ autoload :Frequency, "pay/abacatepay/frequency"
20
20
  autoload :Webhooks, "pay/abacatepay/webhooks"
21
21
 
22
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.1
4
+ version: 0.1.0.pre.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Moreira
@@ -49,16 +49,17 @@ files:
49
49
  - MIT-LICENSE
50
50
  - README.md
51
51
  - app/controllers/pay/webhooks/abacatepay_controller.rb
52
+ - app/models/pay/abacatepay/charge.rb
53
+ - app/models/pay/abacatepay/customer.rb
54
+ - app/models/pay/abacatepay/payment_method.rb
55
+ - app/models/pay/abacatepay/processed_webhook.rb
56
+ - app/models/pay/abacatepay/subscription.rb
52
57
  - lib/generators/pay_abacatepay/install/install_generator.rb
53
58
  - lib/generators/pay_abacatepay/install/templates/create_pay_abacatepay_processed_webhooks.rb.tt
54
59
  - lib/pay-abacatepay.rb
55
60
  - lib/pay/abacatepay.rb
56
- - lib/pay/abacatepay/charge.rb
57
- - lib/pay/abacatepay/customer.rb
58
61
  - lib/pay/abacatepay/engine.rb
59
- - lib/pay/abacatepay/payment_method.rb
60
- - lib/pay/abacatepay/processed_webhook.rb
61
- - lib/pay/abacatepay/subscription.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
@@ -78,13 +79,13 @@ files:
78
79
  - lib/pay/abacatepay/webhooks/transparent_disputed.rb
79
80
  - lib/pay/abacatepay/webhooks/transparent_lost.rb
80
81
  - lib/pay/abacatepay/webhooks/transparent_refunded.rb
81
- homepage: https://github.com/danielmoreira/pay-abacatepay
82
+ homepage: https://github.com/academia-ruby/pay-abacatepay
82
83
  licenses:
83
84
  - MIT
84
85
  metadata:
85
- homepage_uri: https://github.com/danielmoreira/pay-abacatepay
86
- source_code_uri: https://github.com/danielmoreira/pay-abacatepay
87
- changelog_uri: https://github.com/danielmoreira/pay-abacatepay/blob/main/CHANGELOG.md
86
+ homepage_uri: https://github.com/academia-ruby/pay-abacatepay
87
+ source_code_uri: https://github.com/academia-ruby/pay-abacatepay
88
+ changelog_uri: https://github.com/academia-ruby/pay-abacatepay/blob/main/CHANGELOG.md
88
89
  rdoc_options: []
89
90
  require_paths:
90
91
  - lib
File without changes