19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:34 +01:00
parent c5006a6999
commit 80293571e7
420 changed files with 21812 additions and 44297 deletions

View file

@ -1,4 +1,4 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_chart_template
from . import account_move
from . import ir_attachment
from . import template_sa

View file

@ -1,44 +0,0 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountChartTemplate(models.Model):
_inherit = 'account.chart.template'
def _prepare_all_journals(self, acc_template_ref, company, journals_dict=None):
""" If Saudi Arabia chart, we add 3 new journals Tax Adjustments, IFRS 16 and Zakat"""
if self == self.env.ref('l10n_sa.sa_chart_template_standard'):
if not journals_dict:
journals_dict = []
journals_dict.extend(
[{'name': 'Tax Adjustments', 'company_id': company.id, 'code': 'TA', 'type': 'general',
'favorite': True, 'sequence': 1},
{'name': 'IFRS 16 Right of Use Asset', 'company_id': company.id, 'code': 'IFRS', 'type': 'general',
'favorite': True, 'sequence': 10},
{'name': 'Zakat', 'company_id': company.id, 'code': 'ZAKAT', 'type': 'general', 'favorite': True,
'sequence': 10}])
return super()._prepare_all_journals(acc_template_ref, company, journals_dict=journals_dict)
def _load_template(self, company, code_digits=None, account_ref=None, taxes_ref=None):
account_ref, taxes_ref = super(AccountChartTemplate, self)._load_template(company=company,
code_digits=code_digits,
account_ref=account_ref,
taxes_ref=taxes_ref)
if self == self.env.ref('l10n_sa.sa_chart_template_standard'):
ifrs_journal_id = self.env['account.journal'].search([('company_id', '=', company.id), ('code', '=', 'IFRS')], limit=1)
if ifrs_journal_id:
ifrs_account_ids = [self.env.ref('l10n_sa.sa_account_100101').id,
self.env.ref('l10n_sa.sa_account_100102').id,
self.env.ref('l10n_sa.sa_account_400070').id]
ifrs_accounts = self.env['account.account'].browse([account_ref.get(id) for id in ifrs_account_ids])
for account in ifrs_accounts:
account.allowed_journal_ids = [(4, ifrs_journal_id.id, 0)]
zakat_journal_id = self.env['account.journal'].search([('company_id', '=', company.id), ('code', '=', 'ZAKAT')], limit=1)
if zakat_journal_id:
zakat_account_ids = [self.env.ref('l10n_sa.sa_account_201019').id,
self.env.ref('l10n_sa.sa_account_400072').id]
zakat_accounts = self.env['account.account'].browse([account_ref.get(id) for id in zakat_account_ids])
for account in zakat_accounts:
account.allowed_journal_ids = [(4, zakat_journal_id.id, 0)]
return account_ref, taxes_ref

View file

@ -1,35 +1,58 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from datetime import datetime
from pytz import timezone, utc
from odoo import _, api, fields, models
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_repr, format_datetime
ADJUSTMENT_REASONS = [
("BR-KSA-17-reason-1", "Cancellation or suspension of the supplies after its occurrence either wholly or partially"),
("BR-KSA-17-reason-2", "In case of essential change or amendment in the supply, which leads to the change of the VAT due"),
("BR-KSA-17-reason-3", "Amendment of the supply value which is pre-agreed upon between the supplier and consumer"),
("BR-KSA-17-reason-4", "In case of goods or services refund"),
("BR-KSA-17-reason-5", "In case of change in Seller's or Buyer's information"),
]
class AccountMove(models.Model):
_inherit = 'account.move'
l10n_sa_delivery_date = fields.Date(string='Supply Date',
default=fields.Date.context_today,
copy=False,
readonly=True,
states={'draft': [('readonly', False)]},
help="""Date when the supply of the goods and services is performed.
You can set this date to when you want ZATCA to recognize the VAT Due Liability as ZATCA will consider the earlier of this date and the Issue Date.
In case of multiple deliveries, you should take the date of the latest one.""")
l10n_sa_show_delivery_date = fields.Boolean(compute='_compute_show_delivery_date')
l10n_sa_qr_code_str = fields.Char(string='Zatka QR Code', compute='_compute_qr_code_str')
l10n_sa_confirmation_datetime = fields.Datetime(string='Confirmation Date',
l10n_sa_qr_code_str = fields.Char(string='Zatka QR Code', compute='_compute_qr_code_str', compute_sudo=True)
l10n_sa_show_reason = fields.Boolean(compute="_compute_show_l10n_sa_reason")
l10n_sa_reason = fields.Selection(string="ZATCA Reason", selection=ADJUSTMENT_REASONS, copy=False)
l10n_sa_confirmation_datetime = fields.Datetime(string='ZATCA Issue Date',
readonly=True,
copy=False,
help="""Date when the invoice is confirmed and posted.
In other words, it is the date on which the invoice is generated as final document (after securing all internal approvals).""")
help="""Date on which the invoice is generated as final document (after securing all internal approvals).""")
def _get_name_invoice_report(self):
# EXTENDS account
self.ensure_one()
if self.company_id.country_code == 'SA':
return 'l10n_sa.l10n_sa_report_invoice_document'
return super()._get_name_invoice_report()
def _l10n_gcc_get_invoice_title(self):
# EXTENDS l10n_gcc_invoice
self.ensure_one()
if self.company_id.country_code != "SA":
return super()._l10n_gcc_get_invoice_title()
if self._l10n_sa_is_simplified():
return self.env._("Simplified Tax Invoice")
return self.env._("Tax Invoice")
@api.depends('country_code', 'move_type')
def _compute_show_delivery_date(self):
# EXTENDS 'account'
super()._compute_show_delivery_date()
for move in self:
move.l10n_sa_show_delivery_date = move.country_code == 'SA' and move.move_type in ('out_invoice', 'out_refund')
if move.country_code == 'SA':
move.show_delivery_date = move.is_sale_document()
@api.depends('amount_total_signed', 'amount_tax_signed', 'l10n_sa_confirmation_datetime', 'company_id', 'company_id.vat')
def _compute_qr_code_str(self):
@ -48,7 +71,7 @@ class AccountMove(models.Model):
seller_name_enc = get_qr_encoding(1, record.company_id.display_name)
company_vat_enc = get_qr_encoding(2, record.company_id.vat)
time_sa = fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), record.l10n_sa_confirmation_datetime)
timestamp_enc = get_qr_encoding(3, time_sa.isoformat())
timestamp_enc = get_qr_encoding(3, time_sa.strftime(self._get_iso_format_asia_riyadh_date('T')))
totals = record._get_l10n_sa_totals()
invoice_total_enc = get_qr_encoding(4, float_repr(abs(totals['total_amount']), 2))
total_vat_enc = get_qr_encoding(5, float_repr(abs(totals['total_tax']), 2))
@ -59,12 +82,14 @@ class AccountMove(models.Model):
def _post(self, soft=True):
res = super()._post(soft)
for record in self:
if record.country_code == 'SA' and record.move_type in ('out_invoice', 'out_refund'):
if not record.l10n_sa_show_delivery_date:
raise UserError(_('Delivery Date cannot be empty'))
if not record.l10n_sa_confirmation_datetime:
record.l10n_sa_confirmation_datetime = fields.Datetime.now()
for move in self:
if move.country_code == 'SA' and move.is_sale_document():
vals = {}
if not move.l10n_sa_confirmation_datetime:
vals['l10n_sa_confirmation_datetime'] = self._get_normalized_l10n_sa_confirmation_datetime(move.invoice_date)
if not move.delivery_date:
vals['delivery_date'] = move.invoice_date
move.write(vals)
return res
def get_l10n_sa_confirmation_datetime_sa_tz(self):
@ -72,11 +97,20 @@ class AccountMove(models.Model):
return format_datetime(self.env, self.l10n_sa_confirmation_datetime, tz='Asia/Riyadh', dt_format='Y-MM-dd\nHH:mm:ss')
def _l10n_sa_reset_confirmation_datetime(self):
self.filtered(lambda m: m.country_code == 'SA').l10n_sa_confirmation_datetime = False
for move in self.filtered(lambda m: m.country_code == 'SA'):
move.l10n_sa_confirmation_datetime = False
def button_draft(self):
self._l10n_sa_reset_confirmation_datetime()
super().button_draft()
def _l10n_sa_get_adjustment_reason(self):
self.ensure_one()
readable_zatca_reason = dict(self._fields['l10n_sa_reason'].selection).get(self.l10n_sa_reason)
return readable_zatca_reason if self.l10n_sa_show_reason else self.ref
def _compute_show_l10n_sa_reason(self):
for record in self:
record.l10n_sa_show_reason = record.country_code == 'SA' and (record.move_type == 'out_refund' or (record.move_type == 'out_invoice' and record.debit_origin_id))
def _get_iso_format_asia_riyadh_date(self, separator=' '):
return f'%Y-%m-%d{separator}%H:%M:%S'
def _get_l10n_sa_totals(self):
self.ensure_one()
@ -84,3 +118,42 @@ class AccountMove(models.Model):
'total_amount': self.amount_total_signed,
'total_tax': self.amount_tax_signed,
}
def _l10n_sa_is_legal(self):
# Check if the document is legal in Saudi
self.ensure_one()
return self.company_id.country_id.code == 'SA' and self.state == 'posted' and self.l10n_sa_qr_code_str
def _get_normalized_l10n_sa_confirmation_datetime(self, invoice_date, invoice_time=None):
"""
Ensures the confirmation datetime does not exceed the current time in Asia/Riyadh to prevent ZATCA rejections.
"""
sa_tz = timezone('Asia/Riyadh')
now_sa = datetime.now(sa_tz)
selected_date = fields.Date.from_string(invoice_date) if isinstance(invoice_date, str) else invoice_date
if selected_date > now_sa.date():
raise UserError(_("Please set the Invoice Date to be either less than or equal to today as per the Asia/Riyadh time zone, since ZATCA does not allow future-dated invoicing."))
return min(now_sa, sa_tz.localize(datetime.combine(selected_date, invoice_time or now_sa.time()))).astimezone(utc).replace(tzinfo=None)
def write(self, vals):
result = super().write(vals)
invoice_date = vals.get('invoice_date')
if not invoice_date:
return result
for move in self.filtered('l10n_sa_confirmation_datetime'):
sa_time = move.l10n_sa_confirmation_datetime.replace(tzinfo=utc).astimezone(timezone('Asia/Riyadh')).time()
move.l10n_sa_confirmation_datetime = self._get_normalized_l10n_sa_confirmation_datetime(invoice_date, sa_time)
return result
def _l10n_sa_is_simplified(self):
"""
Returns True if the customer is an individual, i.e: The invoice is B2C
:return:
"""
self.ensure_one()
return (
self.partner_id.commercial_partner_id.company_type == "person"
if self.partner_id.commercial_partner_id
else self.partner_id.company_type == "person"
)

View file

@ -0,0 +1,21 @@
from odoo import _, api, models
from odoo.exceptions import UserError
class IrAttachment(models.Model):
_inherit = "ir.attachment"
@api.ondelete(at_uninstall=False)
def _unlink_except_posted_pdf_invoices(self):
'''
Prevents unlinking of invoice pdfs linked to an invoice that is posted.
'''
restricted_moves = self._get_posted_pdf_moves_to_check().filtered(lambda move: move.country_code == 'SA' and move.state == 'posted')
if restricted_moves:
raise UserError(_("The Invoice PDF(s) cannot be deleted according to ZATCA rules: %s", ', '.join(restricted_moves.mapped('invoice_pdf_report_id.name'))))
def _get_posted_pdf_moves_to_check(self):
'''
Returns the moves to check whether they can be unlinked.
'''
return self.env['account.move'].browse(self.filtered(lambda rec: rec.res_model == 'account.move' and rec.res_field == 'invoice_pdf_report_file').mapped('res_id'))

View file

@ -0,0 +1,73 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
@template('sa')
def _get_sa_template_data(self):
return {
'property_account_receivable_id': 'sa_account_102011',
'property_account_payable_id': 'sa_account_201002',
'code_digits': '6',
}
@template('sa', 'res.company')
def _get_sa_res_company(self):
return {
self.env.company.id: {
'account_fiscal_country_id': 'base.sa',
'bank_account_code_prefix': '101',
'cash_account_code_prefix': '105',
'transfer_account_code_prefix': '100',
'account_default_pos_receivable_account_id': 'sa_account_102012',
'income_currency_exchange_account_id': 'sa_account_500011',
'expense_currency_exchange_account_id': 'sa_account_400053',
'account_sale_tax_id': 'sa_sales_tax_15',
'account_purchase_tax_id': 'sa_purchase_tax_15',
'expense_account_id': 'sa_account_400001',
'income_account_id': 'sa_account_500001',
'deferred_expense_account_id': 'sa_account_104020',
'deferred_revenue_account_id': 'sa_account_201018',
'account_cash_basis_base_account_id': 'sa_account_201030',
'account_stock_journal_id': 'inventory_valuation',
'account_stock_valuation_id': 'sa_account_131100',
},
}
@template('sa', 'account.journal')
def _get_sa_account_journal(self):
""" If Saudi Arabia chart, we add 3 new journals Tax Adjustments, IFRS 16 and Zakat"""
return {
"tax_adjustment": {
'name': 'Tax Adjustments',
'code': 'TA',
'type': 'general',
'show_on_dashboard': True,
'sequence': 1,
},
"ifrs16": {
'name': 'IFRS 16 Right of Use Asset',
'code': 'IFRS',
'type': 'general',
'show_on_dashboard': True,
'sequence': 10,
},
"zakat": {
'name': 'Zakat',
'code': 'ZAKAT',
'type': 'general',
'show_on_dashboard': True,
'sequence': 10,
}
}
@template('sa', 'account.account')
def _get_sa_account_account(self):
return {
'sa_account_131100': {
'account_stock_variation_id': 'sa_account_400001',
},
}