/*
* This file is part of LibEuFin.
* Copyright (C) 2023-2024 Taler Systems S.A.
* LibEuFin is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.bank.db
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import tech.libeufin.bank.*
import tech.libeufin.common.*
import java.time.Instant
private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-tx-dao")
/** Data access logic for transactions */
class TransactionDAO(private val db: Database) {
/** Result status of bank transaction creation .*/
sealed interface BankTransactionResult {
data class Success(val id: Long): BankTransactionResult
data object UnknownCreditor: BankTransactionResult
data object AdminCreditor: BankTransactionResult
data object UnknownDebtor: BankTransactionResult
data object BothPartySame: BankTransactionResult
data object BalanceInsufficient: BankTransactionResult
data object TanRequired: BankTransactionResult
}
/** Create a new transaction */
suspend fun create(
creditAccountPayto: Payto,
debitAccountUsername: String,
subject: String,
amount: TalerAmount,
timestamp: Instant,
is2fa: Boolean
): BankTransactionResult = db.serializable { conn ->
val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank()
conn.transaction {
val stmt = conn.prepareStatement("""
SELECT
out_creditor_not_found
,out_debtor_not_found
,out_same_account
,out_balance_insufficient
,out_tan_required
,out_credit_bank_account_id
,out_debit_bank_account_id
,out_credit_row_id
,out_debit_row_id
,out_creditor_is_exchange
,out_debtor_is_exchange
,out_creditor_admin
FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?)
"""
)
stmt.setString(1, creditAccountPayto.canonical)
stmt.setString(2, debitAccountUsername)
stmt.setString(3, subject)
stmt.setLong(4, amount.value)
stmt.setInt(5, amount.frac)
stmt.setLong(6, now)
stmt.setBoolean(7, is2fa)
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Bank transaction didn't properly return")
it.getBoolean("out_creditor_not_found") -> BankTransactionResult.UnknownCreditor
it.getBoolean("out_debtor_not_found") -> BankTransactionResult.UnknownDebtor
it.getBoolean("out_same_account") -> BankTransactionResult.BothPartySame
it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BalanceInsufficient
it.getBoolean("out_creditor_admin") -> BankTransactionResult.AdminCreditor
it.getBoolean("out_tan_required") -> BankTransactionResult.TanRequired
else -> {
val creditAccountId = it.getLong("out_credit_bank_account_id")
val creditRowId = it.getLong("out_credit_row_id")
val debitAccountId = it.getLong("out_debit_bank_account_id")
val debitRowId = it.getLong("out_debit_row_id")
val exchangeCreditor = it.getBoolean("out_creditor_is_exchange")
val exchangeDebtor = it.getBoolean("out_debtor_is_exchange")
if (exchangeCreditor && exchangeDebtor) {
logger.warn("exchange account $exchangeDebtor sent a manual transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop, may fail in the future")
} else if (exchangeCreditor) {
val bounceCause = runCatching { parseIncomingTxMetadata(subject) }.fold(
onSuccess = { reservePub ->
val registered = conn.prepareStatement("CALL register_incoming(?, ?)").run {
setBytes(1, reservePub.raw)
setLong(2, creditRowId)
executeProcedureViolation()
}
if (!registered) {
logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key")
"reserve public key reuse"
} else {
null
}
},
onFailure = { e ->
logger.warn("exchange account $creditAccountId received a manual transaction $creditRowId with malformed metadata: ${e.message}")
"malformed metadata: ${e.message}"
}
)
if (bounceCause != null) {
// No error can happens because an opposite transaction already took place in the same transaction
conn.prepareStatement("""
SELECT bank_wire_transfer(
?, ?, ?, (?, ?)::taler_amount, ?,
NULL, NULL, NULL
);
"""
).run {
setLong(1, debitAccountId)
setLong(2, creditAccountId)
setString(3, "Bounce $creditRowId: $bounceCause")
setLong(4, amount.value)
setInt(5, amount.frac)
setLong(6, now)
executeQuery()
}
}
} else if (exchangeDebtor) {
logger.warn("exchange account $debitAccountId sent a manual transaction $debitRowId which will not be recorderd as a taler outgoing transaction, use the API instead")
}
BankTransactionResult.Success(debitRowId)
}
}
}
}
}
/** Get transaction [rowId] owned by [login] */
suspend fun get(rowId: Long, login: String, ctx: BankPaytoCtx): BankAccountTransactionInfo? = db.conn { conn ->
val stmt = conn.prepareStatement("""
SELECT
creditor_payto_uri
,creditor_name
,debtor_payto_uri
,debtor_name
,subject
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,transaction_date
,direction
,bank_transaction_id
FROM bank_account_transactions
JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id
JOIN customers ON customer_id=owning_customer_id
WHERE bank_transaction_id=? AND login=?
""")
stmt.setLong(1, rowId)
stmt.setString(2, login)
stmt.oneOrNull {
BankAccountTransactionInfo(
creditor_payto_uri = it.getBankPayto("creditor_payto_uri", "creditor_name", ctx),
debtor_payto_uri = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx),
amount = it.getAmount("amount", db.bankCurrency),
direction = TransactionDirection.valueOf(it.getString("direction")),
subject = it.getString("subject"),
date = it.getTalerTimestamp("transaction_date"),
row_id = it.getLong("bank_transaction_id")
)
}
}
/** Pool [accountId] transactions history */
suspend fun pollHistory(
params: HistoryParams,
accountId: Long,
ctx: BankPaytoCtx
): List {
return db.poolHistory(params, accountId, NotificationWatcher::listenBank, """
SELECT
bank_transaction_id
,transaction_date
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,debtor_payto_uri
,debtor_name
,creditor_payto_uri
,creditor_name
,subject
,direction
FROM bank_account_transactions WHERE
""") {
BankAccountTransactionInfo(
row_id = it.getLong("bank_transaction_id"),
date = it.getTalerTimestamp("transaction_date"),
creditor_payto_uri = it.getBankPayto("creditor_payto_uri", "creditor_name", ctx),
debtor_payto_uri = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx),
amount = it.getAmount("amount", db.bankCurrency),
subject = it.getString("subject"),
direction = TransactionDirection.valueOf(it.getString("direction"))
)
}
}
/** Query [accountId] history of incoming transactions to its account */
suspend fun revenueHistory(
params: HistoryParams,
accountId: Long,
ctx: BankPaytoCtx
): List
= db.poolHistory(params, accountId, NotificationWatcher::listenRevenue, """
SELECT
bank_transaction_id
,transaction_date
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,debtor_payto_uri
,debtor_name
,subject
FROM bank_account_transactions WHERE direction='credit' AND
""") {
RevenueIncomingBankTransaction(
row_id = it.getLong("bank_transaction_id"),
date = it.getTalerTimestamp("transaction_date"),
amount = it.getAmount("amount", db.bankCurrency),
debit_account = it.getBankPayto("debtor_payto_uri", "debtor_name", ctx),
subject = it.getString("subject")
)
}
}