diff --git a/docs/specifications/Girocode/EPC069-12 v2.1 Quick Response Code - Guidelines to Enable the Data Capture for the Initiation of a SCT.pdf b/docs/specifications/Girocode/EPC069-12 v2.1 Quick Response Code - Guidelines to Enable the Data Capture for the Initiation of a SCT.pdf new file mode 100644 index 00000000..1af9393e Binary files /dev/null and b/docs/specifications/Girocode/EPC069-12 v2.1 Quick Response Code - Guidelines to Enable the Data Capture for the Initiation of a SCT.pdf differ diff --git a/docs/specifications/Girocode/QR-Code and BCD Definition 2.pdf b/docs/specifications/Girocode/QR-Code and BCD Definition 2.pdf new file mode 100644 index 00000000..9b00da14 Binary files /dev/null and b/docs/specifications/Girocode/QR-Code and BCD Definition 2.pdf differ diff --git a/settings.gradle b/settings.gradle index 7b21624c..b5b4098e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -71,8 +71,10 @@ project(':fints4kRest').projectDir = "$rootDir/rest/fints4kRest/" as File include ':BankFinder' include ':LuceneBankFinder' include ':BankListCreator' +include ':EpcQrCodeParser' project(':BankFinder').projectDir = "$rootDir/tools/BankFinder/" as File project(':LuceneBankFinder').projectDir = "$rootDir/tools/LuceneBankFinder/" as File project(':BankListCreator').projectDir = "$rootDir/tools/BankListCreator/" as File +project(':EpcQrCodeParser').projectDir = "$rootDir/tools/EpcQrCodeParser/" as File diff --git a/tools/EpcQrCodeParser/README.md b/tools/EpcQrCodeParser/README.md new file mode 100644 index 00000000..b44ce2a7 --- /dev/null +++ b/tools/EpcQrCodeParser/README.md @@ -0,0 +1,7 @@ +# EPC QR code Parser + +The [EPC QR code](https://en.wikipedia.org/wiki/EPC_QR_code), marketed as GiroCode, Scan2Pay or Zahlen mit Code amongst others, is a QR code +defined by the European Payments Council which contains all data to initiate a SEPA credit transfer. + +This library is a multi platform implementation to extract the credit transfer data from decoded QR code. +(So it does not implement decoding the QR code itself, but extracting the data from decoded QR code text.) \ No newline at end of file diff --git a/tools/EpcQrCodeParser/build.gradle b/tools/EpcQrCodeParser/build.gradle new file mode 100644 index 00000000..e98e7511 --- /dev/null +++ b/tools/EpcQrCodeParser/build.gradle @@ -0,0 +1,159 @@ +plugins { + id 'org.jetbrains.kotlin.multiplatform' +// id 'java-library' + id "com.android.library" // TODO: get rid off, use java-library instead + id "maven-publish" +} + + +ext.artifactName = "epc-qr-code-parser" + +def frameworkName = "EpcQrCodeParser" + + +kotlin { + + targets { + final def iOSTarget = iOSIsRealDevice ? presets.iosArm64 : presets.iosX64 + + fromPreset(iOSTarget, 'ios') { + binaries { + framework { + baseName = frameworkName + } + } + } + } + + jvm() + + js { + + nodejs { + testTask { + enabled = false + } + } + + browser { + testTask { + enabled = false + } + + } + + } + + sourceSets { + commonMain { + dependencies { + implementation kotlin('stdlib-common') + } + } + commonTest { + dependencies { + implementation kotlin('test-common') + implementation kotlin('test-annotations-common') + } + } + + jvmMain { + dependencies { + implementation kotlin('stdlib-jdk8') + } + } + + jvmTest { + dependencies { + implementation kotlin('test') + implementation kotlin('test-junit') + } + } + + jsMain { + dependencies { + implementation kotlin('stdlib-js') + } + } + jsTest { + dependencies { + implementation kotlin('test-js') + } + } + + iosMain { + } + iosTest { + } + } + +} + + +// Task to generate iOS framework for xcode projects. +task packForXcode(type: Sync) { + + final File frameworkDir = new File(buildDir, "xcode-frameworks") + final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG' + + final def framework = kotlin.targets.ios.binaries.getFramework("", mode) + + inputs.property "mode", mode + dependsOn framework.linkTask + + from { framework.outputFile.parentFile } + into frameworkDir + + doLast { + new File(frameworkDir, 'gradlew').with { + text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd '${rootProject.rootDir}'\n./gradlew \$@\n" + setExecutable(true) + } + } +} + +// Run packForXcode when building. +tasks.build.dependsOn packForXcode + + +// TODO: get rid of this +android { + compileSdkVersion androidCompileSdkVersion + + + defaultConfig { + minSdkVersion androidMinSdkVersion + targetSdkVersion androidTargetSdkVersion + + versionName version + versionCode appVersionCode + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + pickFirst 'META-INF/ktor-http.kotlin_module' + pickFirst 'META-INF/kotlinx-io.kotlin_module' + pickFirst 'META-INF/atomicfu.kotlin_module' + pickFirst 'META-INF/ktor-utils.kotlin_module' + pickFirst 'META-INF/kotlinx-coroutines-io.kotlin_module' + pickFirst 'META-INF/ktor-client-core.kotlin_module' + pickFirst 'META-INF/DEPENDENCIES' + pickFirst 'META-INF/NOTICE' + pickFirst 'META-INF/LICENSE' + pickFirst 'META-INF/LICENSE.txt' + pickFirst 'META-INF/NOTICE.txt' + } + + lintOptions { + abortOnError false + } + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCode.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCode.kt new file mode 100644 index 00000000..ca1cab88 --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCode.kt @@ -0,0 +1,88 @@ +package net.codinux.banking.tools.epcqrcode + + +open class EpcQrCode( + + /** + * 3 bytes. + * Always(?) has value "BCD". + */ + open val serviceTag: String, + + /** + * 3 bytes. + * Has either value "001" or "002". + */ + open val version: EpcQrCodeVersion, + + /** + * 1 byte. + * The values 1,2,3,4,5,6,7 and 8 determine the interpretation of data to be used. + * In that order they qualify UTF-8, ISO 8895-1, ISO 8895-2, ISO 8895-4, ISO 8895-5, ISO 8895- 7, ISO 8895-10 and ISO 8895-15 + */ + open val coding: EpcQrCodeCharacterSet, + + /** + * 3 bytes. + * The Function is defined by its key value: SCT - SEPA Credit Transfer + */ + open val function: String, + + /** + * Receiver's BIC. + * Mandatory in Version 001, optional in Version 002. + * Either 8 or 11 bytes. + */ + open val bic: String?, + + /** + * Receiver name. + * Max. 70 characters + */ + open val receiverName: String, + + /** + * Receiver's IBAN. + * Max. 34 bytes. + */ + open val iban: String, + + /** + * Three capital letter currency code. + * Only set if amount is also set. + */ + open val currencyCode: String?, + + /** + * Optional amount. + * Max. 12 bytes. + */ + open val amount: Double?, // TODO: use BigDecimal + + /** + * Optional purpose code. + * Max. 4 bytes. + */ + open val purposeCode: String?, + + open val remittanceReference: String?, + + open val remittanceText: String?, + + open val originatorInformation: String? + +) { + + /** + * [remittanceReference] and [remittanceText] are mutual exclusive, that means one of both has to be set + * but they are never set both at the same time. + * + * remittance returns the one that is set. + */ + open val remittance: String + get() = remittanceReference ?: remittanceText ?: "" // they should never be both null + + override fun toString(): String { + return "$receiverName $amount $currencyCode ${remittance}" + } +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeCharacterSet.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeCharacterSet.kt new file mode 100644 index 00000000..b2d4752a --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeCharacterSet.kt @@ -0,0 +1,22 @@ +package net.codinux.banking.tools.epcqrcode + + +enum class EpcQrCodeCharacterSet(val code: Int) { + + UTF_8(1), + + ISO_8895_1(2), + + ISO_8895_2(3), + + ISO_8895_4(4), + + ISO_8895_5(5), + + ISO_8895_7(6), + + ISO_8895_10(7), + + ISO_8895_15(8) + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParser.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParser.kt new file mode 100644 index 00000000..6aa0ddca --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParser.kt @@ -0,0 +1,121 @@ +package net.codinux.banking.tools.epcqrcode + + +open class EpcQrCodeParser { + + open fun parseEpcQrCode(decodedQrCode: String): ParseEpcQrCodeResult { + try { + val lines = decodedQrCode.split("\n", "\r\n").dropLastWhile { isNotSet(it) } + + if (lines.size < 10 || lines.size > 12) { + return createInvalidFormatResult(decodedQrCode, "A EPC-QR-Code consists of 10 to 12 lines, but passed string has ${lines.size} lines") + } + if (lines[0] != "BCD") { + return createInvalidFormatResult(decodedQrCode, "A EPC-QR-Code's first lines has to be exactly 'BCD'") + } + + return parseEpcQrAfterSuccessfulFormatCheck(decodedQrCode, lines) + } catch (e: Exception) { + return ParseEpcQrCodeResult(decodedQrCode, ParseEpcQrCodeResultCode.UnpredictedErrorOccurred, null, e.message) + } + } + + protected open fun parseEpcQrAfterSuccessfulFormatCheck(decodedQrCode: String, lines: List): ParseEpcQrCodeResult { + val validVersionCodes = EpcQrCodeVersion.values().map { it.code } + if (lines[1].length != 3 || validVersionCodes.contains(lines[1]) == false) { + return createInvalidFormatResult(decodedQrCode, "The second line has to be exactly one of these values: ${validVersionCodes.joinToString(", ")}") + } + + val version = EpcQrCodeVersion.values().first { it.code == lines[1] } + + val codingCode = lines[2].toIntOrNull() + if (lines[2].length != 1 || codingCode == null || codingCode < 1 || codingCode > 8) { + return createInvalidFormatResult(decodedQrCode, "The third line has to be exactly one of these values: ${EpcQrCodeCharacterSet.values().map { it.code }.joinToString(", ")}") + } + + val coding = EpcQrCodeCharacterSet.values().first { it.code == codingCode } + + if (lines[3].length != 3) { + return createInvalidFormatResult(decodedQrCode, "The fourth line, ${lines[3]}, has to be exactly three characters long.") + } + + if (version == EpcQrCodeVersion.Version1 && isNotSet(lines[4])) { + return createInvalidFormatResult(decodedQrCode, "The BIC line 5 may only be omitted in EPC-QR-Code version 002") + } + if (lines[4].length != 8 && lines[4].length != 11 && (version == EpcQrCodeVersion.Version1 || isNotSet(lines[4]) == false)) { + return createInvalidFormatResult(decodedQrCode, "The BIC line 5, ${lines[4]}, must be either 8 or 11 characters long.") + } + + if (isNotSet(lines[5])) { + return createInvalidFormatResult(decodedQrCode, "The receiver in line 6 may not be omitted.") + } + + val receiver = parseWithCoding(lines[5], coding) + if (receiver.length > 70) { // omit check for parsing + return createInvalidFormatResult(decodedQrCode, "The receiver in line 6, ${lines[5]}, may not be longer than 70 characters.") + } + + if (isNotSet(lines[6])) { + return createInvalidFormatResult(decodedQrCode, "The IBAN in line 7 may not be omitted.") + } +// if (lines[6].length > 34) { // omit check for parsing +// return createInvalidFormatResult("The IBAN in line 7, ${lines[6]}, may not be longer than 70 characters.") +// } + + var currencyCode: String? = null + var amount: Double? = null + val currencyCodeAndAmount = lines[7] + if (currencyCodeAndAmount.length > 3) { + // TODO: check if the first three characters are letter +// if (currencyCodeAndAmount.length > 15) { +// return createInvalidFormatResult("The eighth line has to start with three upper case letters currency code and amount may not have more than 12 digits (including a dot as decimal separator).") +// } + + currencyCode = currencyCodeAndAmount.substring(0, 3) + amount = currencyCodeAndAmount.substring(3).toDouble() + } + +// if (lines[8].length > 4) { // omit check for parsing +// return createInvalidFormatResult("The purpose code in line 9, ${lines[8]}, may not be longer than 4 characters.") +// } + +// if (lines[9].length > 35) { // omit check for parsing +// return createInvalidFormatResult("The reconciliation reference in line 10, ${lines[9]}, may not be longer than 35 characters.") +// } + + val reconciliationText = if (lines.size < 11) null else parseToNullableString(lines[10])?.let { parseWithCoding(it, coding) } +// if ((reconciliationText?.length ?: 0) > 140) { // omit check for parsing +// return createInvalidFormatResult("The reconciliation text in line 11, ${lines[10]}, may not be longer than 140 characters.") +// } + + val displayText = if (lines.size < 12) null else lines[11] +// if (displayText != null && displayText.length > 70) { // omit check for parsing +// return createInvalidFormatResult("The display text in line 12, ${displayText}, may not be longer than 70 characters.") +// } + + return ParseEpcQrCodeResult( + decodedQrCode, ParseEpcQrCodeResultCode.Success, EpcQrCode( + lines[0], version, coding, lines[3], parseToNullableString(lines[4]), lines[5], lines[6], + currencyCode, amount, parseToNullableString(lines[8]), parseToNullableString(lines[9]), reconciliationText, displayText + ) + , null + ) + } + + protected open fun parseWithCoding(line: String, coding: EpcQrCodeCharacterSet): String { + return line // TODO: does encoding work out of the box? // TODO: are there any encodings with more than one byte per characters allowed per specification + } + + protected open fun parseToNullableString(lines: String): String? { + return if (isNotSet(lines)) null else lines + } + + protected open fun isNotSet(line: String): Boolean { + return line.isBlank() + } + + protected open fun createInvalidFormatResult(decodedQrCode: String, error: String): ParseEpcQrCodeResult { + return ParseEpcQrCodeResult(decodedQrCode, ParseEpcQrCodeResultCode.NotAValidEpcQrCode, null, error) + } + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeVersion.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeVersion.kt new file mode 100644 index 00000000..cf373dfc --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeVersion.kt @@ -0,0 +1,10 @@ +package net.codinux.banking.tools.epcqrcode + + +enum class EpcQrCodeVersion(val code: String) { + + Version1("001"), // cannot name it '1' + + Version2("002") + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResult.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResult.kt new file mode 100644 index 00000000..f6170b33 --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResult.kt @@ -0,0 +1,24 @@ +package net.codinux.banking.tools.epcqrcode + + +open class ParseEpcQrCodeResult( + open val decodedQrCode: String, + open val resultCode: ParseEpcQrCodeResultCode, + open val epcQrCode: EpcQrCode?, + open val error: String? +) { + + open val successful: Boolean + get() = resultCode == ParseEpcQrCodeResultCode.Success + + + override fun toString(): String { + return if (successful) { + "Success: $epcQrCode" + } + else { + "Error: $error" + } + } + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResultCode.kt b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResultCode.kt new file mode 100644 index 00000000..5b2831c1 --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonMain/kotlin/net/codinux/banking/tools/epcqrcode/ParseEpcQrCodeResultCode.kt @@ -0,0 +1,12 @@ +package net.codinux.banking.tools.epcqrcode + + +enum class ParseEpcQrCodeResultCode { + + Success, + + NotAValidEpcQrCode, + + UnpredictedErrorOccurred + +} \ No newline at end of file diff --git a/tools/EpcQrCodeParser/src/commonTest/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParserTest.kt b/tools/EpcQrCodeParser/src/commonTest/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParserTest.kt new file mode 100644 index 00000000..72adf3e0 --- /dev/null +++ b/tools/EpcQrCodeParser/src/commonTest/kotlin/net/codinux/banking/tools/epcqrcode/EpcQrCodeParserTest.kt @@ -0,0 +1,173 @@ +package net.codinux.banking.tools.epcqrcode + +import kotlin.test.* + + +class EpcQrCodeParserTest { + + private val underTest = EpcQrCodeParser() + + + @Test + fun wikipediaExampleBelgianRedCross() { + + // when + val result = underTest.parseEpcQrCode(""" +BCD +001 +1 +SCT +BPOTBEB1 +Red Cross +BE72000000001616 +EUR1 +CHAR + +Urgency fund +Sample EPC QR Code +""".trim()) + + // then + assertParsingSuccessful(result) + + assertEpcQrCode(result, EpcQrCodeVersion.Version1, EpcQrCodeCharacterSet.UTF_8, "SCT", "BPOTBEB1", "Red Cross", + "BE72000000001616", "EUR", 1.0, "CHAR", null, + "Urgency fund", "Sample EPC QR Code") + } + + @Test + fun spendeAnAerzteOhneGrenzen() { + + // when + val result = underTest.parseEpcQrCode(""" +BCD +001 +1 +SCT +BFSWDE33XXX +Ärzte ohne Grenzen e.V. +DE72370205000009709700 +EUR100 + + +Spende +Danke für Ihre Spende +""".trim()) + + // then + assertParsingSuccessful(result) + + assertEpcQrCode(result, EpcQrCodeVersion.Version1, EpcQrCodeCharacterSet.UTF_8, "SCT", "BFSWDE33XXX", "Ärzte ohne Grenzen e.V.", + "DE72370205000009709700", "EUR", 100.00, null, null, + "Spende", "Danke für Ihre Spende") + } + + @Test + fun stuzzaExample01() { + + // when + val result = underTest.parseEpcQrCode(""" +BCD +001 +1 +SCT +BICVXXDD123 +35 Zeichen langer Empfängername zum +XX17LandMitLangerIBAN2345678901234 +EUR12345689.01 + +35ZeichenLangeREFzurZuordnungBeimBe + +Netter Text für den Zahlenden, damit dieser weiß, was er zahlt und auc +""".trim()) + + // then + assertParsingSuccessful(result) + + assertEpcQrCode(result, EpcQrCodeVersion.Version1, EpcQrCodeCharacterSet.UTF_8, "SCT", "BICVXXDD123", "35 Zeichen langer Empfängername zum", + "XX17LandMitLangerIBAN2345678901234", "EUR", 12345689.01, null, "35ZeichenLangeREFzurZuordnungBeimBe", + null, "Netter Text für den Zahlenden, damit dieser weiß, was er zahlt und auc") + } + + @Test + fun stuzzaExample02() { + + // when + val result = underTest.parseEpcQrCode(""" +BCD +001 +1 +SCT +GIBAATWW +Max Mustermann +AT682011131032423628 +EUR1456.89 + +457845789452 + +Diverse Autoteile, Re 789452 KN 457845 +""".trim()) + + // then + assertParsingSuccessful(result) + + assertEpcQrCode(result, EpcQrCodeVersion.Version1, EpcQrCodeCharacterSet.UTF_8, "SCT", "GIBAATWW", "Max Mustermann", + "AT682011131032423628", "EUR", 1456.89, null, "457845789452", + null, "Diverse Autoteile, Re 789452 KN 457845") + } + + @Test + fun stuzzaExample07() { + + // when + val result = underTest.parseEpcQrCode(""" +BCD +002 +2 +SCT + +35 Zeichen langer Empfängername zum +XX17LandMitLangerIBAN2345678901234 +EUR12345689.01 + +35ZeichenLangeREFzurZuordnungBeimBe + +Netter Text für den Zahlenden, damit dieser weiß, was er zahlt und auc +""".trim()) + + // then + assertParsingSuccessful(result) + + assertEpcQrCode(result, EpcQrCodeVersion.Version2, EpcQrCodeCharacterSet.ISO_8895_1, "SCT", null, "35 Zeichen langer Empfängername zum", + "XX17LandMitLangerIBAN2345678901234", "EUR", 12345689.01, null, "35ZeichenLangeREFzurZuordnungBeimBe", + null, "Netter Text für den Zahlenden, damit dieser weiß, was er zahlt und auc") + } + + + private fun assertParsingSuccessful(result: ParseEpcQrCodeResult) { + assertTrue(result.successful) + assertNull(result.error) + assertNotNull(result.epcQrCode) + } + + private fun assertEpcQrCode(result: ParseEpcQrCodeResult, version: EpcQrCodeVersion, coding: EpcQrCodeCharacterSet, function: String, + bic: String?, receiver: String, iban: String, currency: String?, amount: Double?, + purposeCode: String?, reference: String?, text: String?, displayText: String?) { + + result.epcQrCode?.let { epcQrCode -> + assertEquals(version, epcQrCode.version) + assertEquals(coding, epcQrCode.coding) + assertEquals(function, epcQrCode.function) + assertEquals(bic, epcQrCode.bic) + assertEquals(receiver, epcQrCode.receiverName) + assertEquals(iban, epcQrCode.iban) + assertEquals(currency, epcQrCode.currencyCode) + assertEquals(amount, epcQrCode.amount) + assertEquals(purposeCode, epcQrCode.purposeCode) + assertEquals(reference, epcQrCode.remittanceReference) + assertEquals(text, epcQrCode.remittanceText) + assertEquals(displayText, epcQrCode.originatorInformation) + } + } + +} \ No newline at end of file