# Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import models, fields, api, _ from odoo.exceptions import UserError, RedirectWarning, ValidationError from dateutil.relativedelta import relativedelta from lxml import etree import logging _logger = logging.getLogger(__name__) class AccountMove(models.Model): _inherit = 'account.move' @api.model def _l10n_ar_get_document_number_parts(self, document_number, document_type_code): # import shipments if document_type_code in ['66', '67']: pos = invoice_number = '0' else: pos, invoice_number = document_number.split('-') return {'invoice_number': int(invoice_number), 'point_of_sale': int(pos)} l10n_ar_afip_responsibility_type_id = fields.Many2one( 'l10n_ar.afip.responsibility.type', string='AFIP Responsibility Type', help='Defined by AFIP to' ' identify the type of responsibilities that a person or a legal entity could have and that impacts in the' ' type of operations and requirements they need.') l10n_ar_currency_rate = fields.Float(copy=False, digits=(16, 6), readonly=True, string="Currency Rate") # Mostly used on reports l10n_ar_afip_concept = fields.Selection( compute='_compute_l10n_ar_afip_concept', selection='_get_afip_invoice_concepts', string="AFIP Concept", help="A concept is suggested regarding the type of the products on the invoice but it is allowed to force a" " different type if required.") l10n_ar_afip_service_start = fields.Date(string='AFIP Service Start Date', readonly=True, states={'draft': [('readonly', False)]}) l10n_ar_afip_service_end = fields.Date(string='AFIP Service End Date', readonly=True, states={'draft': [('readonly', False)]}) @api.constrains('move_type', 'journal_id') def _check_moves_use_documents(self): """ Do not let to create not invoices entries in journals that use documents """ not_invoices = self.filtered(lambda x: x.company_id.country_id.code == "AR" and x.journal_id.type in ['sale', 'purchase'] and x.l10n_latam_use_documents and not x.is_invoice()) if not_invoices: raise ValidationError(_("The selected Journal can't be used in this transaction, please select one that doesn't use documents as these are just for Invoices.")) @api.constrains('move_type', 'l10n_latam_document_type_id') def _check_invoice_type_document_type(self): """ LATAM module define that we are not able to use debit_note or invoice document types in an invoice refunds, However for Argentinian Document Type's 99 (internal type = invoice) we are able to used in a refund invoices. In this method we exclude the argentinian document type 99 from the generic constraint """ ar_doctype_99 = self.filtered( lambda x: x.country_code == 'AR' and x.l10n_latam_document_type_id.code == '99' and x.move_type in ['out_refund', 'in_refund']) super(AccountMove, self - ar_doctype_99)._check_invoice_type_document_type() def _get_afip_invoice_concepts(self): """ Return the list of values of the selection field. """ return [('1', 'Products / Definitive export of goods'), ('2', 'Services'), ('3', 'Products and Services'), ('4', '4-Other (export)')] @api.depends('invoice_line_ids', 'invoice_line_ids.product_id', 'invoice_line_ids.product_id.type', 'journal_id') def _compute_l10n_ar_afip_concept(self): recs_afip = self.filtered(lambda x: x.company_id.country_id.code == "AR" and x.l10n_latam_use_documents) for rec in recs_afip: rec.l10n_ar_afip_concept = rec._get_concept() remaining = self - recs_afip remaining.l10n_ar_afip_concept = '' def _get_concept(self): """ Method to get the concept of the invoice considering the type of the products on the invoice """ self.ensure_one() invoice_lines = self.invoice_line_ids.filtered(lambda x: not x.display_type) product_types = set([x.product_id.type for x in invoice_lines if x.product_id]) consumable = set(['consu', 'product']) service = set(['service']) mixed = set(['consu', 'service', 'product']) # on expo invoice you can mix services and products expo_invoice = self.l10n_latam_document_type_id.code in ['19', '20', '21'] # Default value "product" afip_concept = '1' if product_types == service: afip_concept = '2' elif product_types - consumable and product_types - service and not expo_invoice: afip_concept = '3' return afip_concept def _get_l10n_latam_documents_domain(self): self.ensure_one() domain = super()._get_l10n_latam_documents_domain() if self.journal_id.company_id.country_id.code == "AR": letters = self.journal_id._get_journal_letter(counterpart_partner=self.partner_id.commercial_partner_id) domain += ['|', ('l10n_ar_letter', '=', False), ('l10n_ar_letter', 'in', letters)] codes = self.journal_id._get_journal_codes() if codes: domain.append(('code', 'in', codes)) if self.move_type == 'in_refund': domain = ['|', ('code', 'in', ['99'])] + domain return domain def _check_argentinian_invoice_taxes(self): # check vat on companies thats has it (Responsable inscripto) for inv in self.filtered(lambda x: x.company_id.l10n_ar_company_requires_vat): purchase_aliquots = 'not_zero' # we require a single vat on each invoice line except from some purchase documents if inv.move_type in ['in_invoice', 'in_refund'] and inv.l10n_latam_document_type_id.purchase_aliquots == 'zero': purchase_aliquots = 'zero' for line in inv.mapped('invoice_line_ids').filtered(lambda x: x.display_type not in ('line_section', 'line_note')): vat_taxes = line.tax_ids.filtered(lambda x: x.tax_group_id.l10n_ar_vat_afip_code) if len(vat_taxes) != 1: raise UserError(_('There must be one and only one VAT tax per line. Check line "%s"', line.name)) elif purchase_aliquots == 'zero' and vat_taxes.tax_group_id.l10n_ar_vat_afip_code != '0': raise UserError(_('On invoice id "%s" you must use VAT Not Applicable on every line.') % inv.id) elif purchase_aliquots == 'not_zero' and vat_taxes.tax_group_id.l10n_ar_vat_afip_code == '0': raise UserError(_('On invoice id "%s" you must use VAT taxes different than VAT Not Applicable.') % inv.id) def _set_afip_service_dates(self): for rec in self.filtered(lambda m: m.invoice_date and m.l10n_ar_afip_concept in ['2', '3', '4']): if not rec.l10n_ar_afip_service_start: rec.l10n_ar_afip_service_start = rec.invoice_date + relativedelta(day=1) if not rec.l10n_ar_afip_service_end: rec.l10n_ar_afip_service_end = rec.invoice_date + relativedelta(day=1, days=-1, months=+1) @api.onchange('partner_id') def _onchange_afip_responsibility(self): if self.company_id.country_id.code == 'AR' and self.l10n_latam_use_documents and self.partner_id \ and not self.partner_id.l10n_ar_afip_responsibility_type_id: return {'warning': { 'title': _('Missing Partner Configuration'), 'message': _('Please configure the AFIP Responsibility for "%s" in order to continue') % ( self.partner_id.name)}} @api.onchange('partner_id') def _onchange_partner_journal(self): """ This method is used when the invoice is created from the sale or subscription """ expo_journals = ['FEERCEL', 'FEEWS', 'FEERCELP'] for rec in self.filtered(lambda x: x.company_id.country_id.code == "AR" and x.journal_id.type == 'sale' and x.l10n_latam_use_documents and x.partner_id.l10n_ar_afip_responsibility_type_id): res_code = rec.partner_id.l10n_ar_afip_responsibility_type_id.code domain = [('company_id', '=', rec.company_id.id), ('l10n_latam_use_documents', '=', True), ('type', '=', 'sale')] journal = self.env['account.journal'] msg = False if res_code in ['9', '10'] and rec.journal_id.l10n_ar_afip_pos_system not in expo_journals: # if partner is foregin and journal is not of expo, we try to change to expo journal journal = journal.search(domain + [('l10n_ar_afip_pos_system', 'in', expo_journals)], limit=1) msg = _('You are trying to create an invoice for foreign partner but you don\'t have an exportation journal') elif res_code not in ['9', '10'] and rec.journal_id.l10n_ar_afip_pos_system in expo_journals: # if partner is NOT foregin and journal is for expo, we try to change to local journal journal = journal.search(domain + [('l10n_ar_afip_pos_system', 'not in', expo_journals)], limit=1) msg = _('You are trying to create an invoice for domestic partner but you don\'t have a domestic market journal') if journal: rec.journal_id = journal.id elif msg: # Throw an error to user in order to proper configure the journal for the type of operation action = self.env.ref('account.action_account_journal_form') raise RedirectWarning(msg, action.id, _('Go to Journals')) def _post(self, soft=True): ar_invoices = self.filtered(lambda x: x.company_id.country_id.code == "AR" and x.l10n_latam_use_documents) for rec in ar_invoices: rec.l10n_ar_afip_responsibility_type_id = rec.commercial_partner_id.l10n_ar_afip_responsibility_type_id.id if rec.company_id.currency_id == rec.currency_id: l10n_ar_currency_rate = 1.0 else: l10n_ar_currency_rate = rec.currency_id._convert( 1.0, rec.company_id.currency_id, rec.company_id, rec.invoice_date or fields.Date.today(), round=False) rec.l10n_ar_currency_rate = l10n_ar_currency_rate # We make validations here and not with a constraint because we want validation before sending electronic # data on l10n_ar_edi ar_invoices._check_argentinian_invoice_taxes() posted = super()._post(soft) posted._set_afip_service_dates() return posted def _reverse_moves(self, default_values_list=None, cancel=False): if not default_values_list: default_values_list = [{} for move in self] for move, default_values in zip(self, default_values_list): default_values.update({ 'l10n_ar_afip_service_start': move.l10n_ar_afip_service_start, 'l10n_ar_afip_service_end': move.l10n_ar_afip_service_end, }) return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel) @api.onchange('l10n_latam_document_type_id', 'l10n_latam_document_number') def _inverse_l10n_latam_document_number(self): super()._inverse_l10n_latam_document_number() to_review = self.filtered( lambda x: x.journal_id.type == 'sale' and x.l10n_latam_document_type_id and x.l10n_latam_document_number and (x.l10n_latam_manual_document_number or not x.highest_name)) for rec in to_review: number = rec.l10n_latam_document_type_id._format_document_number(rec.l10n_latam_document_number) current_pos = int(number.split("-")[0]) if current_pos != rec.journal_id.l10n_ar_afip_pos_number: invoices = self.search([('journal_id', '=', rec.journal_id.id), ('posted_before', '=', True)], limit=1) # If there is no posted before invoices the user can change the POS number (x.l10n_latam_document_number) if (not invoices): rec.journal_id.l10n_ar_afip_pos_number = current_pos rec.journal_id._onchange_set_short_name() # If not, avoid that the user change the POS number else: raise UserError(_('The document number can not be changed for this journal, you can only modify' ' the POS number if there is not posted (or posted before) invoices')) def _get_formatted_sequence(self, number=0): return "%s %05d-%08d" % (self.l10n_latam_document_type_id.doc_code_prefix, self.journal_id.l10n_ar_afip_pos_number, number) def _get_starting_sequence(self): """ If use documents then will create a new starting sequence using the document type code prefix and the journal document number with a 8 padding number """ if self.journal_id.l10n_latam_use_documents and self.env.company.country_id.code == "AR": if self.l10n_latam_document_type_id: return self._get_formatted_sequence() return super()._get_starting_sequence() def _get_last_sequence_domain(self, relaxed=False): where_string, param = super(AccountMove, self)._get_last_sequence_domain(relaxed) if self.company_id.country_id.code == "AR" and self.l10n_latam_use_documents: if not self.journal_id.l10n_ar_share_sequences: where_string += " AND l10n_latam_document_type_id = %(l10n_latam_document_type_id)s" param['l10n_latam_document_type_id'] = self.l10n_latam_document_type_id.id or 0 elif self.journal_id.l10n_ar_share_sequences: where_string += " AND l10n_latam_document_type_id in %(l10n_latam_document_type_ids)s" param['l10n_latam_document_type_ids'] = tuple(self.l10n_latam_document_type_id.search( [('l10n_ar_letter', '=', self.l10n_latam_document_type_id.l10n_ar_letter)]).ids) return where_string, param def _l10n_ar_get_amounts(self, company_currency=False): """ Method used to prepare data to present amounts and taxes related amounts when creating an electronic invoice for argentinian and the txt files for digital VAT books. Only take into account the argentinian taxes """ self.ensure_one() amount_field = company_currency and 'balance' or 'price_subtotal' # if we use balance we need to correct sign (on price_subtotal is positive for refunds and invoices) sign = -1 if (company_currency and self.is_inbound()) else 1 tax_lines = self.line_ids.filtered('tax_line_id') vat_taxes = tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_vat_afip_code) vat_taxable = self.env['account.move.line'] for line in self.invoice_line_ids: if any(tax.tax_group_id.l10n_ar_vat_afip_code and tax.tax_group_id.l10n_ar_vat_afip_code not in ['0', '1', '2'] for tax in line.tax_ids): vat_taxable |= line profits_tax_group = self.env.ref('l10n_ar.tax_group_percepcion_ganancias') return {'vat_amount': sign * sum(vat_taxes.mapped(amount_field)), # For invoices of letter C should not pass VAT 'vat_taxable_amount': sign * sum(vat_taxable.mapped(amount_field)) if self.l10n_latam_document_type_id.l10n_ar_letter != 'C' else self.amount_untaxed, 'vat_exempt_base_amount': sign * sum(self.invoice_line_ids.filtered(lambda x: x.tax_ids.filtered(lambda y: y.tax_group_id.l10n_ar_vat_afip_code == '2')).mapped(amount_field)), 'vat_untaxed_base_amount': sign * sum(self.invoice_line_ids.filtered(lambda x: x.tax_ids.filtered(lambda y: y.tax_group_id.l10n_ar_vat_afip_code == '1')).mapped(amount_field)), # used on FE 'not_vat_taxes_amount': sign * sum((tax_lines - vat_taxes).mapped(amount_field)), # used on BFE + TXT 'iibb_perc_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '07').mapped(amount_field)), 'mun_perc_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '08').mapped(amount_field)), 'intern_tax_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '04').mapped(amount_field)), 'other_taxes_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '99').mapped(amount_field)), 'profits_perc_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id == profits_tax_group).mapped(amount_field)), 'vat_perc_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '06').mapped(amount_field)), 'other_perc_amount': sign * sum(tax_lines.filtered(lambda r: r.tax_line_id.tax_group_id.l10n_ar_tribute_afip_code == '09' and r.tax_line_id.tax_group_id != profits_tax_group).mapped(amount_field)), } def _get_vat(self, company_currency=False): """ Applies on wsfe web service and in the VAT digital books """ amount_field = company_currency and 'balance' or 'price_subtotal' # if we use balance we need to correct sign (on price_subtotal is positive for refunds and invoices) sign = -1 if (company_currency and self.is_inbound()) else 1 res = [] vat_taxable = self.env['account.move.line'] # get all invoice lines that are vat taxable for line in self.line_ids: if any(tax.tax_group_id.l10n_ar_vat_afip_code and tax.tax_group_id.l10n_ar_vat_afip_code not in ['0', '1', '2'] for tax in line.tax_line_id) and line[amount_field]: vat_taxable |= line for vat in vat_taxable: base_imp = sum(self.invoice_line_ids.filtered(lambda x: x.tax_ids.filtered(lambda y: y.tax_group_id.l10n_ar_vat_afip_code == vat.tax_line_id.tax_group_id.l10n_ar_vat_afip_code)).mapped(amount_field)) res += [{'Id': vat.tax_line_id.tax_group_id.l10n_ar_vat_afip_code, 'BaseImp': sign * base_imp, 'Importe': sign * vat[amount_field]}] # Report vat 0% vat_base_0 = sign * sum(self.invoice_line_ids.filtered(lambda x: x.tax_ids.filtered(lambda y: y.tax_group_id.l10n_ar_vat_afip_code == '3')).mapped(amount_field)) if vat_base_0: res += [{'Id': '3', 'BaseImp': vat_base_0, 'Importe': 0.0}] return res if res else [] def _get_name_invoice_report(self): self.ensure_one() if self.l10n_latam_use_documents and self.company_id.country_id.code == 'AR': return 'l10n_ar.report_invoice_document' return super()._get_name_invoice_report()