Architecture review · be-pspfunding and the card payment stack

Card Vault Architecture Review

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.

15 Oct 2026 · revision 3 Repos: be-pspfunding · be-psp-payment · be-psp-checkout · be-psp-fiserv · be-cde-carddata · be-cde-externalcarddata · web-cde-carddata · web-payment-hub Decisions confirmed with Johan Dahlqvist in this session

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.

  • Direction: the box is be-psp-payment plus the two CDE services exactly as they are. be-pspfunding and Rebtel are both clients of it.
  • CDE: no code change except one TabaPay proxy in be-cde-externalcarddata, plus a config entry for Rebtel's Checkout processing channel. The storage/processing split in the CDE is a PCI control and stays.
  • Repos: one repo or two does not matter. What matters is a hard contract: own package with no Majority types, own database, own deployable, one-way dependency enforced by a build-time test.
  • Databases: the box owns its database (MoneyflowPayment); the product database (MinorityPspFunding) must hold no provider state. Today it does: Astra's provider tables and an Astra flag on the User table live in the product database, and provider verification facts are duplicated on the product's ExternalCard table. That is the mix-up to clean up, and the rule TabaPay must not repeat.
  • First mover: build TabaPay as the first clean adapter inside the box, never inside be-pspfunding. It replaces Astra flow by flow: payment-link pull, then MPay2Anyone push, then me2me.
  • PSP refactor projects: reconciled. "One repo" stands if it means two apps with a hard contract. The step that merges the two CDE services is dropped.
  • Keep: ProviderResolver, IPaymentProviderService, the CDE access-token + public-key iframe, card fingerprints, event-driven refunds and webhooks, per-licence Checkout processing channels.

Where we are

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;
Red boxes: provider-specific logic living in the product layerDashed: in progress on feature branches

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.

How a card gets in and gets charged today

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

Findings

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.

SeverityFindingWhere
HighAstra 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.csbe-pspfunding/…/PaymentProviders/PaymentProviderService.cs; be-psp-payment IAstraPaymentProviderService.cs
HighRouting 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 PaymentProviderbe-pspfunding ExternalCardService.cs:1140; PaymentProviderRequestBuilder.cs; be-psp-payment ProviderResolver.cs
High3DS 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-checkoutbe-pspfunding Service.Contract/PaymentProviders/ICheckoutThreeDsService.cs; be-psp-checkout Mappings/PspFundingThreeDsMappings.cs
MediumPII 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; UserIntentRequestbe-pspfunding PaymentProviderRequestBuilder.cs; Minority.Payment.Astra.Client/RequestModels/UserIntentRequest.cs
MediumTenancy 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; TransactionIntentLicenseIdbe-cde-externalcarddata PaymentProvider/CheckoutProcessingChannelResolver.cs; be-pspfunding SelfFunding/TransactionIntentLicenseId.cs
MediumThe 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 onlybe-psp-payment Payment/IPaymentService.cs; AstraPaymentService.Credit
Mediumbe-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, testsbe-psp-payment/Minority/MoneyFlow.Payment/
MediumProvider 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 / ProviderStatusResponsebe-pspfunding Minority.PspFunding.Db/Migrations/160_Astra_Provider_Tables.sql, 165_Astra_Chargeback_Table.sql, 148_User_AstraVerified_Column.sql; Card/Repository/ExternalCardEntity.cs
MediumThe 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-407be-psp-payment branch grobe-407-tabapay-client-library-query-card
LowPAN 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
LowPer-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
LowDuplicated 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
GoodSolid 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

CDE stays as is

Fixed scope: no change to CDE code or data flows, with two named exceptions

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 storage/processing split is a PCI control, not an accident. It limits which components can see a clear PAN to one, and limits which components can send data out of the CDE to one. Merging them would put provider SDKs, provider credentials and outbound connectivity into the service that holds the keys.
  • The CDE data flows are what the PCI assessment documents. Changing them means re-confirming scope and re-assessing, which is a project of its own. Nobody has budgeted that, and nothing in the TabaPay or Rebtel goals needs it.
  • CDE repos already carry their own approval gate in CI. Every change there is slower and reviewed by more people by design.
  • "PAN on every call" is the agreed charging model for Checkout and Fiserv. It keeps the vault useful for providers that charge for token storage and keeps all PAN handling in one place.

The two exceptions.

  • One TabaPay proxy in be-cde-externalcarddata. TabaPay's Create Account and Query Card calls take the card number, so they must run where the PAN can be decrypted. This is the same pattern Checkout, Fiserv and Astra already use in that service. After Create Account, TabaPay works on its accountID and every later call runs outside the CDE. Net effect: fewer PAN transmissions, not more.
  • One config entry for Rebtel's Checkout processing channel in the existing per-licence configuration. No code.

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.

PSP refactor projects, reconciled

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 stepAs writtenAgreed position
1. Retire be-cde-externalcarddata into be-cde-carddataMove 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-pspfundingOne 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 requirementsMake 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-pspfundingFewer 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.

The contract is hard. That is the whole condition.

  • Own contract package (NuGet, as today's Service.Contract projects). No Majority types, no product enums such as SelfFundRequest or MpayToAnyone, no risk or KYC fields, no provider-specific payloads.
  • One-way dependency: product → box contract. A build-time architecture test fails the build if the box references product code.
  • Own deployable, own host name, own config, own CODEOWNERS entry. The shared CI already builds and deploys per project, so this works in one repo.
  • Own database, never shared with the product app. The box keeps MoneyflowPayment. The template builds one database project per repo, so if the box moves into be-pspfunding the pipeline must build two database projects before the first box table exists. Otherwise the Astra pattern repeats: see Databases.
  • Once Rebtel is live, changes are additive and versioned. No breaking change rides along with a product release.

Why the refactor's goals still hold

  • Fewer repos: Astra projects leave be-pspfunding; new adapters are in-process in the box; be-psp-payment can move into the same repo.
  • Fewer cross-repo PRs: a new provider touches the box app and one CDE proxy.
  • One owner for payments: the box, with one contract shared by be-pspfunding and Rebtel.
  • Simpler be-pspfunding: it loses PaymentProviderService, the Astra adapter, the 3DS contract and the mirror models.

Precedent and pitfall already in the repo

  • be-pspfunding already hosts a second app: the Astra Api and Service have their own charts and host name.
  • That second app shares the PspFunding database and leaks its types into the product code. That is exactly what the hard contract forbids for the box.

Still to settle in the meeting

  • Who owns the ADR and re-scopes P-PAY-1392 and P-BCP-1159.
  • Repo choice: keep be-psp-payment separate, or move it into be-pspfunding as the box app. Either is fine under the condition above.
  • Which TabaPay tickets change target (GROBE-407 and successors) and by when.

Target architecture

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.

What moves into the box

  • Provider selection, including brand, issuer country and tenant policy. The product layer stops sending a provider hint.
  • 3DS / SCA as a provider-agnostic authentications resource. The underlying call to be-cde-externalcarddata is unchanged.
  • Push to card (payouts, OCT) as a sibling of pull.
  • Tenant registry: credentials, routing policy, webhook endpoints and signing secrets.
  • Inbound webhook ingress for every provider, outbound signed webhooks for every tenant.
  • TabaPay accountID per card, stored next to the card's metadata.

What stays out of the box

  • KYC and provider customer onboarding (TabaPay Users with DOB and ID, Astra UserIntent, Socure). The product layer does these directly with the provider and hands the box an opaque customer reference.
  • Risk decisions, ANI ladders, name policies, step-up verification flows, block lists.
  • Self-funding, payment links, MPay2Anyone, auto-fund, Majority account crediting, Firebase status.
  • Bank-account (ACH) funding. Only card flows go through the box.
  • Anything that touches a PAN. That is the CDE's job and the CDE is unchanged.

Tenancy model

  • tenantId on every call and every event. Today's licenseId (MPS, MUSA) maps onto it, so Majority already has two tenants in practice.
  • Each tenant owns provider credentials (Checkout processing channel, Fiserv merchant, TabaPay client and settlement account), a routing policy and webhook endpoints. These live in the box.
  • Rebtel is a separate tenant with its own merchant accounts. Majority Payment Services is not the merchant of record for Rebtel traffic.
  • The CDE does not know about tenants. Rebtel's Checkout processing channel is one more entry in the existing per-licence config of be-cde-externalcarddata.

Data the box accepts about a person

  • Account holder: first name, last name, billing address. Required for AVS and ANI.
  • Customer reference: tenant-scoped opaque id, plus optional provider customer ids the product layer obtained itself.
  • Risk context (optional, per tenant policy): email, phone, IP, device fingerprint, forwarded to provider fraud tools and 3DS. Labelled as risk context, never stored as identity.
  • Never: date of birth, government ids, KYC outcomes.

Databases: the separation rule and today's mix-up

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.

The rule

  • CDE: MinorityCardData. Untouched.
  • Box: MoneyflowPayment. Payments, payouts, refunds, payment-method registry, provider references (TabaPay accountID), tenant registry.
  • Adapters: their own databases (be-psp-checkout, be-psp-fiserv) for provider-raw state such as webhook events and provider transaction ids. If an adapter folds into the box app, its tables move into MoneyflowPayment, not into the product database.
  • Product: MinorityPspFunding. Orders, transaction attempts, payment links, auto-fund settings, app-facing card fields (preferred, image, description, status, risk outcome). No provider table, column or enum.
  • Provider facts the product needs arrive by event (PaymentCardVerifiedEvent, PaymentCompletedEvent) and are stored as product facts, not mirrored provider rows.
  • One repo does not mean one database. The be-template default is one database project per repo; a second one is a pipeline change that must land before any box table is created in the product repo.

Today's mix-up, concretely

  • Astra's card, user, payment, transfer, batch-transfer and chargeback tables sit in MinorityPspFunding (migrations 160 and 165). Provider state in the product database.
  • The product User table has an AstraVerified column (migration 148). A provider's verification outcome on the product's identity row.
  • The product ExternalCard table carries AvsCheck, MitSchemeTransactionId and ProviderStatusResponse. The same facts also live in be-psp-checkout's Card table, be-psp-fiserv's Card table and the Astra Card table, under different names.
  • Payment state exists in four shapes: product Transaction and TransactionAttempt, box Payment and PaymentProviderTransaction, adapter Payment and Transaction, Astra Payment and Transfer.
  • Astra config and the Astra service host are configured from the product repo's app-config.
StateLives todayShould live
Encrypted PAN, CVV, expiry, fingerprintMinorityCardData (CDE)Unchanged
App-facing card fields (preferred, image, description, app status, risk outcome)MinorityPspFunding ExternalCardMinorityPspFunding (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 MinorityPspFundingMoneyflowPayment 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 storedMoneyflowPayment, one table keyed by card, provider and tenant. The TabaPay accountID is its first row. Never in MinorityPspFunding.
Payments, payouts, refundsMoneyflowPayment (box); be-psp-checkout and be-psp-fiserv (adapters); Astra Payment, Transfer, BatchTransfer inside MinorityPspFundingMoneyflowPayment is the source of truth; adapters keep provider-raw state; Astra tables dropped at decommission.
Product orders and attempts (Transaction, TransactionAttempt, PaymentIdentifier, auto-fund)MinorityPspFundingMinorityPspFunding. 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 MinorityPspFundingProduct 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.

The Braintree-shaped interface

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 conceptBox resourceToday
Client token (ClientToken.generate)POST /v1/tenants/{t}/card-capture-sessions → short-lived access token for the hosted iframeExists: be-cde-carddata access-token + public-key; web-cde-carddata iframe; web-payment-hub cardService
Hosted Fields / Drop-in → payment method nonceHosted 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
CustomerAccount 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_cardPOST /payment-methods/{id}/verifications → zero-amount auth, AVS, CVV, ANI, scheme transaction id, provider-routedExists: 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.salePOST /payments (pull, AFT where the tenant and scheme require it)Exists: credit
Transaction.credit / payoutsPOST /payouts (push, OCT)Missing in the box; Astra only
Refund / voidPOST /payments/{id}/refundsExists: refund, routed to the capturing provider
3D Secure (three_d_secure on nonce)POST /authentications → session, challenge or fingerprint data; authenticationId passed to /paymentsExists for Checkout only, defined in be-pspfunding
WebhooksSigned 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 defaultGET/PATCH/DELETE /payment-methods/{id}; "default" stays a client concernExists partly: delete card, delete CVV, expiry and bin lookups
fail_on_duplicate_payment_methodFingerprint-based duplicate check per tenantExists: CardFingerprintId in CDE
Account updaterPOST /payment-methods/{id}/refresh backed by a provider account-updaterMissing; later, only with a reason
IdempotencyIdempotency-Key header on every writePartly; BCP-2585 open
The "nonce until verified" rule matters for PCI scope and for Rebtel: a client can create a payment method only through the hosted iframe, and the box refuses to charge a payment method whose status is still Unverified. That is the same guarantee Braintree gives with a single-use nonce.

Provider capabilities

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.

CapabilityCheckout.comFiserv Commerce HubTabaPayAstra (sunset)
Pull (charge / AFT)Integrated. POST /payments, AFT flag per licenceIntegrated. Credit-funds + MIT initiateAvailable. type: pull, ?authorize for auth-onlyIntegrated via routines
Push (payout / OCT)Available, not integratedAvailable (unverified), not integratedAvailable. type: push, destinationAccountIntegrated (MPay2Anyone)
Zero-amount verification + AVS + CVVIntegrated. amount 0, cvv_check, AVS codeIntegrated. Account verificationAvailable. Query Card ?AVS → codeAVS, codeSecurityCode, avsIDIntegrated
Account name inquiry (ANI)Integrated. account_holder + account_name_inquiry; Visa US/CA/UK, Mastercard globalIntegrated. Name match indicatorAvailable. ?ANI → codeFullName, codeFirstName, codeLastName (M/P/N)Integrated
3DS / SCAIntegrated (Checkout-only contract). Sessions, fingerprint, challengeNot integratedAccepts 3DS results in pullOptions.3DSecure; no authentication service; US onlyNo
Stored credential / tokenInstruments src_; created, not usedPayment token; created, not usedaccountID 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 builtAvailable (unverified)mobilePay network token inputNo
GeographyGlobal incl. EU/UKUSUSUS
WebhooksIntegrated (HMAC), payment, card, refund, disputeSync responses; chargebacks by SFTP jobTransaction and Verification webhooksIntegrated
Account updaterAvailable, not integratedAvailable (unverified)Query Card ?AU blockNo
Customer / user objectcus_ with email onlyNone requiredUsers API with DOB and identification (KYC-ish); transactions need only owner name + addressUserIntent 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.

The plan

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.

FIRST · SMALL

Settle the shape

this month

Write down the direction so the TabaPay work lands in the right place.

  • An ADR in be-docs with the six decisions from this review: box = be-psp-payment + CDE as is; be-pspfunding and Rebtel are clients; CDE scope fixed; one or two repos, contract hard; KYC outside the box; adapters in-process in the box.
  • The hard-contract rules written into the ADR and enforced by a build-time architecture test from day one.
  • The database rule written into the same ADR: box state in MoneyflowPayment, product state in MinorityPspFunding, no provider tables or columns in the product database. If the box moves into the be-pspfunding repo, the two-database pipeline change is the first ticket.
  • Re-scope Linear P-PAY-1392 and P-BCP-1159 as in the reconciliation table: drop the CDE merge, keep "one repo" as "one repo, two apps", point "single point for payments" at the box.
  • Freeze rule: no new provider-specific code in be-pspfunding. TabaPay tickets (GROBE-406/407/411 and successors) target the box app and be-cde-externalcarddata only.
be-docs · Linear
NOW · IN FLIGHT

TabaPay, built as the first clean adapter

replaces Astra flow by flow

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.

  • Adapter in the box. Add 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.
  • PAN calls in the CDE. Create Account and Query Card run in a new TabaPay proxy in be-cde-externalcarddata, next to the Checkout and Fiserv proxies. The card-query models currently on the branch inside be-psp-payment move there. This is the only CDE code change in the plan.
  • Stored reference. The adapter stores the TabaPay accountID per card in the box's database (MoneyflowPayment) and uses it for every later call. Never in MinorityPspFunding, which is where the Astra tables went. Checkout and Fiserv are untouched.
  • Push is a verb. Add Payout (push, OCT) to IPaymentService and IPaymentProviderService. TabaPay implements it; Checkout and Fiserv return NotSupported until wired.
  • Webhook host. Add an API project to be-psp-payment for TabaPay webhooks, modelled on the be-psp-checkout pipeline: authenticate, persist raw event, publish typed event.
  • Tenant id. Add tenantId to CreditRequest, VerifyPaymentMethodRequest and the new PayoutRequest, defaulted from licenseId (MPS, MUSA) so nothing breaks.
  • Flow 1, payment link "you to me". be-pspfunding sends a plain credit with no provider hint; routing config sends it to TabaPay. Delete PaymentProvider = Provider.Astra from PaymentProviderRequestBuilder.
  • Flow 2, MPay2Anyone "me to you". be-pspfunding calls Payout. Delete the Astra account-to-card path.
  • Flow 3, me2me. Enable TabaPay in routing config per brand and licence next to Checkout (MUSA account funding, MPS remittance landing) and Fiserv; compare cost and approval rates before changing defaults.
  • TabaPay Users stay outside. Keep the merged Users client library, but register and call it from be-pspfunding, not from the adapter. The box receives the TabaPay user id as an opaque customer reference.
  • Decommission Astra. Delete Minority.Payment.Astra.* from be-pspfunding, the Astra proxy from be-cde-externalcarddata (a removal, reviewed through the CDE gate) and the Astra config, after flows 1 and 2 are live. Drop the Astra tables and the User.AstraVerified column from MinorityPspFunding in the same change set, after archiving what reporting needs.
be-psp-payment · be-cde-externalcarddata (one proxy) · be-pspfunding (removals only) · app-config
ALONGSIDE

Take provider knowledge out of be-pspfunding

The product layer knows no provider names. Nothing here touches the CDE.

  • Routing. Delete PaymentProviderService and the brand rules in GetVerifyCardRequest. The box decides. Keep only the regression-test override, as a header the box honours outside production.
  • 3DS. Define a provider-agnostic authentication interface (create session, submit fingerprint result, get session) in the box contract. be-psp-checkout implements it with the same calls to be-cde-externalcarddata it makes today. Delete 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.
  • Person data. Replace Sender/Recipient AdditionalDetails with account holder (name, billing address), customer reference and an optional risk context (email, phone, IP, device). Document per tenant which providers receive risk context.
  • One contract. be-pspfunding uses the MoneyFlow models and Provider enum directly; delete the mirror models and the mapping layers.
  • One card record. Fold the four CardEntity shapes into the box's payment-method registry in MoneyflowPayment; be-pspfunding keeps only product fields (preferred, image, description, app status, risk outcome) and drops AvsCheck, MitSchemeTransactionId and ProviderStatusResponse from ExternalCard once it reads them by event.
be-pspfunding · be-psp-payment · be-psp-checkout · be-psp-fiserv
FOR REBTEL

Multi-tenancy and the Rebtel pilot

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.

  • Tenant registry in the box: credentials per provider (Checkout processing channel, Fiserv merchant, TabaPay client and settlement account), routing policy, webhook endpoints and signing secrets. The Checkout processing channel itself is a config entry in be-cde-externalcarddata's existing per-licence map.
  • Public façade. REST on the box's API project behind the platform gateway, API-key or OAuth client credentials per tenant, Idempotency-Key on writes, and an outbound webhook dispatcher with retries and signatures.
  • Card capture. The existing hosted iframe (web-cde-carddata) with a tenant-scoped access token issued by the box. The CDE's access-token service is used as today.
  • EU/UK. Route by issuer country to Checkout; expose the authentication resource and the browser fingerprint step to Rebtel's checkout page.
  • Migration. Confirm with Braintree whether their vault can be exported to a PCI-compliant third party; otherwise re-collect cards through the iframe at next purchase.
  • Recurring charges. Reuse the scheme transaction id handling already present for Checkout and Fiserv.
be-psp-payment · web-cde-carddata · platform gateway · be-cde-externalcarddata (config only)
LATER · ONLY WITH A REASON

Things deliberately not in the plan

Each of these needs its own business case. Listed so nobody mistakes them for implied steps.

  • A provider-token mode for Checkout or Fiserv. It changes CDE data flows and needs a PCI review and a cost argument.
  • Folding be-psp-checkout and be-psp-fiserv into the box app. Evaluate against the 2026-01-21 decision-log criteria when there is a scaling or ownership reason.
  • Account updater, Apple Pay and Google Pay, bank-account funding through the box.
  • Any merge or move of the two CDE services.

Do now, inside the TabaPay work

Eight choices that cost little today and save a rewrite later. They are the concrete asks for the GROBE tickets.

  1. Adapter in the box. The TabaPay IPaymentProviderService implementation lives in be-psp-payment. No TabaPay code in be-pspfunding, no new be-psp-tabapay repo.
  2. Routing by config. Provider chosen by ProviderResolver from payment-provider-config; be-pspfunding stops sending PaymentProvider.
  3. Push is a verb. Add Payout to the contract now, for MPay2Anyone. Do not model it as a credit with a special PaymentType.
  4. PAN only in the CDE. Create Account and Query Card run in a TabaPay proxy inside be-cde-externalcarddata, the same way Checkout and Fiserv do. Move the card-query models off the be-psp-payment branch into that repo before merging.
  5. Stored reference in the box database. Store the TabaPay accountID per card in MoneyflowPayment and charge by it. Not in MinorityPspFunding, not on ExternalCard. Nothing changes for Checkout or Fiserv.
  6. Tenant id. Put tenantId on the requests now, defaulted from licenseId, so MUSA, MPS and later Rebtel are first-class.
  7. Webhook host. Create an API project in be-psp-payment for TabaPay webhooks with tenant mapping, modelled on be-psp-checkout.
  8. Users stay outside. TabaPay Users (DOB, identification) are called from be-pspfunding; the box only receives the user id as a customer reference.

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.

Open questions and assumptions

Needs an answer from you

  • Repo choice: keep be-psp-payment as its own repo, or move it into be-pspfunding as the box app under the hard-contract rules. Both work.
  • Rebtel's Checkout setup: a separate Checkout entity, or a processing channel under Majority's entity? Both fit the tenant registry; contracts and settlement differ.
  • Does Rebtel need recurring auto top-up (merchant-initiated) in v1? It changes the scheme-transaction-id requirements on the first charge.
  • Where does the public façade sit: the box's API project behind the platform gateway, or the existing web-majority-goliath-api edge?
  • Who owns the ADR and re-scopes P-PAY-1392 and P-BCP-1159 (lead today: gowtham.krishnakumar)?

Assumptions this review makes

  • Bank-account (ACH) funding leaves the card box entirely; be-ach / Plaid own it.
  • TabaPay has no EU acquiring, so EU/UK pulls route to Checkout only.
  • Braintree vault export for Rebtel is possible but unconfirmed; the fallback is re-collection through the hosted iframe.
  • Fiserv Commerce Hub push and wallet support are taken from third-party summaries, not Fiserv's own reference.
  • The licenseId values MPS and MUSA are the only Majority tenants in scope.
  • The CDE change control (approval gate, reviewers) can absorb one new provider proxy on the TabaPay timeline.

Risks

  • Scheme rules differ per flow: AFT indicators for account funding, OCT for payouts, MIT for recurring. Each tenant and provider combination needs explicit configuration, not inference.
  • In one repo, the hard contract is one ProjectReference away from eroding. The architecture test and CODEOWNERS entry must land with the first box commit, not later.
  • In one repo, the template's one-database default will pull box tables into MinorityPspFunding unless the two-database pipeline change lands first. The Astra tables show how easily this happens.
  • Two routing layers coexist while be-pspfunding still sends hints. Remove the hint per flow behind config to avoid a big-bang switch.
  • Webhook ingress for a multi-tenant box must map provider references to tenants before any state change. The reference-suffix convention from be-psp-checkout can carry tenant plus environment.
  • The TabaPay branch currently compiles PAN-shaped models into a non-CDE repo. If merged as is, the CDE boundary blurs before the adapter is even live.