extras_de_cont 1.4.0 → 1.5.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 +4 -4
- data/README.md +14 -0
- data/extras_de_cont.gemspec +5 -3
- data/lib/extras_de_cont/rules/revolut_csv.rb +84 -0
- data/lib/extras_de_cont/transaction.rb +17 -2
- data/lib/extras_de_cont.rb +48 -10
- metadata +32 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4d3354f9da96a1d04fc3a8bd388be67338417b12dedeea21073390725606bd9
|
|
4
|
+
data.tar.gz: 26201c9fad4bbad47fa7cf241c9bc9418621e65c01ff14031fb938fb783dcf85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 738d0e661f0d5ad9bf74efad90e1101626602245db40790b2216c0cf6ea41bf54a0569581f723538261cbc19a084eddccd74830475770f184a884be406a4fe7c
|
|
7
|
+
data.tar.gz: 96abcb5cc28ae8ac7ca676ff95f0d9903735cb5ed04876a3d5f055c804aca435e38868f89280592976c15527d3872c8256e1513df035b3885f64d8614c4e9108
|
data/README.md
CHANGED
|
@@ -24,6 +24,19 @@ transactions.each do |t|
|
|
|
24
24
|
end
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Revolut CSV exports are supported with `:revolut_csv`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
transactions = ExtrasDeCont.parse(csv_file, bank: :revolut_csv)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Completed and reverted rows are returned; pending rows are ignored. Reverted amounts are inverted so they offset the original transaction. To parse overlapping statements safely, pass the keys of transactions already imported:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
already_imported = imported_transactions.map(&:deduplication_key)
|
|
37
|
+
transactions = ExtrasDeCont.parse(csv_file, bank: :revolut_csv, exclude: already_imported)
|
|
38
|
+
```
|
|
39
|
+
|
|
27
40
|
Or use the included entrypoint:
|
|
28
41
|
|
|
29
42
|
```bash
|
|
@@ -41,6 +54,7 @@ ruby -Ilib:test test/revolut_rule_test.rb
|
|
|
41
54
|
| Bank | Symbol | Currencies | Features |
|
|
42
55
|
|-----------|--------------|---|--------------------------------------------------------|
|
|
43
56
|
| Revolut | `:revolut` | RON, EUR, USD, GBP, PLN, CZK, HUF, BGN, TRY, UAH | Personal & Business, multi-section, symbol currencies |
|
|
57
|
+
| Revolut CSV | `:revolut_csv` | RON, EUR, USD, GBP, PLN, CZK, HUF, BGN, TRY, UAH | Completed/reverted rows, fees, duplicate exclusion |
|
|
44
58
|
| UniCredit | `:unicredit` | RON, EUR | Romanian month names, page breaks, transaction markers |
|
|
45
59
|
| BRD | `:brd` | RON, EUR | Below-line amounts, Romanian number format |
|
|
46
60
|
| ING | `:ing` | RON | Normal bank statements in RON |
|
data/extras_de_cont.gemspec
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |s|
|
|
4
4
|
s.name = "extras_de_cont"
|
|
5
|
-
s.version = "1.
|
|
5
|
+
s.version = "1.5.0"
|
|
6
6
|
s.licenses = ["GPLv3"]
|
|
7
|
-
s.summary = "A
|
|
7
|
+
s.summary = "A library for extracting transactions from bank statements."
|
|
8
8
|
s.description = <<~TEXT
|
|
9
|
-
A
|
|
9
|
+
A library for extracting transactions from PDF bank statements and Revolut CSV exports.
|
|
10
10
|
Fine tuned for Romanian bank statements.
|
|
11
11
|
|
|
12
12
|
Repository: https://github.com/dnutiu/extras-de-cont
|
|
@@ -25,5 +25,7 @@ Gem::Specification.new do |s|
|
|
|
25
25
|
s.require_paths = ["lib"]
|
|
26
26
|
|
|
27
27
|
s.add_dependency "pdf-reader", "~> 2.15"
|
|
28
|
+
s.add_dependency "csv", "~> 3.3"
|
|
29
|
+
s.add_dependency "bigdecimal", "~> 3.1"
|
|
28
30
|
s.add_dependency "zeitwerk", "~> 2.8"
|
|
29
31
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "csv"
|
|
5
|
+
require "date"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module ExtrasDeCont
|
|
9
|
+
module Rules
|
|
10
|
+
# Parses Revolut's account-statement CSV export.
|
|
11
|
+
class RevolutCsv < Rules::Base
|
|
12
|
+
REQUIRED_HEADERS = ["Started Date", "Description", "Amount", "Fee", "Currency", "State"].freeze
|
|
13
|
+
INCLUDED_STATES = %w[COMPLETED REVERTED].freeze
|
|
14
|
+
|
|
15
|
+
def parse(source)
|
|
16
|
+
csv_data = source.respond_to?(:read) ? source.read : source.to_s
|
|
17
|
+
csv_data = csv_data.dup.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
18
|
+
rows = CSV.parse(csv_data, headers: true, skip_blanks: true)
|
|
19
|
+
validate_headers!(rows.headers)
|
|
20
|
+
|
|
21
|
+
rows.filter_map do |row|
|
|
22
|
+
state = row["State"].to_s.strip.upcase
|
|
23
|
+
next unless INCLUDED_STATES.include?(state)
|
|
24
|
+
|
|
25
|
+
parse_row(row, state)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_headers!(headers)
|
|
32
|
+
missing_headers = REQUIRED_HEADERS - Array(headers)
|
|
33
|
+
return if missing_headers.empty?
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "Invalid Revolut CSV: missing #{missing_headers.join(", ")} column(s)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_row(row, state)
|
|
39
|
+
date = DateTime.strptime(row.fetch("Started Date").to_s.strip, "%Y-%m-%d %H:%M:%S").to_date
|
|
40
|
+
description = row.fetch("Description").to_s.strip
|
|
41
|
+
currency = row.fetch("Currency").to_s.strip.upcase
|
|
42
|
+
amount = decimal(row.fetch("Amount")) - decimal(row["Fee"], default: "0")
|
|
43
|
+
amount = -amount if state == "REVERTED"
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "Invalid Revolut CSV: included row has no description" if description.empty?
|
|
46
|
+
return if amount.zero?
|
|
47
|
+
|
|
48
|
+
Transaction.new(
|
|
49
|
+
date,
|
|
50
|
+
description,
|
|
51
|
+
amount,
|
|
52
|
+
currency,
|
|
53
|
+
state: state,
|
|
54
|
+
deduplication_key: deduplication_key(row, state)
|
|
55
|
+
)
|
|
56
|
+
rescue ArgumentError => e
|
|
57
|
+
raise e if e.message.start_with?("Invalid Revolut CSV")
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "Invalid Revolut CSV row: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def decimal(value, default: nil)
|
|
63
|
+
value = default if value.to_s.strip.empty? && !default.nil?
|
|
64
|
+
BigDecimal(value.to_s.strip)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deduplication_key(row, state)
|
|
68
|
+
Digest::SHA256.hexdigest(
|
|
69
|
+
[
|
|
70
|
+
row["Type"],
|
|
71
|
+
row["Product"],
|
|
72
|
+
row["Started Date"],
|
|
73
|
+
row["Completed Date"],
|
|
74
|
+
row["Description"],
|
|
75
|
+
row["Amount"],
|
|
76
|
+
row["Fee"],
|
|
77
|
+
row["Currency"],
|
|
78
|
+
state
|
|
79
|
+
].map { |value| value.to_s.strip }.join("\u001f")
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module ExtrasDeCont
|
|
4
6
|
# Models a simple bank transaction
|
|
5
7
|
class Transaction
|
|
6
|
-
attr_reader :date, :description, :amount, :currency
|
|
8
|
+
attr_reader :date, :description, :amount, :currency, :state, :deduplication_key
|
|
7
9
|
|
|
8
10
|
def initialize(
|
|
9
11
|
date,
|
|
10
12
|
description,
|
|
11
13
|
amount,
|
|
12
|
-
currency
|
|
14
|
+
currency,
|
|
15
|
+
**options
|
|
13
16
|
)
|
|
14
17
|
@date = date
|
|
15
18
|
@description = description
|
|
16
19
|
@amount = amount
|
|
17
20
|
@currency = currency
|
|
21
|
+
@state = options[:state]
|
|
22
|
+
@deduplication_key = options[:deduplication_key] || build_deduplication_key
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reverted?
|
|
26
|
+
state.to_s.casecmp?("REVERTED")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_deduplication_key
|
|
32
|
+
Digest::SHA256.hexdigest([date, description, amount, currency, state].join("\u001f"))
|
|
18
33
|
end
|
|
19
34
|
end
|
|
20
35
|
end
|
data/lib/extras_de_cont.rb
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "zeitwerk"
|
|
3
4
|
|
|
4
|
-
require 'zeitwerk'
|
|
5
5
|
loader = Zeitwerk::Loader.for_gem
|
|
6
6
|
loader.inflector.inflect(
|
|
7
7
|
"unicredit" => "UniCredit"
|
|
8
8
|
)
|
|
9
|
-
loader.setup
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
loader.setup
|
|
12
10
|
|
|
13
11
|
# The ExtrasDeCont module contains utilities for parsing bank statements.
|
|
14
12
|
module ExtrasDeCont
|
|
@@ -17,24 +15,64 @@ module ExtrasDeCont
|
|
|
17
15
|
brd: ExtrasDeCont::Rules::Brd,
|
|
18
16
|
ing: ExtrasDeCont::Rules::Ing,
|
|
19
17
|
revolut: ExtrasDeCont::Rules::Revolut,
|
|
18
|
+
revolut_csv: ExtrasDeCont::Rules::RevolutCsv,
|
|
20
19
|
unicredit: ExtrasDeCont::Rules::UniCredit
|
|
21
20
|
}.freeze
|
|
22
21
|
|
|
22
|
+
RAW_TEXT_BANKS = %i[revolut_csv].freeze
|
|
23
|
+
|
|
23
24
|
class << self
|
|
24
|
-
# Parses a
|
|
25
|
+
# Parses a bank statement and returns structured transactions.
|
|
25
26
|
#
|
|
26
27
|
# @param file [String, Pathname, IO] path to the PDF file or an IO-like object
|
|
27
|
-
# @param bank [Symbol] the bank identifier (:unicredit, :revolut, etc.)
|
|
28
|
+
# @param bank [Symbol] the bank identifier (:unicredit, :revolut, :revolut_csv, etc.)
|
|
29
|
+
# @param exclude [Array<String, Transaction>] deduplication keys or transactions already imported
|
|
28
30
|
# @return [Array<ExtrasDeCont::Transaction>]
|
|
29
31
|
# @raise [ArgumentError] if the bank is not supported
|
|
30
|
-
def parse(file, bank:)
|
|
32
|
+
def parse(file, bank:, exclude: [])
|
|
33
|
+
bank = bank.to_sym
|
|
31
34
|
rule_class = BANK_RULES[bank]
|
|
32
35
|
raise ArgumentError, "Unsupported bank: #{bank}. Supported banks: #{BANK_RULES.keys.join(", ")}" unless rule_class
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
transactions =
|
|
38
|
+
if RAW_TEXT_BANKS.include?(bank)
|
|
39
|
+
rule_class.new.parse(read_raw_file(file))
|
|
40
|
+
else
|
|
41
|
+
ExtrasDeCont::Parser.new(file).parse_with(rule_class.new)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
deduplicate(transactions, exclude, within_file: RAW_TEXT_BANKS.include?(bank))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read_raw_file(file)
|
|
50
|
+
return file.read if file.respond_to?(:read)
|
|
51
|
+
return file if file.is_a?(String) && !file_path?(file)
|
|
52
|
+
|
|
53
|
+
File.binread(file.respond_to?(:to_path) ? file.to_path : file)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def file_path?(file)
|
|
57
|
+
File.file?(file)
|
|
58
|
+
rescue Errno::ENAMETOOLONG
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def deduplicate(transactions, exclude, within_file:)
|
|
63
|
+
seen = Array(exclude).to_h do |item|
|
|
64
|
+
[item.respond_to?(:deduplication_key) ? item.deduplication_key : item.to_s, true]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
transactions.each_with_object([]) do |transaction, unique_transactions|
|
|
68
|
+
key = transaction.deduplication_key
|
|
69
|
+
next if seen.key?(key)
|
|
70
|
+
|
|
71
|
+
unique_transactions << transaction
|
|
72
|
+
seen[key] = true if within_file
|
|
73
|
+
end
|
|
36
74
|
end
|
|
37
75
|
end
|
|
38
76
|
end
|
|
39
77
|
|
|
40
|
-
loader.eager_load
|
|
78
|
+
loader.eager_load
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: extras_de_cont
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denis Nutiu
|
|
@@ -23,6 +23,34 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.15'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: csv
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.3'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: bigdecimal
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
26
54
|
- !ruby/object:Gem::Dependency
|
|
27
55
|
name: zeitwerk
|
|
28
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -38,7 +66,7 @@ dependencies:
|
|
|
38
66
|
- !ruby/object:Gem::Version
|
|
39
67
|
version: '2.8'
|
|
40
68
|
description: |
|
|
41
|
-
A
|
|
69
|
+
A library for extracting transactions from PDF bank statements and Revolut CSV exports.
|
|
42
70
|
Fine tuned for Romanian bank statements.
|
|
43
71
|
|
|
44
72
|
Repository: https://github.com/dnutiu/extras-de-cont
|
|
@@ -56,6 +84,7 @@ files:
|
|
|
56
84
|
- lib/extras_de_cont/rules/brd.rb
|
|
57
85
|
- lib/extras_de_cont/rules/ing.rb
|
|
58
86
|
- lib/extras_de_cont/rules/revolut.rb
|
|
87
|
+
- lib/extras_de_cont/rules/revolut_csv.rb
|
|
59
88
|
- lib/extras_de_cont/rules/unicredit.rb
|
|
60
89
|
- lib/extras_de_cont/transaction.rb
|
|
61
90
|
homepage: https://nuculabs.dev
|
|
@@ -80,5 +109,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
80
109
|
requirements: []
|
|
81
110
|
rubygems_version: 4.0.10
|
|
82
111
|
specification_version: 4
|
|
83
|
-
summary: A
|
|
112
|
+
summary: A library for extracting transactions from bank statements.
|
|
84
113
|
test_files: []
|