camt 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +31 -0
- data/Rakefile +10 -0
- data/camt.gemspec +25 -0
- data/lib/camt.rb +100 -0
- data/lib/camt/file.rb +47 -0
- data/lib/camt/object_extension.rb +11 -0
- data/lib/camt/parser.rb +174 -0
- data/lib/camt/version.rb +3 -0
- data/test/files/camt.xml +1909 -0
- data/test/test_helper.rb +3 -0
- data/test/unit/camt/file_test.rb +23 -0
- data/test/unit/camt/parser_test.rb +7 -0
- metadata +105 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8bfc23e67cc63e70b660a029d859f138f397427a
|
4
|
+
data.tar.gz: 3f1a661d0c80e14264b48198b3cb17bd953ec7ba
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e6208aa0f98688518eb4f6b5b80192088eebbe2129204f5cc834d6017f2814b296356092f94492c50682a8d6b822712974f9f1ef16c1b9f72214b2683445ed88
|
7
|
+
data.tar.gz: 59d5e30cfbf93252e6f0b6996a0d0ffa429d6f068c776cd1334801f36a838f31b2125c997d85f8a0751c084afa143ce7014f89c39c6ddba7cec5ed3af2703e45
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Reinier de Lange
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# camt: A ruby gem for parsing CAMT.053 files
|
2
|
+
|
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
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add to your Gemfile: gem 'camt', github: 'moiristo/camt'
|
8
|
+
|
9
|
+
## Usage
|
10
|
+
|
11
|
+
* Parse a CAMT file:
|
12
|
+
* camt = Camt::File.parse 'camt.xml'
|
13
|
+
* Get information:
|
14
|
+
* puts camt.messages.inspect
|
15
|
+
* puts camt.statements.inspect
|
16
|
+
* puts camt.transactions.inspect
|
17
|
+
|
18
|
+
## TODO
|
19
|
+
|
20
|
+
* More testing
|
21
|
+
* Extend Camt::Parser to support the full CAMT.053 specification
|
22
|
+
|
23
|
+
## Note on Patches/Pull Requests
|
24
|
+
|
25
|
+
* Fork the project.
|
26
|
+
* Make your feature addition or bug fix.
|
27
|
+
* Send me a pull request
|
28
|
+
|
29
|
+
## Copyright
|
30
|
+
|
31
|
+
Copyright (c) 2014 Reinier de Lange. See LICENSE for details.
|
data/Rakefile
ADDED
data/camt.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'camt/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "camt"
|
8
|
+
spec.version = Camt::VERSION
|
9
|
+
spec.authors = ["Reinier de Lange"]
|
10
|
+
spec.email = ["r.j.delange@nedforce.nl"]
|
11
|
+
spec.description = %q{A gem for parsing CAMT.053 files}
|
12
|
+
spec.summary = %q{A gem for parsing CAMT.053 file}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "nokogiri"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
end
|
data/lib/camt.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
require 'camt/version'
|
5
|
+
require 'camt/object_extension'
|
6
|
+
require 'camt/file'
|
7
|
+
require 'camt/parser'
|
8
|
+
|
9
|
+
module Camt
|
10
|
+
|
11
|
+
# Define structs
|
12
|
+
|
13
|
+
Message = Struct.new(:group_header, :statements)
|
14
|
+
GroupHeader = Struct.new(:message_id, :created_at, :recipient, :pagination, :additional_info)
|
15
|
+
|
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
|
+
Reasons = {
|
37
|
+
'EN' => {
|
38
|
+
'AC01' => "Account identifier incorrect (i.e. invalid IBAN)",
|
39
|
+
'AC04' => "Account closed",
|
40
|
+
'AC06' => "Account blocked",
|
41
|
+
'AG01' => "Direct debit forbidden on this account for regulatory reasons",
|
42
|
+
'AG02' => "Operation/transaction code incorrect, invalid file format",
|
43
|
+
'AM04' => "Insufficient funds",
|
44
|
+
'AM05' => "Duplicate collection",
|
45
|
+
'BE01' => "Debtor's name does not match with the account holder's name",
|
46
|
+
'BE04' => "Creditor adress missing or incorrect",
|
47
|
+
'BE05' => "Creditor identifier incorrect",
|
48
|
+
'FF01' => "Operation/transaction code incorrect, invalid file format",
|
49
|
+
'FF05' => "Operation/transaction type incorrect",
|
50
|
+
'FOCR' => "Returned after cancellation request of account holder",
|
51
|
+
'MD01' => "No Mandate",
|
52
|
+
'MD02' => "Mandate data missing or incorrect",
|
53
|
+
'MD06' => "Returned after request of account holder",
|
54
|
+
'MD07' => "Debtor deceased",
|
55
|
+
'MS02' => "Refusal by the Debtor",
|
56
|
+
'MS03' => "Reason not specified",
|
57
|
+
'RC01' => "Bank identifier incorrect (i.e. invalid BIC)",
|
58
|
+
'RR01' => "Regulatory Reason",
|
59
|
+
'RR02' => "Regulatory Reason",
|
60
|
+
'RR03' => "Regulatory Reason",
|
61
|
+
'RR04' => "Regulatory Reason",
|
62
|
+
'SL01' => "Specific Service offered by the Debtor Bank",
|
63
|
+
'CNOR' => "Creditor bank is not registered under this BIC in the CSM",
|
64
|
+
'DNOR' => "Debtor bank is not registered under this BIC in the CSM",
|
65
|
+
'TM01' => "File received after Cut-off Time",
|
66
|
+
},
|
67
|
+
'NL' => {
|
68
|
+
'AC01' => "Rekeningnummer incorrect",
|
69
|
+
'AC04' => "Rekeningnummer gesloten",
|
70
|
+
'AC06' => "Rekeningnummer geblokkeerd",
|
71
|
+
'AC13' => "Debiteur rekening is klant rekening",
|
72
|
+
'AG01' => "Transactie niet toegestaan",
|
73
|
+
'AG02' => "Transactiecode incorrect, ongeldig bestandsformaat",
|
74
|
+
'AM04' => "Onvoldoende saldo",
|
75
|
+
'AM05' => "Dubbel betaald",
|
76
|
+
'BE01' => "Debiteur naam en rekeningnummer komen niet overeen",
|
77
|
+
'BE04' => "Adres crediteur ontbreekt of incorrect",
|
78
|
+
'BE05' => "Identificatie van de crediteur is incorrect",
|
79
|
+
'FF01' => "Transactie code incorrect",
|
80
|
+
'FF05' => "Incasso type incorrect",
|
81
|
+
'FOCR' => "Terugboeking na annuleringsverzoek",
|
82
|
+
'MD01' => "Geen machtiging verstrekt",
|
83
|
+
'MD02' => "Verplichte informatie over machtiging ontbreekt of incorrect",
|
84
|
+
'MD06' => "Terugboeking op verzoek van klant",
|
85
|
+
'MD07' => "Klant overleden",
|
86
|
+
'MS02' => "Onbekende reden klant",
|
87
|
+
'MS03' => "Onbekende reden bank",
|
88
|
+
'RC01' => "BIC incorrect",
|
89
|
+
'RR01' => "Wet of regelgeving",
|
90
|
+
'RR02' => "Voor wet of regelgeving benodigde naam en/of adres van de debiteur is onvolledig of ontbreekt",
|
91
|
+
'RR03' => "Voor wet of regelgeving benodigde naam en/of adres van de crediteur ontbreekt",
|
92
|
+
'RR04' => "Wet of regelgeving",
|
93
|
+
'SL01' => "Specifieke dienstverlening bank (bv selectieve incassoblokkade)",
|
94
|
+
'CNOR' => "Bank van de crediteur is niet bekend onder deze BIC",
|
95
|
+
'DNOR' => "Bank van de debiteur is niet bekend onder deze BIC",
|
96
|
+
'TM01' => "Bestand aangeleverd na cut-off tijd (uiterste aanlevertijdstip)"
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
end
|
data/lib/camt/file.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
module Camt
|
2
|
+
class File
|
3
|
+
attr_accessor :code, :country_code, :name
|
4
|
+
attr_accessor :doc, :ns
|
5
|
+
|
6
|
+
def self.parse file
|
7
|
+
Camt::File.new Nokogiri::XML ::File.read(file)
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize doc, options = { code: 'CAMT', country_code: 'NL', name: 'Generic CAMT Format' }
|
11
|
+
self.code = options[:code] || 'CAMT'
|
12
|
+
self.country_code = options[:country_code] || 'NL'
|
13
|
+
self.name = options[:name] || 'Generic CAMT Format'
|
14
|
+
|
15
|
+
self.doc = doc
|
16
|
+
self.ns = doc.namespaces['xmlns']
|
17
|
+
|
18
|
+
check_version
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
def check_version
|
23
|
+
# Sanity check the document's namespace
|
24
|
+
raise 'This does not seem to be a CAMT format bank statement' unless ns.start_with?('urn:iso:std:iso:20022:tech:xsd:camt.')
|
25
|
+
raise 'Only CAMT.053 is supported at the moment.' unless ns.start_with?('urn:iso:std:iso:20022:tech:xsd:camt.053.')
|
26
|
+
return true
|
27
|
+
end
|
28
|
+
|
29
|
+
def messages
|
30
|
+
@messages ||= Parser.new.parse self
|
31
|
+
end
|
32
|
+
|
33
|
+
def statements
|
34
|
+
@statements ||= messages.map(&:statements).flatten
|
35
|
+
end
|
36
|
+
|
37
|
+
def transactions
|
38
|
+
@transactions ||= statements.map(&:transactions).flatten
|
39
|
+
end
|
40
|
+
|
41
|
+
|
42
|
+
def to_s
|
43
|
+
"#{name}: #{messages.map{|message| message.group_header.message_id }.join(', ')}"
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
data/lib/camt/parser.rb
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
module Camt
|
2
|
+
class Parser
|
3
|
+
|
4
|
+
attr_accessor :file
|
5
|
+
|
6
|
+
def get_balance_type_node node, balance_type
|
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
|
46
|
+
# Parse a single Stmt node.
|
47
|
+
#
|
48
|
+
# Be sure to craft a unique, but short enough statement identifier,
|
49
|
+
# as it is used as the basis of the generated move lines' names
|
50
|
+
# 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
|
136
|
+
end
|
137
|
+
|
138
|
+
def parse_message node
|
139
|
+
group_header_node = node.at('./GrpHdr')
|
140
|
+
|
141
|
+
group_header = GroupHeader.new
|
142
|
+
group_header.message_id = group_header_node.at('./MsgId').text
|
143
|
+
group_header.created_at = Time.parse(group_header_node.at('./CreDtTm').text)
|
144
|
+
group_header.additional_info = group_header_node.at('./AddtlInf').try(:text)
|
145
|
+
|
146
|
+
if recipient_node = group_header_node.at('./MsgRcpt')
|
147
|
+
group_header.recipient = {
|
148
|
+
name: recipient_node.at('./Nm').try(:text),
|
149
|
+
postal_address: recipient_node.at('./PstlAdr').try(:text),
|
150
|
+
identification: recipient_node.at('./Id').try(:text),
|
151
|
+
country_of_residence: recipient_node.at('./CtryOfRes').try(:text),
|
152
|
+
contact_details: recipient_node.at('./CtctDtls').try(:text)
|
153
|
+
}
|
154
|
+
end
|
155
|
+
|
156
|
+
if pagination_node = group_header_node.at('./MsgPgntn')
|
157
|
+
group_header.pagination = { page: pagination_node.at('./PgNb').text, last_page: (pagination_node.at('./LastPgInd').text == 'true') }
|
158
|
+
end
|
159
|
+
|
160
|
+
message = Message.new
|
161
|
+
message.group_header = group_header
|
162
|
+
message.statements = node.xpath('./Stmt').map{|node| parse_Stmt node }
|
163
|
+
|
164
|
+
message
|
165
|
+
end
|
166
|
+
|
167
|
+
def parse file
|
168
|
+
self.file = file
|
169
|
+
file.doc.remove_namespaces!
|
170
|
+
file.doc.xpath('//BkToCstmrStmt').map{|node| parse_message node }
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
174
|
+
end
|