ofx_kit 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/LICENSE +21 -0
- data/README.md +315 -0
- data/lib/generators/ofx/eject_generator.rb +25 -0
- data/lib/generators/ofx/templates/ofx_mappings.yml +33 -0
- data/lib/ofx_kit/base/account.rb +12 -0
- data/lib/ofx_kit/base/builder.rb +105 -0
- data/lib/ofx_kit/base/document.rb +32 -0
- data/lib/ofx_kit/base/entity.rb +22 -0
- data/lib/ofx_kit/base/statement.rb +20 -0
- data/lib/ofx_kit/configuration/core.rb +110 -0
- data/lib/ofx_kit/configuration/date_parser.rb +28 -0
- data/lib/ofx_kit/configuration/mapping_applicator.rb +34 -0
- data/lib/ofx_kit/configuration/section_proxy.rb +30 -0
- data/lib/ofx_kit/configuration.rb +6 -0
- data/lib/ofx_kit/domain/balance.rb +10 -0
- data/lib/ofx_kit/domain/bank_account.rb +8 -0
- data/lib/ofx_kit/domain/bank_statement.rb +9 -0
- data/lib/ofx_kit/domain/credit_card_account.rb +8 -0
- data/lib/ofx_kit/domain/credit_card_statement.rb +9 -0
- data/lib/ofx_kit/domain/transaction.rb +13 -0
- data/lib/ofx_kit/domain/transaction_collection.rb +94 -0
- data/lib/ofx_kit/errors.rb +36 -0
- data/lib/ofx_kit/mappings/core_mappings.yml +23 -0
- data/lib/ofx_kit/mappings/field_mappings.yml +19 -0
- data/lib/ofx_kit/parser.rb +131 -0
- data/lib/ofx_kit/tokenizer/base.rb +34 -0
- data/lib/ofx_kit/tokenizer/ofx1.rb +53 -0
- data/lib/ofx_kit/tokenizer/ofx2.rb +30 -0
- data/lib/ofx_kit/version.rb +5 -0
- data/lib/ofx_kit.rb +84 -0
- metadata +156 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2f82d22f9c1dce1691202e851448967eccd5ee7438871b10b5392038c128f084
|
|
4
|
+
data.tar.gz: 1bb156f591d24d2297457e7e0a27a7bcf13bb003fc69670c349285af757610de
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8586682ea5992fb07a2fa516bc02f113cad703034d97fa088590fd55c913e9996a850e43d5442898af6642b37d3af9563584038912454db5e96a9371089e6b8e
|
|
7
|
+
data.tar.gz: 7e8ab351c06a24a55d13299563bce802e32247c22d6046d36aef40f43c010dfa9597a1b7481108fca7f635886638a0a340c3ff6d8c658b37ec990bad6790811e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Geron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# OFX
|
|
2
|
+
|
|
3
|
+
A Ruby gem for parsing OFX (Open Financial Exchange) files. Supports OFX 1.x (SGML) and OFX 2.x (XML), bank statements and credit card statements, with a fluent API and configurable field mappings.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "ofx_kit"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Basic parsing
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Parse from a file path
|
|
19
|
+
ofx = OFX.new("statement.ofx")
|
|
20
|
+
|
|
21
|
+
# Parse from an IO object
|
|
22
|
+
ofx = OFX.new(File.open("statement.ofx"))
|
|
23
|
+
ofx = OFX.new(StringIO.new(raw_content))
|
|
24
|
+
|
|
25
|
+
# Block form — yields the parser
|
|
26
|
+
OFX.new("statement.ofx") do |p|
|
|
27
|
+
puts p.account.account_id
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Accessing data
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
ofx.filename # => "statement.ofx" (nil for IO inputs without a path)
|
|
35
|
+
ofx.headers # => { "VERSION" => "102", "ENCODING" => "USASCII", ... }
|
|
36
|
+
|
|
37
|
+
# Single-statement files
|
|
38
|
+
ofx.account # => OFX::BankAccount or OFX::CreditCardAccount
|
|
39
|
+
ofx.transactions # => Array of OFX::Transaction
|
|
40
|
+
ofx.balance # => OFX::Balance
|
|
41
|
+
|
|
42
|
+
# Multiple-statement files — use the plural forms
|
|
43
|
+
ofx.accounts # => [OFX::BankAccount, ...]
|
|
44
|
+
ofx.statements # => [OFX::BankStatement, ...]
|
|
45
|
+
ofx.balances # => [OFX::Balance, ...]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Transactions
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
t = ofx.transactions.first
|
|
52
|
+
|
|
53
|
+
t.fit_id # => "20240115001" String
|
|
54
|
+
t.type # => "DEBIT" String
|
|
55
|
+
t.memo # => "Pagamento boleto" String
|
|
56
|
+
t.posted_at # => Time object
|
|
57
|
+
t.amount # => Money object (positive = credit, negative = debit)
|
|
58
|
+
t.amount_cents # => Integer (same as t.amount.fractional)
|
|
59
|
+
|
|
60
|
+
t.amount.currency.iso_code # => "BRL"
|
|
61
|
+
t.amount.to_d # => BigDecimal("-150.50")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Credits, debits, and scopes
|
|
65
|
+
|
|
66
|
+
`stmt.transactions` and `account.transactions` both return an `OFX::TransactionCollection`
|
|
67
|
+
with `.credits`, `.debits`, `length`, and the full `Enumerable` API:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
stmt = ofx.statements.first
|
|
71
|
+
txns = stmt.transactions # => OFX::TransactionCollection
|
|
72
|
+
|
|
73
|
+
txns.length # => 2
|
|
74
|
+
txns.credits # => TransactionCollection of positive amounts
|
|
75
|
+
txns.debits # => TransactionCollection of negative amounts
|
|
76
|
+
txns.total_credits # => Money (sum of positive transactions)
|
|
77
|
+
txns.total_debits # => Money (sum of negative transactions)
|
|
78
|
+
txns.net # => Money (total_credits + total_debits)
|
|
79
|
+
txns.map(&:memo) # => ["Pagamento boleto", "Deposito salario"]
|
|
80
|
+
txns.sort_by(&:posted_at) # => Array, sorted by date
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Balance
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
bal = ofx.balance
|
|
87
|
+
bal.amount # => Money object
|
|
88
|
+
bal.amount_cents # => Integer
|
|
89
|
+
bal.posted_at # => Time object
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Summary
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
ofx.summary
|
|
96
|
+
# => {
|
|
97
|
+
# headers: { "VERSION" => "102", ... },
|
|
98
|
+
# statements: {
|
|
99
|
+
# "12345-6" => {
|
|
100
|
+
# currency: "BRL",
|
|
101
|
+
# transactions: { count: 2, net_cents: 284_950 },
|
|
102
|
+
# credits: { count: 1, total_cents: 300_000 },
|
|
103
|
+
# debits: { count: 1, total_cents: -15_050 },
|
|
104
|
+
# balance_cents: 500_000
|
|
105
|
+
# }
|
|
106
|
+
# }
|
|
107
|
+
# }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Error handling
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
OFX.new("missing.ofx") # => Errno::ENOENT
|
|
114
|
+
OFX.new("bad_header.ofx") # => OFX::InvalidHeaderError
|
|
115
|
+
OFX.new("bad_xml.ofx") # => OFX::InvalidBodyError
|
|
116
|
+
OFX.new(42) # => ArgumentError
|
|
117
|
+
|
|
118
|
+
# Calling #account or #balance on a multi-statement file:
|
|
119
|
+
ofx.account # => OFX::MultipleStatementsError (use `accounts`)
|
|
120
|
+
ofx.balance # => OFX::MultipleStatementsError (use `balances`)
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
Use `map` to bind OFX XML tags to Ruby attribute names of your choice.
|
|
127
|
+
|
|
128
|
+
### Adding new fields
|
|
129
|
+
|
|
130
|
+
Map proprietary XML tags that your bank emits but the gem doesn't know about by default:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
OFX.configure do |config|
|
|
134
|
+
config.bank_account.map "AGENCIA", to: "branch_code"
|
|
135
|
+
config.transaction.map "HISPAYEEMEMO", to: "extended_memo"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
ofx = OFX.new("statement.ofx")
|
|
139
|
+
ofx.account.branch_code # => "0272"
|
|
140
|
+
ofx.transactions.first.extended_memo # => "Tarifa bancaria"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Renaming built-in fields
|
|
144
|
+
|
|
145
|
+
`map` renames the default attribute for any non-protected OFX tag:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
OFX.configure do |config|
|
|
149
|
+
config.transaction.map "FITID", to: "uid" # default is fit_id
|
|
150
|
+
config.transaction.map "NAME", to: "payee_name"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
ofx = OFX.new("statement.ofx")
|
|
154
|
+
ofx.transactions.first.uid # => "20240115001"
|
|
155
|
+
ofx.transactions.first.payee_name # => "ACME Corp"
|
|
156
|
+
# ofx.transactions.first.fit_id # => nil (FITID is now mapped to uid)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
> **Protected core fields** — The following OFX fields are used internally by the
|
|
160
|
+
> gem to build Money objects and parse dates. They cannot be renamed:
|
|
161
|
+
> `CURDEF`, `TRNAMT`, `DTPOSTED`, `DTUSER`, `BALAMT`, `DTASOF`.
|
|
162
|
+
> Attempting to remap them raises `OFX::ConfigurationError`.
|
|
163
|
+
|
|
164
|
+
### Loading mappings from a YAML file
|
|
165
|
+
|
|
166
|
+
For larger configurations, use a YAML file instead of inline `map` calls:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
OFX.configure do |config|
|
|
170
|
+
config.load_mappings("config/ofx_mappings.yml")
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The file must have a `FIELDS:` top-level key, with OFX section tags underneath.
|
|
175
|
+
Each entry maps an XML tag to the Ruby attribute name you want to use:
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
FIELDS:
|
|
179
|
+
STMTTRN:
|
|
180
|
+
# New field: your bank emits HISPAYEEMEMO but the gem doesn't know it by default
|
|
181
|
+
HISPAYEEMEMO: extended_memo # → transaction.extended_memo
|
|
182
|
+
|
|
183
|
+
# Override: rename a standard field to a name that fits your domain
|
|
184
|
+
FITID: uid # → transaction.uid (default was fit_id)
|
|
185
|
+
|
|
186
|
+
BANKACCTFROM:
|
|
187
|
+
# New field: Brazilian banks emit AGENCIA for the branch number
|
|
188
|
+
AGENCIA: branch_code # → account.branch_code
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
After loading:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
ofx = OFX.new("statement.ofx")
|
|
195
|
+
|
|
196
|
+
# Custom fields — read proprietary XML tags as Ruby attributes
|
|
197
|
+
ofx.transactions.first.extended_memo # => "Tarifa bancaria"
|
|
198
|
+
ofx.account.branch_code # => "0272"
|
|
199
|
+
|
|
200
|
+
# Overridden field — standard tag mapped to a different name
|
|
201
|
+
ofx.transactions.first.uid # => "20240115001"
|
|
202
|
+
# ofx.transactions.first.fit_id # => nil (FITID is now mapped to uid)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Silencing warnings
|
|
206
|
+
|
|
207
|
+
`transactions` and `balances` aggregate across all statements in a multi-statement file and emit a warning. To silence them:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
OFX.config.multi_statement_warnings = false
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Default currency
|
|
214
|
+
|
|
215
|
+
When a `TransactionCollection` is empty and has no statement context (e.g. an in-memory collection built in tests), aggregation methods like `total_credits` and `net` need a currency to produce a `Money.new(0, ...)` value. The fallback is `OFX.config.default_currency`, which defaults to `'USD'`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
OFX.config.default_currency = 'BRL'
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
In normal usage this fallback is never reached — the gem wires every collection to its statement at parse time and reads the currency directly from `statement.account.currency`.
|
|
222
|
+
|
|
223
|
+
### Rails
|
|
224
|
+
|
|
225
|
+
**Field mappings** — run the eject generator to copy the default mappings into your app:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
rails generate ofx:eject
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This creates `config/initializers/ofx_mappings.yml` with all default mappings.
|
|
232
|
+
The OFX gem detects and loads this file automatically on boot — no initializer or
|
|
233
|
+
`OFX.configure` call needed.
|
|
234
|
+
|
|
235
|
+
Edit the file to rename built-in fields or capture bank-specific XML tags:
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
# config/initializers/ofx_mappings.yml
|
|
239
|
+
FIELDS:
|
|
240
|
+
STMTTRN:
|
|
241
|
+
FITID: "uid" # transaction.fit_id → transaction.uid
|
|
242
|
+
HISPAYEEMEMO: "extended_memo" # → transaction.extended_memo (new field)
|
|
243
|
+
BANKACCTFROM:
|
|
244
|
+
AGENCIA: "branch_code" # → account.branch_code (new field)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Behavioral options** — create a standard initializer:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# config/initializers/ofx.rb
|
|
251
|
+
OFX.configure do |config|
|
|
252
|
+
config.multi_statement_warnings = false # silence aggregation warnings
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Contributing
|
|
257
|
+
|
|
258
|
+
1. Fork the repository and create a feature branch.
|
|
259
|
+
2. Install dependencies:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
bundle install
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
3. Make your changes. Add or update specs to cover them.
|
|
266
|
+
4. Run the test suite and linter before opening a pull request:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
bundle exec rspec
|
|
270
|
+
bundle exec rubocop
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
All tests must pass and RuboCop must report no offenses.
|
|
274
|
+
|
|
275
|
+
## Testing locally via console
|
|
276
|
+
|
|
277
|
+
You can exercise the gem interactively using `irb` from the project root. The `spec/fixtures/` directory contains sample OFX files ready to use.
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
bundle exec irb -r ./lib/ofx_kit
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
# Parse a bank statement (OFX 1.x)
|
|
285
|
+
ofx = OFX.new("spec/fixtures/bank_simple.ofx")
|
|
286
|
+
ofx.account.account_id # => "12345-6"
|
|
287
|
+
ofx.transactions.length # => 2
|
|
288
|
+
ofx.balance.amount # => Money object
|
|
289
|
+
|
|
290
|
+
# Parse an OFX 2.x file
|
|
291
|
+
ofx = OFX.new("spec/fixtures/bank_ofx2.ofx")
|
|
292
|
+
ofx.headers # => { "VERSION" => "220", ... }
|
|
293
|
+
|
|
294
|
+
# Parse a credit card statement
|
|
295
|
+
ofx = OFX.new("spec/fixtures/credit_card.ofx")
|
|
296
|
+
ofx.account # => OFX::CreditCardAccount
|
|
297
|
+
ofx.transactions.first.amount.to_d # => BigDecimal
|
|
298
|
+
|
|
299
|
+
# Multiple statements
|
|
300
|
+
ofx = OFX.new("spec/fixtures/bank_multiple.ofx")
|
|
301
|
+
ofx.accounts.length # => 2
|
|
302
|
+
ofx.statements.map { |s| s.account.account_id }
|
|
303
|
+
|
|
304
|
+
# Try custom field mappings
|
|
305
|
+
OFX.configure do |config|
|
|
306
|
+
config.transaction.map "HISPAYEEMEMO", to: "extended_memo"
|
|
307
|
+
end
|
|
308
|
+
ofx = OFX.new("spec/fixtures/bank_simple.ofx")
|
|
309
|
+
ofx.transactions.first.extended_memo
|
|
310
|
+
OFX.reset_config! # restore defaults between tests
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module Ofx
|
|
6
|
+
module Generators
|
|
7
|
+
# Ejects OFX field mappings into the Rails application so they can be customized.
|
|
8
|
+
#
|
|
9
|
+
# Creates +config/initializers/ofx_mappings.yml+ with the gem's default field
|
|
10
|
+
# mappings. The file is auto-detected and loaded by the OFX gem on boot —
|
|
11
|
+
# no initializer or +OFX.configure+ call is needed.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# rails generate ofx:eject
|
|
15
|
+
class EjectGenerator < Rails::Generators::Base
|
|
16
|
+
source_root File.expand_path('templates', __dir__)
|
|
17
|
+
|
|
18
|
+
desc 'Ejects OFX field mappings into config/initializers/ofx_mappings.yml'
|
|
19
|
+
|
|
20
|
+
def eject_mappings
|
|
21
|
+
copy_file 'ofx_mappings.yml', 'config/initializers/ofx_mappings.yml'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# config/initializers/ofx_mappings.yml
|
|
2
|
+
#
|
|
3
|
+
# Each value is the Ruby attribute name exposed on the domain object.
|
|
4
|
+
# Example: changing FITID from "fit_id" to "uid" means
|
|
5
|
+
# transaction.fit_id → transaction.uid
|
|
6
|
+
#
|
|
7
|
+
# Bank-specific XML tags not listed below can also be captured.
|
|
8
|
+
# Simply add them to the relevant section:
|
|
9
|
+
#
|
|
10
|
+
# STMTTRN:
|
|
11
|
+
# HISPAYEEMEMO: "extended_memo" # → transaction.extended_memo
|
|
12
|
+
# BANKACCTFROM:
|
|
13
|
+
# AGENCIA: "branch_code" # → account.branch_code
|
|
14
|
+
#
|
|
15
|
+
FIELDS:
|
|
16
|
+
STMTTRN:
|
|
17
|
+
FITID: "fit_id"
|
|
18
|
+
TRNTYPE: "type"
|
|
19
|
+
NAME: "name"
|
|
20
|
+
MEMO: "memo"
|
|
21
|
+
PAYEE: "payee"
|
|
22
|
+
CHECKNUM: "check_number"
|
|
23
|
+
REFNUM: "ref_number"
|
|
24
|
+
SIC: "sic"
|
|
25
|
+
|
|
26
|
+
BANKACCTFROM:
|
|
27
|
+
BANKID: "bank_id"
|
|
28
|
+
ACCTID: "account_id"
|
|
29
|
+
ACCTTYPE: "account_type"
|
|
30
|
+
BRANCHID: "branch_id"
|
|
31
|
+
|
|
32
|
+
CCACCTFROM:
|
|
33
|
+
ACCTID: "account_id"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
module Base
|
|
5
|
+
# Constructs domain objects ({BankStatement}, {CreditCardStatement}, etc.)
|
|
6
|
+
# from a parsed {Base::Document}. Applies field mappings defined in {Configuration}
|
|
7
|
+
# and converts raw OFX values (amounts, dates) into typed Ruby objects.
|
|
8
|
+
class Builder
|
|
9
|
+
include Configuration::DateParser
|
|
10
|
+
include Configuration::MappingApplicator
|
|
11
|
+
|
|
12
|
+
def initialize(document)
|
|
13
|
+
@document = document
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<BankStatement, CreditCardStatement>]
|
|
17
|
+
def statements
|
|
18
|
+
@document.bank_statement_nodes.map { |n| build_bank_statement(n) } +
|
|
19
|
+
@document.credit_card_statement_nodes.map { |n| build_credit_card_statement(n) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_bank_statement(node)
|
|
25
|
+
currency = currency_for(node, :bank_statement)
|
|
26
|
+
account = build_bank_account(node.at_css(OFX.config.xml_tag_for(:bank_account)), currency)
|
|
27
|
+
txns = TransactionCollection.new(
|
|
28
|
+
node.css(OFX.config.xml_tag_for(:transaction)).map { |t| build_transaction(t, currency) }
|
|
29
|
+
)
|
|
30
|
+
bal = build_balance(node.at_css(OFX.config.xml_tag_for(:balance)), currency)
|
|
31
|
+
stmt = BankStatement.new(account: account, transactions: txns, balance: bal)
|
|
32
|
+
wire(stmt)
|
|
33
|
+
stmt
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_credit_card_statement(node)
|
|
37
|
+
currency = currency_for(node, :credit_card_statement)
|
|
38
|
+
account = build_credit_card_account(node.at_css(OFX.config.xml_tag_for(:credit_card_account)), currency)
|
|
39
|
+
txns = TransactionCollection.new(
|
|
40
|
+
node.css(OFX.config.xml_tag_for(:transaction)).map { |t| build_transaction(t, currency) }
|
|
41
|
+
)
|
|
42
|
+
bal = build_balance(node.at_css(OFX.config.xml_tag_for(:balance)), currency)
|
|
43
|
+
stmt = CreditCardStatement.new(account: account, transactions: txns, balance: bal)
|
|
44
|
+
wire(stmt)
|
|
45
|
+
stmt
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_bank_account(node, currency)
|
|
49
|
+
account = BankAccount.new
|
|
50
|
+
apply_mappings(account, node, :bank_account)
|
|
51
|
+
account.currency ||= currency
|
|
52
|
+
account
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_credit_card_account(node, currency)
|
|
56
|
+
account = CreditCardAccount.new
|
|
57
|
+
apply_mappings(account, node, :credit_card_account)
|
|
58
|
+
account.currency ||= currency
|
|
59
|
+
account
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_transaction(node, currency)
|
|
63
|
+
t = Transaction.new
|
|
64
|
+
apply_mappings(t, node, :transaction)
|
|
65
|
+
|
|
66
|
+
if t.amount
|
|
67
|
+
t.amount = Money.from_amount(t.amount.to_d, currency)
|
|
68
|
+
t.amount_cents = t.amount.fractional
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
t.posted_at = parse_date(t.posted_at) if t.posted_at.is_a?(String)
|
|
72
|
+
t.occurred_at = parse_date(t.occurred_at) if t.occurred_at.is_a?(String)
|
|
73
|
+
|
|
74
|
+
t
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_balance(node, currency)
|
|
78
|
+
return nil unless node
|
|
79
|
+
|
|
80
|
+
b = Balance.new
|
|
81
|
+
apply_mappings(b, node, :balance)
|
|
82
|
+
|
|
83
|
+
if b.amount
|
|
84
|
+
b.amount = Money.from_amount(b.amount.to_d, currency)
|
|
85
|
+
b.amount_cents = b.amount.fractional
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
b.posted_at = parse_date(b.posted_at) if b.posted_at.is_a?(String)
|
|
89
|
+
b
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wire(stmt)
|
|
93
|
+
acct = stmt.account
|
|
94
|
+
wire_relations(acct,
|
|
95
|
+
statement: -> { stmt }, balance: -> { stmt.balance },
|
|
96
|
+
transactions: -> { stmt.transactions })
|
|
97
|
+
wire_relations(stmt.balance, statement: -> { stmt }, account: -> { acct }) if stmt.balance
|
|
98
|
+
wire_relations(stmt.transactions, statement: -> { stmt })
|
|
99
|
+
stmt.transactions.each { |t| wire_relations(t, statement: -> { stmt }, account: -> { acct }) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def wire_relations(obj, **methods) = methods.each { |name, fn| obj.define_singleton_method(name, &fn) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
module Base
|
|
5
|
+
# Wraps the parsed OFX file, providing access to headers and XML body nodes.
|
|
6
|
+
# Consumers use {#bank_statement_nodes} and {#credit_card_statement_nodes}
|
|
7
|
+
# to extract statement data for domain object construction.
|
|
8
|
+
class Document
|
|
9
|
+
attr_reader :headers
|
|
10
|
+
|
|
11
|
+
def initialize(headers:, body:)
|
|
12
|
+
@headers = headers
|
|
13
|
+
@body = body
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] OFX version declared in the file header
|
|
17
|
+
def version
|
|
18
|
+
@headers['VERSION']
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Nokogiri::XML::NodeSet] all STMTRS (bank statement) nodes in the document
|
|
22
|
+
def bank_statement_nodes
|
|
23
|
+
@body.css('STMTRS')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Nokogiri::XML::NodeSet] all CCSTMTRS (credit card statement) nodes in the document
|
|
27
|
+
def credit_card_statement_nodes
|
|
28
|
+
@body.css('CCSTMTRS')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
module Base
|
|
5
|
+
# Abstract base for domain objects that support dynamic field mappings
|
|
6
|
+
# and relationship wiring via {Base::Builder}.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses gain two class-level macros:
|
|
9
|
+
# - {.ensure_attribute} — dynamically adds +attr_accessor+ for custom mapped fields
|
|
10
|
+
# - {.wired_by_builder} — declares nil-returning placeholder methods that
|
|
11
|
+
# {Base::Builder#wire_relations} overrides per-instance at build time
|
|
12
|
+
class Entity
|
|
13
|
+
def self.ensure_attribute(name)
|
|
14
|
+
attr_accessor name.to_sym unless method_defined?(name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.wired_by_builder(*names)
|
|
18
|
+
names.each { |name| define_method(name) { nil } }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
module Base
|
|
5
|
+
# Base class for OFX statement types, aggregating an account,
|
|
6
|
+
# its transactions, and the closing balance.
|
|
7
|
+
class Statement
|
|
8
|
+
attr_accessor :account, :transactions, :balance
|
|
9
|
+
|
|
10
|
+
def initialize(account:, transactions:, balance:)
|
|
11
|
+
@account = account
|
|
12
|
+
@transactions = transactions
|
|
13
|
+
@balance = balance
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def bank_statement? = false
|
|
17
|
+
def credit_card_statement? = false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|