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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +23 -0
  3. data/README.md +117 -0
  4. data/lib/schema/pain.001.001.03.ch.02.xsd +1212 -0
  5. data/lib/schema/pain.001.001.03.xsd +921 -0
  6. data/lib/schema/pain.001.001.09.xsd +1114 -0
  7. data/lib/schema/pain.001.001.13.xsd +1251 -0
  8. data/lib/schema/pain.001.002.03.xsd +450 -0
  9. data/lib/schema/pain.001.003.03.xsd +474 -0
  10. data/lib/schema/pain.008.001.02.xsd +879 -0
  11. data/lib/schema/pain.008.001.08.xsd +1106 -0
  12. data/lib/schema/pain.008.001.12.xsd +1135 -0
  13. data/lib/schema/pain.008.002.02.xsd +597 -0
  14. data/lib/schema/pain.008.003.02.xsd +614 -0
  15. data/lib/sepa_rator/account/address.rb +71 -0
  16. data/lib/sepa_rator/account/contact_details.rb +70 -0
  17. data/lib/sepa_rator/account/creditor_account.rb +16 -0
  18. data/lib/sepa_rator/account/creditor_address.rb +5 -0
  19. data/lib/sepa_rator/account/debtor_account.rb +20 -0
  20. data/lib/sepa_rator/account/debtor_address.rb +5 -0
  21. data/lib/sepa_rator/account.rb +64 -0
  22. data/lib/sepa_rator/concerns/attribute_initializer.rb +40 -0
  23. data/lib/sepa_rator/concerns/regulatory_reporting_validator.rb +111 -0
  24. data/lib/sepa_rator/concerns/schema_validation.rb +41 -0
  25. data/lib/sepa_rator/concerns/xml_builder.rb +111 -0
  26. data/lib/sepa_rator/converter.rb +46 -0
  27. data/lib/sepa_rator/error.rb +15 -0
  28. data/lib/sepa_rator/message/credit_transfer.rb +221 -0
  29. data/lib/sepa_rator/message/direct_debit.rb +153 -0
  30. data/lib/sepa_rator/message.rb +284 -0
  31. data/lib/sepa_rator/transaction/credit_transfer_transaction.rb +178 -0
  32. data/lib/sepa_rator/transaction/direct_debit_transaction.rb +104 -0
  33. data/lib/sepa_rator/transaction.rb +114 -0
  34. data/lib/sepa_rator/validator.rb +99 -0
  35. data/lib/sepa_rator/version.rb +5 -0
  36. data/lib/sepa_rator.rb +27 -0
  37. 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