commit e567862552f2bfc2c1f269de9699ceaa60462bdb Author: Suherdy Yacob Date: Thu May 21 11:08:20 2026 +0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e982156 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# POS Loyalty Safe Coupon Patch + +This custom Odoo module resolves a core Odoo 19 bug in the POS Loyalty module where standard Odoo code makes unsafe direct reads on `coupon_id.id` of reward lines. + +When an order is restored (e.g., during table switching in a restaurant) and references a coupon that is not yet fully loaded in the local client registry, Odoo leaves `coupon_id` as `undefined`. Accessing `.id` on this undefined object crashes the POS UI. + +## How it works +Without modifying any core Odoo code, this module dynamically patches: +1. `PosOrder.getLoyaltyPoints()` +2. `OrderPaymentValidation.validateOrder()` +3. `PosStore.postProcessLoyalty()` + +During their respective execution, it temporarily mocks any missing `coupon_id` references with `{ id: 0 }`. It also wraps `this.data.call` during `postProcessLoyalty` to ensure dummy coupon records with ID `0` are filtered out before being communicated back to the Odoo backend server. After execution, the original `undefined` state of the coupon is safely restored. + +This ensures zero side effects, maximum stability, and clean compatibility with standard/custom addons. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..67dee8c --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..e58439f --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'POS Loyalty Safe Coupon Patch', + 'version': '1.0', + 'category': 'Point of Sale', + 'summary': 'Fixes TypeError on undefined coupon_id in Odoo POS Loyalty', + 'description': """ +POS Loyalty Safe Coupon Patch +============================= +This module overrides POS models and helper methods inside the POS loyalty app to safely handle undefined coupon_id structures, preventing POS interface crashes (e.g. when changing tables or updating loyalty). +""", + 'author': 'Antigravity', + 'depends': ['pos_loyalty'], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_loyalty_safe_coupon/static/src/app/pos_loyalty_safe_coupon_patch.js', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/static/src/app/pos_loyalty_safe_coupon_patch.js b/static/src/app/pos_loyalty_safe_coupon_patch.js new file mode 100644 index 0000000..fc28fee --- /dev/null +++ b/static/src/app/pos_loyalty_safe_coupon_patch.js @@ -0,0 +1,75 @@ +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation"; +import { PosStore } from "@point_of_sale/app/services/pos_store"; +import { patch } from "@web/core/utils/patch"; + +patch(PosOrder.prototype, { + getLoyaltyPoints() { + const undefinedCouponLines = []; + for (const line of this._get_reward_lines()) { + if (!line.coupon_id) { + line.coupon_id = { id: 0 }; + undefinedCouponLines.push(line); + } + } + try { + return super.getLoyaltyPoints(...arguments); + } finally { + for (const line of undefinedCouponLines) { + line.coupon_id = undefined; + } + } + } +}); + +patch(OrderPaymentValidation.prototype, { + async validateOrder(isForceValidate) { + const undefinedCouponLines = []; + for (const line of this.order._get_reward_lines()) { + if (!line.coupon_id) { + line.coupon_id = { id: 0 }; + undefinedCouponLines.push(line); + } + } + try { + return await super.validateOrder(...arguments); + } finally { + for (const line of undefinedCouponLines) { + line.coupon_id = undefined; + } + } + } +}); + +patch(PosStore.prototype, { + async postProcessLoyalty(order) { + const undefinedCouponLines = []; + for (const line of order._get_reward_lines()) { + if (!line.coupon_id) { + line.coupon_id = { id: 0 }; + undefinedCouponLines.push(line); + } + } + + // Intercept data.call to strip out the dummy coupon data with key "0" + const originalCall = this.data.call; + this.data.call = function (model, method, args) { + if (model === "pos.order" && method === "confirm_coupon_programs") { + const couponData = args[1]; + if (couponData && "0" in couponData) { + delete couponData["0"]; + } + } + return originalCall.apply(this, arguments); + }; + + try { + return await super.postProcessLoyalty(...arguments); + } finally { + this.data.call = originalCall; + for (const line of undefinedCouponLines) { + line.coupon_id = undefined; + } + } + } +});