# Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime from unittest import mock from unittest.mock import patch from freezegun import freeze_time from odoo import fields from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.tests import tagged @tagged('post_install_l10n', 'post_install', '-at_install') class TestVNEDI(AccountTestInvoicingCommon): @classmethod @AccountTestInvoicingCommon.setup_country('vn') def setUpClass(cls): super().setUpClass() # Setup the default symbol and template. cls.template = cls.env['l10n_vn_edi_viettel.sinvoice.template'].create({ 'name': '1/001', 'template_invoice_type': '1', }) cls.symbol = cls.env['l10n_vn_edi_viettel.sinvoice.symbol'].create({ 'name': 'K24TUT', 'invoice_template_id': cls.template.id, }) cls.env['ir.default'].set( 'res.partner', 'l10n_vn_edi_symbol', cls.symbol.id, company_id=cls.env.company.id ) # Setup a vietnamese address on the partner and company. cls.partner_a.write({ 'street': '121 Hang Bac Street', 'state_id': cls.env.ref('base.state_vn_VN-HN').id, 'city': 'Hoan Kiem District', 'country_id': cls.env.ref('base.vn').id, 'vat': '0100109106-505', 'phone': '3825 7670', 'email': 'partner_a@gmail.com', }) cls.env.company.write({ 'street': '3 Alley 45 Phan Dinh Phung, Quan Thanh Ward', 'state_id': cls.env.ref('base.state_vn_VN-HN').id, 'country_id': cls.env.ref('base.vn').id, 'vat': '0100109106-506', 'phone': '6266 1275', 'email': 'test_company@gmail.com', 'website': 'test_company.com', 'l10n_vn_edi_password': 'a', 'l10n_vn_edi_username': 'b', }) cls.product_a.default_code = 'BN/1035' cls.other_currency = cls.setup_other_currency('EUR') @freeze_time('2024-01-01') def test_invoice_creation(self): """ Create an invoice, and post it. Ensure that the status and symbol is set correctly during this flow. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, ) self.assertFalse(invoice.l10n_vn_edi_invoice_state) # State should be False before posting. self.assertEqual(invoice.l10n_vn_edi_invoice_symbol.id, self.symbol.id) invoice.action_post() self.assertEqual(invoice.l10n_vn_edi_invoice_state, 'ready_to_send') @freeze_time('2024-01-01') def test_default_symbol_on_partner(self): """ Ensure that the default symbol is set correctly if set on the partner of the invoice. """ self.partner_a.l10n_vn_edi_symbol = self.env['l10n_vn_edi_viettel.sinvoice.symbol'].create({ 'name': 'K24TUD', 'invoice_template_id': self.template.id, }) invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, ) self.assertEqual(invoice.l10n_vn_edi_invoice_symbol.id, self.partner_a.l10n_vn_edi_symbol.id) @freeze_time('2024-01-01') def test_json_data_generation(self): """ Test the data dict generated to ensure consistency with the data we set in the system. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, ) self.assertDictEqual( invoice._l10n_vn_edi_generate_invoice_json(), { 'generalInvoiceInfo': { 'transactionUuid': mock.ANY, # Random, not important. 'invoiceType': '1', 'templateCode': '1/001', 'invoiceSeries': 'K24TUT', 'invoiceIssuedDate': 1704067200000, 'currencyCode': 'VND', 'adjustmentType': '1', 'paymentStatus': False, 'cusGetInvoiceRight': True, 'validation': 1, }, 'buyerInfo': { 'buyerName': 'partner_a', 'buyerLegalName': 'partner_a', 'buyerTaxCode': '0100109106-505', 'buyerAddressLine': '121 Hang Bac Street', 'buyerPhoneNumber': '38257670', 'buyerEmail': 'partner_a@gmail.com', 'buyerDistrictName': 'Hà Nội', 'buyerCityName': 'Hoan Kiem District', 'buyerCountryCode': 'VN', 'buyerNotGetInvoice': 0, }, 'sellerInfo': { 'sellerLegalName': 'company_1_data', 'sellerTaxCode': '0100109106-506', 'sellerAddressLine': '3 Alley 45 Phan Dinh Phung, Quan Thanh Ward', 'sellerPhoneNumber': '62661275', 'sellerEmail': 'test_company@gmail.com', 'sellerDistrictName': 'Hà Nội', 'sellerCountryCode': 'VN', 'sellerWebsite': 'http://test_company.com', }, 'payments': [{'paymentMethodName': 'TM/CK'}], 'itemInfo': [{ 'itemCode': 'BN/1035', 'itemName': 'product_a', 'unitName': 'Units', 'unitPrice': 1000.0, 'quantity': 1.0, 'itemTotalAmountWithoutTax': 1000.0, 'taxPercentage': 10.0, 'taxAmount': 100.0, 'discount': 0.0, 'itemTotalAmountAfterDiscount': 1000.0, 'itemTotalAmountWithTax': 1100.0, 'selection': 1, }], 'taxBreakdowns': [{ 'taxPercentage': 10.0, 'taxableAmount': 1000.0, 'taxAmount': 100.0, 'taxableAmountPos': True, 'taxAmountPos': True }] } ) @freeze_time('2024-01-01') def test_adjustment_invoice(self): """ Create an invoice, then create an adjustment invoice from it. Ensure that when generating the data dict, the related fields are set correctly. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, ) invoice.write({ # Would be set by sending it to the edi 'l10n_vn_edi_invoice_number': 'K24TUT01', 'l10n_vn_edi_issue_date': fields.Datetime.now(), 'l10n_vn_edi_invoice_state': 'sent', }) move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=invoice.ids).create({ 'reason': 'Correcting price', 'journal_id': invoice.journal_id.id, 'l10n_vn_edi_adjustment_type': '1', 'l10n_vn_edi_agreement_document_name': 'N/A', 'l10n_vn_edi_agreement_document_date': fields.Datetime.now(), }) reversal = move_reversal.reverse_moves() reverse_move = self.env['account.move'].browse(reversal['res_id']) reverse_move.invoice_line_ids[0].price_unit = 100 # We invoiced 100 too much json_data = reverse_move._l10n_vn_edi_generate_invoice_json() # 1. Check the general info values, ensure correct adjustment type, and that the data were correctly fetched from the original invoice. expected = { 'adjustmentType': '5', 'adjustmentInvoiceType': '1', 'originalInvoiceId': 'K24TUT01', 'originalInvoiceIssueDate': 1704067200000, 'originalTemplateCode': '1/001', 'additionalReferenceDesc': 'N/A', 'additionalReferenceDate': 1704067200000, } actual = json_data['generalInvoiceInfo'] self.assertDictEqual(actual, actual | expected) # 2. Check the itemInfo to ensure that the values make sense expected = { 'unitPrice': -100.0, 'itemTotalAmountWithoutTax': -100.0, 'taxAmount': -10.0, 'itemTotalAmountWithTax': -110.0, 'adjustmentTaxAmount': -10.0, 'isIncreaseItem': False, } actual = json_data['itemInfo'][0] self.assertDictEqual(actual, actual | expected) @freeze_time('2024-01-01') def test_replacement_invoice(self): """ Create an invoice, then create a replacement invoice from it. Ensure that when generating the data dict, the related fields are set correctly. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, ) invoice.write({ # Would be set by sending it to the edi 'l10n_vn_edi_invoice_number': 'K24TUT01', 'l10n_vn_edi_issue_date': fields.Datetime.now(), 'l10n_vn_edi_invoice_state': 'sent', }) move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=invoice.ids).create({ 'reason': 'Correcting price', 'journal_id': invoice.journal_id.id, 'l10n_vn_edi_adjustment_type': '1', 'l10n_vn_edi_agreement_document_name': 'N/A', 'l10n_vn_edi_agreement_document_date': fields.Datetime.now(), }) reversal = move_reversal.reverse_moves(is_modify=True) reverse_move = self.env['account.move'].browse(reversal['res_id']) reverse_move.invoice_line_ids[0].price_unit = 900 # New price is 900 and not 1000 json_data = reverse_move._l10n_vn_edi_generate_invoice_json() # 1. Check the general info values, ensure correct adjustment type, and that the data were correctly fetched from the original invoice. expected = { 'adjustmentType': '3', 'adjustmentInvoiceType': '1', 'originalInvoiceId': 'K24TUT01', 'originalInvoiceIssueDate': 1704067200000, 'originalTemplateCode': '1/001', 'additionalReferenceDesc': 'N/A', 'additionalReferenceDate': 1704067200000, } actual = reverse_move._l10n_vn_edi_generate_invoice_json()['generalInvoiceInfo'] self.assertDictEqual(actual, actual | expected) # 2. Check the itemInfo to ensure that the values make sense expected = { 'unitPrice': 900.0, 'itemTotalAmountWithoutTax': 900.0, 'taxAmount': 90.0, 'itemTotalAmountWithTax': 990.0, } actual = json_data['itemInfo'][0] self.assertDictEqual(actual, actual | expected) @freeze_time('2024-01-01') def test_invoice_foreign_currency(self): """ When invoicing in a foreign currency, we are required to include the rate at the time of the invoice. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, currency=self.other_currency, ) json_data = invoice._l10n_vn_edi_generate_invoice_json() self.assertEqual(json_data['generalInvoiceInfo']['exchangeRate'], 0.5) @freeze_time('2024-01-01') def test_send_and_print(self): """ Test the send & print settings and flows. Note: we are not trying to test the API, thus the few api call will be mocked to not happen. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, currency=self.other_currency, ) self.assertEqual(invoice.l10n_vn_edi_invoice_state, 'ready_to_send') self._send_invoice(invoice) # Check a few things that should be set by the send & print: invoice number, attachments, state, reservation code. self.assertRecordValues( invoice, [{ 'l10n_vn_edi_invoice_number': 'K24TUT01', 'l10n_vn_edi_reservation_code': '123456', 'l10n_vn_edi_invoice_state': 'sent', }] ) self.assertNotEqual(invoice.l10n_vn_edi_sinvoice_xml_file, False) self.assertNotEqual(invoice.l10n_vn_edi_sinvoice_pdf_file, False) self.assertNotEqual(invoice.l10n_vn_edi_sinvoice_file, False) @freeze_time('2024-01-01') def test_cancel_invoice(self): """ Ensure that trying to cancel a sent invoice returns the wizard action, and test the wizard flow. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, currency=self.other_currency, ) self._send_invoice(invoice) # Trying to cancel a sent invoice should result in an action to open the cancellation wizard. action = invoice.button_request_cancel() self.assertEqual(action['res_model'], 'l10n_vn_edi_viettel.cancellation') with patch('odoo.addons.l10n_vn_edi_viettel.models.account_move._l10n_vn_edi_send_request', return_value=(None, None)): self.env['l10n_vn_edi_viettel.cancellation'].create({ 'invoice_id': invoice.id, 'reason': 'Unwanted', 'agreement_document_name': 'N/A', 'agreement_document_date': fields.Datetime.now(), }).button_request_cancel() # Both states should be canceled, but the e-invoicing data should still be there self.assertEqual(invoice.l10n_vn_edi_invoice_state, 'canceled') self.assertEqual(invoice.state, 'cancel') self.assertNotEqual(invoice.l10n_vn_edi_invoice_number, False) def test_access_token(self): """ Ensure that we can fetch access tokens as you would expect. """ invoice = self.init_invoice( move_type='out_invoice', products=self.product_a, taxes=self.tax_sale_a, post=True, currency=self.other_currency, ) request_response = { 'access_token': '123', # In reality, it wouldn't be set here, but for convenience in the tests we'll "cheat" 'expires_in': '600', # 10m } # Do a few tests to ensure that the access token is handled correctly. with patch('odoo.addons.l10n_vn_edi_viettel.models.account_move._l10n_vn_edi_send_request', return_value=(request_response, None)): # First ensure that fetching the token will set the value correctly on the company. with freeze_time('2024-01-01 02:00:00'): invoice._l10n_vn_edi_get_access_token() self.assertEqual(invoice.company_id.l10n_vn_edi_token, '123') self.assertEqual(invoice.company_id.l10n_vn_edi_token_expiry, datetime.strptime('2024-01-01 02:10:00', '%Y-%m-%d %H:%M:%S')) # Second fetch should not set anything as the token isn't expired. with freeze_time('2024-01-01 02:05:00'): invoice._l10n_vn_edi_get_access_token() self.assertEqual(invoice.company_id.l10n_vn_edi_token, '123') self.assertEqual(invoice.company_id.l10n_vn_edi_token_expiry, datetime.strptime('2024-01-01 02:10:00', '%Y-%m-%d %H:%M:%S')) # Third fetch will get a new token due as it expired with freeze_time('2024-01-01 02:15:00'): invoice._l10n_vn_edi_get_access_token() self.assertEqual(invoice.company_id.l10n_vn_edi_token, '123') self.assertEqual(invoice.company_id.l10n_vn_edi_token_expiry, datetime.strptime('2024-01-01 02:25:00', '%Y-%m-%d %H:%M:%S')) def _send_invoice(self, invoice): pdf_response = { 'name': 'sinvoice.pdf', 'mimetype': 'application/pdf', 'raw': b'pdf file', 'res_field': 'l10n_vn_edi_sinvoice_pdf_file', }, "" xml_response = { 'name': 'sinvoice.xml', 'mimetype': 'application/xml', 'raw': b'xml file', 'res_field': 'l10n_vn_edi_sinvoice_xml_file', }, "" request_response = { 'result': { 'reservationCode': '123456', 'invoiceNo': 'K24TUT01', }, 'access_token': '123', # In reality, it wouldn't be set here, but for convenience in the tests we'll "cheat" 'expires_in': '60', } with patch('odoo.addons.l10n_vn_edi_viettel.models.account_move.AccountMove._l10n_vn_edi_fetch_invoice_pdf_file_data', return_value=pdf_response), \ patch('odoo.addons.l10n_vn_edi_viettel.models.account_move.AccountMove._l10n_vn_edi_fetch_invoice_xml_file_data', return_value=xml_response), \ patch('odoo.addons.l10n_vn_edi_viettel.models.account_move._l10n_vn_edi_send_request', return_value=(request_response, None)): self.env['account.move.send.wizard'].with_context(active_model=invoice._name, active_ids=invoice.ids).create({}).action_send_and_print()