832 lines
38 KiB
Python
832 lines
38 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import io
|
|
import re
|
|
import time
|
|
import uuid
|
|
import zipfile
|
|
from datetime import datetime, timedelta
|
|
|
|
import requests
|
|
from requests import RequestException
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
SINVOICE_API_URL = 'https://api-vinvoice.viettel.vn/services/einvoiceapplication/api/'
|
|
SINVOICE_TIMEOUT = 60 # They recommend between 60 and 90 seconds, but 60s is already quite long.
|
|
|
|
|
|
def _l10n_vn_edi_send_request(method, url, json_data=None, params=None, headers=None, cookies=None):
|
|
""" Send a request to the API based on the given parameters. In case of errors, the error message is returned. """
|
|
try:
|
|
response = requests.request(method, url, json=json_data, params=params, headers=headers, cookies=cookies, timeout=SINVOICE_TIMEOUT)
|
|
resp_json = response.json()
|
|
error = None
|
|
if resp_json.get('code') or resp_json.get('error'):
|
|
data = resp_json.get('data') or resp_json.get('error')
|
|
error = _('Error when contacting SInvoice: %s.', data)
|
|
return resp_json, error
|
|
except (RequestException, ValueError) as err:
|
|
return {}, _('Something went wrong, please try again later: %s', err)
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = 'account.move'
|
|
|
|
# EDI values
|
|
l10n_vn_edi_invoice_state = fields.Selection(
|
|
string='Sinvoice Status',
|
|
selection=[
|
|
('ready_to_send', 'Ready to send'),
|
|
('sent', 'Sent'),
|
|
# Set when we write on the payment status
|
|
('payment_state_to_update', 'Payment status to update'),
|
|
('canceled', 'Canceled'),
|
|
('adjusted', 'Adjusted'),
|
|
('replaced', 'Replaced'),
|
|
],
|
|
copy=False,
|
|
compute='_compute_l10n_vn_edi_invoice_state',
|
|
store=True,
|
|
readonly=False,
|
|
)
|
|
# This id is important when sending by batches in order to recognize individual invoices.
|
|
l10n_vn_edi_invoice_transaction_id = fields.Char(
|
|
string='SInvoice Transaction ID',
|
|
help='Technical field to store the transaction ID if needed',
|
|
export_string_translation=False,
|
|
copy=False,
|
|
)
|
|
l10n_vn_edi_invoice_symbol = fields.Many2one(
|
|
string='Invoice Symbol',
|
|
comodel_name='l10n_vn_edi_viettel.sinvoice.symbol',
|
|
compute='_compute_l10n_vn_edi_invoice_symbol',
|
|
readonly=False,
|
|
store=True,
|
|
)
|
|
l10n_vn_edi_invoice_number = fields.Char(
|
|
string='SInvoice Number',
|
|
help='Invoice Number as appearing on SInvoice.',
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
l10n_vn_edi_reservation_code = fields.Char(
|
|
string='Secret Code',
|
|
help='Secret code that can be used by a customer to lookup an invoice on SInvoice.',
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
l10n_vn_edi_issue_date = fields.Datetime(
|
|
string='Issue Date',
|
|
help='Date of issue of the invoice on the e-invoicing system.',
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
l10n_vn_edi_sinvoice_file_id = fields.Many2one(
|
|
comodel_name='ir.attachment',
|
|
compute=lambda self: self._compute_linked_attachment_id('l10n_vn_edi_sinvoice_file_id', 'l10n_vn_edi_sinvoice_file'),
|
|
depends=['l10n_vn_edi_sinvoice_file'],
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_sinvoice_file = fields.Binary(
|
|
string='SInvoice json File',
|
|
copy=False,
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_sinvoice_xml_file_id = fields.Many2one(
|
|
comodel_name='ir.attachment',
|
|
compute=lambda self: self._compute_linked_attachment_id('l10n_vn_edi_sinvoice_xml_file_id', 'l10n_vn_edi_sinvoice_xml_file'),
|
|
depends=['l10n_vn_edi_sinvoice_xml_file'],
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_sinvoice_xml_file = fields.Binary(
|
|
string='SInvoice xml File',
|
|
copy=False,
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_sinvoice_pdf_file_id = fields.Many2one(
|
|
comodel_name='ir.attachment',
|
|
compute=lambda self: self._compute_linked_attachment_id('l10n_vn_edi_sinvoice_pdf_file_id', 'l10n_vn_edi_sinvoice_pdf_file'),
|
|
depends=['l10n_vn_edi_sinvoice_pdf_file'],
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_sinvoice_pdf_file = fields.Binary(
|
|
string='SInvoice pdf File',
|
|
copy=False,
|
|
export_string_translation=False,
|
|
)
|
|
# Replacement/Adjustment fields
|
|
l10n_vn_edi_agreement_document_name = fields.Char(
|
|
string='Agreement Name',
|
|
copy=False,
|
|
)
|
|
l10n_vn_edi_agreement_document_date = fields.Datetime(
|
|
string='Agreement Date',
|
|
copy=False,
|
|
)
|
|
l10n_vn_edi_adjustment_type = fields.Selection(
|
|
string='Adjustment type',
|
|
selection=[
|
|
('1', 'Money adjustment'),
|
|
('2', 'Information adjustment'),
|
|
],
|
|
copy=False,
|
|
)
|
|
# Only used in case of replacement invoice.
|
|
l10n_vn_edi_replacement_origin_id = fields.Many2one(
|
|
comodel_name='account.move',
|
|
string='Replacement of',
|
|
copy=False,
|
|
readonly=True,
|
|
check_company=True,
|
|
export_string_translation=False,
|
|
)
|
|
l10n_vn_edi_reversed_entry_invoice_number = fields.Char(
|
|
string='Revered Entry SInvoice Number', # Need string here to avoid same label warning
|
|
related='reversed_entry_id.l10n_vn_edi_invoice_number',
|
|
export_string_translation=False,
|
|
)
|
|
|
|
@api.depends('l10n_vn_edi_invoice_state')
|
|
def _compute_show_reset_to_draft_button(self):
|
|
# EXTEND 'account'
|
|
super()._compute_show_reset_to_draft_button()
|
|
self.filtered(lambda m: m._l10n_vn_need_cancel_request()).show_reset_to_draft_button = False
|
|
|
|
@api.depends('l10n_vn_edi_invoice_state')
|
|
def _compute_need_cancel_request(self):
|
|
# EXTEND 'account' to add dependencies
|
|
return super()._compute_need_cancel_request()
|
|
|
|
@api.depends('payment_state')
|
|
def _compute_l10n_vn_edi_invoice_state(self):
|
|
""" Automatically set the state to payment_state_to_update when the payment state is updated.
|
|
|
|
This is a bit simplistic, as it can be wrongly set (for example, no need to send when going from in_payment to paid)
|
|
But this shouldn't be an issue since the logic to send the update will check if anything need to change.
|
|
"""
|
|
for invoice in self:
|
|
if invoice.country_code == 'VN' and invoice.l10n_vn_edi_invoice_state == 'sent':
|
|
invoice.l10n_vn_edi_invoice_state = 'payment_state_to_update'
|
|
else:
|
|
invoice.l10n_vn_edi_invoice_state = invoice.l10n_vn_edi_invoice_state
|
|
|
|
@api.depends('company_id', 'partner_id')
|
|
def _compute_l10n_vn_edi_invoice_symbol(self):
|
|
""" Use the property l10n_vn_edi_symbol to set a default invoice symbol. """
|
|
for invoice in self:
|
|
if invoice.country_code == 'VN':
|
|
# Even if there was a value already set, we assume that it should be updated if the partner is changed.
|
|
invoice.l10n_vn_edi_invoice_symbol = invoice.partner_id.l10n_vn_edi_symbol
|
|
else:
|
|
invoice.l10n_vn_edi_invoice_symbol = False
|
|
|
|
def button_request_cancel(self):
|
|
# EXTEND 'account'
|
|
if self._l10n_vn_need_cancel_request():
|
|
return {
|
|
'name': _('Invoice Cancellation'),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'l10n_vn_edi_viettel.cancellation',
|
|
'target': 'new',
|
|
'context': {'default_invoice_id': self.id},
|
|
}
|
|
|
|
return super().button_request_cancel()
|
|
|
|
def _l10n_vn_edi_fetch_invoice_file_data(self, file_format):
|
|
""" Helper to try fetching a few time in case the files are not yet ready. """
|
|
self.ensure_one()
|
|
files_data, error_message = self._l10n_vn_edi_try_fetch_invoice_file_data(file_format)
|
|
|
|
if error_message:
|
|
return '', error_message
|
|
|
|
# Sometimes the documents are not available right away. This is quite rare, but I saw it happen a few times.
|
|
# To handle that we will try up to three time to fetch the document => The impact should be negligible.
|
|
threshold = 1
|
|
while not files_data['fileToBytes'] and threshold < 3:
|
|
time.sleep(0.125 * threshold)
|
|
files_data, error_message = self._l10n_vn_edi_try_fetch_invoice_file_data(file_format)
|
|
threshold += 1
|
|
return files_data, error_message
|
|
|
|
def _l10n_vn_edi_try_fetch_invoice_file_data(self, file_format):
|
|
"""
|
|
Query sinvoice in order to fetch the data representation of the invoice, either zip or pdf.
|
|
"""
|
|
self.ensure_one()
|
|
if not self._l10n_vn_edi_is_sent():
|
|
return {}, _("In order to download the invoice's PDF file, you must first send it to SInvoice")
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
access_token, error = self._l10n_vn_edi_get_access_token()
|
|
if error:
|
|
return {}, error
|
|
|
|
return _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url=f'{SINVOICE_API_URL}InvoiceAPI/InvoiceUtilsWS/getInvoiceRepresentationFile',
|
|
json_data={
|
|
'supplierTaxCode': self.company_id.vat,
|
|
'templateCode': self.l10n_vn_edi_invoice_symbol.invoice_template_id.name,
|
|
'invoiceNo': self.l10n_vn_edi_invoice_number,
|
|
'strIssueDate': self._l10n_vn_edi_format_date(self.l10n_vn_edi_issue_date),
|
|
'transactionUuid': self.l10n_vn_edi_invoice_transaction_id,
|
|
'fileType': file_format,
|
|
},
|
|
cookies={'access_token': access_token},
|
|
)
|
|
|
|
def _l10n_vn_edi_fetch_invoice_xml_file_data(self):
|
|
"""
|
|
Query sinvoice in order to fetch the xsl and xml data representation of the invoice.
|
|
|
|
Returns a list of tuple with both file names, mimetype, content and the field it should be stored in.
|
|
"""
|
|
self.ensure_one()
|
|
files_data, error_message = self._l10n_vn_edi_fetch_invoice_file_data('ZIP')
|
|
if error_message:
|
|
return files_data, error_message
|
|
|
|
file_bytes = base64.b64decode(files_data['fileToBytes'])
|
|
|
|
# For some reason, request_response['fileToBytes'] is a zip file containing the other zip file.
|
|
# The content of the inner zip is a xsl file as well as a xml file.
|
|
# In our case the xsl file is not important, so we can simply ignore it.
|
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zip_file:
|
|
inner_zip_bytes = zip_file.read(zip_file.filelist[0])
|
|
with zipfile.ZipFile(io.BytesIO(inner_zip_bytes)) as inner_zip:
|
|
for file in inner_zip.filelist:
|
|
if file.filename.endswith('.xml'):
|
|
return {
|
|
'name': file.filename,
|
|
'mimetype': 'application/xml',
|
|
'raw': inner_zip.read(file),
|
|
'res_field': 'l10n_vn_edi_sinvoice_xml_file',
|
|
}, ""
|
|
|
|
def _l10n_vn_edi_fetch_invoice_pdf_file_data(self):
|
|
"""
|
|
Query sinvoice in order to fetch the pdf data representation of the invoice.
|
|
|
|
Returns a tuple with the pdf name, mimetype, content and field.
|
|
"""
|
|
self.ensure_one()
|
|
file_data, error_message = self._l10n_vn_edi_fetch_invoice_file_data('PDF')
|
|
if error_message:
|
|
return file_data, error_message
|
|
|
|
file_bytes = base64.b64decode(file_data['fileToBytes'])
|
|
|
|
return {
|
|
'name': file_data['fileName'],
|
|
'mimetype': 'application/pdf',
|
|
'raw': file_bytes,
|
|
'res_field': 'l10n_vn_edi_sinvoice_pdf_file',
|
|
}, ""
|
|
|
|
def action_l10n_vn_edi_update_payment_status(self):
|
|
""" Send a request to update the payment status of the invoice. """
|
|
|
|
invoices = self.filtered(lambda i: i.l10n_vn_edi_invoice_state == 'payment_state_to_update')
|
|
if not invoices:
|
|
return
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(invoices)
|
|
|
|
for invoice in invoices:
|
|
sinvoice_status = 'unpaid'
|
|
|
|
# SInvoice will return a NOT_FOUND_DATA error if the status in Odoo matches the one on their side.
|
|
# Because of that we wouldn't be able to differentiate a real issue (invoice on our side not matching theirs)
|
|
# With simply a status already up to date. So we need to check the status first to see if we need to update.
|
|
invoice_lookup, error_message = invoice._l10n_vn_edi_lookup_invoice()
|
|
if error_message:
|
|
raise UserError(error_message)
|
|
|
|
if 'result' in invoice_lookup:
|
|
invoice_data = invoice_lookup['result'][0]
|
|
if invoice_data['status'] == 'Chưa thanh toán': # Vietnamese for 'unpaid'
|
|
sinvoice_status = 'unpaid'
|
|
else:
|
|
sinvoice_status = 'paid'
|
|
|
|
params = {
|
|
'supplierTaxCode': invoice.company_id.vat,
|
|
'invoiceNo': invoice.l10n_vn_edi_invoice_number,
|
|
'strIssueDate': invoice._l10n_vn_edi_format_date(invoice.l10n_vn_edi_issue_date),
|
|
}
|
|
|
|
if invoice.payment_state in {'in_payment', 'paid'} and sinvoice_status == 'unpaid':
|
|
# Mark the invoice as paid
|
|
endpoint = f'{SINVOICE_API_URL}InvoiceAPI/InvoiceWS/updatePaymentStatus'
|
|
params['templateCode'] = invoice.l10n_vn_edi_invoice_symbol.invoice_template_id.name
|
|
elif invoice.payment_state not in {'in_payment', 'paid'} and sinvoice_status == 'paid':
|
|
# Mark the invoice as not paid
|
|
endpoint = f'{SINVOICE_API_URL}InvoiceAPI/InvoiceWS/cancelPaymentStatus'
|
|
else:
|
|
continue
|
|
|
|
access_token, error = self._l10n_vn_edi_get_access_token()
|
|
if error:
|
|
raise UserError(error)
|
|
|
|
_request_response, error_message = _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url=endpoint,
|
|
params=params,
|
|
headers={
|
|
'Content-Type': 'application/x-www-form-urlencoded;',
|
|
},
|
|
cookies={'access_token': access_token},
|
|
)
|
|
|
|
if error_message:
|
|
raise UserError(error_message)
|
|
|
|
# Revert back to the sent state as the status is up-to-date.
|
|
invoice.l10n_vn_edi_invoice_state = 'sent'
|
|
|
|
if self._can_commit():
|
|
self._cr.commit()
|
|
|
|
def _l10n_vn_need_cancel_request(self):
|
|
return self._l10n_vn_edi_is_sent() and self.l10n_vn_edi_invoice_state != 'canceled'
|
|
|
|
def _need_cancel_request(self):
|
|
# EXTEND 'account'
|
|
return super()._need_cancel_request() or self._l10n_vn_need_cancel_request()
|
|
|
|
def _post(self, soft=True):
|
|
# EXTEND 'account'
|
|
posted = super()._post(soft=soft)
|
|
|
|
# Ensure to tag the move as 'Ready to send' upon posting if it makes sense.
|
|
posted.filtered(
|
|
lambda invoice:
|
|
invoice.country_code == 'VN'
|
|
and invoice.is_sale_document()
|
|
and not invoice._l10n_vn_edi_is_sent()
|
|
).l10n_vn_edi_invoice_state = 'ready_to_send'
|
|
|
|
return posted
|
|
|
|
# -------------------------------------------------------------------------
|
|
# API METHODS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_vn_edi_check_invoice_configuration(self):
|
|
""" Some checks that are used to avoid common errors before sending the invoice. """
|
|
self.ensure_one()
|
|
company = self.company_id
|
|
commercial_partner = self.commercial_partner_id
|
|
errors = []
|
|
if not company.l10n_vn_edi_username or not company.l10n_vn_edi_password:
|
|
errors.append(_('Sinvoice credentials are missing on company %s.', company.display_name))
|
|
if not company.vat:
|
|
errors.append(_('VAT number is missing on company %s.', company.display_name))
|
|
company_phone = company.phone and self._l10n_vn_edi_format_phone_number(company.phone)
|
|
if company_phone and not company_phone.isdecimal():
|
|
errors.append(_('Phone number for company %s must only contain digits or +.', company.display_name))
|
|
commercial_partner_phone = commercial_partner.phone and self._l10n_vn_edi_format_phone_number(commercial_partner.phone)
|
|
if commercial_partner_phone and not commercial_partner_phone.isdecimal():
|
|
errors.append(_('Phone number for partner %s must only contain digits or +.', commercial_partner.display_name))
|
|
if not self.l10n_vn_edi_invoice_symbol:
|
|
errors.append(_('The invoice symbol must be provided.'))
|
|
if self.l10n_vn_edi_invoice_symbol and not self.l10n_vn_edi_invoice_symbol.invoice_template_id:
|
|
errors.append(_("The invoice symbol's template must be provided."))
|
|
if self.move_type == 'out_refund' and (not self.reversed_entry_id or not self.reversed_entry_id._l10n_vn_edi_is_sent()):
|
|
errors.append(_('You can only send a credit note linked to a previously sent invoice.'))
|
|
if not self.partner_id.street or not self.partner_id.city or not self.partner_id.state_id or not self.partner_id.country_id:
|
|
errors.append(_('The street, city, state and country of partner %s must be provided.', self.partner_id.display_name))
|
|
if not company.street or not company.state_id or not company.country_id:
|
|
errors.append(_('The street, state and country of company %s must be provided.', company.display_name))
|
|
if self.company_currency_id.name != 'VND':
|
|
vnd = self.env.ref('base.VND')
|
|
rate = vnd.with_context(date=self.invoice_date or self.date).rate
|
|
if not vnd.active or rate == 1:
|
|
errors.append(_('Please make sure that the VND currency is enabled, and that the exchange rates are set.'))
|
|
return errors
|
|
|
|
def _l10n_vn_edi_send_invoice(self, invoice_json_data):
|
|
""" Send an invoice to the SInvoice system.
|
|
|
|
Handles lookup on the system in order to ensure that the invoice was not sent successfully yet in case of
|
|
timeout or other unforeseen error.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
invoice_data = {}
|
|
# If the request was sent but ended up failing, there is still the possibility that the invoice was saved
|
|
# on their system (timeout, for example)
|
|
if self.l10n_vn_edi_invoice_transaction_id:
|
|
invoice_lookup, error_message = self._l10n_vn_edi_lookup_invoice()
|
|
if 'result' in invoice_lookup:
|
|
invoice_data = invoice_lookup['result'][0]
|
|
# note: We do not catch errors on this endpoint for simplicity, as it should not be required.
|
|
else:
|
|
# We do not store the transaction id on the move right away so that we can avoid the above api call.
|
|
# When sending for the first time, we'll get the id from the file data, which we generated earlier in the flow.
|
|
self.l10n_vn_edi_invoice_transaction_id = invoice_json_data['generalInvoiceInfo']['transactionUuid']
|
|
|
|
# if the above request did not return data, we can assume that the invoice has failed to be created, or was never sent
|
|
if not invoice_data:
|
|
# Send the invoice to the system
|
|
access_token, error = self._l10n_vn_edi_get_access_token()
|
|
if error:
|
|
return [error]
|
|
|
|
request_response, error_message = _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url=f'{SINVOICE_API_URL}InvoiceAPI/InvoiceWS/createInvoice/{self.company_id.vat}',
|
|
json_data=invoice_json_data,
|
|
cookies={'access_token': access_token},
|
|
)
|
|
|
|
if error_message:
|
|
return [error_message]
|
|
|
|
invoice_data = request_response['result']
|
|
|
|
self.write({
|
|
'l10n_vn_edi_reservation_code': invoice_data['reservationCode'],
|
|
'l10n_vn_edi_invoice_number': invoice_data['invoiceNo'],
|
|
'l10n_vn_edi_invoice_state': 'sent',
|
|
})
|
|
|
|
if self._can_commit():
|
|
self._cr.commit()
|
|
|
|
def _l10n_vn_edi_cancel_invoice(self, reason, agreement_document_name, agreement_document_date):
|
|
""" Send a request to cancel the invoice. """
|
|
self.ensure_one()
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
# If no error raised, we try to cancel it on the EDI.
|
|
access_token, error = self._l10n_vn_edi_get_access_token()
|
|
if error:
|
|
raise UserError(error)
|
|
|
|
_request_response, error_message = _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url=f'{SINVOICE_API_URL}InvoiceAPI/InvoiceWS/cancelTransactionInvoice',
|
|
params={
|
|
'supplierTaxCode': self.company_id.vat,
|
|
'templateCode': self.l10n_vn_edi_invoice_symbol.invoice_template_id.name,
|
|
'invoiceNo': self.l10n_vn_edi_invoice_number,
|
|
'strIssueDate': self._l10n_vn_edi_format_date(self.l10n_vn_edi_issue_date),
|
|
'additionalReferenceDesc': agreement_document_name,
|
|
'additionalReferenceDate': self._l10n_vn_edi_format_date(agreement_document_date),
|
|
'reasonDelete': reason,
|
|
},
|
|
headers={
|
|
'Content-Type': 'application/x-www-form-urlencoded;',
|
|
},
|
|
cookies={'access_token': access_token},
|
|
)
|
|
|
|
if error_message:
|
|
raise UserError(error_message)
|
|
|
|
self.l10n_vn_edi_invoice_state = 'canceled'
|
|
|
|
try:
|
|
self._check_fiscal_lock_dates()
|
|
self.line_ids._check_tax_lock_date()
|
|
|
|
self.button_cancel()
|
|
|
|
self.with_context(no_new_invoice=True).message_post(
|
|
body=_('The invoice has been canceled for reason: %(reason)s', reason=reason),
|
|
)
|
|
except UserError as e:
|
|
self.with_context(no_new_invoice=True).message_post(
|
|
body=_('The invoice has been canceled on sinvoice for reason: %(reason)s'
|
|
'But the cancellation in Odoo failed with error: %(error)s', reason=reason, error=e),
|
|
)
|
|
|
|
if self._can_commit():
|
|
self._cr.commit()
|
|
|
|
def button_draft(self):
|
|
# EXTEND account
|
|
# When going from canceled => draft, we ensure to clear the edi fields so that the invoice can be resent if required.
|
|
cancelled_sinvoices = self.filtered(
|
|
lambda i: i.country_code == 'VN' and i.l10n_vn_edi_invoice_state == 'canceled' and i.state == 'cancel'
|
|
)
|
|
res = super().button_draft()
|
|
cancelled_sinvoices.write({
|
|
'l10n_vn_edi_invoice_transaction_id': False,
|
|
'l10n_vn_edi_invoice_number': False,
|
|
'l10n_vn_edi_reservation_code': False,
|
|
'l10n_vn_edi_issue_date': False,
|
|
'l10n_vn_edi_invoice_state': False,
|
|
})
|
|
# Cleanup the files as well. They will still be available in the chatter.
|
|
cancelled_sinvoices.l10n_vn_edi_sinvoice_xml_file_id.unlink()
|
|
cancelled_sinvoices.l10n_vn_edi_sinvoice_pdf_file_id.unlink()
|
|
cancelled_sinvoices.l10n_vn_edi_sinvoice_file_id.unlink()
|
|
return res
|
|
|
|
def _l10n_vn_edi_generate_invoice_json(self):
|
|
""" Return the dict of data that will be sent to the api in order to create the invoice. """
|
|
# We leave the summarized information computation to SInvoice.
|
|
self.ensure_one()
|
|
# This MUST match chronologically with the sequence they generate on their system, which is why it is set to now.
|
|
self.l10n_vn_edi_issue_date = fields.Datetime.now()
|
|
json_values = {}
|
|
self._l10n_vn_edi_add_general_invoice_information(json_values)
|
|
self._l10n_vn_edi_add_buyer_information(json_values)
|
|
self._l10n_vn_edi_add_seller_information(json_values)
|
|
self._l10n_vn_edi_add_payment_information(json_values)
|
|
self._l10n_vn_edi_add_item_information(json_values)
|
|
self._l10n_vn_edi_add_tax_breakdowns(json_values)
|
|
return json_values
|
|
|
|
def _l10n_vn_edi_add_general_invoice_information(self, json_values):
|
|
""" General invoice information, such as the model number, invoice symbol, type, date of issues, ... """
|
|
self.ensure_one()
|
|
invoice_data = {
|
|
'transactionUuid': str(uuid.uuid4()),
|
|
'invoiceType': self.l10n_vn_edi_invoice_symbol.invoice_template_id.template_invoice_type,
|
|
'templateCode': self.l10n_vn_edi_invoice_symbol.invoice_template_id.name,
|
|
'invoiceSeries': self.l10n_vn_edi_invoice_symbol.name,
|
|
# This timestamp is important as it is used to check the chronological order of Invoice Numbers.
|
|
# Since this xml is generated upon posting, just like the invoice number, using now() should keep that order
|
|
# correct in most case.
|
|
'invoiceIssuedDate': self._l10n_vn_edi_format_date(self.l10n_vn_edi_issue_date),
|
|
'currencyCode': self.currency_id.name,
|
|
'adjustmentType': '1', # 1 for original invoice, which is the case during first issuance.
|
|
'paymentStatus': self.payment_state in {'in_payment', 'paid'},
|
|
'cusGetInvoiceRight': True, # Set to true, allowing the customer to see the invoice.
|
|
'validation': 1, # Set to 1, SInvoice will validate tax information while processing the invoice.
|
|
}
|
|
|
|
# When invoicing in a foreign currency, we need to provide the rate, or it will default to 1.
|
|
if self.currency_id.name != 'VND':
|
|
invoice_data['exchangeRate'] = self.env['res.currency']._get_conversion_rate(
|
|
from_currency=self.currency_id,
|
|
to_currency=self.env.ref('base.VND'),
|
|
company=self.company_id,
|
|
date=self.invoice_date or self.date,
|
|
)
|
|
|
|
adjustment_origin_invoice = None
|
|
if self.move_type == 'out_refund': # Credit note are used to adjust an existing invoice
|
|
adjustment_origin_invoice = self.reversed_entry_id
|
|
elif self.l10n_vn_edi_replacement_origin_id: # 'Reverse and create invoice' is used to issue a replacement invoice
|
|
adjustment_origin_invoice = self.l10n_vn_edi_replacement_origin_id
|
|
|
|
if adjustment_origin_invoice:
|
|
invoice_data.update({
|
|
'adjustmentType': '5' if self.move_type == 'out_refund' else '3', # Adjustment or replacement
|
|
'adjustmentInvoiceType': self.l10n_vn_edi_adjustment_type or '',
|
|
'originalInvoiceId': adjustment_origin_invoice.l10n_vn_edi_invoice_number,
|
|
'originalInvoiceIssueDate': self._l10n_vn_edi_format_date(adjustment_origin_invoice.l10n_vn_edi_issue_date),
|
|
'originalTemplateCode': adjustment_origin_invoice.l10n_vn_edi_invoice_symbol.invoice_template_id.name,
|
|
'additionalReferenceDesc': self.l10n_vn_edi_agreement_document_name,
|
|
'additionalReferenceDate': self._l10n_vn_edi_format_date(self.l10n_vn_edi_agreement_document_date),
|
|
})
|
|
|
|
json_values['generalInvoiceInfo'] = invoice_data
|
|
|
|
def _l10n_vn_edi_add_buyer_information(self, json_values):
|
|
""" Create and return the buyer information for the current invoice. """
|
|
self.ensure_one()
|
|
|
|
commercial_partner_phone = self.commercial_partner_id.phone and self._l10n_vn_edi_format_phone_number(self.commercial_partner_id.phone)
|
|
buyer_information = {
|
|
'buyerName': self.partner_id.name,
|
|
'buyerLegalName': self.commercial_partner_id.name,
|
|
'buyerTaxCode': self.commercial_partner_id.vat,
|
|
'buyerAddressLine': self.partner_id.street,
|
|
'buyerPhoneNumber': commercial_partner_phone or '',
|
|
'buyerEmail': self.commercial_partner_id.email or '',
|
|
'buyerDistrictName': self.partner_id.state_id.name,
|
|
'buyerCityName': self.partner_id.city,
|
|
'buyerCountryCode': self.partner_id.country_id.code,
|
|
'buyerNotGetInvoice': 0, # Set to 1 to no send the invoice to the buyer.
|
|
}
|
|
|
|
if self.partner_bank_id:
|
|
buyer_information.update({
|
|
'buyerBankName': self.partner_bank_id.bank_name,
|
|
'buyerBankAccount': self.partner_bank_id.acc_number,
|
|
})
|
|
|
|
json_values['buyerInfo'] = buyer_information
|
|
|
|
def _l10n_vn_edi_add_seller_information(self, json_values):
|
|
""" Create and return the seller information for the current invoice. """
|
|
self.ensure_one()
|
|
company_phone = self.company_id.phone and self._l10n_vn_edi_format_phone_number(self.company_id.phone)
|
|
seller_information = {
|
|
'sellerLegalName': self.company_id.name,
|
|
'sellerTaxCode': self.company_id.vat,
|
|
'sellerAddressLine': self.company_id.street,
|
|
'sellerPhoneNumber': company_phone or '',
|
|
'sellerEmail': self.company_id.email,
|
|
'sellerDistrictName': self.company_id.state_id.name,
|
|
'sellerCountryCode': self.company_id.country_id.code,
|
|
'sellerWebsite': self.company_id.website,
|
|
}
|
|
|
|
if self.partner_bank_id:
|
|
seller_information.update({
|
|
'sellerBankName': self.partner_bank_id.bank_name,
|
|
'sellerBankAccount': self.partner_bank_id.acc_number,
|
|
})
|
|
|
|
if self.partner_bank_id.proxy_type == 'merchant_id':
|
|
seller_information.update({
|
|
'merchantCode': self.partner_bank_id.proxy_value,
|
|
'merchantName': self.company_id.name,
|
|
'merchantCity': self.company_id.city,
|
|
})
|
|
|
|
json_values['sellerInfo'] = seller_information
|
|
|
|
def _l10n_vn_edi_add_payment_information(self, json_values):
|
|
""" Create and return the payment information for the current invoice. Not fully supported. """
|
|
self.ensure_one()
|
|
json_values['payments'] = [{
|
|
# We need to provide a value but when we send the invoice, we may not have this information.
|
|
# According to VN laws, if the payment method has not been determined, we can fill in TM/CK.
|
|
# TM is for bank transfer, CK is for cash payment.
|
|
'paymentMethodName': 'TM/CK',
|
|
}]
|
|
|
|
def _l10n_vn_edi_add_item_information(self, json_values):
|
|
""" Create and return the items information for the current invoice. """
|
|
self.ensure_one()
|
|
items_information = []
|
|
code_map = {
|
|
'product': 1,
|
|
'line_note': 2,
|
|
'discount': 3,
|
|
}
|
|
for line in self.invoice_line_ids.filtered(lambda ln: ln.display_type == 'product'):
|
|
# For credit notes amount, we send negative values (reduces the amount of the original invoice)
|
|
sign = 1 if self.move_type == 'out_invoice' else -1
|
|
item_information = {
|
|
'itemCode': line.product_id.code,
|
|
'itemName': line.product_id.name,
|
|
'unitName': line.product_uom_id.name,
|
|
'unitPrice': line.price_unit * sign,
|
|
'quantity': line.quantity,
|
|
# This amount should be without discount applied.
|
|
'itemTotalAmountWithoutTax': line.currency_id.round(line.price_unit * line.quantity) * sign,
|
|
# In Vietnam a line will always have only one tax.
|
|
# Values are either: -2 (no tax), -1 (not declaring/paying taxes), 0,5,8,10 (the tax %)
|
|
# Most use cases will be -2 or a tax percentage, so we limit the support to these.
|
|
'taxPercentage': line.tax_ids and line.tax_ids[0].amount or -2,
|
|
'taxAmount': (line.price_total - line.price_subtotal) * sign,
|
|
'discount': line.discount,
|
|
'itemTotalAmountAfterDiscount': line.price_subtotal * sign,
|
|
'itemTotalAmountWithTax': line.price_total * sign,
|
|
}
|
|
if line.display_type in code_map:
|
|
item_information['selection'] = code_map[line.display_type]
|
|
if line.display_type == 'discount':
|
|
item_information['isIncreaseItem'] = False
|
|
if self.move_type == 'out_refund':
|
|
item_information.update({
|
|
'adjustmentTaxAmount': item_information['taxAmount'],
|
|
'isIncreaseItem': False,
|
|
})
|
|
items_information.append(item_information)
|
|
|
|
json_values['itemInfo'] = items_information
|
|
|
|
def _l10n_vn_edi_add_tax_breakdowns(self, json_values):
|
|
""" Create and return the tax breakdown of the current invoice. """
|
|
self.ensure_one()
|
|
|
|
def grouping_key_generator(base_line, tax_data):
|
|
# Requirement is to generate a tax breakdown per taxPercentage
|
|
return {'tax_percentage': tax_data['tax'].amount or -2}
|
|
|
|
tax_breakdowns = []
|
|
|
|
tax_details_grouped = self._prepare_invoice_aggregated_taxes(grouping_key_generator=grouping_key_generator)
|
|
for tax_percentage, tax_percentage_values in tax_details_grouped['tax_details'].items():
|
|
tax_breakdowns.append({
|
|
'taxPercentage': tax_percentage['tax_percentage'],
|
|
'taxableAmount': tax_percentage_values['base_amount_currency'],
|
|
'taxAmount': tax_percentage_values['tax_amount_currency'],
|
|
'taxableAmountPos': self.move_type == 'out_invoice', # For adjustment invoice, the amount should be considered as negative.
|
|
'taxAmountPos': self.move_type == 'out_invoice', # Same
|
|
})
|
|
|
|
json_values['taxBreakdowns'] = tax_breakdowns
|
|
|
|
def _l10n_vn_edi_lookup_invoice(self):
|
|
""" Lookup on invoice, returning its current details on SInvoice. """
|
|
self.ensure_one()
|
|
access_token, error = self._l10n_vn_edi_get_access_token()
|
|
if error:
|
|
return {}, error
|
|
|
|
invoice_data, error_message = _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url=f'{SINVOICE_API_URL}InvoiceAPI/InvoiceWS/searchInvoiceByTransactionUuid',
|
|
params={
|
|
'supplierTaxCode': self.company_id.vat,
|
|
'transactionUuid': self.l10n_vn_edi_invoice_transaction_id,
|
|
},
|
|
headers={
|
|
'Content-Type': 'application/x-www-form-urlencoded;',
|
|
},
|
|
cookies={'access_token': access_token},
|
|
)
|
|
return invoice_data, error_message
|
|
|
|
def _l10n_vn_edi_get_access_token(self):
|
|
""" Return an access token to be used to contact the API. Either take a valid stored one or get a new one. """
|
|
self.ensure_one()
|
|
credentials_company = self._l10n_vn_edi_get_credentials_company()
|
|
# First, check if we have a token stored and if it is still valid.
|
|
if credentials_company.l10n_vn_edi_token and credentials_company.l10n_vn_edi_token_expiry > datetime.now():
|
|
return credentials_company.l10n_vn_edi_token, ""
|
|
|
|
data = {'username': credentials_company.l10n_vn_edi_username, 'password': credentials_company.l10n_vn_edi_password}
|
|
request_response, error_message = _l10n_vn_edi_send_request(
|
|
method='POST',
|
|
url='https://api-vinvoice.viettel.vn/auth/login', # This one is special and uses another base address.
|
|
json_data=data
|
|
)
|
|
if error_message:
|
|
return "", error_message
|
|
if 'access_token' not in request_response: # Just in case something else go wrong and it's missing the token
|
|
return "", _('Connection to the API failed, please try again later.')
|
|
|
|
access_token = request_response['access_token']
|
|
|
|
try:
|
|
access_token_expiry = datetime.now() + timedelta(seconds=int(request_response['expires_in']))
|
|
except ValueError: # Simple security measure in case we don't get the expected format in the response.
|
|
return "", _('Error while parsing API answer. Please try again later.')
|
|
|
|
# Tokens are valid for 5 minutes. Storing it helps reduce api calls and speed up things a little bit.
|
|
credentials_company.write({
|
|
'l10n_vn_edi_token': access_token,
|
|
'l10n_vn_edi_token_expiry': access_token_expiry,
|
|
})
|
|
|
|
return request_response['access_token'], ""
|
|
|
|
def _l10n_vn_edi_get_credentials_company(self):
|
|
""" The company holding the credentials could be one of the parent companies.
|
|
We need to ensure that:
|
|
- We use the credentials of the parent company, if no credentials are set on the child one.
|
|
- We store the access token on the appropriate company, based on which holds the credentials.
|
|
"""
|
|
if self.company_id.l10n_vn_edi_username and self.company_id.l10n_vn_edi_password:
|
|
return self.company_id
|
|
|
|
return self.company_id.sudo().parent_ids.filtered(
|
|
lambda c: c.l10n_vn_edi_username and c.l10n_vn_edi_password
|
|
)[-1:]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# HELPERS
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.model
|
|
def _l10n_vn_edi_format_date(self, date):
|
|
"""
|
|
All APIs for Sinvoice uses the same time format, being the current hour, minutes and seconds converted into
|
|
seconds since unix epoch, but formatting like milliseconds since unix epoch.
|
|
It means that the time will end in 000 for the milliseconds as they are not as of today used by the system.
|
|
"""
|
|
return int(date.timestamp()) * 1000 if date else 0
|
|
|
|
@api.model
|
|
def _l10n_vn_edi_format_phone_number(self, number):
|
|
"""
|
|
Simple helper that takes in a phone number and try to format it to fit sinvoice format.
|
|
SInvoice only allows digits, so we will remove any (, ), -, + characters.
|
|
"""
|
|
# We first replace + by 00, then we remove all non digit characters.
|
|
number = number.replace('+', '00')
|
|
return re.sub(r'[^0-9]+', '', number)
|
|
|
|
def _l10n_vn_edi_is_sent(self):
|
|
""" Small helper that returns true if self has been sent to sinvoice. """
|
|
self.ensure_one()
|
|
sent_statuses = {'sent', 'payment_state_to_update', 'canceled', 'adjusted', 'replaced'}
|
|
return self.l10n_vn_edi_invoice_state in sent_statuses
|