# coding: utf-8 import json from base64 import b64decode from odoo import Command from odoo.tests import tagged from odoo.addons.account_edi.tests.common import AccountEdiTestCommon @tagged('post_install_l10n', '-at_install', 'post_install') class TestSaEdiCommon(AccountEdiTestCommon): """ Base test class for Saudi Arabia EDI functionality. Sets up test data for ZATCA (Saudi tax authority) compliance testing including: - Company with Saudi-specific fields - Partners (company and individual) - Products and taxes - XPath templates for XML comparison """ @classmethod @AccountEdiTestCommon.setup_edi_format('l10n_sa_edi.edi_sa_zatca') @AccountEdiTestCommon.setup_chart_template('sa') @AccountEdiTestCommon.setup_country('sa') def setUpClass(cls): super().setUpClass() # Setup frequently used references cls.company = cls.company_data['company'] cls.saudi_arabia = cls.env.ref('base.sa') cls.riyadh = cls._get_or_create_state('Riyadh', 'RUH', cls.saudi_arabia) # Setup test data cls._setup_company() cls._setup_branches() cls._setup_partners() cls._setup_products() cls._setup_taxes() cls._setup_journal() cls._setup_xpath_templates() @classmethod def _get_company_vals(cls, defaults=None): return { 'name': 'SA Company Test', 'email': 'info@company.saexample.com', 'phone': '+966 51 234 5678', 'vat': '311111111111113', # Address fields 'street': 'Al Amir Mohammed Bin Abdul Aziz Street', 'street2': 'Testomania', 'city': 'المدينة المنورة', 'zip': '42317', 'country_id': cls.saudi_arabia.id, 'state_id': cls.riyadh.id, # Saudi-specific fields 'l10n_sa_edi_building_number': '1234', 'l10n_sa_edi_plot_identification': '1234', 'l10n_sa_additional_identification_number': '2525252525252', 'l10n_sa_additional_identification_scheme': 'CRN', # Commercial Registration Number **(defaults or {}) } @classmethod def _setup_company(cls): """Configure the test company with Saudi Arabia specific settings.""" cls.company.write(cls._get_company_vals()) @classmethod def _setup_branches(cls): vals = cls._get_company_vals({"name": "SA Branch", "parent_id": cls.company.id}) cls.sa_branch = cls._create_company(**vals) @classmethod def _setup_partners(cls): """Create test partners for different invoice types.""" # Standard invoice partner (company) cls.partner_sa = cls._create_saudi_company_partner() # Simplified invoice partner (individual) cls.partner_sa_simplified = cls._create_saudi_individual_partner() @classmethod def _create_saudi_company_partner(cls): """Create a Saudi company partner with full ZATCA requirements.""" return cls.env['res.partner'].create({ 'name': 'Saud Ahmed', 'ref': 'Saudi Aramco', 'company_type': 'company', 'lang': 'en_US', # Contact info 'email': 'saudi.aramco@example.com', 'phone': '+966556666666', # Tax info 'vat': '311111111111113', 'l10n_sa_additional_identification_scheme': 'CRN', 'l10n_sa_additional_identification_number': '353535353535353', # Address 'street': '4557 King Salman St', 'street2': 'Neighbor!', 'city': 'Riyadh', 'zip': '94538', 'state_id': cls.riyadh.id, 'country_id': cls.saudi_arabia.id, # Saudi-specific address fields 'l10n_sa_edi_building_number': '12300', 'l10n_sa_edi_plot_identification': '2323', }) @classmethod def _create_saudi_individual_partner(cls): """Create a Saudi individual partner for simplified invoices.""" return cls.env['res.partner'].create({ 'name': 'Mohammed Ali', 'ref': 'Mohammed Ali', 'company_type': 'person', 'lang': 'en_US', 'country_id': cls.saudi_arabia.id, 'state_id': cls.riyadh.id, # Simplified invoices use different ID schemes 'l10n_sa_additional_identification_scheme': 'MOM', # Momra License 'l10n_sa_additional_identification_number': '3123123213131', }) @classmethod def _setup_products(cls): """Create test products.""" cls.product_a = cls._create_product(name='Product A', standard_price=320.0, default_code='P0001') cls.product_b = cls._create_product(name='Product B', standard_price=15.8, default_code='P0002') cls.product_burger = cls._create_product(name='Burger', standard_price=265.0) @classmethod def _setup_taxes(cls): """Setup tax references.""" # Standard 15% VAT in Saudi Arabia cls.tax_15 = cls.env['account.tax'].search([ ('company_id', '=', cls.company.id), ('amount', '=', 15.0) ], limit=1) @classmethod def _setup_journal(cls): """Setup and configure the sales journal.""" cls.customer_invoice_journal = cls.env['account.journal'].search([ ('company_id', '=', cls.company.id), ('type', '=', 'sale') ], limit=1) # Load ZATCA demo data (certificates, etc.) cls.customer_invoice_journal._l10n_sa_load_edi_demo_data() PCSID_Data = json.loads(cls.customer_invoice_journal.l10n_sa_production_csid_json) pcsid_certificate = cls.env['certificate.certificate'].create({ 'name': 'PCSID Certificate', 'content': b64decode(PCSID_Data['binarySecurityToken']), }) cls.customer_invoice_journal.l10n_sa_production_csid_certificate_id = pcsid_certificate @classmethod def _setup_xpath_templates(cls): """ Setup XPath templates for XML testing. These remove or replace dynamic elements (IDs, UUIDs) that change between test runs to allow XML comparison. """ cls.remove_ubl_extensions_xpath = '''''' # Common replacements for all document types common_replacements = ''' ___ignore___ ___ignore___ ___ignore___ ___ignore___ ''' # Invoice-specific replacements cls.invoice_applied_xpath = common_replacements + ''' ___ignore___ ___ignore___ ___ignore___ ''' # Credit note specific replacements cls.credit_note_applied_xpath = common_replacements + ''' ___ignore___ ___ignore___ ___ignore___ ___ignore___ ___ignore___ ''' # Debit note specific replacements cls.debit_note_applied_xpath = common_replacements + ''' ___ignore___ ___ignore___ ___ignore___ ___ignore___ ___ignore___ ___ignore___ ''' @classmethod def _get_or_create_state(cls, name, code, country): """Ensure a state exists for the given country.""" state = cls.env['res.country.state'].search([ ('code', '=', code), ('country_id', '=', country.id) ], limit=1) if not state: state = cls.env['res.country.state'].create({ 'name': name, 'code': code, 'country_id': country.id }) return state # Helper methods for creating documents def _create_invoice( self, name="", move_type="out_invoice", company_id=None, partner_id=None, invoice_date='2025-01-01', invoice_date_due='2025-01-01', currency_id=None, invoice_line_ids=[]): """ Create a draft invoice with the given parameters. """ def _create_invoice_line(line): vals = { 'price_unit': line.get('price_unit', 0.0), 'quantity': line.get('quantity', 1), 'tax_ids': line.get('tax_ids', []), } if product_id := line.get('product_id'): vals['product_id'] = product_id if name := line.get('name'): vals['name'] = name return Command.create(vals) vals = { 'name': name, 'move_type': move_type, 'company_id': (company_id or self.company).id, 'partner_id': partner_id.id, 'invoice_date': invoice_date, 'invoice_date_due': invoice_date_due, 'currency_id': (currency_id or self.company.currency_id).id, 'invoice_line_ids': [ _create_invoice_line(line) for line in invoice_line_ids ], } return self.env['account.move'].create(vals) def _create_debit_note( self, name="", move_type="out_invoice", company_id=None, partner_id=None, invoice_date='2025-01-01', invoice_date_due='2025-01-01', currency_id=None, invoice_line_ids=[], reason="Totes Forgot"): """ Create a draft debit note from the given invoice values. """ # Create and post the original invoice invoice = self._create_invoice( name=name, move_type=move_type, company_id=company_id, partner_id=partner_id, invoice_date=invoice_date, invoice_date_due=invoice_date_due, currency_id=currency_id, invoice_line_ids=invoice_line_ids) invoice.action_post() # Create debit note via wizard debit_note_wizard = self.env['account.debit.note'].with_context({ 'active_ids': [invoice.id], 'active_model': 'account.move', 'default_copy_lines': True }).create({ 'reason': reason, }) res = debit_note_wizard.create_debit() return self.env['account.move'].browse(res.get('res_id', [])) def _create_credit_note( self, name="", move_type="out_invoice", company_id=None, partner_id=None, invoice_date='2025-01-01', invoice_date_due='2025-01-01', currency_id=None, invoice_line_ids=[], reason='Totes Forgot'): """ Create a draft credit note from the given invoice values. """ # Create and post the original invoice invoice = self._create_invoice( name=name, move_type=move_type, company_id=company_id, partner_id=partner_id, invoice_date=invoice_date, invoice_date_due=invoice_date_due, currency_id=currency_id, invoice_line_ids=invoice_line_ids) invoice.action_post() # Create credit note via reversal wizard move_reversal = self.env['account.move.reversal'].with_context({ 'active_model': 'account.move', 'active_ids': invoice.ids }).create({ 'reason': reason, 'journal_id': invoice.journal_id.id, }) reversal = move_reversal.reverse_moves() return self.env['account.move'].browse(reversal['res_id'])