Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

753 donate with apple pay button for iOS to support Kiwix #1022

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
05ee7c9
Add merchantID for ApplePay
BPerlakiH Sep 21, 2024
3fef622
Import PassKit
BPerlakiH Sep 21, 2024
ce2a80c
Initial payment setup
BPerlakiH Sep 21, 2024
695b7e4
Add Donate with Apple Pay button to iOS
BPerlakiH Sep 21, 2024
9fcee6b
Add payment files
BPerlakiH Nov 2, 2024
d3b1bf6
macOS donation window
BPerlakiH Nov 2, 2024
244c896
Integrating PaymentForm
BPerlakiH Nov 2, 2024
9c5c66f
Fixup for iOS
BPerlakiH Nov 2, 2024
d76de07
Handle window close on transaction complete
BPerlakiH Nov 2, 2024
64bc860
Close popup on success/fail
BPerlakiH Nov 3, 2024
1ce64a1
Fixlint, handle sheet to be closed on iOS
BPerlakiH Nov 3, 2024
1e26839
Localize strings
BPerlakiH Nov 3, 2024
3a3c605
Clean up
BPerlakiH Nov 3, 2024
cd0df1a
Remove unused value
BPerlakiH Nov 3, 2024
6113433
Update merchant id
BPerlakiH Nov 5, 2024
027a294
Remove in app payments merchant id
BPerlakiH Nov 7, 2024
5462667
Add Stripe
BPerlakiH Nov 7, 2024
f53347c
Add empty merchant id
BPerlakiH Nov 7, 2024
9fd6b84
Remove stripe dependency
BPerlakiH Nov 7, 2024
9509d7c
Revert "Remove stripe dependency"
BPerlakiH Nov 7, 2024
2a1f2a6
Add merchant id
BPerlakiH Nov 7, 2024
cd9af5c
Add StipeApplePay to iOS
BPerlakiH Nov 8, 2024
ce530ae
Add merchant id, again
BPerlakiH Nov 8, 2024
c28de99
Update dependencies
BPerlakiH Nov 11, 2024
9e9dadc
Remove dependency
BPerlakiH Nov 11, 2024
5b91de8
Add dependency
BPerlakiH Nov 11, 2024
24486ac
Externalise public key, test with local server
BPerlakiH Nov 12, 2024
d92962c
Add merchant id, log payment phase. Working on iPad sym with local se…
BPerlakiH Nov 12, 2024
44b0a74
Add merchant session for macOS
BPerlakiH Nov 14, 2024
c4e5087
Change required postal address to email address
BPerlakiH Nov 15, 2024
60a1d10
Fixlint
BPerlakiH Nov 15, 2024
3a61563
Use api.donation.kiwix.org
rgaudin Nov 15, 2024
ddb5d0a
Fixlint
BPerlakiH Nov 16, 2024
86e1518
Show Thank You on donation success
BPerlakiH Nov 17, 2024
f0fa920
Show thank you on donation success
BPerlakiH Nov 19, 2024
889af89
Remove support button for macOS
BPerlakiH Nov 22, 2024
cfcb6fc
Disable monthly options from payments
BPerlakiH Nov 22, 2024
af9a95b
Add payment log
BPerlakiH Nov 22, 2024
7b910dc
Fixlint
BPerlakiH Nov 23, 2024
b8f5f9d
Fix closures
BPerlakiH Nov 23, 2024
7018978
Try build with full bundleID
BPerlakiH Nov 24, 2024
90ee8c7
Revert "Try build with full bundleID"
BPerlakiH Nov 24, 2024
efa35e6
Remove in app payments merchant id
BPerlakiH Nov 24, 2024
281d618
Add merchant id to Kiwix target only
BPerlakiH Nov 24, 2024
9f761d4
Revert CI/CD to develop
BPerlakiH Nov 24, 2024
52727f5
Fix donation button click area, header padding
BPerlakiH Nov 26, 2024
6ae88f6
Add docs to Payment
BPerlakiH Nov 26, 2024
d23e30e
Payment docs up
BPerlakiH Nov 26, 2024
f18edc6
Revert CI / CD to main
BPerlakiH Nov 26, 2024
92b6f73
Revert
BPerlakiH Nov 26, 2024
a0a585a
Add all payment methods
BPerlakiH Nov 28, 2024
9335169
Add Error pop up for donations
BPerlakiH Nov 30, 2024
ea40c54
Add macOS payment flow, but still with disabled UI
BPerlakiH Nov 30, 2024
9692781
Fixlint
BPerlakiH Nov 30, 2024
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
73 changes: 72 additions & 1 deletion App/App_macOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import UserNotifications
import Combine
import Defaults
import CoreKiwix
import PassKit

#if os(macOS)
final class AppDelegate: NSObject, NSApplicationDelegate {
Expand All @@ -29,8 +30,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct Kiwix: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.openWindow) var openWindow
@StateObject private var libraryRefreshViewModel = LibraryViewModel()
private let notificationCenterDelegate = NotificationCenterDelegate()
private var amountSelected = PassthroughSubject<SelectedAmount?, Never>()
@State private var selectedAmount: SelectedAmount?
@StateObject var formReset = FormReset()

init() {
UNUserNotificationCenter.current().delegate = notificationCenterDelegate
Expand Down Expand Up @@ -79,6 +84,66 @@ struct Kiwix: App {
}
.frame(width: 550, height: 400)
}
Window("payment.donate.title".localized, id: "donation") {
Group {
if let selectedAmount {
PaymentSummary(selectedAmount: selectedAmount, onComplete: {
closeDonation()
switch Payment.showResult() {
case .none: break
case .thankYou:
openWindow(id: "donation-thank-you")
case .error:
openWindow(id: "donation-error")
}
})
} else {
PaymentForm(amountSelected: amountSelected)
.frame(width: 320, height: 320)
}
}
.onReceive(amountSelected) { amount in
selectedAmount = amount
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { notification in
if let window = notification.object as? NSWindow,
window.identifier?.rawValue == "donation" {
formReset.reset()
selectedAmount = nil
}
}
.environmentObject(formReset)
}
.windowResizability(.contentMinSize)
.windowStyle(.titleBar)
.commandsRemoved()
.defaultSize(width: 320, height: 400)

Window("", id: "donation-thank-you") {
PaymentResultPopUp(state: .thankYou)
.padding()
}
.windowResizability(.contentMinSize)
.commandsRemoved()
.defaultSize(width: 320, height: 198)

Window("", id: "donation-error") {
PaymentResultPopUp(state: .error)
.padding()
}
.windowResizability(.contentMinSize)
.commandsRemoved()
.defaultSize(width: 320, height: 198)
}

private func closeDonation() {
// after upgrading to macOS 14, use:
// @Environment(\.dismissWindow) var dismissWindow
// and call:
// dismissWindow(id: "donation")
NSApplication.shared.windows.first { window in
window.identifier?.rawValue == "donation"
}?.close()
}

private class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
Expand All @@ -98,6 +163,7 @@ struct Kiwix: App {
}

struct RootView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.controlActiveState) var controlActiveState
@StateObject private var navigation = NavigationViewModel()
@StateObject private var windowTracker = WindowTracker()
Expand Down Expand Up @@ -127,7 +193,12 @@ struct RootView: View {
}
}
}
.frame(minWidth: 150)
.frame(minWidth: 160)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we disable the support button for macOS

// .safeAreaInset(edge: .bottom) {
// SupportKiwixButton {
// openWindow(id: "donation")
// }
// }
} detail: {
switch navigation.currentItem {
case .loading:
Expand Down
230 changes: 230 additions & 0 deletions Model/Payment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.

import Foundation
import PassKit
import SwiftUI
import Combine
import StripeApplePay
import os

/// Payment processing based on:
/// Apple-Pay button:
/// https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/apple_pay#2872687
/// as described in: What’s new in Wallet and Apple Pay from WWDC 2022
/// (https://developer.apple.com/videos/play/wwdc2022/10041/)
///
/// Combined with Stripe's lightweight Apple Pay framework
/// https://github.com/stripe/stripe-ios/blob/master/StripeApplePay/README.md
/// based on the App Clip example project:
/// https://github.com/stripe/stripe-ios/tree/master/Example/AppClipExample
///
/// Whereas the Stripe SDK is based on the older
/// PKPaymentAuthorizationController (before PayWithApplePayButton was available)
/// https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/apple_pay#2870963
///
/// The Stripe SDK has been brought up to date (with 2022 WWDC changes)
/// and modified to be compatible with macOS as well, see SPM dependencies
/// https://github.com/CodeLikeW/stripe-apple-pay
/// https://github.com/CodeLikeW/stripe-core
struct Payment {

enum FinalResult {
case thankYou
case error
}

/// Decides if the Thank You / Error pop up should be shown
/// - Returns: `FinalResult` only once
@MainActor
static func showResult() -> FinalResult? {
// make sure `true` is "read only once"
let value = Self.finalResult
Self.finalResult = nil
return value
}
@MainActor
static private var finalResult: Payment.FinalResult?

let completeSubject = PassthroughSubject<Void, Never>()

static let kiwixPaymentServer = URL(string: "https://api.donation.kiwix.org/v1/stripe")!
static let merchantSessionURL = URL(string: "https://apple-pay-gateway.apple.com" )!
static let merchantId = "merchant.org.kiwix.apple"
static let paymentSubscriptionManagingURL = "https://www.kiwix.org"
static let supportedNetworks: [PKPaymentNetwork] = [
.amex,
.bancomat,
.bancontact,
.cartesBancaires,
.chinaUnionPay,
.dankort,
.discover,
.eftpos,
.electron,
.elo,
.girocard,
.interac,
.idCredit,
.JCB,
.mada,
.maestro,
.masterCard,
.mir,
.privateLabel,
.quicPay,
.suica,
.visa,
.vPay
]
static let capabilities: PKMerchantCapability = [.threeDSecure, .credit, .debit, .emv]

/// NOTE: consider that these currencies support double precision, eg: 5.25 USD.
/// Revisit `SelectedAmount`, and `SelectedPaymentAmount`
/// before adding a zero-decimal currency such as: ¥100
static let currencyCodes = ["USD", "EUR", "CHF"]
static let defaultCurrencyCode = "USD"
private static let minimumAmount: Double = 5
/// The Sripe `amount` value supports up to eight digits
/// (e.g., a value of 99999999 for a USD charge of $999,999.99).
/// see: https://docs.stripe.com/api/payment_intents/object#payment_intent_object-amount
static let maximumAmount: Int = 99999999
static func isInValidRange(amount: Double?) -> Bool {
guard let amount else { return false }
return minimumAmount <= amount && amount <= Double(maximumAmount)*100.0
}

static let oneTimes: [AmountOption] = [
.init(value: 10),
.init(value: 34, isAverage: true),
.init(value: 50)
]

static let monthlies: [AmountOption] = [
.init(value: 5),
.init(value: 8, isAverage: true),
.init(value: 10)
]

/// Checks Apple Pay capabilities, and returns the button label accrodingly
/// Setup button if no cards added yet,
/// nil if Apple Pay is not supported
/// or donation button, if all is OK
static func paymentButtonType() -> PayWithApplePayButtonLabel? {
if PKPaymentAuthorizationController.canMakePayments() {
return PayWithApplePayButtonLabel.donate
}
if PKPaymentAuthorizationController.canMakePayments(
usingNetworks: Payment.supportedNetworks,
capabilities: Payment.capabilities) {
return PayWithApplePayButtonLabel.setUp
}
return nil
}

func donationRequest(for selectedAmount: SelectedAmount) -> PKPaymentRequest {
let request = PKPaymentRequest()
request.merchantIdentifier = Self.merchantId
request.merchantCapabilities = Self.capabilities
request.countryCode = "CH"
request.currencyCode = selectedAmount.currency
request.supportedNetworks = Self.supportedNetworks
request.merchantCapabilities = .threeDSecure
request.requiredBillingContactFields = [.emailAddress]
let recurring: PKRecurringPaymentRequest? = if selectedAmount.isMonthly {
PKRecurringPaymentRequest(paymentDescription: "payment.description.label".localized,
regularBilling: .init(label: "payment.monthly_support.label".localized,
amount: NSDecimalNumber(value: selectedAmount.value),
type: .final),
managementURL: URL(string: Self.paymentSubscriptionManagingURL)!)
} else {
nil
}
request.recurringPaymentRequest = recurring
request.paymentSummaryItems = [
PKPaymentSummaryItem(
label: "payment.summary.title".localized,
amount: NSDecimalNumber(value: selectedAmount.value),
type: .final
)
]
return request
}

func onPaymentAuthPhase(selectedAmount: SelectedAmount,
phase: PayWithApplePayButtonPaymentAuthorizationPhase) {
switch phase {
case .willAuthorize:
os_log("onPaymentAuthPhase: .willAuthorize")
case .didAuthorize(let payment, let resultHandler):
os_log("onPaymentAuthPhase: .didAuthorize")
// call our server to get payment / setup intent and return the client.secret
Task { @MainActor [resultHandler] in
let paymentServer = StripeKiwix(endPoint: Self.kiwixPaymentServer,
payment: payment)
do {
let publicKey = try await paymentServer.publishableKey()
StripeAPI.defaultPublishableKey = publicKey
} catch let serverError {
Self.finalResult = .error
resultHandler(.init(status: .failure, errors: [serverError]))
return
}
// we should update the return path for confirmations
// see: https://github.com/kiwix/kiwix-apple/issues/1032
let stripe = StripeApplePaySimple()
let result = await stripe.complete(payment: payment,
returnURLPath: nil,
usingClientSecretProvider: {
await paymentServer.clientSecretForPayment(selectedAmount: selectedAmount)
})
// calling any UI refreshing state / subject from here
// will block the UI in the payment state forever
// therefore it's defered via static finalResult
switch result.status {
case .success:
Self.finalResult = .thankYou
case .failure:
Self.finalResult = .error
default:
Self.finalResult = nil
}
resultHandler(result)
os_log("onPaymentAuthPhase: .didAuthorize: \(result.status == .success)")
}
case .didFinish:
os_log("onPaymentAuthPhase: .didFinish")
completeSubject.send(())
@unknown default:
os_log("onPaymentAuthPhase: @unknown default")
}

}

@available(macOS 13.0, *)
func onMerchantSessionUpdate() async -> PKPaymentRequestMerchantSessionUpdate {
guard let session = await StripeKiwix.stripeSession(endPoint: Self.kiwixPaymentServer) else {
await MainActor.run {
Self.finalResult = .error
}
return .init(status: .failure, merchantSession: nil)
}
return .init(status: .success, merchantSession: session)
}
}

private enum MerchantSessionError: Error {
case invalidStatus
}
Loading