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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +349 -0
- data/VERSION +1 -0
- data/lib/pluggy/api_key.rb +57 -0
- data/lib/pluggy/api_requestor.rb +268 -0
- data/lib/pluggy/api_resource.rb +23 -0
- data/lib/pluggy/client.rb +61 -0
- data/lib/pluggy/configuration.rb +114 -0
- data/lib/pluggy/connection_manager.rb +49 -0
- data/lib/pluggy/credential_store.rb +65 -0
- data/lib/pluggy/errors.rb +125 -0
- data/lib/pluggy/lists/array_list.rb +23 -0
- data/lib/pluggy/lists/base_list.rb +69 -0
- data/lib/pluggy/lists/cursor_list.rb +57 -0
- data/lib/pluggy/lists/offset_list.rb +46 -0
- data/lib/pluggy/lists.rb +36 -0
- data/lib/pluggy/pluggy_object.rb +274 -0
- data/lib/pluggy/resources/account.rb +84 -0
- data/lib/pluggy/resources/bill.rb +149 -0
- data/lib/pluggy/resources/connector.rb +70 -0
- data/lib/pluggy/resources/item.rb +101 -0
- data/lib/pluggy/resources/loan.rb +88 -0
- data/lib/pluggy/resources/merchant.rb +33 -0
- data/lib/pluggy/resources/misc.rb +48 -0
- data/lib/pluggy/resources/transaction.rb +108 -0
- data/lib/pluggy/services/base_service.rb +42 -0
- data/lib/pluggy/services/other_services.rb +212 -0
- data/lib/pluggy/services/transaction_service.rb +128 -0
- data/lib/pluggy/util.rb +166 -0
- data/lib/pluggy/version.rb +5 -0
- data/lib/pluggy-rb.rb +5 -0
- data/lib/pluggy.rb +55 -0
- metadata +96 -0
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
|