[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] branch master updated: Bank refactoring.
From: |
gnunet |
Subject: |
[libeufin] branch master updated: Bank refactoring. |
Date: |
Mon, 11 Sep 2023 15:37:31 +0200 |
This is an automated email from the git hooks/post-receive script.
ms pushed a commit to branch master
in repository libeufin.
The following commit(s) were added to refs/heads/master by this push:
new 16fa54c9 Bank refactoring.
16fa54c9 is described below
commit 16fa54c96f3d9600748ea8b0530fd8ad4b146319
Author: MS <ms@taler.net>
AuthorDate: Mon Sep 11 15:08:46 2023 +0200
Bank refactoring.
Deleting entirely the previous Sandbox tree and
creating a new one with only one libeufin-bank.
---
.idea/$PRODUCT_WORKSPACE_FILE$ | 19 -
.idea/.gitignore | 3 -
.idea/codeStyles/Project.xml | 11 -
.idea/codeStyles/codeStyleConfig.xml | 5 -
.idea/dictionaries/dold.xml | 25 -
.idea/gradle.xml | 6 +-
.idea/inspectionProfiles/Project_Default.xml | 9 -
.idea/kotlinc.xml | 3 -
.idea/libraries-with-intellij-classes.xml | 65 -
.idea/misc.xml | 10 +-
.idea/runConfigurations/SchedulingTest.xml | 21 -
.idea/runConfigurations/run_sandbox.xml | 21 -
.idea/runConfigurations/test_nexus.xml | 21 -
.idea/runConfigurations/test_sandbox.xml | 23 -
.idea/uiDesigner.xml | 124 --
.idea/workspace.xml | 231 +++
bank/build.gradle | 3 +
.../main/kotlin/tech/libeufin/bank/CircuitApi.kt | 841 ---------
.../kotlin/tech/libeufin/bank/ConversionService.kt | 433 -----
bank/src/main/kotlin/tech/libeufin/bank/DB.kt | 747 --------
.../src/main/kotlin/tech/libeufin/bank/Database.kt | 266 ++-
.../tech/libeufin/bank/EbicsProtocolBackend.kt | 1436 ----------------
bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt | 472 -----
bank/src/main/kotlin/tech/libeufin/bank/JSON.kt | 154 --
bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 1801 ++------------------
.../kotlin/tech/libeufin/bank/XMLEbicsConverter.kt | 70 -
.../main/kotlin/tech/libeufin/bank/bankAccount.kt | 276 ---
bank/src/main/resources/logback.xml | 2 +-
bank/src/test/kotlin/BalanceTest.kt | 115 --
bank/src/test/kotlin/DBTest.kt | 152 --
bank/src/test/kotlin/DatabaseTest.kt | 90 +-
bank/src/test/kotlin/EbicsErrorTest.kt | 24 -
bank/src/test/kotlin/LibeuFinApiTest.kt | 26 +
bank/src/test/kotlin/StringsTest.kt | 37 -
database-versioning/new/libeufin-bank-0001.sql | 47 +-
database-versioning/new/procedures.sql | 38 +-
nexus/build.gradle | 5 +-
nexus/src/test/kotlin/ConversionServiceTest.kt | 395 -----
nexus/src/test/kotlin/DbEventTest.kt | 71 -
nexus/src/test/kotlin/EbicsTest.kt | 383 -----
nexus/src/test/kotlin/Iso20022Test.kt | 204 ---
nexus/src/test/kotlin/JsonTest.kt | 109 --
nexus/src/test/kotlin/LetterFormatTest.kt | 25 -
nexus/src/test/kotlin/MakeEnv.kt | 772 ---------
nexus/src/test/kotlin/NexusApiTest.kt | 272 ---
nexus/src/test/kotlin/PainTest.kt | 33 -
nexus/src/test/kotlin/PostFinance.kt | 158 --
nexus/src/test/kotlin/SandboxAccessApiTest.kt | 491 ------
nexus/src/test/kotlin/SandboxBankAccountTest.kt | 73 -
nexus/src/test/kotlin/SandboxCircuitApiTest.kt | 662 -------
nexus/src/test/kotlin/SandboxLegacyApiTest.kt | 192 ---
nexus/src/test/kotlin/SchedulingTest.kt | 179 --
nexus/src/test/kotlin/SplitString.kt | 14 -
nexus/src/test/kotlin/SubjectNormalization.kt | 36 -
nexus/src/test/kotlin/TalerTest.kt | 260 ---
nexus/src/test/kotlin/XLibeufinBankTest.kt | 159 --
nexus/src/test/kotlin/XPathTest.kt | 42 -
.../camt.053/de.camt.053.001.02.xml | 488 ------
nexus/src/test/resources/logback-test.xml | 28 -
util/build.gradle | 3 -
util/src/main/kotlin/Config.kt | 5 +-
util/src/main/kotlin/DB.kt | 6 +-
util/src/main/kotlin/HTTP.kt | 34 +-
util/src/main/kotlin/iban.kt | 4 +-
util/src/main/kotlin/startServer.kt | 2 +
util/src/main/kotlin/time.kt | 2 +
util/src/test/kotlin/StartServerTest.kt | 32 -
67 files changed, 750 insertions(+), 12016 deletions(-)
diff --git a/.idea/$PRODUCT_WORKSPACE_FILE$ b/.idea/$PRODUCT_WORKSPACE_FILE$
deleted file mode 100644
index a1409e4b..00000000
--- a/.idea/$PRODUCT_WORKSPACE_FILE$
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
- <component name="masterDetails">
- <states>
- <state key="ProjectJDKs.UI">
- <settings>
- <last-edited>12</last-edited>
- <splitter-proportions>
- <option name="proportions">
- <list>
- <option value="0.2" />
- </list>
- </option>
- </splitter-proportions>
- </settings>
- </state>
- </states>
- </component>
-</project>
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 0e40fe8f..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-
-# Default ignored files
-/workspace.xml
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 1eb52aaa..00000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-<component name="ProjectCodeStyleConfiguration">
- <code_scheme name="Project" version="173">
- <JetCodeStyleSettings>
- <option name="SPACE_AROUND_RANGE" value="true" />
- <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
- </JetCodeStyleSettings>
- <codeStyleSettings language="kotlin">
- <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
- </codeStyleSettings>
- </code_scheme>
-</component>
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml
b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 79ee123c..00000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<component name="ProjectCodeStyleConfiguration">
- <state>
- <option name="USE_PER_PROJECT_SETTINGS" value="true" />
- </state>
-</component>
\ No newline at end of file
diff --git a/.idea/dictionaries/dold.xml b/.idea/dictionaries/dold.xml
deleted file mode 100644
index d6ddbaa2..00000000
--- a/.idea/dictionaries/dold.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<component name="ProjectDictionaryState">
- <dictionary name="dold">
- <words>
- <w>affero</w>
- <w>camt</w>
- <w>combinators</w>
- <w>crdt</w>
- <w>cronspec</w>
- <w>dbit</w>
- <w>ebics</w>
- <w>gnunet</w>
- <w>iban</w>
- <w>infos</w>
- <w>keyletter</w>
- <w>libeufin</w>
- <w>payto</w>
- <w>pdng</w>
- <w>servicer</w>
- <w>sqlite</w>
- <w>taler</w>
- <w>talerwiregateway</w>
- <w>wtid</w>
- </words>
- </dictionary>
-</component>
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index f5d0571c..e1d33bca 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,16 +4,12 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
- <option name="delegatedBuild" value="true" />
- <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
- <option name="gradleJvm" value="16" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
- <option value="$PROJECT_DIR$/nexus" />
- <option value="$PROJECT_DIR$/sandbox" />
+ <option value="$PROJECT_DIR$/bank" />
<option value="$PROJECT_DIR$/util" />
</set>
</option>
diff --git a/.idea/inspectionProfiles/Project_Default.xml
b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index c29fcc6b..00000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-<component name="InspectionProjectProfileManager">
- <profile version="1.0">
- <option name="myName" value="Project Default" />
- <inspection_tool class="FoldInitializerAndIfToElvis" enabled="false"
level="INFO" enabled_by_default="false" />
- <inspection_tool class="JsonStandardCompliance" enabled="true"
level="ERROR" enabled_by_default="true">
- <option name="myWarnAboutComments" value="false" />
- </inspection_tool>
- </profile>
-</component>
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 059e602f..4251b727 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
- <component name="Kotlin2JvmCompilerArguments">
- <option name="jvmTarget" value="1.8" />
- </component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.22" />
</component>
diff --git a/.idea/libraries-with-intellij-classes.xml
b/.idea/libraries-with-intellij-classes.xml
deleted file mode 100644
index 9fa31567..00000000
--- a/.idea/libraries-with-intellij-classes.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
- <component name="libraries-with-intellij-classes">
- <option name="intellijApiContainingLibraries">
- <list>
- <LibraryCoordinatesState>
- <option name="artifactId" value="ideaIU" />
- <option name="groupId" value="com.jetbrains.intellij.idea" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="ideaIU" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="ideaIC" />
- <option name="groupId" value="com.jetbrains.intellij.idea" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="ideaIC" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="pycharmPY" />
- <option name="groupId" value="com.jetbrains.intellij.pycharm" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="pycharmPY" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="pycharmPC" />
- <option name="groupId" value="com.jetbrains.intellij.pycharm" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="pycharmPC" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="clion" />
- <option name="groupId" value="com.jetbrains.intellij.clion" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="clion" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="riderRD" />
- <option name="groupId" value="com.jetbrains.intellij.rider" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="riderRD" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="goland" />
- <option name="groupId" value="com.jetbrains.intellij.goland" />
- </LibraryCoordinatesState>
- <LibraryCoordinatesState>
- <option name="artifactId" value="goland" />
- <option name="groupId" value="com.jetbrains" />
- </LibraryCoordinatesState>
- </list>
- </option>
- </component>
-</project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index fe327906..223dc613 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,13 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
- <component name="EntryPointsManager">
- <list size="1">
- <item index="0" class="java.lang.String"
itemvalue="com.fasterxml.jackson.annotation.JsonValue" />
- </list>
- </component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
- <component name="FrameworkDetectionExcludesConfiguration">
- <file type="web" url="file://$PROJECT_DIR$" />
- </component>
- <component name="ProjectRootManager" version="2" languageLevel="JDK_11"
default="true" project-jdk-name="11" project-jdk-type="JavaSDK" />
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_11"
project-jdk-name="16" project-jdk-type="JavaSDK" />
</project>
\ No newline at end of file
diff --git a/.idea/runConfigurations/SchedulingTest.xml
b/.idea/runConfigurations/SchedulingTest.xml
deleted file mode 100644
index 6b78179c..00000000
--- a/.idea/runConfigurations/SchedulingTest.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<component name="ProjectRunConfigurationManager">
- <configuration default="false" name="SchedulingTest"
type="GradleRunConfiguration" factoryName="Gradle">
- <ExternalSystemSettings>
- <option name="executionName" />
- <option name="externalProjectPath" value="$PROJECT_DIR$" />
- <option name="externalSystemIdString" value="GRADLE" />
- <option name="scriptParameters" value=":nexus:test --tests --quiet
"SchedulingTest"" />
- <option name="taskDescriptions">
- <list />
- </option>
- <option name="taskNames">
- <list />
- </option>
- <option name="vmOptions" />
- </ExternalSystemSettings>
- <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
-
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
- <DebugAllEnabled>false</DebugAllEnabled>
- <method v="2" />
- </configuration>
-</component>
\ No newline at end of file
diff --git a/.idea/runConfigurations/run_sandbox.xml
b/.idea/runConfigurations/run_sandbox.xml
deleted file mode 100644
index ca2e3410..00000000
--- a/.idea/runConfigurations/run_sandbox.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<component name="ProjectRunConfigurationManager">
- <configuration default="false" name="run-sandbox"
type="GradleRunConfiguration" factoryName="Gradle">
- <ExternalSystemSettings>
- <option name="executionName" />
- <option name="externalProjectPath" value="$PROJECT_DIR$/sandbox" />
- <option name="externalSystemIdString" value="GRADLE" />
- <option name="scriptParameters" value="" />
- <option name="taskDescriptions">
- <list />
- </option>
- <option name="taskNames">
- <list>
- <option value="run" />
- </list>
- </option>
- <option name="vmOptions" value="" />
- </ExternalSystemSettings>
- <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
- <method v="2" />
- </configuration>
-</component>
\ No newline at end of file
diff --git a/.idea/runConfigurations/test_nexus.xml
b/.idea/runConfigurations/test_nexus.xml
deleted file mode 100644
index 5b3bcf60..00000000
--- a/.idea/runConfigurations/test_nexus.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<component name="ProjectRunConfigurationManager">
- <configuration default="false" name="test-nexus"
type="GradleRunConfiguration" factoryName="Gradle">
- <ExternalSystemSettings>
- <option name="executionName" />
- <option name="externalProjectPath" value="$PROJECT_DIR$/nexus" />
- <option name="externalSystemIdString" value="GRADLE" />
- <option name="scriptParameters" value="" />
- <option name="taskDescriptions">
- <list />
- </option>
- <option name="taskNames">
- <list>
- <option value="test" />
- </list>
- </option>
- <option name="vmOptions" value="" />
- </ExternalSystemSettings>
- <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled>
- <method v="2" />
- </configuration>
-</component>
\ No newline at end of file
diff --git a/.idea/runConfigurations/test_sandbox.xml
b/.idea/runConfigurations/test_sandbox.xml
deleted file mode 100644
index 2bd23e79..00000000
--- a/.idea/runConfigurations/test_sandbox.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-<component name="ProjectRunConfigurationManager">
- <configuration default="false" name="test-sandbox"
type="GradleRunConfiguration" factoryName="Gradle"
show_console_on_std_out="true">
- <ExternalSystemSettings>
- <option name="executionName" />
- <option name="externalProjectPath" value="$PROJECT_DIR$/sandbox" />
- <option name="externalSystemIdString" value="GRADLE" />
- <option name="scriptParameters" value="--info" />
- <option name="taskDescriptions">
- <list />
- </option>
- <option name="taskNames">
- <list>
- <option value="test" />
- </list>
- </option>
- <option name="vmOptions" value="" />
- </ExternalSystemSettings>
- <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
-
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
- <DebugAllEnabled>false</DebugAllEnabled>
- <method v="2" />
- </configuration>
-</component>
\ No newline at end of file
diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml
deleted file mode 100644
index e96534fb..00000000
--- a/.idea/uiDesigner.xml
+++ /dev/null
@@ -1,124 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
- <component name="Palette2">
- <group name="Swing">
- <item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal
Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="1" hsize-policy="6" anchor="0"
fill="1" />
- </item>
- <item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical
Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="1" anchor="0"
fill="2" />
- </item>
- <item class="javax.swing.JPanel"
icon="/com/intellij/uiDesigner/icons/panel.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="3" hsize-policy="3" anchor="0"
fill="3" />
- </item>
- <item class="javax.swing.JScrollPane"
icon="/com/intellij/uiDesigner/icons/scrollPane.png" removable="false"
auto-create-binding="false" can-attach-label="true">
- <default-constraints vsize-policy="7" hsize-policy="7" anchor="0"
fill="3" />
- </item>
- <item class="javax.swing.JButton"
icon="/com/intellij/uiDesigner/icons/button.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="3" anchor="0"
fill="1" />
- <initial-values>
- <property name="text" value="Button" />
- </initial-values>
- </item>
- <item class="javax.swing.JRadioButton"
icon="/com/intellij/uiDesigner/icons/radioButton.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="3" anchor="8"
fill="0" />
- <initial-values>
- <property name="text" value="RadioButton" />
- </initial-values>
- </item>
- <item class="javax.swing.JCheckBox"
icon="/com/intellij/uiDesigner/icons/checkBox.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="3" anchor="8"
fill="0" />
- <initial-values>
- <property name="text" value="CheckBox" />
- </initial-values>
- </item>
- <item class="javax.swing.JLabel"
icon="/com/intellij/uiDesigner/icons/label.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="0" anchor="8"
fill="0" />
- <initial-values>
- <property name="text" value="Label" />
- </initial-values>
- </item>
- <item class="javax.swing.JTextField"
icon="/com/intellij/uiDesigner/icons/textField.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="8"
fill="1">
- <preferred-size width="150" height="-1" />
- </default-constraints>
- </item>
- <item class="javax.swing.JPasswordField"
icon="/com/intellij/uiDesigner/icons/passwordField.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="8"
fill="1">
- <preferred-size width="150" height="-1" />
- </default-constraints>
- </item>
- <item class="javax.swing.JFormattedTextField"
icon="/com/intellij/uiDesigner/icons/formattedTextField.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="8"
fill="1">
- <preferred-size width="150" height="-1" />
- </default-constraints>
- </item>
- <item class="javax.swing.JTextArea"
icon="/com/intellij/uiDesigner/icons/textArea.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JTextPane"
icon="/com/intellij/uiDesigner/icons/textPane.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JEditorPane"
icon="/com/intellij/uiDesigner/icons/editorPane.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JComboBox"
icon="/com/intellij/uiDesigner/icons/comboBox.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="0" hsize-policy="2" anchor="8"
fill="1" />
- </item>
- <item class="javax.swing.JTable"
icon="/com/intellij/uiDesigner/icons/table.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JList"
icon="/com/intellij/uiDesigner/icons/list.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="2" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JTree"
icon="/com/intellij/uiDesigner/icons/tree.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3">
- <preferred-size width="150" height="50" />
- </default-constraints>
- </item>
- <item class="javax.swing.JTabbedPane"
icon="/com/intellij/uiDesigner/icons/tabbedPane.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="3" hsize-policy="3" anchor="0"
fill="3">
- <preferred-size width="200" height="200" />
- </default-constraints>
- </item>
- <item class="javax.swing.JSplitPane"
icon="/com/intellij/uiDesigner/icons/splitPane.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="3" hsize-policy="3" anchor="0"
fill="3">
- <preferred-size width="200" height="200" />
- </default-constraints>
- </item>
- <item class="javax.swing.JSpinner"
icon="/com/intellij/uiDesigner/icons/spinner.png" removable="false"
auto-create-binding="true" can-attach-label="true">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="8"
fill="1" />
- </item>
- <item class="javax.swing.JSlider"
icon="/com/intellij/uiDesigner/icons/slider.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="8"
fill="1" />
- </item>
- <item class="javax.swing.JSeparator"
icon="/com/intellij/uiDesigner/icons/separator.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="6" anchor="0"
fill="3" />
- </item>
- <item class="javax.swing.JProgressBar"
icon="/com/intellij/uiDesigner/icons/progressbar.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="0"
fill="1" />
- </item>
- <item class="javax.swing.JToolBar"
icon="/com/intellij/uiDesigner/icons/toolbar.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="6" anchor="0"
fill="1">
- <preferred-size width="-1" height="20" />
- </default-constraints>
- </item>
- <item class="javax.swing.JToolBar$Separator"
icon="/com/intellij/uiDesigner/icons/toolbarSeparator.png" removable="false"
auto-create-binding="false" can-attach-label="false">
- <default-constraints vsize-policy="0" hsize-policy="0" anchor="0"
fill="1" />
- </item>
- <item class="javax.swing.JScrollBar"
icon="/com/intellij/uiDesigner/icons/scrollbar.png" removable="false"
auto-create-binding="true" can-attach-label="false">
- <default-constraints vsize-policy="6" hsize-policy="0" anchor="0"
fill="2" />
- </item>
- </group>
- </component>
-</project>
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 00000000..12738e16
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,231 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="AutoImportSettings">
+ <option name="autoReloadType" value="SELECTIVE" />
+ </component>
+ <component name="ChangeListManager">
+ <list default="true" id="9436eb1e-de48-4f11-8ff7-f359340cb458"
name="Changes" comment="">
+ <change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
+ <change
afterPath="$PROJECT_DIR$/bank/src/test/kotlin/LibeuFinApiTest.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/$PRODUCT_WORKSPACE_FILE$"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/codeStyles/Project.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/codeStyles/codeStyleConfig.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/dictionaries/dold.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/gradle.xml" beforeDir="false"
afterPath="$PROJECT_DIR$/.idea/gradle.xml" afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/kotlinc.xml" beforeDir="false"
afterPath="$PROJECT_DIR$/.idea/kotlinc.xml" afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/libraries-with-intellij-classes.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false"
afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/runConfigurations/SchedulingTest.xml"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/runConfigurations/run_sandbox.xml"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/runConfigurations/test_nexus.xml"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/.idea/runConfigurations/test_sandbox.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/.idea/uiDesigner.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/README" beforeDir="false"
afterPath="$PROJECT_DIR$/bank/README" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/build.gradle" beforeDir="false"
afterPath="$PROJECT_DIR$/bank/build.gradle" afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/DB.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/DB.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/src/main/resources/logback.xml"
beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/resources/logback.xml"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/BalanceTest.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/BalanceTest.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/DBTest.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/DBTest.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/DatabaseTest.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/test/kotlin/DatabaseTest.kt" afterDir="false"
/>
+ <change
beforePath="$PROJECT_DIR$/bank/src/test/kotlin/EbicsErrorTest.kt"
beforeDir="false"
afterPath="$PROJECT_DIR$/bank/src/test/kotlin/EbicsErrorTest.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/StringsTest.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/StringsTest.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/database-versioning/new/libeufin-bank-0001.sql"
beforeDir="false"
afterPath="$PROJECT_DIR$/database-versioning/new/libeufin-bank-0001.sql"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/database-versioning/new/procedures.sql"
beforeDir="false"
afterPath="$PROJECT_DIR$/database-versioning/new/procedures.sql"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/build.gradle" beforeDir="false"
afterPath="$PROJECT_DIR$/nexus/build.gradle" afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/ConversionServiceTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/DbEventTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/EbicsTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/Iso20022Test.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/JsonTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/LetterFormatTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/MakeEnv.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/NexusApiTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/PainTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/PostFinance.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxAccessApiTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxBankAccountTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxCircuitApiTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxLegacyApiTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SchedulingTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SplitString.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SubjectNormalization.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/TalerTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/XLibeufinBankTest.kt"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/XPathTest.kt"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml"
beforeDir="false" />
+ <change
beforePath="$PROJECT_DIR$/nexus/src/test/resources/logback-test.xml"
beforeDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/build.gradle" beforeDir="false"
afterPath="$PROJECT_DIR$/util/build.gradle" afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/Config.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/Config.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/DB.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/DB.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/HTTP.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/HTTP.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/iban.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/iban.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/startServer.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/startServer.kt"
afterDir="false" />
+ <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/time.kt"
beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/time.kt"
afterDir="false" />
+ <change
beforePath="$PROJECT_DIR$/util/src/test/kotlin/StartServerTest.kt"
beforeDir="false" />
+ </list>
+ <option name="SHOW_DIALOG" value="false" />
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+ <option name="LAST_RESOLUTION" value="IGNORE" />
+ </component>
+ <component name="ExternalProjectsData">
+ <projectState path="$PROJECT_DIR$">
+ <ProjectState />
+ </projectState>
+ </component>
+ <component name="ExternalProjectsManager">
+ <system id="GRADLE">
+ <state>
+ <task path="$PROJECT_DIR$">
+ <activation />
+ </task>
+ <projects_view />
+ </state>
+ </system>
+ </component>
+ <component name="Git.Settings">
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
+ </component>
+ <component name="MarkdownSettingsMigration">
+ <option name="stateVersion" value="1" />
+ </component>
+ <component name="ProjectId" id="2V4jS1FHAIvLu5eYODKLzGxNSP4" />
+ <component name="ProjectViewState">
+ <option name="hideEmptyMiddlePackages" value="true" />
+ <option name="showLibraryContents" value="true" />
+ </component>
+ <component name="PropertiesComponent">{
+ "keyToString": {
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true"
+ }
+}</component>
+ <component name="RunManager"
selected="Gradle.LibeuFinApiTest.createAccountTest">
+ <configuration name="DatabaseTest" type="GradleRunConfiguration"
factoryName="Gradle" temporary="true">
+ <ExternalSystemSettings>
+ <option name="executionName" />
+ <option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="externalSystemIdString" value="GRADLE" />
+ <option name="scriptParameters" value="--quiet" />
+ <option name="taskDescriptions">
+ <list />
+ </option>
+ <option name="taskNames">
+ <list>
+ <option value=":bank:test" />
+ <option value="--tests" />
+ <option value=""DatabaseTest"" />
+ </list>
+ </option>
+ <option name="vmOptions" />
+ </ExternalSystemSettings>
+
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+ <DebugAllEnabled>false</DebugAllEnabled>
+ <method v="2" />
+ </configuration>
+ <configuration name="DatabaseTest.bearerTokenTest"
type="GradleRunConfiguration" factoryName="Gradle" temporary="true">
+ <ExternalSystemSettings>
+ <option name="executionName" />
+ <option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="externalSystemIdString" value="GRADLE" />
+ <option name="scriptParameters" value="--quiet" />
+ <option name="taskDescriptions">
+ <list />
+ </option>
+ <option name="taskNames">
+ <list>
+ <option value=":bank:test" />
+ <option value="--tests" />
+ <option value=""DatabaseTest.bearerTokenTest"" />
+ </list>
+ </option>
+ <option name="vmOptions" />
+ </ExternalSystemSettings>
+
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+ <DebugAllEnabled>false</DebugAllEnabled>
+ <method v="2" />
+ </configuration>
+ <configuration name="LibeuFinApiTest.createAccountTest"
type="GradleRunConfiguration" factoryName="Gradle" temporary="true">
+ <ExternalSystemSettings>
+ <option name="executionName" />
+ <option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="externalSystemIdString" value="GRADLE" />
+ <option name="scriptParameters" value="--quiet" />
+ <option name="taskDescriptions">
+ <list />
+ </option>
+ <option name="taskNames">
+ <list>
+ <option value=":bank:test" />
+ <option value="--tests" />
+ <option value=""LibeuFinApiTest.createAccountTest"" />
+ </list>
+ </option>
+ <option name="vmOptions" />
+ </ExternalSystemSettings>
+
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+ <DebugAllEnabled>false</DebugAllEnabled>
+ <method v="2" />
+ </configuration>
+ <configuration name="libeufin [dependencies]"
type="GradleRunConfiguration" factoryName="Gradle" temporary="true">
+ <ExternalSystemSettings>
+ <option name="executionName" />
+ <option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="externalSystemIdString" value="GRADLE" />
+ <option name="scriptParameters" />
+ <option name="taskDescriptions">
+ <list />
+ </option>
+ <option name="taskNames">
+ <list>
+ <option value="dependencies" />
+ </list>
+ </option>
+ <option name="vmOptions" />
+ </ExternalSystemSettings>
+ <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+ <DebugAllEnabled>false</DebugAllEnabled>
+ <method v="2" />
+ </configuration>
+ <list>
+ <item itemvalue="Gradle.libeufin [dependencies]" />
+ <item itemvalue="Gradle.DatabaseTest" />
+ <item itemvalue="Gradle.DatabaseTest.bearerTokenTest" />
+ <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" />
+ </list>
+ <recent_temporary>
+ <list>
+ <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" />
+ <item itemvalue="Gradle.libeufin [dependencies]" />
+ <item itemvalue="Gradle.DatabaseTest" />
+ <item itemvalue="Gradle.DatabaseTest.bearerTokenTest" />
+ </list>
+ </recent_temporary>
+ </component>
+ <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0"
CustomDictionaries="0" DefaultDictionary="application-level"
UseSingleDictionary="true" transferred="true" />
+ <component name="TaskManager">
+ <task active="true" id="Default" summary="Default task">
+ <changelist id="9436eb1e-de48-4f11-8ff7-f359340cb458" name="Changes"
comment="" />
+ <created>1694102242996</created>
+ <option name="number" value="Default" />
+ <option name="presentableId" value="Default" />
+ <updated>1694102242996</updated>
+ </task>
+ <servers />
+ </component>
+</project>
\ No newline at end of file
diff --git a/bank/build.gradle b/bank/build.gradle
index 71e44a5a..a7925da7 100644
--- a/bank/build.gradle
+++ b/bank/build.gradle
@@ -73,6 +73,9 @@ dependencies {
implementation "io.ktor:ktor-server-test-host:$ktor_version"
implementation "io.ktor:ktor-auth:$ktor_auth_version"
implementation "io.ktor:ktor-serialization-jackson:$ktor_version"
+ // implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
+ // implementation("io.ktor:ktor-serialization-gson:$ktor_version")
+ implementation "io.ktor:ktor-server-request-validation:$ktor_version"
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21'
testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21'
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
deleted file mode 100644
index 93fcdb6a..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
+++ /dev/null
@@ -1,841 +0,0 @@
-package tech.libeufin.bank
-
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.server.application.*
-import io.ktor.http.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.bank.CashoutOperationsTable.uuid
-import tech.libeufin.util.*
-import java.io.File
-import java.io.InputStreamReader
-import java.math.BigDecimal
-import java.util.concurrent.TimeUnit
-import kotlin.text.toByteArray
-
-// CIRCUIT API TYPES
-/**
- * This type is used by clients to ask the bank a cash-out
- * estimate to show to the customer before they confirm the
- * cash-out creation.
- */
-data class CircuitCashoutEstimateRequest(
- /**
- * This is the amount that the customer will get deducted
- * from their regio bank account to fuel the cash-out operation.
- */
- val amount_debit: String
-)
-data class CircuitCashoutRequest(
- val subject: String?,
- val amount_debit: String, // As specified by the user via the SPA.
- val amount_credit: String, // What actually to transfer after the rates.
- /**
- * The String type here allows more flexibility with regard to
- * the supported TAN methods. This way, supported TAN methods
- * can be specified via the configuration or when starting the
- * bank. OTOH, catching unsupported TAN methods only via the
- * 'enum' type would require to change the source code upon every
- * change in the TAN policy.
- */
- val tan_channel: String?
-)
-const val FIAT_CURRENCY = "CHF" // FIXME: make configurable.
-// Configuration response:
-data class ConfigResp(
- val name: String = "circuit",
- val version: String = PROTOCOL_VERSION_UNIFIED,
- val ratios_and_fees: RatioAndFees,
- val fiat_currency: String = FIAT_CURRENCY
-)
-
-// After fixing #7527, the values held by this
-// type must be read from the configuration.
-data class RatioAndFees(
- val buy_at_ratio: Float = 1F,
- val sell_at_ratio: Float = 0.95F,
- val buy_in_fee: Float = 0F,
- val sell_out_fee: Float = 0F
-)
-val ratiosAndFees = RatioAndFees()
-
-// User registration request
-data class CircuitAccountRequest(
- val username: String,
- val password: String,
- val contact_data: CircuitContactData,
- val name: String,
- val cashout_address: String, // payto
- val internal_iban: String? // Shall be "= null" ?
-)
-// User contact data to send the TAN.
-data class CircuitContactData(
- val email: String?,
- val phone: String?
-)
-
-data class CircuitAccountReconfiguration(
- val contact_data: CircuitContactData,
- val cashout_address: String?,
- val name: String? = null
-)
-
-data class AccountPasswordChange(
- val new_password: String
-)
-
-/**
- * That doesn't belong to the Access API because it
- * contains the cash-out address and the contact data.
- */
-data class CircuitAccountInfo(
- val username: String,
- val iban: String,
- val contact_data: CircuitContactData,
- val name: String,
- val cashout_address: String?
-)
-
-data class CashoutOperationInfo(
- val status: CashoutOperationStatus,
- val amount_credit: String,
- val amount_debit: String,
- val subject: String,
- val creation_time: Long, // milliseconds
- val confirmation_time: Long?, // milliseconds
- val tan_channel: SupportedTanChannels,
- val account: String,
- val cashout_address: String,
- val ratios_and_fees: RatioAndFees
-)
-
-data class CashoutConfirmation(val tan: String)
-
-// Validate phone number
-fun checkPhoneNumber(phoneNumber: String): Boolean {
- // From Taler TypeScript
- // /^\+[0-9 ]*$/;
- val regex = "^\\+[1-9][0-9]+$"
- val R = Regex(regex)
- return R.matches(phoneNumber)
-}
-
-// Validate e-mail address
-fun checkEmailAddress(emailAddress: String): Boolean {
- // From Taler TypeScript:
- //
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
- val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$"
- val R = Regex(regex)
- return R.matches(emailAddress)
-}
-
-fun throwIfInstitutionalName(resourceName: String) {
- if (resourceName == "bank" || resourceName == "admin")
- throw forbidden("Can't operate on institutional resource
'$resourceName'")
-}
-
-fun generateCashoutSubject(
- amountCredit: AmountWithCurrency,
- amountDebit: AmountWithCurrency
-): String {
- return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" +
- " to ${amountCredit.currency}:${amountCredit.amount}"
-}
-
-/**
- * By default, it takes the amount in the regional currency
- * and applies ratio and fees to convert it to fiat. If the
- * 'fromCredit' parameter is true, then it does the inverse
- * operation: returns the regional amount that would lead to
- * such fiat amount given in the 'amount' parameter.
- */
-fun applyCashoutRatioAndFee(
- amount: BigDecimal,
- ratiosAndFees: RatioAndFees,
- fromCredit: Boolean = false
-): BigDecimal {
- // Normal case, when the calculation starts from the regional amount.
- if (!fromCredit) {
- val maybeCashoutAmount = ((amount *
ratiosAndFees.sell_at_ratio.toBigDecimal()) -
- ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits()
- // throws 500, since bank should not allow to get negative fiat
amounts.
- if (maybeCashoutAmount < BigDecimal.ZERO) {
- logger.error("Cash-out operation caused a negative fiat output." +
- " Regional amount was '$amount', cash-out ratio is
'${ratiosAndFees.sell_at_ratio}," +
- " cash-out fee is '${ratiosAndFees.sell_out_fee}''"
- )
- throw internalServerError("Applying cash-out fees yielded negative
fiat amount.")
- }
- return maybeCashoutAmount
- }
- // UI convenient case, when the calculation starts from the
- // desired fiat amount that the user wants eventually be paid.
- return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) /
- ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits()
-}
-
-/**
- * NOTE: future versions take the supported TAN method from
- * the configuration, or options passed when starting the bank.
- */
-const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt"
-enum class SupportedTanChannels {
- SMS,
- EMAIL,
- FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location.
-}
-fun isTanChannelSupported(tanChannel: String): Boolean {
- enumValues<SupportedTanChannels>().forEach {
- if (tanChannel.uppercase() == it.name) return true
- }
- return false
-}
-
-var EMAIL_TAN_CMD: String? = null
-var SMS_TAN_CMD: String? = null
-
-// Convenience class to collect TAN data.
-private data class TanData(
- val cmd: String,
- val address: String,
- val msg: String
-)
-
-/**
- * Runs the command and returns True/False if that succeeded/failed.
- * A failed command causes "500 Internal Server Error" to be responded
- * along a cash-out creation. 'address' is a phone number or a e-mail address,
- * according to which TAN channel is used. 'message' carries the TAN.
- *
- * The caller is expected to manage the exceptions thrown by this function.
- */
-fun runTanCommand(command: String, address: String, message: String): Boolean {
- val prep = ProcessBuilder(command, address)
- prep.redirectErrorStream(true) // merge STDOUT and STDERR
- val proc = prep.start()
- proc.outputStream.write(message.toByteArray())
- proc.outputStream.flush(); proc.outputStream.close()
- var isSuccessful = false
- // Wait the command to finish.
- proc.waitFor(10L, TimeUnit.SECONDS)
- // Check if timed out. Kill if so.
- if (proc.isAlive) {
- logger.error("TAN command '$command' timed out, killing it.")
- proc.destroy()
- // Check if exited gracefully. Kill forcibly if not.
- proc.waitFor(5L, TimeUnit.SECONDS)
- if (proc.isAlive) {
- logger.error("TAN command '$command' didn't terminate after
killing it. Try forcefully.")
- proc.destroyForcibly()
- }
- }
- // Check if successful. Switch the state if so.
- if (proc.exitValue() == 0) isSuccessful = true
- // Log STDOUT and STDERR if failed.
- if (!isSuccessful)
- logger.error(InputStreamReader(proc.inputStream).readText())
- return isSuccessful
-}
-
-fun circuitApi(circuitRoute: Route) {
- // Abort a cash-out operation.
- circuitRoute.post("/cashouts/{uuid}/abort") {
- call.request.basicAuth() // both admin and author allowed
- val arg = call.expectUriComponent("uuid")
- // Parse and check the UUID.
- val maybeUuid = parseUuid(arg)
- val maybeOperation = transaction {
- CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull()
- }
- if (maybeOperation == null)
- throw notFound("Cash-out operation $uuid not found.")
- if (maybeOperation.status == CashoutOperationStatus.CONFIRMED)
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out operation '$uuid' was confirmed already."
- )
- if (maybeOperation.status != CashoutOperationStatus.PENDING)
- throw internalServerError("Found an unsupported cash-out operation
state: ${maybeOperation.status}")
- // Operation found and pending: delete from the database.
- transaction { maybeOperation.delete() }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Confirm a cash-out operation
- circuitRoute.post("/cashouts/{uuid}/confirm") {
- val user = call.request.basicAuth()
- // Exclude admin from this operation.
- if (user == "admin" || user == "bank")
- throw conflict("Institutional user '$user' shouldn't confirm any
cash-out.")
- // Get the operation identifier.
- val operationUuid = parseUuid(call.expectUriComponent("uuid"))
- val op = transaction {
- CashoutOperationEntity.find {
- uuid eq operationUuid
- }.firstOrNull()
- }
- // 404 if the operation is not found.
- if (op == null)
- throw notFound("Cash-out operation $operationUuid not found")
- /**
- * Check the TAN. Give precedence to the TAN found
- * in the environment, for testing purposes. If that's
- * not found, then check with the actual TAN found in
- * the database.
- */
- val req = call.receive<CashoutConfirmation>()
- val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN")
- if (maybeTanFromEnv != null)
- logger.warn("TAN being read from the environment. Assuming tests
are being run")
- val checkTan = maybeTanFromEnv ?: op.tan
- if (req.tan != checkTan)
- throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN
'${req.tan}'")
- /**
- * Correct TAN. Wire the funds to the admin's bank account. After
- * this step, the conversion monitor should detect this payment and
- * soon initiate the final transfer towards the user fiat bank account.
- * NOTE: the funds availability got already checked when this operation
- * was created. On top of that, the 'wireTransfer()' helper does also
- * check for funds availability. */
- val customer = maybeGetCustomer(user ?: throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "This endpoint isn't served when the authentication is disabled."
- ))
- transaction {
- if (op.cashoutAddress != customer?.cashout_address) throw conflict(
- "Inconsistent cash-out address: ${op.cashoutAddress} vs
${customer?.cashout_address}"
- )
- // 412 if the operation got already confirmed.
- if (op.status == CashoutOperationStatus.CONFIRMED)
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out operation $operationUuid was already confirmed."
- )
- wireTransfer(
- debitAccount = op.account,
- creditAccount = "admin",
- subject = op.subject,
- amount = op.amountDebit
- )
- op.status = CashoutOperationStatus.CONFIRMED
- op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli()
- // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING)
- }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Retrieve the status of a cash-out operation.
- circuitRoute.get("/cashouts/{uuid}") {
- call.request.basicAuth() // both admin and author
- val operationUuid = call.expectUriComponent("uuid")
- // Parse and check the UUID.
- val maybeUuid = parseUuid(operationUuid)
- // Get the operation from the database.
- val maybeOperation = transaction {
- CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull()
- }
- if (maybeOperation == null)
- throw notFound("Cash-out operation $operationUuid not found.")
- val ret = CashoutOperationInfo(
- amount_credit = maybeOperation.amountCredit,
- amount_debit = maybeOperation.amountDebit,
- subject = maybeOperation.subject,
- status = maybeOperation.status,
- creation_time = maybeOperation.creationTime,
- confirmation_time = maybeOperation.confirmationTime,
- tan_channel = maybeOperation.tanChannel,
- account = maybeOperation.account,
- cashout_address = maybeOperation.cashoutAddress,
- ratios_and_fees = RatioAndFees(
- buy_in_fee = maybeOperation.buyInFee.toFloat(),
- buy_at_ratio = maybeOperation.buyAtRatio.toFloat(),
- sell_out_fee = maybeOperation.sellOutFee.toFloat(),
- sell_at_ratio = maybeOperation.sellAtRatio.toFloat()
- )
- )
- call.respond(ret)
- return@get
- }
- // Gets the list of all the cash-out operations,
- // or those belonging to the account given as a parameter.
- circuitRoute.get("/cashouts") {
- val user = call.request.basicAuth()
- val whichAccount = call.request.queryParameters["account"]
- /**
- * Only admin's allowed to omit the target account (= get
- * all the accounts) or to check other customers cash-out
- * operations.
- */
- if (user != "admin" && whichAccount != user) throw forbidden(
- "Ordinary users can only request their own account"
- )
- /**
- * At this point, the client has the rights over the account(s)
- * whose operations are to be returned. Double-checking that
- * Admin doesn't ask its own cash-outs, since that's not supported.
- */
- if (whichAccount == "admin") throw badRequest("Cash-out for admin is
not supported")
-
- // Preparing the response.
- val node = jacksonObjectMapper().createObjectNode()
- val maybeArray = node.putArray("cashouts")
-
- if (whichAccount == null) { // no target account, return all the
cash-outs
- transaction {
- CashoutOperationEntity.all().forEach {
- maybeArray.add(it.uuid.toString())
- }
- }
- } else { // do filter on the target account.
- transaction {
- CashoutOperationEntity.find {
- CashoutOperationsTable.account eq whichAccount
- }.forEach {
- maybeArray.add(it.uuid.toString())
- }
- }
- }
- if (maybeArray.size() == 0) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(node)
- return@get
- }
- circuitRoute.get("/cashouts/estimates") {
- call.request.basicAuth()
- val demobank = ensureDemobank(call)
- // Optionally parsing param 'amount_debit' into number and checking
its currency
- val maybeAmountDebit: String? =
call.request.queryParameters["amount_debit"]
- val amountDebit: BigDecimal? = if (maybeAmountDebit != null) {
- val amount = parseAmount(maybeAmountDebit)
- if (amount.currency != demobank.config.currency) throw badRequest(
- "parameter 'amount_debit' has the wrong currency:
${amount.currency}"
- )
- try { amount.amount.toBigDecimal() } catch (e: Exception) {
- throw badRequest("Cannot extract a number from 'amount_debit'")
- }
- } else null
- // Optionally parsing param 'amount_credit' into number and checking
its currency
- val maybeAmountCredit: String? =
call.request.queryParameters["amount_credit"]
- val amountCredit: BigDecimal? = if (maybeAmountCredit != null) {
- val amount = parseAmount(maybeAmountCredit)
- if (amount.currency != FIAT_CURRENCY) throw badRequest(
- "parameter 'amount_credit' has the wrong currency:
${amount.currency}"
- )
- try { amount.amount.toBigDecimal() } catch (e: Exception) {
- throw badRequest("Cannot extract a number from
'amount_credit'")
- }
- } else null
- val respAmountCredit = if (amountDebit != null) {
- val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees)
- if (amountCredit != null && estimate != amountCredit) throw
badRequest(
- "Wrong calculation found in 'amount_credit', bank estimates:
$estimate"
- )
- estimate
- } else null
- if (amountDebit == null && amountCredit == null) throw badRequest(
- "Both 'amount_credit' and 'amount_debit' are missing"
- )
- val respAmountDebit = if (amountCredit != null) {
- val estimate = applyCashoutRatioAndFee(
- amountCredit,
- ratiosAndFees,
- fromCredit = true
- )
- if (amountDebit != null && estimate != amountDebit) throw
badRequest(
- "Wrong calculation found in 'amount_credit', bank estimates:
$estimate"
- )
- estimate
- } else null
- call.respond(object {
- val amount_credit = "$FIAT_CURRENCY:$respAmountCredit"
- val amount_debit = "${demobank.config.currency}:$respAmountDebit"
- })
- return@get
- }
-
- // Create a cash-out operation.
- circuitRoute.post("/cashouts") {
- val user = call.request.basicAuth()
- if (user == "admin" || user == "bank") throw forbidden("$user can't
cash-out.")
- // No suitable default user, when the authentication is disabled.
- if (user == null) throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "This endpoint isn't served when the authentication is disabled."
- )
- val req = call.receive<CircuitCashoutRequest>()
-
- // validate amounts: well-formed and supported currency.
- val amountDebit = parseAmount(req.amount_debit) // amount before rates.
- val amountCredit = parseAmount(req.amount_credit) // amount after
rates, as expected by the client
- val demobank = ensureDemobank(call)
- // Currency check of the cash-out's circuit part.
- if (amountDebit.currency != demobank.config.currency)
- throw badRequest("'${req::amount_debit.name}'
(${req.amount_debit})" +
- " doesn't match the regional currency
(${demobank.config.currency})"
- )
- // Currency check of the cash-out's fiat part.
- if (amountCredit.currency != FIAT_CURRENCY)
- throw badRequest("'${req::amount_credit.name}'
(${req.amount_credit})" +
- " doesn't match the fiat currency ($FIAT_CURRENCY)."
- )
- // check if TAN is supported. Default to SMS, if that's missing.
- val tanChannel = req.tan_channel?.uppercase() ?:
SupportedTanChannels.SMS.name
- if (!isTanChannelSupported(tanChannel))
- throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "TAN channel '$tanChannel' not supported."
- )
- // check if the user contact data would allow the TAN channel.
- val customer: DemobankCustomerEntity? = maybeGetCustomer(username =
user)
- if (customer == null) throw internalServerError(
- "Customer profile '$user' not found after authenticating it."
- )
- if (customer.cashout_address == null) throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out address not found. Did the user register via Circuit
API?"
- )
- if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email
== null))
- throw conflict("E-mail address not found for '$user'. Can't send
the TAN")
- if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone
== null))
- throw conflict("Phone number not found for '$user'. Can't send
the TAN")
- // check rates correctness
- val amountDebitAsNumber = BigDecimal(amountDebit.amount)
- val expectedAmountCredit =
applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees)
- val amountCreditAsNumber =
BigDecimal(amountCredit.amount).roundToTwoDigits()
- if (expectedAmountCredit != amountCreditAsNumber) {
- throw badRequest("Rates application are incorrect." +
- " The expected amount to credit is:
${expectedAmountCredit}," +
- " but ${amountCredit.amount} was specified.")
- }
- // check that the balance is sufficient
- val balance = getBalance(
- user,
- demobank.name
- )
- val balanceCheck = balance - amountDebitAsNumber
- if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() >
BigDecimal(demobank.config.usersDebtLimit))
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out not possible due to insufficient funds. Balance
${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}"
- )
- // generate a subject if that's missing
- val cashoutSubject = req.subject ?: generateCashoutSubject(
- amountCredit = amountCredit,
- amountDebit = amountDebit
- )
- val op = transaction {
- CashoutOperationEntity.new {
- this.amountDebit = req.amount_debit
- this.amountCredit = req.amount_credit
- this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString()
- this.buyInFee = ratiosAndFees.buy_in_fee.toString()
- this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString()
- this.sellOutFee = ratiosAndFees.sell_out_fee.toString()
- this.subject = cashoutSubject
- this.creationTime =
getSystemTimeNow().toInstant().toEpochMilli()
- this.tanChannel = SupportedTanChannels.valueOf(tanChannel)
- this.account = user
- this.tan = getRandomString(5)
- this.cashoutAddress = customer.cashout_address ?: throw
internalServerError(
- "Cash-out address for '$user' not found, after previous
check succeeded"
- )
- }
- }
- when (tanChannel) {
- SupportedTanChannels.EMAIL.name -> {
- val isSuccessful = try {
- runTanCommand(
- command = EMAIL_TAN_CMD ?: throw internalServerError(
- "E-mail TAN supported but the command" +
- " was not found. See the --email-tan
option from 'serve'"
- ),
- address = customer.email ?: throw internalServerError(
- "Customer has no e-mail address, but previous
check should" +
- " have detected it!"
- ),
- message = op.tan
- )
- } catch (e: Exception) {
- logger.error("Sending the e-mail TAN to ${customer.email}
was impossible." +
- " Reason: ${e.message}")
- throw internalServerError("Could not send the e-mail TAN.")
- }
- if (!isSuccessful)
- throw internalServerError("E-mail TAN command failed.")
- }
- SupportedTanChannels.SMS.name -> {
- val isSuccessful = try {
- runTanCommand(
- command = SMS_TAN_CMD ?: throw internalServerError(
- "SMS TAN supported but the command" +
- " was not found. See the --sms-tan option
from 'serve'"
- ),
- address = customer.phone ?: throw internalServerError(
- "Customer has no phone number, but previous check
should" +
- " have detected it!"
-
- ),
- message = op.tan
- )
-
- } catch (e: Exception) {
- logger.error("Sending the SMS TAN to ${customer.phone} was
impossible." +
- " Reason: ${e.message}")
- throw internalServerError("Could not send the SMS TAN.")
- }
- if (!isSuccessful)
- throw internalServerError("SMS TAN command failed.")
- }
- SupportedTanChannels.FILE.name -> {
- try {
- File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan)
- } catch (e: Exception) {
- logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE.
Reason: ${e.message}")
- throw internalServerError("File TAN failed.")
- }
- }
- else ->
- throw internalServerError("The bank tried an unsupported TAN
channel: $tanChannel.")
- }
- call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid})
- return@post
- }
- // Get Circuit-relevant account data.
- circuitRoute.get("/accounts/{resourceName}") {
- val username = call.request.basicAuth()
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden(
- "User $username has no rights over $resourceName"
- )
- val customer = getCustomer(resourceName)
- /**
- * CUSTOMER AND BANK ACCOUNT INVARIANT.
- *
- * After having found a 'customer' associated with the resourceName
- * - see previous line -, the bank must ensure that a 'bank account'
- * exist under the same resourceName. If that fails, the bank broke
the
- * invariant and should respond 500.
- */
- val bankAccount = getBankAccountFromLabel(resourceName, withBankFault
= true)
- /**
- * Throwing when name or cash-out address aren't found ensures
- * that the customer was indeed added via the Circuit API, as opposed
- * to the Access API.
- */
- call.respond(CircuitAccountInfo(
- username = customer.username,
- name = customer.name ?: throw internalServerError(
- "Account '$resourceName' was found without owner's name."
- ),
- cashout_address = customer.cashout_address,
- contact_data = CircuitContactData(
- email = customer.email,
- phone = customer.phone
- ),
- iban = bankAccount.iban
- ))
- return@get
- }
-
- // Get summary of all the accounts.
- circuitRoute.get("/accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val maybeFilter: String? = call.request.queryParameters["filter"]
- /**
- * Equip the given filter with left and right catch-all wildcards,
- * otherwise use one catch-all wildcard.
- */
- val filter = if (maybeFilter != null) {
- "%${maybeFilter}%"
- } else "%"
- val customers = mutableListOf<Any>()
- val demobank = ensureDemobank(call)
- transaction {
- /**
- * This block builds the DB query so that IF the %-wildcard was
- * given, then BOTH name and name-less accounts are returned.
- */
- val query: Op<Boolean> = SqlExpressionBuilder.run {
- val like = DemobankCustomersTable.name.like(filter)
- /**
- * This IF statement is needed because Postgres would NOT
- * match a null column even with the %-wildcard.
- */
- if (filter == "%") {
- return@run like.or(DemobankCustomersTable.name.isNull())
- }
- return@run like
- }
- DemobankCustomerEntity.find { query }.forEach {
- customers.add(object {
- val username = it.username
- val name = it.name
- val balance = getBalanceForJson(
- getBalance(it.username, demobank.name),
- demobank.config.currency
- )
- val debitThreshold = getMaxDebitForUser(
- it.username,
- demobank.name
- )
- })
- }
- StdOutSqlLogger
- }
- if (customers.size == 0) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(object {val customers = customers})
- return@get
- }
-
- // Change password.
- circuitRoute.patch("/accounts/{customerUsername}/auth") {
- val username = call.request.basicAuth()
- val customerUsername = call.expectUriComponent("customerUsername")
- throwIfInstitutionalName(customerUsername)
- if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden(
- "User $username has no rights over $customerUsername"
- )
- // Flow here means admin or username have the rights for this
operation.
- val req = call.receive<AccountPasswordChange>()
- /**
- * The resource/customer might still not exist, in case admin has
requested.
- * On the other hand, when ordinary customers request, their existence
is checked
- * along the basic authentication check.
- */
- transaction {
- val customer = getCustomer(customerUsername) // throws 404, if not
found.
- customer.passwordHash = CryptoUtil.hashpw(req.new_password)
- }
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- // Change account (mostly contact) data.
- circuitRoute.patch("/accounts/{resourceName}") {
- val username = call.request.basicAuth()
- if (username == null)
- throw internalServerError("Authentication disabled, don't have a
default for this request.")
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden(
- "User $username has no rights over $resourceName"
- )
- // account found and authentication succeeded
- val req = call.receive<CircuitAccountReconfiguration>()
- // Only admin's allowed to change the legal name
- if (req.name != null && username != "admin") throw forbidden(
- "Only admin can change the user legal name"
- )
- if ((req.contact_data.email != null) &&
(!checkEmailAddress(req.contact_data.email)))
- throw badRequest("Invalid e-mail address:
${req.contact_data.email}")
- if ((req.contact_data.phone != null) &&
(!checkPhoneNumber(req.contact_data.phone)))
- throw badRequest("Invalid phone number: ${req.contact_data.phone}")
- try { if (req.cashout_address != null) parsePayto(req.cashout_address)
}
- catch (e: InvalidPaytoError) {
- throw badRequest("Invalid cash-out address:
${req.cashout_address}")
- }
- transaction {
- val user = getCustomer(resourceName)
- user.email = req.contact_data.email
- user.phone = req.contact_data.phone
- user.cashout_address = req.cashout_address
- }
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- // Create new account.
- circuitRoute.post("/accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val req = call.receive<CircuitAccountRequest>()
- // Validity and availability check on the input data.
- if (req.contact_data.email != null) {
- if (!checkEmailAddress(req.contact_data.email))
- throw badRequest("Invalid e-mail address:
${req.contact_data.email}. Won't register")
- val maybeEmailConflict = transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.email eq req.contact_data.email
- }.firstOrNull()
- }
- // Warning since two individuals claimed one same e-mail address.
- if (maybeEmailConflict != null)
- throw conflict("Won't register user ${req.username}: e-mail
conflict on ${req.contact_data.email}")
- }
- if (req.contact_data.phone != null) {
- if (!checkPhoneNumber(req.contact_data.phone))
- throw badRequest("Invalid phone number:
${req.contact_data.phone}. Won't register")
-
- val maybePhoneConflict = transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.phone eq req.contact_data.phone
- }.firstOrNull()
- }
- // Warning since two individuals claimed one same phone number.
- if (maybePhoneConflict != null)
- throw conflict("Won't register user ${req.username}: phone
conflict on ${req.contact_data.phone}")
- }
- /**
- * Check that cash-out address parses. IBAN is not
- * check-summed in this version; the cash-out operation
- * just fails for invalid IBANs and the user has then
- * the chance to update their IBAN.
- */
- try {
- parsePayto(req.cashout_address)
- }
- catch (e: InvalidPaytoError) {
- throw badRequest("Won't register account ${req.username}: invalid
cash-out address: ${req.cashout_address}")
- }
- transaction {
- val newAccount = insertNewAccount(
- username = req.username,
- password = req.password,
- name = req.name,
- iban = req.internal_iban,
- demobank = ensureDemobank(call).name
- )
- newAccount.customer.phone = req.contact_data.phone
- newAccount.customer.email = req.contact_data.email
- newAccount.customer.cashout_address = req.cashout_address
- }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Get (conversion rates via) config values.
- circuitRoute.get("/config") {
- call.respond(ConfigResp(ratios_and_fees = ratiosAndFees))
- return@get
- }
- // Only Admin and only when balance is zero.
- circuitRoute.delete("/accounts/{resourceName}") {
- call.request.basicAuth(onlyAdmin = true)
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- val customer = getCustomer(resourceName)
- val bankAccount = getBankAccountFromLabel(
- resourceName,
- withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT
INVARIANT".
- )
- val balance: BigDecimal = getBalance(bankAccount)
- if (!isAmountZero(balance)) {
- logger.error("Account $resourceName has $balance balance. Won't
delete it")
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Account $resourceName doesn't have zero balance. Won't
delete it"
- )
- }
- transaction {
- bankAccount.delete()
- customer.delete()
- }
- call.respond(HttpStatusCode.NoContent)
- return@delete
- }
-}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt
b/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt
deleted file mode 100644
index f9f9dc68..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt
+++ /dev/null
@@ -1,433 +0,0 @@
-package tech.libeufin.bank
-
-import CamtBankAccountEntry
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.math.BigDecimal
-import kotlin.system.exitProcess
-
-/**
- * This file contains the logic for downloading/submitting incoming/outgoing
- * fiat transactions to Nexus. It needs the following values for operating.
- *
- * 1. Nexus URL.
- * 2. Credentials to authenticate at Nexus JSON API.
- * 3. Long-polling interval.
- * 4. Frequency of the download loop.
- *
- * Notes:
- *
- * 1. The account to credit on incoming transactions is ALWAYS "admin".
- * 2. The time to submit a new payment is as soon as "admin" receives one
- * incoming regional payment.
- * 3. At this time, Nexus does NOT offer long polling when it serves the
- * transactions via its JSON API. => Fixed.
- * 4. At this time, Nexus does NOT offer any filter when it serves the
- * transactions via its JSON API. => Can be fixed by using the TWG.
- */
-
-// DEFINITIONS AND HELPERS
-
-/**
- * Timeout the HTTP client waits for the server to respond,
- * after the request is made.
- */
-val waitTimeout = 30000L
-
-/**
- * Time to wait before HTTP requesting again to the server.
- * This helps to avoid tight cycles in case the server responds
- * quickly or the client doesn't long-poll.
- */
-val newIterationTimeout = 2000L
-
-/**
- * Response format of Nexus GET /transactions.
- */
-data class TransactionItem(
- val index: String,
- val camtData: CamtBankAccountEntry
-)
-data class NexusTransactions(
- val transactions: List<TransactionItem>
-)
-
-/**
- * This exception signals that the buy-in service could NOT
- * GET the list of fiat transactions from Nexus due to a client
- * error. Because this is fatal (e.g. wrong credentials, URL not found..),
- * the service should be stopped.
- */
-class BuyinClientError : Exception()
-
-/**
- * This exception signals that POSTing a cash-out operation
- * to Nexus failed due to the client. This is a fatal condition
- * therefore the monitor should be stopped.
- */
-class CashoutClientError : Exception()
-/**
- * Executes the 'block' function every 'loopNewReqMs' milliseconds.
- * Does not exit/fail the process upon exceptions - just logs them.
- */
-fun downloadLoop(block: () -> Unit) {
- // Needs "runBlocking {}" to call "delay()" and in case 'block'
- // contains suspend functions.
- runBlocking {
- while(true) {
- try { block() }
- catch (e: BuyinClientError) {
- logger.error("The buy-in monitor had a client error while
GETting new" +
- " transactions from Neuxs. Stopping it")
- // Rethrowing and let the caller manage it
- throw e
- }
- // Tolerating any other error type that's not due to the client.
- catch (e: Exception) {
- logger.error("Sandbox fiat-incoming monitor excepted:
${e.message}")
- }
- delay(newIterationTimeout)
- }
- }
-}
-
-// BUY-IN SIDE.
-
-/**
- * Applies the buy-in ratio and fees to the fiat amount
- * that came from Nexus. The result is the regional amount
- * that will be wired to the exchange Sandbox account.
- */
-fun applyBuyinRatioAndFees(
- amount: BigDecimal,
- ratiosAndFees: RatioAndFees
-): BigDecimal {
- val maybeBuyinAmount = ((amount *
ratiosAndFees.buy_at_ratio.toBigDecimal())
- - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits()
- // Bank's fault, as buying in should never lead to negative.
- if (maybeBuyinAmount < BigDecimal.ZERO) {
- logger.error("Negative buy-in scenario: input fiat amount was
'${amount}'" +
- ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," +
- " buy-in fee was '${ratiosAndFees.buy_in_fee}'")
- throw internalServerError("Applying buy-in fees yielded negative
regional amount")
- }
- return maybeBuyinAmount
-}
-
-private fun ensureDisabledRedirects(client: HttpClient) {
- client.config {
- if (followRedirects) throw Exception(
- "HTTP client follows redirects, please disable."
- )
- }
-}
-/**
- * This function downloads the incoming fiat transactions from Nexus,
- * stores them into the database and triggers the related wire transfer
- * to the Taler exchange (to be specified in 'accountToCredit'). Once
- * started, this function is not supposed to return, except on _client
- * side_ errors. On server side errors it pauses and retries. When
- * it returns, the caller is expected to handle the error.
- */
-fun buyinMonitor(
- demobankName: String, // used to get config values.
- client: HttpClient,
- accountToCredit: String,
- accountToDebit: String = "admin"
-) {
- ensureDisabledRedirects(client)
- val demobank = ensureDemobank(demobankName)
- /**
- * Getting the config values to send authenticated requests
- * to Nexus. Sandbox needs one account at Nexus before being
- * able to use these values.
- */
- val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl)
- val usernameAtNexus =
getConfigValueOrThrow(demobank.config::usernameAtNexus)
- val passwordAtNexus =
getConfigValueOrThrow(demobank.config::passwordAtNexus)
- /**
- * This is the endpoint where Nexus serves all the transactions that
- * have ingested from the fiat bank.
- */
- val endpoint = "bank-accounts/$usernameAtNexus/transactions"
- val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) +
"?long_poll_ms=$waitTimeout"
-
- // downloadLoop does already try-catch (without failing the process).
- downloadLoop {
- /**
- * This bank account will act as the debtor, once a new fiat
- * payment is detected. It's the debtor that pays the related
- * regional amount to the exchange, in order to start a withdrawal
- * operation (in regional coins).
- */
- val debitBankAccount = getBankAccountFromLabel(accountToDebit)
- /**
- * Setting the 'start' URI param in the following command
- * lets Sandbox receive only unseen payments from Nexus.
- */
- val uriWithStart =
"$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}"
- runBlocking {
- // Maybe get new fiat transactions.
- logger.debug("GETting fiat transactions from: $uriWithStart")
- val resp = client.get(uriWithStart) {
- expectSuccess = false // Avoids excepting on !2xx
- basicAuth(usernameAtNexus, passwordAtNexus)
- }
- // The server failed, pause and try again
- if (resp.status.value.toString().startsWith('5')) {
- logger.error("Buy-in monitor requested to a failing Nexus.
Retry.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- return@runBlocking
- }
- // The client failed, fail the process.
- if (resp.status.value.toString().startsWith('4')) {
- logger.error("Buy-in monitor failed at GETting to Nexus.
Stopping the buy-in monitor.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- throw BuyinClientError()
- }
- // Expect 200 OK. What if 3xx?
- if (resp.status.value != HttpStatusCode.OK.value) {
- logger.error("Unhandled response status ${resp.status.value},
failing Sandbox")
- throw BuyinClientError()
- }
- // Nexus responded 200 OK, analyzing the result.
- /**
- * Wire to "admin" if the subject is a public key, or do
- * nothing otherwise.
- */
- val respObj = jacksonObjectMapper().readValue(
- resp.bodyAsText(),
- NexusTransactions::class.java
- ) // errors are logged by the caller (without failing).
- respObj.transactions.forEach {
- // Ignoring payments with an invalid reserved public key.
- if
(extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null)
- return@forEach
- // Extracts the amount and checks it's at most two fractional
digits.
- val maybeValidAmount = it.camtData.amount.value
- if (!validatePlainAmount(maybeValidAmount)) {
- logger.error("Nexus gave one amount with invalid
fractional digits: $maybeValidAmount." +
- " The transaction has index ${it.index}")
- // Advancing the last fetched pointer, to avoid GETting
- // this invalid payment again.
- transaction {
- debitBankAccount.refresh()
- debitBankAccount.lastFiatFetch = it.index
- }
- }
- val convertedAmount = applyBuyinRatioAndFees(
- maybeValidAmount.toBigDecimal(),
- ratiosAndFees
- )
- transaction {
- wireTransfer(
- debitAccount = accountToDebit,
- creditAccount = accountToCredit,
- demobank = demobankName,
- subject = it.camtData.getSingletonSubject(),
- amount = "${demobank.config.currency}:$convertedAmount"
- )
- // Nexus enqueues the transactions such that the index
increases.
- // If Sandbox crashes here, it'll ask again using the last
successful
- // index as the start parameter. Being this an exclusive
bound, only
- // transactions later than it are expected.
- debitBankAccount.refresh()
- debitBankAccount.lastFiatFetch = it.index
- }
- }
- }
- }
-}
-
-/* DB query helper that fetches the latest cash-out operations that were
- confirmed in the regional currency. A cash-out operation is 'confirmed'
- when the bank account pointed by the parameter 'bankAccountLabel' gets
- one incoming payment.
-
- The List return type (instead of SizedIterable) lets the caller NOT open
- a transaction block to access the values -- although some operations _on
- the values_ may be forbidden.
-*/
-fun getUnsubmittedTransactions(bankAccountLabel: String):
List<BankAccountTransactionEntity> {
- return transaction {
- val bankAccount = getBankAccountFromLabel(bankAccountLabel)
- val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.id greater lowerExclusiveLimit and (
- BankAccountTransactionsTable.direction eq "CRDT"
- ) and (BankAccountTransactionsTable.account eq bankAccount.id)
- }.sortedBy { it.id }.map { it }
- /* The latest payment must occupy the highest index,
- to reliably update the 'lastFiatSubmission' column of
- the bank account. */
- }
-}
-
-// CASH-OUT SIDE.
-
-/**
- * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX)
- * on the 'watchedBankAccount' and submits the related cash-out payment
- * to Nexus. The fiat payment will then take place ENTIRELY on Nexus'
- * responsibility.
- */
-suspend fun cashoutMonitor(
- httpClient: HttpClient,
- watchedBankAccount: String = "admin",
- demobankName: String = "default", // used to get config values.
- dbEventTimeout: Long = 0 // 0 waits forever.
-) {
- ensureDisabledRedirects(httpClient)
- // Register for a REGIO_TX event.
- val eventChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- watchedBankAccount
- )
- val objectMapper = jacksonObjectMapper()
- val demobank = getDemobank(demobankName)
- val bankAccount = getBankAccountFromLabel(watchedBankAccount)
- val config = demobank?.config ?: throw internalServerError(
- "Demobank '$demobankName' has no configuration."
- )
- /**
- * The monitor needs the cash-out currency to correctly POST
- * payment initiations at Nexus. Recall: Nexus bank accounts
- * do not mandate any particular currency, as they serve as mere
- * bridges to the backing bank. And: a backing bank may have
- * multiple currencies, or the backing bank may not explicitly
- * specify any currencies to be _the_ currency of the backed
- * bank account.
- */
- if (config.cashoutCurrency == null) {
- logger.error("Config lacks cash-out currency.")
- exitProcess(1)
- }
- val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl)
- val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus)
- val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus)
- val paymentInitEndpoint = nexusBaseUrl.run {
- var nexusBaseUrlFromConfig = this
- if (!nexusBaseUrlFromConfig.endsWith('/'))
- nexusBaseUrlFromConfig += '/'
- /**
- * WARNING: Nexus gives the possibility to have bank account names
- * DIFFERENT from their owner's username. Sandbox however MUST have
- * its Nexus bank account named THE SAME as its username.
- */
- nexusBaseUrlFromConfig +
"bank-accounts/$usernameAtNexus/payment-initiations"
- }
- while (true) {
- val listenHandle = PostgresListenHandle(eventChannel)
- // pessimistically LISTEN
- listenHandle.postgresListen()
- // but optimistically check for data, case some
- // arrived _before_ the LISTEN.
- var newTxs = getUnsubmittedTransactions(watchedBankAccount)
- // Data found, UNLISTEN.
- if (newTxs.isNotEmpty()) {
- logger.debug("Found cash-out's without waiting any DB event.")
- listenHandle.postgresUnlisten()
- }
- // Data not found, wait.
- else {
- logger.debug("Need to wait a DB event for new cash-out's")
- val isNotificationArrived =
listenHandle.waitOnIODispatchers(dbEventTimeout)
- if (isNotificationArrived && listenHandle.receivedPayload ==
"CRDT")
- newTxs = getUnsubmittedTransactions(watchedBankAccount)
- }
- if (newTxs.isEmpty()) {
- logger.debug("DB event timeout expired")
- continue
- }
- logger.debug("POSTing new cash-out's")
- newTxs.forEach {
- logger.debug("POSTing cash-out '${it.subject}' to
$paymentInitEndpoint")
- val body = object {
- /**
- * This field is UID of the request _as assigned by the
- * client_. That helps to reconcile transactions or lets
- * Nexus implement idempotency. It will NOT identify the
created
- * resource at the server side. The ID of the created
resource is
- * assigned _by Nexus_ and communicated in the (successful)
response.
- */
- val uid = it.accountServicerReference
- val iban = it.creditorIban
- val bic = it.creditorBic
- val amount = "${config.cashoutCurrency}:${it.amount}"
- val subject = it.subject
- val name = it.creditorName
- }
- val resp = try {
- httpClient.post(paymentInitEndpoint) {
- expectSuccess = false // Avoids excepting on !2xx
- basicAuth(usernameAtNexus, passwordAtNexus)
- contentType(ContentType.Application.Json)
- setBody(objectMapper.writeValueAsString(body))
- }
- }
- // Hard-error, response did not even arrive.
- catch (e: Exception) {
- logger.error("Cash-out monitor could not reach Nexus. Pause
and retry")
- logger.error(e.message)
- /**
- * Explicit delaying because the monitor normally
- * waits on DB events, and this retry likely won't
- * wait on a DB event.
- */
- delay(2000)
- return@forEach
- }
- // Server fault. Pause and retry.
- if (resp.status.value.toString().startsWith('5')) {
- logger.error("Cash-out monitor POSTed to a failing Nexus.
Pause and retry")
- logger.error("Server responded: ${resp.bodyAsText()}")
- /**
- * Explicit delaying because the monitor normally
- * waits on DB events, and this retry likely won't
- * wait on a DB event.
- */
- delay(2000L)
- return@forEach
- }
- // Client fault, fail Sandbox.
- if (resp.status.value.toString().startsWith('4')) {
- logger.error("Cash-out monitor failed at POSTing to Nexus.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- throw CashoutClientError()
- }
- // Expecting 200 OK. What if 3xx?
- if (resp.status.value != HttpStatusCode.OK.value) {
- logger.error("Cash-out monitor, unhandled response status:
${resp.status.value}.")
- throw CashoutClientError()
- }
- // Successful case, mark the wire transfer as submitted,
- // and advance the pointer to the last submitted payment.
- val responseBody = resp.bodyAsText()
- transaction {
- CashoutSubmissionEntity.new {
- localTransaction = it.id
- submissionTime = resp.responseTime.timestamp
- /**
- * The following block associates the submitted payment
- * to the UID that Nexus assigned to it. It is currently
not
- * used in Sandbox, but might help for reconciliation.
- */
- if (responseBody.isNotEmpty())
- maybeNexusResposnse = responseBody
- }
- // Advancing the 'last submitted bookmark', to avoid
- // handling the same transaction multiple times.
- bankAccount.lastFiatSubmission = it
- }
- }
- }
-}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/DB.kt
b/bank/src/main/kotlin/tech/libeufin/bank/DB.kt
deleted file mode 100644
index e20aa6ad..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/DB.kt
+++ /dev/null
@@ -1,747 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import org.jetbrains.exposed.dao.Entity
-import org.jetbrains.exposed.dao.EntityClass
-import org.jetbrains.exposed.dao.IntEntity
-import org.jetbrains.exposed.dao.LongEntity
-import org.jetbrains.exposed.dao.IntEntityClass
-import org.jetbrains.exposed.dao.LongEntityClass
-import org.jetbrains.exposed.dao.id.EntityID
-import org.jetbrains.exposed.dao.id.IdTable
-import org.jetbrains.exposed.dao.id.IntIdTable
-import org.jetbrains.exposed.dao.id.LongIdTable
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import kotlin.reflect.*
-import kotlin.reflect.full.*
-
-/**
- * All the states to give a subscriber.
- */
-enum class SubscriberState {
- /**
- * No keys at all given to the bank.
- */
- NEW,
-
- /**
- * Only INI electronic message was successfully sent.
- */
- PARTIALLY_INITIALIZED_INI,
-
- /**r
- * Only HIA electronic message was successfully sent.
- */
- PARTIALLY_INITIALIZED_HIA,
-
- /**
- * Both INI and HIA were electronically sent with success.
- */
- INITIALIZED,
-
- /**
- * All the keys accounted in INI and HIA have been confirmed
- * via physical mail.
- */
- READY
-}
-
-/**
- * All the states that one key can be assigned.
- */
-enum class KeyState {
-
- /**
- * The key was never communicated.
- */
- MISSING,
-
- /**
- * The key has been electronically sent.
- */
- NEW,
-
- /**
- * The key has been confirmed (either via physical mail
- * or electronically -- e.g. with certificates)
- */
- RELEASED
-}
-
-/**
- * Stores one config object to the database. Each field
- * name and value populate respectively the configKey and
- * configValue columns. Rows are defined in the following way:
- * demobankName | configKey | configValue
- */
-fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) {
- // Fill the config key-value pairs in the DB.
- config::class.declaredMemberProperties.forEach { configField ->
- val maybeValue = configField.getter.call(config)
- if (override) {
- val maybeConfigPair = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.configKey eq configField.name
- }.firstOrNull()
- if (maybeConfigPair == null)
- throw internalServerError("Cannot override config value
'${configField.name}' not found.")
- maybeConfigPair.configValue = maybeValue?.toString()
- return@forEach
- }
- DemobankConfigPairEntity.new {
- this.demobankName = config.demobankName
- this.configKey = configField.name
- this.configValue = maybeValue?.toString()
- }
- }
-}
-
-object DemobankConfigPairsTable : LongIdTable() {
- val demobankName = text("demobankName")
- val configKey = text("configKey")
- val configValue = text("configValue").nullable()
-}
-
-class DemobankConfigPairEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<DemobankConfigPairEntity>(DemobankConfigPairsTable)
- var demobankName by DemobankConfigPairsTable.demobankName
- var configKey by DemobankConfigPairsTable.configKey
- var configValue by DemobankConfigPairsTable.configValue
-}
-
-object DemobankConfigsTable : LongIdTable() {
- val name = text("hostname")
-}
-
-// Helpers for handling config values in memory.
-typealias DemobankConfigKey = String
-typealias DemobankConfigValue = String?
-fun Pair<DemobankConfigKey, DemobankConfigValue>.expectValue(): String {
- if (this.second == null) throw internalServerError("Config value for
'${this.first}' is null in the database.")
- return this.second as String
-}
-
-class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<DemobankConfigEntity>(DemobankConfigsTable)
- var name by DemobankConfigsTable.name
- /**
- * This object gets defined by parsing all the configuration
- * values found in the DB for one demobank. Those values are
- * retrieved from _another_ table.
- */
- val config: DemobankConfig by lazy {
- // Getting all the values for this demobank.
- val configPairs: List<Pair<DemobankConfigKey, DemobankConfigValue>> =
transaction {
- val maybeConfigPairs = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.demobankName.eq(name)
- }
- if (maybeConfigPairs.empty()) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "No config values of $name were found in the database"
- )
- // Copying results to a DB-agnostic list, to later operate out of
"transaction {}"
- maybeConfigPairs.map { Pair(it.configKey, it.configValue) }
- }
- // Building the args to instantiate a DemobankConfig (non-Exposed)
object.
- val args = mutableMapOf<KParameter, Any?>()
- // For each constructor parameter name, find the same-named database
entry.
- val configClass = DemobankConfig::class
- if (configClass.primaryConstructor == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "${configClass.simpleName} primaryConstructor is null."
- )
- }
- if (configClass.primaryConstructor?.parameters == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "${configClass.simpleName} primaryConstructor" +
- " arguments is null. Cannot set any config value."
- )
- }
- // For each field in the config object, find the respective DB row.
- configClass.primaryConstructor?.parameters?.forEach { par: KParameter
->
- val configPairFromDb: Pair<DemobankConfigKey, DemobankConfigValue>?
- = configPairs.firstOrNull {
- configPair: Pair<DemobankConfigKey, DemobankConfigValue> ->
- configPair.first == par.name
- }
- if (configPairFromDb == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Config key '${par.name}' not found in the database."
- )
- }
- when(par.type) {
- // non-nullable
- typeOf<Boolean>() -> { args[par] =
configPairFromDb.expectValue().toBoolean() }
- typeOf<Int>() -> { args[par] =
configPairFromDb.expectValue().toInt() }
- // nullable
- typeOf<Boolean?>() -> { args[par] =
configPairFromDb.second?.toBoolean() }
- typeOf<Int?>() -> { args[par] =
configPairFromDb.second?.toInt() }
- else -> args[par] = configPairFromDb.second
- }
- }
- // Proceeding now to instantiate the config class, and make it a field
of this type.
- configClass.primaryConstructor!!.callBy(args)
- }
-}
-
-/**
- * Users who are allowed to log into the demo bank.
- * Created via the /demobanks/{demobankname}/register endpoint.
- */
-object DemobankCustomersTable : LongIdTable() {
- val username = text("username")
- val passwordHash = text("passwordHash")
- val name = text("name").nullable()
- val email = text("email").nullable()
- val phone = text("phone").nullable()
- val cashout_address = text("cashout_address").nullable()
-}
-
-class DemobankCustomerEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<DemobankCustomerEntity>(DemobankCustomersTable)
- var username by DemobankCustomersTable.username
- var passwordHash by DemobankCustomersTable.passwordHash
- var name by DemobankCustomersTable.name
- var email by DemobankCustomersTable.email
- var phone by DemobankCustomersTable.phone
- var cashout_address by DemobankCustomersTable.cashout_address
-}
-
-/**
- * This table stores RSA public keys of subscribers.
- */
-object EbicsSubscriberPublicKeysTable : IntIdTable() {
- val rsaPublicKey = blob("rsaPublicKey")
- val state = enumeration("state", KeyState::class)
-}
-
-class EbicsSubscriberPublicKeyEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object :
IntEntityClass<EbicsSubscriberPublicKeyEntity>(EbicsSubscriberPublicKeysTable)
- var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey
- var state by EbicsSubscriberPublicKeysTable.state
-}
-
-/**
- * Ebics 'host'(s) that are served by one Sandbox instance.
- */
-object EbicsHostsTable : IntIdTable() {
- val hostID = text("hostID")
- val ebicsVersion = text("ebicsVersion")
- val signaturePrivateKey = blob("signaturePrivateKey")
- val encryptionPrivateKey = blob("encryptionPrivateKey")
- val authenticationPrivateKey = blob("authenticationPrivateKey")
-}
-
-class EbicsHostEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<EbicsHostEntity>(EbicsHostsTable)
- var hostId by EbicsHostsTable.hostID
- var ebicsVersion by EbicsHostsTable.ebicsVersion
- var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey
- var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey
- var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey
-}
-
-/**
- * Ebics Subscribers table.
- */
-object EbicsSubscribersTable : IntIdTable() {
- val userId = text("userID")
- val partnerId = text("partnerID")
- val systemId = text("systemID").nullable()
- val hostId = text("hostID")
- val signatureKey = reference("signatureKey",
EbicsSubscriberPublicKeysTable).nullable()
- val encryptionKey = reference("encryptionKey",
EbicsSubscriberPublicKeysTable).nullable()
- val authenticationKey = reference("authorizationKey",
EbicsSubscriberPublicKeysTable).nullable()
- val nextOrderID = integer("nextOrderID")
- val state = enumeration("state", SubscriberState::class)
- val bankAccount = reference(
- "bankAccount",
- BankAccountsTable,
- onDelete = ReferenceOption.CASCADE
- ).nullable()
-}
-
-class EbicsSubscriberEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object :
IntEntityClass<EbicsSubscriberEntity>(EbicsSubscribersTable)
- var userId by EbicsSubscribersTable.userId
- var partnerId by EbicsSubscribersTable.partnerId
- var systemId by EbicsSubscribersTable.systemId
- var hostId by EbicsSubscribersTable.hostId
- var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn
EbicsSubscribersTable.signatureKey
- var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn
EbicsSubscribersTable.encryptionKey
- var authenticationKey by EbicsSubscriberPublicKeyEntity
optionalReferencedOn EbicsSubscribersTable.authenticationKey
- var nextOrderID by EbicsSubscribersTable.nextOrderID
- var state by EbicsSubscribersTable.state
- var bankAccount by BankAccountEntity optionalReferencedOn
EbicsSubscribersTable.bankAccount
-}
-
-/**
- * Details of a download order.
- */
-object EbicsDownloadTransactionsTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val orderType = text("orderType")
- val host = reference("host", EbicsHostsTable)
- val subscriber = reference("subscriber", EbicsSubscribersTable)
- val encodedResponse = text("encodedResponse")
- val transactionKeyEnc = blob("transactionKeyEnc")
- val numSegments = integer("numSegments")
- val segmentSize = integer("segmentSize")
- val receiptReceived = bool("receiptReceived")
-}
-
-class EbicsDownloadTransactionEntity(id: EntityID<String>) :
Entity<String>(id) {
- companion object : EntityClass<String,
EbicsDownloadTransactionEntity>(EbicsDownloadTransactionsTable)
-
- var orderType by EbicsDownloadTransactionsTable.orderType
- var host by EbicsHostEntity referencedOn
EbicsDownloadTransactionsTable.host
- var subscriber by EbicsSubscriberEntity referencedOn
EbicsDownloadTransactionsTable.subscriber
- var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse
- var numSegments by EbicsDownloadTransactionsTable.numSegments
- var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc
- var segmentSize by EbicsDownloadTransactionsTable.segmentSize
- var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived
-}
-
-/**
- * Details of a upload order.
- */
-object EbicsUploadTransactionsTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val orderType = text("orderType")
- val orderID = text("orderID")
- val host = reference("host", EbicsHostsTable)
- val subscriber = reference("subscriber", EbicsSubscribersTable)
- val numSegments = integer("numSegments")
- val lastSeenSegment = integer("lastSeenSegment")
- val transactionKeyEnc = blob("transactionKeyEnc")
-}
-
-class EbicsUploadTransactionEntity(id: EntityID<String>) : Entity<String>(id) {
- companion object : EntityClass<String,
EbicsUploadTransactionEntity>(EbicsUploadTransactionsTable)
- var orderType by EbicsUploadTransactionsTable.orderType
- var orderID by EbicsUploadTransactionsTable.orderID
- var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host
- var subscriber by EbicsSubscriberEntity referencedOn
EbicsUploadTransactionsTable.subscriber
- var numSegments by EbicsUploadTransactionsTable.numSegments
- var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment
- var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc
-}
-
-/**
- * FIXME: document this.
- */
-object EbicsOrderSignaturesTable : IntIdTable() {
- val orderID = text("orderID")
- val orderType = text("orderType")
- val partnerID = text("partnerID")
- val userID = text("userID")
- val signatureAlgorithm = text("signatureAlgorithm")
- val signatureValue = blob("signatureValue")
-}
-
-class EbicsOrderSignatureEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object :
IntEntityClass<EbicsOrderSignatureEntity>(EbicsOrderSignaturesTable)
- var orderID by EbicsOrderSignaturesTable.orderID
- var orderType by EbicsOrderSignaturesTable.orderType
- var partnerID by EbicsOrderSignaturesTable.partnerID
- var userID by EbicsOrderSignaturesTable.userID
- var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm
- var signatureValue by EbicsOrderSignaturesTable.signatureValue
-}
-
-/**
- * FIXME: document this.
- */
-object EbicsUploadTransactionChunksTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val chunkIndex = integer("chunkIndex")
- val chunkContent = blob("chunkContent")
-}
-
-// FIXME: Is upload chunking not implemented somewhere?!
-class EbicsUploadTransactionChunkEntity(id: EntityID<String>) :
Entity<String>(id) {
- companion object : EntityClass<String,
EbicsUploadTransactionChunkEntity>(EbicsUploadTransactionChunksTable)
- var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex
- var chunkContent by EbicsUploadTransactionChunksTable.chunkContent
-}
-
-
-/**
- * Holds those transactions that aren't yet reported in a Camt.053 document.
- * After reporting those, the table gets emptied. Rows are merely references
- * to the main ledger.
- */
-object BankAccountFreshTransactionsTable : LongIdTable() {
- val transactionRef = reference(
- "transaction",
- BankAccountTransactionsTable,
- onDelete = ReferenceOption.CASCADE
- )
-}
-class BankAccountFreshTransactionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<BankAccountFreshTransactionEntity>(BankAccountFreshTransactionsTable)
- var transactionRef by BankAccountTransactionEntity referencedOn
BankAccountFreshTransactionsTable.transactionRef
-}
-
-/**
- * Table that keeps all the payments initiated by PAIN.001.
- */
-object BankAccountTransactionsTable : LongIdTable() {
- val creditorIban = text("creditorIban")
- val creditorBic = text("creditorBic").nullable()
- val creditorName = text("creditorName")
- val debtorIban = text("debtorIban")
- val debtorBic = text("debtorBic").nullable()
- val debtorName = text("debtorName")
- val subject = text("subject")
- // Amount is a BigDecimal in String form.
- val amount = text("amount")
- val currency = text("currency")
- // Milliseconds since the Epoch.
- val date = long("date")
-
- /**
- * UID assigned to the payment by Sandbox. Despite the camt-looking
- * name, this UID is always given, even when no EBICS or camt are being
- * served.
- */
- val accountServicerReference = text("accountServicerReference")
- /**
- * The following two values are pain.001 specific. Sandbox stores
- * them when it serves EBICS connections.
- */
- val pmtInfId = text("pmtInfId").nullable()
- val endToEndId = text("EndToEndId").nullable()
- val direction = text("direction")
- /**
- * Bank account of the party whose 'direction' refers. This version allows
- * only both parties to be registered at the running Sandbox.
- */
- val account = reference(
- "account", BankAccountsTable,
- onDelete = ReferenceOption.CASCADE
- )
- // Redundantly storing the demobank for query convenience.
- val demobank = reference("demobank", DemobankConfigsTable)
-}
-
-class BankAccountTransactionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<BankAccountTransactionEntity>(BankAccountTransactionsTable) {
- override fun new(init: BankAccountTransactionEntity.() -> Unit):
BankAccountTransactionEntity {
- /**
- * Fresh transactions are those that wait to be included in a
- * "history" report, likely a Camt.5x message. The "fresh
transactions"
- * table keeps a list of such transactions.
- */
- val freshTx = super.new(init)
- BankAccountFreshTransactionsTable.insert {
- it[transactionRef] = freshTx.id
- }
- /**
- * The bank account involved in this transaction points to
- * it as the "last known" transaction, to make it easier to
- * build histories that depend on such record.
- */
- freshTx.account.lastTransaction = freshTx
- return freshTx
- }
- }
- var creditorIban by BankAccountTransactionsTable.creditorIban
- var creditorBic by BankAccountTransactionsTable.creditorBic
- var creditorName by BankAccountTransactionsTable.creditorName
- var debtorIban by BankAccountTransactionsTable.debtorIban
- var debtorBic by BankAccountTransactionsTable.debtorBic
- var debtorName by BankAccountTransactionsTable.debtorName
- var subject by BankAccountTransactionsTable.subject
- var amount by BankAccountTransactionsTable.amount
- var currency by BankAccountTransactionsTable.currency
- var date by BankAccountTransactionsTable.date
- var accountServicerReference by
BankAccountTransactionsTable.accountServicerReference
- var pmtInfId by BankAccountTransactionsTable.pmtInfId
- var endToEndId by BankAccountTransactionsTable.endToEndId
- var direction by BankAccountTransactionsTable.direction
- var account by BankAccountEntity referencedOn
BankAccountTransactionsTable.account
- var demobank by DemobankConfigEntity referencedOn
BankAccountTransactionsTable.demobank
-}
-
-/**
- * Table that keeps information about which bank accounts (iban+bic+name)
- * are active in the system. In the current version, 'label' and 'owner'
- * are always equal; future versions may change this, when one customer can
- * own multiple bank accounts.
- */
-object BankAccountsTable : IntIdTable() {
- val balance = text("balance").default("0")
- val iban = text("iban")
- val bic = text("bic").default("SANDBOXX")
- val label = text("label").uniqueIndex("accountLabelIndex")
- /**
- * This field is the username of the customer that owns the
- * bank account. Admin is the only exception: that can specify
- * this field as "admin" although no customer backs it.
- */
- val owner = text("owner")
- val isPublic = bool("isPublic").default(false)
- val demoBank = reference("demoBank", DemobankConfigsTable)
-
- /**
- * Point to the last transaction related to this account, regardless
- * of it being credit or debit. This reference helps to construct
- * history results that start from / depend on the last transaction.
- */
- val lastTransaction = reference("lastTransaction",
BankAccountTransactionsTable).nullable()
-
- /**
- * Points to the transaction that was last submitted by the conversion
- * service to Nexus, in order to initiate a fiat payment related to a
- * cash-out operation.
- */
- val lastFiatSubmission = reference("lastFiatSubmission",
BankAccountTransactionsTable).nullable()
-
- /**
- * Tracks the last fiat payment that was read from Nexus. This tracker
- * gets updated ONLY IF the exchange gets successfully paid with the
related
- * amount in the regional currency.
- */
- val lastFiatFetch = text("lastFiatFetch").default("0")
-}
-
-class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<BankAccountEntity>(BankAccountsTable)
-
- var balance by BankAccountsTable.balance
- var iban by BankAccountsTable.iban
- var bic by BankAccountsTable.bic
- var label by BankAccountsTable.label
- var owner by BankAccountsTable.owner
- var isPublic by BankAccountsTable.isPublic
- var demoBank by DemobankConfigEntity referencedOn
BankAccountsTable.demoBank
- var lastTransaction by BankAccountTransactionEntity optionalReferencedOn
BankAccountsTable.lastTransaction
- var lastFiatSubmission by BankAccountTransactionEntity
optionalReferencedOn BankAccountsTable.lastFiatSubmission
- var lastFiatFetch by BankAccountsTable.lastFiatFetch
-}
-
-object BankAccountStatementsTable : IntIdTable() {
- val statementId = text("statementId")
- val creationTime = long("creationTime")
- val xmlMessage = text("xmlMessage")
- val bankAccount = reference("bankAccount", BankAccountsTable)
- // Signed BigDecimal representing a Camt.053 CLBD field.
- val balanceClbd = text("balanceClbd").nullable()
-}
-
-class BankAccountStatementEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object :
IntEntityClass<BankAccountStatementEntity>(BankAccountStatementsTable)
- var statementId by BankAccountStatementsTable.statementId
- var creationTime by BankAccountStatementsTable.creationTime
- var xmlMessage by BankAccountStatementsTable.xmlMessage
- var bankAccount by BankAccountEntity referencedOn
BankAccountStatementsTable.bankAccount
- var balanceClbd by BankAccountStatementsTable.balanceClbd
-}
-
-enum class CashoutOperationStatus { CONFIRMED, PENDING }
-object CashoutOperationsTable : LongIdTable() {
- val uuid = uuid("uuid").autoGenerate()
- /**
- * This amount is the one the user entered in the cash-out
- * dialog. That will show up as the outgoing transfer in their
- * local currency bank account.
- */
- val amountDebit = text("amountDebit")
- val amountCredit = text("amountCredit")
- val buyAtRatio = text("buyAtRatio")
- val buyInFee = text("buyInFee")
- val sellAtRatio = text("sellAtRatio")
- val sellOutFee = text("sellOutFee")
- val subject = text("subject")
- val creationTime = long("creationTime") // in milliseconds.
- val confirmationTime = long("confirmationTime").nullable() // in
milliseconds.
- val tanChannel = enumeration("tanChannel", SupportedTanChannels::class)
- val account = text("account")
- val cashoutAddress = text("cashoutAddress")
- val tan = text("tan")
- val status = enumeration("status",
CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING)
-}
-
-class CashoutOperationEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<CashoutOperationEntity>(CashoutOperationsTable)
- var uuid by CashoutOperationsTable.uuid
- var amountDebit by CashoutOperationsTable.amountDebit
- var amountCredit by CashoutOperationsTable.amountCredit
- var buyAtRatio by CashoutOperationsTable.buyAtRatio
- var buyInFee by CashoutOperationsTable.buyInFee
- var sellAtRatio by CashoutOperationsTable.sellAtRatio
- var sellOutFee by CashoutOperationsTable.sellOutFee
- var subject by CashoutOperationsTable.subject
- var creationTime by CashoutOperationsTable.creationTime
- var confirmationTime by CashoutOperationsTable.confirmationTime
- var tanChannel by CashoutOperationsTable.tanChannel
- var account by CashoutOperationsTable.account
- var cashoutAddress by CashoutOperationsTable.cashoutAddress
- var tan by CashoutOperationsTable.tan
- var status by CashoutOperationsTable.status
-}
-object TalerWithdrawalsTable : LongIdTable() {
- val wopid = uuid("wopid").autoGenerate()
- val amount = text("amount") // $currency:x.y
- /**
- * Turns to true after the wallet gave the reserve public key
- * and the exchange details to the bank.
- */
- val selectionDone = bool("selectionDone").default(false)
- val aborted = bool("aborted").default(false)
- /**
- * Turns to true after the wire transfer to the exchange bank account
- * gets completed _on the bank's side_. This does never guarantees that
- * the payment arrived at the exchange's bank yet.
- */
- val confirmationDone = bool("confirmationDone").default(false)
- val reservePub = text("reservePub").nullable()
- val selectedExchangePayto = text("selectedExchangePayto").nullable()
- val walletBankAccount = reference("walletBankAccount", BankAccountsTable)
-}
-class TalerWithdrawalEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<TalerWithdrawalEntity>(TalerWithdrawalsTable)
- var wopid by TalerWithdrawalsTable.wopid
- var selectionDone by TalerWithdrawalsTable.selectionDone
- var confirmationDone by TalerWithdrawalsTable.confirmationDone
- var reservePub by TalerWithdrawalsTable.reservePub
- var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto
- var amount by TalerWithdrawalsTable.amount
- var walletBankAccount by BankAccountEntity referencedOn
TalerWithdrawalsTable.walletBankAccount
- var aborted by TalerWithdrawalsTable.aborted
-}
-
-object BankAccountReportsTable : IntIdTable() {
- val reportId = text("reportId")
- val creationTime = long("creationTime")
- val xmlMessage = text("xmlMessage")
- val bankAccount = reference("bankAccount", BankAccountsTable)
-}
-
-/**
- * This table tracks the cash-out requests that Sandbox sends to Nexus.
- * Only successful requests make it to this table. Failed request would
- * either _stop_ the conversion service (for client-side errors) or get retried
- * at a later time (for server-side errors.)
- */
-object CashoutSubmissionsTable: LongIdTable() {
- val localTransaction = reference("localTransaction",
BankAccountTransactionsTable).uniqueIndex()
- val maybeNexusResponse = text("maybeNexusResponse").nullable()
- val submissionTime = long("submissionTime").nullable() // failed don't
have it.
-}
-
-class CashoutSubmissionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object :
LongEntityClass<CashoutSubmissionEntity>(CashoutSubmissionsTable)
- var localTransaction by CashoutSubmissionsTable.localTransaction
- var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse
- var submissionTime by CashoutSubmissionsTable.submissionTime
-}
-
-fun dbDropTables(connStringFromEnv: String) {
- connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv))
- if (isPostgres()) {
- val ret = execCommand(
- listOf(
- "libeufin-load-sql",
- "-d",
- connStringFromEnv,
- "-s",
- "sandbox",
- "-r" // the drop option
- ),
- /**
- * Tolerating a failure here helps to manage the case
- * where an empty database is attempted to be dropped.
- */
- throwIfFails = false
- )
- if (ret != 0)
- logger.warn("Dropping the sandbox tables failed. Was the DB
filled before?")
- return
- }
- transaction {
- SchemaUtils.drop(
- CashoutSubmissionsTable,
- EbicsSubscribersTable,
- EbicsSubscriberPublicKeysTable,
- EbicsHostsTable,
- EbicsDownloadTransactionsTable,
- EbicsUploadTransactionsTable,
- EbicsUploadTransactionChunksTable,
- EbicsOrderSignaturesTable,
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable,
- BankAccountsTable,
- BankAccountReportsTable,
- BankAccountStatementsTable,
- DemobankConfigsTable,
- DemobankConfigPairsTable,
- TalerWithdrawalsTable,
- DemobankCustomersTable,
- CashoutOperationsTable
- )
- }
-
-}
-
-fun dbCreateTables(connStringFromEnv: String) {
- connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv))
- if (isPostgres()) {
- execCommand(listOf(
- "libeufin-load-sql",
- "-d",
- connStringFromEnv,
- "-s",
- "sandbox"
- ))
- return
- }
- // Still using the legacy way for other DBMSs, like SQLite.
- transaction {
- SchemaUtils.create(
- CashoutSubmissionsTable,
- DemobankConfigsTable,
- DemobankConfigPairsTable,
- EbicsSubscribersTable,
- EbicsSubscriberPublicKeysTable,
- EbicsHostsTable,
- EbicsDownloadTransactionsTable,
- EbicsUploadTransactionsTable,
- EbicsUploadTransactionChunksTable,
- EbicsOrderSignaturesTable,
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable,
- BankAccountsTable,
- BankAccountReportsTable,
- BankAccountStatementsTable,
- TalerWithdrawalsTable,
- DemobankCustomersTable,
- CashoutOperationsTable
- )
- }
-}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
index ba370c3b..799788c0 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -14,26 +14,29 @@ data class Customer(
val login: String,
val passwordHash: String,
val name: String,
- val email: String,
- val phone: String,
- val cashoutPayto: String,
- val cashoutCurrency: String
+ val dbRowId: Long? = null, // mostly used when retrieving records.
+ val email: String?,
+ val phone: String?,
+ val cashoutPayto: String?,
+ val cashoutCurrency: String?
)
+fun Customer.expectRowId(): Long = this.dbRowId ?: throw
internalServerError("Cutsomer '${this.login}' had no DB row ID")
data class TalerAmount(
val value: Long,
val frac: Int
)
+// BIC got removed, because it'll be expressed in the internal_payto_uri.
data class BankAccount(
- val iban: String,
- val bic: String,
- val bankAccountLabel: String,
+ val internalPaytoUri: String,
val owningCustomerId: Long,
val isPublic: Boolean = false,
- val lastNexusFetchRowId: Long,
+ val isTalerExchange: Boolean = false,
+ val lastNexusFetchRowId: Long = 0L,
val balance: TalerAmount? = null,
- val hasDebt: Boolean
+ val hasDebt: Boolean,
+ val maxDebt: TalerAmount
)
enum class TransactionDirection {
@@ -44,6 +47,25 @@ enum class TanChannel {
sms, email, file
}
+enum class TokenScope {
+ readonly, readwrite
+}
+
+data class BearerToken(
+ val content: ByteArray,
+ val scope: TokenScope,
+ val creationTime: Long,
+ val expirationTime: Long,
+ /**
+ * Serial ID of the database row that hosts the bank customer
+ * that is associated with this token. NOTE: if the token is
+ * refreshed by a client that doesn't have a user+password login
+ * in the system, the creator remains always the original bank
+ * customer that created the very first token.
+ */
+ val bankCustomer: Long
+)
+
data class BankInternalTransaction(
val creditorAccountId: Long,
val debtorAccountId: Long,
@@ -56,11 +78,9 @@ data class BankInternalTransaction(
)
data class BankAccountTransaction(
- val creditorIban: String,
- val creditorBic: String,
+ val creditorPaytoUri: String,
val creditorName: String,
- val debtorIban: String,
- val debtorBic: String,
+ val debtorPaytoUri: String,
val debtorName: String,
val subject: String,
val amount: TalerAmount,
@@ -98,7 +118,7 @@ data class Cashout(
val tanChannel: TanChannel,
val tanCode: String,
val bankAccount: Long,
- val cashoutAddress: String,
+ val credit_payto_uri: String,
val cashoutCurrency: String
)
@@ -170,7 +190,15 @@ class Database(private val dbConfig: String) {
}
// CUSTOMERS
- fun customerCreate(customer: Customer): Boolean {
+ /**
+ * This method INSERTs a new customer into the database and
+ * returns its row ID. That is useful because often a new user
+ * ID has to be specified in more database records, notably in
+ * bank accounts to point at their owners.
+ *
+ * In case of conflict, this method returns null.
+ */
+ fun customerCreate(customer: Customer): Long? {
reconnect()
val stmt = prepare("""
INSERT INTO customers (
@@ -182,7 +210,8 @@ class Database(private val dbConfig: String) {
,cashout_payto
,cashout_currency
)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ RETURNING customer_id
"""
)
stmt.setString(1, customer.login)
@@ -193,12 +222,84 @@ class Database(private val dbConfig: String) {
stmt.setString(6, customer.cashoutPayto)
stmt.setString(7, customer.cashoutCurrency)
- return myExecute(stmt)
+ val res = try {
+ stmt.executeQuery()
+ } catch (e: SQLException) {
+ logger.error(e.message)
+ if (e.errorCode == 0) return null // unique constraint violation.
+ throw e // rethrow on other errors.
+ }
+ res.use {
+ if (!it.next())
+ throw internalServerError("SQL RETURNING gave nothing.")
+ return it.getLong("customer_id")
+ }
+ }
+
+ fun customerPwAuth(login: String, pwHash: String): Customer? {
+ reconnect()
+ val stmt = prepare("""
+ SELECT
+ name,
+ email,
+ phone,
+ cashout_payto,
+ cashout_currency
+ FROM customers
+ WHERE login=? AND password_hash=?
+ """)
+ stmt.setString(1, login)
+ stmt.setString(2, pwHash)
+ val rs = stmt.executeQuery()
+ rs.use {
+ if (!rs.next()) return null
+ return Customer(
+ login = login,
+ passwordHash = pwHash,
+ name = it.getString("name"),
+ phone = it.getString("phone"),
+ email = it.getString("email"),
+ cashoutCurrency = it.getString("cashout_currency"),
+ cashoutPayto = it.getString("cashout_payto")
+ )
+ }
+ }
+
+ // Mostly used to get customers out of bearer tokens.
+ fun customerGetFromRowId(customer_id: Long): Customer? {
+ reconnect()
+ val stmt = prepare("""
+ SELECT
+ login,
+ password_hash,
+ name,
+ email,
+ phone,
+ cashout_payto,
+ cashout_currency
+ FROM customers
+ WHERE customer_id=?
+ """)
+ stmt.setLong(1, customer_id)
+ val rs = stmt.executeQuery()
+ rs.use {
+ if (!rs.next()) return null
+ return Customer(
+ login = it.getString("login"),
+ passwordHash = it.getString("password_hash"),
+ name = it.getString("name"),
+ phone = it.getString("phone"),
+ email = it.getString("email"),
+ cashoutCurrency = it.getString("cashout_currency"),
+ cashoutPayto = it.getString("cashout_payto")
+ )
+ }
}
fun customerGetFromLogin(login: String): Customer? {
reconnect()
val stmt = prepare("""
SELECT
+ customer_id,
password_hash,
name,
email,
@@ -219,84 +320,140 @@ class Database(private val dbConfig: String) {
phone = it.getString("phone"),
email = it.getString("email"),
cashoutCurrency = it.getString("cashout_currency"),
- cashoutPayto = it.getString("cashout_payto")
+ cashoutPayto = it.getString("cashout_payto"),
+ dbRowId = it.getLong("customer_id")
)
}
}
// Possibly more "customerGetFrom*()" to come.
+ // BEARER TOKEN
+ fun bearerTokenCreate(token: BearerToken): Boolean {
+ reconnect()
+ val stmt = prepare("""
+ INSERT INTO bearer_tokens
+ (content,
+ creation_time,
+ expiration_time,
+ scope,
+ bank_customer
+ ) VALUES
+ (?, ?, ?, ?::token_scope_enum, ?)
+ """)
+ stmt.setBytes(1, token.content)
+ stmt.setLong(2, token.creationTime)
+ stmt.setLong(3, token.expirationTime)
+ stmt.setString(4, token.scope.name)
+ stmt.setLong(5, token.bankCustomer)
+
+ return myExecute(stmt)
+ }
+ fun bearerTokenGet(token: ByteArray): BearerToken? {
+ reconnect()
+ val stmt = prepare("""
+ SELECT
+ expiration_time,
+ creation_time,
+ bank_customer,
+ scope
+ FROM bearer_tokens
+ WHERE content=?;
+ """)
+
+ stmt.setBytes(1, token)
+ stmt.executeQuery().use {
+ if (!it.next()) return null
+ return BearerToken(
+ content = token,
+ creationTime = it.getLong("creation_time"),
+ expirationTime = it.getLong("expiration_time"),
+ bankCustomer = it.getLong("bank_customer"),
+ scope = it.getString("scope").run {
+ if (this == TokenScope.readwrite.name) return@run
TokenScope.readwrite
+ if (this == TokenScope.readonly.name) return@run
TokenScope.readonly
+ else throw internalServerError("Wrong token scope found in
the database: $this")
+ }
+ )
+ }
+ }
+
// BANK ACCOUNTS
// Returns false on conflicts.
fun bankAccountCreate(bankAccount: BankAccount): Boolean {
reconnect()
+ // FIXME: likely to be changed to only do internal_payto_uri
val stmt = prepare("""
INSERT INTO bank_accounts
- (iban
- ,bic
- ,bank_account_label
+ (internal_payto_uri
,owning_customer_id
,is_public
- ,last_nexus_fetch_row_id
+ ,is_taler_exchange
+ ,max_debt
)
- VALUES (?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, (?, ?)::taler_amount)
""")
- stmt.setString(1, bankAccount.iban)
- stmt.setString(2, bankAccount.bic)
- stmt.setString(3, bankAccount.bankAccountLabel)
- stmt.setLong(4, bankAccount.owningCustomerId)
- stmt.setBoolean(5, bankAccount.isPublic)
- stmt.setLong(6, bankAccount.lastNexusFetchRowId)
+ stmt.setString(1, bankAccount.internalPaytoUri)
+ stmt.setLong(2, bankAccount.owningCustomerId)
+ stmt.setBoolean(3, bankAccount.isPublic)
+ stmt.setBoolean(4, bankAccount.isTalerExchange)
+ stmt.setLong(5, bankAccount.maxDebt.value)
+ stmt.setInt(6, bankAccount.maxDebt.frac)
// using the default zero value for the balance.
return myExecute(stmt)
}
fun bankAccountSetMaxDebt(
- bankAccountLabel: String,
+ owningCustomerId: Long,
maxDebt: TalerAmount
): Boolean {
reconnect()
val stmt = prepare("""
UPDATE bank_accounts
SET max_debt=(?,?)::taler_amount
- WHERE bank_account_label=?
+ WHERE owning_customer_id=?
""")
stmt.setLong(1, maxDebt.value)
stmt.setInt(2, maxDebt.frac)
- stmt.setString(3, bankAccountLabel)
+ stmt.setLong(3, owningCustomerId)
return myExecute(stmt)
}
- fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? {
+ fun bankAccountGetFromOwnerId(ownerId: Long): BankAccount? {
reconnect()
val stmt = prepare("""
SELECT
- iban
- ,bic
+ internal_payto_uri
,owning_customer_id
,is_public
+ ,is_taler_exchange
,last_nexus_fetch_row_id
- ,(balance).val AS balance_value
+ ,(balance).val AS balance_val
,(balance).frac AS balance_frac
,has_debt
+ ,(max_debt).val AS max_debt_val
+ ,(max_debt).frac AS max_debt_frac
FROM bank_accounts
- WHERE bank_account_label=?
+ WHERE owning_customer_id=?
""")
- stmt.setString(1, bankAccountLabel)
+ stmt.setLong(1, ownerId)
val rs = stmt.executeQuery()
rs.use {
if (!it.next()) return null
return BankAccount(
- iban = it.getString("iban"),
- bic = it.getString("bic"),
+ internalPaytoUri = it.getString("internal_payto_uri"),
balance = TalerAmount(
- it.getLong("balance_value"),
+ it.getLong("balance_val"),
it.getInt("balance_frac")
),
- bankAccountLabel = bankAccountLabel,
lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"),
owningCustomerId = it.getLong("owning_customer_id"),
- hasDebt = it.getBoolean("has_debt")
+ hasDebt = it.getBoolean("has_debt"),
+ isTalerExchange = it.getBoolean("is_taler_exchange"),
+ maxDebt = TalerAmount(
+ value = it.getLong("max_debt_val"),
+ frac = it.getInt("max_debt_frac")
+ )
)
}
}
@@ -355,11 +512,9 @@ class Database(private val dbConfig: String) {
reconnect()
val stmt = prepare("""
SELECT
- creditor_iban
- ,creditor_bic
+ creditor_payto_uri
,creditor_name
- ,debtor_iban
- ,debtor_bic
+ ,debtor_payto_uri
,debtor_name
,subject
,(amount).val AS amount_val
@@ -386,11 +541,9 @@ class Database(private val dbConfig: String) {
do {
ret.add(
BankAccountTransaction(
- creditorIban = it.getString("creditor_iban"),
- creditorBic = it.getString("creditor_bic"),
+ creditorPaytoUri = it.getString("creditor_payto_uri"),
creditorName = it.getString("creditor_name"),
- debtorIban = it.getString("debtor_iban"),
- debtorBic = it.getString("debtor_bic"),
+ debtorPaytoUri = it.getString("debtor_payto_uri"),
debtorName = it.getString("debtor_name"),
amount = TalerAmount(
it.getLong("amount_val"),
@@ -409,7 +562,8 @@ class Database(private val dbConfig: String) {
paymentInformationId =
it.getString("payment_information_id"),
subject = it.getString("subject"),
transactionDate = it.getLong("transaction_date")
- ))
+ )
+ )
} while (it.next())
return ret
}
@@ -516,7 +670,7 @@ class Database(private val dbConfig: String) {
,tan_channel
,tan_code
,bank_account
- ,cashout_address
+ ,credit_payto_uri
,cashout_currency
)
VALUES (
@@ -552,7 +706,7 @@ class Database(private val dbConfig: String) {
stmt.setString(14, op.tanChannel.name)
stmt.setString(15, op.tanCode)
stmt.setLong(16, op.bankAccount)
- stmt.setString(17, op.cashoutAddress)
+ stmt.setString(17, op.credit_payto_uri)
stmt.setString(18, op.cashoutCurrency)
return myExecute(stmt)
}
@@ -610,7 +764,7 @@ class Database(private val dbConfig: String) {
,tan_channel
,tan_code
,bank_account
- ,cashout_address
+ ,credit_payto_uri
,cashout_currency
,tan_confirmation_time
,local_transaction
@@ -635,7 +789,7 @@ class Database(private val dbConfig: String) {
value = it.getLong("buy_in_fee_val"),
frac = it.getInt("buy_in_fee_frac")
),
- cashoutAddress = it.getString("cashout_address"),
+ credit_payto_uri = it.getString("credit_payto_uri"),
cashoutCurrency = it.getString("cashout_currency"),
cashoutUuid = opUuid,
creationTime = it.getLong("creation_time"),
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt
b/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt
deleted file mode 100644
index a2a6deb9..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt
+++ /dev/null
@@ -1,1436 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-
-package tech.libeufin.bank
-
-import io.ktor.server.application.*
-import io.ktor.http.ContentType
-import io.ktor.http.HttpStatusCode
-import io.ktor.server.request.*
-import io.ktor.server.response.respond
-import io.ktor.server.response.respondText
-import io.ktor.util.AttributeKey
-import io.ktor.util.date.*
-import org.apache.xml.security.binding.xmldsig.RSAKeyValueType
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.statements.api.ExposedBlob
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.w3c.dom.Document
-import tech.libeufin.util.*
-import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse
-import tech.libeufin.util.ebics_h004.*
-import tech.libeufin.util.ebics_hev.HEVResponse
-import tech.libeufin.util.ebics_hev.SystemReturnCodeType
-import tech.libeufin.util.ebics_s001.SignatureTypes
-import tech.libeufin.util.ebics_s001.UserSignatureData
-import java.math.BigDecimal
-import java.security.interfaces.RSAPrivateCrtKey
-import java.security.interfaces.RSAPublicKey
-import java.sql.Connection
-import java.util.*
-import java.util.zip.DeflaterInputStream
-import java.util.zip.InflaterInputStream
-
-val EbicsHostIdAttribute = AttributeKey<String>("RequestedEbicsHostID")
-
-data class PainParseResult(
- val creditorIban: String,
- val creditorName: String,
- val creditorBic: String?,
- val debtorIban: String,
- val debtorName: String,
- val debtorBic: String?,
- val subject: String,
- val amount: String,
- val currency: String,
- val pmtInfId: String,
- val endToEndId: String,
- val msgId: String
-)
-
-open class EbicsRequestError(
- val errorText: String,
- val errorCode: String
-) : Exception("$errorText (EBICS error code: $errorCode)")
-
-class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError(
- "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else
"",
- "090005"
-)
-
-class EbicsInvalidRequestError : EbicsRequestError(
- "[EBICS_INVALID_REQUEST] Invalid request",
- "060102"
-)
-class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError(
- "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason",
- "091302"
-)
-
-/**
- * This error is thrown whenever the Subscriber's state is not suitable
- * for the requested action. For example, the subscriber sends a EbicsRequest
- * message without having first uploaded their keys (#5973).
- */
-class EbicsSubscriberStateError : EbicsRequestError(
- "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state
inadmissible",
- "091002"
-)
-// hint should mention at least the userID
-class EbicsUserUnknown(hint: String) : EbicsRequestError(
- "[EBICS_USER_UNKNOWN] $hint",
- "091003"
-)
-
-class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError(
- "[EBICS_ORDER_PARAMS_IGNORED] $hint",
- "031001"
-)
-
-
-open class EbicsKeyManagementError(private val errorText: String, private val
errorCode: String) :
- Exception("EBICS key management error: $errorText ($errorCode)")
-
-private class EbicsInvalidXmlError : EbicsKeyManagementError(
- "[EBICS_INVALID_XML]",
- "091010"
-)
-
-private class EbicsUnsupportedOrderType : EbicsRequestError(
- "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported",
- "091005"
-)
-
-/**
- * Used here also for "Internal server error". For example, when the
- * sandbox itself generates a invalid XML response.
- */
-class EbicsProcessingError(detail: String?) : EbicsRequestError(
- // a missing detail is already the bank's fault.
- "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"),
- "091116"
-)
-
-class EbicsAmountCheckError(detail: String): EbicsRequestError(
- "[EBICS_AMOUNT_CHECK_FAILED] $detail",
- "091303"
-)
-
-suspend fun respondEbicsTransfer(
- call: ApplicationCall,
- errorText: String,
- errorCode: String
-) {
- /**
- * Because this handler runs for any error, it could
- * handle the case where the Ebics host ID is unknown due
- * to an invalid request. Recall: Sandbox is multi-host, and
- * which Ebics host was requested belongs to the request document.
- *
- * Therefore, because any Ebics response
- * should speak for one Ebics host, we can't respond any Ebics
- * type when the Ebics host ID remains unknown due to invalid
- * request. Instead, we'll respond plain text:
- */
- if (!call.attributes.contains(EbicsHostIdAttribute)) {
- call.respondText("Invalid document.", status =
HttpStatusCode.BadRequest)
- return
- }
- val resp = EbicsResponse.createForUploadWithError(
- errorText,
- errorCode,
- // For now, phase gets hard-coded as TRANSFER,
- // because errors during initialization should have
- // already been caught by the chunking logic.
- EbicsTypes.TransactionPhaseType.TRANSFER
- )
- val hostAuthPriv = transaction {
- val host = EbicsHostEntity.find {
- EbicsHostsTable.hostID.upperCase() eq
call.attributes[EbicsHostIdAttribute]
- .uppercase()
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Requested Ebics host ID
(${call.attributes[EbicsHostIdAttribute]}) not found."
- )
- CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes)
- }
- call.respondText(
- signEbicsResponse(resp, hostAuthPriv),
- ContentType.Application.Xml,
- HttpStatusCode.OK
- )
-}
-
-private suspend fun ApplicationCall.respondEbicsKeyManagement(
- errorText: String,
- errorCode: String,
- bankReturnCode: String,
- dataTransfer: CryptoUtil.EncryptionResult? = null,
- orderId: String? = null
-) {
- val responseXml = EbicsKeyManagementResponse().apply {
- version = "H004"
- header = EbicsKeyManagementResponse.Header().apply {
- authenticate = true
- mutable = EbicsKeyManagementResponse.MutableHeaderType().apply {
- reportText = errorText
- returnCode = errorCode
- if (orderId != null) {
- this.orderID = orderId
- }
- }
- _static = EbicsKeyManagementResponse.EmptyStaticHeader()
- }
- body = EbicsKeyManagementResponse.Body().apply {
- this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply {
- this.authenticate = true
- this.value = bankReturnCode
- }
- if (dataTransfer != null) {
- this.dataTransfer =
EbicsKeyManagementResponse.DataTransfer().apply {
- this.dataEncryptionInfo =
EbicsTypes.DataEncryptionInfo().apply {
- this.authenticate = true
- this.transactionKey =
dataTransfer.encryptedTransactionKey
- this.encryptionPubKeyDigest =
EbicsTypes.PubKeyDigest().apply {
- this.algorithm =
"http://www.w3.org/2001/04/xmlenc#sha256"
- this.version = "E002"
- this.value = dataTransfer.pubKeyDigest
- }
- }
- this.orderData =
EbicsKeyManagementResponse.OrderData().apply {
- this.value =
Base64.getEncoder().encodeToString(dataTransfer.encryptedData)
- }
- }
- }
- }
- }
- val text = XMLUtil.convertJaxbToString(responseXml)
- // logger.info("responding with:\n${text}")
- if (!XMLUtil.validateFromString(text)) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoint EBICS key management response is invalid"
- )
- respondText(text, ContentType.Application.Xml, HttpStatusCode.OK)
-}
-
-fun <T> expectNonNull(x: T?): T {
- if (x == null) {
- throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null
value")
- }
- return x;
-}
-
-private fun getRelatedParty(branch: XmlElementBuilder, payment:
XLibeufinBankTransaction) {
- val otherParty = object {
- var ibanPath = "CdtrAcct/Id/IBAN"
- var namePath = "Cdtr/Nm"
- var iban = payment.creditorIban
- var name = payment.creditorName
- var bicPath = "CdtrAgt/FinInstnId/BIC"
- var bic = payment.creditorBic
- }
- if (payment.direction == XLibeufinBankDirection.CREDIT) {
- otherParty.iban = payment.debtorIban
- otherParty.ibanPath = "DbtrAcct/Id/IBAN"
- otherParty.namePath = "Dbtr/Nm"
- otherParty.name = payment.debtorName
- otherParty.bic = payment.debtorBic
- otherParty.bicPath = "DbtrAgt/FinInstnId/BIC"
- }
- branch.element("RltdPties") {
- element(otherParty.namePath) {
- text(otherParty.name)
- }
- element(otherParty.ibanPath) {
- text(otherParty.iban)
- }
- }
- val otherPartyBic = otherParty.bic
- if (otherPartyBic != null) {
- branch.element("RltdAgts") {
- element(otherParty.bicPath) {
- text(otherPartyBic)
- }
- }
- }
-}
-
-// This should fix #6269.
-private fun getCreditDebitInd(balance: BigDecimal): String {
- if (balance < BigDecimal.ZERO) return "DBIT"
- return "CRDT"
-}
-
-fun buildCamtString(
- type: Int,
- subscriberIban: String,
- history: MutableList<XLibeufinBankTransaction>,
- currency: String
-): SandboxCamt {
- /**
- * ID types required:
- *
- * - Message Id
- * - Statement / Report Id
- * - Electronic sequence number
- * - Legal sequence number
- * - Entry Id by the Servicer
- * - Payment information Id
- * - Proprietary code of the bank transaction
- * - Id of the servicer (Issuer and Code)
- */
- val camtCreationTime = getSystemTimeNow() // FIXME: should this be the
payment time?
- val dashedDate = camtCreationTime.toDashedDate()
- val zonedDateTime = camtCreationTime.toZonedString()
- val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli()
- val messageId = "sandbox-${creationTimeMillis /
1000}-${getRandomString(10)}"
-
- val camtMessage = constructXml(indent = true) {
- root("Document") {
- attribute("xmlns",
"urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02")
- attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
- attribute(
- "xsi:schemaLocation",
- "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02
camt.0${type}.001.02.xsd"
- )
- element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") {
- element("GrpHdr") {
- element("MsgId") {
- text(messageId)
- }
- element("CreDtTm") {
- text(zonedDateTime)
- }
- }
- element(if (type == 52) "Rpt" else "Stmt") {
- element("Id") {
- text("0")
- }
- element("ElctrncSeqNb") {
- text("0")
- }
- element("LglSeqNb") {
- text("0")
- }
- element("CreDtTm") {
- text(zonedDateTime)
- }
- element("Acct") {
- // mandatory account identifier
- element("Id/IBAN") {
- text(subscriberIban)
- }
- element("Ccy") {
- text(currency)
- }
- element("Ownr/Nm") {
- text("Debitor/Owner Name")
- }
- element("Svcr/FinInstnId") {
- element("Nm") {
- text("Libeufin Bank")
- }
- element("Othr") {
- element("Id") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- }
- history.forEach {
- this.element("Ntry") {
- element("Amt") {
- attribute("Ccy", it.currency)
- text(it.amount)
- }
- element("CdtDbtInd") {
- text(
- if (subscriberIban.equals(it.creditorIban))
- "CRDT" else "DBIT"
- )
- }
- element("Sts") {
- /* Status of the entry (see 2.4.2.15.5 from
the ISO20022 reference document.)
- * From the original text:
- * "Status of an entry on the books of the
account servicer" */
- text("BOOK")
- }
- element("BookgDt/Dt") {
- text(dashedDate)
- } // date of the booking
- element("ValDt/Dt") {
- text(dashedDate)
- } // date of assets' actual (un)availability
- element("AcctSvcrRef") {
- text(it.uid)
- }
- element("BkTxCd") {
- /* "Set of elements used to fully identify
the type of underlying
- * transaction resulting in an entry". */
- element("Domn") {
- element("Cd") {
- text("PMNT")
- }
- element("Fmly") {
- element("Cd") {
- text("ICDT")
- }
- element("SubFmlyCd") {
- text("ESCT")
- }
- }
- }
- element("Prtry") {
- element("Cd") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- element("NtryDtls/TxDtls") {
- element("Refs") {
- element("MsgId") {
- text(it.msgId ?: "NOTPROVIDED")
- }
- element("PmtInfId") {
- text(it.pmtInfId ?: "NOTPROVIDED")
- }
- element("EndToEndId") {
- text(it.endToEndId ?: "NOTPROVIDED")
- }
- }
- element("AmtDtls/TxAmt/Amt") {
- attribute("Ccy", currency)
- text(it.amount)
- }
- element("BkTxCd") {
- element("Domn") {
- element("Cd") {
- text("PMNT")
- }
- element("Fmly") {
- element("Cd") {
- text("ICDT")
- }
- element("SubFmlyCd") {
- text("ESCT")
- }
- }
- }
- element("Prtry") {
- element("Cd") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- getRelatedParty(this, it)
- element("RmtInf/Ustrd") {
- text(it.subject)
- }
- }
- }
- }
- }
- }
- }
- }
- return SandboxCamt(
- camtMessage = camtMessage,
- messageId = messageId,
- creationTime = creationTimeMillis
- )
-}
-
-/**
- * Builds CAMT response.
- *
- * @param type 52 or 53.
- */
-private fun constructCamtResponse(
- type: Int,
- subscriber: EbicsSubscriberEntity,
- dateRange: Pair<Long, Long>?
-): List<String> {
- if (type != 53 && type != 52) throw EbicsUnsupportedOrderType()
- val bankAccount = getBankAccountFromSubscriber(subscriber)
- val history = mutableListOf<XLibeufinBankTransaction>()
- if (type == 52) {
- if (dateRange != null) {
- logger.debug("Finding date-ranged transactions for account:
${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}")
- transaction {
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.account eq bankAccount.id and
- BankAccountTransactionsTable.date.between(
- dateRange.first, dateRange.second
- )
- }.forEach {
history.add(getHistoryElementFromTransactionRow(it)) }
- }
- } else
- transaction {
- BankAccountFreshTransactionEntity.all().forEach {
- if (it.transactionRef.account.label == bankAccount.label) {
- history.add(getHistoryElementFromTransactionRow(it))
- }
- }
- }
- if (history.size == 0) throw EbicsNoDownloadDataAvailable()
- val camtData = buildCamtString(
- type,
- bankAccount.iban,
- history,
- bankAccount.demoBank.config.currency
- )
- val paymentsList: String = if (logger.isDebugEnabled) {
- var ret = " It includes the payments:"
- for (p in history) ret += "\n- ${p.subject}"
- ret
- } else ""
- logger.debug("camt.052 document '${camtData.messageId}'
generated.$paymentsList")
- return listOf(camtData.camtMessage)
- } // end of C52 case.
- val ret = mutableListOf<String>()
- /**
- * Retrieve all the records whose creation date lies into the
- * time range given in the function parameters.
- */
- if (dateRange != null) {
- logger.debug("Serving C53 with date range: $dateRange")
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.creationTime.between(
- dateRange.first,
- dateRange.second) and(
- BankAccountStatementsTable.bankAccount eq bankAccount.id)
- }.forEach {
- logger.debug("Including Camt.053: ${it.statementId}")
- ret.add(it.xmlMessage)
- }
- } else {
- logger.debug("Serving C53 without date range.")
- // No time range was given, hence pick the latest statement.
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq bankAccount.id
- }.lastOrNull().apply {
- if (this != null) {
- logger.debug("Including Camt.053: ${this.statementId}")
- ret.add(this.xmlMessage)
- }
- }
- }
- if (ret.size == 0) throw EbicsNoDownloadDataAvailable()
- return ret
-}
-
-/**
- * TSD (test download) message.
- *
- * This is a non-standard EBICS order type use by LibEuFin to
- * test download transactions.
- *
- * In the future, additional parameters (size, chunking, inject fault for
retry) might
- * be added to the order parameters.
- */
-private fun handleEbicsTSD(): ByteArray {
- return "Hello World\n".repeat(1024).toByteArray()
-}
-
-private fun handleEbicsPTK(): ByteArray {
- return "Hello I am a dummy PTK response.".toByteArray()
-}
-
-private fun parsePain001(paymentRequest: String): PainParseResult {
- val painDoc = XMLUtil.parseStringIntoDom(paymentRequest)
- return destructXml(painDoc) {
- requireRootElement("Document") {
- requireUniqueChildNamed("CstmrCdtTrfInitn") {
- val msgId = requireUniqueChildNamed("GrpHdr") {
- requireUniqueChildNamed("MsgId") {
focusElement.textContent }
- }
- requireUniqueChildNamed("PmtInf") {
- val debtorName = requireUniqueChildNamed("Dbtr"){
- requireUniqueChildNamed("Nm") {
- focusElement.textContent
- }
- }
- val debtorIban = requireUniqueChildNamed("DbtrAcct"){
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("IBAN") {
- focusElement.textContent
- }
- }
- }
- val debtorBic = requireUniqueChildNamed("DbtrAgt"){
- requireUniqueChildNamed("FinInstnId") {
- requireUniqueChildNamed("BIC") {
- focusElement.textContent
- }
- }
- }
- val pmtInfId = requireUniqueChildNamed("PmtInfId") {
focusElement.textContent }
- val txDetails = requireUniqueChildNamed("CdtTrfTxInf") {
- object {
- val creditorIban =
requireUniqueChildNamed("CdtrAcct") {
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("IBAN") {
focusElement.textContent }
- }
- }
- val creditorName = requireUniqueChildNamed("Cdtr")
{
- requireUniqueChildNamed("Nm") {
- focusElement.textContent
- }
- }
- val creditorBic = maybeUniqueChildNamed("CdtrAgt")
{
- requireUniqueChildNamed("FinInstnId") {
- requireUniqueChildNamed("BIC") {
- focusElement.textContent
- }
- }
- }
- val amt = requireUniqueChildNamed("Amt") {
- requireOnlyChild { focusElement }
- }
- val subject = requireUniqueChildNamed("RmtInf") {
- requireUniqueChildNamed("Ustrd") {
focusElement.textContent }
- }
- val endToEndId = requireUniqueChildNamed("PmtId") {
- requireUniqueChildNamed("EndToEndId") {
focusElement.textContent }
- }
- }
- }
- /**
- * NOTE: this check breaks the compatibility with pain.001,
- * because that allows up to 5 fractional digits. For
Taler
- * compatibility however, we enforce the max 2 fractional
digits policy.
- */
- if (!validatePlainAmount(txDetails.amt.textContent)) {
- throw EbicsProcessingError(
- "Amount number malformed:
${txDetails.amt.textContent}"
- )
- }
- PainParseResult(
- currency = txDetails.amt.getAttribute("Ccy"),
- amount = txDetails.amt.textContent,
- subject = txDetails.subject,
- debtorIban = debtorIban,
- debtorName = debtorName,
- debtorBic = debtorBic,
- creditorName = txDetails.creditorName,
- creditorIban = txDetails.creditorIban,
- creditorBic = txDetails.creditorBic,
- pmtInfId = pmtInfId,
- endToEndId = txDetails.endToEndId,
- msgId = msgId
- )
- }
- }
- }
- }
-}
-
-/**
- * Process a payment request in the pain.001 format. Note:
- * the receiver IBAN is NOT checked to have one account at
- * the Sandbox. That's because (1) it leaves open to send
- * payments outside of the running Sandbox and (2) may ease
- * tests where the preparation logic can skip creating also
- * the receiver account. */
-private fun handleCct(
- paymentRequest: String,
- requestingSubscriber: EbicsSubscriberEntity
-) {
- val parseResult = parsePain001(paymentRequest)
- logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " +
- "for payment: ${parseResult.subject}")
- transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) {
- // Check that subscriber has a bank account
- // and that they have rights over the debtor IBAN
- if (requestingSubscriber.bankAccount == null) throw
EbicsProcessingError(
- "Subscriber '${requestingSubscriber.userId}' does not have a bank
account."
- )
- if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban)
throw
- EbicsAccountAuthorisationFailed(
- "Subscriber '${requestingSubscriber.userId}' does not have
rights" +
- " over the debtor IBAN '${parseResult.debtorIban}'"
- )
- val maybeExist = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId
- }.firstOrNull()
- if (maybeExist != null) {
- logger.info(
- "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not
taking any action." +
- " Sandbox gave it this reference:
${maybeExist.accountServicerReference}"
- )
- return@transaction
- }
- val bankAccount = getBankAccountFromIban(parseResult.debtorIban)
- if (parseResult.currency != bankAccount.demoBank.config.currency)
throw EbicsRequestError(
- "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not
supported.",
- "091116"
- )
- // Check for the debit case.
- val maybeAmount = try {
- BigDecimal(parseResult.amount)
- } catch (e: Exception) {
- logger.warn("Although PAIN validated, BigDecimal didn't parse its
amount (${parseResult.amount})!")
- throw EbicsProcessingError("The CCT request contains an invalid
amount: ${parseResult.amount}")
- }
- if (maybeDebit(bankAccount.label, maybeAmount,
bankAccount.demoBank.name))
- throw EbicsAmountCheckError("The requested amount
(${parseResult.amount}) would exceed the debit threshold")
- logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}")
- wireTransfer(
- bankAccount.label,
- getBankAccountFromIban(parseResult.creditorIban).label,
- bankAccount.demoBank.name,
- parseResult.subject,
- "${parseResult.currency}:${parseResult.amount}",
- endToEndId = parseResult.endToEndId
- )
- }
-}
-
-/**
- * This handler reports all the fresh transactions, belonging
- * to the querying subscriber.
- */
-private fun handleEbicsC52(requestContext: RequestContext): ByteArray {
- val maybeDateRange =
requestContext.requestObject.header.static.orderDetails?.orderParams
- val dateRange: Pair<Long, Long>? = if (maybeDateRange is
EbicsRequest.StandardOrderParams) {
- val start: Long? =
maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis
- val end: Long? =
maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis
- Pair(start ?: 0L, end ?: Long.MAX_VALUE)
- } else null
- logger.debug("Date range: $dateRange")
- val report = constructCamtResponse(
- 52,
- requestContext.subscriber,
- dateRange = dateRange
- )
- sandboxAssert(
- report.size == 1,
- "C52 response contains more than one Camt.052 document"
- )
- if (!XMLUtil.validateFromString(report[0])) {
- logger.error("This document was generated invalid:\n${report[0]}")
- throw EbicsProcessingError("One outgoing report was found invalid.")
- }
- return report.map { it.toByteArray() }.zip()
-}
-
-private fun handleEbicsC53(requestContext: RequestContext): ByteArray {
- // Fetch date range.
- val orderParams =
requestContext.requestObject.header.static.orderDetails?.orderParams // as
EbicsRequest.StandardOrderParams
- val dateRange = if (orderParams != null) {
- val standardOrderParams = orderParams as
EbicsRequest.StandardOrderParams
- val start =
standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis
- val end =
standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis
- if (start == null || end == null) {
- // only accepting when both start/end are given.
- null
- } else {
- Pair(start, end)
- }
- } else
- null
- /**
- * By multiple statements, this function is responsible to return
- * a list of Strings: one for each statement.
- */
- val camtStatements = constructCamtResponse(
- 53,
- requestContext.subscriber,
- dateRange
- )
- camtStatements.forEach {
- if (!XMLUtil.validateFromString(it)) {
- logger.error("This document was generated invalid:\n$it")
- throw EbicsProcessingError("One outgoing statement was found
invalid.")
- }
- }
- return camtStatements.map { it.toByteArray() }.zip()
-}
-
-private suspend fun ApplicationCall.handleEbicsHia(header:
EbicsUnsecuredRequest.Header, orderData: ByteArray) {
- InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() }
- val keyObject =
EbicsOrderUtil.decodeOrderDataXml<HIARequestOrderData>(orderData)
- val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue
- val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue
- val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus,
encPubXml.exponent)
- val authPub =
CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus,
authPubXml.exponent)
-
- val ok = transaction {
- val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID,
header.static.userID, header.static.systemID)
- if (ebicsSubscriber == null) {
- logger.warn("ebics subscriber not found")
- throw EbicsInvalidRequestError()
- }
- when (ebicsSubscriber.state) {
- SubscriberState.NEW -> {}
- SubscriberState.PARTIALLY_INITIALIZED_INI -> {}
- SubscriberState.PARTIALLY_INITIALIZED_HIA,
SubscriberState.INITIALIZED, SubscriberState.READY -> {
- return@transaction false
- }
- }
-
- ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new
{
- this.rsaPublicKey = ExposedBlob(authPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new {
- this.rsaPublicKey = ExposedBlob(encPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.state = when (ebicsSubscriber.state) {
- SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA
- SubscriberState.PARTIALLY_INITIALIZED_INI ->
SubscriberState.INITIALIZED
- else -> throw Exception("internal invariant failed")
- }
- return@transaction true
- }
- if (ok) {
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000")
- } else {
- respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]",
"091002", "000000")
- }
-}
-
-private suspend fun ApplicationCall.handleEbicsIni(header:
EbicsUnsecuredRequest.Header, orderData: ByteArray) {
- InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() }
- val keyObject =
EbicsOrderUtil.decodeOrderDataXml<SignatureTypes.SignaturePubKeyOrderData>(orderData)
- val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue
- val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus,
sigPubXml.exponent)
-
- val ok = transaction {
- val ebicsSubscriber =
- findEbicsSubscriber(header.static.partnerID, header.static.userID,
header.static.systemID)
- if (ebicsSubscriber == null) {
- logger.warn("ebics subscriber,
${dumpEbicsSubscriber(header.static)}, not found")
- throw EbicsUserUnknown(dumpEbicsSubscriber(header.static))
- }
- when (ebicsSubscriber.state) {
- SubscriberState.NEW -> {}
- SubscriberState.PARTIALLY_INITIALIZED_HIA -> {}
- SubscriberState.PARTIALLY_INITIALIZED_INI,
SubscriberState.INITIALIZED, SubscriberState.READY -> {
- return@transaction false
- }
- }
- ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new {
- this.rsaPublicKey = ExposedBlob(sigPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.state = when (ebicsSubscriber.state) {
- SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI
- SubscriberState.PARTIALLY_INITIALIZED_HIA ->
SubscriberState.INITIALIZED
- else -> throw Error("internal invariant failed")
- }
- return@transaction true
- }
- logger.info("Signature key inserted in database _and_ subscriber state
changed accordingly")
- if (ok) {
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000")
- } else {
- respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]",
"091002", "000000")
- }
-}
-
-private suspend fun ApplicationCall.handleEbicsHpb(
- ebicsHostInfo: EbicsHostPublicInfo,
- requestDocument: Document,
- header: EbicsNpkdRequest.Header
-) {
- val subscriberKeys = transaction {
- val ebicsSubscriber =
- findEbicsSubscriber(header.static.partnerID, header.static.userID,
header.static.systemID)
- if (ebicsSubscriber == null) {
- throw EbicsInvalidRequestError()
- }
- if (ebicsSubscriber.state != SubscriberState.INITIALIZED) {
- throw EbicsSubscriberStateError()
- }
- val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey
- val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey
- val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey
- SubscriberKeys(
- CryptoUtil.loadRsaPublicKey(authPubBlob.bytes),
- CryptoUtil.loadRsaPublicKey(encPubBlob.bytes),
- CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes)
- )
- }
- val validationResult =
- XMLUtil.verifyEbicsDocument(requestDocument,
subscriberKeys.authenticationPublicKey)
- if (!validationResult) {
- throw EbicsKeyManagementError("invalid signature", "90000")
- }
- val hpbRespondeData = HPBResponseOrderData().apply {
- this.authenticationPubKeyInfo =
EbicsTypes.AuthenticationPubKeyInfoType().apply {
- this.authenticationVersion = "X002"
- this.pubKeyValue = EbicsTypes.PubKeyValueType().apply {
- this.rsaKeyValue = RSAKeyValueType().apply {
- this.exponent =
ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray()
- this.modulus =
ebicsHostInfo.authenticationPublicKey.modulus.toByteArray()
- }
- }
- }
- this.encryptionPubKeyInfo =
EbicsTypes.EncryptionPubKeyInfoType().apply {
- this.encryptionVersion = "E002"
- this.pubKeyValue = EbicsTypes.PubKeyValueType().apply {
- this.rsaKeyValue = RSAKeyValueType().apply {
- this.exponent =
ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray()
- this.modulus =
ebicsHostInfo.encryptionPublicKey.modulus.toByteArray()
- }
- }
- }
- this.hostID = ebicsHostInfo.hostID
- }
- val compressedOrderData =
EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData)
- val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData,
subscriberKeys.encryptionPublicKey)
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000",
encryptionResult, "OR01")
-}
-
-/**
- * Find the ebics host corresponding to the one specified in the header.
- */
-private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo {
- return transaction {
- val ebicsHost =
- EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq
requestHostID.uppercase(Locale.getDefault()) }.firstOrNull()
- if (ebicsHost == null) {
- logger.warn("client requested unknown HostID ${requestHostID}")
- throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011")
- }
- val encryptionPrivateKey =
CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes)
- val authenticationPrivateKey =
CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes)
- EbicsHostPublicInfo(
- requestHostID,
- CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey),
- CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey)
- )
- }
-}
-fun receiveEbicsXmlInternal(xmlData: String): Document {
- // logger.debug("Data received: $xmlData")
- val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData)
- if (!XMLUtil.validateFromDom(requestDocument)) {
- println("Problematic document was: $requestDocument")
- throw EbicsInvalidXmlError()
- }
- return requestDocument
-}
-
-private fun makePartnerInfo(subscriber: EbicsSubscriberEntity):
EbicsTypes.PartnerInfo {
- val bankAccount = getBankAccountFromSubscriber(subscriber)
- val customerProfile = getCustomer(bankAccount.label)
- return EbicsTypes.PartnerInfo().apply {
- this.accountInfoList = listOf(
- EbicsTypes.AccountInfo().apply {
- this.id = bankAccount.label
- this.accountHolder = customerProfile.name ?: "Never Given"
- this.accountNumberList = listOf(
- EbicsTypes.GeneralAccountNumber().apply {
- this.international = true
- this.value = bankAccount.iban
- }
- )
- this.currency = bankAccount.demoBank.config.currency
- this.description = "Ordinary Bank Account"
- this.bankCodeList = listOf(
- EbicsTypes.GeneralBankCode().apply {
- this.international = true
- this.value = bankAccount.bic
- }
- )
- }
- )
- this.addressInfo = EbicsTypes.AddressInfo().apply {
- this.name = "Address Info Object"
- }
- this.bankInfo = EbicsTypes.BankInfo().apply {
- this.hostID = subscriber.hostId
- }
- this.orderInfoList = listOf(
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Transactions statement"
- this.orderType = "C53"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Transactions report"
- this.orderType = "C52"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Payment initiation (ZIPped payload)"
- this.orderType = "CCC"
- this.transferType = "Upload"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Payment initiation (plain text payload)"
- this.orderType = "CCT"
- this.transferType = "Upload"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "vmk"
- this.orderType = "VMK"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "sta"
- this.orderType = "STA"
- this.transferType = "Download"
- }
- )
- }
-}
-
-private fun handleEbicsHtd(requestContext: RequestContext): ByteArray {
- val htd = HTDResponseOrderData().apply {
- this.partnerInfo = makePartnerInfo(requestContext.subscriber)
- this.userInfo = EbicsTypes.UserInfo().apply {
- this.name = "Some User"
- this.userID = EbicsTypes.UserIDType().apply {
- this.status = 5
- this.value = requestContext.subscriber.userId
- }
- this.permissionList = listOf(
- EbicsTypes.UserPermission().apply {
- this.orderTypes = "C53 C52 CCC VMK STA"
- }
- )
- }
- }
- val str = XMLUtil.convertJaxbToString(htd)
- return str.toByteArray()
-}
-
-private fun handleEbicsHkd(requestContext: RequestContext): ByteArray {
- val hkd = HKDResponseOrderData().apply {
- this.partnerInfo = makePartnerInfo(requestContext.subscriber)
- this.userInfoList = listOf(
- EbicsTypes.UserInfo().apply {
- this.name = "Some User"
- this.userID = EbicsTypes.UserIDType().apply {
- this.status = 1
- this.value = requestContext.subscriber.userId
- }
- this.permissionList = listOf(
- EbicsTypes.UserPermission().apply {
- this.orderTypes = "C54 C53 C52 CCC"
- }
- )
- })
- }
- val str = XMLUtil.convertJaxbToString(hkd)
- return str.toByteArray()
-}
-
-private data class RequestContext(
- val ebicsHost: EbicsHostEntity,
- val subscriber: EbicsSubscriberEntity,
- val clientEncPub: RSAPublicKey,
- val clientAuthPub: RSAPublicKey,
- val clientSigPub: RSAPublicKey,
- val hostEncPriv: RSAPrivateCrtKey,
- val hostAuthPriv: RSAPrivateCrtKey,
- val requestObject: EbicsRequest,
- val uploadTransaction: EbicsUploadTransactionEntity?,
- val downloadTransaction: EbicsDownloadTransactionEntity?
-)
-
-/**
- * Get segmentation values and the EBICS transaction ID, before
- * handing the response to 'createForDownloadTransferPhase()'.
- */
-private fun handleEbicsDownloadTransactionTransfer(requestContext:
RequestContext): EbicsResponse {
- val segmentNumber =
- requestContext.requestObject.header.mutable.segmentNumber?.value ?:
throw EbicsInvalidRequestError()
- val transactionID =
requestContext.requestObject.header.static.transactionID ?: throw
EbicsInvalidRequestError()
- val downloadTransaction = requestContext.downloadTransaction ?: throw
AssertionError()
- return EbicsResponse.createForDownloadTransferPhase(
- transactionID,
- downloadTransaction.numSegments,
- downloadTransaction.segmentSize,
- downloadTransaction.encodedResponse,
- segmentNumber.toInt()
- )
-}
-
-private fun handleEbicsDownloadTransactionInitialization(requestContext:
RequestContext): EbicsResponse {
- val orderType =
- requestContext.requestObject.header.static.orderDetails?.orderType ?:
throw EbicsInvalidRequestError()
- val nonce = requestContext.requestObject.header.static.nonce
- val transactionID = EbicsOrderUtil.generateTransactionId()
- logger.debug(
- "Handling download initialization for order type $orderType, " +
- "nonce: ${nonce?.toHexString() ?: "not given"}, " +
- "transaction ID: $transactionID"
- )
- val response = when (orderType) {
- "HTD" -> handleEbicsHtd(requestContext)
- "HKD" -> handleEbicsHkd(requestContext)
- "C53" -> handleEbicsC53(requestContext)
- "C52" -> handleEbicsC52(requestContext)
- "TSD" -> handleEbicsTSD()
- "PTK" -> handleEbicsPTK()
- else -> throw EbicsInvalidXmlError()
- }
- val compressedResponse = DeflaterInputStream(response.inputStream()).use {
- it.readAllBytes()
- }
- val enc = CryptoUtil.encryptEbicsE002(compressedResponse,
requestContext.clientEncPub)
- val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData)
-
- val segmentSize = 4096
- val totalSize = encodedResponse.length
- val numSegments = ((totalSize + segmentSize - 1) / segmentSize)
-
- EbicsDownloadTransactionEntity.new(transactionID) {
- this.subscriber = requestContext.subscriber
- this.host = requestContext.ebicsHost
- this.orderType = orderType
- this.segmentSize = segmentSize
- this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey)
- this.encodedResponse = encodedResponse
- this.numSegments = numSegments
- this.receiptReceived = false
- }
- /**
- * In case of C52, the payload (that includes all the pending
- * transactions) got at this point persisted into the database.
- * The next block causes such transactions NOT to be returned
- * along the next C52 request.
- */
- if (orderType == "C52") {
- val account = getBankAccountFromSubscriber(requestContext.subscriber)
- BankAccountFreshTransactionEntity.all().forEach {
- if (it.transactionRef.account.label == account.label)
- it.delete()
- }
- }
- return EbicsResponse.createForDownloadInitializationPhase(
- transactionID,
- numSegments,
- segmentSize,
- enc, // has customer key
- encodedResponse
- )
-}
-
-private fun handleEbicsUploadTransactionInitialization(requestContext:
RequestContext): EbicsResponse {
- val orderType =
- requestContext.requestObject.header.static.orderDetails?.orderType ?:
throw EbicsInvalidRequestError()
- val transactionID = EbicsOrderUtil.generateTransactionId()
- logger.debug("Handling upload initialization for order $orderType, " +
- "transactionID $transactionID, nonce: " +
- (requestContext.requestObject.header.static.nonce?.toHexString()
?: "not given")
- )
- val oidn = requestContext.subscriber.nextOrderID++
- if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError()
- val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn)
- val numSegments =
- requestContext.requestObject.header.static.numSegments ?: throw
EbicsInvalidRequestError()
- val transactionKeyEnc =
-
requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey
- ?: throw EbicsInvalidRequestError()
- val encPubKeyDigest =
-
requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value
- ?: throw EbicsInvalidRequestError()
- val encSigData =
requestContext.requestObject.body.dataTransfer?.signatureData?.value
- ?: throw EbicsInvalidRequestError()
- val decryptedSignatureData = CryptoUtil.decryptEbicsE002(
- CryptoUtil.EncryptionResult(
- transactionKeyEnc,
- encPubKeyDigest,
- encSigData
- ), requestContext.hostEncPriv
- )
- val plainSigData =
InflaterInputStream(decryptedSignatureData.inputStream()).use {
- it.readAllBytes()
- }
- EbicsUploadTransactionEntity.new(transactionID) {
- this.host = requestContext.ebicsHost
- this.subscriber = requestContext.subscriber
- this.lastSeenSegment = 0
- this.orderType = orderType
- this.orderID = orderID
- this.numSegments = numSegments.toInt()
- this.transactionKeyEnc = ExposedBlob(transactionKeyEnc)
- }
- val sigObj =
XMLUtil.convertStringToJaxb<UserSignatureData>(plainSigData.toString(Charsets.UTF_8))
- for (sig in sigObj.value.orderSignatureList ?: listOf()) {
- logger.debug("inserting order signature for orderID $orderID, order
type $orderType, transaction '$transactionID'")
- EbicsOrderSignatureEntity.new {
- this.orderID = orderID
- this.orderType = orderType
- this.partnerID = sig.partnerID
- this.userID = sig.userID
- this.signatureAlgorithm = sig.signatureVersion
- this.signatureValue = ExposedBlob(sig.signatureValue)
- }
- }
- return EbicsResponse.createForUploadInitializationPhase(transactionID,
orderID)
-}
-
-private fun handleEbicsUploadTransactionTransmission(requestContext:
RequestContext): EbicsResponse {
- val uploadTransaction = requestContext.uploadTransaction ?: throw
EbicsInvalidRequestError()
- val requestObject = requestContext.requestObject
- val requestSegmentNumber =
-
requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?:
throw EbicsInvalidRequestError()
- val requestTransactionID = requestObject.header.static.transactionID ?:
throw EbicsInvalidRequestError()
- if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) {
- val encOrderData =
- requestObject.body.dataTransfer?.orderData ?: throw
EbicsInvalidRequestError()
- val zippedData = CryptoUtil.decryptEbicsE002(
- uploadTransaction.transactionKeyEnc.bytes,
- Base64.getDecoder().decode(encOrderData),
- requestContext.hostEncPriv
- )
- val unzippedData =
- InflaterInputStream(zippedData.inputStream()).use {
it.readAllBytes() }
-
- val sigs = EbicsOrderSignatureEntity.find {
- (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID)
and
- (EbicsOrderSignaturesTable.orderType eq
uploadTransaction.orderType)
- }
- if (sigs.count() == 0L) {
- throw EbicsInvalidRequestError()
- }
- for (sig in sigs) {
- if (sig.signatureAlgorithm == "A006") {
-
- val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData)
- val res1 = CryptoUtil.verifyEbicsA006(
- sig.signatureValue.bytes,
- signedData,
- requestContext.clientSigPub
- )
- if (!res1) {
- throw EbicsInvalidRequestError()
- }
-
- } else {
- throw NotImplementedError()
- }
- }
- if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") {
- handleCct(unzippedData.toString(Charsets.UTF_8),
- requestContext.subscriber
- )
- }
- return EbicsResponse.createForUploadTransferPhase(
- requestTransactionID,
- requestSegmentNumber,
- true,
- uploadTransaction.orderID
- )
- } else {
- throw NotImplementedError()
- }
-}
-// req.header.static.hostID.
-private fun makeRequestContext(requestObject: EbicsRequest): RequestContext {
- val staticHeader = requestObject.header.static
- val requestedHostId = staticHeader.hostID
- val ebicsHost =
- EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq
requestedHostId.uppercase(Locale.getDefault()) }
- .firstOrNull()
- val requestTransactionID = requestObject.header.static.transactionID
- var downloadTransaction: EbicsDownloadTransactionEntity? = null
- var uploadTransaction: EbicsUploadTransactionEntity? = null
- val subscriber = if (requestTransactionID != null) {
- downloadTransaction =
EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault()))
- if (downloadTransaction != null) {
- downloadTransaction.subscriber
- } else {
- uploadTransaction =
EbicsUploadTransactionEntity.findById(requestTransactionID)
- uploadTransaction?.subscriber
- }
- } else {
- val partnerID = staticHeader.partnerID ?: throw
EbicsInvalidRequestError()
- val userID = staticHeader.userID ?: throw EbicsInvalidRequestError()
- findEbicsSubscriber(partnerID, userID, staticHeader.systemID)
- }
-
- if (ebicsHost == null) throw EbicsInvalidRequestError()
-
- /**
- * NOTE: production logic must check against READY state (the
- * one activated after the subscriber confirms their keys via post)
- */
- if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED)
- throw EbicsSubscriberStateError()
-
- val hostAuthPriv = CryptoUtil.loadRsaPrivateKey(
- ebicsHost.authenticationPrivateKey.bytes
- )
- val hostEncPriv = CryptoUtil.loadRsaPrivateKey(
- ebicsHost.encryptionPrivateKey.bytes
- )
- val clientAuthPub =
-
CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes)
- val clientEncPub =
-
CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes)
- val clientSigPub =
-
CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes)
-
- return RequestContext(
- hostAuthPriv = hostAuthPriv,
- hostEncPriv = hostEncPriv,
- clientAuthPub = clientAuthPub,
- clientEncPub = clientEncPub,
- clientSigPub = clientSigPub,
- ebicsHost = ebicsHost,
- requestObject = requestObject,
- subscriber = subscriber,
- downloadTransaction = downloadTransaction,
- uploadTransaction = uploadTransaction
- )
-}
-
-suspend fun ApplicationCall.ebicsweb() {
- val requestDocument = this.request.call.receive<Document>()
- val requestedHostID = requestDocument.getElementsByTagName("HostID")
- this.attributes.put(
- EbicsHostIdAttribute,
- requestedHostID.item(0).textContent
- )
- when (requestDocument.documentElement.localName) {
- "ebicsUnsecuredRequest" -> {
- val requestObject =
requestDocument.toObject<EbicsUnsecuredRequest>()
- logger.info("Serving a
${requestObject.header.static.orderDetails.orderType} request")
-
- val orderData = requestObject.body.dataTransfer.orderData.value
- val header = requestObject.header
-
- when (header.static.orderDetails.orderType) {
- "INI" -> handleEbicsIni(header, orderData)
- "HIA" -> handleEbicsHia(header, orderData)
- else -> throw EbicsInvalidXmlError()
- }
- }
- "ebicsHEVRequest" -> {
- val hevResponse = HEVResponse().apply {
- this.systemReturnCode = SystemReturnCodeType().apply {
- this.reportText = "[EBICS_OK]"
- this.returnCode = "000000"
- }
- this.versionNumber =
listOf(HEVResponse.VersionNumber.create("H004", "02.50"))
- }
-
- val strResp = XMLUtil.convertJaxbToString(hevResponse)
- if (!XMLUtil.validateFromString(strResp)) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoing HEV response is invalid"
- )
- respondText(strResp, ContentType.Application.Xml,
HttpStatusCode.OK)
- }
- // FIXME: should check subscriber state?
- "ebicsNoPubKeyDigestsRequest" -> {
- val requestObject = requestDocument.toObject<EbicsNpkdRequest>()
- val hostInfo = ensureEbicsHost(requestObject.header.static.hostID)
- when (requestObject.header.static.orderDetails.orderType) {
- "HPB" -> handleEbicsHpb(hostInfo, requestDocument,
requestObject.header)
- else -> throw EbicsInvalidXmlError()
- }
- }
- // FIXME: must check subscriber state.
- "ebicsRequest" -> {
- val requestObject = requestDocument.toObject<EbicsRequest>()
- val responseXmlStr =
transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) {
- // Step 1 of 3: Get information about the host and subscriber
- val requestContext = makeRequestContext(requestObject)
- // Step 2 of 3: Validate the signature
- val verifyResult =
XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub)
- if (!verifyResult) {
- throw EbicsAccountAuthorisationFailed("Subscriber's
signature did not verify")
- }
- // Step 3 of 3: Generate response
- val ebicsResponse: EbicsResponse = when
(requestObject.header.mutable.transactionPhase) {
- EbicsTypes.TransactionPhaseType.INITIALISATION -> {
- if (requestObject.header.static.numSegments == null) {
-
handleEbicsDownloadTransactionInitialization(requestContext)
- } else {
-
handleEbicsUploadTransactionInitialization(requestContext)
- }
- }
- EbicsTypes.TransactionPhaseType.TRANSFER -> {
- if (requestContext.uploadTransaction != null) {
-
handleEbicsUploadTransactionTransmission(requestContext)
- } else if (requestContext.downloadTransaction != null)
{
-
handleEbicsDownloadTransactionTransfer(requestContext)
- } else {
- throw AssertionError()
- }
- }
- EbicsTypes.TransactionPhaseType.RECEIPT -> {
- val requestTransactionID =
- requestObject.header.static.transactionID ?: throw
EbicsInvalidRequestError()
- if (requestContext.downloadTransaction == null)
- throw EbicsInvalidRequestError()
- logger.debug("Handling download receipt for EBICS
transaction: " +
- requestTransactionID)
- /**
- * The receipt phase means that the client has already
- * received all the data related to the current
download
- * transaction. Hence this data can now be removed
from
- * the database.
- */
- val ebicsData = transaction {
-
EbicsDownloadTransactionEntity.findById(requestTransactionID)
- }
- if (ebicsData == null)
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "EBICS transaction $requestTransactionID was
not" +
- "found in the database for deletion.",
-
LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE
- )
- ebicsData.delete()
- val receiptCode =
- requestObject.body.transferReceipt?.receiptCode ?:
throw EbicsInvalidRequestError()
-
EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode
== 0)
- }
- }
- signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv)
- }
- if (!XMLUtil.validateFromString(responseXmlStr)) throw
SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoing EBICS XML is invalid"
- )
- respondText(responseXmlStr, ContentType.Application.Xml,
HttpStatusCode.OK)
- }
- else -> {
- /* Log to console and return "unknown type" */
- logger.info("Unknown message, just logging it!")
- respond(
- HttpStatusCode.NotImplemented,
- SandboxError(
- HttpStatusCode.NotImplemented,
- "Not Implemented"
- )
- )
- }
- }
-}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt
b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt
deleted file mode 100644
index f0326a00..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt
+++ /dev/null
@@ -1,472 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2020 Taler Systems S.A.
- *
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
- *
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.bank
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.SerializationFeature
-import io.ktor.server.application.*
-import io.ktor.http.HttpStatusCode
-import io.ktor.server.request.*
-import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.security.interfaces.RSAPublicKey
-import java.util.*
-import java.util.zip.DeflaterInputStream
-import kotlin.reflect.KProperty
-
-data class DemobankConfig(
- val allowRegistrations: Boolean,
- val currency: String,
- val cashoutCurrency: String? = null,
- val bankDebtLimit: Int,
- val usersDebtLimit: Int,
- val withSignupBonus: Boolean,
- val demobankName: String, // demobank name.
- val captchaUrl: String? = null,
- val smsTan: String? = null, // fixme: move the config subcommand
- val emailTan: String? = null, // fixme: same as above.
- val suggestedExchangeBaseUrl: String? = null,
- val suggestedExchangePayto: String? = null,
- val nexusBaseUrl: String? = null,
- val usernameAtNexus: String? = null,
- val passwordAtNexus: String? = null,
- val enableConversionService: Boolean = false
-)
-
-fun <T>getConfigValueOrThrow(configKey: KProperty<T?>): T {
- return configKey.getter.call() ?: throw
nullConfigValueError(configKey.name)
-}
-
-/**
- * Helps to communicate Camt values without having
- * to parse the XML each time one is needed.
- */
-data class SandboxCamt(
- val camtMessage: String,
- val messageId: String,
- /**
- * That is the number of SECONDS since Epoch. This
- * value is exactly what goes into the Camt document.
- */
- val creationTime: Long
-)
-
-/**
- * DB helper inserting a new "account" into the database.
- * The account is made of a 'customer' and 'bank account'
- * object. The helper checks first that the username is
- * acceptable (chars, no institutional names, available
- * names); then checks that IBAN is available and then adds
- * the two database objects under the given demobank. This
- * function contains the common logic shared by the Access
- * and Circuit API. Additional data that is peculiar to one
- * API should be added separately.
- *
- * It returns a AccountPair type. That contains the customer
- * object and the bank account; the caller may this way add custom
- * values to them. */
-data class AccountPair(
- val customer: DemobankCustomerEntity,
- val bankAccount: BankAccountEntity
-)
-fun insertNewAccount(username: String,
- password: String,
- name: String? = null, // tests and access API may not
give one.
- iban: String? = null,
- demobank: String = "default",
- isPublic: Boolean = false): AccountPair {
- requireValidResourceName(username)
- // Forbid institutional usernames.
- if (username == "bank" || username == "admin") {
- logger.info("Username: $username not allowed.")
- throw forbidden("Username: $username is not allowed.")
- }
- return transaction {
- val demobankFromDb = getDemobank(demobank)
- // Bank's fault, because when this function gets
- // called, the demobank must exist.
- if (demobankFromDb == null) {
- logger.error("Demobank '$demobank' not found. Won't add account
$username")
- throw internalServerError("Demobank $demobank not found. Won't
add account $username")
- }
- // Generate a IBAN if the caller didn't provide one.
- val newIban = iban ?: getIban()
- // Check IBAN collisions.
- val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq
newIban).firstOrNull()
- if (checkIbanExist != null) {
- logger.info("IBAN $newIban not available. Won't register username
$username")
- throw conflict("IBAN $iban not available.")
- }
- // Check username availability.
- val checkCustomerExist = DemobankCustomerEntity.find {
- DemobankCustomersTable.username eq username
- }.firstOrNull()
- if (checkCustomerExist != null) {
- throw SandboxError(
- HttpStatusCode.Conflict,
- "Username $username not available."
- )
- }
- val newCustomer = DemobankCustomerEntity.new {
- this.username = username
- passwordHash = CryptoUtil.hashpw(password)
- this.name = name // nullable
- }
- // Actual account creation.
- val newBankAccount = BankAccountEntity.new {
- this.iban = newIban
- /**
- * For now, keep same semantics of Pybank: a username
- * is AS WELL a bank account label. In other words, it
- * identifies a customer AND a bank account. The reason
- * to have the two values (label and owner) is to allow
- * multiple bank accounts being owned by one customer.
- */
- label = username
- owner = username
- this.demoBank = demobankFromDb
- this.isPublic = isPublic
- }
- if (demobankFromDb.config.withSignupBonus)
- newBankAccount.bonus("${demobankFromDb.config.currency}:100")
- AccountPair(customer = newCustomer, bankAccount = newBankAccount)
- }
-}
-
-/**
- * Return true if access to the bank account can be granted,
- * false otherwise.
- *
- * Given the policy of having bank account names matching
- * their owner's username, this function enforces such policy
- * with the exception that 'admin' can access every bank
- * account. A null username indicates disabled authentication
- * checks, hence it grants the access.
- */
-fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean {
- if (username == null) return true
- if (username == "admin") return true
- return username == bankAccountLabel
-}
-
-/**
- * Throws exception if the credentials are wrong.
- *
- * Return:
- * - null if the authentication is disabled (during tests, for example).
- * This facilitates tests because allows requests to lack entirely an
- * Authorization header.
- * - the username of the authenticated user
- * - throw exception when the authentication fails
- *
- * Note: at this point it is ONLY checked whether the user provided
- * a valid password for the username mentioned in the Authorization header.
- * The actual access to the resources must be later checked by each handler.
- */
-fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? {
- val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY)
- if (!withAuth) {
- logger.info("Authentication is disabled - assuming tests currently
running.")
- return null
- }
- val credentials = getHTTPBasicAuthCredentials(this)
- if (credentials.first == "admin") {
- // env must contain the admin password, because --with-auth is true.
- val adminPassword: String =
this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY)
- if (credentials.second != adminPassword) throw unauthorized(
- "Admin authentication failed"
- )
- return credentials.first
- }
- if (onlyAdmin) throw forbidden("Only admin allowed.")
- val passwordHash = transaction {
- val customer = getCustomer(credentials.first)
- customer.passwordHash
- }
- if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash))
- throw unauthorized("Customer '${credentials.first}' gave wrong
credentials")
- return credentials.first
-}
-
-fun sandboxAssert(condition: Boolean, reason: String) {
- if (!condition) throw SandboxError(HttpStatusCode.InternalServerError,
reason)
-}
-
-fun getOrderTypeFromTransactionId(transactionID: String): String {
- val uploadTransaction = transaction {
- EbicsUploadTransactionEntity.findById(transactionID)
- } ?: throw SandboxError(
- /**
- * NOTE: at this point, it might even be the server's fault.
- * For example, if it failed to store a ID earlier.
- */
- HttpStatusCode.NotFound,
- "Could not retrieve order type for transaction: $transactionID"
- )
- return uploadTransaction.orderType
-}
-
-fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity):
XLibeufinBankTransaction {
- return XLibeufinBankTransaction(
- subject = dbRow.subject,
- creditorIban = dbRow.creditorIban,
- creditorBic = dbRow.creditorBic,
- creditorName = dbRow.creditorName,
- debtorIban = dbRow.debtorIban,
- debtorBic = dbRow.debtorBic,
- debtorName = dbRow.debtorName,
- date = dbRow.date.toString(),
- amount = dbRow.amount,
- currency = dbRow.currency,
- // UID assigned by the bank itself.
- uid = dbRow.accountServicerReference,
- direction =
XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction),
- // UIDs as gotten from a pain.001 (from EBICS connections.)
- pmtInfId = dbRow.pmtInfId,
- endToEndId = dbRow.endToEndId
- )
-}
-
-fun printConfig(demobank: DemobankConfigEntity) {
- val ret = ObjectMapper()
- ret.configure(SerializationFeature.INDENT_OUTPUT, true)
- println(
- ret.writeValueAsString(object {
- val currency = demobank.config.currency
- val bankDebtLimit = demobank.config.bankDebtLimit
- val usersDebtLimit = demobank.config.usersDebtLimit
- val allowRegistrations = demobank.config.allowRegistrations
- val name = demobank.name // always 'default'
- val withSignupBonus = demobank.config.withSignupBonus
- val captchaUrl = demobank.config.captchaUrl
- val suggestedExchangeBaseUrl =
demobank.config.suggestedExchangeBaseUrl
- val suggestedExchangePayto = demobank.config.suggestedExchangePayto
- })
- )
-}
-
-fun getHistoryElementFromTransactionRow(
- dbRow: BankAccountFreshTransactionEntity
-): XLibeufinBankTransaction {
- return getHistoryElementFromTransactionRow(dbRow.transactionRef)
-}
-
-/**
- * Need to be called within a transaction {} block. It
- * is acceptable to pass a bank account's label as the
- * parameter, because usernames can only own one bank
- * account whose label equals the owner's username.
- *
- * Future versions may relax this policy to allow one
- * customer to own multiple bank accounts.
- */
-fun getCustomer(username: String): DemobankCustomerEntity {
- return maybeGetCustomer(username) ?: throw notFound("Customer
'${username}' not found")
-}
-fun maybeGetCustomer(username: String): DemobankCustomerEntity? {
- return transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.username eq username
- }.firstOrNull()
- }
-}
-
-/**
- * Get person name from a customer's username, or throw
- * exception if not found.
- */
-fun getPersonNameFromCustomer(customerUsername: String): String {
- return when (customerUsername) {
- "admin" -> "Admin"
- else -> transaction {
- val ownerCustomer = DemobankCustomerEntity.find(
- DemobankCustomersTable.username eq customerUsername
- ).firstOrNull() ?: run {
- logger.error("Customer '${customerUsername}' not found,
couldn't get their name.")
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "'$customerUsername' not a customer."
- )
-
- }
- ownerCustomer.name ?: "Never given."
- }
- }
-}
-
-fun getDefaultDemobank(): DemobankConfigEntity {
- return transaction {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq "default"
- }.firstOrNull()
- } ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Default demobank is missing."
- )
-}
-
-fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity {
- val uuid = parseUuid(opId)
- return transaction {
- TalerWithdrawalEntity.find {
- TalerWithdrawalsTable.wopid eq uuid
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound, "Withdrawal operation $opId not found."
- )
- }
-}
-
-fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity {
- val paytoParse = parsePayto(paytoUri)
- return getBankAccountFromIban(paytoParse.iban)
-}
-
-fun getBankAccountFromIban(iban: String): BankAccountEntity {
- return transaction {
- BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull()
- } ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Did not find a bank account for $iban"
- )
-}
-
-/**
- * The argument 'withBankFault' represents the case where
- * _the bank_ must ensure that a resource (in this case a bank
- * account) exists. For example, every 'customer' should have
- * a 'bank account', and if a customer is found without a bank
- * account, then the bank broke such condition.
- */
-fun getBankAccountFromLabel(
- label: String,
- demobank: String = "default",
- withBankFault: Boolean = false
-): BankAccountEntity {
- val maybeDemobank = getDemobank(demobank)
- if (maybeDemobank == null) {
- logger.error("Demobank '$demobank' not found")
- throw SandboxError(
- HttpStatusCode.NotFound,
- "Demobank '$demobank' not found"
- )
- }
- return getBankAccountFromLabel(
- label,
- maybeDemobank,
- withBankFault
- )
-}
-
-// Get bank account DAO, given its name and demobank.
-fun getBankAccountFromLabel(
- label: String,
- demobank: DemobankConfigEntity,
- withBankFault: Boolean = false // documented along the other same-named
function.
-): BankAccountEntity {
- val maybeBankAccount = transaction {
- BankAccountEntity.find(
- BankAccountsTable.label eq label and (
- BankAccountsTable.demoBank eq demobank.id
- )
- ).firstOrNull()
- }
- if (maybeBankAccount == null && withBankFault)
- throw internalServerError(
- "Bank account $label was not found, but it should."
- )
- if (maybeBankAccount == null)
- throw notFound(
- "Bank account $label was not found."
- )
- return maybeBankAccount
-}
-
-fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity):
BankAccountEntity {
- return transaction {
- subscriber.bankAccount ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Subscriber doesn't have any bank account"
- )
- }
-}
-
-fun BankAccountEntity.bonus(amount: String) {
- wireTransfer(
- "admin",
- this.label,
- this.demoBank.name,
- "Sign-up bonus",
- amount
- )
-}
-
-fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity {
- return ensureDemobank(call.expectUriComponent("demobankid"))
-}
-
-fun ensureDemobank(name: String): DemobankConfigEntity {
- return transaction {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq name
- }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it
ever created?")
- }
-}
-
-fun getDemobank(name: String?): DemobankConfigEntity? {
- return transaction {
- if (name == null) {
- DemobankConfigEntity.all().firstOrNull()
- } else {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq name
- }.firstOrNull()
- }
- }
-}
-
-fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID:
String): EbicsSubscriberEntity {
- return transaction {
- EbicsSubscriberEntity.find {
- (EbicsSubscribersTable.userId eq userID) and
(EbicsSubscribersTable.partnerId eq partnerID) and
- (EbicsSubscribersTable.hostId eq hostID)
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found"
- )
- }
-}
-
-/**
- * Compress, encrypt, encode a EBICS payload. The payload
- * is assumed to be a Zip archive with only one entry.
- * Return the customer key (second element) along the data.
- */
-fun prepareEbicsPayload(
- payload: String, pub: RSAPublicKey
-): Pair<String, CryptoUtil.EncryptionResult> {
- val zipSingleton = mutableListOf(payload.toByteArray()).zip()
- val compressedResponse =
DeflaterInputStream(zipSingleton.inputStream()).use {
- it.readAllBytes()
- }
- val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub)
- return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc)
-}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt
b/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt
deleted file mode 100644
index b7f245f3..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.bank
-
-import tech.libeufin.util.PaymentInfo
-
-data class WithdrawalRequest(
- /**
- * Note: the currency is redundant, because at each point during
- * the execution the Demobank should have a handle of the currency.
- */
- val amount: String // $CURRENCY:X.Y
-)
-data class BalanceJson(
- val amount: String,
- val credit_debit_indicator: String
-)
-data class Demobank(
- val currency: String,
- val name: String,
- val userDebtLimit: Int,
- val bankDebtLimit: Int,
- val allowRegistrations: Boolean
-)
-/**
- * Used to show the list of Ebics hosts that exist
- * in the system.
- */
-data class EbicsHostsResponse(
- val ebicsHosts: List<String>
-)
-
-data class EbicsHostCreateRequest(
- val hostID: String,
- val ebicsVersion: String
-)
-
-/**
- * List type that show all the payments existing in the system.
- */
-data class AccountTransactions(
- val payments: MutableList<PaymentInfo> = mutableListOf()
-)
-
-/**
- * Used to create AND show one Ebics subscriber.
- */
-data class EbicsSubscriberInfo(
- val hostID: String,
- val partnerID: String,
- val userID: String,
- val systemID: String? = null,
- val demobankAccountLabel: String
-)
-
-data class AdminGetSubscribers(
- var subscribers: MutableList<EbicsSubscriberInfo> = mutableListOf()
-)
-
-/**
- * The following definition is obsolete because it
- * doesn't allow to specify a demobank that will host
- * the Ebics subscriber. */
-data class EbicsSubscriberObsoleteApi(
- val hostID: String,
- val partnerID: String,
- val userID: String,
- val systemID: String? = null
-)
-
-/**
- * Allows the admin to associate a new bank account
- * to a EBICS subscriber.
- */
-data class EbicsBankAccountRequest(
- val subscriber: EbicsSubscriberObsoleteApi,
- val iban: String,
- val bic: String,
- val name: String,
- /**
- * This value labels the bank account to be created
- * AND its owner. The 'owner' is a bank's customer
- * whose username equals this label AND has the rights
- * over such bank accounts.
- */
- val label: String
-)
-
-data class CustomerRegistration(
- val username: String,
- val password: String,
- val isPublic: Boolean = false,
- // When missing, it's autogenerated.
- val iban: String?,
- // When missing, stays null in the DB.
- val name: String?
-)
-
-// Could be used as a general bank account info container.
-data class PublicAccountInfo(
- val balance: String,
- val iban: String,
- // Name / Label of the bank account _and_ of the
- // Sandbox username that owns it.
- val accountLabel: String
- // more ..?
-)
-
-data class CamtParams(
- // name/label of the bank account to query.
- val bankaccount: String,
- val type: Int,
- // need range parameter
-)
-
-data class TalerWithdrawalStatus(
- val selection_done: Boolean,
- val transfer_done: Boolean,
- val amount: String,
- val wire_types: List<String> = listOf("iban"),
- val suggested_exchange: String? = null,
- val sender_wire: String? = null,
- val aborted: Boolean,
- // Not needed with CLI wallets.
- val confirm_transfer_url: String?
-)
-
-data class TalerWithdrawalSelection(
- val reserve_pub: String,
- val selected_exchange: String?
-)
-
-data class SandboxConfig(
- val currency: String,
- val version: String,
- val name: String
-)
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index ab3c15e6..8b228fa8 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -1,575 +1,111 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
package tech.libeufin.bank
-import UtilError
-import com.fasterxml.jackson.core.util.DefaultIndenter
-import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.SerializationFeature
-import com.fasterxml.jackson.module.kotlin.KotlinFeature
-import com.fasterxml.jackson.module.kotlin.KotlinModule
-import com.github.ajalt.clikt.core.CliktCommand
-import com.github.ajalt.clikt.core.context
-import com.github.ajalt.clikt.core.subcommands
-import com.github.ajalt.clikt.output.CliktHelpFormatter
-import com.github.ajalt.clikt.parameters.arguments.argument
-import com.github.ajalt.clikt.parameters.options.*
-import com.github.ajalt.clikt.parameters.types.int
-import execThrowableOrTerminate
-import io.ktor.server.application.*
import io.ktor.http.*
import io.ktor.serialization.jackson.*
+import io.ktor.server.application.*
import io.ktor.server.plugins.*
+import io.ktor.server.plugins.requestvalidation.*
+import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
+import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
-import io.ktor.server.util.*
-import io.ktor.server.plugins.callloging.*
-import io.ktor.server.plugins.cors.routing.*
-import io.ktor.util.date.*
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.statements.api.ExposedBlob
-import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.event.Level
-import org.w3c.dom.Document
-import startServer
import tech.libeufin.util.*
-import java.math.BigDecimal
-import java.net.URL
-import java.security.interfaces.RSAPublicKey
-import javax.xml.bind.JAXBContext
-import kotlin.system.exitProcess
+import javax.xml.bind.ValidationException
+// GLOBALS
val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank")
-const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using
the same version.
-const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION"
-private val adminPassword: String? =
System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD")
-var WITH_AUTH = true // Needed by helpers too, hence not making it private.
+val db = Database(System.getProperty("BANK_DB_CONNECTION_STRING"))
-// Internal error type.
-data class SandboxError(
- val statusCode: HttpStatusCode,
- val reason: String,
- val errorCode: LibeufinErrorCode? = null
-) : Exception(reason)
-
-// HTTP response error type.
-data class SandboxErrorJson(val error: SandboxErrorDetailJson)
-data class SandboxErrorDetailJson(val type: String, val description: String)
-
-class DefaultExchange : CliktCommand("Set default Taler exchange for a
demobank.") {
- init { context { helpFormatter = CliktHelpFormatter(showDefaultValues =
true) } }
- private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of
the default exchange")
- private val exchangePayto by argument("EXCHANGE-PAYTO", "default
exchange's payto-address")
- private val demobank by option("--demobank", help = "Which demobank
defaults to EXCHANGE").default("default")
+// TYPES
+data class ChallengeContactData(
+ val email: String? = null,
+ val phone: String? = null
+)
+data class RegisterAccountRequest(
+ val username: String,
+ val password: String,
+ val name: String,
+ val is_public: Boolean = false,
+ val is_taler_exchange: Boolean = false,
+ val challenge_contact_data: ChallengeContactData,
+ val cashout_payto_uri: String?,
+ val internal_payto_uri: String?
+)
- override fun run() {
- val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
- execThrowableOrTerminate {
- dbCreateTables(dbConnString)
- transaction {
- val maybeDemobank: DemobankConfigEntity? =
DemobankConfigEntity.find {
- DemobankConfigsTable.name eq demobank
- }.firstOrNull()
- if (maybeDemobank == null) {
- System.err.println("Error, demobank $demobank not found.")
- exitProcess(1)
- }
- val config = maybeDemobank.config
- /**
- * Iterating over the config object's field that hold the
exchange
- * base URL and Payto. The iteration is only used to retrieve
the
- * correct names of the DB column 'configKey', because this is
named
- * after such fields.
- */
- listOf(
- Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl),
- Pair(config::suggestedExchangePayto, exchangePayto)
- ).forEach {
- val maybeConfigPair = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.demobankName eq demobank and(
- DemobankConfigPairsTable.configKey eq
it.first.name)
- }.firstOrNull()
- /**
- * The DB doesn't contain any column to hold the exchange
URL
- * or Payto, fail. That should never happen, because the
DB row
- * are created _after_ the DemobankConfig object that
_does_ contain
- * such fields.
- */
- if (maybeConfigPair == null) {
- System.err.println("Config key '${it.first.name}' for
demobank '$demobank' not found in DB.")
- exitProcess(1)
- }
- maybeConfigPair.configValue = it.second
- }
- }
- }
- }
-}
-
-class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into
the database.") {
- init { context { helpFormatter = CliktHelpFormatter(showDefaultValues =
true) } }
- private val nameArgument by argument(
- "NAME", help = "Name of this configuration. Currently, only 'default'
is admitted."
- )
- private val showOption by option(
- "--show",
- help = "Only show values, other options will be ignored."
- ).flag("--no-show", default = false)
- // FIXME: This really should not be a global option!
- private val captchaUrlOption by option(
- "--captcha-url", help = "Needed for browser wallets."
- ).default("https://bank.demo.taler.net/")
- private val currencyOption by option("--currency").default("EUR")
- private val bankDebtLimitOption by
option("--bank-debt-limit").int().default(1000000)
- private val usersDebtLimitOption by
option("--users-debt-limit").int().default(1000)
- private val allowRegistrationsOption by option(
- "--with-registrations",
- help = "(defaults to allow registrations)" /* mentioning here as help
message did not. */
- ).flag("--without-registrations", default = true)
- private val withSignupBonusOption by option(
- "--with-signup-bonus",
- help = "Award new customers with 100 units of currency! (defaults to
NO bonus)"
- ).flag("--without-signup-bonus", default = false)
-
- override fun run() {
- val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
- if (nameArgument != "default") {
- System.err.println("This version admits only the 'default' name")
- exitProcess(1)
- }
- execThrowableOrTerminate {
- dbCreateTables(dbConnString)
- val maybeDemobank = transaction { getDemobank(nameArgument) }
- if (showOption) {
- if (maybeDemobank != null) {
- printConfig(maybeDemobank)
- } else {
- println("Demobank: $nameArgument not found.")
- System.exit(1)
- }
- return@execThrowableOrTerminate
- }
- if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) {
- System.err.println("Debt numbers can't be negative.")
- exitProcess(1)
- }
- /*
- Warning if the CAPTCHA URL does not include the {wopid}
placeholder.
- Not a reason to fail because the bank may be run WITHOUT
providing Taler.
- */
- if (!hasWopidPlaceholder(captchaUrlOption))
- logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." +
- " Taler withdrawals decrease usability")
-
- // The user asks to _set_ values, regardless of overriding or
creating.
- val config = DemobankConfig(
- currency = currencyOption,
- bankDebtLimit = bankDebtLimitOption,
- usersDebtLimit = usersDebtLimitOption,
- allowRegistrations = allowRegistrationsOption,
- demobankName = nameArgument,
- withSignupBonus = withSignupBonusOption,
- captchaUrl = captchaUrlOption
- )
- /**
- * The demobank didn't exist. Now:
- * 1, Store the config values in the database.
- * 2, Store the demobank name in the database.
- * 3, Create the admin bank account under this demobank.
- */
- if (maybeDemobank == null) {
- transaction {
- insertConfigPairs(config)
- val demoBank = DemobankConfigEntity.new { this.name =
nameArgument }
- BankAccountEntity.new {
- iban = getIban()
- label = "admin"
- owner = "admin" // Not backed by an actual customer
object.
- // For now, the model assumes always one demobank
- this.demoBank = demoBank
- }
- }
- }
- // Demobank exists: update its config values in the database.
- else transaction { insertConfigPairs(config, override = true) }
- }
- }
+// Generates a new Payto-URI with IBAN scheme.
+fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}"
+fun parseTalerAmount(amount: String): TalerAmount {
+ val amountWithCurrencyRe = "^([A-Z]+):([0-9]+(\\.[0-9][0-9]?)?)$"
+ val match = Regex(amountWithCurrencyRe).find(amount) ?:
+ throw badRequest("Invalid amount")
+ val value = match.destructured.component2()
+ val fraction = match.destructured.component3().substring(1)
+ return TalerAmount(value.toLong(), fraction.toInt())
}
/**
- * This command generates Camt53 statements - for all the bank accounts -
- * every time it gets run. The statements are only stored into the database.
- * The user should then query either via Ebics or via the JSON interface,
- * in order to retrieve their statements.
+ * Performs the HTTP basic authentication. Returns the
+ * authenticated customer on success, or null otherwise.
*/
-class Camt053Tick : CliktCommand(
- "Make a new Camt.053 time tick; all the fresh transactions" +
- " will be inserted in a new Camt.053 report"
-) {
- override fun run() {
- val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
- execThrowableOrTerminate { dbCreateTables(dbConnString) }
- val newStatements = mutableMapOf<String,
MutableList<XLibeufinBankTransaction>>()
- /**
- * For each bank account, extract the latest statement and
- * include all the later transactions in a new statement.
- * Build empty statement, if the account does not have any
- * transaction yet.
- */
- transaction {
- BankAccountEntity.all().forEach { accountIter ->
- // Give this account a entry in the final output.
- newStatements.putIfAbsent(accountIter.label, mutableListOf())
- val lastStatement = BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq
accountIter.id.value
- }.lastOrNull()
- val lastStatementTime = lastStatement?.creationTime ?: 0L
- BankAccountTransactionEntity.find {
-
BankAccountTransactionsTable.date.greater(lastStatementTime) and(
- BankAccountTransactionsTable.account eq
accountIter.id.value
- )
- }.forEach {
- newStatements[accountIter.label]?.add(
- getHistoryElementFromTransactionRow(it)
- ) ?: run {
- logger.error("Array operation failed while building
statements for account: ${accountIter.label}")
- System.err.println("Fatal array error while building
the statement, please report.")
- exitProcess(1)
- }
- }
- /**
- * Resorting the closing (CLBD) balance of the last statement;
will
- * become the PRCD balance of the _new_ one.
- */
- val camtData = buildCamtString(
- 53,
- accountIter.iban,
- newStatements[accountIter.label]!!,
- currency = accountIter.demoBank.config.currency
- )
- BankAccountStatementEntity.new {
- statementId = camtData.messageId
- creationTime = getSystemTimeNow().toInstant().epochSecond
- xmlMessage = camtData.camtMessage
- bankAccount = accountIter
- }
- }
- BankAccountFreshTransactionsTable.deleteAll()
- }
- }
-}
-
-class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank
accounts") {
- init {
- context { helpFormatter = CliktHelpFormatter(showDefaultValues = true)
}
- }
- private val creditAccount by option(help = "Label of the bank account
receiving the payment").required()
- private val debitAccount by option(help = "Label of the bank account
issuing the payment").required()
- private val demobankArg by option("--demobank", help = "Which Demobank
books this transaction").default("default")
- private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format")
- private val subjectArg by argument("SUBJECT", "Payment's subject")
-
- override fun run() {
+fun doBasicAuth(encodedCredentials: String): Customer? {
+ val plainUserAndPass = String(base64ToBytes(encodedCredentials),
Charsets.UTF_8) // :-separated
+ val userAndPassSplit = plainUserAndPass.split(
+ ":",
/**
- * Merely connecting here (and NOT creating any table) because this
- * command should only be run after actual bank accounts exist in the
- * system, meaning therefore that the database got already set up.
+ * this parameter allows colons to occur in passwords.
+ * Without this, passwords that have colons would be split
+ * and become meaningless.
*/
- execThrowableOrTerminate {
- val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
- connectWithSchema(getJdbcConnectionFromPg(pgConnString))
- }
- // Refuse to operate without a default demobank.
- val demobank = getDemobank("default")
- if (demobank == null) {
- System.err.println("Sandbox cannot operate without a 'default'
demobank.")
- System.err.println("Please make one with the 'libeufin-sandbox
config' command.")
- exitProcess(1)
- }
- try {
- wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg,
amount)
- } catch (e: SandboxError) {
- System.err.println(e.message)
- exitProcess(1)
- } catch (e: Exception) {
- System.err.println(e.message)
- exitProcess(1)
- }
- }
-}
-
-class ResetTables : CliktCommand("Drop all the tables from the database") {
- init {
- context {
- helpFormatter = CliktHelpFormatter(showDefaultValues = true)
- }
- }
- override fun run() {
- val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
- execThrowableOrTerminate {
- dbDropTables(dbConnString)
- dbCreateTables(dbConnString)
- }
- }
-}
-
-class Serve : CliktCommand("Run sandbox HTTP server") {
- init {
- context {
- helpFormatter = CliktHelpFormatter(showDefaultValues = true)
- }
- }
- private val auth by option(
- "--auth",
- help = "Disable authentication."
- ).flag("--no-auth", default = true)
- private val localhostOnly by option(
- "--localhost-only",
- help = "Bind only to localhost. On all interfaces otherwise"
- ).flag("--no-localhost-only", default = true)
- private val ipv4Only by option(
- "--ipv4-only",
- help = "Bind only to ipv4"
- ).flag(default = false)
- private val logLevel by option(
- help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug',
'trace', 'all'"
- )
- private val port by option().int().default(5000)
- private val withUnixSocket by option(
- help = "Bind the Sandbox to the Unix domain socket at PATH.
Overrides" +
- " --port, when both are given", metavar = "PATH"
+ limit = 2
)
- private val smsTan by option(help = "Command to send the TAN via SMS." +
- " The command gets the TAN via STDIN and the phone number" +
- " as its first parameter"
- )
- private val emailTan by option(help = "Command to send the TAN via
e-mail." +
- " The command gets the TAN via STDIN and the e-mail address as
its" +
- " first parameter.")
- override fun run() {
- WITH_AUTH = auth
- setLogLevel(logLevel)
- if (WITH_AUTH && adminPassword == null) {
- System.err.println(
- "Error: auth is enabled, but env " +
- "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not."
- + " (Option --no-auth exists for tests)"
- )
- exitProcess(1)
- }
- execThrowableOrTerminate {
- dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME))
- }
- // Refuse to operate without a 'default' demobank.
- val demobank = getDemobank("default")
- if (demobank == null) {
- System.err.println("Sandbox cannot operate without a 'default'
demobank.")
- System.err.println("Please make one with the 'libeufin-sandbox
config' command.")
- exitProcess(1)
- }
- if (withUnixSocket != null) {
- startServer(
- withUnixSocket!!,
- app = sandboxApp
- )
- exitProcess(0)
- }
- SMS_TAN_CMD = smsTan
- EMAIL_TAN_CMD = emailTan
-
- logger.info("Starting Sandbox on port ${this.port}")
- startServerWithIPv4Fallback(
- options = StartServerOptions(
- ipv4OnlyOpt = this.ipv4Only,
- localhostOnlyOpt = this.localhostOnly,
- portOpt = this.port
- ),
- app = sandboxApp
- )
- }
+ if (userAndPassSplit.size != 2) throw badRequest("Malformed Basic auth
credentials found in the Authorization header.")
+ val login = userAndPassSplit[0]
+ val plainPassword = userAndPassSplit[1]
+ return db.customerPwAuth(login, CryptoUtil.hashpw(plainPassword))
+}
+
+/* Performs the bearer-token authentication. Returns the
+ * authenticated customer on success, null otherwise. */
+fun doTokenAuth(
+ token: String,
+ requiredScope: TokenScope, // readonly or readwrite
+): Customer? {
+ val maybeToken: BearerToken =
db.bearerTokenGet(token.toByteArray(Charsets.UTF_8)) ?: return null
+ val isExpired: Boolean = maybeToken.expirationTime - getNow().toMicro() < 0
+ if (isExpired || maybeToken.scope != requiredScope) return null // FIXME:
mention the reason?
+ // Getting the related username.
+ return db.customerGetFromRowId(maybeToken.bankCustomer)
+ ?: throw internalServerError("Customer not found, despite token
mentions it.")
}
-private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank {
- return Demobank(
- currency = fromDb.config.currency,
- userDebtLimit = fromDb.config.usersDebtLimit,
- bankDebtLimit = fromDb.config.bankDebtLimit,
- allowRegistrations = fromDb.config.allowRegistrations,
- name = fromDb.name
- )
-}
-fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?):
EbicsSubscriberEntity? {
- return if (systemID == null) {
- EbicsSubscriberEntity.find {
- (EbicsSubscribersTable.partnerId eq partnerID) and
(EbicsSubscribersTable.userId eq userID)
- }
- } else {
- EbicsSubscriberEntity.find {
- (EbicsSubscribersTable.partnerId eq partnerID) and
- (EbicsSubscribersTable.userId eq userID) and
- (EbicsSubscribersTable.systemId eq systemID)
- }
- }.firstOrNull()
-}
-
-data class SubscriberKeys(
- val authenticationPublicKey: RSAPublicKey,
- val encryptionPublicKey: RSAPublicKey,
- val signaturePublicKey: RSAPublicKey
-)
-
-data class EbicsHostPublicInfo(
- val hostID: String,
- val encryptionPublicKey: RSAPublicKey,
- val authenticationPublicKey: RSAPublicKey
-)
-
-data class BankAccountInfo(
- val label: String,
- val name: String,
- val iban: String,
- val bic: String,
-)
-
-inline fun <reified T> Document.toObject(): T {
- val jc = JAXBContext.newInstance(T::class.java)
- val m = jc.createUnmarshaller()
- return m.unmarshal(this, T::class.java).value
-}
-
-fun ensureNonNull(param: String?): String {
- return param ?: throw SandboxError(
- HttpStatusCode.BadRequest, "Bad ID given: $param"
- )
-}
-
-class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true,
printHelpOnEmptyArgs = true) {
- init { versionOption(getVersion()) }
- override fun run() = Unit
-}
-
-fun main(args: Array<String>) {
- SandboxCommand().subcommands(
- Serve(),
- ResetTables(),
- Config(),
- MakeTransaction(),
- Camt053Tick(),
- DefaultExchange()
- ).main(args)
-}
-
-fun setJsonHandler(ctx: ObjectMapper) {
- ctx.enable(SerializationFeature.INDENT_OUTPUT)
- ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
- indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
- indentObjectsWith(DefaultIndenter(" ", "\n"))
- })
- ctx.registerModule(
- KotlinModule.Builder()
- .withReflectionCacheSize(512)
- .configure(KotlinFeature.NullToEmptyCollection, false)
- .configure(KotlinFeature.NullToEmptyMap, false)
- .configure(KotlinFeature.NullIsSameAsDefault, enabled = true)
- .configure(KotlinFeature.SingletonSupport, enabled = false)
- .configure(KotlinFeature.StrictNullChecks, false)
- .build()
- )
-}
-
-private suspend fun getWithdrawal(call: ApplicationCall) {
- val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id"))
- if (!op.selectionDone && op.reservePub != null) throw internalServerError(
- "Unselected withdrawal has a reserve public key",
- LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE
- )
- call.respond(object {
- val amount = op.amount
- val aborted = op.aborted
- val confirmation_done = op.confirmationDone
- val selection_done = op.selectionDone
- val selected_reserve_pub = op.reservePub
- val selected_exchange_account = op.selectedExchangePayto
- })
-}
-
-private suspend fun confirmWithdrawal(call: ApplicationCall) {
- val withdrawalId = call.expectUriComponent("withdrawal_id")
- logger.debug("Maybe confirming withdrawal: $withdrawalId")
- transaction {
- val wo = getWithdrawalOperation(withdrawalId)
- if (wo.aborted) throw SandboxError(
- HttpStatusCode.Conflict,
- "Cannot confirm an aborted withdrawal."
- )
- if (!wo.selectionDone) throw SandboxError(
- HttpStatusCode.UnprocessableEntity,
- "Cannot confirm a unselected withdrawal: " +
- "specify exchange and reserve public key via Integration
API first."
- )
- /**
- * The wallet chose not to select any exchange, use the default.
- */
- val demobank = ensureDemobank(call)
- if (wo.selectedExchangePayto == null) {
- wo.selectedExchangePayto = demobank.config.suggestedExchangePayto
- }
- val exchangeBankAccount = getBankAccountFromPayto(
- wo.selectedExchangePayto ?: throw internalServerError(
- "Cannot withdraw without an exchange."
- )
- )
- logger.debug("Withdrawal ${wo.wopid} confirmed?
${wo.confirmationDone}")
- if (!wo.confirmationDone) {
- wireTransfer(
- debitAccount = wo.walletBankAccount.label,
- creditAccount = exchangeBankAccount.label,
- amount = wo.amount,
- subject = wo.reservePub ?: throw internalServerError(
- "Cannot transfer funds without reserve public key."
- ),
- // provide the currency.
- demobank = ensureDemobank(call).name
- )
- wo.confirmationDone = true
- }
- wo.confirmationDone
+/**
+ * This function tries to authenticate the call according
+ * to the scheme that is mentioned in the Authorization header.
+ * The allowed schemes are either 'HTTP basic auth' or 'bearer token'.
+ *
+ * requiredScope can be either "readonly" or "readwrite".
+ *
+ * Returns the authenticated customer, or null if they failed.
+ */
+fun ApplicationCall.myAuth(requiredScope: TokenScope): Customer? {
+ // Extracting the Authorization header.
+ val header = getAuthorizationRawHeader(this.request)
+ val authDetails = getAuthorizationDetails(header)
+ return when (authDetails.scheme) {
+ "Basic" -> doBasicAuth(authDetails.content)
+ "Bearer" -> doTokenAuth(authDetails.content, requiredScope)
+ else -> throw badRequest("Authorization scheme '${authDetails.scheme}'
is not supported.")
}
- call.respond(object {})
}
-private suspend fun abortWithdrawal(call: ApplicationCall) {
- val withdrawalId = call.expectUriComponent("withdrawal_id")
- val operation = getWithdrawalOperation(withdrawalId)
- if (operation.confirmationDone) throw conflict("Cannot abort paid
withdrawal.")
- transaction { operation.aborted = true }
- call.respond(object {})
-}
-val sandboxApp: Application.() -> Unit = {
+val webApp: Application.() -> Unit = {
install(CallLogging) {
this.level = Level.DEBUG
this.logger = tech.libeufin.bank.logger
@@ -587,1125 +123,72 @@ val sandboxApp: Application.() -> Unit = {
allowCredentials = true
}
install(IgnoreTrailingSlash)
- install(ContentNegotiation) {
- register(ContentType.Text.Xml, XMLEbicsConverter())
- /**
- * Content type "text" must go to the XML parser
- * because Nexus can't set explicitly the Content-Type
- * (see https://github.com/ktorio/ktor/issues/1127) to
- * "xml" and the request made gets somehow assigned the
- * "text/plain" type: */
- register(ContentType.Text.Plain, XMLEbicsConverter())
- jackson(contentType = ContentType.Application.Json) {
setJsonHandler(this) }
- /**
- * Make jackson the default parser. It runs also when
- * the Content-Type request header is missing. */
- jackson(contentType = ContentType.Any) { setJsonHandler(this) }
- }
- install(StatusPages) {
- // Bank's fault: it should check the operands. Respond 500
- exception<ArithmeticException> { call, cause ->
- logger.error("Exception while handling '${call.request.uri}',
${cause.stackTraceToString()}")
- call.respond(
- HttpStatusCode.InternalServerError,
- SandboxErrorJson(
- error = SandboxErrorDetailJson(
- type = "sandbox-error",
- description = cause.message ?: "Bank's error:
arithmetic exception."
- )
- )
- )
- }
- // Not necessarily the bank's fault.
- exception<SandboxError> { call, cause ->
- logger.error("Exception while handling '${call.request.uri}',
${cause.reason}")
- call.respond(
- cause.statusCode,
- SandboxErrorJson(
- error = SandboxErrorDetailJson(
- type = "sandbox-error",
- description = cause.reason
- )
- )
- )
- }
- // Not necessarily the bank's fault.
- exception<UtilError> { call, cause ->
- logger.error("Exception while handling '${call.request.uri}',
${cause.reason}")
- call.respond(
- cause.statusCode,
- SandboxErrorJson(
- error = SandboxErrorDetailJson(
- type = "util-error",
- description = cause.reason
- )
- )
- )
- }
- /**
- * Happens when a request fails to parse. This branch triggers
- * only when a JSON request fails. XML problems are caught within
- * the /ebicsweb handler and always ultimately rethrown as
"EbicsRequestError",
- * hence they do not reach this branch.
- */
- exception<BadRequestException> { call, wrapper ->
- var rootCause = wrapper.cause
- while (rootCause?.cause != null) rootCause = rootCause.cause
- val errorMessage: String? = rootCause?.message ?: wrapper.message
- if (errorMessage == null) {
- logger.error("The bank didn't detect the cause of a bad
request, fail.")
- logger.error(wrapper.stackTraceToString())
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Did not find bad request details."
- )
- }
- logger.error(errorMessage)
- call.respond(
- HttpStatusCode.BadRequest,
- SandboxErrorJson(
- error = SandboxErrorDetailJson(
- type = "sandbox-error",
- description = errorMessage
- )
- )
- )
- }
- // Catch-all error, respond 500 because the bank didn't handle it.
- exception<Throwable> { call, cause ->
- logger.error("Unhandled exception while handling
'${call.request.uri}'\n${cause.stackTraceToString()}")
- call.respond(
- HttpStatusCode.InternalServerError,
- SandboxErrorJson(
- error = SandboxErrorDetailJson(
- type = "sandbox-error",
- description = cause.message ?: "Bank's error:
unhandled exception."
- )
- )
- )
- }
- exception<EbicsRequestError> { call, cause ->
- logger.error("Handling EbicsRequestError: ${cause.message}")
- respondEbicsTransfer(call, cause.errorText, cause.errorCode)
- }
- }
- intercept(ApplicationCallPipeline.Setup) {
- val ac: ApplicationCall = call
- ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH)
- if (WITH_AUTH) {
- if(adminPassword == null) {
- throw internalServerError(
- "Sandbox has no admin password defined." +
- " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in
the environment, " +
- "or launch with --no-auth."
-
- )
- }
- ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword)
- }
- return@intercept
- }
- intercept(ApplicationCallPipeline.Fallback) {
- if (this.call.response.status() == null) {
- call.respondText(
- "Not found (no route matched).\n",
- io.ktor.http.ContentType.Text.Plain,
- io.ktor.http.HttpStatusCode.NotFound
- )
- return@intercept finish()
- }
- }
+ install(ContentNegotiation) { jackson {} }
routing {
- get("/") {
- call.respondText(
- "Hello, this is the Sandbox\n",
- ContentType.Text.Plain
- )
- }
- // Respond with the last statement of the requesting account.
- // Query details in the body.
- post("/admin/payments/camt") {
- val username = call.request.basicAuth()
- val body = call.receive<CamtParams>()
- if (body.type != 53) throw SandboxError(
- HttpStatusCode.NotFound,
- "Only Camt.053 documents can be generated."
- )
- if (!allowOwnerOrAdmin(username, body.bankaccount))
- throw unauthorized("User '${username}' has no rights over" +
- " bank account '${body.bankaccount}'")
- val camtMessage = transaction {
- val bankaccount = getBankAccountFromLabel(
- body.bankaccount,
- getDefaultDemobank()
- )
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq bankaccount.id
- }.lastOrNull()?.xmlMessage ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Could not find any statements; please wait next tick"
- )
- }
- call.respondText(
- camtMessage, ContentType.Text.Xml, HttpStatusCode.OK
- )
+ post("/accounts") {
+ // check if only admin.
+ val maybeOnlyAdmin = db.configGet("only_admin_registrations")
+ if (maybeOnlyAdmin?.lowercase() == "yes") {
+ val customer: Customer? = call.myAuth(TokenScope.readwrite)
+ if (customer == null || customer.login != "admin")
+ // OK to leak the only-admin policy here?
+ throw forbidden("Only admin allowed, and it failed to
authenticate.")
+ }
+ // auth passed, proceed with activity.
+ val req = call.receive<RegisterAccountRequest>()
+ // Prohibit reserved usernames:
+ if (req.username == "admin" || req.username == "bank")
+ throw conflict("Username '${req.username}' is reserved.")
+ // Checking imdepotency.
+ val maybeCustomerExists = db.customerGetFromLogin(req.username)
+ if (maybeCustomerExists != null) {
+ val bankingInfo =
db.bankAccountGetFromOwnerId(maybeCustomerExists.expectRowId())
+ ?: throw internalServerError("Existing customer had no
bank account!")
+ // Checking _all_ the details are the same.
+ val isIdentic =
+ maybeCustomerExists.name == req.name &&
+ maybeCustomerExists.email ==
req.challenge_contact_data.email &&
+ maybeCustomerExists.phone ==
req.challenge_contact_data.phone &&
+ maybeCustomerExists.cashoutPayto == req.cashout_payto_uri
&&
+ maybeCustomerExists.passwordHash ==
CryptoUtil.hashpw(req.password) &&
+ bankingInfo.isPublic == req.is_public &&
+ bankingInfo.isTalerExchange == req.is_taler_exchange &&
+ bankingInfo.internalPaytoUri == req.internal_payto_uri
+ if (isIdentic) call.respond(HttpStatusCode.Created)
+ call.respond(HttpStatusCode.Conflict)
+ }
+ // From here: fresh user being added.
+ val newCustomer = Customer(
+ login = req.username,
+ name = req.name,
+ email = req.challenge_contact_data.email,
+ phone = req.challenge_contact_data.phone,
+ cashoutPayto = req.cashout_payto_uri,
+ // Following could be gone, if included in cashout_payto
+ cashoutCurrency = db.configGet("cashout_currency"),
+ passwordHash = CryptoUtil.hashpw(req.password)
+ )
+ val newCustomerRowId = db.customerCreate(newCustomer)
+ ?: throw internalServerError("New customer INSERT failed
despite the previous checks")
+ /* Crashing here won't break data consistency between customers
+ * and bank accounts, because of the idempotency. Client will
+ * just have to retry. */
+ val maxDebt = db.configGet("max_debt_ordinary_customers").run {
+ if (this == null) throw internalServerError("Max debt not
configured")
+ parseTalerAmount(this)
+ }
+ val newBankAccount = BankAccount(
+ hasDebt = false,
+ internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(),
+ owningCustomerId = newCustomerRowId,
+ isPublic = req.is_public,
+ isTalerExchange = req.is_taler_exchange,
+ maxDebt = maxDebt
+ )
+ if (!db.bankAccountCreate(newBankAccount))
+ throw internalServerError("Could not INSERT bank account
despite all the checks.")
+ call.respond(HttpStatusCode.Created)
return@post
}
-
- /**
- * Create a new bank account, no EBICS relation. Okay
- * to let a user, since having a particular username allocates
- * already a bank account with such label.
- */
- post("/admin/bank-accounts/{label}") {
- val username = call.request.basicAuth()
- val body = call.receive<BankAccountInfo>()
- if (!allowOwnerOrAdmin(username, body.label))
- throw unauthorized("User '$username' has no rights over" +
- " bank account '${body.label}'"
- )
- if (body.label == "admin" || body.label == "bank") throw forbidden(
- "Requested bank account label '${body.label}' not allowed."
- )
- transaction {
- val maybeBankAccount = BankAccountEntity.find {
- BankAccountsTable.label eq body.label
- }.firstOrNull()
- if (maybeBankAccount != null)
- throw conflict("Bank account '${body.label}' exist
already")
- // owner username == bank account label
- val maybeCustomer = DemobankCustomerEntity.find {
- DemobankCustomersTable.username eq body.label
- }.firstOrNull()
- if (maybeCustomer == null)
- throw notFound("Customer '${body.label}' not found," +
- " cannot own any bank account.")
- BankAccountEntity.new {
- iban = body.iban
- bic = body.bic
- label = body.label
- owner = body.label
- demoBank = getDefaultDemobank()
- }
- }
- call.respond(object {})
- return@post
- }
-
- // Information about one bank account.
- get("/admin/bank-accounts/{label}") {
- val username = call.request.basicAuth()
- val label = call.expectUriComponent("label")
- val ret = transaction {
- val demobank = getDefaultDemobank()
- val bankAccount = getBankAccountFromLabel(label, demobank)
- if (!allowOwnerOrAdmin(username, label))
- throw unauthorized("'${username}' has no rights over
'$label'")
- val balance = getBalance(bankAccount)
- object {
- val balance =
"${bankAccount.demoBank.config.currency}:${balance}"
- val iban = bankAccount.iban
- val bic = bankAccount.bic
- val label = bankAccount.label
- }
- }
- call.respond(ret)
- return@get
- }
-
- // Book one incoming payment for the requesting account.
- // The debtor is not required to have a customer account at this
Sandbox.
- post("/admin/bank-accounts/{label}/simulate-incoming-transaction") {
- call.request.basicAuth(onlyAdmin = true)
- val body = call.receive<IncomingPaymentInfo>()
- val accountLabel = ensureNonNull(call.parameters["label"])
- val reqDebtorBic = body.debtorBic
- if (reqDebtorBic != null && !validateBic(reqDebtorBic)) {
- throw SandboxError(
- HttpStatusCode.BadRequest,
- "invalid BIC"
- )
- }
- val amount = parseAmount(body.amount)
- transaction {
- val demobank = getDefaultDemobank()
- val account = getBankAccountFromLabel(
- accountLabel, demobank
- )
- val randId = getRandomString(16)
- val customer = getCustomer(accountLabel)
- BankAccountTransactionEntity.new {
- creditorIban = account.iban
- creditorBic = account.bic
- creditorName = customer.name ?: "Name not given."
- debtorIban = body.debtorIban
- debtorBic = reqDebtorBic
- debtorName = body.debtorName
- subject = body.subject
- this.amount = amount.amount
- date = getSystemTimeNow().toInstant().toEpochMilli()
- accountServicerReference = "sandbox-$randId"
- this.account = account
- direction = "CRDT"
- this.demobank = demobank
- this.currency = demobank.config.currency
- }
- }
- call.respond(object {})
- }
- // Associates a new bank account with an existing Ebics subscriber.
- post("/admin/ebics/bank-accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val body = call.receive<EbicsBankAccountRequest>()
- val subscriber = getEbicsSubscriberFromDetails(
- body.subscriber.userID,
- body.subscriber.partnerID,
- body.subscriber.hostID
- )
- val res = insertNewAccount(
- username = body.label,
- /**
- * This value makes only happy the account creator helper.
- * Logic using this OBSOLETE HTTP handler would NOT expect
- * to use this password anyway. The reason is that such
obsolete
- * tests access their banking data always through the EBICS
- * subscriber, needing therefore no HTTP basic password to
operate.
- */
- password = "not-used",
- iban = body.iban
- )
- transaction { subscriber.bankAccount = res.bankAccount }
- call.respond({})
- return@post
- }
-
- // Information about all the default demobank's bank accounts
- get("/admin/bank-accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val accounts = mutableListOf<BankAccountInfo>()
- transaction {
- val demobank = getDefaultDemobank()
- // Finds all the accounts of this demobank.
- BankAccountEntity.find { BankAccountsTable.demoBank eq
demobank.id }.forEach {
- accounts.add(
- BankAccountInfo(
- label = it.label,
- bic = it.bic,
- iban = it.iban,
- name = "Bank account owner's name"
- )
- )
- }
- }
- call.respond(accounts)
- }
-
- // Details of all the transactions of one bank account.
- get("/admin/bank-accounts/{label}/transactions") {
- val username = call.request.basicAuth()
- val ret = AccountTransactions()
- val accountLabel = ensureNonNull(call.parameters["label"])
- if (!allowOwnerOrAdmin(username, accountLabel))
- throw unauthorized("Requesting user '${username}'" +
- " has no rights over bank account '${accountLabel}'"
- )
- transaction {
- val demobank = getDefaultDemobank()
- val account = getBankAccountFromLabel(accountLabel, demobank)
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.account eq account.id
- }.forEach {
- ret.payments.add(
- PaymentInfo(
- accountLabel = account.label,
- creditorIban = it.creditorIban,
- accountServicerReference =
it.accountServicerReference,
- paymentInformationId = it.pmtInfId,
- debtorIban = it.debtorIban,
- subject = it.subject,
- date = GMTDate(it.date).toHttpDate(),
- amount = it.amount,
- creditorBic = it.creditorBic,
- creditorName = it.creditorName,
- debtorBic = it.debtorBic,
- debtorName = it.debtorName,
- currency = it.currency,
- creditDebitIndicator = when (it.direction) {
- "CRDT" -> "credit"
- "DBIT" -> "debit"
- else -> throw Error("invalid direction")
- }
- )
- )
- }
- }
- call.respond(ret)
- }
- /**
- * Generate one incoming and one outgoing transactions for
- * one bank account. Counterparts do not need to have an account
- * at this Sandbox.
- */
- post("/admin/bank-accounts/{label}/generate-transactions") {
- call.request.basicAuth(onlyAdmin = true)
- transaction {
- val accountLabel = ensureNonNull(call.parameters["label"])
- val demobank = getDefaultDemobank()
- val account = getBankAccountFromLabel(accountLabel, demobank)
- val transactionReferenceCrdt = getRandomString(8)
- val transactionReferenceDbit = getRandomString(8)
-
- run {
- val amount = kotlin.random.Random.nextLong(5, 25)
- BankAccountTransactionEntity.new {
- creditorIban = account.iban
- creditorBic = account.bic
- creditorName = "Creditor Name"
- debtorIban = "DE64500105178797276788"
- debtorBic = "DEUTDEBB101"
- debtorName = "Max Mustermann"
- subject = "sample transaction
$transactionReferenceCrdt"
- this.amount = amount.toString()
- date = getSystemTimeNow().toInstant().toEpochMilli()
- accountServicerReference = transactionReferenceCrdt
- this.account = account
- direction = "CRDT"
- this.demobank = demobank
- currency = demobank.config.currency
- }
- }
-
- run {
- val amount = kotlin.random.Random.nextLong(5, 25)
-
- BankAccountTransactionEntity.new {
- debtorIban = account.iban
- debtorBic = account.bic
- debtorName = "Debitor Name"
- creditorIban = "DE64500105178797276788"
- creditorBic = "DEUTDEBB101"
- creditorName = "Max Mustermann"
- subject = "sample transaction
$transactionReferenceDbit"
- this.amount = amount.toString()
- date = getSystemTimeNow().toInstant().toEpochMilli()
- accountServicerReference = transactionReferenceDbit
- this.account = account
- direction = "DBIT"
- this.demobank = demobank
- currency = demobank.config.currency
- }
- }
- }
- call.respond(object {})
- }
-
- /**
- * Create a new EBICS subscriber without associating
- * a bank account to it. Currently every registered
- * user is allowed to call this.
- */
- post("/admin/ebics/subscribers") {
- call.request.basicAuth(onlyAdmin = true)
- val body = call.receive<EbicsSubscriberObsoleteApi>()
- transaction {
- // Check the host ID exists.
- EbicsHostEntity.find {
- EbicsHostsTable.hostID eq body.hostID
- }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not
found.")
- // Check it exists first.
- val maybeSubscriber = EbicsSubscriberEntity.find {
- EbicsSubscribersTable.userId eq body.userID and (
- EbicsSubscribersTable.partnerId eq body.partnerID
- ) and (EbicsSubscribersTable.systemId eq
body.systemID) and
- (EbicsSubscribersTable.hostId eq body.hostID)
- }.firstOrNull()
- if (maybeSubscriber != null) throw conflict("EBICS subscriber
exists already")
- EbicsSubscriberEntity.new {
- partnerId = body.partnerID
- userId = body.userID
- systemId = null
- hostId = body.hostID
- state = SubscriberState.NEW
- nextOrderID = 1
- }
- }
- call.respondText(
- "Subscriber created.",
- ContentType.Text.Plain, HttpStatusCode.OK
- )
- return@post
- }
-
- // Shows details of all the EBICS subscribers of this Sandbox.
- get("/admin/ebics/subscribers") {
- call.request.basicAuth(onlyAdmin = true)
- val ret = AdminGetSubscribers()
- transaction {
- EbicsSubscriberEntity.all().forEach {
- ret.subscribers.add(
- EbicsSubscriberInfo(
- userID = it.userId,
- partnerID = it.partnerId,
- hostID = it.hostId,
- demobankAccountLabel = it.bankAccount?.label ?:
"not associated yet"
- )
- )
- }
- }
- call.respond(ret)
- return@get
- }
-
- // Change keys used in the EBICS communications.
- post("/admin/ebics/hosts/{hostID}/rotate-keys") {
- call.request.basicAuth(onlyAdmin = true)
- val hostID: String = call.parameters["hostID"] ?: throw
SandboxError(
- io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in
URL"
- )
- transaction {
- val host = EbicsHostEntity.find {
- EbicsHostsTable.hostID eq hostID
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound, "Host $hostID not found"
- )
- val pairA = CryptoUtil.generateRsaKeyPair(2048)
- val pairB = CryptoUtil.generateRsaKeyPair(2048)
- val pairC = CryptoUtil.generateRsaKeyPair(2048)
- host.authenticationPrivateKey =
ExposedBlob(pairA.private.encoded)
- host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded)
- host.signaturePrivateKey = ExposedBlob(pairC.private.encoded)
- }
- call.respondText(
- "Keys of '${hostID}' rotated.",
- ContentType.Text.Plain,
- HttpStatusCode.OK
- )
- return@post
- }
-
- // Create a new EBICS host
- post("/admin/ebics/hosts") {
- call.request.basicAuth(onlyAdmin = true)
- val req = call.receive<EbicsHostCreateRequest>()
- val pairA = CryptoUtil.generateRsaKeyPair(2048)
- val pairB = CryptoUtil.generateRsaKeyPair(2048)
- val pairC = CryptoUtil.generateRsaKeyPair(2048)
- transaction {
- val maybeHost = EbicsHostEntity.find {
- EbicsHostsTable.hostID eq req.hostID
- }.firstOrNull()
- if (maybeHost != null) {
- logger.info("EBICS host '${req.hostID}' exists already,
this request conflicts.")
- throw conflict("EBICS host '${req.hostID}' exists already")
- }
- EbicsHostEntity.new {
- this.ebicsVersion = req.ebicsVersion
- this.hostId = req.hostID
- this.authenticationPrivateKey =
ExposedBlob(pairA.private.encoded)
- this.encryptionPrivateKey =
ExposedBlob(pairB.private.encoded)
- this.signaturePrivateKey =
ExposedBlob(pairC.private.encoded)
- }
- }
- call.respondText(
- "Host '${req.hostID}' created.",
- ContentType.Text.Plain,
- HttpStatusCode.OK
- )
- return@post
- }
-
- // Show the names of all the Ebics hosts
- get("/admin/ebics/hosts") {
- call.request.basicAuth(onlyAdmin = true)
- val ebicsHosts = transaction {
- EbicsHostEntity.all().map { it.hostId }
- }
- call.respond(EbicsHostsResponse(ebicsHosts))
- }
- // Process one EBICS request
- post("/ebicsweb") {
- try { call.ebicsweb() }
- /**
- * The catch blocks try to extract a EBICS error message from the
- * exception type being handled. NOT logging under each catch
block
- * as ultimately the registered exception handler is expected to
log. */
- catch (e: UtilError) {
- throw EbicsProcessingError("Serving EBICS threw unmanaged
UtilError: ${e.reason}")
- }
- catch (e: SandboxError) {
- val errorInfo: String = e.message ?: e.stackTraceToString()
- logger.info(errorInfo)
- // Should translate to EBICS error code.
- when (e.errorCode) {
- LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw
EbicsProcessingError("Invalid bank state.")
- LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw
EbicsProcessingError("Inconsistent bank state.")
- else -> throw EbicsProcessingError("Unknown Libeufin error
code: ${e.errorCode}.")
- }
- }
- catch (e: EbicsNoDownloadDataAvailable) {
- respondEbicsTransfer(call, e.errorText, e.errorCode)
- }
- catch (e: EbicsRequestError) {
- /**
- * Preventing the last catch-all block from handling
- * a known error type. Rethrowing here to let the top-level
- * handler take action.
- */
- throw e
- }
- catch (e: Exception) {
- logger.error(e.stackTraceToString())
- throw EbicsProcessingError(e.message)
- }
- return@post
- }
-
- /**
- * Create a new demobank instance with a particular currency,
- * debt limit and possibly other configuration
- * (could also be a CLI command for now)
- */
- post("/demobanks") {
- throw NotImplementedError("Feature only available at the
libeufin-sandbox CLI")
- }
-
- get("/demobanks") {
- expectAdmin(call.request.basicAuth())
- val ret = object { val demoBanks = mutableListOf<Demobank>() }
- transaction {
- DemobankConfigEntity.all().forEach {
- ret.demoBanks.add(getJsonFromDemobankConfig(it))
- }
- }
- call.respond(ret)
- return@get
- }
-
- get("/demobanks/{demobankid}") {
- val demobank = ensureDemobank(call)
- expectAdmin(call.request.basicAuth())
- call.respond(getJsonFromDemobankConfig(demobank))
- return@get
- }
-
- route("/demobanks/{demobankid}") {
- // NOTE: TWG assumes that username == bank account label.
- route("/taler-wire-gateway") {
- post("/{exchangeUsername}/admin/add-incoming") {
- val username = call.expectUriComponent("exchangeUsername")
- val usernameAuth = call.request.basicAuth()
- if (username != usernameAuth)
- throw forbidden("Bank account name and username
differ: $username vs $usernameAuth")
- logger.debug("TWG add-incoming passed authentication")
- val body = try { call.receive<TWGAdminAddIncoming>() }
- catch (e: Exception) {
- logger.error("/admin/add-incoming failed at parsing
the request body")
- throw SandboxError(
- HttpStatusCode.BadRequest,
- "Invalid request"
- )
- }
- val singletonTx = transaction {
- val demobank = ensureDemobank(call)
- val bankAccountCredit =
getBankAccountFromLabel(username, demobank)
- if (bankAccountCredit.owner != username) throw
forbidden(
- "User '$username' cannot access bank account with
label: $username."
- )
- val bankAccountDebit =
getBankAccountFromPayto(body.debit_account)
- logger.debug("TWG add-incoming about to wire transfer")
- val ref = wireTransfer(
- bankAccountDebit.label,
- bankAccountCredit.label,
- demobank.name,
- body.reserve_pub,
- body.amount
- )
- /**
- * The remaining part aims at returning an
x-libeufin-bank-formatted
- * message to Nexus, to let it ingest the (incoming
side of the) payment
- * information. The format choice makes it more
practical for Nexus,
- * because it handles this format already for the
x-libeufin-bank connection
- * type.
- */
- val incomingTx = BankAccountTransactionEntity.find {
-
BankAccountTransactionsTable.accountServicerReference eq ref and (
- BankAccountTransactionsTable.direction eq
"CRDT"
- ) // closes the 'and'.
- }.firstOrNull()
- if (incomingTx == null)
- throw internalServerError("Just created
transaction not found in DB. AcctSvcrRef: $ref")
- val incomingHistoryElement =
getHistoryElementFromTransactionRow(incomingTx)
- logger.debug("TWG add-incoming has wire transferred,
AcctSvcrRef: $ref")
- incomingHistoryElement
- }
- val resp = object {
- val transactions = listOf(singletonTx)
- }
- call.respond(resp)
- return@post
- }
- }
- // Talk to wallets.
- route("/integration-api") {
- get("/config") {
- val demobank = ensureDemobank(call)
- call.respond(SandboxConfig(
- name = "taler-bank-integration",
- version = PROTOCOL_VERSION_UNIFIED,
- currency = demobank.config.currency
- ))
- return@get
- }
- post("/withdrawal-operation/{wopid}") {
- val arg = ensureNonNull(call.parameters["wopid"])
- val withdrawalUuid = parseUuid(arg)
- val body = call.receive<TalerWithdrawalSelection>()
- val transferDone = transaction {
- val wo = TalerWithdrawalEntity.find {
- TalerWithdrawalsTable.wopid eq withdrawalUuid
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound, "Withdrawal operation
$withdrawalUuid not found."
- )
- if (wo.confirmationDone) {
- return@transaction true
- }
- if (wo.selectionDone) {
- if (body.reserve_pub != wo.reservePub) throw
SandboxError(
- HttpStatusCode.Conflict,
- "Selecting a different reserve from the one
already selected"
- )
- if (body.selected_exchange !=
wo.selectedExchangePayto) throw SandboxError(
- HttpStatusCode.Conflict,
- "Selecting a different exchange from the one
already selected"
- )
- return@transaction false
- }
- // Flow here means never selected, hence must as well
never be paid.
- if (wo.confirmationDone) throw internalServerError(
- "Withdrawal ${wo.wopid} knew NO exchange and
reserve pub, " +
- "but is marked as paid!"
- )
- wo.reservePub = body.reserve_pub
- wo.selectedExchangePayto = body.selected_exchange
- wo.selectionDone = true
- false
- }
- call.respond(object {
- val transfer_done: Boolean = transferDone
- })
- return@post
- }
- get("/withdrawal-operation/{wopid}") {
- val arg = ensureNonNull(call.parameters["wopid"])
- val maybeWithdrawalUuid = parseUuid(arg)
- val maybeWithdrawalOp = transaction {
- TalerWithdrawalEntity.find {
- TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Withdrawal operation: $arg not found"
- )
- }
- val demobank = ensureDemobank(call)
- val captchaPage: String? =
demobank.config.captchaUrl?.replace("{wopid}",arg)
- if (captchaPage == null)
- throw internalServerError("demobank ${demobank.name}
lacks the CAPTCHA URL from the configuration.")
- val ret = TalerWithdrawalStatus(
- selection_done = maybeWithdrawalOp.selectionDone,
- transfer_done = maybeWithdrawalOp.confirmationDone,
- amount = maybeWithdrawalOp.amount,
- suggested_exchange =
demobank.config.suggestedExchangeBaseUrl,
- aborted = maybeWithdrawalOp.aborted,
- confirm_transfer_url = captchaPage
- )
- call.respond(ret)
- return@get
- }
- }
- route("/circuit-api") {
- circuitApi(this)
- }
- // Talk to Web UI.
- route("/access-api") {
- post("/accounts/{account_name}/transactions") {
- val username = call.request.basicAuth()
- val demobank = ensureDemobank(call)
- val bankAccount = getBankAccountFromLabel(
- call.expectUriComponent("account_name"),
- demobank
- )
- // note: admin has no rights to create transactions on
non-admin accounts.
- val authGranted: Boolean = !WITH_AUTH
- if (!authGranted && username != bankAccount.label)
- throw unauthorized("Username '$username' has no rights
over bank account ${bankAccount.label}")
- val req = call.receive<XLibeufinBankPaytoReq>()
- val payto = parsePayto(req.paytoUri)
- val amount: String? = payto.amount ?: req.amount
- if (amount == null) throw badRequest("Amount is missing")
- /**
- * The transaction block below lets the 'demoBank' field
- * of 'bankAccount' be correctly accessed. */
- transaction {
- wireTransfer(
- debitAccount = bankAccount.label,
- creditAccount =
getBankAccountFromIban(payto.iban).label,
- demobank = bankAccount.demoBank.name,
- subject = payto.message ?: throw badRequest(
- "'message' query parameter missing in Payto
address"
- ),
- amount = amount,
- pmtInfId = req.pmtInfId
- )
- }
- call.respond(object {})
- return@post
- }
- // Information about one withdrawal.
- get("/accounts/{account_name}/withdrawals/{withdrawal_id}") {
- getWithdrawal(call)
- return@get
- }
- // account-less style:
- get("/withdrawals/{withdrawal_id}") {
- getWithdrawal(call)
- return@get
- }
- // Create a new withdrawal operation.
- post("/accounts/{account_name}/withdrawals") {
- var username = call.request.basicAuth()
- val demobank = ensureDemobank(call)
- /**
- * Check here if the user has the right over the claimed
bank account. After
- * this check, the withdrawal operation will be allowed
only by providing its
- * UID. */
- val maybeOwnedAccount = getBankAccountFromLabel(
- call.expectUriComponent("account_name"),
- demobank
- )
- val authGranted = !WITH_AUTH // note: admin not allowed on
non-admin accounts
- if (!authGranted && maybeOwnedAccount.owner != username)
- throw unauthorized("Customer '$username' has no rights
over bank account '${maybeOwnedAccount.label}'")
- val req = call.receive<WithdrawalRequest>()
- // Check for currency consistency
- val amount = parseAmount(req.amount)
- if (amount.currency != demobank.config.currency)
- throw badRequest("Currency ${amount.currency} differs
from Demobank's: ${demobank.config.currency}")
- // Check funds are sufficient.
- if (
- maybeDebit(
- maybeOwnedAccount.label,
- BigDecimal(amount.amount),
- transaction { maybeOwnedAccount.demoBank.name }
- )) {
- logger.error("Account ${maybeOwnedAccount.label} would
surpass debit threshold. Not withdrawing")
- throw SandboxError(HttpStatusCode.Conflict,
"Insufficient funds")
- }
- val wo: TalerWithdrawalEntity = transaction {
- TalerWithdrawalEntity.new {
- this.amount = req.amount
- walletBankAccount = maybeOwnedAccount
- }
- }
- val baseUrl = URL(call.request.getBaseUrl())
- val withdrawUri = url {
- protocol = URLProtocol(
- name = "taler".plus(if
(baseUrl.protocol.lowercase() == "http") "+http" else ""),
- defaultPort = -1
- )
- host = "withdraw"
- val pathSegments = mutableListOf(
- /**
- * encodes the hostname(+port) of the actual
- * bank that will serve the withdrawal request.
- */
- baseUrl.host.plus(
- if (baseUrl.port != -1)
- ":${baseUrl.port}"
- else ""
- )
- )
- /**
- * Slashes can only be intermediate and single,
- * any other combination results in badly formed URIs.
- * The following loop ensure this for the current URI
path.
- * This might even come from X-Forwarded-Prefix.
- */
- baseUrl.path.split("/").forEach {
- if (it.isNotEmpty()) pathSegments.add(it)
- }
-
pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}")
- this.appendPathSegments(pathSegments)
- }
- call.respond(object {
- val withdrawal_id = wo.wopid.toString()
- val taler_withdraw_uri = withdrawUri
- })
- return@post
- }
- // Confirm a withdrawal: no basic auth, because the ID should
be unguessable.
-
post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") {
- confirmWithdrawal(call)
- return@post
- }
- // account-less style:
- post("/withdrawals/{withdrawal_id}/confirm") {
- confirmWithdrawal(call)
- return@post
- }
- // Aborting withdrawals:
-
post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") {
- abortWithdrawal(call)
- return@post
- }
- // account-less style:
- post("/withdrawals/{withdrawal_id}/abort") {
- abortWithdrawal(call)
- return@post
- }
- // Bank account basic information.
- get("/accounts/{account_name}") {
- val username = call.request.basicAuth()
- val accountAccessed =
call.expectUriComponent("account_name")
- val demobank = ensureDemobank(call)
- val bankAccount = getBankAccountFromLabel(accountAccessed,
demobank)
- val authGranted = !WITH_AUTH || bankAccount.isPublic ||
username == "admin"
- if (!authGranted && bankAccount.owner != username)
- throw forbidden("Customer '$username' cannot access
bank account '$accountAccessed'")
- val balance = getBalance(bankAccount)
- logger.debug("Balance of '$username':
${balance.toPlainString()}")
- call.respond(object {
- val balance = object {
- val amount =
"${demobank.config.currency}:${balance.abs().toPlainString()}"
- val credit_debit_indicator = if (balance <
BigDecimal.ZERO) "debit" else "credit"
- }
- val paytoUri = buildIbanPaytoUri(
- iban = bankAccount.iban,
- bic = bankAccount.bic,
- // username 'null' should only happen when auth is
disabled.
- receiverName =
getPersonNameFromCustomer(bankAccount.owner)
- )
- val iban = bankAccount.iban
- // The Elvis operator helps the --no-auth case,
- // where username would be empty
- val debitThreshold = getMaxDebitForUser(
- username = username ?: "admin",
- demobankName = demobank.name
- ).toString()
- })
- return@get
- }
- get("/accounts/{account_name}/transactions/{tId}") {
- val username = call.request.basicAuth()
- val demobank = ensureDemobank(call)
- val bankAccount = getBankAccountFromLabel(
- call.expectUriComponent("account_name"),
- demobank
- )
- val authGranted: Boolean = bankAccount.isPublic ||
!WITH_AUTH || username == "admin"
- if (!authGranted && username != bankAccount.owner)
- throw forbidden("Cannot access bank account
${bankAccount.label}")
- val tId = call.parameters["tId"] ?: throw badRequest("URI
didn't contain the transaction ID")
- val tx: BankAccountTransactionEntity? = transaction {
- BankAccountTransactionEntity.find {
-
BankAccountTransactionsTable.accountServicerReference eq tId
- }.firstOrNull()
- }
- if (tx == null) throw notFound("Transaction $tId wasn't
found")
- call.respond(getHistoryElementFromTransactionRow(tx))
- return@get
- }
- get("/accounts/{account_name}/transactions") {
- val username = call.request.basicAuth()
- val demobank = ensureDemobank(call)
- val bankAccount = getBankAccountFromLabel(
- call.expectUriComponent("account_name"),
- demobank
- )
- val authGranted: Boolean = bankAccount.isPublic ||
!WITH_AUTH || username == "admin"
- if (!authGranted && bankAccount.owner != username)
- throw forbidden("Cannot access bank account
${bankAccount.label}")
- // Paging values.
- val page: Int =
expectInt(call.request.queryParameters["page"] ?: "1")
- if (page < 1) throw badRequest("'page' param is less than
1")
- val size: Int =
expectInt(call.request.queryParameters["size"] ?: "5")
- if (size < 1) throw badRequest("'size' param is less than
1")
- // Time range filter values
- val fromMs: Long =
expectLong(call.request.queryParameters["from_ms"] ?: "0")
- if (fromMs < 0) throw badRequest("'from_ms' param is less
than 0")
- val untilMs: Long =
expectLong(call.request.queryParameters["until_ms"] ?:
Long.MAX_VALUE.toString())
- if (untilMs < 0) throw badRequest("'until_ms' param is
less than 0")
- val longPollMs: Long? = call.maybeLong("long_poll_ms")
- // LISTEN, if Postgres.
- val listenHandle = if (isPostgres() && longPollMs != null)
{
- val channelName = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- call.expectUriComponent("account_name")
- )
- val listenHandle = PostgresListenHandle(channelName)
- // Can't LISTEN on the same DB TX that checks for
data, as Exposed
- // closes that connection and the notification getter
would fail.
- // Can't invoke the notification getter in the same DB
TX either,
- // as it would block the DB.
- listenHandle.postgresListen()
- listenHandle
- } else null
- val historyParams = HistoryParams(
- pageNumber = page,
- pageSize = size,
- bankAccount = bankAccount,
- fromMs = fromMs,
- untilMs = untilMs
- )
- var ret: List<XLibeufinBankTransaction> = transaction {
- extractTxHistory(historyParams)
- }
- logger.debug("Is payment data empty? ${ret.isEmpty()}")
- // Data was found already, UNLISTEN and respond.
- if (listenHandle != null && ret.isNotEmpty()) {
- logger.debug("No need to wait DB events, payment data
found.")
- listenHandle.postgresUnlisten()
- call.respond(object {val transactions = ret})
- return@get
- }
- // No data was found, sleep until the timeout or getting
woken up.
- // Third condition only silences the compiler.
- if (listenHandle != null && longPollMs != null) {
- logger.debug("Waiting DB event for new payment data.")
- val notificationArrived =
listenHandle.waitOnIODispatchers(longPollMs)
- // Only if the awaited event fired, query again the DB.
- if (notificationArrived)
- {
- ret = transaction {
- // Refreshing to update the index to the very
last transaction.
- historyParams.bankAccount.refresh()
- extractTxHistory(historyParams)
- }
- }
- }
- call.respond(object {val transactions = ret})
- return@get
- }
- get("/public-accounts") {
- val demobank = ensureDemobank(call)
- val ret = object {
- val publicAccounts = mutableListOf<PublicAccountInfo>()
- }
- transaction {
- BankAccountEntity.find {
- BankAccountsTable.isPublic eq true and(
- BankAccountsTable.demoBank eq demobank.id
- )
- }.forEach {
- val balanceIter = getBalance(it)
- ret.publicAccounts.add(
- PublicAccountInfo(
- balance =
"${demobank.config.currency}:$balanceIter",
- iban = it.iban,
- accountLabel = it.label
- )
- )
- }
- }
- call.respond(ret)
- return@get
- }
- delete("accounts/{account_name}") {
- val username = call.request.basicAuth()
- val demobank = ensureDemobank(call)
- val authGranted = !WITH_AUTH || username == "admin"
- val bankAccountLabel =
call.expectUriComponent("account_name")
- /**
- * This helper fails if the demobank that is mentioned in
the URI
- * is not hosting the account to be deleted.
- */
- val bankAccount = getBankAccountFromLabel(
- bankAccountLabel,
- demobank
- )
- if (!authGranted && username != bankAccount.owner)
- throw unauthorized("User '$username' has no rights to
delete bank account '$bankAccountLabel'")
- transaction {
- val customerAccount = getCustomer(bankAccount.owner)
- bankAccount.delete()
- customerAccount.delete()
- }
- call.respond(object {})
- return@delete
- }
- // Keeping the prefix "testing" not to break tests.
- post("/testing/register") {
- // Check demobank was created.
- val demobank = ensureDemobank(call)
- if (!demobank.config.allowRegistrations) {
- throw SandboxError(
- HttpStatusCode.UnprocessableEntity,
- "The bank doesn't allow new registrations at the
moment."
- )
- }
- val req = call.receive<CustomerRegistration>()
- val newAccount = insertNewAccount(
- req.username,
- req.password,
- name = req.name,
- iban = req.iban,
- demobank = demobank.name,
- isPublic = req.isPublic
- )
- val balance = getBalance(newAccount.bankAccount)
- call.respond(object {
- val balance = getBalanceForJson(balance,
demobank.config.currency)
- val paytoUri = buildIbanPaytoUri(
- iban = newAccount.bankAccount.iban,
- bic = newAccount.bankAccount.bic,
- receiverName =
getPersonNameFromCustomer(req.username)
- )
- val iban = newAccount.bankAccount.iban
- val debitThreshold = getMaxDebitForUser(
- req.username,
- demobank.name
- ).toString()
- })
- return@post
- }
- }
- route("/ebics") {
- /**
- * Associate an existing bank account to one EBICS subscriber.
- * If the subscriber is not found, it is created.
- */
- post("/subscribers") {
- // Only the admin can create Ebics subscribers.
- val user = call.request.basicAuth()
- if (WITH_AUTH && (user != "admin")) throw forbidden("Only
the Administrator can create Ebics subscribers.")
- val body = call.receive<EbicsSubscriberInfo>()
- // Create or get the Ebics subscriber that is found.
- transaction {
- // Check that host ID exists
- EbicsHostEntity.find {
- EbicsHostsTable.hostID eq body.hostID
- }.firstOrNull() ?: throw notFound("Host ID
${body.hostID} not found.")
- val subscriber: EbicsSubscriberEntity =
EbicsSubscriberEntity.find {
- (EbicsSubscribersTable.partnerId eq
body.partnerID).and(
- EbicsSubscribersTable.userId eq body.userID
- ).and(EbicsSubscribersTable.hostId eq body.hostID)
- }.firstOrNull() ?: EbicsSubscriberEntity.new {
- partnerId = body.partnerID
- userId = body.userID
- systemId = null
- hostId = body.hostID
- state = SubscriberState.NEW
- nextOrderID = 1
- }
- val bankAccount = getBankAccountFromLabel(
- body.demobankAccountLabel,
- ensureDemobank(call)
- )
- subscriber.bankAccount = bankAccount
- }
- call.respond(object {})
- return@post
- }
- }
- }
}
-}
+}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt
b/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt
deleted file mode 100644
index 2475ff53..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.http.content.*
-import io.ktor.serialization.*
-import io.ktor.util.reflect.*
-import io.ktor.utils.io.*
-import io.ktor.utils.io.charsets.*
-import io.ktor.utils.io.jvm.javaio.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import tech.libeufin.util.XMLUtil
-
-class XMLEbicsConverter : ContentConverter {
- override suspend fun deserialize(
- charset: Charset,
- typeInfo: TypeInfo,
- content: ByteReadChannel
- ): Any {
- return withContext(Dispatchers.IO) {
- try {
-
receiveEbicsXmlInternal(content.toInputStream().reader().readText())
- } catch (e: Exception) {
- throw SandboxError(
- HttpStatusCode.BadRequest,
- "Document is invalid XML."
- )
- }
- }
- }
-
- // The following annotation was suggested by Intellij.
- @Deprecated(
- "Please override and use serializeNullable instead",
- replaceWith = ReplaceWith("serializeNullable(charset, typeInfo,
contentType, value)"),
- level = DeprecationLevel.WARNING
- )
- override suspend fun serialize(
- contentType: ContentType,
- charset: Charset,
- typeInfo: TypeInfo,
- value: Any
- ): OutgoingContent? {
- return super.serializeNullable(contentType, charset, typeInfo, value)
- }
-
- override suspend fun serializeNullable(
- contentType: ContentType,
- charset: Charset,
- typeInfo: TypeInfo,
- value: Any?
- ): OutgoingContent? {
- val conv = try {
- XMLUtil.convertJaxbToString(value)
- } catch (e: Exception) {
- /**
- * Not always an error: the content negotiation might have
- * only checked if this handler could convert the response.
- */
- return null
- }
- return OutputStreamContent({
- val out = this;
- withContext(Dispatchers.IO) {
- out.write(conv.toByteArray())
- }},
- contentType.withCharset(charset)
- )
- }
-}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt
b/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt
deleted file mode 100644
index 04812192..00000000
--- a/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.math.BigDecimal
-
-/**
- * Check whether the given bank account would surpass the
- * debit threshold, in case the potential amount gets transferred.
- * Returns true when the debit WOULD be surpassed. */
-fun maybeDebit(
- accountLabel: String,
- requestedAmount: BigDecimal,
- demobankName: String = "default"
-): Boolean {
- val demobank = getDemobank(demobankName) ?: throw notFound(
- "Demobank '${demobankName}' not found when trying to check the debit
threshold" +
- " for user $accountLabel"
- )
- val balance = getBalance(accountLabel, demobankName)
- val maxDebt = if (accountLabel == "admin") {
- demobank.config.bankDebtLimit
- } else demobank.config.usersDebtLimit
- val balanceCheck = balance - requestedAmount
- if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() >
BigDecimal.valueOf(maxDebt.toLong())) {
- logger.warn("User '$accountLabel' would surpass the debit" +
- " threshold of $maxDebt, given the requested amount of
${requestedAmount.toPlainString()}")
- return true
- }
- return false
-}
-
-fun getMaxDebitForUser(
- username: String,
- demobankName: String = "default"
-): Int {
- val bank = getDemobank(demobankName) ?: throw internalServerError(
- "demobank $demobankName not found"
- )
- if (username == "admin") return bank.config.bankDebtLimit
- return bank.config.usersDebtLimit
-}
-
-fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson {
- return BalanceJson(
- amount = "${currency}:${value.abs()}",
- credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else
"credit"
- )
-}
-
-fun getBalance(bankAccount: BankAccountEntity): BigDecimal {
- return BigDecimal(bankAccount.balance)
-}
-
-/**
- * This function balances _in bank account statements_. A statement
- * witnesses the bank account after a given business time slot. Therefore
- * _this_ type of balance is not guaranteed to hold the _actual_ and
- * more up-to-date bank account. It'll be used when Sandbox will support
- * the issuing of bank statement.
- */
-fun getBalanceForStatement(
- bankAccount: BankAccountEntity,
- withPending: Boolean = true
-): BigDecimal {
- val lastStatement = transaction {
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq bankAccount.id
- }.lastOrNull()
- }
- var lastBalance = if (lastStatement == null) {
- BigDecimal.ZERO
- } else { BigDecimal(lastStatement.balanceClbd) }
- if (!withPending) return lastBalance
- /**
- * Caller asks to include the pending transactions in the
- * balance. The block below gets the transactions happened
- * later than the last statement and adds them to the balance
- * that was calculated so far.
- */
- transaction {
- val pendingTransactions = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.account eq bankAccount.id and (
-
BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L))
- }
- pendingTransactions.forEach { tx ->
- when (tx.direction) {
- "DBIT" -> lastBalance -= parseDecimal(tx.amount)
- "CRDT" -> lastBalance += parseDecimal(tx.amount)
- else -> {
- logger.error("Transaction ${tx.id} is neither debit nor
credit.")
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Error in transactions state."
- )
- }
- }
- }
- }
- return lastBalance
-}
-
-// Gets the balance of 'accountLabel', which is hosted at 'demobankName'.
-fun getBalance(accountLabel: String,
- demobankName: String = "default"
-): BigDecimal {
- val demobank = getDemobank(demobankName) ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Demobank '$demobankName' not found"
- )
-
- /**
- * Setting withBankFault to true for the following reason:
- * when asking for a balance, the bank should have made sure
- * that the user has a bank account (together with a customer profile).
- * If that's not the case, it's bank's fault, since it didn't check
- * earlier.
- */
- val account = getBankAccountFromLabel(
- accountLabel,
- demobank,
- withBankFault = true
- )
- return getBalance(account)
-}
-
-/**
- * 'debitAccount' and 'creditAccount' are customer usernames
- * and ALSO labels of the bank accounts owned by them. They are
- * used to both resort a bank account and the legal name owning
- * the bank accounts.
- */
-fun wireTransfer(
- debitAccount: String,
- creditAccount: String,
- demobank: String = "default",
- subject: String,
- amount: String, // $currency:x.y
- pmtInfId: String? = null,
- endToEndId: String? = null
-): String {
- logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount
-> $creditAccount, $subject, $amount")
- return transaction {
- val demobankDb = ensureDemobank(demobank)
- val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb)
- val creditAccountDb = getBankAccountFromLabel(creditAccount,
demobankDb)
- val parsedAmount = parseAmount(amount)
- // Potential amount to transfer.
- val amountAsNumber = BigDecimal(parsedAmount.amount)
- if (amountAsNumber == BigDecimal.ZERO)
- throw badRequest("Wire transfers of zero not possible.")
- if (parsedAmount.currency != demobankDb.config.currency)
- throw badRequest(
- "Won't wire transfer with currency: ${parsedAmount.currency}."
+
- " Only ${demobankDb.config.currency} allowed."
- )
- // Check funds are sufficient.
- if (
- maybeDebit(
- debitAccountDb.label,
- amountAsNumber,
- demobankDb.name
- )) {
- logger.error("Account ${debitAccountDb.label} would surpass debit
threshold. Rollback wire transfer")
- throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds")
- }
- val timeStamp = getNowMillis()
- val transactionRef = getRandomString(8)
- BankAccountTransactionEntity.new {
- creditorIban = creditAccountDb.iban
- creditorBic = creditAccountDb.bic
- this.creditorName =
getPersonNameFromCustomer(creditAccountDb.owner)
- debtorIban = debitAccountDb.iban
- debtorBic = debitAccountDb.bic
- debtorName = getPersonNameFromCustomer(debitAccountDb.owner)
- this.subject = subject
- this.amount = parsedAmount.amount
- this.currency = demobankDb.config.currency
- date = timeStamp
- accountServicerReference = transactionRef
- account = creditAccountDb
- direction = "CRDT"
- this.demobank = demobankDb
- this.pmtInfId = pmtInfId
- }
- BankAccountTransactionEntity.new {
- creditorIban = creditAccountDb.iban
- creditorBic = creditAccountDb.bic
- this.creditorName =
getPersonNameFromCustomer(creditAccountDb.owner)
- debtorIban = debitAccountDb.iban
- debtorBic = debitAccountDb.bic
- debtorName = getPersonNameFromCustomer(debitAccountDb.owner)
- this.subject = subject
- this.amount = parsedAmount.amount
- this.currency = demobankDb.config.currency
- date = timeStamp
- accountServicerReference = transactionRef
- account = debitAccountDb
- direction = "DBIT"
- this.demobank = demobankDb
- this.pmtInfId = pmtInfId
- this.endToEndId = endToEndId
- }
-
- // Adjusting the balances (acceptable debit conditions checked before).
- // Debit:
- val newDebitBalance = (BigDecimal(debitAccountDb.balance) -
amountAsNumber).roundToTwoDigits()
- debitAccountDb.balance = newDebitBalance.toPlainString()
- // Credit:
- val newCreditBalance = (BigDecimal(creditAccountDb.balance) +
amountAsNumber).roundToTwoDigits()
- creditAccountDb.balance = newCreditBalance.toPlainString()
-
- // Signaling this wire transfer's event.
- if (this.isPostgres()) {
- val creditChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- creditAccountDb.label
- )
- this.postgresNotify(creditChannel, "CRDT")
- val debitChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- debitAccountDb.label
- )
- this.postgresNotify(debitChannel, "DBIT")
- }
- transactionRef
- }
-}
-
-/**
- * Helper that constructs a transactions history page
- * according to the URI parameters passed to Access API's
- * GET /transactions.
- */
-data class HistoryParams(
- val pageNumber: Int,
- val pageSize: Int,
- val fromMs: Long,
- val untilMs: Long,
- val bankAccount: BankAccountEntity
-)
-
-fun extractTxHistory(params: HistoryParams): List<XLibeufinBankTransaction> {
- val ret = mutableListOf<XLibeufinBankTransaction>()
-
- /**
- * Helper that gets transactions earlier than the 'firstElementId'
- * transaction AND that match the URI parameters.
- */
- fun getPage(firstElementId: Long): Iterable<BankAccountTransactionEntity> {
- return BankAccountTransactionEntity.find {
- (BankAccountTransactionsTable.id lessEq firstElementId) and
- (BankAccountTransactionsTable.account eq
params.bankAccount.id) and
- (BankAccountTransactionsTable.date.between(params.fromMs,
params.untilMs))
- }.sortedByDescending { it.id.value }.take(params.pageSize)
- }
- // Gets a pointer to the last transaction of this bank account.
- val lastTransaction: BankAccountTransactionEntity? =
params.bankAccount.lastTransaction
- if (lastTransaction == null) return ret
- var nextPageIdUpperLimit: Long = lastTransaction.id.value
-
- // This loop fetches (and discards) pages until the desired one is found.
- for (i in 1..(params.pageNumber)) {
- val pageBuf = getPage(nextPageIdUpperLimit)
- logger.debug("pageBuf #$i follows. Request wants
#${params.pageNumber}:")
- pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject},
amount: ${it.currency}:${it.amount}") }
- if (pageBuf.none()) return ret
- nextPageIdUpperLimit = pageBuf.last().id.value - 1
- if (i == params.pageNumber) pageBuf.forEach {
- ret.add(getHistoryElementFromTransactionRow(it))
- }
- }
- return ret
-}
\ No newline at end of file
diff --git a/bank/src/main/resources/logback.xml
b/bank/src/main/resources/logback.xml
index cefb7182..d04f095e 100644
--- a/bank/src/main/resources/logback.xml
+++ b/bank/src/main/resources/logback.xml
@@ -6,7 +6,7 @@
</encoder>
</appender>
- <logger name="tech.libeufin.sandbox" level="ALL" additivity="false">
+ <logger name="tech.libeufin.bank" level="ALL" additivity="false">
<appender-ref ref="STDERR" />
</logger>
<logger name="tech.libeufin.util" level="ALL" additivity="false">
diff --git a/bank/src/test/kotlin/BalanceTest.kt
b/bank/src/test/kotlin/BalanceTest.kt
deleted file mode 100644
index eb09cc64..00000000
--- a/bank/src/test/kotlin/BalanceTest.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-import org.jetbrains.exposed.sql.SchemaUtils
-import org.jetbrains.exposed.sql.insert
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.millis
-import tech.libeufin.util.roundToTwoDigits
-import java.math.BigDecimal
-import java.time.LocalDateTime
-
-class BalanceTest {
- @Test
- fun balanceTest() {
- val config = DemobankConfig(
- currency = "EUR",
- bankDebtLimit = 1000000,
- usersDebtLimit = 10000,
- allowRegistrations = true,
- demobankName = "default",
- withSignupBonus = false
- )
- withTestDatabase {
- transaction {
- insertConfigPairs(config)
- val demobank = DemobankConfigEntity.new {
- name = "default"
- }
- val one = BankAccountEntity.new {
- iban = "IBAN 1"
- bic = "BIC"
- label = "label 1"
- owner = "admin"
- this.demoBank = demobank
- }
- val other = BankAccountEntity.new {
- iban = "IBAN 2"
- bic = "BIC"
- label = "label 2"
- owner = "admin"
- this.demoBank = demobank
- }
- BankAccountTransactionEntity.new {
- account = one
- creditorIban = "earns"
- creditorBic = "BIC"
- creditorName = "Creditor Name"
- debtorIban = "spends"
- debtorBic = "BIC"
- debtorName = "Debitor Name"
- subject = "deal"
- amount = "1"
- date = LocalDateTime.now().millis()
- currency = "EUR"
- pmtInfId = "0"
- direction = "CRDT"
- accountServicerReference =
"test-account-servicer-reference"
- this.demobank = demobank
- }
- BankAccountTransactionEntity.new {
- account = one
- creditorIban = "earns"
- creditorBic = "BIC"
- creditorName = "Creditor Name"
- debtorIban = "spends"
- debtorBic = "BIC"
- debtorName = "Debitor Name"
- subject = "deal"
- amount = "1"
- date = LocalDateTime.now().millis()
- currency = "EUR"
- pmtInfId = "0"
- direction = "CRDT"
- accountServicerReference =
"test-account-servicer-reference"
- this.demobank = demobank
- }
- BankAccountTransactionEntity.new {
- account = one
- creditorIban = "earns"
- creditorBic = "BIC"
- creditorName = "Creditor Name"
- debtorIban = "spends"
- debtorBic = "BIC"
- debtorName = "Debitor Name"
- subject = "deal"
- amount = "1"
- date = LocalDateTime.now().millis()
- currency = "EUR"
- pmtInfId = "0"
- direction = "DBIT"
- accountServicerReference =
"test-account-servicer-reference"
- this.demobank = demobank
- }
- wireTransfer(
- other.label, one.label, demobank.name, "one gets 1",
"EUR:1"
- )
- wireTransfer(
- other.label, one.label, demobank.name, "one gets another
1", "EUR:1"
- )
- wireTransfer(
- one.label, other.label, demobank.name, "one gives 1",
"EUR:1"
- )
- val maybeOneBalance: BigDecimal = getBalance(one)
- println(maybeOneBalance)
- assert(BigDecimal.ONE.roundToTwoDigits() ==
maybeOneBalance.roundToTwoDigits())
- }
- }
- }
- @Test
- fun balanceAbsTest() {
- val minus = BigDecimal.ZERO - BigDecimal.ONE
- val plus = BigDecimal.ONE
- println(minus.abs().toPlainString())
- println(plus.abs().toPlainString())
- }
-}
diff --git a/bank/src/test/kotlin/DBTest.kt b/bank/src/test/kotlin/DBTest.kt
deleted file mode 100644
index bc5a33c5..00000000
--- a/bank/src/test/kotlin/DBTest.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2020 Taler Systems S.A.
- *
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
- *
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.connectWithSchema
-import tech.libeufin.util.getCurrentUser
-import tech.libeufin.util.getJdbcConnectionFromPg
-import tech.libeufin.util.millis
-import java.io.File
-import java.time.LocalDateTime
-import kotlin.reflect.KProperty
-import kotlin.reflect.typeOf
-
-/**
- * Run a block after connecting to the test database.
- * Cleans up the DB file afterwards.
- */
-fun withTestDatabase(f: () -> Unit) {
- dbDropTables("postgresql:///libeufincheck")
- dbCreateTables("postgresql:///libeufincheck")
- f()
-}
-
-class DBTest {
- private var config = DemobankConfig(
- currency = "EUR",
- bankDebtLimit = 1000000,
- usersDebtLimit = 10000,
- allowRegistrations = true,
- demobankName = "default",
- withSignupBonus = false,
- )
-
- /**
- * This tests the conversion from a Postgres connection
- * string to a JDBC one.
- */
- @Test
- fun connectionStringTest() {
- getJdbcConnectionFromPg("postgres://auditor-basedb")
- var conv = getJdbcConnectionFromPg("postgresql:///libeufincheck")
- connectWithSchema(getJdbcConnectionFromPg("postgres:///libeufincheck"))
- connectWithSchema(conv)
- conv =
getJdbcConnectionFromPg("postgresql://localhost:5432/libeufincheck?user=${System.getProperty("user.name")}")
- connectWithSchema(conv)
- conv =
getJdbcConnectionFromPg("postgresql:///libeufincheck?host=/tmp/libeufin")
- var exception: Exception? = null
- try {
- connectWithSchema(conv)
- } catch (e: Exception) {
- exception = e
- }
- assert(exception is UtilError)
- }
-
- /**
- * Storing configuration values into the database,
- * then extract them and check that they equal the
- * configuration model object.
- */
- @Test
- fun insertPairsTest() {
- withTestDatabase {
- // Config model.
- val config = DemobankConfig(
- currency = "EUR",
- bankDebtLimit = 1,
- usersDebtLimit = 2,
- allowRegistrations = true,
- demobankName = "default",
- withSignupBonus = true
- )
- transaction {
- DemobankConfigEntity.new { name = "default" }
- insertConfigPairs(config)
- val db = getDefaultDemobank()
- /**
- * db.config extracts config values from the database
- * and puts them in a fresh config model object.
- */
- assert(config.hashCode() == db.config.hashCode())
- }
- }
- }
-
- @Test
- fun betweenDates() {
- withTestDatabase {
- transaction {
- insertConfigPairs(config)
- val demobank = DemobankConfigEntity.new {
- name = "default"
- }
- val bankAccount = BankAccountEntity.new {
- iban = "iban"
- bic = "bic"
- label = "label"
- owner = "test"
- demoBank = demobank
- }
- BankAccountTransactionEntity.new {
- account = bankAccount
- creditorIban = "earns"
- creditorBic = "BIC"
- creditorName = "Creditor Name"
- debtorIban = "spends"
- debtorBic = "BIC"
- debtorName = "Debitor Name"
- subject = "deal"
- amount = "EUR:1"
- date = LocalDateTime.now().millis()
- currency = "EUR"
- pmtInfId = "0"
- direction = "DBIT"
- accountServicerReference =
"test-account-servicer-reference"
- this.demobank = demobank
- }
- }
- // The block below tests the date range in the database query
- transaction {
- addLogger(StdOutSqlLogger)
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.date.between(
- 0, // 1970-01-01
- LocalDateTime.now().millis() //
- )
- }.apply {
- assert(this.count() == 1L)
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/DatabaseTest.kt
b/bank/src/test/kotlin/DatabaseTest.kt
index 7716cd26..cb4755db 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -1,6 +1,9 @@
import org.junit.Test
-import tech.libeufin.sandbox.*
+import tech.libeufin.bank.*
import tech.libeufin.util.execCommand
+import tech.libeufin.util.getNow
+import tech.libeufin.util.toMicro
+import java.util.Random
import java.util.UUID
class DatabaseTest {
@@ -23,20 +26,18 @@ class DatabaseTest {
cashoutCurrency = "KUDOS"
)
private val bankAccountFoo = BankAccount(
- iban = "FOO-IBAN-XYZ",
- bic = "FOO-BIC",
- bankAccountLabel = "foo",
+ internalPaytoUri = "FOO-IBAN-XYZ",
lastNexusFetchRowId = 1L,
owningCustomerId = 1L,
- hasDebt = false
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1)
)
private val bankAccountBar = BankAccount(
- iban = "BAR-IBAN-ABC",
- bic = "BAR-BIC",
- bankAccountLabel = "bar",
+ internalPaytoUri = "BAR-IBAN-ABC",
lastNexusFetchRowId = 1L,
owningCustomerId = 2L,
- hasDebt = false
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1)
)
fun initDb(): Database {
@@ -53,22 +54,41 @@ class DatabaseTest {
return db
}
+ @Test
+ fun bearerTokenTest() {
+ val db = initDb()
+ val tokenBytes = ByteArray(32)
+ Random().nextBytes(tokenBytes)
+ val token = BearerToken(
+ bankCustomer = 1L,
+ content = tokenBytes,
+ creationTime = getNow().toMicro(), // make .toMicro()? implicit?
+ expirationTime = getNow().plusDays(1).toMicro(),
+ scope = TokenScope.readonly
+ )
+ assert(db.bearerTokenGet(token.content) == null)
+ db.customerCreate(customerBar) // Tokens need owners.
+ assert(db.bearerTokenCreate(token))
+ assert(db.bearerTokenGet(tokenBytes) != null)
+ }
@Test
fun bankTransactionsTest() {
val db = initDb()
- assert(db.customerCreate(customerFoo))
- assert(db.customerCreate(customerBar))
+ val fooId = db.customerCreate(customerFoo)
+ assert(fooId != null)
+ val barId = db.customerCreate(customerBar)
+ assert(barId != null)
assert(db.bankAccountCreate(bankAccountFoo))
assert(db.bankAccountCreate(bankAccountBar))
- var fooAccount = db.bankAccountGetFromLabel("foo")
+ var fooAccount = db.bankAccountGetFromOwnerId(fooId!!)
assert(fooAccount?.hasDebt == false) // Foo has NO debit.
// Preparing the payment data.
db.bankAccountSetMaxDebt(
- "foo",
+ fooId,
TalerAmount(100, 0)
)
db.bankAccountSetMaxDebt(
- "bar",
+ barId!!,
TalerAmount(50, 0)
)
val fooPaysBar = BankInternalTransaction(
@@ -83,13 +103,13 @@ class DatabaseTest {
)
val firstSpending = db.bankTransactionCreate(fooPaysBar) // Foo pays
Bar and goes debit.
assert(firstSpending == Database.BankTransactionResult.SUCCESS)
- fooAccount = db.bankAccountGetFromLabel("foo")
+ fooAccount = db.bankAccountGetFromOwnerId(fooId)
// Foo: credit -> debit
assert(fooAccount?.hasDebt == true) // Asserting Foo's debit.
// Now checking that more spending doesn't get Foo out of debit.
val secondSpending = db.bankTransactionCreate(fooPaysBar)
assert(secondSpending == Database.BankTransactionResult.SUCCESS)
- fooAccount = db.bankAccountGetFromLabel("foo")
+ fooAccount = db.bankAccountGetFromOwnerId(fooId)
// Checking that Foo's debit is two times the paid amount
// Foo: debit -> debit
assert(fooAccount?.balance?.value == 20L
@@ -97,7 +117,7 @@ class DatabaseTest {
&& fooAccount.hasDebt
)
// Asserting Bar has a positive balance and what Foo paid so far.
- var barAccount = db.bankAccountGetFromLabel("bar")
+ var barAccount = db.bankAccountGetFromOwnerId(barId)
val barBalance: TalerAmount? = barAccount?.balance
assert(
barAccount?.hasDebt == false
@@ -116,7 +136,7 @@ class DatabaseTest {
)
val barPays = db.bankTransactionCreate(barPaysFoo)
assert(barPays == Database.BankTransactionResult.SUCCESS)
- barAccount = db.bankAccountGetFromLabel("bar")
+ barAccount = db.bankAccountGetFromOwnerId(barId)
val barBalanceTen: TalerAmount? = barAccount?.balance
// Bar: credit -> credit
assert(barAccount?.hasDebt == false && barBalanceTen?.value == 10L &&
barBalanceTen.frac == 0)
@@ -124,8 +144,8 @@ class DatabaseTest {
val barPaysAgain = db.bankTransactionCreate(barPaysFoo)
assert(barPaysAgain == Database.BankTransactionResult.SUCCESS)
// Refreshing the two accounts.
- barAccount = db.bankAccountGetFromLabel("bar")
- fooAccount = db.bankAccountGetFromLabel("foo")
+ barAccount = db.bankAccountGetFromOwnerId(barId)
+ fooAccount = db.bankAccountGetFromOwnerId(fooId)
// Foo should have returned to zero and no debt, same for Bar.
// Foo: debit -> credit
assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == false)
@@ -134,8 +154,8 @@ class DatabaseTest {
// Bringing Bar to debit.
val barPaysMore = db.bankTransactionCreate(barPaysFoo)
assert(barPaysAgain == Database.BankTransactionResult.SUCCESS)
- barAccount = db.bankAccountGetFromLabel("bar")
- fooAccount = db.bankAccountGetFromLabel("foo")
+ barAccount = db.bankAccountGetFromOwnerId(barId)
+ fooAccount = db.bankAccountGetFromOwnerId(fooId)
// Bar: credit -> debit
assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == true)
assert(fooAccount?.balance?.equals(TalerAmount(10, 0)) == true)
@@ -148,7 +168,7 @@ class DatabaseTest {
db.customerCreate(customerFoo)
assert(db.customerGetFromLogin("foo")?.name == "Foo")
// Trigger conflict.
- assert(!db.customerCreate(customerFoo))
+ assert(db.customerCreate(customerFoo) == null)
}
@Test
fun configTest() {
@@ -161,19 +181,18 @@ class DatabaseTest {
@Test
fun bankAccountTest() {
val db = initDb()
- assert(db.bankAccountGetFromLabel("foo") == null)
- assert(db.customerCreate(customerFoo))
+ assert(db.bankAccountGetFromOwnerId(1L) == null)
+ assert(db.customerCreate(customerFoo) != null)
assert(db.bankAccountCreate(bankAccountFoo))
assert(!db.bankAccountCreate(bankAccountFoo)) // Triggers conflict.
- assert(db.bankAccountGetFromLabel("foo")?.bankAccountLabel == "foo")
-
assert(db.bankAccountGetFromLabel("foo")?.balance?.equals(TalerAmount(0, 0)) ==
true)
+
assert(db.bankAccountGetFromOwnerId(1L)?.balance?.equals(TalerAmount(0, 0)) ==
true)
}
@Test
fun withdrawalTest() {
val db = initDb()
val uuid = UUID.randomUUID()
- assert(db.customerCreate(customerFoo))
+ assert(db.customerCreate(customerFoo) != null)
assert(db.bankAccountCreate(bankAccountFoo))
// insert new.
assert(db.talerWithdrawalCreate(
@@ -220,16 +239,17 @@ class DatabaseTest {
buyInFee = TalerAmount(0, 22),
sellAtRatio = 2,
sellOutFee = TalerAmount(0, 44),
- cashoutAddress = "IBAN",
+ credit_payto_uri = "IBAN",
cashoutCurrency = "KUDOS",
creationTime = 3L,
subject = "31st",
tanChannel = TanChannel.sms,
tanCode = "secret",
)
- assert(db.customerCreate(customerFoo))
+ val fooId = db.customerCreate(customerFoo)
+ assert(fooId != null)
assert(db.bankAccountCreate(bankAccountFoo))
- assert(db.customerCreate(customerBar))
+ assert(db.customerCreate(customerBar) != null)
assert(db.bankAccountCreate(bankAccountBar))
assert(db.cashoutCreate(op))
val fromDb = db.cashoutGetFromUuid(op.cashoutUuid)
@@ -237,10 +257,11 @@ class DatabaseTest {
assert(db.cashoutDelete(op.cashoutUuid) ==
Database.CashoutDeleteResult.SUCCESS)
assert(db.cashoutCreate(op))
db.bankAccountSetMaxDebt(
- "foo",
+ fooId!!,
TalerAmount(100, 0)
)
- assert(db.bankTransactionCreate(BankInternalTransaction(
+ assert(db.bankTransactionCreate(
+ BankInternalTransaction(
creditorAccountId = 2,
debtorAccountId = 1,
subject = "backing the cash-out",
@@ -249,7 +270,8 @@ class DatabaseTest {
endToEndId = "end-to-end-id",
paymentInformationId = "pmtinfid",
transactionDate = 100000L
- )) == Database.BankTransactionResult.SUCCESS)
+ )
+ ) == Database.BankTransactionResult.SUCCESS)
// Confirming the cash-out
assert(db.cashoutConfirm(op.cashoutUuid, 1L, 1L))
// Checking the confirmation took place.
diff --git a/bank/src/test/kotlin/EbicsErrorTest.kt
b/bank/src/test/kotlin/EbicsErrorTest.kt
deleted file mode 100644
index e0be736b..00000000
--- a/bank/src/test/kotlin/EbicsErrorTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-import org.apache.xml.security.binding.xmldsig.SignatureType
-import org.junit.Test
-import tech.libeufin.util.CryptoUtil
-import tech.libeufin.util.XMLUtil
-import tech.libeufin.util.ebics_h004.EbicsResponse
-import tech.libeufin.util.ebics_h004.EbicsTypes
-
-class EbicsErrorTest {
-
- @Test
- fun makeEbicsErrorResponse() {
- val pair = CryptoUtil.generateRsaKeyPair(2048)
- val resp = EbicsResponse.createForUploadWithError(
- "[EBICS_ERROR] abc",
- "012345",
- EbicsTypes.TransactionPhaseType.INITIALISATION
- )
- val signedResp = XMLUtil.signEbicsResponse(resp, pair.private)
- XMLUtil.validateFromString(signedResp)
- assert(resp.header.mutable.reportText == "[EBICS_ERROR] abc")
- assert(resp.header.mutable.returnCode == "012345")
- assert(resp.body.returnCode.value == "012345")
- }
-}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt
b/bank/src/test/kotlin/LibeuFinApiTest.kt
new file mode 100644
index 00000000..c292d796
--- /dev/null
+++ b/bank/src/test/kotlin/LibeuFinApiTest.kt
@@ -0,0 +1,26 @@
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import org.junit.Test
+import tech.libeufin.bank.Database
+import tech.libeufin.bank.webApp
+
+class LibeuFinApiTest {
+ @Test
+ fun createAccountTest() {
+ testApplication {
+ System.setProperty(
+ "BANK_DB_CONNECTION_STRING",
+ "jdbc:postgresql:///libeufincheck"
+ )
+ val db = Database("jdbc:postgresql:///libeufincheck")
+ db.configSet("max_debt_ordinary_customers", "KUDOS:11")
+ application(webApp)
+ client.post("/test-json") {
+ expectSuccess = true
+ contentType(ContentType.Application.Json)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/StringsTest.kt
b/bank/src/test/kotlin/StringsTest.kt
deleted file mode 100644
index 892a419c..00000000
--- a/bank/src/test/kotlin/StringsTest.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-import org.junit.Test
-import tech.libeufin.util.hasWopidPlaceholder
-import tech.libeufin.util.validateBic
-
-class StringsTest {
-
- @Test
- fun hasWopidTest() {
- assert(hasWopidPlaceholder("http://example.com/#/{wopid}"))
- assert(!hasWopidPlaceholder("http://example.com"))
- assert(hasWopidPlaceholder("http://example.com/#/{WOPID}"))
- assert(!hasWopidPlaceholder("{ W O P I D }"))
- }
-
- @Test
- fun replaceWopidPlaceholderTest() {
- assert(
- "http://example.com/#/operation/{wopid}".replace("{wopid}", "987")
- == "http://example.com/#/operation/987"
- )
- assert("http://example.com".replace("{wopid}", "not-replaced")
- == "http://example.com"
- )
- }
-
- @Test
- fun bicTest() {
- assert(validateBic("GENODEM1GLS"))
- assert(validateBic("AUTOATW1XXX"))
- }
-
- @Test
- fun booleanToString() {
- assert(true.toString() == "true")
- assert(false.toString() == "false")
- }
-}
\ No newline at end of file
diff --git a/database-versioning/new/libeufin-bank-0001.sql
b/database-versioning/new/libeufin-bank-0001.sql
index 314c4ea2..9daaa7b0 100644
--- a/database-versioning/new/libeufin-bank-0001.sql
+++ b/database-versioning/new/libeufin-bank-0001.sql
@@ -32,6 +32,9 @@ COMMENT ON TYPE taler_amount
CREATE TYPE direction_enum
AS ENUM ('credit', 'debit');
+CREATE TYPE token_scope_enum
+ AS ENUM ('readonly', 'readwrite');
+
CREATE TYPE tan_enum
AS ENUM ('sms', 'email', 'file'); -- file is for testing purposes.
@@ -44,7 +47,6 @@ CREATE TYPE subscriber_key_state_enum
CREATE TYPE subscriber_state_enum
AS ENUM ('new', 'confirmed');
-
-- FIXME: comments on types (see exchange for example)!
-- start of: bank config tables. FIXME: eventually replaced by the INI file.
@@ -65,30 +67,44 @@ CREATE TABLE IF NOT EXISTS customers
,name TEXT
,email TEXT
,phone TEXT
- ,cashout_payto TEXT
+ ,cashout_payto TEXT -- here because has no business meaning inside
libeufin-bank
,cashout_currency TEXT
);
COMMENT ON COLUMN customers.cashout_payto
IS 'RFC 8905 payto URI to collect fiat payments that come from the
conversion of regional currency cash-out operations.';
-
COMMENT ON COLUMN customers.name
IS 'Full name of the customer.';
+CREATE TABLE IF NOT EXISTS bearer_tokens
+ (bearer_token_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+ ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32)
+ ,creation_time INT8
+ ,expiration_time INT8
+ ,scope token_scope_enum
+ ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE
CASCADE
+);
+
+COMMENT ON TABLE bearer_tokens
+ IS 'Login tokens associated with one bank customer. There is currently'
+ ' no garbage collector that deletes the expired tokens from the table';
+
+COMMENT ON COLUMN bearer_tokens.bank_customer
+ IS 'The customer that directly created this token, or the customer that'
+ ' created the very first token that originated all the refreshes until'
+ ' this token was created.';
CREATE TABLE IF NOT EXISTS bank_accounts
(bank_account_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
- ,iban TEXT NOT NULL UNIQUE
- ,bic TEXT NOT NULL
- ,bank_account_label TEXT NOT NULL
- ,owning_customer_id BIGINT NOT NULL
+ ,internal_payto_uri TEXT NOT NULL UNIQUE
+ ,owning_customer_id BIGINT NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with
customers
REFERENCES customers(customer_id)
,is_public BOOLEAN DEFAULT FALSE NOT NULL -- privacy by default
+ ,is_taler_exchange BOOLEAN DEFAULT FALSE NOT NULL
,last_nexus_fetch_row_id BIGINT
,balance taler_amount DEFAULT (0, 0)
,max_debt taler_amount DEFAULT (0, 0)
,has_debt BOOLEAN NOT NULL DEFAULT FALSE
- ,UNIQUE (owning_customer_id, bank_account_label)
);
COMMENT ON TABLE bank_accounts
@@ -110,9 +126,6 @@ COMMENT ON COLUMN bank_accounts.is_public
IS 'Indicates whether the bank account history
can be publicly shared';
-COMMENT ON COLUMN bank_accounts.bank_account_label
- IS 'Label of the bank account';
-
COMMENT ON COLUMN bank_accounts.owning_customer_id
IS 'Login that owns the bank account';
@@ -122,11 +135,9 @@ COMMENT ON COLUMN bank_accounts.owning_customer_id
CREATE TABLE IF NOT EXISTS bank_account_transactions
(bank_transaction_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
- ,creditor_iban TEXT NOT NULL
- ,creditor_bic TEXT NULL
+ ,creditor_payto_uri TEXT NOT NULL
,creditor_name TEXT NOT NULL
- ,debtor_iban TEXT NOT NULL
- ,debtor_bic TEXT NULL
+ ,debtor_payto_uri TEXT NOT NULL
,debtor_name TEXT NOT NULL
,subject TEXT NOT NULL
,amount taler_amount NOT NULL
@@ -175,16 +186,14 @@ CREATE TABLE IF NOT EXISTS cashout_operations
REFERENCES bank_accounts(bank_account_id)
ON DELETE CASCADE
ON UPDATE RESTRICT
- ,cashout_address TEXT NOT NULL -- FIXME: clarify payto, if it's a payto use
it in the name
- ,cashout_currency TEXT NOT NULL
+ ,credit_payto_uri TEXT NOT NULL
+ ,cashout_currency TEXT NOT NULL -- need, or include in credit_payto_uri?
);
-- FIXME: table comment missing
COMMENT ON COLUMN cashout_operations.tan_confirmation_time
IS 'Timestamp when the customer confirmed the cash-out operation via TAN';
-COMMENT ON COLUMN cashout_operations.cashout_address
- IS 'IBAN that ultimately gets the fiat payment';
COMMENT ON COLUMN cashout_operations.tan_code
IS 'text that the customer must send to confirm the cash-out operation';
diff --git a/database-versioning/new/procedures.sql
b/database-versioning/new/procedures.sql
index 8fd3c8c1..c31aa5f1 100644
--- a/database-versioning/new/procedures.sql
+++ b/database-versioning/new/procedures.sql
@@ -104,11 +104,9 @@ AS $$
DECLARE
debtor_has_debt BOOLEAN;
debtor_balance taler_amount;
-debtor_iban TEXT;
-debtor_bic TEXT;
+debtor_payto_uri TEXT;
debtor_name TEXT;
-creditor_iban TEXT;
-creditor_bic TEXT;
+creditor_payto_uri TEXT;
creditor_name TEXT;
debtor_max_debt taler_amount;
creditor_has_debt BOOLEAN;
@@ -128,12 +126,12 @@ SELECT
has_debt,
(balance).val, (balance).frac,
(max_debt).val, (max_debt).frac,
- iban, bic, customers.name
+ internal_payto_uri, customers.name
INTO
debtor_has_debt,
debtor_balance.val, debtor_balance.frac,
debtor_max_debt.val, debtor_max_debt.frac,
- debtor_iban, debtor_bic, debtor_name
+ debtor_payto_uri, debtor_name
FROM bank_accounts
JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id)
WHERE bank_account_id=in_debtor_account_id;
@@ -148,11 +146,11 @@ out_nx_debtor=FALSE;
SELECT
has_debt,
(balance).val, (balance).frac,
- iban, bic, customers.name
+ internal_payto_uri, customers.name
INTO
creditor_has_debt,
creditor_balance.val, creditor_balance.frac,
- creditor_iban, creditor_bic, creditor_name
+ creditor_payto_uri, creditor_name
FROM bank_accounts
JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id)
WHERE bank_account_id=in_creditor_account_id;
@@ -251,11 +249,9 @@ out_balance_insufficient=FALSE;
-- now actually create the bank transaction.
-- debtor side:
INSERT INTO bank_account_transactions (
- creditor_iban
- ,creditor_bic
+ creditor_payto_uri
,creditor_name
- ,debtor_iban
- ,debtor_bic
+ ,debtor_payto_uri
,debtor_name
,subject
,amount
@@ -267,11 +263,9 @@ INSERT INTO bank_account_transactions (
,bank_account_id
)
VALUES (
- creditor_iban,
- creditor_bic,
+ creditor_payto_uri,
creditor_name,
- debtor_iban,
- debtor_bic,
+ debtor_payto_uri,
debtor_name,
in_subject,
in_amount,
@@ -285,11 +279,9 @@ VALUES (
-- debtor side:
INSERT INTO bank_account_transactions (
- creditor_iban
- ,creditor_bic
+ creditor_payto_uri
,creditor_name
- ,debtor_iban
- ,debtor_bic
+ ,debtor_payto_uri
,debtor_name
,subject
,amount
@@ -301,11 +293,9 @@ INSERT INTO bank_account_transactions (
,bank_account_id
)
VALUES (
- creditor_iban,
- creditor_bic,
+ creditor_payto_uri,
creditor_name,
- debtor_iban,
- debtor_bic,
+ debtor_payto_uri,
debtor_name,
in_subject,
in_amount,
diff --git a/nexus/build.gradle b/nexus/build.gradle
index 13a2c806..66ec7f98 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -102,14 +102,13 @@ dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21'
testImplementation 'io.ktor:ktor-client-mock:2.2.4'
testImplementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2'
- testImplementation project(":bank")
}
test {
useJUnit()
failFast = true
testLogging.showStandardStreams = false
- environment.put("LIBEUFIN_SANDBOX_ADMIN_PASSWORD", "foo")
+ environment.put("LIBEUFIN_BANK_ADMIN_PASSWORD", "foo")
environment.put("LIBEUFIN_CASHOUT_TEST_TAN", "foo")
}
@@ -132,4 +131,4 @@ run {
task pofi(type: JavaExec) {
classpath = sourceSets.test.runtimeClasspath
mainClass = "PostFinanceKt"
-}
\ No newline at end of file
+}
diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt
b/nexus/src/test/kotlin/ConversionServiceTest.kt
deleted file mode 100644
index f38dece0..00000000
--- a/nexus/src/test/kotlin/ConversionServiceTest.kt
+++ /dev/null
@@ -1,395 +0,0 @@
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.*
-import io.ktor.client.engine.cio.*
-import io.ktor.client.engine.mock.*
-import io.ktor.client.request.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.coroutines.*
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.nexus.server.nexusApp
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.parseAmount
-import java.math.BigDecimal
-
-class ConversionServiceTest {
- // Tests the helper that fetches the new cash-out's to POST to Nexus.
- @Test
- fun testCashoutFetcher() {
- withTestDatabase {
- prepSandboxDb()
- // making a transaction that means cash-out for bar, but not for
foo.
- // That lets test the singleton and empty result sets.
- wireTransfer(
- debitAccount = "foo",
- creditAccount = "bar",
- subject = "a cash-out for bar",
- amount = "TESTKUDOS:3"
- )
- // Expecting the fetcher to return an empty set.
- val expectEmpty = getUnsubmittedTransactions("foo")
- assert(expectEmpty.isEmpty())
- // Expecting the fetcher to return a one-element set.
- val expectOne = getUnsubmittedTransactions("bar")
- assert(expectOne.size == 1)
- // Generating a bunch of cash-out operations for "foo"
- for (i in 1..5)
- wireTransfer(
- debitAccount = "bar",
- creditAccount = "foo",
- subject = "foo #$i",
- amount = "TESTKUDOS:3"
- )
- // Expecting 5 entries for foo.
- val expectFive = getUnsubmittedTransactions("foo")
- assert(expectFive.size == 5)
- /* Checking the order. The order should ensure that
- * later payments get higher indexes. */
- assert(expectFive[0].subject == "foo #1")
- assert(expectFive[4].subject == "foo #5")
- }
- }
- // Tests the helper that applies buy-in ratio and fees
- @Test
- fun buyinRatioTest() {
- val highFees = RatioAndFees(
- buy_at_ratio = 1F,
- buy_in_fee = 10F
- )
- // Checks that negatives aren't let through.
- assertException<UtilError>({
- applyBuyinRatioAndFees(
- BigDecimal.ONE,
- highFees)
- })
- // Checks successful case.
- val fees = RatioAndFees(
- buy_at_ratio = 3.5F,
- buy_in_fee = 0.33F
- )
- assert(applyBuyinRatioAndFees(BigDecimal.valueOf(3), fees) ==
BigDecimal("10.17"))
- }
- private fun CoroutineScope.launchBuyinMonitor(httpClient: HttpClient): Job
{
- val job = launch {
- /**
- * The runInterruptible wrapper lets code without suspension
- * points be cancel()'d. Without it, such code would ignore
- * any call to cancel() and the test never return.
- */
- runInterruptible {
- buyinMonitor(
- demobankName = "default",
- accountToCredit = "exchange-0",
- client = httpClient
- )
- }
- }
- return job
- }
- /**
- * Testing the buy-in monitor in all the HTTP scenarios,
- * successful case, client's and server's error cases.
- */
- @Test
- fun buyinTest() {
- // 1, testing the successful case.
- /* First create an incoming fiat payment _at Nexus_.
- This payment is addressed to the Nexus user whose
- (Nexus) credentials will be used by Sandbox to fetch
- new incoming fiat payments. */
- withTestDatabase {
- prepSandboxDb(currency = "REGIO")
- prepNexusDb()
- // Credits 22 TESTKUDOS to "foo". This information comes
- // normally from the fiat bank that Nexus is connected to.
- val reservePub =
"GX5H5RME193FDRCM1HZKERXXQ2K21KH7788CKQM8X6MYKYRBP8F0"
- newNexusBankTransaction(
- currency = "TESTKUDOS",
- value = "22",
- /**
- * If the subject does NOT have the format of a public key,
- * the conversion service does NOT wire any regio amount to the
- * exchange, just ignores it.
- */
- subject = reservePub
- )
- // Start Nexus, to let it serve the fiat transaction.
- testApplication {
- val client = this.createClient {
- followRedirects = false
- }
- application(nexusApp)
- // Start the buy-in monitor to let it download the fiat
transaction.
- runBlocking {
- val job = launchBuyinMonitor(client)
- delay(1000L) // Lets the DB persist.
- job.cancelAndJoin()
- }
- }
- // Checking that exchange got the converted amount.
- transaction {
- /**
- * Asserting that the exchange has only one incoming
transaction.
- *
- * The Sandbox DB has two entries where the exchange IBAN shows
- * as the 'creditorIban': one DBIT related to the "admin"
account,
- * and one CRDT related to the "exchange-0" account. Thus
filtering
- * the direction is also required.
- */
- assert(
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.creditorIban eq
"AT561936082973364859" and (
- BankAccountTransactionsTable.direction eq "CRDT"
- )
- }.count() == 1L
- )
- val boughtIn = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.creditorIban eq
"AT561936082973364859"
- }.first()
- // Asserting that the one incoming transaction has the wired
reserve public key
- // and the regional currency.
- assert(boughtIn.subject == reservePub && boughtIn.currency ==
"REGIO")
- }
- // 2, testing the client side error case.
- assertException<BuyinClientError>(
- {
- runBlocking {
- /**
- * As soon as the buy-in monitor requests again the
history
- * to Nexus, it'll get 400 from the mock client.
- */
- launchBuyinMonitor(getMockedClient {
respondBadRequest() })
- }
- }
- )
- /**
- * 3, testing the server side error case. Here the monitor should
- * NOT throw any error and instead keep operating normally. This
allows
- * Sandbox to tolerate server errors and retry the requests.
- */
- runBlocking {
- /**
- * As soon as the buy-in monitor requests again the history
- * to Nexus, it'll get 500 from the mock client.
- */
- val job = launchBuyinMonitor(getMockedClient {
respondError(HttpStatusCode.InternalServerError) })
- delay(1000L)
- // Getting here means no exceptions. Can now cancel the
service.
- job.cancelAndJoin()
- }
- /**
- * 4, testing the unhandled error case. This case is treated
- * as a client error, to signal the calling logic to intervene.
- */
- assertException<BuyinClientError>(
- {
- runBlocking {
- /**
- * As soon as the buy-in monitor requests again the
history
- * to Nexus, it'll get 307 from the mock client.
- */
- launchBuyinMonitor(getMockedClient { respondRedirect()
})
- }
- }
- )
- }
- }
- private fun CoroutineScope.launchCashoutMonitor(httpClient: HttpClient):
Job {
- val job = launch {
- /**
- * The runInterruptible wrapper lets code without suspension
- * points be cancel()'d. Without it, such code would ignore
- * any call to cancel() and the test never return.
- */
- runInterruptible {
- /**
- * Without the runBlocking wrapper, cashoutMonitor doesn't
- * compile. That's because it is a 'suspend' function and
- * it needs a coroutine environment to execute;
runInterruptible
- * does NOT provide one. Furthermore, replacing runBlocking
- * with "launch {}" would nullify runInterruptible, due to
other
- * jobs that cashoutMonitor internally launches and would
escape
- * the interruptible policy.
- */
- runBlocking { cashoutMonitor(httpClient) }
- }
- }
- return job
- }
-
- // This function mocks a 500 response to a cash-out request.
- private fun MockRequestHandleScope.mock500Response(): HttpResponseData {
- return respondError(HttpStatusCode.InternalServerError)
- }
- // This function implements a mock server that checks the currency in the
cash-out request.
- private suspend fun MockRequestHandleScope.inspectCashoutCurrency(request:
HttpRequestData): HttpResponseData {
- // Asserting that the currency is indeed the FIAT.
- return if (request.url.encodedPath ==
"/bank-accounts/foo/payment-initiations" && request.method == HttpMethod.Post) {
- val body =
jacksonObjectMapper().readTree(request.body.toByteArray())
- val postedAmount = body.get("amount").asText()
- assert(parseAmount(postedAmount).currency == "FIAT")
- respondOk("cash-out-nonce")
- } else {
- println("Cash-out monitor wrongly requested to: ${request.url}")
- // This is a minimal Web server that support only the above
endpoint.
- respondError(status = HttpStatusCode.NotImplemented)
- }
- }
-
- /**
- * Checks that the cash-out monitor reacts after
- * a CRDT transaction arrives at the designated account.
- */
- @Test
- fun cashoutTest() {
- withTestDatabase {
- prepSandboxDb(
- currency = "REGIO",
- cashoutCurrency = "FIAT"
- )
- prepNexusDb()
- testApplication {
- val client = this.createClient {
- followRedirects = false
- }
- application(nexusApp)
- // Mock server to intercept and inspect the cash-out request.
- val checkCurrencyClient = HttpClient(MockEngine) {
- followRedirects = false
- engine {
- addHandler {
- request -> inspectCashoutCurrency(request)
- }
- }
- }
- // Starting the cash-out monitor with the mocked client.
- runBlocking {
- var job = launchCashoutMonitor(checkCurrencyClient)
- // Following are various cases of a cash-out scenario.
-
- /**
- * 1, Ordinary/successful case. We test that the
conversion
- * service sent indeed one request to Nexus and that the
currency
- * is correct.
- */
- wireTransfer(
- debitAccount = "foo",
- creditAccount = "admin",
- subject = "fiat #0",
- amount = "REGIO:3"
- )
- delay(1000L) // Lets DB persist the information.
- // Checking now the Sandbox side, and namely that one
- // cash-out operation got carried out.
- transaction {
- assert(CashoutSubmissionEntity.all().count() == 1L)
- val op = CashoutSubmissionEntity.all().first()
- /**
- * The next assert witnesses that the mock client's
- * currency assert succeeded.
- */
- assert(op.maybeNexusResposnse == "cash-out-nonce")
- }
- /* 2, Internal server error case. We test that after
requesting
- * to a failing Nexus, the last accounted cash-out did NOT
increase.
- */
- job.cancelAndJoin()
- val error500Client = HttpClient(MockEngine) {
- followRedirects = false
- engine {
- addHandler {
- request -> mock500Response()
- }
- }
- }
- job = launchCashoutMonitor(error500Client)
- // Sending a new payment to trigger the conversion service.
- wireTransfer(
- debitAccount = "foo",
- creditAccount = "admin",
- subject = "fiat #1",
- amount = "REGIO:2"
- )
- delay(1000L) // Lets the reaction complete.
- job.cancelAndJoin()
- transaction {
- val bankaccount = getBankAccountFromLabel("admin")
- // Checks that the counter did NOT increase.
- assert(bankaccount.lastFiatSubmission?.id?.value == 1L)
- }
- /* Removing now the mocked 500 response and checking that
- * the problematic cash-out get then sent. */
- job = launchCashoutMonitor(client) // Should find the non
cashed-out wire transfer and react.
- delay(1000L) // Lets the reaction complete.
- job.cancelAndJoin()
- transaction {
- val bankaccount = getBankAccountFromLabel("admin")
- // Checks that the once failing cash-out did go
through.
- assert(bankaccount.lastFiatSubmission?.subject ==
"fiat #1")
- }
- /**
- * 3, testing the client error case, where
- * the conversion service is supposed to throw exception.
- */
- assertException<CashoutClientError>({
- runBlocking {
- launchCashoutMonitor(
- httpClient = getMockedClient {
- tech.libeufin.sandbox.logger.debug("MOCK
400")
- /**
- * This causes the cash-out request sent
to Nexus to
- * respond with 400.
- */
- respondBadRequest()
- }
- )
- // Triggering now a cash-out operation via a new
wire transfer to admin.
- wireTransfer(
- debitAccount = "foo",
- creditAccount = "admin",
- subject = "fiat #2",
- amount = "REGIO:22"
- )
- }})
- /**
- * 4, checking a redirect response. Because this is an
unhandled
- * error case, it is treated as a client error. No need
to wire a
- * new cash-out to trigger a cash-out request, since the
last failed
- * one will be retried.
- */
- assertException<CashoutClientError>({
- runBlocking {
- launchCashoutMonitor(
- getMockedClient {
- /**
- * This causes the cash-out request sent
to Nexus to
- * respond with 307 Temporary Redirect.
- */
- respondRedirect()
- }
- )
- }
- })
- /* 5, Mocking a network error. The previous failed
cash-out
- will again trigger the service to POST to Nexus. Here
the
- monitor tolerates the failure, as it's not due to its
state
- and should be temporary.
- */
- var requestMade = false
- job = launchCashoutMonitor(
- getMockedClient {
- requestMade = true
- throw Exception("Network Issue.")
- }
- )
- delay(2000L) // Lets the reaction complete.
- // asserting that the service is still running after the
failed request.
- assert(requestMade && job.isActive)
- job.cancelAndJoin()
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/DbEventTest.kt
b/nexus/src/test/kotlin/DbEventTest.kt
deleted file mode 100644
index d3922a76..00000000
--- a/nexus/src/test/kotlin/DbEventTest.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.util.NotificationsChannelDomains
-import tech.libeufin.util.PostgresListenHandle
-import tech.libeufin.util.buildChannelName
-import tech.libeufin.util.postgresNotify
-
-
-class DbEventTest {
- /**
- * LISTENs to one DB channel but only wakes up
- * if the payload is how expected.
- */
- @Test
- fun payloadTest() {
- withTestDatabase {
- val listenHandle = PostgresListenHandle("X")
- transaction { listenHandle.postgresListen() }
- runBlocking {
- launch {
- val isArrived = listenHandle.waitOnIoDispatchersForPayload(
- timeoutMs = 1000L,
- expectedPayload = "Y"
- )
- assert(isArrived)
- }
- launch {
- delay(500L); // Ensures the wait helper runs first.
- transaction { this.postgresNotify("X", "Y") }
- }
- }
- }
- }
-
- /**
- * This function tests the NOTIFY sent by a Exposed's
- * "new {}" overridden static method.
- */
- @Test
- fun automaticNotifyTest() {
- withTestDatabase {
- prepNexusDb()
- val nexusTxChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_NEXUS_TX,
- "foo" // bank account label.
- )
- val listenHandle = PostgresListenHandle(nexusTxChannel)
- transaction { listenHandle.postgresListen() }
- runBlocking {
- launch {
- val isArrived = listenHandle.waitOnIODispatchers(timeoutMs
= 1000L)
- assert(isArrived)
- }
- launch {
- delay(500L); // Ensures the wait helper runs first.
- transaction {
- newNexusBankTransaction(
- "TESTKUDOS",
- "2",
- "unblocking event"
- )
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/EbicsTest.kt
b/nexus/src/test/kotlin/EbicsTest.kt
deleted file mode 100644
index e004070f..00000000
--- a/nexus/src/test/kotlin/EbicsTest.kt
+++ /dev/null
@@ -1,383 +0,0 @@
-import io.ktor.server.application.*
-import io.ktor.http.*
-import io.ktor.server.plugins.contentnegotiation.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import io.ktor.server.testing.*
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import org.w3c.dom.Document
-import tech.libeufin.nexus.*
-import tech.libeufin.nexus.bankaccount.addPaymentInitiation
-import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions
-import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations
-import tech.libeufin.nexus.ebics.*
-import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData
-import tech.libeufin.nexus.iso20022.createPain001document
-import tech.libeufin.nexus.server.*
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.*
-import tech.libeufin.util.ebics_h004.EbicsRequest
-import tech.libeufin.util.ebics_h004.EbicsResponse
-import tech.libeufin.util.ebics_h004.EbicsTypes
-import tech.libeufin.util.ebics_h005.Ebics3Request
-import java.time.LocalDate
-import java.time.ZonedDateTime
-
-/**
- * These test cases run EBICS CCT and C52, mixing ordinary operations
- * and some error cases.
- */
-
-/**
- * Data to make the test server return for EBICS
- * phases. Currently only init is supported.
- */
-data class EbicsResponses(
- val init: String,
- val download: String? = null,
- val receipt: String? = null
-)
-
-/**
- * Minimal server responding always the 'init' field of a EbicsResponses
- * object to a download EBICS message. Suitable to set arbitrary data
- * in said response. Signs the response assuming the client is the one
- * created in MakeEnv.kt.
- */
-fun getCustomEbicsServer(r: EbicsResponses, endpoint: String = "/ebicsweb"):
Application.() -> Unit {
- val ret: Application.() -> Unit = {
- install(ContentNegotiation) {
- register(ContentType.Text.Xml, XMLEbicsConverter())
- register(ContentType.Text.Plain, XMLEbicsConverter())
- }
- routing {
- post(endpoint) {
- val requestDocument = this.call.receive<Document>()
- val req = requestDocument.toObject<EbicsRequest>()
- val clientKey =
CryptoUtil.loadRsaPublicKey(userKeys.enc.public.encoded)
- val msgId = EbicsOrderUtil.generateTransactionId()
- val resp: EbicsResponse = if (
- req.header.mutable.transactionPhase ==
EbicsTypes.TransactionPhaseType.INITIALISATION
- ) {
- val payload = prepareEbicsPayload(r.init, clientKey)
- EbicsResponse.createForDownloadInitializationPhase(
- msgId,
- 1,
- 4096,
- payload.second, // for key material
- payload.first // actual payload
- )
- } else {
- // msgId doesn't have to match the one used for the init
phase.
- EbicsResponse.createForDownloadReceiptPhase(msgId, true)
- }
- val sigEbics = XMLUtil.signEbicsResponse(
- resp,
- CryptoUtil.loadRsaPrivateKey(bankKeys.auth.private.encoded)
- )
- call.respond(sigEbics)
- }
- }
- }
- return ret
-}
-
-class DownloadAndSubmit {
- // Downloads a C52 report from the bank.
- @Test
- fun download() {
- withNexusAndSandboxUser {
- wireTransfer(
- "admin",
- "foo",
- "default",
- "Show up in logging!",
- "TESTKUDOS:1"
- )
- wireTransfer(
- "admin",
- "foo",
- "default",
- "Exist in logging!",
- "TESTKUDOS:5"
- )
-
- testApplication {
- application(sandboxApp)
- runBlocking {
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecTimeRangeJson(
- level = FetchLevel.REPORT,
- start = "2020-10-10",
- end = "3000-10-10",
- bankConnection = "foo"
- ),
- accountId = "foo"
- )
- }
- transaction {
- // FIXME: assert on the subject.
- assert(
- NexusBankTransactionEntity[1].amount == "1" &&
- NexusBankTransactionEntity[2].amount == "5"
- )
- }
- }
- }
- }
-
- // Uploads one payment instruction to the bank.
- @Test
- fun upload() {
- withNexusAndSandboxUser {
- testApplication {
- application(sandboxApp)
- val conn = EbicsBankConnectionProtocol()
- runBlocking {
- // Create Pain.001 to be submitted.
- addPaymentInitiation(
- Pain001Data(
- creditorIban = BAR_USER_IBAN,
- creditorBic = "SANDBOXX",
- creditorName = "Tester",
- subject = "test payment",
- sum = "1",
- currency = "TESTKUDOS"
- ),
- transaction {
- NexusBankAccountEntity.findByName(
- "foo"
- ) ?: throw Exception("Test failed")
- }
- )
- conn.submitPaymentInitiation(
- client,
- 1L
- )
- }
- transaction {
- val howMany = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.debtorIban eq
FOO_USER_IBAN and (
- BankAccountTransactionsTable.subject eq "test
payment"
- ) and (BankAccountTransactionsTable.direction eq
"DBIT")
- }.count()
- assert(howMany == 1L)
- }
- }
- }
- }
-
- /**
- * Upload one payment instruction charging one IBAN
- * that does not belong to the requesting EBICS subscriber.
- */
- @Test
- fun unallowedDebtorIban() {
- withNexusAndSandboxUser {
- testApplication {
- application(sandboxApp)
- runBlocking {
- val bar = transaction {
NexusBankAccountEntity.findByName("bar") }
- val painMessage = createPain001document(
- NexusPaymentInitiationData(
- debtorIban = bar!!.iban,
- debtorBic = bar.bankCode,
- debtorName = bar.accountHolder,
- currency = "TESTKUDOS",
- amount = "1",
- creditorIban = getIban(),
- creditorName = "Get",
- creditorBic = "SANDBOXX",
- paymentInformationId = "entropy-0",
- preparationTimestamp = 1970L,
- subject = "Unallowed",
- messageId = "entropy-1",
- endToEndId = null,
- instructionId = null
- )
- )
- val unallowedSubscriber = transaction {
getEbicsSubscriberDetails("foo") }
- var thrown = false
- try {
- doEbicsUploadTransaction(
- client,
- unallowedSubscriber,
- EbicsUploadSpec(
- orderType = "CCT",
- isEbics3 = false,
- orderParams = EbicsStandardOrderParams()
- ),
- painMessage.toByteArray(Charsets.UTF_8)
- )
- } catch (e: EbicsProtocolError) {
- if (e.ebicsTechnicalCode ==
-
EbicsReturnCode.EBICS_ACCOUNT_AUTHORISATION_FAILED
- )
- thrown = true
- }
- assert(thrown)
- }
- }
- }
- }
-
- /**
- * Submits one pain.001 document with the wrong currency and checks
- * that the bank responded with EBICS_PROCESSING_ERROR.
- */
- @Test
- fun unsupportedCurrency() {
- withNexusAndSandboxUser {
- testApplication {
- application(sandboxApp)
- runBlocking {
- // Create Pain.001 to be submitted.
- addPaymentInitiation(
- Pain001Data(
- creditorIban = getIban(),
- creditorBic = "SANDBOXX",
- creditorName = "Tester",
- subject = "test payment",
- sum = "1",
- currency = "EUR" // EUR not supported.
- ),
- transaction {
- NexusBankAccountEntity.findByName("foo") ?: throw
Exception("Test failed")
- }
- )
- var thrown = false
- try {
- submitAllPaymentInitiations(client, "foo")
- } catch (e: EbicsProtocolError) {
- if (e.ebicsTechnicalCode ==
EbicsReturnCode.EBICS_PROCESSING_ERROR)
- thrown = true
- }
- assert(thrown)
- }
- }
- }
- }
-
- /**
- * Test that pain.001 amounts ALSO have max 2 fractional digits, like
Taler's.
- * That makes Sandbox however NOT completely compatible with the pain.001
standard,
- * since this allows up to 5 fractional digits. */
- @Test
- fun testFractionalDigits() {
- withNexusAndSandboxUser {
- testApplication {
- application(sandboxApp)
- runBlocking {
- // Create Pain.001 with excessive amount.
- addPaymentInitiation(
- Pain001Data(
- creditorIban = getIban(),
- creditorBic = "SANDBOXX",
- creditorName = "Tester",
- subject = "test payment",
- sum = "1.001", // wrong 3 fractional digits.
- currency = "TESTKUDOS"
- ),
- "foo"
- )
- assertException<EbicsProtocolError>({
submitAllPaymentInitiations(client, "foo") })
- }
- }
- }
- }
-
- // Test the EBICS error message in case of debt threshold being surpassed
- @Test
- fun testDebit() {
- withNexusAndSandboxUser {
- testApplication {
- application(sandboxApp)
- runBlocking {
- // Create Pain.001 with excessive amount.
- addPaymentInitiation(
- Pain001Data(
- creditorIban = getIban(),
- creditorBic = "SANDBOXX",
- creditorName = "Tester",
- subject = "test payment",
- sum = "1000000",
- currency = "TESTKUDOS"
- ),
- "foo"
- )
- assertException<EbicsProtocolError>(
- { submitAllPaymentInitiations(client, "foo") },
- {
- val nexusEbicsException = it as EbicsProtocolError
- assert(
-
EbicsReturnCode.EBICS_AMOUNT_CHECK_FAILED.errorCode ==
-
nexusEbicsException.ebicsTechnicalCode?.errorCode
- )
- }
- )
- }
- }
- }
- }
-}
-
-class EbicsTest {
-
- @Test
- fun genEbics3Upload() {
- withTestDatabase {
- prepNexusDb()
- val foo = transaction { getEbicsSubscriberDetails("foo") }
- val uploadDoc = createEbicsRequestForUploadInitialization(
- subscriberDetails = foo,
- ebics3OrderService =
Ebics3Request.OrderDetails.Service().apply {
- serviceName = "OTH"
- scope = "BIL"
- serviceOption = "CH002LMF"
- messageName =
Ebics3Request.OrderDetails.Service.MessageName().apply {
- value = "csv"
- }
- },
- null,
- prepareUploadPayload(
- foo,
- "foo".toByteArray(),
- isEbics3 = true
- )
- )
- assert(XMLUtil.validateFromString(uploadDoc))
- }
- }
-
- /**
- * Tests the validity of EBICS 3.0 messages.
- */
- @Test
- fun genEbics3Download() {
- withTestDatabase {
- prepNexusDb()
- val foo = transaction { getEbicsSubscriberDetails("foo") }
- val downloadDoc = createEbicsRequestForDownloadInitialization(
- subscriberDetails = foo,
- ebics3OrderService =
Ebics3Request.OrderDetails.Service().apply {
- messageName =
Ebics3Request.OrderDetails.Service.MessageName().apply {
- value = "camt.054"
- version = "04"
- }
- scope = "CH"
- serviceName = "REP"
- container =
Ebics3Request.OrderDetails.Service.Container().apply {
- containerType = "ZIP"
- }
- },
- orderParams = EbicsStandardOrderParams()
- )
- assert(XMLUtil.validateFromString(downloadDoc))
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt
b/nexus/src/test/kotlin/Iso20022Test.kt
deleted file mode 100644
index 06d14ce1..00000000
--- a/nexus/src/test/kotlin/Iso20022Test.kt
+++ /dev/null
@@ -1,204 +0,0 @@
-package tech.libeufin.nexus
-import CamtBankAccountEntry
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Ignore
-import org.junit.Test
-import org.w3c.dom.Document
-import poFiCamt054_2019_incoming
-import poFiCamt054_2019_outgoing
-import prepNexusDb
-import tech.libeufin.nexus.iso20022.*
-import tech.libeufin.nexus.server.EbicsDialects
-import tech.libeufin.nexus.server.FetchLevel
-import tech.libeufin.util.DestructionError
-import tech.libeufin.util.XMLUtil
-import tech.libeufin.util.destructXml
-import tech.libeufin.util.getNow
-import withTestDatabase
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-fun loadXmlResource(name: String): Document {
- val classLoader = ClassLoader.getSystemClassLoader()
- val res = classLoader.getResource(name)
- if (res == null) {
- throw Exception("resource $name not found");
- }
- return XMLUtil.parseStringIntoDom(res.readText())
-}
-
-class Iso20022Test {
- @Test(expected = DestructionError::class)
- fun testUniqueChild() {
- val xml = """
- <a>
- <b/>
- <b/>
- </a>
- """.trimIndent()
- // when XML is invalid, DestructionError is thrown.
- val doc = XMLUtil.parseStringIntoDom(xml)
- destructXml(doc) {
- requireRootElement("a") {
- requireOnlyChild { }
- }
- }
- }
-
- /**
- * This test is currently ignored because the Camt sample being parsed
- * contains a money movement which is not a singleton. This is not in
- * line with the current parsing logic (that expects the style used by GLS)
- */
- @Ignore
- fun testTransactionsImport() {
- val camt53 =
loadXmlResource("iso20022-samples/camt.053/de.camt.053.001.02.xml")
- val r = parseCamtMessage(camt53)
- assertEquals("msg-001", r.messageId)
- assertEquals("2020-07-03T12:44:40+05:30", r.creationDateTime)
- assertEquals(CashManagementResponseType.Statement, r.messageType)
- assertEquals(1, r.reports.size)
-
- // First Entry
- assertTrue("100" == r.reports[0].entries[0].amount.value)
- assertEquals("EUR", r.reports[0].entries[0].amount.currency)
- assertEquals(CreditDebitIndicator.CRDT,
r.reports[0].entries[0].creditDebitIndicator)
- assertEquals(EntryStatus.BOOK, r.reports[0].entries[0].status)
- assertEquals(null, r.reports[0].entries[0].entryRef)
- assertEquals("acctsvcrref-001",
r.reports[0].entries[0].accountServicerRef)
- assertEquals("PMNT-RCDT-ESCT",
r.reports[0].entries[0].bankTransactionCode)
- assertNotNull(r.reports[0].entries[0].batches?.get(0))
- assertEquals(
- "unstructured info one",
-
r.reports[0].entries[0].batches?.get(0)?.batchTransactions?.get(0)?.details?.unstructuredRemittanceInformation
- )
-
- // Second Entry
- assertEquals(
- "unstructured info across lines",
-
r.reports[0].entries[1].batches?.get(0)?.batchTransactions?.get(0)?.details?.unstructuredRemittanceInformation
- )
-
- // Third Entry
- // Make sure that round-tripping of entry CamtBankAccountEntry JSON
works
- for (entry in r.reports.flatMap { it.entries }) {
- val txStr =
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(entry)
- val tx2 = jacksonObjectMapper().readValue(txStr,
CamtBankAccountEntry::class.java)
- val tx2Str =
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(tx2)
- assertEquals(jacksonObjectMapper().readTree(txStr),
jacksonObjectMapper().readTree(tx2Str))
- }
-
-
println(jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(r))
- }
-
- /**
- * PoFi timestamps aren't zoned, therefore the usual ZonedDateTime
- * doesn't cover it. They must switch to (java.time.)LocalDateTime.
- */
- @Test
- fun parsePostFinanceDate() {
- // 2011-12-03T10:15:30 from Java Doc as ISO_LOCAL_DATE_TIME.
- // 2023-05-09T11:04:09 from PoFi
-
- getTimestampInMillis(
- "2011-12-03T10:15:30",
- EbicsDialects.POSTFINANCE.dialectName
- )
- getTimestampInMillis(
- "2011-12-03T10:15:30Z" // ! with timezone
- )
- }
-
- @Test
- fun parsePoFiCamt054() {
- val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_incoming)
- parseCamtMessage(doc, dialect = "pf")
- }
-
- /**
- * Testing how outgoing payments get ingested and how their
- * deduplication logic reacts, given that sometimes camt.054
- * was seen without the AcctSvcrRef.
- */
- @Test
- fun ingestPoFiCamt054_outgoing() {
- val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_outgoing)
- withTestDatabase {
- prepNexusDb()
- transaction { assert(NexusBankTransactionEntity.all().count() ==
0L) }
- ingestCamtMessageIntoAccount(
- "foo",
- doc,
- FetchLevel.NOTIFICATION,
- dialect = "pf"
- )
- transaction { assert(NexusBankTransactionEntity.all().count() ==
1L) }
- // Checking that the payment doesn't get stored twice.
- ingestCamtMessageIntoAccount(
- "foo",
- doc,
- FetchLevel.NOTIFICATION,
- dialect = "pf"
- )
- transaction { assert(NexusBankTransactionEntity.all().count() ==
1L) }
- }
- }
-
- @Test
- fun ingestPoFiCamt054() {
- val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_incoming)
- withTestDatabase {
- prepNexusDb()
- // Checking that no transactions exist already in the database.
- transaction { assert(NexusBankTransactionEntity.all().count() ==
0L) }
- ingestCamtMessageIntoAccount(
- "foo",
- doc,
- FetchLevel.NOTIFICATION,
- dialect = "pf"
- )
- // Checking that now ONE transaction exist in the database.
- transaction { assert(NexusBankTransactionEntity.all().count() ==
1L) }
- // Checking now that the same payment doesn't get ingested twice.
- ingestCamtMessageIntoAccount(
- "foo",
- doc,
- FetchLevel.NOTIFICATION,
- dialect = "pf"
- )
- // The count should have stayed the same.
- transaction { assert(NexusBankTransactionEntity.all().count() ==
1L) }
- }
- }
- // Checks that the 2019 pain.001 version validates.
- @Test
- fun validatePain001() {
- val pain001 = createPain001document(
- NexusPaymentInitiationData(
- debtorIban = "CH0889144371988976754",
- debtorBic = "POFICHBEXXX",
- debtorName = "Sample Debtor Name",
- currency = "CHF",
- amount = "5.00",
- creditorIban = "CH9789144829733648596",
- creditorName = "Sample Creditor Name",
- creditorBic = "POFICHBEXXX",
- paymentInformationId = "8aae7a2ded2f",
- preparationTimestamp = getNow().toInstant().toEpochMilli(),
- subject = "Unstructured remittance information",
- instructionId = "InstructionId",
- endToEndId = "71cfbdaf901f",
- messageId = "2a16b35ed69c"
- ),
- dialect = "pf"
- )
- val doc = XMLUtil.parseStringIntoDom(pain001)
- assert(XMLUtil.validateFromDom(doc))
- }
-}
diff --git a/nexus/src/test/kotlin/JsonTest.kt
b/nexus/src/test/kotlin/JsonTest.kt
deleted file mode 100644
index 30e919d9..00000000
--- a/nexus/src/test/kotlin/JsonTest.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-import org.junit.Test
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.fasterxml.jackson.module.kotlin.readValue
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import io.ktor.utils.io.jvm.javaio.*
-import org.junit.Ignore
-import tech.libeufin.nexus.server.CreateBankConnectionFromBackupRequestJson
-import tech.libeufin.nexus.server.CreateBankConnectionFromNewRequestJson
-import tech.libeufin.sandbox.NexusTransactions
-import tech.libeufin.sandbox.sandboxApp
-
-enum class EnumTest { TEST }
-data class EnumWrapper(val enum_test: EnumTest)
-
-class JsonTest {
-
- @Test
- fun testJackson() {
- val mapper = jacksonObjectMapper()
- val backupObj = CreateBankConnectionFromBackupRequestJson(
- name = "backup", passphrase = "secret", data =
mapper.readTree("{}")
- )
- val roundTrip =
mapper.readValue<CreateBankConnectionFromBackupRequestJson>(mapper.writeValueAsString(backupObj))
- assert(roundTrip.data.toString() == "{}" && roundTrip.passphrase ==
"secret" && roundTrip.name == "backup")
- val newConnectionObj = CreateBankConnectionFromNewRequestJson(
- name = "new-connection", type = "ebics", data =
mapper.readTree("{}")
- )
- val roundTripNew =
mapper.readValue<CreateBankConnectionFromNewRequestJson>(mapper.writeValueAsString(newConnectionObj))
- assert(roundTripNew.data.toString() == "{}" && roundTripNew.type ==
"ebics" && roundTripNew.name == "new-connection")
- }
-
- // Tests how Jackson+Kotlin handle enum types. Fails if an exception is
thrown
- @Test
- fun enumTest() {
- val m = jacksonObjectMapper()
- m.readValue<EnumWrapper>("{\"enum_test\":\"TEST\"}")
- m.readValue<EnumTest>("\"TEST\"")
- }
-
- /**
- * Ignored because this test was only used to check
- * the logs, as opposed to assert over values. Consider
- * to remove the Ignore
- */
- @Ignore
- @Test
- fun testSandboxJsonParsing() {
- testApplication {
- application(sandboxApp)
- client.post("/admin/ebics/subscribers") {
- basicAuth("admin", "foo")
- contentType(ContentType.Application.Json)
- setBody("{}")
- }
- }
- }
-
- data class CamtEntryWrapper(
- val unusedValue: String,
- val camtData: CamtBankAccountEntry
- )
-
- // Testing whether generating and parsing a CaMt JSON mapping works.
- @Test
- fun testCamtRoundTrip() {
- val obj = genNexusIncomingCamt(
- CurrencyAmount(value = "2", currency = "EUR"),
- subject = "round trip test"
- )
- val str =
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(obj)
- val map = jacksonObjectMapper().readValue(str,
CamtBankAccountEntry::class.java)
- assert(str ==
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(map))
- }
-
- @Test
- fun parseRawJson() {
- val camtModel = """
- {
- "amount" : "TESTKUDOS:22",
- "creditDebitIndicator" : "CRDT",
- "status" : "BOOK",
- "bankTransactionCode" : "mock",
- "batches" : [ {
- "batchTransactions" : [ {
- "amount" : "TESTKUDOS:22",
- "creditDebitIndicator" : "CRDT",
- "details" : {
- "debtor" : {
- "name" : "Mock Payer"
- },
- "debtorAccount" : {
- "iban" : "MOCK-IBAN"
- },
- "debtorAgent" : {
- "bic" : "MOCK-BIC"
- },
- "unstructuredRemittanceInformation" : "raw"
- }
- } ]
- } ]
- }
- """.trimIndent()
- val obj = jacksonObjectMapper().readValue(camtModel,
CamtBankAccountEntry::class.java)
- assert(obj.getSingletonSubject() == "raw")
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/LetterFormatTest.kt
b/nexus/src/test/kotlin/LetterFormatTest.kt
deleted file mode 100644
index a268492d..00000000
--- a/nexus/src/test/kotlin/LetterFormatTest.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package tech.libeufin.nexus
-
-import org.junit.Test
-import tech.libeufin.util.chunkString
-import tech.libeufin.util.toHexString
-import java.security.SecureRandom
-
-/**
- * @param size in bits
- */
-private fun getNonce(size: Int): ByteArray {
- val sr = SecureRandom()
- val ret = ByteArray(size / 8)
- sr.nextBytes(ret)
- return ret
-}
-
-class LetterFormatTest {
-
- @Test
- fun chunkerTest() {
- val blob = getNonce(1024)
- println(chunkString(blob.toHexString()))
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt
deleted file mode 100644
index 32c6e607..00000000
--- a/nexus/src/test/kotlin/MakeEnv.kt
+++ /dev/null
@@ -1,772 +0,0 @@
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.*
-import io.ktor.client.engine.mock.*
-import io.ktor.client.request.*
-import org.jetbrains.exposed.sql.Database
-import org.jetbrains.exposed.sql.statements.api.ExposedBlob
-import org.jetbrains.exposed.sql.transactions.TransactionManager
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.nexus.*
-import tech.libeufin.nexus.dbCreateTables
-import tech.libeufin.nexus.dbDropTables
-import tech.libeufin.nexus.server.BankConnectionType
-import tech.libeufin.nexus.server.FetchLevel
-import tech.libeufin.nexus.server.FetchSpecAllJson
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.*
-
-data class EbicsKeys(
- val auth: CryptoUtil.RsaCrtKeyPair,
- val enc: CryptoUtil.RsaCrtKeyPair,
- val sig: CryptoUtil.RsaCrtKeyPair
-)
-// Convenience DB connection to switch to Postgresql:
-val currentUser = System.getProperty("user.name")
-
-val BANK_IBAN = getIban()
-val FOO_USER_IBAN = getIban()
-val BAR_USER_IBAN = getIban()
-val
TCP_POSTGRES_CONN="jdbc:postgresql://localhost:5432/libeufincheck?user=$currentUser"
-val UNIX_SOCKET_CONN= "postgresql:///libeufincheck"
-val TEST_DB_CONN = UNIX_SOCKET_CONN
-
-val bankKeys = EbicsKeys(
- auth = CryptoUtil.generateRsaKeyPair(2048),
- enc = CryptoUtil.generateRsaKeyPair(2048),
- sig = CryptoUtil.generateRsaKeyPair(2048)
-)
-val userKeys = EbicsKeys(
- auth = CryptoUtil.generateRsaKeyPair(2048),
- enc = CryptoUtil.generateRsaKeyPair(2048),
- sig = CryptoUtil.generateRsaKeyPair(2048)
-)
-
-fun assertWithPrint(cond: Boolean, msg: String) {
- try {
- assert(cond)
- } catch (e: AssertionError) {
- System.err.println(msg)
- throw e
- }
-}
-
-// New versions of JUnit provide this!
-inline fun <reified ExceptionType> assertException(
- block: () -> Unit,
- assertBlock: (Throwable) -> Unit = {}
-) {
- try {
- block()
- } catch (e: Throwable) {
- assert(e.javaClass == ExceptionType::class.java)
- // Expected type, try more custom asserts on it
- assertBlock(e)
- return
- }
- return assert(false)
-}
-
-/**
- * Run a block after connecting to the test database.
- * Cleans up the DB file afterwards.
- */
-fun withTestDatabase(keepData: Boolean = false, f: () -> Unit) {
- if (!keepData) {
- dbDropTables(TEST_DB_CONN)
- tech.libeufin.sandbox.dbDropTables(TEST_DB_CONN)
- }
- f()
-}
-
-val reportSpec: String = jacksonObjectMapper().
-writerWithDefaultPrettyPrinter().
-writeValueAsString(
- FetchSpecAllJson(
- level = FetchLevel.REPORT,
- "foo"
- )
-)
-
-fun prepNexusDb() {
- dbCreateTables(TEST_DB_CONN)
- transaction {
- val u = NexusUserEntity.new {
- username = "foo"
- passwordHash = CryptoUtil.hashpw("foo")
- superuser = true
- }
- val b = NexusUserEntity.new {
- username = "bar"
- passwordHash = CryptoUtil.hashpw("bar")
- superuser = true
- }
- val c = NexusBankConnectionEntity.new {
- connectionId = "bar"
- owner = b
- type = "x-libeufin-bank"
- }
- val d = NexusBankConnectionEntity.new {
- connectionId = "foo"
- owner = b
- type = "ebics"
- }
- XLibeufinBankUserEntity.new {
- username = "bar"
- password = "bar"
- // Only addressing mild cases where ONE slash ends the base URL.
- baseUrl = "http://localhost/demobanks/default/access-api"
- nexusBankConnection = c
- }
- tech.libeufin.nexus.EbicsSubscriberEntity.new {
- // ebicsURL = "http://localhost:5000/ebicsweb"
- ebicsURL = "http://localhost/ebicsweb"
- hostID = "eufinSandbox"
- partnerID = "foo"
- userID = "foo"
- systemID = "foo"
- signaturePrivateKey = ExposedBlob(userKeys.sig.private.encoded)
- encryptionPrivateKey = ExposedBlob(userKeys.enc.private.encoded)
- authenticationPrivateKey =
ExposedBlob(userKeys.auth.private.encoded)
- nexusBankConnection = d
- ebicsIniState = EbicsInitState.NOT_SENT
- ebicsHiaState = EbicsInitState.NOT_SENT
- bankEncryptionPublicKey = ExposedBlob(bankKeys.enc.public.encoded)
- bankAuthenticationPublicKey =
ExposedBlob(bankKeys.auth.public.encoded)
- }
- NexusBankAccountEntity.new {
- bankAccountName = "foo"
- iban = FOO_USER_IBAN
- bankCode = "SANDBOXX"
- defaultBankConnection = d
- highestSeenBankMessageSerialId = 0
- accountHolder = "foo"
- }
- NexusBankAccountEntity.new {
- bankAccountName = "bar"
- iban = BAR_USER_IBAN
- bankCode = "SANDBOXX"
- defaultBankConnection = c
- highestSeenBankMessageSerialId = 0
- accountHolder = "bar"
- }
- NexusScheduledTaskEntity.new {
- resourceType = "bank-account"
- resourceId = "foo"
- this.taskCronspec = "* * *" // Every second.
- this.taskName = "read-report"
- this.taskType = "fetch"
- this.taskParams = reportSpec
- }
- NexusScheduledTaskEntity.new {
- resourceType = "bank-account"
- resourceId = "foo"
- this.taskCronspec = "* * *" // Every second.
- this.taskName = "send-payment"
- this.taskType = "submit"
- this.taskParams = "{}"
- }
- // Giving 'foo' a Taler facade.
- val f = FacadeEntity.new {
- facadeName = "foo-facade"
- type = "taler-wire-gateway"
- creator = u
- }
- FacadeStateEntity.new {
- bankAccount = "foo"
- bankConnection = "foo"
- currency = "TESTKUDOS"
- reserveTransferLevel = "report"
- facade = f
- highestSeenMessageSerialId = 0
- }
- // Giving 'bar' a Taler facade
- val g = FacadeEntity.new {
- facadeName = "bar-facade"
- type = "taler-wire-gateway"
- creator = b
- }
- FacadeStateEntity.new {
- bankAccount = "bar"
- bankConnection = "bar" // uses x-libeufin-bank connection.
- currency = "TESTKUDOS"
- reserveTransferLevel = "report"
- facade = g
- highestSeenMessageSerialId = 0
- }
- }
-}
-
-fun prepSandboxDb(
- usersDebtLimit: Int = 1000,
- currency: String = "TESTKUDOS",
- cashoutCurrency: String = "EUR"
-) {
- tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN)
- transaction {
- val config = DemobankConfig(
- currency = currency,
- cashoutCurrency = cashoutCurrency,
- bankDebtLimit = 10000,
- usersDebtLimit = usersDebtLimit,
- allowRegistrations = true,
- demobankName = "default",
- withSignupBonus = false,
- captchaUrl = "http://example.com/",
- suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}",
- nexusBaseUrl = "http://localhost/",
- usernameAtNexus = "foo",
- passwordAtNexus = "foo",
- enableConversionService = true
- )
- insertConfigPairs(config)
- val demoBank = DemobankConfigEntity.new { name = "default" }
- BankAccountEntity.new {
- iban = BANK_IBAN
- label = "admin" // used by the wire helper
- owner = "admin" // used by the person name finder
- // For now, the model assumes always one demobank
- this.demoBank = demoBank
- }
- EbicsHostEntity.new {
- this.ebicsVersion = "3.0"
- this.hostId = "eufinSandbox"
- this.authenticationPrivateKey =
ExposedBlob(bankKeys.auth.private.encoded)
- this.encryptionPrivateKey =
ExposedBlob(bankKeys.enc.private.encoded)
- this.signaturePrivateKey =
ExposedBlob(bankKeys.sig.private.encoded)
- }
- val bankAccount = BankAccountEntity.new {
- iban = FOO_USER_IBAN
- /**
- * For now, keep same semantics of Pybank: a username
- * is AS WELL a bank account label. In other words, it
- * identifies a customer AND a bank account.
- */
- label = "foo"
- owner = "foo"
- this.demoBank = demoBank
- isPublic = false
- }
- BankAccountEntity.new {
- iban = BAR_USER_IBAN
- /**
- * For now, keep same semantics of Pybank: a username
- * is AS WELL a bank account label. In other words, it
- * identifies a customer AND a bank account.
- */
- label = "bar"
- owner = "bar"
- this.demoBank = demoBank
- isPublic = false
- }
- tech.libeufin.sandbox.EbicsSubscriberEntity.new {
- hostId = "eufinSandbox"
- partnerId = "foo"
- userId = "foo"
- systemId = "foo"
- signatureKey = EbicsSubscriberPublicKeyEntity.new {
- rsaPublicKey = ExposedBlob(userKeys.sig.public.encoded)
- state = KeyState.RELEASED
- }
- encryptionKey = EbicsSubscriberPublicKeyEntity.new {
- rsaPublicKey = ExposedBlob(userKeys.enc.public.encoded)
- state = KeyState.RELEASED
- }
- authenticationKey = EbicsSubscriberPublicKeyEntity.new {
- rsaPublicKey = ExposedBlob(userKeys.auth.public.encoded)
- state = KeyState.RELEASED
- }
- state = SubscriberState.INITIALIZED
- nextOrderID = 1
- this.bankAccount = bankAccount
- }
- DemobankCustomerEntity.new {
- username = "foo"
- passwordHash = CryptoUtil.hashpw("foo")
- name = "Foo"
- cashout_address = "payto://iban/OUTSIDE"
- }
- DemobankCustomerEntity.new {
- username = "bar"
- passwordHash = CryptoUtil.hashpw("bar")
- name = "Bar"
- cashout_address = "payto://iban/FIAT"
- }
- // Note: exchange doesn't have the cash-out address.
- DemobankCustomerEntity.new {
- username = "exchange-0"
- passwordHash = CryptoUtil.hashpw("foo")
- name = "Exchange"
- }
- BankAccountEntity.new {
- iban = "AT561936082973364859"
- /**
- * For now, keep same semantics of Pybank: a username
- * is AS WELL a bank account label. In other words, it
- * identifies a customer AND a bank account.
- */
- label = "exchange-0"
- owner = "exchange-0"
- this.demoBank = demoBank
- isPublic = false
- }
- }
-}
-
-fun withNexusAndSandboxUser(f: () -> Unit) {
- withTestDatabase {
- prepNexusDb()
- prepSandboxDb()
- f()
- }
-}
-
-// Creates tables, the default demobank, and admin's bank account.
-fun withSandboxTestDatabase(f: () -> Unit) {
- withTestDatabase {
- tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN)
- transaction {
- val config = DemobankConfig(
- currency = "TESTKUDOS",
- cashoutCurrency = "NOTUSED",
- bankDebtLimit = 10000,
- usersDebtLimit = 1000,
- allowRegistrations = true,
- demobankName = "default",
- withSignupBonus = false,
- captchaUrl = "http://example.com/" // unused
- )
- insertConfigPairs(config)
- val d = DemobankConfigEntity.new { name = "default" }
- // admin's bank account.
- BankAccountEntity.new {
- iban = BANK_IBAN
- label = "admin" // used by the wire helper
- owner = "admin" // used by the person name finder
- // For now, the model assumes always one demobank
- this.demoBank = d
- }
- }
- f()
- }
-}
-
-fun newNexusBankTransaction(
- currency: String,
- value: String,
- subject: String,
- creditorAcct: String = "foo",
- connType: BankConnectionType = BankConnectionType.EBICS
-) {
- val jDetails: String = when(connType) {
- BankConnectionType.EBICS -> {
- jacksonObjectMapper(
- ).writerWithDefaultPrettyPrinter(
- ).writeValueAsString(
- genNexusIncomingCamt(
- amount = CurrencyAmount(currency,value),
- subject = subject
- )
- )
- }
- /**
- * Note: x-libeufin-bank ALSO stores the transactions in the
- * CaMt representation, hence this branch should be removed.
- */
- BankConnectionType.X_LIBEUFIN_BANK -> {
- jacksonObjectMapper(
- ).writerWithDefaultPrettyPrinter(
- ).writeValueAsString(genNexusIncomingCamt(
- amount = CurrencyAmount(currency, value),
- subject = subject
- ))
- }
- else -> throw Exception("Unsupported connection type:
${connType.typeName}")
- }
- transaction {
- NexusBankTransactionEntity.new {
- bankAccount = NexusBankAccountEntity.findByName(creditorAcct)!!
- accountTransactionId = "mock"
- creditDebitIndicator = "CRDT"
- this.currency = currency
- this.amount = value
- status = EntryStatus.BOOK
- transactionJson = jDetails
- }
- }
-}
-
-/**
- * This function generates the Nexus JSON model of one transaction
- * as if it got downloaded via one x-libeufin-bank connection. The
- * non given values are either resorted from other sources by Nexus,
- * or actually not useful so far.
- */
-private fun genNexusIncomingXLibeufinBank(
- amount: CurrencyAmount,
- subject: String
-): XLibeufinBankTransaction =
- XLibeufinBankTransaction(
- creditorIban = "NOTUSED",
- creditorBic = null,
- creditorName = "Not Used",
- debtorIban = "NOTUSED",
- debtorBic = null,
- debtorName = "Not Used",
- amount = amount.value,
- currency = amount.currency,
- subject = subject,
- date = "0",
- uid = "not-used",
- direction = XLibeufinBankDirection.CREDIT
- )
-/**
- * This function generates the Nexus JSON model of one transaction
- * as if it got downloaded via one Ebics connection. The non given
- * values are either resorted from other sources by Nexus, or actually
- * not useful so far.
- */
-fun genNexusIncomingCamt(
- amount: CurrencyAmount,
- subject: String,
-): CamtBankAccountEntry =
- CamtBankAccountEntry(
- amount = amount,
- creditDebitIndicator = CreditDebitIndicator.CRDT,
- status = EntryStatus.BOOK,
- bankTransactionCode = "mock",
- valueDate = null,
- bookingDate = null,
- accountServicerRef = null,
- entryRef = null,
- currencyExchange = null,
- counterValueAmount = null,
- instructedAmount = null,
- batches = listOf(
- Batch(
- paymentInformationId = null,
- messageId = null,
- batchTransactions = listOf(
- BatchTransaction(
- amount = amount,
- creditDebitIndicator = CreditDebitIndicator.CRDT,
- details = TransactionDetails(
- unstructuredRemittanceInformation = subject,
- debtor = PartyIdentification(
- name = "Mock Payer",
- countryOfResidence = null,
- privateId = null,
- organizationId = null,
- postalAddress = null,
- otherId = null
- ),
- debtorAccount = CashAccount(
- iban = "MOCK-IBAN",
- name = null,
- currency = null,
- otherId = null
- ),
- debtorAgent = AgentIdentification(
- bic = "MOCK-BIC",
- lei = null,
- clearingSystemMemberId = null,
- clearingSystemCode = null,
- proprietaryClearingSystemCode = null,
- postalAddress = null,
- otherId = null,
- name = null
- ),
- creditor = null,
- creditorAccount = null,
- creditorAgent = null,
- ultimateCreditor = null,
- ultimateDebtor = null,
- purpose = null,
- proprietaryPurpose = null,
- currencyExchange = null,
- instructedAmount = null,
- counterValueAmount = null,
- interBankSettlementAmount = null,
- returnInfo = null
- )
- )
- )
- )
- )
- )
-
-val poFiCamt054_2019_outgoing: String = """
- <?xml version="1.0" encoding="UTF-8"?>
- <Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08
file:///C:/Users/burkhalterl/Documents/Musterfiles%20ISOV19/Schemen/camt.054.001.08.xsd">
- <BkToCstmrDbtCdtNtfctn>
- <GrpHdr>
- <MsgId>20200618375204295372463</MsgId>
- <CreDtTm>2022-03-10T23:40:14</CreDtTm>
- <MsgPgntn>
- <PgNb>1</PgNb>
- <LastPgInd>true</LastPgInd>
- </MsgPgntn>
- <AddtlInf>SPS/2.0/PROD</AddtlInf>
- </GrpHdr>
- <Ntfctn>
- <Id>20200618375204295372465</Id>
- <CreDtTm>2022-03-10T23:40:14</CreDtTm>
- <FrToDt>
- <FrDtTm>2022-03-10T00:00:00</FrDtTm>
- <ToDtTm>2022-03-10T23:59:59</ToDtTm>
- </FrToDt>
- <Acct>
- <Id>
- <IBAN>${FOO_USER_IBAN}</IBAN>
- </Id>
- <Ccy>CHF</Ccy>
- <Ownr>
- <Nm>Robert Schneider SA Grands magasins
Biel/Bienne</Nm>
- </Ownr>
- </Acct>
- <Ntry>
- <NtryRef>CH2909000000250094239</NtryRef>
- <Amt Ccy="CHF">522.10</Amt>
- <CdtDbtInd>DBIT</CdtDbtInd>
- <RvslInd>false</RvslInd>
- <Sts>
- <Cd>BOOK</Cd>
- </Sts>
- <BookgDt>
- <Dt>2022-03-10</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2022-03-10</Dt>
- </ValDt>
- <AcctSvcrRef>1000000000000000</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
-
<SubFmlyCd>ATXN</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <NtryDtls>
- <Btch>
- <NbOfTxs>1</NbOfTxs>
- </Btch>
- <TxDtls>
- <Refs>
-
<InstrId>1006265-25bbb3b1a</InstrId>
-
<EndToEndId>client-generated</EndToEndId>
-
<UETR>b009c997-97b3-4a9c-803c-d645a7276bf0</UETR>
- <Prtry>
- <Tp>00</Tp>
-
<Ref>00000000000000000000020</Ref>
- </Prtry>
- </Refs>
- <Amt Ccy="CHF">522.10</Amt>
- <CdtDbtInd>DBIT</CdtDbtInd>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
-
<Cd>RCDT</Cd>
-
<SubFmlyCd>ATXN</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <RltdPties>
- <Dbtr>
- <Pty>
-
<Nm>Bernasconi Maria</Nm>
-
<PstlAdr>
-
<AdrLine>Place de la Gare 12</AdrLine>
-
<AdrLine>2502 Biel/Bienne</AdrLine>
-
</PstlAdr>
- </Pty>
- </Dbtr>
- <DbtrAcct>
- <Id>
-
<IBAN>CH5109000000250092291</IBAN>
- </Id>
- </DbtrAcct>
- <CdtrAcct>
- <Id>
-
<IBAN>CH2909000000250094239</IBAN>
- </Id>
- </CdtrAcct>
- </RltdPties>
- <RltdAgts>
- <DbtrAgt>
- <FinInstnId>
-
<BICFI>POFICHBEXXX</BICFI>
-
<Nm>POSTFINANCE AG</Nm>
-
<PstlAdr>
-
<AdrLine>MINGERSTRASSE 20</AdrLine>
-
<AdrLine>3030 BERNE</AdrLine>
-
</PstlAdr>
- </FinInstnId>
- </DbtrAgt>
- </RltdAgts>
- <RmtInf>
- <Strd>
-
<AddtlRmtInf>?REJECT?0</AddtlRmtInf>
-
<AddtlRmtInf>?ERROR?000</AddtlRmtInf>
- </Strd>
- <Ustrd>Reserve pub.</Ustrd>
- </RmtInf>
- <RltdDts>
-
<AccptncDtTm>2022-03-10T20:00:00</AccptncDtTm>
- </RltdDts>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>GUTSCHRIFT AUFTRAGGEBER:
Bernasconi Maria Place de la Gare 12 2502 Biel/Bienne REFERENZEN: NOTPROVIDED
1006265-25bbb3b1a 2000000000000000</AddtlNtryInf>
- </Ntry>
- </Ntfctn>
- </BkToCstmrDbtCdtNtfctn>
- </Document>
-""".trimIndent()
-
-// Comes from a "mit Sammelbuchung" sample.
-// "mit Einzelbuchung" sample didn't have the "Ustrd"
-// See:
https://www.postfinance.ch/de/support/services/dokumente/musterfiles-fuer-geschaeftskunden.html
-val poFiCamt054_2019_incoming: String = """
-<?xml version="1.0" encoding="UTF-8"?>
-<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08
file:///C:/Users/burkhalterl/Documents/Musterfiles%20ISOV19/Schemen/camt.054.001.08.xsd">
- <BkToCstmrDbtCdtNtfctn>
- <GrpHdr>
- <MsgId>20200618375204295372463</MsgId>
- <CreDtTm>2022-03-08T23:31:31</CreDtTm>
- <MsgPgntn>
- <PgNb>1</PgNb>
- <LastPgInd>true</LastPgInd>
- </MsgPgntn>
- <AddtlInf>SPS/2.0/PROD</AddtlInf>
- </GrpHdr>
- <Ntfctn>
- <Id>20200618375204295372465</Id>
- <CreDtTm>2022-03-08T23:31:31</CreDtTm>
- <FrToDt>
- <FrDtTm>2022-03-08T00:00:00</FrDtTm>
- <ToDtTm>2022-03-08T23:59:59</ToDtTm>
- </FrToDt>
- <Acct>
- <Id>
- <IBAN>${FOO_USER_IBAN}</IBAN>
- </Id>
- <Ccy>CHF</Ccy>
- <Ownr>
- <Nm>Robert Schneider SA Grands magasins
Biel/Bienne</Nm>
- </Ownr>
- </Acct>
- <Ntry>
- <NtryRef>CH2909000000250094239</NtryRef>
- <Amt Ccy="CHF">501.05</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <RvslInd>false</RvslInd>
- <Sts>
- <Cd>BOOK</Cd>
- </Sts>
- <BookgDt>
- <Dt>2022-03-08</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2022-03-08</Dt>
- </ValDt>
- <AcctSvcrRef>1000000000000000</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
-
<SubFmlyCd>AUTT</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <NtryDtls>
- <Btch>
- <NbOfTxs>1</NbOfTxs>
- </Btch>
- <TxDtls>
- <Refs>
-
<AcctSvcrRef>2000000000000000</AcctSvcrRef>
-
<InstrId>1006265-25bbb3b1a</InstrId>
-
<EndToEndId>NOTPROVIDED</EndToEndId>
-
<UETR>b009c997-97b3-4a9c-803c-d645a7276b0</UETR>
- <Prtry>
- <Tp>00</Tp>
-
<Ref>00000000000000000000020</Ref>
- </Prtry>
- </Refs>
- <Amt Ccy="CHF">501.05</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
-
<Cd>RCDT</Cd>
-
<SubFmlyCd>AUTT</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <RltdPties>
- <Dbtr>
- <Pty>
-
<Nm>Bernasconi Maria</Nm>
-
<PstlAdr>
-
<AdrLine>Place de la Gare 12</AdrLine>
-
<AdrLine>2502 Biel/Bienne</AdrLine>
-
</PstlAdr>
- </Pty>
- </Dbtr>
- <DbtrAcct>
- <Id>
-
<IBAN>CH5109000000250092291</IBAN>
- </Id>
- </DbtrAcct>
- <CdtrAcct>
- <Id>
-
<IBAN>CH2909000000250094239</IBAN>
- </Id>
- </CdtrAcct>
- </RltdPties>
- <RltdAgts>
- <DbtrAgt>
- <FinInstnId>
-
<BICFI>POFICHBEXXX</BICFI>
-
<Nm>POSTFINANCE AG</Nm>
-
<PstlAdr>
-
<AdrLine>MINGERSTRASSE , 20</AdrLine>
-
<AdrLine>3030 BERN</AdrLine>
-
</PstlAdr>
- </FinInstnId>
- </DbtrAgt>
- </RltdAgts>
- <RmtInf>
- <Ustrd>Muster</Ustrd>
- <Ustrd>
Musterfile</Ustrd>
- <Strd>
-
<AddtlRmtInf>?REJECT?0</AddtlRmtInf>
-
<AddtlRmtInf>?ERROR?000</AddtlRmtInf>
- </Strd>
- </RmtInf>
- <RltdDts>
-
<AccptncDtTm>2022-03-08T20:00:00</AccptncDtTm>
- </RltdDts>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>SAMMELGUTSCHRIFT FÜR KONTO:
CH2909000000250094239 VERARBEITUNG VOM 08.03.2022 PAKET ID:
200000000000XXX</AddtlNtryInf>
- </Ntry>
- </Ntfctn>
- </BkToCstmrDbtCdtNtfctn>
-</Document>
-""".trimIndent()
-
-// Abstracts the mock handler installation.
-fun getMockedClient(handler: MockRequestHandleScope.(HttpRequestData) ->
HttpResponseData): HttpClient {
- return HttpClient(MockEngine) {
- followRedirects = false
- engine {
- addHandler {
- request -> handler(request)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/NexusApiTest.kt
b/nexus/src/test/kotlin/NexusApiTest.kt
deleted file mode 100644
index 01ef9565..00000000
--- a/nexus/src/test/kotlin/NexusApiTest.kt
+++ /dev/null
@@ -1,272 +0,0 @@
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.engine.mock.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.config.*
-import io.ktor.server.testing.*
-import io.netty.handler.codec.http.HttpResponseStatus
-import kotlinx.coroutines.async
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.nexus.PaymentInitiationEntity
-import tech.libeufin.nexus.bankaccount.ingestBankMessagesIntoAccount
-import tech.libeufin.nexus.getConnectionPlugin
-import tech.libeufin.nexus.iso20022.ingestCamtMessageIntoAccount
-import tech.libeufin.nexus.server.*
-import tech.libeufin.sandbox.BankAccountTransactionEntity
-import tech.libeufin.sandbox.BankAccountTransactionsTable
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.sandbox.wireTransfer
-
-/**
- * This class tests the API offered by Nexus,
- * documented here: https://docs.taler.net/libeufin/api-nexus.html
- */
-class NexusApiTest {
- private val jMapper = ObjectMapper()
- // Testing long-polling on GET /transactions
- @Test
- fun getTransactions() {
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- /**
- * Requesting /transactions with long polling, and assert that
- * the response arrives _after_ the unblocking INSERT into the
- * database.
- */
- val longPollMs = 5000
- runBlocking {
- val requestJob = async {
-
client.get("/bank-accounts/foo/transactions?long_poll_ms=$longPollMs") {
- basicAuth("foo", "foo")
- contentType(ContentType.Application.Json)
- }
- }
- /**
- * The following delay ensures that the payment below
- * gets inserted after the client has issued the long
- * polled request above (and it is therefore waiting)
- */
- delay(2000)
- // Ensures that the request is active _before_ the
- // upcoming payment. This ensures that the request
- // didn't find already another payment in the database.
- requestJob.ensureActive()
- newNexusBankTransaction(
- currency = "TESTKUDOS",
- value = "2",
- subject = "first"
- )
- val R = requestJob.await()
- // Ensures that the request did NOT wait all the timeout
- assert((R.responseTime.timestamp -
R.requestTime.timestamp) < longPollMs)
- val body = jacksonObjectMapper().readTree(R.bodyAsText())
- // Ensures that the unblocking payment exists in the
response.
- val tx = body.get("transactions")
- assert(tx.isArray && tx.size() == 1)
- }
- }
- }
- }
- @Test
- fun facadeIdempotence() {
- val facadeData = """{
- "name": "foo-facade",
- "type": "taler-wire-gateway",
- "config": {
- "bankAccount": "foo",
- "bankConnection": "foo",
- "reserveTransferLevel": "report",
- "currency": "TESTKUDOS"
- }
- }""".trimIndent()
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- client.post("/facades") {
- expectSuccess = true
- basicAuth("foo", "foo")
- contentType(ContentType.Application.Json)
- setBody(facadeData)
- }
- // Changing one detail, and expecting 409 Conflict.
- var resp = client.post("/facades") {
- expectSuccess = false
- basicAuth("foo", "foo")
- contentType(ContentType.Application.Json)
- setBody(facadeData.replace(
- "taler-wire-gateway",
- "anastasis"
- ))
- }
- assert(resp.status.value == HttpStatusCode.Conflict.value)
- // Changing a value deeper in the request object.
- resp = client.post("/facades") {
- expectSuccess = false
- basicAuth("foo", "foo")
- contentType(ContentType.Application.Json)
- setBody(facadeData.replace(
- "report",
- "statement"
- ))
- }
- assert(resp.status.value == HttpStatusCode.Conflict.value)
- }
- }
- }
- // Testing basic operations on facades.
- @Test
- fun facades() {
- // Deletes the facade (created previously by MakeEnv.kt)
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- client.delete("/facades/foo-facade") {
- basicAuth("foo", "foo")
- expectSuccess = true
- }
- }
- }
- }
-
- // Testing the creation of scheduled tasks.
- @Test
- fun schedule() {
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- // POSTing omitted 'params', to test whether Nexus
- // expects it as 'null' for a 'submit' task.
- client.post("/bank-accounts/foo/schedule") {
- contentType(ContentType.Application.Json)
- expectSuccess = true
- basicAuth("foo", "foo")
- setBody("""{
- "name": "send-payments",
- "cronspec": "* * *",
- "type": "submit",
- "params": null
- }""".trimIndent())
- }
- }
- }
- }
- /**
- * Testing the idempotence of payment submissions. That
- * helps Sandbox not to create multiple payment initiations
- * in case it fails at keeping track of what it submitted
- * already.
- */
- @Test
- fun paymentInitIdempotence() {
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- // Check no pay. ini. exist.
- transaction { PaymentInitiationEntity.all().count() == 0L }
- // Create one.
- fun f(futureThis: HttpRequestBuilder, subject: String =
"idempotence pay. init. test") {
- futureThis.basicAuth("foo", "foo")
- futureThis.expectSuccess = true
- futureThis.contentType(ContentType.Application.Json)
- futureThis.setBody("""
- {"iban": "TESTIBAN",
- "bic": "SANDBOXX",
- "name": "TEST NAME",
- "amount": "TESTKUDOS:3",
- "subject": "$subject",
- "uid": "salt"
- }
- """.trimIndent())
- }
- val R = client.post("/bank-accounts/foo/payment-initiations")
{ f(this) }
- println(jMapper.readTree(R.bodyAsText()).get("uuid"))
- // Submit again
- client.post("/bank-accounts/foo/payment-initiations") {
f(this) }
- // Checking that Nexus serves it.
- client.get("/bank-accounts/foo/payment-initiations/1") {
- basicAuth("foo", "foo")
- expectSuccess = true
- }
- // Checking that the database has only one, despite the double
submission.
- transaction {
- assert(PaymentInitiationEntity.all().count() == 1L)
- }
- /**
- * Causing a conflict by changing one payment detail
- * (the subject in this case) but not the "uid".
- */
- val maybeConflict =
client.post("/bank-accounts/foo/payment-initiations") {
- f(this, "different-subject")
- expectSuccess = false
- }
- assert(maybeConflict.status.value ==
HttpStatusCode.Conflict.value)
- }
- }
- }
- @Test
- fun timeRangeFetch() {
- withTestDatabase {
- prepSandboxDb()
- prepNexusDb()
- val ref = wireTransfer(
- "admin",
- "foo",
- subject = "past payment",
- amount = "TESTKUDOS:30"
- )
- transaction {
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.accountServicerReference eq
ref
- }.first().date = 1577833200000L // Jan, 1st, 2020
- }
- testApplication {
- application(sandboxApp)
- val conn = getConnectionPlugin("ebics")
-
- // Asking a time range where the one payment is expected to
exist
- conn.fetchTransactions(
- fetchSpec = FetchSpecTimeRangeJson(
- FetchLevel.REPORT,
- start = "2019-12-31",
- end = "2020-01-02",
- bankConnection = null
- ),
- accountId = "foo",
- bankConnectionId = "foo",
- client = client
- )
- val res = ingestBankMessagesIntoAccount("foo", "foo")
- assert(res.newTransactions == 1)
- // Asking a time range where the one payment is NOT expected
to exist
- conn.fetchTransactions(
- fetchSpec = FetchSpecTimeRangeJson(
- FetchLevel.REPORT,
- start = "2019-10-31",
- end = "2019-11-30",
- bankConnection = null
- ),
- accountId = "foo",
- bankConnectionId = "foo",
- client = client
- )
- val resNoData = ingestBankMessagesIntoAccount("foo", "foo")
- assert(resNoData.downloadedTransactions == 0)
- assert(resNoData.newTransactions == 0)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/PainTest.kt
b/nexus/src/test/kotlin/PainTest.kt
deleted file mode 100644
index 33140cbc..00000000
--- a/nexus/src/test/kotlin/PainTest.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-import ch.qos.logback.core.joran.spi.XMLUtil
-import org.junit.Test
-import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData
-import tech.libeufin.nexus.iso20022.createPain001document
-import kotlin.test.assertTrue
-
-class PainTest {
-
- @Test
- fun validationTest() {
- val xml = createPain001document(
- NexusPaymentInitiationData(
- debtorIban = "GB33BUKB20201222222222",
- debtorBic = "BUKBGB33",
- debtorName = "Oliver Smith",
- currency = "EUR",
- amount = "1",
- creditorIban = "GB33BUKB20201222222222",
- creditorName = "Oliver Smith",
- messageId = "message id",
- paymentInformationId = "payment information id",
- preparationTimestamp = 0,
- subject = "subject",
- instructionId = "instruction id",
- endToEndId = "end to end id",
- creditorBic = "BUKBGB33"
- )
- )
- assertTrue {
- tech.libeufin.util.XMLUtil.validateFromString(xml)
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/PostFinance.kt
b/nexus/src/test/kotlin/PostFinance.kt
deleted file mode 100644
index 8c392487..00000000
--- a/nexus/src/test/kotlin/PostFinance.kt
+++ /dev/null
@@ -1,158 +0,0 @@
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.github.ajalt.clikt.core.*
-import com.github.ajalt.clikt.parameters.options.default
-import com.github.ajalt.clikt.parameters.options.option
-import io.ktor.client.*
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.nexus.bankaccount.addPaymentInitiation
-import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions
-import tech.libeufin.nexus.ebics.EbicsUploadSpec
-import tech.libeufin.nexus.ebics.doEbicsUploadTransaction
-import tech.libeufin.nexus.ebics.getEbicsSubscriberDetails
-import tech.libeufin.nexus.getBankAccount
-import tech.libeufin.nexus.getBankConnection
-import tech.libeufin.nexus.getConnectionPlugin
-import tech.libeufin.nexus.getNexusUser
-import tech.libeufin.nexus.server.*
-import tech.libeufin.util.ebics_h005.Ebics3Request
-import java.io.BufferedReader
-import java.io.File
-import kotlin.system.exitProcess
-
-// Asks a camt.054 to the bank.
-private fun downloadPayments() {
- val httpClient = HttpClient()
- runBlocking {
- fetchBankAccountTransactions(
- client = httpClient,
- fetchSpec = FetchSpecLatestJson(
- level = FetchLevel.NOTIFICATION,
- bankConnection = null
- ),
- accountId = "foo"
- )
- }
-}
-
-/* Simulates one incoming payment for the 'payee' argument.
- * It pays the test platform's bank account if none is found.
- * The QRR format is NOT used in Taler, it is just convenient.
- * */
-private fun uploadQrrPayment(maybePayee: String? = null) {
- val payee = if (maybePayee == null) {
- val localAccount = getBankAccount("foo")
- localAccount.iban
- } else maybePayee
- val httpClient = HttpClient()
- val qrr = """
-
Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText
-
QRR;PO;$payee;CHF;33;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo
- """.trimIndent()
- runBlocking {
- doEbicsUploadTransaction(
- httpClient,
- getEbicsSubscriberDetails("postfinance"),
- EbicsUploadSpec(
- ebics3Service = Ebics3Request.OrderDetails.Service().apply {
- serviceName = "OTH"
- scope = "BIL"
- serviceOption = "CH002LMF"
- messageName =
Ebics3Request.OrderDetails.Service.MessageName().apply {
- value = "csv"
- }
- },
- isEbics3 = true
- ),
- qrr.toByteArray(Charsets.UTF_8)
- )
- }
-}
-
-/**
- * Submits a pain.001 version 2019 message to the bank.
- *
- * Causes one DBIT payment to show up in the camt.054. This one
- * however lacks the AcctSvcrRef, so other ways to pin it are needed.
- * Notably, EndToEndId is mandatory in pain.001 _and_ is controlled
- * by the sender. Hence, the sender can itself ensure the EndToEndId
- * uniqueness.
- */
-private fun uploadPain001Payment(
- subject: String,
- creditorIban: String = "CH9300762011623852957" // random creditor
-) {
- transaction {
- addPaymentInitiation(
- Pain001Data(
- creditorIban = creditorIban,
- creditorBic = "POFICHBEXXX",
- creditorName = "Muster Frau",
- sum = "2",
- currency = "CHF",
- subject = subject,
- endToEndId = "Zufall"
- ),
- getBankAccount("foo").bankAccountName
- )
- }
- val ebicsConn = getConnectionPlugin("ebics")
- val httpClient = HttpClient()
- runBlocking { ebicsConn.submitPaymentInitiation(httpClient, 1L) }
-}
-
-class PostFinanceCommand : CliktCommand() {
- private val myIban by option(
- help = "IBAN as assigned by the PostFinance test platform."
- ).default("CH9789144829733648596")
- override fun run() { prepare(myIban) }
-}
-class Download : CliktCommand("Download the latest camt.054 from the bank") {
- // Ask 'notification' to the bank.
- override fun run() {
- // uploadPain001Payment("auto")
- downloadPayments()
- }
-}
-
-class Upload : CliktCommand("Upload a pain.001 to the bank") {
- private val subject by option(help = "Payment subject").default("Muster
Zahlung")
- override fun run() { uploadPain001Payment(subject) }
-}
-
-class GenIncoming : CliktCommand("Uploads a CSV document to create one
incoming payment") {
- override fun run() {
- val bankAccount = getBankAccount("foo")
- uploadQrrPayment(bankAccount.iban)
- }
-}
-
-private fun prepare(iban: String) {
- // Loads EBICS subscriber's keys from disk.
- // The keys should be found under libeufin-internal.git/convenience/
- val bufferedReader: BufferedReader =
File("/tmp/pofi.json").bufferedReader()
- val accessDataTxt = bufferedReader.use { it.readText() }
- val ebicsConn = getConnectionPlugin("ebics")
- val accessDataJson = jacksonObjectMapper().readTree(accessDataTxt)
-
- // Creates a connection handle to the bank, using the loaded keys.
- withTestDatabase {
- prepNexusDb()
- transaction {
- ebicsConn.createConnectionFromBackup(
- connId = "postfinance",
- user = getNexusUser("foo"),
- passphrase = "secret",
- accessDataJson
- )
- val fooBankAccount = getBankAccount("foo")
- // Hooks the PoFi details to the local bank account.
- // No need to run the canonical steps (creating account,
downloading bank accounts, ..)
- fooBankAccount.defaultBankConnection =
getBankConnection("postfinance")
- fooBankAccount.iban = iban
- }
- }
-}
-fun main(args: Array<String>) {
- PostFinanceCommand().subcommands(Download(), Upload(),
GenIncoming()).main(args)
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SandboxAccessApiTest.kt
b/nexus/src/test/kotlin/SandboxAccessApiTest.kt
deleted file mode 100644
index 4236e0ce..00000000
--- a/nexus/src/test/kotlin/SandboxAccessApiTest.kt
+++ /dev/null
@@ -1,491 +0,0 @@
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.ObjectMapper
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import io.ktor.util.*
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.time.delay
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.getDatabaseName
-import java.util.*
-import kotlin.concurrent.schedule
-
-class SandboxAccessApiTest {
- val mapper = ObjectMapper()
- private fun getTxs(respJson: String): JsonNode {
- val mapper = ObjectMapper()
- return mapper.readTree(respJson).get("transactions")
- }
-
- /**
- * Testing that ..access-api/withdrawals/{wopid} and
- * ..access-api/accounts/{account_name}/withdrawals/{wopid}
- * are handled in the same way.
- */
- @Test
- fun doubleUriStyle() {
- // Creating one withdrawal operation.
- withTestDatabase {
- prepSandboxDb()
- val wo: TalerWithdrawalEntity = transaction {
- TalerWithdrawalEntity.new {
- this.amount = "TESTKUDOS:3.3"
- walletBankAccount = getBankAccountFromLabel("foo")
- selectedExchangePayto =
"payto://iban/SANDBOXX/${BAR_USER_IBAN}"
- reservePub = "not used"
- selectionDone = true
- }
- }
- testApplication {
- application(sandboxApp)
- // Showing withdrawal info.
- val get_with_account =
client.get("/demobanks/default/access-api/accounts/foo/withdrawals/${wo.wopid}")
{
- expectSuccess = true
- }
- val get_without_account =
client.get("/demobanks/default/access-api/withdrawals/${wo.wopid}") {
- expectSuccess = true
- }
- assert(get_without_account.bodyAsText() ==
get_with_account.bodyAsText())
- assert(get_with_account.bodyAsText() ==
get_without_account.bodyAsText())
- // Confirming a withdrawal.
- val confirm_with_account =
client.post("/demobanks/default/access-api/accounts/foo/withdrawals/${wo.wopid}/confirm")
{
- expectSuccess = true
- }
- val confirm_without_account =
client.post("/demobanks/default/access-api/withdrawals/${wo.wopid}/confirm") {
- expectSuccess = true
- }
- assert(confirm_with_account.status.value ==
confirm_without_account.status.value)
- assert(confirm_with_account.bodyAsText() ==
confirm_without_account.bodyAsText())
- // Aborting one withdrawal.
- var wo_to_abort = transaction {
- TalerWithdrawalEntity.new {
- this.amount = "TESTKUDOS:3.3"
- walletBankAccount = getBankAccountFromLabel("foo")
- selectedExchangePayto =
"payto://iban/SANDBOXX/${BAR_USER_IBAN}"
- reservePub = "not used"
- selectionDone = true
- }
- }
- val abort_with_account =
client.post("/demobanks/default/access-api/accounts/foo/withdrawals/${wo_to_abort.wopid}/abort")
{
- expectSuccess = true
- }
- wo_to_abort = transaction {
- TalerWithdrawalEntity.new {
- this.amount = "TESTKUDOS:3.3"
- walletBankAccount = getBankAccountFromLabel("foo")
- selectedExchangePayto =
"payto://iban/SANDBOXX/${BAR_USER_IBAN}"
- reservePub = "not used"
- selectionDone = true
- }
- }
- val abort_without_account =
client.post("/demobanks/default/access-api/withdrawals/${wo_to_abort.wopid}/abort")
{
- expectSuccess = true
- }
- assert(abort_with_account.status.value ==
abort_without_account.status.value)
- // Not checking the content as they abort two different
operations.
- }
- }
- }
-
- // Move funds between accounts.
- @Test
- fun wireTransfer() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- runBlocking {
- // Foo gives 20 to Bar
-
client.post("/demobanks/default/access-api/accounts/foo/transactions") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- setBody("""{
- "paytoUri":
"payto://iban/${BAR_USER_IBAN}?message=test",
- "amount": "TESTKUDOS:20"
- }""".trimIndent()
- )
- }
- // Foo checks its balance: -20
- var R =
client.get("/demobanks/default/access-api/accounts/foo") {
- basicAuth("foo", "foo")
- }
- val mapper = ObjectMapper()
- var j = mapper.readTree(R.readBytes())
- val expectDebitOf20 =
j.get("balance").get("amount").asText()
- println("Expect debit of 20: $expectDebitOf20")
- val testkudos20regex = "^TESTKUDOS:20(.00)?$".toRegex()
- assert(testkudos20regex.matches(expectDebitOf20))
-
assert(j.get("balance").get("credit_debit_indicator").asText().lowercase() ==
"debit")
- // Bar checks its balance: 20
- R =
client.get("/demobanks/default/access-api/accounts/bar") {
- basicAuth("bar", "bar")
- }
- j = mapper.readTree(R.readBytes())
-
assert(testkudos20regex.matches(j.get("balance").get("amount").asText()))
-
assert(j.get("balance").get("credit_debit_indicator").asText().lowercase() ==
"credit")
- // Foo tries with an invalid amount
- R =
client.post("/demobanks/default/access-api/accounts/foo/transactions") {
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- setBody("""{
- "paytoUri":
"payto://iban/${BAR_USER_IBAN}?message=test",
- "amount": "TESTKUDOS:20.001"
- }""".trimIndent()
- )
- }
- assert(R.status.value == HttpStatusCode.BadRequest.value)
- }
- }
- }
- }
-
- // Tests the time range filter of Access API's GET /transactions
- @Test
- fun timeRangedTransactions() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var R =
client.get("/demobanks/default/access-api/accounts/foo/transactions") {
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 0) // Checking that no
transactions exist.
- wireTransfer(
- "admin",
- "foo",
- "default",
- "#0",
- "TESTKUDOS:2"
- )
- R =
client.get("/demobanks/default/access-api/accounts/foo/transactions") {
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 1) // Checking that #0
shows up.
- // Asking up to a point in the past, where no txs should exist.
- R =
client.get("/demobanks/default/access-api/accounts/foo/transactions?until_ms=3000")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 0) // Not expecting
any transaction.
- // Moving the transaction back in the past
- transaction {
- val tx_0 = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.subject eq "#0" and
- (BankAccountTransactionsTable.direction eq
"CRDT")
- }.first()
- tx_0.date = 10000
- }
- // Picking the past transaction from one including time range,
- // therefore expecting one entry in the result
- R =
client.get("/demobanks/default/access-api/accounts/foo/transactions?from_ms=9000&until_ms=11000")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 1)
- // Not enough txs to fill the second page, expecting no txs
therefore.
- R =
client.get("/demobanks/default/access-api/accounts/foo/transactions?page=2&size=1")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 0)
- // Creating one more tx and asking the second 1-sized page,
expecting therefore one result.
- wireTransfer(
- "admin",
- "foo",
- "default",
- "#1",
- "TESTKUDOS:2"
- )
- R =
client.get("/demobanks/default/access-api/accounts/foo/transactions?page=2&size=1")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 1)
- }
- }
- }
-
- // Tests for #7482
- @Test
- fun highAmountWithdraw() {
- withTestDatabase {
- prepSandboxDb(usersDebtLimit = 900000000)
- testApplication {
- application(sandboxApp)
- // Create the operation.
- val r =
client.post("/demobanks/default/access-api/accounts/foo/withdrawals") {
- expectSuccess = true
- setBody("{\"amount\": \"TESTKUDOS:500000000\"}")
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- }
- println(r.bodyAsText())
- val j = mapper.readTree(r.readBytes())
- val op = j.get("withdrawal_id").asText()
- // Select exchange and specify a reserve pub.
-
client.post("/demobanks/default/integration-api/withdrawal-operation/$op") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- setBody("""{
- "selected_exchange":"payto://iban/${BAR_USER_IBAN}",
- "reserve_pub": "not-used"
- }""".trimIndent())
- }
- // Confirm the operation.
-
client.post("/demobanks/default/access-api/accounts/foo/withdrawals/$op/confirm")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- // Check the withdrawal amount in the unique transaction.
- val t =
client.get("/demobanks/default/access-api/accounts/foo/transactions") {
- basicAuth("foo", "foo")
- expectSuccess = true
- }
- println(t.bodyAsText())
- val amount =
mapper.readTree(t.readBytes()).get("transactions").get(0).get("amount").asText()
- assert(amount == "500000000")
- }
- }
- }
- @Test
- fun withdrawWithHighBalance() {
- withTestDatabase {
- prepSandboxDb()
- /**
- * A problem appeared (Sandbox responding "insufficient funds")
- * when B - A > T, where B is the balance, A the potential amount
- * to withdraw and T is the debit threshold for the user. T is
- * 1000 here, therefore setting B as 2000 and A as 1 should get
- * this case tested.
- */
- wireTransfer(
- "admin",
- "foo",
- "default",
- "bring balance to high amount",
- "TESTKUDOS:2000"
- )
- testApplication {
- this.application(sandboxApp)
- runBlocking {
-
client.post("/demobanks/default/access-api/accounts/foo/withdrawals") {
- expectSuccess = true
- setBody("{\"amount\": \"TESTKUDOS:1\"}")
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- }
- }
- }
- }
- }
- // Check successful and failing case due to insufficient funds.
- @Test
- fun debitWithdraw() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- this.application(sandboxApp)
- runBlocking {
- // Normal, successful withdrawal.
-
client.post("/demobanks/default/access-api/accounts/foo/withdrawals") {
- expectSuccess = true
- setBody("{\"amount\": \"TESTKUDOS:1\"}")
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- }
- // Withdrawal over the debit threshold.
- val r: HttpResponse =
client.post("/demobanks/default/access-api/accounts/foo/withdrawals") {
- expectSuccess = false
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- setBody("{\"amount\": \"TESTKUDOS:99999999999\"}")
- }
- assert(HttpStatusCode.Conflict.value == r.status.value)
- }
- }
- }
- }
-
- /**
- * Tests that 'admin' and 'bank' are not possible to register
- * and that after 'admin' logs in it gets access to the bank's
- * main account.
- */ // FIXME: avoid giving Content-Type at every request.
- @Test
- fun adminRegisterAndLoginTest() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- runBlocking {
- val registerAdmin = mapper.writeValueAsString(object {
- val username = "admin"
- val password = "y"
- })
- val registerBank = mapper.writeValueAsString(object {
- val username = "bank"
- val password = "y"
- })
- for (b in mutableListOf<String>(registerAdmin,
registerBank)) {
- val r =
client.post("/demobanks/default/access-api/testing/register") {
- setBody(b)
- contentType(ContentType.Application.Json)
- expectSuccess = false
- }
- assert(r.status.value ==
HttpStatusCode.Forbidden.value)
- }
- // Set arbitrary balance to the bank.
- wireTransfer(
- "foo",
- "admin",
- "default",
- "setting the balance",
- "TESTKUDOS:99"
- )
- // Get admin's balance. Not asserting; it
- // fails on != 200 responses.
- val r =
client.get("/demobanks/default/access-api/accounts/admin") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- println(r)
- }
- }
- }
- }
-
- // Checks that the debit threshold belongs to the balance response.
- @Test
- fun debitInfoCheck() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var r =
client.get("/demobanks/default/access-api/accounts/foo") {
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- // Checking that the response holds the debit threshold.
- val mapper = ObjectMapper()
- var respJson = mapper.readTree(r.bodyAsText())
- var debitThreshold = respJson.get("debitThreshold").asText()
- assert(debitThreshold == "1000")
- r = client.get("/demobanks/default/access-api/accounts/admin")
{
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- respJson = mapper.readTree(r.bodyAsText())
- debitThreshold = respJson.get("debitThreshold").asText()
- assert(debitThreshold == "10000")
- }
- }
- }
-
- @Test
- fun registerTest() {
- // Test IBAN conflict detection.
- withSandboxTestDatabase {
- testApplication {
- application(sandboxApp)
- runBlocking {
- val bodyFoo = mapper.writeValueAsString(object {
- val username = "x"
- val password = "y"
- val iban = FOO_USER_IBAN
- })
- val bodyBar = mapper.writeValueAsString(object {
- val username = "y"
- val password = "y"
- val iban = FOO_USER_IBAN // conflicts
- })
- val bodyBaz = mapper.writeValueAsString(object {
- val username = "y"
- val password = "y"
- val iban = BAR_USER_IBAN
- })
- // Succeeds.
-
client.post("/demobanks/default/access-api/testing/register") {
- setBody(bodyFoo)
- contentType(ContentType.Application.Json)
- expectSuccess = true
- }
- // Hits conflict, because of the same IBAN.
- val r =
client.post("/demobanks/default/access-api/testing/register") {
- setBody(bodyBar)
- expectSuccess = false
- contentType(ContentType.Application.Json)
- }
- assert(r.status.value == HttpStatusCode.Conflict.value)
- // Succeeds, because of a new IBAN.
-
client.post("/demobanks/default/access-api/testing/register") {
- setBody(bodyBaz)
- expectSuccess = true
- contentType(ContentType.Application.Json)
- }
- }
- }
-
- }
- }
-
- /**
- * This test checks that the bank hangs before responding with the list
- * of transactions, in case there is none to return. The timing checks
- * that the server hangs for as long as the unblocking payment takes place
- * but NOT as long as the long_poll_ms parameter would suggest. This last
- * check ensures that the response can only contain the artificial
unblocking
- * payment (that happens after a certain timeout).
- */
- @Test
- fun longPolledTransactions() {
- val unblockingTxTimer = Timer()
- val testStartTime = System.currentTimeMillis()
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- runBlocking {
- launch {
- // long polls at most 50 seconds.
- val R =
client.get("/demobanks/default/access-api/accounts/foo/transactions?long_poll_ms=50000")
{
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- assert(getTxs(R.bodyAsText()).size() == 1)
- val testEndTime = System.currentTimeMillis()
- val timeDiff = (testEndTime - testStartTime) / 1000L
- /**
- * Now checking that the server responded after the
unblocking tx
- * took place and before the long poll timeout would
occur.
- */
- println(timeDiff)
- assert(timeDiff in 4 .. 39)
- }
- unblockingTxTimer.schedule(
- delay = 4000L, // unblocks the server in (at least) 4
seconds.
- action = {
- wireTransfer(
- "admin",
- "foo",
- "default",
- "#9",
- "TESTKUDOS:2"
- )
- }
- )
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SandboxBankAccountTest.kt
b/nexus/src/test/kotlin/SandboxBankAccountTest.kt
deleted file mode 100644
index 350ff3da..00000000
--- a/nexus/src/test/kotlin/SandboxBankAccountTest.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-import io.ktor.http.*
-import org.junit.Test
-import tech.libeufin.sandbox.SandboxError
-import tech.libeufin.sandbox.getBalance
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.sandbox.wireTransfer
-import tech.libeufin.util.buildBasicAuthLine
-import tech.libeufin.util.parseDecimal
-import tech.libeufin.util.roundToTwoDigits
-
-class SandboxBankAccountTest {
- // Check if the balance shows debit.
- @Test
- fun debitBalance() {
- withTestDatabase {
- prepSandboxDb()
- wireTransfer(
- "admin",
- "foo",
- "default",
- "Show up in logging!",
- "TESTKUDOS:1"
- )
- /**
- * Bank gave 1 to foo, should be -1 debit now. Because
- * the payment is still pending (= not booked), the pending
- * transactions must be included in the calculation.
- */
- var bankBalance = getBalance("admin")
- assert(bankBalance.roundToTwoDigits() ==
parseDecimal("-1").roundToTwoDigits())
- wireTransfer(
- "foo",
- "admin",
- "default",
- "Show up in logging!",
- "TESTKUDOS:5"
- )
- bankBalance = getBalance("admin")
- assert(bankBalance.roundToTwoDigits() ==
parseDecimal("4").roundToTwoDigits())
- // Trigger Insufficient funds case for users.
- try {
- wireTransfer(
- "foo",
- "admin",
- "default",
- "Show up in logging!",
- "TESTKUDOS:5000"
- )
- } catch (e: SandboxError) {
- // Future versions may wrap this case into a dedicated
exception type.
- assert(e.statusCode == HttpStatusCode.Conflict)
- }
- // Trigger Insufficient funds case for the bank.
- try {
- wireTransfer(
- "admin",
- "foo",
- "default",
- "Show up in logging!",
- "TESTKUDOS:5000000"
- )
- } catch (e: SandboxError) {
- // Future versions may wrap this case into a dedicated
exception type.
- assert(e.statusCode == HttpStatusCode.Conflict)
- }
- // Check balance didn't change for both parties.
- bankBalance = getBalance("admin")
- assert(bankBalance.roundToTwoDigits() ==
parseDecimal("4").roundToTwoDigits())
- val fooBalance = getBalance("foo")
- assert(fooBalance.roundToTwoDigits() ==
parseDecimal("-4").roundToTwoDigits())
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
deleted file mode 100644
index bbfffd1b..00000000
--- a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
+++ /dev/null
@@ -1,662 +0,0 @@
-import com.fasterxml.jackson.databind.ObjectMapper
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.lowerCase
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.sandbox.*
-import tech.libeufin.util.getIban
-import tech.libeufin.util.parseAmount
-import tech.libeufin.util.roundToTwoDigits
-import java.io.File
-import java.math.BigDecimal
-import java.util.*
-
-class SandboxCircuitApiTest {
-
- /**
- * Testing that the admin is able to conduct ordinary
- * account operations even on non-circuit accounts. Recall:
- * such accounts are just those without the cash-out address.
- */
- @Test
- fun opOnNonCircuitAccounts() {
- withTestDatabase {
- testApplication {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- // Only testing that this doesn't except.
- client.get("/demobanks/default/circuit-api/accounts") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- // Trying to PATCH non circuit account
-
client.patch("/demobanks/default/circuit-api/accounts/exchange-0") {
- expectSuccess = true
- basicAuth("admin", "foo")
- contentType(ContentType.Application.Json)
- setBody("""
- {"name": "Exchange 0",
- "contact_data": {},
- "cashout_address":
"payto://iban/SANDBOXX/${getIban()}"
- }
- """.trimIndent())
- }
- // PATCH it again passing a null name and cashout-address.
-
client.patch("/demobanks/default/circuit-api/accounts/exchange-0") {
- expectSuccess = true
- basicAuth("admin", "foo")
- contentType(ContentType.Application.Json)
- setBody("{ \"contact_data\": {} }")
- }
- // PATCH the password.
-
client.patch("/demobanks/default/circuit-api/accounts/exchange-0/auth") {
- expectSuccess = true
- basicAuth("admin", "foo")
- contentType(ContentType.Application.Json)
- setBody("{ \"new_password\": \"secret\" }")
- }
- // Check that PATCHing worked.
-
client.get("/demobanks/default/access-api/accounts/exchange-0") {
- expectSuccess = true
- basicAuth("exchange-0", "secret")
- contentType(ContentType.Application.Json)
- }
- // Deleting the account.
-
client.delete("/demobanks/default/circuit-api/accounts/exchange-0") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- // Checking actual deletion.
- val R =
client.get("/demobanks/default/circuit-api/accounts/exchange-0") {
- expectSuccess = false
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.NotFound.value)
- }
- }
- }
- }
- // Get /config, fails if != 200.
- @Test
- fun config() {
- withSandboxTestDatabase {
- testApplication {
- application(sandboxApp)
- runBlocking {
- val r= client.get("/demobanks/default/circuit-api/config")
- println(r.bodyAsText())
- }
- }
- }
- }
-
- // Tests the application of cash-out ratio and fee.
- @Test
- fun estimationTest() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var R = client.get(
-
"/demobanks/default/circuit-api/cashouts/estimates?amount_debit=TESTKUDOS:2"
- ) {
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- val mapper = ObjectMapper()
- var respJson = mapper.readTree(R.bodyAsText())
- val creditAmount = respJson.get("amount_credit").asText()
- // sell ratio and fee are the following constants: 0.95 and 0.
- // expected credit amount = 2 * 0.95 - 0 = 1.90
- assert("CHF:1.90" == creditAmount || "CHF:1.9" == creditAmount)
- R = client.get(
-
"/demobanks/default/circuit-api/cashouts/estimates?amount_credit=CHF:1.9"
- ) {
- expectSuccess = true
- basicAuth("foo", "foo")
- }
- respJson = mapper.readTree(R.bodyAsText())
- val debitAmount = respJson.get("amount_debit").asText()
- assertWithPrint(
- "TESTKUDOS:2.00" == debitAmount,
- "'debit_amount' was $debitAmount for a 'credit_amount' of
CHF:1.9"
- )
- R = client.get(
-
"/demobanks/default/circuit-api/cashouts/estimates?amount_credit=CHF:1&amount_debit=TESTKUDOS=1"
- ) {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assertWithPrint(
- R.status.value == HttpStatusCode.BadRequest.value,
- "Expected status code was 400, but got '${R.status.value}'
instead."
- )
- }
- }
- }
-
- /**
- * Checking that the ordinary user foo doesn't get to access bar's
- * data, but admin does.
- */
- @Test
- fun accessAccountsTest() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var R =
client.get("/demobanks/default/circuit-api/accounts/bar") {
- basicAuth("foo", "foo")
- expectSuccess = false
- }
- assert(R.status.value == HttpStatusCode.Forbidden.value)
- client.get("/demobanks/default/circuit-api/accounts/bar") {
- basicAuth("admin", "foo")
- expectSuccess = true
- }
- }
- }
- }
- // Only tests that the calls get a 2xx status code.
- @Test
- fun listAccountsTest() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var R = client.get("/demobanks/default/circuit-api/accounts") {
- basicAuth("admin", "foo")
- }
- println(R.bodyAsText())
- client.get("/demobanks/default/circuit-api/accounts/baz") {
- basicAuth("admin", "foo")
- }
- }
- }
- }
- @Test
- fun badUuidTest() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- val R =
client.post("/demobanks/default/circuit-api/cashouts/---invalid_UUID---/confirm")
{
- expectSuccess = false
- basicAuth("foo", "foo")
- contentType(ContentType.Application.Json)
- setBody("{\"tan\":\"foo\"}")
- }
- assert(R.status.value == HttpStatusCode.BadRequest.value)
- }
- }
- }
- @Test
- fun contactDataValidation() {
- // Phone number.
- assert(checkPhoneNumber("+987"))
- assert(!checkPhoneNumber("987"))
- assert(!checkPhoneNumber("foo"))
- assert(!checkPhoneNumber(""))
- assert(!checkPhoneNumber("+00"))
- assert(checkPhoneNumber("+4900"))
- // E-mail address
- assert(checkEmailAddress("test@example.com"))
- assert(!checkEmailAddress("foo.bar"))
- assert(checkEmailAddress("foo.bar@example.com"))
- assert(!checkEmailAddress("foo+bar@example.com"))
- assert(checkEmailAddress("admin@example.info"))
- assert(checkEmailAddress("AdMiN@COM.example.INFO"))
- }
-
- @Test
- fun listCashouts() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- var R = client.get("/demobanks/default/circuit-api/cashouts") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- transaction {
- CashoutOperationEntity.new {
- tan = "unused"
- uuid = UUID.randomUUID()
- amountDebit = "unused"
- amountCredit = "unused"
- subject = "unused"
- creationTime = 0L
- tanChannel = SupportedTanChannels.FILE // change type
to enum?
- account = "foo"
- status = CashoutOperationStatus.PENDING
- cashoutAddress = "not used"
- buyAtRatio = "1"
- buyInFee = "1"
- sellAtRatio = "1"
- sellOutFee = "1"
- }
- }
- R = client.get("/demobanks/default/circuit-api/cashouts") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- // Extract the UUID and check it.
- val mapper = ObjectMapper()
- var respJson = mapper.readTree(R.bodyAsText())
- val uuid = respJson.get("cashouts").get(0).asText()
- R =
client.get("/demobanks/default/circuit-api/cashouts/$uuid") {
- expectSuccess = true
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- respJson = mapper.readTree(R.bodyAsText())
- val status = respJson.get("status").asText()
- assert(status.uppercase() == "PENDING")
- println(R.bodyAsText())
- // Check that bar doesn't get foo's cash-out
- R =
client.get("/demobanks/default/circuit-api/cashouts?account=foo") {
- expectSuccess = false
- basicAuth("bar", "bar")
- }
- assert(R.status.value == HttpStatusCode.Forbidden.value)
- // Check that foo can get its own
- R =
client.get("/demobanks/default/circuit-api/cashouts?account=foo") {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- }
- }
- }
-
- // Testing that only the admin can change an account legal name.
- @Test
- fun patchPerm() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- val R
=client.patch("/demobanks/default/circuit-api/accounts/foo") {
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- expectSuccess = false
- setBody("""
- {
- "name": "new name",
- "contact_data": {},
- "cashout_address": "payto://iban/OUTSIDE"
- }
- """.trimIndent())
- }
- assert(R.status.value == HttpStatusCode.Forbidden.value)
- client.patch("/demobanks/default/circuit-api/accounts/foo") {
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- expectSuccess = true
- setBody("""
- {
- "name": "new name",
- "contact_data": {},
- "cashout_address": "payto://iban/OUTSIDE"
- }
- """.trimIndent())
- }
- }
- }
- }
- // Tests the creation and confirmation of a cash-out operation.
- @Test
- fun cashout() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- // Register a new account.
- client.post("/demobanks/default/circuit-api/accounts") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- setBody("""
- {"username":"shop",
- "password": "secret",
- "contact_data": {},
- "name": "Test",
- "cashout_address": "payto://iban/SAMPLE"
- }
- """.trimIndent())
- }
- // Give initial balance to the new account.
- // Forcing different debt limit:
- transaction {
- val configRaw = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.demobankName eq "default" and(
- DemobankConfigPairsTable.configKey eq
"usersDebtLimit"
- )
- }.first()
- configRaw.configValue = 0.toString()
- }
- val initialBalance = "TESTKUDOS:50.00"
- val balanceAfterCashout = "TESTKUDOS:30.00"
- wireTransfer(
- debitAccount = "admin",
- creditAccount = "shop",
- subject = "cash-out",
- amount = initialBalance
- )
- // Check the balance before cashing out.
- var R =
client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- val mapper = ObjectMapper()
- var respJson = mapper.readTree(R.bodyAsText())
- assert(respJson.get("balance").get("amount").asText() ==
initialBalance)
- // Configure the user phone number, before the cash-out.
- R =
client.patch("/demobanks/default/circuit-api/accounts/shop") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""
- {
- "contact_data": {
- "phone": "+98765"
- },
- "cashout_address": "payto://iban/SAMPLE"
- }
- """.trimIndent())
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- /**
- * Cash-out a portion. Ordering a cash-out of 20 TESTKUDOS
- * should result in the following final amount, that the user
- * will see as incoming in the fiat bank account: 19 = 20 *
0.95 - 0.00.
- * Note: ratios and fees are currently hard-coded.
- */
- R = client.post("/demobanks/default/circuit-api/cashouts") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""{
- "amount_debit": "TESTKUDOS:20",
- "amount_credit": "CHF:19",
- "tan_channel": "file"
- }""".trimIndent())
- }
- assert(R.status.value == HttpStatusCode.Accepted.value)
- val operationUuid =
mapper.readTree(R.readBytes()).get("uuid").asText()
- // Check that the operation is found by the bank.
- R =
client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") {
- // Asking as the Admin but for the 'shop' account.
- basicAuth("admin", "foo")
- }
- // Check that the status is pending.
- assert(mapper.readTree(R.readBytes()).get("status").asText()
== "PENDING")
- // Now confirm the operation.
-
client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/confirm")
{
- basicAuth("shop", "secret")
- contentType(ContentType.Application.Json)
- setBody("{\"tan\":\"foo\"}")
- expectSuccess = true
- }
- // Check that the operation is found by the bank and set to
'confirmed'.
- R =
client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") {
- // Asking as the Admin but for the 'shop' account.
- basicAuth("foo", "foo")
- }
- assert(mapper.readTree(R.readBytes()).get("status").asText()
== "CONFIRMED")
- // Check that the amount got deducted by the account.
- R = client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- respJson = mapper.readTree(R.bodyAsText())
- assert(respJson.get("balance").get("amount").asText() ==
balanceAfterCashout)
- // Attempt to cash-out with wrong regional currency.
- R = client.post("/demobanks/default/circuit-api/cashouts") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""{
- "amount_debit": "NOTFOUND:20",
- "amount_credit": "CHF:19",
- "tan_channel": "file"
- }""".trimIndent())
- expectSuccess = false
- }
- assert(R.status.value == HttpStatusCode.BadRequest.value)
- // Attempt to cash-out with wrong fiat currency.
- R = client.post("/demobanks/default/circuit-api/cashouts") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""{
- "amount_debit": "TESTKUDOS:20",
- "amount_credit": "NOTFOUND:19",
- "tan_channel": "file"
- }""".trimIndent())
- expectSuccess = false
- }
- assert(R.status.value == HttpStatusCode.BadRequest.value)
- // Create a new cash-out and delete it.
- R = client.post("/demobanks/default/circuit-api/cashouts") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""{
- "amount_debit": "TESTKUDOS:20",
- "amount_credit": "CHF:19",
- "tan_channel": "file"
- }""".trimIndent())
- }
- assert(R.status.value == HttpStatusCode.Accepted.value)
- val toAbort =
mapper.readTree(R.readBytes()).get("uuid").asText()
- // Check it exists.
- R =
client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") {
- // Asking as the Admin but for the 'shop' account.
- basicAuth("foo", "foo")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- // Ask to delete the operation.
- R =
client.post("/demobanks/default/circuit-api/cashouts/${toAbort}/abort") {
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- // Check actual disappearance.
- R =
client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") {
- // Asking as the Admin but for the 'shop' account.
- basicAuth("foo", "foo")
- }
- assert(R.status.value == HttpStatusCode.NotFound.value)
- // Ask to delete a confirmed operation.
- R =
client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/abort") {
- basicAuth("admin", "foo")
- }
- assert(R.status.value ==
HttpStatusCode.PreconditionFailed.value)
- }
- }
- }
-
- // Test user registration and deletion.
- @Test
- fun registration() {
- withSandboxTestDatabase {
- testApplication {
- application(sandboxApp)
- runBlocking {
- // Successful registration.
- var R =
client.post("/demobanks/default/circuit-api/accounts") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- setBody("""
- {"username":"shop",
- "password": "secret",
- "contact_data": {},
- "name": "Test",
- "cashout_address": "payto://iban/SAMPLE"
- }
- """.trimIndent())
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- // Check accounts list.
- R = client.get("/demobanks/default/circuit-api/accounts") {
- basicAuth("admin", "foo")
- expectSuccess = true
- }
- println(R.bodyAsText())
- // Update contact data.
- R =
client.patch("/demobanks/default/circuit-api/accounts/shop") {
- contentType(ContentType.Application.Json)
- basicAuth("shop", "secret")
- setBody("""
- {"contact_data": {"email": "user@example.com"},
- "cashout_address": "payto://iban/SAMPLE"
- }
- """.trimIndent())
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- // Get user data via the Access API.
- R =
client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- // Get Circuit data via the Circuit API.
- R =
client.get("/demobanks/default/circuit-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- println(R.bodyAsText())
- assert(R.status.value == HttpStatusCode.OK.value)
- // Change password.
- R =
client.patch("/demobanks/default/circuit-api/accounts/shop/auth") {
- basicAuth("shop", "secret")
- setBody("{\"new_password\":\"new_secret\"}")
- contentType(ContentType.Application.Json)
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- // Check that the password changed: expect 401 with
previous password.
- R =
client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- assert(R.status.value == HttpStatusCode.Unauthorized.value)
- // Check that the password changed: expect 200 with
current password.
- R =
client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "new_secret")
- }
- assert(R.status.value == HttpStatusCode.OK.value)
- // Change user balance.
- transaction {
- val account = BankAccountEntity.find {
- BankAccountsTable.label eq "shop"
- }.firstOrNull() ?: throw Exception("Circuit test
account not found in the database!")
- account.bonus("TESTKUDOS:30")
- account
- }
- // Delete account. Fails because the balance is not zero.
- R =
client.delete("/demobanks/default/circuit-api/accounts/shop") {
- basicAuth("admin", "foo")
- }
- assert(R.status.value ==
HttpStatusCode.PreconditionFailed.value)
- // Bring the balance again to zero
- transaction {
- wireTransfer(
- "shop",
- "admin",
- "default",
- "deletion condition",
- "TESTKUDOS:30"
- )
- }
- // Now delete the account successfully.
- R =
client.delete("/demobanks/default/circuit-api/accounts/shop") {
- basicAuth("admin", "foo")
- }
- assert(R.status.value == HttpStatusCode.NoContent.value)
- // Check actual deletion.
- R =
client.get("/demobanks/default/access-api/accounts/shop") {
- basicAuth("shop", "secret")
- }
- assert(R.status.value == HttpStatusCode.NotFound.value)
- }
- }
- }
- }
-
- // Tests the database RegEx filter on customer names.
- @Ignore // Since no assert takes place.
- @Test
- fun customerFilter() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- val R =
client.get("/demobanks/default/circuit-api/accounts?filter=b") {
- basicAuth("admin", "foo")
- expectSuccess = true
- }
- println(R.bodyAsText())
- }
- }
- }
-
- /**
- * Testing that deleting a user doesn't cause a _different_ user
- * to lose data.
- */
- @Test
- fun deletionIsolation() {
- withTestDatabase {
- prepSandboxDb()
- transaction {
- // Admin makes sure foo has balance 100.
- wireTransfer(
- "admin",
- "foo",
- subject = "set to 100",
- amount = "TESTKUDOS:100"
- )
- val fooBalance = getBalance("foo")
- assert(fooBalance.roundToTwoDigits() ==
BigDecimal("100").roundToTwoDigits())
- // Foo pays 3 to bar.
- wireTransfer(
- "foo",
- "bar",
- subject = "donation",
- amount = "TESTKUDOS:3"
- )
- val barBalance = getBalance("bar")
- assert(barBalance.roundToTwoDigits() ==
BigDecimal("3").roundToTwoDigits())
- // Deleting foo from the system.
- transaction {
- val uBankAccount = getBankAccountFromLabel("foo")
- val uCustomerProfile = getCustomer("foo")
- uBankAccount.delete()
- uCustomerProfile.delete()
- }
- val barBalanceUpdate = getBalance("bar")
- assert(barBalanceUpdate.roundToTwoDigits() ==
BigDecimal("3").roundToTwoDigits())
- }
- }
- }
-
- @Test
- fun tanCommandTest() {
- /**
- * 'tee' allows to test the SMS/e-mail command execution
- * because it relates to STDIN and the first command line argument
- * in the same way the SMS/e-mail command is expected to.
- */
- val tanLocation = File("/tmp/libeufin-tan-cmd-test.txt")
- val tanContent = "libeufin"
- if (tanLocation.exists()) tanLocation.delete()
- runTanCommand(
- command = "tee",
- address = tanLocation.path,
- message = tanContent
- )
- val maybeTan = tanLocation.readText()
- assert(maybeTan == tanContent)
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SandboxLegacyApiTest.kt
b/nexus/src/test/kotlin/SandboxLegacyApiTest.kt
deleted file mode 100644
index 76ee405d..00000000
--- a/nexus/src/test/kotlin/SandboxLegacyApiTest.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-import com.fasterxml.jackson.databind.ObjectMapper
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import io.ktor.util.*
-import io.ktor.utils.io.*
-import io.netty.handler.codec.http.HttpResponseStatus
-import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.util.buildBasicAuthLine
-import tech.libeufin.util.getIban
-import java.io.ByteArrayOutputStream
-
-class SandboxLegacyApiTest {
- fun dbHelper (f: () -> Unit) {
- withTestDatabase {
- prepSandboxDb()
- f()
- }
- }
- val mapper = ObjectMapper()
-
- // EBICS Subscribers API.
- @Test
- fun adminEbicsSubscribers() {
- dbHelper {
- testApplication {
- application(sandboxApp)
- runBlocking {
- /**
- * Create a EBICS subscriber. That conflicts because
- * MakeEnv.kt created it already, but tests access control
- * and conflict detection.
- */
- var body = mapper.writeValueAsString(object {
- val hostID = "eufinSandbox"
- val userID = "foo"
- val systemID = "foo"
- val partnerID = "foo"
- })
- var r: HttpResponse =
client.post("/admin/ebics/subscribers") {
- expectSuccess = false
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- setBody(body)
- }
- assert(r.status.value == HttpStatusCode.Conflict.value)
-
- // Check that EBICS subscriber indeed exists.
- r = client.get("/admin/ebics/subscribers") {
- basicAuth("admin", "foo")
- }
- assert(r.status.value == HttpStatusCode.OK.value)
- val respObj = mapper.readTree(r.bodyAsText())
- assert("foo" ==
respObj.get("subscribers").get(0).get("userID").asText())
-
- // Try same operations as above, with wrong admin
credentials
- r = client.get("/admin/ebics/subscribers") {
- expectSuccess = false
- basicAuth("admin", "wrong")
- }
- assert(r.status.value == HttpStatusCode.Unauthorized.value)
- r = client.post("/admin/ebics/subscribers") {
- expectSuccess = false
- basicAuth("admin", "wrong")
- }
- assert(r.status.value == HttpStatusCode.Unauthorized.value)
- // Good credentials, but insufficient rights.
- r = client.get("/admin/ebics/subscribers") {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assert(r.status.value == HttpStatusCode.Forbidden.value)
- r = client.post("/admin/ebics/subscribers") {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assert(r.status.value == HttpStatusCode.Forbidden.value)
- /**
- * Give a bank account to the existing subscriber. Bank
account
- * is (implicitly / hard-coded) hosted at default demobank.
- */
- // Create new subscriber. No need to have the related
customer.
- body = mapper.writeValueAsString(object {
- val hostID = "eufinSandbox"
- val userID = "baz"
- val partnerID = "baz"
- val systemID = "foo"
- })
- client.post("/admin/ebics/subscribers") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- setBody(body)
- }
- // Associate new bank account to it.
- body = mapper.writeValueAsString(object {
- val subscriber = object {
- val userID = "baz"
- val partnerID = "baz"
- val systemID = "baz"
- val hostID = "eufinSandbox"
- }
- val iban = getIban()
- val bic = "SANDBOXX"
- val name = "Now Have Account"
- val label = "baz"
- })
- client.post("/admin/ebics/bank-accounts") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- setBody(body)
- }
- r = client.get("/admin/ebics/subscribers") {
- basicAuth("admin", "foo")
- }
- assert(r.status.value == HttpStatusCode.OK.value)
- val respObj_ = mapper.readTree(r.bodyAsText())
- val bankAccountLabel =
respObj_.get("subscribers").get(1).get("demobankAccountLabel").asText()
- assert("baz" == bankAccountLabel)
- // Same operation, wrong/unauth credentials.
- r = client.post("/admin/ebics/bank-accounts") {
- expectSuccess = false
- basicAuth("admin", "wrong")
- }
- assert(r.status.value == HttpStatusCode.Unauthorized.value)
- r = client.post("/admin/ebics/bank-accounts") {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assert(r.status.value == HttpStatusCode.Forbidden.value)
- }
- }
- }
- }
-
- // EBICS Hosts API.
- @Test
- fun adminEbicsCreateHost() {
- dbHelper {
- testApplication {
- application(sandboxApp)
- runBlocking {
- val body = mapper.writeValueAsString(
- object {
- val hostID = "www"
- var ebicsVersion = "www"
- }
- )
- // Valid request, good credentials.
- client.post("/admin/ebics/hosts") {
- expectSuccess = true
- setBody(body)
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- }
- var r = client.get("/admin/ebics/hosts") { expectSuccess =
false }
- assert(r.status.value ==
HttpResponseStatus.UNAUTHORIZED.code())
- client.get("/admin/ebics/hosts") {
- basicAuth("admin", "foo")
- expectSuccess = true
- }
- // Invalid, with good credentials.
- r = client.post("/admin/ebics/hosts") {
- expectSuccess = false
- setBody("invalid")
- contentType(ContentType.Application.Json)
- basicAuth("admin", "foo")
- }
- assert(r.status.value == HttpStatusCode.BadRequest.value)
- // Unauth: admin with wrong password.
- r = client.post("/admin/ebics/hosts") {
- expectSuccess = false
- basicAuth("admin", "bar")
- }
- assert(r.status.value == HttpStatusCode.Unauthorized.value)
- // Auth & forbidden resource.
- r = client.post("/admin/ebics/hosts") {
- expectSuccess = false
- basicAuth("foo", "foo")
- }
- assert(r.status.value == HttpStatusCode.Forbidden.value)
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SchedulingTest.kt
b/nexus/src/test/kotlin/SchedulingTest.kt
deleted file mode 100644
index 0e1b4be9..00000000
--- a/nexus/src/test/kotlin/SchedulingTest.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-import io.ktor.client.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.coroutines.*
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions
-import tech.libeufin.nexus.server.FetchLevel
-import tech.libeufin.nexus.server.FetchSpecAllJson
-import tech.libeufin.nexus.whileTrueOperationScheduler
-import tech.libeufin.sandbox.sandboxApp
-import java.util.*
-import kotlin.concurrent.schedule
-import kotlin.text.get
-
-/**
- * This test suite helps to _measure_ the scheduler performance.
- * It is NOT meant to assert on values, but rather to _launch_ and
- * give the chance to monitor the CPU usage with TOP(1)
- */
-
-/**
- * It emerged that whether asking transactions via EBICS or x-libeufin-bank
- * is NOT performance relevant! For example, asking for a bank account
- * balance - via the plain Access API - brings the CPU usage to > 10%. Asking
- * for /config - via Integration API - used to oscillate the CPU usage
- * between 3 and 10%.
- *
- * The scheduler's loop style is not relevant either: a while-true &
delay(1000)
- * or a Java Timer did NOT change the perf.
- */
-
-// This class focuses on the perf. of Nexus scheduling.
-class SchedulingTest {
- // Launching the scheduler to measure its perf with TOP(1)
- @Ignore // Ignoring because no assert takes place.
- @Test
- fun normalOperation() {
- withTestDatabase {
- prepNexusDb()
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- whileTrueOperationScheduler(client)
- // javaTimerOperationScheduler(client)
- }
- }
- runBlocking {
- launch { awaitCancellation() }
- }
- }
-
- // Allows TOP(1) on the bare connection operations without the scheduling
overhead.
- // Not strictly related to scheduling, but perf. is a major part of
scheduling.
- @Test
- @Ignore // Ignoring because no assert takes place.
- fun bareOperationXLibeufinBank() {
- withTestDatabase {
- prepNexusDb()
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- runBlocking {
- while (true) {
- // Even x-libeufin-bank takes 10-20% CPU
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecAllJson(
- level = FetchLevel.STATEMENT,
- bankConnection = "bar"
- ),
- accountId = "bar"
- )
- delay(1000L)
- }
- }
- }
- }
- }
- // Same as the previous, but on a EBICS connection.
- // Perf. is only slightly worse than the JSON based x-libeufin-bank
connection.
- @Ignore // Ignoring because no assert takes place.
- @Test
- fun bareOperationEbics() {
- withTestDatabase {
- prepNexusDb()
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- runBlocking {
- while (true) {
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecAllJson(
- level = FetchLevel.STATEMENT,
- bankConnection = "foo"
- ),
- accountId = "foo"
- )
- delay(1000L)
- }
- }
- }
- }
- }
-
- // HTTP requests loop, to measure perf. via TOP(1)
- @Ignore // because no assert takes place.
- @Test
- fun plainSandboxReqLoop() {
- withTestDatabase {
- prepSandboxDb()
- testApplication {
- application(sandboxApp)
- while (true) {
- // This brings the CPU to > 10%
- /*client.get("/demobanks/default/access-api/accounts/foo")
{
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- }*/
- // This brings the CPU between 3 and 10%
- /*client.get("/demobanks/default/integration-api/config") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- // This caused 3 to 9% CPU => did not cause more usage.
- // basicAuth("foo", "foo")
- }*/
- // Between 2 and 3% CPU.
- client.get("/")
- delay(1000L)
- }
- }
- }
- }
-}
-
-// This class investigates two ways of scheduling, regardless of the one used
by Nexus.
-class PlainJavaScheduling {
- val instanceTimer = Timer()
- // Below 5% CPU time.
- private fun loopWithJavaTimer() {
- println("with Java Timer " +
- "doing at ${System.currentTimeMillis() / 1000}.."
- ) // uncertain time goes by.
- instanceTimer.schedule(
- delay = 1200,
- action = { loopWithJavaTimer() }
- )
- }
- // Below 5% CPU time.
- private suspend fun loopWithWhileTrue() {
- val client = HttpClient()
- while (true) {
- println("With while-true " +
- "doing at ${System.currentTimeMillis() / 1000}.."
- ) // uncertain time goes by.
- client.get("https://exchange.demo.taler.net/wrong") {
- basicAuth("foo", "foo")
- }
- delay(1000)
- }
- }
- @Ignore // due to no assert.
- @Test
- fun javaTimerLoop() {
- loopWithJavaTimer()
- runBlocking { delay(timeMillis = 30000) }
- }
- @Ignore // due to no assert.
- @Test
- fun whileTrueLoop() {
- runBlocking {
- loopWithWhileTrue()
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SplitString.kt
b/nexus/src/test/kotlin/SplitString.kt
deleted file mode 100644
index a0fb0069..00000000
--- a/nexus/src/test/kotlin/SplitString.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package tech.libeufin.nexus
-
-import org.junit.Test
-
-class SplitString {
-
- @Test
- fun splitString() {
- val chunks = mutableListOf<String>("first", "second", "third",
"fourth")
- val join = chunks.joinToString("|")
- val chunkAgain = join.split("|")
- assert(chunks == chunkAgain)
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SubjectNormalization.kt
b/nexus/src/test/kotlin/SubjectNormalization.kt
deleted file mode 100644
index d6eee674..00000000
--- a/nexus/src/test/kotlin/SubjectNormalization.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-import org.junit.Test
-import tech.libeufin.util.CryptoUtil
-import tech.libeufin.util.extractReservePubFromSubject
-
-class SubjectNormalization {
-
- @Test
- fun testBeforeAndAfter() {
- val mereValue = "1ENVZ6EYGB6Z509KRJ6E59GK1EQXZF8XXNY9SN33C2KDGSHV9KA0"
- assert(mereValue == extractReservePubFromSubject(mereValue))
- assert(mereValue == extractReservePubFromSubject("noise before
${mereValue} noise after"))
- val mereValueNewLines =
"\t1ENVZ6EYGB6Z\n\n\n509KRJ6E59GK1EQXZF8XXNY9\nSN33C2KDGSHV9KA0"
- assert(mereValue == extractReservePubFromSubject(mereValueNewLines))
- assert(mereValue == extractReservePubFromSubject("noise before
$mereValueNewLines noise after"))
- }
-
- /**
- * Here we test whether the value that the extractor picks
- * from a payment subjects is then validated by the crypto backend.
- */
- @Test
- fun extractorVsDecoder() {
- val validPub = "7R422Z6C5TPG0JM32KRWV093J0AG0GVZV1247F9PBSFZT6Y61G1G"
- assert(CryptoUtil.checkValidEddsaPublicKey(validPub))
- // Swapping zeros with Os.
- assert(CryptoUtil.checkValidEddsaPublicKey(validPub.replace('0', 'O')))
- // At this point, the decoder handles 0s and Os interchangeably.
- // Now check that the reserve pub. extractor behaves equally.
- val extractedPub = extractReservePubFromSubject(validPub) // has 0s.
- // The "!!" ensures that the extractor found a likely reserve pub.
- assert(CryptoUtil.checkValidEddsaPublicKey(extractedPub!!))
- val extractedPubWithOs =
extractReservePubFromSubject(validPub.replace('0', 'O'))
- // The "!!" ensures that the extractor did find the reserve pub. with
Os instead of zeros.
- assert(CryptoUtil.checkValidEddsaPublicKey(extractedPubWithOs!!))
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/TalerTest.kt
b/nexus/src/test/kotlin/TalerTest.kt
deleted file mode 100644
index 8c9a5edd..00000000
--- a/nexus/src/test/kotlin/TalerTest.kt
+++ /dev/null
@@ -1,260 +0,0 @@
-import com.fasterxml.jackson.databind.ObjectMapper
-import io.ktor.client.call.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.coroutines.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions
-import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations
-import tech.libeufin.nexus.ingestFacadeTransactions
-import tech.libeufin.nexus.maybeTalerRefunds
-import tech.libeufin.nexus.server.*
-import tech.libeufin.nexus.talerFilter
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.sandbox.wireTransfer
-import tech.libeufin.util.NotificationsChannelDomains
-import tech.libeufin.util.getIban
-
-// This class tests the features related to the Taler facade.
-class TalerTest {
- private val mapper = ObjectMapper()
-
- @Test
- fun historyOutgoingTestEbics() {
- historyOutgoingTest("foo")
- }
- @Test
- fun historyOutgoingTestXLibeufinBank() {
- historyOutgoingTest("bar")
- }
-
- // Checking that a call to POST /transfer results in
- // an outgoing payment in GET /history/outgoing.
- fun historyOutgoingTest(testedAccount: String) {
- withNexusAndSandboxUser {
- testApplication {
- application(nexusApp)
-
client.post("/facades/$testedAccount-facade/taler-wire-gateway/transfer") {
- contentType(ContentType.Application.Json)
- basicAuth(testedAccount, testedAccount) // exchange's
credentials
- expectSuccess = true
- setBody("""
- { "request_uid": "twg_transfer_0",
- "amount": "TESTKUDOS:3",
- "exchange_base_url": "http://exchange.example.com/",
- "wtid": "T0",
- "credit_account":
"payto://iban/${BANK_IBAN}?receiver-name=Not-Used"
- }
- """.trimIndent())
- }
- }
- /* The bank connection sends the payment instruction to the bank
here.
- * and the reconciliation mechanism in Nexus should detect that one
- * outgoing payment was indeed the one instructed via the TWG. The
- * reconciliation will make the outgoing payment visible via
/history/outgoing.
- * The following block achieve this by starting Sandbox and
sending all
- * the prepared payments to it.
- */
- testApplication {
- application(sandboxApp)
- submitAllPaymentInitiations(client, testedAccount)
- /* Now downloads transactions from the bank, where the payment
- submitted in the previous block is expected to appear as
outgoing.
- */
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecTimeRangeJson(
- level = if (testedAccount == "bar")
FetchLevel.STATEMENT else FetchLevel.REPORT,
- start = "2020-01-01",
- end = "3000-01-01",
- bankConnection = testedAccount
- ),
- accountId = testedAccount
- )
- }
- /**
- * Now Nexus starts again, in order to serve /history/outgoing
- * along the TWG.
- */
- testApplication {
- application(nexusApp)
- val r =
client.get("/facades/$testedAccount-facade/taler-wire-gateway/history/outgoing?delta=5")
{
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth(testedAccount, testedAccount)
- }
- assert(r.status.value == HttpStatusCode.OK.value)
- val j = mapper.readTree(r.readBytes())
- val wtidFromTwg =
j.get("outgoing_transactions").get(0).get("wtid").asText()
- assert(wtidFromTwg == "T0")
- }
- }
- }
-
- // Tests that incoming Taler txs arrive via EBICS.
- @Test
- fun historyIncomingTestEbics() {
- historyIncomingTest(
- testedAccount = "foo",
- connType = BankConnectionType.EBICS
- )
- }
-
- // Tests that incoming Taler txs arrive via x-libeufin-bank.
- @Test
- fun historyIncomingTestXLibeufinBank() {
- historyIncomingTest(
- testedAccount = "bar",
- connType = BankConnectionType.X_LIBEUFIN_BANK
- )
- }
-
- // Tests that even if one call is long-polling, other calls respond.
- @Test
- fun servingTest() {
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- val currentTime = System.currentTimeMillis()
- runBlocking {
- launch {
- val r =
client.get("/facades/foo-facade/taler-wire-gateway/history/incoming?delta=5&start=0&long_poll_ms=5000")
{
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo") // user & pw always equal.
- }
- assert(r.status.value ==
HttpStatusCode.NoContent.value)
- }
- val R = client.get("/") {
- expectSuccess = true
- }
- val latestTime = System.currentTimeMillis()
- // Checks that the call didn't hang for the whole
long_poll_ms.
- assert(R.status.value == HttpStatusCode.OK.value
- && (latestTime - currentTime) < 2000
- )
- }
- }
- }
- }
-
- // Downloads Taler txs via the default connection of 'testedAccount'.
- // This allows to test the Taler logic on different connection types.
- private fun historyIncomingTest(testedAccount: String, connType:
BankConnectionType) {
- val reservePub = "GX5H5RME193FDRCM1HZKERXXQ2K21KH7788CKQM8X6MYKYRBP8F0"
- withNexusAndSandboxUser {
- testApplication {
- application(nexusApp)
- runBlocking {
- /**
- * This block issues the request by long-polling and
- * lets the execution proceed where the actions to unblock
- * the polling are taken.
- */
- launch {
- val r =
client.get("/facades/${testedAccount}-facade/taler-wire-gateway/history/incoming?delta=5&start=0&long_poll_ms=30000")
{
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth(testedAccount, testedAccount) // user &
pw always equal.
- }
- assertWithPrint(
- r.status.value == HttpStatusCode.OK.value,
- "Long-polling history had status:
${r.status.value} and" +
- " body: ${r.bodyAsText()}"
- )
- val j = mapper.readTree(r.readBytes())
- val reservePubFromTwg =
j.get("incoming_transactions").get(0).get("reserve_pub").asText()
- assert(reservePubFromTwg == reservePub)
- }
- launch {
- delay(500)
- newNexusBankTransaction(
- currency = "KUDOS",
- value = "10",
- subject = reservePub,
- creditorAcct = testedAccount,
- connType = connType
- )
- ingestFacadeTransactions(
- bankAccountId = testedAccount, // bank account
local to Nexus.
- facadeType = NexusFacadeType.TALER,
- incomingFilterCb = ::talerFilter,
- refundCb = ::maybeTalerRefunds
- )
- }
- }
- }
- }
- }
-
- @Ignore // Ignoring because no assert takes place.
- @Test // Triggering a refund because of a duplicate reserve pub.
- fun refundTest() {
- withNexusAndSandboxUser {
- // Creating a Taler facade for the user 'foo'.
- testApplication {
- application(nexusApp)
- client.post("/facades") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "foo")
- setBody("""
- { "name":"foo-facade",
- "type":"taler-wire-gateway",
- "config": {
- "bankAccount":"foo",
- "bankConnection":"foo",
- "currency":"TESTKUDOS",
- "reserveTransferLevel":"report"
- }
- }""".trimIndent()
- )
- }
- }
- wireTransfer(
- "bar",
- "foo",
- demobank = "default",
- "5WFM8PXN7Y315RVZFJ280299B94W1HR1AAHH6XNDYEJBC0T3E5N0",
- "TESTKUDOS:3"
- )
- testApplication {
- application(sandboxApp)
- // Nexus downloads the fresh transaction.
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecAllJson(
- level = FetchLevel.REPORT,
- "foo"
- ),
- "foo"
- )
- }
- wireTransfer(
- "bar",
- "foo",
- demobank = "default",
- "5WFM8PXN7Y315RVZFJ280299B94W1HR1AAHH6XNDYEJBC0T3E5N0",
- "TESTKUDOS:3"
- )
- testApplication {
- application(sandboxApp)
- // Nexus downloads the new transaction, having a duplicate
subject.
- fetchBankAccountTransactions(
- client,
- fetchSpec = FetchSpecAllJson(
- level = FetchLevel.REPORT,
- "foo"
- ),
- "foo"
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/XLibeufinBankTest.kt
b/nexus/src/test/kotlin/XLibeufinBankTest.kt
deleted file mode 100644
index 7961f9db..00000000
--- a/nexus/src/test/kotlin/XLibeufinBankTest.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.server.testing.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.junit.Test
-import tech.libeufin.nexus.*
-import tech.libeufin.nexus.bankaccount.addPaymentInitiation
-import tech.libeufin.nexus.bankaccount.ingestBankMessagesIntoAccount
-import tech.libeufin.nexus.server.*
-import tech.libeufin.nexus.xlibeufinbank.XlibeufinBankConnectionProtocol
-import tech.libeufin.sandbox.BankAccountTransactionEntity
-import tech.libeufin.sandbox.BankAccountTransactionsTable
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.sandbox.wireTransfer
-import tech.libeufin.util.XLibeufinBankTransaction
-import tech.libeufin.util.getIban
-import java.net.URL
-
-// Testing the x-libeufin-bank communication
-
-class XLibeufinBankTest {
- private val mapper = jacksonObjectMapper()
- @Test
- fun urlParse() {
- val u = URL("http://localhost")
- println(u.authority)
- }
-
- /**
- * This test tries to submit a transaction to Sandbox
- * via the x-libeufin-bank connection and later - after
- * having downloaded its transactions - tries to reconcile
- * it as sent.
- */
- @Test
- fun submitTransaction() {
- withTestDatabase {
- prepSandboxDb()
- prepNexusDb()
- testApplication {
- application(sandboxApp)
- val pId = addPaymentInitiation(
- Pain001Data(
- creditorIban = FOO_USER_IBAN,
- creditorBic = "SANDBOXX",
- creditorName = "Tester",
- subject = "test payment",
- sum = "1",
- currency = "TESTKUDOS"
- ),
- transaction {
- NexusBankAccountEntity.findByName("bar") ?:
- throw Exception("Test failed, env didn't provide Nexus
bank account 'bar'")
- }
- )
- val conn = XlibeufinBankConnectionProtocol()
- conn.submitPaymentInitiation(this.client, pId.id.value)
- val maybeArrivedPayment = transaction {
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.pmtInfId eq
pId.paymentInformationId
- }.firstOrNull()
- }
- // Now look for the payment in the database.
- assert(maybeArrivedPayment != null)
- }
- }
- }
- /**
- * Testing that Nexus downloads one transaction from
- * Sandbox via the x-libeufin-bank protocol supplier
- * and stores it in the Nexus internal transactions
- * table.
- *
- * NOTE: the test should be extended by checking that
- * downloading twice the transaction doesn't lead to asset
- * duplication locally in Nexus.
- */
- @Test
- fun fetchTransaction() {
- withTestDatabase {
- prepSandboxDb()
- prepNexusDb()
- testApplication {
- // Creating the Sandbox transaction that's expected to be
ingested.
- wireTransfer(
- debitAccount = "bar",
- creditAccount = "foo",
- demobank = "default",
- subject = "x-libeufin-bank test transaction",
- amount = "TESTKUDOS:333"
- )
- val fooUser = getNexusUser("foo")
- // Creating the x-libeufin-bank connection to interact with
Sandbox.
- val conn = XlibeufinBankConnectionProtocol()
- val jDetails = """{
- "username": "foo",
- "password": "foo",
- "baseUrl": "http://localhost/demobanks/default/access-api"
- }""".trimIndent()
- conn.createConnection(
- connId = "x",
- user = fooUser,
- data = mapper.readTree(jDetails)
- )
- // Starting _Sandbox_ to check how it reacts to Nexus request.
- application(sandboxApp)
- /**
- * Doing two rounds of download: the first is expected to
- * record the payment as new, and the second is expected to
- * ignore it because it has already it in the database.
- */
- repeat(2) {
- // Invoke transaction fetcher offered by the
x-libeufin-bank connection.
- conn.fetchTransactions(
- fetchSpec = FetchSpecAllJson(
- FetchLevel.STATEMENT,
- null
- ),
- accountId = "foo",
- bankConnectionId = "x",
- client = client
- )
- }
- // The messages are in the database now, invoke the
- // ingestion routine to parse them into the Nexus internal
- // format.
- ingestBankMessagesIntoAccount("x", "foo")
- // Asserting that the payment made it to the database in the
Nexus format.
- transaction {
- val maybeTx = NexusBankTransactionEntity.all()
- // This assertion checks that the payment is not doubled
in the database:
- assert(maybeTx.count() == 1L)
- val tx =
maybeTx.first().parseDetailsIntoObject<CamtBankAccountEntry>()
- assert(tx.getSingletonSubject() == "x-libeufin-bank test
transaction")
- }
- }
- }
- }
-
- // Testing that Nexus responds with correct connection details.
- // Currently only testing that the request doesn't throw any error.
- @Test
- fun connectionDetails() {
- withTestDatabase {
- prepNexusDb()
- testApplication {
- application(nexusApp)
- val r = client.get("/bank-connections/bar") {
- basicAuth("bar", "bar")
- expectSuccess = true
- }
- println(r.bodyAsText())
- }
- }
- }
-}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/XPathTest.kt
b/nexus/src/test/kotlin/XPathTest.kt
deleted file mode 100644
index be48c4fb..00000000
--- a/nexus/src/test/kotlin/XPathTest.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package tech.libeufin.nexus
-
-import org.junit.Test
-import org.w3c.dom.Document
-import tech.libeufin.util.XMLUtil
-import tech.libeufin.util.pickString
-
-class XPathTest {
-
- @Test
- fun pickDataFromSimpleXml() {
- val xml = """
- <root xmlns="foo">
- <node>lorem ipsum</node>
- </root>""".trimIndent()
- val doc: Document = XMLUtil.parseStringIntoDom(xml)
- println(doc.pickString( "//*[local-name()='node']"))
- }
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git
a/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml
b/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml
deleted file mode 100644
index 14a36eb5..00000000
--- a/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml
+++ /dev/null
@@ -1,488 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- This file has been placed in the public domain -->
-<!-- Sample camt.053 according to the interpretation of the German DK rules -->
-<!-- IBANs have been randomly generated with a BBAN of 12345678 -->
-<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02
camt.053.001.02.xsd">
- <BkToCstmrStmt>
- <GrpHdr>
- <MsgId>msg-001</MsgId>
- <CreDtTm>2020-07-03T12:44:40+05:30</CreDtTm>
- </GrpHdr>
- <Stmt>
- <Id>stmt-001</Id>
- <CreDtTm>2020-07-03T11:00:40+05:30</CreDtTm>
- <Acct>
- <Id>
- <IBAN>DE54123456784713474163</IBAN>
- </Id>
- </Acct>
- <Bal>
- <Tp>
- <CdOrPrtry>
- <Cd>PRCD</Cd>
- </CdOrPrtry>
- </Tp>
- <Amt Ccy="EUR">500</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <Dt>
- <Dt>2020-07-03</Dt>
- </Dt>
- </Bal>
-
- <!-- Credit due to incoming SCT -->
- <Ntry>
- <Amt Ccy="EUR">100.00</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <Sts>BOOK</Sts>
- <BookgDt>
- <Dt>2020-07-02</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2020-07-04</Dt>
- </ValDt>
- <AcctSvcrRef>acctsvcrref-001</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>166</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <NtryDtls>
- <TxDtls>
- <Refs>
- <EndToEndId>e2e-001</EndToEndId>
- </Refs>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NTRF+166</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <RltdPties>
- <Dbtr>
- <Nm>Debtor One</Nm>
- </Dbtr>
- <DbtrAcct>
- <Id>
- <IBAN>DE52123456789473323175</IBAN>
- </Id>
- </DbtrAcct>
- <UltmtDbtr>
- <Nm>Ultimate Debtor One</Nm>
- </UltmtDbtr>
- <Cdtr>
- <Nm>Creditor One</Nm>
- </Cdtr>
- <UltmtCdtr>
- <Nm>Ultimate Creditor One</Nm>
- </UltmtCdtr>
- </RltdPties>
- <Purp>
- <Cd>GDDS</Cd>
- </Purp>
- <RmtInf>
- <Ustrd>unstructured info one</Ustrd>
- </RmtInf>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>SEPA GUTSCHRIFT</AddtlNtryInf>
- </Ntry>
-
- <!-- Entry to illustrate multiple ustrd elements -->
- <Ntry>
- <Amt Ccy="EUR">50.00</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <Sts>BOOK</Sts>
- <BookgDt>
- <Dt>2020-07-02</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2020-07-04</Dt>
- </ValDt>
- <AcctSvcrRef>acctsvcrref-002</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>166</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <!-- Credit due to incoming SCT -->
- <NtryDtls>
- <TxDtls>
- <Refs>
- <EndToEndId>e2e-002</EndToEndId>
- </Refs>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NTRF+166</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <RltdPties>
- <Dbtr>
- <Nm>Debtor One</Nm>
- </Dbtr>
- <DbtrAcct>
- <Id>
- <IBAN>DE52123456789473323175</IBAN>
- </Id>
- </DbtrAcct>
- <Cdtr>
- <Nm>Creditor One</Nm>
- </Cdtr>
- </RltdPties>
- <RmtInf>
- <Ustrd>unstructured </Ustrd>
- <Ustrd>info </Ustrd>
- <Ustrd>across </Ustrd>
- <Ustrd>lines</Ustrd>
- </RmtInf>
- </TxDtls>
- </NtryDtls>
- </Ntry>
-
- <!--
- Credit due to a return resulting from a batch payment initiation
where only one payment failed.
- This data was obtained by doing a transaction on a GLS Bank
account, but we've replaced
- the account's IBAN with a random one.
- Note how the original creditor and debtor are preserved and not
flipped.
- Unfortunately the original payment didn't have an end-to-end ID,
so it would be harder
- to correlate this message to the original payment initiation -->
- <Ntry>
- <Amt Ccy="EUR">1.12</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <Sts>BOOK</Sts>
- <BookgDt>
- <Dt>2020-06-30</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2020-06-30</Dt>
- </ValDt>
- <AcctSvcrRef>2020063011423362000</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>ICDT</Cd>
- <SubFmlyCd>RRTN</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NRTI+159+00931</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <NtryDtls>
- <TxDtls>
- <Refs>
- <EndToEndId>NOTPROVIDED</EndToEndId>
- </Refs>
- <AmtDtls>
- <TxAmt>
- <Amt Ccy="EUR">1.12</Amt>
- </TxAmt>
- </AmtDtls>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>ICDT</Cd>
- <SubFmlyCd>RRTN</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NRTI+159+00931</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <RltdPties>
- <Dbtr>
- <Nm>Account Owner</Nm>
- </Dbtr>
- <DbtrAcct>
- <Id>
- <IBAN>DE54123456784713474163</IBAN>
- </Id>
- </DbtrAcct>
- <Cdtr>
- <Nm>Nonexistent Creditor</Nm>
- </Cdtr>
- <CdtrAcct>
- <Id>
- <IBAN>DE24500105177398216438</IBAN>
- </Id>
- </CdtrAcct>
- </RltdPties>
- <RmtInf>
- <Ustrd>Retoure SEPA Ueberweisung vom 29.06.2020,
Rueckgabegrund: AC01 IBAN fehlerhaft und ungültig SVWZ: RETURN, Sammelposten
Nummer Zwei IBAN: DE2</Ustrd>
- <Ustrd>4500105177398216438 BIC: INGDDEFFXXX</Ustrd>
- </RmtInf>
- <RtrInf>
- <OrgnlBkTxCd>
- <Prtry>
- <Cd>116</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </OrgnlBkTxCd>
- <Orgtr>
- <Id>
- <OrgId>
- <BICOrBEI>GENODEM1GLS</BICOrBEI>
- </OrgId>
- </Id>
- </Orgtr>
- <Rsn>
- <Cd>AC01</Cd>
- </Rsn>
- <AddtlInf>IBAN fehlerhaft und ungültig</AddtlInf>
- </RtrInf>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>Retouren</AddtlNtryInf>
- </Ntry>
-
- <!-- Credit due to incoming USD transfer -->
- <Ntry>
- <Amt Ccy="EUR">1000</Amt>
- <CdtDbtInd>CRDT</CdtDbtInd>
- <Sts>BOOK</Sts>
- <BookgDt>
- <Dt>2020-07-03</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2020-07-04</Dt>
- </ValDt>
- <AcctSvcrRef>acctsvcrref-002</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>XBCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NTRF+202</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <NtryDtls>
- <TxDtls>
- <AmtDtls>
- <InstdAmt>
- <Amt Ccy="USD">1500</Amt>
- </InstdAmt>
- <TxAmt>
- <Amt Ccy="EUR">1000</Amt>
- </TxAmt>
- <CntrValAmt>
- <Amt Ccy="EUR">1250.0</Amt>
- <CcyXchg>
- <SrcCcy>USD</SrcCcy>
- <TrgtCcy>EUR</TrgtCcy>
- <XchgRate>1.20</XchgRate>
- </CcyXchg>
- </CntrValAmt>
- </AmtDtls>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>RCDT</Cd>
- <SubFmlyCd>XBCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>NTRF+202</Cd>
- <Issr>DK</Issr>
- </Prtry>
- </BkTxCd>
- <Chrgs>
- <Amt Ccy="EUR">250.00</Amt>
- </Chrgs>
- <RltdPties>
- <Dbtr>
- <Nm>Mr USA</Nm>
- <PstlAdr>
- <Ctry>US</Ctry>
- <AdrLine>42 Some Street</AdrLine>
- <AdrLine>4242 Somewhere</AdrLine>
- </PstlAdr>
- </Dbtr>
- <DbtrAcct>
- <Id>
- <Othr>
- <Id>9876543</Id>
- </Othr>
- </Id>
- </DbtrAcct>
- </RltdPties>
- <RltdAgts>
- <DbtrAgt>
- <FinInstnId>
- <BIC>BANKUSNY</BIC>
- </FinInstnId>
- </DbtrAgt>
- </RltdAgts>
- <RmtInf>
- <Ustrd>Invoice No. 4242</Ustrd>
- </RmtInf>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>AZV-UEBERWEISUNGSGUTSCHRIFT</AddtlNtryInf>
- </Ntry>
-
- <Ntry>
- <Amt Ccy="EUR">48.42</Amt>
- <CdtDbtInd>DBIT</CdtDbtInd>
- <Sts>BOOK</Sts>
- <BookgDt>
- <Dt>2020-07-07</Dt>
- </BookgDt>
- <ValDt>
- <Dt>2020-07-07</Dt>
- </ValDt>
- <AcctSvcrRef>acctsvcrref-005</AcctSvcrRef>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>ICDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <AmtDtls>
- <TxAmt>
- <Amt Ccy="CHF">46.3</Amt>
- </TxAmt>
- </AmtDtls>
- <NtryDtls>
- <Btch>
- <MsgId>UXC20070700006</MsgId>
- <PmtInfId>UXC20070700006PI00001</PmtInfId>
- <NbOfTxs>2</NbOfTxs>
- <TtlAmt Ccy="EUR">46.3</TtlAmt>
- <CdtDbtInd>DBIT</CdtDbtInd>
- </Btch>
- <TxDtls>
- <AmtDtls>
- <TxAmt>
- <Amt Ccy="EUR">23.1</Amt>
- </TxAmt>
- </AmtDtls>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>ICDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- </BkTxCd>
- <RltdPties>
- <Cdtr>
- <Nm>Zahlungsempfaenger 23, ZA 5, DE</Nm>
- <PstlAdr>
- <Ctry>DE</Ctry>
- <AdrLine>DE Adresszeile 1</AdrLine>
- <AdrLine>DE Adresszeile 2</AdrLine>
- </PstlAdr>
- </Cdtr>
- <CdtrAcct>
- <Id>
- <IBAN>DE32733516350012345678</IBAN>
- </Id>
- </CdtrAcct>
- </RltdPties>
- <RltdAgts>
- <CdtrAgt>
- <FinInstnId>
- <BIC>BYLADEM1ALR</BIC>
- </FinInstnId>
- </CdtrAgt>
- </RltdAgts>
- </TxDtls>
- <TxDtls>
- <Refs>
- <MsgId>asdfasdf</MsgId>
- <AcctSvcrRef>5j3k453k45</AcctSvcrRef>
- <PmtInfId>6j564l56</PmtInfId>
- <InstrId>6jl5lj65afasdf</InstrId>
- <EndToEndId>jh45k34h5l</EndToEndId>
- </Refs>
- <AmtDtls>
- <TxAmt>
- <Amt Ccy="EUR">23.2</Amt>
- </TxAmt>
- </AmtDtls>
- <BkTxCd>
- <Domn>
- <Cd>PMNT</Cd>
- <Fmly>
- <Cd>ICDT</Cd>
- <SubFmlyCd>ESCT</SubFmlyCd>
- </Fmly>
- </Domn>
- <Prtry>
- <Cd>K25</Cd>
- </Prtry>
- </BkTxCd>
- <RltdPties>
- <Cdtr>
- <Nm>Zahlungsempfaenger 23, ZA 5, AT</Nm>
- <PstlAdr>
- <Ctry>AT</Ctry>
- <AdrLine>AT Adresszeile 1</AdrLine>
- <AdrLine>AT Adresszeile 2</AdrLine>
- </PstlAdr>
- </Cdtr>
- <CdtrAcct>
- <Id>
- <IBAN>AT071100000012345678</IBAN>
- </Id>
- </CdtrAcct>
- </RltdPties>
- <RltdAgts>
- <CdtrAgt>
- <FinInstnId>
- <BIC>BKAUATWW</BIC>
- </FinInstnId>
- </CdtrAgt>
- </RltdAgts>
- </TxDtls>
- </NtryDtls>
- <AddtlNtryInf>Order</AddtlNtryInf>
- </Ntry>
-
- </Stmt>
- </BkToCstmrStmt>
-</Document>
diff --git a/nexus/src/test/resources/logback-test.xml
b/nexus/src/test/resources/logback-test.xml
deleted file mode 100644
index cbd96f5f..00000000
--- a/nexus/src/test/resources/logback-test.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<!-- configuration scan="true" -->
-<configuration>
- <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
- <target>System.err</target>
- <encoder>
- <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -
%msg%n</pattern>
- </encoder>
- </appender>
-
- <logger name="tech.libeufin.nexus" level="ALL" additivity="false">
- <appender-ref ref="STDERR" />
- </logger>
-
- <logger name="tech.libeufin.sandbox" level="ALL" additivity="false">
- <appender-ref ref="STDERR" />
- </logger>
-
- <logger name="io.netty" level="WARN"/>
- <logger name="ktor" level="WARN"/>
- <logger name="Exposed" level="WARN"/>
- <logger name="tech.libeufin.util" level="DEBUG"/>
- <logger name="ch.qos" level="WARN"/>
-
- <root level="WARN">
- <appender-ref ref="STDERR"/>
- </root>
-
-</configuration>
diff --git a/util/build.gradle b/util/build.gradle
index 9c4642b7..38a8ab61 100644
--- a/util/build.gradle
+++ b/util/build.gradle
@@ -58,9 +58,6 @@ dependencies {
testImplementation group: 'junit', name: 'junit', version: '4.13.2'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21'
testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21'
- testImplementation project(":bank")
- testImplementation project(":nexus")
-
}
application {
diff --git a/util/src/main/kotlin/Config.kt b/util/src/main/kotlin/Config.kt
index 17ec8a56..ac1c636e 100644
--- a/util/src/main/kotlin/Config.kt
+++ b/util/src/main/kotlin/Config.kt
@@ -64,10 +64,7 @@ fun getValueFromEnv(varName: String): String? {
return ret
}
-/**
- * Gets the connection string in Postgres format and
- * returns the JDBC version of it.
- */
+// Gets the DB connection string from env, or fail if not found.
fun getDbConnFromEnv(varName: String): String {
val dbConnStr = System.getenv(varName)
if (dbConnStr.isNullOrBlank() or dbConnStr.isNullOrEmpty()) {
diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt
index d61fa2d2..786e2e1e 100644
--- a/util/src/main/kotlin/DB.kt
+++ b/util/src/main/kotlin/DB.kt
@@ -255,12 +255,16 @@ fun connectWithSchema(jdbcConn: String, schemaName:
String? = null) {
}
}
+// Prepends "jdbc:" to the Postgres database connection string.
+fun getJdbcConnectionFromPg(pgConn: String): String {
+ return "jdbc:$pgConn"
+}
/**
* This function converts a postgresql://-URI to a JDBC one.
* It is only needed because JDBC strings based on Unix domain
* sockets need individual intervention.
*/
-fun getJdbcConnectionFromPg(pgConn: String): String {
+fun _getJdbcConnectionFromPg(pgConn: String): String {
if (!pgConn.startsWith("postgresql://") &&
!pgConn.startsWith("postgres://")) {
logger.info("Not a Postgres connection string: $pgConn")
throw internalServerError("Not a Postgres connection string: $pgConn")
diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt
index c41c6aa2..0e4b05a9 100644
--- a/util/src/main/kotlin/HTTP.kt
+++ b/util/src/main/kotlin/HTTP.kt
@@ -36,7 +36,7 @@ fun badGateway(msg: String): UtilError {
/**
* Returns the token (including the 'secret-token:' prefix)
- * from a Authorization header. Throws exception on malformations
+ * from an Authorization header. Throws exception on malformations
* Note, the token gets URL-decoded before being returned.
*/
fun extractToken(authHeader: String): String {
@@ -153,20 +153,14 @@ fun expectAdmin(username: String?) {
}
fun getHTTPBasicAuthCredentials(request:
io.ktor.server.request.ApplicationRequest): Pair<String, String> {
- val authHeader = getAuthorizationHeader(request)
+ val authHeader = getAuthorizationRawHeader(request)
return extractUserAndPassword(authHeader)
}
-/**
- * Extracts the Authorization:-header line and throws error if not found.
- */
-fun getAuthorizationHeader(request: ApplicationRequest): String {
+// Extracts the Authorization:-header line and throws error if not found.
+fun getAuthorizationRawHeader(request: ApplicationRequest): String {
val authorization = request.headers["Authorization"]
- // logger.debug("Found Authorization header: $authorization")
- return authorization ?: throw UtilError(
- HttpStatusCode.Unauthorized, "Authorization header not found",
- LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED
- )
+ return authorization ?: throw badRequest("Authorization header not found")
}
// Builds the Authorization:-header value, given the credentials.
@@ -176,6 +170,24 @@ fun buildBasicAuthLine(username: String, password:
String): String {
val enc = bytesToBase64(cred.toByteArray(Charsets.UTF_8))
return ret+enc
}
+
+/**
+ * Holds the details contained in an Authorization header.
+ * The content is held as it was found in the header and supposed
+ * to be processed according to the scheme.
+ */
+data class AuthorizationDetails(
+ val scheme: String,
+ val content: String
+)
+// Returns the authorization scheme mentioned in the Auth header.
+fun getAuthorizationDetails(authorizationHeader: String): AuthorizationDetails
{
+ val split = authorizationHeader.split(" ")
+ if (split.isEmpty()) throw badRequest("malformed Authorization header:
contains no space")
+ if (split.size != 2) throw badRequest("malformed Authorization header:
contains more than one space")
+ return AuthorizationDetails(scheme = split[0], content = split[1])
+}
+
/**
* 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
diff --git a/util/src/main/kotlin/iban.kt b/util/src/main/kotlin/iban.kt
index b2de9351..76e2fcc5 100644
--- a/util/src/main/kotlin/iban.kt
+++ b/util/src/main/kotlin/iban.kt
@@ -4,10 +4,10 @@ import java.math.BigInteger
fun getIban(): String {
val ccNoCheck = "131400" // DE00
- val bban = (0..3).map {
+ val bban = (0..10).map {
(0..9).random()
}.joinToString("") // 4 digits BBAN.
- var checkDigits =
"98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString()
+ var checkDigits: String =
"98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString()
if (checkDigits.length == 1) {
checkDigits = "0${checkDigits}"
}
diff --git a/util/src/main/kotlin/startServer.kt
b/util/src/main/kotlin/startServer.kt
index cabd91a7..3d6e9a15 100644
--- a/util/src/main/kotlin/startServer.kt
+++ b/util/src/main/kotlin/startServer.kt
@@ -30,6 +30,8 @@ private fun serverMain(options: StartServerOptions, app:
Application.() -> Unit)
}
module(app)
},
+ // Maybe remove this? Was introduced
+ // to debug concurrency issues..
configure = {
connectionGroupSize = 1
workerGroupSize = 1
diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt
index 1dd37df9..ff75158d 100644
--- a/util/src/main/kotlin/time.kt
+++ b/util/src/main/kotlin/time.kt
@@ -30,6 +30,8 @@ fun setClock(rel: Duration) {
fun getNow(): ZonedDateTime {
return ZonedDateTime.now(ZoneId.systemDefault())
}
+
+fun ZonedDateTime.toMicro(): Long = this.nano / 1000L
fun getNowMillis(): Long = getNow().toInstant().toEpochMilli()
fun getSystemTimeNow(): ZonedDateTime {
diff --git a/util/src/test/kotlin/StartServerTest.kt
b/util/src/test/kotlin/StartServerTest.kt
deleted file mode 100644
index ef615179..00000000
--- a/util/src/test/kotlin/StartServerTest.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.nexus.server.nexusApp
-import tech.libeufin.sandbox.sandboxApp
-import tech.libeufin.util.StartServerOptions
-import tech.libeufin.util.startServerWithIPv4Fallback
-
-@Ignore
-class StartServerTest {
- @Test
- fun sandboxStart() {
- startServerWithIPv4Fallback(
- options = StartServerOptions(
- ipv4OnlyOpt = false,
- localhostOnlyOpt = false,
- portOpt = 5000
- ),
- app = sandboxApp
- )
- }
- @Test
- fun nexusStart() {
- startServerWithIPv4Fallback(
- options = StartServerOptions(
- ipv4OnlyOpt = false,
- localhostOnlyOpt = true,
- portOpt = 5000
- ),
- app = nexusApp
- )
- }
-}
\ No newline at end of file
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [libeufin] branch master updated: Bank refactoring.,
gnunet <=