Skip to content

Commit 447a744

Browse files
authored
Merge pull request #8 from AmoreComputer/feat/checkout
feat(checkout): add AmoreCheckout for in-app Stripe checkout
2 parents 3d3eda4 + 0701eee commit 447a744

43 files changed

Lines changed: 3994 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ docs:
77
--transform-for-static-hosting \
88
--target AmoreLicensing \
99
--target AmoreStore \
10+
--target AmoreCheckout \
1011
--output-path ./docs \
1112
--enable-experimental-combined-documentation \
1213

‎Package.swift‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ targets.append(
5959
)
6060
#endif
6161

62+
#if os(macOS)
63+
products.append(
64+
.library(
65+
name: "AmoreCheckout",
66+
targets: ["AmoreCheckout"]
67+
)
68+
)
69+
targets.append(
70+
.target(
71+
name: "AmoreCheckout",
72+
dependencies: ["AmoreStore", "AmoreLicensing"]
73+
)
74+
)
75+
targets.append(
76+
.testTarget(
77+
name: "AmoreCheckoutTests",
78+
dependencies: ["AmoreCheckout", "AmoreLicensing", "AmoreStore"]
79+
)
80+
)
81+
#endif
82+
6283
let package = Package(
6384
name: "AmoreKit",
6485
platforms: [
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import struct AmoreStore.Product
2+
import Foundation
3+
4+
extension AmoreCheckout {
5+
6+
/// Abandons the checkout and returns to ``CheckoutState/idle``: a flow in
7+
/// progress ends with a pending ``start(_:customerEmail:)`` returning
8+
/// `.cancelled`, and a terminal state is cleared so the next start begins
9+
/// fresh. Refused during ``CheckoutState/activating``
10+
/// (``CheckoutState/isCancellable`` is `false`), so a paid session always
11+
/// runs to a terminal state.
12+
public func cancel() {
13+
if case .activating = state { return }
14+
flow?.cancel()
15+
state = .idle
16+
}
17+
18+
/// Runs a checkout for `product` to its terminal result: creates a
19+
/// session, polls until payment resolves, and activates the purchased
20+
/// license.
21+
///
22+
/// ``state`` tracks progress for observers and settles on the matching
23+
/// terminal state before the result is returned, so the two can never
24+
/// disagree. Calling from ``CheckoutState/completed(_:licenseKey:)`` or
25+
/// ``CheckoutState/failed(_:)`` starts a fresh attempt, so a Try Again
26+
/// button is just another `await checkout.start(product)`. Calling while
27+
/// an attempt is already in progress joins it and returns that attempt's
28+
/// result, whichever product it was for, so a surface offering several
29+
/// products should disable its buy buttons on ``isInProgress``.
30+
/// Cancelling the surrounding task, or calling ``cancel()``, ends the
31+
/// flow with `.cancelled`.
32+
///
33+
/// A purchase interrupted after payment is picked up instead of charged
34+
/// again: a persisted pending session that completed activates its
35+
/// license, and one still processing is polled to its result. Only an
36+
/// unpaid, expired, or stale pending session is superseded by a fresh one.
37+
/// A ``recoverPendingPurchase()`` still in flight is awaited first, and if
38+
/// it activated the interrupted purchase the attempt ends `.cancelled`
39+
/// rather than opening a checkout for a product now owned.
40+
/// - Parameters:
41+
/// - product: The product to purchase, from `AmoreStore.products()`.
42+
/// - customerEmail: Prefills the email field on the checkout page.
43+
@discardableResult
44+
public func start(
45+
_ product: Product,
46+
customerEmail: String? = nil
47+
) async -> CheckoutResult {
48+
// A re-entrant start (a double-clicked retry, two surfaces driving
49+
// one checkout) joins the attempt in progress instead of reporting a
50+
// false .cancelled for a flow that is still running.
51+
if let flow, isInProgress {
52+
return await flow.value
53+
}
54+
state = .preparing
55+
56+
let flow = Task { await run(product: product, customerEmail: customerEmail) }
57+
self.flow = flow
58+
let result = await withTaskCancellationHandler {
59+
await flow.value
60+
} onCancel: {
61+
flow.cancel()
62+
}
63+
// A flow ended by cancelling the surrounding task still owns the state
64+
// and returns it to idle; once cancel() or a newer start() has taken
65+
// over, it keeps its hands off.
66+
if case .cancelled = result, self.flow == flow, isInProgress {
67+
state = .idle
68+
}
69+
return result
70+
}
71+
72+
// MARK: - Private
73+
74+
private func run(product: Product, customerEmail: String?) async -> CheckoutResult {
75+
if let recovery {
76+
_ = await recovery.value
77+
if case .valid = licensing.status { return .cancelled }
78+
}
79+
if let resumed = await resumePendingSession(for: product) { return resumed }
80+
let session: CheckoutSession
81+
do {
82+
session = try await client.createSession(
83+
productId: product.id, customerEmail: customerEmail
84+
)
85+
} catch {
86+
guard !Task.isCancelled else { return .cancelled }
87+
return fail(.sessionCreationFailed(underlying: error))
88+
}
89+
guard !Task.isCancelled else { return .cancelled }
90+
do {
91+
try store.save(PendingCheckout(
92+
session: session, productId: product.id, createdAt: .now
93+
))
94+
} catch {
95+
store.clear()
96+
return fail(.checkoutNotRecordable)
97+
}
98+
state = .awaitingPayment(session.checkoutURL)
99+
return await poll(
100+
sessionId: session.sessionId, paymentDeadline: session.pollDeadline(from: .now)
101+
)
102+
}
103+
104+
// A pending session persisted by an earlier interrupted purchase is
105+
// resolved before any new session is created, so the customer cannot be
106+
// charged twice: a completed session activates its license, one still
107+
// processing is polled to its result, and an open one for the same
108+
// product is reopened at its original URL. Only an unpaid session falls
109+
// through to a fresh checkout: one for another product, an expired or
110+
// stale one, one the server has no record of, or a leftover held by an
111+
// already-licensed user.
112+
private func resumePendingSession(for product: Product) async -> CheckoutResult? {
113+
guard let pending = store.load() else { return nil }
114+
guard pending.createdAt.addingTimeInterval(Self.maxPendingAge) > .now else {
115+
store.clear()
116+
return nil
117+
}
118+
let status: CheckoutSessionStatusResponse
119+
do {
120+
status = try await client.sessionStatus(sessionId: pending.sessionId)
121+
} catch {
122+
guard !Task.isCancelled else { return .cancelled }
123+
if case .httpStatus(404) = error {
124+
store.clear()
125+
return nil
126+
}
127+
return fail(.pendingSessionUnresolved(underlying: error))
128+
}
129+
guard !Task.isCancelled else { return .cancelled }
130+
switch status.status {
131+
case .complete:
132+
guard let key = status.licenseKey else {
133+
return fail(.pendingSessionUnresolved(underlying: nil))
134+
}
135+
return await activate(licenseKey: key)
136+
case .processing:
137+
state = .activating
138+
return await poll(sessionId: pending.sessionId, paymentDeadline: nil)
139+
case .expired:
140+
store.clear()
141+
return nil
142+
case .open:
143+
if case .valid = licensing.status {
144+
store.clear()
145+
return nil
146+
}
147+
// The session's own expiry bounds the reopened wait, falling back
148+
// to the pending-age ceiling when the server omitted one.
149+
let expiry = pending.session.pollDeadline(from: pending.createdAt)
150+
guard pending.productId == product.id, expiry > .now else { return nil }
151+
state = .awaitingPayment(pending.session.checkoutURL)
152+
return await poll(sessionId: pending.sessionId, paymentDeadline: expiry)
153+
}
154+
}
155+
156+
// The wait for payment is bounded by the session's own expiry; the wait
157+
// for the license that follows it is bounded by the number of requests
158+
// instead, so a server that never issues one cannot be polled
159+
// indefinitely. A nil `paymentDeadline` is a session already paid for.
160+
private func poll(sessionId: String, paymentDeadline: Date?) async -> CheckoutResult {
161+
var deadline = paymentDeadline
162+
var activationPolls = 0
163+
var consecutiveFailures = 0
164+
while !Task.isCancelled {
165+
if case .activating = state { deadline = nil }
166+
if let deadline {
167+
if deadline <= .now { return fail(.sessionExpired) }
168+
} else {
169+
activationPolls += 1
170+
if activationPolls > Self.maxActivationPolls {
171+
return fail(.licenseIssuanceTimedOut)
172+
}
173+
}
174+
do {
175+
let status = try await client.sessionStatus(sessionId: sessionId)
176+
guard !Task.isCancelled else { return .cancelled }
177+
consecutiveFailures = 0
178+
switch status.status {
179+
case .complete:
180+
if let key = status.licenseKey {
181+
return await activate(licenseKey: key)
182+
}
183+
// Payment landed; the license is still being issued.
184+
state = .activating
185+
case .processing:
186+
state = .activating
187+
case .expired:
188+
return fail(.sessionExpired)
189+
case .open:
190+
break
191+
}
192+
} catch {
193+
consecutiveFailures += 1
194+
if state != .activating {
195+
if case .httpStatus(404) = error { return fail(.sessionExpired) }
196+
if consecutiveFailures > Self.maxPollingFailures {
197+
return fail(.pollingFailed(underlying: error))
198+
}
199+
}
200+
}
201+
try? await Task.sleep(for: pollInterval * (1 << min(consecutiveFailures, 3)))
202+
}
203+
return .cancelled
204+
}
205+
206+
private func activate(licenseKey: String) async -> CheckoutResult {
207+
state = .activating
208+
do {
209+
try await licensing.activate(licenseKey: licenseKey)
210+
} catch {
211+
guard !Task.isCancelled else { return .cancelled }
212+
return fail(.activationFailed(licenseKey: licenseKey, underlying: error))
213+
}
214+
store.clear()
215+
guard case .valid(let license) = licensing.status else {
216+
return fail(.activationFailed(licenseKey: licenseKey, underlying: .invalidToken))
217+
}
218+
state = .completed(license, licenseKey: licenseKey)
219+
return .completed(license, licenseKey: licenseKey)
220+
}
221+
222+
private func fail(_ error: CheckoutError) -> CheckoutResult {
223+
state = .failed(error)
224+
return .failed(error)
225+
}
226+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
3+
enum CheckoutNavigationAction: Equatable {
4+
case allow
5+
case interceptSuccess
6+
case interceptCancel
7+
}
8+
9+
// The server's success and cancel redirect pages are intercepted before they
10+
// render and folded back into the flow.
11+
extension AmoreCheckout {
12+
func navigationAction(for url: URL) -> CheckoutNavigationAction {
13+
if matches(url, pathSuffix: "checkout/success") { return .interceptSuccess }
14+
if matches(url, pathSuffix: "checkout/cancel") { return .interceptCancel }
15+
return .allow
16+
}
17+
18+
// The polling loop picks up the completed session and activates as usual.
19+
func handleSuccessRedirect() {
20+
guard isInProgress else { return }
21+
state = .activating
22+
}
23+
24+
func handleCancelRedirect() {
25+
if case .activating = state { return }
26+
cancel()
27+
}
28+
29+
private func matches(_ url: URL, pathSuffix: String) -> Bool {
30+
guard url.scheme == baseURL.scheme, url.host() == baseURL.host() else { return false }
31+
let expected = baseURL.appending(path: pathSuffix).path()
32+
return url.path() == expected || url.path().hasPrefix(expected + "/")
33+
}
34+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import AmoreLicensing
2+
import Foundation
3+
4+
extension AmoreCheckout {
5+
/// Pending sessions older than this are discarded without a network call.
6+
static let maxPendingAge: TimeInterval = 24 * 60 * 60
7+
8+
/// How many times a paid session asks for its license before the flow
9+
/// stops waiting. Bounding the requests rather than the elapsed time keeps
10+
/// a server that never issues the license from being polled indefinitely.
11+
static let maxActivationPolls = 100
12+
13+
/// How many status checks in a row may fail before a checkout that has not
14+
/// been paid for gives up. A paid one keeps polling to its own bound.
15+
static let maxPollingFailures = 8
16+
17+
/// Resolves a checkout interrupted by the app quitting after payment: if
18+
/// the persisted pending session completed, the license is activated
19+
/// silently and returned. Call once at launch.
20+
///
21+
/// Payment that succeeded but could not be activated on this device is
22+
/// reported through ``state`` as
23+
/// ``CheckoutError/activationFailed(licenseKey:underlying:)``, which
24+
/// carries the license key, rather than resolved silently.
25+
///
26+
/// A second call made while the first is still running joins it rather
27+
/// than resolving the same pending session twice, and the two entry points
28+
/// wait for each other: ``start(_:customerEmail:)`` waits for a recovery in
29+
/// flight before reading the pending session, and a recovery started during
30+
/// a checkout waits for that checkout, so neither order activates the same
31+
/// key twice.
32+
@discardableResult
33+
public func recoverPendingPurchase() async -> License? {
34+
if let recovery { return await recovery.value }
35+
// A checkout in progress resolves the same pending session before it
36+
// creates anything new, so recovery waits for its result instead of
37+
// reading that session too.
38+
if let flow, isInProgress {
39+
guard case .completed(let license, _) = await flow.value else { return nil }
40+
return license
41+
}
42+
let recovery = Task { await resolvePendingPurchase() }
43+
self.recovery = recovery
44+
let license = await recovery.value
45+
if self.recovery == recovery { self.recovery = nil }
46+
return license
47+
}
48+
49+
// MARK: - Private
50+
51+
private func resolvePendingPurchase() async -> License? {
52+
guard let pending = store.load() else { return nil }
53+
guard Date.now.timeIntervalSince(pending.createdAt) < Self.maxPendingAge else {
54+
store.clear()
55+
return nil
56+
}
57+
let status: CheckoutSessionStatusResponse
58+
do {
59+
status = try await client.sessionStatus(sessionId: pending.sessionId)
60+
} catch {
61+
// An unreachable server says nothing about the purchase, so the
62+
// pending session is left for the next launch.
63+
return nil
64+
}
65+
switch status.status {
66+
case .complete:
67+
guard let key = status.licenseKey else { return nil }
68+
return await activateRecovered(licenseKey: key)
69+
case .expired:
70+
store.clear()
71+
case .open:
72+
if case .valid = licensing.status { store.clear() }
73+
case .processing:
74+
break
75+
}
76+
return nil
77+
}
78+
79+
// A key the server issued that this device cannot activate fails the same
80+
// way at every launch, so the failure is surfaced while the key is still
81+
// recoverable: retrying in silence would end at `maxPendingAge` discarding
82+
// the record, and with it the only copy of a key the customer paid for.
83+
private func activateRecovered(licenseKey: String) async -> License? {
84+
do {
85+
try await licensing.activate(licenseKey: licenseKey)
86+
} catch {
87+
state = .failed(.activationFailed(licenseKey: licenseKey, underlying: error))
88+
return nil
89+
}
90+
store.clear()
91+
if case .valid(let license) = licensing.status { return license }
92+
return nil
93+
}
94+
}

0 commit comments

Comments
 (0)