camt 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in camt.gemspec
4
+ gemspec
5
+
6
+ # gem 'debugger'
@@ -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.
@@ -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.
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |test|
5
+ test.libs << 'lib' << 'test'
6
+ test.pattern = 'test/**/*_test.rb'
7
+ test.verbose = true
8
+ end
9
+
10
+ task :default => :test
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,11 @@
1
+ unless Object.respond_to?(:try)
2
+ class Object
3
+ def try(*a, &b)
4
+ if a.empty? && block_given?
5
+ yield self
6
+ else
7
+ public_send(*a, &b) if respond_to?(a.first)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -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