Compare commits

...

No commits in common. "5e7d8804992f671c4d7232970874739bfe9fd96b" and "345f84c0b201f6538db52ca10414e6723473ab20" have entirely different histories.

567 changed files with 4981 additions and 29368 deletions

19
.gitignore vendored Executable file → Normal file
View File

@ -1,12 +1,9 @@
.idea/
*.iml
build/
**/build
out/
**/out
/captures
**/kotlin-js-store
**/target
.gradle
.kotlin
@ -18,17 +15,3 @@ out/
/data/
**/*.log
docs/received_messages
keys.gradle
# Xcode
xcuserdata/
*.xcworkspace
*.ipa
*.dSYM.zip
*.dSYM

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "gradle/scripts"]
path = gradle/scripts
url = git@github.com:dankito/GradleScripts.git

View File

@ -0,0 +1,108 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
plugins {
kotlin("multiplatform")
}
kotlin {
jvmToolchain(8)
jvm {
withJava()
testRuns["test"].executionTask.configure {
useJUnitPlatform()
testLogging {
showExceptions = true
showStandardStreams = true
events("passed", "skipped", "failed")
// exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
}
js {
moduleName = "banking-client"
binaries.executable()
browser {
testTask {
useKarma {
useChromeHeadless()
useFirefoxHeadless()
}
}
}
nodejs {
testTask {
useMocha {
timeout = "20s" // Mocha times out after 2 s, which is too short for bufferExceeded() test
}
}
}
}
wasmJs()
linuxX64()
mingwX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
watchosArm64()
watchosSimulatorArm64()
tvosArm64()
tvosSimulatorArm64()
applyDefaultHierarchyTemplate()
val coroutinesVersion: String by project
sourceSets {
commonMain {
dependencies {
api(project(":BankingClientModel"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
}
}
jvmMain {
dependencies {
}
}
jvmTest { }
jsMain {
dependencies {
}
}
jsTest { }
nativeMain { }
nativeTest { }
}
}
ext["customArtifactId"] = "banking-client"
apply(from = "../gradle/scripts/publish-codinux.gradle.kts")

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
interface BankingClient {
suspend fun getAccountDataAsync(bankCode: String, loginName: String, password: String) =
getAccountDataAsync(GetAccountDataRequest(bankCode, loginName, password))
suspend fun getAccountDataAsync(request: GetAccountDataRequest): Response<GetAccountDataResponse>
}

View File

@ -0,0 +1,10 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.client.model.tan.TanChallenge
interface BankingClientCallback {
fun enterTan(tanChallenge: TanChallenge, callback: (EnterTanResult) -> Unit)
}

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
interface BankingClientForCustomer {
// for languages not supporting default parameters (Java, Swift, JS, ...)
suspend fun getAccountDataAsync() = getAccountDataAsync(GetAccountDataOptions())
suspend fun getAccountDataAsync(options: GetAccountDataOptions): Response<GetAccountDataResponse>
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.AccountCredentials
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
abstract class BankingClientForCustomerBase(
protected val credentials: AccountCredentials,
protected val client: BankingClient
) : BankingClientForCustomer {
override suspend fun getAccountDataAsync(options: GetAccountDataOptions) =
client.getAccountDataAsync(GetAccountDataRequest(credentials, options))
}

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
interface BlockingBankingClient {
suspend fun getAccountData(bankCode: String, loginName: String, password: String) =
getAccountData(GetAccountDataRequest(bankCode, loginName, password))
fun getAccountData(request: GetAccountDataRequest): Response<GetAccountDataResponse>
}

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
interface BlockingBankingClientForCustomer {
// for languages not supporting default parameters (Java, Swift, JS, ...)
fun getAccountData() = getAccountData(GetAccountDataOptions())
fun getAccountData(options: GetAccountDataOptions): Response<GetAccountDataResponse>
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.AccountCredentials
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
abstract class BlockingBankingClientForCustomerBase(
protected val credentials: AccountCredentials,
protected val client: BlockingBankingClient
) : BlockingBankingClientForCustomer {
override fun getAccountData(options: GetAccountDataOptions) =
client.getAccountData(GetAccountDataRequest(credentials, options))
}

View File

@ -0,0 +1,18 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.client.model.tan.TanChallenge
open class SimpleBankingClientCallback(
protected val enterTan: ((tanChallenge: TanChallenge, callback: (EnterTanResult) -> Unit) -> Unit)? = null
) : BankingClientCallback {
override fun enterTan(tanChallenge: TanChallenge, callback: (EnterTanResult) -> Unit) {
if (enterTan != null) {
enterTan.invoke(tanChallenge, callback)
} else {
callback(EnterTanResult(null))
}
}
}

View File

@ -0,0 +1,21 @@
package net.codinux.banking.client
import kotlinx.coroutines.runBlocking
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
fun BankingClient.getAccountData(bankCode: String, loginName: String, password: String) = runBlocking {
this@getAccountData.getAccountDataAsync(bankCode, loginName, password)
}
fun BankingClient.getAccountData(request: GetAccountDataRequest) = runBlocking {
this@getAccountData.getAccountDataAsync(request)
}
fun BankingClientForCustomer.getAccountData() = runBlocking {
this@getAccountData.getAccountDataAsync()
}
fun BankingClientForCustomer.getAccountData(options: GetAccountDataOptions) = runBlocking {
this@getAccountData.getAccountDataAsync(options)
}

View File

@ -0,0 +1,21 @@
package net.codinux.banking.client
import kotlinx.coroutines.runBlocking
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
fun BankingClient.getAccountData(bankCode: String, loginName: String, password: String) = runBlocking {
this@getAccountData.getAccountDataAsync(bankCode, loginName, password)
}
fun BankingClient.getAccountData(request: GetAccountDataRequest) = runBlocking {
this@getAccountData.getAccountDataAsync(request)
}
fun BankingClientForCustomer.getAccountData() = runBlocking {
this@getAccountData.getAccountDataAsync()
}
fun BankingClientForCustomer.getAccountData(options: GetAccountDataOptions) = runBlocking {
this@getAccountData.getAccountDataAsync(options)
}

View File

@ -0,0 +1,122 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
plugins {
kotlin("multiplatform")
kotlin("plugin.noarg")
// kotlin("plugin.serialization")
}
kotlin {
jvmToolchain(8)
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
// suppresses compiler warning: [EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING] 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta.
freeCompilerArgs.add("-Xexpect-actual-classes")
}
jvm {
withJava()
testRuns["test"].executionTask.configure {
useJUnitPlatform()
testLogging {
showExceptions = true
showStandardStreams = true
events("passed", "skipped", "failed")
// exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
}
js {
moduleName = "banking-client-model"
binaries.executable()
browser {
testTask {
useKarma {
useChromeHeadless()
useFirefoxHeadless()
}
}
}
nodejs {
testTask {
useMocha {
timeout = "20s" // Mocha times out after 2 s, which is too short for bufferExceeded() test
}
}
}
}
wasmJs()
linuxX64()
mingwX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
watchosArm64()
watchosSimulatorArm64()
tvosArm64()
tvosSimulatorArm64()
applyDefaultHierarchyTemplate()
val kotlinxDateTimeVersion: String by project
val jsJodaTimeZoneVersion: String by project
sourceSets {
commonMain {
dependencies {
api("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
}
}
jvmMain {
dependencies {
compileOnly("com.fasterxml.jackson.core:jackson-annotations:2.15.0")
}
}
jvmTest { }
jsMain {
dependencies {
api(npm("@js-joda/timezone", jsJodaTimeZoneVersion))
}
}
jsTest { }
nativeMain { }
nativeTest { }
}
}
noArg {
annotation("net.codinux.accounting.common.config.NoArgConstructor")
}
ext["customArtifactId"] = "banking-client-model"
apply(from = "../gradle/scripts/publish-codinux.gradle.kts")

View File

@ -0,0 +1,12 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class AccountCredentials(
val bankCode: String,
val loginName: String,
val password: String
) {
override fun toString() = "$bankCode $loginName"
}

View File

@ -0,0 +1,65 @@
package net.codinux.banking.client.model
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class AccountTransaction(
val amount: Amount = Amount.Zero,
val currency: String,
val reference: String, // Alternative: purpose (or Remittance information)
/**
* Transaction date (Buchungstag) - der Tag, an dem ein Zahlungsvorgang in das System einer Bank eingegangen ist.
* Das bedeutet aber nicht automatisch, dass das Geld schon verfügbar ist. Dafür ist die Wertstellung entscheidend.
*/
val bookingDate: LocalDate,
/**
* Effective date (Wertstellung / Valutadatum) - der Tag an dem das Geld verfügbar ist. An diesem Tag wird die
* Kontobewegung wirksam.
*
* Buchung und Wertstellung erfolgen häufig am gleichen Tag, das muss aber nicht immer der Fall sein.
*/
val valueDate: LocalDate,
val otherPartyName: String? = null, // Alternatives: Parties involved, Transaction parties.single names: Beneficiary, Payee respectively Payer, Debtor
val otherPartyBankCode: String? = null,
val otherPartyAccountId: String? = null,
val bookingText: String? = null,
val information: String? = null,
val statementNumber: Int? = null,
val sequenceNumber: Int? = null,
val openingBalance: Amount? = null,
val closingBalance: Amount? = null,
val endToEndReference: String? = null,
val customerReference: String? = null,
val mandateReference: String? = null,
val creditorIdentifier: String? = null,
val originatorsIdentificationCode: String? = null,
val compensationAmount: String? = null,
val originalAmount: String? = null,
val sepaReference: String? = null,
val deviantOriginator: String? = null,
val deviantRecipient: String? = null,
val referenceWithNoSpecialType: String? = null,
val primaNotaNumber: String? = null,
val textKeySupplement: String? = null,
val currencyType: String? = null,
val bookingKey: String? = null,
val referenceForTheAccountOwner: String? = null,
val referenceOfTheAccountServicingInstitution: String? = null,
val supplementaryDetails: String? = null,
val transactionReferenceNumber: String? = null,
val relatedReferenceNumber: String? = null,
var userSetDisplayName: String? = null,
var notes: String? = null,
) {
override fun toString() = "${valueDate.dayOfMonth}.${valueDate.monthNumber}.${valueDate.year} ${amount.toString().padStart(4, ' ')} ${if (currency == "EUR") "€" else currency} ${otherPartyName ?: ""} - $reference"
}

View File

@ -0,0 +1,18 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
import kotlin.jvm.JvmInline
@JvmInline
@NoArgConstructor
value class Amount(val amount: String = "0") {
companion object {
val Zero = Amount("0")
fun fromString(amount: String): Amount = Amount(amount)
}
override fun toString() = amount
}

View File

@ -0,0 +1,38 @@
package net.codinux.banking.client.model
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class BankAccount(
val identifier: String,
var accountHolderName: String,
val type: BankAccountType = BankAccountType.Other,
val iban: String? = null,
val subAccountNumber: String? = null,
val productName: String? = null,
val currency: String = "EUR",
var accountLimit: String? = null,
val isAccountTypeSupportedByApplication: Boolean = true,
val features: Set<BankAccountFeatures> = emptySet(),
// var balance: BigDecimal = BigDecimal.ZERO,
var balance: Amount = Amount.Zero, // TODO: add a BigDecimal library
var retrievedTransactionsFrom: LocalDate? = null,
var retrievedTransactionsTo: LocalDate? = null,
var haveAllTransactionsBeenRetrieved: Boolean = false,
val countDaysForWhichTransactionsAreKept: Int? = null,
val bookedTransactions: MutableList<AccountTransaction> = mutableListOf(),
val unbookedTransactions: MutableList<UnbookedAccountTransaction> = mutableListOf(),
var userSetDisplayName: String? = null,
var displayIndex: Int = 0,
var hideAccount: Boolean = false,
var includeInAutomaticAccountsUpdate: Boolean = true
) {
override fun toString() = "$type $identifier $productName (IBAN: $iban)"
}

View File

@ -0,0 +1,8 @@
package net.codinux.banking.client.model
enum class BankAccountFeatures {
RetrieveTransactions,
RetrieveBalance,
TransferMoney,
InstantPayment
}

View File

@ -1,8 +1,6 @@
package net.dankito.banking.client.model
package net.codinux.banking.client.model
enum class BankAccountType {
CheckingAccount,
SavingsAccount,
@ -22,5 +20,4 @@ enum class BankAccountType {
InsuranceContract,
Other
}

View File

@ -0,0 +1,18 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
/**
* Contains only the basic info of a [BankAccount], just enough that a client application can display it to the user
* and the user knows exactly which [BankAccount] is meant / referred.
*/
@NoArgConstructor
open class BankAccountViewInfo(
val identifier: String,
val subAccountNumber: String? = null,
val type: BankAccountType = BankAccountType.Other,
val iban: String? = null,
val productName: String? = null,
) {
override fun toString() = "$type $productName $identifier"
}

View File

@ -0,0 +1,31 @@
package net.codinux.banking.client.model
enum class BankingGroup {
Sparkasse,
DKB,
OldenburgischeLandesbank,
VolksUndRaiffeisenbanken,
Sparda,
PSD,
GLS,
SonstigeGenossenschaftsbank,
DeutscheBank,
Postbank,
Commerzbank,
Comdirect,
Unicredit,
Targobank,
ING,
Santander,
Norisbank,
Degussa,
Oberbank,
Bundesbank,
KfW,
N26,
Consors
}

View File

@ -0,0 +1,34 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class CustomerAccount(
val bankCode: String,
var loginName: String,
/**
* User may decides to not save password .
*/
var password: String?,
val bankName: String,
val bic: String,
val customerName: String,
val userId: String = loginName,
val accounts: List<BankAccount> = emptyList(),
var bankingGroup: BankingGroup? = null,
var iconUrl: String? = null,
) {
var wrongCredentialsEntered: Boolean = false
var userSetDisplayName: String? = null
var displayIndex: Int = 0
override fun toString() = "$bankName $loginName, ${accounts.size} accounts"
}

View File

@ -0,0 +1,16 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
/**
* Contains only the basic info of a [CustomerAccount], just enough that a client application can display it to the user
* and the user knows exactly which [CustomerAccount] is meant / referred.
*/
@NoArgConstructor
open class CustomerAccountViewInfo(
val bankCode: String,
var loginName: String,
val bankName: String
) {
override fun toString() = "$bankCode $bankName $loginName"
}

View File

@ -0,0 +1,7 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class UnbookedAccountTransaction {
}

View File

@ -0,0 +1,9 @@
package net.codinux.banking.client.model.config
/**
* Annotation to be able to apply Jackson's @com.fasterxml.jackson.annotation.JsonIgnore in common module
*/
// match the target and retention settings of Jackson's JsonIgnore annotation
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
expect annotation class JsonIgnore()

View File

@ -0,0 +1,6 @@
package net.codinux.banking.client.model.config
/**
* Marker interface for Kotlin No-arg plugin so that No-arg plugin adds no-arg constructors to classes marked with this annotation.
*/
annotation class NoArgConstructor

View File

@ -0,0 +1,17 @@
package net.codinux.banking.client.model.options
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class GetAccountDataOptions(
val retrieveBalance: Boolean = true,
val retrieveTransactions: RetrieveTransactions = RetrieveTransactions.OfLast90Days,
val retrieveTransactionsFrom: LocalDate? = null,
val retrieveTransactionsTo: LocalDate? = null,
val abortIfTanIsRequired: Boolean = false
) {
override fun toString(): String {
return "retrieveBalance=$retrieveBalance, retrieveTransactions=$retrieveTransactions, abortIfTanIsRequired=$abortIfTanIsRequired"
}
}

View File

@ -0,0 +1,18 @@
package net.codinux.banking.client.model.options
enum class RetrieveTransactions {
No,
All,
/**
* Some banks support that according to PSD2 account transactions of last 90 days may be retrieved without
* a TAN (= no strong customer authorization needed). So try this options if you don't want to enter a TAN.
*/
OfLast90Days,
/**
* Retrieves account transactions in the boundaries of [GetAccountDataOptions.retrieveTransactionsFrom] to [GetAccountDataOptions.retrieveTransactionsTo].
*/
AccordingToRetrieveFromAndTo
}

View File

@ -0,0 +1,10 @@
package net.codinux.banking.client.model.request
import net.codinux.banking.client.model.tan.EnterTanResult
class EnterTanResultDto(
val tanRequestId: String,
enteredTan: String?
) : EnterTanResult(enteredTan) {
override fun toString() = "$tanRequestId, entered Tan: $enteredTan"
}

View File

@ -0,0 +1,15 @@
package net.codinux.banking.client.model.request
import net.codinux.banking.client.model.AccountCredentials
import net.codinux.banking.client.model.config.NoArgConstructor
import net.codinux.banking.client.model.options.GetAccountDataOptions
@NoArgConstructor
open class GetAccountDataRequest(bankCode: String, loginName: String, password: String, val options: GetAccountDataOptions? = null)
: AccountCredentials(bankCode, loginName, password) {
constructor(credentials: AccountCredentials, options: GetAccountDataOptions? = null)
: this(credentials.bankCode, credentials.loginName, credentials.password, options)
override fun toString() = "${super.toString()}: $options"
}

View File

@ -0,0 +1,19 @@
package net.codinux.banking.client.model.response
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class Error(
val type: ErrorType,
/**
* A banking client internal error like an error occurred during response parsing.
*/
val internalError: String? = null,
/**
* Error messages as received from bank
*/
val errorMessagesFromBank: List<String> = emptyList(),
) {
override fun toString() = "$type: ${internalError ?: errorMessagesFromBank.joinToString()}"
}

View File

@ -0,0 +1,35 @@
package net.codinux.banking.client.model.response
enum class ErrorType {
BankDoesNotSupportFinTs3,
NetworkError,
InternalError,
BankReturnedError,
WrongCredentials,
AccountLocked,
JobNotSupported,
UserCancelledAction,
TanRequiredButShouldAbortIfRequiresTan,
TanRequestIdNotFound,
NoneOfTheAccountsSupportsRetrievingData,
DidNotRetrieveAllAccountData,
CanNotDetermineBicForIban,
NoAccountSupportsMoneyTransfer,
MoreThanOneAccountSupportsMoneyTransfer,
UnknownError
}

View File

@ -0,0 +1,20 @@
package net.codinux.banking.client.model.response
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.CustomerAccount
import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor
@Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor
open class GetAccountDataResponse(
val customer: CustomerAccount
) {
@get:JsonIgnore
val bookedTransactions: List<AccountTransaction>
get() = customer.accounts.flatMap { it.bookedTransactions }.sortedByDescending { it.valueDate }
override fun toString() = customer.toString()
}

View File

@ -0,0 +1,34 @@
package net.codinux.banking.client.model.response
import net.codinux.banking.client.model.config.NoArgConstructor
// TODO: may differentiate between ClientResponse, which is either Success or Error, and RestResponse, which can be Success, Error and TanRequired
@NoArgConstructor
open class Response<T> protected constructor(
val type: ResponseType,
val data: T? = null,
val error: Error? = null,
val tanRequired: TanRequired? = null
) {
companion object {
fun <T> success(data: T): Response<T> =
Response(ResponseType.Success, data)
fun <T> error(errorType: ErrorType, internalError: String? = null, errorMessagesFromBank: List<String> = emptyList()): Response<T> =
Response(ResponseType.Error, null, Error(errorType, internalError, errorMessagesFromBank))
fun <T> tanRequired(tanRequired: TanRequired): Response<T> =
Response(ResponseType.TanRequired, null, null, tanRequired)
fun <T> bankReturnedError(errorMessagesFromBank: List<String>): Response<T> =
Response.error(ErrorType.BankReturnedError, null, errorMessagesFromBank)
}
override fun toString() = when (type) {
ResponseType.Success -> "Success: $data"
ResponseType.Error -> "Error: $error"
ResponseType.TanRequired -> "TanRequired: $tanRequired"
}
}

View File

@ -0,0 +1,9 @@
package net.codinux.banking.client.model.response
enum class ResponseType {
Success,
Error,
TanRequired
}

View File

@ -0,0 +1,12 @@
package net.codinux.banking.client.model.response
import net.codinux.banking.client.model.config.NoArgConstructor
import net.codinux.banking.client.model.tan.TanChallenge
@NoArgConstructor
open class TanRequired (
val tanRequestId: String,
val tanChallenge: TanChallenge
) {
override fun toString() = "$tanChallenge"
}

View File

@ -1,4 +1,4 @@
package net.dankito.banking.fints.model
package net.codinux.banking.client.model.tan
enum class ActionRequiringTan {
GetAnonymousBankInfo,

View File

@ -0,0 +1,9 @@
package net.codinux.banking.client.model.tan
enum class AllowedTanFormat {
Numeric,
Alphanumeric,
TanIsEnteredOnOtherDevice
}

View File

@ -0,0 +1,11 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class EnterTanResult(
val enteredTan: String?,
// val changeTanMethodTo: TanMethod? = null,
// val changeTanMediumTo: TanMedium? = null,
// val changeTanMediumResultCallback: ((BankingClientResponse) -> Unit)? = null
)

View File

@ -1,12 +1,17 @@
package net.dankito.banking.fints.tan
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor
@Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor
open class FlickerCode(
val challengeHHD_UC: String,
val parsedDataSet: String,
val decodingError: Exception? = null
val decodingError: String? = null
) {
@get:JsonIgnore
val decodingSuccessful: Boolean
get() = decodingError == null

View File

@ -0,0 +1,10 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class MobilePhoneTanMedium(
val phoneNumber: String?
) {
override fun toString() = phoneNumber ?: "No phone number"
}

View File

@ -0,0 +1,28 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.BankAccountViewInfo
import net.codinux.banking.client.model.CustomerAccountViewInfo
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class TanChallenge(
val type: TanChallengeType,
val forAction: ActionRequiringTan,
val messageToShowToUser: String,
val tanMethod: TanMethod,
val tanImage: TanImage? = null,
val flickerCode: FlickerCode? = null,
val customer: CustomerAccountViewInfo,
val account: BankAccountViewInfo? = null
// TODO: add availableTanMethods, selectedTanMedium, availableTanMedia
) {
override fun toString(): String {
return "$tanMethod $forAction: $messageToShowToUser" + when (type) {
TanChallengeType.EnterTan -> ""
TanChallengeType.Image -> ", Image: $tanImage"
TanChallengeType.Flickercode -> ", FlickerCode: $flickerCode"
}
}
}

View File

@ -0,0 +1,9 @@
package net.codinux.banking.client.model.tan
enum class TanChallengeType {
Image,
Flickercode,
EnterTan
}

View File

@ -0,0 +1,10 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class TanGeneratorTanMedium(
val cardNumber: String
) {
override fun toString() = cardNumber
}

View File

@ -0,0 +1,27 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor
@Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor
open class TanImage(
val mimeType: String,
val imageBytesBase64: String,
val decodingError: String? = null
) {
@get:JsonIgnore
val decodingSuccessful: Boolean
get() = decodingError == null
override fun toString(): String {
if (decodingSuccessful == false) {
return "Decoding error: $decodingError"
}
return mimeType
}
}

View File

@ -0,0 +1,20 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class TanMedium(
val type: TanMediumType,
val displayName: String,
val status: TanMediumStatus,
/**
* Only set if [type] is [TanMediumType.TanGenerator].
*/
val tanGenerator: TanGeneratorTanMedium? = null,
/**
* Only set if [type] is [TanMediumType.MobilePhone].
*/
val mobilePhone: MobilePhoneTanMedium? = null
) {
override fun toString() = "$displayName $status"
}

View File

@ -0,0 +1,7 @@
package net.codinux.banking.client.model.tan
enum class TanMediumStatus {
Used,
Available
}

View File

@ -0,0 +1,18 @@
package net.codinux.banking.client.model.tan
enum class TanMediumType {
/**
* All other TAN media, like AppTan.
*/
Generic,
/**
* If I'm not wrong MobilePhone is only used for SmsTan.
*/
MobilePhone,
/**
* Mostly used for chipTan.
*/
TanGenerator
}

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client.model.tan
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class TanMethod(
val displayName: String,
val type: TanMethodType,
val identifier: String,
val maxTanInputLength: Int? = null,
val allowedTanFormat: AllowedTanFormat = AllowedTanFormat.Alphanumeric
) {
override fun toString() = "$displayName ($type, ${identifier})"
}

View File

@ -1,8 +1,6 @@
package net.dankito.banking.fints.model
package net.codinux.banking.client.model.tan
enum class TanMethodType {
EnterTan,
ChipTanManuell,
@ -22,5 +20,4 @@ enum class TanMethodType {
photoTan,
QrCode
}

View File

@ -0,0 +1,7 @@
package net.codinux.banking.client.model
@JsModule("@js-joda/timezone")
@JsNonModule
external object JsJodaTimeZoneModule
private val jsJodaTz = JsJodaTimeZoneModule

View File

@ -0,0 +1,3 @@
package net.codinux.banking.client.model.config
actual annotation class JsonIgnore

View File

@ -0,0 +1,5 @@
package net.codinux.banking.client.model.config
import com.fasterxml.jackson.annotation.JsonIgnore
actual typealias JsonIgnore = JsonIgnore

View File

@ -0,0 +1,3 @@
package net.codinux.banking.client.model.config
actual annotation class JsonIgnore

View File

@ -0,0 +1,3 @@
package net.codinux.banking.client.model.config
actual annotation class JsonIgnore

View File

@ -0,0 +1,139 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
plugins {
kotlin("multiplatform")
id("maven-publish")
}
repositories {
mavenLocal()
}
kotlin {
jvmToolchain(8)
jvm {
withJava()
testRuns["test"].executionTask.configure {
useJUnitPlatform()
testLogging {
showExceptions = true
showStandardStreams = true
events("passed", "skipped", "failed")
// exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
}
js {
moduleName = "fints4k-banking-client"
binaries.executable()
browser {
testTask {
useKarma {
useChromeHeadless()
useFirefoxHeadless()
}
}
}
nodejs {
testTask {
useMocha {
timeout = "20s" // Mocha times out after 2 s, which is too short for bufferExceeded() test
}
}
}
}
// wasmJs()
linuxX64()
mingwX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
watchosArm64()
watchosSimulatorArm64()
tvosArm64()
tvosSimulatorArm64()
applyDefaultHierarchyTemplate()
val coroutinesVersion: String by project
val kotlinxDateTimeVersion: String by project
sourceSets {
commonMain {
dependencies {
api(project(":BankingClient"))
api("net.codinux.banking:fints4k:1.0.0-Alpha-11")
api("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
}
}
jvmMain {
dependencies {
}
}
jvmTest {
dependencies {
implementation(kotlin("test"))
}
}
jsMain {
dependencies {
}
}
jsTest { }
nativeMain { }
nativeTest { }
}
}
//ext["customArtifactId"] = "fints4k-banking-client"
//
//apply(from = "../gradle/scripts/publish-codinux.gradle.kts")
publishing {
repositories {
maven {
name = "codinux"
url = uri("https://maven.dankito.net/api/packages/codinux/maven")
credentials(PasswordCredentials::class.java) {
username = project.property("codinuxRegistryWriterUsername") as String
password = project.property("codinuxRegistryWriterPassword") as String
}
}
}
}

View File

@ -0,0 +1,32 @@
package net.codinux.banking.client.fints4k
import net.codinux.banking.client.BankingClientCallback
import net.dankito.banking.fints.callback.FinTsClientCallback
import net.dankito.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.dankito.banking.fints.model.BankData
import net.dankito.banking.fints.model.EnterTanGeneratorAtcResult
import net.dankito.banking.fints.model.TanMethod
open class BridgeFintTsToBankingClientCallback(
protected val bankingClientCallback: BankingClientCallback,
protected val mapper: FinTs4kMapper
) : FinTsClientCallback {
override suspend fun askUserForTanMethod(supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?): TanMethod? {
return suggestedTanMethod
}
override suspend fun enterTan(tanChallenge: net.dankito.banking.fints.model.TanChallenge) {
bankingClientCallback.enterTan(mapper.mapTanChallenge(tanChallenge)) { enterTanResult ->
if (enterTanResult.enteredTan != null) {
tanChallenge.userEnteredTan(enterTanResult.enteredTan!!)
} else {
tanChallenge.userDidNotEnterTan()
}
}
}
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {
return EnterTanGeneratorAtcResult.userDidNotEnterAtc()
}
}

View File

@ -0,0 +1,26 @@
package net.codinux.banking.client.fints4k
import net.codinux.banking.client.BankingClient
import net.codinux.banking.client.BankingClientCallback
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
import net.dankito.banking.fints.FinTsClient
open class FinTs4kBankingClient(
callback: BankingClientCallback
) : BankingClient {
private val mapper = FinTs4kMapper()
private val client = FinTsClient(BridgeFintTsToBankingClientCallback(callback, mapper))
override suspend fun getAccountDataAsync(request: GetAccountDataRequest): Response<GetAccountDataResponse> {
val response = client.getAccountDataAsync(mapper.mapToGetAccountDataParameter(request, request.options ?: GetAccountDataOptions()))
return mapper.map(response)
}
}

View File

@ -0,0 +1,13 @@
package net.codinux.banking.client.fints4k
import net.codinux.banking.client.BankingClientCallback
import net.codinux.banking.client.BankingClientForCustomerBase
import net.codinux.banking.client.model.AccountCredentials
open class FinTs4kBankingClientForCustomer(credentials: AccountCredentials, callback: BankingClientCallback)
: BankingClientForCustomerBase(credentials, FinTs4kBankingClient(callback)) {
constructor(bankCode: String, loginName: String, password: String, callback: BankingClientCallback)
: this(AccountCredentials(bankCode, loginName, password), callback)
}

View File

@ -0,0 +1,164 @@
package net.codinux.banking.client.fints4k
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.tan.*
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.response.*
import net.codinux.banking.client.model.tan.ActionRequiringTan
import net.codinux.banking.client.model.tan.TanChallenge
import net.codinux.banking.client.model.tan.TanImage
import net.codinux.banking.client.model.tan.TanMethod
import net.codinux.banking.client.model.tan.TanMethodType
import net.dankito.banking.client.model.BankAccountIdentifierImpl
import net.dankito.banking.client.model.parameter.GetAccountDataParameter
import net.dankito.banking.client.model.parameter.RetrieveTransactions
import net.dankito.banking.client.model.response.ErrorCode
import net.dankito.banking.fints.mapper.FinTsModelMapper
import net.dankito.banking.fints.model.*
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
open class FinTs4kMapper {
private val fintsModelMapper = FinTsModelMapper()
fun mapToGetAccountDataParameter(credentials: AccountCredentials, options: GetAccountDataOptions) = GetAccountDataParameter(
credentials.bankCode, credentials.loginName, credentials.password,
options.accounts.map { BankAccountIdentifierImpl(it.identifier, it.subAccountNumber, it.iban) },
options.retrieveBalance,
RetrieveTransactions.valueOf(options.retrieveTransactions.name), options.retrieveTransactionsFrom, options.retrieveTransactionsTo,
abortIfTanIsRequired = options.abortIfTanIsRequired
)
fun map(response: net.dankito.banking.client.model.response.GetAccountDataResponse): Response<GetAccountDataResponse> {
return if (response.successful && response.customerAccount != null) {
Response.success(GetAccountDataResponse(mapCustomer(response.customerAccount!!)))
} else {
mapError(response)
}
}
fun mapToCustomerAccountViewInfo(bank: BankData): CustomerAccountViewInfo = CustomerAccountViewInfo(
bank.bankCode, bank.customerId, bank.bankName
)
fun mapToBankAccountViewInfo(account: AccountData): BankAccountViewInfo = BankAccountViewInfo(
account.accountIdentifier, account.subAccountAttribute,
mapAccountType(fintsModelMapper.map(account.accountType)),
account.iban, account.productName
)
private fun mapCustomer(customer: net.dankito.banking.client.model.CustomerAccount): CustomerAccount = CustomerAccount(
customer.bankCode, customer.loginName, customer.password,
customer.bankName, customer.bic, customer.customerName, customer.userId,
customer.accounts.map { mapAccount(it) }
)
private fun mapAccount(account: net.dankito.banking.client.model.BankAccount): BankAccount = BankAccount(
account.identifier, account.accountHolderName, mapAccountType(account.type), account.iban, account.subAccountNumber,
account.productName, account.currency, account.accountLimit, account.isAccountTypeSupportedByApplication,
mapFeatures(account),
mapAmount(account.balance), account.retrievedTransactionsFrom, account.retrievedTransactionsTo,
// TODO: map haveAllTransactionsBeenRetrieved
countDaysForWhichTransactionsAreKept = account.countDaysForWhichTransactionsAreKept,
bookedTransactions = account.bookedTransactions.map { mapTransaction(it) }.toMutableList()
)
private fun mapAccountType(type: net.dankito.banking.client.model.BankAccountType): BankAccountType =
BankAccountType.valueOf(type.name)
private fun mapFeatures(account: net.dankito.banking.client.model.BankAccount): Set<BankAccountFeatures> = buildSet {
if (account.supportsRetrievingBalance) {
add(BankAccountFeatures.RetrieveBalance)
}
if (account.supportsRetrievingTransactions) {
add(BankAccountFeatures.RetrieveTransactions)
}
if (account.supportsTransferringMoney) {
add(BankAccountFeatures.TransferMoney)
}
if (account.supportsInstantPayment) {
add(BankAccountFeatures.InstantPayment)
}
}
private fun mapTransaction(transaction: net.dankito.banking.client.model.AccountTransaction): AccountTransaction = AccountTransaction(
mapAmount(transaction.amount), transaction.amount.currency.code, transaction.unparsedReference,
transaction.bookingDate, transaction.valueDate,
transaction.otherPartyName, transaction.otherPartyBankCode, transaction.otherPartyAccountId,
transaction.bookingText, null,
transaction.statementNumber, transaction.sequenceNumber,
mapNullableAmount(transaction.openingBalance), mapNullableAmount(transaction.closingBalance),
// TODO: map other properties
)
private fun mapNullableAmount(amount: Money?) = amount?.let { mapAmount(it) }
private fun mapAmount(amount: Money) = Amount.fromString(amount.amount.string.replace(',', '.'))
fun mapTanChallenge(challenge: net.dankito.banking.fints.model.TanChallenge): TanChallenge {
val type = mapTanChallengeType(challenge)
val action = mapActionRequiringTan(challenge.forAction)
val tanMethod = mapTanMethod(challenge.tanMethod)
val customer = mapToCustomerAccountViewInfo(challenge.bank)
val account = challenge.account?.let { mapToBankAccountViewInfo(it) }
val tanImage = if (challenge is ImageTanChallenge) mapTanImage(challenge.image) else null
val flickerCode = if (challenge is FlickerCodeTanChallenge) mapFlickerCode(challenge.flickerCode) else null
return TanChallenge(type, action, challenge.messageToShowToUser, tanMethod, tanImage, flickerCode, customer, account)
}
private fun mapTanChallengeType(challenge: net.dankito.banking.fints.model.TanChallenge): TanChallengeType = when {
challenge is ImageTanChallenge -> TanChallengeType.Image
challenge is FlickerCodeTanChallenge -> TanChallengeType.Flickercode
else -> TanChallengeType.EnterTan
}
private fun mapActionRequiringTan(action: net.dankito.banking.fints.model.ActionRequiringTan): ActionRequiringTan =
ActionRequiringTan.valueOf(action.name)
private fun mapTanMethod(method: net.dankito.banking.fints.model.TanMethod): TanMethod = TanMethod(
method.displayName, mapTanMethodType(method.type), method.securityFunction.code, method.maxTanInputLength, mapAllowedTanFormat(method.allowedTanFormat)
)
private fun mapTanMethodType(type: net.dankito.banking.fints.model.TanMethodType): TanMethodType =
TanMethodType.valueOf(type.name)
private fun mapAllowedTanFormat(allowedTanFormat: net.dankito.banking.fints.messages.datenelemente.implementierte.tan.AllowedTanFormat?): AllowedTanFormat =
allowedTanFormat?.let { AllowedTanFormat.valueOf(it.name) } ?: AllowedTanFormat.Alphanumeric
private fun mapTanImage(image: net.dankito.banking.fints.tan.TanImage): TanImage =
TanImage(image.mimeType, mapToBase64(image.imageBytes), mapException(image.decodingError))
@OptIn(ExperimentalEncodingApi::class)
private fun mapToBase64(bytes: ByteArray): String {
return Base64.Default.encode(bytes)
}
private fun mapFlickerCode(flickerCode: net.dankito.banking.fints.tan.FlickerCode): FlickerCode =
FlickerCode(flickerCode.challengeHHD_UC, flickerCode.parsedDataSet, mapException(flickerCode.decodingError))
private fun <T> mapError(response: net.dankito.banking.client.model.response.GetAccountDataResponse): Response<T> {
return if (response.error != null) {
Response.error(ErrorType.valueOf(response.error!!.name), if (response.error == ErrorCode.BankReturnedError) null else response.errorMessage,
if (response.error == ErrorCode.BankReturnedError && response.errorMessage !== null) listOf(response.errorMessage!!) else emptyList())
} else {
Response.error(ErrorType.UnknownError, response.errorMessage)
}
}
private fun mapException(exception: Exception?): String? =
exception?.stackTraceToString()
}

View File

@ -0,0 +1,36 @@
package net.codinux.banking.client.fints4k
import kotlinx.coroutines.test.runTest
import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.model.response.ResponseType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class FinTs4kBankingClientTest {
companion object {
// set your credentials here:
private const val bankCode = ""
private const val loginName = ""
private const val password = ""
}
private val underTest = FinTs4kBankingClientForCustomer(bankCode, loginName, password, SimpleBankingClientCallback { customer, tanChallenge ->
})
@Test
fun getAccountDataAsync() = runTest {
val result = underTest.getAccountDataAsync()
assertEquals(ResponseType.Success, result.type)
assertNotNull(result.data)
assertTrue(result.data!!.bookedTransactions.isNotEmpty())
}
}

View File

@ -1,16 +0,0 @@
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at sales@dankito.net. For AGPL licensing, see below.
AGPL licensing:
This program 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 of the License, or
(at your option) any later version.
This program 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 this program. If not, see <https://www.gnu.org/licenses/>.

128
README.md
View File

@ -1,97 +1,93 @@
# fints4k
# Banking Client
fints4k is an implementation of the FinTS 3.0 online banking protocol used by most German banks.
Library to abstract over different banking client implementations like [fints4k](https://git.dankito.net/codinux/fints4k).
It's fast, easy extendable and running on multiple platforms: JVM, Android, (iOS, JavaScript, Windows, MacOS, Linux).
However it's not a full implementation of FinTS standard but implements all common use cases:
## Features
- Retrieving account information, balances and turnovers (Kontoumsätze und -saldo).
- Transfer money and real-time transfers (SEPA Überweisungen und Echtzeitüberweisung).
- Supports TAN methods chipTAN manual, Flickercode, QrCode and Photo (Matrix code), pushTAN, smsTAN and appTAN.
It's primary purpose is to abstract away the different implementation details and to create a common model that can be
used in all projects directly or indirectly referencing it - Web Service, Middleware, Native Apps, HTML Apps - so that
not each project has the implement to model again.
## Setup
Not uploaded to Maven Central yet, will do this the next few days!
Gradle:
### Gradle:
```
dependencies {
compile 'net.dankito.banking:fints4k:0.1.0'
plugins {
kotlin("jvm") version "2.0.10" // or kotlin("multiplatform"), depending on your requirements
}
```
Maven:
```
<dependency>
<groupId>net.dankito.banking</groupId>
<artifactId>fints4k</artifactId>
<version>0.1.0</version>
</dependency>
repositories {
mavenCentral()
maven {
setUrl("https://maven.dankito.net/api/packages/codinux/maven")
}
}
dependencies {
implementation("net.codinux.banking.client:fints4k-banking-client:0.5.0")
}
```
## Usage
See e.g. [JavaShowcase](fints4k/src/test/java/net/dankito/banking/fints/JavaShowcase.java) or [FinTsClientTest](fints4k/src/test/kotlin/net/dankito/banking/fints/FinTsClientTest.kt).
### Get AccountData
```java
// Set your bank code (Bankleitzahl) here.
// BankInfo contains e.g. a bank's FinTS server address, country code and BIC (needed for money transfer)
List<BankInfo> foundBanks = new InMemoryBankFinder().findBankByNameBankCodeOrCity("<bank code, bank name or city>");
Retrieves data like accounts, balance and booked transactions (Konten, Saldo und Kontoumsätze).
if (foundBanks.isEmpty() == false) { // please also check if bank supports FinTS 3.0
BankData bank = new BankDataMapper().mapFromBankInfo(foundBanks.get(0));
Basically:
// set your customer data (customerId = username you use to log in; pin = online banking pin / password)
CustomerData customer = new CustomerData("<customer_id>", "<pin>");
```kotlin
class ShowUsage {
FinTsClientCallback callback = new SimpleFinTsClientCallback(); // see advanced showcase for configuring callback
private val bankCode = "" // Bankleitzahl deiner Bank
FinTsClient finTsClient = new FinTsClient(callback, new Java8Base64Service());
private val loginName = "" // Online-Banking Login Name mit dem du dich beim Online-Banking deiner Bank anmeldest
AddAccountResponse addAccountResponse = finTsClient.addAccount(bank, customer);
private val password = "" // Online-Banking Password mit dem du dich beim Online-Banking deiner Bank anmeldest
if (addAccountResponse.isSuccessful()) {
System.out.println("Successfully added account for " + bank.getBankCode() + " " + customer.getCustomerId());
if (addAccountResponse.getBookedTransactions().isEmpty() == false) {
System.out.println("Account transactions of last 90 days:");
showGetTransactionsResponse(addAccountResponse);
fun getAccountData() {
val client = FinTs4kBankingClientForCustomer(bankCode, loginName, password, SimpleBankingClientCallback())
val response = client.getAccountData()
response.data?.let { data ->
val customer = data.customer
println("Kunde: ${customer.customerName} ${customer.accounts.size} Konten @ ${customer.bic} ${customer.bankName}")
println()
println("Konten:")
customer.accounts.sortedBy { it.type }.forEach { account ->
println("${account.identifier} ${account.productName} ${account.balance} ${account.currency}")
}
println()
println("Umsätze:")
data.bookedTransactions.forEach { transaction ->
println("${transaction.valueDate} ${transaction.amount} ${transaction.currency} ${transaction.otherPartyName ?: ""} - ${transaction.reference}")
}
}
else {
System.out.println("Could not add account for " + bank.getBankCode() + " " + customer.getCustomerId() + ":");
showResponseError(addAccountResponse);
}
// see advanced show case what else you can do with this library, e.g. retrieving all account transactions and transferring money
}
}
```
## Logging
This fetches the booked account transactions of the last 90 days. In most cases no TAN is required for this.
fints4k uses slf4j as logging facade.
In case there is, add TAN handling in Client Callback:
So you can use any logger that supports slf4j, like Logback and log4j, to configure and get fints4k's log output.
## Sample applications
### WebApp
Directly requesting bank servers is forbidden in browsers due to CORS.
In order to use fints4k directly in browser you need a CORS proxy like the one from CorsProxy
[Application.kt](SampleApplications/CorsProxy/src/main/kotlin/net/codinux/web/cors/Application.kt) or https://github.com/Rob--W/cors-anywhere.
Set CORS proxy's URL in WebApp [main.kt](SampleApplications/WebApp/src/main/kotlin/main.kt).
Start sample WebApp then with
```shell
./gradlew WebApp:run --continuous
```kotlin
val client = FinTs4kBankingClientForCustomer(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
val tan: String? = null // if a TAN is required, add a UI or ...
callback.invoke(EnterTanResult(tan)) // ... set a break point here, get TAN e.g. from your TAN app, set tan variable in debugger view and resume debugger
})
```
## License
Add some error handling by checking `response.error`:
Not free for commercial applications. More details to follow or [contact](mailto:sales@codinux.net) us.
```kotlin
response.error?.let{ error ->
println("Could not fetch account data: ${error.internalError ?: error.errorMessagesFromBank.joinToString()}")
}
```

View File

@ -0,0 +1,16 @@
plugins {
kotlin("jvm")
}
repositories {
mavenCentral()
maven {
setUrl("https://maven.dankito.net/api/packages/codinux/maven")
}
}
dependencies {
implementation("net.codinux.banking.client:fints4k-banking-client:0.5.0")
}

View File

@ -0,0 +1,50 @@
package net.codinux.banking.client.fints4k.example
import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.fints4k.FinTs4kBankingClientForCustomer
import net.codinux.banking.client.getAccountData
import net.codinux.banking.client.model.tan.EnterTanResult
fun main() {
ShowUsage().getAccountData()
}
class ShowUsage {
private val bankCode = "" // Bankleitzahl deiner Bank
private val loginName = "" // Online-Banking Login Name mit dem du dich beim Online-Banking deiner Bank anmeldest
private val password = "" // Online-Banking Password mit dem du dich beim Online-Banking deiner Bank anmeldest
fun getAccountData() {
val client = FinTs4kBankingClientForCustomer(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
val tan: String? = null // if a TAN is required, add a UI or ...
callback.invoke(EnterTanResult(tan)) // ... set a break point here, get TAN e.g. from your TAN app, set tan variable in debugger view and resume debugger
})
val response = client.getAccountData()
response.error?.let{ error ->
println("Could not fetch account data: ${error.internalError ?: error.errorMessagesFromBank.joinToString()}")
}
response.data?.let { data ->
val customer = data.customer
println("Kunde: ${customer.customerName} ${customer.accounts.size} Konten @ ${customer.bic} ${customer.bankName}")
println()
println("Konten:")
customer.accounts.sortedBy { it.type }.forEach { account ->
println("${account.identifier} ${account.productName} ${account.balance} ${account.currency}")
}
println()
println("Umsätze:")
data.bookedTransactions.forEach { transaction ->
println("${transaction.valueDate} ${transaction.amount} ${transaction.currency} ${transaction.otherPartyName ?: ""} - ${transaction.reference}")
}
}
}
}

View File

@ -1,51 +0,0 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 31
defaultConfig {
applicationId "net.codinux.banking.fints4k.android"
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(":fints4k")
implementation "net.dankito.utils:android-utils:1.1.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.navigation:navigation-fragment-ktx:2.0.0-rc02'
implementation 'androidx.navigation:navigation-ui-ktx:2.0.0-rc02'
implementation "org.slf4j:slf4j-android:1.7.32"
}

View File

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.codinux.banking.fints4k.android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Fints4kProject">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Fints4kProject.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,61 +0,0 @@
package net.codinux.banking.fints4k.android
import android.os.Bundle
import android.view.ContextThemeWrapper
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import net.codinux.banking.fints4k.android.adapter.AccountTransactionsListRecyclerAdapter
import net.codinux.banking.fints4k.android.databinding.FragmentFirstBinding
import net.codinux.banking.fints4k.android.dialogs.EnterTanDialog
/**
* A simple [Fragment] subclass as the default destination in the navigation.
*/
class FirstFragment : Fragment() {
private var _binding: FragmentFirstBinding? = null
private val accountTransactionsAdapter = AccountTransactionsListRecyclerAdapter()
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFirstBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.rcyvwAccountTransactions.apply {
layoutManager = LinearLayoutManager(this@FirstFragment.context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(DividerItemDecoration(ContextThemeWrapper(this@FirstFragment.context, R.style.Theme_Fints4kProject), (layoutManager as LinearLayoutManager).orientation))
adapter = accountTransactionsAdapter
}
val presenter = Presenter() // TODO: inject
presenter.enterTanCallback = { tanChallenge ->
EnterTanDialog().show(tanChallenge, activity!!)
}
// TODO: set your credentials here
presenter.retrieveAccountData("", "", "") { response ->
response.customerAccount?.let { customer ->
accountTransactionsAdapter.items = customer.accounts.flatMap { it.bookedTransactions }
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -1,58 +0,0 @@
package net.codinux.banking.fints4k.android
import android.os.Bundle
import com.google.android.material.snackbar.Snackbar
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import android.view.Menu
import android.view.MenuItem
import net.codinux.banking.fints4k.android.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
val navController = findNavController(R.id.nav_host_fragment_content_main)
appBarConfiguration = AppBarConfiguration(navController.graph)
setupActionBarWithNavController(navController, appBarConfiguration)
binding.fab.setOnClickListener { view ->
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_main, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) {
R.id.action_settings -> true
else -> super.onOptionsItemSelected(item)
}
}
override fun onSupportNavigateUp(): Boolean {
val navController = findNavController(R.id.nav_host_fragment_content_main)
return navController.navigateUp(appBarConfiguration)
|| super.onSupportNavigateUp()
}
}

View File

@ -1,63 +0,0 @@
package net.codinux.banking.fints4k.android
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import net.dankito.banking.client.model.parameter.GetAccountDataParameter
import net.dankito.banking.client.model.response.GetAccountDataResponse
import net.dankito.banking.fints.FinTsClient
import net.dankito.banking.fints.callback.SimpleFinTsClientCallback
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.utils.multiplatform.extensions.millisSinceEpochAtSystemDefaultTimeZone
import org.slf4j.LoggerFactory
import java.math.BigDecimal
import java.text.DateFormat
import java.util.*
open class Presenter {
companion object {
val ValueDateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
private val log = LoggerFactory.getLogger(Presenter::class.java)
}
private val fintsClient = FinTsClient(SimpleFinTsClientCallback { challenge -> enterTan(challenge) })
open var enterTanCallback: ((TanChallenge) -> Unit)? = null
open protected fun enterTan(tanChallenge: TanChallenge) {
enterTanCallback?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
}
open fun retrieveAccountData(bankCode: String, loginName: String, password: String, retrievedResult: (GetAccountDataResponse) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
val response = fintsClient.getAccountDataAsync(GetAccountDataParameter(bankCode, loginName, password))
log.info("Retrieved response from ${response.customerAccount?.bankName} for ${response.customerAccount?.customerName}")
withContext(Dispatchers.Main) {
retrievedResult(response)
}
}
}
fun formatDate(date: LocalDate): String {
try {
return ValueDateFormat.format(Date(date.millisSinceEpochAtSystemDefaultTimeZone))
} catch (e: Exception) {
log.error("Could not format date $date", e)
}
return date.toString()
}
fun formatAmount(amount: BigDecimal): String {
return String.format("%.02f", amount)
}
}

View File

@ -1,44 +0,0 @@
package net.codinux.banking.fints4k.android
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import net.codinux.banking.fints4k.android.databinding.FragmentSecondBinding
/**
* A simple [Fragment] subclass as the second destination in the navigation.
*/
class SecondFragment : Fragment() {
private var _binding: FragmentSecondBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentSecondBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonSecond.setOnClickListener {
findNavController().navigate(R.id.action_SecondFragment_to_FirstFragment)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -1,46 +0,0 @@
package net.codinux.banking.fints4k.android.adapter
import android.view.View
import net.codinux.banking.fints4k.android.Presenter
import net.codinux.banking.fints4k.android.R
import net.codinux.banking.fints4k.android.adapter.viewholder.AccountTransactionsViewHolder
import net.dankito.banking.client.model.AccountTransaction
import net.dankito.banking.fints.util.toBigDecimal
import net.dankito.utils.android.extensions.setTextColorToColorResource
import net.dankito.utils.android.ui.adapter.ListRecyclerAdapter
import org.slf4j.LoggerFactory
import java.math.BigDecimal
class AccountTransactionsListRecyclerAdapter : ListRecyclerAdapter<AccountTransaction, AccountTransactionsViewHolder>() {
private val presenter = Presenter() // TOOD: inject
private val log = LoggerFactory.getLogger(AccountTransactionsListRecyclerAdapter::class.java)
override fun getListItemLayoutId() = R.layout.list_item_account_transaction
override fun createViewHolder(itemView: View): AccountTransactionsViewHolder {
return AccountTransactionsViewHolder(itemView)
}
override fun bindItemToView(viewHolder: AccountTransactionsViewHolder, item: AccountTransaction) {
try {
viewHolder.txtvwBookingText.text = item.bookingText ?: ""
viewHolder.txtvwOtherPartyName.visibility = if (item.showOtherPartyName) View.VISIBLE else View.GONE
viewHolder.txtvwOtherPartyName.text = item.otherPartyName ?: ""
viewHolder.txtvwReference.text = item.reference
viewHolder.txtvwDate.text = presenter.formatDate(item.valueDate)
val amount = item.amount.toBigDecimal()
viewHolder.txtvwAmount.text = presenter.formatAmount(amount)
viewHolder.txtvwAmount.setTextColorToColorResource(if (amount >= BigDecimal.ZERO) R.color.positiveAmount else R.color.negativeAmount)
} catch (e: Exception) {
log.error("Could not display account transaction $item", e)
}
}
}

View File

@ -1,20 +0,0 @@
package net.codinux.banking.fints4k.android.adapter.viewholder
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import net.codinux.banking.fints4k.android.R
class AccountTransactionsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val txtvwBookingText: TextView = itemView.findViewById(R.id.txtvwBookingText)
val txtvwOtherPartyName: TextView = itemView.findViewById(R.id.txtvwOtherPartyName)
val txtvwReference: TextView = itemView.findViewById(R.id.txtvwReference)
val txtvwAmount: TextView = itemView.findViewById(R.id.txtvwAmount)
val txtvwDate: TextView = itemView.findViewById(R.id.txtvwDate)
}

View File

@ -1,140 +0,0 @@
package net.codinux.banking.fints4k.android.dialogs
import android.graphics.BitmapFactory
import android.os.Bundle
import android.os.Handler
import android.text.InputFilter
import android.text.InputType
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.fints4k.android.Presenter
import net.codinux.banking.fints4k.android.R
import net.dankito.banking.fints.model.FlickerCodeTanChallenge
import net.dankito.banking.fints.model.ImageTanChallenge
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.utils.android.extensions.getSpannedFromHtml
import net.dankito.utils.android.extensions.show
open class EnterTanDialog : DialogFragment() {
companion object {
const val DialogTag = "EnterTanDialog"
}
protected lateinit var tanChallenge: TanChallenge
open fun show(tanChallenge: TanChallenge, activity: FragmentActivity) {
this.tanChallenge = tanChallenge
setStyle(STYLE_NORMAL, R.style.FullscreenDialogWithStatusBar)
show(activity.supportFragmentManager, DialogTag)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val rootView = inflater.inflate(R.layout.dialog_enter_tan, container, false)
setupUI(rootView)
return rootView
}
protected open fun setupUI(rootView: View) {
setupTanView(rootView)
setupEnteringTan(rootView)
rootView.findViewById<TextView>(R.id.txtvwMessageToShowToUser).text = tanChallenge.messageToShowToUser.getSpannedFromHtml()
rootView.findViewById<Button>(R.id.btnCancel).setOnClickListener { enteringTanDone(null) }
rootView.findViewById<Button>(R.id.btnEnteringTanDone).setOnClickListener {
enteringTanDone(rootView.findViewById<EditText>(R.id.edtxtEnteredTan).text.toString())
}
}
protected open fun setupTanView(rootView: View) {
if (tanChallenge is FlickerCodeTanChallenge) {
// setupFlickerCodeTanView(rootView)
}
else if (tanChallenge is ImageTanChallenge) {
setupImageTanView(rootView)
}
}
protected open fun setupEnteringTan(rootView: View) {
val edtxtEnteredTan = rootView.findViewById<EditText>(R.id.edtxtEnteredTan)
if (tanChallenge.tanMethod.isNumericTan) {
edtxtEnteredTan.inputType = InputType.TYPE_CLASS_NUMBER
}
tanChallenge.tanMethod.maxTanInputLength?.let { maxInputLength ->
edtxtEnteredTan.filters = arrayOf<InputFilter>(InputFilter.LengthFilter(maxInputLength))
}
edtxtEnteredTan.setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_ENTER) {
enteringTanDone(edtxtEnteredTan.text.toString())
return@setOnKeyListener true
}
false
}
}
protected open fun setupImageTanView(rootView: View) {
val tanImageView = rootView.findViewById<ImageView>(R.id.tanImageView)
tanImageView.show()
val decodedImage = (tanChallenge as ImageTanChallenge).image
if (decodedImage.decodingSuccessful) {
val bitmap = BitmapFactory.decodeByteArray(decodedImage.imageBytes, 0, decodedImage.imageBytes.size)
tanImageView.setImageBitmap(bitmap)
}
else {
showDecodingTanChallengeFailedErrorDelayed(decodedImage.decodingError)
}
}
/**
* This method gets called right on start up before dialog is shown -> Alert would get displayed before dialog and
* therefore covered by dialog -> delay displaying alert.
*/
protected open fun showDecodingTanChallengeFailedErrorDelayed(error: Exception?) {
val handler = Handler()
handler.postDelayed({ showDecodingTanChallengeFailedError(error) }, 500)
}
protected open fun showDecodingTanChallengeFailedError(error: Exception?) {
activity?.let { context ->
AlertDialog.Builder(context)
.setMessage(context.getString(R.string.dialog_enter_tan_error_could_not_decode_tan_image, error?.localizedMessage))
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
.show()
}
}
protected open fun enteringTanDone(enteredTan: String?) {
if (enteredTan != null) {
tanChallenge.userEnteredTan(enteredTan)
} else {
tanChallenge.userDidNotEnterTan()
}
dismiss()
}
}

View File

@ -1,31 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="49.59793"
android:startX="42.9492"
android:endY="92.4963"
android:endX="85.84757"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>

View File

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:theme="@style/Theme.Fints4kProject.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/Theme.Fints4kProject.PopupOverlay"/>
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="@dimen/fab_margin"
android:layout_marginBottom="16dp"
app:srcCompat="@android:drawable/ic_dialog_email"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_content_padding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/dialog_enter_tan_padding"
android:isScrollContainer="true"
>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<ImageView
android:id="@+id/tanImageView"
android:layout_width="match_parent"
android:layout_height="350dp"
android:layout_gravity="center"
android:gravity="center_vertical"
android:layout_marginBottom="@dimen/dialog_enter_tan_margin_before_enter_tan"
android:visibility="gone"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dialog_enter_tan_tan_description_label"
/>
<TextView
android:id="@+id/txtvwMessageToShowToUser"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</TextView>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="@dimen/dialog_enter_tan_enter_tan_height"
android:layout_marginBottom="@dimen/dialog_enter_tan_enter_tan_margin_bottom"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:textStyle="bold"
android:text="@string/dialog_enter_tan_enter_tan"
/>
<EditText
android:id="@+id/edtxtEnteredTan"
android:layout_width="@dimen/dialog_enter_tan_enter_tan_width"
android:layout_height="match_parent"
android:layout_marginLeft="@dimen/dialog_enter_tan_enter_tan_margin_left"
android:layout_marginStart="@dimen/dialog_enter_tan_enter_tan_margin_left"
/>
</LinearLayout>
<RelativeLayout
android:id="@+id/lytButtonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<Button
android:id="@+id/btnEnteringTanDone"
android:layout_width="@dimen/dialog_enter_tan_buttons_width"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
style="?android:attr/buttonBarButtonStyle"
android:text="@android:string/ok"
/>
<Button
android:id="@+id/btnCancel"
android:layout_width="@dimen/dialog_enter_tan_buttons_width"
android:layout_height="wrap_content"
android:layout_toLeftOf="@+id/btnEnteringTanDone"
android:layout_toStartOf="@+id/btnEnteringTanDone"
style="?android:attr/buttonBarButtonStyle"
android:text="@android:string/cancel"
/>
</RelativeLayout>
</LinearLayout>
</ScrollView>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<androidx.recyclerview.widget.RecyclerView android:id="@+id/rcyvwAccountTransactions"
android:layout_width="match_parent" android:layout_height="match_parent"
android:layout_alignParentTop="true"
android:layout_alignParentBottom="true"
>
</androidx.recyclerview.widget.RecyclerView>
</RelativeLayout>

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SecondFragment">
<TextView
android:id="@+id/textview_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/button_second"
/>
<Button
android:id="@+id/button_second"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/previous"
app:layout_constraintTop_toBottomOf="@id/textview_second"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/list_item_account_transaction_height"
android:padding="@dimen/list_item_account_transaction_padding"
>
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_alignParentTop="true"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_toLeftOf="@+id/lytAmountAndDate"
android:layout_toStartOf="@+id/lytAmountAndDate"
android:layout_marginLeft="@dimen/list_item_account_transaction_transaction_text_margin_left_and_right"
android:layout_marginStart="@dimen/list_item_account_transaction_transaction_text_margin_left_and_right"
android:layout_marginRight="@dimen/list_item_account_transaction_transaction_text_margin_left_and_right"
android:layout_marginEnd="@dimen/list_item_account_transaction_transaction_text_margin_left_and_right"
android:gravity="center_vertical"
>
<TextView
android:id="@+id/txtvwBookingText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:id="@+id/txtvwOtherPartyName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_item_account_transaction_other_party_name_margin_top_and_bottom"
android:layout_marginBottom="@dimen/list_item_account_transaction_other_party_name_margin_top_and_bottom"
/>
<TextView
android:id="@+id/txtvwReference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
</LinearLayout>
<LinearLayout
android:id="@+id/lytAmountAndDate"
android:orientation="vertical"
android:layout_width="@dimen/list_item_account_transaction_amount_width"
android:layout_height="match_parent"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:gravity="center_vertical"
>
<ImageView
android:id="@+id/imgvwBankIcon"
android:layout_width="@dimen/list_item_account_transaction_bank_icon_width_and_height"
android:layout_height="@dimen/list_item_account_transaction_bank_icon_width_and_height"
android:layout_marginTop="@dimen/list_item_account_transaction_bank_icon_margin_top"
android:layout_marginBottom="@dimen/list_item_account_transaction_bank_icon_margin_bottom"
android:layout_gravity="end"
android:visibility="gone"
/>
<TextView
android:id="@+id/txtvwAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
/>
<TextView
android:id="@+id/txtvwDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_item_account_transaction_date_margin_top"
android:layout_marginBottom="@dimen/list_item_account_transaction_date_margin_bottom"
android:gravity="end"
/>
</LinearLayout>
</RelativeLayout>

View File

@ -1,9 +0,0 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="net.codinux.banking.fints4k.android.MainActivity">
<item android:id="@+id/action_settings"
android:title="@string/action_settings"
android:orderInCategory="100"
app:showAsAction="never"/>
</menu>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/FirstFragment">
<fragment
android:id="@+id/FirstFragment"
android:name="net.codinux.banking.fints4k.android.FirstFragment"
android:label="@string/first_fragment_label"
tools:layout="@layout/fragment_first">
<action
android:id="@+id/action_FirstFragment_to_SecondFragment"
app:destination="@id/SecondFragment"/>
</fragment>
<fragment
android:id="@+id/SecondFragment"
android:name="net.codinux.banking.fints4k.android.SecondFragment"
android:label="@string/second_fragment_label"
tools:layout="@layout/fragment_second">
<action
android:id="@+id/action_SecondFragment_to_FirstFragment"
app:destination="@id/FirstFragment"/>
</fragment>
</navigation>

View File

@ -1,3 +0,0 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

Some files were not shown because too many files have changed in this diff Show More