Payment account custody

Cüte owns the payout experience and the payment-domain state; the provider owns the bank credentials

Cüte owns the payout-account user experience and payment-domain state. The configured payment provider owns the underlying bank credentials.

The three tables

user_payment_accounts and user_bank_accounts do not exist. Provider identities live in the seller domain; buyer identities are separate, because a buyer who saves a card has no seller existence to hang it on.

  • seller_accounts — the user's seller existence in ONE market (active | closed). Exactly one active row per user; closed rows are relocation history.
  • seller_payment_accounts — one row per (seller account, provider, environment). Stores the provider seller id, normalized seller/merchant/compliance status, the provider payout-destination id plus its normalized status, bank code / account type / last four for display, and the sync timestamp. 1:N: a relocated seller keeps the old market's identity for refunds and payouts, and a sandbox→production flip adds a row rather than mutating one.
  • buyer_payment_accounts — one row per (user, provider, environment), holding the provider customer id behind saved cards.

The read that matters is getActiveSellerPaymentAccount: derived market → active seller account → that account's row for the market's registry provider and environment. Anything keyed on (user_id, provider, environment) alone will match rows under closed accounts.

Cüte never stores full account numbers, branch numbers, holder documents, IBANs, BICs, card PANs, or CVVs.

User experience

Sellers still add and update payout accounts in Cüte:

  1. The browser collects the country-appropriate fields.
  2. A Server Action validates them and derives identity data such as CPF from trusted profile state.
  3. The payment gateway receives the full values transiently.
  4. Its market adapter maps them to Stripe, Pagar.me, or another provider.
  5. The provider stores the credentials.
  6. Cüte persists only the safe normalized result listed above.

Raw payout credentials must not enter URLs, cookies, browser storage, analytics, logs, workflow inputs, events, or database rows.

Provider implementations

Pagar.me

Brazilian sellers enter bank details in Cüte because Pagar.me does not provide a hosted onboarding surface. The Pagar.me adapter sends the details as default_bank_account during recipient creation or updates the recipient's default bank account. It returns only a safe payout-destination result to application code.

Stripe

EU sellers use Stripe-hosted onboarding and account-management links. The browser never sends an IBAN or bank-account number to Cüte. Stripe owns the form, verification, and credential storage. Cüte stores the merchant uid (as providerSellerId) and normalized readiness status; no local EU bank model exists.

The split is a capability, not a market: adapters declare getCapabilities().hostedOnboarding, and ensureSellerPaymentAccount branches on that. Never on if (market === "EU").

Saved cards

Saved cards follow the same custody rule. The provider stores the card credentials; Cüte may retain an opaque provider token and safe display metadata such as brand, expiry, and last four digits.

Eligibility

For BR, withdrawal readiness requires:

  • seller_status = "active";
  • a non-empty provider_payout_destination_id; and
  • payout_destination_status = "active".

EU requires the same three facts. An active merchant is not on its own proof that money can land somewhere: a Stripe account can exist and compliance-verified at level 100 with no bank account attached, so a real, active payout destination is required on every market. AR remains parked.

deriveEligibility is the only predicate. Capabilities are never JWT claims — the access-token hook stamps app_role and country_code and strips the rest.

Code map

WhatWhere
Schemapackages/db/schema/seller-accounts.ts, seller-payment-accounts.ts, buyer-payment-accounts.ts
The portpackages/payments/core/payment-gateway.ts
Adapterspackages/payments/pagarme/adapter.ts, packages/payments/stripe/adapter.ts
Wiringpackages/features/onboarding/actions/, packages/features/wallet/views/settings/payout-destination-section.tsx
Access-token hookpackages/db/migrations/*_auth/migration.sql

What the tests prove

  • BR bank details reach Pagar.me but appear nowhere in Cüte's database or logs.
  • Updating a BR destination replaces the provider default and refreshes only safe metadata.
  • Stripe onboarding stays hosted; no local IBAN or BIC is stored.
  • An inactive or missing payout destination cannot withdraw.
  • Admin, wallet and data-export views show only provider references and masked metadata.