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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Services
|
|
5
|
+
class BaseService
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
@requestor = client.requestor
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def list_request(path, klass:, paginated: false, **params)
|
|
14
|
+
@requestor.list(path, params: params, klass: klass, client: @client, paginated: paginated)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def object_request(method, path, klass:, body: nil, opaque: [], **params)
|
|
18
|
+
payload = @requestor.request(
|
|
19
|
+
method, path,
|
|
20
|
+
params: params.empty? ? nil : params,
|
|
21
|
+
body: body,
|
|
22
|
+
opaque_body_keys: opaque
|
|
23
|
+
)
|
|
24
|
+
klass.new(payload, client: @client)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Several endpoints take a required query param on an operation that
|
|
28
|
+
# documents only a 200 (/accounts and /loans need itemId; /bills and
|
|
29
|
+
# /transactions need accountId). Failing here with a clear message beats
|
|
30
|
+
# a confusing empty result or a 500.
|
|
31
|
+
def require_args!(**args)
|
|
32
|
+
missing = args.select { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }.keys
|
|
33
|
+
return if missing.empty?
|
|
34
|
+
|
|
35
|
+
name = self.class.name.split("::").last
|
|
36
|
+
raise ArgumentError, "#{name}: #{missing.join(", ")} #{missing.one? ? "is" : "are"} required"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def config = @client.config
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Services
|
|
5
|
+
class AccountService < BaseService
|
|
6
|
+
# GET /accounts. `type` filters between "BANK" and "CREDIT".
|
|
7
|
+
#
|
|
8
|
+
# paginated: false -- the endpoint returns a page envelope but documents no
|
|
9
|
+
# page/pageSize parameters, so there is no way to request page 2.
|
|
10
|
+
def list(item_id:, type: nil)
|
|
11
|
+
require_args!(item_id: item_id)
|
|
12
|
+
list_request("/accounts", klass: Resources::Account, itemId: item_id, type: type)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def retrieve(id)
|
|
16
|
+
require_args!(id: id)
|
|
17
|
+
object_request(:get, "/accounts/#{id}", klass: Resources::Account)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Live balance, read from the institution rather than the last sync. Slower
|
|
21
|
+
# than Account#balance and the one endpoint in scope that documents a 429
|
|
22
|
+
# (institution rate limit) and a 502 (institution unavailable).
|
|
23
|
+
def balance(id)
|
|
24
|
+
require_args!(id: id)
|
|
25
|
+
object_request(:get, "/accounts/#{id}/balance", klass: Resources::Balance)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Monthly statement documents. Each url is signed and valid 30 minutes.
|
|
29
|
+
def statements(id)
|
|
30
|
+
require_args!(id: id)
|
|
31
|
+
list_request("/accounts/#{id}/statements", klass: Resources::Statement)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class BillService < BaseService
|
|
36
|
+
# GET /bills. Scoped to one credit-card account.
|
|
37
|
+
def list(account_id:)
|
|
38
|
+
require_args!(account_id: account_id)
|
|
39
|
+
result = list_request("/bills", klass: Resources::Bill, accountId: account_id)
|
|
40
|
+
annotate_cycles!(result)
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def retrieve(id)
|
|
45
|
+
require_args!(id: id)
|
|
46
|
+
object_request(:get, "/bills/#{id}", klass: Resources::Bill)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convenience mirroring Bill#transactions.
|
|
50
|
+
def transactions(bill, **options)
|
|
51
|
+
bill = retrieve(bill) unless bill.is_a?(Resources::Bill)
|
|
52
|
+
bill.transactions(**options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Chain each bill to its predecessor's closing date so Bill#transactions
|
|
58
|
+
# can window on exactly one statement cycle instead of guessing.
|
|
59
|
+
def annotate_cycles!(list)
|
|
60
|
+
list.results
|
|
61
|
+
.select { |b| b["billClosingDate"] }
|
|
62
|
+
.sort_by { |b| b["billClosingDate"].to_s }
|
|
63
|
+
.each_cons(2) { |prev, cur| cur.previous_closing_date = prev["billClosingDate"] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class LoanService < BaseService
|
|
68
|
+
# GET /loans. Note loans are scoped by ITEM, not by account.
|
|
69
|
+
def list(item_id:)
|
|
70
|
+
require_args!(item_id: item_id)
|
|
71
|
+
list_request("/loans", klass: Resources::Loan, itemId: item_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def retrieve(id)
|
|
75
|
+
require_args!(id: id)
|
|
76
|
+
object_request(:get, "/loans/#{id}", klass: Resources::Loan)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class ItemService < BaseService
|
|
81
|
+
# There is deliberately no #list: GET /items does not exist in the API.
|
|
82
|
+
# Persist the ids you create, normally keyed by your own clientUserId.
|
|
83
|
+
|
|
84
|
+
# POST /items.
|
|
85
|
+
#
|
|
86
|
+
# `parameters` is a connector-defined {String => String} credential map
|
|
87
|
+
# (or an encrypted string). Its keys are passed through untouched -- see
|
|
88
|
+
# Util.encode_body's `opaque` handling -- because camelizing a credential
|
|
89
|
+
# named "cpf_cnpj" would break item creation.
|
|
90
|
+
#
|
|
91
|
+
# Never retried on failure: the API has no idempotency key, so a retry
|
|
92
|
+
# could open a duplicate connection.
|
|
93
|
+
def create(connector_id:, parameters:, **options)
|
|
94
|
+
require_args!(connector_id: connector_id, parameters: parameters)
|
|
95
|
+
object_request(:post, "/items",
|
|
96
|
+
klass: Resources::Item,
|
|
97
|
+
opaque: %w[parameters],
|
|
98
|
+
body: { connector_id: connector_id, parameters: parameters, **options })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def retrieve(id)
|
|
102
|
+
require_args!(id: id)
|
|
103
|
+
object_request(:get, "/items/#{id}", klass: Resources::Item)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# PATCH /items/{id} triggers a fresh sync, optionally updating credentials.
|
|
107
|
+
def update(id, **body)
|
|
108
|
+
require_args!(id: id)
|
|
109
|
+
object_request(:patch, "/items/#{id}",
|
|
110
|
+
klass: Resources::Item, opaque: %w[parameters], body: body)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def delete(id)
|
|
114
|
+
require_args!(id: id)
|
|
115
|
+
object_request(:delete, "/items/#{id}", klass: Resources::Count)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# POST /items/{id}/mfa. The body is a bare {name => value} map, not a
|
|
119
|
+
# wrapped object, e.g. send_mfa(id, token: "123456").
|
|
120
|
+
def send_mfa(id, values)
|
|
121
|
+
require_args!(id: id, values: values)
|
|
122
|
+
object_request(:post, "/items/#{id}/mfa",
|
|
123
|
+
klass: Resources::Item,
|
|
124
|
+
body: Util.stringify_keys(values.to_h))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# PATCH, and it takes no body.
|
|
128
|
+
def disable_auto_sync(id)
|
|
129
|
+
require_args!(id: id)
|
|
130
|
+
object_request(:patch, "/items/#{id}/disable-auto-sync", klass: Resources::Item)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class ConnectorService < BaseService
|
|
135
|
+
# GET /connectors. `countries` and `types` are comma-joined by the encoder.
|
|
136
|
+
def list(countries: nil, types: nil, name: nil, sandbox: nil, health_details: nil,
|
|
137
|
+
is_open_finance: nil, supports_payment_initiation: nil,
|
|
138
|
+
supports_smart_transfers: nil, supports_automatic_pix: nil,
|
|
139
|
+
page: nil, page_size: nil)
|
|
140
|
+
list_request("/connectors",
|
|
141
|
+
klass: Resources::Connector,
|
|
142
|
+
paginated: !page.nil? || !page_size.nil?,
|
|
143
|
+
countries: countries,
|
|
144
|
+
types: types,
|
|
145
|
+
name: name,
|
|
146
|
+
sandbox: sandbox,
|
|
147
|
+
healthDetails: health_details,
|
|
148
|
+
isOpenFinance: is_open_finance,
|
|
149
|
+
supportsPaymentInitiation: supports_payment_initiation,
|
|
150
|
+
supportsSmartTransfers: supports_smart_transfers,
|
|
151
|
+
supportsAutomaticPix: supports_automatic_pix,
|
|
152
|
+
page: page,
|
|
153
|
+
pageSize: page_size)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# NOTE: a connector id is a NUMBER, not a UUID.
|
|
157
|
+
def retrieve(id, health_details: nil)
|
|
158
|
+
require_args!(id: id)
|
|
159
|
+
object_request(:get, "/connectors/#{id}",
|
|
160
|
+
klass: Resources::Connector, healthDetails: health_details)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class CategoryService < BaseService
|
|
165
|
+
# GET /categories. The spec declares a bare array but its own example is a
|
|
166
|
+
# page envelope, so the shape is sniffed at runtime; either way you get a
|
|
167
|
+
# list that answers #each.
|
|
168
|
+
def list(parent_id: nil)
|
|
169
|
+
list_request("/categories", klass: Resources::Category, parentId: parent_id)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def retrieve(id)
|
|
173
|
+
require_args!(id: id)
|
|
174
|
+
object_request(:get, "/categories/#{id}", klass: Resources::Category)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class MerchantService < BaseService
|
|
179
|
+
# GET /merchants. Returns three buckets (found, not found, invalid) rather
|
|
180
|
+
# than a list, so it maps to MerchantSearch rather than a list object.
|
|
181
|
+
def search(cnpjs)
|
|
182
|
+
list = Array(cnpjs)
|
|
183
|
+
require_args!(cnpjs: list)
|
|
184
|
+
object_request(:get, "/merchants", klass: Resources::MerchantSearch, cnpjs: list)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Resolve a single CNPJ, or nil when it is unknown or invalid.
|
|
188
|
+
def retrieve(cnpj)
|
|
189
|
+
search([cnpj]).found_merchants&.first
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class ConnectTokenService < BaseService
|
|
194
|
+
# POST /connect_token.
|
|
195
|
+
#
|
|
196
|
+
# Returns a 30-minute token for the Connect Widget in your frontend. It is
|
|
197
|
+
# not an apiKey and cannot authenticate API calls.
|
|
198
|
+
#
|
|
199
|
+
# Pass item_id: to let the widget update an existing item instead of
|
|
200
|
+
# creating a new one.
|
|
201
|
+
def create(item_id: nil, options: nil)
|
|
202
|
+
body = {}
|
|
203
|
+
body[:item_id] = item_id if item_id
|
|
204
|
+
body[:options] = options if options
|
|
205
|
+
|
|
206
|
+
object_request(:post, "/connect_token",
|
|
207
|
+
klass: Resources::ConnectToken,
|
|
208
|
+
body: body.empty? ? nil : body)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Services
|
|
5
|
+
class TransactionService < BaseService
|
|
6
|
+
V2_PATH = "/v2/transactions"
|
|
7
|
+
V1_PATH = "/transactions"
|
|
8
|
+
|
|
9
|
+
# Filters that only exist on the deprecated v1 endpoint. Passing any of
|
|
10
|
+
# them routes there automatically.
|
|
11
|
+
V1_ONLY_PARAMS = %i[bill_id page page_size].freeze
|
|
12
|
+
|
|
13
|
+
# List an account's transactions.
|
|
14
|
+
#
|
|
15
|
+
# Defaults to GET /v2/transactions (cursor-paginated, current). Routes to
|
|
16
|
+
# the deprecated GET /transactions when a v1-only filter is given, or when
|
|
17
|
+
# version: :v1 is explicit.
|
|
18
|
+
#
|
|
19
|
+
# v2 filters: date_from, date_to, created_at_from, ids, after
|
|
20
|
+
# v1 filters: from, to, created_at_from, ids, bill_id, page, page_size
|
|
21
|
+
def list(account_id:, version: nil, **filters)
|
|
22
|
+
require_args!(account_id: account_id)
|
|
23
|
+
|
|
24
|
+
resolved = version || implied_version(filters)
|
|
25
|
+
resolved == :v1 ? list_v1(account_id, **filters) : list_v2(account_id, **filters)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resume cursor pagination from a persisted CursorList#next_token.
|
|
29
|
+
#
|
|
30
|
+
# The token is the whole "?..." query string the API handed back, filters
|
|
31
|
+
# included -- not a bare `after` value. See Lists::CursorList.
|
|
32
|
+
def resume(next_token)
|
|
33
|
+
token = next_token.to_s
|
|
34
|
+
unless token.start_with?("?")
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"expected a cursor token starting with '?' (use CursorList#next_token), got #{token.inspect}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@requestor.list_raw("#{V2_PATH}#{token}", klass: Resources::Transaction, client: @client)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def retrieve(id)
|
|
43
|
+
require_args!(id: id)
|
|
44
|
+
object_request(:get, "#{V1_PATH}/#{id}", klass: Resources::Transaction)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# PATCH /transactions/{id}. Recategorizing is the only supported edit.
|
|
48
|
+
def update(id, category_id:)
|
|
49
|
+
require_args!(id: id, category_id: category_id)
|
|
50
|
+
object_request(:patch, "#{V1_PATH}/#{id}",
|
|
51
|
+
klass: Resources::Transaction,
|
|
52
|
+
body: { category_id: category_id })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def implied_version(filters)
|
|
58
|
+
return :v1 if V1_ONLY_PARAMS.any? { |p| filters.key?(p) }
|
|
59
|
+
|
|
60
|
+
config.transactions_api_version
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def list_v2(account_id, date_from: nil, date_to: nil, created_at_from: nil,
|
|
64
|
+
ids: nil, after: nil, **rest)
|
|
65
|
+
reject_unknown!(rest, V2_PATH)
|
|
66
|
+
|
|
67
|
+
# The API returns a 400 for this combination; catching it locally saves
|
|
68
|
+
# a round-trip and gives a clearer message.
|
|
69
|
+
if date_from && created_at_from
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"date_from cannot be combined with created_at_from on #{V2_PATH} " \
|
|
72
|
+
"(the API rejects it with a 400); use one or the other"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
check_ids!(ids)
|
|
76
|
+
|
|
77
|
+
list_request(V2_PATH,
|
|
78
|
+
klass: Resources::Transaction,
|
|
79
|
+
accountId: account_id,
|
|
80
|
+
dateFrom: date_from,
|
|
81
|
+
dateTo: date_to,
|
|
82
|
+
createdAtFrom: created_at_from,
|
|
83
|
+
ids: ids,
|
|
84
|
+
after: after)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def list_v1(account_id, from: nil, to: nil, created_at_from: nil, ids: nil,
|
|
88
|
+
bill_id: nil, page: nil, page_size: nil, date_from: nil, date_to: nil, **rest)
|
|
89
|
+
reject_unknown!(rest, V1_PATH)
|
|
90
|
+
check_ids!(ids)
|
|
91
|
+
|
|
92
|
+
config.log(:info,
|
|
93
|
+
"using deprecated GET /transactions (sunset 2026-12-31)",
|
|
94
|
+
reason: bill_id ? "billId filter is v1-only" : "requested")
|
|
95
|
+
|
|
96
|
+
size = page_size || Lists::OffsetList::DEFAULT_PAGE_SIZE
|
|
97
|
+
if size > Lists::OffsetList::MAX_PAGE_SIZE
|
|
98
|
+
raise ArgumentError, "page_size cannot exceed #{Lists::OffsetList::MAX_PAGE_SIZE}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
list_request(V1_PATH,
|
|
102
|
+
klass: Resources::Transaction,
|
|
103
|
+
paginated: true,
|
|
104
|
+
accountId: account_id,
|
|
105
|
+
# v2 renamed these; accept either spelling here.
|
|
106
|
+
from: from || date_from,
|
|
107
|
+
to: to || date_to,
|
|
108
|
+
createdAtFrom: created_at_from,
|
|
109
|
+
ids: ids,
|
|
110
|
+
billId: bill_id,
|
|
111
|
+
page: page || 1,
|
|
112
|
+
pageSize: size)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_ids!(ids)
|
|
116
|
+
return if ids.nil? || ids.length <= 500
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "at most 500 ids per request (got #{ids.length})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reject_unknown!(rest, path)
|
|
122
|
+
return if rest.empty?
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, "unknown filter#{"s" if rest.size > 1} for #{path}: #{rest.keys.join(", ")}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/pluggy/util.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "time"
|
|
6
|
+
require "date"
|
|
7
|
+
|
|
8
|
+
module Pluggy
|
|
9
|
+
# Naming conversion and request encoding.
|
|
10
|
+
#
|
|
11
|
+
# Pluggy speaks camelCase; Ruby speaks snake_case. Two conversions run in
|
|
12
|
+
# opposite directions: responses are read through snake_case accessors
|
|
13
|
+
# (see PluggyObject), and request params are written from snake_case kwargs
|
|
14
|
+
# into camelCase wire keys.
|
|
15
|
+
#
|
|
16
|
+
# The conversion is deliberately NOT round-tripped: camel_case(snake_case(x))
|
|
17
|
+
# is lossy for acronym-bearing keys ("issuerCNPJ" -> :issuer_cnpj ->
|
|
18
|
+
# "issuerCnpj"). That is fine because only *outgoing* params are camelized,
|
|
19
|
+
# and those are an explicit per-service allowlist rather than something we
|
|
20
|
+
# derive from a response.
|
|
21
|
+
module Util
|
|
22
|
+
# "issuerCNPJName" -> "issuerCNPJ_Name"
|
|
23
|
+
ACRONYM_BOUNDARY = /([A-Z\d]+)([A-Z][a-z])/
|
|
24
|
+
# "createdAt" -> "created_At"
|
|
25
|
+
LOWER_UPPER = /([a-z\d])([A-Z])/
|
|
26
|
+
|
|
27
|
+
# Comma-joined in the query string. `countries` and `types` are
|
|
28
|
+
# `style: form, explode: false` in the spec; `cnpjs` is typed as a bare
|
|
29
|
+
# comma-separated string. Everything else (notably `ids`) repeats the key.
|
|
30
|
+
COMMA_JOINED_PARAMS = %w[countries types cnpjs].freeze
|
|
31
|
+
|
|
32
|
+
# The spec types these as `format: date-time` but every description says
|
|
33
|
+
# "Format (yyyy-mm-dd)". The descriptions win — see plan §7.
|
|
34
|
+
DATE_ONLY_PARAMS = %w[from to dateFrom dateTo].freeze
|
|
35
|
+
|
|
36
|
+
# ...and this one really does want the full timestamp.
|
|
37
|
+
DATETIME_PARAMS = %w[createdAtFrom].freeze
|
|
38
|
+
|
|
39
|
+
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%LZ"
|
|
40
|
+
|
|
41
|
+
@snake_cache = {}
|
|
42
|
+
@camel_cache = {}
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# "createdAt" => "created_at", "CET" => "cet", "hasMFA" => "has_mfa",
|
|
46
|
+
# "issuerCNPJ" => "issuer_cnpj", "payeeMCC" => "payee_mcc"
|
|
47
|
+
def snake_case(key)
|
|
48
|
+
k = key.to_s
|
|
49
|
+
@snake_cache[k] ||= k.gsub(ACRONYM_BOUNDARY, '\1_\2')
|
|
50
|
+
.gsub(LOWER_UPPER, '\1_\2')
|
|
51
|
+
.tr("-", "_")
|
|
52
|
+
.downcase
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# :item_id => "itemId", :oauth_redirect_uri => "oauthRedirectUri"
|
|
56
|
+
def camel_case(key)
|
|
57
|
+
k = key.to_s
|
|
58
|
+
return k unless k.include?("_")
|
|
59
|
+
|
|
60
|
+
@camel_cache[k] ||= begin
|
|
61
|
+
head, *rest = k.split("_")
|
|
62
|
+
head + rest.map(&:capitalize).join
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Symbol keys are snake_case and get camelized. String keys pass through
|
|
67
|
+
# verbatim -- the escape hatch for any key our camelizer would mangle.
|
|
68
|
+
def wire_key(key)
|
|
69
|
+
key.is_a?(String) ? key : camel_case(key)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def encode_query(params)
|
|
73
|
+
pairs = []
|
|
74
|
+
|
|
75
|
+
(params || {}).each do |key, value|
|
|
76
|
+
next if value.nil?
|
|
77
|
+
|
|
78
|
+
wire = wire_key(key)
|
|
79
|
+
|
|
80
|
+
case value
|
|
81
|
+
when Array
|
|
82
|
+
next if value.empty?
|
|
83
|
+
|
|
84
|
+
if COMMA_JOINED_PARAMS.include?(wire)
|
|
85
|
+
pairs << [wire, value.map { |e| format_param(wire, e) }.join(",")]
|
|
86
|
+
else
|
|
87
|
+
value.each { |e| pairs << [wire, format_param(wire, e)] }
|
|
88
|
+
end
|
|
89
|
+
when Hash
|
|
90
|
+
raise ArgumentError, "nested hash is not encodable in a query string: #{wire}"
|
|
91
|
+
else
|
|
92
|
+
pairs << [wire, format_param(wire, value)]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
URI.encode_www_form(pairs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Per-parameter date formatting (see DATE_ONLY_PARAMS / DATETIME_PARAMS).
|
|
100
|
+
def format_param(wire, value)
|
|
101
|
+
if DATE_ONLY_PARAMS.include?(wire)
|
|
102
|
+
to_date_string(value)
|
|
103
|
+
elsif DATETIME_PARAMS.include?(wire)
|
|
104
|
+
to_datetime_string(value)
|
|
105
|
+
else
|
|
106
|
+
value.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def to_date_string(value)
|
|
111
|
+
case value
|
|
112
|
+
when Date then value.iso8601
|
|
113
|
+
when Time then value.to_date.iso8601
|
|
114
|
+
else
|
|
115
|
+
# Tolerate a caller passing a full timestamp for a date-only param.
|
|
116
|
+
value.to_s[0, 10]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def to_datetime_string(value)
|
|
121
|
+
case value
|
|
122
|
+
when Time then value.utc.strftime(DATETIME_FORMAT)
|
|
123
|
+
when DateTime then value.to_time.utc.strftime(DATETIME_FORMAT)
|
|
124
|
+
when Date then Time.utc(value.year, value.month, value.day).strftime(DATETIME_FORMAT)
|
|
125
|
+
else value.to_s
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Deep snake_case -> camelCase for request bodies.
|
|
130
|
+
#
|
|
131
|
+
# `opaque` names keys whose *contents* must not be touched. POST /items
|
|
132
|
+
# {parameters} and POST /items/{id}/mfa are free-form {String => String}
|
|
133
|
+
# maps whose keys are connector-defined ("user", "cpf", "cpf_cnpj"), so
|
|
134
|
+
# camelizing them would silently break item creation.
|
|
135
|
+
def encode_body(hash, opaque: [])
|
|
136
|
+
(hash || {}).each_with_object({}) do |(key, value), out|
|
|
137
|
+
next if value.nil?
|
|
138
|
+
|
|
139
|
+
wire = wire_key(key)
|
|
140
|
+
out[wire] =
|
|
141
|
+
if opaque.include?(wire)
|
|
142
|
+
stringify_keys(value)
|
|
143
|
+
else
|
|
144
|
+
encode_body_value(value, opaque)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def encode_body_value(value, opaque)
|
|
150
|
+
case value
|
|
151
|
+
when Hash then encode_body(value, opaque: opaque)
|
|
152
|
+
when Array then value.map { |e| e.is_a?(Hash) ? encode_body(e, opaque: opaque) : e }
|
|
153
|
+
else value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Shallow String-ify of a free-form map's keys, leaving them otherwise
|
|
158
|
+
# untouched.
|
|
159
|
+
def stringify_keys(value)
|
|
160
|
+
return value unless value.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
value.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/pluggy-rb.rb
ADDED
data/lib/pluggy.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
require_relative "pluggy/version"
|
|
6
|
+
require_relative "pluggy/errors"
|
|
7
|
+
require_relative "pluggy/util"
|
|
8
|
+
require_relative "pluggy/configuration"
|
|
9
|
+
require_relative "pluggy/pluggy_object"
|
|
10
|
+
require_relative "pluggy/api_resource"
|
|
11
|
+
require_relative "pluggy/api_key"
|
|
12
|
+
require_relative "pluggy/credential_store"
|
|
13
|
+
require_relative "pluggy/connection_manager"
|
|
14
|
+
require_relative "pluggy/lists"
|
|
15
|
+
|
|
16
|
+
require_relative "pluggy/resources/merchant"
|
|
17
|
+
require_relative "pluggy/resources/transaction"
|
|
18
|
+
require_relative "pluggy/resources/account"
|
|
19
|
+
require_relative "pluggy/resources/bill"
|
|
20
|
+
require_relative "pluggy/resources/loan"
|
|
21
|
+
require_relative "pluggy/resources/connector"
|
|
22
|
+
require_relative "pluggy/resources/item"
|
|
23
|
+
require_relative "pluggy/resources/misc"
|
|
24
|
+
|
|
25
|
+
require_relative "pluggy/api_requestor"
|
|
26
|
+
require_relative "pluggy/services/base_service"
|
|
27
|
+
require_relative "pluggy/services/transaction_service"
|
|
28
|
+
require_relative "pluggy/services/other_services"
|
|
29
|
+
require_relative "pluggy/client"
|
|
30
|
+
|
|
31
|
+
# Unofficial Ruby client for the Pluggy open-finance API.
|
|
32
|
+
#
|
|
33
|
+
# client = Pluggy::Client.new(client_id: "...", client_secret: "...")
|
|
34
|
+
# client.accounts.list(item_id: item_id).each { |a| puts a.name }
|
|
35
|
+
#
|
|
36
|
+
# Configuration is per-client. The module-level accessors below set defaults
|
|
37
|
+
# that new clients inherit; they are not a way to make calls without a client.
|
|
38
|
+
module Pluggy
|
|
39
|
+
class << self
|
|
40
|
+
extend Forwardable
|
|
41
|
+
|
|
42
|
+
attr_accessor :config
|
|
43
|
+
|
|
44
|
+
def_delegators :config,
|
|
45
|
+
*Configuration::DEFAULTS.keys,
|
|
46
|
+
*Configuration::DEFAULTS.keys.map { |key| :"#{key}=" }
|
|
47
|
+
|
|
48
|
+
def configure
|
|
49
|
+
yield config
|
|
50
|
+
config
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
self.config = Configuration.new
|
|
55
|
+
end
|