Implemented EpcQrCodeParser that extracts credit transfer data from decoded EPC QR code

This commit is contained in:
dankito 2020-10-21 18:06:59 +02:00
parent eff4e131df
commit 4ef8e7330f
12 changed files with 618 additions and 0 deletions

View File

@ -71,8 +71,10 @@ project(':fints4kRest').projectDir = "$rootDir/rest/fints4kRest/" as File
include ':BankFinder' include ':BankFinder'
include ':LuceneBankFinder' include ':LuceneBankFinder'
include ':BankListCreator' include ':BankListCreator'
include ':EpcQrCodeParser'
project(':BankFinder').projectDir = "$rootDir/tools/BankFinder/" as File project(':BankFinder').projectDir = "$rootDir/tools/BankFinder/" as File
project(':LuceneBankFinder').projectDir = "$rootDir/tools/LuceneBankFinder/" as File project(':LuceneBankFinder').projectDir = "$rootDir/tools/LuceneBankFinder/" as File
project(':BankListCreator').projectDir = "$rootDir/tools/BankListCreator/" as File project(':BankListCreator').projectDir = "$rootDir/tools/BankListCreator/" as File
project(':EpcQrCodeParser').projectDir = "$rootDir/tools/EpcQrCodeParser/" as File

View File

@ -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.)

View File

@ -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
}
}

View File

@ -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}"
}
}

View File

@ -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)
}

View File

@ -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<String>): 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)
}
}

View File

@ -0,0 +1,10 @@
package net.codinux.banking.tools.epcqrcode
enum class EpcQrCodeVersion(val code: String) {
Version1("001"), // cannot name it '1'
Version2("002")
}

View File

@ -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"
}
}
}

View File

@ -0,0 +1,12 @@
package net.codinux.banking.tools.epcqrcode
enum class ParseEpcQrCodeResultCode {
Success,
NotAValidEpcQrCode,
UnpredictedErrorOccurred
}

View File

@ -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)
}
}
}