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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# These owners will be the default owners for everything in the repo.
# For more info, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
* @peter-paystack @michael-paystack @PaystackHQ/mobile @dami-paystack @vusi-paystack
* @peter-paystack @michael-paystack @zaheer-paystack @PaystackHQ/mobile @dami-paystack @vusi-paystack
10 changes: 5 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ on:

jobs:
build:
runs-on: macos-15
runs-on: macos-26
name: Build and Test Swift Package

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Select Xcode Version
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: '26.3'
xcode-version: '26.4'

- name: Setup environment
run: |
bundle install

- name: Build and Run tests
run: |
xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO
xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO
brew install sonar-scanner
bundle exec fastlane sonar_scan
env:
Expand All @@ -33,7 +33,7 @@ jobs:


PodLinting:
runs-on: macos-15
runs-on: macos-26
name: Lint Podspec

steps:
Expand All @@ -52,7 +52,7 @@ jobs:

release:
if: ${{ github.event.pull_request.merged == true && github.head_ref == 'release/update-versions' }}
runs-on: macos-15
runs-on: macos-26
needs: [build, PodLinting]

steps:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:

jobs:
deploy:
runs-on: macos-15
runs-on: macos-26
name: Deploy to Cocoapods Trunk

steps:
Expand All @@ -14,15 +14,15 @@ jobs:
- name: Select Xcode Version
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: '26.3'
xcode-version: '26.4'

- name: Setup environment
run: |
bundle install

- name: Build and Run tests
run: |
xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO
xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO

- name: setup-cocoapods
uses: maxim-lobanov/setup-cocoapods@8e97e1e98e6ccf42564fdf5622c8feec74199377 # v1.4.0
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/primary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,23 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

SwiftPackage:
runs-on: macos-15
runs-on: macos-26
name: Build and Test Swift Package

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Select Xcode Version
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: '26.3'
xcode-version: '26.4'

- name: Setup environment
run: |
bundle install

- name: Build and Run tests
run: |
xcodebuild clean build test -scheme PaystackSDK-Package -sdk iphonesimulator26.2 -destination "OS=26.2,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO
xcodebuild clean build test -scheme PaystackSDK-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro" -enableCodeCoverage YES CODE_SIGNING_REQUIRED=NO
brew install sonar-scanner
bundle exec fastlane sonar_scan
env:
Expand All @@ -46,7 +46,7 @@ jobs:

PodLinting:
if: ${{ false }}
runs-on: macos-15
runs-on: macos-26
name: Lint Podspec

steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:

jobs:
release:
runs-on: macos-15
runs-on: macos-26

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ let package = Package(
.copy("API/Charge/Resources/ZapPusherFailed.json"),
.copy("API/Charge/Resources/CapitecPayPusherSuccess.json"),
.copy("API/Charge/Resources/CapitecPayPusherFailed.json"),
.copy("API/Charge/Resources/CapitecPayPusherPending.json"),
.copy("API/Charge/Resources/CapitecRequeryResponse.json"),
.copy("API/Charge/Resources/QRPusherSuccess.json"),
.copy("API/Charge/Resources/QRPusherFailed.json")
.copy("API/Charge/Resources/QRPusherFailed.json"),
.copy("API/Charge/Resources/QRPusherTransAsString.json")

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

/// Public Capitec Pay surface. Used by the UI module to authenticate a
/// Capitec Pay transaction, poll for its status via the Capitec-specific
/// requery endpoint, and subscribe for Pusher events. Can also be called
/// directly by integrators driving their own UI on top of `PaystackCore`.
public extension Paystack {

private var capitecPayService: CapitecPayService {
return CapitecPayServiceImplementation(config: config)
}

/// Authenticates a Capitec Pay transaction against
/// `POST /capitec-pay/authenticate`. The `clientdata` field on
/// ``CapitecPayAuthenticateRequest`` must already be RSA-encrypted
/// using the merchant's public encryption key from
/// ``VerifyAccessCode``.
///
/// - Parameter request: The authenticate payload — pre-encrypted
/// client data, transaction id, and device fingerprint.
/// - Returns: A ``Service`` carrying a ``CapitecPayAuthenticateResponse``
/// with the `timeToLive` window the SDK counts down against.
func authenticateCapitecPay(_ request: CapitecPayAuthenticateRequest)
-> Service<CapitecPayAuthenticateResponse> {
return capitecPayService.postAuthenticate(request)
}

/// Polls the Capitec Pay requery endpoint
/// (`POST /capitec-pay/requery/{transactionReference}`) for the
/// current transaction status. The response follows the standard
/// charge shape — same decoding path as ``checkPendingCharge(forAccessCode:)``.
///
/// - Parameter transactionReference: The `reference` returned from
/// `verify_access_code`.
/// - Returns: A ``Service`` carrying a ``CapitecResponse``.
func requeryCapitecPay(transactionReference: String)
-> Service<CapitecResponse> {
return capitecPayService.postRequery(transactionReference: transactionReference)
}

/// Listens for Capitec Pay status updates on the Pusher channel
/// returned by ``authenticateCapitecPay(_:)``.
///
/// Capitec Pay publishes its own envelope rather than the flat
/// ``Charge3DSResponse`` shape used by card 3-D Secure, mobile money,
/// Zap, QR and bank transfer — the top-level `status` is a `Bool` and
/// the transaction status is nested at `data.status`. See
/// ``CapitecPusherResponse``.
///
/// The underlying listener is single-shot per the existing
/// `PusherSubscriptionListener` contract — one event resolves the
/// listener. Non-terminal events are therefore not expected here; the
/// requery loop is what resolves anything this channel does not.
///
/// - Parameter channelName: The `CAPITECPAY_{transactionId}` channel
/// for this transaction.
/// - Returns: A ``Service`` carrying a ``CapitecPusherResponse`` on the
/// first event the channel emits.
func listenForCapitecPayResponse(onChannel channelName: String)
-> Service<CapitecPusherResponse> {
let subscription: any Subscription = PusherSubscription(
channelName: channelName, eventName: "response")
return Service(subscription)
}
}
27 changes: 27 additions & 0 deletions Sources/PaystackSDK/API/Charge/CapitecPayService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

protocol CapitecPayService: PaystackService {
func postAuthenticate(_ request: CapitecPayAuthenticateRequest)
-> Service<CapitecPayAuthenticateResponse>
func postRequery(transactionReference: String)
-> Service<CapitecResponse>
}

struct CapitecPayServiceImplementation: CapitecPayService {

var config: PaystackConfig

var parentPath: String { "capitec-pay" }

func postAuthenticate(_ request: CapitecPayAuthenticateRequest)
-> Service<CapitecPayAuthenticateResponse> {
return post("/authenticate", request)

Check warning on line 18 in Sources/PaystackSDK/API/Charge/CapitecPayService.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=AaB8S42lfJTHlCHPTR4L&open=AaB8S42lfJTHlCHPTR4L&pullRequest=132
.asService()
}

func postRequery(transactionReference: String)
-> Service<CapitecResponse> {
return get("/requery/\(transactionReference)")
.asService()
}
}
56 changes: 56 additions & 0 deletions Sources/PaystackSDK/API/Charge/QR.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

/// Public QR-payment surface. Used by the UI module to generate a hosted
/// QR image for a transaction (Scan to Pay / Snap Scan et al) and to
/// subscribe for Pusher status events. Can also be called directly by
/// integrators driving their own UI on top of `PaystackCore`.
public extension Paystack {

private var qrService: QRService {
return QRServiceImplementation(config: config)
}

/// Generates a QR code for a transaction against
/// `POST /offline/qr/generate`. The `channel` field on
/// ``QRGenerateRequest`` is the provider code returned in
/// `VerifyAccessCode.channelOptions.qrCode` (for example
/// `"MPASS_OLTI"` for Ukheshe-served flows in ZA) — the SDK does not
/// hard-code that value so the same call works for future markets
/// that expose different provider codes.
///
/// - Parameter request: The generate payload — source, transaction
/// reference, and provider channel code.
/// - Returns: A ``Service`` carrying a ``QRGenerateResponse`` whose
/// `data.url` is a signed S3 URL for the QR image and whose
/// `data.channel` is the Pusher channel to subscribe on.
func generateQR(_ request: QRGenerateRequest)
-> Service<QRGenerateResponse> {
return qrService.postGenerate(request)
}

/// Listens for QR-payment status updates on the Pusher channel
/// returned by ``generateQR(_:)`` — the same channel serves Scan to Pay
/// and SnapScan.
///
/// The QR channel publishes its own envelope rather than the flat
/// ``Charge3DSResponse`` shape used by card 3-D Secure, mobile money,
/// Zap and bank transfer: the top-level `status` is a `Bool`, `trans`
/// is a JSON number, and the redirect key is spelled `redirecturl`.
/// See ``QRPusherResponse``.
///
/// The underlying listener is single-shot per the existing
/// `PusherSubscriptionListener` contract — one event resolves the
/// listener.
///
/// - Parameter channelName: The `data.channel` value returned by
/// ``generateQR(_:)`` (for example
/// `"api_mpass_olti_qr_51826223921246"`).
/// - Returns: A ``Service`` carrying a ``QRPusherResponse`` on the
/// first event the channel emits.
func listenForQRResponse(onChannel channelName: String)
-> Service<QRPusherResponse> {
let subscription: any Subscription = PusherSubscription(
channelName: channelName, eventName: "response")
return Service(subscription)
}
}
19 changes: 19 additions & 0 deletions Sources/PaystackSDK/API/Charge/QRService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

protocol QRService: PaystackService {
func postGenerate(_ request: QRGenerateRequest)
-> Service<QRGenerateResponse>
}

struct QRServiceImplementation: QRService {

var config: PaystackConfig

var parentPath: String { "offline/qr" }

func postGenerate(_ request: QRGenerateRequest)
-> Service<QRGenerateResponse> {
return post("/generate", request)

Check warning on line 16 in Sources/PaystackSDK/API/Charge/QRService.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=AaB8S42efJTHlCHPTR4K&open=AaB8S42efJTHlCHPTR4K&pullRequest=132
.asService()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

public struct CapitecPayAuthenticateRequest: Encodable, Equatable {
public let clientdata: String
public let trans: String
public let device: String

public init(clientdata: String, trans: String, device: String) {
self.clientdata = clientdata
self.trans = trans
self.device = device
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

public struct CapitecPayAuthenticateResponse: Decodable, Equatable {
public let status: Bool
public let type: String
public let code: String
public let data: CapitecPayAuthenticateData
public let message: String

public init(status: Bool,
type: String,
code: String,
data: CapitecPayAuthenticateData,
message: String) {
self.status = status
self.type = type
self.code = code
self.data = data
self.message = message
}
}

public struct CapitecPayAuthenticateData: Decodable, Equatable {
public let status: String
public let timeToLive: Int
public let expiryDate: Date

public init(status: String, timeToLive: Int, expiryDate: Date) {
self.status = status
self.timeToLive = timeToLive
self.expiryDate = expiryDate
}
}
1 change: 1 addition & 0 deletions Sources/PaystackSDK/Core/Models/Models/Channel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum Channel: String, Codable {
case mobileMoney = "mobile_money"
case qr = "qr"
case bankTransfer = "bank_transfer"
case capitecPay = "capitec_pay"
case unsupportedChannel

public init(from decoder: Decoder) throws {
Expand Down
Loading
Loading