feat: implement centralized cash drawer security via HardwareProxy patching to support Bluetooth printer triggers and restrict unauthorized drawer openings.

This commit is contained in:
Suherdy Yacob 2026-06-07 21:53:51 +07:00
parent 5f92333cdf
commit 85692e2fec
3 changed files with 133 additions and 54 deletions

View File

@ -64,6 +64,7 @@ Requirements:
# POS integrations (load after components) # POS integrations (load after components)
'pos_bluetooth_thermal_printer/static/src/js/pos_session_integration.js', '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_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', 'pos_bluetooth_thermal_printer/static/src/js/pos_navbar_extension.js',
# Templates # Templates

View File

@ -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);
}
}
});

View File

@ -108,38 +108,16 @@ patch(PosPrinterService.prototype, {
console.log('[BluetoothPrint] Element classes:', el?.className); console.log('[BluetoothPrint] Element classes:', el?.className);
// Determine print context // 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 pos = this.env?.services?.pos;
const currentPrintContext = pos?.hardwareProxy?.currentPrintContext; const currentPrintContext = pos?.hardwareProxy?.currentPrintContext;
const order = this._currentPrintOrder || currentPrintContext?.order || pos?.getOrder(); const order = this._currentPrintOrder || currentPrintContext?.order || pos?.getOrder();
let printType = currentPrintContext?.printType; let printType = currentPrintContext?.printType;
if (!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) { if (this._currentPrintBasic) {
// basic_receipt flag set in print() props → table checker
printType = 'CHECKER'; printType = 'CHECKER';
} else if (el && el.querySelector && el.querySelector('.new-changes, .preset-name, .o-employee-name, .order-ref-prefix')) { } 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'; 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') { if (printType === 'CHECKER') {
this._currentPrintBasic = true; this._currentPrintBasic = true;
} }
console.log(`[BluetoothPrint] printHtml context - printType: ${printType}, orderState: ${order?.state}`);
// 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}`);
// Check if a Bluetooth printer is configured (from localStorage) // Check if a Bluetooth printer is configured (from localStorage)
const storage = new BluetoothPrinterStorage(); const storage = new BluetoothPrinterStorage();
@ -275,7 +250,6 @@ patch(PosPrinterService.prototype, {
} finally { } finally {
this._currentPrintOrder = null; this._currentPrintOrder = null;
this._currentPrintBasic = false; this._currentPrintBasic = false;
this._isCashPayment = false;
} }
}, },
@ -437,10 +411,10 @@ patch(PosPrinterService.prototype, {
* @param {boolean} isCashPayment - True if context is cash payment print * @param {boolean} isCashPayment - True if context is cash payment print
* @returns {Uint8Array} * @returns {Uint8Array}
*/ */
_applyCashDrawerSecurityGate(escposData, isCashPayment) { _applyCashDrawerSecurityGate(escposData) {
if (!escposData) return 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 initialLen = escposData.length;
const cleanData = EscPosGenerator.stripCashDrawerCommands(escposData); const cleanData = EscPosGenerator.stripCashDrawerCommands(escposData);
const strippedCount = Math.floor((initialLen - cleanData.length) / 5); 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.`); console.log(`[BluetoothPrint] Stripped ${strippedCount} ESC p command(s) from print stream.`);
} }
let finalData = cleanData; return 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;
}, },
/** /**
@ -529,7 +482,7 @@ patch(PosPrinterService.prototype, {
console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of graphics data'); console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of graphics data');
// Apply cash drawer security gate // Apply cash drawer security gate
escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); escposData = this._applyCashDrawerSecurityGate(escposData);
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending graphics 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'); console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of text data');
// Apply cash drawer security gate // Apply cash drawer security gate
escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); escposData = this._applyCashDrawerSecurityGate(escposData);
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending text 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'); console.log('[BluetoothPrint] Generated', escposData.length, 'bytes of ESC/POS data');
// Apply cash drawer security gate // Apply cash drawer security gate
escposData = this._applyCashDrawerSecurityGate(escposData, this._isCashPayment); escposData = this._applyCashDrawerSecurityGate(escposData);
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending to printer...'); console.log('[BluetoothPrint] Sending to printer...');