387 lines
17 KiB
Python
387 lines
17 KiB
Python
# 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()
|