cfonb 0.0.0 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ecb2f0157c3dff40868b71296f77af49bd5b2cb6b0e5a1ecbd9abed606392d3
4
- data.tar.gz: 2187588d8e68fd14626f125530f2c4e0f3ad78356b1ec1a82bf2273b3e93fd50
3
+ metadata.gz: 5ba0bfa88edcb3ffba5faa6dbed80c645e237f2a733c27e8f08d3d7232529dd4
4
+ data.tar.gz: f1b37e0493ca187685ec6cc4ccda93ecfa154aa318c97fd794224b9aef0ff0dc
5
5
  SHA512:
6
- metadata.gz: 81590edc8a9b56dbdacd3470896cc7649131e7b57aa9167eee59001326463efa47422de544ce140c55c50acb0b9311ad34f869f1e88e48d8b173cfe35313da52
7
- data.tar.gz: e41c09652881278e96ec2f43d67494d34b175f21cc651d71dfc4cee2bef47f4c8d67cb01356e52e8be60b6530b1fd3dab6eb4d37254b366ddfe5b370535afa46
6
+ metadata.gz: 261088061d0fab58203fd779bd3ea0ccb6133a8432ec633e189d49f173b8a6dc1d6237b7094d654c714b69c7c7746b48176aa1b7303751ceb5e4b71193b37a4f
7
+ data.tar.gz: 8a6d89059e1dcae01fae5f6dfb16da6519efe300b927729dd22d8464d4806a9bd6a27fb5e980acfc135fb36b69cdb964fb97199654fe69eaf1314040bcde5c44
@@ -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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class RCN
6
+ using CFONB::Refinements::Strings
7
+
8
+ ATTRIBUTES = %i[reference purpose].freeze
9
+
10
+ def self.apply(operation, line)
11
+ operation.reference = [
12
+ operation.reference,
13
+ line.detail[0..34].strip,
14
+ ].filter_map(&:presence).join(' - ')
15
+
16
+ operation.purpose = line.detail[35..-1]&.strip
17
+ end
18
+
19
+ CFONB::OperationDetail.register('RCN', self)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module OperationDetail
5
+ class REF
6
+ using CFONB::Refinements::Strings
7
+
8
+ ATTRIBUTES = %i[reference].freeze
9
+
10
+ def self.apply(operation, line)
11
+ operation.reference = [
12
+ operation.reference,
13
+ line.detail.strip
14
+ ].filter_map(&:presence).join(' - ')
15
+ end
16
+
17
+ CFONB::OperationDetail.register('REF', self)
18
+ end
19
+ end
20
+ 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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CFONB
4
+ module Refinements
5
+ module Strings
6
+ refine String do
7
+ def first(count = 1)
8
+ self[..(count - 1)]
9
+ end
10
+
11
+ def last(count = 1)
12
+ self[-count..]
13
+ end
14
+
15
+ def presence
16
+ strip.empty? ? nil : self
17
+ end
18
+ end
19
+ end
20
+ end
21
+ 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