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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
class ReservedBalance < APIResource
|
|
6
|
+
fields :name, :identification, :availableAmounts
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Populated when type == "BANK". Every field is nullable.
|
|
10
|
+
class BankData < APIResource
|
|
11
|
+
fields :transferNumber, :closingBalance, :automaticallyInvestedBalance,
|
|
12
|
+
:overdraftContractedLimit, :overdraftUsedLimit,
|
|
13
|
+
:unarrangedOverdraftAmount, :hasReservedBalance
|
|
14
|
+
nested reservedBalances: ReservedBalance
|
|
15
|
+
|
|
16
|
+
# "Cheque especial" -- how much of the arranged overdraft is in use.
|
|
17
|
+
def overdraft_in_use? = self["overdraftUsedLimit"].to_f.positive?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class AdditionalCard < APIResource
|
|
21
|
+
fields :number
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class DisaggregatedCreditLimit < APIResource
|
|
25
|
+
fields :creditLineLimitType, :consolidationType, :identificationNumber,
|
|
26
|
+
:isLimitFlexible, :lineName, :lineNameAdditionalInfo, :limitAmount,
|
|
27
|
+
:limitAmountCurrencyCode, :limitAmountReason, :customizedLimitAmount,
|
|
28
|
+
:customizedLimitAmountCurrencyCode, :usedAmount,
|
|
29
|
+
:usedAmountCurrencyCode, :availableAmount, :availableAmountCurrencyCode
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Populated when type == "CREDIT".
|
|
33
|
+
class CreditData < APIResource
|
|
34
|
+
fields :level, :brand, :brandAdditionalInfo, :balanceCloseDate,
|
|
35
|
+
:balanceDueDate, :availableCreditLimit, :balanceForeignCurrency,
|
|
36
|
+
:minimumPayment, :creditLimit, :isLimitFlexible, :status, :holderType
|
|
37
|
+
nested disaggregatedCreditLimits: DisaggregatedCreditLimit,
|
|
38
|
+
additionalCards: AdditionalCard
|
|
39
|
+
|
|
40
|
+
def active? = self["status"] == "ACTIVE"
|
|
41
|
+
def blocked? = self["status"] == "BLOCKED"
|
|
42
|
+
def cancelled? = self["status"] == "CANCELLED"
|
|
43
|
+
def main_holder? = self["holderType"] == "MAIN"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Account < APIResource
|
|
47
|
+
fields :id, :type, :subtype, :number, :name, :marketingName, :balance,
|
|
48
|
+
:itemId, :taxNumber, :owner, :currencyCode, :createdAt, :updatedAt
|
|
49
|
+
nested bankData: BankData, creditData: CreditData
|
|
50
|
+
|
|
51
|
+
def bank? = self["type"] == "BANK"
|
|
52
|
+
def credit? = self["type"] == "CREDIT"
|
|
53
|
+
def credit_card? = self["subtype"] == "CREDIT_CARD"
|
|
54
|
+
def checking? = self["subtype"] == "CHECKING_ACCOUNT"
|
|
55
|
+
def savings? = self["subtype"] == "SAVINGS_ACCOUNT"
|
|
56
|
+
|
|
57
|
+
def transactions(**filters)
|
|
58
|
+
ensure_client!.transactions.list(account_id: self["id"], **filters)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Credit-card statements. Only CREDIT accounts have them, so say so
|
|
62
|
+
# clearly rather than returning a confusing empty list.
|
|
63
|
+
def bills(**filters)
|
|
64
|
+
unless credit?
|
|
65
|
+
raise Error,
|
|
66
|
+
"account #{self["id"]} has type #{self["type"].inspect}, not \"CREDIT\"; " \
|
|
67
|
+
"bills exist only for credit-card accounts"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ensure_client!.bills.list(account_id: self["id"], **filters)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# GET /accounts/{id}/balance -- fetched live from the institution rather
|
|
74
|
+
# than from the last sync, so it is slower and can 429 or 502.
|
|
75
|
+
def live_balance = ensure_client!.accounts.balance(self["id"])
|
|
76
|
+
|
|
77
|
+
def statements = ensure_client!.accounts.statements(self["id"])
|
|
78
|
+
|
|
79
|
+
def item = ensure_client!.items.retrieve(self["itemId"])
|
|
80
|
+
|
|
81
|
+
def refresh = ensure_client!.accounts.retrieve(self["id"])
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Pluggy
|
|
6
|
+
module Resources
|
|
7
|
+
class BillFinanceCharge < APIResource
|
|
8
|
+
fields :id, :type, :amount, :currencyCode, :additionalInfo
|
|
9
|
+
# `creditCardBillId` is in the schema's `required` list but absent from
|
|
10
|
+
# its `properties` (spec bug); it is present at runtime.
|
|
11
|
+
fields :creditCardBillId
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class BillPayment < APIResource
|
|
15
|
+
fields :id, :valueType, :paymentDate, :paymentMode, :amount, :currencyCode
|
|
16
|
+
|
|
17
|
+
def full? = self["valueType"] == "FULL_PAYMENT"
|
|
18
|
+
def installment? = self["valueType"] == "INSTALLMENT_PAYMENT"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A credit-card statement.
|
|
22
|
+
#
|
|
23
|
+
# Bills carry no line items of their own -- see #transactions.
|
|
24
|
+
class Bill < APIResource
|
|
25
|
+
fields :id, :dueDate, :billClosingDate, :totalAmount,
|
|
26
|
+
:totalAmountCurrencyCode, :minimumPaymentAmount, :allowsInstallments
|
|
27
|
+
# Same spec bug as BillFinanceCharge#creditCardBillId: `accountId` is
|
|
28
|
+
# required-but-undeclared, and present at runtime.
|
|
29
|
+
fields :accountId
|
|
30
|
+
nested financeCharges: BillFinanceCharge, payments: BillPayment
|
|
31
|
+
|
|
32
|
+
# How far back to look when the previous cycle is unknown. Two months plus
|
|
33
|
+
# slack, so a 31-day cycle plus a late closing date still fits.
|
|
34
|
+
FALLBACK_WINDOW_DAYS = 62
|
|
35
|
+
|
|
36
|
+
# Stamped by BillService#list, which sees the whole cycle sequence and can
|
|
37
|
+
# therefore give #transactions an exact one-cycle window. Nil for a bill
|
|
38
|
+
# fetched on its own via bills.retrieve.
|
|
39
|
+
attr_accessor :previous_closing_date
|
|
40
|
+
|
|
41
|
+
def paid?
|
|
42
|
+
list = payments
|
|
43
|
+
return false if list.nil? || list.empty?
|
|
44
|
+
|
|
45
|
+
list.sum { |p| p["amount"] || 0 } >= (self["totalAmount"] || 0)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def finance_charges_total
|
|
49
|
+
(finance_charges || []).sum { |c| c["amount"] || 0 }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def account = ensure_client!.accounts.retrieve(self["accountId"])
|
|
53
|
+
|
|
54
|
+
# The bill's line items.
|
|
55
|
+
#
|
|
56
|
+
# GET /v2/transactions has no billId filter (v1 had one, but v1 is
|
|
57
|
+
# deprecated with a 2026-12-31 sunset), so this lists the account's
|
|
58
|
+
# transactions over the statement cycle and filters on
|
|
59
|
+
# creditCardMetadata.billId.
|
|
60
|
+
#
|
|
61
|
+
# The billId check is authoritative and always runs, so the date window
|
|
62
|
+
# only affects how many requests are made, never which transactions come
|
|
63
|
+
# back -- a wrong window yields a slower answer, never a wrong one.
|
|
64
|
+
#
|
|
65
|
+
# Pass `strategy: :legacy` for a single server-side-filtered request
|
|
66
|
+
# against the deprecated v1 endpoint.
|
|
67
|
+
#
|
|
68
|
+
# Returns an Enumerator rather than a list object, because the count is
|
|
69
|
+
# only knowable after filtering. Nothing is requested until you iterate,
|
|
70
|
+
# and `.map`/`.select` behave normally (returning Arrays) -- unlike a
|
|
71
|
+
# lazy enumerator, which would surprise callers. Chain `.lazy` yourself
|
|
72
|
+
# if you want lazy semantics downstream.
|
|
73
|
+
#
|
|
74
|
+
# With strategy: :legacy it returns a real list object instead, since the
|
|
75
|
+
# server did the filtering.
|
|
76
|
+
def transactions(strategy: :v2, date_from: nil, date_to: nil, **filters)
|
|
77
|
+
# Validated up front rather than on first iteration: a missing client or
|
|
78
|
+
# accountId is a programming error, and deferring it to somewhere deep in
|
|
79
|
+
# an enumerator makes it much harder to place.
|
|
80
|
+
ensure_client!
|
|
81
|
+
require_account_id!
|
|
82
|
+
|
|
83
|
+
if strategy == :legacy
|
|
84
|
+
return @client.transactions.list(
|
|
85
|
+
account_id: require_account_id!, bill_id: self["id"], **filters
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless block_given?
|
|
90
|
+
return enum_for(:transactions, strategy: strategy, date_from: date_from,
|
|
91
|
+
date_to: date_to, **filters)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
bill_id = self["id"]
|
|
95
|
+
@client.transactions
|
|
96
|
+
.list(account_id: require_account_id!,
|
|
97
|
+
date_from: date_from || window_start,
|
|
98
|
+
date_to: date_to || window_end,
|
|
99
|
+
**filters)
|
|
100
|
+
.auto_paging_each { |t| yield t if t.bill_id == bill_id }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def require_account_id!
|
|
106
|
+
self["accountId"] || raise(
|
|
107
|
+
Error,
|
|
108
|
+
"bill #{self["id"]} has no accountId, so its transactions cannot be located; " \
|
|
109
|
+
"fetch it via client.bills.list(account_id:)"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Exact when BillService#list supplied the previous cycle; otherwise a
|
|
114
|
+
# deliberately generous fallback.
|
|
115
|
+
def window_start
|
|
116
|
+
if previous_closing_date
|
|
117
|
+
date = to_date(previous_closing_date)
|
|
118
|
+
return date + 1 if date
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
anchor = to_date(self["billClosingDate"] || self["dueDate"])
|
|
122
|
+
return nil unless anchor
|
|
123
|
+
|
|
124
|
+
@client&.config&.log(
|
|
125
|
+
:info,
|
|
126
|
+
"bill #{self["id"]}: previous cycle unknown, widening the transaction window",
|
|
127
|
+
days: FALLBACK_WINDOW_DAYS
|
|
128
|
+
)
|
|
129
|
+
anchor - FALLBACK_WINDOW_DAYS
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Through the due date rather than the closing date: instalments and
|
|
133
|
+
# late-posted items can land after the cycle closes.
|
|
134
|
+
def window_end
|
|
135
|
+
to_date(self["dueDate"] || self["billClosingDate"])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_date(value)
|
|
139
|
+
case value
|
|
140
|
+
when Date then value
|
|
141
|
+
when Time then value.to_date
|
|
142
|
+
when String then Date.parse(value)
|
|
143
|
+
end
|
|
144
|
+
rescue ArgumentError, TypeError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
class CredentialOption < APIResource
|
|
6
|
+
fields :value, :label
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# One input the Connect Widget (or POST /items) must supply. `name` is the
|
|
10
|
+
# key to send in the `parameters` map.
|
|
11
|
+
class ConnectorCredential < APIResource
|
|
12
|
+
fields :name, :label, :type, :assistiveText, :data, :placeholder,
|
|
13
|
+
:validation, :validationMessage, :mfa
|
|
14
|
+
nested options: CredentialOption
|
|
15
|
+
|
|
16
|
+
def mfa? = self["mfa"] == true
|
|
17
|
+
def select? = self["type"] == "select"
|
|
18
|
+
def image? = self["type"] == "image"
|
|
19
|
+
def password? = self["type"] == "password"
|
|
20
|
+
|
|
21
|
+
def valid?(value)
|
|
22
|
+
pattern = self["validation"]
|
|
23
|
+
return true if pattern.nil? || pattern.empty?
|
|
24
|
+
|
|
25
|
+
Regexp.new(pattern).match?(value.to_s)
|
|
26
|
+
rescue RegexpError
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class ConnectorHealthDetails < APIResource
|
|
32
|
+
fields :connectionRateLast6Hours, :connectionsLast6Hours
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Only populated when the request passed healthDetails: true, and may itself
|
|
36
|
+
# be null. `status` is documented in prose as ONLINE/OFFLINE/UNSTABLE but
|
|
37
|
+
# declared as a free string.
|
|
38
|
+
class ConnectorHealth < APIResource
|
|
39
|
+
fields :status, :stage
|
|
40
|
+
nested details: ConnectorHealthDetails
|
|
41
|
+
|
|
42
|
+
def online? = self["status"] == "ONLINE"
|
|
43
|
+
def offline? = self["status"] == "OFFLINE"
|
|
44
|
+
def unstable? = self["status"] == "UNSTABLE"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Connector < APIResource
|
|
48
|
+
# Note `id` is a NUMBER here, not a UUID like every other resource.
|
|
49
|
+
fields :id, :name, :institutionUrl, :imageUrl, :primaryColor, :type,
|
|
50
|
+
:country, :hasMFA, :products, :oauth, :oauthUrl, :resetPasswordUrl,
|
|
51
|
+
:isOpenFinance, :supportsPaymentInitiation, :supportsScheduledPayments,
|
|
52
|
+
:supportsSmartTransfers, :supportsBoletoManagement,
|
|
53
|
+
:supportsAutomaticPix, :createdAt, :updatedAt
|
|
54
|
+
# In the spec's examples but not its schema.
|
|
55
|
+
fields :isSandbox
|
|
56
|
+
nested credentials: ConnectorCredential, health: ConnectorHealth
|
|
57
|
+
|
|
58
|
+
def mfa? = self["hasMFA"] == true
|
|
59
|
+
def oauth? = self["oauth"] == true
|
|
60
|
+
def sandbox? = self["isSandbox"] == true
|
|
61
|
+
def open_finance? = self["isOpenFinance"] == true
|
|
62
|
+
|
|
63
|
+
def supports?(product)
|
|
64
|
+
(self["products"] || []).include?(product.to_s.upcase)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def credential_names = (credentials || []).map { |c| c["name"] }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
class StatusDetailWarning < APIResource
|
|
6
|
+
fields :code, :message, :providerMessage
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class StatusDetailProduct < APIResource
|
|
10
|
+
fields :lastUpdatedAt, :isUpdated
|
|
11
|
+
nested warnings: StatusDetailWarning
|
|
12
|
+
|
|
13
|
+
def updated? = self["isUpdated"] == true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Per-product sync state. Useful for answering "are the transactions ready
|
|
17
|
+
# yet?" without polling the whole item.
|
|
18
|
+
class StatusDetail < APIResource
|
|
19
|
+
PRODUCTS = %w[
|
|
20
|
+
accounts creditCards transactions investments identity
|
|
21
|
+
investmentsTransactions paymentData loans accountStatements
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
nested(PRODUCTS.to_h { |p| [p, StatusDetailProduct] })
|
|
25
|
+
|
|
26
|
+
def updated?(product)
|
|
27
|
+
self[Util.camel_case(product)]&.updated? || false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def warnings
|
|
31
|
+
PRODUCTS.flat_map { |p| self[p]&.warnings || [] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ItemError < APIResource
|
|
36
|
+
fields :code, :message, :providerMessage, :attributes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# What the user must do next: an MFA code, a device authorization, an OAuth
|
|
40
|
+
# redirect.
|
|
41
|
+
class UserAction < APIResource
|
|
42
|
+
fields :instructions, :attributes, :expiresAt
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A connection to one financial institution.
|
|
46
|
+
#
|
|
47
|
+
# There is no GET /items endpoint -- items cannot be listed. Persist the ids
|
|
48
|
+
# you create, normally keyed by your own clientUserId.
|
|
49
|
+
class Item < APIResource
|
|
50
|
+
fields :id, :status, :executionStatus, :webhookUrl, :createdAt, :updatedAt,
|
|
51
|
+
:lastUpdatedAt, :nextAutoSyncAt, :consecutiveFailedLoginAttempts,
|
|
52
|
+
:consentExpiresAt, :products
|
|
53
|
+
# In the spec's examples but not its schema.
|
|
54
|
+
fields :clientUserId, :oauthRedirectUri
|
|
55
|
+
nested connector: Connector,
|
|
56
|
+
parameter: ConnectorCredential,
|
|
57
|
+
userAction: UserAction,
|
|
58
|
+
statusDetail: StatusDetail,
|
|
59
|
+
error: ItemError
|
|
60
|
+
|
|
61
|
+
# `status` and `executionStatus` are unconstrained strings in the spec --
|
|
62
|
+
# no enum is declared anywhere and the documented values appear only in
|
|
63
|
+
# examples. These predicates compare raw strings and never assume the set
|
|
64
|
+
# is closed.
|
|
65
|
+
def updated? = self["status"] == "UPDATED"
|
|
66
|
+
def updating? = self["status"] == "UPDATING"
|
|
67
|
+
def login_error? = self["status"] == "LOGIN_ERROR"
|
|
68
|
+
def outdated? = self["status"] == "OUTDATED"
|
|
69
|
+
def waiting_user_input? = self["status"] == "WAITING_USER_INPUT" || !self["userAction"].nil?
|
|
70
|
+
alias mfa_required? waiting_user_input?
|
|
71
|
+
|
|
72
|
+
def failed? = !self["error"].nil? || self["executionStatus"].to_s.include?("ERROR")
|
|
73
|
+
def succeeded? = self["executionStatus"] == "SUCCESS"
|
|
74
|
+
|
|
75
|
+
def consent_expired?
|
|
76
|
+
expiry = self[:consentExpiresAt]
|
|
77
|
+
expiry.is_a?(Time) && expiry <= Time.now
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def accounts(type: nil) = ensure_client!.accounts.list(item_id: self["id"], type: type)
|
|
81
|
+
def bank_accounts = accounts(type: "BANK")
|
|
82
|
+
def credit_accounts = accounts(type: "CREDIT")
|
|
83
|
+
def loans = ensure_client!.loans.list(item_id: self["id"])
|
|
84
|
+
|
|
85
|
+
# Every transaction on every account of this item, as one lazy stream.
|
|
86
|
+
def transactions(**filters, &block)
|
|
87
|
+
return enum_for(:transactions, **filters) unless block_given?
|
|
88
|
+
|
|
89
|
+
accounts.each do |account|
|
|
90
|
+
account.transactions(**filters).auto_paging_each(&block)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def send_mfa(values) = ensure_client!.items.send_mfa(self["id"], values)
|
|
95
|
+
def disable_auto_sync = ensure_client!.items.disable_auto_sync(self["id"])
|
|
96
|
+
def sync(**body) = ensure_client!.items.update(self["id"], **body)
|
|
97
|
+
def delete = ensure_client!.items.delete(self["id"])
|
|
98
|
+
def refresh = ensure_client!.items.retrieve(self["id"])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
# Every enum below is left as a raw String on purpose.
|
|
6
|
+
#
|
|
7
|
+
# The spec declares these in English (EFFECTIVE, SIMPLE, MONTHLY, UNIQUE,
|
|
8
|
+
# MINIMUM) but the live API returns the Open Finance Brasil Portuguese
|
|
9
|
+
# values (EFETIVA, SIMPLES, AA, UNICA, MINIMO). Modelling them as closed
|
|
10
|
+
# constants would reject real data.
|
|
11
|
+
class LoanInterestRate < APIResource
|
|
12
|
+
fields :taxType, :interestRateType, :taxPeriodicity, :calculation,
|
|
13
|
+
:referentialRateIndexerType, :referentialRateIndexerSubType,
|
|
14
|
+
:referentialRateIndexerAdditionalInfo, :preFixedRate,
|
|
15
|
+
:postFixedRate, :additionalInfo
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class LoanContractedFee < APIResource
|
|
19
|
+
fields :name, :code, :chargeType, :charge, :amount, :rate
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class LoanContractedFinanceCharge < APIResource
|
|
23
|
+
# The schema declares `additionalInfo`/`rate`, but the spec's own examples
|
|
24
|
+
# emit `chargeAdditionalInfo`/`chargeRate`. Declare both spellings and let
|
|
25
|
+
# whichever the API omits return nil.
|
|
26
|
+
fields :type, :additionalInfo, :rate, :chargeAdditionalInfo, :chargeRate
|
|
27
|
+
|
|
28
|
+
def info = self["additionalInfo"] || self["chargeAdditionalInfo"]
|
|
29
|
+
def charge_rate = self["rate"] || self["chargeRate"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class LoanWarranty < APIResource
|
|
33
|
+
fields :currencyCode, :type, :subtype, :amount
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class LoanBalloonPayment < APIResource
|
|
37
|
+
fields :dueDate, :amount
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class LoanInstallments < APIResource
|
|
41
|
+
fields :typeNumberOfInstallments, :totalNumberOfInstallments,
|
|
42
|
+
:typeContractRemaining, :contractRemainingNumber,
|
|
43
|
+
:paidInstallments, :dueInstallments, :pastDueInstallments
|
|
44
|
+
nested balloonPayments: LoanBalloonPayment
|
|
45
|
+
|
|
46
|
+
def overdue? = self["pastDueInstallments"].to_i.positive?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class LoanPaymentRelease < APIResource
|
|
50
|
+
fields :isOverParcelPayment, :installmentId, :paidDate, :currencyCode,
|
|
51
|
+
:paidAmount, :overParcel
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class LoanPayments < APIResource
|
|
55
|
+
fields :contractOutstandingBalance
|
|
56
|
+
nested releases: LoanPaymentRelease
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Loan < APIResource
|
|
60
|
+
# `CET` (Custo Efetivo Total) is the only non-lowercase-first property in
|
|
61
|
+
# the whole spec. It is passed as a String literal here, and the snake_case
|
|
62
|
+
# conversion turns it into a plain `#cet` reader -- `loan.CET` and
|
|
63
|
+
# `loan["CET"]` work too.
|
|
64
|
+
fields :id, :itemId, :contractNumber, :ipocCode, :productName, :type, :kind,
|
|
65
|
+
:date, :contractDate, :disbursementDates, :settlementDate,
|
|
66
|
+
:contractAmount, :currencyCode, :dueDate, :installmentPeriodicity,
|
|
67
|
+
:installmentPeriodicityAdditionalInfo, :firstInstallmentDueDate,
|
|
68
|
+
"CET", :amortizationScheduled, :amortizationScheduledAdditionalInfo,
|
|
69
|
+
:cnpjConsignee
|
|
70
|
+
nested interestRates: LoanInterestRate,
|
|
71
|
+
contractedFees: LoanContractedFee,
|
|
72
|
+
contractedFinanceCharges: LoanContractedFinanceCharge,
|
|
73
|
+
warranties: LoanWarranty,
|
|
74
|
+
installments: LoanInstallments,
|
|
75
|
+
payments: LoanPayments
|
|
76
|
+
|
|
77
|
+
def loan? = self["kind"] == "LOAN"
|
|
78
|
+
def financing? = self["kind"] == "FINANCING"
|
|
79
|
+
def overdraft? = self["kind"] == "UNARRANGED_ACCOUNT_OVERDRAFT"
|
|
80
|
+
|
|
81
|
+
def outstanding_balance = payments&.[]("contractOutstandingBalance")
|
|
82
|
+
def overdue? = installments&.overdue? || false
|
|
83
|
+
|
|
84
|
+
# Loans hang off an item, not an account.
|
|
85
|
+
def item = ensure_client!.items.retrieve(self["itemId"])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
# The counterparty of a transaction, resolved from its CNPJ.
|
|
6
|
+
class Merchant < APIResource
|
|
7
|
+
fields :name, :businessName, :cnpj, :cnae
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# GET /merchants returns neither a list envelope nor a bare array, but three
|
|
11
|
+
# named buckets, so it gets its own type rather than being forced into the
|
|
12
|
+
# list interface.
|
|
13
|
+
class MerchantSearch < APIResource
|
|
14
|
+
fields :notFoundMerchants, :invalidCnpjs
|
|
15
|
+
nested foundMerchants: Merchant
|
|
16
|
+
|
|
17
|
+
alias found found_merchants
|
|
18
|
+
alias not_found not_found_merchants
|
|
19
|
+
alias invalid invalid_cnpjs
|
|
20
|
+
|
|
21
|
+
# Look up a resolved merchant by the CNPJ you asked for.
|
|
22
|
+
def [](key)
|
|
23
|
+
return super if key.is_a?(Symbol) || !key.to_s.match?(/\A\d{14}\z/)
|
|
24
|
+
|
|
25
|
+
(found_merchants || []).find { |m| m["cnpj"] == key.to_s }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h_by_cnpj
|
|
29
|
+
(found_merchants || []).to_h { |m| [m["cnpj"], m] }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
# POST /connect_token. Valid for 30 minutes and meant for the Connect
|
|
6
|
+
# Widget in your frontend -- it is NOT an apiKey and cannot authenticate
|
|
7
|
+
# API calls.
|
|
8
|
+
class ConnectToken < APIResource
|
|
9
|
+
fields :accessToken
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# GET /accounts/{id}/balance -- read live from the institution rather than
|
|
13
|
+
# from the last sync.
|
|
14
|
+
class Balance < APIResource
|
|
15
|
+
fields :balance, :currencyCode, :updateDateTime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /accounts/{id}/statements. The url is a signed link valid for 30
|
|
19
|
+
# minutes. `monthYear` is "2024-03", which is why it is deliberately not
|
|
20
|
+
# coerced to a Date.
|
|
21
|
+
class Statement < APIResource
|
|
22
|
+
fields :id, :monthYear, :url
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# DELETE /items/{id} returns {count}. This is why PluggyObject does not
|
|
26
|
+
# include Enumerable: #count would otherwise be shadowed.
|
|
27
|
+
class Count < APIResource
|
|
28
|
+
fields :count
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A transaction category. Two-level hierarchy: a category with no parentId
|
|
32
|
+
# is a root.
|
|
33
|
+
class Category < APIResource
|
|
34
|
+
fields :id, :description, :descriptionTranslated, :parentId, :parentDescription
|
|
35
|
+
|
|
36
|
+
def root? = self["parentId"].nil?
|
|
37
|
+
|
|
38
|
+
def children
|
|
39
|
+
ensure_client!.categories.list(parent_id: self["id"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parent
|
|
43
|
+
parent_id = self["parentId"]
|
|
44
|
+
parent_id && ensure_client!.categories.retrieve(parent_id)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluggy
|
|
4
|
+
module Resources
|
|
5
|
+
# A CPF or CNPJ, already formatted by Pluggy ("416.799.495-00").
|
|
6
|
+
class Document < APIResource
|
|
7
|
+
fields :type, :value
|
|
8
|
+
|
|
9
|
+
def cpf? = self["type"] == "CPF"
|
|
10
|
+
def cnpj? = self["type"] == "CNPJ"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class PaymentParticipant < APIResource
|
|
14
|
+
fields :name, :accountNumber, :branchNumber, :routingNumber, :routingNumberISPB
|
|
15
|
+
nested documentNumber: Document
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class BoletoMetadata < APIResource
|
|
19
|
+
fields :digitableLine, :barcode, :baseAmount, :interestAmount,
|
|
20
|
+
:penaltyAmount, :discountAmount
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Present for transfers and bill payments. `paymentMethod` is PIX, TED, DOC,
|
|
24
|
+
# TEV or BOLETO -- described in prose but declared as a free string, so it is
|
|
25
|
+
# never validated here.
|
|
26
|
+
class PaymentData < APIResource
|
|
27
|
+
fields :reason, :referenceNumber, :receiverReferenceId,
|
|
28
|
+
:authenticationCode, :paymentMethod
|
|
29
|
+
nested payer: PaymentParticipant,
|
|
30
|
+
receiver: PaymentParticipant,
|
|
31
|
+
boletoMetadata: BoletoMetadata
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class CreditCardMetadata < APIResource
|
|
35
|
+
fields :installmentNumber, :totalInstallments, :totalAmount, :feeType,
|
|
36
|
+
:feeTypeAdditionalInfo, :otherCreditsType, :otherCreditsAdditionalInfo,
|
|
37
|
+
:purchaseDate, :payeeMCC, :cardNumber, :billId, :billForecastDate
|
|
38
|
+
|
|
39
|
+
def fee? = !self["feeType"].nil?
|
|
40
|
+
def installment? = self["totalInstallments"].to_i > 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Transaction < APIResource
|
|
44
|
+
fields :id, :description, :descriptionRaw, :currencyCode, :amount,
|
|
45
|
+
:amountInAccountCurrency, :date, :type, :balance, :providerCode,
|
|
46
|
+
:status, :category, :categoryId, :operationType,
|
|
47
|
+
:operationTypeAdditionalInfo, :providerId, :accountId, :order,
|
|
48
|
+
:createdAt, :updatedAt
|
|
49
|
+
nested paymentData: PaymentData,
|
|
50
|
+
creditCardMetadata: CreditCardMetadata,
|
|
51
|
+
merchant: Merchant
|
|
52
|
+
|
|
53
|
+
# Direction, from the account holder's point of view. Pluggy normalizes
|
|
54
|
+
# the credit-card convention, so a card purchase is always DEBIT and a
|
|
55
|
+
# payment towards the statement is always CREDIT.
|
|
56
|
+
def debit? = self["type"] == "DEBIT"
|
|
57
|
+
def credit? = self["type"] == "CREDIT"
|
|
58
|
+
alias outflow? debit?
|
|
59
|
+
alias inflow? credit?
|
|
60
|
+
|
|
61
|
+
def posted? = self["status"] == "POSTED"
|
|
62
|
+
|
|
63
|
+
# Typical of card purchases not yet on a closed bill.
|
|
64
|
+
def pending? = self["status"] == "PENDING"
|
|
65
|
+
|
|
66
|
+
def credit_card? = !self["creditCardMetadata"].nil?
|
|
67
|
+
|
|
68
|
+
def installment? = credit_card_metadata&.installment? || false
|
|
69
|
+
|
|
70
|
+
def installment_label
|
|
71
|
+
return nil unless installment?
|
|
72
|
+
|
|
73
|
+
"#{credit_card_metadata["installmentNumber"]}/#{credit_card_metadata["totalInstallments"]}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The bill this transaction belongs to. Since GET /v2/transactions dropped
|
|
77
|
+
# the billId filter, this is the authoritative way to group card
|
|
78
|
+
# transactions into statements.
|
|
79
|
+
def bill_id = credit_card_metadata&.[]("billId")
|
|
80
|
+
|
|
81
|
+
def bill
|
|
82
|
+
id = bill_id
|
|
83
|
+
id && ensure_client!.bills.retrieve(id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def payment_method = payment_data&.[]("paymentMethod")
|
|
87
|
+
def pix? = payment_method == "PIX"
|
|
88
|
+
def boleto? = payment_method == "BOLETO"
|
|
89
|
+
def transfer? = %w[TED DOC TEV].include?(payment_method)
|
|
90
|
+
|
|
91
|
+
# Whoever was on the other side: the receiver of an outflow, the payer of
|
|
92
|
+
# an inflow.
|
|
93
|
+
def counterparty
|
|
94
|
+
data = payment_data
|
|
95
|
+
return nil unless data
|
|
96
|
+
|
|
97
|
+
debit? ? data.receiver : data.payer
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def account = ensure_client!.accounts.retrieve(self["accountId"])
|
|
101
|
+
|
|
102
|
+
# PATCH /transactions/{id} -- the only field Pluggy lets you change.
|
|
103
|
+
def recategorize(category_id)
|
|
104
|
+
ensure_client!.transactions.update(self["id"], category_id: category_id)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|