From ece678e2423aa43c0b8f052768c5357865982f1d Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 14:57:47 +0200 Subject: [PATCH 01/16] feat(GiniCaptureSDK): Add new QR code format stubs (SPC, SPD, PayBySquare, UPNQR, HUB3) PP-2324 --- .../GiniCaptureDocumentValidator.swift | 3 ++ .../Core/Helpers/QRCodesExtractor.swift | 50 ++++++++++++------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift index eb87685254..a88ff4a195 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift @@ -109,6 +109,9 @@ fileprivate extension GiniCaptureDocumentValidator { if document.extractedParameters[QRCodesExtractor.giniCodeUrlKey] == nil { throw DocumentValidationError.qrCodeFormatNotValid } + case .some(.spc), .some(.spd), .some(.payBySquare), .some(.upnqr), .some(.hub3): + // Validation implemented in Step 6 + break case .none: throw DocumentValidationError.qrCodeFormatNotValid } diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index 958fefb4e1..b5efedc5f8 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift @@ -13,17 +13,23 @@ public enum QRCodesFormat { case eps4mobile case bezahl case giniQRCode + case spc + case spd + case payBySquare + case upnqr + case hub3 var prefixURL: String { switch self { - case .epc06912: - return "BCD" - case .eps4mobile: - return "epspayment://" - case .bezahl: - return "bank://" - case .giniQRCode: - return "https://pay.gini.net/" + case .epc06912: return "BCD" + case .eps4mobile: return "epspayment://" + case .bezahl: return "bank://" + case .giniQRCode: return "https://pay.gini.net/" + case .spc: return "SPC" + case .spd: return "SPD*" + case .payBySquare: return "" + case .upnqr: return "UPNQR" + case .hub3: return "HRVHUB3" } } } @@ -35,16 +41,16 @@ public final class QRCodesExtractor { class func extractParameters(from string: String, withFormat qrCodeFormat: QRCodesFormat?) -> [String: String] { switch qrCodeFormat { - case .some(.bezahl): - return extractParameters(fromBezhalCodeString: string) - case .some(.epc06912): - return extractParameters(fromEPC06912CodeString: string) - case .some(.eps4mobile): - return [epsCodeUrlKey: string] - case .some(.giniQRCode): - return [giniCodeUrlKey: string] - case .none: - return [:] + case .some(.bezahl): return extractParameters(fromBezhalCodeString: string) + case .some(.epc06912): return extractParameters(fromEPC06912CodeString: string) + case .some(.eps4mobile): return [epsCodeUrlKey: string] + case .some(.giniQRCode): return [giniCodeUrlKey: string] + case .some(.spc): return extractParameters(fromSPCCodeString: string) + case .some(.spd): return extractParameters(fromSPDCodeString: string) + case .some(.payBySquare): return extractParameters(fromPayBySquareString: string) + case .some(.upnqr): return extractParameters(fromUPNQRCodeString: string) + case .some(.hub3): return extractParameters(fromHUB3CodeString: string) + case .none: return [:] } } @@ -132,6 +138,14 @@ public final class QRCodesExtractor { return parameters } + // MARK: - New format extractors (implementations added in Step 3) + + class func extractParameters(fromSPCCodeString string: String) -> [String: String] { [:] } + class func extractParameters(fromSPDCodeString string: String) -> [String: String] { [:] } + class func extractParameters(fromPayBySquareString string: String) -> [String: String] { [:] } + class func extractParameters(fromUPNQRCodeString string: String) -> [String: String] { [:] } + class func extractParameters(fromHUB3CodeString string: String) -> [String: String] { [:] } + fileprivate class func normalize(amount: String, currency: String?) -> String? { let regexCurrency = try? NSRegularExpression(pattern: "[aA-zZ]", options: []) let length = amount.count < 3 ? amount.count : 3 From eeab7f2da49fac0d0198670c4a484db2f3dd8bd1 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 15:10:57 +0200 Subject: [PATCH 02/16] feat(GiniCaptureSDK): Add format detection for SPC, SPD, PayBySquare, UPNQR, HUB3 PP-2324 --- .../Core/Helpers/PayBySquareDecoder.swift | 29 +++++++++++++++ .../Core/Models/GiniQRCodeDocument.swift | 36 +++++++++++++------ 2 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift new file mode 100644 index 0000000000..a205cdf928 --- /dev/null +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -0,0 +1,29 @@ +// +// PayBySquareDecoder.swift +// GiniCaptureSDK +// + +import Foundation + +struct PayBySquarePayment { + let iban: String + let swift: String? + let amount: String + let currency: String + let payeeName: String + let paymentNote: String +} + +final class PayBySquareDecoder { + + static func looksLikePayBySquare(_ string: String) -> Bool { + let base32hexChars = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUV") + return string.count >= 16 + && string.unicodeScalars.allSatisfy { base32hexChars.contains($0) } + } + + // Full implementation (base32hex decode + LZ77 decompress) added in Step 4 + static func decode(_ string: String) -> PayBySquarePayment? { + return nil + } +} diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift index ad565196a4..2614fbbf33 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift @@ -51,18 +51,34 @@ import GiniUtilites return .bezahl } else if self.scannedString.starts(with: QRCodesFormat.eps4mobile.prefixURL) { return .eps4mobile - } else if let lines = Optional(self.scannedString.splitlines), - lines.count > 0 && lines[0] == QRCodesFormat.epc06912.prefixURL { - if lines.indices.contains(2) && !(lines[2] == "1" || lines[2] == "2") { - Log(message: "WARNING: Character set \(lines[2]) is unknown. Expected version 1 or 2.", - event: "EPC QR code") - } - - if lines.indices.contains(6) && IBANValidator().isValid(iban: lines[6]) { - return .epc06912 - } else { + } else if self.scannedString.starts(with: QRCodesFormat.spd.prefixURL) { + return .spd + } else if let lines = Optional(self.scannedString.splitlines), !lines.isEmpty { + switch lines[0] { + case QRCodesFormat.epc06912.prefixURL: + if lines.indices.contains(2) && !(lines[2] == "1" || lines[2] == "2") { + Log(message: "WARNING: Character set \(lines[2]) is unknown. Expected version 1 or 2.", + event: "EPC QR code") + } + if lines.indices.contains(6) && IBANValidator().isValid(iban: lines[6]) { + return .epc06912 + } return nil + case QRCodesFormat.spc.prefixURL: + if lines.indices.contains(3) && IBANValidator().isValid(iban: lines[3]) { + return .spc + } + return nil + case QRCodesFormat.upnqr.prefixURL: + return .upnqr + case QRCodesFormat.hub3.prefixURL: + return .hub3 + default: + if PayBySquareDecoder.looksLikePayBySquare(self.scannedString) { + return .payBySquare + } } + return nil } else { return nil } From 0a2b6a2ccf4a58a45e4a94c4e1ab4ded6d0a2d40 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 15:15:51 +0200 Subject: [PATCH 03/16] feat(GiniCaptureSDK): Implement parameter extraction for SPC, SPD, UPNQR, HUB3, PayBySquare PP-2324 --- .../Core/Helpers/QRCodesExtractor.swift | 130 +++++++++++++++++- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index b5efedc5f8..90e931776c 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift @@ -138,13 +138,131 @@ public final class QRCodesExtractor { return parameters } - // MARK: - New format extractors (implementations added in Step 3) + // MARK: - SPC (Swiss Payment Code / QR-bill) - class func extractParameters(fromSPCCodeString string: String) -> [String: String] { [:] } - class func extractParameters(fromSPDCodeString string: String) -> [String: String] { [:] } - class func extractParameters(fromPayBySquareString string: String) -> [String: String] { [:] } - class func extractParameters(fromUPNQRCodeString string: String) -> [String: String] { [:] } - class func extractParameters(fromHUB3CodeString string: String) -> [String: String] { [:] } + class func extractParameters(fromSPCCodeString string: String) -> [String: String] { + let lines = string.splitlines + var parameters: [String: String] = [:] + + if lines.indices.contains(3) && IBANValidator().isValid(iban: lines[3]) { + parameters["iban"] = lines[3] + } + if lines.indices.contains(5) && !lines[5].isEmpty { + parameters["paymentRecipient"] = lines[5] + } + let currency = lines.indices.contains(19) ? lines[19] : "CHF" + if lines.indices.contains(18) && !lines[18].isEmpty, + let amountToPay = normalize(amount: lines[18], currency: currency) { + parameters["amountToPay"] = amountToPay + } + // Reference value is only meaningful when reference type is not "NON" + if lines.indices.contains(27) && lines[27] != "NON", + lines.indices.contains(28) && !lines[28].isEmpty { + parameters["paymentReference"] = lines[28] + } + + return parameters + } + + // MARK: - SPD (Czech/Slovak Payment Descriptor) + + class func extractParameters(fromSPDCodeString string: String) -> [String: String] { + var parameters: [String: String] = [:] + let segments = string.components(separatedBy: "*").dropFirst(2) // skip "SPD" and version + + for segment in segments { + let parts = segment.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + let key = parts[0] + let value = parts[1] + switch key { + case "ACC": parameters["iban"] = value + case "AM": parameters["amountToPay"] = value + case "CC": + if let amount = parameters["amountToPay"] { + parameters["amountToPay"] = amount + ":" + value + } + case "RN": parameters["paymentRecipient"] = value + case "MSG": parameters["paymentReference"] = value + default: break + } + } + + return parameters + } + + // MARK: - Pay by Square (Slovak compressed QR) + + class func extractParameters(fromPayBySquareString string: String) -> [String: String] { + guard let decoded = PayBySquareDecoder.decode(string) else { return [:] } + var parameters: [String: String] = [:] + + parameters["iban"] = decoded.iban + parameters["paymentRecipient"] = decoded.payeeName + if !decoded.amount.isEmpty && !decoded.currency.isEmpty { + parameters["amountToPay"] = decoded.amount + ":" + decoded.currency + } + if !decoded.paymentNote.isEmpty { + parameters["paymentReference"] = decoded.paymentNote + } + if let bic = decoded.swift, !bic.isEmpty { + parameters["bic"] = bic + } + + return parameters + } + + // MARK: - UPNQR (Slovenian UPN QR) + + class func extractParameters(fromUPNQRCodeString string: String) -> [String: String] { + let lines = string.splitlines + var parameters: [String: String] = [:] + + if lines.indices.contains(9), let cents = Int(lines[9]) { + let amount = String(format: "%.2f", Double(cents) / 100.0) + parameters["amountToPay"] = amount + ":EUR" + } + if lines.indices.contains(13) && !lines[13].isEmpty { + parameters["paymentReference"] = lines[13] + } + if lines.indices.contains(15) { + parameters["iban"] = lines[15] + } + if lines.indices.contains(16) && !lines[16].isEmpty { + parameters["bic"] = lines[16] + } + if lines.indices.contains(17) && !lines[17].isEmpty { + parameters["paymentRecipient"] = lines[17] + } + + return parameters + } + + // MARK: - HUB3 (Croatian PDF417) + + class func extractParameters(fromHUB3CodeString string: String) -> [String: String] { + let lines = string.splitlines + var parameters: [String: String] = [:] + + let currency = lines.indices.contains(1) ? lines[1] : "EUR" + if lines.indices.contains(2), let cents = Int(lines[2]) { + let amount = String(format: "%.2f", Double(cents) / 100.0) + parameters["amountToPay"] = amount + ":" + currency + } + if lines.indices.contains(6) && !lines[6].isEmpty { + parameters["paymentRecipient"] = lines[6] + } + if lines.indices.contains(9) { + parameters["iban"] = lines[9] + } + let model = lines.indices.contains(10) ? lines[10] : "" + let reference = lines.indices.contains(11) ? lines[11] : "" + if !model.isEmpty || !reference.isEmpty { + parameters["paymentReference"] = model + "-" + reference + } + + return parameters + } fileprivate class func normalize(amount: String, currency: String?) -> String? { let regexCurrency = try? NSRegularExpression(pattern: "[aA-zZ]", options: []) From f2335699ab0d05bb3760873ced0ff008f5f57895 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 16:01:26 +0200 Subject: [PATCH 04/16] feat(GiniCaptureSDK): Implement PayBySquare base32hex and LZ77 decoder PP-2324 --- .../Core/Helpers/PayBySquareDecoder.swift | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift index a205cdf928..119d7ab354 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -22,8 +22,119 @@ final class PayBySquareDecoder { && string.unicodeScalars.allSatisfy { base32hexChars.contains($0) } } - // Full implementation (base32hex decode + LZ77 decompress) added in Step 4 static func decode(_ string: String) -> PayBySquarePayment? { - return nil + // 1. Base32hex decode: map each char to 5-bit value, pack into bytes + guard let bytes = base32hexDecode(string) else { return nil } + + // 2. Parse header (3 bytes): + // - Byte 0 bits 3-0: document type (0 = Payment) + // - Bytes 1-2: decompressed data length, little-endian uint16 + guard bytes.count >= 3 else { return nil } + let docType = bytes[0] & 0x0F + guard docType == 0 else { return nil } + let decompressedLength = Int(bytes[1]) | (Int(bytes[2]) << 8) + + // 3. LZ77 decompress bytes[3...] + guard let decompressed = lz77Decompress(Array(bytes[3...]), + expectedLength: decompressedLength) else { return nil } + + // 4. Decode as UTF-8 + guard let text = String(bytes: decompressed, encoding: .utf8) else { return nil } + + // 5. Tab-separated fields per BYSQUARE Payment spec: + // [0] InvoiceID [1] PaymentOptions [2] Amount [3] CurrencyCode + // [4] DueDate [5] VariableSymbol [6] ConstSymbol [7] SpecificSymbol + // [8] OriginatorRefInfo [9] PaymentNote [10] BankAccountsCount + // [11] IBAN [12] SWIFT/BIC [13] PayeeName + let fields = text.components(separatedBy: "\t") + guard fields.count > 13 else { return nil } + + return PayBySquarePayment(iban: fields[11], + swift: fields[12].isEmpty ? nil : fields[12], + amount: fields[2], + currency: fields[3], + payeeName: fields[13], + paymentNote: fields[9]) + } + + // MARK: - Private + + private static func base32hexDecode(_ string: String) -> [UInt8]? { + var accumulator: UInt32 = 0 + var bitsStored = 0 + var result = [UInt8]() + result.reserveCapacity(string.count * 5 / 8) + + for scalar in string.unicodeScalars { + let value: UInt32 + switch scalar.value { + case 48...57: value = scalar.value - 48 // '0'–'9' → 0–9 + case 65...86: value = scalar.value - 55 // 'A'–'V' → 10–31 + default: return nil + } + accumulator = (accumulator << 5) | value + bitsStored += 5 + if bitsStored >= 8 { + bitsStored -= 8 + result.append(UInt8((accumulator >> bitsStored) & 0xFF)) + } + } + + return result + } + + // Haruhiko Okumura LZSS variant used by the BYSQUARE standard. + // Window: 4096-byte ring buffer initialised to 0x20 (space). + // Control byte LSB-first: 1 = literal, 0 = back-reference (2 bytes). + // Back-reference encoding: b0 = low 8 bits of position, + // b1 high-nibble = bits 11-8 of position, b1 low-nibble = length - 3. + private static func lz77Decompress(_ data: [UInt8], expectedLength: Int) -> [UInt8]? { + let windowSize = 4096 + let minMatchLength = 3 // THRESHOLD + 1 + + var ringBuf = [UInt8](repeating: 0x20, count: windowSize) + var r = windowSize - 18 // initial insert position (= 4078) + var output = [UInt8]() + output.reserveCapacity(expectedLength) + + var i = 0 + + while i < data.count, output.count < expectedLength { + let flags = Int(data[i]) + i += 1 + + for bit in 0..<8 { + guard i < data.count, output.count < expectedLength else { break } + + if flags & (1 << bit) != 0 { + // Literal byte + let c = data[i] + i += 1 + output.append(c) + ringBuf[r] = c + r = (r + 1) % windowSize + } else { + // Back-reference + guard i + 1 < data.count else { return nil } + let b0 = Int(data[i]) + let b1 = Int(data[i + 1]) + i += 2 + + var position = b0 | ((b1 & 0xF0) << 4) + let length = (b1 & 0x0F) + minMatchLength + + for _ in 0.. Date: Mon, 22 Jun 2026 16:05:17 +0200 Subject: [PATCH 05/16] feat(-sdk): Add feat(GiniCaptureSDK): Enable PDF417 scanning for HUB3 support PP-2324 --- .../GiniCaptureSDK/Core/Screens/Camera/Camera.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift index 540b7c6c1e..df274b16f7 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift @@ -269,8 +269,9 @@ final class Camera: NSObject, CameraProtocol { } session.addOutput(qrOutput) qrOutput.setMetadataObjectsDelegate(self, queue: sessionQueue) - if qrOutput.availableMetadataObjectTypes.contains(.qr) { - qrOutput.metadataObjectTypes = [.qr] + let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .pdf417] + qrOutput.metadataObjectTypes = desiredTypes.filter { + qrOutput.availableMetadataObjectTypes.contains($0) } session.commitConfiguration() } @@ -497,7 +498,7 @@ extension Camera: AVCaptureMetadataOutputObjectsDelegate { } if let metadataObj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - metadataObj.type == AVMetadataObject.ObjectType.qr, + metadataObj.type == .qr || metadataObj.type == .pdf417, let metaString = qrCodeString(from: metadataObj) { let qrDocument = GiniQRCodeDocument(scannedString: metaString, uploadMetadata: generateUploadMetadata()) if giniConfiguration.qrCodeScanningEnabled || qrDocument.qrCodeFormat == .giniQRCode { From cd3ab5bc3f6a4d165b2676decf071648096c3d76 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 16:07:14 +0200 Subject: [PATCH 06/16] feat(GiniCaptureSDK): Add validation for SPC, SPD, PayBySquare, UPNQR, HUB3 QR formats PP-2324 --- .../Helpers/GiniCaptureDocumentValidator.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift index a88ff4a195..81f9cecff3 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift @@ -109,9 +109,21 @@ fileprivate extension GiniCaptureDocumentValidator { if document.extractedParameters[QRCodesExtractor.giniCodeUrlKey] == nil { throw DocumentValidationError.qrCodeFormatNotValid } - case .some(.spc), .some(.spd), .some(.payBySquare), .some(.upnqr), .some(.hub3): - // Validation implemented in Step 6 - break + case .some(.spc), .some(.upnqr), .some(.hub3): + guard let iban = document.extractedParameters["iban"], + IBANValidator().isValid(iban: iban) else { + throw DocumentValidationError.qrCodeFormatNotValid + } + case .some(.spd): + // SPD IBANs include non-SEPA formats; require presence but skip strict IBAN validation + if document.extractedParameters["iban"] == nil { + throw DocumentValidationError.qrCodeFormatNotValid + } + case .some(.payBySquare): + // Successful decode is indicated by non-empty extractedParameters + if document.extractedParameters.isEmpty { + throw DocumentValidationError.qrCodeFormatNotValid + } case .none: throw DocumentValidationError.qrCodeFormatNotValid } From bc3d6a3deca6afa589541b0eab95b58859ff9015 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 16:23:46 +0200 Subject: [PATCH 07/16] test(GiniCaptureSDK): Add unit tests for SPC, SPD, PayBySquare, UPNQR, HUB3 QR formats PP-2324 --- .../GiniQRCodeDocumentTests.swift | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift index 4fe75a668e..d7c95ed73a 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift @@ -6,6 +6,7 @@ // Copyright © 2017 Gini GmbH. All rights reserved. // +import Testing import XCTest @testable import GiniCaptureSDK final class GiniQRCodeDocumentTests: XCTestCase { @@ -484,3 +485,221 @@ final class GiniQRCodeDocumentTests: XCTestCase { withConfig: giniConfiguration)) } } + +// MARK: - SPC (Swiss Payment Code / QR-bill) + +@Suite("SPC QR Code") +struct SPCQRCodeTests { + + let config = GiniConfiguration() + + private func makeSPC(referenceType: String = "QRR", + reference: String = "210000000003139471430009017", + iban: String = "DE89370400440532013000") -> String { + var lines = Array(repeating: "", count: 31) + lines[0] = "SPC" + lines[1] = "0200" + lines[2] = "1" + lines[3] = iban + lines[4] = "S" + lines[5] = "Test Recipient" + lines[18] = "1234.50" + lines[19] = "CHF" + lines[27] = referenceType + lines[28] = reference + return lines.joined(separator: "\n") + } + + @Test func detectedAsSPC() { + let doc = GiniQRCodeDocument(scannedString: makeSPC()) + #expect(doc.qrCodeFormat == .spc) + } + + @Test func extractsIBANRecipientAndAmount() { + let doc = GiniQRCodeDocument(scannedString: makeSPC()) + #expect(doc.extractedParameters["iban"] == "DE89370400440532013000") + #expect(doc.extractedParameters["paymentRecipient"] == "Test Recipient") + #expect(doc.extractedParameters["amountToPay"] == "1234.50:CHF") + } + + @Test func extractsReferenceWhenQRR() { + let doc = GiniQRCodeDocument(scannedString: makeSPC()) + #expect(doc.extractedParameters["paymentReference"] == "210000000003139471430009017") + } + + @Test func noReferenceWhenNON() { + let doc = GiniQRCodeDocument(scannedString: makeSPC(referenceType: "NON", + reference: "210000000003139471430009017")) + #expect(doc.extractedParameters["paymentReference"] == nil) + } + + @Test func nilFormatForInvalidIBAN() { + let doc = GiniQRCodeDocument(scannedString: makeSPC(iban: "NOTANIBAN")) + #expect(doc.qrCodeFormat == nil) + } +} + +// MARK: - SPD (Czech/Slovak Payment Descriptor) + +@Suite("SPD QR Code") +struct SPDQRCodeTests { + + let config = GiniConfiguration() + + private let validSPD = "SPD*1.0*ACC:CZ6508000000192000145399*AM:100.50*CC:CZK*RN:Test Recipient*MSG:Invoice 123*" + + @Test func detectedAsSPD() { + let doc = GiniQRCodeDocument(scannedString: validSPD) + #expect(doc.qrCodeFormat == .spd) + } + + @Test func extractsAllFields() { + let doc = GiniQRCodeDocument(scannedString: validSPD) + #expect(doc.extractedParameters["iban"] == "CZ6508000000192000145399") + #expect(doc.extractedParameters["amountToPay"] == "100.50:CZK") + #expect(doc.extractedParameters["paymentRecipient"] == "Test Recipient") + #expect(doc.extractedParameters["paymentReference"] == "Invoice 123") + } + + @Test func amountHasNoCurrencySuffixWhenCCMissing() { + let doc = GiniQRCodeDocument(scannedString: "SPD*1.0*ACC:CZ6508000000192000145399*AM:100.50*RN:Test Recipient*") + #expect(doc.extractedParameters["amountToPay"] == "100.50") + } + + @Test func ibanNilWhenACCMissing() { + let doc = GiniQRCodeDocument(scannedString: "SPD*1.0*AM:100.50*CC:CZK*RN:Test Recipient*") + #expect(doc.extractedParameters["iban"] == nil) + } +} + +// MARK: - Pay by Square (Slovak compressed QR) + +@Suite("Pay by Square QR Code") +struct PayBySquareQRCodeTests { + + let config = GiniConfiguration() + + // 16-char base32hex string. bytes[0] decodes to docType != 0, so decode() returns nil. + private let detectionString = "ABCDEF0123456789" + + @Test func detectedAsPayBySquare() { + let doc = GiniQRCodeDocument(scannedString: detectionString) + #expect(doc.qrCodeFormat == .payBySquare) + } + + @Test func nonBase32HexStringNotDetected() { + // Lowercase letters are outside the base32hex alphabet (0–9, A–V) + let doc = GiniQRCodeDocument(scannedString: "abcdef0123456789") + #expect(doc.qrCodeFormat != .payBySquare) + } + + @Test func corruptPayloadYieldsEmptyParameters() { + let doc = GiniQRCodeDocument(scannedString: detectionString) + #expect(doc.extractedParameters.isEmpty) + } + + @Test func corruptPayloadFailsValidation() { + let doc = GiniQRCodeDocument(scannedString: detectionString) + #expect(throws: DocumentValidationError.qrCodeFormatNotValid) { + try GiniCaptureDocumentValidator.validate(doc, withConfig: config) + } + } + + // TODO: Add a test with a verified BYSQUARE test vector to cover correct field extraction + // (iban, paymentRecipient, amountToPay, paymentReference, bic) after full LZ77 decode. +} + +// MARK: - UPNQR (Slovenian UPN QR) + +@Suite("UPNQR QR Code") +struct UPNQRQRCodeTests { + + let config = GiniConfiguration() + + private func makeUPNQR(amountCents: String = "00000048050", + reference: String = "SI00 1234-5678", + iban: String = "DE89370400440532013000", + bic: String = "LJBASI2X", + payee: String = "Gini d.o.o.") -> String { + var lines = Array(repeating: "", count: 18) + lines[0] = "UPNQR" + lines[9] = amountCents + lines[13] = reference + lines[15] = iban + lines[16] = bic + lines[17] = payee + return lines.joined(separator: "\n") + } + + @Test func detectedAsUPNQR() { + let doc = GiniQRCodeDocument(scannedString: makeUPNQR()) + #expect(doc.qrCodeFormat == .upnqr) + } + + @Test func extractsAllFields() { + let doc = GiniQRCodeDocument(scannedString: makeUPNQR()) + #expect(doc.extractedParameters["iban"] == "DE89370400440532013000") + #expect(doc.extractedParameters["paymentRecipient"] == "Gini d.o.o.") + #expect(doc.extractedParameters["amountToPay"] == "480.50:EUR") + #expect(doc.extractedParameters["bic"] == "LJBASI2X") + #expect(doc.extractedParameters["paymentReference"] == "SI00 1234-5678") + } + + @Test func convertsCentsToDecimalEUR() { + let doc = GiniQRCodeDocument(scannedString: makeUPNQR(amountCents: "00000048050")) + #expect(doc.extractedParameters["amountToPay"] == "480.50:EUR") + } + + @Test func emptyReferenceOmitsPaymentReference() { + let doc = GiniQRCodeDocument(scannedString: makeUPNQR(reference: "")) + #expect(doc.extractedParameters["paymentReference"] == nil) + } +} + +// MARK: - HUB3 (Croatian PDF417) + +@Suite("HUB3 QR Code") +struct HUB3QRCodeTests { + + let config = GiniConfiguration() + + private func makeHUB3(currency: String = "EUR", + amountCents: String = "000000000010000", + payee: String = "Gini d.o.o.", + iban: String = "DE89370400440532013000", + model: String = "HR00", + reference: String = "1234567890") -> String { + var lines = Array(repeating: "", count: 12) + lines[0] = "HRVHUB3" + lines[1] = currency + lines[2] = amountCents + lines[6] = payee + lines[9] = iban + lines[10] = model + lines[11] = reference + return lines.joined(separator: "\n") + } + + @Test func detectedAsHUB3() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3()) + #expect(doc.qrCodeFormat == .hub3) + } + + @Test func extractsAllFields() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3()) + #expect(doc.extractedParameters["iban"] == "DE89370400440532013000") + #expect(doc.extractedParameters["paymentRecipient"] == "Gini d.o.o.") + #expect(doc.extractedParameters["amountToPay"] == "100.00:EUR") + #expect(doc.extractedParameters["paymentReference"] == "HR00-1234567890") + } + + @Test func convertsCentsToDecimalEUR() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3(amountCents: "000000000010000")) + #expect(doc.extractedParameters["amountToPay"] == "100.00:EUR") + } + + @Test func paymentReferenceFormattedAsModelDashReference() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3(model: "HR00", reference: "1234567890")) + #expect(doc.extractedParameters["paymentReference"] == "HR00-1234567890") + } +} From 045995e00216b66c53db30ada924b73bc7867f16 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Mon, 22 Jun 2026 17:11:30 +0200 Subject: [PATCH 08/16] fix(GiniCaptureSDK): Correct PayBySquare, UPNQR, HUB3 field parsing PP-2324 --- .../Core/Helpers/PayBySquareDecoder.swift | 181 ++++++++++-------- .../Core/Helpers/QRCodesExtractor.swift | 33 ++-- .../GiniQRCodeDocumentTests.swift | 43 +++-- 3 files changed, 140 insertions(+), 117 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift index 119d7ab354..639cb41d7c 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -4,6 +4,7 @@ // import Foundation +import Compression struct PayBySquarePayment { let iban: String @@ -11,54 +12,80 @@ struct PayBySquarePayment { let amount: String let currency: String let payeeName: String - let paymentNote: String + let paymentReference: String } final class PayBySquareDecoder { static func looksLikePayBySquare(_ string: String) -> Bool { + let upper = string.uppercased() let base32hexChars = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUV") - return string.count >= 16 - && string.unicodeScalars.allSatisfy { base32hexChars.contains($0) } + return upper.count >= 16 + && upper.unicodeScalars.allSatisfy { base32hexChars.contains($0) } } static func decode(_ string: String) -> PayBySquarePayment? { - // 1. Base32hex decode: map each char to 5-bit value, pack into bytes - guard let bytes = base32hexDecode(string) else { return nil } - - // 2. Parse header (3 bytes): - // - Byte 0 bits 3-0: document type (0 = Payment) - // - Bytes 1-2: decompressed data length, little-endian uint16 - guard bytes.count >= 3 else { return nil } - let docType = bytes[0] & 0x0F - guard docType == 0 else { return nil } - let decompressedLength = Int(bytes[1]) | (Int(bytes[2]) << 8) - - // 3. LZ77 decompress bytes[3...] - guard let decompressed = lz77Decompress(Array(bytes[3...]), - expectedLength: decompressedLength) else { return nil } - - // 4. Decode as UTF-8 - guard let text = String(bytes: decompressed, encoding: .utf8) else { return nil } - - // 5. Tab-separated fields per BYSQUARE Payment spec: - // [0] InvoiceID [1] PaymentOptions [2] Amount [3] CurrencyCode - // [4] DueDate [5] VariableSymbol [6] ConstSymbol [7] SpecificSymbol - // [8] OriginatorRefInfo [9] PaymentNote [10] BankAccountsCount - // [11] IBAN [12] SWIFT/BIC [13] PayeeName + let upper = string.uppercased() + + // 1. Base32hex decode + guard let bytes = base32hexDecode(upper) else { return nil } + + // 2. Parse 4-byte bysquare header: + // bytes[0-1]: bysquare header (upper nibble of byte 0 = bysquare type; 0 = PAY) + // bytes[2-3]: payload length (little-endian uint16 = decompressed size including CRC32) + guard bytes.count > 4 else { return nil } + let bysquareType = (Int(bytes[0]) >> 4) & 0x0F + guard bysquareType == 0 else { return nil } + let payloadLength = Int(bytes[2]) | (Int(bytes[3]) << 8) + guard payloadLength > 4 else { return nil } + + // 3. LZMA decompress bytes[4...] + guard let decompressed = lzmaDecompress(rawBody: Array(bytes[4...]), + payloadLength: payloadLength) else { return nil } + + // 4. Skip 4-byte CRC32 prefix; decode remaining as UTF-8 + guard decompressed.count > 4 else { return nil } + guard let text = String(bytes: Array(decompressed[4...]), encoding: .utf8) else { return nil } + + // 5. Tab-separated fields (bysquare spec v1.1): + // [0] invoiceId [1] paymentsCount [2] paymentType [3] amount + // [4] currencyCode [5] paymentDueDate [6] variableSymbol [7] constantSymbol + // [8] specificSymbol [9] originatorRef [10] paymentNote [11] bankAccountsCount + // [12] IBAN [13] BIC ...extensions... [12+N*2+2] beneficiaryName let fields = text.components(separatedBy: "\t") - guard fields.count > 13 else { return nil } - - return PayBySquarePayment(iban: fields[11], - swift: fields[12].isEmpty ? nil : fields[12], - amount: fields[2], - currency: fields[3], - payeeName: fields[13], - paymentNote: fields[9]) + let banksCount = max(1, Int(fields.indices.contains(11) ? fields[11].trimmingCharacters(in: .whitespaces) : "") ?? 1) + let beneficiaryIdx = 12 + banksCount * 2 + 2 + guard fields.indices.contains(beneficiaryIdx) else { return nil } + + let iban = fields.indices.contains(12) ? fields[12] : "" + let bic = fields.indices.contains(13) && !fields[13].isEmpty ? fields[13] : nil + let amount = fields.indices.contains(3) ? fields[3] : "" + let currency = fields.indices.contains(4) ? fields[4] : "" + let beneficiaryName = fields[beneficiaryIdx] + let variableSymbol = fields.indices.contains(6) ? fields[6] : "" + let paymentNote = fields.indices.contains(10) ? fields[10] : "" + let reference = buildReference(variableSymbol: variableSymbol, paymentNote: paymentNote) + + return PayBySquarePayment(iban: iban, + swift: bic, + amount: amount, + currency: currency, + payeeName: beneficiaryName, + paymentReference: reference) } // MARK: - Private + private static func buildReference(variableSymbol: String, paymentNote: String) -> String { + if !variableSymbol.isEmpty && !paymentNote.isEmpty { + return variableSymbol + " " + paymentNote + } else if !variableSymbol.isEmpty { + return variableSymbol + } else { + return paymentNote + } + } + private static func base32hexDecode(_ string: String) -> [UInt8]? { var accumulator: UInt32 = 0 var bitsStored = 0 @@ -83,58 +110,44 @@ final class PayBySquareDecoder { return result } - // Haruhiko Okumura LZSS variant used by the BYSQUARE standard. - // Window: 4096-byte ring buffer initialised to 0x20 (space). - // Control byte LSB-first: 1 = literal, 0 = back-reference (2 bytes). - // Back-reference encoding: b0 = low 8 bits of position, - // b1 high-nibble = bits 11-8 of position, b1 low-nibble = length - 3. - private static func lz77Decompress(_ data: [UInt8], expectedLength: Int) -> [UInt8]? { - let windowSize = 4096 - let minMatchLength = 3 // THRESHOLD + 1 - - var ringBuf = [UInt8](repeating: 0x20, count: windowSize) - var r = windowSize - 18 // initial insert position (= 4078) - var output = [UInt8]() - output.reserveCapacity(expectedLength) - - var i = 0 - - while i < data.count, output.count < expectedLength { - let flags = Int(data[i]) - i += 1 - - for bit in 0..<8 { - guard i < data.count, output.count < expectedLength else { break } - - if flags & (1 << bit) != 0 { - // Literal byte - let c = data[i] - i += 1 - output.append(c) - ringBuf[r] = c - r = (r + 1) % windowSize - } else { - // Back-reference - guard i + 1 < data.count else { return nil } - let b0 = Int(data[i]) - let b1 = Int(data[i + 1]) - i += 2 - - var position = b0 | ((b1 & 0xF0) << 4) - let length = (b1 & 0x0F) + minMatchLength - - for _ in 0.. [UInt8]? { + // Build the 13-byte LZMA "alone" header: + // 1 byte properties: 0x5D = (pb*5+lp)*9+lc = (2*5+0)*9+3 = 93 = 0x5D + // 4 bytes dictionary size: 131072 = 0x00020000, little-endian + // 8 bytes uncompressed size: payloadLength, little-endian uint64 + let header: [UInt8] = [ + 0x5D, + 0x00, 0x00, 0x02, 0x00, + UInt8( payloadLength & 0xFF), + UInt8((payloadLength >> 8) & 0xFF), + UInt8((payloadLength >> 16) & 0xFF), + UInt8((payloadLength >> 24) & 0xFF), + 0, 0, 0, 0 + ] + + let fullData = header + rawBody + var dest = [UInt8](repeating: 0, count: payloadLength) + + let result = fullData.withUnsafeBytes { srcPtr -> Int in + guard let srcBase = srcPtr.baseAddress else { return 0 } + return dest.withUnsafeMutableBytes { dstPtr -> Int in + guard let dstBase = dstPtr.baseAddress else { return 0 } + return compression_decode_buffer( + dstBase.assumingMemoryBound(to: UInt8.self), + payloadLength, + srcBase.assumingMemoryBound(to: UInt8.self), + fullData.count, + nil, + COMPRESSION_LZMA + ) } } - return output.count == expectedLength ? output : nil + guard result == payloadLength else { return nil } + return dest } } diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index 90e931776c..4bd5830cff 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift @@ -29,7 +29,7 @@ public enum QRCodesFormat { case .spd: return "SPD*" case .payBySquare: return "" case .upnqr: return "UPNQR" - case .hub3: return "HRVHUB3" + case .hub3: return "HRVHUB30" } } } @@ -202,8 +202,8 @@ public final class QRCodesExtractor { if !decoded.amount.isEmpty && !decoded.currency.isEmpty { parameters["amountToPay"] = decoded.amount + ":" + decoded.currency } - if !decoded.paymentNote.isEmpty { - parameters["paymentReference"] = decoded.paymentNote + if !decoded.paymentReference.isEmpty { + parameters["paymentReference"] = decoded.paymentReference } if let bic = decoded.swift, !bic.isEmpty { parameters["bic"] = bic @@ -218,21 +218,24 @@ public final class QRCodesExtractor { let lines = string.splitlines var parameters: [String: String] = [:] - if lines.indices.contains(9), let cents = Int(lines[9]) { + if lines.indices.contains(8), let cents = Int(lines[8]) { let amount = String(format: "%.2f", Double(cents) / 100.0) parameters["amountToPay"] = amount + ":EUR" } - if lines.indices.contains(13) && !lines[13].isEmpty { - parameters["paymentReference"] = lines[13] + let primaryRef = lines.indices.contains(12) ? lines[12] : "" + let fallbackRef = lines.indices.contains(4) ? lines[4] : "" + let reference = primaryRef.isEmpty ? fallbackRef : primaryRef + if !reference.isEmpty { + parameters["paymentReference"] = reference } - if lines.indices.contains(15) { - parameters["iban"] = lines[15] + if lines.indices.contains(14) { + parameters["iban"] = lines[14] } - if lines.indices.contains(16) && !lines[16].isEmpty { - parameters["bic"] = lines[16] + if lines.indices.contains(13) && !lines[13].isEmpty { + parameters["bic"] = lines[13] } - if lines.indices.contains(17) && !lines[17].isEmpty { - parameters["paymentRecipient"] = lines[17] + if lines.indices.contains(16) && !lines[16].isEmpty { + parameters["paymentRecipient"] = lines[16] } return parameters @@ -255,10 +258,8 @@ public final class QRCodesExtractor { if lines.indices.contains(9) { parameters["iban"] = lines[9] } - let model = lines.indices.contains(10) ? lines[10] : "" - let reference = lines.indices.contains(11) ? lines[11] : "" - if !model.isEmpty || !reference.isEmpty { - parameters["paymentReference"] = model + "-" + reference + if lines.indices.contains(11) && !lines[11].isEmpty { + parameters["paymentReference"] = lines[11] } return parameters diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift index d7c95ed73a..e2557d4b45 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift @@ -579,7 +579,7 @@ struct PayBySquareQRCodeTests { let config = GiniConfiguration() - // 16-char base32hex string. bytes[0] decodes to docType != 0, so decode() returns nil. + // 16-char base32hex string. Upper nibble of bytes[0] = bysquareType != 0, so decode() returns nil. private let detectionString = "ABCDEF0123456789" @Test func detectedAsPayBySquare() { @@ -606,7 +606,7 @@ struct PayBySquareQRCodeTests { } // TODO: Add a test with a verified BYSQUARE test vector to cover correct field extraction - // (iban, paymentRecipient, amountToPay, paymentReference, bic) after full LZ77 decode. + // (iban, paymentRecipient, amountToPay, paymentReference, bic) after full LZMA decode. } // MARK: - UPNQR (Slovenian UPN QR) @@ -621,13 +621,13 @@ struct UPNQRQRCodeTests { iban: String = "DE89370400440532013000", bic: String = "LJBASI2X", payee: String = "Gini d.o.o.") -> String { - var lines = Array(repeating: "", count: 18) + var lines = Array(repeating: "", count: 20) lines[0] = "UPNQR" - lines[9] = amountCents - lines[13] = reference - lines[15] = iban - lines[16] = bic - lines[17] = payee + lines[8] = amountCents + lines[12] = reference + lines[13] = bic + lines[14] = iban + lines[16] = payee return lines.joined(separator: "\n") } @@ -654,6 +654,17 @@ struct UPNQRQRCodeTests { let doc = GiniQRCodeDocument(scannedString: makeUPNQR(reference: "")) #expect(doc.extractedParameters["paymentReference"] == nil) } + + @Test func fallsBackToPayerReferenceWhenPaymentReferenceIsEmpty() { + var lines = Array(repeating: "", count: 20) + lines[0] = "UPNQR" + lines[4] = "SI01-999" + lines[8] = "0000010000" + lines[14] = "DE89370400440532013000" + lines[16] = "Gini d.o.o." + let doc = GiniQRCodeDocument(scannedString: lines.joined(separator: "\n")) + #expect(doc.extractedParameters["paymentReference"] == "SI01-999") + } } // MARK: - HUB3 (Croatian PDF417) @@ -667,15 +678,13 @@ struct HUB3QRCodeTests { amountCents: String = "000000000010000", payee: String = "Gini d.o.o.", iban: String = "DE89370400440532013000", - model: String = "HR00", - reference: String = "1234567890") -> String { - var lines = Array(repeating: "", count: 12) - lines[0] = "HRVHUB3" + reference: String = "HR00 1234567890") -> String { + var lines = Array(repeating: "", count: 14) + lines[0] = "HRVHUB30" lines[1] = currency lines[2] = amountCents lines[6] = payee lines[9] = iban - lines[10] = model lines[11] = reference return lines.joined(separator: "\n") } @@ -690,7 +699,7 @@ struct HUB3QRCodeTests { #expect(doc.extractedParameters["iban"] == "DE89370400440532013000") #expect(doc.extractedParameters["paymentRecipient"] == "Gini d.o.o.") #expect(doc.extractedParameters["amountToPay"] == "100.00:EUR") - #expect(doc.extractedParameters["paymentReference"] == "HR00-1234567890") + #expect(doc.extractedParameters["paymentReference"] == "HR00 1234567890") } @Test func convertsCentsToDecimalEUR() { @@ -698,8 +707,8 @@ struct HUB3QRCodeTests { #expect(doc.extractedParameters["amountToPay"] == "100.00:EUR") } - @Test func paymentReferenceFormattedAsModelDashReference() { - let doc = GiniQRCodeDocument(scannedString: makeHUB3(model: "HR00", reference: "1234567890")) - #expect(doc.extractedParameters["paymentReference"] == "HR00-1234567890") + @Test func paymentReferenceUsesCallNumberFieldDirectly() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3(reference: "HR99 555-666")) + #expect(doc.extractedParameters["paymentReference"] == "HR99 555-666") } } From 9dbbe45e4a5cec41a0fc819fd56f3fb3c5ea7e59 Mon Sep 17 00:00:00 2001 From: Mahdi Abolfazli Date: Wed, 24 Jun 2026 13:58:23 +0200 Subject: [PATCH 09/16] fix(GiniCaptureSDK): Replace Apple LZMA2 with pure Swift LZMA1 decoder - Add pure Swift LZMA1 decoder (LZMADecoder.swift) since Apple's Compression framework only supports XZ/LZMA2, not the raw LZMA1 format used by the bysquare spec - Fix PayBySquare beneficiary index calculation to account for PaymentOptions extension fields (standing order/direct debit) - Fix looksLikePayBySquare to avoid redundant uppercased() call - Add official bysquare spec test vector with full decode coverage PP-2324 --- .../Core/Helpers/LZMADecoder.swift | 336 ++++++++++++++++++ .../Core/Helpers/PayBySquareDecoder.swift | 62 +--- .../GiniQRCodeDocumentTests.swift | 38 +- 3 files changed, 385 insertions(+), 51 deletions(-) create mode 100644 CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/LZMADecoder.swift diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/LZMADecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/LZMADecoder.swift new file mode 100644 index 0000000000..72dda420c9 --- /dev/null +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/LZMADecoder.swift @@ -0,0 +1,336 @@ +// +// LZMADecoder.swift +// GiniCaptureSDK +// +// Pure Swift LZMA1 decoder for the bysquare QR payment format. +// Parameters are fixed to the values mandated by the bysquare specification: +// lc = 3, lp = 0, pb = 2, dictSize = 131 072 (128 KB). +// +// Based on the public-domain LZMA SDK reference by Igor Pavlov +// (https://www.7-zip.org/sdk.html). +// +// Apple's Compression framework (COMPRESSION_LZMA) only decodes XZ/LZMA2 streams +// and cannot decode the LZMA1 "alone" format used by bysquare. +// + +import Foundation + +enum LZMADecoder { + + // MARK: – Public entry point + + /// Decompresses a raw LZMA1 bitstream using the bysquare fixed parameters. + /// + /// - Parameters: + /// - input: Raw LZMA1 compressed bytes. The first byte must be `0x00` + /// (mandatory range-coder invariant); bytes 1–4 are the initial + /// range-coder `code` value (big-endian). + /// - outputLength: Expected decompressed byte count. + /// - Returns: Exactly `outputLength` decompressed bytes, or `nil` on any error. + static func decode(input: [UInt8], outputLength: Int) -> [UInt8]? { + guard outputLength > 0 else { return [] } + guard input.count >= 5, input[0] == 0x00 else { return nil } + + var p = Probs() + var rc = RangeDecoder(input: input) + + // Sliding-window dictionary (ring buffer) + var dict = [UInt8](repeating: 0, count: dictSize) + var dictPos = 0 // total bytes written; use (dictPos & dictMask) to index dict + + // LZMA state + var state = 0 + var rep0: UInt32 = 0, rep1: UInt32 = 0, + rep2: UInt32 = 0, rep3: UInt32 = 0 + + var output = [UInt8]() + output.reserveCapacity(outputLength) + + while output.count < outputLength { + let posState = dictPos & posStateMask // dictPos & 3 for pb=2 + + if rc.decodeBit(probs: &p.isMatch, index: state * numPosStates + posState) == 0 { + + // ── LITERAL ───────────────────────────────────────────────────────── + let prevByte: UInt8 = dictPos > 0 ? dict[(dictPos - 1) & dictMask] : 0 + // litState = prevByte >> (8 - lc) = prevByte >> 5 (lp=0, lc=3) + let litState = Int(prevByte) >> (8 - lc) + let base = 0x300 * litState + + var sym = 1 + if state >= 7 { + // Matched-literal: use context from last-match position + var matchByte = dict[(dictPos - Int(rep0) - 1) & dictMask] + while sym < 0x100 { + let matchBit = Int(matchByte >> 7) & 1 + matchByte <<= 1 + let bit = rc.decodeBit(probs: &p.litProbs, + index: base + ((1 + matchBit) << 8) + sym) + sym = (sym << 1) | bit + if matchBit != bit { break } // divergence → switch to plain decode + } + } + while sym < 0x100 { + sym = (sym << 1) | rc.decodeBit(probs: &p.litProbs, index: base + sym) + } + + let byte = UInt8(sym & 0xFF) + dict[dictPos & dictMask] = byte + dictPos += 1 + output.append(byte) + state = litNextState[state] + + } else { + + // ── MATCH or REP ───────────────────────────────────────────────────── + let len: Int + + if rc.decodeBit(probs: &p.isRep, index: state) == 0 { + + // ── NEW MATCH (new back-reference distance) ────────────────────── + rep3 = rep2; rep2 = rep1; rep1 = rep0 + + let rawLen = rc.decodeLen(choice: &p.matchLenChoice, + low: &p.matchLenLow, + mid: &p.matchLenMid, + high: &p.matchLenHigh, + posState: posState) + len = rawLen + kMatchMinLen + let lenState = min(rawLen, numLenToPosStates - 1) + let posSlot = rc.decodeBitTree(probs: &p.posSlotProbs, + offset: lenState * numPosSlots, + numBits: numPosSlotBits) + var dist: UInt32 + if posSlot < kStartPosModelIndex { + // Slots 0–3: distance equals slot number + dist = UInt32(posSlot) + } else { + let numDirBits = (posSlot >> 1) - 1 + + if posSlot < kEndPosModelIndex { + // Slots 4–13: decode remaining bits via the special-position model + let distBase = (2 | (posSlot & 1)) << numDirBits + let specOff = distBase - posSlot - 1 // offset into specProbs + dist = UInt32(distBase) + dist |= UInt32(rc.decodeReverseBitTree(probs: &p.specProbs, + offset: specOff, + numBits: numDirBits)) + } else { + // Slots 14+: (numDirBits - 4) direct bits + 4 align bits + let distBase = (2 | (posSlot & 1)) + let directBits = rc.decodeDirectBits(numBits: numDirBits - numAlignBits) + dist = UInt32(distBase) << UInt32(numDirBits) + dist |= UInt32(directBits) << UInt32(numAlignBits) + dist |= UInt32(rc.decodeReverseBitTree(probs: &p.alignProbs, + offset: -1, + numBits: numAlignBits)) + } + } + rep0 = dist + state = matchNextState[state] + + } else { + + // ── REP (reuse a saved back-reference distance) ────────────────── + if rc.decodeBit(probs: &p.isRepG0, index: state) == 0 { + if rc.decodeBit(probs: &p.isRep0Long, + index: state * numPosStates + posState) == 0 { + // Short rep: copy exactly 1 byte from distance rep0 + let b = dict[(dictPos - Int(rep0) - 1) & dictMask] + dict[dictPos & dictMask] = b + dictPos += 1 + output.append(b) + state = shortRepNextState[state] + continue + } + // Long rep0 — distance stays as rep0, fall through to copy + } else if rc.decodeBit(probs: &p.isRepG1, index: state) == 0 { + swap(&rep0, &rep1) + } else if rc.decodeBit(probs: &p.isRepG2, index: state) == 0 { + let tmp = rep2; rep2 = rep1; rep1 = rep0; rep0 = tmp + } else { + let tmp = rep3; rep3 = rep2; rep2 = rep1; rep1 = rep0; rep0 = tmp + } + + let rawLen = rc.decodeLen(choice: &p.repLenChoice, + low: &p.repLenLow, + mid: &p.repLenMid, + high: &p.repLenHigh, + posState: posState) + len = rawLen + kMatchMinLen + state = repNextState[state] + } + + // Copy `len` bytes from the dictionary at distance rep0 + for _ in 0.. 1): states < 7 → 8, else → 11 + private static let repNextState: [Int] = [8, 8, 8, 8, 8, 8, 8, 11, 11, 11, 11, 11] + // After short rep (length == 1): states < 7 → 9, else → 11 + private static let shortRepNextState: [Int] = [9, 9, 9, 9, 9, 9, 9, 11, 11, 11, 11, 11] + + // MARK: – Probability tables + + // All tables initialised to the LZMA midpoint probability (1024 = 2048 / 2). + private struct Probs { + // 0x300 entries per literal state; (lc+lp)=3 → 8 literal states → 6144 total + var litProbs = [UInt16](repeating: 1024, count: 6144) + var isMatch = [UInt16](repeating: 1024, count: 48) // 12 states × 4 pos-states + var isRep = [UInt16](repeating: 1024, count: 12) + var isRepG0 = [UInt16](repeating: 1024, count: 12) + var isRepG1 = [UInt16](repeating: 1024, count: 12) + var isRepG2 = [UInt16](repeating: 1024, count: 12) + var isRep0Long = [UInt16](repeating: 1024, count: 48) // 12 × 4 + var posSlotProbs = [UInt16](repeating: 1024, count: 256) // 4 len-states × 64 slots + // specProbs: 114 entries spanning slots 4..13 (2+2+4+4+8+8+16+16+32+32) + var specProbs = [UInt16](repeating: 1024, count: 114) + var alignProbs = [UInt16](repeating: 1024, count: 16) // 4-bit align bit-tree + // Length coders (match and rep share the same structure) + var matchLenChoice = [UInt16](repeating: 1024, count: 2) + var matchLenLow = [UInt16](repeating: 1024, count: 32) // 4 pos-states × 8 symbols + var matchLenMid = [UInt16](repeating: 1024, count: 32) + var matchLenHigh = [UInt16](repeating: 1024, count: 256) + var repLenChoice = [UInt16](repeating: 1024, count: 2) + var repLenLow = [UInt16](repeating: 1024, count: 32) + var repLenMid = [UInt16](repeating: 1024, count: 32) + var repLenHigh = [UInt16](repeating: 1024, count: 256) + } +} + +// MARK: – Range Decoder + +private struct RangeDecoder { + + private static let topMask: UInt32 = 0xFF00_0000 + private static let bitModelTotal: UInt32 = 2048 + private static let numMoveBits = 5 + + var range: UInt32 = 0xFFFF_FFFF + var code: UInt32 + var input: [UInt8] + var pos: Int = 5 + + init(input: [UInt8]) { + self.input = input + // First byte (index 0) is always 0x00 in valid LZMA1 streams. + // Bytes 1–4 initialise the range-coder 'code' register (big-endian). + code = UInt32(input[1]) << 24 | UInt32(input[2]) << 16 + | UInt32(input[3]) << 8 | UInt32(input[4]) + } + + // Bring range back above the top-byte boundary + private mutating func normalize() { + if range & Self.topMask == 0 { + range <<= 8 + let next: UInt32 = pos < input.count ? UInt32(input[pos]) : 0 + code = (code << 8) | next + pos += 1 + } + } + + // Decode one probability-modelled bit; updates the probability in place + mutating func decodeBit(probs: inout [UInt16], index: Int) -> Int { + let prob = UInt32(probs[index]) + let bound = (range >> 11) * prob + if code < bound { + range = bound + probs[index] += UInt16((Self.bitModelTotal - prob) >> Self.numMoveBits) + normalize() + return 0 + } else { + range -= bound + code -= bound + probs[index] -= UInt16(prob >> Self.numMoveBits) + normalize() + return 1 + } + } + + // Standard (big-endian) bit tree: MSB decoded first; result in 0..(2^numBits - 1) + mutating func decodeBitTree(probs: inout [UInt16], offset: Int, numBits: Int) -> Int { + var m = 1 + for _ in 0.. Int { + var m = 1, sym = 0 + for i in 0.. Int { + var result = 0 + for _ in 0..>= 1 + code &-= range // wrapping subtract + // If code underflowed (was < range), restore it and record a 1-bit + let underflow = Int(code >> 31) // 1 if MSB set after subtract + if underflow != 0 { code &+= range } + result = (result << 1) | underflow + normalize() + } + return result + } + + // Three-tier length coder; returns 0..(kNumLowLenSymbols + kNumMidLenSymbols + kNumHighLenSymbols - 1) + // The caller adds kMatchMinLen (= 2) to obtain the actual match/rep length. + mutating func decodeLen(choice: inout [UInt16], + low: inout [UInt16], + mid: inout [UInt16], + high: inout [UInt16], + posState: Int) -> Int { + if decodeBit(probs: &choice, index: 0) == 0 { + // Low tier: 8 symbols (3 bits) + return decodeBitTree(probs: &low, offset: posState * 8, numBits: 3) + } + if decodeBit(probs: &choice, index: 1) == 0 { + // Mid tier: 8 symbols (3 bits) + return 8 + decodeBitTree(probs: &mid, offset: posState * 8, numBits: 3) + } + // High tier: 256 symbols (8 bits) + return 16 + decodeBitTree(probs: &high, offset: 0, numBits: 8) + } +} diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift index 639cb41d7c..c6286e44b3 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -4,7 +4,6 @@ // import Foundation -import Compression struct PayBySquarePayment { let iban: String @@ -18,10 +17,9 @@ struct PayBySquarePayment { final class PayBySquareDecoder { static func looksLikePayBySquare(_ string: String) -> Bool { - let upper = string.uppercased() let base32hexChars = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUV") - return upper.count >= 16 - && upper.unicodeScalars.allSatisfy { base32hexChars.contains($0) } + return string.count >= 16 + && string.unicodeScalars.allSatisfy { base32hexChars.contains($0) } } static func decode(_ string: String) -> PayBySquarePayment? { @@ -39,9 +37,10 @@ final class PayBySquareDecoder { let payloadLength = Int(bytes[2]) | (Int(bytes[3]) << 8) guard payloadLength > 4 else { return nil } - // 3. LZMA decompress bytes[4...] - guard let decompressed = lzmaDecompress(rawBody: Array(bytes[4...]), - payloadLength: payloadLength) else { return nil } + // 3. LZMA1 decompress bytes[4...] + // Apple's COMPRESSION_LZMA only handles XZ/LZMA2; bysquare uses raw LZMA1. + guard let decompressed = LZMADecoder.decode(input: Array(bytes[4...]), + outputLength: payloadLength) else { return nil } // 4. Skip 4-byte CRC32 prefix; decode remaining as UTF-8 guard decompressed.count > 4 else { return nil } @@ -54,14 +53,18 @@ final class PayBySquareDecoder { // [12] IBAN [13] BIC ...extensions... [12+N*2+2] beneficiaryName let fields = text.components(separatedBy: "\t") let banksCount = max(1, Int(fields.indices.contains(11) ? fields[11].trimmingCharacters(in: .whitespaces) : "") ?? 1) - let beneficiaryIdx = 12 + banksCount * 2 + 2 - guard fields.indices.contains(beneficiaryIdx) else { return nil } + // Extension fields are controlled by the PaymentOptions bitmask (field[2]): + // bit 0 (standing order): +4 extension fields + // bit 1 (direct debit): +10 extension fields + let paymentOptions = Int(fields.indices.contains(2) ? fields[2] : "") ?? 0 + let extFields = ((paymentOptions & 1) != 0 ? 4 : 0) + ((paymentOptions & 2) != 0 ? 10 : 0) + let beneficiaryIdx = 12 + banksCount * 2 + extFields let iban = fields.indices.contains(12) ? fields[12] : "" let bic = fields.indices.contains(13) && !fields[13].isEmpty ? fields[13] : nil let amount = fields.indices.contains(3) ? fields[3] : "" let currency = fields.indices.contains(4) ? fields[4] : "" - let beneficiaryName = fields[beneficiaryIdx] + let beneficiaryName = fields.indices.contains(beneficiaryIdx) ? fields[beneficiaryIdx] : "" let variableSymbol = fields.indices.contains(6) ? fields[6] : "" let paymentNote = fields.indices.contains(10) ? fields[10] : "" let reference = buildReference(variableSymbol: variableSymbol, paymentNote: paymentNote) @@ -110,44 +113,5 @@ final class PayBySquareDecoder { return result } - /// Decompresses raw LZMA1 body (without the 13-byte LZMA "alone" header) using - /// Apple's Compression framework. Reconstructs the standard LZMA "alone" header - /// with the fixed bysquare parameters (lc=3, lp=0, pb=2, dictSize=131072) before - /// passing the data to the decoder. - private static func lzmaDecompress(rawBody: [UInt8], payloadLength: Int) -> [UInt8]? { - // Build the 13-byte LZMA "alone" header: - // 1 byte properties: 0x5D = (pb*5+lp)*9+lc = (2*5+0)*9+3 = 93 = 0x5D - // 4 bytes dictionary size: 131072 = 0x00020000, little-endian - // 8 bytes uncompressed size: payloadLength, little-endian uint64 - let header: [UInt8] = [ - 0x5D, - 0x00, 0x00, 0x02, 0x00, - UInt8( payloadLength & 0xFF), - UInt8((payloadLength >> 8) & 0xFF), - UInt8((payloadLength >> 16) & 0xFF), - UInt8((payloadLength >> 24) & 0xFF), - 0, 0, 0, 0 - ] - - let fullData = header + rawBody - var dest = [UInt8](repeating: 0, count: payloadLength) - - let result = fullData.withUnsafeBytes { srcPtr -> Int in - guard let srcBase = srcPtr.baseAddress else { return 0 } - return dest.withUnsafeMutableBytes { dstPtr -> Int in - guard let dstBase = dstPtr.baseAddress else { return 0 } - return compression_decode_buffer( - dstBase.assumingMemoryBound(to: UInt8.self), - payloadLength, - srcBase.assumingMemoryBound(to: UInt8.self), - fullData.count, - nil, - COMPRESSION_LZMA - ) - } - } - guard result == payloadLength else { return nil } - return dest - } } diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift index e2557d4b45..e83e75652c 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift @@ -605,8 +605,42 @@ struct PayBySquareQRCodeTests { } } - // TODO: Add a test with a verified BYSQUARE test vector to cover correct field extraction - // (iban, paymentRecipient, amountToPay, paymentReference, bic) after full LZMA decode. + // Official bysquare test vector from the bysquare spec (Confluence page 1300430853). + // Encodes: IBAN SK2811000000002620154106, amount 12 EUR, variable symbol 2017001, + // payment note "PAY bysquare - platba za webovú službu balík Wise". + private let officialTestVector = + "0008400060RT11GHI1H3ICS8RR40PJJKAMLODMK50MI251UDD11UNM7E306OLN8AGMJUTE" + + "SOV4TTES0CMS44EP8OJ9JO3RQP89UE7GME4GHQ9GG62L461V517BI4186SI8J5KT45VUGH" + + "OOG9AM35MC87I22BUPU8O2HQLVCDV1DMSQOT1BMEGH00" + + @Test func officialTestVectorDetectedAsPayBySquare() { + let doc = GiniQRCodeDocument(scannedString: officialTestVector) + #expect(doc.qrCodeFormat == .payBySquare) + } + + @Test func officialTestVectorDecodesIBAN() { + let doc = GiniQRCodeDocument(scannedString: officialTestVector) + #expect(doc.extractedParameters["iban"] == "SK2811000000002620154106") + } + + @Test func officialTestVectorDecodesAmount() { + let doc = GiniQRCodeDocument(scannedString: officialTestVector) + #expect(doc.extractedParameters["amountToPay"] == "12:EUR") + } + + @Test func officialTestVectorDecodesPaymentReference() { + // variableSymbol "2017001" + " " + paymentNote "PAY bysquare - platba za webovú službu balík Wise" + let expected = "2017001 PAY bysquare - platba za webov\u{00FA} službu bal\u{00ED}k Wise" + let doc = GiniQRCodeDocument(scannedString: officialTestVector) + #expect(doc.extractedParameters["paymentReference"] == expected) + } + + @Test func officialTestVectorPassesValidation() { + let doc = GiniQRCodeDocument(scannedString: officialTestVector) + #expect(throws: Never.self) { + try GiniCaptureDocumentValidator.validate(doc, withConfig: config) + } + } } // MARK: - UPNQR (Slovenian UPN QR) From 1b94fa569570ea5fbeb70902458c7ebf1f79ad6c Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:02:45 +0200 Subject: [PATCH 10/16] fix(GiniCaptureSDK): Fix base32hex test to use invalid alphabet char PP-2324 --- .../Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift index e2557d4b45..c89285227e 100644 --- a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift +++ b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift @@ -588,8 +588,8 @@ struct PayBySquareQRCodeTests { } @Test func nonBase32HexStringNotDetected() { - // Lowercase letters are outside the base32hex alphabet (0–9, A–V) - let doc = GiniQRCodeDocument(scannedString: "abcdef0123456789") + // 'W' is outside the base32hex alphabet (0–9, A–V); uppercasing won't rescue it + let doc = GiniQRCodeDocument(scannedString: "WWWWWWWWWWWWWWWW") #expect(doc.qrCodeFormat != .payBySquare) } From a7840184714e9e95921687dd1c97c3a627ef4af0 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:03:46 +0200 Subject: [PATCH 11/16] fix(GiniCaptureSDK): Default SPC currency to CHF when field is empty PP-2324 --- .../Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index 4bd5830cff..868b7f55f5 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift @@ -150,7 +150,7 @@ public final class QRCodesExtractor { if lines.indices.contains(5) && !lines[5].isEmpty { parameters["paymentRecipient"] = lines[5] } - let currency = lines.indices.contains(19) ? lines[19] : "CHF" + let currency = (lines.indices.contains(19) && !lines[19].isEmpty) ? lines[19] : "CHF" if lines.indices.contains(18) && !lines[18].isEmpty, let amountToPay = normalize(amount: lines[18], currency: currency) { parameters["amountToPay"] = amountToPay From 6744a9134d7b72974f97d84f7fa96125c21ac813 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:04:56 +0200 Subject: [PATCH 12/16] fix(GiniCaptureSDK): Default SPD currency to EUR when field is empty PP-2324 --- .../Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index 868b7f55f5..1946ac364b 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift @@ -247,7 +247,7 @@ public final class QRCodesExtractor { let lines = string.splitlines var parameters: [String: String] = [:] - let currency = lines.indices.contains(1) ? lines[1] : "EUR" + let currency = (lines.indices.contains(1) && !lines[1].isEmpty) ? lines[1] : "EUR" if lines.indices.contains(2), let cents = Int(lines[2]) { let amount = String(format: "%.2f", Double(cents) / 100.0) parameters["amountToPay"] = amount + ":" + currency From 7a79f1d7845849f014db7a6910b668846abd3bc2 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:06:33 +0200 Subject: [PATCH 13/16] fix(GiniCaptureSDK): Reject SPD QR codes with empty IBAN PP-2324 --- .../Core/Helpers/GiniCaptureDocumentValidator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift index 81f9cecff3..a07c810aa1 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift @@ -115,8 +115,8 @@ fileprivate extension GiniCaptureDocumentValidator { throw DocumentValidationError.qrCodeFormatNotValid } case .some(.spd): - // SPD IBANs include non-SEPA formats; require presence but skip strict IBAN validation - if document.extractedParameters["iban"] == nil { + // SPD IBANs include non-SEPA formats; require non-empty presence but skip strict IBAN validation + guard let iban = document.extractedParameters["iban"], !iban.isEmpty else { throw DocumentValidationError.qrCodeFormatNotValid } case .some(.payBySquare): From c15f97590001470de167a7ba0249eb8c6dccfd36 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:07:32 +0200 Subject: [PATCH 14/16] fix(GiniCaptureSDK): Validate IBAN presence in PayBySquare QR codes PP-2324 --- .../Core/Helpers/GiniCaptureDocumentValidator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift index a07c810aa1..48f9353bb0 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift @@ -120,8 +120,7 @@ fileprivate extension GiniCaptureDocumentValidator { throw DocumentValidationError.qrCodeFormatNotValid } case .some(.payBySquare): - // Successful decode is indicated by non-empty extractedParameters - if document.extractedParameters.isEmpty { + guard let iban = document.extractedParameters["iban"], !iban.isEmpty else { throw DocumentValidationError.qrCodeFormatNotValid } case .none: From d11bef41c46bdd1cd5f29b5df2c53e3f908db9b2 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 15:08:21 +0200 Subject: [PATCH 15/16] fix(GiniCaptureSDK): Update supported QR code formats in doc comment PP-2324 --- .../GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift index 2614fbbf33..21e7d15836 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift @@ -12,10 +12,15 @@ import GiniUtilites /** A Gini Capture document made from a QR code. - The Gini Capture SDK supports the following QR code formats: + The Gini Capture SDK supports the following QR code / payment code formats: - Bezahlcode (http://www.bezahlcode.de). - Stuzza (AT) and GiroCode (DE) (https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/quick-response-code-guidelines-enable-data-capture-initiation). - EPS E-Payment (https://eservice.stuzza.at/de/eps-ueberweisung-dokumentation/category/5-dokumentation.html). + - SPC / Swiss QR-bill (https://www.paymentstandards.ch). + - SPD – Czech/Slovak Payment Descriptor. + - Pay-by-Square (Slovak compressed QR, base32hex + LZMA). + - UPNQR – Slovenian Universal Payment Order QR. + - HUB3 – Croatian payment PDF417 barcode. */ @objc final public class GiniQRCodeDocument: NSObject, GiniCaptureDocument { From b1f68f4337010670da46a9f65a550ffeb6d52654 Mon Sep 17 00:00:00 2001 From: mozhganpeivandiansharbaf Date: Wed, 24 Jun 2026 16:42:07 +0200 Subject: [PATCH 16/16] fix(GiniCaptureSDK): Document why CRC32 validation is skipped PP-2324 --- .../GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift index c6286e44b3..f5fc812fc6 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -43,6 +43,8 @@ final class PayBySquareDecoder { outputLength: payloadLength) else { return nil } // 4. Skip 4-byte CRC32 prefix; decode remaining as UTF-8 + // Note: CRC32 validation was attempted but rejected valid codes from the field — the bysquare + // spec's exact CRC32 variant and byte layout need a verified test vector before re-enabling. guard decompressed.count > 4 else { return nil } guard let text = String(bytes: Array(decompressed[4...]), encoding: .utf8) else { return nil }