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:
- The browser collects the country-appropriate fields.
- A Server Action validates them and derives identity data such as CPF from trusted profile state.
- The payment gateway receives the full values transiently.
- Its market adapter maps them to Stripe, Pagar.me, or another provider.
- The provider stores the credentials.
- 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
| What | Where |
|---|---|
| Schema | packages/db/schema/seller-accounts.ts, seller-payment-accounts.ts, buyer-payment-accounts.ts |
| The port | packages/payments/core/payment-gateway.ts |
| Adapters | packages/payments/pagarme/adapter.ts, packages/payments/stripe/adapter.ts |
| Wiring | packages/features/onboarding/actions/, packages/features/wallet/views/settings/payout-destination-section.tsx |
| Access-token hook | packages/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.