libeufin

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

InitiatedDAO.kt (14430B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024, 2025, 2026 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 
     20 package tech.libeufin.nexus.db
     21 
     22 import tech.libeufin.common.asInstant
     23 import tech.libeufin.common.db.*
     24 import tech.libeufin.common.micros
     25 import tech.libeufin.nexus.iso20022.*
     26 import java.time.Instant
     27 
     28 /** Data access logic for initiated outgoing payments */
     29 class InitiatedDAO(private val db: Database) {
     30     private val UNSETTLED_FILTER = 
     31         "status NOT IN (${SubmissionState.SETTLED.joinToString(",") { "'$it'" }})"
     32     private val PENDING_FILTER =
     33         "status IN (${SubmissionState.PENDING.joinToString(",") { "'$it'" }})"
     34 
     35     /** Outgoing payments initiation result */
     36     sealed interface PaymentInitiationResult {
     37         data class Success(val id: Long): PaymentInitiationResult
     38         data object RequestUidReuse: PaymentInitiationResult
     39     }
     40 
     41     /** Initiate a new payment */
     42     suspend fun create(payment: InitiatedPayment): PaymentInitiationResult = db.serializable(
     43         """
     44         INSERT INTO initiated_outgoing_transactions (
     45              amount
     46             ,subject
     47             ,credit_payto
     48             ,initiation_time
     49             ,end_to_end_id
     50         ) VALUES ((?,?)::taler_amount,?,?,?,?)
     51         RETURNING initiated_outgoing_transaction_id
     52         """
     53     ) { 
     54         // TODO check payto uri
     55         bind(payment.amount)
     56         bind(payment.subject)
     57         bind(payment.creditor.toString())
     58         bind(payment.initiationTime)
     59         bind(payment.endToEndId)
     60         oneUniqueViolation(PaymentInitiationResult.RequestUidReuse) {
     61             PaymentInitiationResult.Success(it.getLong("initiated_outgoing_transaction_id"))
     62         }
     63     }
     64 
     65     /** Register submission success of order [orderId] for batch [id] at [timestamp] */
     66     suspend fun batchSubmissionSuccess(
     67         id: Long,
     68         timestamp: Instant,
     69         orderId: String?
     70     ) = db.serializableTransaction { tx ->
     71         // Update batch status
     72         val updated = tx.withStatement(
     73             """
     74             UPDATE initiated_outgoing_batches 
     75             SET  status = 'pending'
     76                 ,submission_date = ?
     77                 ,status_msg = NULL
     78                 ,order_id = ?
     79                 ,submission_counter = submission_counter + 1
     80             WHERE initiated_outgoing_batch_id = ? AND order_id IS NULL
     81             """
     82         ) {
     83             bind(timestamp)
     84             bind(orderId)
     85             bind(id)
     86             executeUpdate()
     87         }
     88         if (updated > 0) {
     89             // Update unsettled batch's transaction status
     90             tx.withStatement(
     91                 """
     92                 UPDATE initiated_outgoing_transactions 
     93                 SET status = 'pending', status_msg = NULL
     94                 WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
     95                 """
     96             ) {
     97                 bind(id)
     98                 executeUpdate()
     99             }
    100         }
    101     }
    102 
    103     /** Register submission failure with [msg] for batch [id] at [timestamp]*/
    104     suspend fun batchSubmissionFailure(
    105         id: Long,
    106         timestamp: Instant,
    107         msg: String?,
    108         permanent: Boolean = false
    109     ) = db.serializableTransaction { tx ->
    110         // Update batch status
    111         tx.withStatement(
    112             """
    113             UPDATE initiated_outgoing_batches
    114             SET  status = ?::submission_state
    115                 ,submission_date = ?
    116                 ,status_msg = ?
    117                 ,submission_counter = submission_counter + 1
    118             WHERE initiated_outgoing_batch_id = ?
    119             """
    120         ) {
    121             if (permanent) {
    122                 bind(StatusUpdate.permanent_failure)
    123             } else {
    124                 bind(StatusUpdate.transient_failure)
    125             }
    126             bind(timestamp)
    127             bind(msg)
    128             bind(id)
    129             executeUpdate()
    130         }
    131         // Update unsettled batch's transaction status
    132         tx.withStatement(
    133             """
    134             UPDATE initiated_outgoing_transactions 
    135             SET status = ?::submission_state, status_msg = ?
    136             WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
    137             """
    138         ) {
    139             if (permanent) {
    140                 bind(StatusUpdate.permanent_failure)
    141             } else {
    142                 bind(StatusUpdate.transient_failure)
    143             }
    144             bind(msg)
    145             bind(id)
    146             executeUpdate()
    147         }
    148     }
    149 
    150     /** Register order step [msg] for [orderId] */
    151     suspend fun orderStep(orderId: String, msg: String) = db.serializableTransaction { tx ->
    152         // Update pending batch status
    153         val batchId = tx.withStatement(
    154             """
    155             UPDATE initiated_outgoing_batches 
    156             SET status = 'pending', status_msg = ?
    157             WHERE order_id = ? AND $PENDING_FILTER
    158             RETURNING initiated_outgoing_batch_id
    159             """
    160         ) {
    161             bind(msg)
    162             bind(orderId)
    163             oneOrNull { it.getLong(1)  }
    164         }
    165         if (batchId != null) {
    166             // Update pending batch's transaction status
    167             tx.withStatement(
    168                 """
    169                 UPDATE initiated_outgoing_transactions 
    170                 SET status = 'pending', status_msg = ?
    171                 WHERE initiated_outgoing_batch_id = ? AND $PENDING_FILTER
    172                 """
    173             ) {
    174                 bind(msg)
    175                 bind(batchId)
    176                 executeUpdate()
    177             }
    178         }
    179     }
    180 
    181     /** Register order success for [orderId] and return message_id if found */
    182     suspend fun orderSuccess(orderId: String): String? = db.serializableTransaction { tx ->
    183        // Update batch status
    184         val result = tx.withStatement(
    185             """
    186             UPDATE initiated_outgoing_batches 
    187             SET status = 'success'
    188             WHERE order_id = ?
    189             RETURNING initiated_outgoing_batch_id, message_id
    190             """
    191         ) {
    192             bind(orderId)
    193             oneOrNull { 
    194                 Pair(
    195                     it.getLong("initiated_outgoing_batch_id"),
    196                     it.getString("message_id")
    197                 )
    198             }
    199         }
    200         if (result == null) return@serializableTransaction null
    201         val (batchId, messageId) = result
    202         // Update unsettled batch's transaction status
    203         tx.withStatement(
    204             """
    205             UPDATE initiated_outgoing_transactions 
    206             SET status = 'pending'
    207             WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
    208             """
    209         ) {
    210             bind(batchId)
    211             executeUpdate()
    212         }
    213         messageId
    214     }
    215 
    216     /** Register order failure for [orderId] and return message_id and previous status_msg if found */
    217     suspend fun orderFailure(orderId: String): Pair<String, String?>? = db.serializableTransaction { tx ->
    218         // Update batch status
    219         val result = tx.withStatement(
    220             """
    221             UPDATE initiated_outgoing_batches 
    222             SET status = 'permanent_failure'
    223             WHERE order_id = ?
    224             RETURNING initiated_outgoing_batch_id, message_id, status_msg
    225             """
    226         ) {
    227             bind(orderId)
    228             oneOrNull { 
    229                 Triple(
    230                     it.getLong("initiated_outgoing_batch_id"),
    231                     it.getString("message_id"),
    232                     it.getString("status_msg")
    233                 )
    234             }
    235         }
    236         if (result == null) return@serializableTransaction null
    237         val (batchId, messageId, msg) = result
    238         // Update batch's transaction status
    239         tx.withStatement(
    240             """
    241             UPDATE initiated_outgoing_transactions 
    242             SET status = 'permanent_failure'
    243             WHERE initiated_outgoing_batch_id = ?
    244             """
    245         ) {
    246             bind(batchId)
    247             executeUpdate()
    248         }
    249         Pair(messageId, msg)
    250     }
    251 
    252     /** Register payment status [state] with [msg] for batch [msgId] */
    253     suspend fun batchStatusUpdate(msgId: String, state: StatusUpdate, msg: String?) = db.serializable(
    254         "SELECT out_ok FROM batch_status_update(?,?::submission_state,?)"
    255     ) { 
    256         bind(msgId)
    257         bind(state)
    258         bind(msg)
    259         one {
    260             it.getBoolean(1)
    261         }
    262     }
    263 
    264     /** Register payment status [state] with [msg] for transaction [endToEndId] in batch [msgId] */
    265     suspend fun txStatusUpdate(endToEndId: String, msgId: String?, state: StatusUpdate, msg: String?) = db.serializable(
    266         "SELECT out_ok FROM tx_status_update(?,?,?::submission_state,?)"
    267     ) {
    268         bind(endToEndId)
    269         bind(msgId)
    270         bind(state)
    271         bind(msg)
    272         one {
    273             it.getBoolean(1)
    274         }
    275     }
    276 
    277     /** Unsettled initiated payment in batch [msgId] */
    278     suspend fun unsettledTxInBatch(msgId: String, executionTime: Instant) = db.serializable(
    279         """
    280         SELECT end_to_end_id
    281               ,(amount).val as amount_val
    282               ,(amount).frac as amount_frac
    283               ,subject
    284               ,credit_payto
    285         FROM initiated_outgoing_transactions
    286             JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
    287         WHERE message_id = ?
    288             AND initiated_outgoing_transactions.$UNSETTLED_FILTER
    289         """
    290     ) { 
    291         bind(msgId)
    292         all {
    293             OutgoingPayment(
    294                 id = OutgoingId(
    295                     msgId = msgId,
    296                     endToEndId = it.getString("end_to_end_id"),
    297                     acctSvcrRef = null
    298                 ),
    299                 amount = it.getAmount("amount", db.currency),
    300                 subject = it.getString("subject"),
    301                 executionTime = executionTime,
    302                 creditor = it.getIbanPayto("credit_payto")
    303             )
    304         }
    305     }
    306 
    307     suspend fun ack(id: Long): Boolean {
    308         return db.serializable("UPDATE initiated_outgoing_transactions SET awaiting_ack=false WHERE initiated_outgoing_transaction_id = ?") {
    309             bind(id)
    310             executeUpdateCheck()
    311         }
    312     }
    313 
    314     /** Group unbatched transaction into a single batch */
    315     suspend fun batch(timestamp: Instant, ebicsId: String, requireAck: Boolean) {
    316         db.serializable("SELECT FROM batch_outgoing_transactions(?, ?, ?)") {
    317             bind(timestamp)
    318             bind(ebicsId)
    319             bind(requireAck)
    320             executeQuery()
    321         }
    322     }
    323 
    324     /** List every initiated payment pending submission in the order they should be submitted */
    325     suspend fun submittable(): List<PaymentBatch> {
    326         val selectPart = """
    327             SELECT initiated_outgoing_batch_id, message_id, creation_date, (sum).val as sum_val, (sum).frac as sum_frac
    328             FROM initiated_outgoing_batches
    329         """
    330         return db.serializableTransaction { tx ->
    331             // We want to maximize the number of successfully submitted batches in the event 
    332             // of a malformed transaction or a persistent error classified as transient. We send 
    333             // the unsubmitted batches first, starting with the oldest by creation time.
    334             // This is the happy path, giving every batch a chance while being fair on the
    335             // basis of creation date. 
    336             // Then we retry the failed batches, starting with the oldest by submission time.
    337             // This the bad path retrying each failed batch applying a rotation based on 
    338             // resubmission time.
    339             val batches = tx.withStatement(
    340                 """
    341                 ($selectPart WHERE status='unsubmitted' ORDER BY creation_date)
    342                 UNION ALL
    343                 ($selectPart WHERE status='transient_failure' ORDER BY submission_date)
    344                 """
    345             ) {
    346                 all {
    347                     PaymentBatch(
    348                         id = it.getLong("initiated_outgoing_batch_id"),
    349                         messageId = it.getString("message_id"),
    350                         creationDate = it.getLong("creation_date").asInstant(),
    351                         sum = it.getAmount("sum", db.currency),
    352                         payments = emptyList()
    353                     )
    354                 }
    355             }.associate { it.id to Pair(it, mutableListOf<InitiatedPayment>()) }
    356 
    357             // Then load transactions
    358             tx.withStatement(
    359                 """
    360                 SELECT
    361                     initiated_outgoing_transaction_id
    362                     ,(amount).val as amount_val
    363                     ,(amount).frac as amount_frac
    364                     ,subject
    365                     ,credit_payto
    366                     ,initiated_outgoing_transactions.initiation_time
    367                     ,end_to_end_id
    368                     ,initiated_outgoing_batch_id
    369                 FROM initiated_outgoing_transactions
    370                     JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
    371                 WHERE initiated_outgoing_batches.status IN ('unsubmitted', 'transient_failure')
    372                 """
    373             ) {
    374                 all {
    375                     val payment = InitiatedPayment(
    376                         id = it.getLong("initiated_outgoing_transaction_id"),
    377                         amount = it.getAmount("amount", db.currency),
    378                         creditor = it.getIbanPayto("credit_payto"),
    379                         subject = it.getString("subject"),
    380                         initiationTime = it.getLong("initiation_time").asInstant(),
    381                         endToEndId = it.getString("end_to_end_id")
    382                     )
    383                     val batchId = it.getLong("initiated_outgoing_batch_id")
    384                     batches[batchId]!!.second.add(payment)
    385                     Unit
    386                 }
    387             }
    388 
    389             batches.values.map { (it, payments) -> it.copy(payments = payments) }
    390         }
    391     }
    392 }