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 }