diff --git a/pos/config.py b/pos/config.py
index 71c46aa..a94f89f 100644
--- a/pos/config.py
+++ b/pos/config.py
@@ -36,3 +36,7 @@ SMTP_FROM = os.environ.get('SMTP_FROM', 'noreply@nexusautoparts.com')
WHATSAPP_TOKEN = os.environ.get("WHATSAPP_TOKEN", "")
WHATSAPP_PHONE_ID = os.environ.get("WHATSAPP_PHONE_ID", "")
WHATSAPP_VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN", "nexus-wa-verify-2026")
+
+# Multi-currency
+DEFAULT_CURRENCY = os.environ.get('DEFAULT_CURRENCY', 'MXN')
+EXCHANGE_RATE_USD_MXN = float(os.environ.get('EXCHANGE_RATE_USD_MXN', '17.5'))
diff --git a/pos/services/currency.py b/pos/services/currency.py
new file mode 100644
index 0000000..e258c3d
--- /dev/null
+++ b/pos/services/currency.py
@@ -0,0 +1,55 @@
+"""Multi-currency support for border refaccionarias.
+
+Supports MXN and USD with configurable exchange rate.
+"""
+
+from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
+
+CURRENCIES = {
+ 'MXN': {'symbol': '$', 'name': 'Peso Mexicano', 'name_en': 'Mexican Peso', 'decimals': 2},
+ 'USD': {'symbol': 'US$', 'name': 'Dolar Estadounidense', 'name_en': 'US Dollar', 'decimals': 2},
+}
+
+
+def convert(amount, from_currency, to_currency, rate=None):
+ """Convert an amount between currencies.
+
+ Args:
+ amount: The numeric amount to convert.
+ from_currency: Source currency code ('MXN' or 'USD').
+ to_currency: Target currency code ('MXN' or 'USD').
+ rate: Optional custom exchange rate (USD->MXN). Defaults to config value.
+
+ Returns:
+ The converted amount, rounded to 2 decimals.
+ """
+ if from_currency == to_currency:
+ return amount
+ if rate is None:
+ rate = EXCHANGE_RATE_USD_MXN
+ if from_currency == 'USD' and to_currency == 'MXN':
+ return round(amount * rate, 2)
+ if from_currency == 'MXN' and to_currency == 'USD':
+ return round(amount / rate, 2)
+ return amount
+
+
+def format_currency(amount, currency='MXN'):
+ """Format an amount with the appropriate currency symbol.
+
+ Args:
+ amount: Numeric value.
+ currency: Currency code.
+
+ Returns:
+ Formatted string like '$1,234.56' or 'US$1,234.56'.
+ """
+ info = CURRENCIES.get(currency, CURRENCIES['MXN'])
+ return f"{info['symbol']}{amount:,.{info['decimals']}f}"
+
+
+def get_currency_info(code=None):
+ """Return currency metadata dict. If code is None, return all."""
+ if code:
+ return CURRENCIES.get(code)
+ return CURRENCIES
diff --git a/pos/static/js/app-init.js b/pos/static/js/app-init.js
index 755d276..97f2a05 100644
--- a/pos/static/js/app-init.js
+++ b/pos/static/js/app-init.js
@@ -40,9 +40,10 @@
var name = employee.name || payload.name || 'Usuario';
var role = employee.role || payload.role || '';
+ var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
var roleLabels = {
- 'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero',
- 'warehouse': 'Almacén', 'accountant': 'Contador'
+ 'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
+ 'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant')
};
var roleLabel = roleLabels[role] || role;
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
diff --git a/pos/static/js/config.js b/pos/static/js/config.js
index 1d686b6..2659a74 100644
--- a/pos/static/js/config.js
+++ b/pos/static/js/config.js
@@ -416,6 +416,68 @@ const Config = (() => {
});
}
+ // -------------------------------------------------------------------------
+ // Currency
+ // -------------------------------------------------------------------------
+ async function loadCurrency() {
+ try {
+ var res = await fetch(API + '/currency', { headers: headers() });
+ if (!res.ok) return;
+ var d = await res.json();
+ var selCurrency = document.getElementById('cfg-currency');
+ var inpRate = document.getElementById('cfg-exchange-rate');
+ if (selCurrency) selCurrency.value = d.currency || 'MXN';
+ if (inpRate) inpRate.value = d.exchange_rate || 17.5;
+ // Store in localStorage for POS fmt() to pick up
+ localStorage.setItem('pos_currency', d.currency || 'MXN');
+ localStorage.setItem('pos_exchange_rate', d.exchange_rate || 17.5);
+ } catch (e) {
+ console.error('Config.loadCurrency:', e);
+ }
+ }
+
+ async function saveCurrency() {
+ var selCurrency = document.getElementById('cfg-currency');
+ var inpRate = document.getElementById('cfg-exchange-rate');
+ var statusEl = document.getElementById('currency-status');
+ var btn = document.getElementById('btn-save-currency');
+
+ if (!selCurrency || !inpRate) return;
+
+ var currency = selCurrency.value;
+ var rate = parseFloat(inpRate.value);
+
+ if (!rate || rate <= 0) {
+ toast('Tipo de cambio invalido', 'error');
+ return;
+ }
+
+ if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
+
+ try {
+ var res = await fetch(API + '/currency', {
+ method: 'PUT',
+ headers: headers(),
+ body: JSON.stringify({ currency: currency, exchange_rate: rate })
+ });
+ if (!res.ok) {
+ var err = await res.json().catch(function() { return { error: res.statusText }; });
+ throw new Error(err.error || 'Save failed');
+ }
+ localStorage.setItem('pos_currency', currency);
+ localStorage.setItem('pos_exchange_rate', rate);
+ toast('Moneda actualizada');
+ if (statusEl) statusEl.textContent = currency + ' — TC: ' + rate;
+ } catch (e) {
+ toast(e.message, 'error');
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = 'Guardar Moneda';
+ }
+ }
+ }
+
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -441,6 +503,7 @@ const Config = (() => {
loadBranches();
loadEmployees();
loadBusiness();
+ loadCurrency();
}
document.addEventListener('DOMContentLoaded', init);
@@ -448,6 +511,7 @@ const Config = (() => {
return {
init, setTheme, selectThemeOption,
loadBranches, loadEmployees, saveBranch, saveEmployee,
+ loadCurrency, saveCurrency,
openModal, closeModal
};
})();
diff --git a/pos/static/js/i18n.js b/pos/static/js/i18n.js
new file mode 100644
index 0000000..eec20a9
--- /dev/null
+++ b/pos/static/js/i18n.js
@@ -0,0 +1,342 @@
+/**
+ * i18n.js — Simple internationalization for Nexus Autoparts POS
+ * Supports: es (Spanish/Mexico) and en (English/USA)
+ */
+var I18N = {
+ es: {
+ // Sidebar nav
+ 'dashboard': 'Dashboard',
+ 'pos': 'Punto de Venta',
+ 'catalog': 'Catalogo',
+ 'inventory': 'Inventario',
+ 'customers': 'Clientes',
+ 'invoicing': 'Facturacion',
+ 'accounting': 'Contabilidad',
+ 'reports': 'Reportes',
+ 'fleet': 'Flotillas',
+ 'whatsapp': 'WhatsApp',
+ 'config': 'Configuracion',
+ // Sidebar sections
+ 'nav_main': 'Principal',
+ 'nav_management': 'Gestion',
+ 'nav_system': 'Sistema',
+ // Common actions
+ 'search': 'Buscar',
+ 'save': 'Guardar',
+ 'cancel': 'Cancelar',
+ 'delete': 'Eliminar',
+ 'edit': 'Editar',
+ 'new': 'Nuevo',
+ 'close': 'Cerrar',
+ 'confirm': 'Confirmar',
+ 'back': 'Regresar',
+ 'next': 'Siguiente',
+ 'print': 'Imprimir',
+ 'export': 'Exportar',
+ 'import': 'Importar',
+ 'refresh': 'Actualizar',
+ 'loading': 'Cargando...',
+ 'no_results': 'Sin resultados',
+ 'error': 'Error',
+ 'success': 'Exito',
+ 'warning': 'Advertencia',
+ // Financial
+ 'total': 'Total',
+ 'subtotal': 'Subtotal',
+ 'tax': 'IVA',
+ 'price': 'Precio',
+ 'unit_price': 'Precio Unitario',
+ 'cost': 'Costo',
+ 'discount': 'Descuento',
+ 'margin': 'Margen',
+ 'profit': 'Utilidad',
+ 'balance': 'Saldo',
+ 'amount': 'Monto',
+ // Inventory
+ 'quantity': 'Cantidad',
+ 'stock': 'Existencias',
+ 'min_stock': 'Minimo',
+ 'max_stock': 'Maximo',
+ 'sku': 'SKU',
+ 'barcode': 'Codigo de Barras',
+ 'brand': 'Marca',
+ 'category': 'Categoria',
+ 'description': 'Descripcion',
+ 'location': 'Ubicacion',
+ // Table headers
+ 'name': 'Nombre',
+ 'date': 'Fecha',
+ 'status': 'Estado',
+ 'actions': 'Acciones',
+ 'id': 'ID',
+ 'type': 'Tipo',
+ 'notes': 'Notas',
+ // POS
+ 'charge': 'Cobrar',
+ 'quote': 'Cotizacion',
+ 'layaway': 'Apartado',
+ 'credit': 'Credito',
+ 'cash': 'Efectivo',
+ 'transfer': 'Transferencia',
+ 'card': 'Tarjeta',
+ 'mixed': 'Mixto',
+ 'change': 'Cambio',
+ 'customer': 'Cliente',
+ 'general_public': 'Publico General',
+ 'sale': 'Venta',
+ 'sales': 'Ventas',
+ 'ticket': 'Ticket',
+ 'receipt': 'Recibo',
+ 'payment': 'Pago',
+ 'payment_method': 'Metodo de Pago',
+ 'add_to_cart': 'Agregar',
+ 'clear_cart': 'Limpiar Carrito',
+ 'hold_sale': 'Pausar Venta',
+ 'recall_sale': 'Retomar Venta',
+ 'cancel_sale': 'Cancelar Venta',
+ 'confirm_payment': 'Confirmar Pago',
+ 'cash_received': 'Efectivo Recibido',
+ 'amount_due': 'Total a Pagar',
+ 'remaining': 'Faltante',
+ // Customers
+ 'phone': 'Telefono',
+ 'email': 'Correo',
+ 'address': 'Direccion',
+ 'rfc': 'RFC',
+ 'credit_limit': 'Limite de Credito',
+ 'credit_balance': 'Saldo de Credito',
+ 'price_tier': 'Nivel de Precio',
+ // Invoicing
+ 'invoice': 'Factura',
+ 'cfdi': 'CFDI',
+ 'stamp': 'Timbrar',
+ 'cancel_invoice': 'Cancelar Factura',
+ // Config
+ 'appearance': 'Apariencia',
+ 'business_data': 'Datos de la Empresa',
+ 'employees': 'Empleados',
+ 'printers': 'Impresoras',
+ 'branches': 'Sucursales',
+ 'fiscal_params': 'Parametros Fiscales',
+ 'system_prefs': 'Preferencias del Sistema',
+ 'currency_config': 'Moneda',
+ 'language': 'Idioma',
+ 'theme': 'Tema',
+ 'dark_theme': 'Tema oscuro',
+ 'light_theme': 'Tema claro',
+ 'logout': 'Cerrar sesion',
+ // Currency
+ 'currency': 'Moneda',
+ 'exchange_rate': 'Tipo de Cambio',
+ 'default_currency': 'Moneda Predeterminada',
+ 'mxn': 'Peso Mexicano',
+ 'usd': 'Dolar Estadounidense',
+ // Roles
+ 'role_owner': 'Dueno',
+ 'role_admin': 'Administrador',
+ 'role_cashier': 'Cajero',
+ 'role_warehouse': 'Almacen',
+ 'role_accountant': 'Contador',
+ // Reports
+ 'daily_sales': 'Ventas del Dia',
+ 'weekly_sales': 'Ventas de la Semana',
+ 'monthly_sales': 'Ventas del Mes',
+ 'top_products': 'Productos Mas Vendidos',
+ 'low_stock': 'Bajo Stock',
+ // Fleet
+ 'vehicle': 'Vehiculo',
+ 'plate': 'Placa',
+ 'vin': 'VIN',
+ 'mileage': 'Kilometraje',
+ // Misc
+ 'yes': 'Si',
+ 'no': 'No',
+ 'all': 'Todos',
+ 'active': 'Activo',
+ 'inactive': 'Inactivo',
+ 'pending': 'Pendiente',
+ 'completed': 'Completado',
+ 'cancelled': 'Cancelado',
+ },
+ en: {
+ // Sidebar nav
+ 'dashboard': 'Dashboard',
+ 'pos': 'Point of Sale',
+ 'catalog': 'Catalog',
+ 'inventory': 'Inventory',
+ 'customers': 'Customers',
+ 'invoicing': 'Invoicing',
+ 'accounting': 'Accounting',
+ 'reports': 'Reports',
+ 'fleet': 'Fleet',
+ 'whatsapp': 'WhatsApp',
+ 'config': 'Settings',
+ // Sidebar sections
+ 'nav_main': 'Main',
+ 'nav_management': 'Management',
+ 'nav_system': 'System',
+ // Common actions
+ 'search': 'Search',
+ 'save': 'Save',
+ 'cancel': 'Cancel',
+ 'delete': 'Delete',
+ 'edit': 'Edit',
+ 'new': 'New',
+ 'close': 'Close',
+ 'confirm': 'Confirm',
+ 'back': 'Back',
+ 'next': 'Next',
+ 'print': 'Print',
+ 'export': 'Export',
+ 'import': 'Import',
+ 'refresh': 'Refresh',
+ 'loading': 'Loading...',
+ 'no_results': 'No results',
+ 'error': 'Error',
+ 'success': 'Success',
+ 'warning': 'Warning',
+ // Financial
+ 'total': 'Total',
+ 'subtotal': 'Subtotal',
+ 'tax': 'Tax',
+ 'price': 'Price',
+ 'unit_price': 'Unit Price',
+ 'cost': 'Cost',
+ 'discount': 'Discount',
+ 'margin': 'Margin',
+ 'profit': 'Profit',
+ 'balance': 'Balance',
+ 'amount': 'Amount',
+ // Inventory
+ 'quantity': 'Quantity',
+ 'stock': 'Stock',
+ 'min_stock': 'Minimum',
+ 'max_stock': 'Maximum',
+ 'sku': 'SKU',
+ 'barcode': 'Barcode',
+ 'brand': 'Brand',
+ 'category': 'Category',
+ 'description': 'Description',
+ 'location': 'Location',
+ // Table headers
+ 'name': 'Name',
+ 'date': 'Date',
+ 'status': 'Status',
+ 'actions': 'Actions',
+ 'id': 'ID',
+ 'type': 'Type',
+ 'notes': 'Notes',
+ // POS
+ 'charge': 'Charge',
+ 'quote': 'Quote',
+ 'layaway': 'Layaway',
+ 'credit': 'Credit',
+ 'cash': 'Cash',
+ 'transfer': 'Transfer',
+ 'card': 'Card',
+ 'mixed': 'Mixed',
+ 'change': 'Change',
+ 'customer': 'Customer',
+ 'general_public': 'Walk-in Customer',
+ 'sale': 'Sale',
+ 'sales': 'Sales',
+ 'ticket': 'Ticket',
+ 'receipt': 'Receipt',
+ 'payment': 'Payment',
+ 'payment_method': 'Payment Method',
+ 'add_to_cart': 'Add',
+ 'clear_cart': 'Clear Cart',
+ 'hold_sale': 'Hold Sale',
+ 'recall_sale': 'Recall Sale',
+ 'cancel_sale': 'Cancel Sale',
+ 'confirm_payment': 'Confirm Payment',
+ 'cash_received': 'Cash Received',
+ 'amount_due': 'Amount Due',
+ 'remaining': 'Remaining',
+ // Customers
+ 'phone': 'Phone',
+ 'email': 'Email',
+ 'address': 'Address',
+ 'rfc': 'Tax ID (RFC)',
+ 'credit_limit': 'Credit Limit',
+ 'credit_balance': 'Credit Balance',
+ 'price_tier': 'Price Tier',
+ // Invoicing
+ 'invoice': 'Invoice',
+ 'cfdi': 'CFDI',
+ 'stamp': 'Stamp',
+ 'cancel_invoice': 'Cancel Invoice',
+ // Config
+ 'appearance': 'Appearance',
+ 'business_data': 'Business Info',
+ 'employees': 'Employees',
+ 'printers': 'Printers',
+ 'branches': 'Branches',
+ 'fiscal_params': 'Tax Settings',
+ 'system_prefs': 'System Preferences',
+ 'currency_config': 'Currency',
+ 'language': 'Language',
+ 'theme': 'Theme',
+ 'dark_theme': 'Dark theme',
+ 'light_theme': 'Light theme',
+ 'logout': 'Log out',
+ // Currency
+ 'currency': 'Currency',
+ 'exchange_rate': 'Exchange Rate',
+ 'default_currency': 'Default Currency',
+ 'mxn': 'Mexican Peso',
+ 'usd': 'US Dollar',
+ // Roles
+ 'role_owner': 'Owner',
+ 'role_admin': 'Administrator',
+ 'role_cashier': 'Cashier',
+ 'role_warehouse': 'Warehouse',
+ 'role_accountant': 'Accountant',
+ // Reports
+ 'daily_sales': 'Daily Sales',
+ 'weekly_sales': 'Weekly Sales',
+ 'monthly_sales': 'Monthly Sales',
+ 'top_products': 'Top Products',
+ 'low_stock': 'Low Stock',
+ // Fleet
+ 'vehicle': 'Vehicle',
+ 'plate': 'Plate',
+ 'vin': 'VIN',
+ 'mileage': 'Mileage',
+ // Misc
+ 'yes': 'Yes',
+ 'no': 'No',
+ 'all': 'All',
+ 'active': 'Active',
+ 'inactive': 'Inactive',
+ 'pending': 'Pending',
+ 'completed': 'Completed',
+ 'cancelled': 'Cancelled',
+ }
+};
+
+var currentLang = localStorage.getItem('pos_lang') || 'es';
+
+/**
+ * Translate a key to the current language.
+ * Falls back to Spanish, then to the raw key.
+ */
+window.t = function(key) {
+ return (I18N[currentLang] && I18N[currentLang][key]) || (I18N['es'] && I18N['es'][key]) || key;
+};
+
+/**
+ * Switch the UI language and reload.
+ */
+window.setLang = function(lang) {
+ currentLang = lang;
+ localStorage.setItem('pos_lang', lang);
+ location.reload();
+};
+
+/**
+ * Get the current language code.
+ */
+window.getLang = function() {
+ return currentLang;
+};
diff --git a/pos/static/js/sidebar.js b/pos/static/js/sidebar.js
index 1445d80..d418502 100644
--- a/pos/static/js/sidebar.js
+++ b/pos/static/js/sidebar.js
@@ -1,34 +1,39 @@
/**
* sidebar.js — Shared sidebar matching the design system style
* Replaces existing sidebar in each page with a consistent, themed version.
+ * Uses i18n t() for all labels when available.
*/
(function() {
'use strict';
+ // i18n helper — falls back to raw string if i18n.js not loaded
+ var _t = typeof window.t === 'function' ? window.t : function(k) { return k; };
+
var u = window.POS_USER || {};
var name = u.name || 'Usuario';
var roleLabel = u.roleLabel || '';
var initials = u.initials || '?';
var currentPath = window.location.pathname;
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
+ var currentLang = localStorage.getItem('pos_lang') || 'es';
var navSections = [
- { label: 'Principal', items: [
- { name: 'Dashboard', href: '/pos/dashboard', icon: '