summaryrefslogtreecommitdiff
path: root/nexus/src
diff options
context:
space:
mode:
Diffstat (limited to 'nexus/src')
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt49
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt61
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt6
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt58
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt358
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt45
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt62
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt65
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt41
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt128
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt15
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt195
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt3
-rw-r--r--nexus/src/test/kotlin/CliTest.kt34
-rw-r--r--nexus/src/test/kotlin/DatabaseTest.kt53
-rw-r--r--nexus/src/test/kotlin/Iso20022Test.kt69
-rw-r--r--nexus/src/test/kotlin/RevenueApiTest.kt65
-rw-r--r--nexus/src/test/kotlin/WireGatewayApiTest.kt166
-rw-r--r--nexus/src/test/kotlin/helpers.kt117
-rw-r--r--nexus/src/test/kotlin/routines.kt73
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