summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-01-03 01:54:21 +0000
committerAntoine A <>2024-01-03 01:54:21 +0000
commit95a2a1dfabbb5c6f86adb5fb5244886370252c9a (patch)
treeaa6c2d7969e5ba6e6662203f1b6bfc666f0a88b8
parent7f1d0c909e29c0d0a0880dffce7a572f13726b9c (diff)
downloadlibeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.tar.gz
libeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.tar.bz2
libeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.zip
2fa for cashout and remove obsolete cashout tan challenge logic
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt174
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt10
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt167
-rw-r--r--bank/src/test/kotlin/CoreBankApiTest.kt259
-rw-r--r--bank/src/test/kotlin/DatabaseTest.kt98
-rw-r--r--bank/src/test/kotlin/helpers.kt8
-rw-r--r--database-versioning/libeufin-bank-0001.sql2
-rw-r--r--database-versioning/libeufin-bank-0002.sql13
-rw-r--r--database-versioning/libeufin-bank-procedures.sql266
-rw-r--r--integration/test/IntegrationTest.kt8
10 files changed, 150 insertions, 855 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
index fcbe3a36..985ba707 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -562,134 +562,53 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) {
}
}
+suspend fun ApplicationCall.cashoutHttp(db: Database, ctx: BankConfig, req: CashoutRequest, is2fa: Boolean) {
+ ctx.checkRegionalCurrency(req.amount_debit)
+ ctx.checkFiatCurrency(req.amount_credit)
+
+ val res = db.cashout.create(
+ login = username,
+ requestUid = req.request_uid,
+ amountDebit = req.amount_debit,
+ amountCredit = req.amount_credit,
+ subject = req.subject ?: "", // TODO default subject
+ now = Instant.now(),
+ is2fa = is2fa
+ )
+ when (res) {
+ CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
+ CashoutCreationResult.BadConversion -> throw conflict(
+ "Wrong currency conversion",
+ TalerErrorCode.BANK_BAD_CONVERSION
+ )
+ CashoutCreationResult.AccountIsExchange -> throw conflict(
+ "Exchange account cannot perform cashout operation",
+ TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
+ )
+ CashoutCreationResult.BalanceInsufficient -> throw conflict(
+ "Insufficient funds to withdraw with Taler",
+ TalerErrorCode.BANK_UNALLOWED_DEBIT
+ )
+ CashoutCreationResult.RequestUidReuse -> throw conflict(
+ "request_uid used already",
+ TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
+ )
+ CashoutCreationResult.NoCashoutPayto -> throw conflict(
+ "Missing cashout payto uri",
+ TalerErrorCode.BANK_CONFIRM_INCOMPLETE
+ )
+ CashoutCreationResult.TanRequired -> {
+ respondChallenge(db, Operation.cashout, req)
+ }
+ is CashoutCreationResult.Success -> respond(CashoutResponse(res.id))
+ }
+}
+
private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) {
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/cashouts") {
val req = call.receive<CashoutRequest>()
-
- ctx.checkRegionalCurrency(req.amount_debit)
- ctx.checkFiatCurrency(req.amount_credit)
-
- val tanChannel = req.tan_channel ?: TanChannel.sms
- val tanScript = ctx.tanChannels.get(tanChannel) ?: throw libeufinError(
- HttpStatusCode.NotImplemented,
- "Unsupported tan channel $tanChannel",
- TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
- )
-
- val res = db.cashout.create(
- login = username,
- requestUid = req.request_uid,
- amountDebit = req.amount_debit,
- amountCredit = req.amount_credit,
- subject = req.subject ?: "", // TODO default subject
- tanChannel = tanChannel,
- tanCode = Tan.genCode(),
- now = Instant.now(),
- retryCounter = TAN_RETRY_COUNTER,
- validityPeriod = TAN_VALIDITY_PERIOD
- )
- when (res) {
- is CashoutCreationResult.AccountNotFound -> throw unknownAccount(username)
- is CashoutCreationResult.BadConversion -> throw conflict(
- "Wrong currency conversion",
- TalerErrorCode.BANK_BAD_CONVERSION
- )
- is CashoutCreationResult.AccountIsExchange -> throw conflict(
- "Exchange account cannot perform cashout operation",
- TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
- )
- is CashoutCreationResult.BalanceInsufficient -> throw conflict(
- "Insufficient funds to withdraw with Taler",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- is CashoutCreationResult.MissingTanInfo -> throw conflict(
- "Account '$username' missing info for tan channel ${req.tan_channel}",
- TalerErrorCode.BANK_MISSING_TAN_INFO
- )
- is CashoutCreationResult.RequestUidReuse -> throw conflict(
- "request_uid used already",
- TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
- )
- is CashoutCreationResult.Success -> {
- res.tanCode?.run {
- val exitValue = withContext(Dispatchers.IO) {
- val process = ProcessBuilder(tanScript, res.tanInfo).start()
- try {
- process.outputWriter().use { it.write(res.tanCode) }
- process.onExit().await()
- } catch (e: Exception) {
- process.destroy()
- }
- process.exitValue()
- }
- if (exitValue != 0) {
- throw libeufinError(
- HttpStatusCode.BadGateway,
- "Tan channel script failure with exit value $exitValue",
- TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
- )
- }
- db.cashout.markSent(res.id, Instant.now(), TAN_RETRANSMISSION_PERIOD, tanChannel, res.tanInfo)
- }
- call.respond(CashoutPending(res.id))
- }
- }
- }
- post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") {
- val id = call.longUriComponent("CASHOUT_ID")
- when (db.cashout.abort(id, username)) {
- AbortResult.UnknownOperation -> throw notFound(
- "Cashout operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- AbortResult.AlreadyConfirmed -> throw conflict(
- "Cannot abort confirmed cashout",
- TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
- )
- AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
- }
- }
- post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") {
- val req = call.receive<CashoutConfirm>()
- val id = call.longUriComponent("CASHOUT_ID")
- when (db.cashout.confirm(
- id = id,
- login = username,
- tanCode = req.tan,
- timestamp = Instant.now()
- )) {
- CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound(
- "Cashout operation $id not found",
- TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
- )
- CashoutConfirmationResult.ABORTED -> throw conflict(
- "Cannot confirm an aborted cashout",
- TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
- )
- CashoutConfirmationResult.BAD_TAN_CODE -> throw conflict(
- "Incorrect TAN code",
- TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
- )
- CashoutConfirmationResult.NO_RETRY -> throw libeufinError(
- HttpStatusCode.TooManyRequests,
- "Too many failed confirmation attempt",
- TalerErrorCode.BANK_TAN_RATE_LIMITED
- )
- CashoutConfirmationResult.NO_CASHOUT_PAYTO -> throw conflict(
- "Missing cashout payto uri",
- TalerErrorCode.BANK_CONFIRM_INCOMPLETE
- )
- CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.BANK_UNALLOWED_DEBIT
- )
- CashoutConfirmationResult.BAD_CONVERSION -> throw conflict(
- "Wrong currency conversion",
- TalerErrorCode.BANK_BAD_CONVERSION
- )
- CashoutConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
- }
+ call.cashoutHttp(db, ctx, req, false)
}
}
auth(db, TokenScope.readonly) {
@@ -724,7 +643,6 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio
}
}
-
private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
auth(db, TokenScope.readwrite) {
post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
@@ -774,7 +692,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
}
post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
val id = call.longUriComponent("CHALLENGE_ID")
- val req = call.receive<CashoutConfirm>()
+ val req = call.receive<ChallengeSolve>()
val res = db.tan.solve(
id = id,
login = username,
@@ -811,6 +729,10 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
val req = Json.decodeFromString<TransactionCreateRequest>(res.body)
call.bankTransactionHttp(db, ctx, req, true)
}
+ Operation.cashout -> {
+ val req = Json.decodeFromString<CashoutRequest>(res.body)
+ call.cashoutHttp(db, ctx, req, true)
+ }
}
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index e0429423..8aba10ad 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -75,7 +75,8 @@ enum class Timeframe {
enum class Operation {
account_reconfig,
account_delete,
- bank_transaction
+ bank_transaction,
+ cashout
}
@Serializable(with = Option.Serializer::class)
@@ -478,12 +479,11 @@ data class CashoutRequest(
val request_uid: ShortHashCode,
val subject: String?,
val amount_debit: TalerAmount,
- val amount_credit: TalerAmount,
- val tan_channel: TanChannel?
+ val amount_credit: TalerAmount
)
@Serializable
-data class CashoutPending(
+data class CashoutResponse(
val cashout_id: Long,
)
@@ -524,7 +524,7 @@ data class CashoutStatusResponse(
)
@Serializable
-data class CashoutConfirm(
+data class ChallengeSolve(
val tan: String
)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
index 58d2ca03..2c70a144 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -28,14 +28,14 @@ import tech.libeufin.util.*
class CashoutDAO(private val db: Database) {
/** Result of cashout operation creation */
sealed class CashoutCreationResult {
- /** Cashout [id] has been created or refreshed. If [tanCode] is not null, use [tanInfo] to send it via [tanChannel] then call [markSent] */
- data class Success(val id: Long, val tanInfo: String, val tanCode: String?): CashoutCreationResult()
+ data class Success(val id: Long): CashoutCreationResult()
object BadConversion: CashoutCreationResult()
object AccountNotFound: CashoutCreationResult()
object AccountIsExchange: CashoutCreationResult()
- object MissingTanInfo: CashoutCreationResult()
object BalanceInsufficient: CashoutCreationResult()
object RequestUidReuse: CashoutCreationResult()
+ object NoCashoutPayto: CashoutCreationResult()
+ object TanRequired: CashoutCreationResult()
}
/** Create a new cashout operation */
@@ -45,24 +45,20 @@ class CashoutDAO(private val db: Database) {
amountDebit: TalerAmount,
amountCredit: TalerAmount,
subject: String,
- tanChannel: TanChannel,
- tanCode: String,
now: Instant,
- retryCounter: Int,
- validityPeriod: Duration
+ is2fa: Boolean
): CashoutCreationResult = db.serializable { 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_id,
- out_tan_info,
- out_tan_code
- FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?)
+ out_no_cashout_payto,
+ out_tan_required,
+ out_cashout_id
+ FROM cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?)
""")
stmt.setString(1, login)
stmt.setBytes(2, requestUid.raw)
@@ -72,10 +68,7 @@ class CashoutDAO(private val db: Database) {
stmt.setInt(6, amountCredit.frac)
stmt.setString(7, subject)
stmt.setLong(8, now.toDbMicros() ?: throw faultyTimestampByBank())
- stmt.setString(9, tanChannel.name)
- stmt.setString(10, tanCode)
- stmt.setInt(11, retryCounter)
- stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod))
+ stmt.setBoolean(9, is2fa)
stmt.executeQuery().use {
when {
!it.next() ->
@@ -83,114 +76,11 @@ class CashoutDAO(private val db: Database) {
it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion
it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound
it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange
- it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MissingTanInfo
it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BalanceInsufficient
it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.RequestUidReuse
- else -> CashoutCreationResult.Success(
- id = it.getLong("out_cashout_id"),
- tanInfo = it.getString("out_tan_info"),
- tanCode = it.getString("out_tan_code")
- )
- }
- }
- }
-
- /** Mark cashout operation [id] challenge as having being successfully sent [now] and not to be retransmit until after [retransmissionPeriod] */
- suspend fun markSent(
- id: Long,
- now: Instant,
- retransmissionPeriod: Duration,
- tanChannel: TanChannel,
- tanInfo: String
- ) = db.serializable {
- it.transaction { conn ->
- conn.prepareStatement("""
- SELECT challenge_mark_sent(challenge, ?, ?)
- FROM cashout_operations
- WHERE cashout_id=?
- """).run {
- setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank())
- setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
- setLong(3, id)
- executeQueryCheck()
- }
- conn.prepareStatement("""
- UPDATE cashout_operations
- SET tan_channel = ?, tan_info = ?
- WHERE cashout_id=?
- """).run {
- setString(1, tanChannel.name)
- setString(2, tanInfo)
- setLong(3, id)
- executeUpdateCheck()
- }
- }
- }
-
- /** Abort cashout operation [id] owned by [login] */
- suspend fun abort(id: Long, login: String): AbortResult = db.serializable { conn ->
- val stmt = conn.prepareStatement("""
- UPDATE cashout_operations
- SET aborted = local_transaction IS NULL
- FROM bank_accounts JOIN customers ON customer_id=owning_customer_id
- WHERE cashout_id=? AND bank_account=bank_account_id AND login=?
- RETURNING local_transaction IS NOT NULL
- """)
- stmt.setLong(1, id)
- stmt.setString(2, login)
- when (stmt.oneOrNull { it.getBoolean(1) }) {
- null -> AbortResult.UnknownOperation
- true -> AbortResult.AlreadyConfirmed
- false -> AbortResult.Success
- }
- }
-
- /** Result status of cashout operation confirmation */
- enum class CashoutConfirmationResult {
- SUCCESS,
- BAD_CONVERSION,
- OP_NOT_FOUND,
- BAD_TAN_CODE,
- BALANCE_INSUFFICIENT,
- NO_RETRY,
- NO_CASHOUT_PAYTO,
- ABORTED
- }
-
- /** Confirm cashout operation [id] owned by [login] */
- suspend fun confirm(
- id: Long,
- login: String,
- tanCode: String,
- timestamp: Instant
- ): CashoutConfirmationResult = db.serializable { conn ->
- val stmt = conn.prepareStatement("""
- SELECT
- out_no_op,
- out_bad_conversion,
- out_bad_code,
- out_balance_insufficient,
- out_aborted,
- out_no_retry,
- out_no_cashout_payto
- FROM cashout_confirm(?, ?, ?, ?);
- """)
- stmt.setLong(1, id)
- stmt.setString(2, login)
- stmt.setString(3, tanCode)
- stmt.setLong(4, 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
- it.getBoolean("out_no_cashout_payto") -> CashoutConfirmationResult.NO_CASHOUT_PAYTO
- it.getBoolean("out_bad_conversion") -> CashoutConfirmationResult.BAD_CONVERSION
- else -> CashoutConfirmationResult.SUCCESS
+ it.getBoolean("out_no_cashout_payto") -> CashoutCreationResult.NoCashoutPayto
+ it.getBoolean("out_tan_required") -> CashoutCreationResult.TanRequired
+ else -> CashoutCreationResult.Success(it.getLong("out_cashout_id"))
}
}
}
@@ -199,20 +89,18 @@ class CashoutDAO(private val db: Database) {
suspend fun get(id: Long, login: String): CashoutStatusResponse? = db.conn { conn ->
val stmt = conn.prepareStatement("""
SELECT
- CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
- ,(amount_debit).val as amount_debit_val
+ (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
,cashout_operations.subject
,creation_time
,transaction_date as confirmation_date
- ,cashout_operations.tan_channel
- ,tan_info
+ ,tan_channel
+ ,CASE tan_channel
+ WHEN 'sms' THEN phone
+ WHEN 'email' THEN email
+ END as tan_info
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -223,7 +111,7 @@ class CashoutDAO(private val db: Database) {
stmt.setString(2, login)
stmt.oneOrNull {
CashoutStatusResponse(
- status = CashoutStatus.valueOf(it.getString("status")),
+ status = CashoutStatus.confirmed,
amount_debit = it.getAmount("amount_debit", db.bankCurrency),
amount_credit = it.getAmount("amount_credit", db.fiatCurrency!!),
subject = it.getString("subject"),
@@ -244,11 +132,6 @@ class CashoutDAO(private val db: Database) {
SELECT
cashout_id
,login
- ,CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -257,20 +140,14 @@ class CashoutDAO(private val db: Database) {
GlobalCashoutInfo(
cashout_id = it.getLong("cashout_id"),
username = it.getString("login"),
- status = CashoutStatus.valueOf(it.getString("status"))
+ status = CashoutStatus.confirmed
)
}
/** Get a page of all cashout operations owned by [login] */
suspend fun pageForUser(params: PageParams, login: String): List<CashoutInfo> =
db.page(params, "cashout_id", """
- SELECT
- cashout_id
- ,CASE
- WHEN aborted THEN 'aborted'
- WHEN local_transaction IS NOT NULL THEN 'confirmed'
- ELSE 'pending'
- END as status
+ SELECT cashout_id
FROM cashout_operations
JOIN bank_accounts ON bank_account=bank_account_id
JOIN customers ON owning_customer_id=customer_id
@@ -283,7 +160,7 @@ class CashoutDAO(private val db: Database) {
) {
CashoutInfo(
cashout_id = it.getLong("cashout_id"),
- status = CashoutStatus.valueOf(it.getString("status"))
+ status = CashoutStatus.confirmed
)
}
} \ No newline at end of file
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
index 0c10f992..0a254bb4 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -977,40 +977,17 @@ class CoreBankCashoutApiTest {
"amount_credit" to convert("KUDOS:1")
}
- // Check missing TAN info
+ // Missing info
client.postA("/accounts/customer/cashouts") {
json(req)
- }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
- client.patch("/accounts/customer") {
- pwAuth("admin")
- json {
- "contact_data" to obj {
- "phone" to "+99"
- "email" to "foo@example.com"
- }
- }
- }.assertNoContent()
+ }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
- // Check email TAN error TODO use 2FA
- /*client.postA("/accounts/customer/cashouts") {
- json(req) {
- "tan_channel" to "email"
- }
- }.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)*/
+ fillCashoutInfo("customer")
// Check OK
client.postA("/accounts/customer/cashouts") {
json(req)
- }.assertOkJson<CashoutPending> { first ->
- tanCode("+99")
- // Check idempotency
- client.postA("/accounts/customer/cashouts") {
- json(req)
- }.assertOkJson<CashoutPending> { second ->
- assertEquals(first.cashout_id, second.cashout_id)
- assertNull(tanCode("+99"))
- }
- }
+ }.assertOkJson<CashoutResponse>()
// Trigger conflict due to reused request_uid
client.postA("/accounts/customer/cashouts") {
@@ -1028,6 +1005,7 @@ class CoreBankCashoutApiTest {
// Check insufficient fund
client.postA("/accounts/customer/cashouts") {
json(req) {
+ "request_uid" to randShortHashCode()
"amount_debit" to "KUDOS:75"
"amount_credit" to convert("KUDOS:75")
}
@@ -1051,194 +1029,19 @@ class CoreBankCashoutApiTest {
"amount_credit" to "KUDOS:1"
}
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
- }
-
- // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort
- @Test
- fun abort() = bankSetup { _ ->
- authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/abort")
-
- fillCashoutInfo("customer")
-
- val req = obj {
- "request_uid" to randShortHashCode()
- "amount_debit" to "KUDOS:1"
- "amount_credit" to convert("KUDOS:1")
- }
-
- // Check abort created
- client.postA("/accounts/customer/cashouts") {
- json(req)
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check OK
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- // Check idempotence
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- }
-
- // Check abort confirmed
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to tanCode("+99") }
- }.assertNoContent()
-
- // Check error
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT)
- }
-
- // Check bad id
- client.postA("/accounts/customer/cashouts/chocolate/abort") {
- json { "tan" to "code" }
- }.assertBadRequest()
-
- // Check unknown
- client.postA("/accounts/customer/cashouts/42/abort") {
- json { "tan" to "code" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
-
- // Check abort another user's operation
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check error
- client.postA("/accounts/merchant/cashouts/$id/abort")
- .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
- }
- }
-
- // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm
- @Test
- fun confirm() = bankSetup { _ ->
- authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/confirm")
-
- client.patch("/accounts/customer") {
- pwAuth("admin")
- json {
- "contact_data" to obj {
- "phone" to "+99"
- }
- }
- }.assertNoContent()
-
- val req = obj {
- "request_uid" to randShortHashCode()
- "amount_debit" to "KUDOS:1"
- "amount_credit" to convert("KUDOS:1")
- }
- // Check confirm
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check missing cashout address
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to "code" }
- }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
- fillCashoutInfo("customer")
-
- // Check bad TAN code
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to "nice-try" }
- }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
-
- val code = tanCode("+99")
-
- // Check OK
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to code }
- }.assertNoContent()
- // Check idempotence
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to code }
- }.assertNoContent()
- }
-
- // Check confirm another user's operation
- client.postA("/accounts/customer/cashouts") {
- json(req) {
- "request_uid" to randShortHashCode()
- "amount_credit" to convert("KUDOS:1")
- }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
-
- // Check error
- client.postA("/accounts/merchant/cashouts/$id/confirm") {
- json { "tan" to "unused" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
- }
-
- // Check bad conversion
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- client.post("/conversion-info/conversion-rate") {
- pwAuth("admin")
- json {
- "cashin_ratio" to "1"
- "cashin_fee" to "KUDOS:0.1"
- "cashin_tiny_amount" to "KUDOS:0.0001"
- "cashin_rounding_mode" to "nearest"
- "cashin_min_amount" to "EUR:0.0001"
- "cashout_ratio" to "1"
- "cashout_fee" to "EUR:0.1"
- "cashout_tiny_amount" to "EUR:0.0001"
- "cashout_rounding_mode" to "nearest"
- "cashout_min_amount" to "KUDOS:0.0001"
- }
- }.assertNoContent()
-
- client.postA("/accounts/customer/cashouts/$id/confirm"){
- json { "tan" to tanCode("+99") }
- }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)
-
- // Check can abort because not confirmed
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- }
-
- // Check balance insufficient
+ // Check 2fa
+ fillTanInfo("customer")
+ assertBalance("customer", "-KUDOS:1")
client.postA("/accounts/customer/cashouts") {
- json(req) {
+ json(req) {
"request_uid" to randShortHashCode()
- "amount_credit" to convert("KUDOS:1")
}
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- // Send too much money
- tx("customer", "KUDOS:9", "merchant")
- client.postA("/accounts/customer/cashouts/$id/confirm"){
- json { "tan" to tanCode("+99") }
- }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
-
- // Check can abort because not confirmed
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
+ }.assertChallenge { _,_->
+ assertBalance("customer", "-KUDOS:1")
+ }.assertOkJson<CashoutResponse> {
+ assertBalance("customer", "-KUDOS:2")
}
-
- // Check bad UUID
- client.postA("/accounts/customer/cashouts/chocolate/confirm") {
- json { "tan" to "code" }
- }.assertBadRequest()
-
- // Check unknown
- client.postA("/accounts/customer/cashouts/42/confirm") {
- json { "tan" to "code" }
- }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
// GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID}
@@ -1257,41 +1060,15 @@ class CoreBankCashoutApiTest {
// Check confirm
client.postA("/accounts/customer/cashouts") {
json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
+ }.assertOkJson<CashoutResponse> {
val id = it.cashout_id
client.getA("/accounts/customer/cashouts/$id")
.assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.pending, it.status)
+ assertEquals(CashoutStatus.confirmed, it.status)
assertEquals(amountDebit, it.amount_debit)
assertEquals(amountCredit, it.amount_credit)
- assertEquals(TanChannel.sms, it.tan_channel)
- assertEquals("+99", it.tan_info)
- }
-
- client.postA("/accounts/customer/cashouts/$id/confirm") {
- json { "tan" to tanCode("+99") }
- }.assertNoContent()
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.confirmed, it.status)
- }
- }
-
- // Check abort
- client.postA("/accounts/customer/cashouts") {
- json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
- val id = it.cashout_id
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.pending, it.status)
- }
-
- client.postA("/accounts/customer/cashouts/$id/abort")
- .assertNoContent()
- client.getA("/accounts/customer/cashouts/$id")
- .assertOkJson<CashoutStatusResponse> {
- assertEquals(CashoutStatus.aborted, it.status)
+ assertNull(it.tan_channel)
+ assertNull(it.tan_info)
}
}
@@ -1306,7 +1083,7 @@ class CoreBankCashoutApiTest {
// Check get another user's operation
client.postA("/accounts/customer/cashouts") {
json(req) { "request_uid" to randShortHashCode() }
- }.assertOkJson<CashoutPending> {
+ }.assertOkJson<CashoutResponse> {
val id = it.cashout_id
// Check error
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
index da7bd56f..19d552ac 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -69,104 +69,6 @@ class DatabaseTest {
}
@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))
- }
- }}
-
- @Test
fun tanChallenge() = bankSetup { db -> db.conn { conn ->
val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)")
val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)")
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index 09eb62f3..2b8ff89a 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -236,11 +236,7 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) {
}
} else {
res
- }.assertOkJson<CashoutPending> {
- client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") {
- json { "tan" to tanCode("+99") }
- }.assertNoContent()
- }
+ }.assertOk()
}
/** Perform a whithrawal operation of [amount] from customer */
@@ -308,7 +304,7 @@ suspend fun tanCode(info: String): String? {
/* ----- Assert ----- */
suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse {
- assertEquals(status, this.status);
+ assertEquals(status, this.status, "$err")
if (err != null) assertErr(err)
return this
}
diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql
index 6bf7420a..63b39df2 100644
--- a/database-versioning/libeufin-bank-0001.sql
+++ b/database-versioning/libeufin-bank-0001.sql
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS cashout_operations
REFERENCES challenges(challenge_id)
ON DELETE CASCADE
ON UPDATE RESTRICT
- ,tan_channel TEXT NULL DEFAULT NULL -- TODO should be tan_enum but might be removed in the future
+ ,tan_channel TEXT NULL DEFAULT NULL
,tan_info TEXT NULL 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
diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql
index 81f91672..ca20f040 100644
--- a/database-versioning/libeufin-bank-0002.sql
+++ b/database-versioning/libeufin-bank-0002.sql
@@ -18,14 +18,21 @@ BEGIN;
SELECT _v.register_patch('libeufin-bank-0002', NULL, NULL);
SET search_path TO libeufin_bank;
--- TODO remove challenges table
--- TODO remove challenge and status columns from cashout_operations
+-- TODO drop pending cashout operations
+ALTER TABLE cashout_operations
+ DROP COLUMN challenge,
+ DROP COLUMN tan_channel,
+ DROP COLUMN tan_info,
+ DROP COLUMN aborted,
+ ALTER COLUMN local_transaction SET NOT NULL;
+
+DROP TABLE challenges;
ALTER TABLE customers
ADD tan_channel tan_enum NULL;
CREATE TYPE op_enum
- AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction');
+ AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction', 'cashout');
CREATE TABLE tan_challenges
(challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
index 1a2ba2c8..b84ef912 100644
--- a/database-versioning/libeufin-bank-procedures.sql
+++ b/database-versioning/libeufin-bank-procedures.sql
@@ -153,7 +153,7 @@ my_customer_id BIGINT;
my_balance_val INT8;
my_balance_frac INT4;
BEGIN
--- check if login exists
+-- check if login exists and if 2FA is required
SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL)
INTO my_customer_id, out_tan_required
FROM customers
@@ -1034,26 +1034,23 @@ CREATE FUNCTION cashout_create(
IN in_amount_credit taler_amount,
IN in_subject TEXT,
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,
+ IN in_is_tan BOOLEAN,
-- 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,
+ OUT out_no_cashout_payto BOOLEAN,
+ OUT out_tan_required BOOLEAN,
-- Success return
- OUT out_cashout_id BIGINT,
- OUT out_tan_info TEXT,
- OUT out_tan_code TEXT
+ OUT out_cashout_id BIGINT
)
LANGUAGE plpgsql AS $$
DECLARE
account_id BIGINT;
-challenge_id BIGINT;
+admin_account_id BIGINT;
+tx_id BIGINT;
BEGIN
-- check conversion
SELECT too_small OR no_config OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text);
@@ -1061,152 +1058,48 @@ IF out_bad_conversion THEN
RETURN;
END IF;
--- check account exists and has appropriate tan info
+-- check account exists, if has all info and if 2FA is required
SELECT
- bank_account_id, is_taler_exchange,
- CASE in_tan_channel
- WHEN 'sms' THEN phone
- WHEN 'email' THEN email
- END
- INTO account_id, out_account_is_exchange, out_tan_info
+ bank_account_id, is_taler_exchange, cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL)
+ INTO account_id, out_account_is_exchange, out_no_cashout_payto, out_tan_required
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;
+ELSIF out_account_is_exchange OR out_no_cashout_payto THEN
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;
+-- 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';
-- Check for idempotence and conflict
SELECT (amount_debit != in_amount_debit
OR subject != in_subject
OR bank_account != account_id)
- , challenge, cashout_id
- INTO out_request_uid_reuse, challenge_id, out_cashout_id
+ , cashout_id
+ INTO out_request_uid_reuse, out_cashout_id
FROM cashout_operations
WHERE request_uid = in_request_uid;
-
-IF NOT found THEN
- -- New cashout
- 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 (
- request_uid
- ,amount_debit
- ,amount_credit
- ,subject
- ,creation_time
- ,bank_account
- ,challenge
- ) VALUES (
- in_request_uid
- ,in_amount_debit
- ,in_amount_credit
- ,in_subject
- ,in_now_date
- ,account_id
- ,challenge_id
- ) RETURNING cashout_id INTO out_cashout_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 FUNCTION cashout_confirm(
- IN in_cashout_id BIGINT,
- IN in_login TEXT,
- IN in_tan_code TEXT,
- IN in_now_date BIGINT,
- OUT out_no_op BOOLEAN,
- OUT out_bad_conversion BOOLEAN,
- OUT out_bad_code BOOLEAN,
- OUT out_balance_insufficient BOOLEAN,
- OUT out_aborted BOOLEAN,
- OUT out_no_retry BOOLEAN,
- OUT out_no_cashout_payto BOOLEAN
-)
-LANGUAGE plpgsql as $$
-DECLARE
- wallet_account_id BIGINT;
- admin_account_id BIGINT;
- already_confirmed BOOLEAN;
- subject_local TEXT;
- amount_debit_local taler_amount;
- amount_credit_local taler_amount;
- challenge_id BIGINT;
- tx_id BIGINT;
-BEGIN
--- Retrieve cashout operation info
-SELECT
- local_transaction IS NOT NULL,
- aborted, subject,
- bank_account, challenge,
- (amount_debit).val, (amount_debit).frac,
- (amount_credit).val, (amount_credit).frac,
- cashout_payto IS NULL
- INTO
- already_confirmed,
- out_aborted, subject_local,
- wallet_account_id, challenge_id,
- amount_debit_local.val, amount_debit_local.frac,
- amount_credit_local.val, amount_credit_local.frac,
- out_no_cashout_payto
- FROM cashout_operations
- JOIN bank_accounts ON bank_account_id=bank_account
- JOIN customers ON customer_id=owning_customer_id
- WHERE cashout_id=in_cashout_id AND login=in_login;
-IF NOT FOUND THEN
- out_no_op=TRUE;
- RETURN;
-ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN
+IF found OR out_request_uid_reuse OR out_tan_required THEN
RETURN;
END IF;
--- check conversion
-SELECT too_small OR no_config OR amount_credit_local!=converted INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text);
-IF out_bad_conversion 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;
-
--- 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, out_debit_row_id
INTO out_balance_insufficient, tx_id
FROM bank_wire_transfer(
admin_account_id,
- wallet_account_id,
- subject_local,
- amount_debit_local,
+ account_id,
+ in_subject,
+ in_amount_debit,
in_now_date,
NULL,
NULL,
@@ -1216,102 +1109,29 @@ IF out_balance_insufficient THEN
RETURN;
END IF;
--- Confirm operation
-UPDATE cashout_operations
- SET local_transaction = tx_id
- WHERE cashout_id=in_cashout_id;
+-- Create cashout operation
+INSERT INTO cashout_operations (
+ request_uid
+ ,amount_debit
+ ,amount_credit
+ ,creation_time
+ ,bank_account
+ ,subject
+ ,local_transaction
+) VALUES (
+ in_request_uid
+ ,in_amount_debit
+ ,in_amount_credit
+ ,in_now_date
+ ,account_id
+ ,in_subject
+ ,tx_id
+) RETURNING cashout_id INTO out_cashout_id;
-- update stats
-CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_debit_local, amount_credit_local);
+CALL stats_register_payment('cashout', now()::TIMESTAMP, in_amount_debit, in_amount_credit);
END $$;
-CREATE 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 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 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 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 FUNCTION tan_challenge_create (
IN in_body TEXT,
IN in_op op_enum,
diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt
index ae148d85..ec2ea642 100644
--- a/integration/test/IntegrationTest.kt
+++ b/integration/test/IntegrationTest.kt
@@ -174,13 +174,7 @@ class IntegrationTest {
"amount_debit" to amount
"amount_credit" to convert
}
- }.assertOkJson<CashoutPending> {
- val code = File("/tmp/tan-+99.txt").readText()
- client.post("http://0.0.0.0:8090/accounts/customer/cashouts/${it.cashout_id}/confirm") {
- basicAuth("customer", "password")
- json { "tan" to code }
- }.assertNoContent()
- }
+ }.assertOkJson<CashoutResponse>()
}
}
}