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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module OFX
|
|
6
|
+
# Manages XML-to-Ruby field mappings used during OFX document parsing.
|
|
7
|
+
#
|
|
8
|
+
# Mappings are split into two layers:
|
|
9
|
+
# - *Core* ({core_mappings.yml}): OFX-standard fields whose Ruby attribute names are
|
|
10
|
+
# referenced by name inside {Base::Builder}. These cannot be overridden.
|
|
11
|
+
# - *User* ({field_mappings.yml}): convenience mappings that can be added to or replaced
|
|
12
|
+
# at runtime via {#load_mappings} or the {OFX.configure} block.
|
|
13
|
+
class Configuration
|
|
14
|
+
CORE_MAPPINGS_PATH = File.join(__dir__, '..', 'mappings', 'core_mappings.yml')
|
|
15
|
+
MAPPINGS_PATH = File.join(__dir__, '..', 'mappings', 'field_mappings.yml')
|
|
16
|
+
|
|
17
|
+
# Conventional path for user mappings in a Rails application.
|
|
18
|
+
# Auto-loaded on boot when present. Ejected via +rails generate ofx:eject+.
|
|
19
|
+
RAILS_MAPPINGS_PATH = 'config/initializers/ofx_mappings.yml'
|
|
20
|
+
|
|
21
|
+
attr_writer :multi_statement_warnings
|
|
22
|
+
attr_accessor :default_currency
|
|
23
|
+
|
|
24
|
+
def multi_statement_warnings?
|
|
25
|
+
@multi_statement_warnings
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(auto_load_path: File.expand_path(RAILS_MAPPINGS_PATH))
|
|
29
|
+
@multi_statement_warnings = true
|
|
30
|
+
@default_currency = 'USD'
|
|
31
|
+
|
|
32
|
+
core = YAML.safe_load_file(CORE_MAPPINGS_PATH)
|
|
33
|
+
@sections = core.fetch('SECTIONS', {})
|
|
34
|
+
@core_fields = core.fetch('FIELDS', {})
|
|
35
|
+
@section_to_tag = @sections.invert
|
|
36
|
+
|
|
37
|
+
user = YAML.safe_load_file(MAPPINGS_PATH)
|
|
38
|
+
@user_fields = user.fetch('FIELDS', {})
|
|
39
|
+
|
|
40
|
+
load_mappings(auto_load_path) if File.exist?(auto_load_path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a {SectionProxy} for the given section, allowing inline mapping
|
|
44
|
+
# configuration via {SectionProxy#map}.
|
|
45
|
+
%w[bank_statement credit_card_statement transaction bank_account credit_card_account balance].each do |section|
|
|
46
|
+
define_method(section) { SectionProxy.new(@user_fields, @core_fields, xml_tag_for(section)) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the OFX XML tag name corresponding to the given section identifier.
|
|
50
|
+
# @param section_name [String, Symbol] section identifier (e.g. +:transaction+)
|
|
51
|
+
# @return [String, nil] the XML tag name, or +nil+ if not found
|
|
52
|
+
def xml_tag_for(section_name)
|
|
53
|
+
@section_to_tag[section_name.to_s]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the merged hash of XML tag → Ruby attribute mappings for the given section.
|
|
57
|
+
# Core mappings take precedence; user mappings extend them.
|
|
58
|
+
# @param section_name [String, Symbol] section identifier
|
|
59
|
+
# @return [Hash{String => String}] mapping of XML tags to Ruby attribute names
|
|
60
|
+
def xml_mappings_for(section_name)
|
|
61
|
+
tag = xml_tag_for(section_name)
|
|
62
|
+
return {} unless tag
|
|
63
|
+
|
|
64
|
+
(@core_fields[tag] || {}).merge(@user_fields[tag] || {})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Merges additional field mappings from a YAML file into the user-layer configuration.
|
|
68
|
+
# The file must have a top-level +FIELDS+ key. Core OFX fields cannot be overridden.
|
|
69
|
+
# @param path [String] path to the YAML mappings file
|
|
70
|
+
# @raise [ConfigurationError] if the file is missing, malformed, references unknown
|
|
71
|
+
# sections, or attempts to override a core field mapping
|
|
72
|
+
def load_mappings(path)
|
|
73
|
+
raise ConfigurationError, "Mappings file not found: #{path}" unless File.exist?(path)
|
|
74
|
+
|
|
75
|
+
raw = YAML.safe_load_file(path)
|
|
76
|
+
raise ConfigurationError, 'Invalid mappings file: expected a Hash' unless raw.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
fields = raw.fetch('FIELDS') do
|
|
79
|
+
raise ConfigurationError, "Invalid mappings file: missing top-level 'FIELDS' key"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
fields.each { |tag, mappings| merge_user_section(tag, mappings) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def merge_user_section(xml_tag, mappings)
|
|
88
|
+
unless @sections.key?(xml_tag.to_s)
|
|
89
|
+
raise ConfigurationError, "Unknown section '#{xml_tag}'. Valid sections: #{@sections.keys.join(', ')}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless mappings.is_a?(Hash)
|
|
93
|
+
raise ConfigurationError, "Mapping value for '#{xml_tag}' must be a Hash, got #{mappings.class}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
mappings.each_key { |k| assert_not_core!(xml_tag, k) }
|
|
97
|
+
|
|
98
|
+
@user_fields[xml_tag.to_s] ||= {}
|
|
99
|
+
@user_fields[xml_tag.to_s].merge!(mappings)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assert_not_core!(xml_tag, xml_key)
|
|
103
|
+
core_attr = @core_fields.dig(xml_tag.to_s, xml_key.to_s)
|
|
104
|
+
return unless core_attr
|
|
105
|
+
|
|
106
|
+
raise ConfigurationError,
|
|
107
|
+
"Cannot override core mapping '#{xml_tag}.#{xml_key}' (reserved as '#{core_attr}')"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module OFX
|
|
6
|
+
class Configuration
|
|
7
|
+
# Mixin included by {Base::Builder} to parse OFX date strings into +Time+ objects.
|
|
8
|
+
# Handles the two formats found in OFX files (YYYYMMDD and YYYYMMDDHHMMSS),
|
|
9
|
+
# stripping any timezone suffixes (e.g. +[+05:30]+) before parsing.
|
|
10
|
+
module DateParser
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def parse_date(str)
|
|
14
|
+
return nil if str.nil? || str.empty?
|
|
15
|
+
|
|
16
|
+
clean = str.gsub(/\[.*?\]/, '').strip
|
|
17
|
+
|
|
18
|
+
case clean.length
|
|
19
|
+
when 8 then Time.strptime(clean, '%Y%m%d')
|
|
20
|
+
when 14 then Time.strptime(clean, '%Y%m%d%H%M%S')
|
|
21
|
+
else Time.parse(clean)
|
|
22
|
+
end
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
class Configuration
|
|
5
|
+
# Mixin included by {Base::Builder} to apply {Configuration} field mappings
|
|
6
|
+
# from an XML node onto a domain object. Reads XML text values, ensures
|
|
7
|
+
# custom attributes exist via {Base::Entity.ensure_attribute}, and assigns them.
|
|
8
|
+
module MappingApplicator
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def apply_mappings(object, node, section)
|
|
12
|
+
return unless node
|
|
13
|
+
|
|
14
|
+
OFX.config.xml_mappings_for(section).each do |xml_tag, ruby_attr|
|
|
15
|
+
value = text_at(node, xml_tag)
|
|
16
|
+
next if value.nil? || value.empty?
|
|
17
|
+
|
|
18
|
+
object.class.ensure_attribute(ruby_attr) if object.class.respond_to?(:ensure_attribute)
|
|
19
|
+
object.public_send(:"#{ruby_attr}=", value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def currency_for(node, section)
|
|
24
|
+
xml_tag = OFX.config.xml_mappings_for(section).key('currency')
|
|
25
|
+
value = xml_tag && text_at(node, xml_tag)
|
|
26
|
+
(value.nil? || value.empty?) ? OFX.config.default_currency : value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def text_at(node, css_tag)
|
|
30
|
+
node.at_css(css_tag)&.text&.strip
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
class Configuration
|
|
5
|
+
# Proxy object returned by section accessors (e.g. +OFX.config.transaction+).
|
|
6
|
+
# Provides a fluent interface for adding individual user-layer field mappings.
|
|
7
|
+
class SectionProxy
|
|
8
|
+
def initialize(user_fields, core_fields, xml_tag)
|
|
9
|
+
@user_fields = user_fields
|
|
10
|
+
@core_fields = core_fields
|
|
11
|
+
@xml_tag = xml_tag
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Maps an OFX XML key to a Ruby attribute name for this section.
|
|
15
|
+
# @param xml_key [String] the OFX XML element name
|
|
16
|
+
# @param to [String, Symbol] the Ruby attribute name to map to
|
|
17
|
+
# @raise [ConfigurationError] if xml_key is a core-protected field
|
|
18
|
+
def map(xml_key, to:)
|
|
19
|
+
core_attr = @core_fields.dig(@xml_tag.to_s, xml_key.to_s)
|
|
20
|
+
if core_attr
|
|
21
|
+
raise OFX::ConfigurationError,
|
|
22
|
+
"Cannot override core mapping '#{@xml_tag}.#{xml_key}' (reserved as '#{core_attr}')"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@user_fields[@xml_tag] ||= {}
|
|
26
|
+
@user_fields[@xml_tag][xml_key] = to
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
# Represents a complete credit card statement parsed from an OFX file,
|
|
5
|
+
# aggregating the account, its transactions, and the closing balance.
|
|
6
|
+
class CreditCardStatement < Base::Statement
|
|
7
|
+
def credit_card_statement? = true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
# Represents a single financial transaction parsed from an OFX statement.
|
|
5
|
+
class Transaction < Base::Entity
|
|
6
|
+
attr_accessor :fit_id, :type, :posted_at, :occurred_at,
|
|
7
|
+
:amount, :amount_cents,
|
|
8
|
+
:name, :memo, :payee,
|
|
9
|
+
:check_number, :ref_number, :sic
|
|
10
|
+
|
|
11
|
+
wired_by_builder :statement, :account
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
# An +Enumerable+ collection of {Transaction} objects parsed from an OFX statement.
|
|
5
|
+
# Provides convenience filters for credits and debits.
|
|
6
|
+
class TransactionCollection
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
# Placeholder overridden per-instance by {Base::Builder#wire} at build time.
|
|
10
|
+
def statement = nil
|
|
11
|
+
|
|
12
|
+
# @param transactions [Array<Transaction>] the list of transactions
|
|
13
|
+
def initialize(transactions)
|
|
14
|
+
@transactions = transactions
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Iterates over each transaction.
|
|
18
|
+
# @yieldparam transaction [Transaction]
|
|
19
|
+
def each(&)
|
|
20
|
+
@transactions.each(&)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer] number of transactions in the collection
|
|
24
|
+
def length
|
|
25
|
+
@transactions.length
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [TransactionCollection] a new collection containing only positive-amount transactions
|
|
29
|
+
def credits
|
|
30
|
+
sub = self.class.new(select { |t| t.amount.positive? })
|
|
31
|
+
# Propagate statement wiring so inferred_currency resolves to the correct
|
|
32
|
+
# account currency even when this sub-collection has no transactions.
|
|
33
|
+
if (stmt = statement)
|
|
34
|
+
sub.define_singleton_method(:statement) { stmt }
|
|
35
|
+
end
|
|
36
|
+
sub
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [TransactionCollection] a new collection containing only negative-amount transactions
|
|
40
|
+
def debits
|
|
41
|
+
sub = self.class.new(select { |t| t.amount.negative? })
|
|
42
|
+
# Same as credits — statement is the authoritative source of currency.
|
|
43
|
+
if (stmt = statement)
|
|
44
|
+
sub.define_singleton_method(:statement) { stmt }
|
|
45
|
+
end
|
|
46
|
+
sub
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Money] sum of all positive transaction amounts
|
|
50
|
+
def total_credits
|
|
51
|
+
sum_amounts(credits)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Money] sum of all negative transaction amounts
|
|
55
|
+
def total_debits
|
|
56
|
+
sum_amounts(debits)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Money] net amount (credits + debits)
|
|
60
|
+
# @note This is NOT the account balance. It reflects only the transactions present in
|
|
61
|
+
# the OFX file and ignores any accumulated prior balance (e.g. opening cash balance
|
|
62
|
+
# or unpaid invoice from a previous period). Use {Balance} for the actual account balance.
|
|
63
|
+
def net
|
|
64
|
+
total_credits + total_debits
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<Transaction>] a duplicate of the internal transactions array
|
|
68
|
+
def to_a
|
|
69
|
+
@transactions.dup
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param other [TransactionCollection] collection to compare against
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def ==(other)
|
|
75
|
+
@transactions == other.to_a
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def currency
|
|
81
|
+
statement&.account&.currency
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inferred_currency
|
|
85
|
+
currency || OFX.config.default_currency
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sum_amounts(collection)
|
|
89
|
+
return Money.new(0, inferred_currency) if collection.none?
|
|
90
|
+
|
|
91
|
+
collection.map(&:amount).inject(:+)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
# Base error class for all OFX-related exceptions.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the OFX file cannot be parsed.
|
|
8
|
+
class ParseError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the OFX file header is malformed or missing.
|
|
11
|
+
class InvalidHeaderError < ParseError; end
|
|
12
|
+
|
|
13
|
+
# Raised when the OFX file body cannot be parsed into a valid document.
|
|
14
|
+
class InvalidBodyError < ParseError; end
|
|
15
|
+
|
|
16
|
+
# Raised when the OFX version declared in the file is not supported.
|
|
17
|
+
class UnsupportedVersionError < Error
|
|
18
|
+
# @return [String] the unsupported version string found in the file
|
|
19
|
+
attr_reader :version
|
|
20
|
+
|
|
21
|
+
# @param version [String] the unsupported OFX version string
|
|
22
|
+
def initialize(version)
|
|
23
|
+
@version = version
|
|
24
|
+
super("Unsupported OFX version: #{version}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when a character encoding error occurs while reading the OFX file.
|
|
29
|
+
class EncodingError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when the library configuration is invalid or inconsistent.
|
|
32
|
+
class ConfigurationError < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised when an operation requires a single statement but multiple were found.
|
|
35
|
+
class MultipleStatementsError < Error; end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
SECTIONS:
|
|
2
|
+
STMTRS: "bank_statement"
|
|
3
|
+
CCSTMTRS: "credit_card_statement"
|
|
4
|
+
STMTTRN: "transaction"
|
|
5
|
+
BANKACCTFROM: "bank_account"
|
|
6
|
+
CCACCTFROM: "credit_card_account"
|
|
7
|
+
LEDGERBAL: "balance"
|
|
8
|
+
|
|
9
|
+
FIELDS:
|
|
10
|
+
STMTRS:
|
|
11
|
+
CURDEF: "currency"
|
|
12
|
+
|
|
13
|
+
CCSTMTRS:
|
|
14
|
+
CURDEF: "currency"
|
|
15
|
+
|
|
16
|
+
STMTTRN:
|
|
17
|
+
TRNAMT: "amount"
|
|
18
|
+
DTPOSTED: "posted_at"
|
|
19
|
+
DTUSER: "occurred_at"
|
|
20
|
+
|
|
21
|
+
LEDGERBAL:
|
|
22
|
+
BALAMT: "amount"
|
|
23
|
+
DTASOF: "posted_at"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
FIELDS:
|
|
2
|
+
STMTTRN:
|
|
3
|
+
FITID: "fit_id"
|
|
4
|
+
TRNTYPE: "type"
|
|
5
|
+
NAME: "name"
|
|
6
|
+
MEMO: "memo"
|
|
7
|
+
PAYEE: "payee"
|
|
8
|
+
CHECKNUM: "check_number"
|
|
9
|
+
REFNUM: "ref_number"
|
|
10
|
+
SIC: "sic"
|
|
11
|
+
|
|
12
|
+
BANKACCTFROM:
|
|
13
|
+
BANKID: "bank_id"
|
|
14
|
+
ACCTID: "account_id"
|
|
15
|
+
ACCTTYPE: "account_type"
|
|
16
|
+
BRANCHID: "branch_id"
|
|
17
|
+
|
|
18
|
+
CCACCTFROM:
|
|
19
|
+
ACCTID: "account_id"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
# Implementation class for OFX parsing.
|
|
5
|
+
# Prefer the top-level {OFX.new} entry point over instantiating this class directly.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class Parser
|
|
9
|
+
# @param resource [String, IO] file path or IO object containing OFX data
|
|
10
|
+
# @param block [Proc] optional block; receives the parser instance (arity 1)
|
|
11
|
+
# or is evaluated in the parser's context (arity != 1)
|
|
12
|
+
def initialize(resource, &block)
|
|
13
|
+
@filename = extract_filename(resource)
|
|
14
|
+
content = read_resource(resource)
|
|
15
|
+
tokenizer = build_tokenizer(content).new(content)
|
|
16
|
+
@document = Base::Document.new(headers: tokenizer.headers, body: tokenizer.body)
|
|
17
|
+
@statements = Base::Builder.new(@document).statements
|
|
18
|
+
|
|
19
|
+
return unless block_given?
|
|
20
|
+
|
|
21
|
+
block.arity == 1 ? block.call(self) : instance_eval(&block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<BankStatement, CreditCardStatement>] all statements in the file
|
|
25
|
+
# @return [String, nil] original file path, if a path string was provided
|
|
26
|
+
attr_reader :statements, :filename
|
|
27
|
+
|
|
28
|
+
# @return [Hash] parsed OFX header fields
|
|
29
|
+
def headers
|
|
30
|
+
@document.headers
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the account for files containing a single statement.
|
|
34
|
+
# @return [Account, nil]
|
|
35
|
+
# @raise [MultipleStatementsError] if the file contains more than one statement
|
|
36
|
+
def account
|
|
37
|
+
if statements.length > 1
|
|
38
|
+
raise MultipleStatementsError, 'File contains multiple statements. Use `accounts` to get all accounts.'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
statements.first&.account
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Account>] all accounts across all statements
|
|
45
|
+
def accounts
|
|
46
|
+
statements.map(&:account)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns all transactions aggregated across all statements.
|
|
50
|
+
# Emits a warning when the file contains multiple statements.
|
|
51
|
+
# @return [TransactionCollection]
|
|
52
|
+
def transactions
|
|
53
|
+
if statements.length > 1 && OFX.config.multi_statement_warnings?
|
|
54
|
+
warn "[OFX] `transactions` is aggregating across #{statements.length} statements. " \
|
|
55
|
+
'For per-account transactions use `statements[i].transactions`. ' \
|
|
56
|
+
'To disable this warning: OFX.config.multi_statement_warnings = false'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
TransactionCollection.new(statements.flat_map { |s| s.transactions.to_a })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the balance for files containing a single statement.
|
|
63
|
+
# @return [Balance, nil]
|
|
64
|
+
# @raise [MultipleStatementsError] if the file contains more than one statement
|
|
65
|
+
def balance
|
|
66
|
+
if statements.length > 1
|
|
67
|
+
raise MultipleStatementsError, 'File contains multiple statements. Use `balances` to get all balances.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
statements.first&.balance
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<Balance, nil>] balances for each statement
|
|
74
|
+
def balances
|
|
75
|
+
if statements.length > 1 && OFX.config.multi_statement_warnings?
|
|
76
|
+
warn "[OFX] `balances` is aggregating across #{statements.length} statements. " \
|
|
77
|
+
'For per-account balance use `statements[i].balance`. ' \
|
|
78
|
+
'To disable this warning: OFX.config.multi_statement_warnings = false'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
statements.map(&:balance)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns a structured summary of all statements, including transaction counts,
|
|
85
|
+
# credit/debit totals (in cents), and closing balance.
|
|
86
|
+
# @return [Hash]
|
|
87
|
+
def summary
|
|
88
|
+
{
|
|
89
|
+
headers: headers.compact,
|
|
90
|
+
statements: statements.each_with_object({}) do |stmt, h|
|
|
91
|
+
acct = stmt.account
|
|
92
|
+
txns = stmt.transactions
|
|
93
|
+
h[acct.account_id] = {
|
|
94
|
+
currency: acct.currency,
|
|
95
|
+
transactions: { count: txns.length, net_cents: txns.net.fractional },
|
|
96
|
+
credits: { count: txns.credits.length, total_cents: txns.total_credits.fractional },
|
|
97
|
+
debits: { count: txns.debits.length, total_cents: txns.total_debits.fractional },
|
|
98
|
+
balance_cents: stmt.balance&.amount&.fractional
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def extract_filename(resource)
|
|
107
|
+
case resource
|
|
108
|
+
when String then resource
|
|
109
|
+
else resource.respond_to?(:path) ? resource.path : nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_resource(resource)
|
|
114
|
+
raw = case resource
|
|
115
|
+
when String then File.read(resource)
|
|
116
|
+
when IO, StringIO then resource.read
|
|
117
|
+
else raise ArgumentError, "Expected a file path (String) or IO object, got #{resource.class}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
raw.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_tokenizer(content)
|
|
124
|
+
if content.lstrip.start_with?('<?')
|
|
125
|
+
Tokenizer::OFX2
|
|
126
|
+
else
|
|
127
|
+
Tokenizer::OFX1
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OFX
|
|
4
|
+
module Tokenizer
|
|
5
|
+
# Abstract base class for OFX tokenizers.
|
|
6
|
+
# Subclasses must implement {#parse!} to populate +@headers+ and +@body+
|
|
7
|
+
# from the raw file content.
|
|
8
|
+
class Base
|
|
9
|
+
# @return [Hash] parsed header key/value pairs
|
|
10
|
+
# @return [Nokogiri::XML::Document] parsed XML body
|
|
11
|
+
attr_reader :headers, :body
|
|
12
|
+
|
|
13
|
+
# @param content [String] raw OFX file content
|
|
14
|
+
def initialize(content)
|
|
15
|
+
@content = content.dup.force_encoding('UTF-8')
|
|
16
|
+
parse!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse!
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #parse!"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def convert_to_utf8(str)
|
|
26
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
27
|
+
|
|
28
|
+
str.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
29
|
+
rescue Encoding::UndefinedConversionError
|
|
30
|
+
str.dup.force_encoding('ISO-8859-1').encode('UTF-8')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module OFX
|
|
6
|
+
module Tokenizer
|
|
7
|
+
# Tokenizer for OFX version 1 files, which use an SGML-like format.
|
|
8
|
+
# Splits the file into a colon-separated header section and an SGML body,
|
|
9
|
+
# then normalizes the body into valid XML before parsing with Nokogiri.
|
|
10
|
+
class OFX1 < Base
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def parse!
|
|
14
|
+
content = convert_to_utf8(@content)
|
|
15
|
+
parts = content.split(/<OFX>/i, 2)
|
|
16
|
+
|
|
17
|
+
raise InvalidHeaderError, 'Missing <OFX> tag in OFX file' if parts.size < 2
|
|
18
|
+
|
|
19
|
+
@headers = parse_headers(parts[0])
|
|
20
|
+
@body = parse_body("<OFX>#{parts[1]}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse_headers(raw)
|
|
24
|
+
raw.lines.each_with_object({}) do |line, result|
|
|
25
|
+
line = line.strip
|
|
26
|
+
next if line.empty?
|
|
27
|
+
|
|
28
|
+
key, value = line.split(':', 2)
|
|
29
|
+
next unless key && value
|
|
30
|
+
|
|
31
|
+
result[key.strip] = value.strip == 'NONE' ? nil : value.strip
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_body(raw)
|
|
36
|
+
normalized = normalize_sgml(raw)
|
|
37
|
+
doc = Nokogiri::XML(normalized, &:nonet)
|
|
38
|
+
raise InvalidBodyError, "OFX body could not be parsed: #{doc.errors.first}" if doc.errors.any?
|
|
39
|
+
|
|
40
|
+
doc
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_sgml(body)
|
|
44
|
+
body.gsub(%r{(<([A-Z0-9_]+)>)\s*([^<\r\n][^<\r\n]*)(</\2>)?}) do
|
|
45
|
+
tag_open = ::Regexp.last_match(1)
|
|
46
|
+
tag_name = ::Regexp.last_match(2)
|
|
47
|
+
content = ::Regexp.last_match(3).strip
|
|
48
|
+
"#{tag_open}#{content}</#{tag_name}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|