diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift index eb8768525..48f9353bb 100644 --- a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/GiniCaptureDocumentValidator.swift @@ -109,6 +109,20 @@ 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 non-empty presence but skip strict IBAN validation + guard let iban = document.extractedParameters["iban"], !iban.isEmpty else { + throw DocumentValidationError.qrCodeFormatNotValid + } + case .some(.payBySquare): + guard let iban = document.extractedParameters["iban"], !iban.isEmpty else { + throw DocumentValidationError.qrCodeFormatNotValid + } case .none: throw DocumentValidationError.qrCodeFormatNotValid } 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 000000000..72dda420c --- /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 new file mode 100644 index 000000000..f5fc812fc --- /dev/null +++ b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/PayBySquareDecoder.swift @@ -0,0 +1,119 @@ +// +// PayBySquareDecoder.swift +// GiniCaptureSDK +// + +import Foundation + +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 base32hexChars = CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUV") + return string.count >= 16 + && string.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. 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 + // 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 } + + // 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) + // 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.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) + + 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 + } + + +} diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Helpers/QRCodesExtractor.swift index 958fefb4e..1946ac364 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 "HRVHUB30" } } } @@ -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,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].isEmpty) ? 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.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].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 + } + 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 diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Models/GiniQRCodeDocument.swift index ad565196a..21e7d1583 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 { @@ -51,18 +56,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 } diff --git a/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift b/CaptureSDK/GiniCaptureSDK/Sources/GiniCaptureSDK/Core/Screens/Camera/Camera.swift index 540b7c6c1..df274b16f 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 { diff --git a/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift b/CaptureSDK/GiniCaptureSDK/Tests/GiniCaptureSDKTests/GiniQRCodeDocumentTests.swift index 4fe75a668..287ae965c 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,264 @@ 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. Upper nibble of bytes[0] = bysquareType != 0, so decode() returns nil. + private let detectionString = "ABCDEF0123456789" + + @Test func detectedAsPayBySquare() { + let doc = GiniQRCodeDocument(scannedString: detectionString) + #expect(doc.qrCodeFormat == .payBySquare) + } + + @Test func nonBase32HexStringNotDetected() { + // 'W' is outside the base32hex alphabet (0–9, A–V); uppercasing won't rescue it + let doc = GiniQRCodeDocument(scannedString: "WWWWWWWWWWWWWWWW") + #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) + } + } + + // 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) + +@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: 20) + lines[0] = "UPNQR" + lines[8] = amountCents + lines[12] = reference + lines[13] = bic + lines[14] = iban + lines[16] = 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) + } + + @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) + +@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", + 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[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 paymentReferenceUsesCallNumberFieldDirectly() { + let doc = GiniQRCodeDocument(scannedString: makeHUB3(reference: "HR99 555-666")) + #expect(doc.extractedParameters["paymentReference"] == "HR99 555-666") + } +}