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.
| Rail | EU | BR | AR |
|---|---|---|---|
| Status | live | coming soon | coming soon |
| Currency | EUR | BRL | ARS |
| Payments and payouts | OPP | Pagar.me | Mercado Pago (parked) |
| Shipping labels | Sendcloud | Melhor Envio | EnvioPack (parked) |
| New accounts | enabled | disabled | disabled |
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.
No diagram code provided
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
| Surface | Addressability |
|---|---|
| Profile, reviews, follows | global |
| Published public item URL | global direct access |
| Feed, category, search, recommendations | reachable market |
| Seller inventory grid and item count | reachable market |
| Offer, cart, quote, checkout | same 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.
No diagram code provided
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:
| Table | Grain | What it holds |
|---|---|---|
seller_accounts | user × market | status = active | closed — existence, not setup progress. A partial unique index enforces one active row per user. |
seller_payment_accounts | seller account × provider × environment | providerSellerId (Pagar.me recipient id / OPP merchant uid), normalized statuses, and safe payout-destination metadata. |
market_payment_providers | market × provider | Admin 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.
No diagram code provided
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-datais synchronous metadata plus pure normalization and structural validation. It wrapslibphonenumber-jsfor E.164 phone handling.@repo/address-lookupowns 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.
| Capability | EU | BR |
|---|---|---|
| Buy | complete buyer profile | complete profile + CPF |
| Sell | dispatch address + a payment account on the EU rail | CPF + a payment account on the BR rail + bank + address |
| Withdraw | eligible seller + active payment account + active payout destination | eligible 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:
- run a production build and test market-door behavior;
- complete OPP hosted onboarding, charge, refund, release, and payout;
- quote and label supported EU country pairs through Sendcloud;
- verify foreign item URLs remain social-only and mutations reject commerce;
- verify existing BR users retain BR-only behavior;
- deploy and validate required schema migrations before dependent UI.
The detailed source-of-truth document is
docs/architecture/multi-market.md.