diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py
index 4674f23..72f28ff 100644
--- a/pos/blueprints/config_bp.py
+++ b/pos/blueprints/config_bp.py
@@ -752,3 +752,99 @@ def set_onboarding_status():
cur.close()
conn.close()
return jsonify({'completed': completed == 'true'})
+
+
+# ─── Receipt / Ticket Customization ────────────────────────────────────────
+
+RECEIPT_CONFIG_KEYS = [
+ 'receipt_logo',
+ 'receipt_store_name',
+ 'receipt_tagline',
+ 'receipt_rfc',
+ 'receipt_address',
+ 'receipt_phone',
+ 'receipt_footer',
+ 'receipt_thanks_message',
+ 'receipt_show_logo',
+ 'receipt_show_rfc',
+ 'receipt_show_address',
+ 'receipt_show_phone',
+ 'receipt_show_iva_breakdown',
+ 'receipt_show_payment_details',
+ 'receipt_show_employee',
+]
+
+
+@config_bp.route('/receipt', methods=['GET'])
+@require_auth('pos.view')
+def get_receipt_config():
+ """Get receipt customization settings."""
+ conn = get_tenant_conn(g.tenant_id)
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
+ (RECEIPT_CONFIG_KEYS,)
+ )
+ rows = {row[0]: row[1] for row in cur.fetchall()}
+ cur.close()
+ conn.close()
+
+ def _bool(key, default=False):
+ v = rows.get(key, 'true' if default else 'false')
+ return str(v).lower() == 'true'
+
+ return jsonify({
+ 'logo': rows.get('receipt_logo', ''),
+ 'store_name': rows.get('receipt_store_name', ''),
+ 'tagline': rows.get('receipt_tagline', ''),
+ 'rfc': rows.get('receipt_rfc', ''),
+ 'address': rows.get('receipt_address', ''),
+ 'phone': rows.get('receipt_phone', ''),
+ 'footer': rows.get('receipt_footer', ''),
+ 'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
+ 'show_logo': _bool('receipt_show_logo', True),
+ 'show_rfc': _bool('receipt_show_rfc', True),
+ 'show_address': _bool('receipt_show_address', False),
+ 'show_phone': _bool('receipt_show_phone', False),
+ 'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
+ 'show_payment_details': _bool('receipt_show_payment_details', True),
+ 'show_employee': _bool('receipt_show_employee', False),
+ })
+
+
+@config_bp.route('/receipt', methods=['PUT'])
+@require_auth('config.edit')
+def update_receipt_config():
+ """Update receipt customization settings."""
+ data = request.get_json() or {}
+ conn = get_tenant_conn(g.tenant_id)
+ cur = conn.cursor()
+
+ settings = {
+ 'receipt_logo': data.get('logo', ''),
+ 'receipt_store_name': data.get('store_name', ''),
+ 'receipt_tagline': data.get('tagline', ''),
+ 'receipt_rfc': data.get('rfc', ''),
+ 'receipt_address': data.get('address', ''),
+ 'receipt_phone': data.get('phone', ''),
+ 'receipt_footer': data.get('footer', ''),
+ 'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
+ 'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
+ 'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
+ 'receipt_show_address': 'true' if data.get('show_address') else 'false',
+ 'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
+ 'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
+ 'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
+ 'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
+ }
+
+ for key, value in settings.items():
+ cur.execute("""
+ INSERT INTO tenant_config (key, value) VALUES (%s, %s)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
+ """, (key, value))
+
+ conn.commit()
+ cur.close()
+ conn.close()
+ return jsonify({'message': 'Receipt configuration updated'})
diff --git a/pos/static/js/config.js b/pos/static/js/config.js
index af95ea6..86a82ab 100644
--- a/pos/static/js/config.js
+++ b/pos/static/js/config.js
@@ -443,6 +443,133 @@ const Config = (() => {
}
}
+ // -------------------------------------------------------------------------
+ // Receipt / ticket customization
+ // -------------------------------------------------------------------------
+ let _receiptLogo = '';
+
+ async function loadReceiptConfig() {
+ try {
+ var res = await fetch(API + '/receipt', { headers: headers() });
+ if (!res.ok) return;
+ var d = await res.json();
+ _receiptLogo = d.logo || '';
+ setVal('receipt-store-name', d.store_name);
+ setVal('receipt-tagline', d.tagline);
+ setVal('receipt-rfc', d.rfc);
+ setVal('receipt-address', d.address);
+ setVal('receipt-phone', d.phone);
+ setVal('receipt-thanks', d.thanks_message);
+ setVal('receipt-footer', d.footer);
+ setChecked('receipt-show-logo', d.show_logo);
+ setChecked('receipt-show-rfc', d.show_rfc);
+ setChecked('receipt-show-address', d.show_address);
+ setChecked('receipt-show-phone', d.show_phone);
+ setChecked('receipt-show-iva', d.show_iva_breakdown);
+ setChecked('receipt-show-payment', d.show_payment_details);
+ setChecked('receipt-show-employee', d.show_employee);
+ renderReceiptLogoThumb();
+ } catch (e) {
+ console.error('Config.loadReceiptConfig:', e);
+ }
+ }
+
+ function setChecked(id, v) {
+ var el = document.getElementById(id);
+ if (el) el.checked = !!v;
+ }
+
+ function getChecked(id) {
+ var el = document.getElementById(id);
+ return el ? el.checked : false;
+ }
+
+ function renderReceiptLogoThumb() {
+ var thumb = document.getElementById('receipt-logo-thumb');
+ var removeBtn = document.getElementById('receipt-logo-remove');
+ if (!thumb) return;
+ if (_receiptLogo) {
+ thumb.innerHTML = '';
+ if (removeBtn) removeBtn.style.display = '';
+ } else {
+ thumb.innerHTML = 'Sin logo';
+ if (removeBtn) removeBtn.style.display = 'none';
+ }
+ }
+
+ function handleReceiptLogo(input) {
+ var file = input && input.files ? input.files[0] : null;
+ if (!file) return;
+ if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) {
+ toast('Solo se permiten imágenes PNG, JPG o WebP', 'error');
+ input.value = '';
+ return;
+ }
+ var reader = new FileReader();
+ reader.onload = function(e) {
+ var img = new Image();
+ img.onload = function() {
+ var maxWidth = 300;
+ var scale = Math.min(1, maxWidth / img.width);
+ var w = Math.round(img.width * scale);
+ var h = Math.round(img.height * scale);
+ var canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ var ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, w, h);
+ ctx.drawImage(img, 0, 0, w, h);
+ _receiptLogo = canvas.toDataURL('image/jpeg', 0.85);
+ renderReceiptLogoThumb();
+ toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok');
+ };
+ img.src = e.target.result;
+ };
+ reader.readAsDataURL(file);
+ input.value = '';
+ }
+
+ function removeReceiptLogo() {
+ _receiptLogo = '';
+ renderReceiptLogoThumb();
+ }
+
+ async function saveReceiptConfig() {
+ if (!checkAuth()) return;
+ var data = {
+ logo: _receiptLogo,
+ store_name: getVal('receipt-store-name'),
+ tagline: getVal('receipt-tagline'),
+ rfc: getVal('receipt-rfc'),
+ address: getVal('receipt-address'),
+ phone: getVal('receipt-phone'),
+ thanks_message: getVal('receipt-thanks'),
+ footer: getVal('receipt-footer'),
+ show_logo: getChecked('receipt-show-logo'),
+ show_rfc: getChecked('receipt-show-rfc'),
+ show_address: getChecked('receipt-show-address'),
+ show_phone: getChecked('receipt-show-phone'),
+ show_iva_breakdown: getChecked('receipt-show-iva'),
+ show_payment_details: getChecked('receipt-show-payment'),
+ show_employee: getChecked('receipt-show-employee'),
+ };
+ try {
+ var res = await fetch(API + '/receipt', {
+ method: 'PUT',
+ headers: headers(),
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ var err = await res.json().catch(function() { return { error: res.statusText }; });
+ throw new Error(err.error || 'Error al guardar');
+ }
+ toast('Configuración de ticket guardada', 'ok');
+ } catch (e) {
+ toast(e.message, 'error');
+ }
+ }
+
async function saveAll() {
if (!checkAuth()) return;
var btn = document.getElementById('btn-save-all');
@@ -454,6 +581,7 @@ const Config = (() => {
await saveVehicleCompatSource();
await saveAllowedBrands();
await saveModules();
+ await saveReceiptConfig();
toast('Configuración guardada', 'ok');
} catch (e) {
toast(e.message, 'error');
@@ -881,6 +1009,7 @@ const Config = (() => {
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
+ loadReceiptConfig();
}
document.addEventListener('DOMContentLoaded', init);
@@ -900,6 +1029,7 @@ const Config = (() => {
loadCurrency, saveCurrency,
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
+ loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch
};
diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js
index bcdde1e..f3607d4 100644
--- a/pos/static/js/pos.js
+++ b/pos/static/js/pos.js
@@ -30,6 +30,7 @@ const POS = (() => {
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
+ let receiptConfig = {};
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -134,8 +135,9 @@ const POS = (() => {
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
}
- // Load current register
+ // Load current register and receipt config
await loadRegister();
+ await loadReceiptConfig();
// Setup event listeners
setupKeyboard();
@@ -163,6 +165,20 @@ const POS = (() => {
}
}
+ async function loadReceiptConfig() {
+ try {
+ receiptConfig = await api('/pos/api/config/receipt');
+ } catch (e) {
+ console.warn('Could not load receipt config:', e);
+ receiptConfig = {};
+ }
+ }
+
+ function rc(key, fallback) {
+ const v = receiptConfig[key];
+ return v !== undefined && v !== null && v !== '' ? v : fallback;
+ }
+
function showOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.add('open');
document.getElementById('registerOpenResult').innerHTML = '';
@@ -1283,12 +1299,37 @@ const POS = (() => {
`;
});
+ const cfg = receiptConfig || {};
+ const showLogo = cfg.show_logo && cfg.logo;
+ const showRfc = cfg.show_rfc !== false;
+ const showAddress = cfg.show_address;
+ const showPhone = cfg.show_phone;
+ const showIva = cfg.show_iva_breakdown !== false;
+ const showPayment = cfg.show_payment_details !== false;
+ const showEmployee = cfg.show_employee;
+ const storeName = rc('store_name', 'NEXUS AUTOPARTS');
+ const storeTagline = rc('tagline', 'Tu conexion con las refacciones');
+ const storeRfc = rc('rfc', 'NAU210315XX1');
+ const storeAddress = rc('address', '');
+ const storePhone = rc('phone', '');
+ const thanksMsg = rc('thanks_message', 'Gracias por su compra!');
+ const footerMsg = rc('footer', 'Conserve su ticket como comprobante.');
+
+ let storeInfoLines = [];
+ if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`);
+ if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`);
+ if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`);
+ if (showAddress && storeAddress) storeInfoLines.push(storeAddress);
+ if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`);
+
+ const logoHtml = showLogo ? `