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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'configuration/core'
4
+ require_relative 'configuration/section_proxy'
5
+ require_relative 'configuration/date_parser'
6
+ require_relative 'configuration/mapping_applicator'
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ # Represents the ledger balance of an account at a specific point in time.
5
+ class Balance < Base::Entity
6
+ attr_accessor :amount, :amount_cents, :posted_at
7
+
8
+ wired_by_builder :statement, :account
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ # Represents a bank (checking or savings) account parsed from an OFX statement.
5
+ class BankAccount < Base::Account
6
+ attr_accessor :bank_id, :account_id, :account_type, :branch_id
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ # Represents a complete bank statement parsed from an OFX file,
5
+ # aggregating the account, its transactions, and the closing balance.
6
+ class BankStatement < Base::Statement
7
+ def bank_statement? = true
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OFX
4
+ # Represents a credit card account parsed from an OFX statement.
5
+ class CreditCardAccount < Base::Account
6
+ attr_accessor :account_id
7
+ end
8
+ 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