Multi-market commerce rails

How Cüte keeps global identity and URLs while commerce remains market-local

Cüte runs one application and database with three permanent commerce markets: EU, BR, and AR. EU is the only market open to new accounts. BR and AR are coming_soon; existing BR accounts retain their BR catalog, wallet, Pagar.me, and Melhor Envio behavior.

The central rule is: identity and public URLs are global, discovery and transactions are scoped to the viewer's reachable market.

Market is an operational bundle

A market combines transactional currency, payment and payout rails, carrier coverage, fees, eligibility, and legal scope. Language is an independent presentation choice.

RailEUBRAR
Statuslivecoming sooncoming soon
CurrencyEURBRLARS
Payments and payoutsOPPPagar.meMercado Pago (parked)
Shipping labelsSendcloudMelhor EnvioEnvioPack (parked)
New accountsenableddisableddisabled

The source of truth is packages/commerce/markets/market.registry.ts. EU_MARKET_COUNTRY_CODES lives there because enabled-country coverage is a commerce policy. Static postal, phone, and address behavior lives separately in @repo/country-data.

Visitor context

Every request resolves an authoritative country and market:

type VisitorMarketContext = {
  countryCode: CountryCode;
  marketId: MarketId;
  source: "account" | "confirmed_cookie" | "geo" | "default";
};

Resolution precedence is signed-in account/JWT, validated anonymous cookies, production-enterable geo suggestion, then the production default. Signup revalidates country and derives the market on the server, so a stale or forged BR/AR cookie cannot create an account in a parked market.

Language remains cookie-driven through next-intl and never appears in the URL. Currency is not a user preference: it follows the trusted listing, market, and transaction data.

Visibility and transaction boundary

SurfaceAddressability
Profile, reviews, followsglobal
Published public item URLglobal direct access
Feed, category, search, recommendationsreachable market
Seller inventory grid and item countreachable market
Offer, cart, quote, checkoutsame reachable market only

A public foreign-market item can be opened directly and displays its native currency. The viewer can Favorite, Follow, Share, Report, and Ask Seller. Purchase controls, offers, shipping estimates, and checkout are unavailable and the same operations are rejected on the server.

Non-public items are never exposed by the global-URL rule. The pure listing access evaluator covers publication status, visibility restrictions, availability, ownership, and market reachability, and is enforced by UI and server mutations.

Provider selection and historical safety

New operations resolve their adapter from the trusted market:

getPaymentGateway({ market: order.market });
getShippingGateway(order.market);

Historical operations resolve from the provider persisted on the row:

getPaymentGateway({ provider: payment.provider });
getGatewayForFulfillment(fulfillment);

This guarantees a payment is refunded and a shipment is managed by the provider that originally created it, even if the market registry changes later.

The seller's identity per market

A user is one person. Their existence as a seller is per market, and their registration with a payment provider is per provider environment:

TableGrainWhat it holds
seller_accountsuser × marketstatus = active | closed — existence, not setup progress. A partial unique index enforces one active row per user.
seller_payment_accountsseller account × provider × environmentproviderSellerId (Pagar.me recipient id / OPP merchant uid), normalized statuses, and safe payout-destination metadata.
market_payment_providersmarket × providerAdmin enablement per operation (onboarding / payments / payouts). Fails open.

Relocation is an admin operation: it closes the old market's seller_accounts row, and a new one is created lazily on the next seller-setup run. The closed row keeps its payment accounts, which is exactly what lets a refund or payout on an old BR order still reach Pagar.me after the seller moved to the EU.

Read the current identity through the active account — getActiveSellerPaymentAccount(tx, userId, market). Reading seller_payment_accounts by userId alone can return a closed market's identity, or fan out once a seller holds rows on two rails.

providerSellerId is the provider's id for the seller. It is deliberately not called sellerId, because on items, orders and offers that name means a users.id. Provider vocabulary — recipient, merchant, connected account — stops at the adapter; above it the concept is the seller's payment account.

OPP EU money flow

OPP is the sole EU payment provider. It is a marketplace-native rail: the held-funds model is the provider's own, not a collect-then-transfer pattern.

Seller identity and payout-bank details are collected by OPP's hosted onboarding. Cüte stores the environment-specific providerSellerId and the normalized status, not an EU IBAN or any full bank credential.

All transactional values are integer cents plus a persisted currency. The market determines currency; next-intl/Intl only determines localized punctuation and symbol placement.

Addresses, phones, and lookup

Country handling is split by responsibility:

  • @repo/country-data is synchronous metadata plus pure normalization and structural validation. It wraps libphonenumber-js for E.164 phone handling.
  • @repo/address-lookup owns optional server-side postal/address API adapters, organized by country and provider.
  • delivery owns route support and provider payload mapping.
  • internationalization owns translated labels and error messages.

The app uses one canonical address model. It does not maintain separate DE, FR, or BR database tables, and it does not model the canonical address after Sendcloud.

Quotes require explicit origin and destination country/postal route points. Postal values stay text, preserving leading zeros and alphanumeric formats. Sendcloud mapping occurs only in the Sendcloud adapter.

Shipping and meetup

Listings explicitly store shipping, meetup, and same_day methods. Checkout exposes only the intersection of every item in a bundle.

Shipping quotes are re-fetched server-side at final checkout. The authoritative provider, service, amount, currency, route, and timestamp are persisted and then reused for label assembly. A stale, modified, or unavailable client quote returns the buyer to delivery selection.

Meetup exposes only the listing city and schedule. Street addresses and precise coordinates remain private. Meetup-only orders continue through the existing PIN handoff state machine.

Eligibility

deriveEligibility remains the canonical pure predicate.

CapabilityEUBR
Buycomplete buyer profilecomplete profile + CPF
Selldispatch address + a payment account on the EU railCPF + a payment account on the BR rail + bank + address
Withdraweligible seller + active payment account + active payout destinationeligible seller + active payment account + active payout destination

The custom JWT hook mirrors these checks and must stay covered by the TypeScript/SQL conformance matrix.

Release verification

Before enabling production traffic:

  1. run a production build and test market-door behavior;
  2. complete OPP hosted onboarding, charge, refund, release, and payout;
  3. quote and label supported EU country pairs through Sendcloud;
  4. verify foreign item URLs remain social-only and mutations reject commerce;
  5. verify existing BR users retain BR-only behavior;
  6. deploy and validate required schema migrations before dependent UI.

The detailed source-of-truth document is docs/architecture/multi-market.md.