pluggy-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c78f57e0f07dc649945fac8c3c233732b49826f8cf697b5741c487ba5778c20
4
+ data.tar.gz: 8a279912516b2345917388f4d60e9efad45889c51f7065fb18001f93964211a7
5
+ SHA512:
6
+ metadata.gz: 35640ab0faeff5a8ddf45e4628f7c3d311dfcac52aa4f4052a93e5019db0e21dde92ff7beb681f75e7374c70090e5b7293cf412a49e0cf44416626e7327f82ea
7
+ data.tar.gz: 23c7bd4ce4493fb51026762a7be6dbaf0b8875ea863ed86616884c1cd7792864597768fd73df9690e2c469f6a9922dfeeb4b49a1434b0e3c4f6c39a6143e213b
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.1.0] - Unreleased
7
+
8
+ Initial release.
9
+
10
+ ### Added
11
+ - `Pluggy::Client` with transparent apiKey lifecycle: lazy first authentication, caching against the
12
+ expiry in the key's own JWT, and one automatic renewal when Pluggy rejects a key mid-session.
13
+ - Resources: Account (+ live balance, statements), Transaction, Bill, Loan, Item, Connector, Category,
14
+ Merchant, ConnectToken.
15
+ - One pagination interface (`each` / `auto_paging_each`) over the API's three envelope shapes, including
16
+ cursor pagination for `GET /v2/transactions` and a runtime payload sniffer for `GET /categories`, whose
17
+ schema and example disagree.
18
+ - `Bill#transactions`, which reconstructs a statement's line items from `creditCardMetadata.billId` using
19
+ a window derived from the surrounding billing cycles, since `GET /v2/transactions` has no `billId`
20
+ filter. `strategy: :legacy` uses the deprecated v1 filter instead.
21
+ - Exact money: JSON numbers are parsed with `decimal_class: BigDecimal` straight off the wire, and
22
+ `to_json` renders them back as unquoted numbers.
23
+ - Navigation helpers (`item.accounts`, `account.transactions`, `item.transactions`, `bill.transactions`)
24
+ and predicates (`account.credit_card?`, `transaction.pix?`, `item.waiting_user_input?`).
25
+ - Typed error hierarchy carrying the full HTTP transcript, distinguishing an expired apiKey (403 with no
26
+ `codeDescription`) from a genuine denial (403 with one).
27
+
28
+ ### Notes
29
+ - Payments, smart transfers, boletos, consents, webhooks, investments and identity are out of scope;
30
+ `client.get`/`post`/`patch`/`delete` reach them directly.
31
+ - The API has no `GET /items`, so item ids must be persisted by the caller.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thiago Diniz
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,349 @@
1
+ # pluggy-rb
2
+
3
+ An unofficial Ruby client for the [Pluggy](https://pluggy.ai) open-finance API, written by hand in the
4
+ style of [stripe-ruby](https://github.com/stripe/stripe-ruby).
5
+
6
+ It covers the read paths needed to **identify every transaction reachable from a connected item** —
7
+ accounts, transactions, credit-card bills, loans, connectors, items, categories and merchants — with
8
+ transparent API-key renewal, one uniform pagination interface over Pluggy's three different paging
9
+ shapes, and exact decimal money.
10
+
11
+ ```ruby
12
+ client = Pluggy::Client.new(
13
+ client_id: ENV["PLUGGY_CLIENT_ID"],
14
+ client_secret: ENV["PLUGGY_CLIENT_SECRET"]
15
+ )
16
+
17
+ client.accounts.list(item_id: item_id).each do |account|
18
+ puts "#{account.name}: #{account.balance} #{account.currency_code}"
19
+ end
20
+ ```
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ gem "pluggy-rb"
26
+ ```
27
+
28
+ Requires Ruby >= 3.1. The only runtime dependency is `bigdecimal` (see [Money](#money)).
29
+
30
+ ## Authentication
31
+
32
+ Pluggy authentication is two steps, and the first one expires.
33
+
34
+ 1. `POST /auth` with your client keys returns an **apiKey that lasts 2 hours**.
35
+ 2. Every other endpoint takes that key as an `X-API-KEY` header.
36
+
37
+ **The SDK does all of this for you.** It authenticates lazily on your first request, caches the key until
38
+ the expiry inside the key's own JWT, and if Pluggy ever rejects it mid-session it renews and retries once —
39
+ so a long-running process never sees an expiry.
40
+
41
+ ```ruby
42
+ client = Pluggy::Client.new(client_id: "...", client_secret: "...")
43
+ ```
44
+
45
+ You can also set defaults that every new client inherits:
46
+
47
+ ```ruby
48
+ Pluggy.configure do |c|
49
+ c.client_id = ENV["PLUGGY_CLIENT_ID"]
50
+ c.client_secret = ENV["PLUGGY_CLIENT_SECRET"]
51
+ c.logger = Rails.logger
52
+ end
53
+
54
+ Pluggy::Client.new
55
+ ```
56
+
57
+ ### Bringing your own key
58
+
59
+ If you manage the apiKey yourself, pass it instead of credentials. There is then nothing to renew with, so
60
+ an expiry raises `Pluggy::AuthenticationError` telling you to supply credentials:
61
+
62
+ ```ruby
63
+ Pluggy::Client.new(api_key: existing_key)
64
+ ```
65
+
66
+ ### Connect tokens are not API keys
67
+
68
+ `POST /connect_token` returns a **30-minute** token for the Connect Widget in your frontend. It cannot
69
+ authenticate API calls, and issuing one requires an apiKey — so it happens server-side.
70
+
71
+ ```ruby
72
+ token = client.create_connect_token(
73
+ options: { client_user_id: "user-42", webhook_url: "https://example.com/hooks/pluggy" }
74
+ )
75
+ render json: { accessToken: token.access_token }
76
+ ```
77
+
78
+ Pass `item_id:` to let the widget update an existing connection instead of creating a new one.
79
+
80
+ ## The four headline flows
81
+
82
+ ### 1. Accounts for an item
83
+
84
+ ```ruby
85
+ accounts = client.accounts.list(item_id: item_id)
86
+
87
+ accounts.each do |a|
88
+ puts "#{a.type}/#{a.subtype} #{a.name} #{a.number} — #{a.balance} #{a.currency_code}"
89
+ end
90
+
91
+ checking = accounts.find(&:checking?)
92
+ card = accounts.find(&:credit_card?)
93
+ ```
94
+
95
+ `bank?`, `credit?`, `credit_card?`, `checking?` and `savings?` are all available. `type: "BANK"` or
96
+ `type: "CREDIT"` filters server-side.
97
+
98
+ ### 2. Every transaction in a checking account
99
+
100
+ ```ruby
101
+ checking.transactions(date_from: Date.new(2024, 1, 1)).auto_paging_each do |t|
102
+ puts format("%s %10s %s", t.date.strftime("%F"), t.amount, t.description)
103
+ end
104
+ ```
105
+
106
+ `auto_paging_each` walks every page. Without a block it returns an `Enumerator`, so it stays lazy:
107
+
108
+ ```ruby
109
+ checking.transactions.auto_paging_each.lazy.select(&:pending?).first(10)
110
+ ```
111
+
112
+ ### 3. A credit card's bills and each bill's line items
113
+
114
+ ```ruby
115
+ client.bills.list(account_id: card.id).each do |bill|
116
+ puts "closing #{bill.bill_closing_date} due #{bill.due_date}: " \
117
+ "#{bill.total_amount} #{bill.total_amount_currency_code}#{' (paid)' if bill.paid?}"
118
+
119
+ bill.transactions.each do |t|
120
+ puts format(" %s %10s %-40s %s",
121
+ t.date.strftime("%F"), t.amount, t.description, t.installment_label)
122
+ end
123
+ end
124
+ ```
125
+
126
+ Bills carry no line items of their own — see [Bill line items](#bill-line-items) for how this works and
127
+ what it costs.
128
+
129
+ ### 4. Loans
130
+
131
+ Loans hang off the **item**, not an account:
132
+
133
+ ```ruby
134
+ client.loans.list(item_id: item_id).each do |loan|
135
+ puts "#{loan.product_name} #{loan.contract_amount} CET=#{loan.cet}"
136
+ puts " #{loan.installments.paid_installments} paid, #{loan.installments.due_installments} due"
137
+ end
138
+ ```
139
+
140
+ ## Concepts
141
+
142
+ Items are connections to an institution. Accounts belong to items. Transactions belong to accounts. Bills
143
+ belong to `CREDIT` accounts. **Loans belong to items, not accounts.**
144
+
145
+ ```ruby
146
+ item = client.items.retrieve(item_id)
147
+ item.accounts # all of them
148
+ item.credit_accounts # type: "CREDIT"
149
+ item.loans
150
+ item.transactions # every transaction on every account, as one stream
151
+ ```
152
+
153
+ ## Gotchas
154
+
155
+ Most of these are Pluggy's, not the gem's, and the gem cannot paper over them.
156
+
157
+ ### There is no `GET /items`
158
+
159
+ The API has no endpoint to list items, so **you must persist item ids yourself**, normally keyed by your
160
+ own `clientUserId`. `client.items` deliberately has no `list` method. This is the most common surprise for
161
+ people arriving from Plaid.
162
+
163
+ ### Money
164
+
165
+ Amounts are parsed straight off the wire with `JSON.parse(body, decimal_class: BigDecimal)`, so a value
166
+ written `-212.45` becomes an exact `BigDecimal` and never passes through a Float. Whole numbers stay
167
+ `Integer`. Both are exact, which is the property that matters for reconciliation:
168
+
169
+ ```ruby
170
+ bill.total_amount - bill.minimum_payment_amount # exact, no float drift
171
+ ```
172
+
173
+ This is why `bigdecimal` is a declared dependency: it became a *bundled* rather than *default* gem in
174
+ Ruby 3.4, so it has to be requested even though it ships with every Ruby. Set
175
+ `Pluggy.decimal_amounts = false` for plain Floats and a dependency-free install.
176
+
177
+ `to_json` round-trips correctly — BigDecimals are rendered as unquoted JSON numbers, not strings.
178
+
179
+ ### Methods give you Ruby values; `[]` gives you the wire value
180
+
181
+ ```ruby
182
+ t.date # => 2024-03-15 00:00:00 UTC (Time)
183
+ t[:date] # => same
184
+ t["date"] # => "2024-03-15T00:00:00.000Z" (verbatim)
185
+ t["createdAt"] # => camelCase keys work too
186
+ t.to_h # => wire-shaped hash
187
+ ```
188
+
189
+ Date-only values like `bill.due_date` become `Date`. Values that only *look* temporal are left alone:
190
+ `bill_forecast_date` and `month_year` are `"2024-03"` and `"01-2025"`, so they stay Strings.
191
+
192
+ ### Enum-ish fields are raw Strings, on purpose
193
+
194
+ The published spec and the live API disagree. Pluggy's own examples return `EFETIVA` where the schema says
195
+ `EFFECTIVE`, `MES` where it says `MONTH`, `UNICA` where it says `UNIQUE`. Modelling these as closed
196
+ constants would reject real data, so the gem never validates them — it only offers predicates
197
+ (`loan.financing?`, `t.pix?`, `item.updated?`) that compare strings.
198
+
199
+ Related: `LoanContractedFinanceCharge` is documented with `rate`/`additionalInfo` but sends
200
+ `chargeRate`/`chargeAdditionalInfo`; both spellings are readable, and `#charge_rate`/`#info` pick whichever
201
+ arrived. `Bill#accountId` and `BillFinanceCharge#creditCardBillId` are required by the schema yet missing
202
+ from its `properties` — they are present at runtime and readable.
203
+
204
+ ### `loan.cet`
205
+
206
+ `CET` (Custo Efetivo Total) is the only uppercase field name in the API. All three of `loan.cet`,
207
+ `loan.CET` and `loan["CET"]` work.
208
+
209
+ ## Pagination
210
+
211
+ Three shapes in the API, one interface in the gem: everything answers `each` and `auto_paging_each`.
212
+
213
+ | endpoint | shape | notes |
214
+ |---|---|---|
215
+ | `/v2/transactions` | cursor `{results, next}` | the only cursor-paginated endpoint |
216
+ | `/transactions` (v1) | offset `{results, page, total, totalPages}` | deprecated, accepts `page`/`pageSize` |
217
+ | `/connectors` | offset | accepts `page`/`pageSize` |
218
+ | `/accounts`, `/bills`, `/loans`, statements | offset | **single page** — no page parameter exists |
219
+ | `/categories` | array *or* offset | the spec contradicts itself; the gem sniffs the payload |
220
+
221
+ For `/accounts`, `/bills` and `/loans` the API returns a page envelope but documents no way to request
222
+ page 2, so `auto_paging_each` yields the one page and stops rather than re-requesting it.
223
+
224
+ ### Resuming a cursor
225
+
226
+ Persist `next_token` — the whole query string Pluggy handed back, filters included — not a bare cursor:
227
+
228
+ ```ruby
229
+ page = client.transactions.list(account_id: id)
230
+ redis.set("cursor:#{id}", page.next_token)
231
+
232
+ # later, in another process
233
+ page = client.transactions.resume(redis.get("cursor:#{id}"))
234
+ ```
235
+
236
+ ## Transactions: v2 vs v1
237
+
238
+ `GET /v2/transactions` is the default. `GET /transactions` is deprecated with a **2026-12-31 sunset**, and
239
+ is the only place `billId`, `page` and `pageSize` exist — passing any of them routes there automatically
240
+ and logs a deprecation notice.
241
+
242
+ ```ruby
243
+ client.transactions.list(account_id: id, date_from: "2024-01-01") # v2
244
+ client.transactions.list(account_id: id, bill_id: bill.id) # v1, logged
245
+ client.transactions.list(account_id: id, version: :v1) # v1, explicit
246
+ Pluggy.transactions_api_version = :v1 # global default
247
+ ```
248
+
249
+ v2 renamed `from`/`to` to `date_from`/`date_to`, and rejects `date_from` combined with `created_at_from`
250
+ (the gem catches that locally rather than spending a round-trip on a 400).
251
+
252
+ ### Bill line items
253
+
254
+ v2 dropped the `billId` filter that v1 had, so there is no supported server-side way to list one bill's
255
+ transactions. `bill.transactions` lists the account's transactions over the statement cycle and filters on
256
+ `credit_card_metadata.bill_id`.
257
+
258
+ The window is derived, not guessed: `bills.list` sees every bill with its closing date, so each bill knows
259
+ its predecessor's, and the window is exactly one cycle. **The `billId` check is authoritative and always
260
+ runs, so the window only affects how many requests happen, never which transactions come back** — a wrong
261
+ window costs time, never correctness. A bill fetched alone via `bills.retrieve` has no predecessor and
262
+ falls back to a 62-day window, logging that fact.
263
+
264
+ ```ruby
265
+ bill.transactions # Enumerator; nothing fetched until iterated
266
+ bill.transactions.to_a
267
+ bill.transactions(date_from: "2024-01-01") # override the window
268
+ bill.transactions(strategy: :legacy) # v1's server-side billId filter, one request
269
+ ```
270
+
271
+ `transaction.bill_id` is public if you would rather group them yourself.
272
+
273
+ ## Errors
274
+
275
+ All inherit from `Pluggy::Error` and carry the whole transcript: `http_status`, `http_body`, `json_body`,
276
+ `http_headers`, plus Pluggy's `code` and `code_description`. `message` appends the `code_description`
277
+ because that is what you want in a log; `api_message` is the undecorated text.
278
+
279
+ | class | when |
280
+ |---|---|
281
+ | `AuthenticationError` | bad client keys, or an apiKey that could not be renewed |
282
+ | `PermissionError` | a 403 **with** a `codeDescription`, e.g. `BALANCE_CONSENT_ERROR` |
283
+ | `InvalidRequestError` | 400 — `#parameter_errors`, `#invalid_cursor?` |
284
+ | `NotFoundError` | 404 |
285
+ | `ConflictError` | 409 — `#duplicate_item_ids` |
286
+ | `RateLimitError` | 429 — `#retry_after` |
287
+ | `APIError` / `BadGatewayError` | 500 / 502 (institution unavailable) |
288
+ | `ConnectionError` / `TimeoutError` | transport level |
289
+
290
+ Pluggy signals an expired apiKey with a **403 that has no `codeDescription`**, and a genuine denial with a
291
+ 403 that has one. The gem renews only on the former; the latter raises `PermissionError` immediately,
292
+ un-retried.
293
+
294
+ GETs and DELETEs are retried on connection errors and 429/500/502/503/504 with exponential backoff and
295
+ jitter. **`POST /items` is never retried** — the API has no idempotency key, so a retry could open a
296
+ duplicate bank connection.
297
+
298
+ ## Configuration
299
+
300
+ ```ruby
301
+ Pluggy::Client.new(
302
+ client_id: "...", client_secret: "...",
303
+ read_timeout: 80, # /accounts/{id}/balance queries the institution live
304
+ max_network_retries: 2,
305
+ logger: Rails.logger, # secrets are redacted
306
+ log_level: :info, # :debug logs every request
307
+ decimal_amounts: true,
308
+ coerce_times: true,
309
+ transactions_api_version: :v2
310
+ )
311
+ ```
312
+
313
+ Connections are pooled per thread. After forking (Puma, Unicorn, Sidekiq), clear the inherited sockets:
314
+
315
+ ```ruby
316
+ on_worker_boot { Pluggy::ConnectionManager.current.clear! }
317
+ ```
318
+
319
+ ## Out of scope
320
+
321
+ Payments, smart transfers, boletos, consents, webhooks, investments and identity are not modelled. Reach
322
+ them with the raw escape hatches, which handle auth and retries but return parsed JSON rather than
323
+ resource objects:
324
+
325
+ ```ruby
326
+ client.get("/investments", item_id: item_id)
327
+ client.post("/payments/customers", name: "...")
328
+ ```
329
+
330
+ ## Development
331
+
332
+ ```bash
333
+ mise install
334
+ bundle install
335
+ bundle exec rake # rubocop + rspec
336
+ bundle exec rake fixtures:extract # regenerate fixtures from the vendored OpenAPI spec
337
+ bundle exec rake "schema:fields[Transaction]"
338
+ ```
339
+
340
+ The suite is hermetic (WebMock, no network). Fixtures are generated from Pluggy's own OpenAPI examples
341
+ where they exist and hand-written where they do not. To run the live smoke test against the real API:
342
+
343
+ ```bash
344
+ PLUGGY_CLIENT_ID=... PLUGGY_CLIENT_SECRET=... bundle exec rspec --tag live
345
+ ```
346
+
347
+ ## License
348
+
349
+ MIT.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Pluggy
7
+ # An apiKey from POST /auth, with its expiry.
8
+ #
9
+ # Pluggy documents a 2-hour lifetime, but the token is a JWT, so read the
10
+ # `exp` claim instead of assuming: it costs nothing, needs no dependency, and
11
+ # self-corrects if Pluggy ever changes the TTL. No signature verification --
12
+ # we are only reading the clock on a token we were just handed.
13
+ class ApiKey
14
+ # Used only when `exp` cannot be read (an opaque token, or a shape change).
15
+ FALLBACK_TTL = 2 * 60 * 60
16
+
17
+ # Renew slightly early so a request can't expire in flight.
18
+ SKEW = 60
19
+
20
+ attr_reader :token, :expires_at
21
+
22
+ def initialize(token, expires_at: nil)
23
+ raise ArgumentError, "apiKey cannot be empty" if token.nil? || token.to_s.empty?
24
+
25
+ @token = token.to_s
26
+ @expires_at = expires_at || self.class.jwt_expiry(@token) || (Time.now + FALLBACK_TTL)
27
+ end
28
+
29
+ def expired?(now = Time.now)
30
+ now >= (@expires_at - SKEW)
31
+ end
32
+
33
+ def to_s = @token
34
+
35
+ def inspect
36
+ "#<Pluggy::ApiKey ***#{@token[-6..]} expires_at=#{@expires_at.iso8601}>"
37
+ end
38
+
39
+ # Decode a base64url JWT payload and read `exp`.
40
+ #
41
+ # Deliberately does not require "base64": it is a *bundled*, not a default,
42
+ # gem from Ruby 3.4 on, so requiring it can fail under Bundler.
43
+ # String#unpack1("m0") is core and does the same job.
44
+ def self.jwt_expiry(token)
45
+ segment = token.split(".")[1]
46
+ return nil unless segment
47
+
48
+ padded = segment.tr("-_", "+/")
49
+ padded += "=" * ((4 - (padded.length % 4)) % 4)
50
+
51
+ exp = JSON.parse(padded.unpack1("m0"))["exp"]
52
+ exp.is_a?(Numeric) ? Time.at(exp) : nil
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ end
57
+ end