diff --git a/__manifest__.py b/__manifest__.py index ff9f367..e771443 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,6 +1,6 @@ { "name": "POS Loyalty Discount Before Tax", - "version": "1.6", + "version": "19.0.1.0.0", "category": "Point of Sale", "summary": "Modify loyalty reward discount calculation to apply before tax in POS", "author": "Suherdy Yacob", diff --git a/pos_loyalty_discount_before_tax b/pos_loyalty_discount_before_tax new file mode 160000 index 0000000..d975966 --- /dev/null +++ b/pos_loyalty_discount_before_tax @@ -0,0 +1 @@ +Subproject commit d975966b70f550bb2de8dbf35abb0e5106a56371 diff --git a/static/src/overrides/models/loyalty.js b/static/src/overrides/models/loyalty.js index b35f47f..a7ed3dc 100755 --- a/static/src/overrides/models/loyalty.js +++ b/static/src/overrides/models/loyalty.js @@ -1,71 +1,59 @@ /** @odoo-module **/ -import { Order } from "@point_of_sale/app/store/models"; +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { _t } from "@web/core/l10n/translation"; +import { formatCurrency } from "@web/core/currency"; import { patch } from "@web/core/utils/patch"; -import { roundPrecision } from "@web/core/utils/numbers"; -// Patch Order methods to handle all loyalty discounts with discount_before_tax -patch(Order.prototype, { +// Patch PosOrder methods to handle all loyalty discounts with discount_before_tax +patch(PosOrder.prototype, { /** * Override to calculate discountable amount without tax for all reward types */ _getDiscountableOnOrder(reward) { - // For all rewards, we calculate discounts without tax let discountable = 0; const discountablePerTax = {}; - const discountableWithTaxPerTax = {}; - const formattedLines = []; - const orderLines = this.get_orderlines(); - for (const line of orderLines) { - if (!line.get_quantity()) { + for (const line of this.getOrderlines()) { + if (!line.getQuantity()) { continue; } - // Skip reward lines to avoid circular discounts (unless specifically allowed) + // Skip reward lines to avoid circular discounts if (line.reward_id || line.is_reward_line) { continue; } - // Use price without tax for discount calculation - const line_total_without_tax = line.get_price_without_tax(); - const taxKey = ['ewallet', 'gift_card'].includes(reward.program_id.program_type) - ? line.get_taxes().map((t) => t.id) - : line.get_taxes().filter((t) => t.amount_type !== 'fixed').map((t) => t.id); + ? line.tax_ids.map((t) => t.id) + : line.tax_ids.filter((t) => t.amount_type !== 'fixed').map((t) => t.id); - discountable += line_total_without_tax; + // Use priceExcl instead of total_included (price_without_tax) + discountable += line.priceExcl; + if (!discountablePerTax[taxKey]) { discountablePerTax[taxKey] = 0; - discountableWithTaxPerTax[taxKey] = 0; } - discountablePerTax[taxKey] += line_total_without_tax; - discountableWithTaxPerTax[taxKey] += line.get_price_with_tax(); - formattedLines.push(line); + // basePrice is effectively quantity * unit_price * (1 - discount), before tax is handled + discountablePerTax[taxKey] += line.basePrice; } - return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines }; + return { discountable, discountablePerTax }; }, /** * Override to calculate cheapest line discountable without tax for all reward types */ _getDiscountableOnCheapest(reward) { - // For all rewards, we calculate discounts without tax - const cheapestLine = this._getCheapestLine(); + const cheapestLine = this._getCheapestLine(reward); if (!cheapestLine) { return { discountable: 0, discountablePerTax: {} }; } - // Use price without tax for discount calculation - const discountableWithoutTax = cheapestLine.get_price_without_tax(); - const discountableWithTax = cheapestLine.get_price_with_tax(); - const taxKey = cheapestLine.get_taxes().map((t) => t.id); + const taxKey = cheapestLine.tax_ids.map((t) => t.id); return { - discountable: discountableWithoutTax, - discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), - discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]), - formattedLines: [cheapestLine], + discountable: cheapestLine.comboTotalPriceWithoutTax, + discountablePerTax: Object.fromEntries([[taxKey, cheapestLine.comboTotalPriceWithoutTax]]), }; }, @@ -73,45 +61,42 @@ patch(Order.prototype, { * Override to calculate specific product discountable without tax for all reward types */ _getDiscountableOnSpecific(reward) { - // For all rewards, we calculate discounts without tax - const applicableProducts = reward.all_discount_product_ids; + const applicableProductIds = new Set(reward.all_discount_product_ids.map((p) => p.id)); const linesToDiscount = []; const discountLinesPerReward = {}; - const orderLines = this.get_orderlines(); + const orderLines = this.getOrderlines(); + const orderProducts = orderLines.map((line) => line.product_id.id); const remainingAmountPerLine = {}; - const remainingAmountWithTaxPerLine = {}; for (const line of orderLines) { - if (!line.get_quantity() || !line.price) { + if (!line.getQuantity() || !line.price_unit) { continue; } - const product_id = line.get_product().id; - remainingAmountPerLine[line.cid] = line.get_price_without_tax(); - remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax(); - - let included = false; + remainingAmountPerLine[line.uuid] = line.priceExcl; + const product_id = line.combo_parent_id?.product_id.id || line.getProduct().id; if ( - applicableProducts.has(product_id) || - (line.reward_product_id && applicableProducts.has(line.reward_product_id)) + applicableProductIds.has(product_id) || + (line._reward_product_id && applicableProductIds.has(line._reward_product_id.id)) ) { linesToDiscount.push(line); - included = true; } else if (line.reward_id) { - const lineReward = this.pos.reward_by_id[line.reward_id]; - if (lineReward.id === reward.id || - ( - orderLines.some(product => - lineReward.all_discount_product_ids.has(product.get_product().id) && - applicableProducts.has(product.get_product().id) - ) && - lineReward.reward_type === 'discount' && - lineReward.discount_mode != 'percent' - ) + const lineReward = line.reward_id; + const lineRewardApplicableProductsIds = new Set( + lineReward.all_discount_product_ids.map((p) => p.id) + ); + if ( + lineReward.id === reward.id || + (orderProducts.some( + (product) => + lineRewardApplicableProductsIds.has(product) && + applicableProductIds.has(product) + ) && + lineReward.reward_type === "discount" && + lineReward.discount_mode != "percent") ) { linesToDiscount.push(line); - included = true; } if (!discountLinesPerReward[line.reward_identifier_code]) { discountLinesPerReward[line.reward_identifier_code] = []; @@ -120,22 +105,50 @@ patch(Order.prototype, { } } + let cheapestLine = false; + for (const lines of Object.values(discountLinesPerReward)) { + const lineReward = lines[0].reward_id; + if (lineReward.reward_type !== "discount") { + continue; + } + let discountedLines = orderLines; + if (lineReward.discount_applicability === "cheapest") { + cheapestLine = cheapestLine || this._getCheapestLine(lineReward); + discountedLines = [cheapestLine]; + } else if (lineReward.discount_applicability === "specific") { + discountedLines = this._getSpecificDiscountableLines(lineReward); + } + if (!discountedLines.length) { + continue; + } + if (lineReward.discount_mode === "percent") { + const discount = lineReward.discount / 100; + for (const line of discountedLines) { + if (line.reward_id) { + continue; + } + if (lineReward.discount_applicability === "cheapest") { + remainingAmountPerLine[line.uuid] *= 1 - discount / line.getQuantity(); + } else { + remainingAmountPerLine[line.uuid] *= 1 - discount; + } + } + } + } + let discountable = 0; const discountablePerTax = {}; - const discountableWithTaxPerTax = {}; for (const line of linesToDiscount) { - discountable += remainingAmountPerLine[line.cid]; - const taxKey = line.get_taxes().map((t) => t.id); + discountable += remainingAmountPerLine[line.uuid]; + const taxKey = line.tax_ids.map((t) => t.id); if (!discountablePerTax[taxKey]) { discountablePerTax[taxKey] = 0; - discountableWithTaxPerTax[taxKey] = 0; } + discountablePerTax[taxKey] += - remainingAmountPerLine[line.cid]; - discountableWithTaxPerTax[taxKey] += - remainingAmountWithTaxPerLine[line.cid]; + line.basePrice * (remainingAmountPerLine[line.uuid] / line.priceExcl); } - return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount }; + return { discountable, discountablePerTax }; }, /** @@ -144,10 +157,7 @@ patch(Order.prototype, { _getRewardLineValuesDiscount(args) { const reward = args["reward"]; const coupon_id = args["coupon_id"]; - - // For all rewards, we calculate discounts without tax const rewardAppliesTo = reward.discount_applicability; - let getDiscountable; if (rewardAppliesTo === "order") { getDiscountable = this._getDiscountableOnOrder.bind(this); @@ -157,122 +167,104 @@ patch(Order.prototype, { getDiscountable = this._getDiscountableOnSpecific.bind(this); } if (!getDiscountable) { - return "Unknown discount type"; + return _t("Unknown discount type"); } - - let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward); - // For all rewards, we should use total without tax for comparison - const totalForComparison = this.get_total_without_tax(); - discountable = Math.min(totalForComparison, discountable); - if (!discountable) { + + let { discountable, discountablePerTax } = getDiscountable(reward); + + // Use priceExcl instead of priceIncl for maximum limit comparison + discountable = Math.min(this.priceExcl, discountable); + + if (Math.abs(discountable) < 0.0001) { return []; } - let maxDiscount = reward.discount_max_amount || Infinity; if (reward.discount_mode === "per_point") { - const points = (["ewallet", "gift_card"].includes(reward.program_id.program_type)) ? - this._getRealCouponPoints(coupon_id) : - Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * reward.required_points; - maxDiscount = Math.min( - maxDiscount, - roundPrecision(reward.discount * points, this.pos.currency.rounding) - ); + // Rewards cannot be partially offered to customers + const points = ["ewallet", "gift_card"].includes(reward.program_id.program_type) + ? this._getRealCouponPoints(coupon_id) + : Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * + reward.required_points; + maxDiscount = Math.min(maxDiscount, reward.discount * points); } else if (reward.discount_mode === "per_order") { maxDiscount = Math.min(maxDiscount, reward.discount); } else if (reward.discount_mode === "percent") { - maxDiscount = Math.min(maxDiscount, roundPrecision(discountable * (reward.discount / 100), this.pos.currency.rounding)); + maxDiscount = Math.min(maxDiscount, discountable * (reward.discount / 100)); } - - const rewardCode = Math.random().toString(36).substring(3); + + const rewardCode = (Math.random() + 1).toString(36).substring(7); let pointCost = reward.clear_wallet ? this._getRealCouponPoints(coupon_id) : reward.required_points; - + if (reward.discount_mode === "per_point" && !reward.clear_wallet) { - pointCost = roundPrecision(Math.min(maxDiscount, discountable) / reward.discount, this.pos.currency.rounding); + pointCost = Math.min(maxDiscount, discountable) / reward.discount; } + + // These are considered payments and do not require to be either taxed or split by tax + const discountProduct = reward.discount_line_product_id; + if (["ewallet", "gift_card"].includes(reward.program_id.program_type)) { + const price = discountProduct.getTaxDetails({ + overridedValues: { + tax_ids: discountProduct.taxes_id, + price_unit: -Math.min(maxDiscount, discountable), + special_mode: "total_included", + }, + }); - // Apply rounding to pointCost if it's calculated from division - if (pointCost && typeof pointCost === 'number') { - pointCost = roundPrecision(pointCost, this.pos.currency.rounding); - } - - // For all rewards, we calculate discount on price without tax - // Calculate the total discountable amount without tax - let totalDiscountableWithoutTax = 0; - for (const [, amount] of Object.entries(discountablePerTax)) { - totalDiscountableWithoutTax += amount; - } - - const discountFactor = totalDiscountableWithoutTax ? Math.min(1, maxDiscount / totalDiscountableWithoutTax) : 1; - - if ((1 - discountFactor) < 0.00001) { - // 100% Discount: Generate one reward line per original line to prevent rounding aggregation errors (0.01 issues) - // Tags added for UI-level grouping - return formattedLines.map((line, index) => { - const taxKey = ['ewallet', 'gift_card'].includes(reward.program_id.program_type) - ? line.get_taxes().map((t) => t.id) - : line.get_taxes().filter((t) => t.amount_type !== 'fixed').map((t) => t.id); - - return { - product: reward.discount_line_product_id, - price: -line.get_price_without_tax(), - quantity: 1, - reward_id: reward.id, + return [ + { + product_id: discountProduct, + price_unit: price.total_excluded, + qty: 1, + reward_id: reward, is_reward_line: true, coupon_id: coupon_id, - points_cost: 0, + points_cost: pointCost, reward_identifier_code: rewardCode, - tax_ids: taxKey, // Use IDs, not tax objects - merge: false, - is_reward_group_member: true, - reward_group_id: rewardCode, - is_reward_group_head: index === 0, - reward_group_count: formattedLines.length, - }; - }); + tax_ids: discountProduct.taxes_id, + }, + ]; } + const discountFactor = discountable ? Math.min(1, maxDiscount / discountable) : 1; const result = Object.entries(discountablePerTax).reduce((lst, entry) => { + // Ignore 0 price lines if (!entry[1]) { return lst; } - const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); - // Calculate tax-exclusive discount value for display - let preliminaryAmount = entry[1] * discountFactor; + let taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); + if (this.models) { + taxIds = this.models["account.tax"].filter((tax) => taxIds.includes(tax.id)); + } else if (this.pos) { + // Fallback if this is somehow running differently + taxIds = this.pos.taxes.filter((tax) => taxIds.includes(tax.id)); + } - // Back-calculate logic removed as line-by-line strategy handles 100% case. - // Keeping partial discount logic standard for now. - - const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding); + var discount_amount = -(Math.min(this.priceExcl, entry[1]) * discountFactor); + // OVERRIDE: Inject JSON variables (will be automatically assigned in Odoo 19 extraFields, we must make sure these properties are forwarded) lst.push({ - product: reward.discount_line_product_id, - price: -discountAmount, - quantity: 1, - reward_id: reward.id, + product_id: discountProduct, + price_unit: discount_amount, + qty: 1, + reward_id: reward, is_reward_line: true, coupon_id: coupon_id, points_cost: 0, reward_identifier_code: rewardCode, tax_ids: taxIds, - merge: false, + + is_reward_group_member : true, + is_reward_group_head : false, + reward_group_id : reward.id, }); return lst; }, []); - + if (result.length) { result[0]["points_cost"] = pointCost; + result[0]["is_reward_group_head"] = true; } return result; }, - - set_orderline_options(line, options) { - super.set_orderline_options(...arguments); - if (options && options.is_reward_group_member) { - line.is_reward_group_member = options.is_reward_group_member; - line.reward_group_id = options.reward_group_id; - line.is_reward_group_head = options.is_reward_group_head; - line.reward_group_count = options.reward_group_count; - } - }, }); diff --git a/static/src/overrides/models/orderline.js b/static/src/overrides/models/orderline.js index 72e3d57..b2f0301 100644 --- a/static/src/overrides/models/orderline.js +++ b/static/src/overrides/models/orderline.js @@ -1,76 +1,90 @@ /** @odoo-module **/ -import { Order, Orderline } from "@point_of_sale/app/store/models"; +import { PosOrderline } from "@point_of_sale/app/models/pos_order_line"; +import { formatCurrency } from "@web/core/currency"; import { patch } from "@web/core/utils/patch"; -patch(Order.prototype, { - export_for_printing() { - const result = super.export_for_printing(...arguments); - if (result.orderlines) { - result.orderlines = result.orderlines.filter((lineData) => { - const line = this.orderlines.find((l) => l.cid === lineData.cid); - if (line && line.is_reward_group_member && !line.is_reward_group_head) { - return false; - } - return true; - }); - } - return result; +patch(PosOrderline, { + extraFields: { + ...(PosOrderline.extraFields || {}), + is_reward_group_member: { + model: "pos.order.line", + name: "is_reward_group_member", + type: "boolean", + local: true, + }, + reward_group_id: { + model: "pos.order.line", + name: "reward_group_id", + type: "char", + local: true, + }, + is_reward_group_head: { + model: "pos.order.line", + name: "is_reward_group_head", + type: "boolean", + local: true, + }, + reward_group_count: { + model: "pos.order.line", + name: "reward_group_count", + type: "integer", + local: true, + }, }, }); -patch(Orderline.prototype, { - getDisplayClasses() { - // Need to ensure we call the original function, might not exist in some versions or might be different - let classes = {}; - if (typeof super.getDisplayClasses === 'function') { - classes = super.getDisplayClasses(); +patch(PosOrderline.prototype, { + setOptions(options) { + if (options.is_reward_group_member) { + this.is_reward_group_member = options.is_reward_group_member; + this.reward_group_id = options.reward_group_id; + this.is_reward_group_head = options.is_reward_group_head; + this.reward_group_count = options.reward_group_count; } + return super.setOptions(...arguments); + }, + + getDisplayClasses() { + // Hide secondary lines of the reward group + const classes = super.getDisplayClasses ? super.getDisplayClasses() : {}; if (this.is_reward_group_member && !this.is_reward_group_head) { - classes['d-none'] = true; + classes["d-none"] = true; } return classes; }, - getDisplayData() { - const data = super.getDisplayData(); - data.cid = this.cid; + + get currencyDisplayPrice() { if (this.is_reward_group_head) { - // Group and sum all lines in this reward group for display - const groupLines = this.order.get_orderlines().filter( + const groupLines = this.order_id.lines.filter( line => line.reward_group_id === this.reward_group_id ); let totalDisplayPrice = 0; - let totalQty = 0; for (const line of groupLines) { - totalDisplayPrice += line.get_display_price(); - totalQty += line.get_quantity(); + totalDisplayPrice += line.displayPrice; } - // this.env might not exist in Odoo 19 this way, we'll try to use this.pos.env if this.env is absent. - const env = this.env || this.pos.env; - data.price = env.utils.formatCurrency(totalDisplayPrice, this.pos.currency); - data.qty = totalQty.toString(); + return formatCurrency(totalDisplayPrice, this.currency.id); } - return data; + return super.currencyDisplayPrice; }, - export_as_JSON() { - const result = super.export_as_JSON(...arguments); - if (this.is_reward_group_member) { - result.is_reward_group_member = this.is_reward_group_member; - result.reward_group_id = this.reward_group_id; - result.is_reward_group_head = this.is_reward_group_head; - result.reward_group_count = this.reward_group_count; + + getQuantityStr() { + const res = super.getQuantityStr(); + if (this.is_reward_group_head) { + const groupLines = this.order_id.lines.filter( + line => line.reward_group_id === this.reward_group_id + ); + + let totalQty = 0; + for (const line of groupLines) { + totalQty += line.getQuantity(); + } + res.unitPart = "" + totalQty; + res.decimalPart = ""; + res.qtyStr = "" + totalQty; } - return result; - }, - init_from_JSON(json) { - if (json.is_reward_group_member) { - this.is_reward_group_member = json.is_reward_group_member; - this.reward_group_id = json.reward_group_id; - this.is_reward_group_head = json.is_reward_group_head; - this.reward_group_count = json.reward_group_count; - } - super.init_from_JSON(...arguments); + return res; } });