diff options
| author | Katherine Zaoral <kz@adhoc.com.ar> | 2019-08-12 19:53:31 +0000 |
|---|---|---|
| committer | Josse Colpaert <jco@odoo.com> | 2019-08-23 14:27:33 +0000 |
| commit | ebe291560ac9c573cdd6e1b73fc9ea3c658ce070 (patch) | |
| tree | a0cd725fddb35ed9c38c657988409657ed1c6877 /addons/l10n_ar/models | |
| parent | 895f89ff2d32f905ecd4b446f7c94c1c2d0a09c7 (diff) | |
[IMP] l10n_ar: complete refactor of Argentinian localization
Now, it depends on l10n_latam_base for the identification
types and on l10n_latam_invoice_document for the invoice
numbering.
It is the AFIP responsibility on the partner that
determines the document type.
There are also 3 demo companies for the most important
responsibility types for yourself.
Diffstat (limited to 'addons/l10n_ar/models')
| -rw-r--r-- | addons/l10n_ar/models/__init__.py | 18 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_chart_template.py | 65 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_fiscal_position.py | 26 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_fiscal_position_template.py | 11 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_journal.py | 193 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_move.py | 179 | ||||
| -rw-r--r-- | addons/l10n_ar/models/account_tax_group.py | 32 | ||||
| -rw-r--r-- | addons/l10n_ar/models/ir_sequence.py | 13 | ||||
| -rw-r--r-- | addons/l10n_ar/models/l10n_ar_afip_responsibility_type.py | 18 | ||||
| -rw-r--r-- | addons/l10n_ar/models/l10n_latam_document_type.py | 93 | ||||
| -rw-r--r-- | addons/l10n_ar/models/l10n_latam_identification_type.py | 9 | ||||
| -rw-r--r-- | addons/l10n_ar/models/res_company.py | 45 | ||||
| -rw-r--r-- | addons/l10n_ar/models/res_country.py | 19 | ||||
| -rw-r--r-- | addons/l10n_ar/models/res_currency.py | 9 | ||||
| -rw-r--r-- | addons/l10n_ar/models/res_partner.py | 107 | ||||
| -rw-r--r-- | addons/l10n_ar/models/res_partner_bank.py | 27 | ||||
| -rw-r--r-- | addons/l10n_ar/models/uom_uom.py | 9 |
17 files changed, 873 insertions, 0 deletions
diff --git a/addons/l10n_ar/models/__init__.py b/addons/l10n_ar/models/__init__.py new file mode 100644 index 00000000000..63afb0d60ea --- /dev/null +++ b/addons/l10n_ar/models/__init__.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import l10n_latam_identification_type +from . import l10n_ar_afip_responsibility_type +from . import account_journal +from . import account_tax_group +from . import account_fiscal_position +from . import account_fiscal_position_template +from . import l10n_latam_document_type +from . import res_partner +from . import res_country +from . import res_currency +from . import res_company +from . import res_partner_bank +from . import uom_uom +from . import account_chart_template +from . import account_move +from . import ir_sequence diff --git a/addons/l10n_ar/models/account_chart_template.py b/addons/l10n_ar/models/account_chart_template.py new file mode 100644 index 00000000000..f83a3371833 --- /dev/null +++ b/addons/l10n_ar/models/account_chart_template.py @@ -0,0 +1,65 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api, _ +from odoo.exceptions import UserError +from odoo.http import request + + +class AccountChartTemplate(models.Model): + + _inherit = 'account.chart.template' + + def _get_fp_vals(self, company, position): + res = super()._get_fp_vals(company, position) + if company.country_id == self.env.ref('base.ar'): + res['l10n_ar_afip_responsibility_type_ids'] = [ + (6, False, position.l10n_ar_afip_responsibility_type_ids.ids)] + return res + + def _prepare_all_journals(self, acc_template_ref, company, journals_dict=None): + """ If Argentinian chart, we don't create sales journal as we need more + data to create it properly """ + res = super()._prepare_all_journals(acc_template_ref, company, journals_dict=journals_dict) + if company.country_id == self.env.ref('base.ar'): + for vals in res: + if vals['type'] == 'sale': + res.remove(vals) + return res + + @api.model + def _get_ar_responsibility_match(self, chart_template_id=False): + """ If not chart_template_id: return the {recordset(coa): recordset(responsibility recordset)} match dictionary + if chart_template_id: return responsibility type that match with the given chart_template_id + """ + match = { + self.env.ref('l10n_ar.l10nar_base_chart_template').id: self.env.ref('l10n_ar.res_RM'), + self.env.ref('l10n_ar.l10nar_ex_chart_template').id: self.env.ref('l10n_ar.res_IVAE'), + self.env.ref('l10n_ar.l10nar_ri_chart_template').id: self.env.ref('l10n_ar.res_IVARI'), + } + if chart_template_id: + return match.get(chart_template_id) + return match + + def _load(self, sale_tax_rate, purchase_tax_rate, company): + """ Set companies AFIP Responsibility and Country if AR CoA is installed, also set tax calculation rounding + method required in order to properly validate match AFIP invoices. + + Also, raise a warning if the user is trying to install a CoA that does not match with the defined AFIP + Responsibility defined in the company + """ + self.ensure_one() + if company.country_id == self.env.ref('base.ar'): + company_responsibility = company.l10n_ar_afip_responsibility_type_id + coa_responsibility = self._get_ar_responsibility_match(self.id) + if coa_responsibility: + company.write({ + 'l10n_ar_afip_responsibility_type_id': coa_responsibility.id, + 'country_id': self.env.ref('base.ar').id, + 'tax_calculation_rounding_method': 'round_globally', + }) + + if company_responsibility and company_responsibility != coa_responsibility: + raise UserError(_( + 'You are trying to install a chart of account for the %s responsibility but your company is configured' + ' as %s type' % (coa_responsibility.name, company_responsibility.name))) + return super()._load(sale_tax_rate, purchase_tax_rate, company) diff --git a/addons/l10n_ar/models/account_fiscal_position.py b/addons/l10n_ar/models/account_fiscal_position.py new file mode 100644 index 00000000000..9e54831f8ff --- /dev/null +++ b/addons/l10n_ar/models/account_fiscal_position.py @@ -0,0 +1,26 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class AccountFiscalPosition(models.Model): + + _inherit = 'account.fiscal.position' + + l10n_ar_afip_responsibility_type_ids = fields.Many2many( + 'l10n_ar.afip.responsibility.type', 'l10n_ar_afip_reponsibility_type_fiscal_pos_rel', + string='AFIP Responsibility Types', help='List of AFIP responsibilities where this fiscal position ' + 'should be auto-detected') + + def get_fiscal_position(self, partner_id, delivery_id=None): + """ Take into account the partner afip responsibility in order to auto-detect the fiscal position """ + company = self.env['res.company'].browse(self._context.get('force_company', self.env.user.company_id.id)) + if company.country_id == self.env.ref('base.ar'): + domain = [ + ('auto_apply', '=', True), + ('l10n_ar_afip_responsibility_type_ids', '=', self.env['res.partner'].browse( + partner_id).l10n_ar_afip_responsibility_type_id.id), + ] + if self.env.context.get('force_company'): + domain.append(('company_id', '=', self.env.context.get('force_company'))) + return self.search(domain, limit=1).id + return super().get_fiscal_position(partner_id, delivery_id=delivery_id) diff --git a/addons/l10n_ar/models/account_fiscal_position_template.py b/addons/l10n_ar/models/account_fiscal_position_template.py new file mode 100644 index 00000000000..8b6e3c0f945 --- /dev/null +++ b/addons/l10n_ar/models/account_fiscal_position_template.py @@ -0,0 +1,11 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class AccountFiscalPositionTemplate(models.Model): + + _inherit = 'account.fiscal.position.template' + + l10n_ar_afip_responsibility_type_ids = fields.Many2many( + 'l10n_ar.afip.responsibility.type', 'l10n_ar_afip_reponsibility_type_fiscal_pos_temp_rel', + string='AFIP Responsibility Types') diff --git a/addons/l10n_ar/models/account_journal.py b/addons/l10n_ar/models/account_journal.py new file mode 100644 index 00000000000..e568db79685 --- /dev/null +++ b/addons/l10n_ar/models/account_journal.py @@ -0,0 +1,193 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError, RedirectWarning + + +class AccountJournal(models.Model): + + _inherit = "account.journal" + + l10n_ar_afip_pos_system = fields.Selection( + selection='_get_l10n_ar_afip_pos_types_selection', string='AFIP POS System') + l10n_ar_afip_pos_number = fields.Integer( + 'AFIP POS Number', help='This is the point of sale number assigned by AFIP in order to generate invoices') + l10n_ar_afip_pos_partner_id = fields.Many2one( + 'res.partner', 'AFIP POS Address', help='This is the address used for invoice reports of this POS') + l10n_ar_sequence_ids = fields.One2many('ir.sequence', 'l10n_latam_journal_id', string="Sequences") + l10n_ar_share_sequences = fields.Boolean( + 'Unified Book', help='Use same sequence for documents with the same letter') + + def _get_l10n_ar_afip_pos_types_selection(self): + """ Return the list of values of the selection field. """ + return [ + ('II_IM', 'Pre-printed Invoice'), + ('RLI_RLM', 'Online Invoice'), + ('BFERCEL', 'Electronic Fiscal Bonds - Online Invoice'), + ('FEERCELP', 'Export Vouchers - Billing Plus'), + ('FEERCEL', 'Export Vouchers - Online Invoice'), + ('CPERCEL', 'Product Coding - Online Vouchers'), + ] + + def _get_journal_letter(self, counterpart_partner=False): + """ Regarding the AFIP responsibility of the company and the type of journal (sale/purchase), get the allowed + letters. Optionally, receive the counterpart partner (customer/supplier) and get the allowed letters to work + with him. This method is used to populate document types on journals and also to filter document types on + specific invoices to/from customer/supplier + """ + self.ensure_one() + letters_data = { + 'issued': { + '1': ['A', 'B', 'E', 'M'], + '3': [], + '4': ['C'], + '5': [], + '6': ['C', 'E'], + '9': ['I'], + '10': [], + '13': ['C', 'E'], + }, + 'received': { + '1': ['A', 'C', 'M', 'I'], + '3': ['B', 'C', 'I'], + '4': ['B', 'C', 'I'], + '5': ['B', 'C', 'I'], + '6': ['B', 'C', 'I'], + '9': ['E'], + '10': ['E'], + '13': ['B', 'C', 'I'], + }, + } + if not self.company_id.l10n_ar_afip_responsibility_type_id: + action = self.env.ref('base.action_res_company_form') + msg = _('Can not create chart of account until you configure your company AFIP Responsibility and VAT.') + raise RedirectWarning(msg, action.id, _('Go to Companies')) + + letters = letters_data['issued' if self.type == 'sale' else 'received'][ + self.company_id.l10n_ar_afip_responsibility_type_id.code] + if not counterpart_partner: + return letters + + if not counterpart_partner.l10n_ar_afip_responsibility_type_id: + letters = [] + else: + counterpart_letters = letters_data['issued' if self.type == 'purchase' else 'received'][ + counterpart_partner.l10n_ar_afip_responsibility_type_id.code] + letters = list(set(letters) & set(counterpart_letters)) + return letters + + def _get_journal_codes(self): + self.ensure_one() + usual_codes = ['1', '2', '3', '6', '7', '8', '11', '12', '13'] + mipyme_codes = ['201', '202', '203', '206', '207', '208', '211', '212', '213'] + invoice_m_code = ['51', '52', '53'] + receipt_m_code = ['54'] + receipt_codes = ['4', '9', '15'] + expo_codes = ['19', '20', '21'] + if self.type != 'sale': + return [] + elif self.l10n_ar_afip_pos_system == 'II_IM': + # pre-printed invoice + return usual_codes + receipt_codes + expo_codes + invoice_m_code + receipt_m_code + elif self.l10n_ar_afip_pos_system in ['RAW_MAW', 'RLI_RLM']: + # electronic/online invoice + return usual_codes + receipt_codes + invoice_m_code + receipt_m_code + mipyme_codes + elif self.l10n_ar_afip_pos_system in ['CPERCEL', 'CPEWS']: + # invoice with detail + return usual_codes + invoice_m_code + elif self.l10n_ar_afip_pos_system in ['BFERCEL', 'BFEWS']: + # Bonds invoice + return usual_codes + mipyme_codes + elif self.l10n_ar_afip_pos_system in ['FEERCEL', 'FEEWS', 'FEERCELP']: + return expo_codes + + @api.model + def create(self, values): + """ Create Document sequences after create the journal """ + res = super().create(values) + res.create_document_sequences() + return res + + def write(self, values): + """ Update Document sequences after update journal """ + to_check = set(['type', 'l10n_ar_afip_pos_system', 'l10n_ar_afip_pos_number', 'l10n_ar_share_sequences', + 'l10n_latam_use_documents']) + res = super().write(values) + if to_check.intersection(set(values.keys())): + for rec in self: + rec.create_document_sequences() + return res + + @api.constrains('type', 'l10n_ar_afip_pos_system', 'l10n_ar_afip_pos_number', 'l10n_ar_share_sequences', + 'l10n_latam_use_documents') + def _check_afip_configurations(self): + """ Do not let to update journal if already have confirmed invoices """ + self.ensure_one() + if self.company_id.country_id != self.env.ref('base.ar'): + return True + if self.type != 'sale' and self._origin.type != 'sale': + return True + invoices = self.env['account.move'].search([('journal_id', '=', self.id), ('state', '!=', 'draft')]) + if invoices: + raise ValidationError(_( + 'You can not change the journal configuration for a journal that already have validate invoices') + + ':<br/><br/> - %s' % ('<br/>- '.join(invoices.mapped('display_name')))) + + def create_document_sequences(self): + """ IF AFIP Configuration change try to review if this can be done and then create / update the document + sequences """ + self.ensure_one() + if self.company_id.country_id != self.env.ref('base.ar'): + return True + if not self.type == 'sale' or not self.l10n_latam_use_documents: + return False + + sequences = self.l10n_ar_sequence_ids + sequences.unlink() + + # Create Sequences + letters = self._get_journal_letter() + internal_types = ['invoice', 'debit_note', 'credit_note'] + domain = [('country_id.code', '=', 'AR'), ('internal_type', 'in', internal_types), + '|', ('l10n_ar_letter', '=', False), ('l10n_ar_letter', 'in', letters)] + codes = self._get_journal_codes() + if codes: + domain.append(('code', 'in', codes)) + documents = self.env['l10n_latam.document.type'].search(domain) + for document in documents: + if self.l10n_ar_share_sequences and self.l10n_ar_sequence_ids.filtered( + lambda x: x.l10n_ar_letter == document.l10n_ar_letter): + continue + + sequences |= self.env['ir.sequence'].create(document.get_document_sequence_vals(self)) + return sequences + + @api.constrains('l10n_ar_afip_pos_number') + def _check_afip_pos_number(self): + missing_pos_number = self.filtered( + lambda x: x.type == 'sale' and x.l10n_latam_use_documents and x.company_id.country_id == self.env.ref('base.ar') + and x.l10n_ar_afip_pos_number == 0) + if missing_pos_number: + raise ValidationError(_('Please define a valid AFIP POS number')) + + @api.onchange('l10n_ar_afip_pos_system') + def _onchange_l10n_ar_afip_pos_system(self): + """ On 'Pre-printed Invoice' the usual is to share sequences. On other types, do not share """ + self.l10n_ar_share_sequences = bool(self.l10n_ar_afip_pos_system == 'II_IM') + + @api.onchange('company_id') + def _onchange_company_set_domain(self): + """ Will define the AFIP POS Address field domain taking into account the company configured in the journal """ + company_partner = self.company_id.partner_id.id + return {'domain': {'l10n_ar_afip_pos_partner_id': [ + '|', ('id', '=', company_partner), '&', ('id', 'child_of', company_partner), ('type', '!=', 'contact')]}} + + @api.onchange('l10n_ar_afip_pos_number', 'type') + def _onchange_set_short_name(self): + """ Will define the AFIP POS Address field domain taking into account the company configured in the journal + The short code of the journal only admit 5 characters, so depending on the size of the pos_number (also max 5) + we add or not a prefix to identify sales journal. + """ + if self.type == 'sale' and self.l10n_ar_afip_pos_number: + pos_num = str(self.l10n_ar_afip_pos_number) + self.code = pos_num if len(pos_num) > 4 else _('S') + "%04d" % self.l10n_ar_afip_pos_number diff --git a/addons/l10n_ar/models/account_move.py b/addons/l10n_ar/models/account_move.py new file mode 100644 index 00000000000..6dd6b2c8727 --- /dev/null +++ b/addons/l10n_ar/models/account_move.py @@ -0,0 +1,179 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from dateutil.relativedelta import relativedelta +from lxml import etree +import logging +_logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + + _inherit = 'account.move' + + @staticmethod + def _l10n_ar_get_document_number_parts(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.') + + # TODO make it editable, we have to change move creation method + l10n_ar_currency_rate = fields.Float(copy=False, digits=(16, 4), 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)]}) + + 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 == self.env.ref('base.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 + 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']) + # Default value "product" + afip_concept = '1' + if product_types.issubset(mixed): + afip_concept = '3' + if product_types.issubset(service): + afip_concept = '2' + if product_types.issubset(consumable): + afip_concept = '1' + # on expo invoice you can mix services and products + if self.l10n_latam_document_type_id.code in ['19', '20', '21'] and afip_concept == '3': + afip_concept = '1' + 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 == self.env.ref('base.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)) + return domain + + def _check_argentinian_invoice_taxes(self): + _logger.info('Running checks related to argentinian documents') + + # 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.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('invoice_date') + def _onchange_invoice_date(self): + super()._onchange_invoice_date() + self._set_afip_service_dates() + + @api.onchange('partner_id') + def _onchange_afip_responsibility(self): + if self.company_id.country_id == self.env.ref('base.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)}} + + def _get_document_type_sequence(self): + """ Return the match sequences for the given journal and invoice """ + self.ensure_one() + if self.journal_id.l10n_latam_use_documents and self.l10n_latam_country_code == 'AR': + if self.journal_id.l10n_ar_share_sequences: + return self.journal_id.l10n_ar_sequence_ids.filtered( + lambda x: x.l10n_ar_letter == self.l10n_latam_document_type_id.l10n_ar_letter) + res = self.journal_id.l10n_ar_sequence_ids.filtered( + lambda x: x.l10n_latam_document_type_id == self.l10n_latam_document_type_id) + return res + return super()._get_document_type_sequence() + + @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 == self.env.ref('base.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'] + if res_code in ['8', '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) + elif res_code not in ['8', '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) + if journal: + rec.journal_id = journal.id + + def post(self): + ar_invoices = self.filtered(lambda x: x.company_id.country_id == self.env.ref('base.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 validaiton before sending electronic + # data on l10n_ar_edi + ar_invoices._check_argentinian_invoice_taxes() + return super().post() + + 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({ + # TODO enable when we make l10n_ar_currency_rate editable + # 'l10n_ar_currency_rate': move.l10n_ar_currency_rate, + '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) diff --git a/addons/l10n_ar/models/account_tax_group.py b/addons/l10n_ar/models/account_tax_group.py new file mode 100644 index 00000000000..4a26e09a974 --- /dev/null +++ b/addons/l10n_ar/models/account_tax_group.py @@ -0,0 +1,32 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models, api + + +class AccountTaxGroup(models.Model): + + _inherit = 'account.tax.group' + + # values from http://www.afip.gob.ar/fe/documentos/otros_Tributos.xlsx + l10n_ar_tribute_afip_code = fields.Selection([ + ('01', '01 - Impuestos nacionales'), + ('02', '02 - Impuestos provinciales'), + ('03', '03 - Impuestos municipales'), + ('04', '04 - Impuestos internos'), + ('06', '06 - Percepción de IVA'), + ('07', '07 - Percepción de IIBB'), + ('08', '08 - Percepciones por Impuestos Municipales'), + ('09', '09 - Otras Percepciones'), + ('99', '99 - Otros'), + ], string='Tribute AFIP Code', index=True, readonly=True) + # values from http://www.afip.gob.ar/fe/documentos/OperacionCondicionIVA.xls + l10n_ar_vat_afip_code = fields.Selection([ + ('0', 'No Corresponde'), + ('1', 'No Gravado'), + ('2', 'Exento'), + ('3', '0%'), + ('4', '10.5%'), + ('5', '21%'), + ('6', '27%'), + ('8', '5%'), + ('9', '2,5%'), + ], string='VAT AFIP Code', index=True, readonly=True) diff --git a/addons/l10n_ar/models/ir_sequence.py b/addons/l10n_ar/models/ir_sequence.py new file mode 100644 index 00000000000..af0d34c4809 --- /dev/null +++ b/addons/l10n_ar/models/ir_sequence.py @@ -0,0 +1,13 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class IrSequence(models.Model): + + _inherit = 'ir.sequence' + + l10n_ar_letter = fields.Selection(selection='_get_l10n_ar_letters', string='Letter') + + def _get_l10n_ar_letters(self): + """ Return the list of values of the selection field. """ + return self.env['l10n_latam.document.type']._get_l10n_ar_letters() diff --git a/addons/l10n_ar/models/l10n_ar_afip_responsibility_type.py b/addons/l10n_ar/models/l10n_ar_afip_responsibility_type.py new file mode 100644 index 00000000000..1ce42cca98c --- /dev/null +++ b/addons/l10n_ar/models/l10n_ar_afip_responsibility_type.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + + +class L10nArAfipResponsibilityType(models.Model): + + _name = 'l10n_ar.afip.responsibility.type' + _description = 'AFIP Responsibility Type' + _order = 'sequence' + + name = fields.Char(required=True, index=True) + sequence = fields.Integer() + code = fields.Char(required=True, index=True) + active = fields.Boolean(default=True) + + _sql_constraints = [('name', 'unique(name)', 'Name must be unique!'), + ('code', 'unique(code)', 'Code must be unique!')] diff --git a/addons/l10n_ar/models/l10n_latam_document_type.py b/addons/l10n_ar/models/l10n_latam_document_type.py new file mode 100644 index 00000000000..fc7c5a3c8f9 --- /dev/null +++ b/addons/l10n_ar/models/l10n_latam_document_type.py @@ -0,0 +1,93 @@ +from odoo import models, api, fields, _ +from odoo.exceptions import UserError + + +class L10nLatamDocumentType(models.Model): + + _inherit = 'l10n_latam.document.type' + + l10n_ar_letter = fields.Selection( + selection='_get_l10n_ar_letters', + string='Letters', + help='Letters defined by the AFIP that can be used to identify the' + ' documents presented to the government and that depends on the' + ' operation type, the responsibility of both the issuer and the' + ' receptor of the document') + purchase_aliquots = fields.Selection( + [('not_zero', 'Not Zero'), ('zero', 'Zero')], help='Indicates if this type of documents has VAT or Not') + + def _get_l10n_ar_letters(self): + """ Return the list of values of the selection field. """ + return [ + ('A', 'A'), + ('B', 'B'), + ('C', 'C'), + ('E', 'E'), + ('M', 'M'), + ('T', 'T'), + ('R', 'R'), + ('X', 'X'), + ('I', 'I'), # used for mapping of imports + ] + + def get_document_sequence_vals(self, journal): + """ Values to create the sequences """ + values = super().get_document_sequence_vals(journal) + if self.country_id != self.env.ref('base.ar'): + return values + + values.update({'padding': 8, 'implementation': 'no_gap', 'prefix': "%04i-" % (journal.l10n_ar_afip_pos_number), + 'l10n_latam_journal_id': journal.id}) + if journal.l10n_ar_share_sequences: + values.update({'name': '%s - Letter %s Documents' % (journal.name, self.l10n_ar_letter), + 'l10n_ar_letter': self.l10n_ar_letter}) + else: + values.update({'name': '%s - %s' % (journal.name, self.name), 'l10n_latam_document_type_id': self.id}) + return values + + def _filter_taxes_included(self, taxes): + """ In argentina we include taxes depending on document letter """ + self.ensure_one() + if self.country_id == self.env.ref('base.ar') and self.l10n_ar_letter in ['B', 'C', 'X', 'R']: + return taxes.filtered(lambda x: x.tax_group_id.l10n_ar_vat_afip_code) + return super()._filter_taxes_included(taxes) + + def _format_document_number(self, document_number): + """ Make validation of Import Dispatch Number + * making validations on the document_number. If it is wrong it should raise an exception + * format the document_number against a pattern and return it + """ + self.ensure_one() + if self.country_id != self.env.ref('base.ar'): + return super()._format_document_number(document_number) + + if not document_number: + return False + + msg = "'%s' " + _("is not a valid value for") + " '%s'.<br/>%s" + + # Import Dispatch Number Validator + if self.code in ['66', '67']: + if len(document_number) != 16: + raise UserError(msg % (document_number, self.name, _('The number of import Dispatch must be 16 characters'))) + return document_number + + # Invoice Number Validator (For Eg: 123-123) + failed = False + args = document_number.split('-') + if len(args) != 2: + failed = True + else: + pos, number = args + if len(pos) > 5 or not pos.isdigit(): + failed = True + elif len(number) > 8 or not number.isdigit(): + failed = True + document_number = '{:>04s}-{:>08s}'.format(pos, number) + if failed: + raise UserError(msg % (document_number, self.name, _( + 'The document number must be entered with a dash (-) and a maximum of 5 characters for the first part' + 'and 8 for the second. The following are examples of valid numbers:\n* 1-1\n* 0001-00000001' + '\n* 00001-00000001'))) + + return document_number diff --git a/addons/l10n_ar/models/l10n_latam_identification_type.py b/addons/l10n_ar/models/l10n_latam_identification_type.py new file mode 100644 index 00000000000..7158fe2feb3 --- /dev/null +++ b/addons/l10n_ar/models/l10n_latam_identification_type.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, fields + + +class L10nLatamIdentificationType(models.Model): + + _inherit = "l10n_latam.identification.type" + + l10n_ar_afip_code = fields.Char("AFIP Code") diff --git a/addons/l10n_ar/models/res_company.py b/addons/l10n_ar/models/res_company.py new file mode 100644 index 00000000000..26ae52e3f52 --- /dev/null +++ b/addons/l10n_ar/models/res_company.py @@ -0,0 +1,45 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError + +class ResCompany(models.Model): + + _inherit = "res.company" + + l10n_ar_country_code = fields.Char(related='country_id.code', string='Country Code') + l10n_ar_gross_income_number = fields.Char( + related='partner_id.l10n_ar_gross_income_number', string='Gross Income Number', readonly=False, + help="This field is required in order to print the invoice report properly") + l10n_ar_gross_income_type = fields.Selection( + related='partner_id.l10n_ar_gross_income_type', string='Gross Income', readonly=False, + help="This field is required in order to print the invoice report properly") + l10n_ar_afip_responsibility_type_id = fields.Many2one( + domain="[('code', 'in', [1, 4, 6])]", related='partner_id.l10n_ar_afip_responsibility_type_id', readonly=False) + l10n_ar_company_requires_vat = fields.Boolean(compute='_compute_l10n_ar_company_requires_vat', string='Company Requires Vat?') + l10n_ar_afip_start_date = fields.Date('Activities Start Date') + + @api.onchange('country_id') + def onchange_country(self): + """ Argentinian companies use round_globally as tax_calculation_rounding_method """ + for rec in self.filtered(lambda x: x.country_id == self.env.ref('base.ar')): + rec.tax_calculation_rounding_method = 'round_globally' + + @api.depends('l10n_ar_afip_responsibility_type_id') + def _compute_l10n_ar_company_requires_vat(self): + recs_requires_vat = self.filtered(lambda x: x.l10n_ar_afip_responsibility_type_id.code == '1') + recs_requires_vat.l10n_ar_company_requires_vat = True + remaining = self - recs_requires_vat + remaining.l10n_ar_company_requires_vat = False + + def _localization_use_documents(self): + """ Argentinian localization use documents """ + self.ensure_one() + return True if self.country_id == self.env.ref('base.ar') else super()._localization_use_documents() + + @api.constrains('l10n_ar_afip_responsibility_type_id') + def _check_accounting_info(self): + """ Do not let to change the AFIP Responsibility of the company if there is already installed a chart of + account and if there has accounting entries """ + if self.env['account.chart.template'].existing_accounting(self): + raise ValidationError(_( + 'Could not change the AFIP Responsibility of this company because there are already accounting entries.')) diff --git a/addons/l10n_ar/models/res_country.py b/addons/l10n_ar/models/res_country.py new file mode 100644 index 00000000000..15c1aaf6554 --- /dev/null +++ b/addons/l10n_ar/models/res_country.py @@ -0,0 +1,19 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCountry(models.Model): + + _inherit = 'res.country' + + l10n_ar_afip_code = fields.Char('AFIP Code', size=3, help='This code will be used on electronic invoice') + l10n_ar_natural_vat = fields.Char( + 'Natural Person VAT', size=11, help="Generic VAT number defined by AFIP in order to recognize partners from" + " this country that are natural persons") + l10n_ar_legal_entity_vat = fields.Char( + 'Legal Entity VAT', size=11, help="Generic VAT number defined by AFIP in order to recognize partners from this" + " country that are legal entity") + l10n_ar_other_vat = fields.Char( + 'Other VAT', size=11, help="Generic VAT number defined by AFIP in order to recognize partners from this" + " country that are not natural persons or legal entities") diff --git a/addons/l10n_ar/models/res_currency.py b/addons/l10n_ar/models/res_currency.py new file mode 100644 index 00000000000..24ef449c6df --- /dev/null +++ b/addons/l10n_ar/models/res_currency.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResCurrency(models.Model): + + _inherit = "res.currency" + + l10n_ar_afip_code = fields.Char('AFIP Code', size=4, help='This code will be used on electronic invoice') diff --git a/addons/l10n_ar/models/res_partner.py b/addons/l10n_ar/models/res_partner.py new file mode 100644 index 00000000000..2da5914130e --- /dev/null +++ b/addons/l10n_ar/models/res_partner.py @@ -0,0 +1,107 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, ValidationError +import stdnum.ar +import logging + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + + _inherit = 'res.partner' + + l10n_ar_vat = fields.Char( + compute='_compute_l10n_ar_vat', string="VAT", help='Computed field that returns VAT or nothing if this one' + ' is not set for the partner') + l10n_ar_formatted_vat = fields.Char( + compute='_compute_l10n_ar_formatted_vat', string="Formatted VAT", help='Computed field that will convert the' + ' given VAT number to the format {person_category:2}-{number:10}-{validation_number:1}') + + l10n_ar_gross_income_number = fields.Char('Gross Income Number') + l10n_ar_gross_income_type = fields.Selection( + [('multilateral', 'Multilateral'), ('local', 'Local'), ('exempt', 'Exempt')], + 'Gross Income Type', help='Type of gross income: exempt, local, multilateral') + l10n_ar_afip_responsibility_type_id = fields.Many2one( + 'l10n_ar.afip.responsibility.type', string='AFIP Responsibility Type', index=True, 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_special_purchase_document_type_ids = fields.Many2many( + 'l10n_latam.document.type', 'res_partner_document_type_rel', 'partner_id', 'document_type_id', + string='Other Purchase Documents', help='Set here if this partner can issue other documents further than' + ' invoices, credit notes and debit notes') + + @api.depends('l10n_ar_vat') + def _compute_l10n_ar_formatted_vat(self): + """ This will add some dash to the CUIT number in order to show in his natural format: + {person_category}-{number}-{validation_number} """ + recs_ar_vat = self.filtered('l10n_ar_vat') + for rec in recs_ar_vat: + rec.l10n_ar_formatted_vat = stdnum.ar.cuit.format(rec.l10n_ar_vat) + remaining = self - recs_ar_vat + remaining.l10n_ar_formatted_vat = False + + @api.depends('vat', 'l10n_latam_identification_type_id') + def _compute_l10n_ar_vat(self): + """ We add this computed field that returns cuit or nothing if this one is not set for the partner. This + Validation can be also done by calling ensure_vat() method that returns the cuit or error if this one is not + found """ + recs_ar_vat = self.filtered(lambda x: x.l10n_latam_identification_type_id.l10n_ar_afip_code == '80') + recs_ar_vat.l10n_ar_vat = True + remaining = self - recs_ar_vat + remaining.l10n_ar_vat = False + + @api.constrains('vat', 'l10n_latam_identification_type_id') + def check_vat(self): + """ Since we validate more documents than the vat for Argentinian partners (CUIT, CUIL, DNI) we + extend this method in order to process it. """ + # NOTE by the moment we include the CUIT (VAT) validation also here because we extend the messages + # errors to be more friendly to the user. In a future when Odoo improve the base_vat message errors + # we can change this method and use the base_vat.check_vat_ar method.s + l10n_ar_partners = self.filtered(lambda x: x.l10n_latam_identification_type_id.l10n_ar_afip_code) + l10n_ar_partners.l10n_ar_identification_validation() + return super(ResPartner, self - l10n_ar_partners).check_vat() + + @api.model + def _commercial_fields(self): + return super()._commercial_fields() + ['l10n_ar_afip_responsibility_type_id'] + + def ensure_vat(self): + """ This method is a helper that returns the VAT number is this one is defined if not raise an UserError. + + VAT is not mandatory field but for some Argentinian operations the VAT is required, for eg validate an + electronic invoice, build a report, etc. + + This method can be used to validate is the VAT is proper defined in the partner """ + self.ensure_one() + if not self.l10n_ar_vat: + raise UserError(_('No VAT configured for partner [%i] %s') % (self.id, self.name)) + return self.l10n_ar_vat + + def _get_validation_module(self): + self.ensure_one() + if self.l10n_latam_identification_type_id.l10n_ar_afip_code in ['80', '86']: + return stdnum.ar.cuit + elif self.l10n_latam_identification_type_id.l10n_ar_afip_code == '96': + return stdnum.ar.dni + + def l10n_ar_identification_validation(self): + for rec in self.filtered('vat'): + try: + module = rec._get_validation_module() + except Exception as error: + module = False + _logger.log(25, "Argentinian document was not validated: %s", repr(error)) + + if not module: + continue + try: + module.validate(rec.vat) + except module.InvalidChecksum: + raise ValidationError(_('The validation digit is not valid for "%s"') % rec.l10n_latam_identification_type_id.name) + except module.InvalidLength: + raise ValidationError(_('Invalid length for "%s"') % rec.l10n_latam_identification_type_id.name) + except module.InvalidFormat: + raise ValidationError(_('Only numbers allowed for "%s"') % rec.l10n_latam_identification_type_id.name) + except Exception as error: + raise ValidationError(repr(error)) diff --git a/addons/l10n_ar/models/res_partner_bank.py b/addons/l10n_ar/models/res_partner_bank.py new file mode 100644 index 00000000000..2ec902c7f9b --- /dev/null +++ b/addons/l10n_ar/models/res_partner_bank.py @@ -0,0 +1,27 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, api, _ +import stdnum.ar.cbu + + +def validate_cbu(cbu): + return stdnum.ar.cbu.validate(cbu) + + +class ResPartnerBank(models.Model): + + _inherit = 'res.partner.bank' + + @api.model + def _get_supported_account_types(self): + """ Add new account type named cbu used in Argentina """ + res = super()._get_supported_account_types() + res.append(('cbu', _('CBU'))) + return res + + @api.model + def retrieve_acc_type(self, acc_number): + try: + validate_cbu(acc_number) + except Exception: + return super().retrieve_acc_type(acc_number) + return 'cbu' diff --git a/addons/l10n_ar/models/uom_uom.py b/addons/l10n_ar/models/uom_uom.py new file mode 100644 index 00000000000..6b865563736 --- /dev/null +++ b/addons/l10n_ar/models/uom_uom.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class Uom(models.Model): + + _inherit = 'uom.uom' + + l10n_ar_afip_code = fields.Char('AFIP Code', help='This code will be used on electronic invoice') |
