diff options
Diffstat (limited to 'nexus/src')
21 files changed, 1329 insertions, 340 deletions
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt index 59094204..823ed449 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -31,9 +31,13 @@ class NexusFetchConfig(config: TalerConfig) { val ignoreBefore = config.lookupDate("nexus-fetch", "ignore_transactions_before") } +class ApiConfig(config: TalerConfig, section: String) { + val authMethod = config.requireAuthMethod(section) +} + /** Configuration for libeufin-nexus */ class NexusConfig(val config: TalerConfig) { - private fun requireString(option: String): String = config.requireString("nexus-ebics", option) + private fun requireString(option: String, type: String? = null): String = config.requireString("nexus-ebics", option, type) private fun requirePath(option: String): Path = config.requirePath("nexus-ebics", option) /** The bank's currency */ @@ -52,17 +56,26 @@ class NexusConfig(val config: TalerConfig) { bic = requireString("bic"), name = requireString("name") ) + /** Bank account payto */ + val payto = IbanPayto.build(account.iban, account.bic, account.name) /** Path where we store the bank public keys */ val bankPublicKeysPath = requirePath("bank_public_keys_file") /** Path where we store our private keys */ val clientPrivateKeysPath = requirePath("client_private_keys_file") val fetch = NexusFetchConfig(config) - val dialect = when (val type = requireString("bank_dialect")) { + val dialect = when (val type = requireString("bank_dialect", "dialect")) { "postfinance" -> Dialect.postfinance "gls" -> Dialect.gls - else -> throw TalerConfigError.invalid("dialct", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") + else -> throw TalerConfigError.invalid("bank dialect", "libeufin-nexus", "bank_dialect", "expected 'postfinance' or 'gls' got '$type'") } + val accountType = when (val type = requireString("account_type", "account type")) { + "normal" -> AccountType.normal + "exchange" -> AccountType.exchange + else -> throw TalerConfigError.invalid("account type", "libeufin-nexus", "account_type", "expected 'normal' or 'exchange' got '$type'") + } + val wireGatewayApiCfg = config.apiConf("nexus-httpd-wire-gateway-api") + val revenueApiCfg = config.apiConf("nexus-httpd-revenue-api") } fun NexusConfig.checkCurrency(amount: TalerAmount) { @@ -70,4 +83,34 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { "Wrong currency: expected regional $currency got ${amount.currency}", TalerErrorCode.GENERIC_CURRENCY_MISMATCH ) +} + +fun TalerConfig.requireAuthMethod(section: String): AuthMethod { + return when (val method = requireString(section, "auth_method", "auth method")) { + "none" -> AuthMethod.None + "bearer-token" -> { + val token = requireString(section, "auth_bearer_token") + AuthMethod.Bearer(token) + } + else -> throw TalerConfigError.invalid("auth method target type", section, "auth_method", "expected 'bearer-token' or 'none' got '$method'") + } +} + +fun TalerConfig.apiConf(section: String): ApiConfig? { + val enabled = requireBoolean(section, "enabled") + return if (enabled) { + return ApiConfig(this, section) + } else { + null + } +} + +sealed interface AuthMethod { + data object None: AuthMethod + data class Bearer(val token: String): AuthMethod +} + +enum class AccountType { + normal, + exchange }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index 96710648..f1e85513 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -95,7 +95,10 @@ suspend fun ingestOutgoingPayment( db: Database, payment: OutgoingPayment ) { - val result = db.payment.registerOutgoing(payment) + val metadata: Pair<ShortHashCode, ExchangeUrl>? = payment.wireTransferSubject?.let { + runCatching { parseOutgoingTxMetadata(it) }.getOrNull() + } + val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) if (result.new) { if (result.initiated) logger.info("$payment") @@ -106,8 +109,6 @@ suspend fun ingestOutgoingPayment( } } -private val PATTERN = Regex("[a-z0-9A-Z]{52}") - /** * Ingests an incoming payment. Stores the payment into valid talerable ones * or bounces it, according to the subject. @@ -117,18 +118,31 @@ private val PATTERN = Regex("[a-z0-9A-Z]{52}") */ suspend fun ingestIncomingPayment( db: Database, - payment: IncomingPayment + payment: IncomingPayment, + accountType: AccountType ) { suspend fun bounce(msg: String) { - val result = db.payment.registerMalformedIncoming( - payment, - payment.amount, - Instant.now() - ) - if (result.new) { - logger.info("$payment bounced in '${result.bounceId}': $msg") - } else { - logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg") + when (accountType) { + AccountType.exchange -> { + val result = db.payment.registerMalformedIncoming( + payment, + payment.amount, + Instant.now() + ) + if (result.new) { + logger.info("$payment bounced in '${result.bounceId}': $msg") + } else { + logger.debug("$payment already seen and bounced in '${result.bounceId}': $msg") + } + } + AccountType.normal -> { + val res = db.payment.registerIncoming(payment) + if (res.new) { + logger.info("$payment") + } else { + logger.debug("$payment already seen") + } + } } } runCatching { parseIncomingTxMetadata(payment.wireTransferSubject) }.fold( @@ -156,14 +170,14 @@ private suspend fun ingestDocument( whichDocument: SupportedDocument ) { when (whichDocument) { - SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> { + SupportedDocument.CAMT_052, SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> { try { parseTx(xml, cfg.currency, cfg.dialect).forEach { if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) { logger.debug("IGNORE $it") } else { when (it) { - is IncomingPayment -> ingestIncomingPayment(db, it) + is IncomingPayment -> ingestIncomingPayment(db, it, cfg.accountType) is OutgoingPayment -> ingestOutgoingPayment(db, it) is TxNotification.Reversal -> { logger.error("BOUNCE '${it.msgId}': ${it.reason}") @@ -212,10 +226,6 @@ private suspend fun ingestDocument( db.initiated.bankMessage(status.msgId, msg) } } - SupportedDocument.CAMT_052 -> { - // TODO parsing - // TODO ingesting - } } } @@ -307,15 +317,18 @@ enum class EbicsDocument { acknowledgement, /// Payment status - CustomerPaymentStatusReport pain.002 status, - /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 - notification, + /// Account intraday reports - BankToCustomerAccountReport camt.052 + report, /// Account statements - BankToCustomerStatement camt.053 statement, + /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 + notification, ; fun shortDescription(): String = when (this) { acknowledgement -> "EBICS acknowledgement" status -> "Payment status" + report -> "Account intraday reports" statement -> "Account statements" notification -> "Debit & credit notifications" } @@ -323,6 +336,7 @@ enum class EbicsDocument { fun fullDescription(): String = when (this) { acknowledgement -> "EBICS acknowledgement - CustomerAcknowledgement HAC pain.002" status -> "Payment status - CustomerPaymentStatusReport pain.002" + report -> "Account intraday reports - BankToCustomerAccountReport camt.052" statement -> "Account statements - BankToCustomerStatement camt.053" notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054" } @@ -330,6 +344,7 @@ enum class EbicsDocument { fun doc(): SupportedDocument = when (this) { acknowledgement -> SupportedDocument.PAIN_002_LOGS status -> SupportedDocument.PAIN_002 + report -> SupportedDocument.CAMT_052 statement -> SupportedDocument.CAMT_053 notification -> SupportedDocument.CAMT_054 } @@ -363,10 +378,10 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { * mode when no flags are passed to the invocation. */ override fun run() = cliCmd(logger, common.log) { - val cfg = extractEbicsConfig(common.config) + val cfg = loadNexusConfig(common.config) val dbCfg = cfg.config.dbConfig() - Database(dbCfg).use { db -> + Database(dbCfg, cfg.currency).use { db -> val (clientKeys, bankKeys) = expectFullKeys(cfg) val ctx = FetchContext( cfg, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt index 1c9ea902..7da7da07 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -155,7 +155,7 @@ suspend fun doKeysRequestAndUpdateState( * @param configFile location of the configuration entry point. * @return internal representation of the configuration. */ -fun extractEbicsConfig(configFile: Path?): NexusConfig { +fun loadNexusConfig(configFile: Path?): NexusConfig { val config = loadConfig(configFile) return NexusConfig(config) } @@ -197,8 +197,8 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { * This function collects the main steps of setting up an EBICS access. */ override fun run() = cliCmd(logger, common.log) { - val cfg = extractEbicsConfig(common.config) - val client = HttpClient { + val cfg = loadNexusConfig(common.config) + val client = HttpClient { install(HttpTimeout) { // It can take a lot of time for the bank to generate documents socketTimeoutMillis = 5 * 60 * 1000 diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index 8bde6d60..c6a6ceef 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -65,7 +65,7 @@ data class SubmissionContext( private suspend fun submitInitiatedPayment( ctx: SubmissionContext, payment: InitiatedPayment -): String { +): String { val creditAccount = try { val payto = Payto.parse(payment.creditPaytoUri).expectIban() IbanAccountMetadata( @@ -147,7 +147,7 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat * FIXME: reduce code duplication with the fetch subcommand. */ override fun run() = cliCmd(logger, common.log) { - val cfg = extractEbicsConfig(common.config) + val cfg = loadNexusConfig(common.config) val dbCfg = cfg.config.dbConfig() val (clientKeys, bankKeys) = expectFullKeys(cfg) val ctx = SubmissionContext( @@ -157,7 +157,7 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat httpClient = HttpClient(), fileLogger = FileLogger(ebicsLog) ) - Database(dbCfg).use { db -> + Database(dbCfg, cfg.currency).use { db -> val frequency: Duration = if (transient) { logger.info("Transient mode: submitting what found and returning.") Duration.ZERO diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index 192d7375..fce0b224 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -300,13 +300,9 @@ data class OutgoingPayment( private fun XmlDestructor.payto(prefix: String): String? { val iban = opt("${prefix}Acct")?.one("Id")?.one("IBAN")?.text() return if (iban != null) { - val payto = StringBuilder("payto://iban/$iban") val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() } - if (name != null) { - val urlEncName = URLEncoder.encode(name, "utf-8") - payto.append("?receiver-name=$urlEncName") - } - return payto.toString() + // Parse bic ? + IbanPayto.build(iban, null, name) } else { null } @@ -343,8 +339,10 @@ fun parseTx( } } - fun XmlDestructor.bookDate() = - one("BookgDt").one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC) + fun XmlDestructor.executionDate(): Instant { + // Value date if present else booking date + return (opt("ValDt") ?: one("BookgDt")).one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC) + } fun XmlDestructor.nexusId(): String? = opt("Refs") { opt("EndToEndId")?.textProvided() ?: opt("MsgId")?.text() } @@ -385,14 +383,14 @@ fun parseTx( XmlDestructor.fromStream(notifXml, "Document") { when (dialect) { Dialect.gls -> { - opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 + fun XmlDestructor.parseGlsInner() { opt("Acct") { // Sanity check on currency and IBAN ? } each("Ntry") { val entryRef = opt("AcctSvcrRef")?.text() assertBooked(entryRef) - val bookDate = bookDate() + val bookDate = executionDate() val kind = one("CdtDbtInd").enum<Kind>() val amount = amount(acceptedCurrency) one("NtryDtls").one("TxDtls") { // TODO handle batches @@ -440,6 +438,42 @@ fun parseTx( } } } + opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052 + parseGlsInner() + } + opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 + parseGlsInner() + } + opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054 + opt("Acct") { + // Sanity check on currency and IBAN ? + } + each("Ntry") { + val entryRef = opt("AcctSvcrRef")?.text() + assertBooked(entryRef) + val bookDate = executionDate() + val kind = one("CdtDbtInd").enum<Kind>() + val amount = amount(acceptedCurrency) + if (!isReversalCode()) { + one("NtryDtls").one("TxDtls") { + val txRef = one("Refs").opt("AcctSvcrRef")?.text() + val subject = opt("RmtInf")?.map("Ustrd") { text() }?.joinToString("") + if (kind == Kind.CRDT) { + val bankId = one("Refs").opt("TxId")?.text() + val debtorPayto = opt("RltdPties") { payto("Dbtr") } + txsInfo.add(TxInfo.Credit( + ref = bankId ?: txRef ?: entryRef, + bookDate = bookDate, + bankId = bankId, + amount = amount, + subject = subject, + debtorPayto = debtorPayto + )) + } + } + } + } + } } Dialect.postfinance -> { opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053 @@ -449,7 +483,7 @@ fun parseTx( each("Ntry") { val entryRef = opt("AcctSvcrRef")?.text() assertBooked(entryRef) - val bookDate = bookDate() + val bookDate = executionDate() if (isReversalCode()) { one("NtryDtls").one("TxDtls") { val kind = one("CdtDbtInd").enum<Kind>() @@ -475,7 +509,7 @@ fun parseTx( each("Ntry") { val entryRef = opt("AcctSvcrRef")?.text() assertBooked(entryRef) - val bookDate = bookDate() + val bookDate = executionDate() if (!isReversalCode()) { one("NtryDtls").each("TxDtls") { val kind = one("CdtDbtInd").enum<Kind>() diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt index b3153a8e..f93b1829 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -27,14 +27,17 @@ package tech.libeufin.nexus import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.arguments.convert -import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.groups.* import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.path +import com.github.ajalt.clikt.parameters.types.* +import com.github.ajalt.clikt.core.ProgramResult +import io.ktor.client.* +import io.ktor.client.plugins.* import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable import kotlin.io.path.* +import kotlin.math.max import io.ktor.server.application.* import org.slf4j.Logger import org.slf4j.event.Level @@ -44,11 +47,11 @@ import tech.libeufin.common.api.* import tech.libeufin.common.crypto.* import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.nexus.api.* +import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment import java.nio.file.Path -import java.time.Instant -import java.time.ZoneId +import java.time.* import java.time.format.DateTimeFormatter import javax.crypto.EncryptedPrivateKeyInfo @@ -73,6 +76,7 @@ fun Instant.fmtDateTime(): String = fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) { wireGatewayApi(db, cfg) + revenueApi(db, cfg) } /** @@ -130,7 +134,7 @@ class InitiatePayment: CliktCommand("Initiate an outgoing payment") { Base32Crockford.encode(bytes) } - Database(dbCfg).use { db -> + Database(dbCfg, currency).use { db -> db.initiated.create( InitiatedPayment( id = -1, @@ -145,100 +149,41 @@ class InitiatePayment: CliktCommand("Initiate an outgoing payment") { } } -class ConvertBackup: CliktCommand("Convert an old backup to the new config format") { - private val backupPath by argument( - "backup", - help = "Specifies the backup file" - ).path() - - @Serializable - data class EbicsKeysBackupJson( - val userID: String, - val partnerID: String, - val hostID: String, - val ebicsURL: String, - val authBlob: String, - val encBlob: String, - val sigBlob: String - ) +class Serve : CliktCommand("Run libeufin-nexus HTTP server", name = "serve") { + private val common by CommonOption() + private val check by option().flag() - override fun run() = cliCmd(logger, Level.INFO) { - val raw = backupPath.readText() - val backup = Json.decodeFromString<EbicsKeysBackupJson>(raw) - - val (authBlob, encBlob, sigBlob) = Triple( - EncryptedPrivateKeyInfo(backup.authBlob.decodeBase64()), - EncryptedPrivateKeyInfo(backup.encBlob.decodeBase64()), - EncryptedPrivateKeyInfo(backup.sigBlob.decodeBase64()) - ) - lateinit var keys: ClientPrivateKeysFile - while (true) { - val passphrase = prompt("Enter the backup password", hideInput = true)!! - try { - val (authKey, encKey, sigKey) = Triple( - CryptoUtil.decryptKey(authBlob, passphrase), - CryptoUtil.decryptKey(encBlob, passphrase), - CryptoUtil.decryptKey(sigBlob, passphrase) - ) - keys = ClientPrivateKeysFile( - signature_private_key = sigKey, - encryption_private_key = encKey, - authentication_private_key = authKey, - submitted_ini = false, - submitted_hia = false - ) - break - } catch (e: Exception) { - e.fmtLog(logger) + override fun run() = cliCmd(logger, common.log) { + val cfg = loadNexusConfig(common.config) + + if (check) { + // Check if the server is to be started + val apis = listOf( + cfg.wireGatewayApiCfg to "Wire Gateway API", + cfg.revenueApiCfg to "Revenue API" + ) + var startServer = false + for ((api, name) in apis) { + if (api != null) { + startServer = true + logger.info("$name is enabled: starting the server") + } + } + if (!startServer) { + logger.info("All APIs are disabled: not starting the server") + throw ProgramResult(1) + } else { + throw ProgramResult(0) } } - - - println("# KEYS") - println(JSON.encodeToString(kotlinx.serialization.serializer<ClientPrivateKeysFile>(), keys)) - println("# CONFIG") - println(""" -[nexus-ebics] -CURRENCY = CHF - -HOST_BASE_URL = ${backup.ebicsURL} -BANK_DIALECT = postfinance - - -HOST_ID = ${backup.hostID} -USER_ID = ${backup.userID} -PARTNER_ID = ${backup.partnerID} -SYSTEM_ID = - -IBAN = -BIC = -NAME = -""") - - /*val (authKey, encKey, sigKey) = try { - Triple( - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.authBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.encBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.sigBlob)), - passphrase - ) - ) - } catch (e: Exception) { - e.printStackTrace() - logger.info("Restoring keys failed, probably due to wrong passphrase") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad backup given" - ) - }*/ + val dbCfg = cfg.config.dbConfig() + val serverCfg = cfg.config.loadServerConfig("nexus-httpd") + Database(dbCfg, cfg.currency).use { db -> + serve(serverCfg) { + nexusApi(db, cfg) + } + } } } @@ -257,15 +202,14 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { ).convert { Payto.parse(it).expectIban() } override fun run() = cliCmd(logger, common.log) { - val cfg = loadConfig(common.config) - val dbCfg = cfg.dbConfig() - val currency = cfg.requireString("nexus-ebics", "currency") + val cfg = loadNexusConfig(common.config) + val dbCfg = cfg.config.dbConfig() val subject = payto.message ?: subject ?: throw Exception("Missing subject") val amount = payto.amount ?: amount ?: throw Exception("Missing amount") - if (amount.currency != currency) - throw Exception("Wrong currency: expected $currency got ${amount.currency}") + if (amount.currency != cfg.currency) + throw Exception("Wrong currency: expected ${cfg.currency} got ${amount.currency}") val bankId = run { val bytes = ByteArray(16) @@ -273,7 +217,7 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { Base32Crockford.encode(bytes) } - Database(dbCfg).use { db -> + Database(dbCfg, amount.currency).use { db -> ingestIncomingPayment(db, IncomingPayment( amount = amount, @@ -281,15 +225,217 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { wireTransferSubject = subject, executionTime = Instant.now(), bankId = bankId - ) + ), + cfg.accountType ) } } } +enum class ListKind { + incoming, + outgoing, + initiated; + + fun description(): String = when (this) { + incoming -> "Incoming transactions" + outgoing -> "Outgoing transactions" + initiated -> "Initiated transactions" + } +} + +class EbicsDownload: CliktCommand("Perform EBICS requests", name = "ebics-btd") { + private val common by CommonOption() + private val type by option().default("BTD") + private val name by option() + private val scope by option() + private val messageName by option() + private val messageVersion by option() + private val container by option() + private val option by option() + private val ebicsLog by option( + "--debug-ebics", + help = "Log EBICS content at SAVEDIR", + ) + private val pinnedStart by option( + help = "Constant YYYY-MM-DD date for the earliest document" + + " to download (only consumed in --transient mode). The" + + " latest document is always until the current time." + ) + private val dryRun by option().flag() + + class DryRun: Exception() + + override fun run() = cliCmd(logger, common.log) { + val cfg = loadNexusConfig(common.config) + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val pinnedStartVal = pinnedStart + val pinnedStartArg = if (pinnedStartVal != null) { + logger.debug("Pinning start date to: $pinnedStartVal") + // Converting YYYY-MM-DD to Instant. + LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() + } else null + val client = HttpClient { + install(HttpTimeout) { + // It can take a lot of time for the bank to generate documents + socketTimeoutMillis = 5 * 60 * 1000 + } + } + val fileLogger = FileLogger(ebicsLog) + try { + ebicsDownload( + client, + cfg, + clientKeys, + bankKeys, + EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option), + pinnedStartArg, + null + ) { stream -> + if (container == "ZIP") { + val stream = fileLogger.logFetch(stream, false) + stream.unzipEach { fileName, xmlContent -> + println(fileName) + println(xmlContent.readBytes().toString(Charsets.UTF_8)) + } + } else { + val stream = fileLogger.logFetch(stream, true) // TODO better name + println(stream.readBytes().toString(Charsets.UTF_8)) + } + if (dryRun) throw DryRun() + } + } catch (e: DryRun) { + // We throw DryRun to not consume files while testing + } + } +} + +class ListCmd: CliktCommand("List nexus transactions", name = "list") { + private val common by CommonOption() + private val kind: ListKind by argument( + help = "Which list to print", + helpTags = ListKind.entries.map { Pair(it.name, it.description()) }.toMap() + ).enum<ListKind>() + + override fun run() = cliCmd(logger, common.log) { + val cfg = loadConfig(common.config) + val dbCfg = cfg.dbConfig() + val currency = cfg.requireString("nexus-ebics", "currency") + + Database(dbCfg, currency).use { db -> + fun fmtPayto(payto: String?): String { + if (payto == null) return "" + try { + val parsed = Payto.parse(payto).expectIban() + return buildString { + append(parsed.iban.toString()) + if (parsed.bic != null) append(" ${parsed.bic}") + if (parsed.receiverName != null) append(" ${parsed.receiverName}") + } + } catch (e: Exception) { + return payto + } + } + val (columnNames, rows) = when (kind) { + ListKind.incoming -> { + val txs = db.payment.metadataIncoming() + Pair( + listOf( + "transaction", "id", "reserve_pub", "debtor", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + it.reservePub?.toString() ?: "", + fmtPayto(it.debtor), + it.subject + ) + } + ) + } + ListKind.outgoing -> { + val txs = db.payment.metadataOutgoing() + Pair( + listOf( + "transaction", "id", "creditor", "wtid", "exchange URL", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + fmtPayto(it.creditor), + it.wtid?.toString() ?: "", + it.exchangeBaseUrl ?: "", + it.subject ?: "", + ) + } + ) + } + ListKind.initiated -> { + val txs = db.payment.metadataInitiated() + Pair( + listOf( + "transaction", "id", "submission", "creditor", "status", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + "${it.submissionTime} ${it.submissionCounter}", + fmtPayto(it.creditor), + "${it.status} ${it.msg ?: ""}".trim(), + it.subject + ) + } + ) + } + } + val cols: List<Pair<String, Int>> = columnNames.mapIndexed { i, name -> + val maxRow: Int = rows.asSequence().map { it[i].length }.maxOrNull() ?: 0 + Pair(name, max(name.length, maxRow)) + } + val table = buildString { + fun padding(length: Int) { + repeat(length) { append (" ") } + } + var first = true + for ((name, len) in cols) { + if (!first) { + append("|") + } else { + first = false + } + val pad = len - name.length + padding(pad / 2) + append(name) + padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 }) + } + append("\n") + for (row in rows) { + var first = true + for ((met, str) in cols.zip(row)) { + if (!first) { + append("|") + } else { + first = false + } + val (name, len) = met + val pad = len - str.length + append(str) + padding(pad) + } + append("\n") + } + } + print(table) + } + } +} + class TestingCmd : CliktCommand("Testing helper commands", name = "testing") { init { - subcommands(FakeIncoming(), ConvertBackup()) + subcommands(FakeIncoming(), ListCmd(), EbicsDownload()) } override fun run() = Unit @@ -301,7 +447,7 @@ class TestingCmd : CliktCommand("Testing helper commands", name = "testing") { class LibeufinNexusCommand : CliktCommand() { init { versionOption(getVersion()) - subcommands(EbicsSetup(), DbInit(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) + subcommands(EbicsSetup(), DbInit(), Serve(), EbicsSubmit(), EbicsFetch(), InitiatePayment(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) } override fun run() = Unit } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt new file mode 100644 index 00000000..e1435a44 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt @@ -0,0 +1,45 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ +package tech.libeufin.nexus.api + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.* +import tech.libeufin.common.* + +fun Routing.revenueApi(db: Database, cfg: NexusConfig) = authApi(cfg.revenueApiCfg) { + get("/taler-revenue/config") { + call.respond(RevenueConfig( + currency = cfg.currency + )) + } + get("/taler-revenue/history") { + val params = HistoryParams.extract(context.request.queryParameters) + val items = db.payment.revenueHistory(params) + + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(RevenueIncomingHistory(items, cfg.payto)) + } + } +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt index f7374204..d645b953 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -29,10 +29,12 @@ import tech.libeufin.common.* import tech.libeufin.nexus.* import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.PaymentDAO.* +import tech.libeufin.nexus.db.InitiatedDAO.* +import tech.libeufin.nexus.db.ExchangeDAO.* import java.time.Instant -fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { +fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = authApi(cfg.wireGatewayApiCfg) { get("/taler-wire-gateway/config") { call.respond(WireGatewayConfig( currency = cfg.currency @@ -41,69 +43,52 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { post("/taler-wire-gateway/transfer") { val req = call.receive<TransferRequest>() cfg.checkCurrency(req.amount) - // TODO - /*val res = db.exchange.transfer( - req = req, - login = username, - now = Instant.now() + req.credit_account.expectRequestIban() + val bankId = run { + val bytes = ByteArray(16) + kotlin.random.Random.nextBytes(bytes) + Base32Crockford.encode(bytes) + } + val res = db.exchange.transfer( + req, + bankId, + Instant.now() ) when (res) { - is TransferResult.UnknownExchange -> throw unknownAccount(username) - is TransferResult.NotAnExchange -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - is TransferResult.UnknownCreditor -> throw unknownCreditorAccount(req.credit_account.canonical) - is TransferResult.BothPartyAreExchange -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - is TransferResult.ReserveUidReuse -> throw conflict( + TransferResult.RequestUidReuse -> throw conflict( "request_uid used already", TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED ) - is TransferResult.BalanceInsufficient -> throw conflict( - "Insufficient balance for exchange", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) is TransferResult.Success -> call.respond( TransferResponse( timestamp = res.timestamp, row_id = res.id ) ) - }*/ + } } - /*suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( + suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( reduce: (List<T>, String) -> Any, - dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> + dbLambda: suspend ExchangeDAO.(HistoryParams) -> List<T> ) { val params = HistoryParams.extract(context.request.queryParameters) - val bankAccount = call.bankInfo(db, ctx.payto) - - if (!bankAccount.isTalerExchange) - throw conflict( - "$username is not an exchange account.", - TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE - ) - - val items = db.exchange.dbLambda(params, bankAccount.bankAccountId, ctx.payto) - + val items = db.exchange.dbLambda(params) if (items.isEmpty()) { call.respond(HttpStatusCode.NoContent) } else { - call.respond(reduce(items, bankAccount.payto)) + call.respond(reduce(items, cfg.payto)) } - }*/ - /*get("/taler-wire-gateway/history/incoming") { + } + get("/taler-wire-gateway/history/incoming") { historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory) } get("/taler-wire-gateway/history/outgoing") { historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) - }*/ + } post("/taler-wire-gateway/admin/add-incoming") { val req = call.receive<AddIncomingRequest>() cfg.checkCurrency(req.amount) + req.debit_account.expectRequestIban() val timestamp = Instant.now() val bankId = run { val bytes = ByteArray(16) @@ -122,7 +107,6 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) { "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - // TODO timestamp when idempotent is IncomingRegistrationResult.Success -> call.respond( AddIncomingResponse( timestamp = TalerProtocolTimestamp(timestamp), diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt new file mode 100644 index 00000000..df5acb83 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt @@ -0,0 +1,65 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.api + +import tech.libeufin.nexus.* +import tech.libeufin.common.* +import tech.libeufin.common.api.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* + +/** Apply api configuration for a route: conditional access and authentication */ +fun Route.authApi(cfg: ApiConfig?, callback: Route.() -> Unit): Route = + intercept(callback) { + if (cfg == null) { + throw apiError(HttpStatusCode.NotImplemented, "API not implemented", TalerErrorCode.END) + } + val header = context.request.headers["Authorization"] + // Basic auth challenge + when (cfg.authMethod) { + AuthMethod.None -> {} + is AuthMethod.Bearer -> { + if (header == null) { + context.response.header(HttpHeaders.WWWAuthenticate, "Bearer") + throw unauthorized( + "Authorization header not found", + TalerErrorCode.GENERIC_PARAMETER_MISSING + ) + } + val (scheme, content) = header.splitOnce(" ") ?: throw badRequest( + "Authorization is invalid", + TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED + ) + when (scheme) { + "Bearer" -> { + // TODO choose between one of those + if (content != cfg.authMethod.token) { + throw unauthorized("Unknown token") + } + } + else -> throw unauthorized("Authorization method wrong or not supported") + } + } + } + }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt index b6422612..25cfaa59 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt @@ -18,9 +18,12 @@ */ package tech.libeufin.nexus.db +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory import tech.libeufin.common.TalerAmount -import tech.libeufin.common.db.DatabaseConfig -import tech.libeufin.common.db.DbPool +import tech.libeufin.common.db.* import java.time.Instant /** @@ -39,7 +42,39 @@ data class InitiatedPayment( /** * Collects database connection steps and any operation on the Nexus tables. */ -class Database(dbConfig: DatabaseConfig): DbPool(dbConfig, "libeufin_nexus") { +class Database(dbConfig: DatabaseConfig, val bankCurrency: String): DbPool(dbConfig, "libeufin_nexus") { val payment = PaymentDAO(this) val initiated = InitiatedDAO(this) + val exchange = ExchangeDAO(this) + + private val outgoingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() + private val incomingTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() + private val revenueTxFlows: MutableSharedFlow<Long> = MutableSharedFlow() + + init { + watchNotifications(pgSource, "libeufin_nexus", LoggerFactory.getLogger("libeufin-nexus-db-watcher"), mapOf( + "revenue_tx" to { + val id = it.toLong() + revenueTxFlows.emit(id) + }, + "outgoing_tx" to { + val id = it.toLong() + outgoingTxFlows.emit(id) + }, + "incoming_tx" to { + val id = it.toLong() + incomingTxFlows.emit(id) + } + )) + } + + /** Listen for new taler outgoing transactions */ + suspend fun <R> listenOutgoing(lambda: suspend (Flow<Long>) -> R): R + = lambda(outgoingTxFlows) + /** Listen for new taler incoming transactions */ + suspend fun <R> listenIncoming(lambda: suspend (Flow<Long>) -> R): R + = lambda(incomingTxFlows) + /** Listen for new incoming transactions */ + suspend fun <R> listenRevenue(lambda: suspend (Flow<Long>) -> R): R + = lambda(revenueTxFlows) }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt new file mode 100644 index 00000000..6f3a3a3a --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -0,0 +1,128 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.db + +import tech.libeufin.common.db.* +import tech.libeufin.common.* +import java.sql.ResultSet +import java.time.Instant + +/** Data access logic for exchange specific logic */ +class ExchangeDAO(private val db: Database) { + /** Query history of taler incoming transactions */ + suspend fun incomingHistory( + params: HistoryParams + ): List<IncomingReserveTransaction> + = db.poolHistoryGlobal(params, db::listenIncoming, """ + SELECT + incoming_transaction_id + ,execution_time + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,debit_payto_uri + ,reserve_public_key + FROM talerable_incoming_transactions + JOIN incoming_transactions USING(incoming_transaction_id) + WHERE + """, "incoming_transaction_id") { + IncomingReserveTransaction( + row_id = it.getLong("incoming_transaction_id"), + date = it.getTalerTimestamp("execution_time"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getString("debit_payto_uri"), + reserve_pub = EddsaPublicKey(it.getBytes("reserve_public_key")), + ) + } + + /** Query [exchangeId] history of taler outgoing transactions */ + suspend fun outgoingHistory( + params: HistoryParams + ): List<OutgoingTransaction> + = db.poolHistoryGlobal(params, db::listenOutgoing, """ + SELECT + outgoing_transaction_id + ,execution_time AS execution_time + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,credit_payto_uri AS credit_payto_uri + ,wtid + ,exchange_base_url + FROM talerable_outgoing_transactions + JOIN outgoing_transactions USING(outgoing_transaction_id) + WHERE + """, "outgoing_transaction_id") { + OutgoingTransaction( + row_id = it.getLong("outgoing_transaction_id"), + date = it.getTalerTimestamp("execution_time"), + amount = it.getAmount("amount", db.bankCurrency), + credit_account = it.getString("credit_payto_uri"), + wtid = ShortHashCode(it.getBytes("wtid")), + exchange_base_url = it.getString("exchange_base_url") + ) + } + + /** Result of taler transfer transaction creation */ + sealed interface TransferResult { + /** Transaction [id] and wire transfer [timestamp] */ + data class Success(val id: Long, val timestamp: TalerProtocolTimestamp): TransferResult + data object RequestUidReuse: TransferResult + } + + /** Perform a Taler transfer */ + suspend fun transfer( + req: TransferRequest, + bankId: String, + now: Instant + ): TransferResult = db.serializable { conn -> + val subject = "${req.wtid} ${req.exchange_base_url.url}" + val stmt = conn.prepareStatement(""" + SELECT + out_request_uid_reuse + ,out_tx_row_id + ,out_timestamp + FROM + taler_transfer ( + ?, ?, ?, + (?,?)::taler_amount, + ?, ?, ?, ? + ); + """) + + stmt.setBytes(1, req.request_uid.raw) + stmt.setBytes(2, req.wtid.raw) + stmt.setString(3, subject) + stmt.setLong(4, req.amount.value) + stmt.setInt(5, req.amount.frac) + stmt.setString(6, req.exchange_base_url.url) + stmt.setString(7, req.credit_account.canonical) + stmt.setString(8, bankId) + stmt.setLong(9, now.micros()) + + stmt.one { + when { + it.getBoolean("out_request_uid_reuse") -> TransferResult.RequestUidReuse + else -> TransferResult.Success( + id = it.getLong("out_tx_row_id"), + timestamp = it.getTalerTimestamp("out_timestamp") + ) + } + } + } +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt index 04fd3965..052b75f9 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt @@ -22,6 +22,7 @@ package tech.libeufin.nexus.db import tech.libeufin.common.asInstant import tech.libeufin.common.db.all import tech.libeufin.common.db.executeUpdateViolation +import tech.libeufin.common.db.oneUniqueViolation import tech.libeufin.common.db.getAmount import tech.libeufin.common.db.oneOrNull import tech.libeufin.common.micros @@ -32,9 +33,9 @@ import java.time.Instant class InitiatedDAO(private val db: Database) { /** Outgoing payments initiation result */ - enum class PaymentInitiationResult { - REQUEST_UID_REUSE, - SUCCESS + sealed interface PaymentInitiationResult { + data class Success(val id: Long): PaymentInitiationResult + data object RequestUidReuse: PaymentInitiationResult } /** Register a new pending payment in the database */ @@ -47,16 +48,18 @@ class InitiatedDAO(private val db: Database) { ,initiation_time ,request_uid ) VALUES ((?,?)::taler_amount,?,?,?,?) + RETURNING initiated_outgoing_transaction_id """) + // TODO check payto uri stmt.setLong(1, paymentData.amount.value) stmt.setInt(2, paymentData.amount.frac) stmt.setString(3, paymentData.wireTransferSubject) stmt.setString(4, paymentData.creditPaytoUri.toString()) stmt.setLong(5, paymentData.initiationTime.micros()) stmt.setString(6, paymentData.requestUid) - if (stmt.executeUpdateViolation()) - return@conn PaymentInitiationResult.SUCCESS - return@conn PaymentInitiationResult.REQUEST_UID_REUSE + stmt.oneUniqueViolation(PaymentInitiationResult.RequestUidReuse) { + PaymentInitiationResult.Success(it.getLong("initiated_outgoing_transaction_id")) + } } /** Register EBICS submission success */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt index 05548b99..a07857cf 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -19,10 +19,8 @@ package tech.libeufin.nexus.db -import tech.libeufin.common.EddsaPublicKey -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.db.one -import tech.libeufin.common.micros +import tech.libeufin.common.db.* +import tech.libeufin.common.* import tech.libeufin.nexus.IncomingPayment import tech.libeufin.nexus.OutgoingPayment import java.time.Instant @@ -37,10 +35,14 @@ class PaymentDAO(private val db: Database) { ) /** Register an outgoing payment reconciling it with its initiated payment counterpart if present */ - suspend fun registerOutgoing(paymentData: OutgoingPayment): OutgoingRegistrationResult = db.conn { + suspend fun registerOutgoing( + paymentData: OutgoingPayment, + wtid: ShortHashCode?, + baseUrl: ExchangeUrl?, + ): OutgoingRegistrationResult = db.conn { val stmt = it.prepareStatement(""" SELECT out_tx_id, out_initiated, out_found - FROM register_outgoing((?,?)::taler_amount,?,?,?,?) + FROM register_outgoing((?,?)::taler_amount,?,?,?,?,?,?) """) val executionTime = paymentData.executionTime.micros() stmt.setLong(1, paymentData.amount.value) @@ -49,6 +51,17 @@ class PaymentDAO(private val db: Database) { stmt.setLong(4, executionTime) stmt.setString(5, paymentData.creditPaytoUri) stmt.setString(6, paymentData.messageId) + if (wtid != null) { + stmt.setBytes(7, wtid.raw) + } else { + stmt.setNull(7, java.sql.Types.NULL) + } + if (baseUrl != null) { + stmt.setString(8, baseUrl.url) + } else { + stmt.setNull(8, java.sql.Types.NULL) + } + stmt.one { OutgoingRegistrationResult( it.getLong("out_tx_id"), @@ -128,4 +141,172 @@ class PaymentDAO(private val db: Database) { } } } -}
\ No newline at end of file + + /** Register an incoming payment */ + suspend fun registerIncoming( + paymentData: IncomingPayment + ): IncomingRegistrationResult.Success = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT out_found, out_tx_id + FROM register_incoming((?,?)::taler_amount,?,?,?,?) + """) + val executionTime = paymentData.executionTime.micros() + stmt.setLong(1, paymentData.amount.value) + stmt.setInt(2, paymentData.amount.frac) + stmt.setString(3, paymentData.wireTransferSubject) + stmt.setLong(4, executionTime) + stmt.setString(5, paymentData.debitPaytoUri) + stmt.setString(6, paymentData.bankId) + stmt.one { + IncomingRegistrationResult.Success( + it.getLong("out_tx_id"), + !it.getBoolean("out_found") + ) + } + } + + /** Query history of incoming transactions */ + suspend fun revenueHistory( + params: HistoryParams + ): List<RevenueIncomingBankTransaction> + = db.poolHistoryGlobal(params, db::listenRevenue, """ + SELECT + incoming_transaction_id + ,execution_time + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,debit_payto_uri + ,wire_transfer_subject + FROM incoming_transactions WHERE + """, "incoming_transaction_id") { + RevenueIncomingBankTransaction( + row_id = it.getLong("incoming_transaction_id"), + date = it.getTalerTimestamp("execution_time"), + amount = it.getAmount("amount", db.bankCurrency), + debit_account = it.getString("debit_payto_uri"), + subject = it.getString("wire_transfer_subject") + ) + } + + /** List incoming transaction metadata for debugging */ + suspend fun metadataIncoming(): List<IncomingTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,execution_time + ,debit_payto_uri + ,bank_id + ,reserve_public_key + FROM incoming_transactions + LEFT OUTER JOIN talerable_incoming_transactions using (incoming_transaction_id) + ORDER BY execution_time + """) + stmt.all { + IncomingTxMetadata( + date = it.getLong("execution_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + debtor = it.getString("debit_payto_uri"), + id = it.getString("bank_id"), + reservePub = it.getBytes("reserve_public_key")?.run { EddsaPublicKey(this) } + ) + } + } + + /** List outgoing transaction metadata for debugging */ + suspend fun metadataOutgoing(): List<OutgoingTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,execution_time + ,credit_payto_uri + ,message_id + ,wtid + ,exchange_base_url + FROM outgoing_transactions + LEFT OUTER JOIN talerable_outgoing_transactions using (outgoing_transaction_id) + ORDER BY execution_time + """) + stmt.all { + OutgoingTxMetadata( + date = it.getLong("execution_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + creditor = it.getString("credit_payto_uri"), + id = it.getString("message_id"), + wtid = it.getBytes("wtid")?.run { ShortHashCode(this) }, + exchangeBaseUrl = it.getString("exchange_base_url") + ) + } + } + + /** List initiated transaction metadata for debugging */ + suspend fun metadataInitiated(): List<InitiatedTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,initiation_time + ,last_submission_time + ,submission_counter + ,credit_payto_uri + ,submitted + ,request_uid + ,failure_message + FROM initiated_outgoing_transactions + ORDER BY initiation_time + """) + stmt.all { + InitiatedTxMetadata( + date = it.getLong("initiation_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + creditor = it.getString("credit_payto_uri"), + id = it.getString("request_uid"), + status = it.getString("submitted"), + msg = it.getString("failure_message"), + submissionTime = it.getLong("last_submission_time").asInstant(), + submissionCounter = it.getInt("submission_counter") + ) + } + } +} + +/** Incoming transaction metadata for debugging */ +data class IncomingTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String, + val debtor: String, + val id: String, + val reservePub: EddsaPublicKey? +) + +/** Outgoing transaction metadata for debugging */ +data class OutgoingTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String?, + val creditor: String?, + val id: String, + val wtid: ShortHashCode?, + val exchangeBaseUrl: String? +) + +/** Initiated metadata for debugging */ +data class InitiatedTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String, + val creditor: String, + val id: String, + val status: String, + val msg: String?, + val submissionTime: Instant, + val submissionCounter: Int +)
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt index d6cced05..40830093 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -62,11 +62,12 @@ enum class Dialect { } } } + // TODO for GLS we might have to fetch the same kind of files from multiple orders gls -> when (doc) { SupportedDocument.PAIN_002 -> EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT") SupportedDocument.CAMT_052 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP") SupportedDocument.CAMT_053 -> EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP") - SupportedDocument.CAMT_054 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP") + SupportedDocument.CAMT_054 -> EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI") SupportedDocument.PAIN_002_LOGS -> EbicsOrder.V3("HAC") } } diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt index 19bc0853..52b131cc 100644 --- a/nexus/src/test/kotlin/CliTest.kt +++ b/nexus/src/test/kotlin/CliTest.kt @@ -118,4 +118,38 @@ class CliTest { nexusCmd.testErr("ebics-setup -c $conf", "Could not write client private keys at '$clientKeysPath': permission denied on '${clientKeysPath.parent}'") } } + + /** Test server check */ + @Test + fun serveCheck() { + val confs = listOf( + "mini" to 1, + "test" to 0 + ) + for ((conf, statusCode) in confs) { + val result = nexusCmd.test("serve --check -c conf/$conf.conf") + assertEquals(statusCode, result.statusCode) + } + } + + /** Test list cmds */ + @Test + fun listCheck() = setup { db, _ -> + fun check() { + for (list in listOf("incoming", "outgoing", "initiated")) { + val result = nexusCmd.test("testing list $list -c conf/test.conf") + assertEquals(0, result.statusCode) + } + } + // Check empty + check() + // Check with transactions + ingestIn(db) + ingestOut(db) + check() + // Check with taler transactions + talerableOut(db) + talerableIn(db) + check() + } }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt index 66bbe564..29a79799 100644 --- a/nexus/src/test/kotlin/DatabaseTest.kt +++ b/nexus/src/test/kotlin/DatabaseTest.kt @@ -18,10 +18,12 @@ */ import org.junit.Test -import tech.libeufin.common.TalerAmount +import tech.libeufin.common.* import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult +import tech.libeufin.nexus.* import java.time.Instant import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -30,32 +32,43 @@ class OutgoingPaymentsTest { @Test fun register() = setup { db, _ -> // With reconciling - genOutPay("paid by nexus", "first").run { - assertEquals( - PaymentInitiationResult.SUCCESS, - db.initiated.create(genInitPay("waiting for reconciliation", "first")) + genOutPay("paid by nexus").run { + assertIs<PaymentInitiationResult.Success>( + db.initiated.create(genInitPay("waiting for reconciliation", messageId)) ) - db.payment.registerOutgoing(this).run { - assertTrue(new,) + db.payment.registerOutgoing(this, null, null).run { + assertTrue(new) assertTrue(initiated) } - db.payment.registerOutgoing(this).run { + db.payment.registerOutgoing(this, null, null).run { assertFalse(new) assertTrue(initiated) } } // Without reconciling - genOutPay("not paid by nexus", "second").run { - db.payment.registerOutgoing(this).run { + genOutPay("not paid by nexus").run { + db.payment.registerOutgoing(this, null, null).run { assertTrue(new) assertFalse(initiated) } - db.payment.registerOutgoing(this).run { + db.payment.registerOutgoing(this, null, null).run { assertFalse(new) assertFalse(initiated) } } } + + @Test + fun talerable() = setup { db, _ -> + val wtid = ShortHashCode.rand() + val url = "https://exchange.com" + genOutPay("$wtid $url").run { + assertIs<PaymentInitiationResult.Success>( + db.initiated.create(genInitPay("waiting for reconciliation", messageId)) + ) + ingestOutgoingPayment(db, this) + } + } } class IncomingPaymentsTest { @@ -117,8 +130,7 @@ class PaymentInitiationsTest { @Test fun status() = setup { db, _ -> - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY1")) ) db.initiated.submissionFailure(1, Instant.now(), "First failure") @@ -126,8 +138,7 @@ class PaymentInitiationsTest { db.initiated.submissionSuccess(1, Instant.now(), "ORDER1") assertEquals(Pair("PAY1", null), db.initiated.logFailure("ORDER1")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY2")) ) db.initiated.submissionFailure(2, Instant.now(), "First failure") @@ -135,8 +146,7 @@ class PaymentInitiationsTest { db.initiated.logMessage("ORDER2", "status msg") assertEquals(Pair("PAY2", "status msg"), db.initiated.logFailure("ORDER2")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY3")) ) db.initiated.submissionSuccess(3, Instant.now(), "ORDER3") @@ -146,15 +156,13 @@ class PaymentInitiationsTest { assertNull(db.initiated.logSuccess("ORDER_X")) assertNull(db.initiated.logFailure("ORDER_X")) - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY4")) ) db.initiated.bankMessage("PAY4", "status progress") db.initiated.bankFailure("PAY4", "status failure") - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY5")) ) db.initiated.bankMessage("PAY5", "status progress") @@ -164,8 +172,7 @@ class PaymentInitiationsTest { @Test fun submittable() = setup { db, _ -> for (i in 0..5) { - assertEquals( - PaymentInitiationResult.SUCCESS, + assertIs<PaymentInitiationResult.Success>( db.initiated.create(genInitPay(requestUid = "PAY$i")) ) } diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt index c0ff4b98..c0327d69 100644 --- a/nexus/src/test/kotlin/Iso20022Test.kt +++ b/nexus/src/test/kotlin/Iso20022Test.kt @@ -55,14 +55,14 @@ class Iso20022Test { amount = TalerAmount("CHF:10"), wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG", executionTime = instant("2023-12-19"), - debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test" + debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test" ), IncomingPayment( bankId = "62e2b511-7313-4ccd-8d40-c9d8e612cd71", amount = TalerAmount("CHF:2.53"), wireTransferSubject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB", executionTime = instant("2023-12-19"), - debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr+Test" + debitPaytoUri = "payto://iban/CH7389144832588726658?receiver-name=Mr%20Test" ) ), txs @@ -91,8 +91,8 @@ class Iso20022Test { } @Test - fun gls() { - val content = Files.newInputStream(Path("sample/platform/gls.xml")) + fun gls_camt052() { + val content = Files.newInputStream(Path("sample/platform/gls_camt052.xml")) val txs = parseTx(content, "EUR", Dialect.gls) assertEquals( listOf( @@ -101,21 +101,21 @@ class Iso20022Test { amount = TalerAmount("EUR:2"), wireTransferSubject = "TestABC123", executionTime = instant("2024-04-18"), - creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John+Smith" + creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), OutgoingPayment( messageId = "YF5QBARGQ0MNY0VK59S477VDG4", amount = TalerAmount("EUR:1.1"), wireTransferSubject = "This should fail because dummy", executionTime = instant("2024-04-18"), - creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John+Smith" + creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" ), IncomingPayment( bankId = "BYLADEM1WOR-G2910276709458A2", amount = TalerAmount("EUR:3"), wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", executionTime = instant("2024-04-12"), - debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John+Smith" + debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith" ), Reversal( msgId = "G27KNKZAR5DV7HRB085YMA9GB4", @@ -126,4 +126,59 @@ class Iso20022Test { txs ) } + + @Test + fun gls_camt053() { + val content = Files.newInputStream(Path("sample/platform/gls_camt053.xml")) + val txs = parseTx(content, "EUR", Dialect.gls) + assertEquals( + listOf( + OutgoingPayment( + messageId = "G059N0SR5V0WZ0XSFY1H92QBZ0", + amount = TalerAmount("EUR:2"), + wireTransferSubject = "TestABC123", + executionTime = instant("2024-04-18"), + creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" + ), + OutgoingPayment( + messageId = "YF5QBARGQ0MNY0VK59S477VDG4", + amount = TalerAmount("EUR:1.1"), + wireTransferSubject = "This should fail because dummy", + executionTime = instant("2024-04-18"), + creditPaytoUri = "payto://iban/DE20500105172419259181?receiver-name=John%20Smith" + ), + IncomingPayment( + bankId = "BYLADEM1WOR-G2910276709458A2", + amount = TalerAmount("EUR:3"), + wireTransferSubject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-", + executionTime = instant("2024-04-12"), + debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=John%20Smith" + ), + Reversal( + msgId = "G27KNKZAR5DV7HRB085YMA9GB4", + reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", + executionTime = instant("2024-04-12") + ) + ), + txs + ) + } + + @Test + fun gls_camt054() { + val content = Files.newInputStream(Path("sample/platform/gls_camt054.xml")) + val txs = parseTx(content, "EUR", Dialect.gls) + assertEquals( + listOf( + IncomingPayment( + bankId = "IS11PGENODEFF2DA8899900378806", + amount = TalerAmount("EUR:2.5"), + wireTransferSubject = "Test ICT", + executionTime = instant("2024-05-05"), + debitPaytoUri = "payto://iban/DE84500105177118117964?receiver-name=Mr%20Test" + ) + ), + txs + ) + } }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/nexus/src/test/kotlin/RevenueApiTest.kt new file mode 100644 index 00000000..ec7d37d8 --- /dev/null +++ b/nexus/src/test/kotlin/RevenueApiTest.kt @@ -0,0 +1,65 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import io.ktor.http.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.nexus.* + +class RevenueApiTest { + // GET /taler-revenue/config + @Test + fun config() = serverSetup { + authRoutine(HttpMethod.Get, "/taler-revenue/config") + + client.getA("/taler-revenue/config").assertOk() + } + + // GET /taler-revenue/history + @Test + fun history() = serverSetup { db -> + authRoutine(HttpMethod.Get, "/taler-revenue/history") + + historyRoutine<RevenueIncomingHistory>( + url = "/taler-revenue/history", + ids = { it.incoming_transactions.map { it.row_id } }, + registered = listOf( + { + // Transactions using clean transfer logic + talerableIn(db) + }, + { + // Common credit transactions + ingestIn(db) + } + ), + ignored = listOf( + { + // Ignore debit transactions + talerableOut(db) + } + ) + ) + } + + @Test + fun noApi() = serverSetup("mini.conf") { + client.getA("/taler-revenue/config").assertNotImplemented() + } +}
\ No newline at end of file diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt index a8d94b2f..d7b11536 100644 --- a/nexus/src/test/kotlin/WireGatewayApiTest.kt +++ b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -24,47 +24,42 @@ import io.ktor.http.* import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* +import tech.libeufin.nexus.* class WireGatewayApiTest { - // GET /accounts/{USERNAME}/taler-wire-gateway/config + // GET /taler-wire-gateway/config @Test fun config() = serverSetup { _ -> - //authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/config") + authRoutine(HttpMethod.Get, "/taler-wire-gateway/config") - client.get("/taler-wire-gateway/config").assertOk() + client.getA("/taler-wire-gateway/config").assertOk() } - // Testing the POST /transfer call from the TWG API. - /*@Test - fun transfer() = bankSetup { _ -> + // POST /taler-wire-gateway/transfer + @Test + fun transfer() = serverSetup { _ -> val valid_req = obj { "request_uid" to HashCode.rand() - "amount" to "KUDOS:55" + "amount" to "CHF:55" "exchange_base_url" to "http://exchange.example.com/" "wtid" to ShortHashCode.rand() - "credit_account" to merchantPayto.canonical + "credit_account" to grothoffPayto } - authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) - - // Checking exchange debt constraint. - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) + authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer") - // Giving debt allowance and checking the OK case. - setMaxDebt("exchange", "KUDOS:1000") - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + // Check OK + client.postA("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // check idempotency - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) }.assertOk() // Trigger conflict due to reused request_uid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to ShortHashCode.rand() "exchange_base_url" to "http://different-exchange.example.com/" @@ -72,132 +67,117 @@ class WireGatewayApiTest { }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) // Currency mismatch - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - // Unknown account - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) { - "request_uid" to HashCode.rand() - "wtid" to ShortHashCode.rand() - "credit_account" to unknownPayto - } - }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) - - // Same account - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { - json(valid_req) { - "request_uid" to HashCode.rand() - "wtid" to ShortHashCode.rand() - "credit_account" to exchangePayto - } - }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) - // Bad BASE32 wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "wtid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { - "wtid" to randBase32Crockford(31) + "wtid" to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() // Bad BASE32 request_uid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { "request_uid" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len wtid - client.postA("/accounts/exchange/taler-wire-gateway/transfer") { + client.postA("/taler-wire-gateway/transfer") { + json(valid_req) { + "request_uid" to Base32Crockford.encode(ByteArray(65).rand()) + } + }.assertBadRequest() + + // Bad payto kind + client.postA("/taler-wire-gateway/transfer") { json(valid_req) { - "request_uid" to randBase32Crockford(65) + "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar" } }.assertBadRequest() - }*/ - /* - /** - * Testing the /history/incoming call from the TWG API. - */ + } + + // GET /taler-wire-gateway/history/incoming @Test - fun historyIncoming() = serverSetup { - // Give Foo reasonable debt allowance: - setMaxDebt("merchant", "KUDOS:1000") - authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/incoming") + fun historyIncoming() = serverSetup { db -> + authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming") historyRoutine<IncomingHistory>( - url = "/accounts/exchange/taler-wire-gateway/history/incoming", + url = "/taler-wire-gateway/history/incoming", ids = { it.incoming_transactions.map { it.row_id } }, registered = listOf( { - // Transactions using clean add incoming logic - addIncoming("KUDOS:10") + client.postA("/taler-wire-gateway/admin/add-incoming") { + json { + "amount" to "CHF:12" + "reserve_pub" to EddsaPublicKey.rand() + "debit_account" to grothoffPayto + } + }.assertOk() }, { // Transactions using raw bank transaction logic - tx("merchant", "KUDOS:10", "exchange", "history test with ${ShortHashCode.rand()} reserve pub") - }, - { - // Transaction using withdraw logic - withdrawal("KUDOS:9") + talerableIn(db) } ), ignored = listOf( { // Ignore malformed incoming transaction - tx("merchant", "KUDOS:10", "exchange", "ignored") + ingestIn(db) }, { - // Ignore malformed outgoing transaction - tx("exchange", "KUDOS:10", "merchant", "ignored") + // Ignore outgoing transaction + talerableOut(db) } ) ) } - - /** - * Testing the /history/outgoing call from the TWG API. - */ + // GET /taler-wire-gateway/history/outgoing @Test - fun historyOutgoing() = serverSetup { - setMaxDebt("exchange", "KUDOS:1000000") - authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing") + fun historyOutgoing() = serverSetup { db -> + authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing") historyRoutine<OutgoingHistory>( - url = "/accounts/exchange/taler-wire-gateway/history/outgoing", + url = "/taler-wire-gateway/history/outgoing", ids = { it.outgoing_transactions.map { it.row_id } }, registered = listOf( - { - // Transactions using clean add incoming logic - transfer("KUDOS:10") + { + talerableOut(db) } ), ignored = listOf( { - // gnore manual incoming transaction - tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/") + // Ignore pending transfers + transfer() + }, + { + // Ignore manual incoming transaction + talerableIn(db) }, { // Ignore malformed incoming transaction - tx("merchant", "KUDOS:10", "exchange", "ignored") + ingestIn(db) }, { // Ignore malformed outgoing transaction - tx("exchange", "KUDOS:10", "merchant", "ignored") + ingestOutgoingPayment(db, genOutPay("ignored")) } ) ) - }*/ + } - // Testing the /admin/add-incoming call from the TWG API. + // POST /taler-wire-gateway/admin/add-incoming @Test fun addIncoming() = serverSetup { _ -> val valid_req = obj { @@ -206,35 +186,47 @@ class WireGatewayApiTest { "debit_account" to grothoffPayto } - //authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req, requireAdmin = true) + authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/add-incoming") // Check OK - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) }.assertOk() // Trigger conflict due to reused reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) // Currency mismatch - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "amount" to "EUR:33" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Bad BASE32 reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "reserve_pub" to "I love chocolate" } }.assertBadRequest() // Bad BASE32 len reserve_pub - client.post("/taler-wire-gateway/admin/add-incoming") { + client.postA("/taler-wire-gateway/admin/add-incoming") { json(valid_req) { "reserve_pub" to Base32Crockford.encode(ByteArray(31).rand()) } }.assertBadRequest() + + // Bad payto kind + client.postA("/taler-wire-gateway/admin/add-incoming") { + json(valid_req) { + "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar" + } + }.assertBadRequest() + } + + @Test + fun noApi() = serverSetup("mini.conf") { _ -> + client.get("/taler-wire-gateway/config").assertNotImplemented() } }
\ No newline at end of file diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt index e6c4b1a7..0f428230 100644 --- a/nexus/src/test/kotlin/helpers.kt +++ b/nexus/src/test/kotlin/helpers.kt @@ -24,10 +24,8 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.coroutines.runBlocking -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.db.dbInit -import tech.libeufin.common.db.pgDataSource -import tech.libeufin.common.fromFile +import tech.libeufin.common.* +import tech.libeufin.common.db.* import tech.libeufin.nexus.* import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment @@ -49,17 +47,17 @@ fun setup( ) = runBlocking { val config = NEXUS_CONFIG_SOURCE.fromFile(Path("conf/$conf")) val dbCfg = config.dbConfig() - val ctx = NexusConfig(config) + val cfg = NexusConfig(config) pgDataSource(dbCfg.dbConnStr).dbInit(dbCfg, "libeufin-nexus", true) - Database(dbCfg).use { - lambda(it, ctx) + Database(dbCfg, cfg.currency).use { + lambda(it, cfg) } } fun serverSetup( conf: String = "test.conf", lambda: suspend ApplicationTestBuilder.(Database) -> Unit -) = setup { db, cfg -> +) = setup(conf) { db, cfg -> testApplication { application { nexusApi(db, cfg) @@ -79,7 +77,7 @@ fun getMockedClient( followRedirects = false engine { addHandler { - request -> handler(request) + request -> handler(request) } } } @@ -98,21 +96,106 @@ fun genInitPay( ) // Generates an incoming payment, given its subject. -fun genInPay(subject: String) = - IncomingPayment( - amount = TalerAmount(44, 0, "KUDOS"), +fun genInPay(subject: String, amount: String = "KUDOS:44"): IncomingPayment { + val bankId = run { + val bytes = ByteArray(16) + kotlin.random.Random.nextBytes(bytes) + Base32Crockford.encode(bytes) + } + return IncomingPayment( + amount = TalerAmount(amount), debitPaytoUri = "payto://iban/not-used", wireTransferSubject = subject, executionTime = Instant.now(), - bankId = "entropic" + bankId = bankId ) +} // Generates an outgoing payment, given its subject and messageId -fun genOutPay(subject: String, messageId: String) = - OutgoingPayment( +fun genOutPay(subject: String, messageId: String? = null): OutgoingPayment { + val id = messageId ?: run { + val bytes = ByteArray(16) + kotlin.random.Random.nextBytes(bytes) + Base32Crockford.encode(bytes) + } + return OutgoingPayment( amount = TalerAmount(44, 0, "KUDOS"), creditPaytoUri = "payto://iban/CH4189144589712575493?receiver-name=Test", wireTransferSubject = subject, executionTime = Instant.now(), - messageId = messageId - )
\ No newline at end of file + messageId = id + ) +} + +/** Perform a taler outgoing transaction */ +suspend fun ApplicationTestBuilder.transfer() { + client.postA("/taler-wire-gateway/transfer") { + json { + "request_uid" to HashCode.rand() + "amount" to "CHF:55" + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to ShortHashCode.rand() + "credit_account" to grothoffPayto + } + }.assertOk() +} + +/** Ingest a talerable outgoing transaction */ +suspend fun talerableOut(db: Database) { + val wtid = ShortHashCode.rand() + ingestOutgoingPayment(db, genOutPay("$wtid http://exchange.example.com/")) +} + +/** Ingest a talerable incoming transaction */ +suspend fun talerableIn(db: Database) { + val reserve_pub = ShortHashCode.rand() + ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve pub"), AccountType.exchange) +} + +/** Ingest an incoming transaction */ +suspend fun ingestIn(db: Database) { + ingestIncomingPayment(db, genInPay("ignored"), AccountType.normal) +} + +/** Ingest an outgoing transaction */ +suspend fun ingestOut(db: Database) { + ingestOutgoingPayment(db, genOutPay("ignored")) +} + +/* ----- Auth ----- */ + +/** Auto auth get request */ +suspend inline fun HttpClient.getA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return get(url) { + auth() + builder(this) + } +} + +/** Auto auth post request */ +suspend inline fun HttpClient.postA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return post(url) { + auth() + builder(this) + } +} + +/** Auto auth patch request */ +suspend inline fun HttpClient.patchA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return patch(url) { + auth() + builder(this) + } +} + +/** Auto auth delete request */ +suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { + return delete(url) { + auth() + builder(this) + } +} + +fun HttpRequestBuilder.auth() { + headers["Authorization"] = "Bearer secret-token" +}
\ No newline at end of file diff --git a/nexus/src/test/kotlin/routines.kt b/nexus/src/test/kotlin/routines.kt new file mode 100644 index 00000000..7b92dea7 --- /dev/null +++ b/nexus/src/test/kotlin/routines.kt @@ -0,0 +1,73 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import tech.libeufin.common.* +import tech.libeufin.common.test.* +import kotlin.test.assertEquals + + +// Test endpoint is correctly authenticated +suspend fun ApplicationTestBuilder.authRoutine( + method: HttpMethod, + path: String +) { + // No header + client.request(path) { + this.method = method + }.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING) + + // Bad header + client.request(path) { + this.method = method + headers["Authorization"] = "WTF" + }.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED) + + // Bad token + client.request(path) { + this.method = method + headers["Authorization"] = "Bearer bad-token" + }.assertUnauthorized() + + // GLS deployment + // - testing did work ? + // token - basic bearer + // libeufin-nexus + // - wire gateway try camt.052 files +} + + +suspend inline fun <reified B> ApplicationTestBuilder.historyRoutine( + url: String, + crossinline ids: (B) -> List<Long>, + registered: List<suspend () -> Unit>, + ignored: List<suspend () -> Unit> = listOf(), + polling: Boolean = true +) { + abstractHistoryRoutine(ids, registered, ignored, polling) { params: String -> + client.getA("$url?$params") + } +}
\ No newline at end of file |