feat: Implement and configure loyalty discounts to apply before tax in POS.

This commit is contained in:
Suherdy Yacob 2026-03-12 15:57:23 +07:00
parent 8d756a0e6f
commit a7c6781af5
4 changed files with 205 additions and 198 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "POS Loyalty Discount Before Tax", "name": "POS Loyalty Discount Before Tax",
"version": "1.6", "version": "19.0.1.0.0",
"category": "Point of Sale", "category": "Point of Sale",
"summary": "Modify loyalty reward discount calculation to apply before tax in POS", "summary": "Modify loyalty reward discount calculation to apply before tax in POS",
"author": "Suherdy Yacob", "author": "Suherdy Yacob",

@ -0,0 +1 @@
Subproject commit d975966b70f550bb2de8dbf35abb0e5106a56371

View File

@ -1,71 +1,59 @@
/** @odoo-module **/ /** @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 { 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 PosOrder methods to handle all loyalty discounts with discount_before_tax
patch(Order.prototype, { patch(PosOrder.prototype, {
/** /**
* Override to calculate discountable amount without tax for all reward types * Override to calculate discountable amount without tax for all reward types
*/ */
_getDiscountableOnOrder(reward) { _getDiscountableOnOrder(reward) {
// For all rewards, we calculate discounts without tax
let discountable = 0; let discountable = 0;
const discountablePerTax = {}; const discountablePerTax = {};
const discountableWithTaxPerTax = {};
const formattedLines = [];
const orderLines = this.get_orderlines();
for (const line of orderLines) { for (const line of this.getOrderlines()) {
if (!line.get_quantity()) { if (!line.getQuantity()) {
continue; 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) { if (line.reward_id || line.is_reward_line) {
continue; 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) const taxKey = ['ewallet', 'gift_card'].includes(reward.program_id.program_type)
? line.get_taxes().map((t) => t.id) ? line.tax_ids.map((t) => t.id)
: line.get_taxes().filter((t) => t.amount_type !== 'fixed').map((t) => t.id); : line.tax_ids.filter((t) => t.amount_type !== 'fixed').map((t) => t.id);
// Use priceExcl instead of total_included (price_without_tax)
discountable += line.priceExcl;
discountable += line_total_without_tax;
if (!discountablePerTax[taxKey]) { if (!discountablePerTax[taxKey]) {
discountablePerTax[taxKey] = 0; discountablePerTax[taxKey] = 0;
discountableWithTaxPerTax[taxKey] = 0;
} }
discountablePerTax[taxKey] += line_total_without_tax; // basePrice is effectively quantity * unit_price * (1 - discount), before tax is handled
discountableWithTaxPerTax[taxKey] += line.get_price_with_tax(); discountablePerTax[taxKey] += line.basePrice;
formattedLines.push(line);
} }
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines }; return { discountable, discountablePerTax };
}, },
/** /**
* Override to calculate cheapest line discountable without tax for all reward types * Override to calculate cheapest line discountable without tax for all reward types
*/ */
_getDiscountableOnCheapest(reward) { _getDiscountableOnCheapest(reward) {
// For all rewards, we calculate discounts without tax const cheapestLine = this._getCheapestLine(reward);
const cheapestLine = this._getCheapestLine();
if (!cheapestLine) { if (!cheapestLine) {
return { discountable: 0, discountablePerTax: {} }; return { discountable: 0, discountablePerTax: {} };
} }
// Use price without tax for discount calculation const taxKey = cheapestLine.tax_ids.map((t) => t.id);
const discountableWithoutTax = cheapestLine.get_price_without_tax();
const discountableWithTax = cheapestLine.get_price_with_tax();
const taxKey = cheapestLine.get_taxes().map((t) => t.id);
return { return {
discountable: discountableWithoutTax, discountable: cheapestLine.comboTotalPriceWithoutTax,
discountablePerTax: Object.fromEntries([[taxKey, discountableWithoutTax]]), discountablePerTax: Object.fromEntries([[taxKey, cheapestLine.comboTotalPriceWithoutTax]]),
discountableWithTaxPerTax: Object.fromEntries([[taxKey, discountableWithTax]]),
formattedLines: [cheapestLine],
}; };
}, },
@ -73,45 +61,42 @@ patch(Order.prototype, {
* Override to calculate specific product discountable without tax for all reward types * Override to calculate specific product discountable without tax for all reward types
*/ */
_getDiscountableOnSpecific(reward) { _getDiscountableOnSpecific(reward) {
// For all rewards, we calculate discounts without tax const applicableProductIds = new Set(reward.all_discount_product_ids.map((p) => p.id));
const applicableProducts = reward.all_discount_product_ids;
const linesToDiscount = []; const linesToDiscount = [];
const discountLinesPerReward = {}; const discountLinesPerReward = {};
const orderLines = this.get_orderlines(); const orderLines = this.getOrderlines();
const orderProducts = orderLines.map((line) => line.product_id.id);
const remainingAmountPerLine = {}; const remainingAmountPerLine = {};
const remainingAmountWithTaxPerLine = {};
for (const line of orderLines) { for (const line of orderLines) {
if (!line.get_quantity() || !line.price) { if (!line.getQuantity() || !line.price_unit) {
continue; continue;
} }
const product_id = line.get_product().id; remainingAmountPerLine[line.uuid] = line.priceExcl;
remainingAmountPerLine[line.cid] = line.get_price_without_tax(); const product_id = line.combo_parent_id?.product_id.id || line.getProduct().id;
remainingAmountWithTaxPerLine[line.cid] = line.get_price_with_tax();
let included = false;
if ( if (
applicableProducts.has(product_id) || applicableProductIds.has(product_id) ||
(line.reward_product_id && applicableProducts.has(line.reward_product_id)) (line._reward_product_id && applicableProductIds.has(line._reward_product_id.id))
) { ) {
linesToDiscount.push(line); linesToDiscount.push(line);
included = true;
} else if (line.reward_id) { } else if (line.reward_id) {
const lineReward = this.pos.reward_by_id[line.reward_id]; const lineReward = line.reward_id;
if (lineReward.id === reward.id || const lineRewardApplicableProductsIds = new Set(
( lineReward.all_discount_product_ids.map((p) => p.id)
orderLines.some(product => );
lineReward.all_discount_product_ids.has(product.get_product().id) && if (
applicableProducts.has(product.get_product().id) lineReward.id === reward.id ||
) && (orderProducts.some(
lineReward.reward_type === 'discount' && (product) =>
lineReward.discount_mode != 'percent' lineRewardApplicableProductsIds.has(product) &&
) applicableProductIds.has(product)
) &&
lineReward.reward_type === "discount" &&
lineReward.discount_mode != "percent")
) { ) {
linesToDiscount.push(line); linesToDiscount.push(line);
included = true;
} }
if (!discountLinesPerReward[line.reward_identifier_code]) { if (!discountLinesPerReward[line.reward_identifier_code]) {
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; let discountable = 0;
const discountablePerTax = {}; const discountablePerTax = {};
const discountableWithTaxPerTax = {};
for (const line of linesToDiscount) { for (const line of linesToDiscount) {
discountable += remainingAmountPerLine[line.cid]; discountable += remainingAmountPerLine[line.uuid];
const taxKey = line.get_taxes().map((t) => t.id); const taxKey = line.tax_ids.map((t) => t.id);
if (!discountablePerTax[taxKey]) { if (!discountablePerTax[taxKey]) {
discountablePerTax[taxKey] = 0; discountablePerTax[taxKey] = 0;
discountableWithTaxPerTax[taxKey] = 0;
} }
discountablePerTax[taxKey] += discountablePerTax[taxKey] +=
remainingAmountPerLine[line.cid]; line.basePrice * (remainingAmountPerLine[line.uuid] / line.priceExcl);
discountableWithTaxPerTax[taxKey] +=
remainingAmountWithTaxPerLine[line.cid];
} }
return { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines: linesToDiscount }; return { discountable, discountablePerTax };
}, },
/** /**
@ -144,10 +157,7 @@ patch(Order.prototype, {
_getRewardLineValuesDiscount(args) { _getRewardLineValuesDiscount(args) {
const reward = args["reward"]; const reward = args["reward"];
const coupon_id = args["coupon_id"]; const coupon_id = args["coupon_id"];
// For all rewards, we calculate discounts without tax
const rewardAppliesTo = reward.discount_applicability; const rewardAppliesTo = reward.discount_applicability;
let getDiscountable; let getDiscountable;
if (rewardAppliesTo === "order") { if (rewardAppliesTo === "order") {
getDiscountable = this._getDiscountableOnOrder.bind(this); getDiscountable = this._getDiscountableOnOrder.bind(this);
@ -157,122 +167,104 @@ patch(Order.prototype, {
getDiscountable = this._getDiscountableOnSpecific.bind(this); getDiscountable = this._getDiscountableOnSpecific.bind(this);
} }
if (!getDiscountable) { if (!getDiscountable) {
return "Unknown discount type"; return _t("Unknown discount type");
} }
let { discountable, discountablePerTax, discountableWithTaxPerTax, formattedLines } = getDiscountable(reward); let { discountable, discountablePerTax } = getDiscountable(reward);
// For all rewards, we should use total without tax for comparison
const totalForComparison = this.get_total_without_tax(); // Use priceExcl instead of priceIncl for maximum limit comparison
discountable = Math.min(totalForComparison, discountable); discountable = Math.min(this.priceExcl, discountable);
if (!discountable) {
if (Math.abs(discountable) < 0.0001) {
return []; return [];
} }
let maxDiscount = reward.discount_max_amount || Infinity; let maxDiscount = reward.discount_max_amount || Infinity;
if (reward.discount_mode === "per_point") { if (reward.discount_mode === "per_point") {
const points = (["ewallet", "gift_card"].includes(reward.program_id.program_type)) ? // Rewards cannot be partially offered to customers
this._getRealCouponPoints(coupon_id) : const points = ["ewallet", "gift_card"].includes(reward.program_id.program_type)
Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) * reward.required_points; ? this._getRealCouponPoints(coupon_id)
maxDiscount = Math.min( : Math.floor(this._getRealCouponPoints(coupon_id) / reward.required_points) *
maxDiscount, reward.required_points;
roundPrecision(reward.discount * points, this.pos.currency.rounding) maxDiscount = Math.min(maxDiscount, reward.discount * points);
);
} else if (reward.discount_mode === "per_order") { } else if (reward.discount_mode === "per_order") {
maxDiscount = Math.min(maxDiscount, reward.discount); maxDiscount = Math.min(maxDiscount, reward.discount);
} else if (reward.discount_mode === "percent") { } 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 let pointCost = reward.clear_wallet
? this._getRealCouponPoints(coupon_id) ? this._getRealCouponPoints(coupon_id)
: reward.required_points; : reward.required_points;
if (reward.discount_mode === "per_point" && !reward.clear_wallet) { 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;
} }
// Apply rounding to pointCost if it's calculated from division // These are considered payments and do not require to be either taxed or split by tax
if (pointCost && typeof pointCost === 'number') { const discountProduct = reward.discount_line_product_id;
pointCost = roundPrecision(pointCost, this.pos.currency.rounding); 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",
},
});
// For all rewards, we calculate discount on price without tax return [
// Calculate the total discountable amount without tax {
let totalDiscountableWithoutTax = 0; product_id: discountProduct,
for (const [, amount] of Object.entries(discountablePerTax)) { price_unit: price.total_excluded,
totalDiscountableWithoutTax += amount; qty: 1,
} reward_id: reward,
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,
is_reward_line: true, is_reward_line: true,
coupon_id: coupon_id, coupon_id: coupon_id,
points_cost: 0, points_cost: pointCost,
reward_identifier_code: rewardCode, reward_identifier_code: rewardCode,
tax_ids: taxKey, // Use IDs, not tax objects tax_ids: discountProduct.taxes_id,
merge: false, },
is_reward_group_member: true, ];
reward_group_id: rewardCode,
is_reward_group_head: index === 0,
reward_group_count: formattedLines.length,
};
});
} }
const discountFactor = discountable ? Math.min(1, maxDiscount / discountable) : 1;
const result = Object.entries(discountablePerTax).reduce((lst, entry) => { const result = Object.entries(discountablePerTax).reduce((lst, entry) => {
// Ignore 0 price lines
if (!entry[1]) { if (!entry[1]) {
return lst; return lst;
} }
const taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str)); let taxIds = entry[0] === "" ? [] : entry[0].split(",").map((str) => parseInt(str));
// Calculate tax-exclusive discount value for display if (this.models) {
let preliminaryAmount = entry[1] * discountFactor; 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. var discount_amount = -(Math.min(this.priceExcl, entry[1]) * discountFactor);
// Keeping partial discount logic standard for now. // OVERRIDE: Inject JSON variables (will be automatically assigned in Odoo 19 extraFields, we must make sure these properties are forwarded)
const discountAmount = roundPrecision(preliminaryAmount, this.pos.currency.rounding);
lst.push({ lst.push({
product: reward.discount_line_product_id, product_id: discountProduct,
price: -discountAmount, price_unit: discount_amount,
quantity: 1, qty: 1,
reward_id: reward.id, reward_id: reward,
is_reward_line: true, is_reward_line: true,
coupon_id: coupon_id, coupon_id: coupon_id,
points_cost: 0, points_cost: 0,
reward_identifier_code: rewardCode, reward_identifier_code: rewardCode,
tax_ids: taxIds, tax_ids: taxIds,
merge: false,
is_reward_group_member : true,
is_reward_group_head : false,
reward_group_id : reward.id,
}); });
return lst; return lst;
}, []); }, []);
if (result.length) { if (result.length) {
result[0]["points_cost"] = pointCost; result[0]["points_cost"] = pointCost;
result[0]["is_reward_group_head"] = true;
} }
return result; 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;
}
},
}); });

View File

@ -1,76 +1,90 @@
/** @odoo-module **/ /** @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"; import { patch } from "@web/core/utils/patch";
patch(Order.prototype, { patch(PosOrderline, {
export_for_printing() { extraFields: {
const result = super.export_for_printing(...arguments); ...(PosOrderline.extraFields || {}),
if (result.orderlines) { is_reward_group_member: {
result.orderlines = result.orderlines.filter((lineData) => { model: "pos.order.line",
const line = this.orderlines.find((l) => l.cid === lineData.cid); name: "is_reward_group_member",
if (line && line.is_reward_group_member && !line.is_reward_group_head) { type: "boolean",
return false; local: true,
} },
return true; reward_group_id: {
}); model: "pos.order.line",
} name: "reward_group_id",
return result; 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, { patch(PosOrderline.prototype, {
getDisplayClasses() { setOptions(options) {
// Need to ensure we call the original function, might not exist in some versions or might be different if (options.is_reward_group_member) {
let classes = {}; this.is_reward_group_member = options.is_reward_group_member;
if (typeof super.getDisplayClasses === 'function') { this.reward_group_id = options.reward_group_id;
classes = super.getDisplayClasses(); 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) { if (this.is_reward_group_member && !this.is_reward_group_head) {
classes['d-none'] = true; classes["d-none"] = true;
} }
return classes; return classes;
}, },
getDisplayData() {
const data = super.getDisplayData(); get currencyDisplayPrice() {
data.cid = this.cid;
if (this.is_reward_group_head) { if (this.is_reward_group_head) {
// Group and sum all lines in this reward group for display const groupLines = this.order_id.lines.filter(
const groupLines = this.order.get_orderlines().filter(
line => line.reward_group_id === this.reward_group_id line => line.reward_group_id === this.reward_group_id
); );
let totalDisplayPrice = 0; let totalDisplayPrice = 0;
let totalQty = 0;
for (const line of groupLines) { for (const line of groupLines) {
totalDisplayPrice += line.get_display_price(); totalDisplayPrice += line.displayPrice;
totalQty += line.get_quantity();
} }
// this.env might not exist in Odoo 19 this way, we'll try to use this.pos.env if this.env is absent. return formatCurrency(totalDisplayPrice, this.currency.id);
const env = this.env || this.pos.env;
data.price = env.utils.formatCurrency(totalDisplayPrice, this.pos.currency);
data.qty = totalQty.toString();
} }
return data; return super.currencyDisplayPrice;
}, },
export_as_JSON() {
const result = super.export_as_JSON(...arguments); getQuantityStr() {
if (this.is_reward_group_member) { const res = super.getQuantityStr();
result.is_reward_group_member = this.is_reward_group_member; if (this.is_reward_group_head) {
result.reward_group_id = this.reward_group_id; const groupLines = this.order_id.lines.filter(
result.is_reward_group_head = this.is_reward_group_head; line => line.reward_group_id === this.reward_group_id
result.reward_group_count = this.reward_group_count; );
let totalQty = 0;
for (const line of groupLines) {
totalQty += line.getQuantity();
}
res.unitPart = "" + totalQty;
res.decimalPart = "";
res.qtyStr = "" + totalQty;
} }
return result; return res;
},
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);
} }
}); });