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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9393dd1d6d9cec4a77e34393af8a0ccb3459c0486835f7df0eed74410cff435
4
- data.tar.gz: f5008d24c8f24b1f2b7a0d0bda8a675a2c886fba09e86b9d91bb888c7a1d7b92
3
+ metadata.gz: a4d3354f9da96a1d04fc3a8bd388be67338417b12dedeea21073390725606bd9
4
+ data.tar.gz: 26201c9fad4bbad47fa7cf241c9bc9418621e65c01ff14031fb938fb783dcf85
5
5
  SHA512:
6
- metadata.gz: 07e61564409929f0c316b834ac18ad00d54a4a0a9f6b1f221c012e3064f5ad046de1324781545455f6417fb4198450b49773613e1d59b936a8410e25286238e3
7
- data.tar.gz: 64cce605262cdcb5a31aa164b76e6f108f8b24149f136c92d237b888dcd894813cda0fce0bf8e801862e162ecfbd88e095cc479c61661f2bab9054bb822b08a0
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 |
@@ -2,11 +2,11 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "extras_de_cont"
5
- s.version = "1.4.0"
5
+ s.version = "1.5.0"
6
6
  s.licenses = ["GPLv3"]
7
- s.summary = "A simple library which helps you extract transactions from a PDF bank statement."
7
+ s.summary = "A library for extracting transactions from bank statements."
8
8
  s.description = <<~TEXT
9
- A simple library which helps you extract transactions from a PDF bank statement.
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
@@ -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 # ready!
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 PDF bank statement and returns structured transactions.
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
- p = ExtrasDeCont::Parser.new(file)
35
- p.parse_with(rule_class.new)
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 # optionally
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.0
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 simple library which helps you extract transactions from a PDF bank statement.
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 simple library which helps you extract transactions from a PDF bank statement.
112
+ summary: A library for extracting transactions from bank statements.
84
113
  test_files: []