camt 0.0.1 → 1.0.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/.gitignore +1 -0
- data/.rspec +1 -0
- data/.travis.yml +2 -0
- data/Gemfile +0 -2
- data/README.md +22 -7
- data/Rakefile +5 -7
- data/camt.gemspec +2 -0
- data/lib/camt.rb +14 -21
- data/lib/camt/amount.rb +22 -0
- data/lib/camt/file.rb +10 -5
- data/lib/camt/parser.rb +3 -126
- data/lib/camt/statement.rb +86 -0
- data/lib/camt/transaction.rb +102 -0
- data/lib/camt/version.rb +1 -1
- data/spec/amount_spec.rb +43 -0
- data/spec/file_spec.rb +91 -0
- data/spec/integration/camt.xml_spec.rb +441 -0
- data/spec/integration/camt_germany_01.xml_spec.rb +207 -0
- data/spec/integration/camt_switzerland_01.xml_spec.rb +41 -0
- data/spec/integration/camt_switzerland_02.xml_spec.rb +42 -0
- data/{test/files → spec/sample_files}/camt.xml +2 -2
- data/spec/sample_files/camt_error.xml +138 -0
- data/spec/sample_files/camt_germany_01.xml +617 -0
- data/spec/sample_files/camt_switzerland_01.xml +141 -0
- data/spec/sample_files/camt_switzerland_02.xml +144 -0
- data/spec/spec_helper.rb +73 -0
- data/spec/statement_spec.rb +82 -0
- data/spec/support/camt.053_integration.rb +57 -0
- data/spec/transaction_spec.rb +71 -0
- metadata +66 -12
- data/lib/camt/object_extension.rb +0 -11
- data/test/test_helper.rb +0 -3
- data/test/unit/camt/file_test.rb +0 -23
- data/test/unit/camt/parser_test.rb +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fbf8cebe34c15ebcaee72f69f9d00fa5d42c7f71
|
4
|
+
data.tar.gz: 97458bfd5bbf68d50c1de444cf0bf3ce71261d3a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2040170ec53cd9887e563ccd81cfb45191a7ec27d78a8f7a0294175216e0ed9be9f591db7cfb0b3e8a9787d73fe91409c051b97098ec7cc33c5bb1736c06227a
|
7
|
+
data.tar.gz: 0dfebd812a609840381a6fe946188a746978630e0633365f8a388611e2c44951084fc6ce5cf5cbc0c8aa74a65d99e4fe8c9b5984ea3447b89958d5940527b889
|
data/.gitignore
CHANGED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/.travis.yml
ADDED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -2,18 +2,33 @@
|
|
2
2
|
|
3
3
|
This gem is based on the Python implementation by Therp (https://code.launchpad.net/~therp-nl/banking-addons) (camt.py). It is still far from supporting the full CAMT.053 specification, but it does provide the most important transaction information.
|
4
4
|
|
5
|
+
[](https://travis-ci.org/moiristo/camt)
|
6
|
+
|
5
7
|
## Installation
|
6
8
|
|
7
|
-
Add to your Gemfile:
|
9
|
+
Add to your Gemfile:
|
10
|
+
|
11
|
+
gem "camt"
|
12
|
+
|
13
|
+
## Configuration
|
14
|
+
|
15
|
+
You need to set your default country code.
|
16
|
+
|
17
|
+
Camt.configure do |config|
|
18
|
+
config.default_country_code = "NL"
|
19
|
+
end
|
8
20
|
|
9
21
|
## Usage
|
10
22
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
23
|
+
Parse a CAMT file:
|
24
|
+
|
25
|
+
camt = Camt::File.parse 'camt.xml'
|
26
|
+
|
27
|
+
Get information:
|
28
|
+
|
29
|
+
puts camt.messages.inspect
|
30
|
+
puts camt.statements.inspect
|
31
|
+
puts camt.transactions.inspect
|
17
32
|
|
18
33
|
## TODO
|
19
34
|
|
data/Rakefile
CHANGED
@@ -1,10 +1,8 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
|
-
require 'rake/testtask'
|
3
2
|
|
4
|
-
|
5
|
-
test.libs << 'lib' << 'test'
|
6
|
-
test.pattern = 'test/**/*_test.rb'
|
7
|
-
test.verbose = true
|
8
|
-
end
|
3
|
+
task :default => [:spec]
|
9
4
|
|
10
|
-
|
5
|
+
desc 'run Rspec specs'
|
6
|
+
task :spec do
|
7
|
+
sh 'rspec spec'
|
8
|
+
end
|
data/camt.gemspec
CHANGED
@@ -19,7 +19,9 @@ Gem::Specification.new do |spec|
|
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
21
|
spec.add_dependency "nokogiri"
|
22
|
+
spec.add_dependency "activesupport"
|
22
23
|
|
23
24
|
spec.add_development_dependency "bundler", "~> 1.3"
|
24
25
|
spec.add_development_dependency "rake"
|
26
|
+
spec.add_development_dependency "rspec"
|
25
27
|
end
|
data/lib/camt.rb
CHANGED
@@ -1,10 +1,17 @@
|
|
1
1
|
require 'nokogiri'
|
2
2
|
require 'time'
|
3
|
+
require 'bigdecimal'
|
4
|
+
|
5
|
+
require 'active_support/core_ext/object/try'
|
6
|
+
require 'active_support/core_ext/module/delegation'
|
7
|
+
require 'active_support/configurable'
|
3
8
|
|
4
9
|
require 'camt/version'
|
5
|
-
require 'camt/object_extension'
|
6
10
|
require 'camt/file'
|
7
11
|
require 'camt/parser'
|
12
|
+
require 'camt/amount'
|
13
|
+
require 'camt/statement'
|
14
|
+
require 'camt/transaction'
|
8
15
|
|
9
16
|
module Camt
|
10
17
|
|
@@ -13,26 +20,6 @@ module Camt
|
|
13
20
|
Message = Struct.new(:group_header, :statements)
|
14
21
|
GroupHeader = Struct.new(:message_id, :created_at, :recipient, :pagination, :additional_info)
|
15
22
|
|
16
|
-
Statement = Struct.new(
|
17
|
-
:id,
|
18
|
-
:date,
|
19
|
-
:created_at,
|
20
|
-
:local_account,
|
21
|
-
:local_currency,
|
22
|
-
:start_balance,
|
23
|
-
:end_balance,
|
24
|
-
:electronic_sequence_number,
|
25
|
-
:transactions
|
26
|
-
)
|
27
|
-
|
28
|
-
Transaction = Struct.new(
|
29
|
-
:execution_date,
|
30
|
-
:effective_date,
|
31
|
-
:transfer_type,
|
32
|
-
:transferred_amount,
|
33
|
-
:transaction_details
|
34
|
-
)
|
35
|
-
|
36
23
|
Reasons = {
|
37
24
|
'EN' => {
|
38
25
|
'AC01' => "Account identifier incorrect (i.e. invalid IBAN)",
|
@@ -97,4 +84,10 @@ module Camt
|
|
97
84
|
}
|
98
85
|
}
|
99
86
|
|
87
|
+
include ActiveSupport::Configurable
|
88
|
+
|
89
|
+
def self.configure
|
90
|
+
yield config
|
91
|
+
end
|
92
|
+
|
100
93
|
end
|
data/lib/camt/amount.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
module Camt
|
2
|
+
class Amount
|
3
|
+
|
4
|
+
attr_reader :node
|
5
|
+
|
6
|
+
def initialize(xml_node)
|
7
|
+
@node = xml_node
|
8
|
+
end
|
9
|
+
|
10
|
+
def value
|
11
|
+
sign * BigDecimal.new(node.at('./Amt').text)
|
12
|
+
end
|
13
|
+
|
14
|
+
def sign
|
15
|
+
node.at('./CdtDbtInd').text == 'DBIT' ? -1 : 1
|
16
|
+
end
|
17
|
+
|
18
|
+
def currency
|
19
|
+
node.at('./Amt').attribute('Ccy').value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/camt/file.rb
CHANGED
@@ -3,21 +3,26 @@ module Camt
|
|
3
3
|
attr_accessor :code, :country_code, :name
|
4
4
|
attr_accessor :doc, :ns
|
5
5
|
|
6
|
+
delegate :errors, :to => :doc
|
7
|
+
|
6
8
|
def self.parse file
|
7
|
-
Camt::File.new
|
9
|
+
Camt::File.new(Nokogiri::XML(::File.read(file)))
|
8
10
|
end
|
9
11
|
|
10
|
-
def initialize
|
12
|
+
def initialize(doc, options = {})
|
11
13
|
self.code = options[:code] || 'CAMT'
|
12
|
-
self.country_code = options[:country_code] ||
|
14
|
+
self.country_code = options[:country_code] || Camt.config.default_country_code || raise(ArgumentError.new("No country_code given and no default_country_code configured."))
|
13
15
|
self.name = options[:name] || 'Generic CAMT Format'
|
14
16
|
|
15
17
|
self.doc = doc
|
16
18
|
self.ns = doc.namespaces['xmlns']
|
17
19
|
|
18
|
-
check_version
|
20
|
+
check_version if valid?
|
19
21
|
end
|
20
22
|
|
23
|
+
def valid?
|
24
|
+
doc.errors.empty?
|
25
|
+
end
|
21
26
|
|
22
27
|
def check_version
|
23
28
|
# Sanity check the document's namespace
|
@@ -44,4 +49,4 @@ module Camt
|
|
44
49
|
end
|
45
50
|
|
46
51
|
end
|
47
|
-
end
|
52
|
+
end
|
data/lib/camt/parser.rb
CHANGED
@@ -3,136 +3,13 @@ module Camt
|
|
3
3
|
|
4
4
|
attr_accessor :file
|
5
5
|
|
6
|
-
def
|
7
|
-
# :param node: BkToCstmrStmt/Stmt/Bal node
|
8
|
-
# :param balance type: one of 'OPBD', 'PRCD', 'ITBD', 'CLBD'
|
9
|
-
return node.at("./Bal/Tp/CdOrPrtry/Cd[text()='#{balance_type}']/../../..")
|
10
|
-
end
|
11
|
-
|
12
|
-
def parse_amount(node)
|
13
|
-
# Parse an element that contains both Amount and CreditDebitIndicator
|
14
|
-
#
|
15
|
-
# :return: signed amount
|
16
|
-
# :returntype: float
|
17
|
-
|
18
|
-
sign = node.at('./CdtDbtInd').text == 'DBIT' ? -1 : 1
|
19
|
-
return sign * node.at('./Amt').text.to_f
|
20
|
-
end
|
21
|
-
|
22
|
-
def get_start_balance(node)
|
23
|
-
# Find the (only) balance node with code OpeningBalance, or
|
24
|
-
# the only one with code 'PreviousClosingBalance'
|
25
|
-
# or the first balance node with code InterimBalance in
|
26
|
-
# the case of preceeding pagination.
|
27
|
-
#
|
28
|
-
# :param node: BkToCstmrStmt/Stmt/Bal node
|
29
|
-
balance_type_node = nil
|
30
|
-
['OPBD', 'PRCD', 'ITBD'].detect{|code| balance_type_node = get_balance_type_node(node, code) }
|
31
|
-
parse_amount balance_type_node
|
32
|
-
end
|
33
|
-
|
34
|
-
def get_end_balance(node)
|
35
|
-
# Find the (only) balance node with code ClosingBalance, or
|
36
|
-
# the second (and last) balance node with code InterimBalance in
|
37
|
-
# the case of continued pagination.
|
38
|
-
#
|
39
|
-
# :param node: BkToCstmrStmt/Stmt/Bal node
|
40
|
-
balance_type_node = nil
|
41
|
-
['CLBD', 'ITBD'].detect{|code| balance_type_node = get_balance_type_node(node, code) }
|
42
|
-
parse_amount balance_type_node
|
43
|
-
end
|
44
|
-
|
45
|
-
def parse_Stmt node
|
6
|
+
def parse_Stmt(node)
|
46
7
|
# Parse a single Stmt node.
|
47
8
|
#
|
48
9
|
# Be sure to craft a unique, but short enough statement identifier,
|
49
10
|
# as it is used as the basis of the generated move lines' names
|
50
11
|
# which overflow when using the full IBAN and CAMT statement id.
|
51
|
-
|
52
|
-
statement = Statement.new
|
53
|
-
|
54
|
-
statement.id = node.at('./Id').text
|
55
|
-
statement.electronic_sequence_number = node.at('./ElctrncSeqNb').text
|
56
|
-
statement.created_at = Time.parse(node.at('./CreDtTm').text)
|
57
|
-
statement.local_account = node.at('./Acct/Id').text.strip
|
58
|
-
statement.local_currency = node.at('./Acct/Ccy').text
|
59
|
-
statement.date = Time.parse(node.at('./Ntry[1]/ValDt/Dt | ./Ntry[1]/ValDt/DtTm').text)
|
60
|
-
statement.start_balance = get_start_balance(node)
|
61
|
-
statement.end_balance = get_end_balance(node)
|
62
|
-
statement.transactions = node.xpath('./Ntry').map{ |node| parse_Ntry(node) }
|
63
|
-
|
64
|
-
statement
|
65
|
-
end
|
66
|
-
|
67
|
-
def get_transfer_type node
|
68
|
-
# Map properietary codes from BkTxCd/Prtry/Cd.
|
69
|
-
# :param node: BkTxCd/Prtry node
|
70
|
-
{ proprietary_code: node.at('./Cd').text, proprietary_issuer: node.at('./Issr').text } if node
|
71
|
-
end
|
72
|
-
|
73
|
-
def parse_Ntry node
|
74
|
-
# :param node: Ntry node
|
75
|
-
|
76
|
-
transaction = Transaction.new
|
77
|
-
transaction.execution_date = node.at('./BookgDt/Dt | ./BookgDt/DtTm').text
|
78
|
-
transaction.effective_date = node.at('./ValDt/Dt | ./ValDt/DtTm').text
|
79
|
-
transaction.transfer_type = get_transfer_type(node.at('./BkTxCd/Prtry'))
|
80
|
-
transaction.transferred_amount = parse_amount(node)
|
81
|
-
transaction.transaction_details = node.xpath('.//NtryDtls//TxDtls').map{ |node| parse_TxDtls(node) }
|
82
|
-
|
83
|
-
transaction
|
84
|
-
end
|
85
|
-
|
86
|
-
def get_party_values node
|
87
|
-
# Determine to get either the debtor or creditor party node
|
88
|
-
# and extract the available data from it
|
89
|
-
values = {}
|
90
|
-
|
91
|
-
party_type = node.at('../../CdtDbtInd').text == 'CRDT' ? 'Dbtr' : 'Cdtr'
|
92
|
-
|
93
|
-
party_node = node.at("./RltdPties/#{party_type}")
|
94
|
-
account_node = node.at("./RltdPties/#{party_type}Acct/Id")
|
95
|
-
bic_node = node.at("./RltdAgts/#{party_type}Agt/FinInstnId/BIC")
|
96
|
-
|
97
|
-
if party_node
|
98
|
-
values[:remote_owner] = party_node.at('./Nm').try(:text)
|
99
|
-
values[:remote_owner_country] = party_node.at('./PstlAdr/Ctry').try(:text)
|
100
|
-
values[:remote_owner_address] = party_node.at('./PstlAdr/AdrLine').try(:text)
|
101
|
-
end
|
102
|
-
|
103
|
-
if account_node
|
104
|
-
values[:remote_account] = account_node.at('./IBAN').try(:text)
|
105
|
-
values[:remote_account] ||= account_node.at('./Othr/Id').try(:text)
|
106
|
-
values[:remote_bank_bic] = bic_node.text if bic_node
|
107
|
-
end
|
108
|
-
|
109
|
-
return values
|
110
|
-
end
|
111
|
-
|
112
|
-
def parse_TxDtls node
|
113
|
-
# Parse a single TxDtls node
|
114
|
-
transaction_details = {}
|
115
|
-
|
116
|
-
if (unstructured = node.xpath('./RmtInf/Ustrd')).any?
|
117
|
-
transaction_details[:messages] = unstructured.map(&:text)
|
118
|
-
end
|
119
|
-
|
120
|
-
if (structured = node.xpath('./RmtInf/Strd/CdtrRefInf/Ref | ./Refs/EndToEndId')).any?
|
121
|
-
transaction_details[:references] = structured.map(&:text)
|
122
|
-
end
|
123
|
-
|
124
|
-
if mandate_identifier = node.at('./Refs/MndtId').try(:text)
|
125
|
-
transaction_details[:mandate_identifier] = mandate_identifier
|
126
|
-
end
|
127
|
-
|
128
|
-
if reason = node.at('./RtrInf/Rsn/Cd').try(:text)
|
129
|
-
reason_language = Camt::Reasons.keys.include?(file.country_code) ? file.country_code : 'EN'
|
130
|
-
transaction_details[:reason] = { code: reason, description: Camt::Reasons[reason_language][reason] }
|
131
|
-
end
|
132
|
-
|
133
|
-
transaction_details[:party] = get_party_values node
|
134
|
-
|
135
|
-
transaction_details
|
12
|
+
Statement.new(node, file.country_code)
|
136
13
|
end
|
137
14
|
|
138
15
|
def parse_message node
|
@@ -171,4 +48,4 @@ module Camt
|
|
171
48
|
end
|
172
49
|
|
173
50
|
end
|
174
|
-
end
|
51
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Camt
|
2
|
+
class Statement
|
3
|
+
|
4
|
+
attr_reader :node, :country_code
|
5
|
+
|
6
|
+
def initialize(xml_node, country_code)
|
7
|
+
@node = xml_node
|
8
|
+
@country_code = country_code
|
9
|
+
end
|
10
|
+
|
11
|
+
def id
|
12
|
+
@id ||= node.at('./Id').text
|
13
|
+
end
|
14
|
+
|
15
|
+
def electronic_sequence_number
|
16
|
+
@electronic_sequence_number ||= node.at('./ElctrncSeqNb').text
|
17
|
+
end
|
18
|
+
|
19
|
+
def created_at
|
20
|
+
@created_at ||= Time.parse(node.at('./CreDtTm').text)
|
21
|
+
end
|
22
|
+
|
23
|
+
def local_account
|
24
|
+
@local_account ||= node.at('./Acct/Id').text.strip
|
25
|
+
end
|
26
|
+
|
27
|
+
alias_method :iban, :local_account
|
28
|
+
|
29
|
+
def local_currency
|
30
|
+
@local_currency ||= node.at('./Acct/Ccy').try(:text) ||
|
31
|
+
node.at('./Bal/Amt').attribute("Ccy").value
|
32
|
+
end
|
33
|
+
|
34
|
+
def date
|
35
|
+
@date ||= Date.parse(node.at('./Ntry[1]/ValDt/Dt | ./Ntry[1]/ValDt/DtTm').text)
|
36
|
+
end
|
37
|
+
|
38
|
+
def start_balance
|
39
|
+
@start_balance ||= get_start_balance
|
40
|
+
end
|
41
|
+
|
42
|
+
def end_balance
|
43
|
+
@end_balance ||= get_end_balance
|
44
|
+
end
|
45
|
+
|
46
|
+
def transactions
|
47
|
+
@transactions ||= node.xpath('./Ntry').map { |node| parse_Ntry(node) }
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def get_balance_type_node node, balance_type
|
53
|
+
# :param node: BkToCstmrStmt/Stmt/Bal node
|
54
|
+
# :param balance type: one of 'OPBD', 'PRCD', 'ITBD', 'CLBD'
|
55
|
+
node.at("./Bal/Tp/CdOrPrtry/Cd[text()='#{balance_type}']/../../..")
|
56
|
+
end
|
57
|
+
|
58
|
+
def get_start_balance
|
59
|
+
# Find the (only) balance node with code OpeningBalance, or
|
60
|
+
# the only one with code 'PreviousClosingBalance'
|
61
|
+
# or the first balance node with code InterimBalance in
|
62
|
+
# the case of preceeding pagination.
|
63
|
+
#
|
64
|
+
# :param node: BkToCstmrStmt/Stmt/Bal node
|
65
|
+
balance_type_node = nil
|
66
|
+
['OPBD', 'PRCD', 'ITBD'].detect{|code| balance_type_node = get_balance_type_node(node, code) }
|
67
|
+
Amount.new(balance_type_node).value
|
68
|
+
end
|
69
|
+
|
70
|
+
def get_end_balance
|
71
|
+
# Find the (only) balance node with code ClosingBalance (Closing Booked), or
|
72
|
+
# the second (and last) balance node with code InterimBalance (Interim Booked) in
|
73
|
+
# the case of continued pagination. Use ClosingAvailable otherwise.
|
74
|
+
#
|
75
|
+
# :param node: BkToCstmrStmt/Stmt/Bal node
|
76
|
+
balance_type_node = nil
|
77
|
+
['CLBD', 'ITBD', 'CLAV'].detect{|code| balance_type_node = get_balance_type_node(node, code) }
|
78
|
+
Amount.new(balance_type_node).value if balance_type_node
|
79
|
+
end
|
80
|
+
|
81
|
+
def parse_Ntry(node)
|
82
|
+
# :param node: Ntry node
|
83
|
+
Transaction.new(node, country_code)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|