sepa_rator 0.15.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 +7 -0
- data/LICENSE.txt +23 -0
- data/README.md +117 -0
- data/lib/schema/pain.001.001.03.ch.02.xsd +1212 -0
- data/lib/schema/pain.001.001.03.xsd +921 -0
- data/lib/schema/pain.001.001.09.xsd +1114 -0
- data/lib/schema/pain.001.001.13.xsd +1251 -0
- data/lib/schema/pain.001.002.03.xsd +450 -0
- data/lib/schema/pain.001.003.03.xsd +474 -0
- data/lib/schema/pain.008.001.02.xsd +879 -0
- data/lib/schema/pain.008.001.08.xsd +1106 -0
- data/lib/schema/pain.008.001.12.xsd +1135 -0
- data/lib/schema/pain.008.002.02.xsd +597 -0
- data/lib/schema/pain.008.003.02.xsd +614 -0
- data/lib/sepa_rator/account/address.rb +71 -0
- data/lib/sepa_rator/account/contact_details.rb +70 -0
- data/lib/sepa_rator/account/creditor_account.rb +16 -0
- data/lib/sepa_rator/account/creditor_address.rb +5 -0
- data/lib/sepa_rator/account/debtor_account.rb +20 -0
- data/lib/sepa_rator/account/debtor_address.rb +5 -0
- data/lib/sepa_rator/account.rb +64 -0
- data/lib/sepa_rator/concerns/attribute_initializer.rb +40 -0
- data/lib/sepa_rator/concerns/regulatory_reporting_validator.rb +111 -0
- data/lib/sepa_rator/concerns/schema_validation.rb +41 -0
- data/lib/sepa_rator/concerns/xml_builder.rb +111 -0
- data/lib/sepa_rator/converter.rb +46 -0
- data/lib/sepa_rator/error.rb +15 -0
- data/lib/sepa_rator/message/credit_transfer.rb +221 -0
- data/lib/sepa_rator/message/direct_debit.rb +153 -0
- data/lib/sepa_rator/message.rb +284 -0
- data/lib/sepa_rator/transaction/credit_transfer_transaction.rb +178 -0
- data/lib/sepa_rator/transaction/direct_debit_transaction.rb +104 -0
- data/lib/sepa_rator/transaction.rb +114 -0
- data/lib/sepa_rator/validator.rb +99 -0
- data/lib/sepa_rator/version.rb +5 -0
- data/lib/sepa_rator.rb +27 -0
- metadata +128 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
CreditTransferGroup = Data.define(:requested_date, :batch_booking, :service_level, :category_purpose,
|
|
5
|
+
:instruction_priority, :charge_bearer, :debtor_agent_instruction)
|
|
6
|
+
|
|
7
|
+
class CreditTransfer < Message
|
|
8
|
+
self.account_class = DebtorAccount
|
|
9
|
+
self.transaction_class = CreditTransferTransaction
|
|
10
|
+
self.xml_main_tag = 'CstmrCdtTrfInitn'
|
|
11
|
+
self.known_schemas = [PAIN_001_001_03, PAIN_001_001_03_CH_02, PAIN_001_001_09, PAIN_001_001_13, PAIN_001_003_03, PAIN_001_002_03]
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Find groups of transactions which share the same values of some attributes
|
|
16
|
+
def transaction_group(transaction)
|
|
17
|
+
CreditTransferGroup.new(
|
|
18
|
+
requested_date: transaction.requested_date,
|
|
19
|
+
batch_booking: transaction.batch_booking,
|
|
20
|
+
service_level: transaction.service_level,
|
|
21
|
+
category_purpose: transaction.category_purpose,
|
|
22
|
+
instruction_priority: transaction.instruction_priority,
|
|
23
|
+
charge_bearer: transaction.charge_bearer || (transaction.service_level ? 'SLEV' : nil),
|
|
24
|
+
debtor_agent_instruction: transaction.debtor_agent_instruction
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_payment_informations(builder, schema_name)
|
|
29
|
+
grouped_transactions.each do |group, transactions|
|
|
30
|
+
builder.PmtInf do
|
|
31
|
+
builder.PmtInfId(payment_information_identification(group))
|
|
32
|
+
builder.PmtMtd('TRF')
|
|
33
|
+
builder.BtchBookg(group.batch_booking)
|
|
34
|
+
builder.NbOfTxs(transactions.length)
|
|
35
|
+
builder.CtrlSum(format_amount(amount_total(transactions)))
|
|
36
|
+
build_payment_type_information(builder, group)
|
|
37
|
+
build_requested_execution_date(builder, group, schema_name)
|
|
38
|
+
build_debtor_info(builder, schema_name)
|
|
39
|
+
build_pmtinf_debtor_agent_instruction(builder, group, schema_name)
|
|
40
|
+
builder.ChrgBr(group.charge_bearer) if group.charge_bearer
|
|
41
|
+
|
|
42
|
+
transactions.each { |transaction| build_transaction(builder, transaction, schema_name) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_payment_type_information(builder, group)
|
|
48
|
+
return unless group.service_level || group.category_purpose || group.instruction_priority
|
|
49
|
+
|
|
50
|
+
builder.PmtTpInf do
|
|
51
|
+
builder.InstrPrty(group.instruction_priority) if group.instruction_priority
|
|
52
|
+
if group.service_level
|
|
53
|
+
builder.SvcLvl do
|
|
54
|
+
builder.Cd(group.service_level)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
if group.category_purpose
|
|
58
|
+
builder.CtgyPurp do
|
|
59
|
+
builder.Cd(group.category_purpose)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_requested_execution_date(builder, group, schema_name)
|
|
66
|
+
if schema_features(schema_name)[:wrap_date]
|
|
67
|
+
builder.ReqdExctnDt do
|
|
68
|
+
builder.Dt(group.requested_date.iso8601)
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
builder.ReqdExctnDt(group.requested_date.iso8601)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_debtor_info(builder, schema_name)
|
|
76
|
+
builder.Dbtr do
|
|
77
|
+
builder.Nm(account.name)
|
|
78
|
+
build_postal_address(builder, account.address) if account.address
|
|
79
|
+
build_contact_details(builder, account.contact_details)
|
|
80
|
+
end
|
|
81
|
+
build_iban_account(builder, :DbtrAcct, account.iban)
|
|
82
|
+
builder.DbtrAgt do
|
|
83
|
+
build_agent_bic(builder, account.bic, schema_name,
|
|
84
|
+
fallback: !schema_features(schema_name)[:swiss],
|
|
85
|
+
lei: account.agent_lei)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# InstrForDbtrAgt at PmtInf level (v09/v13 only, Max140Text)
|
|
90
|
+
def build_pmtinf_debtor_agent_instruction(builder, group, schema_name)
|
|
91
|
+
return unless CreditTransferTransaction::PMTINF_INSTR_SCHEMAS.include?(schema_name) && group.debtor_agent_instruction
|
|
92
|
+
|
|
93
|
+
builder.InstrForDbtrAgt(group.debtor_agent_instruction)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# XSD element order: PmtId > Amt > [MndtRltdInf v13] > [UltmtDbtr] > [CdtrAgt] > [Cdtr] >
|
|
97
|
+
# [CdtrAcct] > [UltmtCdtr] > [InstrForCdtrAgt] > [InstrForDbtrAgt] > [Purp] > [RgltryRptg] > [RmtInf]
|
|
98
|
+
def build_transaction(builder, transaction, schema_name)
|
|
99
|
+
builder.CdtTrfTxInf do
|
|
100
|
+
build_payment_identification(builder, transaction)
|
|
101
|
+
builder.Amt do
|
|
102
|
+
builder.InstdAmt(format_amount(transaction.amount), Ccy: transaction.currency)
|
|
103
|
+
end
|
|
104
|
+
build_credit_transfer_mandate(builder, transaction, schema_name)
|
|
105
|
+
build_ultimate_party(builder, :UltmtDbtr, transaction.ultimate_debtor_name)
|
|
106
|
+
if transaction.bic || transaction.agent_lei
|
|
107
|
+
builder.CdtrAgt do
|
|
108
|
+
build_agent_bic(builder, transaction.bic, schema_name, fallback: false, lei: transaction.agent_lei)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
builder.Cdtr do
|
|
112
|
+
builder.Nm(transaction.name)
|
|
113
|
+
build_postal_address(builder, transaction.creditor_address) if transaction.creditor_address
|
|
114
|
+
build_contact_details(builder, transaction.creditor_contact_details)
|
|
115
|
+
end
|
|
116
|
+
build_iban_account(builder, :CdtrAcct, transaction.iban)
|
|
117
|
+
build_ultimate_party(builder, :UltmtCdtr, transaction.ultimate_creditor_name)
|
|
118
|
+
build_instructions_for_creditor_agent(builder, transaction)
|
|
119
|
+
build_txn_instruction_for_debtor_agent(builder, transaction, schema_name)
|
|
120
|
+
build_purpose(builder, transaction.purpose_code)
|
|
121
|
+
build_regulatory_reportings(builder, transaction, schema_name)
|
|
122
|
+
build_remittance_information(builder, transaction)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# MndtRltdInf — CreditTransferMandateData1 (v13 only)
|
|
127
|
+
def build_credit_transfer_mandate(builder, transaction, schema_name)
|
|
128
|
+
return unless CreditTransferTransaction::MNDT_RLTD_INF_SCHEMAS.include?(schema_name) && transaction.credit_transfer_mandate?
|
|
129
|
+
|
|
130
|
+
builder.MndtRltdInf do
|
|
131
|
+
builder.MndtId(transaction.credit_transfer_mandate_id) if transaction.credit_transfer_mandate_id
|
|
132
|
+
builder.DtOfSgntr(transaction.credit_transfer_mandate_date_of_signature.iso8601) if transaction.credit_transfer_mandate_date_of_signature
|
|
133
|
+
builder.Frqcy { builder.Tp(transaction.credit_transfer_mandate_frequency) } if transaction.credit_transfer_mandate_frequency
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# InstrForCdtrAgt — unbounded, same XML structure for all versions
|
|
138
|
+
def build_instructions_for_creditor_agent(builder, transaction)
|
|
139
|
+
return unless transaction.instructions_for_creditor_agent
|
|
140
|
+
|
|
141
|
+
transaction.instructions_for_creditor_agent.each do |instr|
|
|
142
|
+
builder.InstrForCdtrAgt do
|
|
143
|
+
builder.Cd(instr[:code]) if instr[:code]
|
|
144
|
+
builder.InstrInf(instr[:instruction_info]) if instr[:instruction_info]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# InstrForDbtrAgt at transaction level — text (v03/v09) or structured (v13)
|
|
150
|
+
def build_txn_instruction_for_debtor_agent(builder, transaction, schema_name)
|
|
151
|
+
return unless transaction.instruction_for_debtor_agent || transaction.instruction_for_debtor_agent_code
|
|
152
|
+
|
|
153
|
+
if schema_features(schema_name)[:instr_for_dbtr_agt_format] == :structured
|
|
154
|
+
builder.InstrForDbtrAgt do
|
|
155
|
+
builder.Cd(transaction.instruction_for_debtor_agent_code) if transaction.instruction_for_debtor_agent_code
|
|
156
|
+
builder.InstrInf(transaction.instruction_for_debtor_agent) if transaction.instruction_for_debtor_agent
|
|
157
|
+
end
|
|
158
|
+
elsif transaction.instruction_for_debtor_agent
|
|
159
|
+
builder.InstrForDbtrAgt(transaction.instruction_for_debtor_agent)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# RgltryRptg — RegulatoryReporting3 (v03/v09) or RegulatoryReporting10 (v13)
|
|
164
|
+
def build_regulatory_reportings(builder, transaction, schema_name)
|
|
165
|
+
return unless transaction.regulatory_reportings
|
|
166
|
+
|
|
167
|
+
version = schema_features(schema_name)[:regulatory_reporting_version]
|
|
168
|
+
|
|
169
|
+
transaction.regulatory_reportings.each do |reporting|
|
|
170
|
+
builder.RgltryRptg do
|
|
171
|
+
builder.DbtCdtRptgInd(reporting[:indicator]) if reporting[:indicator]
|
|
172
|
+
build_regulatory_authority(builder, reporting[:authority])
|
|
173
|
+
reporting[:details]&.each do |detail|
|
|
174
|
+
builder.Dtls do
|
|
175
|
+
# XSD sequence: Tp → Dt → Ctry → Cd/RptgCd → Amt → Inf
|
|
176
|
+
build_regulatory_detail_type(builder, detail, version)
|
|
177
|
+
builder.Dt(detail[:date].iso8601) if detail[:date]
|
|
178
|
+
builder.Ctry(detail[:country]) if detail[:country]
|
|
179
|
+
code_tag = version == :v10 ? :RptgCd : :Cd
|
|
180
|
+
builder.__send__(code_tag, detail[:code]) if detail[:code]
|
|
181
|
+
build_regulatory_detail_amount(builder, detail[:amount])
|
|
182
|
+
Array(detail[:information]).each { |inf| builder.Inf(inf) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_regulatory_authority(builder, authority)
|
|
190
|
+
return unless authority
|
|
191
|
+
|
|
192
|
+
builder.Authrty do
|
|
193
|
+
builder.Nm(authority[:name]) if authority[:name]
|
|
194
|
+
builder.Ctry(authority[:country]) if authority[:country]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_regulatory_detail_type(builder, detail, version)
|
|
199
|
+
return unless detail[:type] || detail[:type_proprietary]
|
|
200
|
+
|
|
201
|
+
if version == :v10
|
|
202
|
+
builder.Tp do
|
|
203
|
+
detail[:type_proprietary] ? builder.Prtry(detail[:type_proprietary]) : builder.Cd(detail[:type])
|
|
204
|
+
end
|
|
205
|
+
elsif detail[:type]
|
|
206
|
+
builder.Tp(detail[:type])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ActiveOrHistoricCurrencyAndAmount allows up to 5 fractional digits (unlike payment amounts at 2).
|
|
211
|
+
def build_regulatory_detail_amount(builder, amount)
|
|
212
|
+
return unless amount
|
|
213
|
+
|
|
214
|
+
decimal = BigDecimal(amount[:value].to_s).truncate(5)
|
|
215
|
+
# Ensure at least 2 fractional digits, up to 5 (ActiveOrHistoricCurrencyAndAmount)
|
|
216
|
+
frac_digits = [decimal.to_s('F').split('.').last&.length.to_i, 2].max
|
|
217
|
+
value = format("%.#{frac_digits}f", decimal)
|
|
218
|
+
builder.Amt(value, Ccy: amount[:currency])
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
DirectDebitGroup = Data.define(:requested_date, :local_instrument, :sequence_type, :batch_booking, :account, :instruction_priority, :charge_bearer)
|
|
5
|
+
|
|
6
|
+
class DirectDebit < Message
|
|
7
|
+
self.account_class = CreditorAccount
|
|
8
|
+
self.transaction_class = DirectDebitTransaction
|
|
9
|
+
self.xml_main_tag = 'CstmrDrctDbtInitn'
|
|
10
|
+
self.known_schemas = [PAIN_008_001_02, PAIN_008_001_08, PAIN_008_001_12, PAIN_008_003_02, PAIN_008_002_02]
|
|
11
|
+
|
|
12
|
+
validate do |record|
|
|
13
|
+
errors.add(:base, 'CORE, COR1 AND B2B must not be mixed in one message!') if record.transactions.map(&:local_instrument).uniq.size > 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Find groups of transactions which share the same values of some attributes
|
|
19
|
+
def transaction_group(transaction)
|
|
20
|
+
DirectDebitGroup.new(
|
|
21
|
+
requested_date: transaction.requested_date,
|
|
22
|
+
local_instrument: transaction.local_instrument,
|
|
23
|
+
sequence_type: transaction.sequence_type,
|
|
24
|
+
batch_booking: transaction.batch_booking,
|
|
25
|
+
account: transaction.creditor_account || account,
|
|
26
|
+
instruction_priority: transaction.instruction_priority,
|
|
27
|
+
charge_bearer: transaction.charge_bearer || 'SLEV'
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_payment_informations(builder, schema_name)
|
|
32
|
+
grouped_transactions.each do |group, transactions|
|
|
33
|
+
builder.PmtInf do
|
|
34
|
+
builder.PmtInfId(payment_information_identification(group))
|
|
35
|
+
builder.PmtMtd('DD')
|
|
36
|
+
builder.BtchBookg(group.batch_booking)
|
|
37
|
+
builder.NbOfTxs(transactions.length)
|
|
38
|
+
builder.CtrlSum(format_amount(amount_total(transactions)))
|
|
39
|
+
build_payment_type_information(builder, group)
|
|
40
|
+
build_creditor_info(builder, group, schema_name)
|
|
41
|
+
build_creditor_scheme_identification(builder, group)
|
|
42
|
+
|
|
43
|
+
transactions.each { |transaction| build_transaction(builder, transaction, schema_name) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_payment_type_information(builder, group)
|
|
49
|
+
builder.PmtTpInf do
|
|
50
|
+
builder.InstrPrty(group.instruction_priority) if group.instruction_priority
|
|
51
|
+
builder.SvcLvl do
|
|
52
|
+
builder.Cd('SEPA')
|
|
53
|
+
end
|
|
54
|
+
builder.LclInstrm do
|
|
55
|
+
builder.Cd(group.local_instrument)
|
|
56
|
+
end
|
|
57
|
+
builder.SeqTp(group.sequence_type)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_creditor_info(builder, group, schema_name)
|
|
62
|
+
builder.ReqdColltnDt(group.requested_date.iso8601)
|
|
63
|
+
builder.Cdtr do
|
|
64
|
+
builder.Nm(group.account.name)
|
|
65
|
+
build_postal_address(builder, group.account.address) if group.account.address
|
|
66
|
+
build_contact_details(builder, group.account.contact_details)
|
|
67
|
+
end
|
|
68
|
+
build_iban_account(builder, :CdtrAcct, group.account.iban)
|
|
69
|
+
builder.CdtrAgt do
|
|
70
|
+
build_agent_bic(builder, group.account.bic, schema_name, lei: group.account.agent_lei)
|
|
71
|
+
end
|
|
72
|
+
builder.ChrgBr(group.charge_bearer)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_creditor_scheme_identification(builder, group)
|
|
76
|
+
builder.CdtrSchmeId do
|
|
77
|
+
builder.Id do
|
|
78
|
+
builder.PrvtId do
|
|
79
|
+
builder.Othr do
|
|
80
|
+
builder.Id(group.account.creditor_identifier)
|
|
81
|
+
builder.SchmeNm do
|
|
82
|
+
builder.Prtry('SEPA')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_amendment_informations(builder, transaction)
|
|
91
|
+
builder.AmdmntInd(true)
|
|
92
|
+
builder.AmdmntInfDtls do
|
|
93
|
+
builder.OrgnlMndtId(transaction.original_mandate_id) if transaction.original_mandate_id
|
|
94
|
+
|
|
95
|
+
if transaction.original_debtor_account
|
|
96
|
+
build_iban_account(builder, :OrgnlDbtrAcct, transaction.original_debtor_account)
|
|
97
|
+
elsif transaction.same_mandate_new_debtor_agent
|
|
98
|
+
builder.OrgnlDbtrAgt do
|
|
99
|
+
builder.FinInstnId do
|
|
100
|
+
builder.Othr do
|
|
101
|
+
builder.Id('SMNDA')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
if transaction.original_creditor_account
|
|
107
|
+
builder.OrgnlCdtrSchmeId do
|
|
108
|
+
builder.Nm(transaction.original_creditor_account.name) if transaction.original_creditor_account.name
|
|
109
|
+
if transaction.original_creditor_account.creditor_identifier
|
|
110
|
+
builder.Id do
|
|
111
|
+
builder.PrvtId do
|
|
112
|
+
builder.Othr do
|
|
113
|
+
builder.Id(transaction.original_creditor_account.creditor_identifier)
|
|
114
|
+
builder.SchmeNm do
|
|
115
|
+
builder.Prtry('SEPA')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_transaction(builder, transaction, schema_name)
|
|
127
|
+
builder.DrctDbtTxInf do
|
|
128
|
+
build_payment_identification(builder, transaction)
|
|
129
|
+
builder.InstdAmt(format_amount(transaction.amount), Ccy: transaction.currency)
|
|
130
|
+
builder.DrctDbtTx do
|
|
131
|
+
builder.MndtRltdInf do
|
|
132
|
+
builder.MndtId(transaction.mandate_id)
|
|
133
|
+
builder.DtOfSgntr(transaction.mandate_date_of_signature.iso8601)
|
|
134
|
+
build_amendment_informations(builder, transaction) if transaction.amendment_informations?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
build_ultimate_party(builder, :UltmtCdtr, transaction.ultimate_creditor_name)
|
|
138
|
+
builder.DbtrAgt do
|
|
139
|
+
build_agent_bic(builder, transaction.bic, schema_name, lei: transaction.agent_lei)
|
|
140
|
+
end
|
|
141
|
+
builder.Dbtr do
|
|
142
|
+
builder.Nm(transaction.name)
|
|
143
|
+
build_postal_address(builder, transaction.debtor_address) if transaction.debtor_address
|
|
144
|
+
build_contact_details(builder, transaction.debtor_contact_details)
|
|
145
|
+
end
|
|
146
|
+
build_iban_account(builder, :DbtrAcct, transaction.iban)
|
|
147
|
+
build_ultimate_party(builder, :UltmtDbtr, transaction.ultimate_debtor_name)
|
|
148
|
+
build_purpose(builder, transaction.purpose_code)
|
|
149
|
+
build_remittance_information(builder, transaction)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
PAIN_008_001_02 = 'pain.008.001.02'
|
|
5
|
+
PAIN_008_001_08 = 'pain.008.001.08'
|
|
6
|
+
PAIN_008_001_12 = 'pain.008.001.12'
|
|
7
|
+
PAIN_008_002_02 = 'pain.008.002.02'
|
|
8
|
+
PAIN_008_003_02 = 'pain.008.003.02'
|
|
9
|
+
PAIN_001_001_03 = 'pain.001.001.03'
|
|
10
|
+
PAIN_001_001_09 = 'pain.001.001.09'
|
|
11
|
+
PAIN_001_001_13 = 'pain.001.001.13'
|
|
12
|
+
PAIN_001_002_03 = 'pain.001.002.03'
|
|
13
|
+
PAIN_001_003_03 = 'pain.001.003.03'
|
|
14
|
+
PAIN_001_001_03_CH_02 = 'pain.001.001.03.ch.02'
|
|
15
|
+
|
|
16
|
+
SCHEMA_FEATURES = {
|
|
17
|
+
PAIN_001_001_03 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: false,
|
|
18
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
19
|
+
PAIN_001_001_09 => { bic_tag: :BICFI, wrap_date: true, swiss: false, requires_bic: false,
|
|
20
|
+
org_bic_tag: :AnyBIC, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
21
|
+
PAIN_001_001_13 => { bic_tag: :BICFI, wrap_date: true, swiss: false, requires_bic: false,
|
|
22
|
+
org_bic_tag: :AnyBIC, instr_for_dbtr_agt_format: :structured, regulatory_reporting_version: :v10 },
|
|
23
|
+
PAIN_001_002_03 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: true,
|
|
24
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
25
|
+
PAIN_001_003_03 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: false,
|
|
26
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
27
|
+
PAIN_001_001_03_CH_02 => { bic_tag: :BIC, wrap_date: false, swiss: true, requires_bic: false,
|
|
28
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
29
|
+
PAIN_008_001_02 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: false,
|
|
30
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
31
|
+
PAIN_008_001_08 => { bic_tag: :BICFI, wrap_date: false, swiss: false, requires_bic: false,
|
|
32
|
+
org_bic_tag: :AnyBIC, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
33
|
+
PAIN_008_001_12 => { bic_tag: :BICFI, wrap_date: false, swiss: false, requires_bic: false,
|
|
34
|
+
org_bic_tag: :AnyBIC, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
35
|
+
PAIN_008_002_02 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: true,
|
|
36
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 },
|
|
37
|
+
PAIN_008_003_02 => { bic_tag: :BIC, wrap_date: false, swiss: false, requires_bic: false,
|
|
38
|
+
org_bic_tag: :BICOrBEI, instr_for_dbtr_agt_format: :text, regulatory_reporting_version: :v3 }
|
|
39
|
+
}.each_value(&:freeze).freeze
|
|
40
|
+
|
|
41
|
+
# Schemas that support LEI (Legal Entity Identifier) in FinInstnId and OrgId
|
|
42
|
+
LEI_SCHEMAS = [PAIN_001_001_09, PAIN_001_001_13, PAIN_008_001_08, PAIN_008_001_12].freeze
|
|
43
|
+
|
|
44
|
+
# Element order follows PostalAddress27 XSD sequence (the superset).
|
|
45
|
+
# Fields absent in older schemas are rejected by XSD validation.
|
|
46
|
+
POSTAL_ADDRESS_FIELDS = [
|
|
47
|
+
%i[CareOf care_of],
|
|
48
|
+
%i[Dept department],
|
|
49
|
+
%i[SubDept sub_department],
|
|
50
|
+
%i[StrtNm street_name],
|
|
51
|
+
%i[BldgNb building_number],
|
|
52
|
+
%i[BldgNm building_name],
|
|
53
|
+
%i[Flr floor],
|
|
54
|
+
%i[UnitNb unit_number],
|
|
55
|
+
%i[PstBx post_box],
|
|
56
|
+
%i[Room room],
|
|
57
|
+
%i[PstCd post_code],
|
|
58
|
+
%i[TwnNm town_name],
|
|
59
|
+
%i[TwnLctnNm town_location_name],
|
|
60
|
+
%i[DstrctNm district_name],
|
|
61
|
+
%i[CtrySubDvsn country_sub_division],
|
|
62
|
+
%i[Ctry country_code],
|
|
63
|
+
%i[AdrLine address_line1],
|
|
64
|
+
%i[AdrLine address_line2]
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
# Element order follows Contact13 XSD sequence (the superset).
|
|
68
|
+
# Fields absent in older schemas are rejected by XSD validation.
|
|
69
|
+
# Othr (OtherContact1) and PrefrdMtd are handled separately (complex/enum structures).
|
|
70
|
+
CONTACT_DETAILS_FIELDS = [
|
|
71
|
+
%i[NmPrfx name_prefix],
|
|
72
|
+
%i[Nm name],
|
|
73
|
+
%i[PhneNb phone_number],
|
|
74
|
+
%i[MobNb mobile_number],
|
|
75
|
+
%i[FaxNb fax_number],
|
|
76
|
+
%i[URLAdr url_address],
|
|
77
|
+
%i[EmailAdr email_address],
|
|
78
|
+
%i[EmailPurp email_purpose],
|
|
79
|
+
%i[JobTitl job_title],
|
|
80
|
+
%i[Rspnsblty responsibility],
|
|
81
|
+
%i[Dept department]
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
class Message
|
|
85
|
+
include ActiveModel::Validations
|
|
86
|
+
include SchemaValidation
|
|
87
|
+
include XmlBuilder
|
|
88
|
+
|
|
89
|
+
attr_reader :account, :grouped_transactions
|
|
90
|
+
attr_accessor :initiation_source_name, :initiation_source_provider
|
|
91
|
+
|
|
92
|
+
INITN_SRC_SCHEMAS = [PAIN_001_001_13].freeze
|
|
93
|
+
|
|
94
|
+
validates_presence_of :transactions
|
|
95
|
+
validates_length_of :initiation_source_name, within: 1..140, allow_nil: true
|
|
96
|
+
validates_length_of :initiation_source_provider, within: 1..35, allow_nil: true
|
|
97
|
+
validate do |record|
|
|
98
|
+
record.errors.add(:account, record.account.errors.full_messages) unless record.account.valid?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class_attribute :account_class, :transaction_class, :xml_main_tag, :known_schemas, instance_writer: false
|
|
102
|
+
|
|
103
|
+
# @param account_options [Hash] attributes for the debtor/creditor account (:name, :iban, :bic)
|
|
104
|
+
def initialize(account_options = {})
|
|
105
|
+
@grouped_transactions = {}
|
|
106
|
+
@account = account_class.new(account_options)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add a transaction to the message. The transaction is validated immediately.
|
|
110
|
+
# @param options [Hash] transaction attributes (see {Transaction} subclasses for valid keys)
|
|
111
|
+
# @raise [SEPA::ValidationError] if the transaction is invalid
|
|
112
|
+
def add_transaction(options)
|
|
113
|
+
transaction = transaction_class.new(options)
|
|
114
|
+
raise SEPA::ValidationError, transaction.errors.full_messages.join("\n") unless transaction.valid?
|
|
115
|
+
|
|
116
|
+
group = transaction_group(transaction)
|
|
117
|
+
@grouped_transactions[group] ||= []
|
|
118
|
+
@grouped_transactions[group] << transaction
|
|
119
|
+
@transactions = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Array<Transaction>] all transactions across all groups
|
|
123
|
+
def transactions
|
|
124
|
+
@transactions ||= grouped_transactions.values.flatten
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Generate the SEPA XML document for the given schema.
|
|
128
|
+
# @param schema_name [String] one of {known_schemas} (defaults to the first)
|
|
129
|
+
# @return [String] UTF-8 encoded XML
|
|
130
|
+
# @raise [SEPA::ValidationError] if the message or account is invalid
|
|
131
|
+
# @raise [SEPA::SchemaValidationError] if transactions are incompatible or XML fails XSD validation
|
|
132
|
+
def to_xml(schema_name = known_schemas.first)
|
|
133
|
+
raise SEPA::ValidationError, errors.full_messages.join("\n") unless valid?
|
|
134
|
+
raise SEPA::SchemaValidationError, "Incompatible with schema #{schema_name}!" unless schema_compatible?(schema_name)
|
|
135
|
+
|
|
136
|
+
xml_builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |builder|
|
|
137
|
+
builder.Document(xml_schema(schema_name)) do
|
|
138
|
+
builder.__send__(xml_main_tag) do
|
|
139
|
+
build_group_header(builder, schema_name)
|
|
140
|
+
build_payment_informations(builder, schema_name)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
validate_final_document!(xml_builder.doc, schema_name)
|
|
146
|
+
xml_builder.to_xml
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param selected_transactions [Array<Transaction>] subset to sum (defaults to all)
|
|
150
|
+
# @return [BigDecimal] total amount
|
|
151
|
+
def amount_total(selected_transactions = transactions)
|
|
152
|
+
selected_transactions.sum(&:amount)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Check if all transactions are compatible with the given schema.
|
|
156
|
+
# @param schema_name [String] one of {known_schemas}
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
# @raise [ArgumentError] if the schema is unknown
|
|
159
|
+
def schema_compatible?(schema_name)
|
|
160
|
+
raise ArgumentError, "Schema #{schema_name} is unknown!" unless known_schemas.include?(schema_name)
|
|
161
|
+
|
|
162
|
+
features = schema_features(schema_name)
|
|
163
|
+
return false if features[:requires_bic] && (account.bic.nil? || account.bic.empty?)
|
|
164
|
+
return false if @initiation_source_name && !INITN_SRC_SCHEMAS.include?(schema_name)
|
|
165
|
+
return false if account.agent_lei && !LEI_SCHEMAS.include?(schema_name)
|
|
166
|
+
return false if account.respond_to?(:initiating_party_lei) && account.initiating_party_lei && !LEI_SCHEMAS.include?(schema_name)
|
|
167
|
+
|
|
168
|
+
transactions.all? { |t| t.schema_compatible?(schema_name) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Set unique identifier for the message (max 35 chars, alphanumeric + punctuation).
|
|
172
|
+
# Validates and assigns immediately (fail-fast) rather than deferring to ActiveModel,
|
|
173
|
+
# because this field has a lazy default and must always be in a valid state once assigned.
|
|
174
|
+
# @param value [String] unique message ID (1-35 chars)
|
|
175
|
+
# @raise [ArgumentError] if value is not a valid string
|
|
176
|
+
def message_identification=(value)
|
|
177
|
+
raise ArgumentError, 'message_identification must be a string!' unless value.is_a?(String)
|
|
178
|
+
|
|
179
|
+
regex = %r{\A([A-Za-z0-9]|[+|?/\-:().,'\ ]){1,35}\z}
|
|
180
|
+
raise ArgumentError, "message_identification does not match #{regex}!" unless value.match?(regex)
|
|
181
|
+
|
|
182
|
+
@message_identification = value
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [String] unique message identifier (auto-generated if not set)
|
|
186
|
+
def message_identification
|
|
187
|
+
@message_identification ||= "MSG/#{SecureRandom.hex(14)}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Set creation date time for the message (ISO 8601 format).
|
|
191
|
+
# Validates and assigns immediately (fail-fast) rather than deferring to ActiveModel,
|
|
192
|
+
# because this field has a lazy default and must always be in a valid state once assigned.
|
|
193
|
+
# @note Rabobank (NL) only accepts the strict format YYYY-MM-DDTHH:MM:SS
|
|
194
|
+
# @param value [String] ISO 8601 datetime
|
|
195
|
+
# @raise [ArgumentError] if value does not match the expected format
|
|
196
|
+
def creation_date_time=(value)
|
|
197
|
+
raise ArgumentError, 'creation_date_time must be a string!' unless value.is_a?(String)
|
|
198
|
+
|
|
199
|
+
regex = /[0-9]{4}-[0-9]{2,2}-[0-9]{2,2}(?:\s|T)[0-9]{2,2}:[0-9]{2,2}:[0-9]{2,2}/
|
|
200
|
+
raise ArgumentError, "creation_date_time does not match #{regex}!" unless value.match?(regex)
|
|
201
|
+
|
|
202
|
+
@creation_date_time = value
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @return [String] ISO 8601 creation datetime (auto-generated if not set)
|
|
206
|
+
def creation_date_time
|
|
207
|
+
@creation_date_time ||= Time.now.iso8601
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Find the PmtInf ID for the batch containing a transaction with the given reference.
|
|
211
|
+
# @param transaction_reference [String] the transaction's EndToEndId reference
|
|
212
|
+
# @return [String, nil] the payment information identification, or nil if not found
|
|
213
|
+
def batch_id(transaction_reference)
|
|
214
|
+
grouped_transactions.each do |group, transactions|
|
|
215
|
+
return payment_information_identification(group) if transactions.any? { |transaction| transaction.reference == transaction_reference }
|
|
216
|
+
end
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [Array<String>] list of all PmtInf IDs in the message
|
|
221
|
+
def batches
|
|
222
|
+
grouped_transactions.keys.map { |group| payment_information_identification(group) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def schema_features(schema_name)
|
|
228
|
+
SCHEMA_FEATURES.fetch(schema_name) { raise ArgumentError, "Schema #{schema_name} is unknown!" }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @return {Hash<Symbol=>String>} xml schema information used in output xml
|
|
232
|
+
def xml_schema(schema_name)
|
|
233
|
+
if schema_features(schema_name)[:swiss]
|
|
234
|
+
{
|
|
235
|
+
xmlns: 'http://www.six-interbank-clearing.com/de/pain.001.001.03.ch.02.xsd',
|
|
236
|
+
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
|
237
|
+
'xsi:schemaLocation': 'http://www.six-interbank-clearing.com/de/pain.001.001.03.ch.02.xsd pain.001.001.03.ch.02.xsd'
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
{
|
|
241
|
+
xmlns: "urn:iso:std:iso:20022:tech:xsd:#{schema_name}",
|
|
242
|
+
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
|
243
|
+
'xsi:schemaLocation': "urn:iso:std:iso:20022:tech:xsd:#{schema_name} #{schema_name}.xsd"
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_group_header(builder, schema_name)
|
|
249
|
+
builder.GrpHdr do
|
|
250
|
+
builder.MsgId(message_identification)
|
|
251
|
+
builder.CreDtTm(creation_date_time)
|
|
252
|
+
builder.NbOfTxs(transactions.length)
|
|
253
|
+
builder.CtrlSum(format_amount(amount_total))
|
|
254
|
+
builder.InitgPty do
|
|
255
|
+
builder.Nm(account.name)
|
|
256
|
+
account.initiating_party_id(builder, schema_name)
|
|
257
|
+
build_contact_details(builder, account.contact_details)
|
|
258
|
+
end
|
|
259
|
+
build_initiation_source(builder, schema_name)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Unique and consecutive identifier (used for the <PmntInf> blocks)
|
|
264
|
+
def payment_information_identification(group)
|
|
265
|
+
suffix = "/#{grouped_transactions.keys.index(group) + 1}"
|
|
266
|
+
max_prefix_length = 35 - suffix.length
|
|
267
|
+
"#{message_identification[0, max_prefix_length]}#{suffix}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Returns a key to determine the group to which the transaction belongs
|
|
271
|
+
def transaction_group(transaction)
|
|
272
|
+
transaction
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def build_initiation_source(builder, schema_name)
|
|
276
|
+
return unless INITN_SRC_SCHEMAS.include?(schema_name) && @initiation_source_name
|
|
277
|
+
|
|
278
|
+
builder.InitnSrc do
|
|
279
|
+
builder.Nm(@initiation_source_name)
|
|
280
|
+
builder.Prvdr(@initiation_source_provider) if @initiation_source_provider
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|