cfonb 0.0.0 → 0.0.2

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: 9ecb2f0157c3dff40868b71296f77af49bd5b2cb6b0e5a1ecbd9abed606392d3
4
- data.tar.gz: 2187588d8e68fd14626f125530f2c4e0f3ad78356b1ec1a82bf2273b3e93fd50
3
+ metadata.gz: 1666b9858511859d77ab8b3c14a11ee214548b5da5154ee927cf7e51b14eca95
4
+ data.tar.gz: 292ed95e4250414dc82ef2967df62663cdadf529d72d8229d5609eee0fb3f0b1
5
5
  SHA512:
6
- metadata.gz: 81590edc8a9b56dbdacd3470896cc7649131e7b57aa9167eee59001326463efa47422de544ce140c55c50acb0b9311ad34f869f1e88e48d8b173cfe35313da52
7
- data.tar.gz: e41c09652881278e96ec2f43d67494d34b175f21cc651d71dfc4cee2bef47f4c8d67cb01356e52e8be60b6530b1fd3dab6eb4d37254b366ddfe5b370535afa46
6
+ metadata.gz: 9c74e319b7448e79163010bea1efeb5a5c5391bd0352b3c38d3145fd41f27ae60b0fe562d25b65698d207418288fb61dc4d49231be968f7dead6905bbc854132
7
+ data.tar.gz: a568a83590cfd3666d9ac96ba4dd6c09a39e73aa3a8136904cc24fe25df7d6b5b2f6d3fac74aee01e4ad57bf4455f9d4cf9e55ed993ca8f07f4b440eb103dc8d
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ class Error < StandardError; end
5
+
6
+ class ParserError < Error; end
7
+
8
+ class InvalidCodeError < ParserError; end
9
+
10
+ class UnstartedStatementError < ParserError; end
11
+
12
+ class UnstartedOperationError < ParserError; end
13
+
14
+ class UnfinishedStatementError < ParserError; end
15
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module CFONB
6
+ module LineParser
7
+ class Base
8
+ using CFONB::Refinements::Strings
9
+
10
+ BASE_DICTIONARY = [
11
+ ['code', (0..1)].freeze,
12
+ ['bank', (2..6)],
13
+ ['branch', (11..15)],
14
+ ['currency', (16..18)],
15
+ ['scale', 19, proc { _1.to_i }],
16
+ ['account', (21..31)],
17
+ ['date', (34..39), proc { |value, instance| instance.send(:parse_date, value) }]
18
+ ].freeze
19
+
20
+ AMOUNT_SPECIFIERS = {
21
+ 'A' => { sign: 1, value: '1' },
22
+ 'B' => { sign: 1, value: '2' },
23
+ 'C' => { sign: 1, value: '3' },
24
+ 'D' => { sign: 1, value: '4' },
25
+ 'E' => { sign: 1, value: '5' },
26
+ 'F' => { sign: 1, value: '6' },
27
+ 'G' => { sign: 1, value: '7' },
28
+ 'H' => { sign: 1, value: '8' },
29
+ 'I' => { sign: 1, value: '9' },
30
+ '{' => { sign: 1, value: '0' },
31
+ 'J' => { sign: -1, value: '1' },
32
+ 'K' => { sign: -1, value: '2' },
33
+ 'L' => { sign: -1, value: '3' },
34
+ 'M' => { sign: -1, value: '4' },
35
+ 'N' => { sign: -1, value: '5' },
36
+ 'O' => { sign: -1, value: '6' },
37
+ 'P' => { sign: -1, value: '7' },
38
+ 'Q' => { sign: -1, value: '8' },
39
+ 'R' => { sign: -1, value: '9' },
40
+ '}' => { sign: -1, value: '0' }
41
+ }.transform_values(&:freeze).freeze
42
+
43
+ attr_reader :body, *BASE_DICTIONARY.map(&:first)
44
+
45
+ def initialize(input)
46
+ @body = input
47
+ (BASE_DICTIONARY + self.class::DICTIONARY).each { parse_attribute(*_1) }
48
+ end
49
+
50
+ private
51
+
52
+ def parse_attribute(attr, position, method = nil)
53
+ input = body[position]&.strip
54
+ value = method ? method.call(input, self) : input
55
+
56
+ instance_variable_set(:"@#{attr}", value)
57
+ end
58
+
59
+ def parse_amount(input)
60
+ specifier = AMOUNT_SPECIFIERS[input.last]
61
+ raise ParserError, "Invalid specifier '#{input.last}' for line #{input}" unless specifier
62
+
63
+ specifier[:sign] * BigDecimal(input[0..-2] + specifier[:value]) / (10**scale)
64
+ end
65
+
66
+ def parse_date(input)
67
+ # This won't work after 2060
68
+ return if input.strip.empty?
69
+
70
+ input_with_year = if input.last(2).to_i > 60
71
+ "#{input[0..3]}19#{input[4..5]}"
72
+ else
73
+ "#{input[0..3]}20#{input[4..5]}"
74
+ end
75
+
76
+ Date.strptime(input_with_year, '%d%m%Y')
77
+ rescue Date::Error
78
+ raise ParserError, "Invalid date '#{input}' for line #{input}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module LineParser
5
+ class NewBalance < Base
6
+ DICTIONARY = [
7
+ ['amount', (90..103), proc { |value, instance| instance.send(:parse_amount, value) }]
8
+ ].freeze
9
+
10
+ attr_reader(*DICTIONARY.map(&:first))
11
+
12
+ CFONB::LineParser.register(CFONB::Parser::NEW_BALANCE_CODE, self)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module LineParser
5
+ class Operation < Base
6
+ DICTIONARY = [
7
+ ['internal_operation_code', (7..10)],
8
+ ['interbank_operation_code', (32..33)],
9
+ ['rejection_code', (40..41)],
10
+ ['value_date', (42..47), proc { |value, instance| instance.send(:parse_date, value) }],
11
+ ['label', (48..79)],
12
+ ['number', (81..87), proc { _1.to_i }],
13
+ ['exoneration_code', 88],
14
+ ['unavailability_code', 89],
15
+ ['reference', (104..119)],
16
+ ['amount', (90..103), proc { |value, instance| instance.send(:parse_amount, value) }]
17
+ ].freeze
18
+
19
+ attr_reader(*DICTIONARY.map(&:first))
20
+
21
+ CFONB::LineParser.register(CFONB::Parser::OPERATION_CODE, self)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module LineParser
5
+ class OperationDetail < Base
6
+ DICTIONARY = [
7
+ ['internal_operation_code', (7..10)],
8
+ ['interbank_operation_code', (32..33)],
9
+ ['detail_code', (45..47)],
10
+ ['detail', (48..117)]
11
+ ].freeze
12
+
13
+ attr_reader(*DICTIONARY.map(&:first))
14
+
15
+ CFONB::LineParser.register(CFONB::Parser::OPERATION_DETAIL_CODE, self)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module LineParser
5
+ class PreviousBalance < Base
6
+ DICTIONARY = [
7
+ ['amount', (90..103), proc { |value, instance| instance.send(:parse_amount, value) }]
8
+ ].freeze
9
+
10
+ attr_reader(*DICTIONARY.map(&:first))
11
+
12
+ CFONB::LineParser.register(CFONB::Parser::PREVIOUS_BALANCE_CODE, self)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module LineParser
5
+ using CFONB::Refinements::Strings
6
+
7
+ @parsers = {}
8
+
9
+ def self.register(code, klass)
10
+ @parsers[code] = klass
11
+ end
12
+
13
+ def self.for(code)
14
+ @parsers[code]
15
+ end
16
+
17
+ def self.parse(input)
18
+ self.for(input.first(2)).new(input)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ class Operation
5
+ BASE_ATTRIBUTES = %i[
6
+ raw
7
+ amount
8
+ currency
9
+ date
10
+ exoneration_code
11
+ interbank_code
12
+ internal_code
13
+ label
14
+ number
15
+ reference
16
+ rejection_code
17
+ unavailability_code
18
+ value_date
19
+ ].freeze
20
+
21
+ attr_accessor(*BASE_ATTRIBUTES)
22
+
23
+ def initialize(line)
24
+ self.raw = line.body
25
+ self.internal_code = line.internal_operation_code
26
+ self.interbank_code = line.interbank_operation_code
27
+ self.rejection_code = line.rejection_code
28
+ self.exoneration_code = line.exoneration_code
29
+ self.unavailability_code = line.unavailability_code
30
+ self.currency = line.currency
31
+ self.amount = line.amount
32
+ self.date = line.date
33
+ self.value_date = line.value_date
34
+ self.label = line.label.strip
35
+ self.number = line.number
36
+ self.reference = line.reference
37
+ end
38
+
39
+ def merge_detail(line)
40
+ self.raw += "\n#{line.body}"
41
+ OperationDetail.for(line)&.apply(self, line)
42
+ end
43
+
44
+ def type_code
45
+ "#{interbank_code}#{direction}"
46
+ end
47
+
48
+ private
49
+
50
+ def direction
51
+ amount.positive? ? 'C' : 'D'
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class FEE
6
+ ATTRIBUTES = %i[fee fee_currency].freeze
7
+
8
+ def self.apply(operation, line)
9
+ operation.fee_currency = line.detail[0..2]
10
+ scale = line.detail[3].to_i
11
+ sign = operation.amount <=> 0 # the detail amount is unsigned
12
+
13
+ operation.fee = sign * BigDecimal(line.detail[4..17]) / (10**scale)
14
+ end
15
+
16
+ CFONB::OperationDetail.register('FEE', self)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class LC2
6
+ def self.apply(operation, line)
7
+ operation.label += "\n#{line.detail.strip}"
8
+ end
9
+
10
+ CFONB::OperationDetail.register('LC2', self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class LCC
6
+ def self.apply(operation, line)
7
+ operation.label += "\n#{line.detail.strip}"
8
+ end
9
+
10
+ CFONB::OperationDetail.register('LCC', self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class LCS
6
+ def self.apply(operation, line)
7
+ operation.label += "\n#{line.detail[0..35].strip}"
8
+ end
9
+
10
+ CFONB::OperationDetail.register('LCS', self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class LIB
6
+ def self.apply(operation, line)
7
+ operation.label += "\n#{line.detail.strip}"
8
+ end
9
+
10
+ CFONB::OperationDetail.register('LIB', self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module CFONB
6
+ module OperationDetail
7
+ class MMO
8
+ ATTRIBUTES = %i[original_currency original_amount].freeze
9
+
10
+ def self.apply(operation, line)
11
+ operation.original_currency = line.detail[0..2]
12
+
13
+ scale = line.detail[3].to_i
14
+ sign = operation.amount <=> 0 # the detail amount is unsigned
15
+
16
+ operation.original_amount = sign * BigDecimal(line.detail[4..17]) / (10**scale)
17
+ end
18
+
19
+ CFONB::OperationDetail.register('MMO', self)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class NBE
6
+ ATTRIBUTES = %i[creditor].freeze
7
+
8
+ def self.apply(operation, line)
9
+ operation.creditor = line.detail.strip
10
+ end
11
+
12
+ CFONB::OperationDetail.register('NBE', self)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class NPY
6
+ ATTRIBUTES = %i[debtor].freeze
7
+
8
+ def self.apply(operation, line)
9
+ operation.debtor = line.detail.strip
10
+ end
11
+
12
+ CFONB::OperationDetail.register('NPY', self)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class RCN
6
+ ATTRIBUTES = %i[reference purpose].freeze
7
+
8
+ def self.apply(operation, line)
9
+ operation.reference = [
10
+ operation.reference,
11
+ line.detail[0..34].strip
12
+ ].compact.join(' - ')
13
+
14
+ operation.purpose = line.detail[35..-1]&.strip
15
+ end
16
+
17
+ CFONB::OperationDetail.register('RCN', self)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class REF
6
+ ATTRIBUTES = %i[reference].freeze
7
+
8
+ def self.apply(operation, line)
9
+ operation.reference = [
10
+ operation.reference,
11
+ line.detail.strip
12
+ ].compact.join(' - ')
13
+ end
14
+
15
+ CFONB::OperationDetail.register('REF', self)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ @details = {}
6
+
7
+ def self.register(code, klass)
8
+ if klass.const_defined?(:ATTRIBUTES)
9
+ Operation.class_eval do
10
+ attr_accessor(*klass::ATTRIBUTES)
11
+ end
12
+ end
13
+
14
+ @details[code] = klass
15
+ end
16
+
17
+ def self.for(line)
18
+ @details[line.detail_code]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ class Parser
5
+ using CFONB::Refinements::Strings
6
+
7
+ CODES = [
8
+ PREVIOUS_BALANCE_CODE = '01',
9
+ OPERATION_CODE = '04',
10
+ OPERATION_DETAIL_CODE = '05',
11
+ NEW_BALANCE_CODE = '07'
12
+ ].freeze
13
+
14
+ def initialize(input)
15
+ @input = input
16
+ end
17
+
18
+ def parse(optimistic: false)
19
+ @statements = []
20
+ @current_statement = nil
21
+ @current_operation = nil
22
+ @optimistic = optimistic
23
+
24
+ each_line { parse_line(_1) }
25
+
26
+ statements
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :input, :statements, :current_statement, :current_operation, :optimistic
32
+
33
+ def each_line
34
+ input.each_line do |line|
35
+ (line.size / 120).times do |index|
36
+ start = index * 120
37
+ finish = start + 119
38
+
39
+ yield line[start..finish]
40
+ end
41
+ end
42
+ end
43
+
44
+ def parse_line(line)
45
+ return if line.strip.empty?
46
+ raise InvalidCodeError, "Invalid line code '#{line.first(2)}'" unless CODES.include?(line.first(2))
47
+
48
+ line = CFONB::LineParser.parse(line)
49
+
50
+ case line.code
51
+ when PREVIOUS_BALANCE_CODE
52
+ return handle_error(UnfinishedStatementError) if current_statement
53
+
54
+ @current_statement = CFONB::Statement.new(line)
55
+ when OPERATION_CODE
56
+ return handle_error(UnstartedStatementError) unless current_statement
57
+
58
+ current_statement.operations << current_operation if current_operation
59
+ @current_operation = CFONB::Operation.new(line)
60
+ when OPERATION_DETAIL_CODE
61
+ return handle_error(UnstartedOperationError) unless current_operation
62
+
63
+ current_operation.merge_detail(line)
64
+ when NEW_BALANCE_CODE
65
+ return handle_error(UnstartedStatementError) unless current_statement
66
+
67
+ current_statement.operations << current_operation if current_operation
68
+ current_statement.merge_new_balance(line)
69
+ statements << current_statement
70
+
71
+ @current_statement = nil
72
+ @current_operation = nil
73
+ end
74
+ rescue CFONB::ParserError => e
75
+ handle_error(e)
76
+ end
77
+
78
+ def handle_error(error)
79
+ raise error unless optimistic
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module Refinements
5
+ module Strings
6
+ refine String do
7
+
8
+ def first(count = 1)
9
+ self[..(count - 1)]
10
+ end
11
+
12
+ def last(count = 1)
13
+ self[-count..]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ class Statement
5
+ attr_accessor(
6
+ *%i[
7
+ begin_raw end_raw
8
+ bank branch currency account
9
+ from from_balance
10
+ to to_balance
11
+ operations
12
+ ]
13
+ )
14
+
15
+ def initialize(line)
16
+ self.begin_raw = line.body
17
+ self.bank = line.bank
18
+ self.branch = line.branch
19
+ self.currency = line.currency
20
+ self.account = line.account
21
+ self.from = line.date
22
+ self.from_balance = line.amount
23
+ self.operations = []
24
+ end
25
+
26
+ def merge_new_balance(line)
27
+ self.end_raw = line.body
28
+ self.to = line.date
29
+ self.to_balance = line.amount
30
+ end
31
+
32
+ def raw
33
+ [
34
+ begin_raw,
35
+ operations.map(&:raw),
36
+ end_raw
37
+ ].join("\n")
38
+ end
39
+
40
+ def rib
41
+ # https://fr.wikipedia.org/wiki/Clé_RIB
42
+ key = 97 - ((
43
+ (bank.to_i * 89) +
44
+ (branch.to_i * 15) +
45
+ (account.upcase.tr('A-IJ-RS-Z', '1-91-92-9').to_i * 3)
46
+ ) % 97)
47
+
48
+ "#{bank}#{branch}#{account}#{key.to_s.rjust(2, '0')}"
49
+ end
50
+
51
+ def iban
52
+ # https://fr.wikipedia.org/wiki/International_Bank_Account_Number
53
+ normalized_rib = "#{rib}FR00".upcase.gsub(/[A-Z]/) { _1.ord - 55 }
54
+ key = 98 - (normalized_rib.to_i % 97)
55
+
56
+ "FR#{key.to_s.rjust(2, '0')}#{rib}"
57
+ end
58
+ end
59
+ end
data/lib/cfonb.rb CHANGED
@@ -0,0 +1,33 @@
1
+ require 'date'
2
+
3
+ require_relative 'cfonb/refinements/strings'
4
+
5
+ require_relative 'cfonb/error'
6
+ require_relative 'cfonb/parser'
7
+ require_relative 'cfonb/statement'
8
+ require_relative 'cfonb/operation'
9
+ require_relative 'cfonb/operation_detail'
10
+
11
+ require_relative 'cfonb/line_parser'
12
+ require_relative 'cfonb/line_parser/base'
13
+ require_relative 'cfonb/line_parser/previous_balance'
14
+ require_relative 'cfonb/line_parser/operation'
15
+ require_relative 'cfonb/line_parser/operation_detail'
16
+ require_relative 'cfonb/line_parser/new_balance'
17
+
18
+ require_relative 'cfonb/operation_detail/lib'
19
+ require_relative 'cfonb/operation_detail/lcc'
20
+ require_relative 'cfonb/operation_detail/lc2'
21
+ require_relative 'cfonb/operation_detail/lcs'
22
+ require_relative 'cfonb/operation_detail/mmo'
23
+ require_relative 'cfonb/operation_detail/nbe'
24
+ require_relative 'cfonb/operation_detail/npy'
25
+ require_relative 'cfonb/operation_detail/rcn'
26
+ require_relative 'cfonb/operation_detail/ref'
27
+ require_relative 'cfonb/operation_detail/fee'
28
+
29
+ module CFONB
30
+ def self.parse(input, optimistic: false)
31
+ Parser.new(input).parse(optimistic: optimistic)
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cfonb'
4
+
5
+ describe CFONB::LineParser::NewBalance do
6
+ describe '.initialize' do
7
+ subject(:line) { described_class.new(input) }
8
+
9
+ let(:input) do
10
+ '0115589 01020EUR2 98765432100 150519 0000000001904L150519160519 '
11
+ end
12
+
13
+ it 'correctly parses a line' do
14
+ expect(line).to have_attributes(
15
+ 'code' => '01',
16
+ 'bank' => '15589',
17
+ 'branch' => '01020',
18
+ 'currency' => 'EUR',
19
+ 'scale' => 2,
20
+ 'account' => '98765432100',
21
+ 'date' => Date.new(2019, 5, 15),
22
+ 'amount' => -190.43
23
+ )
24
+ end
25
+ end
26
+ end