Ummah · Passport · build plan v1

Ummah Passport Build Plan

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

Already there, and what gets built

CapabilityState todayPassport needsWhere
One merchant account, charities as stores with balance accountsexistsNo change. This is the premise the design relies on, and it is already true.config.adyen.merchantAccount · Store.adyenStoreId / adyenBalanceAccountId
Splits: charity leg plus commission to the liable accountexistsNo change. Passport payments use the charity's split profile as today.adyen-split.service.ts buildProfileSplits · payments.service.ts:205-218
Zero-value card storage with 3DSexistsReuse. The card-update flow already posts amount 0 with storePaymentMethod.payments.service.ts:375-449 pmUpdatePayments · checkout.service.ts:216-283
Stored tokens, merchant-initiated charging, subscriptions, dunningexistsReuse unchanged for merchants.StoredPaymentMethod · checkout.service.ts:294 mitCharge · subscriptions/*
Hosted checkout, payment links, Web SDKexistsExtend: a Passport entry point on the hosted link page.ummah-platform-checkout app/l/[token]/LinkCheckout.tsx
Donor identity, credentials, sessionsnoneBuild. Auth today has exactly two scopes, staff and merchant.permissions.registry.ts:23 · roles.guard.ts:20 · jwt.strategy.ts
One token across charitiesblocked by data modelBuild, additively. Customer is unique per merchant and the token owner is always a Customer.schema.prisma Customer @@unique([merchantId, reference]) · checkout.service.ts:117-150
Donor-present payment on a stored cardnoneBuild. Only merchant-initiated charging exists; the shopper never selects a saved card.payments.service.ts:321-341 (three modes, none is CIT on a token)
Saved cards shown in checkoutnoneBuild. The payment-methods call never sends a shopper reference, so Adyen returns no stored cards.payments.service.ts:77-89
Card removal at AdyennoneBuild. The SDK has the call; the platform only flips a local status.adyen-recurring.service.ts (stub) · @adyen/api-library RecurringApi.deleteTokenForStoredPaymentDetails
Donor giving history and receiptsnoneBuild. Receipts exist only for subscription charges, as a PDF behind an emailed token.invoices.controllers.ts:115-133
Email deliveryexistsReuse 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 id = shopperReference · email · passkeys Customer (Islamic Relief) merchantId + passportAccountId StoredPaymentMethod → token T Customer (MATW) merchantId + passportAccountId StoredPaymentMethod → token T Customer (Penny Appeal) created on first gift StoredPaymentMethod → token T Adyen vault · TrustXPayCOM one token T under shopperReference = account id

Flow A: first registration

  1. EntryFrom the hosted checkout of any charity ("Save with Passport" at the end of a normal card payment), or directly on the Passport app.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. CardsThe payment-methods call now includes shopperReference for Passport sessions only, so Adyen returns the stored cards and the picker can render them.
  5. 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.
  6. 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

  1. 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.
  2. 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.

#DecisionRecommendationWhy
D1Donor credentialsrecommended 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.
D2Payment model for the one-tap giftrecommended 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.
D3Token ownershiprecommended 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.
D4First surfacerecommended 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.
D5Where the code livesrecommended 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.
D6Shopper referencerecommended 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.
D7Pricingrecommended 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.
D8Consent and datarecommended 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

RepositoryChangesPhase
ummah-platform-backendMigration 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-workerSchema mirror · adyen-webhook.service.ts token-owner resolution (lands before any Passport card is stored) · mirror tokens onto new spoke customers.1
ummah-platform-webhook · ummah-platform-notificationSchema mirror only. Notification gains the OTP and receipt templates.1 and 2
ummah-platform-checkoutPassport 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-backofficeRead-only Passport accounts list; Passport badge on customer detail.2
ummah-platform-web-sdk · docsPassport 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.
ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:1256 const customer = await this.db.customer.findUnique({ where: { id: shopperReference } });

Once confirmed: the first week

In this order, each as its own change, so the risky one is reviewed alone.

  1. 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.
  2. Migration and modelsPassportAccount, PassportCredential, Customer.passportAccountId. Mirrored to the three sibling schemas. Applied by hand on dev.
  3. 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.
  4. Adyen recurring serviceList and delete stored payment methods, with request logging like every other Adyen call.
  5. Passport auth and read APIEmail code start and verify, PASSPORT scope, me and cards endpoints, rate limits.
  6. 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