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 }