One donor identity and one stored card, usable at every charity on Ummah. UK only, on the single Adyen merchant account. This is the plan to confirm before any code is written; it names the exact seams in the platform it builds on.
Status
Confirmed 2 Oct. POC built 3 Oct on passport-impl branches (local, unpushed) against a separate ummah_passport database. API spike and browser end-to-end both pass: one donor, one card, two charities, no re-entry.
Scope
United Kingdom, GBP, merchant account TrustXPayCOM. One Passport per donor, one region.
Basis
Platform code on development as of 2 Oct 2026, verified line by line.
Shape
Separate product: its own app and domain. Identity, cards, charities and payments all on Ummah.
What Passport is
A donor registers with Ummah once, verifies their email, and stores one card with a zero-value authentication. From then on, at any charity on the platform, they are recognised and give with a single confirmation. No card re-entry, no account per charity. Their receipts and giving history follow them rather than sitting with each charity.
Commercially it makes the donor Ummah's relationship as well as the charity's. That is the product; the payment rails underneath it already exist.
Boundaries you have set
UK only. One Adyen merchant account, one token vault, one liable balance account. Multi-currency settlement is a separate workstream and does not change this plan.
A separate product. Its own application and domain (working name passport.ummah.com), its own brand treatment, its own release cadence.
Authenticated on Ummah. Donor accounts, sessions and credentials live in the Ummah backend under a new auth scope. No third-party identity provider in v1.
Charities are Ummah merchants. Existing stores, balance accounts and split profiles. Nothing changes for a charity that never hears of Passport.
Cards and payments on Ummah. Tokens in Adyen's vault under Ummah's merchant account, charged through the existing payment service with the existing splits.
Already there, and what gets built
Capability
State today
Passport needs
Where
One merchant account, charities as stores with balance accounts
exists
No change. This is the premise the design relies on, and it is already true.
Build. Receipts exist only for subscription charges, as a PDF behind an emailed token.
invoices.controllers.ts:115-133
Email delivery
exists
Reuse for OTP codes and receipts. No WhatsApp or SMS anywhere; out of scope for v1.
ummah-platform-notification (SMTP)
Architecture: a hub with per-charity spokes
The one structural change is small and additive. A Passport account sits above the merchant-scoped customer records. Each charity keeps its own customer row for that donor, created the first time the donor interacts with it, and each row holds a mirror of the same Adyen token. Adyen holds one token; merchants see "their customer's saved card" exactly as they do today.
PassportAccount is new: id (uuid v7), verified email (unique), display name, status, consent timestamps. Credentials are passkeys plus an email one-time code; there are no passwords.
Customer gains one nullable column, passportAccountId. The existing unique constraint on merchant plus reference stays. Every existing customer row and token is untouched.
StoredPaymentMethod does not change shape. For Passport cards the Adyen token is minted with shopperReference set to the account id. Each charity's customer row gets its own row for the same adyenStoredPaymentMethodId; the existing unique key on customer plus token already allows this.
Why mirrors rather than re-scoping: subscriptions, dunning, merchant-initiated charging, portal customer pages and the worker's token persistence all key on a Customer today. Mirroring leaves every one of them working. Removing a Passport card disables all mirrors and revokes the token at Adyen once.
Flow A: first registration
EntryFrom the hosted checkout of any charity ("Save with Passport" at the end of a normal card payment), or directly on the Passport app.
IdentityEmail, then a six-digit code by email. Account created on first successful code. Passkey enrolment offered immediately after, and again on next sign-in if skipped.
CardReuse the existing zero-value flow with the Passport branch: amount 0, storePaymentMethod true, shopperInteraction Ecommerce, recurringProcessingModel CardOnFile, native 3DS preferred, shopperReference = account id. The spoke customer row for that charity is created first so the session carries a customerId.
TokenArrives on the Adyen webhook as today. The worker resolves the owner through the session's customer (see the first risk below), writes the StoredPaymentMethod mirror, and the account's card list shows it.
Flow B: returning donor at another charity
RecognisePassport session cookie on the Passport origin, or email plus code. The hosted checkout opens the Passport step in a popup on the Passport origin so the charity page never sees credentials.
SpokeIf this charity has no customer row for the account yet, create one (merchant, reference = account id, passportAccountId). Mirror the account's active tokens onto it.
SessionA normal checkout session for the charity's store, splits frozen as today, with customerId set and a flag marking it as a Passport session.
CardsThe payment-methods call now includes shopperReference for Passport sessions only, so Adyen returns the stored cards and the picker can render them.
ConfirmOne tap. Payment posted with paymentMethod.storedPaymentMethodId, shopperReference = account id, shopperInteraction Ecommerce, recurringProcessingModel CardOnFile. This is a cardholder-initiated payment on a stored credential. The issuer may challenge; the existing 3DS handling completes it. We do not claim a merchant-initiated exemption, because the donor is present.
AfterTransaction, splits, merchant webhook and merchant records exactly as today. Plus: a receipt email to the donor and the payment in their Passport history.
Flow C: remove a card or leave
Remove cardAdyen DELETE /storedPaymentMethods/{id} under the account's shopper reference, then every mirror set to DISABLED. Any subscription pinned to that card follows the existing needs-action path, so the Passport app warns the donor which charities' regular gifts will stop before confirming.
Delete accountAll tokens revoked at Adyen, credentials and sessions destroyed, email and name cleared from the account, customer rows unlinked. Merchant transaction records are retained as they must be; they no longer resolve to a person through Passport.
Decisions to confirm
Each has a recommendation. Confirming the page as it stands confirms all eight.
#
Decision
Recommendation
Why
D1
Donor credentials
recommended Passwordless: email one-time code to start, passkeys as the fast path. No passwords.
No password policy, breach checks or reset flows to carry. Passkeys give the biometric confirmation the concept wants, on every modern phone, with nothing to install.
D2
Payment model for the one-tap gift
recommended Cardholder-initiated on a stored credential. The issuer decides whether to challenge.
The design document labels a donor-present tap as merchant-initiated to claim an exemption. It is not merchant-initiated when the donor is tapping. The promise is one tap, with a bank challenge when the issuer asks for one.
D3
Token ownership
recommended Hub and spokes with token mirrors, as drawn above.
Zero changes to the existing token, subscription and merchant surfaces. Re-scoping StoredPaymentMethod would touch everything that charges a card.
D4
First surface
recommended The hosted checkout first; the embeddable button in phase 3.
Every charity already uses hosted links or the SDK, so Passport reaches donors with zero charity work. The widget is distribution, not proof.
D5
Where the code lives
recommended New repo ummah-platform-passport (Next.js on Amplify, same security headers, self-hosted fonts). Backend modules in ummah-platform-backend under /api/passport/*, behind a new PASSPORT scope, using the portal's BFF pattern.
Separate product, shared money movement. Tokens, splits and merchants cannot be charged from anywhere else.
D6
Shopper reference
recommended Passport sessions send the account id; everything else keeps sending the customer id.
One shopper reference is what makes the token usable at every store. Legacy paths are untouched.
D7
Pricing
recommended No Passport fee in v1. The charity's split profile applies unchanged.
Keeps the economics identical to today while the product proves itself. A Passport-specific rate is a one-line profile change later.
D8
Consent and data
recommended Explicit opt-in at registration; the donor sees every charity they have given to; card removal and account deletion are self-service and revoke at Adyen.
A consumer product that stores cards and shows cross-charity history needs these from day one. The legal wording is a phase 0 item.
Phases
Phase 0: confirmations
about one week · in parallel with phase 1 setup
Answers that change design if they come back differently.
Adyen: confirm cardholder-initiated payments on a stored token work on the merchant account, which exemptions apply, and that token deletion is enabled.
Adyen: confirm a token under one shopper reference is usable across all stores (expected yes; verify on TEST).
Legal: consent wording, privacy notice covering cross-charity visibility, account deletion versus merchant record retention.
Product: name, domain, brand treatment for the Passport app.
Done when the four answers are written down and D2 and D8 are either confirmed or revised.
Phase 1: foundation
about three weeks · backend and worker
Everything a donor-facing app will need, proven at the API level with no UI.
Migration: PassportAccount, PassportCredential (passkeys), Customer.passportAccountId, consent and audit fields. Mirrored to worker, webhook and notification schemas.
Worker: token owner resolved through the session or transaction customer, not by treating the shopper reference as a customer id.
Payments: the Passport branch (shopper reference, cardholder-initiated mode on a stored token); payment-methods call sends the shopper reference for Passport sessions.
Adyen: list and delete stored payment methods wired through the existing recurring service.
Auth: PASSPORT scope through the JWT strategy, roles guard and permission registry; email-code and passkey endpoints; the existing credential rate limiter extended to them; non-enumerating responses.
Read API: me, cards, history, receipts under /api/passport/*.
Done when a scripted donor saves a card at SimplyDonate on dev and pays a second test merchant with it, no card re-entry, both merchants seeing a normal customer and a normal split. passed 3 Oct on the local rig against Adyen TEST (SimplyDonate → East London Mosque, one token, two four-leg splits).
Phase 2: donor surfaces
about four weeks · new app plus checkout
The product a donor actually touches.
Passport app: sign in, home with giving history, cards, receipts, remove card, delete account, passkey management.
Hosted checkout: "Pay with Passport" entry, recognition popup on the Passport origin, saved-card picker, "Save with Passport" after a normal card payment.
Receipt email per gift through the existing notification pipeline; a per-transaction receipt PDF (today only subscription invoices exist).
Back office: read-only list of Passport accounts; customer detail shows the Passport link. Portal unchanged.
Done when the browser end-to-end on dev passes across two charities with zero CSP violations, and a card removed in Passport is gone at Adyen. browser E2E passed 3 Oct (sign-in, new card via SDK, one tap at the second charity, 0 CSP violations); card removal is wired (deletes at Adyen) and still to be exercised in the browser.
Phase 3: reach
about three weeks
Passport on the charity's own page, and the integration hooks charities will ask for.
Embeddable "Pay with Passport" button: one script, popup on the Passport origin, allowed on the charity's approved domains (the existing merchant domain allowlist).
Web SDK: a Passport option for charities embedding card fields themselves.
Outbound webhooks: Passport account id on payment events; a saved-card event, which merchants have been missing anyway.
Done when SimplyDonate's own site completes a Passport gift with no server change beyond today's session creation.
Later, deliberately not in this plan
separate decisions
Multi-currency and a second region. Tokens do not cross regions; a donor would register once per region. Covered by the settlement-currency workstream.
Receipts API for charities not on Ummah.
Zakat tracker, Gift Aid declarations.
WhatsApp or SMS notifications.
A browser extension. Desktop only, install friction before value; the popup model covers the same ground on every device.
Change inventory by repository
Repository
Changes
Phase
ummah-platform-backend
Migration and models · src/modules/passport/* (auth, accounts, cards, history, receipts) · PASSPORT scope in permissions.registry.ts, roles.guard.ts, jwt.strategy.ts · Passport branch in payments.service.ts and checkout.service.ts · AdyenRecurringService list and delete · rate-limit segments for the new auth routes · env template entries.
1
ummah-platform-worker
Schema mirror · adyen-webhook.service.ts token-owner resolution (lands before any Passport card is stored) · mirror tokens onto new spoke customers.
Schema mirror only. Notification gains the OTP and receipt templates.
1 and 2
ummah-platform-checkout
Passport entry on app/l/[token], popup handshake with the Passport origin, saved-card picker, "Save with Passport" after payment. CSP frame-src and connect-src gain the Passport origin.
2
ummah-platform-passport (new)
Next.js app on Amplify, cloned from the portal's security module (nonce CSP, headers, self-hosted fonts, force-dynamic), BFF routes to /api/passport/*, passkey client.
2
ummah-platform-backoffice
Read-only Passport accounts list; Passport badge on customer detail.
2
ummah-platform-web-sdk · docs
Passport option in the SDK; developer docs for the button and the webhook fields.
3
Risks and the things that will bite
The worker token lookup. When a token arrives on the Adyen webhook, the worker resolves its owner with a direct customer lookup keyed on the shopper reference and throws on a miss. The code states the assumption outright. Send an account id before fixing this and Passport tokens silently never persist while the webhook retries. This lands first, in its own change, before any Passport card is stored.
Issuer challenges in a popup. A cardholder-initiated payment on a stored card can trigger 3DS. The popup must host the challenge or hand back to the page; the checkout's existing redirect helper covers the full-page case and needs the popup case added.
Token processing model. Existing tokens are minted with recurringProcessingModel Subscription; Passport tokens with CardOnFile. Adyen accepts either for a later cardholder-initiated charge. Verify on TEST in phase 0 rather than assume.
The payment-methods change is global. Sending a shopper reference changes what Adyen returns. Gate it to sessions that carry a Passport account so no merchant flow changes.
Migrations are manual per environment and the schema is mirrored across four repositories. One migration, applied by hand before promotion, mirrored the same day.
Next 15 nonce CSP. Every page in the Passport app renders per request or its scripts are blocked. Fonts self-hosted, because Google Fonts broke two production builds on the portal.
Email delivery is the sign-in path. One-time codes ride the SMTP pipeline. The platform audit found bounce feedback unconsumed; Passport makes that a sign-in failure rather than a nuisance. Passkeys reduce the dependence after first sign-in.
Enumeration. Sign-in by email must answer identically for known and unknown addresses, and sit behind the credential rate limiter from the first commit.
Removing a card a charity relies on. A regular gift pinned to that card goes to needs-action and the charity's dunning email fires. The Passport app names the affected charities before the donor confirms.
Deletion versus retention. Deleting a Passport account must not delete merchant transaction records. Unlink and anonymise; keep the money trail.
Once confirmed: the first week
In this order, each as its own change, so the risky one is reviewed alone.
Worker token-owner resolutionResolve through the checkout session or transaction's customer; fall back to the shopper reference as a customer id for legacy paths. Unit test for both. Promoted on its own.
Migration and modelsPassportAccount, PassportCredential, Customer.passportAccountId. Mirrored to the three sibling schemas. Applied by hand on dev.
Payments branchPassport sessions send the account id as shopper reference; cardholder-initiated mode on a stored token; payment-methods call includes the shopper reference for Passport sessions only.
Adyen recurring serviceList and delete stored payment methods, with request logging like every other Adyen call.
Passport auth and read APIEmail code start and verify, PASSPORT scope, me and cards endpoints, rate limits.
The spike scriptRegister at SimplyDonate, pay a second dev merchant with the saved card, assert both transactions, both splits, one Adyen token. This is the phase 1 acceptance test, run early.
Internal · prepared for the Ummah platform team · plan v1 confirmed; POC implemented and verified on the local rig (see ummah-platform-passport and scripts/passport-spike.ts)updated 3 Oct 2026