10 · Multidivisa (Currency-B)¶
Estado del documento
Versión: 1.0 · 17-may-2026 Estado: ✅ completo Audiencia: Equipo de desarrollo
1. Para qué sirve este documento¶
El portal B2B Outlet presenta precios al comprador en su divisa preferida (EUR, USD, GBP) mientras cierra todos los pedidos internamente en EUR. Esta separación entre divisa de presentación y divisa de checkout es la decisión de diseño central de Currency-B (mayo 2026), documentada como ADR en D13.
Este doc explica la implementación operativa: qué hace Shopify Markets nativamente, qué hace el theme custom (currency-switcher.liquid), cómo persiste la elección del comprador entre sesiones, cómo se propaga al Draft Order en submit-order-request, qué scripts hay para activar divisas adicionales, y la historia de PR-CURRENCY-A v1 (revertido).
No se cubre aquí: el modelo conceptual y las alternativas evaluadas — eso vive en D13. Ni los detalles del Draft Order más allá de los customAttributes de divisa (ver 07-solicitudes-pedido). Ni configuración de Markets en Shopify Admin (ese paso es manual, documentado al final del §3).
Lectores principales: cualquier dev o IA que tenga que añadir una divisa, modificar el switcher, debugear por qué un comprador no ve el precio que espera, o escalar Currency-B a checkout multidivisa real cuando llegue el momento.
2. Modelo: presentación vs. checkout¶
Currency-B es presentación multidivisa, no checkout multidivisa. La separación se sostiene en tres puntos:
| Capa | Quién gestiona | Divisa |
|---|---|---|
| Precio mostrado en storefront | Shopify Markets (auto-rates) | EUR / USD / GBP según selección del comprador |
submit-order-request (Edge function) |
Theme + edge | Persiste la divisa elegida en customAttributes del Draft Order |
| Draft Order que ve el backoffice | Shopify Draft Orders | Siempre EUR. El precio es base, no se recalcula. |
| Facturación al cliente | Cliente (Odoo) | EUR. |
Implicación operativa: si un comprador en UK ve "GBP 84.50" en una ficha, esa cifra la calcula Shopify Markets en tiempo real aplicando su tipo de cambio actual EUR→GBP sobre el precio base EUR del producto. Cuando ese comprador envía la solicitud, el Draft Order que llega al backoffice se cierra en EUR al precio base — no en GBP. La divisa GBP queda anotada en customAttributes solo como pista para que el equipo comercial sepa qué moneda vio el comprador al evaluar la oferta.
El comprador nunca paga en GBP en este modelo. El Draft Order se convierte en pedido firme tras revisión comercial y la facturación es EUR. Currency-B es UX informativa para reducir fricción cognitiva al comprador internacional ("este producto cuesta unos 84 libras") sin asumir las complicaciones de checkout multidivisa real (gestión de tipos de cambio comerciales, reconciliación contable con varias divisas, divergencia entre precio mostrado y precio facturado por desfase de tasa). Razonamiento completo en D13 §Decisión y §Alternativas.
3. Activación de divisas en Shopify Markets¶
Shopify Markets es nativo y aplica tasas auto-actualizadas (basadas en sus proveedores estándar) cuando un Market tiene localCurrencies: true. El theme custom no mantiene una tabla de tipos de cambio propia.
Configuración actual¶
Tres Markets activos:
| Market handle | Base currency | localCurrencies | Estado |
|---|---|---|---|
es (ES) |
EUR | n/a (default) | Default activo. EUR es la moneda base del store. |
uk (UK) |
GBP | true |
Activado vía script. |
usa (USA) |
USD | true |
Activado vía script. |
El Market default (ES + EUR) no necesita activación — es la base del store. Los otros dos se activaron una sola vez con el script scripts/activate-market-currencies.mjs.
El script¶
scripts/activate-market-currencies.mjs es un one-shot idempotente. Lo que hace:
- Lista todos los Markets vía
markets(first: 50)query. - Para cada target (
uk→ GBP,usa→ USD), comprueba si el Market ya tienelocalCurrencies=trueybaseCurrency=<código esperado>. - Si ya está activo, hace skip.
- Si no, llama
marketUpdateconcurrencySettings: { localCurrencies: true, baseCurrency: <código> }.
Sin OXR, sin cron, sin tabla de tipos de cambio en Supabase, sin edge function — solo una llamada GraphQL idempotente.
Ejecución:
SHOPIFY_STORE_DOMAIN=ledsc4-b2b-outlet.myshopify.com \
SHOPIFY_ADMIN_TOKEN=shpat_xxx \
node scripts/activate-market-currencies.mjs
Scopes Admin API requeridos: read_markets, write_markets.
Limitación API: localCurrencies no admite baseCurrencyManualRate¶
La combinación localCurrencies: true + baseCurrencyManualRate: <numero> la rechaza la API con:
Documentado en el header del script. La consecuencia es que Currency-B usa siempre auto-rates — no hay manera de fijar manualmente la tasa EUR→USD o EUR→GBP mientras localCurrencies esté activo. Para fijar manualmente habría que desactivar localCurrencies y entrar en un modelo de pricing por Market, que es exactamente lo que se evita (D13 §Alternativas).
Pasos previos manuales (no automatizados)¶
Antes de ejecutar el script, los Markets uk y usa deben existir como Markets en Admin → Settings → Markets. El script no los crea — solo activa la divisa en Markets preexistentes. Si un handle no existe en el listado del Admin, el script lo salta con un warning sin error fatal:
Crear un Market en Admin es manual: clic en "Add market", elegir país/región, asignar locale e idiomas habilitados. Una vez el Market existe, el script se encarga de la divisa.
4. Currency switcher: UI y mapping¶
snippets/currency-switcher.liquid es la UI que ve el comprador. Se renderiza dentro de los headers públicos (b2b-header.liquid, b2b-header-simple.liquid), junto al selector de idioma. Visible para todos los visitantes — no está detrás del gate B2B porque ayuda al comprador a evaluar el catálogo antes de registrarse.
Tres opciones expuestas¶
| Opción visible | Código divisa | Símbolo | country_code enviado al form |
|---|---|---|---|
| € EUR | EUR | € | ES |
| $ USD | USD | $ | US |
| £ GBP | GBP | £ | GB |
El comprador no ve "Market" en la UI — solo divisa. El mapping currency → country_code está fijado en el snippet (en JS y en cada <button data-country=...>). Cambiar el mapping requiere editar el snippet (no es configurable desde Theme Editor).
Mecanismo nativo: {% form 'localization' %}¶
El switcher usa el componente Shopify estándar {% form 'localization' %}. Al submit:
- Se envía POST a
/localizationconcountry_code=<ES|US|GB>yreturn_to=<URL actual>. - Shopify cambia el
@inContextdel Market activo segúncountry_code. - Shopify redirige a
return_tocon el cookie de Market actualizado. - En la nueva carga,
localization.country.currency.iso_codeya devuelve la divisa del Market activo y todos los precios se renderizan en esa divisa.
El theme no implementa lógica de cambio de Market propia — toda la conmutación la hace Shopify nativamente vía el endpoint /localization. El JS del switcher solo dispara el submit del form en el momento adecuado.
return_to: preservar la URL actual¶
El return_to se calcula en Liquid antes de renderizar el form, preservando path + query string:
{%- assign return_path = request.path
if request.query_string != blank
assign return_path = return_path | append: '?' | append: request.query_string
endif
-%}
<input type="hidden" name="return_to" value="{{ return_path | escape }}">
Sin ese cálculo, el redirect tras /localization lanzaría al comprador a la home. Con él, el comprador se queda en la misma ficha de producto, página de listado de colección, página de cuenta, etc. — solo cambia la divisa de los precios mostrados.
5. Persistencia: cookie ledsc4_currency y redirect 1-vez¶
Shopify gestiona el cookie de Market internamente (cart_currency, localization), pero hay dos casos donde queremos persistencia más fuerte:
- El cookie de Market expira o el navegador lo borra → Shopify vuelve a aplicar el Market default (ES + EUR) en la siguiente visita.
- El comprador llegó al portal vía un link directo a una página interna sin pasar por la home, y Shopify aún no tiene cookie de Market.
Para cubrir ambos, el switcher persiste la elección del comprador en una cookie propia ledsc4_currency con TTL de 30 días. La cookie es solo memoria local — Shopify nunca la lee; el JS del switcher la usa al cargar para detectar si el Market activo coincide con la última elección del comprador y forzar un redirect si no.
Flujo en cada carga de página¶
Pseudocódigo del JS en el snippet:
desired = getCookie('ledsc4_currency')
if (!desired) {
desired = 'EUR'
setCookie('ledsc4_currency', 'EUR')
}
active = ACTIVE_CURRENCY // inyectado server-side desde Liquid
if (desired != active) {
// El Market activo no coincide con lo que el comprador eligió.
// Submit silencioso del form para corregir.
if (!sessionStorage.flag_redirected) {
sessionStorage.flag_redirected = desired
form.country_code = COUNTRY_BY_CURRENCY[desired]
form.submit()
return
}
// Si ya intentamos esta divisa en esta sesión y aún no aplicó,
// no reintentar (anti-bucle).
}
Anti-bucle: el flag de sessionStorage¶
Si el comprador eligió USD pero Shopify rechaza el Market USA (por ejemplo, porque el Market no soporta el país de la IP del comprador, o porque hay alguna restricción de envío), el redirect llega de vuelta con la divisa aún en EUR. Sin protección, el JS volvería a forzar el redirect en bucle infinito.
El flag sessionStorage.ledsc4_currency_redirected guarda la última divisa intentada en esta sesión del navegador. Si tras el redirect la divisa activa sigue sin ser la deseada, el flag impide reintentar. La cookie ledsc4_currency queda apuntando a USD (la elección del comprador es respetada), pero el switcher renderiza en EUR (la realidad de Shopify) hasta que:
- El comprador hace clic explícito en una opción del switcher (limpia el flag).
- El comprador inicia una sesión nueva (sessionStorage se borra).
- El comprador cambia el cookie manualmente o lo borra.
Click en una opción del switcher¶
Al pulsar EUR/USD/GBP en el dropdown, el JS:
- Setea
ledsc4_currencya la divisa pulsada. - Borra el flag
sessionStorage.ledsc4_currency_redirected(la elección manual sobrescribe cualquier intento previo fallido). - Setea
country_codeen el form alES/US/GBcorrespondiente. - Submit del form →
/localization→ redirect.
La elección manual es siempre prioritaria sobre el estado de la cookie o el sessionStorage anteriores.
Sin auto-detect por IP¶
Decisión explícita (D13): no se intenta detectar la divisa por la IP del comprador. Por defecto, todo comprador nuevo aterriza en EUR. Si la cookie vence, vuelve a aterrizar en EUR. Solo cambia de divisa si pulsa explícitamente otra opción.
Razonamiento: el portal está orientado a profesionales que llegan con intención, no a tráfico orgánico de retail. Imponer una divisa por geolocalización molesta más de lo que ayuda en un contexto B2B donde el comprador suele tener preferencias claras (algunos clientes UK quieren ver EUR porque facturarán en EUR; auto-detectar GBP les fuerza a cambiar manualmente).
6. Propagación al Draft Order¶
Cuando el comprador envía una solicitud de pedido, el JS de /pages/solicitud lee la cookie ledsc4_currency y la pasa en el body de la llamada a la edge submit-order-request:
{
"customerId": "gid://shopify/Customer/123",
"timestamp": 1712345678,
"signature": "abc...",
"note": "...",
"items": [...],
"currencyCode": "GBP"
}
La edge valida que currencyCode esté en el set permitido (EUR, USD, GBP) y cae a EUR si llega ausente, malformado o con un código no soportado:
const ALLOWED_CURRENCIES = ["EUR", "USD", "GBP"] as const;
const rawCurrency = (body.currencyCode as string | undefined)?.toUpperCase();
const currencyCode = (ALLOWED_CURRENCIES as readonly string[]).includes(rawCurrency ?? "")
? (rawCurrency as string)
: "EUR";
const currencySymbol = SYMBOL_BY_CURRENCY[currencyCode]; // "€" | "$" | "£"
Luego añade dos customAttributes al Draft Order:
customAttributes: [
{ key: "fuente", value: "solicitud-b2b-frontend" },
{ key: "cbm_total", value: cbmTotal.toString() },
{ key: "fecha_solicitud", value: new Date().toISOString() },
{ key: "Moneda mostrada", value: currencyCode },
{ key: "Símbolo moneda", value: currencySymbol },
],
customAttributes se llaman note_attributes en la REST API y se muestran en el Admin del Draft Order bajo "Additional details" como pares clave/valor. Los labels en español ("Moneda mostrada" y "Símbolo moneda") son lo que ve el equipo de backoffice — están en castellano deliberadamente para que sean legibles sin traducción mental.
Por qué no se guarda el tipo de cambio numérico¶
D13 §Decisión lo explica: el equipo comercial no recalcula el precio en EUR desde el precio mostrado en GBP. El Draft Order ya viene en EUR (precio base del producto), y la divisa anotada es solo información de contexto. Persistir un rate numérico añadiría falsa precisión — la tasa que aplicó Shopify Markets en el momento del envío no es necesariamente la tasa que aplicaría en facturación si por algún motivo se intentara cobrar en GBP.
Si en algún momento se escala a checkout multidivisa real (Fase 2 hipotética), entonces sí habría que persistir tasa + timestamp para reconciliación. Hoy no se hace porque no se usa para nada.
Confianza en currencyCode del client¶
El valor del currencyCode viene del client (JS storefront) y no está firmado. Un atacante podría enviar currencyCode: "JPY" en el body. La edge lo trata como informativo y lo valida contra el allowlist — si está fuera, cae a EUR sin error. No hay riesgo financiero porque el Draft Order se cierra siempre en EUR independientemente: el currencyCode no afecta al precio, solo al customAttribute.
Esto es deliberado y aceptable en el modelo Fase 1. En un modelo Fase 2 (checkout multidivisa real) habría que firmar la divisa con el HMAC junto al customerId y timestamp para evitar que un comprador manipule la divisa del checkout. Documentado como pendiente de Fase 2 en §9.
7. Historia: PR-CURRENCY-A v1 revertido¶
Antes de Currency-B (mayo 2026) hubo una primera implementación, PR-CURRENCY-A v1, que construyó infra completa para multidivisa con tasas propias. Lo que se construyó:
- Tabla
currency_ratesen Supabase con columnascurrency_code,rate,fetched_at. - Edge function
refresh-currency-ratesque consultaba OpenExchangeRates (OXR) diariamente con un API key y poblaba la tabla. pg_cronjob que disparaba la edge cada 24h.- Extensión de
submit-order-requestpara leer la tablacurrency_ratesy guardarcurrency_code+rate+rate_fetched_atennote_attributesdel Draft Order.
Al revisar la implementación contra el modelo de negocio real, se vio que toda esa infraestructura no cambiaba nada operativamente:
- Los Draft Orders se cerraban en EUR igualmente (no había cálculo de precio que usara la tasa).
- Las tasas guardadas no se leían en ningún punto downstream (el backoffice ignoraba
note_attributes.rate). - OXR introducía dependencia externa con coste recurrente (~$15/mes para el plan que soporta más de 1000 calls/mes).
- La tabla
currency_ratesy el cron añadían superficie de mantenimiento (monitoring de fallos del cron, recovery si OXR cae, refresh manual si la tasa se quedó stale).
Decisión: revertir todo y migrar a auto-rates nativas de Shopify Markets, que cubre el caso de uso (presentación de precios) sin tabla propia ni cron ni dependencia externa.
Revertido en PR #78. Lo que quedó:
- Tabla
currency_ratesborrada de Supabase. - Edge function
refresh-currency-ratesborrada del repo. - Job
pg_croneliminado vía migration. submit-order-requestsimplificado a solocustomAttributesde divisa (sin rate numérico).
Cualquier referencia a currency_rates, refresh-currency-rates, OXR, o cron diario de tasas en docs históricas o commits previos a PR #78 se lee como histórica — no es parte del sistema actual. D13 §Historia documenta esto con más detalle.
8. Cómo añadir una divisa nueva¶
Pasos para activar una cuarta divisa (por ejemplo, AUD en un futuro Market Australia):
-
Crear el Market en Shopify Admin. Settings → Markets → Add market. Asignar país/región (e.g. Australia), locale, idiomas habilitados. Anotar el
handleque Shopify asigna (normalmenteaustraliaoau). -
Añadir el target en
scripts/activate-market-currencies.mjs:
const TARGETS = [
{ handle: 'uk', currencyCode: 'GBP' },
{ handle: 'usa', currencyCode: 'USD' },
{ handle: 'australia', currencyCode: 'AUD' }, // ← nuevo
];
-
Ejecutar el script. Es idempotente — los Markets ya activos hacen skip. Solo aplica al nuevo.
-
Añadir la opción al switcher. Editar
snippets/currency-switcher.liquiden cuatro sitios: -
La lógica de
active_symbolen el{% liquid %}inicial: -
Una nueva
<li><button data-ledsc4-currency="AUD" data-country="AU">A$ AUD</button></li>en el<ul>de opciones. -
El mapping JS
COUNTRY_BY_CURRENCY: -
Añadir el código en la edge
submit-order-request:
const ALLOWED_CURRENCIES = ["EUR", "USD", "GBP", "AUD"] as const;
const SYMBOL_BY_CURRENCY: Record<string, string> = {
EUR: "€", USD: "$", GBP: "£", AUD: "A$"
};
- Test E2E. Comprador simulado en el storefront: abrir el switcher, seleccionar AUD, verificar que los precios cambian a AUD en una ficha de producto y en el carrito, enviar una solicitud, verificar que el Draft Order resultante muestra
Moneda mostrada: AUDySímbolo moneda: A$en Additional details.
Tiempo total: 30–60 minutos de implementación + test, sin downtime.
9. Cómo escalar a checkout multidivisa real (Fase 2)¶
Hipotético — no planificado, documentado por completitud. Cuando el volumen de pedidos no-EUR justifique el coste de infra, el camino sería:
-
Pricing por Market en cada
PriceList. Shopify B2B soporta nativamente price lists con precios diferenciados por Market. Configurar precios explícitos en USD/GBP en cada Catalog B2B en lugar de depender de las auto-rates de presentación. -
Eliminar el cierre forzado en EUR de
submit-order-request. Hoy el Draft Order ignora la divisa del comprador y se cierra en EUR. En Fase 2 el Draft Order debería heredar la divisa del Market activo (Shopify lo hace si el Market tiene pricing propio). -
Firmar
currencyCodeen el HMAC del frontend. Hoy el HMAC firmacustomerId:timestamp. En Fase 2 firmarcustomerId:timestamp:currencyCodepara evitar manipulación de la divisa por parte del comprador. -
Persistir tasa + timestamp en
customAttributes. Hoy no se persiste (D13 §Decisión). En Fase 2 sí — para reconciliación contable. -
Reconciliación contable en backend del cliente (Odoo). Hoy el cliente factura en EUR. En Fase 2 tendría que gestionar facturación en múltiples divisas, conversión a EUR para contabilidad, gestión de diferencias por tipo de cambio entre fecha de pedido y fecha de cobro.
-
Política de tipos de cambio comercial. Las auto-rates de Shopify Markets son aproximadas — para pedidos reales el cliente probablemente quiera aplicar un margen propio (tasa banco + spread). Esto se hace con
manualRateencurrencySettings, pero requiere desactivarlocalCurrenciesy entrar en pricing explícito por Market. Trade-off documentado.
Estimación de coste: 2–4 semanas de implementación + setup contable + test E2E + período de paralelo con Fase 1. No iniciar sin demanda real y sin alineación previa con el equipo de finanzas del cliente.
10. Pendientes¶
-
Migration
pg_cronremovida en PR #78 — verificar que la migration de cleanup quedó aplicada en la BD de producción y staging. Si no, ejecutar manualmente. Verificación:SELECT * FROM cron.job WHERE jobname LIKE '%currency%'debe devolver vacío. -
Documentar
customAttributesdel Draft Order completos en 07-solicitudes-pedido §X —Moneda mostradaySímbolo monedase mencionan aquí pero pertenecen al inventario completo de attributes que debería estar en 07 (junto confuente,cbm_total,fecha_solicitud). Cross-link cuando 07 lo documente. -
Test de regresión del anti-bucle del switcher — el flag
sessionStorage.ledsc4_currency_redirectedcubre el caso de Market rechazado, pero no hay test automatizado. Posible mejora: añadir test Playwright/Cypress que fuerce un Market deshabilitado y verifique que el JS no entra en redirect loop. -
Auto-detect por IP como opción opcional — D13 lo descarta como default, pero podría exponerse como toggle en settings del theme para casos específicos (e.g. landing dedicada a UK con auto-GBP). No es prioridad.
-
country_codevscurrencyCodedesacoplados — hoy el mappingEUR→ES, USD→US, GBP→GBestá hardcoded. Si en algún momento un Market cubre múltiples países con la misma divisa (e.g. Market "Eurozone" con EUR perocountry_codeambiguo), habría que parametrizar. Hoy no aplica. -
Símbolo moneda en
submit-order-request— el símbolo (€,$,£) se calcula tanto en el switcher (JS) como en la edge (TypeScript) de forma duplicada. Posible refactor: pasarlo desde el frontend en el body junto concurrencyCode. Marginal — la duplicación son 3 líneas. -
Detección de divisa del Market no coincidente con cookie en logs — si el flag anti-bucle dispara (porque Shopify no aplicó el Market deseado), no hay logging server-side. El comprador queda con experiencia inconsistente (cookie GBP, precios EUR) sin que el equipo se entere. Posible mejora: enviar un beacon analytics al detectar el mismatch tras el redirect.
-
D13 referencia
presentation_currencycomo label semántico — el código real usa"Moneda mostrada"y"Símbolo moneda"como keys de loscustomAttributes. Decidir si renombrar las keys del código (más estricto) o aclarar en D13 quepresentation_currencyes el rol conceptual, no el nombre literal del atributo. Recomendación: aclarar en D13, mantener las keys en español que ve el backoffice.
Cambios¶
- v1.0 (17-may-2026): cabecera de estado añadida; documento ya estaba completo. Primera publicación del contenido: 16-may-2026.