libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

camt.kt (25051B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024-2025 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 package tech.libeufin.nexus.iso20022
     20 
     21 import tech.libeufin.common.*
     22 import tech.libeufin.nexus.*
     23 import tech.libeufin.ebics.XmlDestructor
     24 import java.io.InputStream
     25 import java.time.Instant
     26 import java.time.ZoneOffset
     27 import java.util.UUID
     28 import org.slf4j.Logger
     29 import org.slf4j.LoggerFactory
     30 
     31 private val logger: Logger = LoggerFactory.getLogger("libeufin-iso20022")
     32 
     33 sealed interface TxNotification {
     34     val executionTime: Instant
     35 }
     36 
     37 /** ID for incoming transactions */
     38 data class IncomingId(
     39     /** ISO20022 UETR */
     40     val uetr: UUID? = null,
     41     /** ISO20022 TxID */
     42     val txId: String? = null,
     43     /** ISO20022 AcctSvcrRef */
     44     val acctSvcrRef: String? = null,
     45 ) {
     46     constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef);
     47 
     48     fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!!
     49 
     50     override fun toString(): String = buildString {
     51         append('(')
     52         if (uetr != null) {
     53             append("uetr=")
     54             append(uetr.toString())
     55         }
     56         if (txId != null) {
     57             if (length != 1) append(" ")
     58             append("tx=")
     59             append(txId)
     60         }
     61         if (acctSvcrRef != null) {
     62             if (length != 1) append(" ")
     63             append("ref=")
     64             append(acctSvcrRef)
     65         }
     66         append(')')
     67     }
     68 }
     69 
     70 sealed interface OutId {}
     71 
     72 /** ID for outgoing transactions */
     73 data class OutgoingId(
     74     /** 
     75      * Unique msg ID generated by libeufin-nexus
     76      * ISO20022 MessageId
     77      **/
     78     val msgId: String? = null,
     79     /** 
     80      * Unique end-to-end ID generated by libeufin-nexus
     81      * ISO20022 EndToEndId or MessageId (retrocompatibility)
     82      **/
     83     val endToEndId: String? = null,
     84     /** 
     85      * Unique end-to-end ID generated by the bank
     86      * ISO20022 AcctSvcrRef
     87      **/
     88     val acctSvcrRef: String? = null,
     89 ): OutId {
     90     fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!!
     91     override fun toString(): String = buildString {
     92         append('(')
     93         if (msgId != null && msgId != endToEndId) {
     94             append("msg=")
     95             append(msgId.toString())
     96         }
     97         if (endToEndId != null) {
     98             if (length != 1) append(" ")
     99             append("e2e=")
    100             append(endToEndId)
    101         }
    102         if (acctSvcrRef != null) {
    103             if (length != 1) append(" ")
    104             append("ref=")
    105             append(acctSvcrRef)
    106         }
    107         append(')')
    108     }
    109 }
    110 
    111 /** ID for outgoing batches */
    112 data class BatchId(
    113     /** 
    114      * Unique msg ID generated by libeufin-nexus
    115      * ISO20022 MessageId
    116      **/
    117     val msgId: String,
    118     /** 
    119      * Unique end-to-end ID generated by the bank
    120      * ISO20022 AcctSvcrRef
    121      **/
    122     val acctSvcrRef: String? = null,
    123 ): OutId {
    124     fun ref(): String = msgId
    125     override fun toString(): String = buildString {
    126         append("(msg=")
    127         append(msgId)
    128         if (acctSvcrRef != null) {
    129             if (length != 1) append(" ")
    130             append("ref=")
    131             append(acctSvcrRef)
    132         }
    133         append(')')
    134     }
    135 }
    136 
    137 
    138 /** ISO20022 incoming payment */
    139 data class IncomingPayment(
    140     val id: IncomingId,
    141     val amount: TalerAmount,
    142     val creditFee: TalerAmount? = null,
    143     val subject: String?,
    144     override val executionTime: Instant,
    145     val debtor: IbanPayto?
    146 ): TxNotification {
    147     override fun toString(): String = buildString {
    148         append("IN ")
    149         append(executionTime.fmtDate())
    150         append(" ")
    151         append(amount)
    152         if (creditFee != null) {
    153             append("-")
    154             append(creditFee)
    155         }
    156         append(" ")
    157         append(id)
    158         if (debtor != null) {
    159             append(" debtor=")
    160             append(debtor.fmt())
    161         }
    162         if (subject != null) {
    163             append(" subject='")
    164             append(subject)
    165             append("'")
    166         }
    167     }
    168 }
    169 
    170 /** ISO20022 outgoing payment */
    171 data class OutgoingPayment(
    172     val id: OutgoingId,
    173     val amount: TalerAmount,
    174     val debitFee: TalerAmount? = null,
    175     val subject: String?,
    176     override val executionTime: Instant,
    177     val creditor: IbanPayto?
    178 ): TxNotification {
    179     override fun toString(): String = buildString {
    180         append("OUT ")
    181         append(executionTime.fmtDate())
    182         append(" ")
    183         append(amount)
    184         if (debitFee != null) {
    185             append("-")
    186             append(debitFee)
    187         }
    188         append(" ")
    189         append(id)
    190         if (creditor != null) {
    191             append(" creditor=")
    192             append(creditor.fmt())
    193         }
    194         if (subject != null) {
    195             append(" subject='")
    196             append(subject)
    197             append("'")
    198         }
    199     }
    200 }
    201 
    202 /** ISO20022 outgoing batch */
    203 data class OutgoingBatch(
    204     /** ISO20022 MessageId */
    205     val msgId: String,
    206     override val executionTime: Instant,
    207 ): TxNotification {
    208     override fun toString(): String {
    209         return "BATCH ${executionTime.fmtDate()} $msgId"
    210     }
    211 }
    212 
    213 /** ISO20022 outgoing reversal */
    214 data class OutgoingReversal(
    215     /** ISO20022 EndToEndId */
    216     val endToEndId: String,
    217     /** ISO20022 MessageId */
    218     val msgId: String? = null,
    219     val reason: String?,
    220     override val executionTime: Instant
    221 ): TxNotification {
    222     override fun toString(): String {
    223         val msgIdFmt = if (msgId == null) "" else "$msgId."
    224         return "BOUNCE ${executionTime.fmtDate()} $msgIdFmt$endToEndId: $reason"
    225     }
    226 }
    227 
    228 private class IncompleteTx(val msg: String): Exception(msg)
    229 
    230 private enum class Kind {
    231     CRDT,
    232     DBIT
    233 }
    234 
    235 /** Parse a payto */
    236 private fun XmlDestructor.payto(prefix: String): IbanPayto? {
    237     return opt("RltdPties") { 
    238         val iban = opt("${prefix}Acct")?.one("Id")?.opt("IBAN")?.text()
    239         if (iban != null) {
    240             val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() }
    241             // TODO more performant option
    242             ibanPayto(iban, name)
    243         } else {
    244             null
    245         }
    246     }
    247 }
    248 
    249 /** Check if an entry status is BOOK */
    250 private fun XmlDestructor.isBooked(): Boolean {
    251     // We check at the Sts or Sts/Cd level for retrocompatibility
    252     return one("Sts") {
    253         val status = opt("Cd")?.text() ?: text()
    254         status == "BOOK"
    255     }
    256 }
    257 
    258 /** Parse the instruction execution date */
    259 private fun XmlDestructor.executionDate(): Instant {
    260     // Value date if present else booking date
    261     val date = opt("ValDt") ?: one("BookgDt")
    262     val parsed = date.opt("Dt") {
    263         date().atStartOfDay()
    264     } ?: date.one("DtTm") {
    265         dateTime()
    266     }
    267     return parsed.toInstant(ZoneOffset.UTC)
    268 }
    269 
    270 /** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */
    271 private fun XmlDestructor.outgoingId(ref: String?): OutId = 
    272     opt("Refs") {
    273         val endToEndId = opt("EndToEndId")?.text()
    274         val msgId = opt("MsgId")?.text()
    275         val ref = if (ref != "NOTPROVIDED") ref else null
    276         if (msgId != null && endToEndId == null) {
    277             // This is a batch representation
    278             BatchId(msgId, ref)
    279         } else if (endToEndId == "NOTPROVIDED") {
    280             // If not set use MsgId as end-to-end ID for retrocompatibility
    281             OutgoingId(msgId, msgId, ref)
    282         } else {
    283             OutgoingId(msgId, endToEndId, ref)
    284         }
    285     } ?: OutgoingId(acctSvcrRef = ref)
    286 
    287 /** Parse transaction ids as provided by bank*/
    288 private fun XmlDestructor.incomingId(ref: String?): IncomingId =
    289     opt("Refs") {
    290         val uetr = opt("UETR")?.uuid()
    291         val txId = opt("TxId")?.text()
    292         IncomingId(uetr, txId, ref)
    293     } ?: IncomingId(acctSvcrRef = ref)
    294 
    295 
    296 /** Parse and format transaction return reasons */
    297 private fun XmlDestructor.returnReason(): String = opt("RtrInf") {
    298     val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
    299     val info = map("AddtlInf") { text() }.joinToString("")
    300     buildString {
    301         append("${code.isoCode} '${code.description}'")
    302         if (info.isNotEmpty()) {
    303             append(" - '$info'")
    304         }
    305     }
    306 } ?: opt("RmtInf") {
    307     map("Ustrd") { text() }.joinToString("")
    308 } ?: ""
    309 
    310 /** Parse amount */
    311 private fun XmlDestructor.amount() = one("Amt") {
    312     val currency = attr("Ccy")
    313     val amount = text()
    314     val concat = if (amount.startsWith('.')) {
    315         "$currency:0$amount"
    316     } else {
    317         "$currency:$amount"
    318     }
    319     TalerAmount(concat)
    320 }
    321 
    322 data class ComplexAmount(
    323     // Transaction amount
    324     val amount: TalerAmount,
    325     // The applied fee
    326     private val fee: TalerAmount,
    327 ) {
    328     /// The fees to register in database
    329     fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee }
    330 
    331     /// Check that entry and tx amount are compatible and return the result
    332     fun resolve(child: ComplexAmount): ComplexAmount {
    333         // Most time transaction will match
    334         if (this.amount == child.amount && this.fee == child.fee) {
    335             return this
    336         }
    337         
    338         // Or one of the level is missing the fee
    339         if (
    340             (child.amount > child.fee && child.amount - child.fee == this.amount) ||
    341             this.amount - this.fee == child.amount
    342         ) {
    343             if (child.fee.isZero()) {
    344                 return this
    345             } else {
    346                 return child
    347             }
    348         }
    349         
    350         // Or the conversion information are only present at the entry layer
    351         if (child.amount.currency != this.amount.currency) {
    352             return this
    353         }
    354 
    355         throw Error("Amount mismatch, got ${this} in the entry and ${child} in the tx")
    356     }
    357 }
    358 
    359 private fun XmlDestructor.complexAmount(charges: List<ChargeRecord>): ComplexAmount? {
    360     // Amount before charges
    361     var amount = opt("Amt") {
    362         val currency = attr("Ccy")
    363         // In case of fee overflow it's possible to have a negative amount here
    364         // We ignore this as it will be handled elsewhere correctly
    365         val amount = text().trimStart('-')
    366         TalerAmount("$currency:0$amount")
    367     } ?: return null
    368 
    369     var fee: TalerAmount = TalerAmount.zero(amount.currency)
    370 
    371     for (chr in charges) {
    372         if (chr.included && !chr.amount.isZero()) {
    373             fee += chr.amount
    374             if (chr.kind == Kind.DBIT) {
    375                 if (chr.bearer == ChargeBearer.DEBT) {
    376                     if (chr.amount > amount) {
    377                         // This can happen when an incoming transaction fail because of debit fee
    378                         amount = chr.amount - amount
    379                     } else {
    380                         amount -= chr.amount
    381                     }
    382                 } else if (chr.bearer == ChargeBearer.CRED) {
    383                     amount += chr.amount
    384                 } else {
    385                     throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}")
    386                 }
    387             }
    388         }
    389     }
    390     
    391     return ComplexAmount(amount, fee)
    392 }
    393 
    394 /** Parse bank transaction code */
    395 private fun XmlDestructor.bankTransactionCode(): BankTransactionCode {
    396     return one("BkTxCd").one("Domn") {
    397         val domain = one("Cd").enum<ExternalBankTransactionDomainCode>()
    398         one("Fmly") {
    399             val family = one("Cd").enum<ExternalBankTransactionFamilyCode>()
    400             val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>()
    401             
    402             BankTransactionCode(domain, family, subFamily)
    403         }
    404     }
    405 }
    406 
    407 /** Parse optional bank transaction code */
    408 private fun XmlDestructor.optBankTransactionCode(): BankTransactionCode? {
    409     return opt("BkTxCd")?.one("Domn") {
    410         val domain = one("Cd").enum<ExternalBankTransactionDomainCode>()
    411         one("Fmly") {
    412             val family = one("Cd").enum<ExternalBankTransactionFamilyCode>()
    413             val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>()
    414             
    415             BankTransactionCode(domain, family, subFamily)
    416         }
    417     }
    418 }
    419 
    420 /** Parse transaction wire transfer subject */
    421 private fun XmlDestructor.wireTransferSubject(): String? = opt("RmtInf") {
    422     map("Ustrd") { text() }.joinToString("").trim()
    423 }
    424 
    425 /** Parse account information */
    426 private fun XmlDestructor.account(): Pair<String, String?> = one("Acct") {
    427     Pair(
    428         one("Id") {
    429             (opt("IBAN") ?: one("Othr").one("Id")).text()
    430         },
    431         opt("Ccy")?.text()
    432     )
    433 }
    434 
    435 private data class ChargeRecord(
    436     val amount: TalerAmount,
    437     val kind: Kind,
    438     val included: Boolean,
    439     val bearer: ChargeBearer
    440 )
    441 private fun XmlDestructor.charges(): List<ChargeRecord> = opt("Chrgs")?.map("Rcrd") {
    442     val amount = amount()
    443     val kind = opt("CdtDbtInd")?.enum<Kind>() ?: Kind.CRDT
    444     val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec
    445     val bearer = opt("Br")?.enum<ChargeBearer>() ?: ChargeBearer.SHAR
    446     ChargeRecord(amount, kind, included, bearer)
    447 } ?: emptyList()
    448 
    449 data class AccountTransactions(
    450     val iban: String?,
    451     val currency: String?,
    452     val txs: List<TxNotification>
    453 ) {
    454     companion object {
    455         internal fun fromParts(iban: String?, currency: String?, txsInfos: List<TxInfo>): AccountTransactions {
    456             val txs = txsInfos.mapNotNull {
    457                 try {
    458                     it.parse()
    459                 } catch (e: IncompleteTx) {
    460                     // TODO: add more info in doc or in log message?
    461                     logger.warn("skip incomplete tx: ${e.msg}")
    462                     null
    463                 }    
    464             }
    465             return AccountTransactions(iban, currency, txs)
    466         }
    467     }
    468 }
    469 
    470 /** Parse camt.054 or camt.053 file */
    471 fun parseTx(notifXml: InputStream): List<AccountTransactions> {
    472     /*
    473         In ISO 20022 specifications, most fields are optional and the same information 
    474         can be written several times in different places. For libeufin, we're only 
    475         interested in a subset of the available values that can be found in both camt.052,
    476         camt.053 and camt.054. This function should not fail on legitimate files and should 
    477         simply warn when available information are insufficient.
    478 
    479         EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the 
    480         UETR (unique end-to-end transaction reference), which is a universally unique 
    481         identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) 
    482         is a unique identification as assigned by the first instructing agent. As its format 
    483         is ambiguous, its uniqueness is not guaranteed by the standard, and it is only 
    484         supposed to be unique for a “pre-agreed period”, whatever that means. These two 
    485         identifiers are optional in the standard, but have the advantage of being unique 
    486         and can be used to track a transaction between banks so we use them when available.
    487 
    488         It is also possible to use AccountServicerReference, which is a unique reference 
    489         assigned by the account servicing institution. They can be present at several levels
    490         (batch level, transaction level, etc.) and are often optional. They also have the 
    491         disadvantage of being known only by the account servicing institution. They should 
    492         therefore only be used as a last resort.
    493     */
    494     logger.trace("Parse transactions camt file")
    495     val accountTxs = mutableListOf<AccountTransactions>()
    496 
    497     /** Common parsing logic for camt.052, camt.053 and camt.054 */
    498     fun XmlDestructor.parseInner() {
    499         val (iban, currency) = account()
    500         val txInfos = mutableListOf<TxInfo>()
    501         val batches = each("Ntry") {
    502             if (!isBooked()) return@each
    503             val entryCode = bankTransactionCode()
    504             val reversal = opt("RvslInd")?.text() == "true"
    505             val entryKind = opt("CdtDbtInd")?.enum<Kind>();
    506             val entryRef = opt("AcctSvcrRef")?.text()
    507             val bookDate = executionDate()
    508             val entryCharges = charges()
    509             val entryAmount = complexAmount(entryCharges)!!
    510 
    511             // When an entry only contain a single transactions information will sometimes only be stored at the entry level
    512             val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each 
    513             val unique = tmp.size == 1
    514 
    515             for (it in tmp) {it.run {
    516                 // Check information are present and coherent
    517                 val kind = requireNotNull(opt("CdtDbtInd")?.enum<Kind>() ?: entryKind) { "WTF" }
    518 
    519                 // Sometimes the transaction level have a more precise bank transaction code
    520                 val code = optBankTransactionCode() ?: entryCode
    521 
    522                 // Amount
    523                 val amount = if (unique) {
    524                     // When unique the charges can be only at the entry level
    525                     val txCharges = charges()
    526                     val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges)
    527                     // Check coherence
    528                     if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount
    529                 } else {
    530                     // When many inner transaction the entry level is an aggregate of them
    531                     // We only use the transaction level information
    532                     requireNotNull(complexAmount(charges())) { "Missing tx amount" }
    533                 }
    534 
    535                 // We can only use the entry ref as the transaction ref if there is a single transaction in the batch
    536                 val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null
    537                 
    538                 if (code.isReversal() || reversal) {
    539                     val outgoingId = outgoingId(ref)
    540                     when (kind) {
    541                         Kind.CRDT -> {
    542                             val reason = returnReason()
    543                             txInfos.add(TxInfo.CreditReversal(
    544                                 bookDate = bookDate,
    545                                 id = outgoingId,
    546                                 reason = reason,
    547                                 code = code
    548                             ))
    549                         }
    550                         Kind.DBIT -> {
    551                             val id = incomingId(ref)
    552                             val subject = wireTransferSubject()
    553                             val debtor = payto("Dbtr")
    554                             val fee = amount.fee()
    555                             txInfos.add(TxInfo.Credit(
    556                                 bookDate = bookDate,
    557                                 id = id,
    558                                 amount = amount.amount,
    559                                 subject = subject,
    560                                 debtor = debtor,
    561                                 code = code,
    562                                 creditFee = fee
    563                             ))
    564                         }
    565                     }
    566                 } else {
    567                     val subject = wireTransferSubject()
    568                     when (kind) {
    569                         Kind.CRDT -> {
    570                             val id = incomingId(ref)
    571                             val debtor = payto("Dbtr")
    572                             txInfos.add(TxInfo.Credit(
    573                                 bookDate = bookDate,
    574                                 id = id,
    575                                 amount = amount.amount,
    576                                 subject = subject,
    577                                 debtor = debtor,
    578                                 code = code,
    579                                 creditFee = amount.fee()
    580                             ))
    581                         }
    582                         Kind.DBIT -> {
    583                             val outgoingId = outgoingId(ref)
    584                             val creditor = payto("Cdtr")
    585                             txInfos.add(TxInfo.Debit(
    586                                 bookDate = bookDate,
    587                                 id = outgoingId,
    588                                 amount = amount.amount,
    589                                 subject = subject,
    590                                 creditor = creditor,
    591                                 code = code,
    592                                 debitFee = amount.fee()
    593                             ))
    594                         }
    595                     }
    596                 }
    597             }}
    598         }
    599         accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos))
    600     }
    601     XmlDestructor.parse(notifXml, "Document") {
    602         // Camt.053
    603         opt("BkToCstmrStmt")?.each("Stmt") { parseInner() }
    604         // Camt.052
    605         opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() }
    606         // Camt.054
    607         opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() }
    608     }
    609     return accountTxs
    610 }
    611 
    612 sealed interface TxInfo {
    613     data class CreditReversal(
    614         val bookDate: Instant,
    615         val code: BankTransactionCode,
    616         val id: OutId,
    617         val reason: String
    618     ): TxInfo
    619     data class Credit(
    620         val bookDate: Instant,
    621         val code: BankTransactionCode,
    622         val id: IncomingId,
    623         val amount: TalerAmount,
    624         val creditFee: TalerAmount?,
    625         val subject: String?,
    626         val debtor: IbanPayto?
    627     ): TxInfo
    628     data class Debit(
    629         val bookDate: Instant,
    630         val code: BankTransactionCode,
    631         val id: OutId,
    632         val amount: TalerAmount,
    633         val debitFee: TalerAmount?,
    634         val subject: String?,
    635         val creditor: IbanPayto?
    636     ): TxInfo
    637 
    638     fun parse(): TxNotification {
    639         return when (this) {
    640             is TxInfo.CreditReversal -> {
    641                 if (id !is OutgoingId || id.endToEndId == null) 
    642                     throw IncompleteTx("missing unique ID for Credit reversal $id")
    643                 OutgoingReversal(
    644                     endToEndId = id.endToEndId!!,
    645                     msgId = id.msgId,
    646                     reason = reason,
    647                     executionTime = bookDate
    648                 )
    649             }
    650             is TxInfo.Credit -> {
    651                 if (id.uetr == null && id.txId == null && id.acctSvcrRef == null)
    652                     throw IncompleteTx("missing unique ID for Credit $id")
    653                 IncomingPayment(
    654                     amount = amount,
    655                     creditFee = creditFee,
    656                     id = id,
    657                     debtor = debtor,
    658                     executionTime = bookDate,
    659                     subject = subject,
    660                 )
    661             }
    662             is TxInfo.Debit -> {
    663                 when (id) {
    664                     is OutgoingId -> {
    665                         if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) {
    666                             throw IncompleteTx("missing unique ID for Debit $id")
    667                         } else {
    668                             OutgoingPayment(
    669                                 id = OutgoingId(
    670                                     endToEndId = id.endToEndId,
    671                                     acctSvcrRef = id.acctSvcrRef,
    672                                     msgId = id.msgId,
    673                                 ),
    674                                 amount = amount,
    675                                 debitFee = debitFee,
    676                                 executionTime = bookDate,
    677                                 creditor = creditor,
    678                                 subject = subject
    679                             )
    680                         }
    681                     }
    682                     is BatchId -> {
    683                         OutgoingBatch(
    684                             msgId = id.msgId,
    685                             executionTime = bookDate,
    686                         )
    687                     }
    688                 }
    689             }
    690         }
    691     }
    692 }
    693 
    694 data class BankTransactionCode(
    695     val domain: ExternalBankTransactionDomainCode,
    696     val family: ExternalBankTransactionFamilyCode,
    697     val subFamily: ExternalBankTransactionSubFamilyCode
    698 ) {
    699     fun isReversal(): Boolean = REVERSAL_CODE.contains(subFamily)
    700     fun isPayment(): Boolean = domain == ExternalBankTransactionDomainCode.PMNT || subFamily == ExternalBankTransactionSubFamilyCode.PSTE
    701 
    702     override fun toString(): String = 
    703         "${domain.name} ${family.name} ${subFamily.name} - '${domain.description}' '${family.description}' '${subFamily.description}'"
    704 
    705     companion object {
    706         private val REVERSAL_CODE = setOf(
    707             ExternalBankTransactionSubFamilyCode.RPCR,
    708             ExternalBankTransactionSubFamilyCode.RRTN,
    709             ExternalBankTransactionSubFamilyCode.PSTE,
    710         )
    711     }
    712 }