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,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