# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, api, models, _
from odoo.tools.float_utils import float_compare
from odoo.tools import DEFAULT_SERVER_TIME_FORMAT, float_repr, float_round
from odoo.tools import html2plaintext
from .carvajal_request import CarvajalRequest
import pytz
import base64
import re
from collections import defaultdict
from datetime import timedelta
from markupsafe import Markup
class AccountEdiFormat(models.Model):
_inherit = 'account.edi.format'
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
@api.model
def _l10n_co_edi_generate_electronic_invoice_filename(self, invoice):
'''Generates the filename for the XML sent to Carvajal. A separate
sequence is used because Carvajal requires the invoice number
to only contain digits.
'''
seq_code = 'l10n_co_edi.filename'
IrSequence = self.env['ir.sequence'].with_company(invoice.company_id)
invoice_number = IrSequence.next_by_code(seq_code)
# if a sequence does not yet exist for this company create one
if not invoice_number:
IrSequence.sudo().create({
'name': 'Colombian electronic invoicing sequence for company %s' % invoice.company_id.id,
'code': seq_code,
'implementation': 'no_gap',
'padding': 10,
'number_increment': 1,
'company_id': invoice.company_id.id,
})
invoice_number = IrSequence.next_by_code(seq_code)
return 'face_{}{:0>10}{:010x}.xml'.format(invoice._l10n_co_edi_get_electronic_invoice_type(),
invoice.company_id.vat,
int(invoice_number))
def _l10n_co_edi_get_round_amount(self, amount):
if amount == '':
return ''
if abs(amount - float("%.2f" % amount)) > 0.00001:
return "%.3f" % amount
return '%.2f' % amount
def _l10n_co_edi_prepare_tim_sections(self, taxes_dict, invoice_currency, retention, tax_details=None, actual_tax_details=None, in_COP=False):
# taxes_dict is no longer used and will be removed in master
tax_details = tax_details or {}
actual_tax_details = actual_tax_details or {}
suffix = '' if in_COP else '_currency'
currency_name = 'COP' if in_COP else invoice_currency.name
base_amount_field = f'base_amount{suffix}'
tax_amount_field = f'tax_amount{suffix}'
# Mapping CO tax type -> TIM section
new_taxes_dict = defaultdict(lambda: {
'TIM_1': bool(retention),
'TIM_2': 0.0,
'TIM_3': currency_name,
'TIM_4': 0.0,
'TIM_5': currency_name,
'IMPS': [],
})
for grouping_key, tax_detail in actual_tax_details['tax_details'].items():
tax_type = grouping_key['l10n_co_edi_type']
if tax_type.retention != retention:
continue
# Construct the IMP and add it to the TIM section (one IMP per tax *rate*)
tim = new_taxes_dict[tax_type.code]
if tax_type.code == '05':
imp_2 = abs(tax_detail[tax_amount_field] * 100 / 15)
elif tax_type.code == '34':
imp_2 = sum(line.product_id.volume * line.quantity
for line in tax_detail['records']) # Volume
else:
imp_2 = abs(tax_detail[base_amount_field])
imp = {
'IMP_1': tax_type.code,
'IMP_2': imp_2,
'IMP_3': currency_name,
'IMP_4': abs(tax_detail[tax_amount_field]),
'IMP_5': currency_name,
'IMP_11': tax_type.name,
}
if grouping_key['amount_type'] == 'fixed':
imp.update({
'IMP_6': 0,
'IMP_7': 1,
'IMP_8': '94',
'IMP_9': grouping_key['amount'], # Tax rate
'IMP_10': currency_name,
})
if tax_type.code == '22':
imp['IMP_8'] = 'BO'
elif tax_type.code == '34':
imp.update({
'IMP_7': imp['IMP_2'],
'IMP_8': 'MLT',
'IMP_9': imp['IMP_2'] and float_round(abs(tax_detail[tax_amount_field]) * 100 / imp['IMP_2'], 2),
})
else:
imp.update({
'IMP_6': 15.0 if tax_type.code == '05' else abs(grouping_key['amount']),
'IMP_7': '',
'IMP_8': '',
'IMP_9': '',
'IMP_10': '',
})
tim['TIM_4'] += float_round((imp['IMP_6'] / 100.0 * imp['IMP_2']) - imp['IMP_4'], 2)
tim['TIM_2'] += imp['IMP_4']
tim['IMPS'].append(imp)
return new_taxes_dict
# -------------------------------------------------------------------------
# Generation
# -------------------------------------------------------------------------
def _l10n_co_edi_generate_xml(self, invoice):
'''Renders the XML that will be sent to Carvajal.'''
def format_domestic_phone_number(phone):
'''The CDE_3 field only allows for 10 characters (since Anexo 1.9).
Probably since Colombian telephone numbers are 10 digit numbers when excluding the country prefix (January 2024).
'''
phone = (phone or '').replace(' ', '')
if len(phone) <= 10:
return phone
phone = re.sub(r'^(\+57|0057)', '', phone)
return phone[:10]
def format_monetary(number, currency):
# Format the monetary values to avoid trailing decimals (e.g. 90.85000000000001).
return float_repr(number, currency.decimal_places)
def get_notas():
'''This generates notes in a particular format. These notes are pieces
of text that are added to the PDF in various places. |'s are
interpreted as newlines by Carvajal. Each note is added to the
XML as follows:
text
One might wonder why Carvajal uses this arbitrary format
instead of some extra simple XML tags but such questions are best
left to philosophers, not dumb developers like myself.
'''
# Volume has to be reported in l (not e.g. ml).
if invoice.move_type in ('in_invoice', 'in_refund'):
company_partner = invoice.company_id.partner_id
invoice_partner = invoice.partner_id.commercial_partner_id
return [
'23.-%s' % ("|".join([
company_partner.street or '',
company_partner.city or '',
company_partner.country_id.name or '',
company_partner.phone or '',
company_partner.email or '',
])),
'24.-%s' % ("|".join([invoice_partner.phone or '',
invoice_partner.ref or '',
invoice_partner.email or '',
])),
]
lines = invoice.invoice_line_ids.filtered(lambda line: line.product_uom_id.category_id == self.env.ref('uom.product_uom_categ_vol'))
liters = sum(line.product_uom_id._compute_quantity(line.quantity, self.env.ref('uom.product_uom_litre')) for line in lines)
total_volume = int(liters)
# Weight has to be reported in kg (not e.g. g).
lines = invoice.invoice_line_ids.filtered(lambda line: line.product_uom_id.category_id == self.env.ref('uom.product_uom_categ_kgm'))
kg = sum(line.product_uom_id._compute_quantity(line.quantity, self.env.ref('uom.product_uom_kgm')) for line in lines)
total_weight = int(kg)
# Units have to be reported as units (not e.g. boxes of 12).
lines = invoice.invoice_line_ids.filtered(lambda line: line.product_uom_id.category_id == self.env.ref('uom.product_uom_categ_unit'))
units = sum(line.product_uom_id._compute_quantity(line.quantity, self.env.ref('uom.product_uom_unit')) for line in lines)
total_units = int(units)
withholding_amount = invoice.amount_untaxed + abs(sum(invoice.line_ids.filtered(lambda line: line.tax_line_id and not line.tax_line_id.l10n_co_edi_type.retention).mapped('amount_currency')))
amount_in_words = invoice.currency_id.with_context(lang=invoice.partner_id.lang or 'es_ES').amount_to_text(withholding_amount)
reg_a_tag = re.compile('')
clean_narration = re.sub(reg_a_tag, '', invoice.narration) if invoice.narration else False
narration = (html2plaintext(clean_narration or '') and html2plaintext(clean_narration) + ' ') + (invoice.invoice_origin or '')
notas = [
'1.-%s|%s|%s|%s|%s|%s' % (invoice.company_id.l10n_co_edi_header_gran_contribuyente or '',
invoice.company_id.l10n_co_edi_header_tipo_de_regimen or '',
invoice.company_id.l10n_co_edi_header_retenedores_de_iva or '',
invoice.company_id.l10n_co_edi_header_autorretenedores or '',
invoice.company_id.l10n_co_edi_header_resolucion_aplicable or '',
invoice.company_id.l10n_co_edi_header_actividad_economica or ''),
'2.-%s' % (invoice.company_id.l10n_co_edi_header_bank_information or '').replace('\n', '|'),
('3.- %s' % (narration or 'N/A'))[:5000],
'6.- %s|%s' % (html2plaintext(invoice.invoice_payment_term_id.note), amount_in_words),
'7.- %s' % (invoice.company_id.website),
'8.-%s|%s|%s' % (invoice.partner_id.commercial_partner_id._get_vat_without_verification_code() or '', invoice.partner_shipping_id.phone or '', invoice.invoice_origin and invoice.invoice_origin.split(',')[0] or ''),
'10.- | | | |%s' % (invoice.invoice_origin and invoice.invoice_origin.split(',')[0] or 'N/A'),
'11.- |%s| |%s|%s' % (total_units, total_weight, total_volume)
]
return notas
invoice = invoice.with_context(lang=invoice.partner_id.lang)
code_to_filter = ['07', 'ZZ'] if invoice.move_type in ('in_invoice', 'in_refund') else ['ZZ']
move_lines_with_tax_type = invoice.line_ids.filtered(lambda l: l.tax_line_id.l10n_co_edi_type.code not in [False] + code_to_filter)
ovt_tax_codes = ('01C', '02C', '03C')
ovt_taxes = move_lines_with_tax_type.filtered(lambda move: move.tax_line_id.l10n_co_edi_type.code in ovt_tax_codes).tax_line_id
invoice_type_to_ref_1 = {
'out_invoice': 'IV',
'out_refund': 'NC',
}
def group_tax_retention(base_line, tax_values):
tax = tax_values['tax_repartition_line'].tax_id
return {'tax': tax, 'l10n_co_edi_type': tax.l10n_co_edi_type}
def group_tax_tim(base_line, tax_values):
""" Tax details to be used for the TIM section: taxes should be grouped per CO tax type, then per tax rate.
"""
tax = tax_values['tax_repartition_line'].tax_id
return {
'amount': tax.amount,
'amount_type': tax.amount_type,
'l10n_co_edi_type': tax.l10n_co_edi_type,
}
def l10n_co_filter_to_apply(base_line, tax_values):
return tax_values['tax_repartition_line'].tax_id.l10n_co_edi_type.code not in code_to_filter
tax_details_tim = invoice._prepare_edi_tax_details(filter_to_apply=l10n_co_filter_to_apply, grouping_key_generator=group_tax_tim)
tax_details = invoice._prepare_edi_tax_details(filter_to_apply=l10n_co_filter_to_apply, grouping_key_generator=group_tax_retention)
retention_taxes = [(group, detail) for group, detail in tax_details['tax_details'].items() if detail['l10n_co_edi_type'].retention]
regular_taxes = [(group, detail) for group, detail in tax_details['tax_details'].items() if not detail['l10n_co_edi_type'].retention]
exempt_tax_dict = {}
tax_group_covered_goods = self.env.ref('l10n_co.tax_group_covered_goods', raise_if_not_found=False)
for line in invoice.invoice_line_ids:
if tax_group_covered_goods and tax_group_covered_goods in line.mapped('tax_ids.tax_group_id'):
exempt_tax_dict[line.id] = True
# Remove in master: retention_lines_listdict, regular_lines_listdict no longer used
retention_lines = move_lines_with_tax_type.filtered(
lambda move: move.tax_line_id.l10n_co_edi_type.retention)
retention_lines_listdict = defaultdict(list)
for line in retention_lines:
retention_lines_listdict[line.tax_line_id.l10n_co_edi_type.code].append(line)
regular_lines = move_lines_with_tax_type - retention_lines
regular_lines_listdict = defaultdict(list)
for line in regular_lines:
regular_lines_listdict[line.tax_line_id.l10n_co_edi_type.code].append(line)
zero_tax_details = defaultdict(float)
for line, tax_detail in tax_details['tax_details_per_record'].items():
for tax, detail in tax_detail.get('tax_details').items():
if not detail.get('tax_amount'):
tax = tax.get('tax')
for grouped_tax in detail.get('group_tax_details'):
zero_tax_details[tax.l10n_co_edi_type.code] += abs(grouped_tax.get('base_amount'))
retention_taxes_new = self._l10n_co_edi_prepare_tim_sections(retention_lines_listdict, invoice.currency_id, True, None, tax_details_tim)
regular_taxes_new = self._l10n_co_edi_prepare_tim_sections(regular_lines_listdict, invoice.currency_id, False, zero_tax_details, tax_details_tim)
retention_taxes_new_COP = self._l10n_co_edi_prepare_tim_sections(retention_lines_listdict, invoice.currency_id, True, None, tax_details_tim, in_COP=True)
regular_taxes_new_COP = self._l10n_co_edi_prepare_tim_sections(retention_lines_listdict, invoice.currency_id, False, zero_tax_details, tax_details_tim, in_COP=True)
# The rate should indicate how many pesos is one foreign currency
currency_rate_number = tax_details['base_amount'] / tax_details['base_amount_currency'] if tax_details['base_amount_currency'] else 1
currency_rate = "%.2f" % currency_rate_number
sign = 1 if invoice.is_outbound() else -1
regular_tax_lines = invoice.line_ids.filtered(
lambda line: line.tax_line_id and not line.tax_line_id.l10n_co_edi_type.retention and line.tax_line_id.l10n_co_edi_type.code not in code_to_filter
)
withholding_amount = '%.2f' % (invoice.amount_untaxed + abs(sum(regular_tax_lines.mapped('amount_currency'))))
withholding_amount_company = '%.2f' % (-sign * invoice.amount_untaxed_signed + sum([sign * line.balance for line in regular_tax_lines]))
# edi_type
if invoice.move_type == 'out_refund':
edi_type = "91"
elif invoice.move_type == 'out_invoice' and invoice.l10n_co_edi_debit_note:
edi_type = "92"
else:
edi_type = "{0:0=2d}".format(int(invoice.l10n_co_edi_type))
# validation_time
validation_time = fields.Datetime.now()
validation_time = pytz.utc.localize(validation_time)
bogota_tz = pytz.timezone('America/Bogota')
validation_time = validation_time.astimezone(bogota_tz)
validation_time = validation_time.strftime(DEFAULT_SERVER_TIME_FORMAT) + "-05:00"
# description
description_field = None
if invoice.move_type in ('out_refund', 'in_refund'):
description_field = 'l10n_co_edi_description_code_credit'
if invoice.move_type in ('out_invoice', 'in_invoice') and invoice.l10n_co_edi_debit_note:
description_field = 'l10n_co_edi_description_code_debit'
description_code = invoice[description_field] if description_field else None
description = dict(invoice._fields[description_field].selection).get(description_code) if description_code else None
invoice_lines = invoice.invoice_line_ids.filtered(lambda line: line.display_type == 'product')
invoice_lines_values = {}
for line in invoice_lines:
price_subtotal = sign * line.balance
if line.discount == 100:
price_subtotal_before_discount = line.price_unit * line.quantity * currency_rate_number
else:
price_subtotal_before_discount = price_subtotal / (1 - line.discount / 100)
if line.quantity:
price_unit = price_subtotal_before_discount / line.quantity
else:
price_unit = line.price_unit * currency_rate_number
invoice_lines_values[line.id] = {
'price_unit': price_unit,
'price_subtotal': price_subtotal,
'discount_amount': price_subtotal_before_discount - price_subtotal,
'price_subtotal_before_discount': price_subtotal_before_discount,
}
xml_content = self.env['ir.qweb']._render(self._l10n_co_edi_get_electronic_invoice_template(invoice), {
'invoice': invoice,
'sign': sign,
'edi_type': edi_type,
'company_partner': invoice.company_id.partner_id,
'sales_partner': invoice.user_id,
'invoice_partner': invoice.partner_id.commercial_partner_id,
'retention_taxes': retention_taxes,
'retention_taxes_new': retention_taxes_new,
'retention_taxes_new_COP': retention_taxes_new_COP,
'regular_taxes': regular_taxes,
'regular_taxes_new': regular_taxes_new,
'regular_taxes_new_COP': regular_taxes_new_COP,
'tax_details': tax_details,
'tax_types': invoice.mapped('line_ids.tax_ids.l10n_co_edi_type'),
'exempt_tax_dict': exempt_tax_dict,
'currency_rate': currency_rate,
'shipping_partner': invoice.partner_shipping_id,
'invoice_type_to_ref_1': invoice_type_to_ref_1,
'ovt_taxes': ovt_taxes,
'float_compare': float_compare,
'notas': get_notas(),
'withholding_amount': withholding_amount,
'withholding_amount_company': withholding_amount_company,
'invoice_lines': invoice_lines,
'invoice_lines_values': invoice_lines_values,
'validation_time': validation_time,
'delivery_date': invoice.invoice_date + timedelta(1),
'description_code': description_code,
'description': description,
'format_monetary': format_monetary,
'format_domestic_phone_number': format_domestic_phone_number,
'_l10n_co_edi_get_round_amount': self._l10n_co_edi_get_round_amount
})
return b'' + xml_content.encode()
def _l10n_co_edi_get_electronic_invoice_template(self, invoice):
if invoice.move_type in ('in_invoice', 'in_refund'):
return 'l10n_co_edi.electronic_invoice_vendor_document_xml'
return 'l10n_co_edi.electronic_invoice_xml'
def _l10n_co_post_invoice_step_1(self, invoice):
'''Sends the xml to carvajal.
'''
# == Generate XML ==
xml_filename = self._l10n_co_edi_generate_electronic_invoice_filename(invoice)
xml = self._l10n_co_edi_generate_xml(invoice)
attachment = self.env['ir.attachment'].create({
'name': xml_filename,
'res_id': invoice.id,
'res_model': invoice._name,
'type': 'binary',
'raw': xml,
'mimetype': 'application/xml',
'description': _('Colombian invoice UBL generated for the %s document.', invoice.name),
})
# == Upload ==
request = CarvajalRequest(invoice.move_type, invoice.company_id)
response = request.upload(xml_filename, xml)
if 'error' not in response:
invoice.l10n_co_edi_transaction = response['transactionId']
# == Chatter ==
invoice.with_context(no_new_invoice=True).message_post(
body=_('Electronic invoice submission succeeded. Message from Carvajal:') + Markup('
)' + response['message']),
attachment_ids=attachment.ids,
)
# Do not return the attachment because it is not signed yet.
else:
# Return the attachment with the error to allow debugging.
response['attachment'] = attachment
return response
def _l10n_co_post_invoice_step_2(self, invoice):
'''Checks the current status of an uploaded XML with Carvajal. It
posts the results in the invoice chatter and also attempts to
download a ZIP containing the official XML and PDF if the
invoice is reported as fully validated.
'''
request = CarvajalRequest(invoice.move_type, invoice.company_id)
response = request.check_status(invoice)
if not response.get('error'):
response['success'] = True
invoice.l10n_co_edi_cufe_cude_ref = response['l10n_co_edi_cufe_cude_ref']
# == Create the attachment ==
if 'filename' in response and 'xml_file' in response:
response['attachment'] = self.env['ir.attachment'].create({
'name': response['filename'],
'res_id': invoice.id,
'res_model': invoice._name,
'type': 'binary',
'datas': base64.b64encode(response['xml_file']),
'mimetype': 'application/xml',
'description': _('Colombian invoice UBL generated for the %s document.', invoice.name),
})
# == Chatter ==
invoice.with_context(no_new_invoice=True).message_post(body=response['message'], attachments=response['attachments'])
elif response.get('blocking_level') == 'error':
invoice.l10n_co_edi_transaction = False
return response
# -------------------------------------------------------------------------
# BUSINESS FLOW: EDI
# -------------------------------------------------------------------------
def _needs_web_services(self):
# OVERRIDE
return self.code == 'ubl_carvajal' or super()._needs_web_services()
def _is_compatible_with_journal(self, journal):
# OVERRIDE
self.ensure_one()
if self.code != 'ubl_carvajal':
return super()._is_compatible_with_journal(journal)
return journal.type in ['sale', 'purchase'] and journal.country_code == 'CO'
def _get_move_applicability(self, move):
# EXTENDS account_edi
self.ensure_one()
if self.code != 'ubl_carvajal':
return super()._get_move_applicability(move)
# Determine on which invoices the EDI must be generated.
co_edi_needed = move.country_code == 'CO' and (
move.move_type in ('in_invoice', 'in_refund')
and bool(self.env.ref('l10n_co_edi.electronic_invoice_vendor_document_xml', raise_if_not_found=False))
) or (
move.move_type in ('out_invoice', 'out_refund')
)
if co_edi_needed:
if move.l10n_co_edi_transaction:
return {
'post': self._l10n_co_edi_post_invoice_step_2,
}
else:
return {
'post': self._l10n_co_edi_post_invoice_step_1,
}
def _check_move_configuration(self, move):
# OVERRIDE
self.ensure_one()
edi_result = super()._check_move_configuration(move)
if self.code != 'ubl_carvajal':
return edi_result
company = move.company_id
journal = move.journal_id
now = fields.Datetime.now()
oldest_date = now - timedelta(days=5)
newest_date = now + timedelta(days=10)
if not company.sudo().l10n_co_edi_username or not company.sudo().l10n_co_edi_password or not company.l10n_co_edi_company or \
not company.sudo().l10n_co_edi_account:
edi_result.append(_("Carvajal credentials are not set on the company, please go to Accounting Settings and set the credentials."))
if (move.move_type != 'out_refund' and not move.debit_origin_id) and \
(not journal.l10n_co_edi_dian_authorization_number or not journal.l10n_co_edi_dian_authorization_date or not journal.l10n_co_edi_dian_authorization_end_date):
edi_result.append(_("'Resolución DIAN' fields must be set on the journal %s", journal.display_name))
if not move.partner_id.vat:
edi_result.append(_("You can not validate an invoice that has a partner without VAT number."))
if not move.company_id.partner_id.l10n_co_edi_obligation_type_ids:
edi_result.append(_("'Obligaciones y Responsabilidades' on the Customer Fiscal Data section needs to be set for the partner %s.", move.company_id.partner_id.display_name))
if not move.amount_total:
edi_result.append(_("You cannot send Documents in Carvajal without an amount."))
if not move.partner_id.commercial_partner_id.l10n_co_edi_obligation_type_ids:
edi_result.append(_("'Obligaciones y Responsabilidades' on the Customer Fiscal Data section needs to be set for the partner %s.", move.partner_id.commercial_partner_id.display_name))
if (move.l10n_co_edi_type == '2' and \
any(l.product_id and not l.product_id.l10n_co_edi_customs_code for l in move.invoice_line_ids)):
edi_result.append(_("Every exportation product must have a customs code."))
elif move.invoice_date and not (oldest_date <= fields.Datetime.to_datetime(move.invoice_date) <= newest_date):
move.message_post(body=_('The issue date can not be older than 5 days or more than 5 days in the future'))
elif any(l.product_id and not l.product_id.default_code and \
not l.product_id.barcode and not l.product_id.unspsc_code_id for l in move.invoice_line_ids):
edi_result.append(_("Every product on a line should at least have a product code (barcode, internal, UNSPSC) set."))
if not move.company_id.partner_id.l10n_latam_identification_type_id.l10n_co_document_code:
edi_result.append(_("The Identification Number Type on the company\'s partner should be 'NIT'."))
if not move.partner_id.commercial_partner_id.l10n_latam_identification_type_id.l10n_co_document_code:
edi_result.append(_("The Identification Number Type on the customer\'s partner should be 'NIT'."))
if move.l10n_co_edi_operation_type == '20' and not move.l10n_co_edi_description_code_credit:
edi_result.append(_("Credit Notes that reference an invoice require to have a Credit Note Concept, please fill in this value"))
if not move.company_id.partner_id.email:
edi_result.append(_("Your company's contact should have a reception email set."))
# Sugar taxes
for line in move.invoice_line_ids:
if "IBUA" in line.tax_ids.l10n_co_edi_type.mapped('name') and line.product_id.volume == 0:
edi_result.append(_("You should set a volume on product: %s when using IBUA taxes.", line.product_id.name))
return edi_result
def _l10n_co_edi_post_invoice_step_1(self, invoice):
return {invoice: self._l10n_co_post_invoice_step_1(invoice)}
def _l10n_co_edi_post_invoice_step_2(self, invoice):
return {invoice: self._l10n_co_post_invoice_step_2(invoice)}
# to remove in master
def _l10n_co_edi_cancel_invoice(self, invoice):
return {invoice: {'success': True}}