diff --git a/static/src/app/pos_loyalty_safe_coupon_patch.js b/static/src/app/pos_loyalty_safe_coupon_patch.js index eed8858..a84a9bf 100644 --- a/static/src/app/pos_loyalty_safe_coupon_patch.js +++ b/static/src/app/pos_loyalty_safe_coupon_patch.js @@ -1,9 +1,39 @@ +/** @odoo-module **/ + 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"; +/** + * Helper: delete all reward lines in an order where reward_id or reward_id.program_id is missing. + * This prevents TypeError crashes when the core code accesses reward.program_id.xxx + * on lines whose loyalty program was archived, deleted, or not yet loaded into the POS cache. + */ +function purgeOrphanedRewardLines(order) { + if (!order || !order.lines) { + return; + } + const orphans = order.lines.filter( + (line) => + line.is_reward_line && + (!line.reward_id || !line.reward_id.program_id) + ); + for (const line of orphans) { + try { + line.delete(); + } catch (e) { + // Ignore deletion errors — the line may already be gone + } + } +} + +// ─── PosOrder patches ───────────────────────────────────────────────────────── + patch(PosOrder.prototype, { + /** + * getLoyaltyPoints reads coupon_id.id — guard against undefined coupon_id. + */ getLoyaltyPoints() { const undefinedCouponLines = []; for (const line of this._get_reward_lines()) { @@ -20,17 +50,27 @@ patch(PosOrder.prototype, { } } }, + + /** + * _updateRewardLines rebuilds reward lines — purge orphans before core touches them. + */ _updateRewardLines() { - if (this.lines) { - const invalidRewardLines = this.lines.filter((line) => line.is_reward_line && (!line.reward_id || !line.reward_id.program_id)); - for (const line of invalidRewardLines) { - line.delete(); - } - } + purgeOrphanedRewardLines(this); return super._updateRewardLines(...arguments); - } + }, + + /** + * initState fires when an order is initialized (including from server restore). + * Schedule a purge on the next microtask so reactive relations are fully resolved first. + */ + initState() { + super.initState(...arguments); + Promise.resolve().then(() => purgeOrphanedRewardLines(this)); + }, }); +// ─── OrderPaymentValidation patches ─────────────────────────────────────────── + patch(OrderPaymentValidation.prototype, { async validateOrder(isForceValidate) { const undefinedCouponLines = []; @@ -47,11 +87,67 @@ patch(OrderPaymentValidation.prototype, { line.coupon_id = undefined; } } - } + }, }); +// ─── PosStore patches ───────────────────────────────────────────────────────── + patch(PosStore.prototype, { + /** + * afterProcessServerData — earliest global hook after all records are loaded. + * Purge orphaned reward lines from ALL open orders. + */ + async afterProcessServerData() { + for (const order of this.models["pos.order"].getAll()) { + purgeOrphanedRewardLines(order); + } + return await super.afterProcessServerData(...arguments); + }, + + /** + * updateRewards — called on every order-line change. + * Purge orphaned lines from the current order before loyalty recalculation. + */ + updateRewards() { + const order = this.getOrder(); + if (order) { + purgeOrphanedRewardLines(order); + } + return super.updateRewards(...arguments); + }, + + /** + * orderUpdateLoyaltyPrograms — called when switching tables or rescanning codes. + * This is the path triggered by "Check Table" → `setTableFromUi` → `updateOrder`. + */ + async orderUpdateLoyaltyPrograms() { + const order = this.getOrder(); + if (order) { + purgeOrphanedRewardLines(order); + } + return await super.orderUpdateLoyaltyPrograms(...arguments); + }, + + /** + * setTableFromUi — the exact entry point for the "Check Table → blank screen" crash. + * Purge ALL orders before switching so the target table's order is clean. + */ + async setTableFromUi(table, orderUuid = null) { + for (const order of this.models["pos.order"].getAll()) { + purgeOrphanedRewardLines(order); + } + return await super.setTableFromUi(...arguments); + }, + + /** + * postProcessLoyalty — called when pushing/validating an order. + * Guards both reward.program_id === undefined AND coupon_id === undefined. + */ async postProcessLoyalty(order) { + // Remove lines with missing program_id (core reads reward.program_id.id) + purgeOrphanedRewardLines(order); + + // Also guard coupon_id=undefined (original safe_coupon behavior) const undefinedCouponLines = []; for (const line of order._get_reward_lines()) { if (!line.coupon_id) { @@ -59,8 +155,8 @@ patch(PosStore.prototype, { undefinedCouponLines.push(line); } } - - // Intercept data.call to strip out the dummy coupon data with key "0" + + // Strip dummy coupon key "0" from confirm_coupon_programs calls const originalCall = this.data.call; this.data.call = function (model, method, args) { if (model === "pos.order" && method === "confirm_coupon_programs") { @@ -71,7 +167,7 @@ patch(PosStore.prototype, { } return originalCall.apply(this, arguments); }; - + try { return await super.postProcessLoyalty(...arguments); } finally { @@ -81,14 +177,4 @@ patch(PosStore.prototype, { } } }, - async orderUpdateLoyaltyPrograms() { - const order = this.getOrder(); - if (order && order.lines) { - const invalidRewardLines = order.lines.filter((line) => line.is_reward_line && (!line.reward_id || !line.reward_id.program_id)); - for (const line of invalidRewardLines) { - line.delete(); - } - } - return await super.orderUpdateLoyaltyPrograms(...arguments); - } });