commit eabb15f6c29a669f1e2db21fb9d529250540549f
parent 45daf99497fd453b97d6268b6bf88c883ccfeee5
Author: Antoine A <>
Date: Mon, 6 Nov 2023 10:49:35 +0000
Cashout with common challenge code logic
Diffstat:
9 files changed, 629 insertions(+), 325 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -64,6 +64,7 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
}
private fun Routing.coreBankTokenApi(db: Database) {
+ val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
auth(db, TokenScope.refreshable) {
post("/accounts/{USERNAME}/token") {
val maybeAuthToken = call.getAuthToken()
@@ -370,7 +371,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) {
}
}
-fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
+private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/withdrawals") {
val req = call.receive<BankAccountCreateWithdrawalRequest>()
@@ -457,7 +458,11 @@ fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
}
}
-fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
+private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
+ val TAN_RETRY_COUNTER: Int = 3;
+ val TAN_VALIDITY_PERIOD: Duration = Duration.ofHours(1)
+ val TAN_RETRANSMISSION_PERIOD: Duration = Duration.ofMinutes(1)
+
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/cashouts") {
val req = call.receive<CashoutRequest>()
@@ -465,20 +470,21 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
ctx.checkInternalCurrency(req.amount_debit)
ctx.checkFiatCurrency(req.amount_credit)
- val opId = UUID.randomUUID()
val tanChannel = req.tan_channel ?: TanChannel.sms
- val tanCode = UUID.randomUUID().toString()
- val (status, info) = db.cashoutCreate(
+ val res = db.cashout.create(
accountUsername = username,
- cashoutUuid = opId,
+ requestUid = req.request_uid,
+ cashoutUuid = UUID.randomUUID(),
amountDebit = req.amount_debit,
amountCredit = req.amount_credit,
subject = req.subject ?: "", // TODO default subject
- creationTime = Instant.now(),
tanChannel = tanChannel,
- tanCode = tanCode
+ tanCode = UUID.randomUUID().toString(),
+ now = Instant.now(),
+ retryCounter = TAN_RETRY_COUNTER,
+ validityPeriod = TAN_VALIDITY_PERIOD
)
- when (status) {
+ when (res.status) {
CashoutCreationResult.ACCOUNT_NOT_FOUND -> throw notFound(
"Account '$username' not found",
TalerErrorCode.BANK_UNKNOWN_ACCOUNT
@@ -499,22 +505,28 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
"Account '$username' missing info for tan channel ${req.tan_channel}",
TalerErrorCode.BANK_MISSING_TAN_INFO
)
+ CashoutCreationResult.REQUEST_UID_REUSE -> throw conflict(
+ "request_uid used already",
+ TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
+ )
CashoutCreationResult.SUCCESS -> {
- when (tanChannel) {
- TanChannel.sms -> throw Exception("TODO")
- TanChannel.email -> throw Exception("TODO")
- TanChannel.file -> {
- File("/tmp/cashout-tan.txt").writeText(tanCode)
+ res.tanCode?.run {
+ when (tanChannel) {
+ TanChannel.sms -> throw Exception("TODO")
+ TanChannel.email -> throw Exception("TODO")
+ TanChannel.file -> {
+ File("/tmp/cashout-tan.txt").writeText(this)
+ }
}
}
- // TODO delete on error or commit transaction on error
- call.respond(CashoutPending(opId.toString()))
+ db.cashout.markSent(res.id!!, Instant.now(), TAN_RETRANSMISSION_PERIOD)
+ call.respond(CashoutPending(res.id.toString()))
}
}
}
post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") {
val opId = call.uuidUriComponent("CASHOUT_ID")
- when (db.cashoutAbort(opId)) {
+ when (db.cashout.abort(opId)) {
AbortResult.NOT_FOUND -> throw notFound(
"Cashout operation $opId not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
@@ -529,7 +541,7 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") {
val req = call.receive<CashoutConfirm>()
val opId = call.uuidUriComponent("CASHOUT_ID")
- when (db.cashoutConfirm(
+ when (db.cashout.confirm(
opUuid = opId,
tanCode = req.tan,
timestamp = Instant.now()
@@ -544,7 +556,11 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
)
CashoutConfirmationResult.BAD_TAN_CODE -> throw forbidden(
"Incorrect TAN code",
- TalerErrorCode.END
+ TalerErrorCode.END // TODO new ec
+ )
+ CashoutConfirmationResult.NO_RETRY -> throw forbidden(
+ "Too manny failed confirmation attempt",
+ TalerErrorCode.END // TODO new ec
)
CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict(
"Insufficient funds",
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -25,9 +25,10 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.sql.*
-import java.time.Instant
+import java.time.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.*
@@ -91,9 +92,9 @@ private fun PreparedStatement.executeUpdateViolation(): Boolean {
}
}
-class Database(dbConfig: String, private val bankCurrency: String, private val fiatCurrency: String?): java.io.Closeable {
+class Database(dbConfig: String, internal val bankCurrency: String, internal val fiatCurrency: String?): java.io.Closeable {
val dbPool: HikariDataSource
- private val notifWatcher: NotificationWatcher
+ internal val notifWatcher: NotificationWatcher
init {
val pgSource = pgDataSource(dbConfig)
@@ -105,6 +106,8 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
notifWatcher = NotificationWatcher(pgSource)
}
+ val cashout = CashoutDAO(this)
+
override fun close() {
dbPool.close()
}
@@ -397,9 +400,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
),
credit_debit_indicator =
if (it.getBoolean("has_debt")) {
- CorebankCreditDebitInfo.debit
+ CreditDebitInfo.debit
} else {
- CorebankCreditDebitInfo.credit
+ CreditDebitInfo.credit
}
),
debit_threshold = TalerAmount(
@@ -500,9 +503,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
currency = internalCurrency
),
credit_debit_indicator = if (it.getBoolean("has_debt")) {
- CorebankCreditDebitInfo.debit
+ CreditDebitInfo.debit
} else {
- CorebankCreditDebitInfo.credit
+ CreditDebitInfo.credit
}
)
)
@@ -541,9 +544,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
currency = bankCurrency
),
credit_debit_indicator = if (it.getBoolean("balance_has_debt")) {
- CorebankCreditDebitInfo.debit
+ CreditDebitInfo.debit
} else {
- CorebankCreditDebitInfo.credit
+ CreditDebitInfo.credit
}
),
debit_threshold = TalerAmount(
@@ -1080,205 +1083,6 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
}
/**
- * Creates a cashout operation in the database.
- */
- suspend fun cashoutCreate(
- accountUsername: String,
- cashoutUuid: UUID,
- amountDebit: TalerAmount,
- amountCredit: TalerAmount,
- subject: String,
- creationTime: Instant,
- tanChannel: TanChannel,
- tanCode: String,
- ): Pair<CashoutCreationResult, String?> = conn { conn ->
- val stmt = conn.prepareStatement("""
- SELECT
- out_bad_conversion,
- out_account_not_found,
- out_account_is_exchange,
- out_missing_tan_info,
- out_balance_insufficient,
- out_tan_info
- FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?);
- """)
- stmt.setString(1, accountUsername)
- stmt.setObject(2, cashoutUuid)
- stmt.setLong(3, amountDebit.value)
- stmt.setInt(4, amountDebit.frac)
- stmt.setLong(5, amountCredit.value)
- stmt.setInt(6, amountCredit.frac)
- stmt.setString(7, subject)
- stmt.setLong(8, creationTime.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.setString(9, tanChannel.name)
- stmt.setString(10, tanCode)
- stmt.executeQuery().use {
- var info: String? = null;
- val status = when {
- !it.next() ->
- throw internalServerError("No result from DB procedure cashout_create")
- it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BAD_CONVERSION
- it.getBoolean("out_account_not_found") -> CashoutCreationResult.ACCOUNT_NOT_FOUND
- it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.ACCOUNT_IS_EXCHANGE
- it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MISSING_TAN_INFO
- it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT
- else -> {
- info = it.getString("out_tan_info")
- CashoutCreationResult.SUCCESS
- }
- }
- Pair(status, info)
- }
- }
-
- suspend fun cashoutAbort(opUUID: UUID): AbortResult = conn { conn ->
- val stmt = conn.prepareStatement("""
- UPDATE cashout_operations
- SET aborted = tan_confirmation_time IS NULL
- WHERE cashout_uuid=?
- RETURNING tan_confirmation_time IS NOT NULL
- """)
- stmt.setObject(1, opUUID)
- when (stmt.oneOrNull { it.getBoolean(1) }) {
- null -> AbortResult.NOT_FOUND
- true -> AbortResult.CONFIRMED
- false -> AbortResult.SUCCESS
- }
- }
-
- suspend fun cashoutConfirm(
- opUuid: UUID,
- tanCode: String,
- timestamp: Instant,
- accountServicerReference: String = "NOT-USED",
- endToEndId: String = "NOT-USED",
- paymentInfId: String = "NOT-USED"
- ): CashoutConfirmationResult = conn { conn ->
- val stmt = conn.prepareStatement("""
- SELECT
- out_no_op,
- out_bad_code,
- out_balance_insufficient,
- out_aborted
- FROM cashout_confirm(?, ?, ?, ?, ?, ?);
- """)
- stmt.setObject(1, opUuid)
- stmt.setString(2, tanCode)
- stmt.setLong(3, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.setString(4, accountServicerReference)
- stmt.setString(5, endToEndId)
- stmt.setString(6, paymentInfId)
- stmt.executeQuery().use {
- when {
- !it.next() ->
- throw internalServerError("No result from DB procedure cashout_create")
- it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND
- it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE
- it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT
- it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED
- else -> CashoutConfirmationResult.SUCCESS
- }
- }
- }
-
- /**
- * This type is used by the cashout /abort handler.
- */
- enum class CashoutDeleteResult {
- SUCCESS,
- CONFLICT_ALREADY_CONFIRMED
- }
-
- /**
- * Deletes a cashout operation from the database.
- */
- suspend fun cashoutDelete(opUuid: UUID): CashoutDeleteResult = conn { conn ->
- val stmt = conn.prepareStatement("""
- SELECT out_already_confirmed
- FROM cashout_delete(?)
- """)
- stmt.setObject(1, opUuid)
- stmt.executeQuery().use {
- when {
- !it.next() -> throw internalServerError("Cashout deletion gave no result")
- it.getBoolean("out_already_confirmed") -> CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED
- else -> CashoutDeleteResult.SUCCESS
- }
- }
- }
-
- /**
- * Gets a cashout operation from the database, according
- * to its uuid.
- */
- suspend fun cashoutGetFromUuid(opUuid: UUID): Cashout? = conn { conn ->
- val stmt = conn.prepareStatement("""
- SELECT
- (amount_debit).val as amount_debit_val
- ,(amount_debit).frac as amount_debit_frac
- ,(amount_credit).val as amount_credit_val
- ,(amount_credit).frac as amount_credit_frac
- ,buy_at_ratio
- ,(buy_in_fee).val as buy_in_fee_val
- ,(buy_in_fee).frac as buy_in_fee_frac
- ,sell_at_ratio
- ,(sell_out_fee).val as sell_out_fee_val
- ,(sell_out_fee).frac as sell_out_fee_frac
- ,subject
- ,creation_time
- ,tan_channel
- ,tan_code
- ,bank_account
- ,credit_payto_uri
- ,cashout_currency
- ,tan_confirmation_time
- ,local_transaction
- FROM cashout_operations
- WHERE cashout_uuid=?;
- """)
- stmt.setObject(1, opUuid)
- stmt.oneOrNull {
- Cashout(
- amountDebit = TalerAmount(
- value = it.getLong("amount_debit_val"),
- frac = it.getInt("amount_debit_frac"),
- bankCurrency
- ),
- amountCredit = TalerAmount(
- value = it.getLong("amount_credit_val"),
- frac = it.getInt("amount_credit_frac"),
- bankCurrency
- ),
- bankAccount = it.getLong("bank_account"),
- buyAtRatio = it.getInt("buy_at_ratio"),
- buyInFee = TalerAmount(
- value = it.getLong("buy_in_fee_val"),
- frac = it.getInt("buy_in_fee_frac"),
- bankCurrency
- ),
- credit_payto_uri = it.getString("credit_payto_uri"),
- cashoutCurrency = it.getString("cashout_currency"),
- cashoutUuid = opUuid,
- creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(),
- sellAtRatio = it.getInt("sell_at_ratio"),
- sellOutFee = TalerAmount(
- value = it.getLong("sell_out_fee_val"),
- frac = it.getInt("sell_out_fee_frac"),
- bankCurrency
- ),
- subject = it.getString("subject"),
- tanChannel = TanChannel.valueOf(it.getString("tan_channel")),
- tanCode = it.getString("tan_code"),
- localTransaction = it.getLong("local_transaction"),
- tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) {
- 0L -> null
- else -> timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank()
- }
- )
- }
- }
-
- /**
* Holds the result of inserting a Taler transfer request
* into the database.
*/
@@ -1645,7 +1449,8 @@ enum class CashoutCreationResult {
ACCOUNT_NOT_FOUND,
ACCOUNT_IS_EXCHANGE,
MISSING_TAN_INFO,
- BALANCE_INSUFFICIENT
+ BALANCE_INSUFFICIENT,
+ REQUEST_UID_REUSE
}
/** Result status of cashout operation confirmation */
@@ -1654,6 +1459,7 @@ enum class CashoutConfirmationResult {
OP_NOT_FOUND,
BAD_TAN_CODE,
BALANCE_INSUFFICIENT,
+ NO_RETRY,
ABORTED
}
@@ -1664,7 +1470,7 @@ enum class AbortResult {
CONFIRMED
}
-private class NotificationWatcher(private val pgSource: PGSimpleDataSource) {
+internal class NotificationWatcher(private val pgSource: PGSimpleDataSource) {
private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int)
private val bankTxFlows = ConcurrentHashMap<Long, CountedSharedFlow>()
@@ -1754,4 +1560,224 @@ private class NotificationWatcher(private val pgSource: PGSimpleDataSource) {
suspend fun listenIncoming(account: Long, lambda: suspend (Flow<Long>) -> Unit) {
listen(incomingTxFlows, account, lambda)
}
+}
+
+class CashoutDAO(private val db: Database) {
+ data class CashoutCreation(
+ val status: CashoutCreationResult,
+ val id: UUID?,
+ val tanInfo: String?,
+ val tanCode: String?
+ )
+ suspend fun create(
+ accountUsername: String,
+ requestUid: ShortHashCode,
+ cashoutUuid: UUID,
+ amountDebit: TalerAmount,
+ amountCredit: TalerAmount,
+ subject: String,
+ tanChannel: TanChannel,
+ tanCode: String,
+ now: Instant,
+ retryCounter: Int,
+ validityPeriod: Duration
+ ): CashoutCreation = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ out_bad_conversion,
+ out_account_not_found,
+ out_account_is_exchange,
+ out_missing_tan_info,
+ out_balance_insufficient,
+ out_request_uid_reuse,
+ out_cashout_uuid,
+ out_tan_info,
+ out_tan_code
+ FROM cashout_create(?, ?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?)
+ """)
+ stmt.setString(1, accountUsername)
+ stmt.setBytes(2, requestUid.raw)
+ stmt.setObject(3, cashoutUuid)
+ stmt.setLong(4, amountDebit.value)
+ stmt.setInt(5, amountDebit.frac)
+ stmt.setLong(6, amountCredit.value)
+ stmt.setInt(7, amountCredit.frac)
+ stmt.setString(8, subject)
+ stmt.setLong(9, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setString(10, tanChannel.name)
+ stmt.setString(11, tanCode)
+ stmt.setInt(12, retryCounter)
+ stmt.setLong(13, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ stmt.executeQuery().use {
+ var id: UUID? = null
+ var info: String? = null;
+ var code: String? = null;
+ val status = when {
+ !it.next() ->
+ throw internalServerError("No result from DB procedure cashout_create")
+ it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BAD_CONVERSION
+ it.getBoolean("out_account_not_found") -> CashoutCreationResult.ACCOUNT_NOT_FOUND
+ it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.ACCOUNT_IS_EXCHANGE
+ it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MISSING_TAN_INFO
+ it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BALANCE_INSUFFICIENT
+ it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.REQUEST_UID_REUSE
+ else -> {
+ id = it.getObject("out_cashout_uuid") as UUID
+ info = it.getString("out_tan_info")
+ code = it.getString("out_tan_code")
+ CashoutCreationResult.SUCCESS
+ }
+ }
+ CashoutCreation(status, id, info, code)
+ }
+ }
+
+ suspend fun markSent(
+ uuid: UUID,
+ now: Instant,
+ retransmissionPeriod: Duration
+ ) = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT challenge_mark_sent(challenge, ?, ?)
+ FROM cashout_operations
+ WHERE cashout_uuid=?
+ """)
+ stmt.setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+ stmt.setObject(3, uuid)
+ stmt.executeQueryCheck()
+ }
+
+ suspend fun abort(opUUID: UUID): AbortResult = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ UPDATE cashout_operations
+ SET aborted = local_transaction IS NULL
+ WHERE cashout_uuid=?
+ RETURNING local_transaction IS NOT NULL
+ """)
+ stmt.setObject(1, opUUID)
+ when (stmt.oneOrNull { it.getBoolean(1) }) {
+ null -> AbortResult.NOT_FOUND
+ true -> AbortResult.CONFIRMED
+ false -> AbortResult.SUCCESS
+ }
+ }
+
+ suspend fun confirm(
+ opUuid: UUID,
+ tanCode: String,
+ timestamp: Instant
+ ): CashoutConfirmationResult = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ out_no_op,
+ out_bad_code,
+ out_balance_insufficient,
+ out_aborted,
+ out_no_retry
+ FROM cashout_confirm(?, ?, ?);
+ """)
+ stmt.setObject(1, opUuid)
+ stmt.setString(2, tanCode)
+ stmt.setLong(3, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.executeQuery().use {
+ when {
+ !it.next() ->
+ throw internalServerError("No result from DB procedure cashout_create")
+ it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND
+ it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE
+ it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT
+ it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED
+ it.getBoolean("out_no_retry") -> CashoutConfirmationResult.NO_RETRY
+ else -> CashoutConfirmationResult.SUCCESS
+ }
+ }
+ }
+
+ enum class CashoutDeleteResult {
+ SUCCESS,
+ CONFLICT_ALREADY_CONFIRMED
+ }
+
+ suspend fun delete(opUuid: UUID): CashoutDeleteResult = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT out_already_confirmed
+ FROM cashout_delete(?)
+ """)
+ stmt.setObject(1, opUuid)
+ stmt.executeQuery().use {
+ when {
+ !it.next() -> throw internalServerError("Cashout deletion gave no result")
+ it.getBoolean("out_already_confirmed") -> CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED
+ else -> CashoutDeleteResult.SUCCESS
+ }
+ }
+ }
+
+ suspend fun getFromUuid(opUuid: UUID): Cashout? = db.conn { conn ->
+ val stmt = conn.prepareStatement("""
+ SELECT
+ (amount_debit).val as amount_debit_val
+ ,(amount_debit).frac as amount_debit_frac
+ ,(amount_credit).val as amount_credit_val
+ ,(amount_credit).frac as amount_credit_frac
+ ,buy_at_ratio
+ ,(buy_in_fee).val as buy_in_fee_val
+ ,(buy_in_fee).frac as buy_in_fee_frac
+ ,sell_at_ratio
+ ,(sell_out_fee).val as sell_out_fee_val
+ ,(sell_out_fee).frac as sell_out_fee_frac
+ ,subject
+ ,creation_time
+ ,tan_channel
+ ,tan_code
+ ,bank_account
+ ,credit_payto_uri
+ ,cashout_currency
+ ,tan_confirmation_time
+ ,local_transaction
+ FROM cashout_operations
+ WHERE cashout_uuid=?;
+ """)
+ stmt.setObject(1, opUuid)
+ stmt.oneOrNull {
+ Cashout(
+ amountDebit = TalerAmount(
+ value = it.getLong("amount_debit_val"),
+ frac = it.getInt("amount_debit_frac"),
+ db.bankCurrency
+ ),
+ amountCredit = TalerAmount(
+ value = it.getLong("amount_credit_val"),
+ frac = it.getInt("amount_credit_frac"),
+ db.bankCurrency
+ ),
+ bankAccount = it.getLong("bank_account"),
+ buyAtRatio = it.getInt("buy_at_ratio"),
+ buyInFee = TalerAmount(
+ value = it.getLong("buy_in_fee_val"),
+ frac = it.getInt("buy_in_fee_frac"),
+ db.bankCurrency
+ ),
+ credit_payto_uri = it.getString("credit_payto_uri"),
+ cashoutCurrency = it.getString("cashout_currency"),
+ cashoutUuid = opUuid,
+ creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(),
+ sellAtRatio = it.getInt("sell_at_ratio"),
+ sellOutFee = TalerAmount(
+ value = it.getLong("sell_out_fee_val"),
+ frac = it.getInt("sell_out_fee_frac"),
+ db.bankCurrency
+ ),
+ subject = it.getString("subject"),
+ tanChannel = TanChannel.valueOf(it.getString("tan_channel")),
+ tanCode = it.getString("tan_code"),
+ localTransaction = it.getLong("local_transaction"),
+ tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) {
+ 0L -> null
+ else -> timestamp.microsToJavaInstant() ?: throw faultyTimestampByBank()
+ }
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -260,14 +260,14 @@ data class Config(
val version: String = "0:0:0"
}
-enum class CorebankCreditDebitInfo {
+enum class CreditDebitInfo {
credit, debit
}
@Serializable
data class Balance(
val amount: TalerAmount,
- val credit_debit_indicator: CorebankCreditDebitInfo,
+ val credit_debit_indicator: CreditDebitInfo,
)
/**
@@ -434,6 +434,7 @@ data class BankWithdrawalOperationPostResponse(
@Serializable
data class CashoutRequest(
+ val request_uid: ShortHashCode,
val subject: String?,
val amount_debit: TalerAmount,
val amount_credit: TalerAmount,
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -241,21 +241,9 @@ class CoreBankAccountsMgmtApiTest {
"username" to "foo$it"
}
}.assertCreated()
- client.get("/accounts/foo$it") {
- basicAuth("admin", "admin-password")
- }.assertOk().run {
- val obj: AccountData = Json.decodeFromString(bodyAsText())
- assertEquals(TalerAmount("KUDOS:100"), obj.balance.amount)
- assertEquals(CorebankCreditDebitInfo.credit, obj.balance.credit_debit_indicator)
- }
- }
- client.get("/accounts/admin") {
- basicAuth("admin", "admin-password")
- }.assertOk().run {
- val obj: AccountData = Json.decodeFromString(bodyAsText())
- assertEquals(TalerAmount("KUDOS:10000"), obj.balance.amount)
- assertEquals(CorebankCreditDebitInfo.debit, obj.balance.credit_debit_indicator)
+ assertBalance("foo$it", CreditDebitInfo.credit, "KUDOS:100")
}
+ assertBalance("admin", CreditDebitInfo.debit, "KUDOS:10000")
// Check unsufficient fund
client.post("/accounts") {
@@ -792,7 +780,7 @@ class CoreBankTransactionsApiTest {
}.assertOk().run {
val obj: AccountData = Json.decodeFromString(bodyAsText())
assertEquals(
- if (merchantDebt) CorebankCreditDebitInfo.debit else CorebankCreditDebitInfo.credit,
+ if (merchantDebt) CreditDebitInfo.debit else CreditDebitInfo.credit,
obj.balance.credit_debit_indicator)
assertEquals(TalerAmount(merchantAmount), obj.balance.amount)
}
@@ -801,7 +789,7 @@ class CoreBankTransactionsApiTest {
}.assertOk().run {
val obj: AccountData = Json.decodeFromString(bodyAsText())
assertEquals(
- if (customerDebt) CorebankCreditDebitInfo.debit else CorebankCreditDebitInfo.credit,
+ if (customerDebt) CreditDebitInfo.debit else CreditDebitInfo.credit,
obj.balance.credit_debit_indicator)
assertEquals(TalerAmount(customerAmount), obj.balance.amount)
}
@@ -1041,7 +1029,17 @@ class CoreBankWithdrawalApiTest {
class CoreBankCashoutApiTest {
- fun tanCode(): String = File("/tmp/cashout-tan.txt").readText()
+ suspend fun tanCode(): String? {
+ val file = File("/tmp/cashout-tan.txt");
+ if (file.exists()) {
+ val code = file.readText()
+ file.delete()
+ return code;
+ } else {
+ return null
+ }
+ }
+
private suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount {
// Check conversion
@@ -1056,6 +1054,7 @@ class CoreBankCashoutApiTest {
fun create() = bankSetup { _ ->
// TODO auth routine
val req = json {
+ "request_uid" to randShortHashCode()
"amount_debit" to "KUDOS:1"
"amount_credit" to convert("KUDOS:1")
"tan_channel" to "file"
@@ -1065,7 +1064,27 @@ class CoreBankCashoutApiTest {
client.post("/accounts/customer/cashouts") {
basicAuth("customer", "customer-password")
jsonBody(req)
- }.assertOk()
+ }.assertOk().run {
+ val id = json<CashoutPending>().cashout_id
+ tanCode()
+ // Check idempotency
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ assertEquals(id, json<CashoutPending>().cashout_id)
+ assertNull(tanCode())
+ }
+ }
+
+ // Trigger conflict due to reused request_uid
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req) {
+ "amount_debit" to "KUDOS:2"
+ "amount_credit" to convert("KUDOS:2")
+ }
+ }.assertConflict().assertErr(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
// Check exchange account
client.post("/accounts/exchange/cashouts") {
@@ -1118,6 +1137,7 @@ class CoreBankCashoutApiTest {
fun abort() = bankSetup { _ ->
// TODO auth routine
val req = json {
+ "request_uid" to randShortHashCode()
"amount_debit" to "KUDOS:1"
"amount_credit" to convert("KUDOS:1")
"tan_channel" to "file"
@@ -1143,7 +1163,7 @@ class CoreBankCashoutApiTest {
// Check abort confirmed
client.post("/accounts/customer/cashouts") {
basicAuth("customer", "customer-password")
- jsonBody(req)
+ jsonBody(json(req) { "request_uid" to randShortHashCode() })
}.assertOk().run {
val uuid = json<CashoutPending>().cashout_id
@@ -1176,6 +1196,7 @@ class CoreBankCashoutApiTest {
fun confirm() = bankSetup { _ ->
// TODO auth routine
val req = json {
+ "request_uid" to randShortHashCode()
"amount_debit" to "KUDOS:1"
"amount_credit" to convert("KUDOS:1")
"tan_channel" to "file"
@@ -1185,7 +1206,7 @@ class CoreBankCashoutApiTest {
// Check confirm
client.post("/accounts/customer/cashouts") {
basicAuth("customer", "customer-password")
- jsonBody(req)
+ jsonBody(req) { "request_uid" to randShortHashCode() }
}.assertOk().run {
val uuid = json<CashoutPending>().cashout_id
@@ -1195,46 +1216,31 @@ class CoreBankCashoutApiTest {
jsonBody { "tan" to "nice-try" }
}.assertForbidden()
+ val code = tanCode()
+
// Check OK
client.post("/accounts/customer/cashouts/$uuid/confirm") {
basicAuth("customer", "customer-password")
- jsonBody { "tan" to tanCode() }
+ jsonBody { "tan" to code }
}.assertNoContent()
// Check idempotence
client.post("/accounts/customer/cashouts/$uuid/confirm") {
basicAuth("customer", "customer-password")
- jsonBody { "tan" to tanCode() }
- }.assertNoContent()
- }
-
- // Check confirm aborted TODO
- client.post("/accounts/customer/cashouts") {
- basicAuth("customer", "customer-password")
- jsonBody(req)
- }.assertOk().run {
- val uuid = json<CashoutPending>().cashout_id
- client.post("/accounts/customer/cashouts/$uuid/abort") {
- basicAuth("customer", "customer-password")
+ jsonBody { "tan" to code }
}.assertNoContent()
-
- // Check error
- client.post("/accounts/customer/cashouts/$uuid/confirm") {
- basicAuth("customer", "customer-password")
- jsonBody { "tan" to tanCode() }
- }.assertConflict().assertErr(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT)
}
// Check balance insufficient
client.post("/accounts/customer/cashouts") {
basicAuth("customer", "customer-password")
- jsonBody(req)
+ jsonBody(json(req) { "request_uid" to randShortHashCode() })
}.assertOk().run {
val uuid = json<CashoutPending>().cashout_id
// Send too much money
client.post("/accounts/customer/transactions") {
basicAuth("customer", "customer-password")
jsonBody {
- "payto_uri" to "payto://iban/merchant-IBAN-XYZ?message=payout&amount=KUDOS:8"
+ "payto_uri" to "payto://iban/merchant-IBAN-XYZ?message=payout&amount=KUDOS:9"
}
}.assertNoContent()
@@ -1252,13 +1258,13 @@ class CoreBankCashoutApiTest {
// Check bad UUID
client.post("/accounts/customer/cashouts/chocolate/confirm") {
basicAuth("customer", "customer-password")
- jsonBody { "tan" to tanCode() }
+ jsonBody { "tan" to "code" }
}.assertBadRequest()
// Check unknown
client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/confirm") {
basicAuth("customer", "customer-password")
- jsonBody { "tan" to tanCode() }
+ jsonBody { "tan" to "code" }
}.assertNotFound().assertErr(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
@@ -20,11 +20,14 @@
import org.junit.Test
import org.postgresql.jdbc.PgConnection
import tech.libeufin.bank.*
-import tech.libeufin.util.CryptoUtil
+import tech.libeufin.util.*
import java.sql.DriverManager
import java.time.Instant
+import java.time.Duration
+import java.time.temporal.ChronoUnit
import java.util.Random
import java.util.UUID
+import java.util.concurrent.TimeUnit
import kotlin.experimental.inv
import kotlin.test.*
@@ -45,6 +48,104 @@ class DatabaseTest {
// Checking idempotency
assert(maybeCreateAdminAccount(db, ctx))
}
+
+ @Test
+ fun challenge() = setup { db, _ -> db.conn { conn ->
+ val createStmt = conn.prepareStatement("SELECT challenge_create(?,?,?,?)")
+ val sendStmt = conn.prepareStatement("SELECT challenge_mark_sent(?,?,?)")
+ val tryStmt = conn.prepareStatement("SELECT ok, no_retry FROM challenge_try(?,?,?)")
+ val resendStmt = conn.prepareStatement("SELECT challenge_resend(?,?,?,?,?)")
+
+ val validityPeriod = Duration.ofHours(1)
+ val retransmissionPeriod: Duration = Duration.ofMinutes(1)
+ val retryCounter = 3
+
+ fun create(code: String, now: Instant): Long {
+ createStmt.setString(1, code)
+ createStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ createStmt.setLong(3, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ createStmt.setInt(4, retryCounter)
+ return createStmt.oneOrNull { it.getLong(1) }!!
+ }
+
+ fun send(id: Long, now: Instant) {
+ sendStmt.setLong(1, id)
+ sendStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ sendStmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+ return sendStmt.oneOrNull { }!!
+ }
+
+ fun cTry(id: Long, code: String, now: Instant): Pair<Boolean, Boolean> {
+ tryStmt.setLong(1, id)
+ tryStmt.setString(2, code)
+ tryStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ return tryStmt.oneOrNull {
+ Pair(it.getBoolean(1), it.getBoolean(2))
+ }!!
+ }
+
+ fun resend(id: Long, code: String, now: Instant): String? {
+ resendStmt.setLong(1, id)
+ resendStmt.setString(2, code)
+ resendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+ resendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ resendStmt.setInt(5, retryCounter)
+ return resendStmt.oneOrNull { it.getString(1) }
+ }
+
+ val now = Instant.now()
+ val expired = now + validityPeriod
+ val retransmit = now + retransmissionPeriod
+
+ // Check basic
+ create("good-code", now).run {
+ // Bad code
+ assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+ // Good code
+ assertEquals(Pair(true, false), cTry(this, "good-code", now))
+ // Never resend a confirmed challenge
+ assertNull(resend(this, "new-code", expired))
+ // Confirmed challenge always ok
+ assertEquals(Pair(true, false), cTry(this, "good-code", now))
+ }
+
+ // Check retry
+ create("good-code", now).run {
+ send(this, now)
+ // Bad code
+ repeat(retryCounter) {
+ assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+ }
+ // Good code fail
+ assertEquals(Pair(false, true), cTry(this, "good-code", now))
+ // New code
+ assertEquals("new-code", resend(this, "new-code", now))
+ // Good code
+ assertEquals(Pair(true, false), cTry(this, "new-code", now))
+ }
+
+ // Check retransmission and expiration
+ create("good-code", now).run {
+ // Failed to send retransmit
+ assertEquals("good-code", resend(this, "new-code", now))
+ // Code successfully sent and still valid
+ send(this, now)
+ assertNull(resend(this, "new-code", now))
+ // Code is still valid but shoud be resent
+ assertEquals("good-code", resend(this, "new-code", retransmit))
+ // Good code fail because expired
+ assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+ // New code because expired
+ assertEquals("new-code", resend(this, "new-code", expired))
+ // Code successfully sent and still valid
+ send(this, expired)
+ assertNull(resend(this, "another-code", expired))
+ // Old code no longer workds
+ assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+ // New code works
+ assertEquals(Pair(true, false), cTry(this, "new-code", expired))
+ }
+ }}
}
diff --git a/bank/src/test/kotlin/JsonTest.kt b/bank/src/test/kotlin/JsonTest.kt
@@ -49,8 +49,8 @@ class JsonTest {
@Test
fun enumSerializer() {
- assert("\"credit\"" == Json.encodeToString(CorebankCreditDebitInfo.credit))
- assert("\"debit\"" == Json.encodeToString(CorebankCreditDebitInfo.debit))
+ assert("\"credit\"" == Json.encodeToString(CreditDebitInfo.credit))
+ assert("\"debit\"" == Json.encodeToString(CreditDebitInfo.debit))
}
// Testing JSON <--> TalerAmount
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -92,6 +92,17 @@ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmo
}.assertNoContent()
}
+suspend fun ApplicationTestBuilder.assertBalance(account: String, info: CreditDebitInfo, amount: String) {
+ client.get("/accounts/$account") {
+ basicAuth("admin", "admin-password")
+ }.assertOk().run {
+ val balance = json<AccountData>().balance;
+ assertEquals(info, balance.credit_debit_indicator)
+ assertEquals(TalerAmount(amount), balance.amount)
+ }
+}
+
+
/* ----- Assert ----- */
fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse {
@@ -153,7 +164,7 @@ inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = fals
}
}
-inline suspend fun HttpRequestBuilder.jsonBody(
+inline fun HttpRequestBuilder.jsonBody(
from: JsonObject = JsonObject(emptyMap()),
deflate: Boolean = false,
builderAction: JsonBuilder.() -> Unit
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
@@ -155,10 +155,37 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id
-- end of: money transactions
+-- start of: TAN challenge
+CREATE TABLE IF NOT EXISTS challenges
+ (challenge_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE,
+ code TEXT NOT NULL,
+ creation_date INT8 NOT NULL,
+ expiration_date INT8 NOT NULL,
+ retransmission_date INT8 NOT NULL DEFAULT 0,
+ retry_counter INT4 NOT NULL,
+ confirmation_date INT8 DEFAULT NULL);
+COMMENT ON TABLE challenges
+ IS 'Stores a code which is checked for the authentication by SMS, E-Mail..';
+COMMENT ON COLUMN challenges.code
+ IS 'The pin code which is sent to the user and verified';
+COMMENT ON COLUMN challenges.creation_date
+ IS 'Creation date of the code';
+COMMENT ON COLUMN challenges.retransmission_date
+ IS 'When did we last transmit the challenge to the user';
+COMMENT ON COLUMN challenges.expiration_date
+ IS 'When will the code expire';
+COMMENT ON COLUMN challenges.retry_counter
+ IS 'How many tries are left for this code must be > 0';
+COMMENT ON COLUMN challenges.confirmation_date
+ IS 'When was this challenge successfully verified, NULL if pending';
+
+-- end of: TAN challenge
+
-- start of: cashout management
CREATE TABLE IF NOT EXISTS cashout_operations
(cashout_uuid uuid NOT NULL PRIMARY KEY
+ ,request_uid BYTEA NOT NULL UNIQUE CHECK (LENGTH(request_uid)=32)
,amount_debit taler_amount NOT NULL
,amount_credit taler_amount NOT NULL
,subject TEXT NOT NULL
@@ -167,9 +194,10 @@ CREATE TABLE IF NOT EXISTS cashout_operations
REFERENCES bank_accounts(bank_account_id)
ON DELETE CASCADE
ON UPDATE RESTRICT
- ,tan_channel tan_enum NOT NULL
- ,tan_code TEXT NOT NULL
- ,tan_confirmation_time BIGINT DEFAULT NULL
+ ,challenge BIGINT NOT NULL UNIQUE
+ REFERENCES challenges(challenge_id)
+ ON DELETE CASCADE
+ ON UPDATE RESTRICT
,aborted BOOLEAN NOT NULL DEFAULT FALSE
,local_transaction BIGINT UNIQUE DEFAULT NULL-- FIXME: Comment that the transaction only gets created after the TAN confirmation
REFERENCES bank_account_transactions(bank_transaction_id)
@@ -179,11 +207,6 @@ CREATE TABLE IF NOT EXISTS cashout_operations
-- FIXME: table comment missing
-COMMENT ON COLUMN cashout_operations.tan_confirmation_time
- IS 'Timestamp when the customer confirmed the cash-out operation via TAN';
-COMMENT ON COLUMN cashout_operations.tan_code
- IS 'text that the customer must send to confirm the cash-out operation';
-
-- end of: cashout management
-- start of: Taler integration
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -991,25 +991,32 @@ END $$;
CREATE OR REPLACE FUNCTION cashout_create(
IN in_account_username TEXT,
+ IN in_request_uid BYTEA,
IN in_cashout_uuid uuid,
IN in_amount_debit taler_amount,
IN in_amount_credit taler_amount,
IN in_subject TEXT,
- IN in_creation_time BIGINT,
+ IN in_now_date INT8,
IN in_tan_channel tan_enum,
IN in_tan_code TEXT,
+ IN in_retry_counter INT4,
+ IN in_validity_period INT8,
-- Error status
OUT out_bad_conversion BOOLEAN,
OUT out_account_not_found BOOLEAN,
OUT out_account_is_exchange BOOLEAN,
OUT out_missing_tan_info BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
+ OUT out_request_uid_reuse BOOLEAN,
-- Success return
- OUT out_tan_info TEXT
+ OUT out_cashout_uuid uuid,
+ OUT out_tan_info TEXT,
+ OUT out_tan_code TEXT
)
LANGUAGE plpgsql AS $$
DECLARE
account_id BIGINT;
+challenge_id BIGINT;
BEGIN
-- check conversion
SELECT too_small OR in_amount_credit!=to_amount INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'sell'::text);
@@ -1045,39 +1052,57 @@ IF out_balance_insufficient THEN
RETURN;
END IF;
--- Create cashout operation
-INSERT INTO cashout_operations (
- cashout_uuid
- ,amount_debit
- ,amount_credit
- ,subject
- ,creation_time
- ,bank_account
- ,tan_channel
- ,tan_code
-) VALUES (
- in_cashout_uuid
- ,in_amount_debit
- ,in_amount_credit
- ,in_subject
- ,in_creation_time
- ,account_id
- ,in_tan_channel
- ,in_tan_code
-);
+-- Check for idempotence and conflict
+SELECT (amount_debit != in_amount_debit
+ OR subject != in_subject
+ OR bank_account != account_id)
+ ,challenge, cashout_uuid
+ INTO out_request_uid_reuse, challenge_id, out_cashout_uuid
+ FROM cashout_operations
+ WHERE request_uid = in_request_uid;
+
+IF NOT found THEN
+ -- New cashout
+ out_cashout_uuid = in_cashout_uuid;
+ out_tan_code = in_tan_code;
+
+ -- Create challenge
+ SELECT challenge_create(in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO challenge_id;
+
+ -- Create cashout operation
+ INSERT INTO cashout_operations (
+ cashout_uuid
+ ,request_uid
+ ,amount_debit
+ ,amount_credit
+ ,subject
+ ,creation_time
+ ,bank_account
+ ,challenge
+ ) VALUES (
+ in_cashout_uuid
+ ,in_request_uid
+ ,in_amount_debit
+ ,in_amount_credit
+ ,in_subject
+ ,in_now_date
+ ,account_id
+ ,challenge_id
+ );
+ELSE -- Already exist, check challenge retransmission
+ SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO out_tan_code;
+END IF;
END $$;
CREATE OR REPLACE FUNCTION cashout_confirm(
IN in_cashout_uuid uuid,
IN in_tan_code TEXT,
- IN in_confirmation_time BIGINT,
- IN in_acct_svcr_ref TEXT,
- IN in_pmt_inf_id TEXT,
- IN in_end_to_end_id TEXT,
+ IN in_now_date BIGINT,
OUT out_no_op BOOLEAN,
OUT out_bad_code BOOLEAN,
OUT out_balance_insufficient BOOLEAN,
- OUT out_aborted BOOLEAN
+ OUT out_aborted BOOLEAN,
+ OUT out_no_retry BOOLEAN
)
LANGUAGE plpgsql as $$
DECLARE
@@ -1086,26 +1111,34 @@ DECLARE
already_confirmed BOOLEAN;
subject_local TEXT;
amount_local taler_amount;
+ challenge_id BIGINT;
+ tx_id BIGINT;
BEGIN
-- Retrieve cashout operation info
SELECT
- tan_code != in_tan_code,
- tan_confirmation_time IS NOT NULL,
+ local_transaction IS NOT NULL,
aborted, subject,
- bank_account,
+ bank_account, challenge,
(amount_debit).val, (amount_debit).frac
INTO
- out_bad_code,
already_confirmed,
out_aborted, subject_local,
- wallet_account_id,
+ wallet_account_id, challenge_id,
amount_local.val, amount_local.frac
FROM cashout_operations
WHERE cashout_uuid=in_cashout_uuid;
IF NOT FOUND THEN
out_no_op=TRUE;
RETURN;
-ELSIF out_bad_code OR out_bad_code OR out_aborted THEN
+ELSIF already_confirmed OR out_aborted THEN
+ RETURN;
+END IF;
+
+-- Check challenge
+SELECT NOT ok, no_retry
+ INTO out_bad_code, out_no_retry
+ FROM challenge_try(challenge_id, in_tan_code, in_now_date);
+IF out_bad_code OR out_no_retry THEN
RETURN;
END IF;
@@ -1118,16 +1151,17 @@ SELECT bank_account_id
WHERE login = 'admin';
-- Perform bank wire transfer
-SELECT transfer.out_balance_insufficient INTO out_balance_insufficient
+SELECT transfer.out_balance_insufficient, out_debit_row_id
+INTO out_balance_insufficient, tx_id
FROM bank_wire_transfer(
admin_account_id,
wallet_account_id,
subject_local,
amount_local,
- in_confirmation_time,
- in_acct_svcr_ref,
- in_pmt_inf_id,
- in_end_to_end_id
+ in_now_date,
+ 'not-used',
+ 'not-used',
+ 'not-used'
) as transfer;
IF out_balance_insufficient THEN
RETURN;
@@ -1135,10 +1169,96 @@ END IF;
-- Confirm operation
UPDATE cashout_operations
- SET tan_confirmation_time = in_confirmation_time
+ SET local_transaction = tx_id
WHERE cashout_uuid=in_cashout_uuid;
END $$;
+CREATE OR REPLACE FUNCTION challenge_create (
+ IN in_code TEXT,
+ IN in_now_date INT8,
+ IN in_validity_period INT8,
+ IN in_retry_counter INT4,
+ OUT out_challenge_id BIGINT
+)
+LANGUAGE sql AS $$
+ INSERT INTO challenges (
+ code,
+ creation_date,
+ expiration_date,
+ retry_counter
+ ) VALUES (
+ in_code,
+ in_now_date,
+ in_now_date + in_validity_period,
+ in_retry_counter
+ ) RETURNING challenge_id
+$$;
+COMMENT ON FUNCTION challenge_create IS 'Create a new challenge, return the generated id';
+
+CREATE OR REPLACE FUNCTION challenge_mark_sent (
+ IN in_challenge_id BIGINT,
+ IN in_now_date INT8,
+ IN in_retransmission_period INT8
+) RETURNS void
+LANGUAGE sql AS $$
+ UPDATE challenges SET
+ retransmission_date = in_now_date + in_retransmission_period
+ WHERE challenge_id = in_challenge_id;
+$$;
+COMMENT ON FUNCTION challenge_create IS 'Register a challenge as successfully sent';
+
+CREATE OR REPLACE FUNCTION challenge_resend (
+ IN in_challenge_id BIGINT,
+ IN in_code TEXT, -- New code to use if the old code expired
+ IN in_now_date INT8,
+ IN in_validity_period INT8,
+ IN in_retry_counter INT4,
+ OUT out_tan_code TEXT -- Code to send, NULL if nothing should be sent
+)
+LANGUAGE plpgsql as $$
+DECLARE
+expired BOOLEAN;
+retransmit BOOLEAN;
+BEGIN
+-- Recover expiration date
+SELECT
+ (in_now_date >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL
+ ,in_now_date >= retransmission_date AND confirmation_date IS NULL
+ ,code
+INTO expired, retransmit, out_tan_code
+FROM challenges WHERE challenge_id = in_challenge_id;
+
+IF expired THEN
+ UPDATE challenges SET
+ code = in_code
+ ,expiration_date = in_now_date + in_validity_period
+ ,retry_counter = in_retry_counter
+ WHERE challenge_id = in_challenge_id;
+ out_tan_code = in_code;
+ELSIF NOT retransmit THEN
+ out_tan_code = NULL;
+END IF;
+END $$;
+COMMENT ON FUNCTION challenge_resend IS 'Get the challenge code to send, return NULL if nothing should be sent';
+
+CREATE OR REPLACE FUNCTION challenge_try (
+ IN in_challenge_id BIGINT,
+ IN in_code TEXT,
+ IN in_now_date INT8,
+ OUT ok BOOLEAN,
+ OUT no_retry BOOLEAN
+)
+LANGUAGE sql as $$
+ UPDATE challenges SET
+ confirmation_date = CASE
+ WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = in_code) THEN in_now_date
+ ELSE confirmation_date
+ END,
+ retry_counter = retry_counter - 1
+ WHERE challenge_id = in_challenge_id
+ RETURNING confirmation_date IS NOT NULL, retry_counter < 0 AND confirmation_date IS NULL;
+$$;
+COMMENT ON FUNCTION challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed';
CREATE OR REPLACE FUNCTION stats_get_frame(
IN now TIMESTAMP,