Compare commits

...

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

567 changed files with 29368 additions and 4981 deletions

19
.gitignore vendored Normal file → Executable file
View File

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

3
.gitmodules vendored
View File

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

View File

@ -1,108 +0,0 @@
@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

@ -1,14 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,122 +0,0 @@
@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

@ -1,12 +0,0 @@
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

@ -1,65 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,38 +0,0 @@
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

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

View File

@ -1,18 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,16 +0,0 @@
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

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

View File

@ -1,9 +0,0 @@
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

@ -1,6 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,34 +0,0 @@
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

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

View File

@ -1,12 +0,0 @@
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,9 +0,0 @@
package net.codinux.banking.client.model.tan
enum class AllowedTanFormat {
Numeric,
Alphanumeric,
TanIsEnteredOnOtherDevice
}

View File

@ -1,11 +0,0 @@
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,10 +0,0 @@
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

@ -1,28 +0,0 @@
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

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

View File

@ -1,10 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,20 +0,0 @@
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

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

View File

@ -1,18 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,139 +0,0 @@
@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

@ -1,32 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,164 +0,0 @@
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

@ -1,36 +0,0 @@
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())
}
}

16
LICENSE.md Normal file
View File

@ -0,0 +1,16 @@
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,93 +1,97 @@
# Banking Client
# fints4k
Library to abstract over different banking client implementations like [fints4k](https://git.dankito.net/codinux/fints4k).
fints4k is an implementation of the FinTS 3.0 online banking protocol used by most German banks.
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.
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.
## Setup
Not uploaded to Maven Central yet, will do this the next few days!
### Gradle:
Gradle:
```
plugins {
kotlin("jvm") version "2.0.10" // or kotlin("multiplatform"), depending on your requirements
}
repositories {
mavenCentral()
maven {
setUrl("https://maven.dankito.net/api/packages/codinux/maven")
}
}
dependencies {
implementation("net.codinux.banking.client:fints4k-banking-client:0.5.0")
compile 'net.dankito.banking:fints4k:0.1.0'
}
```
Maven:
```
<dependency>
<groupId>net.dankito.banking</groupId>
<artifactId>fints4k</artifactId>
<version>0.1.0</version>
</dependency>
```
## Usage
### Get AccountData
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).
Retrieves data like accounts, balance and booked transactions (Konten, Saldo und Kontoumsätze).
```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>");
Basically:
if (foundBanks.isEmpty() == false) { // please also check if bank supports FinTS 3.0
BankData bank = new BankDataMapper().mapFromBankInfo(foundBanks.get(0));
```kotlin
class ShowUsage {
// set your customer data (customerId = username you use to log in; pin = online banking pin / password)
CustomerData customer = new CustomerData("<customer_id>", "<pin>");
private val bankCode = "" // Bankleitzahl deiner Bank
FinTsClientCallback callback = new SimpleFinTsClientCallback(); // see advanced showcase for configuring callback
private val loginName = "" // Online-Banking Login Name mit dem du dich beim Online-Banking deiner Bank anmeldest
FinTsClient finTsClient = new FinTsClient(callback, new Java8Base64Service());
private val password = "" // Online-Banking Password mit dem du dich beim Online-Banking deiner Bank anmeldest
AddAccountResponse addAccountResponse = finTsClient.addAccount(bank, customer);
if (addAccountResponse.isSuccessful()) {
System.out.println("Successfully added account for " + bank.getBankCode() + " " + customer.getCustomerId());
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}")
if (addAccountResponse.getBookedTransactions().isEmpty() == false) {
System.out.println("Account transactions of last 90 days:");
showGetTransactionsResponse(addAccountResponse);
}
}
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
}
}
```
This fetches the booked account transactions of the last 90 days. In most cases no TAN is required for this.
## Logging
In case there is, add TAN handling in Client Callback:
fints4k uses slf4j as logging facade.
```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
})
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
```
Add some error handling by checking `response.error`:
## License
```kotlin
response.error?.let{ error ->
println("Could not fetch account data: ${error.internalError ?: error.errorMessagesFromBank.joinToString()}")
}
```
Not free for commercial applications. More details to follow or [contact](mailto:sales@codinux.net) us.

View File

@ -1,16 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,21 @@
# 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

@ -0,0 +1,28 @@
<?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

@ -0,0 +1,61 @@
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

@ -0,0 +1,58 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,44 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,140 @@
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

@ -0,0 +1,31 @@
<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

@ -0,0 +1,74 @@
<?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

@ -0,0 +1,35 @@
<?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

@ -0,0 +1,21 @@
<?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

@ -0,0 +1,96 @@
<?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

@ -0,0 +1,19 @@
<?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

@ -0,0 +1,30 @@
<?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

@ -0,0 +1,89 @@
<?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

@ -0,0 +1,9 @@
<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

@ -0,0 +1,5 @@
<?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

@ -0,0 +1,5 @@
<?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.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,28 @@
<?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

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

View File

@ -0,0 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Fints4kProject" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="positiveAmount">#43A047</color>
<color name="negativeAmount">#E53935</color>
</resources>

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