libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit b06990b56aceda9bfe5769e198a2f8ba7f467fee
parent 79a76c0dcf4395d3d015d81e3f1014a1288a2a93
Author: Florian Dold <florian.dold@gmail.com>
Date:   Thu, 21 May 2020 02:51:19 +0530

move towards new API

Diffstat:
Mintegration-tests/test-ebics.py | 47++++++++++++++++++++++++-----------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/DB.kt | 46++++++++++++++++------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt | 151++++++++++++-------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 39++++++++++++++++-----------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 721+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/taler.kt | 1114++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mnexus/src/test/kotlin/authentication.kt | 7+------
Mnexus/src/test/kotlin/taler.kt | 44++++++++++++++++++++++----------------------
Mutil/src/main/kotlin/Ebics.kt | 8++++++++
10 files changed, 1134 insertions(+), 1123 deletions(-)

diff --git a/integration-tests/test-ebics.py b/integration-tests/test-ebics.py @@ -19,7 +19,7 @@ import base64 # -> (a) Make a Nexus user, (b) make a EBICS subscriber # associated to that user # -# 2 Prepare the Ebics transport for the nexus user. +# 2 Prepare the Ebics bank connection for the nexus user. # -> (a) Upload keys from Nexus to the Bank (INI & HIA), # (b) Download key from the Bank (HPB) to the Nexus, # and (c) Fetch the bank account owned by that subscriber @@ -178,12 +178,15 @@ assertResponse( ) ) -# 1.b, make a ebics transport for the new user. +print("creating bank connection") + +# 1.b, make a ebics bank connection for the new user. assertResponse( post( - "http://localhost:5001/bank-transports", + "http://localhost:5001/bank-connections", json=dict( - transport=dict(name="my-ebics", type="ebics"), + name="my-ebics", + type="ebics", data=dict( ebicsURL=EBICS_URL, hostID=HOST_ID, partnerID=PARTNER_ID, userID=USER_ID ), @@ -192,19 +195,21 @@ assertResponse( ) ) +print("sending ini") + # 2.a, upload keys to the bank (INI & HIA) assertResponse( post( - "http://localhost:5001/bank-transports/sendINI", - json=dict(type="ebics", name="my-ebics"), + "http://localhost:5001/bank-connections/my-ebics/ebics/send-ini", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) assertResponse( post( - "http://localhost:5001/bank-transports/sendHIA", - json=dict(type="ebics", name="my-ebics"), + "http://localhost:5001/bank-connections/my-ebics/ebics/send-hia", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -212,8 +217,8 @@ assertResponse( # 2.b, download keys from the bank (HPB) assertResponse( post( - "http://localhost:5001/bank-transports/syncHPB", - json=dict(type="ebics", name="my-ebics"), + "http://localhost:5001/bank-connections/my-ebics/ebics/send-hpb", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -221,8 +226,8 @@ assertResponse( # 2.c, fetch bank account information assertResponse( post( - "http://localhost:5001/bank-transports/syncHTD", - json=dict(type="ebics", name="my-ebics"), + "http://localhost:5001/bank-connections/my-ebics/ebics/import-accounts", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -230,8 +235,8 @@ assertResponse( # 3, ask nexus to download history assertResponse( post( - "http://localhost:5001/bank-accounts/collected-transactions", - json=dict(transport=dict(type="ebics", name="my-ebics")), + f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/fetch-transactions", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -239,9 +244,7 @@ assertResponse( # 4, make sure history is empty resp = assertResponse( get( - "http://localhost:5001/bank-accounts/{}/collected-transactions".format( - BANK_ACCOUNT_LABEL - ), + f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/transactions", headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -271,8 +274,8 @@ if PREPARED_PAYMENT_UUID == None: # 5.b, submit prepared statement assertResponse( post( - "http://localhost:5001/bank-accounts/prepared-payments/submit", - json=dict(uuid=PREPARED_PAYMENT_UUID), + f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/prepared-payments/{PREPARED_PAYMENT_UUID}/submit", + json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -280,7 +283,7 @@ assertResponse( # 6, request history after payment submission assertResponse( post( - "http://localhost:5001/bank-accounts/collected-transactions", + f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/fetch-transactions", json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) @@ -288,9 +291,7 @@ assertResponse( resp = assertResponse( get( - "http://localhost:5001/bank-accounts/{}/collected-transactions".format( - BANK_ACCOUNT_LABEL - ), + f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/transactions", headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt @@ -8,10 +8,6 @@ import org.jetbrains.exposed.sql.StdOutSqlLogger import org.jetbrains.exposed.sql.addLogger import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.EbicsSubscribersTable.entityId -import tech.libeufin.nexus.EbicsSubscribersTable.primaryKey -import tech.libeufin.nexus.NexusUsersTable.entityId -import tech.libeufin.nexus.NexusUsersTable.primaryKey import tech.libeufin.util.amount import java.sql.Connection @@ -152,9 +148,6 @@ object PreparedPaymentsTable : IdTable<String>() { * this state can be reached when the payment gets listed in a CRZ * response OR when the payment doesn't show up in a C52/C53 response */ val invalid = bool("invalid").default(false) - - /** never really used, but it makes sure the user always exists */ - val nexusUser = reference("nexusUser", NexusUsersTable) } class PreparedPaymentEntity(id: EntityID<String>) : Entity<String>(id) { @@ -175,7 +168,6 @@ class PreparedPaymentEntity(id: EntityID<String>) : Entity<String>(id) { var creditorName by PreparedPaymentsTable.creditorName var submitted by PreparedPaymentsTable.submitted var invalid by PreparedPaymentsTable.invalid - var nexusUser by NexusUserEntity referencedOn PreparedPaymentsTable.nexusUser } /** @@ -186,6 +178,7 @@ object BankAccountsTable : IdTable<String>() { val accountHolder = text("accountHolder") val iban = text("iban") val bankCode = text("bankCode") + val defaultBankConnection = reference("defaultBankConnection", NexusBankConnectionsTable).nullable() } class BankAccountEntity(id: EntityID<String>) : Entity<String>(id) { @@ -194,10 +187,10 @@ class BankAccountEntity(id: EntityID<String>) : Entity<String>(id) { var accountHolder by BankAccountsTable.accountHolder var iban by BankAccountsTable.iban var bankCode by BankAccountsTable.bankCode + var defaultBankConnection by NexusBankConnectionEntity optionalReferencedOn BankAccountsTable.defaultBankConnection } -object EbicsSubscribersTable : IdTable<String>() { - override val id = varchar("id", ID_MAX_LENGTH).entityId().primaryKey() +object EbicsSubscribersTable : IntIdTable() { val ebicsURL = text("ebicsURL") val hostID = text("hostID") val partnerID = text("partnerID") @@ -208,11 +201,11 @@ object EbicsSubscribersTable : IdTable<String>() { val authenticationPrivateKey = blob("authenticationPrivateKey") val bankEncryptionPublicKey = blob("bankEncryptionPublicKey").nullable() val bankAuthenticationPublicKey = blob("bankAuthenticationPublicKey").nullable() - var nexusUser = reference("nexusUser", NexusUsersTable) + val nexusBankConnection = reference("nexusBankConnection", NexusBankConnectionsTable) } -class EbicsSubscriberEntity(id: EntityID<String>) : Entity<String>(id) { - companion object : EntityClass<String, EbicsSubscriberEntity>(EbicsSubscribersTable) +class EbicsSubscriberEntity(id: EntityID<Int>) : IntEntity(id) { + companion object : IntEntityClass<EbicsSubscriberEntity>(EbicsSubscribersTable) var ebicsURL by EbicsSubscribersTable.ebicsURL var hostID by EbicsSubscribersTable.hostID @@ -224,7 +217,7 @@ class EbicsSubscriberEntity(id: EntityID<String>) : Entity<String>(id) { var authenticationPrivateKey by EbicsSubscribersTable.authenticationPrivateKey var bankEncryptionPublicKey by EbicsSubscribersTable.bankEncryptionPublicKey var bankAuthenticationPublicKey by EbicsSubscribersTable.bankAuthenticationPublicKey - var nexusUser by NexusUserEntity referencedOn EbicsSubscribersTable.nexusUser + var nexusBankConnection by NexusBankConnectionEntity referencedOn EbicsSubscribersTable.nexusBankConnection } object NexusUsersTable : IdTable<String>() { @@ -240,23 +233,16 @@ class NexusUserEntity(id: EntityID<String>) : Entity<String>(id) { var superuser by NexusUsersTable.superuser } -object BankAccountMapsTable : IntIdTable() { - val ebicsSubscriber = reference("ebicsSubscriber", EbicsSubscribersTable) - val bankAccount = reference("bankAccount", BankAccountsTable) - val nexusUser = reference("nexusUser", NexusUsersTable) -} - -class BankAccountMapEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<BankAccountMapEntity>(BankAccountMapsTable) - - var ebicsSubscriber by EbicsSubscriberEntity referencedOn BankAccountMapsTable.ebicsSubscriber - var bankAccount by BankAccountEntity referencedOn BankAccountMapsTable.bankAccount - var nexusUser by NexusUserEntity referencedOn BankAccountMapsTable.nexusUser +object NexusBankConnectionsTable : IdTable<String>() { + override val id = NexusBankConnectionsTable.text("id").entityId().primaryKey() + val type = text("type") + val owner = reference("user", NexusUsersTable) } -object NexusBankConnectionsTable : IdTable<String>() { - override val id = EbicsSubscribersTable.text("id").entityId().primaryKey() - +class NexusBankConnectionEntity(id: EntityID<String>) : Entity<String>(id) { + companion object : EntityClass<String, NexusBankConnectionEntity>(NexusBankConnectionsTable) + var type by NexusBankConnectionsTable.type + var owner by NexusUserEntity referencedOn NexusBankConnectionsTable.owner } fun dbCreateTables() { @@ -272,7 +258,7 @@ fun dbCreateTables() { RawBankTransactionsTable, TalerIncomingPayments, TalerRequestedPayments, - BankAccountMapsTable + NexusBankConnectionsTable ) } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt @@ -1,3 +1,6 @@ +/** + * High-level interface for the EBICS protocol. + */ package tech.libeufin.nexus import io.ktor.client.HttpClient @@ -6,7 +9,7 @@ import io.ktor.http.HttpStatusCode import tech.libeufin.util.* import java.util.* -suspend inline fun HttpClient.postToBank(url: String, body: String): String { +private suspend inline fun HttpClient.postToBank(url: String, body: String): String { logger.debug("Posting: $body") val response: String = try { this.post<String>( @@ -58,7 +61,10 @@ suspend fun doEbicsDownloadTransaction( // Success, nothing to do! } else -> { - throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code ${initResponse.technicalReturnCode}") + throw NexusError( + HttpStatusCode.InternalServerError, + "unexpected return code ${initResponse.technicalReturnCode}" + ) } } @@ -73,13 +79,19 @@ suspend fun doEbicsDownloadTransaction( } val transactionID = - initResponse.transactionID ?: throw NexusError(HttpStatusCode.InternalServerError, "initial response must contain transaction ID") + initResponse.transactionID ?: throw NexusError( + HttpStatusCode.InternalServerError, + "initial response must contain transaction ID" + ) val encryptionInfo = initResponse.dataEncryptionInfo ?: throw NexusError(HttpStatusCode.InternalServerError, "initial response did not contain encryption info") val initOrderDataEncChunk = initResponse.orderDataEncChunk - ?: throw NexusError(HttpStatusCode.InternalServerError,"initial response for download transaction does not contain data transfer") + ?: throw NexusError( + HttpStatusCode.InternalServerError, + "initial response for download transaction does not contain data transfer" + ) payloadChunks.add(initOrderDataEncChunk) @@ -97,7 +109,7 @@ suspend fun doEbicsDownloadTransaction( EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE -> { } else -> { - throw NexusError(HttpStatusCode.InternalServerError,"unexpected return code") + throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") } } return EbicsDownloadSuccessResult(respPayload) @@ -124,7 +136,10 @@ suspend fun doEbicsUploadTransaction( } val transactionID = - initResponse.transactionID ?: throw NexusError(HttpStatusCode.InternalServerError,"init response must have transaction ID") + initResponse.transactionID ?: throw NexusError( + HttpStatusCode.InternalServerError, + "init response must have transaction ID" + ) logger.debug("INIT phase passed!") /* now send actual payload */ @@ -147,7 +162,58 @@ suspend fun doEbicsUploadTransaction( EbicsReturnCode.EBICS_OK -> { } else -> { - throw NexusError(HttpStatusCode.InternalServerError,"unexpected return code") + throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") } } +} + +suspend fun doEbicsHostVersionQuery(client: HttpClient, ebicsBaseUrl: String, ebicsHostId: String): EbicsHevDetails { + val ebicsHevRequest = makeEbicsHEVRequestRaw(ebicsHostId) + val resp = client.postToBank(ebicsBaseUrl, ebicsHevRequest) + val versionDetails = parseEbicsHEVResponse(resp) + return versionDetails +} + +suspend fun doEbicsIniRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): EbicsKeyManagementResponseContent { + val request = makeEbicsIniRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + return resp +} + +suspend fun doEbicsHiaRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): EbicsKeyManagementResponseContent { + val request = makeEbicsHiaRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + return resp +} + + +suspend fun doEbicsHpbRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): HpbResponseData { + val request = makeEbicsHpbRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + val orderData = parsedResponse.orderData ?: throw NexusError( + HttpStatusCode.InternalServerError, + "Cannot find data in a HPB response" + ) + return parseEbicsHpbOrder(orderData) } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt @@ -1,10 +1,9 @@ package tech.libeufin.nexus -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.treeToValue +import io.ktor.application.ApplicationCall import io.ktor.client.HttpClient import io.ktor.http.HttpStatusCode +import io.ktor.request.ApplicationRequest import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.transactions.transaction import org.joda.time.DateTime @@ -58,49 +57,6 @@ fun extractFirstBic(bankCodes: List<EbicsTypes.AbstractBankCode>?): String? { return null } -fun getTransportFromJsonObject(jo: JsonNode): Transport { - return jacksonObjectMapper().treeToValue(jo.get("transport"), Transport::class.java) -} - -/** - * Retrieve bank account details, only if user owns it. - */ -fun getBankAccount(userId: String, accountId: String): BankAccountEntity { - return transaction { - val bankAccountMap = BankAccountMapEntity.find { - BankAccountMapsTable.nexusUser eq userId - }.firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, - "Bank account '$accountId' not found" - ) - bankAccountMap.bankAccount - } -} - -/** - * Given a nexus user id, returns the _list_ of bank accounts associated to it. - * - * @param id the subscriber id - * @return the (non-empty) list of bank accounts associated with this user. - */ -fun getBankAccountsFromNexusUserId(id: String): MutableList<BankAccountEntity> { - logger.debug("Looking up bank account of user '$id'") - val ret = mutableListOf<BankAccountEntity>() - transaction { - BankAccountMapEntity.find { - BankAccountMapsTable.nexusUser eq id - }.forEach { - ret.add(it.bankAccount) - } - } - if (ret.isEmpty()) { - throw NexusError( - HttpStatusCode.NotFound, - "Such user '$id' does not have any bank account associated" - ) - } - return ret -} fun getEbicsSubscriberDetailsInternal(subscriber: EbicsSubscriberEntity): EbicsClientSubscriberDetails { var bankAuthPubValue: RSAPublicKey? = null @@ -130,32 +86,18 @@ fun getEbicsSubscriberDetailsInternal(subscriber: EbicsSubscriberEntity): EbicsC ) } -fun getEbicsTransport(userId: String, transportId: String? = null): EbicsSubscriberEntity { - val transport = transaction { - if (transportId == null) { - return@transaction EbicsSubscriberEntity.find { - EbicsSubscribersTable.nexusUser eq userId - }.firstOrNull() - } - return@transaction EbicsSubscriberEntity.find { - EbicsSubscribersTable.id eq transportId and (EbicsSubscribersTable.nexusUser eq userId) - }.firstOrNull() - } - ?: throw NexusError( - HttpStatusCode.NotFound, - "Could not find ANY Ebics transport for user $userId" - ) - return transport -} - /** * Retrieve Ebics subscriber details given a Transport * object and handling the default case (when this latter is null). */ -fun getEbicsSubscriberDetails(userId: String, transportId: String?): EbicsClientSubscriberDetails { - val transport = getEbicsTransport(userId, transportId) +fun getEbicsSubscriberDetails(userId: String, transportId: String): EbicsClientSubscriberDetails { + val transport = NexusBankConnectionEntity.findById(transportId) + if (transport == null) { + throw NexusError(HttpStatusCode.NotFound, "transport not found") + } + val subscriber = EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq transport.id }.first() // transport exists and belongs to caller. - return getEbicsSubscriberDetailsInternal(transport) + return getEbicsSubscriberDetailsInternal(subscriber) } suspend fun downloadAndPersistC5xEbics( @@ -164,9 +106,8 @@ suspend fun downloadAndPersistC5xEbics( userId: String, start: String?, // dashed date YYYY-MM(01-12)-DD(01-31) end: String?, // dashed date YYYY-MM(01-12)-DD(01-31) - transportId: String? + subscriberDetails: EbicsClientSubscriberDetails ) { - val subscriberDetails = getEbicsSubscriberDetails(userId, transportId) val orderParamsJson = EbicsStandardOrderParamsJson( EbicsDateRangeJson(start, end) ) @@ -188,6 +129,10 @@ suspend fun downloadAndPersistC5xEbics( val fileName = it.first val camt53doc = XMLUtil.parseStringIntoDom(it.second) transaction { + val user = NexusUserEntity.findById(userId) + if (user == null) { + throw NexusError(HttpStatusCode.NotFound, "user not found") + } RawBankTransactionEntity.new { bankAccount = getBankAccountFromIban( camt53doc.pickString( @@ -203,7 +148,7 @@ suspend fun downloadAndPersistC5xEbics( status = camt53doc.pickString("//*[local-name()='Ntry']//*[local-name()='Sts']") bookingDate = parseDashedDate(camt53doc.pickString("//*[local-name()='BookgDt']//*[local-name()='Dt']")).millis - nexusUser = extractNexusUser(userId) + nexusUser = user counterpartIban = camt53doc.pickString("//*[local-name()='${if (this.transactionType == "DBIT") "CdtrAcct" else "DbtrAcct"}']//*[local-name()='IBAN']") counterpartName = @@ -222,22 +167,6 @@ suspend fun downloadAndPersistC5xEbics( } } -suspend fun submitPaymentEbics( - client: HttpClient, - userId: String, - transportId: String?, - pain001document: String -) { - logger.debug("Uploading PAIN.001: ${pain001document}") - doEbicsUploadTransaction( - client, - getEbicsSubscriberDetails(userId, transportId), - "CCT", - pain001document.toByteArray(Charsets.UTF_8), - EbicsStandardOrderParams() - ) -} - /** * Create a PAIN.001 XML document according to the input data. @@ -394,10 +323,9 @@ fun getNexusUser(id: String): NexusUserEntity { * it will be the account whose money will pay the wire transfer being defined * by this pain document. */ -fun addPreparedPayment(paymentData: Pain001Data, nexusUser: NexusUserEntity): PreparedPaymentEntity { +fun addPreparedPayment(paymentData: Pain001Data, debitorAccount: BankAccountEntity): PreparedPaymentEntity { val randomId = Random().nextLong() return transaction { - val debitorAccount = getBankAccount(nexusUser.id.value, paymentData.debitorAccount) PreparedPaymentEntity.new(randomId.toString()) { subject = paymentData.subject sum = paymentData.sum @@ -410,7 +338,6 @@ fun addPreparedPayment(paymentData: Pain001Data, nexusUser: NexusUserEntity): Pr preparationDate = DateTime.now().millis paymentId = randomId endToEndId = randomId - this.nexusUser = nexusUser } } } @@ -421,25 +348,12 @@ fun ensureNonNull(param: String?): String { ) } -/* Needs a transaction{} block to be called */ -fun extractNexusUser(param: String?): NexusUserEntity { - if (param == null) { - throw NexusError(HttpStatusCode.BadRequest, "Null Id given") - } - return transaction { - NexusUserEntity.findById(param) ?: throw NexusError( - HttpStatusCode.NotFound, - "Subscriber: $param not found" - ) - } -} - /** * This helper function parses a Authorization:-header line, decode the credentials * and returns a pair made of username and hashed (sha256) password. The hashed value * will then be compared with the one kept into the database. */ -fun extractUserAndHashedPassword(authorizationHeader: String): Pair<String, String> { +fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> { logger.debug("Authenticating: $authorizationHeader") val (username, password) = try { val split = authorizationHeader.split(" ") @@ -461,11 +375,12 @@ fun extractUserAndHashedPassword(authorizationHeader: String): Pair<String, Stri * @param authorization the Authorization:-header line. * @return user id */ -fun authenticateRequest(authorization: String?): NexusUserEntity { +fun authenticateRequest(request: ApplicationRequest): NexusUserEntity { + val authorization = request.headers["Authorization"] val headerLine = if (authorization == null) throw NexusError( HttpStatusCode.BadRequest, "Authentication:-header line not found" ) else authorization - val (username, password) = extractUserAndHashedPassword(headerLine) + val (username, password) = extractUserAndPassword(headerLine) val user = NexusUserEntity.find { NexusUsersTable.id eq username }.firstOrNull() @@ -479,22 +394,6 @@ fun authenticateRequest(authorization: String?): NexusUserEntity { } -/** - * Check if the subscriber has the right to use the (claimed) bank account. - * @param subscriber id of the EBICS subscriber to check - * @param bankAccount id of the claimed bank account - * @return true if the subscriber can use the bank account. - */ -fun subscriberHasRights(subscriber: EbicsSubscriberEntity, bankAccount: BankAccountEntity): Boolean { - val row = transaction { - BankAccountMapEntity.find { - BankAccountMapsTable.bankAccount eq bankAccount.id and - (BankAccountMapsTable.ebicsSubscriber eq subscriber.id) - }.firstOrNull() - } - return row != null -} - fun getBankAccountFromIban(iban: String): BankAccountEntity { return transaction { BankAccountEntity.find { @@ -508,12 +407,6 @@ fun getBankAccountFromIban(iban: String): BankAccountEntity { /** Check if the nexus user is allowed to use the claimed bank account. */ fun userHasRights(nexusUser: NexusUserEntity, iban: String): Boolean { - val row = transaction { - val bankAccount = getBankAccountFromIban(iban) - BankAccountMapEntity.find { - BankAccountMapsTable.bankAccount eq bankAccount.id and - (BankAccountMapsTable.nexusUser eq nexusUser.id) - }.firstOrNull() - } - return row != null + // FIXME: implement permissions + return true } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt @@ -80,11 +80,25 @@ data class RawPayments( * API types (used as requests/responses types) * *************************************************/ data class BankTransport( - val transport: Transport, + val transport: String, val backup: Any? = null, val data: Any? ) +data class BankConnectionInfo( + val name: String, + val type: String +) + +data class BankConnectionsList( + val bankConnections: List<BankConnectionInfo> +) + +data class EbicsHostTestRequest( + val ebicsBaseUrl: String, + val ebicsHostId: String +) + /** * This object is used twice: as a response to the backup request, * and as a request to the backup restore. Note: in the second case @@ -137,30 +151,9 @@ data class Transactions( val transactions: MutableList<Transaction> = mutableListOf() ) -/** Specifies the transport to use. */ -data class Transport( - /** - * Must match one of the types implemented by nexus: - * 'ebics', 'local', possibly 'fints' in the near future! - */ - val type: String, - /** - * A mnemonic identifier given by the user to one - * transport instance. - */ - val name: String -) - -/** Request type of "POST /prepared-payments/submit" */ -data class SubmitPayment( - val uuid: String, - /** Policy to pick the default transport is still work in progress. */ - val transport: tech.libeufin.nexus.Transport? -) - /** Request type of "POST /collected-transactions" */ data class CollectedTransaction( - val transport: Transport?, + val transport: String?, val start: String?, val end: String? ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -55,9 +55,7 @@ import io.ktor.routing.post import io.ktor.routing.routing import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty -import io.ktor.util.KtorExperimentalAPI import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.core.ExperimentalIoApi import io.ktor.utils.io.jvm.javaio.toByteReadChannel import io.ktor.utils.io.jvm.javaio.toInputStream import org.jetbrains.exposed.sql.and @@ -69,110 +67,16 @@ import org.slf4j.event.Level import tech.libeufin.util.* import tech.libeufin.util.CryptoUtil.hashpw import tech.libeufin.util.ebics_h004.HTDResponseOrderData +import java.util.* import java.util.zip.InflaterInputStream import javax.crypto.EncryptedPrivateKeyInfo import javax.sql.rowset.serial.SerialBlob -data class NexusError(val statusCode: HttpStatusCode, val reason: String) : Exception() +data class NexusError(val statusCode: HttpStatusCode, val reason: String) : + Exception("${reason} (HTTP status $statusCode)") val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") -suspend fun handleEbicsSendMSG( - httpClient: HttpClient, - userId: String, - transportId: String?, - msg: String, - sync: Boolean -): String { - val subscriber = getEbicsSubscriberDetails(userId, transportId) - val response = when (msg.toUpperCase()) { - "HIA" -> { - val request = makeEbicsHiaRequest(subscriber) - httpClient.postToBank( - subscriber.ebicsUrl, - request - ) - } - "INI" -> { - val request = makeEbicsIniRequest(subscriber) - httpClient.postToBank( - subscriber.ebicsUrl, - request - ) - } - "HPB" -> { - /** should NOT put bank's keys into any table. */ - val request = makeEbicsHpbRequest(subscriber) - val response = httpClient.postToBank( - subscriber.ebicsUrl, - request - ) - if (sync) { - val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriber, response) - val orderData = parsedResponse.orderData ?: throw NexusError( - HttpStatusCode.InternalServerError, - "Cannot find data in a HPB response" - ) - val hpbData = parseEbicsHpbOrder(orderData) - transaction { - val transport = getEbicsTransport(userId, transportId) - transport.bankAuthenticationPublicKey = SerialBlob(hpbData.authenticationPubKey.encoded) - transport.bankEncryptionPublicKey = SerialBlob(hpbData.encryptionPubKey.encoded) - } - } - return response - } - "HTD" -> { - val response = doEbicsDownloadTransaction( - httpClient, subscriber, "HTD", EbicsStandardOrderParams() - ) - when (response) { - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - response.returnCode.errorCode - ) - } - is EbicsDownloadSuccessResult -> { - val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( - response.orderData.toString(Charsets.UTF_8) - ) - if (sync) { - transaction { - payload.value.partnerInfo.accountInfoList?.forEach { - val bankAccount = BankAccountEntity.new(id = it.id) { - accountHolder = it.accountHolder ?: "NOT-GIVEN" - iban = extractFirstIban(it.accountNumberList) - ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") - bankCode = extractFirstBic(it.bankCodeList) ?: throw NexusError( - HttpStatusCode.NotFound, - reason = "bank gave no BIC" - ) - } - BankAccountMapEntity.new { - ebicsSubscriber = getEbicsTransport(userId, transportId) - this.nexusUser = getNexusUser(userId) - this.bankAccount = bankAccount - } - } - } - } - response.orderData.toString(Charsets.UTF_8) - } - } - } - "HEV" -> { - val request = makeEbicsHEVRequest(subscriber) - httpClient.postToBank(subscriber.ebicsUrl, request) - } - else -> throw NexusError( - HttpStatusCode.NotFound, - "Message $msg not found" - ) - } - return response -} - class NexusCommand : CliktCommand() { override fun run() = Unit } @@ -214,7 +118,7 @@ fun main(args: Array<String>) { .main(args) } -suspend inline fun <reified T : Any>ApplicationCall.receiveJson(): T { +suspend inline fun <reified T : Any> ApplicationCall.receiveJson(): T { try { return this.receive<T>(); } catch (e: MissingKotlinParameterException) { @@ -224,8 +128,93 @@ suspend inline fun <reified T : Any>ApplicationCall.receiveJson(): T { } } -@ExperimentalIoApi -@KtorExperimentalAPI +fun createEbicsBankConnection(bankConnectionName: String, user: NexusUserEntity, body: JsonNode) { + val bankConn = NexusBankConnectionEntity.new(bankConnectionName) { + owner = user + type = "ebics" + } + if (body.get("backup") != null) { + val backup = jacksonObjectMapper().treeToValue(body, EbicsKeysBackupJson::class.java) + val (authKey, encKey, sigKey) = try { + Triple( + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(backup.authBlob)), + backup.passphrase + ), + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(backup.encBlob)), + backup.passphrase + ), + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(backup.sigBlob)), + backup.passphrase + ) + ) + } catch (e: Exception) { + e.printStackTrace() + logger.info("Restoring keys failed, probably due to wrong passphrase") + throw NexusError( + HttpStatusCode.BadRequest, + "Bad backup given" + ) + } + try { + EbicsSubscriberEntity.new() { + ebicsURL = backup.ebicsURL + hostID = backup.hostID + partnerID = backup.partnerID + userID = backup.userID + signaturePrivateKey = SerialBlob(sigKey.encoded) + encryptionPrivateKey = SerialBlob(encKey.encoded) + authenticationPrivateKey = SerialBlob(authKey.encoded) + nexusBankConnection = bankConn + } + } catch (e: Exception) { + throw NexusError( + HttpStatusCode.BadRequest, + "exception: $e" + ) + } + return + } + if (body.get("data") != null) { + val data = + jacksonObjectMapper().treeToValue((body.get("data")), EbicsNewTransport::class.java) + val pairA = CryptoUtil.generateRsaKeyPair(2048) + val pairB = CryptoUtil.generateRsaKeyPair(2048) + val pairC = CryptoUtil.generateRsaKeyPair(2048) + EbicsSubscriberEntity.new() { + ebicsURL = data.ebicsURL + hostID = data.hostID + partnerID = data.partnerID + userID = data.userID + systemID = data.systemID + signaturePrivateKey = SerialBlob(pairA.private.encoded) + encryptionPrivateKey = SerialBlob(pairB.private.encoded) + authenticationPrivateKey = SerialBlob(pairC.private.encoded) + nexusBankConnection = bankConn + } + return + } + throw NexusError( + HttpStatusCode.BadRequest, + "Neither restore or new transport were specified." + ) +} + +fun requireBankConnection(call: ApplicationCall, parameterKey: String): NexusBankConnectionEntity { + val name = call.parameters[parameterKey] + if (name == null) { + throw NexusError(HttpStatusCode.InternalServerError, "no parameter for bank connection") + } + val conn = NexusBankConnectionEntity.findById(name) + if (conn == null) { + throw NexusError(HttpStatusCode.NotFound, "bank connection '$name' not found") + } + return conn +} + + fun serverMain() { dbCreateTables() val client = HttpClient() { @@ -237,6 +226,7 @@ fun serverMain() { this.level = Level.DEBUG this.logger = tech.libeufin.nexus.logger } + install(ContentNegotiation) { jackson { enable(SerializationFeature.INDENT_OUTPUT) @@ -248,6 +238,7 @@ fun serverMain() { //registerModule(JavaTimeModule()) } } + install(StatusPages) { exception<NexusError> { cause -> logger.error("Exception while handling '${call.request.uri}'", cause) @@ -282,6 +273,10 @@ fun serverMain() { return@intercept finish() } } + + /** + * Allow request body compression. Needed by Taler. + */ receivePipeline.intercept(ApplicationReceivePipeline.Before) { if (this.context.request.headers["Content-Encoding"] == "deflate") { logger.debug("About to inflate received data") @@ -293,13 +288,14 @@ fun serverMain() { proceed() return@intercept } + routing { /** * Shows information about the requesting user. */ get("/user") { val ret = transaction { - val currentUser = authenticateRequest(call.request.headers["Authorization"]) + val currentUser = authenticateRequest(call.request) UserResponse( username = currentUser.id.value, superuser = currentUser.superuser @@ -321,13 +317,14 @@ fun serverMain() { call.respond(HttpStatusCode.OK, usersResp) return@get } + /** * Add a new ordinary user in the system (requires superuser privileges) */ post("/users") { val body = call.receiveJson<User>() transaction { - val currentUser = authenticateRequest(call.request.headers["Authorization"]) + val currentUser = authenticateRequest(call.request) if (!currentUser.superuser) { throw NexusError(HttpStatusCode.Forbidden, "only superuser can do that") } @@ -343,95 +340,112 @@ fun serverMain() { ) return@post } + get("/bank-connection-protocols") { call.respond(HttpStatusCode.OK, BankProtocolsResponse(listOf("ebics", "loopback"))) return@get } + + post("/bank-connection-protocols/ebics/test-host") { + val r = call.receiveJson<EbicsHostTestRequest>() + val qr = doEbicsHostVersionQuery(client, r.ebicsBaseUrl, r.ebicsHostId) + call.respond(HttpStatusCode.OK, qr) + return@post + } + /** * Shows the bank accounts belonging to the requesting user. */ get("/bank-accounts") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } val bankAccounts = BankAccounts() - getBankAccountsFromNexusUserId(userId).forEach { - bankAccounts.accounts.add( - BankAccount( - holder = it.accountHolder, - iban = it.iban, - bic = it.bankCode, - account = it.id.value - ) - ) + transaction { + authenticateRequest(call.request) + // FIXME(dold): Only return accounts the user has at least read access to? + BankAccountEntity.all().forEach { + bankAccounts.accounts.add(BankAccount(it.accountHolder, it.iban, it.bankCode, it.id.value)) + } } + call.respond(bankAccounts) return@get } + /** - * Submit one particular payment at the bank. + * Submit one particular payment to the bank. */ - post("/bank-accounts/prepared-payments/submit") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val body = call.receive<SubmitPayment>() - val preparedPayment = getPreparedPayment(body.uuid) - transaction { - if (preparedPayment.nexusUser.id.value != userId) throw NexusError( - HttpStatusCode.Forbidden, - "No rights over such payment" - ) + post("/bank-accounts/{accountid}/prepared-payments/{uuid}/submit") { + val uuid = ensureNonNull(call.parameters["uuid"]) + val accountId = ensureNonNull(call.parameters["accountid"]) + val res = transaction { + val user = authenticateRequest(call.request) + val preparedPayment = getPreparedPayment(uuid) if (preparedPayment.submitted) { throw NexusError( HttpStatusCode.PreconditionFailed, - "Payment ${body.uuid} was submitted already" + "Payment ${uuid} was submitted already" ) } - + val bankAccount = BankAccountEntity.findById(accountId) + if (bankAccount == null) { + throw NexusError(HttpStatusCode.NotFound, "unknown bank account") + } + val defaultBankConnection = bankAccount.defaultBankConnection + if (defaultBankConnection == null) { + throw NexusError(HttpStatusCode.NotFound, "needs a default connection") + } + val subscriberDetails = getEbicsSubscriberDetails(user.id.value, defaultBankConnection.id.value); + return@transaction object { + val pain001document = createPain001document(preparedPayment) + val bankConnectionType = defaultBankConnection.type + val subscriberDetails = subscriberDetails + } } - val pain001document = createPain001document(preparedPayment) - if (body.transport != null) { - // type and name aren't null - when (body.transport.type) { - "ebics" -> { - submitPaymentEbics( - client, userId, body.transport.name, pain001document - ) - } - else -> throw NexusError( - HttpStatusCode.NotFound, - "Transport type '${body.transport.type}' not implemented" + // type and name aren't null + when (res.bankConnectionType) { + "ebics" -> { + logger.debug("Uploading PAIN.001: ${res.pain001document}") + doEbicsUploadTransaction( + client, + res.subscriberDetails, + "CCT", + res.pain001document.toByteArray(Charsets.UTF_8), + EbicsStandardOrderParams() ) } - } else { - // default to ebics and "first" transport from user - submitPaymentEbics( - client, userId, null, pain001document + else -> throw NexusError( + HttpStatusCode.NotFound, + "Transport type '${res.bankConnectionType}' not implemented" ) } transaction { + val preparedPayment = getPreparedPayment(uuid) preparedPayment.submitted = true } - call.respondText("Payment ${body.uuid} submitted") + call.respondText("Payment ${uuid} submitted") return@post } + /** * Shows information about one particular prepared payment. */ get("/bank-accounts/{accountid}/prepared-payments/{uuid}") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val preparedPayment = getPreparedPayment(ensureNonNull(call.parameters["uuid"])) - if (preparedPayment.nexusUser.id.value != userId) throw NexusError( - HttpStatusCode.Forbidden, - "No rights over such payment" - ) + val res = transaction { + val user = authenticateRequest(call.request) + val preparedPayment = getPreparedPayment(ensureNonNull(call.parameters["uuid"])) + return@transaction object { + val preparedPayment = preparedPayment + } + } call.respond( PaymentStatus( - uuid = preparedPayment.id.value, - submitted = preparedPayment.submitted, - creditorName = preparedPayment.creditorName, - creditorBic = preparedPayment.creditorBic, - creditorIban = preparedPayment.creditorIban, - amount = "${preparedPayment.sum}:${preparedPayment.currency}", - subject = preparedPayment.subject, - submissionDate = DateTime(preparedPayment.submissionDate).toDashedDate(), - preparationDate = DateTime(preparedPayment.preparationDate).toDashedDate() + uuid = res.preparedPayment.id.value, + submitted = res.preparedPayment.submitted, + creditorName = res.preparedPayment.creditorName, + creditorBic = res.preparedPayment.creditorBic, + creditorIban = res.preparedPayment.creditorIban, + amount = "${res.preparedPayment.sum}:${res.preparedPayment.currency}", + subject = res.preparedPayment.subject, + submissionDate = DateTime(res.preparedPayment.submissionDate).toDashedDate(), + preparationDate = DateTime(res.preparedPayment.preparationDate).toDashedDate() ) ) return@get @@ -440,78 +454,104 @@ fun serverMain() { * Adds a new prepared payment. */ post("/bank-accounts/{accountid}/prepared-payments") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val bankAccount = getBankAccount(userId, ensureNonNull(call.parameters["accountid"])) val body = call.receive<PreparedPaymentRequest>() - val amount = parseAmount(body.amount) - val paymentEntity = addPreparedPayment( - Pain001Data( - creditorIban = body.iban, - creditorBic = body.bic, - creditorName = body.name, - debitorAccount = bankAccount.id.value, - sum = amount.amount, - currency = amount.currency, - subject = body.subject - ), - extractNexusUser(userId) - ) + val accountId = ensureNonNull(call.parameters["accountid"]) + val res = transaction { + authenticateRequest(call.request) + val bankAccount = BankAccountEntity.findById(accountId) + if (bankAccount == null) { + throw NexusError(HttpStatusCode.NotFound, "unknown bank account") + } + val amount = parseAmount(body.amount) + val paymentEntity = addPreparedPayment( + Pain001Data( + creditorIban = body.iban, + creditorBic = body.bic, + creditorName = body.name, + debitorAccount = bankAccount.id.value, + sum = amount.amount, + currency = amount.currency, + subject = body.subject + ), + bankAccount + ) + return@transaction object { + val uuid = paymentEntity.id.value + } + } call.respond( HttpStatusCode.OK, - PreparedPaymentResponse(uuid = paymentEntity.id.value) + PreparedPaymentResponse(uuid = res.uuid) ) return@post } + /** * Downloads new transactions from the bank. - * - * NOTE: 'accountid' is not used. Transaction are asked on - * the basis of a transport subscriber (regardless of their - * bank account details) */ - post("/bank-accounts/collected-transactions") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val body = call.receive<CollectedTransaction>() - if (body.transport != null) { - when (body.transport.type) { - "ebics" -> { - downloadAndPersistC5xEbics( - "C53", - client, - userId, - body.start, - body.end, - body.transport.name - ) - } - else -> throw NexusError( + post("/bank-accounts/{accountid}/fetch-transactions") { + val accountid = call.parameters["accountid"] + if (accountid == null) { + throw NexusError( + HttpStatusCode.BadRequest, + "Account id missing" + ) + } + val res = transaction { + val user = authenticateRequest(call.request) + val acct = BankAccountEntity.findById(accountid) + if (acct == null) { + throw NexusError( + HttpStatusCode.NotFound, + "Account not found" + ) + } + val conn = acct.defaultBankConnection + if (conn == null) { + throw NexusError( HttpStatusCode.BadRequest, - "Transport type '${body.transport.type}' not implemented" + "No default bank connection (explicit connection not yet supported)" + ) + } + val subscriberDetails = getEbicsSubscriberDetails(user.id.value, conn.id.value) + return@transaction object { + val connectionType = conn.type + val connectionName = conn.id.value + val userId = user.id.value + val subscriberDetails = subscriberDetails + } + } + val body = call.receive<CollectedTransaction>() + when (res.connectionType) { + "ebics" -> { + downloadAndPersistC5xEbics( + "C53", + client, + res.userId, + body.start, + body.end, + res.subscriberDetails ) } - } else { - downloadAndPersistC5xEbics( - "C53", - client, - userId, - body.start, - body.end, - null + else -> throw NexusError( + HttpStatusCode.BadRequest, + "Connection type '${res.connectionType}' not implemented" ) } call.respondText("Collection performed") return@post } + /** * Asks list of transactions ALREADY downloaded from the bank. */ - get("/bank-accounts/{accountid}/collected-transactions") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } + get("/bank-accounts/{accountid}/transactions") { val bankAccount = expectNonNull(call.parameters["accountid"]) val start = call.request.queryParameters["start"] val end = call.request.queryParameters["end"] val ret = Transactions() transaction { + val userId = transaction { authenticateRequest(call.request).id.value } RawBankTransactionEntity.find { RawBankTransactionsTable.nexusUser eq userId and (RawBankTransactionsTable.bankAccount eq bankAccount) and @@ -536,151 +576,180 @@ fun serverMain() { call.respond(ret) return@get } + /** * Adds a new bank transport. */ - post("/bank-transports") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } + post("/bank-connections") { // user exists and is authenticated. val body = call.receive<JsonNode>() - val transport: Transport = getTransportFromJsonObject(body) - when (transport.type) { - "ebics" -> { - if (body.get("backup") != null) { - val backup = jacksonObjectMapper().treeToValue(body, EbicsKeysBackupJson::class.java) - val (authKey, encKey, sigKey) = try { - Triple( - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(backup.authBlob)), - backup.passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(backup.encBlob)), - backup.passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(backup.sigBlob)), - backup.passphrase - ) - ) - } catch (e: Exception) { - e.printStackTrace() - logger.info("Restoring keys failed, probably due to wrong passphrase") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad backup given" - ) - } - logger.info("Restoring keys, creating new user: $userId") - try { - transaction { - EbicsSubscriberEntity.new(transport.name) { - this.nexusUser = extractNexusUser(userId) - ebicsURL = backup.ebicsURL - hostID = backup.hostID - partnerID = backup.partnerID - userID = backup.userID - signaturePrivateKey = SerialBlob(sigKey.encoded) - encryptionPrivateKey = SerialBlob(encKey.encoded) - authenticationPrivateKey = SerialBlob(authKey.encoded) - } - } - } catch (e: Exception) { - print(e) - call.respond( - NexusErrorJson("Could not store the new account into database") - ) - return@post - } - call.respondText("Backup restored") - - return@post + val bankConnectionName = body.get("name").textValue() + val bankConnectionType = body.get("type").textValue() + transaction { + val user = authenticateRequest(call.request) + when (bankConnectionType) { + "ebics" -> { + createEbicsBankConnection(bankConnectionName, user, body) } - if (body.get("data") != null) { - val data = - jacksonObjectMapper().treeToValue((body.get("data")), EbicsNewTransport::class.java) - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - transaction { - EbicsSubscriberEntity.new(transport.name) { - nexusUser = extractNexusUser(userId) - ebicsURL = data.ebicsURL - hostID = data.hostID - partnerID = data.partnerID - userID = data.userID - systemID = data.systemID - signaturePrivateKey = SerialBlob(pairA.private.encoded) - encryptionPrivateKey = SerialBlob(pairB.private.encoded) - authenticationPrivateKey = SerialBlob(pairC.private.encoded) - } - } - call.respondText("EBICS user successfully created") - return@post + else -> { + throw NexusError( + HttpStatusCode.BadRequest, + "Invalid bank connection type '${bankConnectionType}'" + ) } - throw NexusError( - HttpStatusCode.BadRequest, - "Neither restore or new transport were specified." - ) } - else -> { + } + call.respond(object {}) + } + + get("/bank-connections") { + val connList = mutableListOf<BankConnectionInfo>() + transaction { + NexusBankConnectionEntity.all().forEach { + connList.add(BankConnectionInfo(it.id.value, it.type)) + } + } + call.respond(BankConnectionsList(connList)) + } + + post("/bank-connections/{connid}/connect") { + throw NotImplementedError() + } + + post("/bank-connections/{connid}/ebics/send-ini") { + val subscriber = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { throw NexusError( HttpStatusCode.BadRequest, - "Invalid transport type '${transport.type}'" + "bank connection is not of type 'ebics' (but '${conn.type}')" ) } + getEbicsSubscriberDetails(user.id.value, conn.id.value) } + val resp = doEbicsIniRequest(client, subscriber) + call.respond(resp) } + + post("/bank-connections/{connid}/ebics/send-hia") { + val subscriber = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(user.id.value, conn.id.value) + } + val resp = doEbicsHiaRequest(client, subscriber) + call.respond(resp) + } + + post("/bank-connections/{connid}/ebics/send-hpb") { + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(user.id.value, conn.id.value) + } + val hpbData = doEbicsHpbRequest(client, subscriberDetails) + transaction { + val conn = requireBankConnection(call, "connid") + val subscriber = + EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq conn.id }.first() + subscriber.bankAuthenticationPublicKey = SerialBlob(hpbData.authenticationPubKey.encoded) + subscriber.bankEncryptionPublicKey = SerialBlob(hpbData.encryptionPubKey.encoded) + } + call.respond(object { }) + } + /** - * Sends to the bank a message "MSG" according to the transport - * "transportName". Does not modify any DB table. + * Directly import accounts. Used for testing. */ - post("/bank-transports/send{MSG}") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val body = call.receive<Transport>() - when (body.type) { - "ebics" -> { - val response = handleEbicsSendMSG( - httpClient = client, - userId = userId, - transportId = body.name, - msg = ensureNonNull(call.parameters["MSG"]), - sync = true + post("/bank-connections/{connid}/ebics/import-accounts") { + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(user.id.value, conn.id.value) + } + val response = doEbicsDownloadTransaction( + client, subscriberDetails, "HTD", EbicsStandardOrderParams() + ) + when (response) { + is EbicsDownloadBankErrorResult -> { + throw NexusError( + HttpStatusCode.BadGateway, + response.returnCode.errorCode ) - call.respondText(response) } - else -> throw NexusError( - HttpStatusCode.NotImplemented, - "Transport '${body.type}' not implemented. Use 'ebics'" - ) + is EbicsDownloadSuccessResult -> { + val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( + response.orderData.toString(Charsets.UTF_8) + ) + transaction { + val conn = requireBankConnection(call, "connid") + payload.value.partnerInfo.accountInfoList?.forEach { + val bankAccount = BankAccountEntity.new(id = it.id) { + accountHolder = it.accountHolder ?: "NOT-GIVEN" + iban = extractFirstIban(it.accountNumberList) + ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") + bankCode = extractFirstBic(it.bankCodeList) ?: throw NexusError( + HttpStatusCode.NotFound, + reason = "bank gave no BIC" + ) + defaultBankConnection = conn + } + } + } + response.orderData.toString(Charsets.UTF_8) + } } - return@post + call.respond(object { }) } - /** - * Sends the bank a message "MSG" according to the transport - * "transportName". DOES alterate DB tables. - */ - post("/bank-transports/sync{MSG}") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val body = call.receive<Transport>() - when (body.type) { - "ebics" -> { - val response = handleEbicsSendMSG( - httpClient = client, - userId = userId, - transportId = body.name, - msg = ensureNonNull(call.parameters["MSG"]), - sync = true + + post("/bank-connections/{connid}/ebics/download/{msgtype}") { + val orderType = requireNotNull(call.parameters["msgtype"]).toUpperCase(Locale.ROOT) + if (orderType.length != 3) { + throw NexusError(HttpStatusCode.BadRequest, "ebics order type must be three characters") + } + val paramsJson = call.receive<EbicsStandardOrderParamsJson>() + val orderParams = paramsJson.toOrderParams() + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(user.id.value, conn.id.value) + } + val response = doEbicsDownloadTransaction( + client, + subscriberDetails, + orderType, + orderParams + ) + when (response) { + is EbicsDownloadSuccessResult -> { + call.respondText( + response.orderData.toString(Charsets.UTF_8), + ContentType.Text.Plain, + HttpStatusCode.OK + ) + } + is EbicsDownloadBankErrorResult -> { + call.respond( + HttpStatusCode.BadGateway, + EbicsErrorJson(EbicsErrorDetailJson("bankError", response.returnCode.errorCode)) ) - call.respondText(response) } - else -> throw NexusError( - HttpStatusCode.NotImplemented, - "Transport '${body.type}' not implemented. Use 'ebics'" - ) } - return@post } + /** * Hello endpoint. */ diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt @@ -20,559 +20,559 @@ import tech.libeufin.util.* import kotlin.math.abs import kotlin.math.min -class Taler(app: Route) { - - /** Payment initiating data structures: one endpoint "$BASE_URL/transfer". */ - private data class TalerTransferRequest( - val request_uid: String, - val amount: String, - val exchange_base_url: String, - val wtid: String, - val credit_account: String - ) - private data class TalerTransferResponse( - // point in time when the nexus put the payment instruction into the database. - val timestamp: GnunetTimestamp, - val row_id: Long - ) - - /** History accounting data structures */ - private data class TalerIncomingBankTransaction( - val row_id: Long, - val date: GnunetTimestamp, // timestamp - val amount: String, - val credit_account: String, // payto form, - val debit_account: String, - val reserve_pub: String - ) - private data class TalerIncomingHistory( - var incoming_transactions: MutableList<TalerIncomingBankTransaction> = mutableListOf() - ) - private data class TalerOutgoingBankTransaction( - val row_id: Long, - val date: GnunetTimestamp, // timestamp - val amount: String, - val credit_account: String, // payto form, - val debit_account: String, - val wtid: String, - val exchange_base_url: String - ) - private data class TalerOutgoingHistory( - var outgoing_transactions: MutableList<TalerOutgoingBankTransaction> = mutableListOf() - ) - - /** Test APIs' data structures. */ - private data class TalerAdminAddIncoming( - val amount: String, - val reserve_pub: String, - /** - * This account is the one giving money to the exchange. It doesn't - * have to be 'created' as it might (and normally is) simply be a payto:// - * address pointing to a bank account hosted in a different financial - * institution. - */ - val debit_account: String - ) - - private data class GnunetTimestamp( - val t_ms: Long - ) - private data class TalerAddIncomingResponse( - val timestamp: GnunetTimestamp, - val row_id: Long - ) - - /** - * Helper data structures. - */ - data class Payto( - val name: String = "NOTGIVEN", - val iban: String, - val bic: String = "NOTGIVEN" - ) - /** - * Helper functions - */ - fun parsePayto(paytoUri: String): Payto { - /** - * First try to parse a "iban"-type payto URI. If that fails, - * then assume a test is being run under the "x-taler-bank" type. - * If that one fails too, throw exception. - * - * Note: since the Nexus doesn't have the notion of "x-taler-bank", - * such URIs must yield a iban-compatible tuple of values. Therefore, - * the plain bank account number maps to a "iban", and the <bank hostname> - * maps to a "bic". - */ - - - /** - * payto://iban/BIC?/IBAN?name=<name> - * payto://x-taler-bank/<bank hostname>/<plain account number> - */ - - val ibanMatch = Regex("payto://iban/([A-Z0-9]+/)?([A-Z0-9]+)\\?name=(\\w+)").find(paytoUri) - if (ibanMatch != null) { - val (bic, iban, name) = ibanMatch.destructured - return Payto(name, iban, bic.replace("/", "")) - } - val xTalerBankMatch = Regex("payto://x-taler-bank/localhost/([0-9]+)").find(paytoUri) - if (xTalerBankMatch != null) { - val xTalerBankAcctNo = xTalerBankMatch.destructured.component1() - return Payto("Taler Exchange", xTalerBankAcctNo, "localhost") - } - - throw NexusError(HttpStatusCode.BadRequest, "invalid payto URI ($paytoUri)") - } - - /** Sort query results in descending order for negative deltas, and ascending otherwise. */ - private fun <T : Entity<Long>> SizedIterable<T>.orderTaler(delta: Int): List<T> { - return if (delta < 0) { - this.sortedByDescending { it.id } - } else { - this.sortedBy { it.id } - } - } - - /** - * NOTE: those payto-builders default all to the x-taler-bank transport. - * A mechanism to easily switch transport is needed, as production needs - * 'iban'. - */ - private fun buildPaytoUri(name: String, iban: String, bic: String): String { - return "payto://x-taler-bank/localhost/$iban" - } - private fun buildPaytoUri(iban: String, bic: String): String { - return "payto://x-taler-bank/localhost/$iban" - } - - /** Builds the comparison operator for history entries based on the sign of 'delta' */ - private fun getComparisonOperator(delta: Int, start: Long, table: IdTable<Long>): Op<Boolean> { - return if (delta < 0) { - Expression.build { - table.id less start - } - } else { - Expression.build { - table.id greater start - } - } - } - /** Helper handling 'start' being optional and its dependence on 'delta'. */ - private fun handleStartArgument(start: String?, delta: Int): Long { - return expectLong(start) ?: if (delta >= 0) { - /** - * Using -1 as the smallest value, as some DBMS might use 0 and some - * others might use 1 as the smallest row id. - */ - -1 - } else { - /** - * NOTE: the database currently enforces there MAX_VALUE is always - * strictly greater than any row's id in the database. In fact, the - * database throws exception whenever a new row is going to occupy - * the MAX_VALUE with its id. - */ - Long.MAX_VALUE - } - } - - /** - * The Taler layer cannot rely on the ktor-internal JSON-converter/responder, - * because this one adds a "charset" extra information in the Content-Type header - * that makes the GNUnet JSON parser unhappy. - * - * The workaround is to explicitly convert the 'data class'-object into a JSON - * string (what this function does), and use the simpler respondText method. - */ - private fun customConverter(body: Any): String { - return jacksonObjectMapper().writeValueAsString(body) - } - - /** - * This function indicates whether a payment in the raw table was already reported - * by some other EBICS message. It works for both incoming and outgoing payments. - * Basically, it tries to match all the relevant details with those from the records - * that are already stored in the local "taler" database. - * - * @param entry a new raw payment to be checked. - * @return true if the payment was already "seen" by the Taler layer, false otherwise. - */ - private fun duplicatePayment(entry: RawBankTransactionEntity): Boolean { - return false - } - - /** - * This function checks whether the bank didn't accept one exchange's payment initiation. - * - * @param entry the raw entry to check - * @return true if the payment failed, false if it was successful. - */ - private fun paymentFailed(entry: RawBankTransactionEntity): Boolean { - return false - } - - /** Attach Taler endpoints to the main Web server */ - - init { - app.get("/taler") { - call.respondText("Taler Gateway Hello\n", ContentType.Text.Plain, HttpStatusCode.OK) - return@get - } - app.post("/taler/transfer") { - val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val transferRequest = call.receive<TalerTransferRequest>() - val amountObj = parseAmount(transferRequest.amount) - val creditorObj = parsePayto(transferRequest.credit_account) - val opaque_row_id = transaction { - val creditorData = parsePayto(transferRequest.credit_account) - val exchangeBankAccount = getBankAccountsFromNexusUserId(exchangeId).first() - val nexusUser = extractNexusUser(exchangeId) - /** Checking the UID has the desired characteristics */ - TalerRequestedPaymentEntity.find { - TalerRequestedPayments.requestUId eq transferRequest.request_uid - }.forEach { - if ( - (it.amount != transferRequest.amount) or - (it.creditAccount != transferRequest.exchange_base_url) or - (it.wtid != transferRequest.wtid) - ) { - throw NexusError( - HttpStatusCode.Conflict, - "This uid (${transferRequest.request_uid}) belongs to a different payment already" - ) - } - } - val pain001 = addPreparedPayment( - Pain001Data( - creditorIban = creditorData.iban, - creditorBic = creditorData.bic, - creditorName = creditorData.name, - subject = transferRequest.wtid, - sum = amountObj.amount, - currency = amountObj.currency, - debitorAccount = exchangeBankAccount.id.value - ), - nexusUser - ) - val rawEbics = if (!isProduction()) { - RawBankTransactionEntity.new { - sourceFileName = "test" - unstructuredRemittanceInformation = transferRequest.wtid - transactionType = "DBIT" - currency = amountObj.currency - this.amount = amountObj.amount.toPlainString() - counterpartBic = creditorObj.bic - counterpartIban = creditorObj.iban - counterpartName = creditorObj.name - bankAccount = exchangeBankAccount - bookingDate = DateTime.now().millis - this.nexusUser = nexusUser - status = "BOOK" - } - } else null - - val row = TalerRequestedPaymentEntity.new { - preparedPayment = pain001 // not really used/needed, just here to silence warnings - exchangeBaseUrl = transferRequest.exchange_base_url - requestUId = transferRequest.request_uid - amount = transferRequest.amount - wtid = transferRequest.wtid - creditAccount = transferRequest.credit_account - rawConfirmed = rawEbics - } - - row.id.value - } - call.respond( - HttpStatusCode.OK, - TextContent( - customConverter( - TalerTransferResponse( - /** - * Normally should point to the next round where the background - * routine will send new PAIN.001 data to the bank; work in progress.. - */ - timestamp = GnunetTimestamp(DateTime.now().millis), - row_id = opaque_row_id - ) - ), - ContentType.Application.Json - ) - ) - return@post - } - /** Test-API that creates one new payment addressed to the exchange. */ - app.post("/taler/admin/add-incoming") { - val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val addIncomingData = call.receive<TalerAdminAddIncoming>() - val debtor = parsePayto(addIncomingData.debit_account) - val amount = parseAmount(addIncomingData.amount) - val (bookingDate, opaque_row_id) = transaction { - val exchangeBankAccount = getBankAccountsFromNexusUserId(exchangeId).first() - val rawPayment = RawBankTransactionEntity.new { - sourceFileName = "test" - unstructuredRemittanceInformation = addIncomingData.reserve_pub - transactionType = "CRDT" - currency = amount.currency - this.amount = amount.amount.toPlainString() - counterpartBic = debtor.bic - counterpartName = debtor.name - counterpartIban = debtor.iban - bookingDate = DateTime.now().millis - status = "BOOK" - nexusUser = extractNexusUser(exchangeId) - bankAccount = exchangeBankAccount - } - /** This payment is "valid by default" and will be returned - * as soon as the exchange will ask for new payments. */ - val row = TalerIncomingPaymentEntity.new { - payment = rawPayment - valid = true - } - Pair(rawPayment.bookingDate, row.id.value) - } - call.respond( - TextContent( - customConverter( - TalerAddIncomingResponse( - timestamp = GnunetTimestamp(bookingDate/ 1000), - row_id = opaque_row_id - ) - ), - ContentType.Application.Json - ) - ) - return@post - } - - /** This endpoint triggers the refunding of invalid payments. 'Refunding' - * in this context means that nexus _prepares_ the payment instruction and - * places it into a further table. Eventually, another routine will perform - * all the prepared payments. */ - app.post("/ebics/taler/{id}/accounts/{acctid}/refund-invalid-payments") { - val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val nexusUser = getNexusUser(userId) - val callerBankAccount = expectNonNull(call.parameters["acctid"]) - transaction { - val bankAccount = getBankAccount( - userId, - callerBankAccount - ) - TalerIncomingPaymentEntity.find { - TalerIncomingPayments.refunded eq false and (TalerIncomingPayments.valid eq false) - }.forEach { - addPreparedPayment( - Pain001Data( - creditorName = it.payment.counterpartName, - creditorIban = it.payment.counterpartIban, - creditorBic = it.payment.counterpartBic, - sum = calculateRefund(it.payment.amount), - subject = "Taler refund", - currency = it.payment.currency, - debitorAccount = callerBankAccount - ), - nexusUser - ) - it.refunded = true - } - } - return@post - } - - /** This endpoint triggers the examination of raw incoming payments aimed - * at separating the good payments (those that will lead to a new reserve - * being created), from the invalid payments (those with a invalid subject - * that will soon be refunded.) Recently, the examination of raw OUTGOING - * payment was added as well. - */ - app.post("/ebics/taler/{id}/crunch-raw-transactions") { - val id = ensureNonNull(call.parameters["id"]) - // first find highest ID value of already processed rows. - transaction { - val subscriberAccount = getBankAccountsFromNexusUserId(id).first() - /** - * Search for fresh incoming payments in the raw table, and making pointers - * from the Taler incoming payments table to the found fresh (and valid!) payments. - */ - val latestIncomingPaymentId: Long = TalerIncomingPaymentEntity.getLast() - RawBankTransactionEntity.find { - /** Those with exchange bank account involved */ - RawBankTransactionsTable.bankAccount eq subscriberAccount.id.value and - /** Those that are incoming */ - (RawBankTransactionsTable.transactionType eq "CRDT") and - /** Those that are booked */ - (RawBankTransactionsTable.status eq "BOOK") and - /** Those that came later than the latest processed payment */ - (RawBankTransactionsTable.id.greater(latestIncomingPaymentId)) - - }.forEach { - if (duplicatePayment(it)) { - logger.warn("Incomint payment already seen") - throw NexusError( - HttpStatusCode.InternalServerError, - "Incoming payment already seen" - ) - } - if (CryptoUtil.checkValidEddsaPublicKey(it.unstructuredRemittanceInformation)) { - TalerIncomingPaymentEntity.new { - payment = it - valid = true - } - } else { - TalerIncomingPaymentEntity.new { - payment = it - valid = false - } - } - } - /** - * Search for fresh OUTGOING transactions acknowledged by the bank. As well - * searching only for BOOKed transactions, even though status changes should - * be really unexpected here. - */ - val latestOutgoingPaymentId = TalerRequestedPaymentEntity.getLast() - RawBankTransactionEntity.find { - /** Those that came after the last processed payment */ - RawBankTransactionsTable.id greater latestOutgoingPaymentId and - /** Those involving the exchange bank account */ - (RawBankTransactionsTable.bankAccount eq subscriberAccount.id.value) and - /** Those that are outgoing */ - (RawBankTransactionsTable.transactionType eq "DBIT") - }.forEach { - if (paymentFailed(it)) { - logger.error("Bank didn't accept one payment from the exchange") - throw NexusError( - HttpStatusCode.InternalServerError, - "Bank didn't accept one payment from the exchange" - ) - } - if (duplicatePayment(it)) { - logger.warn("Incomint payment already seen") - throw NexusError( - HttpStatusCode.InternalServerError, - "Outgoing payment already seen" - ) - } - var talerRequested = TalerRequestedPaymentEntity.find { - TalerRequestedPayments.wtid eq it.unstructuredRemittanceInformation - }.firstOrNull() ?: throw NexusError( - HttpStatusCode.InternalServerError, - "Unrecognized fresh outgoing payment met (subject: ${it.unstructuredRemittanceInformation})." - ) - talerRequested.rawConfirmed = it - } - } - - call.respondText ( - "New raw payments Taler-processed", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - /** Responds only with the payments that the EXCHANGE made. Typically to - * merchants but possibly to refund invalid incoming payments. A payment is - * counted only if was once confirmed by the bank. - */ - app.get("/taler/history/outgoing") { - /* sanitize URL arguments */ - val subscriberId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val delta: Int = expectInt(call.expectUrlParameter("delta")) - val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) - val startCmpOp = getComparisonOperator(delta, start, TalerRequestedPayments) - /* retrieve database elements */ - val history = TalerOutgoingHistory() - transaction { - /** Retrieve all the outgoing payments from the _clean Taler outgoing table_ */ - val subscriberBankAccount = getBankAccountsFromNexusUserId(subscriberId).first() - val reqPayments = TalerRequestedPaymentEntity.find { - TalerRequestedPayments.rawConfirmed.isNotNull() and startCmpOp - }.orderTaler(delta) - if (reqPayments.isNotEmpty()) { - reqPayments.subList(0, min(abs(delta), reqPayments.size)).forEach { - history.outgoing_transactions.add( - TalerOutgoingBankTransaction( - row_id = it.id.value, - amount = it.amount, - wtid = it.wtid, - date = GnunetTimestamp(it.rawConfirmed?.bookingDate?.div(1000) ?: throw NexusError( - HttpStatusCode.InternalServerError, "Null value met after check, VERY strange.")), - credit_account = it.creditAccount, - debit_account = buildPaytoUri(subscriberBankAccount.iban, subscriberBankAccount.bankCode), - exchange_base_url = "FIXME-to-request-along-subscriber-registration" - ) - ) - } - } - } - call.respond( - HttpStatusCode.OK, - TextContent(customConverter(history), ContentType.Application.Json) - ) - return@get - } - /** Responds only with the valid incoming payments */ - app.get("/taler/history/incoming") { - val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } - val delta: Int = expectInt(call.expectUrlParameter("delta")) - val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) - val history = TalerIncomingHistory() - val startCmpOp = getComparisonOperator(delta, start, TalerIncomingPayments) - transaction { - /** - * Below, the test harness creates the exchange's bank account - * object based on the payto:// given as the funds receiver. - * - * This is needed because nexus takes this information from the - * bank - normally - but tests are currently avoiding any interaction - * with banks or sandboxes. - */ - if (!isProduction()) { - val EXCHANGE_BANKACCOUNT_ID = "exchange-bankaccount-id" - if (BankAccountEntity.findById(EXCHANGE_BANKACCOUNT_ID) == null) { - val newBankAccount = BankAccountEntity.new(id = EXCHANGE_BANKACCOUNT_ID) { - accountHolder = "Test Exchange" - iban = "42" - bankCode = "localhost" - } - val nexusUser = extractNexusUser(exchangeId) - BankAccountMapEntity.new { - bankAccount = newBankAccount - ebicsSubscriber = getEbicsTransport(exchangeId) - this.nexusUser = nexusUser - } - } - } - val exchangeBankAccount = getBankAccountsFromNexusUserId(exchangeId).first() - val orderedPayments = TalerIncomingPaymentEntity.find { - TalerIncomingPayments.valid eq true and startCmpOp - }.orderTaler(delta) - if (orderedPayments.isNotEmpty()) { - orderedPayments.subList(0, min(abs(delta), orderedPayments.size)).forEach { - history.incoming_transactions.add( - TalerIncomingBankTransaction( - date = GnunetTimestamp(it.payment.bookingDate / 1000), - row_id = it.id.value, - amount = "${it.payment.currency}:${it.payment.amount}", - reserve_pub = it.payment.unstructuredRemittanceInformation, - credit_account = buildPaytoUri( - it.payment.bankAccount.accountHolder, - it.payment.bankAccount.iban, - it.payment.bankAccount.bankCode - ), - debit_account = buildPaytoUri( - it.payment.counterpartName, - it.payment.counterpartIban, - it.payment.counterpartBic - ) - ) - ) - } - } - } - call.respond(TextContent(customConverter(history), ContentType.Application.Json)) - return@get - } - } -} -\ No newline at end of file +//class Taler(app: Route) { +// +// /** Payment initiating data structures: one endpoint "$BASE_URL/transfer". */ +// private data class TalerTransferRequest( +// val request_uid: String, +// val amount: String, +// val exchange_base_url: String, +// val wtid: String, +// val credit_account: String +// ) +// private data class TalerTransferResponse( +// // point in time when the nexus put the payment instruction into the database. +// val timestamp: GnunetTimestamp, +// val row_id: Long +// ) +// +// /** History accounting data structures */ +// private data class TalerIncomingBankTransaction( +// val row_id: Long, +// val date: GnunetTimestamp, // timestamp +// val amount: String, +// val credit_account: String, // payto form, +// val debit_account: String, +// val reserve_pub: String +// ) +// private data class TalerIncomingHistory( +// var incoming_transactions: MutableList<TalerIncomingBankTransaction> = mutableListOf() +// ) +// private data class TalerOutgoingBankTransaction( +// val row_id: Long, +// val date: GnunetTimestamp, // timestamp +// val amount: String, +// val credit_account: String, // payto form, +// val debit_account: String, +// val wtid: String, +// val exchange_base_url: String +// ) +// private data class TalerOutgoingHistory( +// var outgoing_transactions: MutableList<TalerOutgoingBankTransaction> = mutableListOf() +// ) +// +// /** Test APIs' data structures. */ +// private data class TalerAdminAddIncoming( +// val amount: String, +// val reserve_pub: String, +// /** +// * This account is the one giving money to the exchange. It doesn't +// * have to be 'created' as it might (and normally is) simply be a payto:// +// * address pointing to a bank account hosted in a different financial +// * institution. +// */ +// val debit_account: String +// ) +// +// private data class GnunetTimestamp( +// val t_ms: Long +// ) +// private data class TalerAddIncomingResponse( +// val timestamp: GnunetTimestamp, +// val row_id: Long +// ) +// +// /** +// * Helper data structures. +// */ +// data class Payto( +// val name: String = "NOTGIVEN", +// val iban: String, +// val bic: String = "NOTGIVEN" +// ) +// /** +// * Helper functions +// */ +// fun parsePayto(paytoUri: String): Payto { +// /** +// * First try to parse a "iban"-type payto URI. If that fails, +// * then assume a test is being run under the "x-taler-bank" type. +// * If that one fails too, throw exception. +// * +// * Note: since the Nexus doesn't have the notion of "x-taler-bank", +// * such URIs must yield a iban-compatible tuple of values. Therefore, +// * the plain bank account number maps to a "iban", and the <bank hostname> +// * maps to a "bic". +// */ +// +// +// /** +// * payto://iban/BIC?/IBAN?name=<name> +// * payto://x-taler-bank/<bank hostname>/<plain account number> +// */ +// +// val ibanMatch = Regex("payto://iban/([A-Z0-9]+/)?([A-Z0-9]+)\\?name=(\\w+)").find(paytoUri) +// if (ibanMatch != null) { +// val (bic, iban, name) = ibanMatch.destructured +// return Payto(name, iban, bic.replace("/", "")) +// } +// val xTalerBankMatch = Regex("payto://x-taler-bank/localhost/([0-9]+)").find(paytoUri) +// if (xTalerBankMatch != null) { +// val xTalerBankAcctNo = xTalerBankMatch.destructured.component1() +// return Payto("Taler Exchange", xTalerBankAcctNo, "localhost") +// } +// +// throw NexusError(HttpStatusCode.BadRequest, "invalid payto URI ($paytoUri)") +// } +// +// /** Sort query results in descending order for negative deltas, and ascending otherwise. */ +// private fun <T : Entity<Long>> SizedIterable<T>.orderTaler(delta: Int): List<T> { +// return if (delta < 0) { +// this.sortedByDescending { it.id } +// } else { +// this.sortedBy { it.id } +// } +// } +// +// /** +// * NOTE: those payto-builders default all to the x-taler-bank transport. +// * A mechanism to easily switch transport is needed, as production needs +// * 'iban'. +// */ +// private fun buildPaytoUri(name: String, iban: String, bic: String): String { +// return "payto://x-taler-bank/localhost/$iban" +// } +// private fun buildPaytoUri(iban: String, bic: String): String { +// return "payto://x-taler-bank/localhost/$iban" +// } +// +// /** Builds the comparison operator for history entries based on the sign of 'delta' */ +// private fun getComparisonOperator(delta: Int, start: Long, table: IdTable<Long>): Op<Boolean> { +// return if (delta < 0) { +// Expression.build { +// table.id less start +// } +// } else { +// Expression.build { +// table.id greater start +// } +// } +// } +// /** Helper handling 'start' being optional and its dependence on 'delta'. */ +// private fun handleStartArgument(start: String?, delta: Int): Long { +// return expectLong(start) ?: if (delta >= 0) { +// /** +// * Using -1 as the smallest value, as some DBMS might use 0 and some +// * others might use 1 as the smallest row id. +// */ +// -1 +// } else { +// /** +// * NOTE: the database currently enforces there MAX_VALUE is always +// * strictly greater than any row's id in the database. In fact, the +// * database throws exception whenever a new row is going to occupy +// * the MAX_VALUE with its id. +// */ +// Long.MAX_VALUE +// } +// } +// +// /** +// * The Taler layer cannot rely on the ktor-internal JSON-converter/responder, +// * because this one adds a "charset" extra information in the Content-Type header +// * that makes the GNUnet JSON parser unhappy. +// * +// * The workaround is to explicitly convert the 'data class'-object into a JSON +// * string (what this function does), and use the simpler respondText method. +// */ +// private fun customConverter(body: Any): String { +// return jacksonObjectMapper().writeValueAsString(body) +// } +// +// /** +// * This function indicates whether a payment in the raw table was already reported +// * by some other EBICS message. It works for both incoming and outgoing payments. +// * Basically, it tries to match all the relevant details with those from the records +// * that are already stored in the local "taler" database. +// * +// * @param entry a new raw payment to be checked. +// * @return true if the payment was already "seen" by the Taler layer, false otherwise. +// */ +// private fun duplicatePayment(entry: RawBankTransactionEntity): Boolean { +// return false +// } +// +// /** +// * This function checks whether the bank didn't accept one exchange's payment initiation. +// * +// * @param entry the raw entry to check +// * @return true if the payment failed, false if it was successful. +// */ +// private fun paymentFailed(entry: RawBankTransactionEntity): Boolean { +// return false +// } +// +// /** Attach Taler endpoints to the main Web server */ +// +// init { +// app.get("/taler") { +// call.respondText("Taler Gateway Hello\n", ContentType.Text.Plain, HttpStatusCode.OK) +// return@get +// } +// app.post("/taler/transfer") { +// val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } +// val transferRequest = call.receive<TalerTransferRequest>() +// val amountObj = parseAmount(transferRequest.amount) +// val creditorObj = parsePayto(transferRequest.credit_account) +// val opaque_row_id = transaction { +// val creditorData = parsePayto(transferRequest.credit_account) +// val exchangeBankAccount = getBankAccountsFromNexusUserId(exchangeId).first() +// val nexusUser = extractNexusUser(exchangeId) +// /** Checking the UID has the desired characteristics */ +// TalerRequestedPaymentEntity.find { +// TalerRequestedPayments.requestUId eq transferRequest.request_uid +// }.forEach { +// if ( +// (it.amount != transferRequest.amount) or +// (it.creditAccount != transferRequest.exchange_base_url) or +// (it.wtid != transferRequest.wtid) +// ) { +// throw NexusError( +// HttpStatusCode.Conflict, +// "This uid (${transferRequest.request_uid}) belongs to a different payment already" +// ) +// } +// } +// val pain001 = addPreparedPayment( +// Pain001Data( +// creditorIban = creditorData.iban, +// creditorBic = creditorData.bic, +// creditorName = creditorData.name, +// subject = transferRequest.wtid, +// sum = amountObj.amount, +// currency = amountObj.currency, +// debitorAccount = exchangeBankAccount.id.value +// ), +// nexusUser +// ) +// val rawEbics = if (!isProduction()) { +// RawBankTransactionEntity.new { +// sourceFileName = "test" +// unstructuredRemittanceInformation = transferRequest.wtid +// transactionType = "DBIT" +// currency = amountObj.currency +// this.amount = amountObj.amount.toPlainString() +// counterpartBic = creditorObj.bic +// counterpartIban = creditorObj.iban +// counterpartName = creditorObj.name +// bankAccount = exchangeBankAccount +// bookingDate = DateTime.now().millis +// this.nexusUser = nexusUser +// status = "BOOK" +// } +// } else null +// +// val row = TalerRequestedPaymentEntity.new { +// preparedPayment = pain001 // not really used/needed, just here to silence warnings +// exchangeBaseUrl = transferRequest.exchange_base_url +// requestUId = transferRequest.request_uid +// amount = transferRequest.amount +// wtid = transferRequest.wtid +// creditAccount = transferRequest.credit_account +// rawConfirmed = rawEbics +// } +// +// row.id.value +// } +// call.respond( +// HttpStatusCode.OK, +// TextContent( +// customConverter( +// TalerTransferResponse( +// /** +// * Normally should point to the next round where the background +// * routine will send new PAIN.001 data to the bank; work in progress.. +// */ +// timestamp = GnunetTimestamp(DateTime.now().millis), +// row_id = opaque_row_id +// ) +// ), +// ContentType.Application.Json +// ) +// ) +// return@post +// } +// /** Test-API that creates one new payment addressed to the exchange. */ +// app.post("/taler/admin/add-incoming") { +// val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } +// val addIncomingData = call.receive<TalerAdminAddIncoming>() +// val debtor = parsePayto(addIncomingData.debit_account) +// val amount = parseAmount(addIncomingData.amount) +// val (bookingDate, opaque_row_id) = transaction { +// val exchangeBankAccount = getBankAccountsFromNexusUserId(exchangeId).first() +// val rawPayment = RawBankTransactionEntity.new { +// sourceFileName = "test" +// unstructuredRemittanceInformation = addIncomingData.reserve_pub +// transactionType = "CRDT" +// currency = amount.currency +// this.amount = amount.amount.toPlainString() +// counterpartBic = debtor.bic +// counterpartName = debtor.name +// counterpartIban = debtor.iban +// bookingDate = DateTime.now().millis +// status = "BOOK" +// nexusUser = extractNexusUser(exchangeId) +// bankAccount = exchangeBankAccount +// } +// /** This payment is "valid by default" and will be returned +// * as soon as the exchange will ask for new payments. */ +// val row = TalerIncomingPaymentEntity.new { +// payment = rawPayment +// valid = true +// } +// Pair(rawPayment.bookingDate, row.id.value) +// } +// call.respond( +// TextContent( +// customConverter( +// TalerAddIncomingResponse( +// timestamp = GnunetTimestamp(bookingDate/ 1000), +// row_id = opaque_row_id +// ) +// ), +// ContentType.Application.Json +// ) +// ) +// return@post +// } +// +// /** This endpoint triggers the refunding of invalid payments. 'Refunding' +// * in this context means that nexus _prepares_ the payment instruction and +// * places it into a further table. Eventually, another routine will perform +// * all the prepared payments. */ +// app.post("/ebics/taler/{id}/accounts/{acctid}/refund-invalid-payments") { +// val userId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } +// val nexusUser = getNexusUser(userId) +// val callerBankAccount = expectNonNull(call.parameters["acctid"]) +// transaction { +// val bankAccount = getBankAccount( +// userId, +// callerBankAccount +// ) +// TalerIncomingPaymentEntity.find { +// TalerIncomingPayments.refunded eq false and (TalerIncomingPayments.valid eq false) +// }.forEach { +// addPreparedPayment( +// Pain001Data( +// creditorName = it.payment.counterpartName, +// creditorIban = it.payment.counterpartIban, +// creditorBic = it.payment.counterpartBic, +// sum = calculateRefund(it.payment.amount), +// subject = "Taler refund", +// currency = it.payment.currency, +// debitorAccount = callerBankAccount +// ), +// nexusUser +// ) +// it.refunded = true +// } +// } +// return@post +// } +// +// /** This endpoint triggers the examination of raw incoming payments aimed +// * at separating the good payments (those that will lead to a new reserve +// * being created), from the invalid payments (those with a invalid subject +// * that will soon be refunded.) Recently, the examination of raw OUTGOING +// * payment was added as well. +// */ +// app.post("/ebics/taler/{id}/crunch-raw-transactions") { +// val id = ensureNonNull(call.parameters["id"]) +// // first find highest ID value of already processed rows. +// transaction { +// val subscriberAccount = getBankAccountsFromNexusUserId(id).first() +// /** +// * Search for fresh incoming payments in the raw table, and making pointers +// * from the Taler incoming payments table to the found fresh (and valid!) payments. +// */ +// val latestIncomingPaymentId: Long = TalerIncomingPaymentEntity.getLast() +// RawBankTransactionEntity.find { +// /** Those with exchange bank account involved */ +// RawBankTransactionsTable.bankAccount eq subscriberAccount.id.value and +// /** Those that are incoming */ +// (RawBankTransactionsTable.transactionType eq "CRDT") and +// /** Those that are booked */ +// (RawBankTransactionsTable.status eq "BOOK") and +// /** Those that came later than the latest processed payment */ +// (RawBankTransactionsTable.id.greater(latestIncomingPaymentId)) +// +// }.forEach { +// if (duplicatePayment(it)) { +// logger.warn("Incomint payment already seen") +// throw NexusError( +// HttpStatusCode.InternalServerError, +// "Incoming payment already seen" +// ) +// } +// if (CryptoUtil.checkValidEddsaPublicKey(it.unstructuredRemittanceInformation)) { +// TalerIncomingPaymentEntity.new { +// payment = it +// valid = true +// } +// } else { +// TalerIncomingPaymentEntity.new { +// payment = it +// valid = false +// } +// } +// } +// /** +// * Search for fresh OUTGOING transactions acknowledged by the bank. As well +// * searching only for BOOKed transactions, even though status changes should +// * be really unexpected here. +// */ +// val latestOutgoingPaymentId = TalerRequestedPaymentEntity.getLast() +// RawBankTransactionEntity.find { +// /** Those that came after the last processed payment */ +// RawBankTransactionsTable.id greater latestOutgoingPaymentId and +// /** Those involving the exchange bank account */ +// (RawBankTransactionsTable.bankAccount eq subscriberAccount.id.value) and +// /** Those that are outgoing */ +// (RawBankTransactionsTable.transactionType eq "DBIT") +// }.forEach { +// if (paymentFailed(it)) { +// logger.error("Bank didn't accept one payment from the exchange") +// throw NexusError( +// HttpStatusCode.InternalServerError, +// "Bank didn't accept one payment from the exchange" +// ) +// } +// if (duplicatePayment(it)) { +// logger.warn("Incomint payment already seen") +// throw NexusError( +// HttpStatusCode.InternalServerError, +// "Outgoing payment already seen" +// ) +// } +// var talerRequested = TalerRequestedPaymentEntity.find { +// TalerRequestedPayments.wtid eq it.unstructuredRemittanceInformation +// }.firstOrNull() ?: throw NexusError( +// HttpStatusCode.InternalServerError, +// "Unrecognized fresh outgoing payment met (subject: ${it.unstructuredRemittanceInformation})." +// ) +// talerRequested.rawConfirmed = it +// } +// } +// +// call.respondText ( +// "New raw payments Taler-processed", +// ContentType.Text.Plain, +// HttpStatusCode.OK +// ) +// return@post +// } +// /** Responds only with the payments that the EXCHANGE made. Typically to +// * merchants but possibly to refund invalid incoming payments. A payment is +// * counted only if was once confirmed by the bank. +// */ +// app.get("/taler/history/outgoing") { +// /* sanitize URL arguments */ +// val subscriberId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } +// val delta: Int = expectInt(call.expectUrlParameter("delta")) +// val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) +// val startCmpOp = getComparisonOperator(delta, start, TalerRequestedPayments) +// /* retrieve database elements */ +// val history = TalerOutgoingHistory() +// transaction { +// /** Retrieve all the outgoing payments from the _clean Taler outgoing table_ */ +// val subscriberBankAccount = getBankAccountsFromNexusUserId(subscriberId).first() +// val reqPayments = TalerRequestedPaymentEntity.find { +// TalerRequestedPayments.rawConfirmed.isNotNull() and startCmpOp +// }.orderTaler(delta) +// if (reqPayments.isNotEmpty()) { +// reqPayments.subList(0, min(abs(delta), reqPayments.size)).forEach { +// history.outgoing_transactions.add( +// TalerOutgoingBankTransaction( +// row_id = it.id.value, +// amount = it.amount, +// wtid = it.wtid, +// date = GnunetTimestamp(it.rawConfirmed?.bookingDate?.div(1000) ?: throw NexusError( +// HttpStatusCode.InternalServerError, "Null value met after check, VERY strange.")), +// credit_account = it.creditAccount, +// debit_account = buildPaytoUri(subscriberBankAccount.iban, subscriberBankAccount.bankCode), +// exchange_base_url = "FIXME-to-request-along-subscriber-registration" +// ) +// ) +// } +// } +// } +// call.respond( +// HttpStatusCode.OK, +// TextContent(customConverter(history), ContentType.Application.Json) +// ) +// return@get +// } +// /** Responds only with the valid incoming payments */ +// app.get("/taler/history/incoming") { +// val exchangeId = transaction { authenticateRequest(call.request.headers["Authorization"]).id.value } +// val delta: Int = expectInt(call.expectUrlParameter("delta")) +// val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) +// val history = TalerIncomingHistory() +// val startCmpOp = getComparisonOperator(delta, start, TalerIncomingPayments) +// transaction { +// /** +// * Below, the test harness creates the exchange's bank account +// * object based on the payto:// given as the funds receiver. +// * +// * This is needed because nexus takes this information from the +// * bank - normally - but tests are currently avoiding any interaction +// * with banks or sandboxes. +// */ +// if (!isProduction()) { +// throw Error("currently not implemented") +// val EXCHANGE_BANKACCOUNT_ID = "exchange-bankaccount-id" +// if (BankAccountEntity.findById(EXCHANGE_BANKACCOUNT_ID) == null) { +// val newBankAccount = BankAccountEntity.new(id = EXCHANGE_BANKACCOUNT_ID) { +// accountHolder = "Test Exchange" +// iban = "42" +// bankCode = "localhost" +// } +// val nexusUser = extractNexusUser(exchangeId) +// BankAccountMapEntity.new { +// bankAccount = newBankAccount +// ebicsSubscriber = getEbicsTransport(exchangeId) +// this.nexusUser = nexusUser +// } +// } +// } +// val orderedPayments = TalerIncomingPaymentEntity.find { +// TalerIncomingPayments.valid eq true and startCmpOp +// }.orderTaler(delta) +// if (orderedPayments.isNotEmpty()) { +// orderedPayments.subList(0, min(abs(delta), orderedPayments.size)).forEach { +// history.incoming_transactions.add( +// TalerIncomingBankTransaction( +// date = GnunetTimestamp(it.payment.bookingDate / 1000), +// row_id = it.id.value, +// amount = "${it.payment.currency}:${it.payment.amount}", +// reserve_pub = it.payment.unstructuredRemittanceInformation, +// credit_account = buildPaytoUri( +// it.payment.bankAccount.accountHolder, +// it.payment.bankAccount.iban, +// it.payment.bankAccount.bankCode +// ), +// debit_account = buildPaytoUri( +// it.payment.counterpartName, +// it.payment.counterpartIban, +// it.payment.counterpartBic +// ) +// ) +// ) +// } +// } +// } +// call.respond(TextContent(customConverter(history), ContentType.Application.Json)) +// return@get +// } +// } +//} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/authentication.kt b/nexus/src/test/kotlin/authentication.kt @@ -1,17 +1,12 @@ package tech.libeufin.nexus -import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.transactions.transaction import org.junit.Test import junit.framework.TestCase.assertEquals -import tech.libeufin.util.CryptoUtil -import javax.sql.rowset.serial.SerialBlob class AuthenticationTest { @Test fun basicAuthHeaderTest() { - val pass = extractUserAndHashedPassword( + val pass = extractUserAndPassword( "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" ).second assertEquals("password", pass); diff --git a/nexus/src/test/kotlin/taler.kt b/nexus/src/test/kotlin/taler.kt @@ -10,26 +10,26 @@ import tech.libeufin.util.parseAmount class TalerTest { - @InternalAPI - val taler = Taler(Route(null, RootRouteSelector("unused"))) - - @InternalAPI - @Test - fun paytoParserTest() { - val payto = taler.parsePayto("payto://iban/ABC/XYZ?name=foo") - assert(payto.bic == "ABC" && payto.iban == "XYZ" && payto.name == "foo") - val paytoNoBic = taler.parsePayto("payto://iban/XYZ?name=foo") - assert(paytoNoBic.bic == "" && paytoNoBic.iban == "XYZ" && paytoNoBic.name == "foo") - } - - @InternalAPI - @Test - fun amountParserTest() { - val amount = parseAmount("EUR:1") - assert(amount.currency == "EUR" && amount.amount.equals(BigDecimal(1))) - val amount299 = parseAmount("EUR:2.99") - assert(amount299.amount.compareTo(Amount("2.99")) == 0) - val amount25 = parseAmount("EUR:2.5") - assert(amount25.amount.compareTo(Amount("2.5")) == 0) - } +// @InternalAPI +// val taler = Taler(Route(null, RootRouteSelector("unused"))) +// +// @InternalAPI +// @Test +// fun paytoParserTest() { +// val payto = taler.parsePayto("payto://iban/ABC/XYZ?name=foo") +// assert(payto.bic == "ABC" && payto.iban == "XYZ" && payto.name == "foo") +// val paytoNoBic = taler.parsePayto("payto://iban/XYZ?name=foo") +// assert(paytoNoBic.bic == "" && paytoNoBic.iban == "XYZ" && paytoNoBic.name == "foo") +// } +// +// @InternalAPI +// @Test +// fun amountParserTest() { +// val amount = parseAmount("EUR:1") +// assert(amount.currency == "EUR" && amount.amount.equals(BigDecimal(1))) +// val amount299 = parseAmount("EUR:2.99") +// assert(amount299.amount.compareTo(Amount("2.99")) == 0) +// val amount25 = parseAmount("EUR:2.5") +// assert(amount25.amount.compareTo(Amount("2.5")) == 0) +// } } \ No newline at end of file diff --git a/util/src/main/kotlin/Ebics.kt b/util/src/main/kotlin/Ebics.kt @@ -488,6 +488,14 @@ fun makeEbicsHEVRequest(subscriberDetails: EbicsClientSubscriberDetails): String return XMLUtil.convertDomToString(doc) } +fun makeEbicsHEVRequestRaw(hostID: String): String { + val req = HEVRequest().apply { + hostId = hostId + } + val doc = XMLUtil.convertJaxbToDocument(req) + return XMLUtil.convertDomToString(doc) +} + fun parseEbicsHEVResponse(respStr: String): EbicsHevDetails { val resp = try { XMLUtil.convertStringToJaxb<HEVResponse>(respStr)