Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ece678e
feat(GiniCaptureSDK): Add new QR code format stubs (SPC, SPD, PayBySq…
abolfazlimahdi Jun 22, 2026
eeab7f2
feat(GiniCaptureSDK): Add format detection for SPC, SPD, PayBySquare,…
abolfazlimahdi Jun 22, 2026
0a2b6a2
feat(GiniCaptureSDK): Implement parameter extraction for SPC, SPD, UP…
abolfazlimahdi Jun 22, 2026
f233569
feat(GiniCaptureSDK): Implement PayBySquare base32hex and LZ77 decoder
abolfazlimahdi Jun 22, 2026
34b1091
feat(-sdk): Add feat(GiniCaptureSDK): Enable PDF417 scanning for HUB3…
abolfazlimahdi Jun 22, 2026
cd3ab5b
feat(GiniCaptureSDK): Add validation for SPC, SPD, PayBySquare, UPNQR…
abolfazlimahdi Jun 22, 2026
bc3d6a3
test(GiniCaptureSDK): Add unit tests for SPC, SPD, PayBySquare, UPNQR…
abolfazlimahdi Jun 22, 2026
045995e
fix(GiniCaptureSDK): Correct PayBySquare, UPNQR, HUB3 field parsing
abolfazlimahdi Jun 22, 2026
9dbbe45
fix(GiniCaptureSDK): Replace Apple LZMA2 with pure Swift LZMA1 decoder
abolfazlimahdi Jun 24, 2026
1b94fa5
fix(GiniCaptureSDK): Fix base32hex test to use invalid alphabet char
MozhganPeivandianSharbaf Jun 24, 2026
a784018
fix(GiniCaptureSDK): Default SPC currency to CHF when field is empty
MozhganPeivandianSharbaf Jun 24, 2026
6744a91
fix(GiniCaptureSDK): Default SPD currency to EUR when field is empty
MozhganPeivandianSharbaf Jun 24, 2026
7a79f1d
fix(GiniCaptureSDK): Reject SPD QR codes with empty IBAN
MozhganPeivandianSharbaf Jun 24, 2026
c15f975
fix(GiniCaptureSDK): Validate IBAN presence in PayBySquare QR codes
MozhganPeivandianSharbaf Jun 24, 2026
d11bef4
fix(GiniCaptureSDK): Update supported QR code formats in doc comment
MozhganPeivandianSharbaf Jun 24, 2026
8dd329d
Merge branch 'PP-2324-support-new-qr-code-types' of https://github.co…
MozhganPeivandianSharbaf Jun 24, 2026
b1f68f4
fix(GiniCaptureSDK): Document why CRC32 validation is skipped
MozhganPeivandianSharbaf Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ fileprivate extension GiniCaptureDocumentValidator {
if document.extractedParameters[QRCodesExtractor.giniCodeUrlKey] == nil {
throw DocumentValidationError.qrCodeFormatNotValid
}
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
}
Comment thread
MozhganPeivandianSharbaf marked this conversation as resolved.
case .some(.payBySquare):
// Successful decode is indicated by non-empty extractedParameters
if document.extractedParameters.isEmpty {
throw DocumentValidationError.qrCodeFormatNotValid
}
Comment thread
MozhganPeivandianSharbaf marked this conversation as resolved.
case .none:
throw DocumentValidationError.qrCodeFormatNotValid
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// PayBySquareDecoder.swift
// GiniCaptureSDK
//

import Foundation
import Compression

struct PayBySquarePayment {
let iban: String
let swift: String?
let amount: String
let currency: String
let payeeName: String
let paymentReference: String
}

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

static func decode(_ string: String) -> PayBySquarePayment? {
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 }

Comment thread
MozhganPeivandianSharbaf marked this conversation as resolved.
// 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")
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
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
}

/// 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]? {
Comment thread
MozhganPeivandianSharbaf marked this conversation as resolved.
Outdated
// 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 "HRVHUB30"
}
}
}
Expand All @@ -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 [:]
}
}

Expand Down Expand Up @@ -132,6 +138,133 @@ public final class QRCodesExtractor {
return parameters
}

// MARK: - SPC (Swiss Payment Code / QR-bill)

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
Comment thread
Copilot marked this conversation as resolved.
Outdated
}
// 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.paymentReference.isEmpty {
parameters["paymentReference"] = decoded.paymentReference
}
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(8), let cents = Int(lines[8]) {
let amount = String(format: "%.2f", Double(cents) / 100.0)
parameters["amountToPay"] = amount + ":EUR"
}
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(14) {
parameters["iban"] = lines[14]
}
if lines.indices.contains(13) && !lines[13].isEmpty {
parameters["bic"] = lines[13]
}
if lines.indices.contains(16) && !lines[16].isEmpty {
parameters["paymentRecipient"] = lines[16]
}

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
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
if lines.indices.contains(6) && !lines[6].isEmpty {
parameters["paymentRecipient"] = lines[6]
}
if lines.indices.contains(9) {
parameters["iban"] = lines[9]
}
if lines.indices.contains(11) && !lines[11].isEmpty {
parameters["paymentReference"] = lines[11]
}

return parameters
}

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
Expand Down
Loading
Loading