From 6e016d19e244f4d1330fdc9a36ea86ba0a987b9f Mon Sep 17 00:00:00 2001 From: Kitti U Date: Mon, 13 Dec 2021 16:52:15 +0700 Subject: [PATCH 01/98] / --- .pre-commit-config.yaml | 2 + l10n_th_account_tax/__init__.py | 5 + l10n_th_account_tax/__manifest__.py | 31 ++ l10n_th_account_tax/data/pit_rate_data.xml | 71 +++ l10n_th_account_tax/models/__init__.py | 11 + l10n_th_account_tax/models/account.py | 47 ++ l10n_th_account_tax/models/account_move.py | 437 +++++++++++++++ l10n_th_account_tax/models/account_payment.py | 132 +++++ .../models/account_withholding_move.py | 66 +++ .../models/account_withholding_tax.py | 54 ++ .../models/personal_income_tax.py | 164 ++++++ l10n_th_account_tax/models/product.py | 10 + l10n_th_account_tax/models/res_partner.py | 42 ++ .../models/withholding_tax_cert.py | 361 +++++++++++++ l10n_th_account_tax/readme/CONFIGURE.rst | 46 ++ l10n_th_account_tax/readme/CONTRIBUTORS.rst | 5 + l10n_th_account_tax/readme/DESCRIPTION.rst | 9 + l10n_th_account_tax/readme/USAGE.rst | 39 ++ .../security/account_security.xml | 18 + .../security/ir.model.access.csv | 15 + .../static/description/icon.png | Bin 0 -> 9455 bytes l10n_th_account_tax/tests/__init__.py | 6 + l10n_th_account_tax/tests/test_tax_invoice.py | 386 +++++++++++++ .../tests/test_withholding_tax.py | 508 ++++++++++++++++++ .../tests/test_withholding_tax_pit.py | 169 ++++++ .../views/account_move_view.xml | 199 +++++++ .../views/account_payment_view.xml | 104 ++++ l10n_th_account_tax/views/account_view.xml | 32 ++ .../views/account_withholding_move.xml | 92 ++++ .../views/account_withholding_tax.xml | 56 ++ .../views/personal_income_tax_view.xml | 72 +++ l10n_th_account_tax/views/product_view.xml | 16 + .../views/res_partner_view.xml | 40 ++ .../views/withholding_tax_cert.xml | 152 ++++++ l10n_th_account_tax/wizard/__init__.py | 5 + .../wizard/account_payment_register.py | 195 +++++++ .../wizard/account_payment_register_views.xml | 33 ++ .../wizard/create_pit_withholding_tax_cert.py | 72 +++ .../create_pit_withholding_tax_cert.xml | 47 ++ .../wizard/create_withholding_tax_cert.py | 132 +++++ .../wizard/create_withholding_tax_cert.xml | 111 ++++ l10n_th_account_tax_expense/__init__.py | 4 + l10n_th_account_tax_expense/__manifest__.py | 17 + .../models/__init__.py | 4 + .../models/account_move.py | 28 + .../models/hr_expense.py | 43 ++ .../readme/CONTRIBUTORS.rst | 2 + .../readme/DESCRIPTION.rst | 6 + .../static/description/icon.png | Bin 0 -> 9455 bytes l10n_th_account_tax_expense/tests/__init__.py | 3 + .../tests/test_expense_tax_invoice.py | 88 +++ .../tests/test_expense_withholding_tax.py | 128 +++++ .../views/hr_expense_view.xml | 63 +++ .../wizard/__init__.py | 3 + .../wizard/account_payment_register.py | 24 + l10n_th_account_tax_multi/__init__.py | 3 + l10n_th_account_tax_multi/__manifest__.py | 17 + .../readme/CONTRIBUTORS.rst | 5 + .../readme/DESCRIPTION.rst | 1 + l10n_th_account_tax_multi/readme/USAGE.rst | 20 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 448 +++++++++++++++ l10n_th_account_tax_multi/tests/__init__.py | 3 + .../tests/test_withholding_tax_multi.py | 314 +++++++++++ l10n_th_account_tax_multi/wizard/__init__.py | 2 + .../wizard/account_payment_register.py | 163 ++++++ .../wizard/account_payment_register_view.xml | 21 + l10n_th_account_tax_report/__init__.py | 6 + l10n_th_account_tax_report/__manifest__.py | 31 ++ .../data/paper_format.xml | 19 + .../data/report_data.xml | 99 ++++ l10n_th_account_tax_report/models/__init__.py | 3 + .../models/report_withholding_tax.py | 144 +++++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 1 + l10n_th_account_tax_report/readme/USAGE.rst | 5 + .../reports/__init__.py | 6 + .../reports/report_wht_qweb.xml | 167 ++++++ .../reports/report_wht_text.xml | 7 + .../reports/report_wht_xlsx.py | 204 +++++++ .../reports/tax_report.py | 113 ++++ .../reports/tax_report.xml | 195 +++++++ .../reports/tax_report_xlsx.py | 162 ++++++ .../security/ir.model.access.csv | 7 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/src/css/report.css | 71 +++ .../js/l10n_th_account_tax_report_backend.js | 107 ++++ .../src/js/withholding_tax_report_widgets.js | 85 +++ l10n_th_account_tax_report/tests/__init__.py | 5 + .../tests/date_range_test_data.xml | 14 + .../tests/test_tax_report.py | 131 +++++ .../tests/test_wht_cert_report.py | 78 +++ .../views/account_withholding_tax.xml | 9 + l10n_th_account_tax_report/wizard/__init__.py | 5 + .../wizard/tax_report_wizard.py | 70 +++ .../wizard/tax_report_wizard_view.xml | 65 +++ .../wizard/withholding_tax_report_wizard.py | 77 +++ .../withholding_tax_report_wizard_view.xml | 53 ++ l10n_th_account_wht_cert_form/__init__.py | 3 + l10n_th_account_wht_cert_form/__manifest__.py | 26 + .../data/mail_template.xml | 41 ++ .../data/paper_format.xml | 17 + .../data/withholding_tax_cert_data.xml | 21 + .../models/__init__.py | 3 + .../models/withholding_tax_cert.py | 29 + .../readme/CONTRIBUTORS.rst | 2 + .../readme/DESCRIPTION.rst | 5 + .../readme/USAGE.rst | 2 + .../reports/withholding_tax_cert_form.xml | 17 + .../withholding_tax_cert_form_view.xml | 410 ++++++++++++++ .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/src/img/WithholdingCert.jpg | Bin 0 -> 3543401 bytes .../static/src/scss/style_report.scss | 412 ++++++++++++++ .../tests/__init__.py | 3 + .../tests/test_wht_cert_form.py | 49 ++ l10n_th_company_novat/__manifest__.py | 8 +- l10n_th_company_novat/models/account_move.py | 18 +- l10n_th_company_novat/models/hr_expense.py | 14 +- .../tests/test_company_novat.py | 32 +- .../views/account_move_view.xml | 18 +- .../views/hr_expense_view.xml | 4 +- .../odoo/addons/l10n_th_account_tax | 1 + .../setup.py | 0 .../odoo/addons/l10n_th_account_tax_multi | 1 + setup/l10n_th_account_tax_multi/setup.py | 6 + .../odoo/addons/l10n_th_account_tax_report | 1 + setup/l10n_th_account_tax_report/setup.py | 6 + .../odoo/addons/l10n_th_account_wht_cert_form | 1 + setup/l10n_th_account_wht_cert_form/setup.py | 6 + .../odoo/addons/l10n_th_company_novat | 1 - 130 files changed, 8453 insertions(+), 48 deletions(-) create mode 100644 l10n_th_account_tax/__init__.py create mode 100644 l10n_th_account_tax/__manifest__.py create mode 100644 l10n_th_account_tax/data/pit_rate_data.xml create mode 100644 l10n_th_account_tax/models/__init__.py create mode 100644 l10n_th_account_tax/models/account.py create mode 100644 l10n_th_account_tax/models/account_move.py create mode 100644 l10n_th_account_tax/models/account_payment.py create mode 100644 l10n_th_account_tax/models/account_withholding_move.py create mode 100644 l10n_th_account_tax/models/account_withholding_tax.py create mode 100644 l10n_th_account_tax/models/personal_income_tax.py create mode 100644 l10n_th_account_tax/models/product.py create mode 100644 l10n_th_account_tax/models/res_partner.py create mode 100644 l10n_th_account_tax/models/withholding_tax_cert.py create mode 100644 l10n_th_account_tax/readme/CONFIGURE.rst create mode 100644 l10n_th_account_tax/readme/CONTRIBUTORS.rst create mode 100644 l10n_th_account_tax/readme/DESCRIPTION.rst create mode 100644 l10n_th_account_tax/readme/USAGE.rst create mode 100644 l10n_th_account_tax/security/account_security.xml create mode 100644 l10n_th_account_tax/security/ir.model.access.csv create mode 100644 l10n_th_account_tax/static/description/icon.png create mode 100644 l10n_th_account_tax/tests/__init__.py create mode 100644 l10n_th_account_tax/tests/test_tax_invoice.py create mode 100644 l10n_th_account_tax/tests/test_withholding_tax.py create mode 100644 l10n_th_account_tax/tests/test_withholding_tax_pit.py create mode 100644 l10n_th_account_tax/views/account_move_view.xml create mode 100644 l10n_th_account_tax/views/account_payment_view.xml create mode 100644 l10n_th_account_tax/views/account_view.xml create mode 100644 l10n_th_account_tax/views/account_withholding_move.xml create mode 100644 l10n_th_account_tax/views/account_withholding_tax.xml create mode 100644 l10n_th_account_tax/views/personal_income_tax_view.xml create mode 100644 l10n_th_account_tax/views/product_view.xml create mode 100644 l10n_th_account_tax/views/res_partner_view.xml create mode 100644 l10n_th_account_tax/views/withholding_tax_cert.xml create mode 100644 l10n_th_account_tax/wizard/__init__.py create mode 100644 l10n_th_account_tax/wizard/account_payment_register.py create mode 100644 l10n_th_account_tax/wizard/account_payment_register_views.xml create mode 100644 l10n_th_account_tax/wizard/create_pit_withholding_tax_cert.py create mode 100644 l10n_th_account_tax/wizard/create_pit_withholding_tax_cert.xml create mode 100644 l10n_th_account_tax/wizard/create_withholding_tax_cert.py create mode 100644 l10n_th_account_tax/wizard/create_withholding_tax_cert.xml create mode 100644 l10n_th_account_tax_expense/__init__.py create mode 100644 l10n_th_account_tax_expense/__manifest__.py create mode 100644 l10n_th_account_tax_expense/models/__init__.py create mode 100644 l10n_th_account_tax_expense/models/account_move.py create mode 100644 l10n_th_account_tax_expense/models/hr_expense.py create mode 100644 l10n_th_account_tax_expense/readme/CONTRIBUTORS.rst create mode 100644 l10n_th_account_tax_expense/readme/DESCRIPTION.rst create mode 100644 l10n_th_account_tax_expense/static/description/icon.png create mode 100644 l10n_th_account_tax_expense/tests/__init__.py create mode 100644 l10n_th_account_tax_expense/tests/test_expense_tax_invoice.py create mode 100644 l10n_th_account_tax_expense/tests/test_expense_withholding_tax.py create mode 100644 l10n_th_account_tax_expense/views/hr_expense_view.xml create mode 100644 l10n_th_account_tax_expense/wizard/__init__.py create mode 100644 l10n_th_account_tax_expense/wizard/account_payment_register.py create mode 100644 l10n_th_account_tax_multi/__init__.py create mode 100644 l10n_th_account_tax_multi/__manifest__.py create mode 100644 l10n_th_account_tax_multi/readme/CONTRIBUTORS.rst create mode 100644 l10n_th_account_tax_multi/readme/DESCRIPTION.rst create mode 100644 l10n_th_account_tax_multi/readme/USAGE.rst create mode 100644 l10n_th_account_tax_multi/static/description/icon.png create mode 100644 l10n_th_account_tax_multi/static/description/index.html create mode 100644 l10n_th_account_tax_multi/tests/__init__.py create mode 100644 l10n_th_account_tax_multi/tests/test_withholding_tax_multi.py create mode 100644 l10n_th_account_tax_multi/wizard/__init__.py create mode 100644 l10n_th_account_tax_multi/wizard/account_payment_register.py create mode 100644 l10n_th_account_tax_multi/wizard/account_payment_register_view.xml create mode 100644 l10n_th_account_tax_report/__init__.py create mode 100644 l10n_th_account_tax_report/__manifest__.py create mode 100644 l10n_th_account_tax_report/data/paper_format.xml create mode 100644 l10n_th_account_tax_report/data/report_data.xml create mode 100644 l10n_th_account_tax_report/models/__init__.py create mode 100644 l10n_th_account_tax_report/models/report_withholding_tax.py create mode 100644 l10n_th_account_tax_report/readme/CONTRIBUTORS.rst create mode 100644 l10n_th_account_tax_report/readme/DESCRIPTION.rst create mode 100644 l10n_th_account_tax_report/readme/USAGE.rst create mode 100644 l10n_th_account_tax_report/reports/__init__.py create mode 100644 l10n_th_account_tax_report/reports/report_wht_qweb.xml create mode 100644 l10n_th_account_tax_report/reports/report_wht_text.xml create mode 100644 l10n_th_account_tax_report/reports/report_wht_xlsx.py create mode 100644 l10n_th_account_tax_report/reports/tax_report.py create mode 100644 l10n_th_account_tax_report/reports/tax_report.xml create mode 100644 l10n_th_account_tax_report/reports/tax_report_xlsx.py create mode 100644 l10n_th_account_tax_report/security/ir.model.access.csv create mode 100644 l10n_th_account_tax_report/static/description/icon.png create mode 100644 l10n_th_account_tax_report/static/src/css/report.css create mode 100644 l10n_th_account_tax_report/static/src/js/l10n_th_account_tax_report_backend.js create mode 100644 l10n_th_account_tax_report/static/src/js/withholding_tax_report_widgets.js create mode 100644 l10n_th_account_tax_report/tests/__init__.py create mode 100644 l10n_th_account_tax_report/tests/date_range_test_data.xml create mode 100644 l10n_th_account_tax_report/tests/test_tax_report.py create mode 100644 l10n_th_account_tax_report/tests/test_wht_cert_report.py create mode 100644 l10n_th_account_tax_report/views/account_withholding_tax.xml create mode 100644 l10n_th_account_tax_report/wizard/__init__.py create mode 100644 l10n_th_account_tax_report/wizard/tax_report_wizard.py create mode 100644 l10n_th_account_tax_report/wizard/tax_report_wizard_view.xml create mode 100644 l10n_th_account_tax_report/wizard/withholding_tax_report_wizard.py create mode 100644 l10n_th_account_tax_report/wizard/withholding_tax_report_wizard_view.xml create mode 100644 l10n_th_account_wht_cert_form/__init__.py create mode 100644 l10n_th_account_wht_cert_form/__manifest__.py create mode 100644 l10n_th_account_wht_cert_form/data/mail_template.xml create mode 100644 l10n_th_account_wht_cert_form/data/paper_format.xml create mode 100644 l10n_th_account_wht_cert_form/data/withholding_tax_cert_data.xml create mode 100644 l10n_th_account_wht_cert_form/models/__init__.py create mode 100644 l10n_th_account_wht_cert_form/models/withholding_tax_cert.py create mode 100644 l10n_th_account_wht_cert_form/readme/CONTRIBUTORS.rst create mode 100644 l10n_th_account_wht_cert_form/readme/DESCRIPTION.rst create mode 100644 l10n_th_account_wht_cert_form/readme/USAGE.rst create mode 100644 l10n_th_account_wht_cert_form/reports/withholding_tax_cert_form.xml create mode 100644 l10n_th_account_wht_cert_form/reports/withholding_tax_cert_form_view.xml create mode 100644 l10n_th_account_wht_cert_form/static/description/icon.png create mode 100644 l10n_th_account_wht_cert_form/static/src/img/WithholdingCert.jpg create mode 100644 l10n_th_account_wht_cert_form/static/src/scss/style_report.scss create mode 100644 l10n_th_account_wht_cert_form/tests/__init__.py create mode 100644 l10n_th_account_wht_cert_form/tests/test_wht_cert_form.py create mode 120000 setup/l10n_th_account_tax/odoo/addons/l10n_th_account_tax rename setup/{l10n_th_company_novat => l10n_th_account_tax}/setup.py (100%) create mode 120000 setup/l10n_th_account_tax_multi/odoo/addons/l10n_th_account_tax_multi create mode 100644 setup/l10n_th_account_tax_multi/setup.py create mode 120000 setup/l10n_th_account_tax_report/odoo/addons/l10n_th_account_tax_report create mode 100644 setup/l10n_th_account_tax_report/setup.py create mode 120000 setup/l10n_th_account_wht_cert_form/odoo/addons/l10n_th_account_wht_cert_form create mode 100644 setup/l10n_th_account_wht_cert_form/setup.py delete mode 120000 setup/l10n_th_company_novat/odoo/addons/l10n_th_company_novat diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b58b1be37..4672f1305 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,8 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS + ^l10n_th_account_tax_expense/| + ^l10n_th_company_novat/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| diff --git a/l10n_th_account_tax/__init__.py b/l10n_th_account_tax/__init__.py new file mode 100644 index 000000000..d5a964721 --- /dev/null +++ b/l10n_th_account_tax/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from . import models +from . import wizard diff --git a/l10n_th_account_tax/__manifest__.py b/l10n_th_account_tax/__manifest__.py new file mode 100644 index 000000000..ef3f2e92c --- /dev/null +++ b/l10n_th_account_tax/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +{ + "name": "Thai Localization - VAT and Withholding Tax", + "version": "14.0.1.0.0", + "author": "Ecosoft, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/l10n-thailand", + "category": "Localization / Accounting", + "depends": ["account"], + "data": [ + "data/pit_rate_data.xml", + "security/account_security.xml", + "security/ir.model.access.csv", + "wizard/account_payment_register_views.xml", + "wizard/create_withholding_tax_cert.xml", + "wizard/create_pit_withholding_tax_cert.xml", + "views/account_view.xml", + "views/account_move_view.xml", + "views/withholding_tax_cert.xml", + "views/account_withholding_tax.xml", + "views/account_withholding_move.xml", + "views/product_view.xml", + "views/account_payment_view.xml", + "views/personal_income_tax_view.xml", + "views/res_partner_view.xml", + ], + "installable": True, + "development_status": "Beta", + "maintainers": ["kittiu"], +} diff --git a/l10n_th_account_tax/data/pit_rate_data.xml b/l10n_th_account_tax/data/pit_rate_data.xml new file mode 100644 index 000000000..0f71e910d --- /dev/null +++ b/l10n_th_account_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_account_tax/models/__init__.py b/l10n_th_account_tax/models/__init__.py new file mode 100644 index 000000000..2859192a7 --- /dev/null +++ b/l10n_th_account_tax/models/__init__.py @@ -0,0 +1,11 @@ +# Copyright 2019 Ecosoft Co., Ltd (https://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from . import product +from . import personal_income_tax +from . import withholding_tax_cert +from . import account +from . import account_move +from . import account_payment +from . import account_withholding_tax +from . import account_withholding_move +from . import res_partner diff --git a/l10n_th_account_tax/models/account.py b/l10n_th_account_tax/models/account.py new file mode 100644 index 000000000..1c041ba85 --- /dev/null +++ b/l10n_th_account_tax/models/account.py @@ -0,0 +1,47 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo import api, fields, models + + +class AccountAccount(models.Model): + _inherit = "account.account" + + wht_account = fields.Boolean( + string="WHT Account", + default=False, + help="If check, this account is for withholding tax", + ) + + +class AccountTax(models.Model): + _inherit = "account.tax" + + taxinv_sequence_id = fields.Many2one( + comodel_name="ir.sequence", + string="Tax Invoice Sequence", + help="Optional sequence as Tax Invoice number", + copy=False, + ) + sequence_number_next = fields.Integer( + string="Next Number", + help="The next sequence number will be used for the next tax invoice.", + compute="_compute_seq_number_next", + inverse="_inverse_seq_number_next", + ) + + @api.depends( + "taxinv_sequence_id.use_date_range", "taxinv_sequence_id.number_next_actual" + ) + def _compute_seq_number_next(self): + for tax in self: + if tax.taxinv_sequence_id: + sequence = tax.taxinv_sequence_id._get_current_sequence() + tax.sequence_number_next = sequence.number_next_actual + else: + tax.sequence_number_next = 1 + + def _inverse_seq_number_next(self): + for tax in self: + if tax.taxinv_sequence_id and tax.sequence_number_next: + sequence = tax.taxinv_sequence_id._get_current_sequence() + sequence.sudo().number_next = tax.sequence_number_next diff --git a/l10n_th_account_tax/models/account_move.py b/l10n_th_account_tax/models/account_move.py new file mode 100644 index 000000000..329fba65d --- /dev/null +++ b/l10n_th_account_tax/models/account_move.py @@ -0,0 +1,437 @@ +# Copyright 2019 Ecosoft Co., Ltd (https://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +import calendar +import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare +from odoo.tools.misc import format_date + + +class AccountMoveTaxInvoice(models.Model): + _name = "account.move.tax.invoice" + _description = "Tax Invoice Info" + + tax_invoice_number = fields.Char(string="Tax Invoice Number", copy=False) + tax_invoice_date = fields.Date(string="Tax Invoice Date", copy=False) + report_late_mo = fields.Selection( + [ + ("0", "0 month"), + ("1", "1 month"), + ("2", "2 months"), + ("3", "3 months"), + ("4", "4 months"), + ("5", "5 months"), + ("6", "6 months"), + ], + string="Report Late", + default="0", + required=True, + ) + report_date = fields.Date( + string="Report Date", + compute="_compute_report_date", + store=True, + ) + move_line_id = fields.Many2one( + comodel_name="account.move.line", index=True, copy=True, ondelete="cascade" + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Partner", + ondelete="restrict", + ) + move_id = fields.Many2one(comodel_name="account.move", index=True, copy=True) + move_state = fields.Selection(related="move_id.state", store=True) + payment_id = fields.Many2one( + comodel_name="account.payment", + compute="_compute_payment_id", + store=True, + copy=True, + ) + to_clear_tax = fields.Boolean(related="payment_id.to_clear_tax") + company_id = fields.Many2one( + comodel_name="res.company", related="move_id.company_id", store=True + ) + company_currency_id = fields.Many2one( + comodel_name="res.currency", related="company_id.currency_id" + ) + account_id = fields.Many2one( + comodel_name="account.account", related="move_line_id.account_id" + ) + tax_line_id = fields.Many2one( + comodel_name="account.tax", related="move_line_id.tax_line_id" + ) + tax_base_amount = fields.Monetary( + string="Tax Base", currency_field="company_currency_id", copy=False + ) + balance = fields.Monetary( + string="Tax Amount", currency_field="company_currency_id", copy=False + ) + reversing_id = fields.Many2one( + comodel_name="account.move", help="The move that reverse this move" + ) + reversed_id = fields.Many2one( + comodel_name="account.move", help="This move that this move reverse" + ) + + @api.depends("move_line_id") + def _compute_payment_id(self): + for rec in self: + if not rec.payment_id: + origin_move = rec.move_id.reversed_entry_id + payment = origin_move.tax_invoice_ids.mapped("payment_id") + rec.payment_id = ( + payment and payment.id or self._context.get("payment_id", False) + ) + + @api.depends("report_late_mo", "tax_invoice_date") + def _compute_report_date(self): + for rec in self: + if rec.tax_invoice_date: + eval_date = rec.tax_invoice_date + relativedelta( + months=int(rec.report_late_mo) + ) + last_date = calendar.monthrange(eval_date.year, eval_date.month)[1] + rec.report_date = datetime.date( + eval_date.year, eval_date.month, last_date + ) + else: + rec.report_date = False + + def unlink(self): + """ Do not allow remove the last tax_invoice of move_line """ + line_taxinv = {} + for move_line in self.mapped("move_line_id"): + line_taxinv.update({move_line.id: move_line.tax_invoice_ids.ids}) + for rec in self.filtered("move_line_id"): + if len(line_taxinv[rec.move_line_id.id]) == 1 and not self._context.get( + "force_remove_tax_invoice" + ): + raise UserError(_("Cannot delete this last tax invoice line")) + line_taxinv[rec.move_line_id.id].remove(rec.id) + return super().unlink() + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + tax_invoice_ids = fields.One2many( + comodel_name="account.move.tax.invoice", inverse_name="move_line_id" + ) + manual_tax_invoice = fields.Boolean( + copy=False, help="Create Tax Invoice for this debit/credit line" + ) + wht_tax_id = fields.Many2one( + comodel_name="account.withholding.tax", + string="WHT", + compute="_compute_wht_tax_id", + store=True, + readonly=False, + ) + + @api.depends("product_id", "account_id") + def _compute_wht_tax_id(self): + for rec in self: + # From invoice, default from product + if rec.move_id.move_type in ("out_invoice", "out_refund", "in_receipt"): + rec.wht_tax_id = rec.product_id.wht_tax_id + elif rec.move_id.move_type in ("in_invoice", "in_refund", "out_receipt"): + rec.wht_tax_id = rec.product_id.supplier_wht_tax_id + elif ( + rec.payment_id + and rec.payment_id.wht_tax_id.account_id == rec.account_id + ): + rec.wht_tax_id = rec.payment_id.wht_tax_id + else: + rec.wht_tax_id = False + + def _get_wht_base_amount(self, currency, currency_date): + self.ensure_one() + wht_base_amount = 0 + if not currency or self.currency_id == currency: + # Same currency + wht_base_amount = self.amount_currency + elif currency == self.company_currency_id: + # Payment expressed on the company's currency. + wht_base_amount = self.balance + else: + # Foreign currency on payment different than the one set on the journal entries. + wht_base_amount = self.company_currency_id._convert( + self.balance, currency, self.company_id, currency_date + ) + return wht_base_amount + + def _get_wht_amount(self, currency, wht_date): + """ Calculate withholding tax and base amount based on currency """ + wht_lines = self.filtered("wht_tax_id") + pit_lines = wht_lines.filtered("wht_tax_id.is_pit") + wht_lines = wht_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("wht_tax_id") + if len(wht_tax) != 1: + return (0, 0) + amount_base = 0 + amount_wht = 0 + for line in wht_lines: + base_amount = line._get_wht_base_amount(currency, wht_date) + amount_wht += line.wht_tax_id.amount / 100 * base_amount + amount_base += base_amount + return (amount_base, amount_wht) + # PIT + if pit_lines: + pit_tax = pit_lines.mapped("wht_tax_id") + pit_tax.ensure_one() + move_lines = self.filtered(lambda l: l.wht_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, wht_date + ) + effective_pit = pit_tax.with_context(pit_date=wht_date).pit_id + if not effective_pit: + raise UserError( + _("No effective PIT rate for date %s") + % format_date(self.env, wht_date) + ) + amount_wht = effective_pit._compute_expected_wht( + partner, + amount_base, + pit_date=wht_date, + currency=currency, + company=company, + ) + return (amount_base, amount_wht) + + def _checkout_tax_invoice_amount(self): + for line in self: + if not line.manual_tax_invoice and line.tax_invoice_ids: + tax = sum(line.tax_invoice_ids.mapped("balance")) + if float_compare(abs(line.balance), abs(tax), 2) != 0: + raise UserError(_("Invalid Tax Amount")) + + @api.model_create_multi + def create(self, vals_list): + move_lines = super().create(vals_list) + TaxInvoice = self.env["account.move.tax.invoice"] + sign = self._context.get("reverse_tax_invoice") and -1 or 1 + for line in move_lines: + if (line.tax_line_id and line.tax_exigible) or line.manual_tax_invoice: + taxinv = TaxInvoice.create( + { + "move_id": line.move_id.id, + "move_line_id": line.id, + "partner_id": line.partner_id.id, + "tax_invoice_number": sign < 0 and "/" or False, + "tax_invoice_date": sign < 0 and fields.Date.today() or False, + "tax_base_amount": sign * abs(line.tax_base_amount), + "balance": sign * abs(line.balance), + "reversed_id": ( + line.move_id.move_type == "entry" + and line.move_id.reversed_entry_id.id + or False + ), + } + ) + line.tax_invoice_ids |= taxinv + # Assign back the reversing id + for taxinv in line.tax_invoice_ids.filtered("reversed_id"): + TaxInvoice.search([("move_id", "=", taxinv.reversed_id.id)]).write( + {"reversing_id": taxinv.move_id.id} + ) + return move_lines + + def write(self, vals): + if "manual_tax_invoice" in vals: + if vals["manual_tax_invoice"]: + TaxInvoice = self.env["account.move.tax.invoice"] + for line in self: + taxinv = TaxInvoice.create( + { + "move_id": line.move_id.id, + "move_line_id": line.id, + "partner_id": line.partner_id.id, + "tax_base_amount": abs(line.tax_base_amount), + "balance": abs(line.balance), + } + ) + line.tax_invoice_ids |= taxinv + else: + self = self.with_context(force_remove_tax_invoice=True) + self.mapped("tax_invoice_ids").unlink() + return super().write(vals) + + +class AccountMove(models.Model): + _inherit = "account.move" + + tax_invoice_ids = fields.One2many( + comodel_name="account.move.tax.invoice", + inverse_name="move_id", + readonly=True, + states={"draft": [("readonly", False)]}, + copy=False, + ) + wht_cert_ids = fields.One2many( + comodel_name="withholding.tax.cert", + inverse_name="move_id", + string="Withholding Tax Cert.", + readonly=True, + ) + wht_cert_cancel = fields.Boolean( + compute="_compute_wht_cert_cancel", + store=True, + help="This document has WHT Cert(s) and all are cancelled or not WHT Cert", + ) + + @api.depends("wht_cert_ids.state") + def _compute_wht_cert_cancel(self): + for record in self: + wht_state = list(set(record.wht_cert_ids.mapped("state"))) + wht_cancel = False + if not wht_state or (len(wht_state) == 1 and "cancel" in wht_state): + wht_cancel = True + record.wht_cert_cancel = wht_cancel + + def button_wht_certs(self): + self.ensure_one() + action = self.env.ref("l10n_th_account_tax.action_withholding_tax_cert_menu") + result = action.sudo().read()[0] + result["domain"] = [("id", "in", self.wht_cert_ids.ids)] + return result + + def _post(self, soft=True): + """Additional tax invoice info (tax_invoice_number, tax_invoice_date) + Case sales tax, use Odoo's info, as document is issued out. + Case purchase tax, use vendor's info to fill back.""" + # Purchase Taxes + for move in self: + for tax_invoice in move.tax_invoice_ids.filtered( + lambda l: l.tax_line_id.type_tax_use == "purchase" + or ( + l.move_id.move_type == "entry" + and not l.payment_id + and l.move_id.journal_id.type != "sale" + ) + ): + if ( + not tax_invoice.tax_invoice_number + or not tax_invoice.tax_invoice_date + ): + if tax_invoice.payment_id: # Defer posting for payment + tax_invoice.payment_id.write({"to_clear_tax": True}) + return self.browse() # return False + elif self.mapped("move_type") == ["entry", "entry"]: + # Case Invoice reconcile with Refund, not perfect yet! + return self.browse() # return False + else: + raise UserError(_("Please fill in tax invoice and tax date")) + + # TOFIX: this operation does cause serious impact in some case. + # I.e., When a normal invoice with amount 0.0 line, deletion is prohibited, + # because it can set back the invoice status of invoice. + # Until there is better way to resolve, please keep this commented. + # Cleanup, delete lines with same account_id and sum(amount) == 0 + # cash_basis_account_ids = ( + # self.env["account.tax"] + # .search([("cash_basis_transition_account_id", "!=", False)]) + # .mapped("cash_basis_transition_account_id.id") + # ) + # for move in self: + # accounts = move.line_ids.mapped("account_id") + # partners = move.line_ids.mapped("partner_id") + # for account in accounts: + # for partner in partners: + # lines = move.line_ids.filtered( + # lambda l: l.account_id == account + # and l.partner_id == partner + # and not l.tax_invoice_ids + # and l.account_id.id not in cash_basis_account_ids + # ) + # if sum(lines.mapped("balance")) == 0: + # lines.unlink() + + res = super()._post(soft) + + # Sales Taxes + for move in self: + for tax_invoice in move.tax_invoice_ids.filtered( + lambda l: l.tax_line_id.type_tax_use == "sale" + or l.move_id.journal_id.type == "sale" + ): + tinv_number, tinv_date = self._get_tax_invoice_number( + move, tax_invoice, tax_invoice.tax_line_id + ) + tax_invoice.write( + {"tax_invoice_number": tinv_number, "tax_invoice_date": tinv_date} + ) + + # Check amount tax invoice with move line + for move in self: + move.line_ids._checkout_tax_invoice_amount() + return res + + def _get_tax_invoice_number(self, move, tax_invoice, tax): + """Tax Invoice Numbering for Customer Invioce / Receipt + - If move_type in ("out_invoice", "out_refund") + - If number is (False, "/"), consider it no valid number then, + - If sequence -> use sequence + - If not sequence -> use move number + - Else, + - If no number + - If move_type = "entry" and has reversed entry, use origin number + """ + origin_move = move.move_type == "entry" and move.reversed_entry_id or move + sequence = tax_invoice.tax_line_id.taxinv_sequence_id + number = tax_invoice.tax_invoice_number + invoice_date = tax_invoice.tax_invoice_date or origin_move.date + if move.move_type in ("out_invoice", "out_refund"): + number = False if number in (False, "/") else number + if not number: + if sequence: + if move != origin_move: # Case reversed entry, use origin + tax_invoices = origin_move.tax_invoice_ids.filtered( + lambda l: l.tax_line_id == tax + ) + number = ( + tax_invoices and tax_invoices[0].tax_invoice_number or False + ) + if not number: + raise ValidationError( + _("Cannot set tax invoice number, number already exists.") + ) + else: # Normal case, use new sequence + number = sequence.next_by_id(sequence_date=move.date) + else: # Now sequence for this tax, use document number + number = tax_invoice.payment_id.name or origin_move.name + return (number, invoice_date) + + def _reverse_moves(self, default_values_list=None, cancel=False): + self = self.with_context(reverse_tax_invoice=True) + return super()._reverse_moves( + default_values_list=default_values_list, cancel=cancel + ) + + +class AccountPartialReconcile(models.Model): + _inherit = "account.partial.reconcile" + + def _create_tax_cash_basis_moves(self): + """This method is called from the move lines that + create cash basis entry. We want to use the same payment_id when + create account.move.tax.invoice""" + move_lines = self.debit_move_id | self.credit_move_id + payment = move_lines.mapped("payment_id") + if len(payment) == 1: + self = self.with_context(payment_id=payment.id) + return super()._create_tax_cash_basis_moves() diff --git a/l10n_th_account_tax/models/account_payment.py b/l10n_th_account_tax/models/account_payment.py new file mode 100644 index 000000000..cb2d3ac6d --- /dev/null +++ b/l10n_th_account_tax/models/account_payment.py @@ -0,0 +1,132 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + to_clear_tax = fields.Boolean( + default=False, + copy=False, + help="When defer journal entry posting, this will show button", + ) + tax_invoice_ids = fields.One2many( + comodel_name="account.move.tax.invoice", + inverse_name="payment_id", + copy=False, + domain=[("reversing_id", "=", False), ("reversed_id", "=", False)], + ) + tax_invoice_move_id = fields.Many2one( + comodel_name="account.move", + string="Tax Invoice's Journal Entry", + compute="_compute_tax_invoice_move_id", + ) + wht_tax_id = fields.Many2one( + comodel_name="account.withholding.tax", + string="Withholding Tax", + copy=False, + help="Optional hidden field to keep wht_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, + ) + wht_cert_ids = fields.One2many( + comodel_name="withholding.tax.cert", + inverse_name="payment_id", + string="Withholding Tax Cert.", + readonly=True, + ) + wht_cert_cancel = fields.Boolean( + compute="_compute_wht_cert_cancel", + store=True, + help="This document has WHT Cert(s) and all are cancelled or not WHT Cert", + ) + + @api.depends("wht_cert_ids.state") + def _compute_wht_cert_cancel(self): + for record in self: + wht_state = list(set(record.wht_cert_ids.mapped("state"))) + wht_cancel = False + if not wht_state or (len(wht_state) == 1 and "cancel" in wht_state): + wht_cancel = True + record.wht_cert_cancel = wht_cancel + + def button_wht_certs(self): + self.ensure_one() + action = self.env.ref("l10n_th_account_tax.action_withholding_tax_cert_menu") + result = action.sudo().read()[0] + result["domain"] = [("id", "in", self.wht_cert_ids.ids)] + return result + + def clear_tax_cash_basis(self): + for payment in self: + for tax_invoice in payment.tax_invoice_ids: + if ( + not tax_invoice.tax_invoice_number + or not tax_invoice.tax_invoice_date + ): + raise UserError(_("Please fill in tax invoice and tax date")) + payment.write({"to_clear_tax": False}) + moves = payment.tax_invoice_ids.mapped("move_id") + for move in moves.filtered(lambda l: l.state == "draft"): + move.ensure_one() + move.action_post() + return True + + def _compute_tax_invoice_move_id(self): + for payment in self: + payment.tax_invoice_move_id = payment.tax_invoice_ids.mapped("move_id")[:1] + + def button_journal_entries(self): + return { + "name": _("Journal Entries"), + "view_mode": "tree,form", + "res_model": "account.move", + "view_id": False, + "type": "ir.actions.act_window", + "domain": [("id", "in", [self.move_id.id, self.tax_invoice_move_id.id])], + } + + 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_wht": -line.amount_wht, + } + ) + line.cancelled = True + return res + + def _update_partner_move_line_writeoff(self, line_vals_list, write_off_line): + for line in line_vals_list: + if line["name"] == write_off_line["name"] and write_off_line.get( + "partner_id", False + ): + line["partner_id"] = write_off_line["partner_id"] + + def _prepare_move_line_default_vals(self, write_off_line_vals=None): + """ Overwrite default partner_id from write_off_line """ + line_vals_list = super()._prepare_move_line_default_vals(write_off_line_vals) + if isinstance(write_off_line_vals, list) and write_off_line_vals: + for write_off_line in write_off_line_vals: + self._update_partner_move_line_writeoff(line_vals_list, write_off_line) + elif isinstance(write_off_line_vals, dict) and write_off_line_vals: + self._update_partner_move_line_writeoff(line_vals_list, write_off_line_vals) + return line_vals_list diff --git a/l10n_th_account_tax/models/account_withholding_move.py b/l10n_th_account_tax/models/account_withholding_move.py new file mode 100644 index 000000000..61da4ca90 --- /dev/null +++ b/l10n_th_account_tax/models/account_withholding_move.py @@ -0,0 +1,66 @@ +# 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 .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_wht = fields.Monetary(string="Withholding Amount") + wht_tax_id = fields.Many2one( + comodel_name="account.withholding.tax", + index=True, + ) + is_pit = fields.Boolean( + related="wht_tax_id.is_pit", + store=True, + ) + wht_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_account_tax/models/account_withholding_tax.py b/l10n_th_account_tax/models/account_withholding_tax.py new file mode 100644 index 000000000..46e827735 --- /dev/null +++ b/l10n_th_account_tax/models/account_withholding_tax.py @@ -0,0 +1,54 @@ +# 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.exceptions import ValidationError + + +class AccountWithholdingTax(models.Model): + _name = "account.withholding.tax" + _description = "Account Withholding Tax" + + name = fields.Char(required=True) + account_id = fields.Many2one( + comodel_name="account.account", + string="Withholding Tax Account", + domain=[("wht_account", "=", True)], + required=True, + ondelete="restrict", + ) + amount = fields.Float( + 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.wht_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_account_tax/models/personal_income_tax.py b/l10n_th_account_tax/models/personal_income_tax.py new file mode 100644 index 000000000..05f0719d4 --- /dev/null +++ b/l10n_th_account_tax/models/personal_income_tax.py @@ -0,0 +1,164 @@ +# 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_wht(self, rate_amount, tax_rate): + return rate_amount * (tax_rate / 100) + + def calculate_rate_wht(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_wht( + rate_amount, rate_range.tax_rate + ) + else: + current_amount += self._compute_total_wht( + income_residual, rate_range.tax_rate + ) + break + return current_amount + + def _compute_expected_wht( + 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_wht 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_wht = self.calculate_rate_wht( + total_pit, base_amount, pit_date=pit_date + ) + # From company currency to currency of base_amount + expected_wht = company.currency_id._convert( + expected_wht, currency, company, pit_date + ) + return expected_wht + + @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_account_tax/models/product.py b/l10n_th_account_tax/models/product.py new file mode 100644 index 000000000..b87922601 --- /dev/null +++ b/l10n_th_account_tax/models/product.py @@ -0,0 +1,10 @@ +# 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 fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + wht_tax_id = fields.Many2one(comodel_name="account.withholding.tax") + supplier_wht_tax_id = fields.Many2one(comodel_name="account.withholding.tax") diff --git a/l10n_th_account_tax/models/res_partner.py b/l10n_th_account_tax/models/res_partner.py new file mode 100644 index 000000000..5cf262e5d --- /dev/null +++ b/l10n_th_account_tax/models/res_partner.py @@ -0,0 +1,42 @@ +# 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", + } + + def button_wht_certs(self): + self.ensure_one() + action = self.env.ref("l10n_th_account_tax.action_withholding_tax_cert_menu") + result = action.sudo().read()[0] + certs = self.env["withholding.tax.cert"].search( + [("supplier_partner_id", "=", self.id)] + ) + result["domain"] = [("id", "in", certs.ids)] + return result diff --git a/l10n_th_account_tax/models/withholding_tax_cert.py b/l10n_th_account_tax/models/withholding_tax_cert.py new file mode 100644 index 000000000..c7d6234ac --- /dev/null +++ b/l10n_th_account_tax/models/withholding_tax_cert.py @@ -0,0 +1,361 @@ +# Copyright 2019 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.exceptions import ValidationError +from odoo.tools.float_utils import float_compare + +INCOME_TAX_FORM = [ + ("pnd1", "PND1"), + ("pnd3", "PND3"), + ("pnd3a", "PND3a"), + ("pnd53", "PND53"), +] + + +WHT_CERT_INCOME_TYPE = [ + ("1", "1. เงินเดือน ค่าจ้าง ฯลฯ 40(1)"), + ("2", "2. ค่าธรรมเนียม ค่านายหน้า ฯลฯ 40(2)"), + ("3", "3. ค่าแห่งลิขสิทธิ์ ฯลฯ 40(3)"), + ("4A", "4. ดอกเบี้ย ฯลฯ 40(4)ก"), + ( + "4B11", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (1.1) " + "กิจการที่ต้องเสียภาษีเงินได้นิติบุคคลร้อยละ 30 ของกำไรสุทธิ", + ), + ( + "4B12", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (1.2) " + "กิจการที่ต้องเสียภาษีเงินได้นิติบุคคลร้อยละ 25 ของกำไรสุทธิ", + ), + ( + "4B13", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (1.3) " + "กิจการที่ต้องเสียภาษีเงินได้นิติบุคคลร้อยละ 20 ของกำไรสุทธิ", + ), + ( + "4B14", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (1.4) " + "กิจการที่ต้องเสียภาษีเงินได้นิติบุคคลร้อยละ อื่นๆ (ระบุ) ของกำไรสุทธิ", + ), + ( + "4B21", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (2.1) " + "กำไรสุทธิกิจการที่ได้รับยกเว้นภาษีเงินได้นิติบุคคล", + ), + ( + "4B22", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (2.2) " + "ได้รับยกเว้นไม่ต้องนำมารวมคำนวณเป็นรายได้", + ), + ( + "4B23", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (2.3) " + "กำไรสุทธิส่วนที่หักผลขาดทุนสุทธิยกมาไม่เกิน 5 ปี", + ), + ( + "4B24", + "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (2.4) " + "กำไรที่รับรู้ทางบัญชีโดยวิธีส่วนได้เสีย", + ), + ("4B25", "4. เงินปันผล เงินส่วนแบ่งกำไร ฯลฯ 40(4)ข (2.5) อื่นๆ (ระบุ)"), + ("5", "5. ค่าจ้างทำของ ค่าบริการ ค่าเช่า ค่าขนส่ง ฯลฯ 3 เตรส"), + ("6", "6. อื่นๆ (ระบุ)"), +] + + +TAX_PAYER = [("withholding", "Withholding"), ("paid_one_time", "Paid One Time")] + + +class WithholdingTaxCert(models.Model): + _name = "withholding.tax.cert" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Withholding Tax Certificate" + + name = fields.Char( + string="Number", + compute="_compute_wht_cert_data", + states={"draft": [("readonly", False)]}, + store=True, + tracking=True, + ) + date = fields.Date( + string="Date", + required=True, + compute="_compute_wht_cert_data", + store=True, + states={"draft": [("readonly", False)]}, + tracking=True, + ) + state = fields.Selection( + selection=[("draft", "Draft"), ("done", "Done"), ("cancel", "Cancelled")], + string="Status", + default="draft", + copy=False, + tracking=True, + ) + ref_wht_cert_id = fields.Many2one( + string="Ref WHT Cert.", + comodel_name="withholding.tax.cert", + help="This field related from Old WHT Cert.", + ) + payment_id = fields.Many2one( + comodel_name="account.payment", + string="Payment", + copy=False, + readonly=True, + states={"draft": [("readonly", False)]}, + domain="[('partner_id', '=', supplier_partner_id)," + "('wht_cert_cancel', '=', True)]", + ondelete="restrict", + tracking=True, + ) + move_id = fields.Many2one( + comodel_name="account.move", + string="Move", + copy=False, + readonly=True, + states={"draft": [("readonly", False)]}, + domain="[('journal_id.type', '=', 'general')," + "('wht_cert_cancel', '=', True), ('state', '=', 'posted')]", + ondelete="restrict", + tracking=True, + ) + company_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Company", + readonly=True, + copy=False, + default=lambda self: self.env.company.partner_id, + ondelete="restrict", + ) + supplier_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Supplier", + required=True, + compute="_compute_wht_cert_data", + store=True, + states={"draft": [("readonly", False)]}, + ondelete="restrict", + ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Main Company", + required=True, + readonly=True, + default=lambda self: self._default_company_id(), + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + related="company_id.currency_id", + store=True, + string="Currency", + readonly=True, + ) + company_taxid = fields.Char( + related="company_partner_id.vat", string="Company Tax ID", readonly=True + ) + supplier_taxid = fields.Char( + related="supplier_partner_id.vat", string="Supplier Tax ID", readonly=True + ) + income_tax_form = fields.Selection( + selection=INCOME_TAX_FORM, + string="Income Tax Form", + required=True, + readonly=True, + copy=False, + states={"draft": [("readonly", False)]}, + ) + wht_line = fields.One2many( + comodel_name="withholding.tax.cert.line", + inverse_name="cert_id", + string="Withholding Line", + readonly=True, + states={"draft": [("readonly", False)]}, + copy=False, + ) + tax_payer = fields.Selection( + selection=TAX_PAYER, + string="Tax Payer", + default="withholding", + required=True, + readonly=True, + states={"draft": [("readonly", False)]}, + copy=False, + ) + + @api.model + def _default_company_id(self): + return self.env.company + + @api.depends("payment_id", "move_id") + def _compute_wht_cert_data(self): + wht_account_ids = self._context.get("wht_account_ids", []) + wht_ref_id = self._context.get("wht_ref_id", False) + income_tax_form = self._context.get("income_tax_form", False) + CertLine = self.env["withholding.tax.cert.line"] + Cert = self.env["withholding.tax.cert"] + if wht_account_ids: + wht_reference = Cert.browse(wht_ref_id) + for record in self: + # Hook to find wht move lines + wht_move_lines = record._get_wht_move_line( + record.payment_id, record.move_id, wht_account_ids + ) + partner_id = record.payment_id.partner_id or record.move_id.partner_id + # WHT from journal entry, use partner from line. + if record.move_id and record.move_id.move_type == "entry": + partner = wht_move_lines.mapped("partner_id") + if len(partner) == 1: + partner_id = wht_move_lines[0].partner_id + + record.update( + { + "name": record.payment_id.name or record.move_id.name, + "date": record.payment_id.date or record.move_id.date, + "ref_wht_cert_id": wht_reference or False, + "supplier_partner_id": partner_id, + "income_tax_form": income_tax_form, + } + ) + for line in wht_move_lines: + record.wht_line = CertLine.new(record._prepare_wht_line(line)) + + @api.model + def _prepare_wht_line(self, move_line): + """ Hook point to prepare wht_line """ + wht_percent = move_line.wht_tax_id.amount + wht_cert_income_type = self._context.get("wht_cert_income_type") + select_dict = dict(WHT_CERT_INCOME_TYPE) + wht_cert_income_desc = select_dict.get(wht_cert_income_type, False) + vals = { + "wht_cert_income_type": wht_cert_income_type, + "wht_cert_income_desc": wht_cert_income_desc, + "wht_percent": wht_percent, + "base": (abs(move_line.balance) / wht_percent * 100) + if wht_percent + else False, + "amount": abs(move_line.balance), + "ref_move_line_id": move_line.id, + } + return vals + + @api.model + def _get_wht_move_line(self, payment, move, wht_account_ids): + """ Hook point to get wht_move_lines """ + wht_move_lines = [] + if payment: + wht_move_lines = payment.move_id.line_ids.filtered( + lambda l: l.account_id.id in wht_account_ids + ) + elif move: + wht_move_lines = move.line_ids.filtered( + lambda l: l.account_id.id in wht_account_ids + ) + return wht_move_lines + + def action_draft(self): + self.write({"state": "draft"}) + return True + + def action_done(self): + for rec in self: + if rec.ref_wht_cert_id: + rec.ref_wht_cert_id.write({"state": "cancel"}) + rec.ref_wht_cert_id.message_post( + body=_("This document was substituted by %s." % (rec.name)) + ) + self.write({"state": "done"}) + return True + + def action_cancel(self): + self.write({"state": "cancel"}) + return True + + def _get_wht_cert_model_view(self): + res_model = "create.withholding.tax.cert" + view = "l10n_th_account_tax.create_withholding_tax_cert" + if len(self._context.get("active_ids")) > 1: + view = "l10n_th_account_tax.create_withholding_tax_cert_multi" + view_id = self.env.ref(view).id + return res_model, view_id + + def action_create_withholding_tax_cert(self): + """ This function is called from either account.move or account.payment """ + if not self._context.get("active_ids"): + return + res_model, view_id = self._get_wht_cert_model_view() + return { + "name": _("Create Withholding Tax Cert."), + "res_model": res_model, + "view_mode": "form", + "view_id": view_id, + "context": self.env.context, + "target": "new", + "type": "ir.actions.act_window", + } + + def action_create_pit_withholding_tax_cert(self): + view = "l10n_th_account_tax.create_pit_withholding_tax_cert" + return { + "name": _("Create Withholding Tax Cert."), + "res_model": "create.pit.withholding.tax.cert", + "view_mode": "form", + "view_id": self.env.ref(view).id, + "context": self.env.context, + "target": "new", + "type": "ir.actions.act_window", + } + + +class WithholdingTaxCertLine(models.Model): + _name = "withholding.tax.cert.line" + _description = "Withholding Tax Cert Lines" + + cert_id = fields.Many2one( + comodel_name="withholding.tax.cert", string="WHT Cert", index=True + ) + wht_cert_income_type = fields.Selection( + selection=WHT_CERT_INCOME_TYPE, string="Type of Income", required=True + ) + wht_cert_income_desc = fields.Char( + string="Income Description", size=500, required=False + ) + base = fields.Float(string="Base Amount", readonly=False) + wht_percent = fields.Float(string="% Tax") + amount = fields.Float(string="Tax Amount", readonly=False) + ref_move_line_id = fields.Many2one( + comodel_name="account.move.line", + string="Ref Journal Item", + readonly=False, + help="Reference back to journal item which create wht move", + ) + company_id = fields.Many2one( + comodel_name="res.company", related="cert_id.company_id" + ) + + @api.constrains("base", "wht_percent", "amount") + def _check_wht_line(self): + for rec in self: + prec = self.env.company.currency_id.decimal_places + if ( + rec.wht_percent + and float_compare(rec.amount, rec.base * rec.wht_percent / 100, prec) + != 0 + ): + raise ValidationError(_("WHT Base/Percent/Tax mismatch!")) + + @api.onchange("wht_cert_income_type") + def _onchange_wht_cert_income_type(self): + if self.wht_cert_income_type: + select_dict = dict(WHT_CERT_INCOME_TYPE) + self.wht_cert_income_desc = select_dict[self.wht_cert_income_type] + else: + self.wht_cert_income_desc = False + + @api.onchange("wht_percent") + def _onchange_wht_percent(self): + if self.wht_percent: + self.base = self.amount * 100 / self.wht_percent + else: + self.base = 0.0 diff --git a/l10n_th_account_tax/readme/CONFIGURE.rst b/l10n_th_account_tax/readme/CONFIGURE.rst new file mode 100644 index 000000000..e29675ea4 --- /dev/null +++ b/l10n_th_account_tax/readme/CONFIGURE.rst @@ -0,0 +1,46 @@ +=========== +Tax Invoice +=========== + +For Sales Taxes (tax scope = Sales), there is option to run Tax Invoice number by sequence model. +If set properly, Tax Invoice Number will be using this sequence instead of customer invoice number / payment number + +* Goto Accounting > Configuration > Invoicing > Taxes +* For sales tax, choose 'Tax Invoice Sequence' (or create new one) +* Make sure you also set the same sequence for the same tax on payment too. + I.e., Output VAT7% and Undue Output VAT7% must select the same sequence, both both of them will result in same Tax Report + +Note: this option is not avaiable for purchase tax, because the Tax Invoice Number is from vendor, and are filled manually. + +------------------------------------------------------------- + +================================================= +Withholding Tax and Personal Income Tax (PIT) +================================================= + +Setup account related to withholding tax will help set default tax amount +when create certificate from payment. + +* Go to Invoicing > Configuration > Accounting > Chart of Accounts +* Search for withholding tax related account +* Check "WHT Account" +* Go to Invoicing > Configuration > Invoicing > Withholding Tax +* 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_account_tax/readme/CONTRIBUTORS.rst b/l10n_th_account_tax/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..1f5e31a19 --- /dev/null +++ b/l10n_th_account_tax/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* `Ecosoft `__: + + * Kitti U. + * Saran Lim. + * Pimolnat Suntian diff --git a/l10n_th_account_tax/readme/DESCRIPTION.rst b/l10n_th_account_tax/readme/DESCRIPTION.rst new file mode 100644 index 000000000..e9b370960 --- /dev/null +++ b/l10n_th_account_tax/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This module address the undue tax requirement for Thailand + +In Odoo, cash basis feature is being used to cover case of undue tax (due on payment). +This can answer the issue about undue tax in Thailand partially. +For customer invoice/payment, Odoo's cash basis alone is enough. + +But for supplier invoice/payment and expense, where tax invoice number and tax date is given from vendor. +This module will provide ways for user to do so on vendor bill (for normal tax) and +in supplier payment (for undue tax which is due on payment). diff --git a/l10n_th_account_tax/readme/USAGE.rst b/l10n_th_account_tax/readme/USAGE.rst new file mode 100644 index 000000000..45e2ff6af --- /dev/null +++ b/l10n_th_account_tax/readme/USAGE.rst @@ -0,0 +1,39 @@ +With this, 2 new tax related fields will be introduced in account.move.tax.invoice + +1. tax_invoice_number : Tax Invoice Number +2. tax_invoice_date : Tax Invoice Date + +In customer invoice, both field will be defaulted by Odoo's document number and date. +But in supplier invoice, both field must be filled by user with tax invoice number and date from vendor. + +Note: The new table account.move.tax.invoice is the main source of Thai VAT Report + +**Preparing Undue Tax** + +- Use Developer Mode +- In Chart of Account, add new account for Undue Tax, if not already exists. +- As admin, add access rights ``Show Full Accounting Features`` +- Go to Invoicing > Configuration > Settings and select ``Cash Basis``, this will open cash basis options in Tax master data +- Go to Invoicing > Configuration > Accounting > Taxes, create new undue tax for both sales and purchase + +**Case Customer Invoice and Payment** + +- Create new customer invoice and choose to use sales' VAT or Undue VAT +- Process invoice and payment as per normal, Odoo document number and date should be used as Tax Invoice Number/Date + +**Case Supplier Invoice/Payment** + +**1) Case Input VAT** + +- Create new vendor bill and choose Input VAT +- At vendor bill's tax invoice tab, fill in 2 new fields, ``Tax Invoice`` and ``Tax Date`` given by your vendor +- Validate this vendor bill, the given Tax Invoice Number and Tax Date are set in account.move.tax.invoice + +**2) Case Undue Input VAT** + +- Create new vendor bill and choose Undue Input VAT +- Validate vendor bill, journal entry will be created with Undue VAT +- Register payment as per normal, this will create new payment document as per normal +- From this process, the journal entry (cash basis) for clear undue is created, but it will be in state **Draft** +- Go to payment document, in tax invoice tab, fill in Tax Invoice Number and Tax Date +- Click on Clear VAT button, the journal entry (cash basis) for clear undue will now be **Posted** diff --git a/l10n_th_account_tax/security/account_security.xml b/l10n_th_account_tax/security/account_security.xml new file mode 100644 index 000000000..947a91a92 --- /dev/null +++ b/l10n_th_account_tax/security/account_security.xml @@ -0,0 +1,18 @@ + + + Withholding Tax + + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Withholding Tax Line + + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + diff --git a/l10n_th_account_tax/security/ir.model.access.csv b/l10n_th_account_tax/security/ir.model.access.csv new file mode 100644 index 000000000..b00ae50f9 --- /dev/null +++ b/l10n_th_account_tax/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_move_tax_invoice_invoice,account.move.tax.invoice invoice,model_account_move_tax_invoice,account.group_account_invoice,1,1,1,1 +access_account_move_tax_invoice_portal,account.move.tax.invoice portal,model_account_move_tax_invoice,base.group_portal,1,0,0,0 +access_account_move_tax_invoice_manager,account.move.tax.invoice manager,model_account_move_tax_invoice,account.group_account_manager,1,0,0,0 +access_account_move_tax_invoice_user,account.move.tax.invoice user,model_account_move_tax_invoice,base.group_user,1,1,1,0 +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 +access_withholding_tax_cert,withholding.tax.cert,model_withholding_tax_cert,account.group_account_invoice,1,1,1,1 +access_withholding_tax_cert_line,withholding.tax.cert.line,model_withholding_tax_cert_line,account.group_account_invoice,1,1,1,1 +access_create_withholding_tax_cert,access_create_withholding_tax_cert,model_create_withholding_tax_cert,account.group_account_invoice,1,1,1,1 +access_create_pit_withholding_tax_cert,access_create_pit_withholding_tax_cert,model_create_pit_withholding_tax_cert,account.group_account_user,1,1,1,1 diff --git a/l10n_th_account_tax/static/description/icon.png b/l10n_th_account_tax/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/l10n_th_account_tax/tests/__init__.py b/l10n_th_account_tax/tests/__init__.py new file mode 100644 index 000000000..07a89a9d7 --- /dev/null +++ b/l10n_th_account_tax/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +from . import test_tax_invoice +from . import test_withholding_tax +from . import test_withholding_tax_pit diff --git a/l10n_th_account_tax/tests/test_tax_invoice.py b/l10n_th_account_tax/tests/test_tax_invoice.py new file mode 100644 index 000000000..0ed35954e --- /dev/null +++ b/l10n_th_account_tax/tests/test_tax_invoice.py @@ -0,0 +1,386 @@ +# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests.common import Form, SingleTransactionCase + + +class TestTaxInvoice(SingleTransactionCase): + @classmethod + def setUpClass(cls): + super(TestTaxInvoice, cls).setUpClass() + Journal = cls.env["account.journal"] + # Setup company to allow using tax cash basis + cls.journal_undue = cls.env["account.journal"].create( + {"name": "UndueVAT", "type": "general", "code": "UNDUE"} + ) + company = cls.env.ref("base.main_company") + company.write( + {"tax_exigibility": True, "tax_cash_basis_journal_id": cls.journal_undue.id} + ) + type_current_asset = cls.env.ref("account.data_account_type_current_assets") + type_current_liability = cls.env.ref( + "account.data_account_type_current_liabilities" + ) + # Journals + cls.journal_purchase = Journal.search([("type", "=", "purchase")])[0] + cls.journal_sale = Journal.search([("type", "=", "sale")])[0] + cls.journal_bank = Journal.search([("type", "=", "bank")])[0] + # Payment Methods + cls.payment_method_manual_out = cls.env.ref( + "account.account_payment_method_manual_out" + ) + cls.payment_method_manual_in = cls.env.ref( + "account.account_payment_method_manual_out" + ) + # Accounts + cls.output_vat_acct = cls.env["account.account"].create( + {"name": "O7", "code": "O7", "user_type_id": type_current_liability.id} + ) + cls.undue_output_vat_acct = cls.env["account.account"].create( + {"name": "DO7", "code": "DO7", "user_type_id": type_current_asset.id} + ) + cls.input_vat_acct = cls.env["account.account"].create( + {"name": "V7", "code": "V7", "user_type_id": type_current_liability.id} + ) + cls.undue_input_vat_acct = cls.env["account.account"].create( + {"name": "DV7", "code": "DV7", "user_type_id": type_current_asset.id} + ) + # Tax Group + cls.tax_group_undue_vat = cls.env["account.tax.group"].create( + {"name": "UndueVAT"} + ) + cls.tax_group_vat = cls.env["account.tax.group"].create({"name": "VAT"}) + # Tax + cls.output_vat = cls.env["account.tax"].create( + { + "name": "O7", + "type_tax_use": "sale", + "amount_type": "percent", + "amount": 7.0, + "tax_group_id": cls.tax_group_vat.id, + "tax_exigibility": "on_invoice", + "invoice_repartition_line_ids": [ + (0, 0, {"factor_percent": 100.0, "repartition_type": "base"}), + ( + 0, + 0, + { + "factor_percent": 100.0, + "repartition_type": "tax", + "account_id": cls.output_vat_acct.id, + }, + ), + ], + } + ) + cls.undue_output_vat = cls.env["account.tax"].create( + { + "name": "DO7", + "type_tax_use": "sale", + "amount_type": "percent", + "amount": 7.0, + "tax_group_id": cls.tax_group_undue_vat.id, + "tax_exigibility": "on_payment", + "invoice_repartition_line_ids": [ + (0, 0, {"factor_percent": 100.0, "repartition_type": "base"}), + ( + 0, + 0, + { + "factor_percent": 100.0, + "repartition_type": "tax", + "account_id": cls.output_vat_acct.id, + }, + ), + ], + "cash_basis_transition_account_id": cls.undue_output_vat_acct.id, + } + ) + cls.input_vat = cls.env["account.tax"].create( + { + "name": "V7", + "type_tax_use": "purchase", + "amount_type": "percent", + "amount": 7.0, + "tax_group_id": cls.tax_group_vat.id, + "tax_exigibility": "on_invoice", + "invoice_repartition_line_ids": [ + (0, 0, {"factor_percent": 100.0, "repartition_type": "base"}), + ( + 0, + 0, + { + "factor_percent": 100.0, + "repartition_type": "tax", + "account_id": cls.input_vat_acct.id, + }, + ), + ], + } + ) + cls.undue_input_vat = cls.env["account.tax"].create( + { + "name": "DV7", + "type_tax_use": "purchase", + "amount_type": "percent", + "amount": 7.0, + "tax_group_id": cls.tax_group_undue_vat.id, + "tax_exigibility": "on_payment", + "invoice_repartition_line_ids": [ + (0, 0, {"factor_percent": 100.0, "repartition_type": "base"}), + ( + 0, + 0, + { + "factor_percent": 100.0, + "repartition_type": "tax", + "account_id": cls.input_vat_acct.id, + }, + ), + ], + "cash_basis_transition_account_id": cls.undue_input_vat_acct.id, + } + ) + cls.payment_term_immediate = cls.env["account.payment.term"].create( + {"name": "", "line_ids": [(0, 0, {"value": "balance", "days": 15})]} + ) + + # Optiona tax sequence + cls.cust_vat_sequence = cls.env["ir.sequence"].create( + {"name": "Cust VAT Sequence", "padding": 4} + ) + + def create_invoice(name, partner, journal, invoice_type, account_type, vat): + invoice_dict = { + "name": name, + "partner_id": partner.id, + "journal_id": journal.id, + "move_type": invoice_type, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "quantity": 1.0, + "account_id": cls.env["account.account"] + .search([("user_type_id", "=", account_type.id)], limit=1) + .id, + "name": "Advice", + "price_unit": 100.00, + "tax_ids": [(6, 0, [vat.id])], + }, + ) + ], + } + return cls.env["account.move"].create(invoice_dict) + + # Prepare Supplier Invoices + cls.supplier_invoice_vat = create_invoice( + "Test Supplier Invoice VAT", + cls.env.ref("base.res_partner_12"), + cls.journal_purchase, + "in_invoice", + cls.env.ref("account.data_account_type_expenses"), + cls.input_vat, + ) + cls.supplier_invoice_undue_vat = create_invoice( + "Test Supplier Invoice UndueVAT", + cls.env.ref("base.res_partner_12"), + cls.journal_purchase, + "in_invoice", + cls.env.ref("account.data_account_type_expenses"), + cls.undue_input_vat, + ) + cls.supplier_refund_undue_vat = create_invoice( + "Test Supplier Refund UndueVAT", + cls.env.ref("base.res_partner_12"), + cls.journal_purchase, + "in_refund", + cls.env.ref("account.data_account_type_expenses"), + cls.undue_input_vat, + ) + + # Prepare Customer Invoices + cls.customer_invoice_vat = create_invoice( + "Test Customer Invoice VAT", + cls.env.ref("base.res_partner_10"), + cls.journal_sale, + "out_invoice", + cls.env.ref("account.data_account_type_revenue"), + cls.output_vat, + ) + cls.customer_invoice_vat_seq = cls.customer_invoice_vat.copy() + cls.customer_invoice_undue_vat = create_invoice( + "Test Customer Invoice UndueVAT", + cls.env.ref("base.res_partner_10"), + cls.journal_sale, + "out_invoice", + cls.env.ref("account.data_account_type_revenue"), + cls.undue_output_vat, + ) + cls.customer_invoice_undue_vat_seq = cls.customer_invoice_undue_vat.copy() + + def test_supplier_invoice_vat(self): + """Supplier Invoice with VAT, + user must fill in Tax Invoice/Date on Invoice""" + # User have not filled in Tax Invoice / Date in account_invoice_tax + with self.assertRaises(UserError) as e: + self.supplier_invoice_vat.action_post() + self.assertEqual(e.exception.args[0], "Please fill in tax invoice and tax date") + tax_invoice = "SINV-10001" + tax_date = fields.Date.today() + self.supplier_invoice_vat.tax_invoice_ids.write( + {"tax_invoice_number": tax_invoice, "tax_invoice_date": tax_date} + ) + self.supplier_invoice_vat.action_post() + + def test_supplier_invoice_undue_vat(self): + """ Register Payment from Vendor Invoice""" + # Do not allow user to fill in Tax Invoice/Date + tax_invoice = "SINV-10001" + tax_date = fields.Date.today() + self.supplier_invoice_undue_vat.action_post() + action = self.supplier_invoice_undue_vat.action_register_payment() + ctx = action.get("context") + + # Make full payment from invoice + with Form(self.env["account.payment.register"].with_context(ctx)) as f: + f.journal_id = self.journal_bank + payment_wiz = f.save() + res = payment_wiz.action_create_payments() + payment = self.env["account.payment"].browse(res.get("res_id")) + self.assertTrue(payment.tax_invoice_ids) + # Clear tax cash basis + with self.assertRaises(UserError) as e: + payment.clear_tax_cash_basis() + self.assertEqual(e.exception.args[0], "Please fill in tax invoice and tax date") + # Fill in tax invoice and clear undue vat + payment.tax_invoice_ids.write( + {"tax_invoice_number": tax_invoice, "tax_invoice_date": tax_date} + ) + # Test for wrong tax amount + payment.tax_invoice_ids.write({"balance": 6.0}) + with self.assertRaises(UserError): + payment.clear_tax_cash_basis() + payment.tax_invoice_ids.write({"balance": 7.0}) + payment.clear_tax_cash_basis() + # Cash basis journal is now posted + self.assertEqual(payment.tax_invoice_ids.mapped("move_id").state, "posted") + # Check the move_line_ids, from both Bank and Cash Basis journal + self.assertTrue(payment.move_id) + self.assertTrue(payment.tax_invoice_move_id) + payment.action_draft() # Unlink the relation + self.assertEqual(payment.move_id.state, "draft") + self.assertFalse(payment.tax_invoice_move_id) + + def test_customer_invoice_vat(self): + """Supplier Invoice with VAT, + system auto fill in Tax Invoice/Date on Invoice""" + self.customer_invoice_vat.action_post() + tax_invoices = self.customer_invoice_vat.tax_invoice_ids + tax_invoice_number = tax_invoices.mapped("tax_invoice_number")[0] + self.assertEqual(tax_invoice_number, "Test Customer Invoice VAT") + + def test_customer_invoice_undue_vat(self): + """ Register Payment from Customer Invoice""" + # Do not allow user to fill in Tax Invoice/Date + self.customer_invoice_undue_vat.action_post() + action = self.customer_invoice_undue_vat.action_register_payment() + ctx = action.get("context") + # Make full payment from invoice + with Form(self.env["account.payment.register"].with_context(ctx)) as f: + f.journal_id = self.journal_bank + payment_wiz = f.save() + res = payment_wiz.action_create_payments() + payment = self.env["account.payment"].browse(res.get("res_id")) + self.assertTrue(payment.tax_invoice_ids) + # Clear tax cash basis + payment.clear_tax_cash_basis() + # Cash basis journal is now posted + tax_invoices = payment.tax_invoice_ids + self.assertEqual(tax_invoices.mapped("move_id").state, "posted") + tax_invoice_number = tax_invoices.mapped("tax_invoice_number")[0] + self.assertEqual(tax_invoice_number, payment.name) + # Check the move_line_ids, from both Bank and Cash Basis journal + self.assertTrue(payment.move_id) + self.assertTrue(payment.tax_invoice_move_id) + payment.action_draft() # Unlink the relation + self.assertEqual(payment.move_id.state, "draft") + self.assertFalse(payment.tax_invoice_move_id) + + def test_customer_invoice_vat_sequence(self): + """Supplier Invoice with VAT, + system auto fill in Tax Invoice using sequence""" + # Assign opptional sequence to vat + self.cust_vat_sequence.prefix = "CTX" + self.cust_vat_sequence.number_next_actual = 1 # CTX0001 + self.output_vat.taxinv_sequence_id = self.cust_vat_sequence + self.customer_invoice_vat_seq.action_post() + tax_invoices = self.customer_invoice_vat_seq.tax_invoice_ids + tax_invoice_number = tax_invoices.mapped("tax_invoice_number")[0] + self.assertEqual(tax_invoice_number, "CTX0001") + + def test_customer_invoice_undue_vat_sequence(self): + """Register Payment from Customer Invoice + system auto fill in Tax Invoice using sequence""" + # Assign opptional sequence to undue vat + self.cust_vat_sequence.prefix = "CTX" + self.cust_vat_sequence.number_next_actual = 2 # CTX0002 + self.undue_output_vat.taxinv_sequence_id = self.cust_vat_sequence + # Do not allow user to fill in Tax Invoice/Date + self.customer_invoice_undue_vat_seq.action_post() + # Make full payment from invoice + action = self.customer_invoice_undue_vat_seq.action_register_payment() + ctx = action.get("context") + with Form(self.env["account.payment.register"].with_context(ctx)) as f: + f.journal_id = self.journal_bank + payment_wiz = f.save() + res = payment_wiz.action_create_payments() + payment = self.env["account.payment"].browse(res.get("res_id")) + self.assertTrue(payment.tax_invoice_ids) + # Clear tax cash basis + payment.clear_tax_cash_basis() + # Cash basis journal is now posted + tax_invoices = payment.tax_invoice_ids + self.assertEqual(tax_invoices.mapped("move_id").state, "posted") + tax_invoice_number = tax_invoices.mapped("tax_invoice_number")[0] + self.assertEqual(tax_invoice_number, "CTX0002") + # Check the move_line_ids, from both Bank and Cash Basis journal + self.assertTrue(payment.move_id) + self.assertTrue(payment.tax_invoice_move_id) + payment.action_draft() # Unlink the relation + self.assertEqual(payment.move_id.state, "draft") + self.assertFalse(payment.tax_invoice_move_id) + + def test_supplier_invoice_refund_reconcile(self): + """Case on undue vat, to net refund with vendor bill. + In this case, cash basis journal entry will be created, make sure it + can not post until all Tax Invoice number is filled""" + # Post suupplier invoice + invoice = self.supplier_invoice_undue_vat.copy() + invoice.invoice_date = invoice.date + invoice.action_post() + # Post supplier refund + refund = self.supplier_refund_undue_vat.copy() + refund.invoice_date = refund.date + refund.action_post() + # At invoice add refund to reconcile + payable_account = refund.partner_id.property_account_payable_id + refund_ml = refund.line_ids.filtered(lambda l: l.account_id == payable_account) + invoice.js_assign_outstanding_line(refund_ml.id) + cash_basis_entries = self.env["account.move"].search( + [("ref", "in", [invoice.name, refund.name])] + ) + cash_basis_entries.action_post() + # Not yet add tax invoice number, posting not affected + self.assertEqual(cash_basis_entries[0].state, "draft") + self.assertEqual(cash_basis_entries[1].state, "draft") + for tax_invoice in cash_basis_entries.mapped("tax_invoice_ids"): + tax_invoice.tax_invoice_number = "/" + tax_invoice.tax_invoice_date = fields.Date.today() + # After tax invoice is filled, can now posted + cash_basis_entries.action_post() + self.assertEqual(cash_basis_entries[0].state, "posted") + self.assertEqual(cash_basis_entries[1].state, "posted") diff --git a/l10n_th_account_tax/tests/test_withholding_tax.py b/l10n_th_account_tax/tests/test_withholding_tax.py new file mode 100644 index 000000000..0d6b99fc1 --- /dev/null +++ b/l10n_th_account_tax/tests/test_withholding_tax.py @@ -0,0 +1,508 @@ +# 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 fields +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import Form, SavepointCase + + +class TestWithholdingTax(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_1 = cls.env.ref("base.res_partner_12") + cls.partner_2 = cls.env.ref("base.res_partner_2") + cls.product_1 = cls.env.ref("product.product_product_4") + cls.current_asset = cls.env.ref("account.data_account_type_current_assets") + cls.expenses = cls.env.ref("account.data_account_type_expenses") + cls.revenue = cls.env.ref("account.data_account_type_revenue") + cls.register_view_id = "account.view_account_payment_register_form" + cls.account_move = cls.env["account.move"] + cls.account_payment_register = cls.env["account.payment.register"] + cls.account_account = cls.env["account.account"] + cls.account_journal = cls.env["account.journal"] + cls.account_wht = cls.env["account.withholding.tax"] + cls.wht_cert = cls.env["withholding.tax.cert"] + cls.wht_account = cls.account_account.create( + { + "code": "X152000", + "name": "Withholding Tax Account Test", + "user_type_id": cls.current_asset.id, + "wht_account": True, + } + ) + cls.wht_3 = cls.account_wht.create( + { + "name": "Withholding Tax 3%", + "account_id": cls.wht_account.id, + "amount": 3, + } + ) + cls.expense_account = cls.account_account.search( + [ + ("user_type_id", "=", cls.expenses.id), + ("company_id", "=", cls.env.user.company_id.id), + ], + limit=1, + ) + cls.sale_account = cls.account_account.search( + [ + ("user_type_id", "=", cls.revenue.id), + ("company_id", "=", cls.env.user.company_id.id), + ], + limit=1, + ) + cls.expenses_journal = cls.account_journal.search( + [ + ("type", "=", "purchase"), + ("company_id", "=", cls.env.user.company_id.id), + ], + limit=1, + ) + cls.sales_journal = cls.account_journal.search( + [("type", "=", "sale"), ("company_id", "=", cls.env.user.company_id.id)], + limit=1, + ) + cls.liquidity_account = cls.account_account.search( + [ + ( + "user_type_id", + "=", + cls.env.ref("account.data_account_type_liquidity").id, + ), + ("company_id", "=", cls.env.user.company_id.id), + ], + limit=1, + ) + cls.misc_journal = cls.account_journal.search( + [("type", "=", "general"), ("company_id", "=", cls.env.user.company_id.id)], + limit=1, + ) + + def _create_invoice( + self, + partner_id, + journal_id, + invoice_type, + line_account_id, + price_unit, + product_id=False, + wht_amount=0.0, + ): + invoice_dict = { + "name": "Test Supplier Invoice WHT", + "partner_id": partner_id, + "journal_id": journal_id, + "move_type": invoice_type, + "invoice_date": fields.Date.today(), + } + if invoice_type == "entry": + invoice_dict.update( + { + "line_ids": [ + ( + 0, + 0, + { + "account_id": line_account_id, # wht + "name": "Test line wht", + "credit": wht_amount, + "partner_id": partner_id, + }, + ), + ( + 0, + 0, + { + "account_id": self.liquidity_account.id, + "name": "Test line balance", + "credit": price_unit - wht_amount, + }, + ), + ( + 0, + 0, + { + "account_id": self.expense_account.id, + "name": "Test line product", + "debit": price_unit, + }, + ), + ] + } + ) + else: + invoice_dict.update( + { + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": product_id, + "quantity": 1.0, + "account_id": line_account_id, + "name": "Advice", + "price_unit": price_unit or 0.0, + }, + ) + ], + } + ) + invoice = self.account_move.create(invoice_dict) + return invoice + + def _register_payment(self, invoice, price_unit): + ctx = { + "active_ids": [invoice.id], + "active_id": invoice.id, + "active_model": "account.move", + } + with Form( + self.account_payment_register.with_context(ctx), view=self.register_view_id + ) as f: + register_payment = f.save() + self.assertEqual( + register_payment.writeoff_account_id, + invoice.invoice_line_ids.wht_tax_id.account_id, + ) + self.assertEqual(register_payment.payment_difference, price_unit * 0.03) + self.assertEqual(register_payment.writeoff_label, "Withholding Tax 3%") + action_payment = register_payment.action_create_payments() + payment = self.env[action_payment["res_model"]].browse(action_payment["res_id"]) + return payment + + def _config_product_withholding_tax( + self, product_id, account, customer=False, vendor=False + ): + if customer: + product_id.write({"wht_tax_id": account}) + if vendor: + product_id.write({"supplier_wht_tax_id": account}) + return product_id + + def test_01_create_payment_withholding_tax(self): + """ Create payment with withholding tax""" + price_unit = 100.0 + with self.assertRaises(ValidationError): + self.wht_3.write({"account_id": self.expense_account.id}) + invoice = self._create_invoice( + self.partner_1.id, + self.expenses_journal.id, + "in_invoice", + self.expense_account.id, + price_unit, + ) + self.assertFalse(invoice.invoice_line_ids.wht_tax_id) + invoice.invoice_line_ids.write({"wht_tax_id": self.wht_3.id}) + self.assertTrue(invoice.invoice_line_ids.wht_tax_id) + invoice.action_post() + # Payment by writeoff with withholding tax account + ctx = { + "active_ids": [invoice.id], + "active_id": invoice.id, + "active_model": "account.move", + } + with Form( + self.account_payment_register.with_context(ctx), view=self.register_view_id + ) as f: + register_payment = f.save() + self.assertEqual( + register_payment.writeoff_account_id, + invoice.invoice_line_ids.wht_tax_id.account_id, + ) + self.assertEqual(register_payment.payment_difference, price_unit * 0.03) + self.assertEqual(register_payment.writeoff_label, "Withholding Tax 3%") + action_payment = register_payment.action_create_payments() + payment_id = self.env[action_payment["res_model"]].browse( + action_payment["res_id"] + ) + self.assertEqual(payment_id.state, "posted") + self.assertEqual(payment_id.amount, price_unit * 0.97) + + # Create WHT Cert from Payment's Action Wizard + ctx = { + "active_id": payment_id.id, + "active_ids": [payment_id.id], + "active_model": "account.payment", + } + res = self.wht_cert.with_context(ctx).action_create_withholding_tax_cert() + view = self.env["ir.ui.view"].browse(res["view_id"]).xml_id + f = Form(self.env[res["res_model"]].with_context(res["context"]), view=view) + wizard = f.save() + wizard.write({"wht_account_ids": [self.wht_account.id]}) + res = wizard.create_wht_cert() + # New WHT Cert + ctx_cert = res.get("context") + ctx_cert.update({"income_tax_form": "pnd3", "wht_cert_income_type": "1"}) + with Form(self.wht_cert.with_context(ctx_cert)) as f: + f.income_tax_form = "pnd3" + cert = f.save() + self.assertEqual(cert.state, "draft") + self.assertRecordValues(cert.wht_line, [{"amount": 3.0}]) + payment_id.button_wht_certs() + cert.action_done() + self.assertEqual(cert.state, "done") + # substitute WHT Cert + wizard.write({"substitute": True, "wht_cert_id": cert}) + res = wizard.create_wht_cert() + ctx_cert = res.get("context") + ctx_cert.update({"income_tax_form": "pnd3", "wht_cert_income_type": "1"}) + with Form(self.wht_cert.with_context(ctx_cert)) as f: + f.income_tax_form = "pnd3" + cert2 = f.save() + self.assertFalse(cert.ref_wht_cert_id) + self.assertTrue(cert2.ref_wht_cert_id) + self.assertEqual(cert2.ref_wht_cert_id.id, cert.id) + self.assertNotEqual(cert2.id, cert.id) + cert2.action_done() + self.assertEqual(cert2.state, "done") + self.assertEqual(cert.state, "cancel") + + def test_02_create_payment_withholding_tax_product(self): + """ Create payment with withholding tax from product""" + price_unit = 100.0 + product_id = self._config_product_withholding_tax( + self.product_1, self.wht_3.id, vendor=True + ) + invoice = self._create_invoice( + self.partner_1.id, + self.expenses_journal.id, + "in_invoice", + self.expense_account.id, + price_unit, + product_id.id, + ) + wht_tax_id = invoice.invoice_line_ids.wht_tax_id + self.assertTrue(wht_tax_id) + self.assertEqual(wht_tax_id.account_id, self.wht_3.account_id) + invoice.action_post() + # Payment by writeoff with withholding tax account + ctx = { + "active_ids": [invoice.id], + "active_id": invoice.id, + "active_model": "account.move", + } + with Form( + self.account_payment_register.with_context(ctx), view=self.register_view_id + ) as f: + register_payment = f.save() + self.assertEqual( + register_payment.writeoff_account_id, + invoice.invoice_line_ids.wht_tax_id.account_id, + ) + self.assertEqual(register_payment.payment_difference, price_unit * 0.03) + self.assertEqual(register_payment.writeoff_label, "Withholding Tax 3%") + action_payment = register_payment.action_create_payments() + payment_id = self.env[action_payment["res_model"]].browse( + action_payment["res_id"] + ) + self.assertEqual(payment_id.state, "posted") + self.assertEqual(payment_id.amount, price_unit * 0.97) + + def test_03_withholding_tax_customer_invoice(self): + """ Test case withholding tax from customer invoice""" + price_unit = 100.0 + product_id = self._config_product_withholding_tax( + self.product_1, self.wht_3.id, customer=True + ) + invoice = self._create_invoice( + self.partner_1.id, + self.sales_journal.id, + "out_invoice", + self.sale_account.id, + price_unit, + product_id.id, + ) + wht_tax_id = invoice.invoice_line_ids.wht_tax_id + self.assertTrue(wht_tax_id) + self.assertEqual(wht_tax_id.account_id, self.wht_3.account_id) + invoice.action_post() + + def test_04_withholding_tax_multi_invoice(self): + """ Test case withholding tax with multi invoices""" + price_unit = 100.0 + invoice = self._create_invoice( + self.partner_1.id, + self.expenses_journal.id, + "in_invoice", + self.expense_account.id, + price_unit, + ) + self.assertFalse(invoice.invoice_line_ids.wht_tax_id) + invoice.invoice_line_ids.write({"wht_tax_id": self.wht_3.id}) + self.assertTrue(invoice.invoice_line_ids.wht_tax_id) + # Duplicate invoice + invoice_dict = { + "invoice1": invoice.copy(), + "invoice2": invoice.copy(), + "invoice3": invoice.copy(), + } + for k in invoice_dict.keys(): + invoice_dict[k]["invoice_date"] = fields.Date.today() + invoice_dict["invoice3"]["partner_id"] = (self.partner_2.id,) + for invoice in invoice_dict.values(): + invoice.action_post() + # Test multi partners + ctx = { + "active_ids": [invoice_dict["invoice1"].id, invoice_dict["invoice3"].id], + "active_model": "account.move", + } + with self.assertRaises(UserError): + with Form( + self.account_payment_register.with_context(ctx), + view=self.register_view_id, + ) as f: + register_payment = f.save() + register_payment.action_create_payments() + # Test same partner and not group payments + ctx = { + "active_ids": [invoice_dict["invoice1"].id, invoice_dict["invoice2"].id], + "active_model": "account.move", + } + with self.assertRaises(UserError): + with Form( + self.account_payment_register.with_context(ctx), + view=self.register_view_id, + ) as f: + register_payment = f.save() + register_payment.group_payment = False + register_payment.action_create_payments() + # Test same partner and group payments + ctx = { + "active_ids": [invoice_dict["invoice1"].id, invoice_dict["invoice2"].id], + "active_model": "account.move", + } + with Form( + self.account_payment_register.with_context(ctx), view=self.register_view_id + ) as f: + register_payment = f.save() + self.assertEqual( + register_payment.writeoff_account_id, + invoice.invoice_line_ids.wht_tax_id.account_id, + ) + self.assertEqual(register_payment.payment_difference, 2 * price_unit * 0.03) + self.assertEqual(register_payment.writeoff_label, "Withholding Tax 3%") + action_payment = register_payment.action_create_payments() + payment = self.env[action_payment["res_model"]].browse(action_payment["res_id"]) + self.assertEqual(payment.state, "posted") + self.assertEqual(payment.amount, 2 * price_unit * 0.97) + + def test_05_create_wht_cert_journal(self): + """ Journal Entry to WHT Cert """ + price_unit = 100 + wht_amount = 3 + invoice = self._create_invoice( + self.partner_1.id, + self.misc_journal.id, + "entry", + self.wht_account.id, + price_unit, + wht_amount=wht_amount, + ) + self.assertEqual(invoice.state, "draft") + invoice.action_post() + self.assertEqual(invoice.state, "posted") + # Create WHT Cert from Journal Entry's Action Wizard + ctx = { + "active_id": invoice.id, + "active_ids": [invoice.id], + "active_model": "account.move", + } + res = self.wht_cert.with_context(ctx).action_create_withholding_tax_cert() + view = self.env["ir.ui.view"].browse(res["view_id"]).xml_id + f = Form(self.env[res["res_model"]].with_context(res["context"]), view=view) + wizard = f.save() + wizard.write({"wht_account_ids": [self.wht_account.id]}) + res = wizard.create_wht_cert() + # New WHT Cert + ctx_cert = res.get("context") + ctx_cert.update({"income_tax_form": "pnd3", "wht_cert_income_type": "1"}) + with Form(self.wht_cert.with_context(ctx_cert)) as f: + f.income_tax_form = "pnd3" + wht_cert = f.save() + self.assertEqual(wht_cert.supplier_partner_id, self.partner_1) + invoice.button_wht_certs() + + def test_06_create_wht_cert_multi_payment(self): + """ Payments to WHT Certs """ + price_unit = 100 + invoice = self._create_invoice( + self.partner_1.id, + self.expenses_journal.id, + "in_invoice", + self.expense_account.id, + price_unit, + ) + invoice.invoice_line_ids.write({"wht_tax_id": self.wht_3.id}) + invoice2 = invoice.copy() + invoice2.invoice_date = fields.Date.today() + invoice.action_post() + invoice2.action_post() + payment = self._register_payment(invoice, price_unit) + payment2 = self._register_payment(invoice2, price_unit) + # Create WHT Cert from Payment's Action Wizard + ctx = { + "active_ids": [payment.id, payment2.id], + "active_model": "account.payment", + } + res = self.wht_cert.with_context(ctx).action_create_withholding_tax_cert() + view = self.env["ir.ui.view"].browse(res["view_id"]).xml_id + with Form( + self.env[res["res_model"]].with_context(res["context"]), view=view + ) as f: + f.income_tax_form = "pnd3" + f.wht_cert_income_type = "1" + wizard = f.save() + wizard.write({"wht_account_ids": [self.wht_account.id]}) + res = wizard.create_wht_cert_multi() + certs = self.wht_cert.search(res["domain"]) + self.assertEqual(len(certs), 2) + for cert in certs: + self.assertEqual(cert.wht_line.amount, 3) + + def test_07_create_wht_cert_multi_journal(self): + """ Journal Entries to WHT Certs """ + price_unit = 100 + wht_amount = 3 + invoice = self._create_invoice( + self.partner_1.id, + self.misc_journal.id, + "entry", + self.wht_account.id, + price_unit, + wht_amount=wht_amount, + ) + invoice.invoice_line_ids.write({"wht_tax_id": self.wht_3.id}) + self.assertEqual(invoice.state, "draft") + invoice.action_post() + self.assertEqual(invoice.state, "posted") + invoice2 = invoice.copy() + self.assertEqual(invoice2.state, "draft") + # Create WHT Cert from Journal Entry's Action Wizard + ctx = { + "active_ids": [invoice.id, invoice2.id], + "active_model": "account.move", + } + res = self.wht_cert.with_context(ctx).action_create_withholding_tax_cert() + view = self.env["ir.ui.view"].browse(res["view_id"]).xml_id + # Error when create WHT Cert with draft invoice + with self.assertRaises(UserError): + with Form( + self.env[res["res_model"]].with_context(res["context"]), view=view + ) as f: + f.income_tax_form = "pnd3" + f.wht_cert_income_type = "1" + wizard = f.save() + invoice2.action_post() + with Form( + self.env[res["res_model"]].with_context(res["context"]), view=view + ) as f: + f.income_tax_form = "pnd3" + f.wht_cert_income_type = "1" + wizard = f.save() + wizard.write({"wht_account_ids": [self.wht_account.id]}) + res = wizard.create_wht_cert_multi() + certs = self.wht_cert.search(res["domain"]) + self.assertEqual(len(certs), 2) + for cert in certs: + self.assertEqual(cert.wht_line.amount, 3) diff --git a/l10n_th_account_tax/tests/test_withholding_tax_pit.py b/l10n_th_account_tax/tests/test_withholding_tax_pit.py new file mode 100644 index 000000000..b7d0adf05 --- /dev/null +++ b/l10n_th_account_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, + "wht_account": True, + } + ) + self.wht_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.wht_tax_id = ( + line["pit"] and self.wht_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.wht_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.wht_tax_id = self.wht_pit # Test refreshing wht_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_wht")), 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_wht")), 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_wht")), 40) + # Cancel payment + payment = self.env[res["res_model"]].browse(res["res_id"]) + self.assertEqual(sum(payment.pit_move_ids.mapped("amount_wht")), 30) + payment.action_cancel() + self.assertEqual(sum(payment.pit_move_ids.mapped("amount_wht")), 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_wht")), 10) diff --git a/l10n_th_account_tax/views/account_move_view.xml b/l10n_th_account_tax/views/account_move_view.xml new file mode 100644 index 000000000..d4ee7bd77 --- /dev/null +++ b/l10n_th_account_tax/views/account_move_view.xml @@ -0,0 +1,199 @@ + + + + account.move.form + account.move + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +