Multi-market rails
What a market owns, how it is derived, and why no code may branch on a market's name
Cüte runs one app and one database across three markets: EU (live, the launch market), BR (live), and AR (a parked skeleton).
The rule that shapes everything: identity and public URLs are global; discovery and transactions are market-local. Anyone can open a seller's profile or a public item URL from anywhere. Only someone in that item's market can buy it.
What a market is
A market is a bundle of operational facts — currency, who takes the money, who carries the parcel, what identity is required, which countries it serves. Language is separate: a German buyer and an Austrian buyer are in the same market whatever language they read it in.
| EU | BR | AR | |
|---|---|---|---|
| Status | live | live | parked |
| Currency | EUR | BRL | ARS |
| Payments | Stripe | Pagar.me | Mercado Pago (parked) |
| Shipping labels | Sendcloud | Melhor Envio | Enviopack (parked) |
| Tax ID to buy | no | yes (CPF) | — |
| Installments | none | 12×, interest-free | — |
The source of truth is the market's own config file — packages/commerce/config/eu.ts, br.ts, ar.ts. Its identity block is what the market is; marketIdentityFor(id) reads it. Everything in packages/commerce/markets/ only operates on that: findMarketForCountry, isMarketEnterable, the trade graph, the envelopes.
Country-level facts — postal formats, phone shapes, address forms, tax identifiers, time zones — live in @repo/country-data, not the market config. Optional postal/address lookup APIs live in @repo/address-lookup.
A market is derived, never stored
There is no users.market column and no market JWT claim. The only location value is user_profiles.country_code, and the market follows from it.
resolveActiveMarketId(); // the current request, visitors included
getUserMarketId(userId); // anyone else
findMarketForCountry(cc); // the underlying ruleTransactions are the exception. listings, orders, offers and checkout_sessions each carry their own market column. That is a snapshot — history — and must never be re-derived from where the person lives today.
Never branch on a market name
market === "BR" in runtime code is always a stand-in for a capability nobody named. Ask the registry for the real one instead:
| Instead of | Ask |
|---|---|
| "is BR" for installments | installmentMode |
| "is BR" for CPF at checkout | identityRules.taxIdRequiredToBuy |
| "is EU" for a country list | marketIdentityFor(id).countries.includes(cc) |
| "is not AR" for the door | isMarketEnterable |
If the capability has no field yet, add the field. BR is a market with its own payment provider and delivery gateway, nothing more. The full table and the bugs this caused are in docs/architecture/multi-market.md.
Choosing a provider
Always through a factory, always with a selector — never by instantiating an adapter.
// New work: resolve from the market.
getPaymentGateway({ market: order.market });
getShippingGateway(order.market);
// Existing work: resolve from what the row says.
getPaymentGateway({ provider: payment.provider });
getGatewayForFulfillment(fulfillment);A refund goes back through the provider that took the money, and a shipment stays with the gateway that made the label — even if the market's rails change afterwards.
No diagram code provided
What a visitor can reach
| Surface | Reach |
|---|---|
| Profile, reviews, follows | global |
| A published item's URL | global |
| Feed, category, search, recommendations | their market |
| A seller's inventory grid and item count | their market |
| Offer, cart, quote, checkout | their market only |
Open an item from another market and you see it in its own currency, and you can favourite, follow, share, report and message the seller. You cannot buy it — and the server rejects the same operations, not just the UI.
Non-public items are never exposed by the global-URL rule: one access evaluator covers publication status, visibility restrictions, availability, ownership and market reach, and both the UI and every mutation run it.
No diagram code provided
Where a visitor's market comes from
Precedence: the signed-in account's country claim → a validated cookie → a geo suggestion, but only for a market you can actually enter → the default. Signup re-derives the country on the server, so a forged cookie cannot open an account in a parked market.
Language stays cookie-driven through next-intl and never appears in the URL. Currency is not a preference — it follows the listing and the order.
Selling in a market
A person is one user. Their existence as a seller is per market, and their registration with a payment provider is per provider environment.
| Table | Grain | Holds |
|---|---|---|
seller_accounts | user × market | active or closed. One active row per user, enforced by a partial unique index |
seller_payment_accounts | seller account × provider × environment | The provider's seller id, normalized statuses, safe payout metadata |
market_payment_providers | market × provider | Admin on/off per operation. Fails open |
Moving a seller between markets closes the old seller_accounts row; a new one is created at their next first publish, from the identity dialog. The closed row keeps its payment accounts — which is exactly what lets a refund on an old BR order still reach Pagar.me after the seller moved to the EU.
Read identity through the active account: getActiveSellerPaymentAccount(tx, userId, market). Looking rows up by userId alone can return a closed market's identity.
providerSellerId is deliberately not called sellerId — on listings, orders and offers that name means a users.id. Provider words (recipient, merchant, connected account) stop at the adapter.
How EU money moves
A destination charge: the seller's cut lands in their Stripe connected account at capture, Cüte keeps the application fee, and the account's manual payout schedule is the buyer-protection hold. Cüte never holds the buyer's money.
No diagram code provided
Stripe's hosted onboarding collects the seller's identity and bank details. Cüte stores the provider's seller id and a normalized status — never an IBAN. See Payment account custody.
Shipping and meetup
Listings declare which of shipping, meetup and same_day they accept; a bundle offers only what every item in it accepts. Quotes reject origin or destination countries outside the listing's market — never feed a BR destination into an EU listing.
Quotes are re-fetched server-side at final checkout, and the provider, service, amount, currency, route and timestamp are persisted for label assembly. A stale or modified client quote sends the buyer back to delivery selection.
Meetup exposes the listing's city and the schedule only. Street addresses and precise coordinates stay private; the handoff code confirms the exchange.
Eligibility
deriveEligibility is the only predicate, and it reads live database state immediately before the mutation it gates.
| 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 | same |
Eligibility is never a JWT claim. The access-token hook stamps exactly two fields — app_role and country_code — and strips everything else. A token can be an hour stale, so it must never gate money.
Related
docs/architecture/multi-market.md— the full source-of-truth document- Payment account custody
- Seller eligibility