Skip to content

v0.2.0

  • v0.2.0
  • 5303f44
  • Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
  • Choose a tag to compare

  • v0.2.0
  • 5303f44
  • Choose a tag to compare

  • Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
@silvo137 silvo137 tagged this 02 Sep 16:27
* Session snapshot: decode the wallets block and account_funding

The checkout session already carries the merchant's wallet configuration;
the iOS SDK simply threw it away. Decode `wallets` and `account_funding`
so the wallet gate has something to read.

Every member is optional on purpose. A session snapshotted before the
backend shipped the block carries no `wallets` at all, and a member the
server had no opinion about arrives null; both are distinct from an
explicit `false` and the gate treats only the latter as a refusal. The two
new initialiser parameters are defaulted so existing construction sites in
the tests and in DemoHarnessCore keep compiling.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Wallet gate: strict-false session rule and Apple Pay eligibility

Two pure decisions the wallet branch needs before any PassKit exists, so
both are provable in a Linux container.

The session rule is strict-false to match the Android SDK and the checkout
page: an absent block and a null member both mean the server had no
opinion and the button is still offered. Reading either as a refusal would
drop the wallet from every session snapshotted before the backend shipped
the block. Account funding is the one override, because core rejects those
wallet payments server-side.

Eligibility keeps the session question separate from the device and
configuration ones so a merchant chasing a missing button can tell which
condition answered no. An empty merchant identifier counts as
unconfigured: it is what an unset build constant yields, and letting it
through produces a submit body the edge misreads as a web token.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Session snapshot: a malformed wallet flag costs only itself

SessionData decodes as one value, so a wrong-typed `apple_pay` threw and
took the field groups and the saved cards with it. PaymentSheet swallows
that error, so session data became nil -- and the strict-false gate reads
nil as permission. A merchant who switched Apple Pay off in a shape iOS
could not parse got the button, on a form the server never described.
Android does not have this failure because Gson coerces the same payload.

Read both wallet fields leniently instead: true/"true"/1 and
false/"false"/0 all mean what they say, anything else is nil for that
member alone, and a `wallets` value that is not an object drops the block,
which the gate already treats as "no opinion". Every other field still
throws, because a malformed field group has no safe reading.

account_funding gets the same treatment. It poisons the decode identically
and inverts the same way, and fixing one key while leaving its neighbour
would have left the reported bug reachable through the other.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Wallet gate: a blank merchant identifier is not a configured one

Whitespace passed eligibility. It is what a hand-cleared text field leaves
behind, and it fails worse than nil: it is truthy enough to reach the
submit body, where it cannot match the signed session claim and the
shopper gets a bare 400. The doc comment already named a trimmed text
field as a source of the unconfigured value and the code stopped one step
short of it. Trimming matches the house `nonEmpty` helper in
SessionResolution.

Also adds the eligibility row for an absent wallets block. The other eight
rows all ran against a block that exists, while every session minted
before the backend shipped the block takes the absent path.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Core: a JSON passthrough value for wallet tokens

Apple's payment token is not ours to model. The edge reads `paymentData`,
the vault reads four fields inside it, and Apple may add a fifth without
telling anyone; a `Codable` struct would drop whatever it did not name and
the symptom would be a decryption failure with no field to point at.

Integers decode through `Int64` rather than `Double`, so a long numeric
field cannot come back in exponent notation, and an explicit null stays a
value rather than becoming an absent key.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Submit: an apple_pay body carrying the wallet token and its merchant identifier

`merchant_identifier` is a non-optional String because an omitted one is
not refused anywhere. The edge reads an absent field as "this is a web
token", the vault falls back to the environment default key, `openGCM`
fails, and the shopper gets a 400 that reads exactly like a decline. A
non-optional field cannot be dropped by the encoder, and `applePay` is the
only constructor, so an empty identifier yields nil rather than a body
that fails in the one way nothing downstream can diagnose.

The payment method comes from the token rather than from a second
argument. The edge refuses a body whose `wallet_token.type` disagrees with
its `payment_method`, so two arguments would only be two chances to make
them disagree.

The token body rides as `JSONValue`, verbatim. The card path is untouched
and pinned by a test that asserts a card body still carries `card` and no
`wallet_token`.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Core: the Apple Pay request spec and the wallet authorization protocol

Everything Apple's sheet needs is decided here, in a target that cannot
import PassKit, so all of it is provable on Linux. `ApplePayNetwork` and
`ApplePayCapability` name the schemes and capabilities without naming
`PKPaymentNetwork`: spelling Apple's raw values in a file that cannot see
the symbols defining them would turn a typo into an empty network list and
a button that never appears, with nothing to grep for. The adapter maps
these cases to PassKit by symbol.

The amount is a `Decimal`, not a `Double`. It is the number a shopper
reads before authorising, and a hundredth lost to binary floating point is
a hundredth they did not agree to. A zero-decimal currency divides by one,
so a JPY sheet quotes 1234 rather than 12.34.

The country falls back to US when the session names none, because Apple
refuses a request without one. It is a default, not an inference about the
shopper.

`WalletAuthorizing` mirrors `ThreeDSPresenting`: the only part of this
flow needing a platform framework is the presentation, so it sits behind a
protocol and the branch stays testable on Linux. A cancellation is its own
outcome rather than a failure, because the shopper dismissed a sheet and
is still looking at the card form.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Core: pin the Int64 branch with a value a Double cannot hold

`testAnIntegerIsNotTurnedIntoAFloat` does not test what it says. Deleting
the `Int64` branch entirely, so every number decodes through `Double`,
left the whole suite green: Foundation writes a whole `Double` without an
exponent, so 1234567890123 comes back spelled exactly the same either way.
The branch the card wrote that test to protect was unprotected.

2^53 + 1 is the smallest integer a `Double` cannot hold. Through `Double`
it comes back 9007199254740992, one less than it went in, which is the
failure a long numeric field in Apple's token would actually suffer: a
silent digit change rather than a visible exponent. With the branch
deleted this test is the only one that fails.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: pin every field the token fixture carries, not six of them

The round-trip test walked six key paths and the fixture holds ten. The
four it skipped were not minor ones: `paymentData.data` is the encrypted
blob itself and `header.ephemeralPublicKey` is an input to key agreement,
so losing either fails decryption with nothing to point at, and
`paymentMethod.displayName` and `type` become the back office's
`display_digits` and `funding`. Making the encoder silently drop any one
of those four left all 254 tests green, which is the exact regression
`JSONValue` exists to prevent shipping green through.

Each of the four now fails the round-trip test. The nested key sets are
asserted too, so a field added to the fixture cannot sit unasserted the
way these did.

The fixture also gains `applicationData`, the fifth header string the
vault reads, so the type's "Apple may add a field nobody named" rationale
is something the fixture demonstrates rather than only claims. Non-ASCII
and empty containers get an assertion apiece: `displayName` carries the
card's own label and is where non-ASCII actually arrives.

No new tests. Every assertion here lands inside a test that already
existed.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Submit: the token builder trims the identifier, as the gate already does

The gate offered the button for `"  merchant.pay-cross.com\n"` and the
builder then carried the padding into the submit body verbatim. The edge
compares that value against the signed session claim byte for byte, so the
shopper authorised with Face ID and got a 400. A merchant reads this
identifier out of a plist, an environment variable or a copied console
field, and this campaign has already shipped one stray-newline bug.

The two guards now agree, both trimming whitespace and newlines, which is
what the type's own doc comment already claimed. Nothing distinguished
them before: switching the guard to the trimmed form left all 254 tests
green in either direction.

Whitespace-only is now refused rather than sent as itself.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Spec: uppercase the country and currency before they reach PassKit

Apple wants ISO 4217 and ISO 3166-1 alpha-2 in their conventional case,
and nothing guarantees the session sends them that way. This repo has
already decided lowercase is reachable: `Amounts` uppercases at all three
of its own call sites. So within this one struct `amountMajorUnits`
survived a "gbp" session, because it goes through `Amounts`, while
`currencyCode` handed "gbp" straight to `PKPaymentRequest`.

An inconsistency inside a single type is the finding. The consequence, if
it fires, is a sheet that fails to present at the exact moment of payment,
which is the worst place to discover it.

Task 03's adapter can now assign both onto `PKPaymentRequest` without
normalising them itself.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Core: WalletAuthorizationOutcome is Equatable, as ThreeDSOutcome is

The protocol mirrors `ThreeDSPresenting` in every respect but this one.
`ThreeDSOutcome` is `Sendable, Equatable`; this was `Sendable` only, which
forces task 03's adapter tests into a `guard case ... else { XCTFail }`
dance that cannot sit inside `XCTAssertEqual`.

Both payloads are already `Equatable` -- `JSONValue` is `Hashable` and the
failure carries a `String` -- so the conformance synthesises and no design
decision rides on it. The frozen-names table pins the cases rather than
the conformances, so nothing downstream is contradicted.

The test compares payloads rather than only cases, because a conformance
nothing exercises is one a later cleanup deletes.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Docs: name the passthrough's ceiling and what "verbatim" means

Two facts both reviews had to measure from scratch, written down where the
next reader meets them.

"Verbatim" is field-verbatim, not byte-verbatim. Key order is not
preserved and number spelling normalises, and neither matters because the
edge re-serializes the envelope anyway and Apple's EC_v1 signature covers
field values rather than their serialization. Task 03 could otherwise
waste effort trying to preserve bytes, or chase a phantom bug.

`Int64` is the ceiling, and a JSON number above it loses precision through
`Double`. Unreachable from a real token, where every field the vault reads
is a string, but worth naming so nobody re-derives it as a bug or widens
the type without a reason.

Also notes that the type/payment_method assertion cannot fail as the code
stands, and why it is kept anyway.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Version: the User-Agent reports the version that ships

PayCrossAPI.version said 0.1.0-alpha while the pods shipped 0.1.1, so every
payment made through this SDK told the backend it came from a version that was
never released. A support question about one merchant's payments could not be
narrowed to one SDK build.

The test reads the version out of PayCross.podspec rather than restating the
literal, so the next release has one place to edit rather than two.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Configuration: the merchant's Apple merchant identifier

Nil means no Apple Pay button, ever. The parameter is defaulted so every
existing configure call site keeps compiling, and nothing else about the
function changes.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* PassKit: present Apple's sheet and return the token as JSON

The only file in the SDK that imports PassKit. Every decision around it lives
in PayCrossCore and is proved on Linux; what is left here is presentation and
one mapping, both of which only a device can exercise.

The outcome is recorded by didAuthorizePayment and delivered by
paymentAuthorizationControllerDidFinish, because PassKit reports the
authorisation and the dismissal through two separate callbacks. Resuming in the
first would leave a sheet over a finished flow; not resuming in the second
would hang the payment forever when the shopper swipes the sheet away.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Payment sheet: an Apple Pay branch beside the card form

Apple's own PKPaymentButton renders above the card form, offered when Core's
gate says the session allows the wallet, the integration configured an
identifier, the device has a card, and the session snapshot actually loaded.

That fourth condition is this layer's, not Core's. The gate is deliberately
permissive about a nil snapshot, because an absent wallets block is "the server
had no opinion" and every session minted before the backend shipped the block
reads that way. A snapshot that never arrived is a different thing: load()
swallows a transport failure and a 5xx alike, and the request spec needs the
session's own currency and amount, so the button would open onto nothing or
onto a sheet quoting the wrong money. Core's semantics are unchanged; the rule
lives where the render is decided.

payWithApplePay() is a sibling of pay() rather than a mode inside it, so the
card path gains no way to be wrong. Field groups are validated before the sheet
opens, because core validates them ahead of the wallet branch server-side and
would otherwise reject a payment the shopper has already authorised with Face
ID. A cancelled sheet emits nothing at all.

The configured identifier is trimmed once, here, where it stops being a gate
input and becomes payload. The gate trims before answering and the token
builder trims before building the body, so an untrimmed value reaching
PKPaymentRequest would put an identifier Apple cannot match beside a submit
body carrying the right one.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: the Apple Pay button, the spec it sends, and the ordering it must keep

Twelve presentation tests plus one screenshot. The model-level truth table is
written as five rows including the positive one, because on a simulator the
real canMakePayments is always false: a model that asked PassKit directly would
answer "no button" to every question and every negative row would pass for a
reason that has nothing to do with what it claims to test.

The fake authorizer is an actor that records every spec it is handed, and the
tests assert on the merchant identifier, country, currency and amount rather
than on a payment having finished. The field-group test asserts a zero call
count, not the presence of an error: asserting an error would pass just as well
against a sheet that opened first and validated afterwards, which is the
failure the ordering exists to prevent.

The coordinator becomes an NSObject. UIKit hands registered targets back
through allTargets, whose elements bridge as NSObject, so a Swift-native
coordinator traps the moment anything reads that set.

The tap is dispatched through the registered target-action pair rather than
sendActions(for:). That call routes through UIApplication's event machinery and
an xctest bundle has no UIApplication object, so the count stays zero however
well the button is wired; this was measured, not assumed. Reaching for the pair
also asserts more, and matches what ThreeDSPresentationTests does for the 3DS
challenge's own Cancel control.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* PassKit: bound the resume, refuse a second sheet, name what failed

Three defects the review found in the adapter, none of which any test could
have caught, because before this commit nothing executed the adapter at all.

The continuation was resumed only from inside dismiss's completion block. That
completion is not promised to run when there is nothing to dismiss, and there
is no timeout anywhere on the path, so a payment could hang with isLoading true
for the life of the sheet. didFinish now asks for the dismissal and resumes
without waiting on it. Nothing that matters is given up: the next thing that
happens is a network submit. It also removes the assumeIsolated, whose failure
mode in a shipped payments SDK is a trap mid-payment rather than the race it
prevents, and with it the [weak self] that the outer closure made inert.

A second authorize on the same instance overwrote the first continuation and
left its caller waiting forever, reported only as SWIFT TASK CONTINUATION
MISUSE. Not reachable through the sheet, whose isLoading guard serialises it,
but that guard is in another file and a bridge is exactly what adds a second
caller.

The presentation failure said only that Apple Pay could not be presented.
present() answers a bare false and answers it almost exclusively for a
misconfigured request, so the merchant integrating the SDK was left with a
sentence naming nothing.

Nine tests. makeRequest is extracted as a pure function of the spec so the
request can be asserted without a sheet, including that it keeps the 3-D Secure
capability Apple requires and that a zero-decimal currency is quoted whole. The
continuation discipline is driven through the delegate directly, against a
controller that is built and never presented, because a simulator has no
entitlement and present() there never answers.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Button: dim it while a payment is in flight, and label the real control

For the whole submit-and-poll phase, which can run to a deadline of minutes,
Apple's button stayed fully lit and did nothing when tapped: the model's
re-entry guard swallowed the tap, and the spinner was on the card button, which
is not the control the shopper is looking at. The primary action of the feature
lied about its state.

isUserInteractionEnabled rather than isEnabled alone. PKPaymentButton ignores
isEnabled and keeps reporting true however it is set, measured on iOS 26.5 with
both set on the same view: the alpha changed and the enabled flag did not. Both
are set anyway, so the control is right if Apple starts honouring it. SwiftUI's
.disabled() does not reach a UIViewRepresentable's underlying view at all.

The accessibility identifier now also lands on the control. SwiftUI puts the
modifier on its own accessibility node, so anything walking UIViews found
nothing, which the demo harness in task 08 will want.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: the sheet's on-switch, and the path that carries a payment token

Two gaps the review measured. Hardcoding showsApplePayButton to false in the
sheet left the whole suite green while Apple Pay never appeared: the model's
rule had five tests and the form's rendering had two, but the one line joining
them had none. And nothing anywhere executed the authorised branch, so the
first run of submitWallet, WalletToken.applePay and the wallet SubmitCardRequest
would have been on a shopper's device.

Two seams make both testable, both defaulted initialiser parameters beside the
three this task already added. isPreparing, because it starts true and only
load() clears it, so a hosted sheet renders a spinner and never reaches the card
form. And the transport, for the same reason PaymentFlowRunner takes one in
Core: otherwise a model test opens a real socket to the checkout API.

The join tests drive a stub session through the sheet's own load(), so the
button appears in response to a session arriving rather than to an injected
snapshot. The negative half waits for the same session to land first, so it
cannot pass by looking too early.

The authorised test asserts the encoded body, not the struct: payment_method,
a present and non-empty merchant_identifier, no card block, and the token
walked by named key path. Its twin drives a 400 and asserts the form re-arms
where a card decline re-arms.

Also moves the identifier guard above the field-group validation, so a sheet
that could never open does not first decorate the form with errors, and makes
deviceCanPay private like every other seam beside it.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: a continuation that never resumes must fail, not hang

The adapter tests joined the waiting task unconditionally, so the exact
regression they exist to catch turned a failed assertion into a run that hung
until xcodebuild gave up: ten minutes, and no report. Measured while proving
the mutation. The task is now joined only when it resumed, and abandoned
otherwise.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: bound the second-sheet check so its own regression fails it

Removing the re-entrancy guard sends the second authorize on to present(),
which on a simulator with no entitlement neither succeeds nor returns, so the
test hung the run rather than failing. Measured while proving the mutation.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: read the shipped strings, and pin the two lists that must agree

The second version test compared a constant to itself: the same interpolated
prefix on both sides, so it could not fail independently of the first and never
read a string that ships. It now drives a session fetch through a stub
transport and reads the User-Agent off the request the client built, and a
sibling covers the fallback agent a 3DS submission carries until the device
agent resolves. Nothing checked PayCrossCore.podspec either, though PayCross
depends on it at its own version, so a half-bump would have surfaced only when
pod lib lint refused with the version already chosen.

In Core, one test pins that the request spec offers every network the device
check asks PassKit about. The sheet checks ApplePayNetwork.allCases and the
request carries a hardcoded five; they agree today, and a sixth case would make
the button appear for a card the sheet then refuses. Asserted rather than
wired, because which schemes a merchant is offered should stay a deliberate
list. Linux 259 to 260.

The stub transport moves to its own file now that two test classes need it.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Release 0.2.0: Apple Pay, and the changelog

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* PassKit: pin the token conversion, and close the seam's own gap

Three findings from the fix-round confirm.

tokenJSON was the last function here a device was the only thing executing.
Deleting the paymentMethod key from what it emits left all fifty tests green,
because the authorised-path test walks a fixture the fake authorizer supplies
rather than anything this function produced. The edge lifts three fields out of
that object: network becomes the card brand the vault seals into the wallet
credential, displayName becomes the masked digits, and type becomes the funding
type the back office shows. Losing it degrades all three at once with nothing
anywhere reporting it.

PassKit hands out PKPaymentToken and PKPaymentMethod and offers no initialiser
that fills them, but both are Objective-C classes whose relevant properties are
readonly and overridable, so a test-local subclass supplies one without a
sheet. Five tests: every field the edge reads, walked by key path off the
encoded JSON; an unknown key inside paymentData surviving, which is what the
passthrough exists for; the funding type mapped by symbol for all four kinds; a
card with neither name nor network omitting rather than nulling them; and bytes
that are not JSON converting to nothing rather than to an empty body. tokenJSON
drops private to be reachable.

Two doc comments disagreed about present() on a simulator, one saying it
answers false and one saying it never answers. The second is what was measured,
twice, and the first is the one that invites the unbounded test that consumed a
ten-minute run.

awaitDelegateOutcome installed a continuation without the guard authorize
carries, making the seam the one route back to the leak that guard exists to
prevent. It now repeats the check, which also closes a window authorize alone
cannot: two callers can both pass the guard there and then suspend on present()
before either arrives here.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq

* Tests: hold the guard on the seam that installs the continuation

Repeating authorize's guard inside awaitDelegateOutcome closed the seam's own
route back to the leak, and then nothing held it there: removing it again left
all fifty-five tests green. Bounded like its sibling, because without the guard
the second call installs a second continuation and never returns, which would
hang the run rather than fail the test.

Claude-Session: https://claude.ai/code/session_01ESYanixFjGbbKEGjgYKeDq
Assets 2
Loading