Where Majority's card payment stack is today, where a provider-agnostic, Braintree-like vault should take it, and a plan that uses the TabaPay work to take the first steps. The CDE is out of scope for change; the plan says why.
Verdict. The box you want already exists in embryo: be-psp-payment (MoneyFlow.Payment) has a provider-agnostic contract, config-driven routing and per-provider adapters, and the CDE already gives you a PAN vault with a hosted card iframe. What is missing is discipline at the edges. The product layer (be-pspfunding) still talks to providers directly, routing is hard-coded in two places, 3DS is Checkout-specific and defined in the wrong repo, PII beyond name and address flows through the box, and there is no tenant concept beyond a licence string.
Six services and two web apps take part in a card payment. The diagram shows the call paths as they exist in code today. Dashed arrows are work in progress on branches.
flowchart LR
subgraph clients["Clients"]
app["Majority app"]
web["be-majorityweb / web-payment-hub"]
iframe["web-cde-carddata iframe
RSA-encrypts PAN in browser"]
end
subgraph product["Product layer · be-pspfunding"]
pf["ExternalCardService · PaymentProcessorService · TransactionService
risk, ANI ladders, self-funding, payment links, MPay2Anyone, auto-fund"]
pps["PaymentProviderService
hard-coded routing"]
tds["ThreeDsAuthService
Checkout-only contract"]
astra["Astra adapter
Minority.Payment.Astra.*"]
end
subgraph box["Gateway · be-psp-payment"]
ps["PaymentService
credit · verify · refund · reflow"]
pr["ProviderResolver
config routing"]
tp["TabaPay client library
users merged · query card on branch"]
end
subgraph adapters["Provider adapter services"]
cko["be-psp-checkout
+ webhooks, 3DS"]
fis["be-psp-fiserv
+ chargeback job"]
end
subgraph cde["CDE"]
ecd["be-cde-externalcarddata
decrypts PAN, calls providers"]
cd["be-cde-carddata
encrypted PAN · CVV · expiry
access tokens · public key"]
end
subgraph ext["Providers"]
CKO["Checkout.com"]
FIS["Fiserv"]
AST["Astra · sunset"]
TAB["TabaPay"]
end
app --> pf
web --> pf
iframe -->|"encrypted card"| cd
pf --> pps
pps -->|"credit / verify"| ps
pps -->|"links, MPay2Anyone"| astra
pf --> tds
tds -->|"bypasses the box"| cko
ps --> pr
pr --> cko
pr --> fis
cko -->|"PAN ops"| ecd
fis -->|"PAN ops"| ecd
astra -->|"create card"| ecd
astra -->|"routines, users, KYC"| AST
ecd --> cd
ecd --> CKO
ecd --> FIS
ecd --> AST
tp -.-> TAB
classDef leak fill:#b3261e,stroke:#7a1a14,color:#ffffff;
classDef wip stroke-dasharray: 5 5;
class pps,tds,astra leak;
class tp wip;
Every charge, verification and 3DS session sends the PAN from the CDE to the provider. That is by design and stays: the CDE is the only place that can decrypt, and the only place with a network path to a provider.
sequenceDiagram autonumber participant C as Client participant PF as be-pspfunding participant PP as be-psp-payment participant PC as be-psp-checkout participant ECD as be-cde-externalcarddata participant CD as be-cde-carddata participant CKO as Checkout.com C->>CD: POST encrypted PAN (iframe, access token) CD-->>C: cardId, bin, last4, fingerprint C->>PF: AddCardV2(cardId, holder, billing address) PF->>PF: block list, name policy, risk pre-check PF->>PP: verify-payment-method (provider hint: Visa→Fiserv else Checkout) PP->>PC: verify-payment-method PC->>ECD: VerifyCard(cardId) ECD->>CD: GetExternalCardData (RSA) ECD->>CKO: $0 payment + AVS + ANI with PAN CKO-->>PF: AVS, CVV, name match, MIT scheme id PF->>PF: ANI ladder, risk decision, step-up C->>PF: charge (transaction / payment link / self-fund) PF->>PP: credit (sender, recipient, DOB, email, phone, IP) PP->>PC: credit PC->>ECD: Credit(cardId) ECD->>CD: GetExternalCardData (RSA) ECD->>CKO: POST /payments with PAN, AFT flag by licence CKO-->>PC: webhook payment_captured PC-->>PP: ProviderPaymentUpdatedEvent PP-->>PF: PaymentCompletedEvent
Ranked by how much they block a provider-agnostic, multi-tenant box. "Where" points at the code that proves the finding. None of the High or Medium findings is inside the CDE.
| Severity | Finding | Where |
|---|---|---|
| High | Astra is a full provider adapter embedded in the product layer and called directly, bypassing the box. The box declares an Astra adapter interface that nothing implements.Minority.Payment.Astra.* projects in be-pspfunding; PaymentProviderService.cs | be-pspfunding/…/PaymentProviders/PaymentProviderService.cs; be-psp-payment IAstraPaymentProviderService.cs |
| High | Routing is decided in two places with hard-coded brand rules in the product layer (Visa→Fiserv, else Checkout; self-fund→Checkout; links and MPay2Anyone→Astra) and the box only honours the "suggestion".ProviderForVerify / ProviderForCredit; GetVerifyCardRequest; PaymentProviderRequestBuilder sets PaymentProvider | be-pspfunding ExternalCardService.cs:1140; PaymentProviderRequestBuilder.cs; be-psp-payment ProviderResolver.cs |
| High | 3DS is a Checkout-specific contract defined in be-pspfunding and implemented in be-psp-checkout, which creates a circular dependency (psp-checkout references the pspfunding contract) and a provider leak. Rebtel's EU cards need this as a box feature.ICheckoutThreeDsService; PspFundingThreeDsMappings in psp-checkout | be-pspfunding Service.Contract/PaymentProviders/ICheckoutThreeDsService.cs; be-psp-checkout Mappings/PspFundingThreeDsMappings.cs |
| Medium | PII beyond AVS/ANI flows through the box: Sender and Recipient carry date of birth, email, phone and IP; Astra additionally needs full KYC (UserIntent, Socure). The box should carry account-holder name and billing address plus an opaque customer reference.AdditionalDetails; UserIntentRequest | be-pspfunding PaymentProviderRequestBuilder.cs; Minority.Payment.Astra.Client/RequestModels/UserIntentRequest.cs |
| Medium | Tenancy exists only as a licence string (MPS, MUSA) threaded to the Checkout config for processing channel and AFT flag. No tenant owns credentials, routing policy or webhooks. Rebtel needs all three.CheckoutProcessingChannelResolver; TransactionIntentLicenseId | be-cde-externalcarddata PaymentProvider/CheckoutProcessingChannelResolver.cs; be-pspfunding SelfFunding/TransactionIntentLicenseId.cs |
| Medium | The box has no push (payout, OCT) operation. Astra's MPay2Anyone is a push disguised as a credit with PaymentType MpayToAnyone. TabaPay push needs a first-class operation.IPaymentService has credit, verify, refund, reflow only | be-psp-payment Payment/IPaymentService.cs; AstraPaymentService.Credit |
| Medium | be-psp-payment has no API host. An in-process TabaPay adapter needs an inbound webhook endpoint with provider and tenant routing.Projects: Service, Service.Contract, Db, TabaPay.Client, tests | be-psp-payment/Minority/MoneyFlow.Payment/ |
| Medium | Provider state is mixed into the product database. Astra's card, user, payment, transfer and chargeback tables and an AstraVerified column on the User table live in MinorityPspFunding, and the product's ExternalCard table carries provider verification facts (AVS result, MIT scheme transaction id, provider status). Card and payment state is spread over four databases with four different shapes.Minority.PspFunding.Db migrations 148, 160, 165; ExternalCardEntity.AvsCheck / MitSchemeTransactionId / ProviderStatusResponse | be-pspfunding Minority.PspFunding.Db/Migrations/160_Astra_Provider_Tables.sql, 165_Astra_Chargeback_Table.sql, 148_User_AstraVerified_Column.sql; Card/Repository/ExternalCardEntity.cs |
| Medium | The TabaPay branch puts the card-query models (account number, security code) in a project inside be-psp-payment, which is not a CDE repo. Code that handles a PAN belongs in be-cde-externalcarddata. Review before merge.MoneyFlow.Payment.TabaPay.Cde.Client on branch grobe-407 | be-psp-payment branch grobe-407-tabapay-client-library-query-card |
| Low | PAN is sent to the provider on every call; Fiserv tokens and Checkout stored instruments are created but not used. Decision: keep it. The CDE is not changed for a token mode. TabaPay is token-native (accountID) and that bookkeeping happens outside the CDE. | be-cde-externalcarddata CheckoutService.cs:41-53; Fiserv/FiservService.cs:45-81 |
| Low | Per-provider card state is fragmented: four CardEntity shapes (pspfunding, psp-checkout, psp-fiserv, Astra) each hold AVS, ANI and MIT fields with different names. | ExternalCardEntity.cs; psp-checkout Models/CardEntity.cs; psp-fiserv Models/CardEntity.cs, TokenEntity.cs; Astra Repositories/Entities/CardEntity.cs |
| Low | Duplicated contracts: be-pspfunding mirrors the MoneyFlow models and Provider enum and maps between them. Two enums, two model sets, one meaning. | be-pspfunding Service.Contract/PaymentProviders/*; Mappings/AstraMoneyFlowMapping.cs |
| Good | Solid primitives to keep: config-driven ProviderResolver with brand, type and test-condition matching; IPaymentProviderService adapter contract; CDE access token and public-key iframe (SAQ-A-style capture); card fingerprint de-duplication; refunds routed to the capturing provider; per-licence Checkout processing channels; idempotent webhook pipeline. | be-psp-payment ProviderResolver.cs; be-cde-carddata IAccessTokenService, ICryptoController; web-cde-carddata/src/index.ts |
What the CDE is today. Two services with two roles. be-cde-carddata is storage: it holds the encrypted PAN, CVV and expiry, issues the access tokens and public key the hosted iframe uses, and never returns a clear PAN. It re-encrypts the card under a key the caller supplies. be-cde-externalcarddata is processing: it is the only service that can decrypt, and the only service with a network path to Checkout, Fiserv and Astra. It makes every provider call that needs a card number: verify, charge, refund lookup, 3DS session, Fiserv tokenize, Astra create card.
Why it stays.
The two exceptions.
Explicitly not doing: merging the two CDE services, moving the PAN proxies anywhere, a provider-token mode for Checkout or Fiserv, changes to CVV retention, tenant partitioning inside the CDE. Any of these later is its own PCI-reviewed project with its own business case.
Two Linear projects describe a PSP consolidation: P-BCP-1159 "PSP Refactor" (May 2025, In Progress, lead gowtham.krishnakumar) wants "5 services into one" and "PSP service as the single point for payments"; P-PAY-1392 "PSP refactor" (June 2026, Backlog) lists four steps. After discussion the positions reconcile as follows.
| Refactor step | As written | Agreed position |
|---|---|---|
| 1. Retire be-cde-externalcarddata into be-cde-carddata | Move the Checkout and Fiserv clients into their repos, move the CDE code into be-cde-carddata. | Dropped PCI reason, see "CDE stays as is". Both CDE services stay as they are. |
| 2. Move be-psp-payment into be-pspfunding | One repo, fewer cross-repo PRs. | Accepted with a condition One repo is fine. Inside it, the box is a separate app with its own contract package, database and host. The product app references the box contract; the box never references product code. If the condition cannot be met, two repos. |
| 3. Refactor be-pspfunding on upcoming requirements | Make it easier to change. | Accepted The main requirement is: take provider knowledge out of it so it becomes a client of the box. |
| 4. Migrate Checkout and Fiserv repos into be-pspfunding | Fewer repos. | Re-aimed If they are folded, they fold into the box app, not the product app. Same consolidation, different host. Not needed for TabaPay or Rebtel; decide later on its own merits. |
| "Single point for payments" | be-pspfunding. | The box. be-pspfunding keeps risk, ANI policy, self-funding, links, auto-fund and Majority account crediting, none of which Rebtel can be exposed to. |
One box, two kinds of client, three providers, CDE unchanged. The box owns payment methods, routing, tenants and provider adapters. The CDE stays the only place a PAN exists in clear text and the only place with a network path to a provider for PAN calls. Everything about a specific product (risk, ANI policy, self-funding, payment links, auto-fund) stays in the client that owns it.
flowchart LR
subgraph tenants["Clients"]
maj["be-pspfunding · Majority product app
risk · ANI policy · self-funding · links · MPay2Anyone · auto-fund"]
reb["Rebtel backend"]
iframe["Hosted card iframe
web-cde-carddata"]
end
subgraph box["The box · be-psp-payment, separate app, hard contract"]
api["Vault API · REST + signed webhooks
payment-methods · verifications · payments · payouts · refunds · authentications"]
tenant["Tenant registry
credentials, routing policy, webhook endpoints"]
router["Router
operation × brand × issuer country × tenant → provider"]
registry["Payment-method registry
cardId · fingerprint · bin/last4 · expiry · holder · billing address · status · TabaPay accountID"]
a1["Checkout adapter
(be-psp-checkout today)"]
a2["Fiserv adapter
(be-psp-fiserv today)"]
a3["TabaPay adapter
new, in-process"]
wh["Webhook ingress
per provider, mapped to tenant"]
end
subgraph cde["CDE · unchanged"]
ecd["be-cde-externalcarddata
decrypts PAN, calls providers
Checkout · Fiserv · + one TabaPay proxy"]
cd["be-cde-carddata
encrypted PAN · CVV · expiry
access tokens · public key"]
end
subgraph ext["Providers"]
CKO["Checkout.com
US + EU/UK, 3DS"]
FIS["Fiserv
US"]
TAB["TabaPay
US, push + pull"]
end
maj --> api
reb --> api
iframe -->|"encrypted card → cardId"| cd
api --> tenant --> router
api --> registry
router --> a1
router --> a2
router --> a3
a1 -->|"PAN per call, as today"| ecd
a2 -->|"PAN per call, as today"| ecd
a3 -->|"create account / query card, once per card"| ecd
a3 -->|"every later call, by accountID"| TAB
ecd --> cd
ecd --> CKO
ecd --> FIS
ecd --> TAB
CKO -->|"webhooks"| wh
FIS -->|"webhooks"| wh
TAB -->|"webhooks"| wh
wh --> api
classDef boxc fill:#9a6f14,stroke:#6b4d0e,color:#ffffff;
classDef cdec fill:#b3261e,stroke:#7a1a14,color:#ffffff;
class api,router,tenant,registry boxc;
class ecd,cd cdec;
Red: the CDE, unchanged apart from one TabaPay proxy. Brass: the new or moved parts of the box. Checkout and Fiserv keep sending the PAN per call through be-cde-externalcarddata exactly as today. TabaPay is the only provider charged by a stored reference, because that is how TabaPay works, and that reference lives in the box, not in the CDE.
authentications resource. The underlying call to be-cde-externalcarddata is unchanged.tenantId on every call and every event. Today's licenseId (MPS, MUSA) maps onto it, so Majority already has two tenants in practice.The box boundary is a data boundary as much as a code boundary. The rule is simple: each tier owns one database and nothing else writes to it. Today that rule is already broken in the product database, which is why it needs to be stated before TabaPay adds more tables.
| State | Lives today | Should live |
|---|---|---|
| Encrypted PAN, CVV, expiry, fingerprint | MinorityCardData (CDE) | Unchanged |
| App-facing card fields (preferred, image, description, app status, risk outcome) | MinorityPspFunding ExternalCard | MinorityPspFunding (product) |
| Provider-facing card facts (AVS, CVV result, name match, MIT scheme transaction id, provider status) | Four places: MinorityPspFunding ExternalCard; be-psp-checkout Card + BillingAddress; be-psp-fiserv Card; Astra Card inside MinorityPspFunding | MoneyflowPayment payment-method registry, one row per card. Adapters keep only provider-raw columns. |
| Provider references used to charge (TabaPay accountID, Fiserv token, Checkout instrument) | Fiserv token in be-psp-fiserv (unused); Astra card id in MinorityPspFunding; Checkout instrument not stored | MoneyflowPayment, one table keyed by card, provider and tenant. The TabaPay accountID is its first row. Never in MinorityPspFunding. |
| Payments, payouts, refunds | MoneyflowPayment (box); be-psp-checkout and be-psp-fiserv (adapters); Astra Payment, Transfer, BatchTransfer inside MinorityPspFunding | MoneyflowPayment is the source of truth; adapters keep provider-raw state; Astra tables dropped at decommission. |
| Product orders and attempts (Transaction, TransactionAttempt, PaymentIdentifier, auto-fund) | MinorityPspFunding | MinorityPspFunding. They reference box payment ids, nothing provider-specific. |
| Provider customer or user state (TabaPay user id, Astra user, AstraVerified) | Astra User table and User.AstraVerified in MinorityPspFunding | Product layer owns provider customer references as part of KYC; the box stores only an opaque customer reference per payment method. |
| Tenant and provider configuration (credentials, processing channels, routing) | payment-provider-config (box), per-licence config in be-cde-externalcarddata (CDE), astra-config in be-pspfunding (product) | Box tenant registry for credentials and routing; CDE config unchanged; nothing provider-related in product config. |
Clean-up order: TabaPay state goes to MoneyflowPayment from the first commit; Astra tables and the AstraVerified column are dropped with the Astra decommission; the provider facts on ExternalCard are removed once the box registry is the source of truth and the product reads them by event.
Rebtel today vaults at Braintree and routes charges to Adyen behind it. The mapping below gives them the same shape against our box. Resource names are proposals; the "today" column says what already exists.
| Braintree concept | Box resource | Today |
|---|---|---|
Client token (ClientToken.generate) | POST /v1/tenants/{t}/card-capture-sessions → short-lived access token for the hosted iframe | Exists: be-cde-carddata access-token + public-key; web-cde-carddata iframe; web-payment-hub cardService |
| Hosted Fields / Drop-in → payment method nonce | Hosted iframe posts the RSA-encrypted card to the CDE and returns an unverified paymentMethodId. It behaves like a nonce until a verification activates it. | Exists: CDE AddExternalCard returns cardId, bin, last4, fingerprint; status Unverified |
| Customer | Account holder on the payment method: name + billing address + tenant customer reference. No KYC. | Partly: billing address lives in be-pspfunding ExternalCardEntity and in psp-checkout BillingAddress |
PaymentMethod.create with verify_card | POST /payment-methods/{id}/verifications → zero-amount auth, AVS, CVV, ANI, scheme transaction id, provider-routed | Exists: verify-payment-method via Checkout and Fiserv; TabaPay Query Card in progress |
Vaulted token (token) | paymentMethodId (today's cardId), multi-use, tenant-scoped. The TabaPay accountID is stored behind it. | Exists as cardId |
Transaction.sale | POST /payments (pull, AFT where the tenant and scheme require it) | Exists: credit |
Transaction.credit / payouts | POST /payouts (push, OCT) | Missing in the box; Astra only |
| Refund / void | POST /payments/{id}/refunds | Exists: refund, routed to the capturing provider |
3D Secure (three_d_secure on nonce) | POST /authentications → session, challenge or fingerprint data; authenticationId passed to /payments | Exists for Checkout only, defined in be-pspfunding |
| Webhooks | Signed tenant webhooks: verification.completed, payment.captured/declined, payout.completed/failed, refund.*, dispute.* | Exists as RabbitMQ events for internal consumers; no outbound HTTP webhooks |
PaymentMethod.update / delete / find, make default | GET/PATCH/DELETE /payment-methods/{id}; "default" stays a client concern | Exists partly: delete card, delete CVV, expiry and bin lookups |
fail_on_duplicate_payment_method | Fingerprint-based duplicate check per tenant | Exists: CardFingerprintId in CDE |
| Account updater | POST /payment-methods/{id}/refresh backed by a provider account-updater | Missing; later, only with a reason |
| Idempotency | Idempotency-Key header on every write | Partly; BCP-2585 open |
From the provider docs and from what is wired in code. "Integrated" means callable through the box today. Entries marked "unverified" come from third-party summaries rather than the vendor's own reference.
| Capability | Checkout.com | Fiserv Commerce Hub | TabaPay | Astra (sunset) |
|---|---|---|---|---|
| Pull (charge / AFT) | Integrated. POST /payments, AFT flag per licence | Integrated. Credit-funds + MIT initiate | Available. type: pull, ?authorize for auth-only | Integrated via routines |
| Push (payout / OCT) | Available, not integrated | Available (unverified), not integrated | Available. type: push, destinationAccount | Integrated (MPay2Anyone) |
| Zero-amount verification + AVS + CVV | Integrated. amount 0, cvv_check, AVS code | Integrated. Account verification | Available. Query Card ?AVS → codeAVS, codeSecurityCode, avsID | Integrated |
| Account name inquiry (ANI) | Integrated. account_holder + account_name_inquiry; Visa US/CA/UK, Mastercard global | Integrated. Name match indicator | Available. ?ANI → codeFullName, codeFirstName, codeLastName (M/P/N) | Integrated |
| 3DS / SCA | Integrated (Checkout-only contract). Sessions, fingerprint, challenge | Not integrated | Accepts 3DS results in pullOptions.3DSecure; no authentication service; US only | No |
| Stored credential / token | Instruments src_; created, not used | Payment token; created, not used | accountID via Create Account; duplicate card check. The only provider charged by reference. | Card id |
| Card capture (SAQ A) | Frames | — | PCI iframe / browser SDK, or RSA keyID + data | — |
| Wallets (Apple Pay, Google Pay) | Available via token_data; designed in be-pspfunding docs, not built | Available (unverified) | mobilePay network token input | No |
| Geography | Global incl. EU/UK | US | US | US |
| Webhooks | Integrated (HMAC), payment, card, refund, dispute | Sync responses; chargebacks by SFTP job | Transaction and Verification webhooks | Integrated |
| Account updater | Available, not integrated | Available (unverified) | Query Card ?AU block | No |
| Customer / user object | cus_ with email only | None required | Users API with DOB and identification (KYC-ish); transactions need only owner name + address | UserIntent with full KYC + Socure |
Consequence for routing: Rebtel's EU and UK cards can only go to Checkout. TabaPay is the natural home for US push and for US pull where its pricing wins. Fiserv stays the US Visa pull path until a cost comparison says otherwise.
Five work packages, each named by what it delivers. "Settle the shape" comes first and is small. "TabaPay" is the work already under way and absorbs most of the new box features. The other three can run alongside or after. Repos touched are listed per package. None of them changes the CDE beyond the two exceptions above.
Write down the direction so the TabaPay work lands in the right place.
Every Astra use case moves to TabaPay through the box, and the box grows the three things Astra was hiding: push, a stored provider reference, a tenant id.
Provider.TabaPay to the MoneyFlow enum and a TabaPay implementation of IPaymentProviderService inside be-psp-payment, registered so ProviderResolver can pick it from payment-provider-config. No TabaPay code in be-pspfunding.Payout (push, OCT) to IPaymentService and IPaymentProviderService. TabaPay implements it; Checkout and Fiserv return NotSupported until wired.tenantId to CreditRequest, VerifyPaymentMethodRequest and the new PayoutRequest, defaulted from licenseId (MPS, MUSA) so nothing breaks.PaymentProvider = Provider.Astra from PaymentProviderRequestBuilder.Payout. Delete the Astra account-to-card path.The product layer knows no provider names. Nothing here touches the CDE.
PaymentProviderService and the brand rules in GetVerifyCardRequest. The box decides. Keep only the regression-test override, as a header the box honours outside production.ICheckoutThreeDsService and the Checkout-specific 3DS models from the be-pspfunding contract. be-psp-checkout drops its reference to the be-pspfunding contract and the circular dependency disappears.Rebtel charges US and EU/UK cards through the box with its own merchant accounts and no Braintree. The CDE is unchanged; Rebtel's cards are stored the same way Majority's are.
Idempotency-Key on writes, and an outbound webhook dispatcher with retries and signatures.Each of these needs its own business case. Listed so nobody mistakes them for implied steps.
Eight choices that cost little today and save a rewrite later. They are the concrete asks for the GROBE tickets.
IPaymentProviderService implementation lives in be-psp-payment. No TabaPay code in be-pspfunding, no new be-psp-tabapay repo.ProviderResolver from payment-provider-config; be-pspfunding stops sending PaymentProvider.Payout to the contract now, for MPay2Anyone. Do not model it as a credit with a special PaymentType.tenantId on the requests now, defaulted from licenseId, so MUSA, MPS and later Rebtel are first-class.Do not build: Astra-shaped concepts (routines, user intents) in the box; 3DS for TabaPay; KYC storage in the box; any change to be-cde-carddata.