Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ let package = Package(
.copy("API/Charge/Resources/PayWithTransferPusherCreditReceived.json"),
.copy("API/Charge/Resources/PayWithTransferPusherCreditPending.json"),
.copy("API/Charge/Resources/PayWithTransferPusherCreditRejected.json"),
.copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json")
.copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json"),
.copy("API/Charge/Resources/ZapMandateResponse.json")

])
]
Expand Down
45 changes: 45 additions & 0 deletions Sources/PaystackSDK/API/Charge/Zap.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// Public Zap surface. Used by the UI module to initiate a digital bank
/// mandate and listen for Event status updates ; can also be called
/// directly by integrators driving their own UI on top of `PaystackCore`.
public extension Paystack {

private var zapService: ZapMandateService {
return ZapMandateServiceImplementation(config: config)
}

/// Initiates a Zap digital bank mandate for the given supported-bank
/// entry + transaction. Returns the deeplink URL, QR image URL, and
/// the Pusher channel to subscribe to for status updates.
///
/// - Parameter request: Combines the Zap `supported_banks.id`, the
/// numeric transaction id from `verify_access_code`, and the
/// customer's email (sent as `wallet_id` in the form body).
/// - Returns: A ``Service`` carrying a ``ZapMandateResponse``.
func initiateZapMandate(_ request: ZapMandateRequest)
-> Service<ZapMandateResponse> {
return zapService.postZapMandate(request)
}

/// Listens for Zap status updates on the Pusher channel returned by
/// ``initiateZapMandate(_:)``. The status taxonomy is shared with
/// Pay-with-Transfer, so this
/// helper returns the existing `PayWithTransferPusherResponse` shape.
///
/// The underlying listener is single-shot per the existing
/// `PusherSubscriptionListener` contract ; callers that need to keep
/// listening through transient statuses must re-subscribe after each
/// event.
///
/// - Parameter channelName: The `pusherChannel` value returned from
/// `initiateZapMandate` (e.g. `DBMAN_6222375579`).
/// - Returns: A ``Service`` carrying a ``PayWithTransferPusherResponse``
/// on the first event the channel emits.
func listenForZapResponse(onChannel channelName: String)
-> Service<PayWithTransferPusherResponse> {
let subscription: any Subscription = PusherSubscription(
channelName: channelName, eventName: "response")
return Service(subscription)
}
}
23 changes: 23 additions & 0 deletions Sources/PaystackSDK/API/Charge/ZapMandateService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation


protocol ZapMandateService: PaystackService {
func postZapMandate(_ request: ZapMandateRequest)
-> Service<ZapMandateResponse>
}

struct ZapMandateServiceImplementation: ZapMandateService {

var config: PaystackConfig

var parentPath: String { "bank/digitalbankmandate" }

var baseURL: String { "https://standard.paystack.co" }

Check warning on line 15 in Sources/PaystackSDK/API/Charge/ZapMandateService.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AZ7v5zgy2oNuUBvrXVyN&open=AZ7v5zgy2oNuUBvrXVyN&pullRequest=126

func postZapMandate(_ request: ZapMandateRequest)
-> Service<ZapMandateResponse> {
return postForm("/\(request.id)/\(request.transactionId)",
["wallet_id": request.walletId])
.asService()
}
}
15 changes: 15 additions & 0 deletions Sources/PaystackSDK/Core/Models/Models/SupportedBank.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public struct SupportedBank: Decodable, Equatable {
public let id: Int
public let code: String
public let name: String?
public let slug: String?

public init(id: Int, code: String, name: String? = nil, slug: String? = nil) {
self.id = id
self.code = code
self.name = name
self.slug = slug
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ public struct VerifyAccessCodeData: Decodable {
public var merchantChannelSettings: MerchantChannelSettings?
public var publicEncryptionKey: String

public var supportedBanks: [SupportedBank]?

public init(id: Int?, email: String, amount: Decimal, reference: String, accessCode: String,
merchantLogo: String? = nil, merchantName: String, domain: Domain,
currency: String, channels: [Channel], channelOptions: ChannelOptions,
merchantChannelSettings: MerchantChannelSettings? = nil,
publicEncryptionKey: String) {
publicEncryptionKey: String,
supportedBanks: [SupportedBank]? = nil) {
self.id = id
self.email = email
self.amount = amount
Expand All @@ -33,5 +36,6 @@ public struct VerifyAccessCodeData: Decodable {
self.channelOptions = channelOptions
self.merchantChannelSettings = merchantChannelSettings
self.publicEncryptionKey = publicEncryptionKey
self.supportedBanks = supportedBanks
}
}
16 changes: 16 additions & 0 deletions Sources/PaystackSDK/Core/Models/Models/ZapMandateRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

public struct ZapMandateRequest: Equatable {

public let id: Int

public let transactionId: Int

public let walletId: String

public init(id: Int, transactionId: Int, walletId: String) {
self.id = id
self.transactionId = transactionId
self.walletId = walletId
}
}
21 changes: 21 additions & 0 deletions Sources/PaystackSDK/Core/Models/Models/ZapMandateResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

public struct ZapMandateResponse: Decodable, Equatable {
public let status: String
public let message: String
public let pusherChannel: String
public let paymentUrl: String
public let qrImage: String

public init(status: String,
message: String,
pusherChannel: String,
paymentUrl: String,
qrImage: String) {
self.status = status
self.message = message
self.pusherChannel = pusherChannel
self.paymentUrl = paymentUrl
self.qrImage = qrImage
}
}
7 changes: 6 additions & 1 deletion Sources/PaystackSDK/Core/Service/PaystackService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
public protocol PaystackService: URLRequestBuilderHelper {
var config: PaystackConfig { get set }
var parentPath: String { get }
var baseURL: String { get }
}

public extension PaystackService {

var baseURL: String {
return "https://api.paystack.co"

Check warning on line 12 in Sources/PaystackSDK/Core/Service/PaystackService.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=PaystackHQ_paystack-sdk-ios&issues=AZ7v5zf22oNuUBvrXVyM&open=AZ7v5zf22oNuUBvrXVyM&pullRequest=126
}

var endpoint: String {
return "https://api.paystack.co/\(parentPath)"
return "\(baseURL)/\(parentPath)"
}

var bearerToken: String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ public class URLRequestBuilder {
return self
}

public func setFormBody(_ fields: [String: String]) -> Self {
let pairs = fields.map { key, value -> String in
let encodedKey = key.addingPercentEncoding(
withAllowedCharacters: .formURLEncodedAllowed) ?? key
let encodedValue = value.addingPercentEncoding(
withAllowedCharacters: .formURLEncodedAllowed) ?? value
return "\(encodedKey)=\(encodedValue)"
}
self.body = pairs.joined(separator: "&").data(using: .utf8)
return addHeader("Content-Type", "application/x-www-form-urlencoded")
}

public func build() throws -> URLRequest {
guard let method = method else {
throw URLRequestBuilderError.invalidMethod
Expand Down Expand Up @@ -116,3 +128,13 @@ public extension URLRequestBuilder {
}

}

private extension CharacterSet {

static let formURLEncodedAllowed: CharacterSet = {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return allowed
}()

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public protocol URLRequestBuilderHelper {
func get() -> URLRequestBuilder
func get(_ path: String) -> URLRequestBuilder
func post<T: Encodable>(_ path: String, _ body: T) -> URLRequestBuilder
func postForm(_ path: String, _ fields: [String: String]) -> URLRequestBuilder
func put<T: Encodable>(_ path: String, _ body: T) -> URLRequestBuilder
}

Expand All @@ -30,6 +31,13 @@ public extension URLRequestBuilderHelper {
.setBody(body)
}

func postForm(_ path: String, _ fields: [String: String]) -> URLRequestBuilder {
return builder
.setMethod(.post)
.setPath(path)
.setFormBody(fields)
}

func put<T: Encodable>(_ path: String, _ body: T) -> URLRequestBuilder {
return builder
.setMethod(.put)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"status": "pending",
"message": "Transaction Initiated",
"pusher_channel": "DBMAN_6222375579",
"payment_url": "https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu",
"qr_image": "https://paystack-production-zap-eu-west-1.s3.eu-west-1.amazonaws.com/merchant-payments/qr/f3k3t3c88ovR6P7CDkKu/qr_f3k3t3c88ovR6P7CDkKu.png"
}
66 changes: 66 additions & 0 deletions Tests/PaystackSDKTests/API/Charge/ZapTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import XCTest
@testable import PaystackCore

final class ZapTests: PSTestCase {

let apiKey = "testsk_Example"

var serviceUnderTest: Paystack!

override func setUpWithError() throws {
try super.setUpWithError()
serviceUnderTest = try PaystackBuilder.newInstance
.setKey(apiKey)
.build()
}

// MARK: - initiateZapMandate

func testInitiateZapMandateHitsStandardHostNotApiHost() async throws {
mockServiceExecutor
.expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579")
.expectMethod(.post)
.expectHeader("Authorization", "Bearer \(apiKey)")
.expectHeader("Content-Type", "application/x-www-form-urlencoded")
.andReturn(json: "ZapMandateResponse")

let request = ZapMandateRequest(id: 870,
transactionId: 6222375579,
walletId: "customer@example.com")
_ = try await serviceUnderTest.initiateZapMandate(request).async()
}

func testInitiateZapMandateDecodesAllFieldsFromResponse() async throws {
mockServiceExecutor
.expectURL("https://standard.paystack.co/bank/digitalbankmandate/870/6222375579")
.expectMethod(.post)
.expectHeader("Authorization", "Bearer \(apiKey)")
.andReturn(json: "ZapMandateResponse")

let request = ZapMandateRequest(id: 870,
transactionId: 6222375579,
walletId: "customer@example.com")
let result = try await serviceUnderTest.initiateZapMandate(request).async()

XCTAssertEqual(result.status, "pending")
XCTAssertEqual(result.message, "Transaction Initiated")
XCTAssertEqual(result.pusherChannel, "DBMAN_6222375579")
XCTAssertEqual(result.paymentUrl,
"https://joinzap.com/app/merchant-payment/f3k3t3c88ovR6P7CDkKu")
XCTAssertTrue(result.qrImage.hasPrefix("https://"))
}

// MARK: - listenForZapResponse

func testListenForZapResponseSubscribesToProvidedChannelWithResponseEvent() async throws {
let channelName = "DBMAN_6222375579"
mockSubscriptionListener
.expectSubscription(PusherSubscription(channelName: channelName, eventName: "response"))
.andReturnString(fromJson: "PayWithTransferPusherSuccess")

let result = try await serviceUnderTest
.listenForZapResponse(onChannel: channelName).async()

XCTAssertEqual(result.status, "success")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"bank_transfer": {
"fulfil_late_notification": true
}
}
},
"supported_banks": [
{ "id": 870, "code": "00zap", "name": "Zap by Paystack", "slug": "zap" },
{ "id": 871, "code": "044", "name": "Access Bank", "slug": "access-bank" }
]
}
}
19 changes: 19 additions & 0 deletions Tests/PaystackSDKTests/API/Transactions/TransactionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,23 @@ class TransactionsTests: PSTestCase {
_ = try await serviceUnderTest.checkPendingCharge(forAccessCode: "access_code_test").async()
}

func testVerifyAccessCodeDecodesSupportedBanksFromResponse() async throws {
mockServiceExecutor
.expectURL("https://api.paystack.co/transaction/verify_code/access_code_test")
.expectMethod(.get)
.expectHeader("Authorization", "Bearer \(apiKey)")
.andReturn(json: "VerifyAccessCode")

let result = try await serviceUnderTest
.verifyAccessCode("access_code_test").async()

let supportedBanks = try XCTUnwrap(result.data.supportedBanks)
XCTAssertEqual(supportedBanks.count, 2)
XCTAssertEqual(supportedBanks[0].id, 870)
XCTAssertEqual(supportedBanks[0].code, "00zap")
XCTAssertEqual(supportedBanks[0].name, "Zap by Paystack")
XCTAssertEqual(supportedBanks[0].slug, "zap")
XCTAssertEqual(supportedBanks[1].code, "044")
}

}
Loading
Loading