From 9aa5975cd230d0b7dde0a1ef1c532e32b8891101 Mon Sep 17 00:00:00 2001 From: Kitti U Date: Thu, 7 Oct 2021 19:33:35 +0700 Subject: [PATCH] [14.0][IMP] l10n_th_withholding_tax, Add PIT feature --- l10n_th_withholding_tax/__manifest__.py | 5 + .../data/pit_rate_data.xml | 71 ++++++++ .../i18n/l10n_th_withholding_tax.pot | 11 -- l10n_th_withholding_tax/models/__init__.py | 3 + .../models/account_move.py | 58 +++++- .../models/account_payment.py | 29 +++ .../models/account_withholding_move.py | 68 +++++++ .../models/account_withholding_tax.py | 29 ++- .../models/personal_income_tax.py | 162 +++++++++++++++++ l10n_th_withholding_tax/models/res_partner.py | 32 ++++ .../readme/CONFIGURATION.rst | 14 +- .../readme/DESCRIPTION.rst | 14 +- .../security/ir.model.access.csv | 5 + l10n_th_withholding_tax/tests/__init__.py | 1 + .../tests/test_withholding_tax_pit.py | 169 ++++++++++++++++++ .../views/account_payment.xml | 29 +++ .../views/account_withholding_move.xml | 92 ++++++++++ .../views/account_withholding_tax.xml | 18 +- .../views/personal_income_tax_view.xml | 72 ++++++++ .../views/res_partner_view.xml | 29 +++ .../wizard/account_payment_register.py | 104 +++++++++-- .../wizard/account_payment_register_views.xml | 1 + 22 files changed, 975 insertions(+), 41 deletions(-) create mode 100644 l10n_th_withholding_tax/data/pit_rate_data.xml create mode 100644 l10n_th_withholding_tax/models/account_withholding_move.py create mode 100644 l10n_th_withholding_tax/models/personal_income_tax.py create mode 100644 l10n_th_withholding_tax/models/res_partner.py create mode 100644 l10n_th_withholding_tax/tests/test_withholding_tax_pit.py create mode 100644 l10n_th_withholding_tax/views/account_payment.xml create mode 100644 l10n_th_withholding_tax/views/account_withholding_move.xml create mode 100644 l10n_th_withholding_tax/views/personal_income_tax_view.xml create mode 100644 l10n_th_withholding_tax/views/res_partner_view.xml diff --git a/l10n_th_withholding_tax/__manifest__.py b/l10n_th_withholding_tax/__manifest__.py index 0375a8b1a..478867fc3 100644 --- a/l10n_th_withholding_tax/__manifest__.py +++ b/l10n_th_withholding_tax/__manifest__.py @@ -10,12 +10,17 @@ "category": "Localization / Accounting", "depends": ["account"], "data": [ + "data/pit_rate_data.xml", "security/ir.model.access.csv", "wizard/account_payment_register_views.xml", "views/account_view.xml", "views/account_move_view.xml", "views/account_withholding_tax.xml", "views/product_view.xml", + "views/account_payment.xml", + "views/account_withholding_move.xml", + "views/personal_income_tax_view.xml", + "views/res_partner_view.xml", ], "installable": True, "development_status": "Beta", diff --git a/l10n_th_withholding_tax/data/pit_rate_data.xml b/l10n_th_withholding_tax/data/pit_rate_data.xml new file mode 100644 index 000000000..6aac4ee7f --- /dev/null +++ b/l10n_th_withholding_tax/data/pit_rate_data.xml @@ -0,0 +1,71 @@ + + + + + + + + + 1 + 0.0 + 150000.0 + 0.0 + + + + + 2 + 150000.0 + 300000.0 + 5.0 + + + + + 3 + 300000.0 + 500000.0 + 10.0 + + + + + 4 + 500000.0 + 750000.0 + 15.0 + + + + + 5 + 750000.0 + 1000000.0 + 20.0 + + + + + 6 + 1000000.0 + 2000000.0 + 25.0 + + + + + 7 + 2000000.0 + 4000000.0 + 30.0 + + + + + 7 + 4000000.0 + 99999999999.0 + 35.0 + + + diff --git a/l10n_th_withholding_tax/i18n/l10n_th_withholding_tax.pot b/l10n_th_withholding_tax/i18n/l10n_th_withholding_tax.pot index 2f7e127d4..a6f1e02bc 100644 --- a/l10n_th_withholding_tax/i18n/l10n_th_withholding_tax.pot +++ b/l10n_th_withholding_tax/i18n/l10n_th_withholding_tax.pot @@ -28,11 +28,6 @@ msgstr "" msgid "Amount" msgstr "" -#. module: l10n_th_withholding_tax -#: model:ir.model.fields,help:l10n_th_withholding_tax.field_account_payment_register__wt_amount_base -msgid "Based amount for the tax amount" -msgstr "" - #. module: l10n_th_withholding_tax #: model:ir.model.fields,field_description:l10n_th_withholding_tax.field_account_withholding_tax__create_uid msgid "Created by" @@ -149,12 +144,6 @@ msgstr "" msgid "WT Account" msgstr "" -#. module: l10n_th_withholding_tax -#: model:ir.model.fields,field_description:l10n_th_withholding_tax.field_account_payment_register__wt_amount_base -#: model_terms:ir.ui.view,arch_db:l10n_th_withholding_tax.view_account_payment_register_form -msgid "Withholding Base" -msgstr "" - #. module: l10n_th_withholding_tax #: model:ir.actions.act_window,name:l10n_th_withholding_tax.action_account_withholding_tax_menu #: model:ir.model.fields,field_description:l10n_th_withholding_tax.field_account_payment__wt_tax_id diff --git a/l10n_th_withholding_tax/models/__init__.py b/l10n_th_withholding_tax/models/__init__.py index 3250fc91a..6ad8e3bfa 100644 --- a/l10n_th_withholding_tax/models/__init__.py +++ b/l10n_th_withholding_tax/models/__init__.py @@ -4,3 +4,6 @@ from . import account_payment from . import account_move from . import account_withholding_tax +from . import personal_income_tax +from . import account_withholding_move +from . import res_partner diff --git a/l10n_th_withholding_tax/models/account_move.py b/l10n_th_withholding_tax/models/account_move.py index ed746338b..4dc82fe08 100644 --- a/l10n_th_withholding_tax/models/account_move.py +++ b/l10n_th_withholding_tax/models/account_move.py @@ -1,6 +1,8 @@ # Copyright 2020 Ecosoft Co., Ltd (https://ecosoft.co.th/) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.misc import format_date class AccountMoveLine(models.Model): @@ -45,12 +47,50 @@ def _get_wt_base_amount(self, currency, currency_date): ) return wt_base_amount - def _get_wt_amount(self, currency, currency_date): + def _get_wt_amount(self, currency, wt_date): """ Calculate withholding tax and base amount based on currency """ - amount_base = 0 - amount_wt = 0 - for line in self: - base_amount = line._get_wt_base_amount(currency, currency_date) - amount_wt += line.wt_tax_id.amount / 100 * base_amount - amount_base += base_amount - return (amount_base, amount_wt) + wt_lines = self.filtered("wt_tax_id") + pit_lines = wt_lines.filtered("wt_tax_id.is_pit") + wht_lines = wt_lines - pit_lines + # Mixing PIT and WHT or > 1 type, no auto deduct + if pit_lines and wht_lines: + return (0, 0) + # WHT + if wht_lines: + wht_tax = wht_lines.mapped("wt_tax_id") + if len(wht_tax) != 1: + return (0, 0) + amount_base = 0 + amount_wt = 0 + for line in wht_lines: + base_amount = line._get_wt_base_amount(currency, wt_date) + amount_wt += line.wt_tax_id.amount / 100 * base_amount + amount_base += base_amount + return (amount_base, amount_wt) + # PIT + if pit_lines: + pit_tax = pit_lines.mapped("wt_tax_id") + pit_tax.ensure_one() + move_lines = self.filtered(lambda l: l.wt_tax_id == pit_tax) + amount_invoice_currency = sum(move_lines.mapped("amount_currency")) + move = move_lines[0] + company = move.company_id + partner = move.partner_id + # Convert invoice currency to payment currency + amount_base = move.currency_id._convert( + amount_invoice_currency, currency, company, wt_date + ) + effective_pit = pit_tax.with_context(pit_date=wt_date).pit_id + if not effective_pit: + raise UserError( + _("No effective PIT rate for date %s") + % format_date(self.env, wt_date) + ) + amount_wt = effective_pit._compute_expected_wt( + partner, + amount_base, + pit_date=wt_date, + currency=currency, + company=company, + ) + return (amount_base, amount_wt) diff --git a/l10n_th_withholding_tax/models/account_payment.py b/l10n_th_withholding_tax/models/account_payment.py index ec5b834ab..059559dc6 100644 --- a/l10n_th_withholding_tax/models/account_payment.py +++ b/l10n_th_withholding_tax/models/account_payment.py @@ -9,5 +9,34 @@ class AccountPayment(models.Model): wt_tax_id = fields.Many2one( comodel_name="account.withholding.tax", string="Withholding Tax", + copy=False, help="Optional hidden field to keep wt_tax. Useful for case 1 tax only", ) + wht_move_ids = fields.One2many( + comodel_name="account.withholding.move", + inverse_name="payment_id", + string="Withholding", + copy=False, + help="All withholding moves, including non-PIT", + ) + pit_move_ids = fields.One2many( + comodel_name="account.withholding.move", + inverse_name="payment_id", + string="Personal Income Tax", + domain=[("is_pit", "=", True)], + copy=False, + ) + + def action_cancel(self): + res = super().action_cancel() + for payment in self: + # Create the mirror only for those posted + for line in payment.wht_move_ids: + line.copy( + { + "amount_income": -line.amount_income, + "amount_wt": -line.amount_wt, + } + ) + line.cancelled = True + return res diff --git a/l10n_th_withholding_tax/models/account_withholding_move.py b/l10n_th_withholding_tax/models/account_withholding_move.py new file mode 100644 index 000000000..b2fb722ce --- /dev/null +++ b/l10n_th_withholding_tax/models/account_withholding_move.py @@ -0,0 +1,68 @@ +# Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +from odoo.addons.l10n_th_withholding_tax_cert.models.withholding_tax_cert import ( + WHT_CERT_INCOME_TYPE, +) + + +class PersonalIncomeTaxMove(models.Model): + _name = "account.withholding.move" + _description = "Personal Income Tax Move" + + payment_id = fields.Many2one( + comodel_name="account.payment", + string="Payment", + index=True, + required=True, + ondelete="cascade", + domain=[("state", "not in", ["draft", "cancel"])], + ) + payment_state = fields.Selection(related="payment_id.state") + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Vendor", + index=True, + required=True, + ondelete="cascade", + ) + cancelled = fields.Boolean(readonly=True, help="For filtering cancelled payment") + date = fields.Date( + compute="_compute_date", + store=True, + ) + calendar_year = fields.Char( + string="Calendar Year", + compute="_compute_date", + store=True, + index=True, + ) + amount_income = fields.Monetary( + string="Income", + required=True, + ) + amount_wt = fields.Monetary(string="Withholding Amount") + wt_tax_id = fields.Many2one( + comodel_name="account.withholding.tax", + index=True, + ) + is_pit = fields.Boolean( + related="wt_tax_id.is_pit", + store=True, + ) + wt_cert_income_type = fields.Selection( + WHT_CERT_INCOME_TYPE, + string="Type of Income", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + default=lambda self: self.env.user.company_id.currency_id, + ) + + @api.depends("payment_id") + def _compute_date(self): + for rec in self: + rec.date = rec.payment_id and rec.payment_id.date or False + rec.calendar_year = rec.date and rec.date.strftime("%Y") diff --git a/l10n_th_withholding_tax/models/account_withholding_tax.py b/l10n_th_withholding_tax/models/account_withholding_tax.py index 3ee6ea7d8..bfcb19e54 100644 --- a/l10n_th_withholding_tax/models/account_withholding_tax.py +++ b/l10n_th_withholding_tax/models/account_withholding_tax.py @@ -17,11 +17,38 @@ class AccountWithholdingTax(models.Model): ondelete="restrict", ) amount = fields.Float( - string="Amount", + string="Percent", ) + is_pit = fields.Boolean( + string="Personal Income Tax", + help="As PIT, the calculation of withholding amount is based on pit.rate", + ) + pit_id = fields.Many2one( + comodel_name="personal.income.tax", + string="PIT Rate", + compute="_compute_pit_id", + help="Latest PIT Rates used to calcuate withholiding amount", + ) + _sql_constraints = [ + ("name_unique", "UNIQUE(name)", "Name must be unique!"), + ] + + @api.constrains("is_pit") + def _check_is_pit(self): + pits = self.search_count([("is_pit", "=", True)]) + if pits > 1: + raise ValidationError(_("Only 1 personal income tax allowed!")) @api.constrains("account_id") def _check_account_id(self): for rec in self: if rec.account_id and not rec.account_id.wt_account: raise ValidationError(_("Selected account is not for withholding tax")) + + @api.depends("is_pit") + def _compute_pit_id(self): + pit_date = self.env.context.get("pit_date") or fields.Date.context_today(self) + pit = self.env["personal.income.tax"].search( + [("effective_date", "<=", pit_date)], order="effective_date desc", limit=1 + ) + self.update({"pit_id": pit.id}) diff --git a/l10n_th_withholding_tax/models/personal_income_tax.py b/l10n_th_withholding_tax/models/personal_income_tax.py new file mode 100644 index 000000000..463b0890c --- /dev/null +++ b/l10n_th_withholding_tax/models/personal_income_tax.py @@ -0,0 +1,162 @@ +# Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class PersonalIncomeTax(models.Model): + _name = "personal.income.tax" + _description = "PIT Table" + _rec_name = "calendar_year" + + calendar_year = fields.Char( + required=True, + default=lambda self: fields.Date.context_today(self).strftime("%Y"), + ) + effective_date = fields.Date( + string="Effective Date", + compute="_compute_effective_date", + store=True, + ) + rate_ids = fields.One2many( + comodel_name="personal.income.tax.rate", + inverse_name="pit_id", + string="Withholding Tax Rates", + ) + active = fields.Boolean(default=True) + + @api.depends("calendar_year") + def _compute_effective_date(self): + for rec in self: + rec.effective_date = "{}-01-01".format(rec.calendar_year) + + @api.constrains("rate_ids") + def _check_rate_ids(self): + for rec in self: + prev_income_to = 0.0 + for i, rate in enumerate(rec.rate_ids): + if i == 0 and rate.income_from != 0.0: + raise UserError(_("Income amount must start from 0.0")) + if i > 0 and float_compare(rate.income_from, prev_income_to, 2) != 0: + raise UserError( + _( + "Discontinued income range!\n" + "Please make sure Income From = Previous Income To" + ) + ) + prev_income_to = rate.income_to + + def _compute_total_wt(self, rate_amount, tax_rate): + return rate_amount * (tax_rate / 100) + + def calculate_rate_wt(self, total_income, income, pit_date=False): + self.ensure_one() + if not pit_date: + pit_date = fields.Date.context_today(self) + pit_date.strftime("%Y") + rate_ranges = self.rate_ids.filtered( + lambda l: abs(total_income) > l.income_from + ) + current_amount = 0.0 + income_residual = income + total_income_residual = total_income + for rate_range in reversed(rate_ranges): + rate_amount = total_income_residual - rate_range.income_from + if income > rate_amount and income_residual >= rate_amount: + total_income_residual -= rate_amount + income_residual -= rate_amount + current_amount += self._compute_total_wt( + rate_amount, rate_range.tax_rate + ) + else: + current_amount += self._compute_total_wt( + income_residual, rate_range.tax_rate + ) + break + return current_amount + + def _compute_expected_wt( + self, partner, base_amount, pit_date=False, currency=False, company=False + ): + """Calc PIT amount of a partner's yearly income + - if pit_date=False, pit_date is today + - if currency=False, base_amount, and expected_wt are in company currency + - if company=False, company is currency user's company + """ + self.ensure_one() + # Setup default values + if not pit_date: + pit_date = fields.Date.context_today(self) + if not company: + company = self.env.company + if not currency: + currency = company.currency_id + pit_amount_year = self._get_pit_amount_yearly(partner, pit_date=pit_date) + # From currency to company currency + base_amount = currency._convert( + base_amount, company.currency_id, company, pit_date + ) + # Calculate PIT amount from PIT Rate Table + total_pit = pit_amount_year + base_amount + expected_wt = self.calculate_rate_wt(total_pit, base_amount, pit_date=pit_date) + # From company currency to currency of base_amount + expected_wt = company.currency_id._convert( + expected_wt, currency, company, pit_date + ) + return expected_wt + + @api.model + def _get_pit_amount_yearly(self, partner, pit_date=False): + if not pit_date: + pit_date = fields.Date.context_today(self) + calendar_year = pit_date.strftime("%Y") + pit_year = partner.pit_move_ids.filtered( + lambda l: l.calendar_year == calendar_year + ) + return sum(pit_year.mapped("amount_income")) + + +class PersonalIncomeTaxRate(models.Model): + _name = "personal.income.tax.rate" + _description = "PIT Rates" + _order = "sequence, id" + + sequence = fields.Integer(string="sequence") + pit_id = fields.Many2one( + comodel_name="personal.income.tax", + string="PIT Table", + ondelete="cascade", + index=True, + ) + income_from = fields.Float( + string="Income From (>)", + ) + income_to = fields.Float( + string="Income To (<=)", + ) + tax_rate = fields.Float( + string="Tax Rate (%)", + ) + amount_tax_max = fields.Float( + string="Maximum Tax in Range", + compute="_compute_amount_tax_max", + store=True, + ) + amount_tax_accum = fields.Float( + string="Tax Accumulate", + compute="_compute_amount_accum", + ) + + @api.depends("income_from", "income_to", "tax_rate") + def _compute_amount_tax_max(self): + for rec in self: + rec.amount_tax_max = (rec.income_to - rec.income_from) * ( + rec.tax_rate / 100 + ) + + def _compute_amount_accum(self): + for rec in self: + prev_rec = self.filtered(lambda l: l.sequence <= rec.sequence) + rec.amount_tax_accum = sum(prev_rec.mapped("amount_tax_max")) diff --git a/l10n_th_withholding_tax/models/res_partner.py b/l10n_th_withholding_tax/models/res_partner.py new file mode 100644 index 000000000..b884e426d --- /dev/null +++ b/l10n_th_withholding_tax/models/res_partner.py @@ -0,0 +1,32 @@ +# Copyright 2021 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + pit_move_ids = fields.One2many( + comodel_name="account.withholding.move", + inverse_name="partner_id", + string="Personal Income Tax", + domain=[("is_pit", "=", True), ("payment_state", "!=", "draft")], + ) + + def _get_context_pit_monitoring(self): + ctx = self.env.context.copy() + ctx.update({"search_default_group_by_calendar_year": 1}) + return ctx + + def action_view_pit_move_yearly_summary(self): + ctx = self._get_context_pit_monitoring() + domain = [("is_pit", "=", True), ("partner_id", "=", self.id)] + return { + "name": _("Personal Income Tax Yearly"), + "res_model": "account.withholding.move", + "view_mode": "pivot,tree,graph", + "context": ctx, + "domain": domain, + "type": "ir.actions.act_window", + } diff --git a/l10n_th_withholding_tax/readme/CONFIGURATION.rst b/l10n_th_withholding_tax/readme/CONFIGURATION.rst index b40db9920..eae053219 100644 --- a/l10n_th_withholding_tax/readme/CONFIGURATION.rst +++ b/l10n_th_withholding_tax/readme/CONFIGURATION.rst @@ -5,10 +5,22 @@ when create certificate from payment. * Search for withholding tax related account * Check "WT Account" * Go to Invoicing > Configuration > Invoicing > Withholding Tax -* Create withholding tax type +* Create withholding Tax (normal and personal income tax) **Configured Withholding Tax by Product (optional)** * Go to Invoicing > Customers or Vendors > Products * Select product that you need withholding tax * Add Withholding Tax on General Information Tab (Invoices) / Purchase Tab (Bills) + +**Configure Personal Income Tax Rate** + +#. Go to Invoicing > Configuration > Accounting > PIT Rate +#. Change range or tax rate (if any) + +Configure PIT Withholding Tax as following, + +#. Go to Invoicing > Configuration > Invoicing > Withholing Tax +#. Create new Withholding Tax for PIT and check Personal Income Tax checkbox +#. PIT Rate that will be used to caclulate the withholding amount will be shown +#. You can set default Withholding Tax on the Product's Purchase Tab diff --git a/l10n_th_withholding_tax/readme/DESCRIPTION.rst b/l10n_th_withholding_tax/readme/DESCRIPTION.rst index f964e7e62..a1292bdc4 100644 --- a/l10n_th_withholding_tax/readme/DESCRIPTION.rst +++ b/l10n_th_withholding_tax/readme/DESCRIPTION.rst @@ -1,2 +1,12 @@ -This module allow user to register payment and -compute amount include withholding tax automatic. +This module now support both withholding tax and personal income tax. + +Withholding Tax is tax to be withhold when register payment. Withholding tax is calculated by percent. + +Personal Income Tax is just another type of Withholding Tax, with some difference, + +1. The amount to be withheld is calculated base on progressive rate (PIT Rate table) +2. The witholding amount is saved in account.withholding.move table, and used on next calc. +3. Normally, the certification will be printed out at the end of year, for a person (partner) + +Reference: +https://www.rd.go.th/fileadmin/user_upload/AEC/AseanTax-Thailand.pdf diff --git a/l10n_th_withholding_tax/security/ir.model.access.csv b/l10n_th_withholding_tax/security/ir.model.access.csv index f9e4b40a8..a0a15e305 100644 --- a/l10n_th_withholding_tax/security/ir.model.access.csv +++ b/l10n_th_withholding_tax/security/ir.model.access.csv @@ -1,2 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_account_withholding_tax,account.withholding.tax,model_account_withholding_tax,account.group_account_invoice,1,1,1,1 +access_personal_income_tax,access_personal_income_tax,model_personal_income_tax,account.group_account_user,1,1,1,1 +access_personal_income_tax_user,access_personal_income_tax_user,model_personal_income_tax,,1,0,0,0 +access_personal_income_tax_rate,access_personal_income_tax_rate,model_personal_income_tax_rate,account.group_account_user,1,1,1,1 +access_personal_income_tax_rate_user,access_personal_income_tax_rate_user,model_personal_income_tax_rate,,1,0,0,0 +access_account_withholding_move,access_account_withholding_move,model_account_withholding_move,account.group_account_invoice,1,1,1,1 diff --git a/l10n_th_withholding_tax/tests/__init__.py b/l10n_th_withholding_tax/tests/__init__.py index c22976353..57a7ea7d0 100644 --- a/l10n_th_withholding_tax/tests/__init__.py +++ b/l10n_th_withholding_tax/tests/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) from . import test_withholding_tax +from . import test_withholding_tax_pit diff --git a/l10n_th_withholding_tax/tests/test_withholding_tax_pit.py b/l10n_th_withholding_tax/tests/test_withholding_tax_pit.py new file mode 100644 index 000000000..00181967a --- /dev/null +++ b/l10n_th_withholding_tax/tests/test_withholding_tax_pit.py @@ -0,0 +1,169 @@ +# Copyright 2021 Ecosoft Co., Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import datetime + +from freezegun import freeze_time + +from odoo import fields +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import Form, TransactionCase + + +class TestWithholdingTaxPIT(TransactionCase): + @freeze_time("2001-02-01") + def setUp(self): + super(TestWithholdingTaxPIT, self).setUp() + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + self.product = self.env["product.product"].create( + {"name": "Test", "standard_price": 500.0} + ) + self.RegisterPayment = self.env["account.payment.register"] + # Setup PIT withholding tax + self.account_pit = self.env["account.account"].create( + { + "code": "100", + "name": "Personal Income Tax", + "user_type_id": self.env.ref( + "account.data_account_type_current_assets" + ).id, + "wt_account": True, + } + ) + self.wt_pit = self.env["account.withholding.tax"].create( + { + "name": "PIT", + "account_id": self.account_pit.id, + "is_pit": True, + } + ) + + def _create_pit(self, calendar_year): + """ Create a simple PIT rate table """ + with Form(self.env["personal.income.tax"]) as f: + f.calendar_year = calendar_year + with f.rate_ids.new() as rate: + rate.income_from = 0 + rate.income_to = 1000 + rate.tax_rate = 0 + with f.rate_ids.new() as rate: + rate.income_from = 1000 + rate.income_to = 2000 + rate.tax_rate = 2 + with f.rate_ids.new() as rate: + rate.income_from = 2000 + rate.income_to = 9999999999999 + rate.tax_rate = 4 + return f.save() + + @freeze_time("2001-02-01") + def _create_invoice(self, data): + """Create test invoice + data = [{"amount": 1, "pit": True}, ...] + """ + move_form = Form( + self.env["account.move"].with_context( + default_move_type="in_invoice", check_move_validity=False + ) + ) + move_form.invoice_date = fields.Date.context_today(self.env.user) + move_form.partner_id = self.partner + for line in data: + with move_form.invoice_line_ids.new() as line_form: + line_form.product_id = self.product + line_form.price_unit = line["amount"] + line_form.wt_tax_id = ( + line["pit"] and self.wt_pit or self.env["account.withholding.tax"] + ) + for i in range(len(line_form.tax_ids)): + line_form.tax_ids.remove(index=i) + return move_form.save() + + def test_00_pit_tax(self): + """ No 2 PIT Tax allowed """ + with self.assertRaises(ValidationError): + self.wt_pit = self.env["account.withholding.tax"].create( + { + "name": "PIT2", + "account_id": self.account_pit.id, + "is_pit": True, + } + ) + + @freeze_time("2001-02-01") + def test_01_pit_rate(self): + """ Test PIT Rate table """ + # Create an effective PIT Rate + self.pit_rate = self._create_pit("2001") + # Test effective date + self.assertEqual(self.pit_rate.calendar_year, "2001") + self.assertEqual(self.pit_rate.effective_date, datetime.date(2001, 1, 1)) + # First rate must be zero + with self.assertRaises(UserError): + with Form(self.pit_rate) as pit_rate: + with pit_rate.rate_ids.edit(0) as rate: + rate.income_from = 1 + # income_to must equal previous income_from + with self.assertRaises(UserError): + with Form(self.pit_rate) as pit_rate: + with pit_rate.rate_ids.edit(1) as rate: + rate.income_from = 1001 + + @freeze_time("2001-02-01") + def test_02_withholding_tax_pit(self): + """Create 3 Invoice/Payment, and check validity of amount + Based on pit_rate table, + - 1st invoice = 500, withhold = 0 + - 2nd invoice = 1000, withhold = 500*0.02 = 10 + - 3nd invoice = 1000, withhold = 500*0.02 + 500*0.04 = 30 + Then, create withholding tax cert for year 2001, total withholding = 40 + """ + # 1st invoice + data = [{"amount": 500, "pit": True}, {"amount": 1500, "pit": False}] + self.invoice = self._create_invoice(data) + self.invoice.action_post() + res = self.invoice.action_register_payment() + # Register payment, without PIT rate yet + with self.assertRaises(UserError): + form = Form(self.RegisterPayment.with_context(res["context"])) + # Create an effective PIT Rate, and try again. + self.pit_rate = self._create_pit("2001") + with Form(self.RegisterPayment.with_context(res["context"])) as f: + f.wt_tax_id = self.wt_pit # Test refreshing wt_tax_id + wizard = f.save() + wizard.action_create_payments() + # PIT created but not PIT amount yet. + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_income")), 500) + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_wt")), 0) + + # 2nd invoice + data = [{"amount": 1000, "pit": True}] + self.invoice = self._create_invoice(data) + self.invoice.action_post() + res = self.invoice.action_register_payment() + form = Form(self.RegisterPayment.with_context(res["context"])) + wizard = form.save() + wizard.action_create_payments() + # Sum up amount_income and withholding amount = 10 + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_income")), 1500) + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_wt")), 10) + + # 3nd invoice + data = [{"amount": 1000, "pit": True}] + self.invoice = self._create_invoice(data) + self.invoice.action_post() + res = self.invoice.action_register_payment() + form = Form(self.RegisterPayment.with_context(res["context"])) + wizard = form.save() + res = wizard.action_create_payments() + # Sum up amount_income and withholding amount = 10 + 30 = 40 + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_income")), 2500) + self.assertEqual(sum(self.partner.pit_move_ids.mapped("amount_wt")), 40) + # Cancel payment + payment = self.env[res["res_model"]].browse(res["res_id"]) + self.assertEqual(sum(payment.pit_move_ids.mapped("amount_wt")), 30) + payment.action_cancel() + self.assertEqual(sum(payment.pit_move_ids.mapped("amount_wt")), 0) + # Test calling report for this partner, to get remaining = 10 + res = self.partner.action_view_pit_move_yearly_summary() + moves = self.env[res["res_model"]].search(res["domain"]) + self.assertEqual(sum(moves.mapped("amount_wt")), 10) diff --git a/l10n_th_withholding_tax/views/account_payment.xml b/l10n_th_withholding_tax/views/account_payment.xml new file mode 100644 index 000000000..702a9a89e --- /dev/null +++ b/l10n_th_withholding_tax/views/account_payment.xml @@ -0,0 +1,29 @@ + + + + account.view.account.payment.form + account.payment + + + + + + + + + + + + + + + + + + + + diff --git a/l10n_th_withholding_tax/views/account_withholding_move.xml b/l10n_th_withholding_tax/views/account_withholding_move.xml new file mode 100644 index 000000000..ed91613b6 --- /dev/null +++ b/l10n_th_withholding_tax/views/account_withholding_move.xml @@ -0,0 +1,92 @@ + + + + view.account.withholding.move.tree + account.withholding.move + + + + + + + + + + + + + + + + + view.account.withholding.move.pivot + account.withholding.move + + + + + + + + + + + view.account.withholding.move.graph + account.withholding.move + + + + + + + + + + view.account.withholding.move.search + account.withholding.move + + + + + + + + + + + + + + + Personal Income Tax Move + account.withholding.move + pivot,tree,graph + [('is_pit', '=', True)] + {'group_by':[], 'group_by_no_leaf':1} + + + + + diff --git a/l10n_th_withholding_tax/views/account_withholding_tax.xml b/l10n_th_withholding_tax/views/account_withholding_tax.xml index 45e74dd33..1e1d5d5e2 100644 --- a/l10n_th_withholding_tax/views/account_withholding_tax.xml +++ b/l10n_th_withholding_tax/views/account_withholding_tax.xml @@ -8,17 +8,25 @@ - + diff --git a/l10n_th_withholding_tax/views/personal_income_tax_view.xml b/l10n_th_withholding_tax/views/personal_income_tax_view.xml new file mode 100644 index 000000000..566f9b21f --- /dev/null +++ b/l10n_th_withholding_tax/views/personal_income_tax_view.xml @@ -0,0 +1,72 @@ + + + + view.personal.income.tax.tree + personal.income.tax + + + + + + + + + + view.personal.income.tax.form + personal.income.tax + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + PIT Rate Table + personal.income.tax + tree,form + + + +
diff --git a/l10n_th_withholding_tax/views/res_partner_view.xml b/l10n_th_withholding_tax/views/res_partner_view.xml new file mode 100644 index 000000000..caa82b53c --- /dev/null +++ b/l10n_th_withholding_tax/views/res_partner_view.xml @@ -0,0 +1,29 @@ + + + + res.partner.property.form.inherit + res.partner + + + + +
+
+ + + +
+
+
+
+
diff --git a/l10n_th_withholding_tax/wizard/account_payment_register.py b/l10n_th_withholding_tax/wizard/account_payment_register.py index 20426972c..594921001 100644 --- a/l10n_th_withholding_tax/wizard/account_payment_register.py +++ b/l10n_th_withholding_tax/wizard/account_payment_register.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.tools.misc import format_date class AccountPaymentRegister(models.TransientModel): @@ -20,10 +21,59 @@ class AccountPaymentRegister(models.TransientModel): @api.onchange("wt_tax_id", "wt_amount_base") def _onchange_wt_tax_id(self): if self.wt_tax_id and self.wt_amount_base: - amount_wt = self.wt_tax_id.amount / 100 * self.wt_amount_base - self.amount = self.source_amount_currency - amount_wt - self.writeoff_account_id = self.wt_tax_id.account_id - self.writeoff_label = self.wt_tax_id.display_name + if self.wt_tax_id.is_pit: + self._onchange_pit() + else: + self._onchange_wht() + + def _onchange_wht(self): + """ Onchange set for normal withholding tax """ + amount_wt = self.wt_tax_id.amount / 100 * self.wt_amount_base + amount_currency = self.company_id.currency_id._convert( + self.source_amount, + self.currency_id, + self.company_id, + self.payment_date, + ) + self.amount = amount_currency - amount_wt + self.writeoff_account_id = self.wt_tax_id.account_id + self.writeoff_label = self.wt_tax_id.display_name + + def _onchange_pit(self): + """ Onchange set for personal income tax """ + if not self.wt_tax_id.pit_id: + raise UserError( + _("No effective PIT rate for date %s") + % format_date(self.env, self.payment_date) + ) + amount_base_company = self.currency_id._convert( + self.wt_amount_base, + self.company_id.currency_id, + self.company_id, + self.payment_date, + ) + amount_pit_company = self.wt_tax_id.pit_id._compute_expected_wt( + self.partner_id, + amount_base_company, + pit_date=self.payment_date, + currency=self.company_id.currency_id, + company=self.company_id, + ) + amount_pit = self.company_id.currency_id._convert( + amount_pit_company, + self.currency_id, + self.company_id, + self.payment_date, + ) + amount_currency = self.company_id.currency_id._convert( + self.source_amount, + self.currency_id, + self.company_id, + self.payment_date, + ) + self.amount = amount_currency - amount_pit + self.writeoff_account_id = self.wt_tax_id.account_id + self.writeoff_label = self.wt_tax_id.display_name def _create_payment_vals_from_wizard(self): payment_vals = super()._create_payment_vals_from_wizard() @@ -32,12 +82,14 @@ def _create_payment_vals_from_wizard(self): payment_vals.update({"wt_tax_id": self.wt_tax_id.id}) return payment_vals - def _update_payment_register(self, amount_base, amount_wt, inv_lines): + def _update_payment_register(self, amount_base, amount_wt, wt_move_lines): self.ensure_one() + if not amount_base: + return False self.amount -= amount_wt self.wt_amount_base = amount_base self.payment_difference_handling = "reconcile" - wt_tax = inv_lines.mapped("wt_tax_id") + wt_tax = wt_move_lines.mapped("wt_tax_id") if wt_tax and len(wt_tax) == 1: self.wt_tax_id = wt_tax self.writeoff_account_id = self.wt_tax_id.account_id @@ -58,16 +110,15 @@ def _compute_amount(self): if self._context.get("active_model") == "account.move": active_ids = self._context.get("active_ids", []) invoices = self.env["account.move"].browse(active_ids) - move_lines = invoices.mapped("line_ids").filtered("wt_tax_id") - if not move_lines: + wt_move_lines = invoices.mapped("line_ids").filtered("wt_tax_id") + if not wt_move_lines: return res # Case WHT only, ensure only 1 wizard self.ensure_one() - amount_base, amount_wt = move_lines._get_wt_amount( + amount_base, amount_wt = wt_move_lines._get_wt_amount( self.currency_id, self.payment_date ) - if amount_wt: - self._update_payment_register(amount_base, amount_wt, move_lines) + self._update_payment_register(amount_base, amount_wt, wt_move_lines) return res @api.model @@ -97,4 +148,33 @@ def _create_payments(self): "with multiple invoices that has withholding tax." ) ) - return super()._create_payments() + payments = super()._create_payments() + # Create account.withholding.move from table multi deduction + if ( + self.payment_difference_handling == "reconcile" + and self.group_payment + and self.wt_tax_id + ): + vals = self._prepare_withholding_move( + self.partner_id, + self.payment_date, + self.wt_tax_id, + self.wt_amount_base, + self.payment_difference, + self.currency_id, + self.company_id, + ) + payments[0].write({"wht_move_ids": [(0, 0, vals)]}) + return payments + + def _prepare_withholding_move( + self, partner, date, wt_tax, base, amount, currency, company + ): + amount_income = currency._convert(base, company.currency_id, company, date) + amount_wt = currency._convert(amount, company.currency_id, company, date) + return { + "partner_id": partner.id, + "amount_income": amount_income, + "wt_tax_id": wt_tax.id, + "amount_wt": amount_wt, + } diff --git a/l10n_th_withholding_tax/wizard/account_payment_register_views.xml b/l10n_th_withholding_tax/wizard/account_payment_register_views.xml index c075d316e..6ea9546f9 100644 --- a/l10n_th_withholding_tax/wizard/account_payment_register_views.xml +++ b/l10n_th_withholding_tax/wizard/account_payment_register_views.xml @@ -3,6 +3,7 @@ account.payment.register.form account.payment.register +