commit b83ca72dfbc015ed3dd8a351cf812e1757632eab
parent eb2f11f533bdde5b0c97335b025fd69847412bb5
Author: Antoine A <>
Date: Fri, 27 Oct 2023 14:37:26 +0000
Cashout create, confirm and abort
Diffstat:
7 files changed, 651 insertions(+), 179 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -27,6 +27,7 @@ import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
+import java.io.File
import kotlin.random.Random
import net.taler.common.errorcodes.TalerErrorCode
import net.taler.wallet.crypto.Base32Crockford
@@ -369,10 +370,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) {
fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
post("/accounts/{USERNAME}/withdrawals") {
val (login, _) = call.authCheck(db, TokenScope.readwrite)
- val req =
- call.receive<
- BankAccountCreateWithdrawalRequest>() // Checking that the user has enough
- // funds.
+ val req = call.receive<BankAccountCreateWithdrawalRequest>()
ctx.checkInternalCurrency(req.amount)
@@ -423,14 +421,13 @@ fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
post("/withdrawals/{withdrawal_id}/abort") {
val opId = call.uuidUriComponent("withdrawal_id")
when (db.talerWithdrawalAbort(opId)) {
- WithdrawalAbortResult.NOT_FOUND ->
- throw notFound(
- "Withdrawal operation $opId not found",
- TalerErrorCode.TALER_EC_END
- )
- WithdrawalAbortResult.CONFIRMED ->
+ AbortResult.NOT_FOUND -> throw notFound(
+ "Withdrawal operation $opId not found",
+ TalerErrorCode.TALER_EC_END
+ )
+ AbortResult.CONFIRMED ->
throw conflict("Cannot abort confirmed withdrawal", TalerErrorCode.TALER_EC_END)
- WithdrawalAbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ AbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
}
}
post("/withdrawals/{withdrawal_id}/confirm") {
@@ -478,15 +475,94 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) {
ctx.checkInternalCurrency(req.amount_debit)
ctx.checkFiatCurrency(req.amount_credit)
- // TODO
+ val opId = UUID.randomUUID()
+ val tanChannel = req.tan_channel ?: TanChannel.sms
+ val tanCode = UUID.randomUUID().toString()
+ val (status, info) = db.cashoutCreate(
+ accountUsername = login,
+ cashoutUuid = opId,
+ amountDebit = req.amount_debit,
+ amountCredit = req.amount_credit,
+ subject = req.subject ?: "", // TODO default subject
+ creationTime = Instant.now(),
+ tanChannel = tanChannel,
+ tanCode = tanCode
+ )
+ when (status) {
+ CashoutCreationResult.BAD_CONVERSION -> throw conflict(
+ "Wrong currency conversion",
+ TalerErrorCode.TALER_EC_END // TODO EC ?
+ )
+ CashoutCreationResult.ACCOUNT_NOT_FOUND -> throw notFound(
+ "Customer $login not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ CashoutCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict(
+ "Exchange account cannot perform cashout operation",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ CashoutCreationResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient funds to withdraw with Taler",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ CashoutCreationResult.MISSING_TAN_INFO -> throw conflict(
+ "Customer $login missing iinfo for tan channel ${req.tan_channel}",
+ TalerErrorCode.TALER_EC_END // TODO EC ?
+ )
+ CashoutCreationResult.SUCCESS -> {
+ when (tanChannel) {
+ TanChannel.sms -> throw Exception("TODO")
+ TanChannel.email -> throw Exception("TODO")
+ TanChannel.file -> {
+ File("/tmp/cashout-tan.txt").writeText(tanCode)
+ }
+ }
+ // TODO delete on error or commit transaction on error
+ call.respond(CashoutPending(opId.toString()))
+ }
+ }
}
post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") {
- val (login, _) = call.authCheck(db, TokenScope.readwrite)
- // TODO
+ call.authCheck(db, TokenScope.readwrite)
+ val opId = call.uuidUriComponent("CASHOUT_ID")
+ when (db.cashoutAbort(opId)) {
+ AbortResult.NOT_FOUND -> throw notFound(
+ "Cashout operation $opId not found",
+ TalerErrorCode.TALER_EC_END
+ )
+ AbortResult.CONFIRMED ->
+ throw conflict("Cannot abort confirmed cashout", TalerErrorCode.TALER_EC_END)
+ AbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ }
}
post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") {
- val (login, _) = call.authCheck(db, TokenScope.readwrite)
- // TODO
+ call.authCheck(db, TokenScope.readwrite)
+ val req = call.receive<CashoutConfirm>()
+ val opId = call.uuidUriComponent("CASHOUT_ID")
+ when (db.cashoutConfirm(
+ opUuid = opId,
+ tanCode = req.tan,
+ timestamp = Instant.now()
+ )) {
+ CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound(
+ "Cashout operation $opId not found",
+ TalerErrorCode.TALER_EC_END
+ )
+ CashoutConfirmationResult.ABORTED -> throw conflict(
+ "Cannot confirm an aborted cashout",
+ TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT
+ )
+ CashoutConfirmationResult.BAD_TAN_CODE -> throw forbidden(
+ "Incorrect TAN code",
+ TalerErrorCode.TALER_EC_END
+ )
+ CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ CashoutConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ }
+
}
get("/accounts/{USERNAME}/cashouts") {
val (login, _) = call.authCheck(db, TokenScope.readonly)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -729,19 +729,10 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Bank transaction didn't properly return")
- it.getBoolean("out_creditor_not_found") -> {
- logger.error("No creditor account found")
- BankTransactionResult.NO_CREDITOR
- }
- it.getBoolean("out_debtor_not_found") -> {
- logger.error("No debtor account found")
- BankTransactionResult.NO_DEBTOR
- }
+ it.getBoolean("out_creditor_not_found") -> BankTransactionResult.NO_CREDITOR
+ it.getBoolean("out_debtor_not_found") -> BankTransactionResult.NO_DEBTOR
it.getBoolean("out_same_account") -> BankTransactionResult.SAME_ACCOUNT
- it.getBoolean("out_balance_insufficient") -> {
- logger.error("Balance insufficient")
- BankTransactionResult.BALANCE_INSUFFICIENT
- }
+ it.getBoolean("out_balance_insufficient") -> BankTransactionResult.BALANCE_INSUFFICIENT
else -> {
handleExchangeTx(conn, subject, it.getLong("out_credit_bank_account_id"), it.getLong("out_debit_bank_account_id"), it)
BankTransactionResult.SUCCESS
@@ -983,6 +974,7 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
}
}
}
+
suspend fun talerWithdrawalGet(opUUID: UUID): TalerWithdrawalOperation? = conn { conn ->
val stmt = conn.prepareStatement("""
SELECT
@@ -1021,7 +1013,7 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
* Aborts one Taler withdrawal, only if it wasn't previously
* confirmed. It returns false if the UPDATE didn't succeed.
*/
- suspend fun talerWithdrawalAbort(opUUID: UUID): WithdrawalAbortResult = conn { conn ->
+ suspend fun talerWithdrawalAbort(opUUID: UUID): AbortResult = conn { conn ->
val stmt = conn.prepareStatement("""
UPDATE taler_withdrawal_operations
SET aborted = NOT confirmation_done
@@ -1031,9 +1023,9 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
)
stmt.setObject(1, opUUID)
when (stmt.oneOrNull { it.getBoolean(1) }) {
- null -> WithdrawalAbortResult.NOT_FOUND
- true -> WithdrawalAbortResult.CONFIRMED
- false -> WithdrawalAbortResult.SUCCESS
+ null -> AbortResult.NOT_FOUND
+ true -> AbortResult.CONFIRMED
+ false -> AbortResult.SUCCESS
}
}
@@ -1123,81 +1115,103 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f
/**
* Creates a cashout operation in the database.
*/
- suspend fun cashoutCreate(op: Cashout): Boolean = conn { conn ->
+ 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("""
- INSERT INTO cashout_operations (
- cashout_uuid
- ,amount_debit
- ,amount_credit
- ,buy_at_ratio
- ,buy_in_fee
- ,sell_at_ratio
- ,sell_out_fee
- ,subject
- ,creation_time
- ,tan_channel
- ,tan_code
- ,bank_account
- ,credit_payto_uri
- ,cashout_currency
- )
- VALUES (
- ?
- ,(?,?)::taler_amount
- ,(?,?)::taler_amount
- ,?
- ,(?,?)::taler_amount
- ,?
- ,(?,?)::taler_amount
- ,?
- ,?
- ,?::tan_enum
- ,?
- ,?
- ,?
- ,?
- );
+ 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.setObject(1, op.cashoutUuid)
- stmt.setLong(2, op.amountDebit.value)
- stmt.setInt(3, op.amountDebit.frac)
- stmt.setLong(4, op.amountCredit.value)
- stmt.setInt(5, op.amountCredit.frac)
- stmt.setInt(6, op.buyAtRatio)
- stmt.setLong(7, op.buyInFee.value)
- stmt.setInt(8, op.buyInFee.frac)
- stmt.setInt(9, op.sellAtRatio)
- stmt.setLong(10, op.sellOutFee.value)
- stmt.setInt(11, op.sellOutFee.frac)
- stmt.setString(12, op.subject)
- stmt.setLong(13, op.creationTime.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.setString(14, op.tanChannel.name)
- stmt.setString(15, op.tanCode)
- stmt.setLong(16, op.bankAccount)
- stmt.setString(17, op.credit_payto_uri)
- stmt.setString(18, op.cashoutCurrency)
- stmt.executeUpdateViolation()
+ 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
+ }
}
- /**
- * Flags one cashout operation as confirmed. The backing
- * payment should already have taken place, before calling
- * this function.
- */
suspend fun cashoutConfirm(
opUuid: UUID,
- tanConfirmationTimestamp: Long,
- bankTransaction: Long // regional payment backing the operation
- ): Boolean = conn { conn ->
+ tanCode: String,
+ timestamp: Instant,
+ accountServicerReference: String = "NOT-USED",
+ endToEndId: String = "NOT-USED",
+ paymentInfId: String = "NOT-USED"
+ ): CashoutConfirmationResult = conn { conn ->
val stmt = conn.prepareStatement("""
- UPDATE cashout_operations
- SET tan_confirmation_time = ?, local_transaction = ?
- WHERE cashout_uuid=?;
+ SELECT
+ out_no_op,
+ out_bad_code,
+ out_balance_insufficient,
+ out_aborted
+ FROM cashout_confirm(?, ?, ?, ?, ?, ?);
""")
- stmt.setLong(1, tanConfirmationTimestamp)
- stmt.setLong(2, bankTransaction)
- stmt.setObject(3, opUuid)
- stmt.executeUpdateViolation()
+ 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
+ }
+ }
}
/**
@@ -1632,17 +1646,7 @@ enum class WithdrawalSelectionResult {
ACCOUNT_IS_NOT_EXCHANGE
}
-/** Result status of withdrawal operation abortion */
-enum class WithdrawalAbortResult {
- SUCCESS,
- NOT_FOUND,
- CONFIRMED
-}
-
-/**
- * This type communicates the result of a database operation
- * to confirm one withdrawal operation.
- */
+/** Result status of withdrawal operation confirmation */
enum class WithdrawalConfirmationResult {
SUCCESS,
OP_NOT_FOUND,
@@ -1652,6 +1656,32 @@ enum class WithdrawalConfirmationResult {
ABORTED
}
+/** Result status of cashout operation creation */
+enum class CashoutCreationResult {
+ SUCCESS,
+ BAD_CONVERSION,
+ ACCOUNT_NOT_FOUND,
+ ACCOUNT_IS_EXCHANGE,
+ MISSING_TAN_INFO,
+ BALANCE_INSUFFICIENT
+}
+
+/** Result status of cashout operation confirmation */
+enum class CashoutConfirmationResult {
+ SUCCESS,
+ OP_NOT_FOUND,
+ BAD_TAN_CODE,
+ BALANCE_INSUFFICIENT,
+ ABORTED
+}
+
+/** Result status of withdrawal or cashout operation abortion */
+enum class AbortResult {
+ SUCCESS,
+ NOT_FOUND,
+ CONFIRMED
+}
+
private class NotificationWatcher(private val pgSource: PGSimpleDataSource) {
private class CountedSharedFlow(val flow: MutableSharedFlow<Long>, var count: Int)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -437,6 +437,11 @@ data class CashoutRequest(
)
@Serializable
+data class CashoutPending(
+ val cashout_id: String,
+)
+
+@Serializable
data class Cashouts(
val cashouts: List<CashoutInfo>,
)
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -19,6 +19,7 @@ import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
+import java.io.File
import kotlin.random.Random
import kotlin.test.*
import kotlinx.coroutines.*
@@ -1009,6 +1010,227 @@ class CoreBankWithdrawalApiTest {
class CoreBankCashoutApiTest {
+ fun tanCode(): String = File("/tmp/cashout-tan.txt").readText()
+
+ private suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount {
+ // Check conversion
+ client.get("/cashout-rate?amount_debit=$amount").assertOk().run {
+ val resp = Json.decodeFromString<CashoutConversionResponse>(bodyAsText())
+ return resp.amount_credit
+ }
+ }
+
+ // POST /accounts/{USERNAME}/cashouts
+ @Test
+ fun create() = bankSetup { _ ->
+ // TODO auth routine
+ val req = json {
+ "amount_debit" to "KUDOS:1"
+ "amount_credit" to convert("KUDOS:1")
+ "tan_channel" to "file"
+ }
+
+ // Check OK
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk()
+
+ // Check exchange account
+ client.post("/accounts/exchange/cashouts") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(req)
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT)
+
+ // Check insufficient fund
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json(req) {
+ "amount_debit" to "KUDOS:75"
+ "amount_credit" to convert("KUDOS:75")
+ })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT)
+
+ // Check wrong conversion
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json(req) {
+ "amount_credit" to convert("KUDOS:2")
+ })
+ }.assertConflict()
+
+ // Check wrong currency
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json(req) {
+ "amount_debit" to "EUR:1"
+ })
+ }.assertBadRequest().assertErr(TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH)
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json(req) {
+ "amount_credit" to "EUR:1"
+ })
+ }.assertBadRequest().assertErr(TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH)
+
+ // Check missing TAN info
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json(req) {
+ "tan_channel" to "sms"
+ })
+ }.assertConflict()
+ }
+
+ // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort
+ @Test
+ fun abort() = bankSetup { _ ->
+ // TODO auth routine
+ val req = json {
+ "amount_debit" to "KUDOS:1"
+ "amount_credit" to convert("KUDOS:1")
+ "tan_channel" to "file"
+ }
+
+ // Check abort created
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ val uuid = Json.decodeFromString<CashoutPending>(bodyAsText()).cashout_id
+
+ // Check OK
+ client.post("/accounts/customer/cashouts/$uuid/abort") {
+ basicAuth("customer", "customer-password")
+ }.assertNoContent()
+ // Check idempotence
+ client.post("/accounts/customer/cashouts/$uuid/abort") {
+ basicAuth("customer", "customer-password")
+ }.assertNoContent()
+ }
+
+ // Check abort confirmed
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ val uuid = Json.decodeFromString<CashoutPending>(bodyAsText()).cashout_id
+
+ client.post("/accounts/customer/cashouts/$uuid/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertNoContent()
+
+ // Check error
+ client.post("/accounts/customer/cashouts/$uuid/abort") {
+ basicAuth("customer", "customer-password")
+ }.assertConflict()
+ }
+
+ // Check bad UUID
+ client.post("/accounts/customer/cashouts/chocolate/abort") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertBadRequest()
+
+ // Check unknown
+ client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/abort") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertNotFound()
+ }
+
+ // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm
+ @Test
+ fun confirm() = bankSetup { _ ->
+ // TODO auth routine
+ val req = json {
+ "amount_debit" to "KUDOS:1"
+ "amount_credit" to convert("KUDOS:1")
+ "tan_channel" to "file"
+ }
+
+ // TODO check sms and mail TAN channel
+ // Check confirm
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ val uuid = Json.decodeFromString<CashoutPending>(bodyAsText()).cashout_id
+
+ // Check bad TAN code
+ client.post("/accounts/customer/cashouts/$uuid/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to "nice-try" })
+ }.assertForbidden()
+
+ // Check OK
+ client.post("/accounts/customer/cashouts/$uuid/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertNoContent()
+ // Check idempotence
+ client.post("/accounts/customer/cashouts/$uuid/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertNoContent()
+ }
+
+ // Check confirm aborted TODO
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ val uuid = Json.decodeFromString<CashoutPending>(bodyAsText()).cashout_id
+ client.post("/accounts/customer/cashouts/$uuid/abort") {
+ basicAuth("customer", "customer-password")
+ }.assertNoContent()
+
+ // Check error
+ client.post("/accounts/customer/cashouts/$uuid/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT)
+ }
+
+ // Check balance insufficient
+ client.post("/accounts/customer/cashouts") {
+ basicAuth("customer", "customer-password")
+ jsonBody(req)
+ }.assertOk().run {
+ val uuid = Json.decodeFromString<CashoutPending>(bodyAsText()).cashout_id
+ // Send too much money
+ client.post("/accounts/customer/transactions") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json {
+ "payto_uri" to "payto://iban/merchant-IBAN-XYZ?message=payout&amount=KUDOS:8"
+ })
+ }.assertNoContent()
+
+ client.post("/accounts/customer/cashouts/$uuid/confirm"){
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT)
+
+ // Check can abort because not confirmed
+ client.post("/accounts/customer/cashouts/$uuid/abort") {
+ basicAuth("customer", "customer-password")
+ }.assertNoContent()
+ }
+
+ // Check bad UUID
+ client.post("/accounts/customer/cashouts/chocolate/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertBadRequest()
+
+ // Check unknown
+ client.post("/accounts/customer/cashouts/${UUID.randomUUID()}/confirm") {
+ basicAuth("customer", "customer-password")
+ jsonBody(json { "tan" to tanCode() })
+ }.assertNotFound()
+ }
+
// GET /cashout-rate
@Test
fun rate() = bankSetup { _ ->
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -43,7 +43,7 @@ fun bankSetup(
password = "merchant-password",
name = "Merchant",
internalPaytoUri = IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"),
- maxDebt = TalerAmount(10, 1, "KUDOS"),
+ maxDebt = TalerAmount(10, 0, "KUDOS"),
isTalerExchange = false,
isPublic = false,
bonus = null
@@ -53,7 +53,7 @@ fun bankSetup(
password = "exchange-password",
name = "Exchange",
internalPaytoUri = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
- maxDebt = TalerAmount(10, 1, "KUDOS"),
+ maxDebt = TalerAmount(10, 0, "KUDOS"),
isTalerExchange = true,
isPublic = false,
bonus = null
@@ -63,7 +63,7 @@ fun bankSetup(
password = "customer-password",
name = "Customer",
internalPaytoUri = IbanPayTo("payto://iban/CUSTOMER-IBAN-XYZ"),
- maxDebt = TalerAmount(10, 1, "KUDOS"),
+ maxDebt = TalerAmount(10, 0, "KUDOS"),
isTalerExchange = false,
isPublic = false,
bonus = null
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
@@ -165,8 +165,9 @@ CREATE TABLE IF NOT EXISTS cashout_operations
ON UPDATE RESTRICT
,tan_channel tan_enum NOT NULL
,tan_code TEXT NOT NULL
- ,tan_confirmation_time BIGINT
- ,local_transaction BIGINT UNIQUE -- FIXME: Comment that the transaction only gets created after the TAN confirmation
+ ,tan_confirmation_time BIGINT DEFAULT NULL
+ ,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)
ON DELETE RESTRICT
ON UPDATE RESTRICT
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -80,19 +80,60 @@ END $$;
COMMENT ON FUNCTION amount_left_minus_right
IS 'Subtracts the right amount from the left and returns the difference and TRUE, if the left amount is larger than the right, or an invalid amount and FALSE otherwise.';
-CREATE OR REPLACE PROCEDURE bank_set_config(
- IN in_key TEXT,
- IN in_value TEXT
+CREATE OR REPLACE FUNCTION account_balance_is_sufficient(
+ IN in_account_id BIGINT,
+ IN in_amount taler_amount,
+ OUT out_balance_insufficient BOOLEAN
)
-LANGUAGE plpgsql AS $$
+LANGUAGE plpgsql AS $$
+DECLARE
+account_has_debt BOOLEAN;
+account_balance taler_amount;
+account_max_debt taler_amount;
BEGIN
-UPDATE configuration SET config_value=in_value WHERE config_key=in_key;
-IF NOT FOUND THEN
- INSERT INTO configuration (config_key, config_value) VALUES (in_key, in_value);
+-- get account info, we expect the account to exist
+SELECT
+ has_debt,
+ (balance).val, (balance).frac,
+ (max_debt).val, (max_debt).frac
+ INTO
+ account_has_debt,
+ account_balance.val, account_balance.frac,
+ account_max_debt.val, account_max_debt.frac
+ FROM bank_accounts WHERE bank_account_id=in_account_id;
+
+-- check enough funds
+IF account_has_debt THEN
+ -- debt case: simply checking against the max debt allowed.
+ SELECT sum.val, sum.frac
+ INTO account_balance.val, account_balance.frac
+ FROM amount_add(account_balance, in_amount) as sum;
+ SELECT NOT ok
+ INTO out_balance_insufficient
+ FROM amount_left_minus_right(account_max_debt, account_balance);
+ IF out_balance_insufficient THEN
+ RETURN;
+ END IF;
+ELSE -- not a debt account
+ SELECT NOT ok
+ INTO out_balance_insufficient
+ FROM amount_left_minus_right(account_balance, in_amount);
+ IF out_balance_insufficient THEN
+ -- debtor will switch to debt: determine their new negative balance.
+ SELECT
+ (diff).val, (diff).frac
+ INTO
+ account_balance.val, account_balance.frac
+ FROM amount_left_minus_right(in_amount, account_balance);
+ SELECT NOT ok
+ INTO out_balance_insufficient
+ FROM amount_left_minus_right(account_max_debt, account_balance);
+ IF out_balance_insufficient THEN
+ RETURN;
+ END IF;
+ END IF;
END IF;
END $$;
-COMMENT ON PROCEDURE bank_set_config(TEXT, TEXT)
- IS 'Update or insert configuration values';
CREATE OR REPLACE FUNCTION account_reconfig(
IN in_login TEXT,
@@ -555,21 +596,10 @@ CREATE OR REPLACE FUNCTION create_taler_withdrawal(
LANGUAGE plpgsql AS $$
DECLARE
account_id BIGINT;
-account_has_debt BOOLEAN;
-account_balance taler_amount;
-account_max_debt taler_amount;
BEGIN
-- check account exists
-SELECT
- has_debt, bank_account_id,
- (balance).val, (balance).frac,
- (max_debt).val, (max_debt).frac,
- is_taler_exchange
- INTO
- account_has_debt, account_id,
- account_balance.val, account_balance.frac,
- account_max_debt.val, account_max_debt.frac,
- out_account_is_exchange
+SELECT bank_account_id, is_taler_exchange
+ INTO account_id, out_account_is_exchange
FROM bank_accounts
JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id
WHERE login=in_account_username;
@@ -581,35 +611,9 @@ ELSIF out_account_is_exchange THEN
END IF;
-- check enough funds
-IF account_has_debt THEN
- -- debt case: simply checking against the max debt allowed.
- SELECT sum.val, sum.frac
- INTO account_balance.val, account_balance.frac
- FROM amount_add(account_balance, in_amount) as sum;
- SELECT NOT ok
- INTO out_balance_insufficient
- FROM amount_left_minus_right(account_max_debt, account_balance);
- IF out_balance_insufficient THEN
- RETURN;
- END IF;
-ELSE -- not a debt account
- SELECT NOT ok
- INTO out_balance_insufficient
- FROM amount_left_minus_right(account_balance, in_amount);
- IF out_balance_insufficient THEN
- -- debtor will switch to debt: determine their new negative balance.
- SELECT
- (diff).val, (diff).frac
- INTO
- account_balance.val, account_balance.frac
- FROM amount_left_minus_right(in_amount, account_balance);
- SELECT NOT ok
- INTO out_balance_insufficient
- FROM amount_left_minus_right(account_max_debt, account_balance);
- IF out_balance_insufficient THEN
- RETURN;
- END IF;
- END IF;
+SELECT account_balance_is_sufficient(account_id, in_amount) INTO out_balance_insufficient;
+IF out_balance_insufficient THEN
+ RETURN;
END IF;
-- Create withdrawal operation
@@ -1013,23 +1017,157 @@ WHERE bank_account_id=in_creditor_account_id;
PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || out_debit_row_id || ' ' || out_credit_row_id);
END $$;
-CREATE OR REPLACE FUNCTION cashout_delete(
- IN in_cashout_uuid UUID,
- OUT out_already_confirmed BOOLEAN
+CREATE OR REPLACE FUNCTION cashout_create(
+ IN in_account_username TEXT,
+ 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_tan_channel tan_enum,
+ IN in_tan_code TEXT,
+ -- 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,
+ -- Success return
+ OUT out_tan_info TEXT
)
-LANGUAGE plpgsql AS $$
+LANGUAGE plpgsql AS $$
+DECLARE
+account_id BIGINT;
BEGIN
- PERFORM
- FROM cashout_operations
- WHERE cashout_uuid=in_cashout_uuid AND tan_confirmation_time IS NOT NULL;
- IF FOUND THEN
- out_already_confirmed=TRUE;
- RETURN;
- END IF;
- out_already_confirmed=FALSE;
- DELETE FROM cashout_operations WHERE cashout_uuid=in_cashout_uuid;
+-- check conversion
+SELECT in_amount_credit!=expected INTO out_bad_conversion FROM conversion_internal_to_fiat(in_amount_debit) AS expected;
+IF out_bad_conversion THEN
+ RETURN;
+END IF;
+
+-- check account exists and has appropriate tan info
+SELECT
+ bank_account_id, is_taler_exchange,
+ CASE
+ WHEN in_tan_channel = 'file' THEN login -- unused
+ WHEN in_tan_channel = 'sms' THEN phone
+ WHEN in_tan_channel = 'email' THEN email
+ END
+ INTO account_id, out_account_is_exchange, out_tan_info
+ FROM bank_accounts
+ JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id
+ WHERE login=in_account_username;
+IF NOT FOUND THEN
+ out_account_not_found=TRUE;
+ RETURN;
+ELSIF out_account_is_exchange THEN
+ RETURN;
+ELSIF out_tan_info IS NULL THEN
+ out_missing_tan_info=TRUE;
+ RETURN;
+END IF;
+
+-- check enough funds
+SELECT account_balance_is_sufficient(account_id, in_amount_debit) INTO out_balance_insufficient;
+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
+);
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,
+ OUT out_no_op BOOLEAN,
+ OUT out_bad_code BOOLEAN,
+ OUT out_balance_insufficient BOOLEAN,
+ OUT out_aborted BOOLEAN
+)
+LANGUAGE plpgsql as $$
+DECLARE
+ wallet_account_id BIGINT;
+ admin_account_id BIGINT;
+ already_confirmed BOOLEAN;
+ subject_local TEXT;
+ amount_local taler_amount;
+BEGIN
+-- Retrieve cashout operation info
+SELECT
+ tan_code != in_tan_code,
+ tan_confirmation_time IS NOT NULL,
+ aborted, subject,
+ bank_account,
+ (amount_debit).val, (amount_debit).frac
+ INTO
+ out_bad_code,
+ already_confirmed,
+ out_aborted, subject_local,
+ wallet_account_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
+ RETURN;
+END IF;
+
+-- Retrieve admin account id
+SELECT bank_account_id
+ INTO admin_account_id
+ FROM bank_accounts
+ JOIN customers
+ ON customer_id=owning_customer_id
+ WHERE login = 'admin';
+
+-- Perform bank wire transfer
+SELECT transfer.out_balance_insufficient INTO out_balance_insufficient
+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
+) as transfer;
+IF out_balance_insufficient THEN
+ RETURN;
+END IF;
+
+-- Confirm operation
+UPDATE cashout_operations
+ SET tan_confirmation_time = in_confirmation_time
+ WHERE cashout_uuid=in_cashout_uuid;
+END $$;
+
+
CREATE OR REPLACE FUNCTION stats_get_frame(
IN now TIMESTAMP,
IN in_timeframe stat_timeframe_enum,