From 85692e2fec6806c0309b8f2991a0beb936ea09be Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Sun, 7 Jun 2026 21:53:51 +0700 Subject: [PATCH] feat: implement centralized cash drawer security via HardwareProxy patching to support Bluetooth printer triggers and restrict unauthorized drawer openings. --- __manifest__.py | 1 + static/src/js/pos_hardware_proxy_patch.js | 125 ++++++++++++++++++++++ static/src/js/pos_receipt_printer.js | 61 ++--------- 3 files changed, 133 insertions(+), 54 deletions(-) create mode 100644 static/src/js/pos_hardware_proxy_patch.js diff --git a/__manifest__.py b/__manifest__.py index eb92644..c7643b9 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -64,6 +64,7 @@ Requirements: # POS integrations (load after components) 'pos_bluetooth_thermal_printer/static/src/js/pos_session_integration.js', 'pos_bluetooth_thermal_printer/static/src/js/pos_receipt_printer.js', + 'pos_bluetooth_thermal_printer/static/src/js/pos_hardware_proxy_patch.js', 'pos_bluetooth_thermal_printer/static/src/js/pos_navbar_extension.js', # Templates diff --git a/static/src/js/pos_hardware_proxy_patch.js b/static/src/js/pos_hardware_proxy_patch.js new file mode 100644 index 0000000..2206b61 --- /dev/null +++ b/static/src/js/pos_hardware_proxy_patch.js @@ -0,0 +1,125 @@ +/** @odoo-module */ + +import { HardwareProxy } from "@point_of_sale/app/services/hardware_proxy_service"; +import { PosStore } from "@point_of_sale/app/services/pos_store"; +import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation"; +import { patch } from "@web/core/utils/patch"; +import { getBluetoothPrintingServices } from "./pos_receipt_printer"; + +// Patch OrderPaymentValidation to track validation context +patch(OrderPaymentValidation.prototype, { + async finalizeValidation() { + if (this.pos.hardwareProxy) { + this.pos.hardwareProxy.currentValidationContext = { + isValidating: true, + order: this.order + }; + } + try { + return await super.finalizeValidation(...arguments); + } finally { + if (this.pos.hardwareProxy) { + this.pos.hardwareProxy.currentValidationContext = null; + } + } + } +}); + +// Patch PosStore to track print context +patch(PosStore.prototype, { + async printReceipt(options = {}) { + const printType = options.basic ? 'CHECKER' : (options.printBillActionTriggered ? 'BILL' : 'PAYMENT'); + const order = options.order || this.getOrder(); + + if (this.hardwareProxy) { + this.hardwareProxy.currentPrintContext = { + printType, + order + }; + } + + try { + return await super.printReceipt(...arguments); + } finally { + if (this.hardwareProxy) { + this.hardwareProxy.currentPrintContext = null; + } + } + } +}); + +// Patch HardwareProxy to restrict cashbox opening AND trigger Bluetooth printer's cashbox +patch(HardwareProxy.prototype, { + async openCashbox(action = false) { + // === CASH DRAWER SECURITY GATE === + // + // The physical cash drawer should only open for: + // (A) Manual actions: Cash In/Out, Opening/Closing control, etc. + // → identified by the `action` argument being truthy. + // (B) Payment screen manual "Open Cashbox" button (no action arg, no context). + // → passed through as a legitimate manual trigger. + // (C) Order finalisation when the order is paid with cash. + // → identified by currentValidationContext being active. + // + // Blocked when a print context is active and the print is not a cash payment: + // → CHECKER (table checker / basic receipt) + // → BILL (proforma invoice / temporary receipt) + // → PAYMENT for non-cash methods + + let allowOpen = false; + + // (A) Manual action always allowed (Cash In/Out, Opening/Closing control) + if (action) { + console.log(`[CashDrawer] Manual action "${action}" — allowing.`); + allowOpen = true; + } + // (C) Order validation in progress — only allow if paid with cash + else if (this.currentValidationContext) { + const order = this.currentValidationContext.order; + if (order && order.isPaidWithCash()) { + console.log("[CashDrawer] Allowing — validation context: order is paid with cash."); + allowOpen = true; + } else { + console.log("[CashDrawer] Blocking — validation context: order is not paid with cash."); + } + } + // Print context active → block unless it is a cash payment print + else if (this.currentPrintContext) { + const { printType, order } = this.currentPrintContext; + if (printType === 'PAYMENT' && order && order.isPaidWithCash()) { + console.log("[CashDrawer] Allowing — print context: cash payment receipt."); + allowOpen = true; + } else { + console.log(`[CashDrawer] Blocking — print context: printType=${printType}, cash=${order?.isPaidWithCash()}`); + } + } + // (B) No context at all — manual button (e.g. payment screen Open Cashbox button) + else { + console.log("[CashDrawer] No context — allowing as manual trigger."); + allowOpen = true; + } + + if (allowOpen) { + // Trigger Bluetooth printer's cashbox if enabled and connected + const pos = this.pos || this.env?.services?.pos; + if (pos && pos.config?.bluetooth_printer_enabled) { + try { + const services = getBluetoothPrintingServices(this.env?.services?.notification); + const { bluetoothManager } = services; + if (bluetoothManager && bluetoothManager.getConnectionStatus() === 'connected') { + console.log('[BluetoothPrint] Sending cash drawer pulse command via Bluetooth.'); + const pulseCmds = new Uint8Array([ + 0x1B, 0x3D, 0x01, // ESC = 1 (Select printer) + 0x1B, 0x70, 0x00, 0x19, 0x19, // ESC p 0 25 25 (pulse pin 2) + 0x1B, 0x70, 0x01, 0x19, 0x19 // ESC p 1 25 25 (pulse pin 5) + ]); + await bluetoothManager.sendData(pulseCmds, false); + } + } catch (error) { + console.error('[BluetoothPrint] Failed to send cash drawer pulse via Bluetooth:', error); + } + } + return super.openCashbox(...arguments); + } + } +}); diff --git a/static/src/js/pos_receipt_printer.js b/static/src/js/pos_receipt_printer.js index 45aa887..324e1a1 100755 --- a/static/src/js/pos_receipt_printer.js +++ b/static/src/js/pos_receipt_printer.js @@ -108,38 +108,16 @@ patch(PosPrinterService.prototype, { console.log('[BluetoothPrint] Element classes:', el?.className); // Determine print context - // Priority: hardware proxy context (set by pos_cash_drawer_cash_only) > local flags > HTML heuristics const pos = this.env?.services?.pos; const currentPrintContext = pos?.hardwareProxy?.currentPrintContext; const order = this._currentPrintOrder || currentPrintContext?.order || pos?.getOrder(); let printType = currentPrintContext?.printType; if (!printType) { - // Fallback heuristics when hardware proxy context is not yet set - // (can happen if bluetooth module loads before cash_drawer module, or context race) if (this._currentPrintBasic) { - // basic_receipt flag set in print() props → table checker printType = 'CHECKER'; } else if (el && el.querySelector && el.querySelector('.new-changes, .preset-name, .o-employee-name, .order-ref-prefix')) { - // HTML contains table-checker specific elements printType = 'CHECKER'; - } else if (el && el.querySelector && el.querySelector('.proforma-invoice, .bill-receipt, .pre-payment')) { - // HTML contains proforma/bill specific elements - printType = 'BILL'; - } else { - // Cannot determine from HTML — check order state. - // An order with state 'open' (not yet paid) printed is a BILL/proforma. - // An order with state 'paid' is a PAYMENT receipt. - const orderState = order?.state; - if (orderState && orderState !== 'paid' && orderState !== 'invoiced') { - // Order not yet paid — this must be a bill/proforma print - printType = 'BILL'; - } else if (orderState === 'paid' || orderState === 'invoiced') { - printType = 'PAYMENT'; - } else { - // Unknown state — fail safe: treat as BILL (no drawer) - printType = 'BILL'; - } } } @@ -149,10 +127,7 @@ patch(PosPrinterService.prototype, { if (printType === 'CHECKER') { this._currentPrintBasic = true; } - - // Cash drawer should only open for actual cash payment receipts - this._isCashPayment = printType === 'PAYMENT' && order && (typeof order.isPaidWithCash === 'function' ? order.isPaidWithCash() : false); - console.log(`[BluetoothPrint] printHtml context - printType: ${printType}, orderState: ${order?.state}, isCashPayment: ${this._isCashPayment}`); + console.log(`[BluetoothPrint] printHtml context - printType: ${printType}, orderState: ${order?.state}`); // Check if a Bluetooth printer is configured (from localStorage) const storage = new BluetoothPrinterStorage(); @@ -275,7 +250,6 @@ patch(PosPrinterService.prototype, { } finally { this._currentPrintOrder = null; this._currentPrintBasic = false; - this._isCashPayment = false; } }, @@ -437,10 +411,10 @@ patch(PosPrinterService.prototype, { * @param {boolean} isCashPayment - True if context is cash payment print * @returns {Uint8Array} */ - _applyCashDrawerSecurityGate(escposData, isCashPayment) { + _applyCashDrawerSecurityGate(escposData) { if (!escposData) return escposData; - // 1. Strip any existing cash drawer pulse commands (0x1B 0x70) to prevent unauthorized triggers + // Strip any existing cash drawer pulse commands (0x1B 0x70) to prevent unauthorized triggers const initialLen = escposData.length; const cleanData = EscPosGenerator.stripCashDrawerCommands(escposData); const strippedCount = Math.floor((initialLen - cleanData.length) / 5); @@ -448,28 +422,7 @@ patch(PosPrinterService.prototype, { console.log(`[BluetoothPrint] Stripped ${strippedCount} ESC p command(s) from print stream.`); } - let finalData = cleanData; - - // 2. If it is a cash payment print context, inject the cash drawer pulse command - if (isCashPayment) { - console.log('[BluetoothPrint] Cash payment context: Appending cash drawer pulse commands to ESC/POS stream.'); - // Standard ESC/POS cashbox open commands (pin 2 and pin 5) - const pulseCmds = new Uint8Array([ - 0x1B, 0x3D, 0x01, // ESC = 1 (Select printer) - 0x1B, 0x70, 0x00, 0x19, 0x19, // ESC p 0 25 25 (pulse pin 2) - 0x1B, 0x70, 0x01, 0x19, 0x19 // ESC p 1 25 25 (pulse pin 5) - ]); - - // Combine pulse commands with the clean receipt data - const combined = new Uint8Array(pulseCmds.length + finalData.length); - combined.set(pulseCmds, 0); - combined.set(finalData, pulseCmds.length); - finalData = combined; - } else { - console.log('[BluetoothPrint] Non-cash print context: Cash drawer pulse blocked.'); - } - - return finalData; + return cleanData; }, /** @@ -529,7 +482,7 @@ patch(PosPrinterService.prototype, { console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of graphics data'); // Apply cash drawer security gate - escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); + escposData = this._applyCashDrawerSecurityGate(escposData); // Send data to printer console.log('[BluetoothPrint] Sending graphics to printer...'); @@ -596,7 +549,7 @@ patch(PosPrinterService.prototype, { console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of text data'); // Apply cash drawer security gate - escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); + escposData = this._applyCashDrawerSecurityGate(escposData); // Send data to printer console.log('[BluetoothPrint] Sending text to printer...'); @@ -639,7 +592,7 @@ patch(PosPrinterService.prototype, { console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of ESC/POS data'); // Apply cash drawer security gate - escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); + escposData = this._applyCashDrawerSecurityGate(escposData); // Send data to printer console.log('[BluetoothPrint] Sending to printer...');