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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluggy
4
+ VERSION = File.read(File.join(__dir__, "..", "..", "VERSION")).strip
5
+ end
data/lib/pluggy-rb.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shim so `gem "pluggy-rb"` auto-requires cleanly under Bundler.
4
+ # `require "pluggy"` is the canonical form.
5
+ require_relative "pluggy"
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