diff --git a/addons/l10n_th/__manifest__.py b/addons/l10n_th/__manifest__.py index c14d876c456..e08f613c375 100644 --- a/addons/l10n_th/__manifest__.py +++ b/addons/l10n_th/__manifest__.py @@ -14,7 +14,7 @@ Thai accounting chart and localization. 'author': 'Almacom (http://almacom.co.th/)', 'website': 'https://www.odoo.com/documentation/master/applications/finance/fiscal_localizations.html', 'depends': [ - 'account', + 'account_qr_code_emv', ], 'data': [ 'data/account_tax_report_data.xml', diff --git a/addons/l10n_th/models/__init__.py b/addons/l10n_th/models/__init__.py index 077b61eb998..a47e44290ec 100644 --- a/addons/l10n_th/models/__init__.py +++ b/addons/l10n_th/models/__init__.py @@ -3,3 +3,4 @@ from . import template_th from . import res_partner from . import account_move from . import ir_actions_report +from . import res_bank diff --git a/addons/l10n_th/models/res_bank.py b/addons/l10n_th/models/res_bank.py new file mode 100644 index 00000000000..0f9d2ffe853 --- /dev/null +++ b/addons/l10n_th/models/res_bank.py @@ -0,0 +1,61 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResPartnerBank(models.Model): + _inherit = 'res.partner.bank' + + proxy_type = fields.Selection(selection_add=[('ewallet_id', 'Ewallet ID'), + ('merchant_tax_id', 'Merchant Tax ID'), + ('mobile', "Mobile Number")], + ondelete={'ewallet_id': 'set default', 'merchant_tax_id': 'set default', 'mobile': 'set default'}) + + @api.constrains('proxy_type', 'proxy_value', 'partner_id') + def _check_th_proxy(self): + tax_id_re = re.compile(r'^[0-9]{13}$') + mobile_re = re.compile(r'^[0-9]{10}$') + for bank in self.filtered(lambda b: b.country_code == 'TH'): + if bank.proxy_type not in ['ewallet_id', 'merchant_tax_id', 'mobile', 'none', False]: + raise ValidationError(_("The QR Code Type must be either Ewallet ID, Merchant Tax ID or Mobile Number to generate a Thailand Bank QR code for account number %s.", bank.acc_number)) + if bank.proxy_type == 'merchant_tax_id' and (not bank.proxy_value or not tax_id_re.match(bank.proxy_value)): + raise ValidationError(_("The Merchant Tax ID must be in the format 1234567890123 for account number %s.", bank.acc_number)) + if bank.proxy_type == 'mobile' and (not bank.proxy_value or not mobile_re.match(bank.proxy_value)): + raise ValidationError(_("The Mobile Number must be in the format 0812345678 for account number %s.", bank.acc_number)) + + @api.depends('country_code') + def _compute_display_qr_setting(self): + bank_th = self.filtered(lambda b: b.country_code == 'TH') + bank_th.display_qr_setting = self.env.company.qr_code + super(ResPartnerBank, self - bank_th)._compute_display_qr_setting() + + def _get_merchant_account_info(self): + if self.country_code == 'TH': + proxy_type_mapping = { + 'mobile': 1, + 'merchant_tax_id': 2, + 'ewallet_id': 3, + } + proxy_value = re.sub(r"^0", "66", self.proxy_value).zfill(13) if self.proxy_type == 'mobile' else self.proxy_value + vals = [ + (0, 'A000000677010111'), + (proxy_type_mapping[self.proxy_type], proxy_value), + ] + return (29, ''.join([self._serialize(*val) for val in vals])) + return super()._get_merchant_account_info() + + def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency): + if qr_method == 'emv_qr' and self.country_code == 'TH': + if currency.name not in ['THB']: + return _("Can't generate a PayNow QR code with a currency other than THB.") + return None + + return super()._get_error_messages_for_qr(qr_method, debtor_partner, currency) + + def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication): + if qr_method == 'emv_qr' and self.country_code == 'TH' and self.proxy_type not in ['ewallet_id', 'merchant_tax_id', 'mobile']: + return _("The PayNow Type must be either Ewallet ID, Merchant Tax ID or Mobile Number to generate a Thailand Bank QR code") + + return super()._check_for_qr_code_errors(qr_method, amount, currency, debtor_partner, free_communication, structured_communication) diff --git a/addons/l10n_th/tests/__init__.py b/addons/l10n_th/tests/__init__.py new file mode 100644 index 00000000000..8fc10c6e933 --- /dev/null +++ b/addons/l10n_th/tests/__init__.py @@ -0,0 +1 @@ +from . import test_l10n_th_emv_qr diff --git a/addons/l10n_th/tests/test_l10n_th_emv_qr.py b/addons/l10n_th/tests/test_l10n_th_emv_qr.py new file mode 100644 index 00000000000..faca72495f0 --- /dev/null +++ b/addons/l10n_th/tests/test_l10n_th_emv_qr.py @@ -0,0 +1,76 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestL10nTHEmvQrCode(AccountTestInvoicingCommon): + """ Test the generation of the EMV QR Code on invoices """ + + @classmethod + def setUpClass(cls, chart_template_ref='th'): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.company_data['company'].qr_code = True + cls.company_data['company'].partner_id.update({ + 'country_id': cls.env.ref('base.th').id, + 'city': 'Thailand', + }) + + cls.acc_emv_th = cls.env['res.partner.bank'].create({ + 'acc_number': '123456789012345678', + 'partner_id': cls.company_data['company'].partner_id.id, + 'proxy_type': 'mobile', + 'proxy_value': '0810704060', + }) + + cls.acc_emv_th_without_promptpay_info = cls.env['res.partner.bank'].create({ + 'acc_number': '1234567890', + 'partner_id': cls.company_data['company'].partner_id.id, + }) + + cls.emv_qr_invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'currency_id': cls.env.ref('base.THB').id, + 'partner_bank_id': cls.acc_emv_th.id, + 'company_id': cls.company_data['company'].id, + 'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 100})], + }) + + def test_emv_qr_code_generation(self): + self.emv_qr_invoice.qr_code_method = 'emv_qr' + self.emv_qr_invoice._generate_qr_code() + + # Using invoice currency other than THB should fail + self.emv_qr_invoice.currency_id = self.env.ref('base.USD') + with self.assertRaises(UserError, msg="The chosen QR-code type is not eligible for this invoice."): + self.emv_qr_invoice._generate_qr_code() + + # Without company partner city should fail + self.emv_qr_invoice.currency_id = self.env.ref('base.THB') + self.company_data['company'].partner_id.city = False + with self.assertRaises(UserError, msg="Missing Merchant City."): + self.emv_qr_invoice._generate_qr_code() + + # Without PromptPay infomation should fail + self.company_data['company'].partner_id.city = 'Thailand' + self.emv_qr_invoice.partner_bank_id = self.acc_emv_th_without_promptpay_info + with self.assertRaises(UserError, msg="The account receiving the payment must have a Proxy type and a Proxy value set."): + self.emv_qr_invoice._generate_qr_code() + + def test_emv_qr_vals(self): + self.emv_qr_invoice.qr_code_method = 'emv_qr' + unstruct_ref = 'INV/TEST/0001' + emv_qr_vals = self.emv_qr_invoice.partner_bank_id._get_qr_vals( + qr_method=self.emv_qr_invoice.qr_code_method, + amount=self.emv_qr_invoice.amount_residual, + currency=self.emv_qr_invoice.currency_id, + debtor_partner=self.emv_qr_invoice.partner_id, + free_communication=unstruct_ref, + structured_communication=self.emv_qr_invoice.payment_reference, + ) + + # Check the whole qr code string + self.assertEqual(emv_qr_vals, '00020101021229370016A0000006770101110113006681070406052040000530376454031005802TH5914company_1_data6008Thailand63048B76')