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.

EUBRAR
Statusliveliveparked
CurrencyEURBRLARS
PaymentsStripePagar.meMercado Pago (parked)
Shipping labelsSendcloudMelhor EnvioEnviopack (parked)
Tax ID to buynoyes (CPF)—
Installmentsnone12×, 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 rule

Transactions 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 ofAsk
"is BR" for installmentsinstallmentMode
"is BR" for CPF at checkoutidentityRules.taxIdRequiredToBuy
"is EU" for a country listmarketIdentityFor(id).countries.includes(cc)
"is not AR" for the doorisMarketEnterable

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.

What a visitor can reach

SurfaceReach
Profile, reviews, followsglobal
A published item's URLglobal
Feed, category, search, recommendationstheir market
A seller's inventory grid and item counttheir market
Offer, cart, quote, checkouttheir 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.

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.

TableGrainHolds
seller_accountsuser × marketactive or closed. One active row per user, enforced by a partial unique index
seller_payment_accountsseller account × provider × environmentThe provider's seller id, normalized statuses, safe payout metadata
market_payment_providersmarket × providerAdmin 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.

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.

EUBR
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 destinationsame

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.