Skip to content
3 changes: 2 additions & 1 deletion EvervaultPayment.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Pod::Spec.new do |spec|
spec.description = <<-DESC
The Evervault Pay SDK for iOS provides a secure way to integrate Apple Pay into your iOS applications.
It handles the complete payment flow including token encryption and decryption through Evervault's
secure infrastructure, supporting one-off payments, recurring payments, and disbursements.
secure infrastructure, supporting one-off payments, recurring payments, automatic reload (top-up)
payments, and disbursements.
DESC

spec.homepage = "https://evervault.com"
Expand Down
54 changes: 50 additions & 4 deletions ios/Demo/Demo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ fileprivate func buildTransaction(type: TransactionType) -> EvervaultPayment.Tra
recurringBillingRequest.trialBilling = trialBilling
recurringBillingRequest.supportsCouponCode = true
return .recurringPayment(recurringBillingRequest)
case .automaticReload:
let automaticReloadRequest = try! AutomaticReloadPaymentTransaction(
country: "IE",
currency: "EUR",
paymentDescription: "Automatic reload example.",
automaticReloadBilling: SummaryItem(label: "Wallet Top-Up", amount: Amount("20.00")),
automaticReloadThresholdAmount: Amount("5.00"),
managementURL: URL(string: "https://www.merchant.com/manage-wallet")!,
requestPayerDetails: [.postalAddress, .name, .emailAddress, .phoneNumber],
billingContact: makeSampleBillingContact(),
shippingContact: makeSampleShippingContact()
)
return .automaticReload(automaticReloadRequest)
}
}

Expand Down Expand Up @@ -165,10 +178,27 @@ fileprivate func getUpdatedTransaction(_ newAddress: ShippingContact, transactio

return summaryItems
case .disbursement(let disbursement):
// Calculate new line items and total for address change
return disbursement.paymentSummaryItems
var summaryItems = disbursement.paymentSummaryItems
if disbursement.merchantCapability == .instantFundsOut,
let instantOutFee = disbursement.instantOutFee {
summaryItems.append(instantOutFee)
}
summaryItems.append(disbursement.disbursementItem)
return summaryItems
case .recurringPayment(let recurring):
return recurring.paymentSummaryItems
// regularBilling/trialBilling are the real PKRecurringPaymentSummaryItem type (unlike
// disbursementItem/automaticReloadBilling below), so they need converting to SummaryItem first.
var summaryItems = recurring.paymentSummaryItems
summaryItems.append(SummaryItem(label: recurring.regularBilling.label, amount: Amount(recurring.regularBilling.amount.stringValue)))
if let trial = recurring.trialBilling {
summaryItems.append(SummaryItem(label: trial.label, amount: Amount(trial.amount.stringValue)))
}
return summaryItems
case .automaticReload(let automaticReload):
// Note: thresholdAmount can't be preserved here - onShippingAddressChange's [SummaryItem]
// return type has no field for it, and this always maps to a plain PKPaymentSummaryItem
// rather than the real PKAutomaticReloadPaymentSummaryItem (see EvervaultPayment+SwiftUI.swift).
return automaticReload.paymentSummaryItems + [automaticReload.automaticReloadBilling]
}
}

Expand Down Expand Up @@ -261,11 +291,16 @@ fileprivate func getCouponCodeUpdate(_ couponCode: String, transaction: Evervaul

case .disbursement:
return PKPaymentRequestCouponCodeUpdate(paymentSummaryItems: [])

case .automaticReload:
// Coupon codes aren't the primary use case for a wallet top-up, so no discount logic here.
return PKPaymentRequestCouponCodeUpdate(paymentSummaryItems: [])
}
}

/// Example shipping-method handling: recomputes the total to include the selected method's cost.
/// Not wired up for recurring/disbursement transactions - shipping methods are only modeled for one-off purchases.
/// Not wired up for recurring/disbursement/automaticReload transactions - shipping methods are only
/// modeled for one-off purchases.
fileprivate func getShippingMethodUpdate(_ shippingMethod: PKShippingMethod, transaction: EvervaultPayment.Transaction) -> PKPaymentRequestShippingMethodUpdate {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
Expand Down Expand Up @@ -297,13 +332,19 @@ fileprivate func getShippingMethodUpdate(_ shippingMethod: PKShippingMethod, tra
return PKPaymentRequestShippingMethodUpdate(
paymentSummaryItems: disbursement.paymentSummaryItems.map { PKPaymentSummaryItem(label: $0.label, amount: $0.amount.amount, type: $0.type) }
)

case .automaticReload(let automaticReload):
return PKPaymentRequestShippingMethodUpdate(
paymentSummaryItems: automaticReload.paymentSummaryItems.map { PKPaymentSummaryItem(label: $0.label, amount: $0.amount.amount, type: $0.type) }
)
}
}

enum TransactionType {
case oneOff
case recurring
case disbursement
case automaticReload
}

// Example merchant-owned error type, passed to `shouldAuthorize`'s `.failure(_:)`.
Expand Down Expand Up @@ -422,6 +463,11 @@ struct ContentView: View {
.tabItem {
Label("Recurring", systemImage: "person.crop.circle")
}

TransactionHandler(name: "Automatic Reload", type: .automaticReload)
.tabItem {
Label("Automatic Reload", systemImage: "arrow.clockwise")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,9 @@ public struct EvervaultPaymentViewRepresentable: UIViewRepresentable {
}
}

// Only `OneOffPaymentTransaction` models `shippingMethods` — recurring and
// disbursement transactions have no defined semantics for a selectable
// shipping surcharge, so they always return `[]`.
// Only `OneOffPaymentTransaction` models `shippingMethods`.
// Recurring, disbursement, and automatic reload transactions have no defined semantics
// for a selectable shipping surcharge, so they always return `[]`.
private func getShippingMethods(transaction: Transaction) -> [PKShippingMethod] {
switch transaction {
case .oneOffPayment(let paymentRequest):
Expand All @@ -197,6 +197,10 @@ public struct EvervaultPaymentViewRepresentable: UIViewRepresentable {
return []
case .disbursement(_):
return []
default:
// Covers .automaticReload, which can't be named directly here since it's
// gated to iOS 16+ while this switch compiles at the package's iOS 15 minimum.
return []
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,20 @@ public class EvervaultPaymentView: UIView {
throw EvervaultError.ApplePayPaymentSheetError
}
vc.delegate = self


// Present the Payment Sheet from the frontmost window
rootVC?.present(vc, animated: true)
} else {
throw EvervaultError.UnsupportedVersionError
}
default:
if #available(iOS 16.0, *), case let .automaticReload(automaticReloadTransaction) = self.transaction {
let paymentRequest = self.buildPaymentRequest(transaction: automaticReloadTransaction)
guard let vc = PKPaymentAuthorizationViewController(paymentRequest: paymentRequest) else {
throw EvervaultError.ApplePayPaymentSheetError
}
vc.delegate = self

// Present the Payment Sheet from the frontmost window
rootVC?.present(vc, animated: true)
} else {
Expand Down Expand Up @@ -397,7 +410,61 @@ public class EvervaultPaymentView: UIView {

return paymentRequest
}


// Reconstructs the real PassKit type here so the model is iOS-15-safe.
// Shared by `buildPaymentRequest` and `getPaymentSummaryItems` to keep what's shown
// initially and what's shown after PassKit-triggered summary-item rebuilds
// (shipping/payment-method/coupon changes) consistent.
@available(iOS 16.0, *)
private static func buildAutomaticReloadBillingItem(transaction: AutomaticReloadPaymentTransaction) -> PKAutomaticReloadPaymentSummaryItem {
let automaticReloadBilling = PKAutomaticReloadPaymentSummaryItem(
label: transaction.automaticReloadBilling.label,
amount: transaction.automaticReloadBilling.amount.amount
)
if let thresholdAmount = transaction.automaticReloadThresholdAmount {
automaticReloadBilling.thresholdAmount = thresholdAmount.amount
}
return automaticReloadBilling
}

@available(iOS 16.0, *)
private func buildPaymentRequest(transaction: AutomaticReloadPaymentTransaction) -> PKPaymentRequest {
let paymentRequest = PKPaymentRequest()
paymentRequest.merchantIdentifier = self.appleMerchantIdentifier
paymentRequest.supportedNetworks = self.supportedNetworks
paymentRequest.countryCode = transaction.country
paymentRequest.currencyCode = transaction.currency
paymentRequest.paymentSummaryItems = transaction.paymentSummaryItems.map { item in
PKPaymentSummaryItem(label: item.label, amount: item.amount.amount)
}
paymentRequest.merchantCapabilities = .threeDSecure

let automaticReloadBilling = Self.buildAutomaticReloadBillingItem(transaction: transaction)
paymentRequest.paymentSummaryItems.append(automaticReloadBilling)
let automaticReload = PKAutomaticReloadPaymentRequest(
paymentDescription: transaction.paymentDescription,
automaticReloadBilling: automaticReloadBilling,
managementURL: transaction.managementURL
)
automaticReload.billingAgreement = transaction.billingAgreement
paymentRequest.automaticReloadPaymentRequest = automaticReload
paymentRequest.requiredBillingContactFields = transaction.requestPayerDetails
paymentRequest.supportsCouponCode = transaction.supportsCouponCode
paymentRequest.couponCode = transaction.couponCode

paymentRequest.shippingType = transaction.shippingType
paymentRequest.requiredShippingContactFields = transaction.requiredShippingContactFields

if let billingContact = transaction.billingContact {
paymentRequest.billingContact = PKContact(billingContact)
}
if let shippingContact = transaction.shippingContact {
paymentRequest.shippingContact = PKContact(shippingContact)
}

return paymentRequest
}

private func getPaymentSummaryItems() -> [PKPaymentSummaryItem] {
switch self.transaction {
case let .oneOffPayment(oneOffTransaction):
Expand All @@ -406,6 +473,16 @@ public class EvervaultPaymentView: UIView {
return self.buildSummaryItems(for: dispersementTransaction)
case let .recurringPayment(recurringTransaction):
return self.buildSummaryItems(for: recurringTransaction)
default:
// Covers .automaticReload (gated to iOS 16+) while this switch compiles at the package's iOS 15 minimum.
if #available(iOS 16.0, *), case let .automaticReload(automaticReloadTransaction) = self.transaction {
var items = automaticReloadTransaction.paymentSummaryItems.map { item in
PKPaymentSummaryItem(label: item.label, amount: item.amount.amount)
}
items.append(Self.buildAutomaticReloadBillingItem(transaction: automaticReloadTransaction))
return items
}
return []
}
}
}
Expand All @@ -418,7 +495,7 @@ extension EvervaultPaymentView : PKPaymentAuthorizationViewControllerDelegate {
do {
// Send the token to the Evervault backend for decryption and re-encryption with Evervault Encryption
let decoded = try await EvervaultApi.sendPaymentToken(appUuid, payment)
let transactionType = await MainActor.run { ApplePayTransactionType(self.transaction) }
let transactionType = try await MainActor.run { try ApplePayTransactionType(self.transaction) }
let enriched = decoded?.enriched(
billingContact: ApplePayContact(payment.billingContact),
shippingContact: ApplePayContact(payment.shippingContact),
Expand Down
73 changes: 72 additions & 1 deletion ios/EvervaultPayment/Sources/EvervaultPayment/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,22 @@ public enum ApplePayTransactionType: String, Codable, Sendable, Equatable {
case oneOff
case recurring
case disbursement
case automaticReload

init(_ transaction: Transaction) {
init(_ transaction: Transaction) throws {
switch transaction {
case .oneOffPayment:
self = .oneOff
case .recurringPayment:
self = .recurring
case .disbursement:
self = .disbursement
default:
if #available(iOS 16.0, *), case .automaticReload = transaction {
self = .automaticReload
} else {
throw EvervaultError.UnsupportedVersionError
}
}
}
}
Expand Down Expand Up @@ -494,10 +501,74 @@ public struct RecurringPaymentTransaction {
}
}

public struct AutomaticReloadPaymentTransaction {
public var country: String
public var currency: String
public var paymentSummaryItems: [SummaryItem]
public var paymentDescription: String
public var automaticReloadBilling: SummaryItem
public var automaticReloadThresholdAmount: Amount?
public var managementURL: URL
public var billingAgreement: String?
public var requestPayerDetails: Set<ContactField>
public var supportsCouponCode: Bool
public var couponCode: String?
public var shippingType: PKShippingType
public var requiredShippingContactFields: Set<ContactField>
public var billingContact: ApplePayPaymentContact?
public var shippingContact: ApplePayPaymentContact?

public init(country: String, currency: String, paymentSummaryItems: [SummaryItem] = [], paymentDescription: String, automaticReloadBilling: SummaryItem, automaticReloadThresholdAmount: Amount? = nil, managementURL: URL, requestPayerDetails: Set<ContactField> = [], supportsCouponCode: Bool = false, couponCode: String? = nil, billingContact: ApplePayPaymentContact? = nil, shippingType: PKShippingType = .shipping, requiredShippingContactFields: Set<ContactField> = [], shippingContact: ApplePayPaymentContact? = nil) throws {
self.country = country
self.currency = currency
self.paymentSummaryItems = paymentSummaryItems
self.paymentDescription = paymentDescription
self.automaticReloadBilling = automaticReloadBilling
self.automaticReloadThresholdAmount = automaticReloadThresholdAmount
self.managementURL = managementURL
self.requestPayerDetails = requestPayerDetails
self.supportsCouponCode = supportsCouponCode
self.couponCode = couponCode
self.shippingType = shippingType
self.requiredShippingContactFields = requiredShippingContactFields
self.billingContact = billingContact
self.shippingContact = shippingContact
}

@available(iOS 16.0, *)
public init(country: Locale.Region, currency: Locale.Currency, paymentSummaryItems: [SummaryItem] = [], paymentDescription: String, automaticReloadBilling: SummaryItem, automaticReloadThresholdAmount: Amount? = nil, managementURL: URL, requestPayerDetails: Set<ContactField> = [], supportsCouponCode: Bool = false, couponCode: String? = nil, billingContact: ApplePayPaymentContact? = nil, shippingType: PKShippingType = .shipping, requiredShippingContactFields: Set<ContactField> = [], shippingContact: ApplePayPaymentContact? = nil) throws {
self.country = country.identifier
self.currency = currency.identifier
self.paymentSummaryItems = paymentSummaryItems
self.paymentDescription = paymentDescription
self.automaticReloadBilling = automaticReloadBilling
self.automaticReloadThresholdAmount = automaticReloadThresholdAmount
self.managementURL = managementURL
self.requestPayerDetails = requestPayerDetails
self.supportsCouponCode = supportsCouponCode
self.couponCode = couponCode
self.shippingType = shippingType
self.requiredShippingContactFields = requiredShippingContactFields
self.billingContact = billingContact
self.shippingContact = shippingContact

guard currency.isISOCurrency else {
throw EvervaultError.InvalidCurrencyError
}
guard country.isISORegion else {
throw EvervaultError.InvalidCountryError
}
}
}

public enum Transaction {
case oneOffPayment(OneOffPaymentTransaction)
case disbursement(DisbursementTransaction)
case recurringPayment(RecurringPaymentTransaction)
// Not @available-gated. Swift disallows @available on enum cases with an associated
// value, and AutomaticReloadPaymentTransaction itself is safe to construct pre-iOS 16.
// The iOS 16+ requirement is enforced when we actually build/present the PassKit request.
case automaticReload(AutomaticReloadPaymentTransaction)
}

struct ApplePayTokenHeader: Codable {
Expand Down
Loading
Loading