diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 4b74985d8bd..ed19a306515 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -214,6 +214,9 @@ class PosConfig(models.Model): company_has_template = fields.Boolean(string="Company has chart of accounts", compute="_compute_company_has_template") current_user_id = fields.Many2one('res.users', string='Current Session Responsible', compute='_compute_current_session_user') other_devices = fields.Boolean(string="Other Devices", help="Connect devices to your PoS without an IoT Box.") + rounding_method = fields.Many2one('account.cash.rounding', string="Cash rounding") + cash_rounding = fields.Boolean(string="Cash Rounding") + only_round_cash_method = fields.Boolean(string="Only apply rounding on cash") @api.depends('use_pricelist', 'available_pricelist_ids') def _compute_allowed_pricelist_ids(self): @@ -307,6 +310,11 @@ class PosConfig(models.Model): if open_session: raise ValidationError(_("You are not allowed to change the cash control status while a session is already opened.")) + @api.constrains('rounding_method') + def _check_rounding_method_strategy(self): + if self.cash_rounding and self.rounding_method.strategy != 'add_invoice_line': + raise ValidationError(_("Cash rounding strategy must be: 'Add a rounding line'")) + @api.constrains('company_id', 'journal_id') def _check_company_journal(self): if self.journal_id and self.journal_id.company_id.id != self.company_id.id: diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index c558da249ba..df5ad7db13f 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -8,8 +8,8 @@ import psycopg2 import pytz from odoo import api, fields, models, tools, _ -from odoo.tools import float_is_zero -from odoo.exceptions import UserError +from odoo.tools import float_is_zero, float_round +from odoo.exceptions import ValidationError, UserError from odoo.http import request from odoo.osv.expression import AND import base64 @@ -271,7 +271,6 @@ class PosOrder(models.Model): for order in self: order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order) - @api.onchange('payment_ids', 'lines') def _onchange_amount_all(self): for order in self: @@ -358,8 +357,15 @@ class PosOrder(models.Model): def action_pos_order_paid(self): self.ensure_one() - if not float_is_zero(self.amount_total - self.amount_paid, precision_rounding=self.currency_id.rounding): + + if not self.config_id.cash_rounding: + total = self.amount_total + else: + total = float_round(self.amount_total, precision_rounding=self.config_id.rounding_method.rounding, rounding_method=self.config_id.rounding_method.rounding_method) + + if not float_is_zero(total - self.amount_paid, precision_rounding=self.currency_id.rounding): raise UserError(_("Order %s is not fully paid.") % self.name) + self.write({'state': 'paid'}) return True @@ -389,6 +395,7 @@ class PosOrder(models.Model): 'invoice_date': order.date_order.date(), 'fiscal_position_id': order.fiscal_position_id.id, 'invoice_line_ids': [(0, None, order._prepare_invoice_line(line)) for line in order.lines], + 'invoice_cash_rounding_id': order.config_id.rounding_method.id if order.config_id.cash_rounding else False } new_move = moves.sudo()\ .with_company(order.company_id)\ @@ -824,3 +831,13 @@ class ReportSaleDetails(models.AbstractModel): configs = self.env['pos.config'].browse(data['config_ids']) data.update(self.get_sale_details(data['date_start'], data['date_stop'], configs)) return data + + class AccountCashRounding(models.Model): + _inherit = 'account.cash.rounding' + + @api.constrains('rounding', 'rounding_method', 'strategy') + def _check_session_state(self): + open_session = self.env['pos.session'].search([('config_id.rounding_method', '=', self.id), ('state', '!=', 'closed')]) + if open_session: + raise ValidationError( + _("You are not allowed to change the cash rounding configuration while a pos session using it is already opened.")) diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 7b8bc3b1c6e..f7235a8584e 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -6,7 +6,7 @@ from datetime import timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError, ValidationError -from odoo.tools import float_is_zero +from odoo.tools import float_is_zero, float_compare class PosSession(models.Model): @@ -375,6 +375,7 @@ class PosSession(models.Model): taxes = defaultdict(tax_amounts) stock_expense = defaultdict(amounts) stock_output = defaultdict(amounts) + rounding_difference = {'amount': 0.0, 'amount_converted': 0.0} # Track the receivable lines of the invoiced orders' account moves for reconciliation # These receivable lines are reconciled to the corresponding invoice receivable lines # of this session's move_id. @@ -400,7 +401,10 @@ class PosSession(models.Model): if order.is_invoiced: # Combine invoice receivable lines key = order.partner_id.property_account_receivable_id.id - invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_total}, order.date_order) + if self.config_id.cash_rounding: + invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_paid}, order.date_order) + else: + invoice_receivables[key] = self._update_amounts(invoice_receivables[key], {'amount': order.amount_total}, order.date_order) # side loop to gather receivable lines by account for reconciliation for move_line in order.account_move.line_ids.filtered(lambda aml: aml.account_id.internal_type == 'receivable'): order_account_move_receivable_lines[move_line.account_id.id] |= move_line @@ -447,6 +451,10 @@ class PosSession(models.Model): stock_expense[exp_key] = self._update_amounts(stock_expense[exp_key], {'amount': amount}, move.picking_id.date) stock_output[out_key] = self._update_amounts(stock_output[out_key], {'amount': amount}, move.picking_id.date) + if self.config_id.cash_rounding: + diff = order.amount_paid - order.amount_total + rounding_difference = self._update_amounts(rounding_difference, {'amount': diff}, order.date_order) + if self.company_id.anglo_saxon_accounting: global_session_pickings = self.picking_ids.filtered(lambda p: not p.pos_order_id) if global_session_pickings: @@ -480,6 +488,10 @@ class PosSession(models.Model): 'Please set corresponding tax account in each repartition line of the following taxes: \n%s' ) % ', '.join(tax_names_no_account) raise UserError(error_message) + rounding_vals = [] + + if not float_is_zero(rounding_difference['amount'], precision_rounding=self.currency_id.rounding) or not float_is_zero(rounding_difference['amount_converted'], precision_rounding=self.currency_id.rounding): + rounding_vals = [self._get_rounding_difference_vals(rounding_difference['amount'], rounding_difference['amount_converted'])] MoveLine.create( tax_vals @@ -487,6 +499,7 @@ class PosSession(models.Model): + [self._get_stock_expense_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in stock_expense.items()] + [self._get_split_receivable_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in split_receivables.items()] + [self._get_combine_receivable_vals(key, amounts['amount'], amounts['amount_converted']) for key, amounts in combine_receivables.items()] + + rounding_vals ) ## SECTION: Create cash statement lines and cash move lines @@ -603,6 +616,20 @@ class PosSession(models.Model): 'taxes': taxes, } + def _get_rounding_difference_vals(self, amount, amount_converted): + if self.config_id.cash_rounding: + partial_args = { + 'name': 'Rounding line', + 'move_id': self.move_id.id, + } + if float_compare(0.0, amount, precision_rounding=self.currency_id.rounding) > 0: # loss + partial_args['account_id'] = self.config_id.rounding_method.loss_account_id.id + return self._debit_amounts(partial_args, -amount, -amount_converted) + + if float_compare(0.0, amount, precision_rounding=self.currency_id.rounding) < 0: # profit + partial_args['account_id'] = self.config_id.rounding_method.profit_account_id.id + return self._credit_amounts(partial_args, amount, amount_converted) + def _get_split_receivable_vals(self, payment, amount, amount_converted): partial_vals = { 'account_id': payment.payment_method_id.receivable_account_id.id, diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index b0f75a37a93..a81eb81d235 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -419,6 +419,13 @@ exports.PosModel = Backbone.Model.extend({ return new exports.Product({}, product); })); }, + },{ + model: 'account.cash.rounding', + fields: ['name', 'rounding', 'rounding_method'], + domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; }, + loaded: function(self, cash_rounding) { + self.cash_rounding = cash_rounding; + } },{ model: 'pos.payment.method', fields: ['name', 'is_cash_count', 'use_payment_terminal'], @@ -2446,10 +2453,12 @@ exports.Order = Backbone.Model.extend({ paymentlines: paymentlines, subtotal: this.get_subtotal(), total_with_tax: this.get_total_with_tax(), + total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), total_without_tax: this.get_total_without_tax(), total_tax: this.get_total_tax(), total_paid: this.get_total_paid(), total_discount: this.get_total_discount(), + rounding_applied: this.get_rounding_applied(), tax_details: this.get_tax_details(), change: this.get_change(), name : this.get_name(), @@ -2734,7 +2743,7 @@ exports.Order = Backbone.Model.extend({ this.assert_editable(); var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos}); if(!payment_method.is_cash_count || this.pos.config.iface_precompute_cash){ - newPaymentline.set_amount( this.get_due() ); + newPaymentline.set_amount(this.get_due() ); }; this.paymentlines.add(newPaymentline); this.select_paymentline(newPaymentline); @@ -2812,7 +2821,7 @@ exports.Order = Backbone.Model.extend({ } }, /* ---- Payment Status --- */ - get_subtotal : function(){ + get_subtotal: function(){ return round_pr(this.orderlines.reduce((function(sum, orderLine){ return sum + orderLine.get_display_price(); }), 0), this.pos.currency.rounding); @@ -2942,7 +2951,7 @@ exports.Order = Backbone.Model.extend({ }, get_change: function(paymentline) { if (!paymentline) { - var change = this.get_total_paid() - this.get_total_with_tax(); + var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied(); } else { var change = -this.get_total_with_tax(); var lines = this.paymentlines.models; @@ -2957,7 +2966,7 @@ exports.Order = Backbone.Model.extend({ }, get_due: function(paymentline) { if (!paymentline) { - var due = this.get_total_with_tax() - this.get_total_paid(); + var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied(); } else { var due = this.get_total_with_tax(); var lines = this.paymentlines.models; @@ -2971,6 +2980,35 @@ exports.Order = Backbone.Model.extend({ } return round_pr(due, this.pos.currency.rounding); }, + get_rounding_applied: function() { + if(this.pos.config.cash_rounding) { + var total = round_pr(this.get_total_with_tax(), this.pos.cash_rounding[0].rounding); + + var rounding_applied = total - (this.pos.config['iface_tax_included'] === "total"? this.get_subtotal(): this.get_total_with_tax()); + // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. + if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0){ + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + return rounding_applied; + } + return 0; + }, + has_not_valid_rounding: function() { + if(!this.pos.config.cash_rounding) + return false; + + var lines = this.paymentlines.models; + + for(var i = 0; i < lines.length; i++) { + var line = lines[i]; + if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) + return line; + } + return false; + }, is_paid: function(){ return this.get_due() <= 0; }, diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index d7982b538ed..4700df3c0f8 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -2093,7 +2093,7 @@ var PaymentScreenWidget = ScreenWidget.extend({ var extradue = this.compute_extradue(order); this.$('.paymentlines-container').empty(); - var lines = $(QWeb.render('PaymentScreen-Paymentlines', { + var lines = $(QWeb.render('PaymentScreen-Paymentlines', { widget: this, order: order, paymentlines: lines, @@ -2302,6 +2302,15 @@ var PaymentScreenWidget = ScreenWidget.extend({ return false; } + if(order.has_not_valid_rounding()) { + var line = order.has_not_valid_rounding(); + this.gui.show_popup('error',{ + title: _t('Incorrect rounding'), + body: _t('You have to round your payments lines.' + line.amount + ' is not rounded.'), + }); + return false; + } + // The exact amount must be paid if there is no cash payment method defined. if (Math.abs(order.get_total_with_tax() - order.get_total_paid()) > 0.00001) { var cash = false; diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index ad77d69ee05..610eb14f447 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -658,7 +658,7 @@
- +
Please select a payment method. @@ -678,7 +678,7 @@
Total Due - +
@@ -994,6 +994,12 @@ TOTAL
+ +
+ ROUNDED + +
+


diff --git a/addons/point_of_sale/tests/test_pos_basic_config.py b/addons/point_of_sale/tests/test_pos_basic_config.py index fa2bb6e01b7..77365383caf 100644 --- a/addons/point_of_sale/tests/test_pos_basic_config.py +++ b/addons/point_of_sale/tests/test_pos_basic_config.py @@ -21,6 +21,7 @@ class TestPoSBasicConfig(TestPoSCommon): self.product1 = self.create_product('Product 1', self.categ_basic, 10.0, 5) self.product2 = self.create_product('Product 2', self.categ_basic, 20.0, 10) self.product3 = self.create_product('Product 3', self.categ_basic, 30.0, 15) + self.product4 = self.create_product('Product_4', self.categ_basic, 9.96, 4.98) self.adjust_inventory([self.product1, self.product2, self.product3], [100, 50, 50]) def test_orders_no_invoiced(self): @@ -441,3 +442,79 @@ class TestPoSBasicConfig(TestPoSCommon): for line in cash_receivable_lines: self.assertTrue(line.full_reconcile_id, msg='Each cash receivable line should be fully-reconciled.') + + def test_rounding_method(self): + # set the cash rounding method + self.config.cash_rounding = True + self.config.rounding_method = self.env['account.cash.rounding'].create({ + 'name': 'add_invoice_line', + 'rounding': 0.05, + 'strategy': 'add_invoice_line', + 'profit_account_id': self.company['default_cash_difference_income_account_id'].copy().id, + 'loss_account_id': self.company['default_cash_difference_expense_account_id'].copy().id, + 'rounding_method': 'HALF-UP', + }) + + self.open_new_session() + + """ Test for orders: one with invoice + + 3 orders + - order 1, paid by cash + - order 2, paid by bank + - order 3, paid by bank, invoiced + + Orders + ====== + +---------+----------+---------------+----------+-----+-------+ + | order | payments | invoiced? | product | qty | total | + +---------+----------+---------------+----------+-----+-------+ + | order 1 | bank | no | product1 | 6 | 60 | + | | | | product4 | 4 | 39.84 | + +---------+----------+---------------+----------+-----+-------+ + | order 2 | bank | yes | product4 | 3 | 29.88 | + | | | | product2 | 20 | 400 | + +---------+----------+---------------+----------+-----+-------+ + + Expected Result + =============== + +---------------------+---------+ + | account | balance | + +---------------------+---------+ + | sale | -596,56 | + | pos receivable bank | 516,64 | + | Rounding applied | -0,01 | + +---------------------+---------+ + | Total balance | 0.0 | + +---------------------+---------+ + """ + + # create orders + orders = [] + + # create orders + orders = [] + orders.append(self.create_ui_order_data( + [(self.product4, 3), (self.product2, 20)], + payments=[(self.bank_pm, 429.90)] + )) + + orders.append(self.create_ui_order_data( + [(self.product1, 6), (self.product4, 4)], + payments=[(self.bank_pm, 99.85)] + )) + + # sync orders + order = self.env['pos.order'].create_from_ui(orders) + + self.assertEqual(orders[0]['data']['amount_return'], 0, msg='The amount return should be 0') + self.assertEqual(orders[1]['data']['amount_return'], 0, msg='The amount return should be 0') + + # close the session + self.pos_session.action_pos_session_validate() + + # check values after the session is closed + session_account_move = self.pos_session.move_id + + rounding_line = session_account_move.line_ids.filtered(lambda line: line.name == 'Rounding line') + self.assertAlmostEqual(rounding_line.credit, 0.03, msg='The credit should be equals to 0.03') diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index e87b270f7b0..55b13fb2250 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -342,6 +342,23 @@ +
+
+ +
+
+
+
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml index 726d88664c9..79d7b4a79e8 100644 --- a/addons/point_of_sale/views/pos_order_view.xml +++ b/addons/point_of_sale/views/pos_order_view.xml @@ -84,6 +84,11 @@ nolabel="1" class="oe_subtotal_footer_separator" widget="monetary"/> +
@@ -347,4 +352,17 @@ + + + pos.cash.rounding.form.inherited + account.cash.rounding + + + + + + + diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 41810f4ded1..8a273ed2f53 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -83,6 +83,25 @@
+

Payments

+
+
+
+ +
+
+
+
+

Payment Terminals