Merge pull request #84 from expsa/khazraji_acount

make new modul
This commit is contained in:
mohammed-alkhazrji 2026-01-15 05:13:49 +03:00 committed by GitHub
commit 938230b402
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
148 changed files with 11161 additions and 2 deletions

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import models

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
{
'name': 'ODEX Account: 3-Way Matching',
'category': 'Account/Accounting',
'author': 'ODEX',
'summary': 'Manage 3-way matching on bills',
'description': """
Manage 3-way matching on supplier bills
=======================================
In this system, you can manage the verification process for supplier bills against
received goods. This ensures that payments are only made when the items
have actually been delivered.
This feature allows creating the supplier bill based on ordered quantities
while keeping the payment pending until the received quantities on the purchase lines
match the recorded supplier bill.
The system introduces a "release to pay" status that marks for each bill
whether it is ready for payment.
Each bill receives one of the following three states:
- Yes (The bill can be paid)
- No (The bill cannot be paid, delivery is pending)
- Exception (Differences found between received and billed quantities)
""",
'depends': ['purchase'],
'data': [
'views/account_invoice_view.xml',
'views/account_journal_dashboard_view.xml'
],
'license': 'LGPL-3',
}

View File

@ -0,0 +1,119 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_3way_match
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-27 13:54+0000\n"
"PO-Revision-Date: 2025-01-27 13:54+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay_manual
msgid ""
" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
msgstr ""
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills in Exception"
msgstr ""
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills to Pay"
msgstr ""
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills to Validate"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__exception
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__exception
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__exception
msgid "Exception"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__force_release_to_pay
msgid "Force Status"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__force_release_to_pay
msgid ""
"Indicates whether the 'Should Be Paid' status is defined automatically or "
"manually."
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_journal
msgid "Journal"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_move_line
msgid "Journal Item"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__no
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__no
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__no
msgid "No"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay
msgid "Release To Pay"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move_line__can_be_paid
msgid "Release to Pay"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay_manual
msgid "Should Be Paid"
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay
msgid ""
"This field can take the following values :\n"
" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
msgstr ""
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__yes
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__yes
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__yes
msgid "Yes"
msgstr ""

View File

@ -0,0 +1,132 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_3way_match
#
# Translators:
# Wil ODEX, 2025
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-27 13:54+0000\n"
"PO-Revision-Date: 2024-09-25 09:43+0000\n"
"Last-Translator: Wil ODEX, 2025\n"
"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay_manual
msgid ""
" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
msgstr ""
"* نعم: عليك سداد قيمة الفاتورة، لقد استلمت المنتجات \n"
"* لا: ليس عليك سداد قيمة الفاتورة، لم تستلم المنتجات\n"
" * استثناء: هناك فرق بين الكمية المستلمة والكمية المدفوع قيمتها\n"
"هذه الحالة تُحدد تلقائياً، لكن يمكنك فرض الحالة من خلال تحديد اختيار 'فرض الحالة'."
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills in Exception"
msgstr "الفواتير المستثناة "
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills to Pay"
msgstr "الفواتير بانتظار السداد "
#. module: odex30_account_3way_match
#: model_terms:ir.ui.view,arch_db:odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match
msgid "Bills to Validate"
msgstr "الفواتير بانتظار التصديق "
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__exception
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__exception
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__exception
msgid "Exception"
msgstr "استثناء "
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__force_release_to_pay
msgid "Force Status"
msgstr "فرض الحالة"
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__force_release_to_pay
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__force_release_to_pay
msgid ""
"Indicates whether the 'Should Be Paid' status is defined automatically or "
"manually."
msgstr "يحدد إذا ما كانت الحالة 'واجبة السداد' تُعين تلقائياً أم يدوياً. "
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_journal
msgid "Journal"
msgstr "دفتر اليومية"
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_move
msgid "Journal Entry"
msgstr "قيد اليومية"
#. module: odex30_account_3way_match
#: model:ir.model,name:odex30_account_3way_match.model_account_move_line
msgid "Journal Item"
msgstr "عنصر اليومية"
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__no
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__no
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__no
msgid "No"
msgstr "لا"
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay
msgid "Release To Pay"
msgstr "جاهزة للسداد"
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move_line__can_be_paid
msgid "Release to Pay"
msgstr "جاهزة للسداد"
#. module: odex30_account_3way_match
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay_manual
#: model:ir.model.fields,field_description:odex30_account_3way_match.field_account_move__release_to_pay_manual
msgid "Should Be Paid"
msgstr "واجبة السداد"
#. module: odex30_account_3way_match
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_bank_statement_line__release_to_pay
#: model:ir.model.fields,help:odex30_account_3way_match.field_account_move__release_to_pay
msgid ""
"This field can take the following values :\n"
" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox."
msgstr ""
"يتحمل هذا الحقل القيم التالية:\n"
" * نعم: عليك سداد قيمة الفاتورة، لقد استلمت المنتجات\n"
" * لا: ليس عليك سداد قيمة الفاتورة، لم تستلم المنتجات\n"
" * استثناء: هناك فرق بين الكمية المستلمة والكمية المدفوع قيمتها\n"
"هذه الحالة تُحدد تلقائياً، لكن يمكنك فرض الحالة من خلال تحديد اختيار 'فرض الحالة'."
#. module: odex30_account_3way_match
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay__yes
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move__release_to_pay_manual__yes
#: model:ir.model.fields.selection,name:odex30_account_3way_match.selection__account_move_line__can_be_paid__yes
msgid "Yes"
msgstr "نعم"

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import account_invoice
from . import account_journal_dashboard

View File

@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools.float_utils import float_compare
from odoo.tools.sql import column_exists, create_column
# Available values for the release_to_pay field.
_release_to_pay_status_list = [('yes', 'Yes'), ('no', 'No'), ('exception', 'Exception')]
class AccountMove(models.Model):
_inherit = 'account.move'
def _auto_init(self):
if not column_exists(self.env.cr, "account_move", "release_to_pay"):
# Create column manually to set default value to 'exception' on postgres level.
# This way we avoid heavy computation on module installation.
self.env.cr.execute("ALTER TABLE account_move ADD COLUMN release_to_pay VARCHAR DEFAULT 'exception'")
return super()._auto_init()
release_to_pay = fields.Selection(
_release_to_pay_status_list,
compute='_compute_release_to_pay',
copy=False,
store=True,
help="This field can take the following values :\n"
" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox.")
release_to_pay_manual = fields.Selection(
_release_to_pay_status_list,
string='Should Be Paid',
compute='_compute_release_to_pay_manual', store='True', readonly=False,
help=" * Yes: you should pay the bill, you have received the products\n"
" * No, you should not pay the bill, you have not received the products\n"
" * Exception, there is a difference between received and billed quantities\n"
"This status is defined automatically, but you can force it by ticking the 'Force Status' checkbox.")
force_release_to_pay = fields.Boolean(
string="Force Status",
help="Indicates whether the 'Should Be Paid' status is defined automatically or manually.")
@api.depends('invoice_line_ids.can_be_paid', 'force_release_to_pay', 'payment_state')
def _compute_release_to_pay(self):
records = self
if self.env.context.get('module') == 'odex30_account_3way_match':
# on module installation we set 'no' for all paid bills and other non relevant records at once
records = records.filtered(lambda r: r.payment_state != 'paid' and r.move_type in ('in_invoice', 'in_refund'))
(self - records).release_to_pay = 'no'
for invoice in records:
if invoice.payment_state == 'paid' or not invoice.is_invoice(include_receipts=True):
# no need to pay, if it's already paid
invoice.release_to_pay = 'no'
elif invoice.force_release_to_pay:
#we must use the manual value contained in release_to_pay_manual
invoice.release_to_pay = invoice.release_to_pay_manual
else:
#otherwise we must compute the field
result = None
for invoice_line in invoice.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_section', 'line_note')):
line_status = invoice_line.can_be_paid
if line_status == 'exception':
#If one line is in exception, the entire bill is
result = 'exception'
break
elif not result:
result = line_status
elif line_status != result:
result = 'exception'
break
#The last two elif conditions model the fact that a
#bill will be in exception if its lines have different status.
#Otherwise, its status will be the one all its lines share.
#'result' can be None if the bill was entirely empty.
invoice.release_to_pay = result or 'no'
@api.depends('release_to_pay', 'force_release_to_pay')
def _compute_release_to_pay_manual(self):
for invoice in self:
if not (invoice.payment_state == 'paid' or not invoice.is_invoice(include_receipts=True) or invoice.force_release_to_pay):
invoice.release_to_pay_manual = invoice.release_to_pay
@api.onchange('release_to_pay_manual')
def _onchange_release_to_pay_manual(self):
if self.release_to_pay and self.release_to_pay_manual != self.release_to_pay:
self.force_release_to_pay = True
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _auto_init(self):
if not column_exists(self.env.cr, "account_move_line", "can_be_paid"):
# Create column manually to set default value to 'exception' on postgres level.
# This way we avoid heavy computation on module installation.
self.env.cr.execute("ALTER TABLE account_move_line ADD COLUMN can_be_paid VARCHAR DEFAULT 'exception'")
return super()._auto_init()
@api.depends('purchase_line_id.qty_received', 'purchase_line_id.qty_invoiced', 'purchase_line_id.product_qty', 'price_unit')
def _can_be_paid(self):
""" Computes the 'release to pay' status of an invoice line, depending on
the invoicing policy of the product linked to it, by calling the dedicated
subfunctions. This function also ensures the line is linked to a purchase
order (otherwise, can_be_paid will be set as 'exception'), and the price
between this order and the invoice did not change (otherwise, again,
the line is put in exception).
"""
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
for invoice_line in self:
po_line = invoice_line.purchase_line_id
if po_line:
invoiced_qty = po_line.qty_invoiced
received_qty = po_line.qty_received
ordered_qty = po_line.product_qty
# A price difference between the original order and the invoice results in an exception
invoice_currency = invoice_line.currency_id
order_currency = po_line.currency_id
invoice_converted_price = invoice_currency._convert(
invoice_line.price_unit, order_currency, invoice_line.company_id, fields.Date.today())
if order_currency.compare_amounts(po_line.price_unit, invoice_converted_price) != 0:
invoice_line.can_be_paid = 'exception'
continue
if po_line.product_id.purchase_method == 'purchase': # 'on ordered quantities'
invoice_line._can_be_paid_ordered_qty(invoiced_qty, received_qty, ordered_qty, precision)
else: # 'on received quantities'
invoice_line._can_be_paid_received_qty(invoiced_qty, received_qty, ordered_qty, precision)
else: # Serves as default if the line is not linked to any Purchase.
invoice_line.can_be_paid = 'exception'
def _can_be_paid_ordered_qty(self, invoiced_qty, received_qty, ordered_qty, precision):
"""
Gives the release_to_pay status of an invoice line for 'on ordered
quantity' billing policy, if this line's invoice is related to a purchase order.
This function sets can_be_paid field to one of the following:
'yes': the content of the line has been ordered and can be invoiced
'no' : the content of the line hasn't been ordered at all, and cannot be invoiced
'exception' : only part of the invoice has been ordered
"""
if float_compare(invoiced_qty - self.quantity, ordered_qty, precision_digits=precision) >= 0:
self.can_be_paid = 'no'
elif float_compare(invoiced_qty, ordered_qty, precision_digits=precision) <= 0:
self.can_be_paid = 'yes'
else:
self.can_be_paid = 'exception'
def _can_be_paid_received_qty(self, invoiced_qty, received_qty, ordered_qty, precision):
"""
Gives the release_to_pay status of an invoice line for 'on received
quantity' billing policy, if this line's invoice is related to a purchase order.
This function sets can_be_paid field to one of the following:
'yes': the content of the line has been received and can be invoiced
'no' : the content of the line hasn't been received at all, and cannot be invoiced
'exception' : ordered and received quantities differ
"""
if float_compare(invoiced_qty, received_qty, precision_digits=precision) <= 0:
self.can_be_paid = 'yes'
elif received_qty == 0 and float_compare(invoiced_qty, ordered_qty, precision_digits=precision) <= 0: # "and" part to ensure a too high billed quantity results in an exception:
self.can_be_paid = 'no'
else:
self.can_be_paid = 'exception'
can_be_paid = fields.Selection(
_release_to_pay_status_list,
compute='_can_be_paid',
copy=False,
store=True,
string='Release to Pay')

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.osv import expression
from odoo.tools import SQL
class AccountJournal(models.Model):
_inherit = 'account.journal'
def open_action(self):
action = super(AccountJournal, self).open_action()
view = self.env.ref('account.action_move_in_invoice_type')
if view and action.get("id") == view.id:
action['context']['search_default_in_invoice'] = 0
account_purchase_filter = self.env.ref('odex30_account_3way_match.account_invoice_filter_inherit_odex30_account_3way_match', False)
action['search_view_id'] = account_purchase_filter and [account_purchase_filter.id, account_purchase_filter.name] or False
return action
def _get_open_sale_purchase_query(self, journal_type):
# OVERRIDE
assert journal_type in ('sale', 'purchase')
query = self.env['account.move']._where_calc([
*self.env['account.move']._check_company_domain(self.env.companies),
('journal_id', 'in', self.ids),
('payment_state', 'in', ('not_paid', 'partial')),
('move_type', 'in', ('out_invoice', 'out_refund') if journal_type == 'sale' else ('in_invoice', 'in_refund')),
('state', '=', 'posted'),
])
selects = [
SQL("journal_id"),
SQL("company_id"),
SQL("currency_id AS currency"),
SQL("invoice_date_due < %s AS late", fields.Date.context_today(self)),
SQL("SUM(amount_residual_signed) AS amount_total_company"),
SQL("SUM((CASE WHEN move_type = 'in_invoice' THEN -1 ELSE 1 END) * amount_residual) AS amount_total"),
SQL("COUNT(*)"),
SQL("release_to_pay IN ('yes', 'exception') AS to_pay")
]
return query, selects
def _get_draft_sales_purchases_query(self):
# OVERRIDE
domain_sale = [
('journal_id', 'in', self.filtered(lambda j: j.type == 'sale').ids),
('move_type', 'in', self.env['account.move'].get_sale_types(include_receipts=True))
]
domain_purchase = [
('journal_id', 'in', self.filtered(lambda j: j.type == 'purchase').ids),
('move_type', 'in', self.env['account.move'].get_purchase_types(include_receipts=False)),
'|',
('invoice_date_due', '<', fields.Date.today()),
('release_to_pay', '=', 'yes')
]
domain = expression.AND([
[('state', '=', 'draft'), ('payment_state', 'in', ('not_paid', 'partial'))],
expression.OR([domain_sale, domain_purchase])
])
return self.env['account.move']._where_calc([
*self.env['account.move']._check_company_domain(self.env.companies),
*domain
])

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import test_release_to_pay_invoice
from . import test_account_journal_dashboard

View File

@ -0,0 +1,240 @@
from freezegun import freeze_time
from odoo.addons.account.tests.test_account_journal_dashboard_common import TestAccountJournalDashboardCommon
from odoo.tests import tagged
from odoo.tools.misc import format_amount
@tagged('post_install', '-at_install')
class AccountJournalDashboard3WayWatchTest(TestAccountJournalDashboardCommon):
@classmethod
def init_invoice(cls, move_type, partner=None, invoice_date=None, post=False, products=None, amounts=None, taxes=None, company=False, currency=None, journal=None, invoice_date_due=None, release_to_pay=None):
move = super().init_invoice(move_type, partner, invoice_date, False, products, amounts, taxes, company, currency, journal)
if invoice_date_due:
move.invoice_date_due = invoice_date_due
if release_to_pay:
move.release_to_pay = release_to_pay
if post:
move.action_post()
return move
def test_sale_purchase_journal_for_purchase(self):
"""
Test different purchase journal setups with or without multicurrency:
1) Journal with no currency, bills in foreign currency -> dashboard data should be displayed in company currency
2) Journal in foreign currency, bills in foreign currency -> dashboard data should be displayed in foreign currency
3) Journal in foreign currency, bills in company currency -> dashboard data should be displayed in foreign currency
4) Journal in company currency, bills in company currency -> dashboard data should be displayed in company currency
5) Journal in company currency, bills in foreign currency -> dashboard data should be displayed in company currency
"""
foreign_currency = self.other_currency
company_currency = self.company_data['currency']
setup_values = [
[self.company_data['default_journal_purchase'], foreign_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': foreign_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), company_currency],
[self.company_data['default_journal_purchase'].copy({'currency_id': company_currency.id, 'default_account_id': self.company_data['default_account_expense'].id}), foreign_currency],
]
expected_vals_list = [
# number_draft, sum_draft, number_waiting, sum_waiting, number_late, sum_late, currency
[ 1, 100, 1, 55, 1, 55, company_currency],
[ 1, 200, 1, 110, 1, 110, foreign_currency],
[ 1, 400, 1, 220, 1, 220, foreign_currency],
[ 1, 200, 1, 110, 1, 110, company_currency],
[ 1, 100, 1, 55, 1, 55, company_currency],
]
for (purchase_journal, bill_currency), expected_vals in zip(setup_values, expected_vals_list):
with self.subTest(purchase_journal_currency=purchase_journal.currency_id, bill_currency=bill_currency, expected_vals=expected_vals):
bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=True, amounts=[200], currency=bill_currency, journal=purchase_journal)
_draft_bill = self.init_invoice('in_invoice', invoice_date='2017-01-01', post=False, amounts=[200], currency=bill_currency, journal=purchase_journal)
payment = self.init_payment(-90, post=True, date='2017-01-01', currency=bill_currency)
(bill + payment.move_id).line_ids.filtered_domain([
('account_id', '=', self.company_data['default_account_payable'].id)
]).reconcile()
self.assertDashboardPurchaseSaleData(purchase_journal, *expected_vals)
@freeze_time("2023-03-15")
def test_purchase_journal_numbers_and_sums(self):
company_currency = self.company_data['currency']
journal = self.company_data['default_journal_purchase']
self._create_test_vendor_bills(journal)
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
# Expected behavior is to have three moves waiting for payment for a total amount of 4440$ one of which would be late
# for a total amount of 40$ (second move has one of three lines late but that's not enough to make the move late)
self.assertEqual(3, dashboard_data['number_waiting'])
self.assertEqual(format_amount(self.env, 4440, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(1, dashboard_data['number_late'])
self.assertEqual(format_amount(self.env, 40, company_currency), dashboard_data['sum_late'])
@freeze_time("2019-01-22")
def test_customer_invoice_dashboard(self):
journal = self.company_data['default_journal_sale']
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-21',
'date': '2019-01-21',
'invoice_line_ids': [(0, 0, {
'product_id': self.product_a.id,
'quantity': 40.0,
'name': 'product test 1',
'discount': 10.00,
'price_unit': 2.27,
'tax_ids': [],
})]
})
refund = self.env['account.move'].create({
'move_type': 'out_refund',
'journal_id': journal.id,
'partner_id': self.partner_a.id,
'invoice_date': '2019-01-21',
'date': '2019-01-21',
'invoice_line_ids': [(0, 0, {
'product_id': self.product_a.id,
'quantity': 1.0,
'name': 'product test 1',
'price_unit': 13.3,
'tax_ids': [],
})]
})
# Check Draft
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 2)
self.assertIn('68.42', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 0)
self.assertIn('0.00', dashboard_data['sum_waiting'])
# Check Both
invoice.action_post()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 1)
self.assertIn('-\N{ZERO WIDTH NO-BREAK SPACE}13.30', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 1)
self.assertIn('81.72', dashboard_data['sum_waiting'])
# Check partial on invoice
partial_payment = self.env['account.payment'].create({
'amount': 13.3,
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': self.partner_a.id,
})
partial_payment.action_post()
(invoice + partial_payment.move_id).line_ids.filtered(lambda line: line.account_type == 'asset_receivable').reconcile()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 1)
self.assertIn('13.3', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 1)
self.assertIn('68.42', dashboard_data['sum_waiting'])
# Check waiting payment
refund.action_post()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 0)
self.assertIn('0.00', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 2)
self.assertIn('55.12', dashboard_data['sum_waiting'])
# Check partial on refund
payment = self.env['account.payment'].create({
'amount': 10.0,
'payment_type': 'outbound',
'partner_type': 'customer',
'partner_id': self.partner_a.id,
})
payment.action_post()
(refund + payment.move_id).line_ids\
.filtered(lambda line: line.account_type == 'asset_receivable')\
.reconcile()
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_draft'], 0)
self.assertIn('0.00', dashboard_data['sum_draft'])
self.assertEqual(dashboard_data['number_waiting'], 2)
self.assertIn('65.12', dashboard_data['sum_waiting'])
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
self.assertEqual(dashboard_data['number_late'], 2)
self.assertIn('65.12', dashboard_data['sum_late'])
def test_sale_purchase_journal_for_multi_currency_sale(self):
currency = self.other_currency
company_currency = self.company_data['currency']
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': '2017-01-01',
'date': '2017-01-01',
'partner_id': self.partner_a.id,
'currency_id': currency.id,
'invoice_line_ids': [
(0, 0, {'name': 'test', 'price_unit': 200})
],
})
invoice.action_post()
payment = self.env['account.payment'].create({
'amount': 90.0,
'date': '2016-01-01',
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': self.partner_a.id,
'currency_id': currency.id,
})
payment.action_post()
(invoice + payment.move_id).line_ids.filtered_domain([
('account_id', '=', self.company_data['default_account_receivable'].id)
]).reconcile()
default_journal_sale = self.company_data['default_journal_sale']
dashboard_data = default_journal_sale._get_journal_dashboard_data_batched()[default_journal_sale.id]
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_waiting'])
self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_late'])
@freeze_time("2023-03-15")
def test_purchase_journal_numbers_and_sums_to_validate(self):
company_currency = self.company_data['currency']
journal = self.company_data['default_journal_purchase']
datas = [
{'invoice_date_due': '2023-04-30'},
{'invoice_date_due': '2023-04-30', 'release_to_pay': 'yes'},
{'invoice_date_due': '2023-04-30', 'release_to_pay': 'no'},
{'invoice_date_due': '2023-03-01'},
{'invoice_date_due': '2023-03-01', 'release_to_pay': 'yes'},
{'invoice_date_due': '2023-03-01', 'release_to_pay': 'no'},
]
for data in datas:
self.init_invoice('in_invoice', invoice_date='2023-03-01', post=False, amounts=[4000], journal=journal, invoice_date_due=data['invoice_date_due'], release_to_pay=data.get('release_to_pay'))
dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id]
# Expected behavior is to have six amls waiting for payment for a total amount of 4440$
# three of which would be late for a total amount of 140$
self.assertEqual(4, dashboard_data['number_draft'])
self.assertEqual(format_amount(self.env, 16000, company_currency), dashboard_data['sum_draft'])

View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import Command, fields
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tests import tagged, Form
@tagged('post_install', '-at_install')
class TestReleaseToPayInvoice(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Zizizapartner'})
cls.product = cls.env['product.product'].create({
'name': 'VR Computer',
'standard_price': 2500.0,
'list_price': 2899.0,
'type': 'service',
'default_code': 'VR-01',
'weight': 1.0,
'purchase_method': 'receive',
})
cls.other_currency = cls.setup_other_currency('HRK', rounding=0.001)
def check_release_to_pay_scenario(self, ordered_qty, scenario, invoicing_policy='receive', order_price=500.0):
""" Generic test function to check that each use scenario behaves properly.
"""
self.product.purchase_method = invoicing_policy
purchase_order = self.env['purchase.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {
'name': self.product.name,
'product_id': self.product.id,
'product_qty': ordered_qty,
'product_uom': self.product.uom_po_id.id,
'price_unit': order_price,
'date_planned': fields.Datetime.now(),
})]
})
purchase_order.button_confirm()
invoices_list = []
purchase_line = purchase_order.order_line[-1]
AccountMove = self.env['account.move'].with_context(default_move_type='in_invoice')
for (action, params) in scenario:
if action == 'invoice':
# <field name="purchase_id" invisible="1"/>
move_form = Form(AccountMove.with_context(default_purchase_id=purchase_order.id))
with move_form.invoice_line_ids.edit(0) as line_form:
if 'price' in params:
line_form.price_unit = params['price']
if 'qty' in params:
line_form.quantity = params['qty']
new_invoice = move_form.save()
new_invoice.write({'invoice_line_ids': [
Command.create({'display_type': 'line_section', 'name': 'Section'}),
Command.create({'display_type': 'line_note', 'name': 'Note'}),
]})
invoices_list.append(new_invoice)
self.assertEqual(new_invoice.release_to_pay, params['rslt'], "Wrong invoice release to pay status for scenario " + str(scenario))
elif action == 'receive':
purchase_line.write({'qty_received': params['qty']}) # as the product is a service, its recieved quantity is set manually
if 'rslt' in params:
for (invoice_index, status) in params['rslt']:
self.assertEqual(invoices_list[invoice_index].release_to_pay, status, "Wrong invoice release to pay status for scenario " + str(scenario))
def test_3_way_match(self):
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'yes'})], invoicing_policy='purchase')
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 10, 'rslt': 'yes'})], invoicing_policy='purchase')
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 10, 'rslt': 'yes'})], invoicing_policy='purchase')
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'yes'}), ('receive',{'qty': 5}), ('invoice', {'qty': 6, 'rslt': 'exception'})], invoicing_policy='purchase')
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 10, 'rslt': 'yes'}), ('invoice', {'qty': 10, 'rslt': 'no'})], invoicing_policy='purchase')
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'yes'})])
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 10, 'rslt': 'exception'})])
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'})])
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 5, 'rslt': [(-1, 'yes')]})])
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 3, 'rslt': [(-1, 'exception')]})])
self.check_release_to_pay_scenario(10, [('invoice', {'qty': 5, 'rslt': 'no'}), ('receive', {'qty': 10, 'rslt': [(-1, 'yes')]})])
# Special use case : a price change between order and invoice should always put the bill in exception
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'exception', 'price':42})])
self.check_release_to_pay_scenario(10, [('receive',{'qty': 5}), ('invoice', {'qty': 5, 'rslt': 'exception', 'price':42})], invoicing_policy='purchase')
def test_amount_currency_edit(self):
"""
Ensure that editing the `amount_currency` of a journal item on an invoice is possible.
In 17.0 changes to Binary fields and web_save were made (related to context key 'bin_size').
They led to tracebacks in the flow tested here.
"""
move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice'))
move_form.invoice_date = fields.Date.from_string('2023-01-01')
move_form.partner_id = self.partner_a
move_form.currency_id = self.other_currency
with move_form.invoice_line_ids.new() as line_form:
line_form.quantity = 1
line_form.price_unit = 10
move_form.save()
with move_form.line_ids.edit(0) as line_form:
line_form.amount_currency = -30
move_form.save()
self.assertEqual(move_form.line_ids.edit(0).amount_currency, -30)

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_invoice_form_inherit" model="ir.ui.view">
<field name="name">account.move.form.inherit</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@id='other_tab']//field[@name='fiscal_position_id']" position='after'>
<label for="release_to_pay_manual" invisible="move_type not in ('in_invoice', 'in_refund')"/>
<div class="o_row" col="4" invisible="move_type not in ('in_invoice', 'in_refund')">
<field name="release_to_pay" invisible="True" force_save="1"/>
<field name="release_to_pay_manual"/>
<label class="fw-bold" for="force_release_to_pay" invisible="not force_release_to_pay"/>
<field name="force_release_to_pay" invisible="not force_release_to_pay"/>
</div>
</xpath>
</field>
</record>
<record id="account_invoice_filter_inherit_odex30_account_3way_match" model="ir.ui.view">
<field name="name">account.invoice.select.inherit.odex30_account_3way_match</field>
<field name="mode">primary</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_account_bill_filter"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='late']" position='after'>
<separator/>
<filter name="bills_to_validate" string="Bills to Validate" domain="['&amp;', '|', ('release_to_pay','=', 'yes'), ('invoice_date_due', '&lt;', time.strftime('%Y-%m-%d')), ('state', '=', 'draft')]"/>
<filter name="bills_to_pay" string="Bills to Pay" domain="['&amp;', '&amp;', ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('release_to_pay','in', ('yes', 'exception'))]"/>
<filter name="exception" string="Bills in Exception" domain="[('release_to_pay','=', 'exception')]"/>
<separator/>
</xpath>
</field>
</record>
<!--This action has been redefined and the account_invoice_filter_inherit_odex30_account_3way_match
created in order to only display 'bills_to_pay' and 'exception' filters
in the view related to vendor bills, as it makes no sense to propose them
in the view related to sales invoices, which share the same model.-->
<record id="account.action_move_in_invoice_type" model="ir.actions.act_window">
<field name="search_view_id" ref="account_invoice_filter_inherit_odex30_account_3way_match"/>
</record>
<record id="account.action_move_in_refund_type" model="ir.actions.act_window">
<field name="search_view_id" ref="account_invoice_filter_inherit_odex30_account_3way_match"/>
</record>
</odoo>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_journal_dashboard_kanban_view_3_way_match" model="ir.ui.view">
<field name="name">account.journal.dashboard.kanban</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.account_journal_dashboard_kanban_view"/>
<field name="arch" type="xml">
<xpath expr="//a[span[@id='account_dashboard_purchase_draft']]" position="attributes">
<attribute name="context">
{'search_default_bills_to_validate': 1}
</attribute>
</xpath>
<xpath expr="//a[span[@id='account_dashboard_bills_to_pay']]" position="attributes">
<attribute name="context">
{'search_default_bills_to_pay':1}
</attribute>
</xpath>
<xpath expr="//a[span[@id='account_dashboard_bills_late']]" position="attributes">
<attribute name="context">
{'search_default_late':1}
</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import models

View File

@ -0,0 +1,26 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
{
'name': 'ODEX Account: Advanced Check Processing',
'version': '1.0',
'category': 'Account/Accounting',
'author': 'ODEX',
'summary': 'Advanced verification and reconciliation for issued checks.',
'description': """
This system provides advanced tools for managing financial check distributions.
It enables seamless verification between your internal accounting records and
issued physical checks within the financial management interface.
Key Features:
- Seamless verification of check payments.
- Real-time reconciliation within the accounting workspace.
- Enhanced audit trails for distributed checks.
""",
'depends': ['odex30_account_accountant', 'account_check_printing'],
'data': [
'views/bank_rec_widget_views.xml',
],
'installable': True,
'auto_install': True,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,34 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_accountant_check_printing
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:26+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_accountant_check_printing
#: model:ir.model.fields,field_description:odex30_account_accountant_check_printing.field_account_move_line__check_number
msgid "Check Number"
msgstr ""
#. module: odex30_account_accountant_check_printing
#: model:ir.model,name:odex30_account_accountant_check_printing.model_account_move_line
msgid "Journal Item"
msgstr ""
#. module: odex30_account_accountant_check_printing
#: model:ir.model.fields,help:odex30_account_accountant_check_printing.field_account_move_line__check_number
msgid ""
"The selected journal is configured to print check numbers. If your pre-"
"printed check paper already has numbers or if the current numbering is "
"wrong, you can change it in the journal configuration page."
msgstr ""

View File

@ -0,0 +1,41 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_accountant_check_printing
#
# Translators:
# Wil ODEX, 2024
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:43+0000\n"
"Last-Translator: Wil ODEX, 2024\n"
"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex30_account_accountant_check_printing
#: model:ir.model.fields,field_description:odex30_account_accountant_check_printing.field_account_move_line__check_number
msgid "Check Number"
msgstr "رقم الشيك "
#. module: odex30_account_accountant_check_printing
#: model:ir.model,name:odex30_account_accountant_check_printing.model_account_move_line
msgid "Journal Item"
msgstr "عنصر دفتر اليومية "
#. module: odex30_account_accountant_check_printing
#: model:ir.model.fields,help:odex30_account_accountant_check_printing.field_account_move_line__check_number
msgid ""
"The selected journal is configured to print check numbers. If your pre-"
"printed check paper already has numbers or if the current numbering is "
"wrong, you can change it in the journal configuration page."
msgstr ""
"تمت تهيئة قيد اليومية المُختار لطباعة أرقام الشيكات. إذا كان لشيكاتك "
"المطبوعة مسبقًا أرقام بالفعل أو إذا كان الترقيم الحالي خاطئًا، فيمكنك تغييره"
" في صفحة تهيئة دفتر اليومية. "

View File

@ -0,0 +1,3 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import account_move_line

View File

@ -0,0 +1,13 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class AccountMoveLine(models.Model):
_name = "account.move.line"
_inherit = "account.move.line"
check_number = fields.Char(
string="Check Number",
related='payment_id.check_number',
)

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_move_line_list_bank_rec_widget" model="ir.ui.view">
<field name="name">account.move.line.list.bank_rec_widget</field>
<field name="model">account.move.line</field>
<field name="inherit_id" ref="odex30_account_accountant.view_account_move_line_list_bank_rec_widget"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="check_number"
optional="hidden"/>
</xpath>
</field>
</record>
<record id="view_account_move_line_search_bank_rec_widget" model="ir.ui.view">
<field name="name">account.move.line.search.bank_rec_widget</field>
<field name="model">account.move.line</field>
<field name="inherit_id" ref="odex30_account_accountant.view_account_move_line_search_bank_rec_widget"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="check_number"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve"> <templates id="template" xml:space="preserve">
<t t-name="odex30_account_accountant_fleet.BankRecRecordFormLineIds" t-inherit="account_accountant.BankRecRecordFormLineIds" t-inherit-mode="extension"> <t t-name="odex30_account_accountant_fleet.BankRecRecordFormLineIds" t-inherit="odex30_account_accountant.BankRecRecordFormLineIds" t-inherit-mode="extension">
<xpath expr="//t[@name='col_taxes']" position="after"> <xpath expr="//t[@name='col_taxes']" position="after">
<t t-if="column[0] === 'vehicle'" name="col_vehicle"> <t t-if="column[0] === 'vehicle'" name="col_vehicle">
<td class="o_data_cell o_field_cell o_field_widget o_list_many2one" <td class="o_data_cell o_field_cell o_field_widget o_list_many2one"
@ -12,7 +12,7 @@
</xpath> </xpath>
</t> </t>
<t t-name="odex30_account_accountant_fleet.BankRecRecordNotebookManualOperations" t-inherit="account_accountant.BankRecRecordNotebookManualOperations" t-inherit-mode="extension"> <t t-name="odex30_account_accountant_fleet.BankRecRecordNotebookManualOperations" t-inherit="odex30_account_accountant.BankRecRecordNotebookManualOperations" t-inherit-mode="extension">
<xpath expr="//div[@name='suggestion']" position="before"> <xpath expr="//div[@name='suggestion']" position="before">
<div name="vehicle" <div name="vehicle"
t-if="!['liquidity', 'new_batch'].includes(line.data.flag)" t-if="!['liquidity', 'new_batch'].includes(line.data.flag)"

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import models

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
{
'name': 'ODEX Account: Asset-Fleet Integration',
'category': 'Odex30-Accounting/Odex30-Accounting',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'summary': 'Advanced integration between fixed assets and vehicle fleet management.',
'description': """
This module provides a robust bridge between your fixed asset records and
the company's vehicle fleet management system.
It allows for:
- Direct linkage of assets to specific fleet vehicles.
- Unified tracking of depreciation and maintenance costs for transport assets.
- Integrated reporting across financial and operational fleet data.
""",
'version': '1.0',
'depends': ['account_fleet', 'odex30_account_asset'],
'data': [
'views/account_asset_views.xml',
'views/account_move_views.xml',
],
'auto_install': True,
}

View File

@ -0,0 +1,43 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_asset_fleet
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:26+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_asset_fleet
#. odoo-python
#: code:addons/odex30_account_asset_fleet/models/account_asset.py:0
msgid "All the lines should be from the same vehicle"
msgstr ""
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_account_asset
msgid "Asset/Revenue Recognition"
msgstr ""
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_fleet_vehicle_log_services
msgid "Services for vehicles"
msgstr ""
#. module: odex30_account_asset_fleet
#: model:ir.model.fields,field_description:odex30_account_asset_fleet.field_account_asset__vehicle_id
#: model_terms:ir.ui.view,arch_db:odex30_account_asset_fleet.view_odex30_account_asset_fleet_form
msgid "Vehicle"
msgstr ""

View File

@ -0,0 +1,47 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_asset_fleet
#
# Translators:
# Wil ODEX, 2024
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:43+0000\n"
"Last-Translator: Wil ODEX, 2024\n"
"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex30_account_asset_fleet
#. odoo-python
#: code:addons/odex30_account_asset_fleet/models/account_asset.py:0
msgid "All the lines should be from the same vehicle"
msgstr "يجب أن تكون كافة البنود من نفس المركبة "
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_account_asset
msgid "Asset/Revenue Recognition"
msgstr "إثبات الأصل/الإيرادات "
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_account_move
msgid "Journal Entry"
msgstr "قيد اليومية"
#. module: odex30_account_asset_fleet
#: model:ir.model,name:odex30_account_asset_fleet.model_fleet_vehicle_log_services
msgid "Services for vehicles"
msgstr "خدمات المركبات "
#. module: odex30_account_asset_fleet
#: model:ir.model.fields,field_description:odex30_account_asset_fleet.field_account_asset__vehicle_id
#: model_terms:ir.ui.view,arch_db:odex30_account_asset_fleet.view_odex30_account_asset_fleet_form
msgid "Vehicle"
msgstr "المركبة"

View File

@ -0,0 +1,5 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import account_asset
from . import account_move
from . import fleet_vehicle_log_services

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAsset(models.Model):
_inherit = 'account.asset'
vehicle_id = fields.Many2one('fleet.vehicle', compute='_compute_vehicle_id', readonly=False, store=True)
@api.depends('original_move_line_ids')
def _compute_vehicle_id(self):
for record in self:
if len(record.original_move_line_ids.vehicle_id) > 1:
raise UserError(_("All the lines should be from the same vehicle"))
record.vehicle_id = record.original_move_line_ids.vehicle_id
def action_open_vehicle(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fleet.vehicle',
'res_id': self.vehicle_id.id,
'view_ids': [(False, 'form')],
'view_mode': 'form',
}

View File

@ -0,0 +1,15 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def _prepare_move_for_asset_depreciation(self, vals):
# Overridden in order to link the depreciation entries with the vehicle_id
move_vals = super()._prepare_move_for_asset_depreciation(vals)
if vals['asset_id'].vehicle_id:
for _command, _id, line_vals in move_vals['line_ids']:
line_vals['vehicle_id'] = vals['asset_id'].vehicle_id.id
return move_vals

View File

@ -0,0 +1,20 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class FleetVehicleLogServices(models.Model):
_inherit = 'fleet.vehicle.log.services'
@api.depends('account_move_line_id.price_subtotal',
'account_move_line_id.non_deductible_tax_value',
'account_move_line_id.account_id.multiple_assets_per_line')
def _compute_amount(self):
for log_service in self:
if not log_service.account_move_line_id:
continue
account_move_line_id = log_service.account_move_line_id
quantity = 1
if account_move_line_id.account_id.multiple_assets_per_line:
quantity = account_move_line_id.quantity
log_service.amount = account_move_line_id.currency_id.round(
(account_move_line_id.debit + account_move_line_id.non_deductible_tax_value) / quantity)

View File

@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="view_odex30_account_asset_fleet_form" model="ir.ui.view">
<field name="name">account.asset.fleet.form</field>
<field name="model">account.asset</field>
<field name="inherit_id" ref="odex30_account_asset.view_account_asset_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet/div[@name='button_box']" position="inside">
<field name='vehicle_id' invisible="1"/>
<button class="oe_stat_button" string="Vehicle" name="action_open_vehicle" type="object" icon="fa-car" invisible="not vehicle_id"/>
</xpath>
<xpath expr="//sheet/notebook/page[@name='related_items']//field[@name='account_id']" position="after">
<field name='vehicle_id' optional='hidden'/>
</xpath>
<xpath expr="//field[@name='already_depreciated_amount_import']" position="after">
<field name='vehicle_id'/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<record id="view_account_move_fleet_form" model="ir.ui.view">
<field name="name">account.move.fleet.form</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account_fleet.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='line_ids']//field[@name='vehicle_id']" position="attributes">
<attribute name="column_invisible">parent.move_type not in ('entry', 'in_invoice', 'in_refund')</attribute>
<attribute name="required">need_vehicle and parent.move_type in ('in_invoice', 'in_refund')</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
{
'name': 'ODEX Account: Inter-Organization Compliance',
'version': '1.1',
'summary': 'Advanced synchronization of financial documents across internal entities.',
'category': 'Odex30-Accounting/Odex30-Accounting',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'description': """
This system facilitates the seamless synchronization of financial documents between
different organizational entities. It ensures automated document flow for internal operations.
Key Features:
- Automated creation of matching Sales/Purchase orders across entities.
- Synchronization of invoices and credit notes.
- Unified compliance across the organizational structure.
""",
'depends': [
'account',
],
'data': [
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
],
'installable': True,
'license': 'LGPL-3',
}

View File

@ -0,0 +1,113 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_inter_company_rules
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:26+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_inter_company_rules
#. odoo-python
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
msgid "%(company)s Invoice: %(entry)s"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_send
msgid "Account Move Send"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_generated
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_generated
msgid "Auto Generated Document"
msgstr ""
#. module: odex30_account_inter_company_rules
#. odoo-python
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
msgid "Automatically generated from %(origin)s of company %(company)s."
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_document_state
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_document_state
msgid "Automation"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_company
msgid "Companies"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__posted
msgid "Create and validate"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
msgid "Create as"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__draft
msgid "Create in draft"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_generate_bills_refund
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_generate_bills_refund
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.res_config_settings_view_form
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
msgid "Generate Bills and Refunds"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
msgid "Inter-Company Transactions"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_line
msgid "Journal Item"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_purchase_journal_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_purchase_journal_id
msgid "Purchase Journal"
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
msgid ""
"Responsible user for creation of documents triggered by intercompany rules."
msgstr ""
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_invoice_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_invoice_id
msgid "Source Invoice"
msgstr ""

View File

@ -0,0 +1,118 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_inter_company_rules
#
# Translators:
# Wil ODEX, 2024
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 09:26+0000\n"
"PO-Revision-Date: 2024-09-25 09:44+0000\n"
"Last-Translator: Wil ODEX, 2024\n"
"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex30_account_inter_company_rules
#. odoo-python
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
msgid "%(company)s Invoice: %(entry)s"
msgstr "%(company)s الفاتورة: %(entry)s "
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_send
msgid "Account Move Send"
msgstr "إرسال حركة الحساب "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_generated
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_generated
msgid "Auto Generated Document"
msgstr "المستند المنشأ تلقائيًا"
#. module: odex30_account_inter_company_rules
#. odoo-python
#: code:addons/odex30_account_inter_company_rules/models/account_move.py:0
msgid "Automatically generated from %(origin)s of company %(company)s."
msgstr "مُنشأ تلقائياً من %(origin)s للشركة %(company)s. "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_document_state
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_document_state
msgid "Automation"
msgstr "الأتمتة "
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_company
msgid "Companies"
msgstr "الشركات"
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_res_config_settings
msgid "Config Settings"
msgstr "تهيئة الإعدادات "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__posted
msgid "Create and validate"
msgstr "إنشاء وتصديق "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
msgid "Create as"
msgstr "إنشاء كـ"
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields.selection,name:odex30_account_inter_company_rules.selection__res_company__intercompany_document_state__draft
msgid "Create in draft"
msgstr "إنشاء في مسودة "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_generate_bills_refund
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_generate_bills_refund
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.res_config_settings_view_form
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
msgid "Generate Bills and Refunds"
msgstr "إنشاء الفواتير ورد الأموال "
#. module: odex30_account_inter_company_rules
#: model_terms:ir.ui.view,arch_db:odex30_account_inter_company_rules.view_company_inter_change_inherit_form
msgid "Inter-Company Transactions"
msgstr "المعاملات بين الشركات "
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move
msgid "Journal Entry"
msgstr "قيد اليومية"
#. module: odex30_account_inter_company_rules
#: model:ir.model,name:odex30_account_inter_company_rules.model_account_move_line
msgid "Journal Item"
msgstr "عنصر اليومية"
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_company__intercompany_purchase_journal_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_res_config_settings__intercompany_purchase_journal_id
msgid "Purchase Journal"
msgstr "دفتر يومية المشتريات "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_company__intercompany_user_id
#: model:ir.model.fields,help:odex30_account_inter_company_rules.field_res_config_settings__intercompany_user_id
msgid ""
"Responsible user for creation of documents triggered by intercompany rules."
msgstr ""
"المستخدم المسؤول عن إنشاء مستندات تُفعل من خلال قواعد ما بين الشركات. "
#. module: odex30_account_inter_company_rules
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_bank_statement_line__auto_invoice_id
#: model:ir.model.fields,field_description:odex30_account_inter_company_rules.field_account_move__auto_invoice_id
msgid "Source Invoice"
msgstr "الفاتورة المصدر"

View File

@ -0,0 +1,4 @@
from . import account_move
from . import account_move_send
from . import res_config_settings
from . import res_company

View File

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, _
class AccountMove(models.Model):
_inherit = 'account.move'
auto_generated = fields.Boolean(string='Auto Generated Document', copy=False, default=False)
auto_invoice_id = fields.Many2one('account.move', string='Source Invoice', readonly=True, copy=False, index='btree_not_null')
def _post(self, soft=True):
# OVERRIDE to generate cross invoice based on company rules.
invoices_map = {}
posted = super()._post(soft)
for invoice in posted.filtered(lambda move: move.is_sale_document()):
company_sudo = self.env['res.company'].sudo()._find_company_from_partner(invoice.partner_id.id)
if company_sudo and company_sudo.intercompany_generate_bills_refund and not invoice.auto_generated:
invoices_map.setdefault(company_sudo, self.env['account.move'])
invoices_map[company_sudo] += invoice
for company_sudo, invoices in invoices_map.items():
context = dict(self.env.context, default_company_id=company_sudo.id)
context.pop('default_journal_id', None)
invoices.with_user(company_sudo.intercompany_user_id.id).with_context(context).with_company(company_sudo.id)._inter_company_create_invoices()
return posted
def _inter_company_create_invoices(self):
''' Create cross company invoices.
:return: The newly created invoices.
'''
# Prepare invoice values.
invoices_vals_per_type = {}
inverse_types = {
'out_invoice': 'in_invoice',
'out_refund': 'in_refund',
}
for inv in self:
invoice_vals = inv._inter_company_prepare_invoice_data(inverse_types[inv.move_type])
invoice_vals['invoice_line_ids'] = []
for line in inv.invoice_line_ids:
invoice_vals['invoice_line_ids'].append((0, 0, line._inter_company_prepare_invoice_line_data()))
inv_new = inv.with_context(default_move_type=invoice_vals['move_type']).new(invoice_vals)
for line in inv_new.invoice_line_ids.filtered(lambda l: l.display_type not in ('line_note', 'line_section')):
# We need to adapt the taxes following the fiscal position, but we must keep the
# price unit.
price_unit = line.price_unit
line.tax_ids = line._get_computed_taxes()
line.price_unit = price_unit
invoice_vals = inv_new._convert_to_write(inv_new._cache)
invoice_vals.pop('line_ids', None)
invoice_vals['origin_invoice'] = inv
invoices_vals_per_type.setdefault(invoice_vals['move_type'], [])
invoices_vals_per_type[invoice_vals['move_type']].append(invoice_vals)
# Create invoices.
moves = self.env['account.move']
for invoice_type, invoices_vals in invoices_vals_per_type.items():
for invoice in invoices_vals:
origin_invoice = invoice['origin_invoice']
invoice.pop('origin_invoice')
msg = _("Automatically generated from %(origin)s of company %(company)s.", origin=origin_invoice.name, company=origin_invoice.company_id.name)
am = self.with_context(default_type=invoice_type).create(invoice)
am.message_post(body=msg)
if self.env.company.intercompany_document_state == "posted":
am._post(soft=True)
moves += am
return moves
def _inter_company_prepare_invoice_data(self, invoice_type):
r''' Get values to create the invoice.
/!\ Doesn't care about lines, see '_inter_company_prepare_invoice_line_data'.
:return: Python dictionary of values.
'''
self.ensure_one()
# We need the fiscal position in the company (already in context) we are creating the
# invoice, not the fiscal position of the current invoice (self.company)
delivery_partner_id = self.company_id.partner_id.address_get(['delivery'])['delivery']
delivery_partner = self.env['res.partner'].browse(delivery_partner_id)
fiscal_position_id = self.env['account.fiscal.position']._get_fiscal_position(
self.company_id.partner_id, delivery=delivery_partner
)
invoice_vals = {
'move_type': invoice_type,
'ref': self.name,
'partner_id': self.company_id.partner_id.id,
'currency_id': self.currency_id.id,
'auto_generated': True,
'auto_invoice_id': self.id,
'company_id': self.env.company.id,
'invoice_date': self.invoice_date,
'invoice_date_due': self.invoice_date_due,
'payment_reference': self.payment_reference,
'invoice_origin': _('%(company)s Invoice: %(entry)s', company=self.company_id.name, entry=self.name),
'fiscal_position_id': fiscal_position_id,
'journal_id': self.env.company.intercompany_purchase_journal_id.id,
'invoice_payment_term_id': self.invoice_payment_term_id.id if self.invoice_payment_term_id and not self.invoice_payment_term_id.company_id else False,
}
return invoice_vals
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _inter_company_prepare_invoice_line_data(self):
''' Get values to create the invoice line.
We prioritize the analytic distribution in the following order:
- Default Analytic Distribution model specific to Company B
- Analytic Distribution set for the line in Company A's document if available to Company B
:return: Python dictionary of values.
'''
self.ensure_one()
vals = {
'display_type': self.display_type,
'sequence': self.sequence,
'name': self.name,
'quantity': self.quantity,
'discount': self.discount,
'price_unit': self.price_unit,
}
if self.product_id.company_id:
vals['name'] = self.product_id.name
else:
vals.update({
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id
})
company_b = self.env['res.company']._find_company_from_partner(self.move_id.partner_id.id)
company_a_partner = self.company_id.partner_id
company_b_default_distribution = self.env['account.analytic.distribution.model']._get_distribution({
"product_id": self.product_id.id,
"product_categ_id": self.product_id.categ_id.id,
"partner_id": company_a_partner.id,
"partner_category_id": company_a_partner.category_id.ids,
"account_prefix": self.account_id.code,
"company_id": company_b.id,
})
analytic_distribution = {}
if self.analytic_distribution:
accounts_with_company = self.distribution_analytic_account_ids.filtered('company_id')
for key, val in self.analytic_distribution.items():
is_company_account = False
for account_id in key.split(','):
if int(account_id) in accounts_with_company.ids:
is_company_account = True
break
if not is_company_account:
analytic_distribution[key] = val
if company_b_default_distribution or analytic_distribution:
vals['analytic_distribution'] = dict(company_b_default_distribution, **analytic_distribution)
return vals

View File

@ -0,0 +1,42 @@
from odoo import models
class AccountMoveSend(models.AbstractModel):
_inherit = 'account.move.send'
def _generate_and_send_invoices(self, moves, from_cron=False, allow_raising=True, allow_fallback_pdf=False, **custom_settings):
# EXTENDS 'account' - to create the pdf attachment
# in the matching inter-company move
res = super()._generate_and_send_invoices(moves, from_cron=from_cron, allow_raising=allow_raising, allow_fallback_pdf=allow_fallback_pdf, **custom_settings)
partner_companies = self.env['res.company'].sudo().search([]).partner_id.ids
moves_with_attachments = moves.filtered(
lambda move: bool(move.message_main_attachment_id)
and move.is_sale_document(include_receipts=True)
and move.commercial_partner_id.id in partner_companies
)
ico_moves = self.env['account.move'].sudo().search([
('move_type', 'in', self.env['account.move'].get_purchase_types(include_receipts=True)),
('auto_generated', '=', True),
('auto_invoice_id', 'in', moves_with_attachments.ids)
])
for ico_move in ico_moves:
original_move = ico_move.auto_invoice_id
move_attachment = original_move.message_main_attachment_id
if not move_attachment: # shouldn't happen but just in case
continue
ico_move.message_main_attachment_id = self.env['ir.attachment']\
.with_user(ico_move.company_id.intercompany_user_id.id).with_company(ico_move.company_id.id).create({
'name': f'{original_move.name}.pdf',
'type': 'binary',
'mimetype': 'application/pdf',
'raw': move_attachment.raw,
'res_model': 'account.move',
'res_id': ico_move.id,
})
return res

View File

@ -0,0 +1,43 @@
from odoo import api, fields, models, SUPERUSER_ID
class res_company(models.Model):
_inherit = 'res.company'
intercompany_generate_bills_refund = fields.Boolean(string="Generate Bills and Refunds")
intercompany_document_state = fields.Selection(
selection=[
('draft', "Create in draft"),
('posted', "Create and validate"),
],
string="Automation",
default='draft',
)
intercompany_purchase_journal_id = fields.Many2one(
comodel_name='account.journal',
string="Purchase Journal",
domain='[("type", "=", "purchase")]',
compute='_compute_intercompany_purchase_journal_id', store=True, readonly=False,
)
intercompany_user_id = fields.Many2one(
comodel_name='res.users',
string="Create as",
default=SUPERUSER_ID,
domain=['|', ['active', '=', True], ['id', '=', SUPERUSER_ID]],
help="Responsible user for creation of documents triggered by intercompany rules.",
)
@api.model
def _find_company_from_partner(self, partner_id):
if not partner_id:
return False
company = self.sudo().search([('partner_id', 'parent_of', partner_id)], limit=1)
return company or False
@api.depends('chart_template')
def _compute_intercompany_purchase_journal_id(self):
journals_by_company = dict(self.env['account.journal']._read_group(domain=[('type', '=', 'purchase')], groupby=['company_id'], aggregates=['id:recordset']))
for company in self:
if not company.intercompany_purchase_journal_id:
company.intercompany_purchase_journal_id = journals_by_company.get(company, [False])[0]

View File

@ -0,0 +1,10 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
intercompany_generate_bills_refund = fields.Boolean(related='company_id.intercompany_generate_bills_refund', readonly=False)
intercompany_user_id = fields.Many2one(related='company_id.intercompany_user_id', readonly=False, required=True)
intercompany_purchase_journal_id = fields.Many2one(related='company_id.intercompany_purchase_journal_id', readonly=False)
intercompany_document_state = fields.Selection(related='company_id.intercompany_document_state', readonly=False)

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from . import test_inter_company_invoice

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo.tests import common
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
class TestInterCompanyRulesCommon(AccountTestInvoicingCommon):
"""This test needs sale_purchase_inter_company_rules to run."""
@classmethod
def setUpClass(cls):
super(TestInterCompanyRulesCommon, cls).setUpClass()
cls.company_a = cls.company_data['company']
cls.company_b = cls.setup_other_company()['company']
# Create a new product named product_consultant
cls.product_consultant = cls.env['product.product'].create({
'name': 'Service',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'categ_id': cls.env.ref('product.product_category_all').id,
'type': 'service',
'taxes_id': [(6, 0, (cls.company_a.account_sale_tax_id + cls.company_b.account_sale_tax_id).ids)],
'supplier_taxes_id': [(6, 0, (cls.company_a.account_purchase_tax_id + cls.company_b.account_purchase_tax_id).ids)],
'company_id': False
})
# Create user of company_a
cls.res_users_company_a = cls.env['res.users'].create({
'name': 'User A',
'login': 'usera',
'email': 'usera@yourcompany.com',
'company_id': cls.company_a.id,
'company_ids': [(6, 0, [cls.company_a.id])],
'groups_id': [(6, 0, [
cls.env.ref('account.group_account_user').id,
cls.env.ref('account.group_account_manager').id
])]
})
# Create user of company_b
cls.res_users_company_b = cls.env['res.users'].create({
'name': 'User B',
'login': 'userb',
'email': 'userb@yourcompany.com',
'company_id': cls.company_b.id,
'company_ids': [(6, 0, [cls.company_b.id])],
'groups_id': [(6, 0, [
cls.env.ref('account.group_account_user').id
])]
})

View File

@ -0,0 +1,270 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import Command, tests
from .common import TestInterCompanyRulesCommon
@tests.tagged('post_install', '-at_install')
class TestInterCompanyInvoice(TestInterCompanyRulesCommon):
@classmethod
def setUpClass(cls):
super(TestInterCompanyInvoice, cls).setUpClass()
# Enable auto generate invoice in company.
(cls.company_a + cls.company_b).write({
'intercompany_generate_bills_refund': True,
})
# Configure Chart of Account for company_b.
cls.env.user.company_id = cls.company_b
cls.env['account.chart.template'].try_loading('generic_coa', cls.company_b, install_demo=False)
# Configure Chart of Account for company_a.
cls.env.user.company_id = cls.company_a
cls.env['account.chart.template'].try_loading('generic_coa', cls.company_a, install_demo=False)
def _configure_analytic(self, product=None, company=None, partner=None):
"""
Configure Analytic Distribution Model for company_a based on Product A
return: analytic account
"""
display_name = "Inter Company"
if company:
self.env.user.company_id = company
display_name = company.display_name
analytic_plan = self.env['account.analytic.plan'].create({'name': f'Analytic Plan {display_name}'})
analytic_account = self.env['account.analytic.account'].create({
'name': f'Account {display_name}',
'company_id': company and company.id,
'plan_id': analytic_plan.id,
})
self.env['account.analytic.distribution.model'].create({
'analytic_distribution': {analytic_account.id: 100},
'product_id': product and product.id,
'company_id': company and company.id,
'partner_id': partner and partner.id,
})
return analytic_account
def _create_post_invoice(self, product_id, analytic_distribution=None):
"""Create a Company A invoice with Company B as the customer and post it"""
invoice_line_vals = {
'product_id': product_id,
'price_unit': 100.0,
'quantity': 1.0,
}
if analytic_distribution:
invoice_line_vals['analytic_distribution'] = analytic_distribution
customer_invoice = self.env['account.move'].with_user(self.res_users_company_a).create({
'move_type': 'out_invoice',
'partner_id': self.company_b.partner_id.id,
'invoice_line_ids': [(0, 0, invoice_line_vals)]
})
customer_invoice.with_user(self.res_users_company_a).action_post()
def test_00_inter_company_invoice_flow(self):
""" Test inter company invoice flow """
self.env.ref('base.EUR').active = True
# Create customer invoice for company A. (No need to call onchange as all the needed values are specified)
self.res_users_company_a.company_ids = [(4, self.company_b.id)]
customer_invoice = self.env['account.move'].with_user(self.res_users_company_a).create({
'move_type': 'out_invoice',
'partner_id': self.company_b.partner_id.id,
'currency_id': self.env.ref('base.EUR').id,
'invoice_line_ids': [(0, 0, {
'product_id': self.product_consultant.id,
'price_unit': 450.0,
'quantity': 1.0,
'name': 'test'
})]
})
# Check account invoice state should be draft.
self.assertEqual(customer_invoice.state, 'draft', 'Initially customer invoice should be in the "Draft" state')
# Validate invoice
customer_invoice.with_user(self.res_users_company_a).action_post()
# Check Invoice status should be open after validate.
self.assertEqual(customer_invoice.state, 'posted', 'Invoice should be in Open state.')
# I check that the vendor bill is created with proper data.
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertTrue(supplier_invoice.invoice_line_ids[0].quantity == 1, "Quantity in invoice line is incorrect.")
self.assertTrue(supplier_invoice.invoice_line_ids[0].product_id.id == self.product_consultant.id, "Product in line is incorrect.")
self.assertTrue(supplier_invoice.invoice_line_ids[0].price_unit == 450, "Unit Price in invoice line is incorrect.")
self.assertTrue(supplier_invoice.invoice_line_ids[0].account_id.company_ids == self.company_b, "Applied account in created invoice line is not relevant to company.")
self.assertTrue(supplier_invoice.state == "draft", "invoice should be in draft state.")
self.assertEqual(supplier_invoice.amount_total, 517.5, "Total amount is incorrect.")
self.assertTrue(supplier_invoice.company_id.id == self.company_b.id, "Applied company in created invoice is incorrect.")
def test_default_analytic_distribution_company_b(self):
"""
[Analytic Distribution Model is set for Company B + Inter Company Analytic Account is set]
- With Company A, create an Invoice for Company B with product A set with an analytic distribution model available for Company B
-> The Analytic Distribution set on the Supplier Invoice Line should be the same as defined in the analytic distribution model set by default for Company B
and the one manually set when the analytic account is also available for Company B
"""
analytic_account_company_b = self._configure_analytic(company=self.company_b, product=self.product_a)
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(analytic_account_company_b.id): 100, str(inter_company_analytic_account.id): 100})
def test_no_default_analytic_distribution_company_b(self):
"""
[Analytic Distribution Model is not set for Company B + Inter Company Analytic Account is set]
- With Company A, create an Invoice for Company B with a line set with an analytic distribution model available for Company B
-> The analytic distribution set on the supplier invoice line should be the same as defined in the customer invoice line created in Company A
as the analytic account is available for Company B and there are no default analytic distribution model set for Company B
"""
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(inter_company_analytic_account.id): 100})
def test_multi_analytic_account_distribution_company_b(self):
"""
Test that the analytic distribution is set properly when multiple analytic accounts (with or without a company) are set on the invoice line
"""
analytic_account_company_a = self._configure_analytic(company=self.company_a, product=self.product_a)
inter_company_analytic_account = self._configure_analytic(product=self.product_a)
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={
analytic_account_company_a.id: 50,
inter_company_analytic_account.id: 50,
f"{analytic_account_company_a.id},{inter_company_analytic_account.id}": 100
})
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, {str(inter_company_analytic_account.id): 50})
def test_default_analytic_distribution_company_a(self):
"""
[Analytic Distribution Model is set for Company A]
- With Company A, create an Invoice for Company B with a line set with an analytic distribution model not available for Company B
-> There should be no analytic distribution set on the supplier invoice line as there is no analytic distribution model available for Company B
and the analytic account is not available for Company B
"""
analytic_account_company_a = self._configure_analytic(company=self.company_a, product=self.product_a)
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={analytic_account_company_a.id: 100})
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertFalse(supplier_invoice.invoice_line_ids.analytic_distribution, "Analytic distribution should not be set on the invoice line.")
def test_inter_company_invoice_flow_sub_companies(self):
"""
Test that the flow with inter company invoice is also working properly with sub companies
"""
# Create branches for company a
self.company_a.write({'child_ids': [
Command.create({'name': 'Branch 1 of company a'}),
Command.create({'name': 'Branch 2 of company a'}),
]})
self.cr.precommit.run() # load the COA
branch_1, branch_2 = self.company_a.child_ids
(branch_1 + branch_2).write({
'intercompany_generate_bills_refund': True,
})
# It's required to have an intercompany_journal_id set to be able to do the generation
for branch in [branch_1, branch_2]:
branch.intercompany_purchase_journal_id = self.env['account.journal'].create({
'name': 'Vendor Bills - Test',
'code': 'TEXJ',
'type': 'purchase',
'company_id': branch.id,
})
# Select the two branches
self.env.user.write({
'company_ids': [Command.set((branch_1 + branch_2).ids)],
'company_id': branch_1.id,
})
# Invoice from Branch 1 to Branch 2
customer_invoice = self.env['account.move'].with_context(allowed_company_ids=branch_1.ids).create({
'move_type': 'out_invoice',
'invoice_date': '2023-05-01',
'partner_id': branch_2.partner_id.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 100.0,
'quantity': 1.0,
'tax_ids': False,
})]
})
customer_invoice.action_post()
bill = self.env['account.move'].search([('move_type', '=', 'in_invoice')], limit=1)
self.assertRecordValues(bill, [{
'partner_id': branch_1.partner_id.id,
'company_id': branch_2.id,
'payment_reference': customer_invoice.payment_reference,
}])
def test_inter_company_invoice_product_not_accessible(self):
"""
Whenever Company A invoices Company B with a Product A defined only for Company A
We don't set Product A (access error) but we define only the invoice line's label
with Product A's name
"""
self.product_a.company_id = self.company_a
self._create_post_invoice(self.product_a.id)
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
self.assertFalse(supplier_invoice.invoice_line_ids.product_id, "No product should be set")
self.assertEqual(supplier_invoice.invoice_line_ids.name, self.product_a.name)
def test_analytic_distribution_model_partner(self):
"""
If company B defines Company A as a partner in its distribution model, the distribution should be retrieved
"""
inter_company_analytic_account = self._configure_analytic(product=self.product_b)
analytic_account_company_b = self._configure_analytic(company=self.company_b, partner=self.company_a.partner_id)
self._create_post_invoice(product_id=self.product_a.id, analytic_distribution={inter_company_analytic_account.id: 100})
supplier_invoice = self.env['account.move'].with_user(self.res_users_company_b).search([('move_type', '=', 'in_invoice')], limit=1)
expected_distribution = {
str(inter_company_analytic_account.id): 100,
str(analytic_account_company_b.id): 100
}
self.assertEqual(supplier_invoice.invoice_line_ids.analytic_distribution, expected_distribution)
def test_inter_company_attachment_with_contact_as_partner(self):
"""
Test that when creating and printing an invoice in company A for an individual contact belonging to company B,
the corresponding bill in company B there is an attachment.
"""
company_partner = self.env['res.partner'].create({
'name': 'company partner',
'parent_id': self.company_b.partner_id.id,
})
customer_invoice = self.env['account.move'].create({
'company_id': self.company_a.id,
'move_type': 'out_invoice',
'invoice_date': '2023-05-01',
'partner_id': company_partner.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 100.0,
'quantity': 1.0,
'tax_ids': False,
})]
})
customer_invoice.action_post()
self.env['account.move.send.wizard'].with_context(active_model='account.move', active_ids=customer_invoice.id)._generate_and_send_invoices(customer_invoice)
bill = self.env['account.move'].search([('move_type', '=', 'in_invoice'), ('company_id', '=', self.company_b.id)], limit=1)
self.assertTrue(bill.attachment_ids)

View File

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<odoo>
<data>
<record model="ir.ui.view" id="view_company_inter_change_inherit_form">
<field name="name">res.company.form.inherit</field>
<field name="inherit_id" ref="base.view_company_form"/>
<field name="model">res.company</field>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Inter-Company Transactions" name="inter_company_transactions" groups="base.group_no_one">
<group>
<group>
<label string="Generate Bills and Refunds" for="intercompany_generate_bills_refund" class="text-nowrap col-lg-6 o_light_label"/>
<field name="intercompany_generate_bills_refund" nolabel="1"/>
<field name="intercompany_user_id" options="{'no_create_edit': True}"/>
<field name="intercompany_purchase_journal_id" options="{'no_create_edit': True}" invisible="not intercompany_generate_bills_refund" required="intercompany_generate_bills_refund"/>
<field name="intercompany_document_state" widget="radio"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.inter.company.rules</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='inter_companies_rules']" position='replace'>
<div class="mt16">
<div class="content-group" name="module_odex30_account_inter_company_rules_company_id" >
<div class="row">
<field name="intercompany_generate_bills_refund" class="oe_inline o_light_label"/>
<label string="Generate Bills and Refunds" for="intercompany_generate_bills_refund" class="col-lg-6 o_light_label"/>
</div>
</div>
<div class="content-group" name="module_inter_company_rules_set_so_po"
>
<div class="row ml16">
<label for="intercompany_user_id" class="col-4 col-lg-5 o_light_label"/>
<field name="intercompany_user_id" options="{'no_create_edit': True}"/>
<label for="intercompany_purchase_journal_id" class="col-4 col-lg-5 o_light_label" invisible="not intercompany_generate_bills_refund"/>
<field name="intercompany_purchase_journal_id" options="{'no_create_edit': True}" invisible="not intercompany_generate_bills_refund" required="intercompany_generate_bills_refund"/>
<label for="intercompany_document_state" class="col-4 col-lg-5 o_light_label"/>
<field name="intercompany_document_state" widget="radio"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,27 @@
import re
from . import models # noqa: F401
def post_init_hook(env):
"""Replace total_overdue by total_overdue_followup in followup email templates."""
mail_templates = env['account_followup.followup.line'].search([]).mapped('mail_template_id')
for template in mail_templates:
template.body_html = re.sub(
r'''t-out=("([^"]+)|'([^']+))object\.total_overdue\b''',
r't-out=\1object.total_overdue_followup',
# unwrap Markup as it cause issue with python <3.12
str(template.body_html),
)
def uninstall_hook(env):
"""Restore total_overdue instead of total_overdue_followup in followup email templates."""
mail_templates = env['account_followup.followup.line'].search([]).mapped('mail_template_id')
for template in mail_templates:
template.body_html = re.sub(
r'''t-out=("([^"]+)|'([^']+))object\.total_overdue_followup\b''',
r't-out=\1object.total_overdue',
# unwrap Markup as it cause issue with python <3.12
str(template.body_html),
)

View File

@ -0,0 +1,30 @@
{
'name': 'ODEX Account: Transaction Exception Control',
'summary': 'Advanced exclusion of specific transactions from automated follow-up cycles.',
'category': 'Odex30-Accounting/Odex30-Accounting',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'description': """
This module provides the ability to exclude specific ledger transactions from
the automated communication and follow-up workflows. It ensures precision in
managing customer and vendor relations.
Key Features:
- Flag individual transactions for follow-up exclusion.
- Maintains clarity in automated debt collection reports.
- Flexible control over transaction-level visibility in follow-up cycles.
""",
'depends': ['odex30_account_followup'],
'data': [
'views/account_move_views.xml',
],
'auto_install': True,
'assets': {
'web.assets_backend': [
'odex30_account_no_followup/static/src/components/**/*',
],
},
'license': 'LGPL-3',
'post_init_hook': 'post_init_hook',
'uninstall_hook': "uninstall_hook",
}

View File

@ -0,0 +1,72 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_no_followup
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-21 18:47+0000\n"
"PO-Revision-Date: 2025-11-21 18:47+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_res_partner
msgid "Contact"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_account_customer_statement_report_handler
msgid "Customer Statement Custom Handler"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_bank_statement_line__no_followup
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_move__no_followup
msgid "Exclude this journal entry from follow-up reports."
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model.fields,help:odex30_account_no_followup.field_account_move_line__no_followup
msgid "Exclude this journal item from follow-up reports."
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_account_followup_report_handler
msgid "Follow-Up Report Custom Handler"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_account_move_line
msgid "Journal Item"
msgstr ""
#. module: odex30_account_no_followup
#. odoo-javascript
#: code:addons/odex30_account_no_followup/static/src/components/partner_ledger_followup/header.xml:0
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_bank_statement_line__no_followup
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_move__no_followup
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_account_move_line__no_followup
msgid "No Follow-Up"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model,name:odex30_account_no_followup.model_account_partner_ledger_report_handler
msgid "Partner Ledger Custom Handler"
msgstr ""
#. module: odex30_account_no_followup
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_res_partner__total_overdue_followup
#: model:ir.model.fields,field_description:odex30_account_no_followup.field_res_users__total_overdue_followup
msgid "Total Overdue Followup"
msgstr ""

View File

@ -0,0 +1,6 @@
from . import account_customer_statement
from . import account_followup_report
from . import account_move_line
from . import account_move
from . import account_partner_ledger
from . import res_partner

View File

@ -0,0 +1,12 @@
from odoo import models
class CustomerStatementCustomHandler(models.AbstractModel):
_inherit = 'account.customer.statement.report.handler'
def _get_custom_display_config(self):
display_config = super()._get_custom_display_config()
display_config['components']['AccountReportLine'] = 'odex30_account_no_followup.PartnerLedgerFollowupLine'
display_config['templates']['AccountReportHeader'] = 'odex30_account_no_followup.PartnerLedgerFollowupHeader'
return display_config

View File

@ -0,0 +1,18 @@
from odoo import models
class AccountFollowupCustomHandler(models.AbstractModel):
_inherit = 'account.followup.report.handler'
def _custom_options_initializer(self, report, options, previous_options):
super()._custom_options_initializer(report, options, previous_options)
if options['export_mode'] == 'print':
# When printing the report, we don't want to include `no_followup` lines.
options['forced_domain'] = options.get('forced_domain', []) + [('no_followup', '=', False)]
def _get_custom_display_config(self):
config = super()._get_custom_display_config()
config['components']['AccountReportLine'] = 'odex30_account_no_followup.PartnerLedgerFollowupLine'
config['templates']['AccountReportHeader'] = 'odex30_account_no_followup.PartnerLedgerFollowupHeader'
return config

View File

@ -0,0 +1,32 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
no_followup = fields.Boolean(
string="No Follow-Up",
compute='_compute_no_followup',
inverse='_inverse_no_followup',
readonly=False,
help="Exclude this journal entry from follow-up reports."
)
@api.depends('line_ids.no_followup')
def _compute_no_followup(self):
for move in self:
if move.is_invoice():
move.no_followup = move.line_ids.filtered(
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
)[:1].no_followup
else:
move.no_followup = True
def _inverse_no_followup(self):
for move in self:
if move.is_invoice():
move.line_ids.filtered(
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
).no_followup = move.no_followup

View File

@ -0,0 +1,29 @@
# Part of ODEX. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
no_followup = fields.Boolean(
string="No Follow-Up",
compute='_compute_no_followup',
inverse='_inverse_no_followup',
store=True,
readonly=False,
help="Exclude this journal item from follow-up reports.",
)
@api.depends('move_id.move_type')
def _compute_no_followup(self):
for aml in self:
aml.no_followup = aml.move_id.is_entry() and not aml.move_id.origin_payment_id
def _inverse_no_followup(self):
# If one line of an invoice gets excluded from or included in the follow up report, we want all
# payable/receivable lines of that invoice to do the same.
for aml in self:
move = aml.move_id
if move.is_invoice():
move.no_followup = aml.no_followup

View File

@ -0,0 +1,50 @@
from odoo import api, models
from odoo.tools import SQL
class PartnerLedgerCustomHandler(models.AbstractModel):
_inherit = 'account.partner.ledger.report.handler'
@api.model
def action_toggle_no_followup(self, line_id, all_line_ids):
"""Toggle the `no_followup` field on the journal item corresponding to the given `line_id`.
Toggling this field may result in other journal items of the same report having their field toggled as well.
This function will return all impacted lines, so the report can be updated dynamically.
:param line_id: The report line ID.
:param all_line_ids: A list containing all the report's line IDs.
:return: A dict containing:
- `updated_value`: the updated `no_followup` value (`True` or `False`)
- `updated_line_ids`: a list of the impacted report lines
"""
model, aml_id = self.env['account.report']._get_model_info_from_id(line_id)
if model != 'account.move.line':
return None
aml = self.env['account.move.line'].browse(aml_id)
aml.no_followup = not aml.no_followup
aml_id_to_line_id = {}
for cur_line_id in all_line_ids:
model, record_id = self.env['account.report']._get_model_info_from_id(cur_line_id)
if model == 'account.move.line':
aml_id_to_line_id[record_id] = cur_line_id
res = {'updated_value': aml.no_followup, 'updated_line_ids': [aml_id_to_line_id[aml.id]]}
move = aml.move_id
if move.is_invoice():
# For invoices, the `no_followup` toggle will impact all its receivable/payable lines.
res['updated_line_ids'] = move.line_ids.filtered(
lambda line: line.account_type in ('asset_receivable', 'liability_payable'),
).mapped(lambda line: aml_id_to_line_id[line.id])
return res
def _get_report_line_move_line(self, options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift=0):
line = super()._get_report_line_move_line(options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift)
line['no_followup'] = aml_query_result['no_followup']
return line
def _get_aml_value_extra_select(self):
return super()._get_aml_value_extra_select() + [
SQL(', account_move_line.no_followup')
]

View File

@ -0,0 +1,36 @@
from collections import defaultdict
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
total_overdue_followup = fields.Monetary(
compute='_compute_total_due_followup',
groups='account.group_account_readonly,account.group_account_invoice'
)
def action_open_overdue_entries(self):
action = super().action_open_overdue_entries()
action.pop('view_mode', None)
action['views'] = [(self.env.ref('odex30_account_no_followup.view_followup_invoice_list').id, 'list'), (None, 'form')]
return action
@api.depends('invoice_ids.line_ids.no_followup')
def _compute_total_due_followup(self):
receivable_overdue_followup_data = defaultdict(float)
for account_type, overdue, partner, no_followup, amount_residual_sum in self.env['account.move.line']._read_group(
domain=self._get_unreconciled_aml_domain(),
groupby=['account_type', 'followup_overdue', 'partner_id', 'no_followup'],
aggregates=['amount_residual:sum'],
):
if account_type == 'asset_receivable' and overdue and not no_followup:
receivable_overdue_followup_data[partner] += amount_residual_sum
for partner in self:
partner.total_overdue_followup = receivable_overdue_followup_data.get(partner, 0.0)
def _get_followup_data_query_extra_join_conditions(self):
return super()._get_followup_data_query_extra_join_conditions() + 'AND line.no_followup IS NOT TRUE\n'

View File

@ -0,0 +1,13 @@
/** @odoo-module */
import { AccountReportController } from "@odex30_account_reports/components/account_report/controller";
import { patch } from "@web/core/utils/patch";
patch(AccountReportController.prototype, {
updateLines(lineIds, key, value) {
for (const lineId of lineIds) {
const lineIndex = this.lines.findIndex((line) => line.id === lineId);
this.lines.splice(lineIndex, 1, { ...this.lines[lineIndex], [key]: value });
}
}
})

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="odex30_account_no_followup.PartnerLedgerFollowupHeader" t-inherit="odex30_account_reports.AccountReportHeaderCustomizable">
<xpath expr="//tr[@data-id='column_headers_row']/th[1]" position="attributes">
<attribute name="colspan">2</attribute>
</xpath>
<xpath expr="//tr[@data-id='column_custom_subheaders_row']/th[1]" position="attributes">
<attribute name="colspan">2</attribute>
</xpath>
<xpath expr="//tr[@data-id='column_subheaders_row']/th[1]" position="after">
<th>No Follow-Up</th>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,14 @@
/** @odoo-module */
import { AccountReport } from "@odex30_account_reports/components/account_report/account_report";
import { AccountReportLine } from "@odex30_account_reports/components/account_report/line/line";
import { PartnerLedgerFollowupLineCell } from "@odex30_account_no_followup/components/partner_ledger_followup/line_cell/line_cell";
export class PartnerLedgerFollowupLine extends AccountReportLine {
static template = "odex30_account_no_followup.PartnerLedgerFollowupLine";
static components = {
...AccountReportLine.components,
PartnerLedgerFollowupLineCell,
};
}
AccountReport.registerCustomComponent(PartnerLedgerFollowupLine);

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="odex30_account_no_followup.PartnerLedgerFollowupLine" t-inherit="odex30_account_reports.AccountReportLineCustomizable">
<xpath expr="//t[@data-id='line_column']" position="before">
<PartnerLedgerFollowupLineCell
t-if="props.line.id.includes('account.move.line')"
t-props="{ line: props.line, cell: {} }"
/>
<td t-else="" data-id="line_cell" class="line_cell"/>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,16 @@
/** @odoo-module */
import { AccountReportLineCell } from "@odex30_account_reports/components/account_report/line_cell/line_cell";
export class PartnerLedgerFollowupLineCell extends AccountReportLineCell {
static template = "odex30_account_reports.PartnerLedgerFollowupLineCell";
async toggleNoFollowup(ev) {
const res = await this.orm.call(
"account.partner.ledger.report.handler",
"action_toggle_no_followup",
[this.props.line.id, this.controller.lines.map((line) => line.id)]
);
this.controller.updateLines(res.updated_line_ids, "no_followup", res.updated_value);
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="odex30_account_reports.PartnerLedgerFollowupLineCell" t-inherit="odex30_account_reports.AccountReportLineCellCustomizable">
<xpath expr="//div[@class='content']" position="replace">
<div class="content">
<div
class="o-checkbox form-check o_boolean_toggle form-switch"
t-on-click.stop.prevent="toggleNoFollowup"
>
<input type="checkbox" class="form-check-input" role="switch" t-att-checked="props.line.no_followup"/>
</div>
</div>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,3 @@
from . import test_account_followup
from . import test_account_move_out_invoice
from . import test_partner_ledger_report

View File

@ -0,0 +1,24 @@
from freezegun import freeze_time
from odoo.tests import tagged
from odoo.addons.odex30_account_followup.tests.test_account_followup import TestAccountFollowupReports
@tagged('post_install', '-at_install')
class TestNoFollowupAccountFollowupReports(TestAccountFollowupReports):
def test_followup_line_and_status(self):
followup_line = self.create_followup(delay=-10)
self.create_invoice('2022-01-02')
with freeze_time('2022-02-03'):
aml_ids = self.partner_a.unreconciled_aml_ids
# Exclude every unreconciled invoice line.
aml_ids.no_followup = True
# Every unreconciled invoice line is excluded, so the result should be `no_action_needed`.
self.assertPartnerFollowup(self.partner_a, 'no_action_needed', followup_line)
# It resets if we don't exclude them anymore.
aml_ids.no_followup = False
self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)

View File

@ -0,0 +1,65 @@
from odoo.addons.account.tests.test_account_move_out_invoice import TestAccountMoveOutInvoiceOnchanges
from odoo.tests import tagged
from odoo import fields, Command
@tagged('post_install', '-at_install')
class TestAccountMoveOutInvoiceOnchangesNoFollowup(TestAccountMoveOutInvoiceOnchanges):
def test_invoice_no_followup(self):
"""Make sure that excluding an invoice from follow-up excludes all its receivable lines."""
installments_payment_term = self.env['account.payment.term'].create({
'name': "3 installments",
'line_ids': [
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': fields.Date.from_string('2024-08-01'),
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
'invoice_payment_term_id': installments_payment_term.id,
})
invoice_terms = invoice.line_ids.filtered(lambda line: line.display_type == 'payment_term')
self.assertFalse(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
invoice.no_followup = True
self.assertTrue(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [True, True, True])
invoice.no_followup = False
self.assertFalse(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
def test_invoice_line_no_followup(self):
"""Make sure that excluding one receivable line from an invoice excludes all the others."""
installments_payment_term = self.env['account.payment.term'].create({
'name': "3 installments",
'line_ids': [
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'invoice_date': fields.Date.from_string('2024-08-01'),
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
'invoice_payment_term_id': installments_payment_term.id,
})
invoice_terms = invoice.line_ids.filtered(lambda line: line.display_type == 'payment_term')
self.assertFalse(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])
invoice_terms[0].no_followup = True
self.assertTrue(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [True, True, True])
invoice_terms[1].no_followup = False
self.assertFalse(invoice.no_followup)
self.assertEqual(invoice_terms.mapped('no_followup'), [False, False, False])

View File

@ -0,0 +1,45 @@
from odoo.addons.odex30_account_reports.tests.test_partner_ledger_report import TestPartnerLedgerReport
from odoo import Command, fields
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestNoFollowupPartnerLedgerReport(TestPartnerLedgerReport):
def test_partner_ledger_toggle_followup(self):
"""Make sure that toggling the followup also (and only) toggles other lines of the same invoice."""
installments_payment_term = self.env['account.payment.term'].create({
'name': "3 installments",
'line_ids': [
Command.create({'value_amount': 40, 'value': 'percent', 'nb_days': 0}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 30}),
Command.create({'value_amount': 30, 'value': 'percent', 'nb_days': 60}),
],
})
invoices = self.env['account.move'].create([
{
'move_type': 'out_invoice',
'invoice_date': fields.Date.from_string('2024-08-01'),
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 1000})],
'invoice_payment_term_id': installments_payment_term.id,
},
{
'move_type': 'out_invoice',
'invoice_date': fields.Date.from_string('2024-08-10'),
'partner_id': self.partner_a.id,
'invoice_line_ids': [Command.create({'quantity': 1, 'price_unit': 500})],
'invoice_payment_term_id': installments_payment_term.id,
},
])
invoices.action_post()
invoice_name = invoices[0].name
options = self._generate_options(self.report, '2024-01-01', '2024-12-31', default_options={'unfold_all': True})
lines = self.report._get_lines(options)
line_ids = [line['id'] for line in lines]
invoice_1_line_ids = [line['id'] for line in lines if invoice_name in line['name']]
self.assertEqual(
self.env['account.partner.ledger.report.handler'].action_toggle_no_followup(invoice_1_line_ids[0], line_ids)['updated_line_ids'],
invoice_1_line_ids,
)

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="view_followup_invoice_list" model="ir.ui.view">
<field name="name">account.followup.invoice.list</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_invoice_tree"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<field name="invoice_date_due" position="after">
<field name="no_followup" widget="boolean_toggle"/>
</field>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,28 @@
{
'name': 'ODEX Account: Secure Digital Settlement',
'summary': 'Advanced gateway for secure digital financial transactions.',
'category': 'Odex30-Accounting/Odex30-Accounting',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'description': """
This module enables advanced digital settlement capabilities for financial obligations.
It provides a secure interface for handling transactions through integrated
financial service providers.
Key Features:
- Secure digital payment initiation.
- Integration with major financial settlement frameworks.
- Automated reconciliation of digital receipts.
""",
'depends': ['odex30_account_online_synchronization', 'odex30_account_batch_payment',],
'data': [
'data/actions.xml',
'views/account_batch_payment_views.xml',
],
'assets': {
'web.assets_backend': [
'odex30_account_online_payment/static/src/components/**/*',
],
},
'auto_install': True,
}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_bank_sync_update_payment_status" model="ir.cron">
<field name="name">Account: Update payment status</field>
<field name="model_id" ref="model_account_batch_payment"/>
<field name="interval_number">6</field>
<field name="interval_type">hours</field>
<field name="code">model._cron_check_payment_status()</field>
<field name="state">code</field>
</record>
<record model="ir.actions.server" id="action_odex30_account_online_payment_check_status">
<field name="name">Check Status</field>
<field name="model_id" ref="model_account_batch_payment"/>
<field name="binding_model_id" ref="model_account_batch_payment"/>
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
if records:
action = records.check_online_payment_status()
</field>
</record>
</odoo>

View File

@ -0,0 +1,149 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_online_payment
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-27 18:48+0000\n"
"PO-Revision-Date: 2025-06-27 18:48+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"\n"
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
" "
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__accepted
msgid "Accepted"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__account_online_linked
msgid "Account Online Linked"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.actions.server,name:odex30_account_online_payment.ir_cron_bank_sync_update_payment_status_ir_actions_server
msgid "Account: Update payment status"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_online_link
msgid "Bank Connection"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_identifier
msgid "Batch ID"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_batch_payment
msgid "Batch Payment"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__canceled
msgid "Canceled"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.actions.server,name:odex30_account_online_payment.action_odex30_account_online_payment_check_status
msgid "Check Status"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_payment__end_to_end_id
msgid "End to End ID"
msgstr ""
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "Initiate Payment"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_online_status
msgid "PIS Status"
msgstr ""
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid "Payment already been signed"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_payment
msgid "Payments"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__pending
msgid "Pending"
msgstr ""
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"Please be aware that signed payments may have already been processed and "
"sent to the bank."
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__redirect_url
msgid "Redirect URL"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__rejected
msgid "Rejected"
msgstr ""
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "Sign Payment"
msgstr ""
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"This payment might have already been signed. Refreshing the payment "
"status..."
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__uninitiated
msgid "Uninitiated"
msgstr ""
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__unsigned
msgid "Unsigned"
msgstr ""
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "XML"
msgstr ""
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_payment.py:0
msgid "You cannot modify a payment that has already been sent to the bank."
msgstr ""

View File

@ -0,0 +1,160 @@
# Translation of ODEX Server.
# This file contains the translation of the following modules:
# * odex30_account_online_payment
#
# Translators:
# Malaz Abuidris <msea@odoo.com>, 2025
# Wil ODEX, 2025
#
msgid ""
msgstr ""
"Project-Id-Version: ODEX Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-27 18:48+0000\n"
"PO-Revision-Date: 2024-09-25 09:44+0000\n"
"Last-Translator: Wil ODEX, 2025\n"
"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"\n"
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
" "
msgstr ""
"\n"
" This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.\n"
" You will be notified once the KYC flow is completed and you can proceed with the online payment.\n"
" "
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__accepted
msgid "Accepted"
msgstr "تم القبول"
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__account_online_linked
msgid "Account Online Linked"
msgstr "تم ربط الحساب عبر الإنترنت "
#. module: odex30_account_online_payment
#: model:ir.actions.server,name:odex30_account_online_payment.ir_cron_bank_sync_update_payment_status_ir_actions_server
msgid "Account: Update payment status"
msgstr "الحساب: تحديث حالة الدفع "
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_online_link
msgid "Bank Connection"
msgstr "اتصال البنك"
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_identifier
msgid "Batch ID"
msgstr "معرّف الدفعة "
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_batch_payment
msgid "Batch Payment"
msgstr "دفعة مجمعة "
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__canceled
msgid "Canceled"
msgstr "تم الإلغاء "
#. module: odex30_account_online_payment
#: model:ir.actions.server,name:odex30_account_online_payment.action_odex30_account_online_payment_check_status
msgid "Check Status"
msgstr "تحقق من الحالة "
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_payment__end_to_end_id
msgid "End to End ID"
msgstr "معرف طرف إلى طرف "
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "Initiate Payment"
msgstr "بدء عملية الدفع "
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__payment_online_status
msgid "PIS Status"
msgstr "حالة PIS "
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid "Payment already been signed"
msgstr "لقد تم التوقيع على المدفوعات بالفعل "
#. module: odex30_account_online_payment
#: model:ir.model,name:odex30_account_online_payment.model_account_payment
msgid "Payments"
msgstr "المدفوعات"
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__pending
msgid "Pending"
msgstr "قيد الانتظار "
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"Please be aware that signed payments may have already been processed and "
"sent to the bank."
msgstr ""
"يُرجى العلم بأنه قد تكون المدفوعات الموقعة قد تمت معالجتها بالفعل وإرسالها "
"إلى البنك. "
#. module: odex30_account_online_payment
#: model:ir.model.fields,field_description:odex30_account_online_payment.field_account_batch_payment__redirect_url
msgid "Redirect URL"
msgstr "إعادة توجيه رابط URL "
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__rejected
msgid "Rejected"
msgstr "تم الرفض "
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "Sign Payment"
msgstr "التوقيع على الدفع "
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_batch_payment.py:0
msgid ""
"This payment might have already been signed. Refreshing the payment "
"status..."
msgstr "قد تكون هذه الدفعة قد تم توقيعها بالفعل. جاري تحديث حالة الدفع... "
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__uninitiated
msgid "Uninitiated"
msgstr "لم يتم البدء به "
#. module: odex30_account_online_payment
#: model:ir.model.fields.selection,name:odex30_account_online_payment.selection__account_batch_payment__payment_online_status__unsigned
msgid "Unsigned"
msgstr "لم يتم توقيعه "
#. module: odex30_account_online_payment
#: model_terms:ir.ui.view,arch_db:odex30_account_online_payment.view_batch_payment_form_inherit
msgid "XML"
msgstr "XML"
#. module: odex30_account_online_payment
#. odoo-python
#: code:addons/odex30_account_online_payment/models/account_payment.py:0
msgid "You cannot modify a payment that has already been sent to the bank."
msgstr "لا يمكنك تعديل دفعة تم إرسالها بالفعل إلى البنك. "

View File

@ -0,0 +1,3 @@
from . import account_batch_payment
from . import account_online_link
from . import account_payment

View File

@ -0,0 +1,185 @@
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.addons.account.tools.structured_reference import is_valid_structured_reference
STATUSES = [
('uninitiated', 'Uninitiated'),
('unsigned', 'Unsigned'),
('pending', 'Pending'),
('accepted', 'Accepted'),
('canceled', 'Canceled'),
('rejected', 'Rejected'),
]
class AccountBatchPayment(models.Model):
_inherit = 'account.batch.payment'
payment_identifier = fields.Char(string='Batch ID', readonly=True)
redirect_url = fields.Char(string='Redirect URL', readonly=True)
payment_online_status = fields.Selection(selection=STATUSES, string='PIS Status', default='uninitiated', readonly=True)
account_online_linked = fields.Boolean(compute='_compute_account_online_linked')
def initiate_payment(self):
"""
This function handles the two currently supported flows for validating batch payments:
- Signing the payment online through ODEXfin
- Using the regular batch validation and exporting an SCT XML file
"""
self.ensure_one()
if self.payment_online_status == 'unsigned' and self.state == 'sent':
self.check_online_payment_status()
if self.payment_online_status != 'unsigned':
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Payment already been signed'),
'message': _('This payment might have already been signed. Refreshing the payment status...'),
'type': 'warning',
'next': {
'type': 'ir.actions.client',
'tag': 'soft_reload',
},
},
}
return self._sign_payment()
return self.with_context(xml_export=False).validate_batch()
def validate_batch(self):
if not self.payment_method_code == 'sepa_ct' or not self.account_online_linked or self._context.get('xml_export'):
return super().validate_batch()
action = self._check_batch_validity()
if action and action.get('res_model') == 'account.batch.error.wizard':
return action
account_online_link = self.journal_id.account_online_link_id
data = self._prepare_payment_data()
while True:
response = account_online_link._fetch_odoo_fin('/proxy/v1/initiate_payment', data)
# In case of token expiration, we receive a special next_data field that we use to redo the request
if not response.get('next_data'):
break
data['next_data'] = response['next_data']
if response.get('kyc_flow'):
self.with_user(SUPERUSER_ID).message_post(body=_("""
This payment requires a KYC flow. As this process can take a few days, please use SEPA XML export in the meantime.
You will be notified once the KYC flow is completed and you can proceed with the online payment.
"""))
else:
self._send_after_validation()
self.write({
'payment_identifier': response.get('payment_identifier'),
'payment_online_status': response.get('payment_online_status'),
})
return {
'type': 'ir.actions.act_url',
'url': response.get('redirect_url'),
'target': '_blank',
}
def check_online_payment_status(self):
statuses = {}
for batch in self:
account_online_account = batch.journal_id.account_online_account_id
data = {
"payment_identifier": batch.payment_identifier,
"account_id": account_online_account.online_identifier,
"payment_type": "bulk",
"provider_data": account_online_account.account_online_link_id.provider_data,
}
while True:
response = batch.journal_id.account_online_link_id._fetch_odoo_fin('/proxy/v1/get_payment_status', data)
# In case of token expiration, we receive a special next_data field that we use to redo the request
if not response.get('next_data'):
break
data['next_data'] = response['next_data']
batch.payment_online_status = response.get('payment_online_status')
statuses[batch.id] = batch.payment_online_status
return statuses
def export_batch_payment(self):
to_be_exported = self.env['account.batch.payment']
for record in self:
if record.payment_method_code == 'sepa_ct' and record.account_online_linked and not self.env.context.get('xml_export'):
continue
to_be_exported += record
super(AccountBatchPayment, to_be_exported).export_batch_payment()
if any(payment.payment_online_status in {'pending', 'accepted'} for payment in to_be_exported):
self.with_user(SUPERUSER_ID).message_post(body=_("Please be aware that signed payments may have already been processed and sent to the bank."))
def _sign_payment(self):
self.ensure_one()
account_online_link = self.journal_id.account_online_link_id
data = {
**self._prepare_payment_data(),
"payment_identifier": self.payment_identifier,
}
while True:
response = account_online_link._fetch_odoo_fin('/proxy/v1/sign_payment', data)
if not response.get('next_data'):
break
data['next_data'] = response['next_data']
self.payment_online_status = response['payment_online_status']
self.payment_identifier = response['payment_identifier']
return {
'type': 'ir.actions.act_url',
'url': response['redirect_url'],
'target': '_blank',
}
def _cron_check_payment_status(self):
self.env['account.batch.payment'].search([
('state', '!=', 'reconciled'),
('payment_method_code', '=', 'sepa_ct'),
('journal_id.account_online_link_id.provider_type', '=ilike', '%activated'),
('payment_online_status', 'in', ('unsigned', 'pending')),
]).check_online_payment_status()
@api.depends('journal_id.account_online_link_id', 'journal_id.account_online_link_id.provider_type')
def _compute_account_online_linked(self):
for batch in self:
account_online_link = batch.journal_id.account_online_link_id
batch.account_online_linked = account_online_link.provider_type and 'payment' in account_online_link.provider_type
def _prepare_payment_data(self):
self.ensure_one()
payments = []
for payment in self.payment_ids:
payments.append({
"amount": payment.amount,
"account_number": payment.partner_bank_id.sanitized_acc_number,
"account_type": "IBAN",
"creditor_name": payment.partner_id.name,
"currency": payment.currency_id.display_name,
"date": fields.Date.to_string(payment.date),
"reference": payment.memo,
"structured_reference": is_valid_structured_reference(payment.memo),
"end_to_end_id": payment.end_to_end_id,
})
return {
"account_id": self.journal_id.account_online_account_id.online_identifier,
"batch_booking": self.iso20022_batch_booking,
"date": fields.Date.to_string(self.date),
"payment_type": "bulk",
"payments": payments,
"provider_data": self.journal_id.account_online_link_id.provider_data,
"reference": self.name,
}
def _get_payment_vals(self, payment):
return {**super()._get_payment_vals(payment), 'end_to_end_id': payment.end_to_end_id}

View File

@ -0,0 +1,32 @@
from odoo import models
class AccountOnlineLink(models.Model):
_inherit = 'account.online.link'
def _update_payments_activated(self, data):
self.ensure_one()
if not self.provider_type:
self.provider_type = ''
# Hacky way to know whether the synchronization has payment enabled/activated or not
if data.get('is_payment_enabled'):
if 'payment' not in self.provider_type:
self.provider_type = f'{self.provider_type}_payment'
else:
self.provider_type = self.provider_type.replace('_payment', '')
if data.get('is_payment_activated'):
if 'activated' not in self.provider_type:
self.provider_type = f'{self.provider_type}_activated'
else:
self.provider_type = self.provider_type.replace('_activated', '')
def _update_connection_status(self):
# EXTENDS odex30_account_online_synchronization
data = super()._update_connection_status()
self._update_payments_activated(data)
return data

View File

@ -0,0 +1,20 @@
from time import time
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountPayment(models.Model):
_inherit = 'account.payment'
end_to_end_id = fields.Char(string='End to End ID', readonly=True, compute='_compute_end_to_end_id', store=True)
def action_draft(self):
if any(payment.batch_payment_id and payment.payment_method_code == 'sepa_ct' and payment.batch_payment_id.payment_online_status in {'pending', 'accepted'} for payment in self):
raise UserError(_('You cannot modify a payment that has already been sent to the bank.'))
return super().action_draft()
@api.depends('journal_id')
def _compute_end_to_end_id(self):
for payment in self:
payment.end_to_end_id = f"{time()}{payment.journal_id.id}{payment.id}".strip()[-30:]

View File

@ -0,0 +1,35 @@
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class RefreshButton extends Component {
static template = "odex30_account_online_payment.RefreshButton";
static props = ["name", "id", "record", "readonly"];
setup() {
this.state = useState({
status: this.props.record.data.payment_online_status,
isFetching: false,
});
this.orm = useService("orm");
}
async onClickFetchStatus() {
this.state.isFetching = true;
const response = await this.orm.call(
"account.batch.payment",
"check_online_payment_status",
[this.props.record.data.id],
);
this.state.status = response[this.props.record.data.id];
this.state.isFetching = false;
}
}
export const refreshButtonComp = {
component: RefreshButton,
};
registry.category("fields").add("odex30_account_online_payment_refresh_button", refreshButtonComp);

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<div t-name="odex30_account_online_payment.RefreshButton" class="w-100 d-sm-contents">
<div class="o_field_widget o_readonly_modifier o_field_char d-flex gap-2">
<span class="w-25" t-esc="state.status"/>
<t t-if="state.isFetching">
<div>
<i class="fa fa-refresh fa-spin"/>
</div>
</t>
<t t-else="">
<div name="check_online_payment_status" t-on-click="onClickFetchStatus">
<i class="fa fa-refresh"/>
</div>
</t>
</div>
</div>
</templates>

View File

@ -0,0 +1 @@
from . import test_account_online_link

View File

@ -0,0 +1,32 @@
from unittest.mock import patch
from odoo.tests import tagged
from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
@tagged('post_install', '-at_install')
class TestAccountOnlineLinkPayment(AccountOnlineSynchronizationCommon):
@patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status")
def test_update_status_when_payment_enabled(self, patched_update_connection_status):
self.account_online_link.provider_type = 'provider_A'
patched_update_connection_status.return_value = {
'consent_expiring_date': None,
'is_payment_enabled': True,
'is_payment_activated': False,
}
self.account_online_link._update_connection_status()
self.assertEqual(self.account_online_link.provider_type, 'provider_A_payment')
@patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status")
def test_update_status_when_payment_deactivated(self, patched_update_connection_status):
self.account_online_link.provider_type = 'provider_A_payment_activated'
patched_update_connection_status.return_value = {
'consent_expiring_date': None,
'is_payment_enabled': True,
'is_payment_activated': False,
}
self.account_online_link._update_connection_status()
self.assertEqual(self.account_online_link.provider_type, 'provider_A_payment')

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_batch_payment_form_inherit" model="ir.ui.view">
<field name="name">sct.account.batch.payment.form.inherit</field>
<field name="model">account.batch.payment</field>
<field name="inherit_id" ref="account_batch_payment.view_batch_payment_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@id='validate_button']" position="attributes">
<attribute name="invisible">(account_online_linked and batch_type == 'outbound' and payment_method_code == 'sepa_ct') or state != 'draft' or not payment_ids</attribute>
</xpath>
<xpath expr="//button[@id='regenerate_file_button']" position="attributes">
<attribute name="invisible" add="payment_online_status != 'uninitiated'" separator=" or "/>
</xpath>
<xpath expr="//button[@id='validate_button']" position="after">
<button
name="initiate_payment"
class="oe_highlight"
type="object"
string="Initiate Payment"
invisible="not account_online_linked or state != 'draft' or payment_online_status not in ['uninitiated'] or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
/>
<button
name="validate_batch_button"
type="object"
string="XML"
context="{'xml_export': True}"
invisible="not account_online_linked or state != 'draft' or payment_online_status not in ['uninitiated'] or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
/>
<button
name="export_batch_payment"
type="object"
string="XML"
context="{'xml_export': True}"
invisible="not account_online_linked or state == 'draft' or payment_method_code != 'sepa_ct'"
/>
<button
name="initiate_payment"
type="object"
string="Sign Payment"
invisible="not account_online_linked or state == 'draft' or payment_online_status != 'unsigned' or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
/>
<button
name="initiate_payment"
type="object"
string="Initiate Payment"
invisible="not account_online_linked or state == 'draft' or payment_online_status != 'uninitiated' or batch_type != 'outbound' or payment_method_code != 'sepa_ct'"
/>
</xpath>
<xpath expr="//field[@name='iso20022_batch_booking']" position="after">
<field name="payment_online_status" widget="odex30_account_online_payment_refresh_button" invisible="state == 'draft' or not account_online_linked or payment_online_status == 'uninitiated'"/>
</xpath>
</field>
</record>
<record id="view_batch_payment_tree_inherit" model="ir.ui.view">
<field name="name">sct.account.batch.payment.tree.inherit</field>
<field name="model">account.batch.payment</field>
<field name="inherit_id" ref="account_batch_payment.view_batch_payment_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="before">
<field
name="payment_online_status"
widget="badge"
decoration-success="payment_online_status == 'accepted'"
decoration-info="payment_online_status in ['pending', 'unsigned']"
decoration-danger="payment_online_status in ['rejected', 'canceled']"
/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from . import wizard

View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
{
'name': "ODEX Account: Financial Data Stream Synchronization",
'summary': "Advanced real-time synchronization of external financial data streams.",
'category': 'Odex30-Accounting/Odex30-Accounting',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'description': """
This system enables automated streams of financial data from external institutions
directly into your accounting workspace. It provides tools for configuring periodic
data pulls and maintaining up-to-date ledger balances.
Key Features:
- Seamless linkage with external financial data sources.
- Automated periodic synchronization cycles.
- Real-time visibility into account balances and transaction history.
""",
'version': '1.0',
'depends': ['odex30_account_accountant'],
'data': [
'data/config_parameter.xml',
'data/ir_cron.xml',
'data/mail_activity_type_data.xml',
'data/sync_reminder_email_template.xml',
'security/ir.model.access.csv',
'security/account_online_sync_security.xml',
'views/account_online_sync_views.xml',
'views/account_bank_statement_view.xml',
'views/account_journal_view.xml',
'views/account_online_sync_portal_templates.xml',
'views/account_journal_dashboard_view.xml',
'wizard/account_bank_selection_wizard.xml',
'wizard/account_journal_missing_transactions.xml',
'wizard/account_journal_duplicate_transactions.xml',
'wizard/account_bank_statement_line.xml',
],
'auto_install': True,
'assets': {
'web.assets_backend': [
'odex30_account_online_synchronization/static/src/components/**/*',
'odex30_account_online_synchronization/static/src/js/odex_fin_connector.js',
],
'web.assets_frontend': [
'odex30_account_online_synchronization/static/src/js/online_sync_portal.js',
],
'web.qunit_suite_tests': [
'odex30_account_online_synchronization/static/tests/helpers/*.js',
'odex30_account_online_synchronization/static/tests/*.js',
],
}
}

View File

@ -0,0 +1 @@
from . import portal

View File

@ -0,0 +1,58 @@
import json
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.tools import format_amount, format_date
from odoo.exceptions import AccessError, MissingError, UserError
class OnlineSynchronizationPortal(CustomerPortal):
@http.route(['/renew_consent/<int:journal_id>'], type='http', auth="public", website=True, sitemap=False)
def portal_online_sync_renew_consent(self, journal_id, access_token=None, **kw):
# Display a page to the user allowing to renew the consent for his bank sync.
# Requires the same rights as the button in odoo.
try:
journal_sudo = self._document_check_access('account.journal', journal_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
values = self._prepare_portal_layout_values()
# Ignore the route if the journal isn't one using bank sync.
if not journal_sudo.account_online_account_id:
raise request.not_found()
balance = journal_sudo.account_online_account_id.balance
if journal_sudo.account_online_account_id.currency_id:
formatted_balance = format_amount(request.env, balance, journal_sudo.account_online_account_id.currency_id)
else:
formatted_balance = format_amount(request.env, balance, journal_sudo.currency_id or journal_sudo.company_id.currency_id)
values.update({
'bank': journal_sudo.bank_account_id.bank_name or journal_sudo.account_online_account_id.name,
'bank_account': journal_sudo.bank_account_id.acc_number,
'journal': journal_sudo.name,
'latest_balance_formatted': formatted_balance,
'latest_balance': balance,
'latest_sync': format_date(request.env, journal_sudo.account_online_account_id.last_sync, date_format="MMM dd, YYYY"),
'iframe_params': json.dumps(journal_sudo.action_extend_consent()),
})
return request.render("odex30_account_online_synchronization.portal_renew_consent", values)
@http.route(['/renew_consent/<int:journal_id>/complete'], type='http', auth="public", methods=['POST'], website=True)
def portal_online_sync_action_complete(self, journal_id, access_token=None, **kw):
# Complete the consent renewal process
try:
journal_sudo = self._document_check_access('account.journal', journal_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
# Ignore the route if the journal isn't one using bank sync.
if not journal_sudo.account_online_link_id:
raise request.not_found()
try:
journal_sudo.account_online_link_id._update_connection_status()
journal_sudo.manual_sync()
except UserError:
pass
return request.make_response(json.dumps({'status': 'done'}))

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record forcecreate="True" id="config_online_sync_proxy_mode" model="ir.config_parameter">
<field name="key">odex30_account_online_synchronization.proxy_mode</field>
<field name="value">production</field>
</record>
<record forcecreate="True" id="config_online_sync_request_timeout" model="ir.config_parameter">
<field name="key">odex30_account_online_synchronization.request_timeout</field>
<field name="value">60</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Cron to synchronize transaction -->
<record id="online_sync_cron" model="ir.cron">
<field name="name">Account: Journal online sync</field>
<field name="model_id" ref="account.model_account_journal"/>
<field name="state">code</field>
<field name="code">model._cron_fetch_online_transactions()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">12</field>
<field name="interval_type">hours</field>
</record>
<record id="online_sync_cron_waiting_synchronization" model="ir.cron">
<field name="name">Account: Journal online Waiting Synchronization</field>
<field name="model_id" ref="account.model_account_journal"/>
<field name="state">code</field>
<field name="code">model._cron_fetch_waiting_online_transactions()</field>
<field name="active" eval="False"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
</record>
<!-- Cron to handle sending of reminder email -->
<record id="online_sync_mail_cron" model="ir.cron">
<field name="name">Account: Journal online sync reminder</field>
<field name="model_id" ref="account.model_account_journal"/>
<field name="state">code</field>
<field name="code">model._cron_send_reminder_email()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
</record>
<!-- Cron to delete unused connections -->
<record id="online_sync_unused_connection_cron" model="ir.cron">
<field name="name">Account: Journal online sync cleanup unused connections</field>
<field name="model_id" ref="odex30_account_online_synchronization.model_account_online_link"/>
<field name="state">code</field>
<field name="code">model._cron_delete_unused_connection()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="bank_sync_activity_update_consent" model="mail.activity.type">
<field name="name">Bank Synchronization: Update consent</field>
<field name="icon">fa-university</field>
<field name="decoration_type">warning</field>
<field name="res_model">account.journal</field>
<field name="delay_count">0</field>
</record>
<record id="bank_sync_consent_renewal" model="mail.message.subtype">
<field name="name">Consent Renewal</field>
<field name="default" eval="False"/>
<field name="hidden" eval="True"/>
<field name="res_model">account.journal</field>
<field name="sequence" eval="900"/>
<field name="track_recipients" eval="True"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,4 @@
-- disable bank synchronisation links
UPDATE account_online_link
SET provider_data = '',
client_id = 'duplicate';

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="email_template_sync_reminder" model="mail.template">
<field name="name">Bank connection expiration reminder</field>
<field name="subject">Your bank connection is expiring soon</field>
<field name="email_from">{{ object.company_id.email_formatted or user.email_formatted }}</field>
<field name="email_to">{{ object.renewal_contact_email }}</field>
<field name="model_id" ref="odex30_account_online_synchronization.model_account_journal"/>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
<tbody>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr>
<td valign="top" style="font-size: 13px;">
<div>
Hello,<br /><br />
The connection between <b><a t-att-href='object.get_base_url()' t-out="object.get_base_url() or ''">https://yourcompany.odoo.com</a></b> and <t t-out="object.account_online_link_id.name or ''">Belfius</t> <t t-if="not object.expiring_synchronization_due_day">expired.</t><t t-else="">expires in <t t-out="object.expiring_synchronization_due_day or ''">10</t> days.</t><br/>
<div style="margin: 16px 0px 16px 0px;">
<a t-attf-href="{{ website_url }}/renew_consent/{{ object.id }}?access_token={{object.access_token}}"
style="background-color: #4caf50; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
Renew Consent
</a>
</div>
Security Tip: Check that the domain name you are redirected to is: <b><a t-att-href='object.get_base_url()' t-out="object.get_base_url() or ''">https://yourcompany.odoo.com</a></b>
</div>
</td>
</tr>
<tr>
<td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- POWERED BY -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr>
<td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=auth" style="color: #875A7B;">ODEX</a>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; color: #454748; padding: 8px; border-collapse:separate;">
<tr>
<td style="text-align: center; font-size: 11px;">
PS: This is an automated email sent by ODEX Accounting to remind you before a bank sync consent expiration.
</td>
</tr>
</table>
</td>
</tr>
</table>
</field>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from . import account_bank_statement
from . import account_journal
from . import account_online
from . import company
from . import mail_activity_type
from . import partner
from . import bank_rec_widget

View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
import threading
import time
import json
from odoo import api, fields, models, SUPERUSER_ID, tools, _
from odoo.tools import date_utils
from odoo.exceptions import UserError, ValidationError
STATEMENT_LINE_CREATION_BATCH_SIZE = 500 # When importing transactions, batch the process to commit after importing batch_size
class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line'
online_transaction_identifier = fields.Char("Online Transaction Identifier", readonly=True)
online_partner_information = fields.Char(readonly=True)
online_account_id = fields.Many2one(comodel_name='account.online.account', readonly=True)
online_link_id = fields.Many2one(
comodel_name='account.online.link',
related='online_account_id.account_online_link_id',
store=True,
readonly=True,
)
@api.model_create_multi
def create(self, vals_list):
"""
Some transactions can be marked as "Zero Balancing",
which is a transaction used at the end of the day to summarize all the transactions
of the day. As we already manage the details of all the transactions, this one is not
useful and moreover create duplicates. To deal with that, we cancel the move and so
the bank statement line.
"""
# EXTEND account
bank_statement_lines = super().create(vals_list)
moves_to_cancel = self.env['account.move']
for bank_statement_line in bank_statement_lines:
transaction_details = json.loads(bank_statement_line.transaction_details) if bank_statement_line.transaction_details else {}
if not transaction_details.get('is_zero_balancing'):
continue
moves_to_cancel |= bank_statement_line.move_id
moves_to_cancel.button_cancel()
return bank_statement_lines
@api.model
def _online_sync_bank_statement(self, transactions, online_account):
"""
build bank statement lines from a list of transaction and post messages is also post in the online_account of the journal.
:param transactions: A list of transactions that will be created.
The format is : [{
'id': online id, (unique ID for the transaction)
'date': transaction date, (The date of the transaction)
'name': transaction description, (The description)
'amount': transaction amount, (The amount of the transaction. Negative for debit, positive for credit)
}, ...]
:param online_account: The online account for this statement
Return: The number of imported transaction for the journal
"""
start_time = time.time()
lines_to_reconcile = self.env['account.bank.statement.line']
try:
for journal in online_account.journal_ids:
# Since the synchronization succeeded, set it as the bank_statements_source of the journal
journal.sudo().write({'bank_statements_source': 'online_sync'})
if not transactions:
continue
sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date'])
total = self.env.context.get('transactions_total') or sum([transaction['amount'] for transaction in transactions])
# For first synchronization, an opening line is created to fill the missing bank statement data
any_st_line = self.search_count([('journal_id', '=', journal.id)], limit=1)
journal_currency = journal.currency_id or journal.company_id.currency_id
# If there are neither statement and the ending balance != 0, we create an opening bank statement at the day of the oldest transaction.
# We set the sequence to >1 to ensure the computed internal_index will force its display before any other statement with the same date.
if not any_st_line and not journal_currency.is_zero(online_account.balance - total):
opening_st_line = self.with_context(skip_statement_line_cron_trigger=True).create({
'date': sorted_transactions[0]['date'],
'journal_id': journal.id,
'payment_ref': _("Opening statement: first synchronization"),
'amount': online_account.balance - total,
'sequence': 2,
})
lines_to_reconcile += opening_st_line
filtered_transactions = online_account._get_filtered_transactions(sorted_transactions)
do_commit = not (hasattr(threading.current_thread(), 'testing') and threading.current_thread().testing)
if filtered_transactions:
# split transactions import in batch and commit after each batch except in testing mode
for index in range(0, len(filtered_transactions), STATEMENT_LINE_CREATION_BATCH_SIZE):
lines_to_reconcile += self.with_user(SUPERUSER_ID).with_company(journal.company_id).with_context(skip_statement_line_cron_trigger=True).create(filtered_transactions[index:index + STATEMENT_LINE_CREATION_BATCH_SIZE])
if do_commit:
self.env.cr.commit()
# Set last sync date as the last transaction date
journal.account_online_account_id.sudo().write({'last_sync': filtered_transactions[-1]['date']})
if lines_to_reconcile:
# 'limit_time_real_cron' defaults to -1.
# Manual fallback applied for non-POSIX systems where this key is disabled (set to None).
cron_limit_time = tools.config['limit_time_real_cron'] or -1
limit_time = (cron_limit_time if cron_limit_time > 0 else 180) - (time.time() - start_time)
if limit_time > 0:
lines_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
# Catch any configuration error that would prevent creating the entries, reset fetching_status flag and re-raise the error
# Otherwise flag is never reset and user is under the impression that we are still fetching transactions
except (UserError, ValidationError) as e:
self.env.cr.rollback()
online_account.account_online_link_id._log_information('error', subject=_("Error"), message=str(e))
self.env.cr.commit()
raise
return lines_to_reconcile

View File

@ -0,0 +1,380 @@
# -*- coding: utf-8 -*-
import logging
import requests
from dateutil.relativedelta import relativedelta
from requests.exceptions import RequestException, Timeout
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError, RedirectWarning
from odoo.tools import SQL
_logger = logging.getLogger(__name__)
class AccountJournal(models.Model):
_inherit = "account.journal"
def __get_bank_statements_available_sources(self):
rslt = super(AccountJournal, self).__get_bank_statements_available_sources()
rslt.append(("online_sync", _("Online Synchronization")))
return rslt
next_link_synchronization = fields.Datetime("Online Link Next synchronization", related='account_online_link_id.next_refresh')
expiring_synchronization_date = fields.Date(related='account_online_link_id.expiring_synchronization_date')
expiring_synchronization_due_day = fields.Integer(compute='_compute_expiring_synchronization_due_day')
account_online_account_id = fields.Many2one('account.online.account', copy=False, ondelete='set null')
account_online_link_id = fields.Many2one('account.online.link', related='account_online_account_id.account_online_link_id', readonly=True, store=True)
account_online_link_state = fields.Selection(related="account_online_link_id.state", readonly=True)
renewal_contact_email = fields.Char(
string='Connection Requests',
help='Comma separated list of email addresses to send consent renewal notifications 15, 3 and 1 days before expiry',
default=lambda self: self.env.user.email,
)
online_sync_fetching_status = fields.Selection(related="account_online_account_id.fetching_status", readonly=True)
def write(self, vals):
# When changing the bank_statement_source, unlink the connection if there is any
if 'bank_statements_source' in vals and vals.get('bank_statements_source') != 'online_sync':
for journal in self:
if journal.bank_statements_source == 'online_sync':
# unlink current connection
vals['account_online_account_id'] = False
journal.account_online_link_id.has_unlinked_accounts = True
return super().write(vals)
@api.depends('expiring_synchronization_date')
def _compute_expiring_synchronization_due_day(self):
for record in self:
if record.expiring_synchronization_date:
due_day_delta = record.expiring_synchronization_date - fields.Date.context_today(record)
record.expiring_synchronization_due_day = due_day_delta.days
else:
record.expiring_synchronization_due_day = 0
def _fill_bank_cash_dashboard_data(self, dashboard_data):
super()._fill_bank_cash_dashboard_data(dashboard_data)
# Caching data to avoid one call per journal
self.browse(list(dashboard_data.keys())).fetch(['type', 'account_online_account_id'])
for journal_id, journal_data in dashboard_data.items():
journal = self.browse(journal_id)
journal_data['display_connect_bank_in_dashboard'] = journal.type in ('bank', 'credit') \
and not journal.account_online_account_id \
and journal.company_id.id == self.env.company.id
@api.constrains('account_online_account_id')
def _check_account_online_account_id(self):
for journal in self:
if len(journal.account_online_account_id.journal_ids) > 1:
raise ValidationError(_('You cannot have two journals associated with the same Online Account.'))
def _fetch_online_transactions(self):
for journal in self:
try:
journal.account_online_link_id._pop_connection_state_details(journal=journal)
journal.manual_sync()
# for cron jobs it is usually recommended committing after each iteration,
# so that a later error or job timeout doesn't discard previous work
self.env.cr.commit()
except (UserError, RedirectWarning):
# We need to rollback here otherwise the next iteration will still have the error when trying to commit
self.env.cr.rollback()
def fetch_online_sync_favorite_institutions(self):
self.ensure_one()
timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_synchronization.request_timeout')) or 60
endpoint_url = self.env['account.online.link']._get_odoofin_url('/proxy/v1/get_dashboard_institutions')
params = {'country': self.sudo().company_id.account_fiscal_country_id.code, 'limit': 28}
try:
resp = requests.post(endpoint_url, json=params, timeout=timeout)
resp_dict = resp.json()['result']
for institution in resp_dict:
if institution['picture'].startswith('/'):
institution['picture'] = self.env['account.online.link']._get_odoofin_url(institution['picture'])
return resp_dict
except (Timeout, ConnectionError, RequestException, ValueError) as e:
_logger.warning(e)
return []
@api.model
def _cron_fetch_waiting_online_transactions(self):
""" This method is only called when the user fetch transactions asynchronously.
We only fetch transactions on synchronizations that are in "waiting" status.
Once the synchronization is done, the status is changed for "done".
We have to that to avoid having too much logic in the same cron function to do
2 different things. This cron should only be used for asynchronous fetchs.
"""
# 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120.
# Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None).
limit_time = tools.config['limit_time_real_cron'] or -1
if limit_time <= 0:
limit_time = tools.config['limit_time_real'] or 120
journals = self.search([
'|',
('online_sync_fetching_status', 'in', ('planned', 'waiting')),
'&',
('online_sync_fetching_status', '=', 'processing'),
('account_online_link_id.last_refresh', '<', fields.Datetime.now() - relativedelta(seconds=limit_time)),
])
journals.with_context(cron=True)._fetch_online_transactions()
@api.model
def _cron_fetch_online_transactions(self):
""" This method is called by the cron (by default twice a day) to fetch (for all journals)
the new transactions.
"""
journals = self.search([('account_online_account_id', '!=', False)])
journals.with_context(cron=True)._fetch_online_transactions()
@api.model
def _cron_send_reminder_email(self):
for journal in self.search([('account_online_account_id', '!=', False)]):
if journal.expiring_synchronization_due_day in {1, 3, 15}:
journal.action_send_reminder()
def manual_sync(self):
self.ensure_one()
if self.account_online_link_id:
account = self.account_online_account_id
return self.account_online_link_id._fetch_transactions(accounts=account)
def unlink(self):
"""
Override of the unlink method.
That's useful to unlink account.online.account too.
"""
if self.account_online_account_id:
self.account_online_account_id.unlink()
return super(AccountJournal, self).unlink()
def action_configure_bank_journal(self):
"""
Override the "action_configure_bank_journal" and change the flow for the
"Configure" button in dashboard.
"""
self.ensure_one()
return self.env['account.online.link'].action_new_synchronization()
def action_open_account_online_link(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': self.account_online_link_id.name,
'res_model': 'account.online.link',
'target': 'main',
'view_mode': 'form',
'views': [[False, 'form']],
'res_id': self.account_online_link_id.id,
}
def action_extend_consent(self):
"""
Extend the consent of the user by redirecting him to update his credentials
"""
self.ensure_one()
return self.account_online_link_id._open_iframe(
mode='updateCredentials',
include_param={
'account_online_identifier': self.account_online_account_id.online_identifier,
},
)
def action_reconnect_online_account(self):
self.ensure_one()
return self.account_online_link_id.action_reconnect_account()
def action_send_reminder(self):
self.ensure_one()
self._portal_ensure_token()
template = self.env.ref('odex30_account_online_synchronization.email_template_sync_reminder')
subtype = self.env.ref('odex30_account_online_synchronization.bank_sync_consent_renewal')
self.message_post_with_source(source_ref=template, subtype_id=subtype.id)
def action_open_missing_transaction_wizard(self):
""" This method allows to open the wizard to fetch the missing
transactions and the pending ones.
Depending on where the function is called, we'll receive
one journal or none of them.
If we receive more or less than one journal, we do not set
it on the wizard, the user should select it by himself.
:return: An action opening the wizard.
"""
journal_id = None
if len(self) == 1:
if not self.account_online_account_id or self.account_online_link_state != 'connected':
raise UserError(_("You can't find missing transactions for a journal that isn't connected."))
journal_id = self.id
wizard = self.env['account.missing.transaction.wizard'].create({'journal_id': journal_id})
return {
'name': _("Find Missing Transactions"),
'type': 'ir.actions.act_window',
'res_model': 'account.missing.transaction.wizard',
'res_id': wizard.id,
'views': [(False, 'form')],
'target': 'new',
}
def action_open_duplicate_transaction_wizard(self, from_date=None):
""" This method allows to open the wizard to find duplicate transactions.
:param from_date: date from with we must check for duplicates.
:return: An action opening the wizard.
"""
wizard = self.env['account.duplicate.transaction.wizard'].create({
'journal_id': self.id if len(self) == 1 else None,
**({'date': from_date} if from_date else {}),
})
return wizard._get_records_action(name=_("Find Duplicate Transactions"))
def _has_duplicate_transactions(self, date_from):
""" Has any transaction with
- same amount &
- same date &
- same account number
We do not check on online_transaction_identifier because this is called after the fetch
where transitions would already have been filtered on existing online_transaction_identifier.
:param from_date: date from with we must check for duplicates.
"""
self.env.cr.execute(SQL.join(SQL(''), [
self._get_duplicate_amount_date_account_transactions_query(date_from),
SQL('LIMIT 1'),
]))
return bool(self.env.cr.rowcount)
def _get_duplicate_transactions(self, date_from):
"""Find all transaction with
- same amount &
- same date &
- same account number
or
- same transaction id
:param from_date: date from with we must check for duplicates.
"""
query = SQL.join(SQL(''), [
self._get_duplicate_amount_date_account_transactions_query(date_from),
SQL('UNION'),
self._get_duplicate_online_transaction_identifier_transactions_query(date_from),
SQL('ORDER BY ids'),
])
return [res[0] for res in self.env.execute_query(query)]
def _get_duplicate_amount_date_account_transactions_query(self, date_from):
self.ensure_one()
return SQL('''
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
FROM account_bank_statement_line st_line
JOIN account_move move ON move.id = st_line.move_id
WHERE st_line.journal_id = %(journal_id)s AND move.date >= %(date_from)s
GROUP BY st_line.currency_id, st_line.amount, st_line.account_number, move.date
HAVING count(st_line.id) > 1
''',
journal_id=self.id,
date_from=date_from,
)
def _get_duplicate_online_transaction_identifier_transactions_query(self, date_from):
return SQL('''
SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
FROM account_bank_statement_line st_line
JOIN account_move move ON move.id = st_line.move_id
WHERE st_line.journal_id = %(journal_id)s AND
move.date >= %(prior_date)s AND
st_line.online_transaction_identifier IS NOT NULL
GROUP BY st_line.online_transaction_identifier
HAVING count(st_line.id) > 1 AND BOOL_OR(move.date >= %(date_from)s) -- at least one date is > date_from
''',
journal_id=self.id,
date_from=date_from,
prior_date=date_from - relativedelta(months=3), # allow 1 of duplicate statements to be older than "from" date
)
def action_open_dashboard_asynchronous_action(self):
""" This method allows to open action asynchronously
during the fetching process.
When a user clicks on the Fetch Transactions button in
the dashboard, we fetch the transactions asynchronously
and save connection state details on the synchronization.
This action allows the user to open the action saved in
the connection state details.
"""
self.ensure_one()
if not self.account_online_account_id:
raise UserError(_("You can only execute this action for bank-synchronized journals."))
connection_state_details = self.account_online_link_id._pop_connection_state_details(journal=self)
if connection_state_details and connection_state_details.get('action'):
if connection_state_details.get('error_type') == 'redirect_warning':
self.env.cr.commit()
raise RedirectWarning(connection_state_details['error_message'], connection_state_details['action'], _('Report Issue'))
else:
return connection_state_details['action']
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
def _get_journal_dashboard_data_batched(self):
dashboard_data = super()._get_journal_dashboard_data_batched()
for journal in self.filtered(lambda j: j.type in ('bank', 'credit')):
if journal.account_online_account_id:
if journal.company_id.id not in self.env.companies.ids:
continue
connection_state_details = journal.account_online_link_id._get_connection_state_details(journal=journal)
if not connection_state_details and journal.account_online_account_id.fetching_status in ('waiting', 'processing'):
connection_state_details = {'status': 'fetching'}
dashboard_data[journal.id]['connection_state_details'] = connection_state_details
dashboard_data[journal.id]['show_sync_actions'] = journal.account_online_link_id.show_sync_actions
return dashboard_data
def get_related_connection_state_details(self):
""" This method allows JS widget to get the last connection state details
It's useful if the user wasn't on the dashboard when we send the message
by websocket that the asynchronous flow is finished.
In case we don't have a connection state details and if the fetching
status is set on "waiting" or "processing". We're returning that the sync
is currently fetching.
"""
self.ensure_one()
connection_state_details = self.account_online_link_id._get_connection_state_details(journal=self)
if not connection_state_details and self.account_online_account_id.fetching_status in ('waiting', 'processing'):
connection_state_details = {'status': 'fetching'}
return connection_state_details
def _consume_connection_state_details(self):
self.ensure_one()
if self.account_online_link_id and self.env.user.has_group('account.group_account_manager'):
# In case we have a bank synchronization connected to the journal
# we want to remove the last connection state because it means that we
# have "mark as read" this state, and we don't want to display it again to
# the user.
self.account_online_link_id._pop_connection_state_details(journal=self)
def open_action(self):
# Extends 'odex30_account_accountant'
if not self._context.get('action_name') and self.type == 'bank' and self.bank_statements_source == 'online_sync':
self._consume_connection_state_details()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
default_context={'search_default_journal_id': self.id},
)
return super().open_action()
def action_open_reconcile(self):
# Extends 'odex30_account_accountant'
self._consume_connection_state_details()
return super().action_open_reconcile()
def action_open_bank_transactions(self):
# Extends 'odex30_account_accountant'
self._consume_connection_state_details()
return super().action_open_bank_transactions()
@api.model
def _toggle_asynchronous_fetching_cron(self):
cron = self.env.ref('odex30_account_online_synchronization.online_sync_cron_waiting_synchronization', raise_if_not_found=False)
if cron:
cron.sudo().toggle(model=self._name, domain=[('account_online_account_id', '!=', False)])

View File

@ -0,0 +1,15 @@
from odoo import models
class BankRecWidget(models.Model):
_inherit = 'bank.rec.widget'
def _action_validate(self):
# EXTENDS odex30_account_accountant
super()._action_validate()
line = self.st_line_id
if line.partner_id and line.online_partner_information:
value_merchant = line.partner_id.online_partner_information or line.online_partner_information
value_merchant = value_merchant if value_merchant == line.online_partner_information else False
line.partner_id.online_partner_information = value_merchant

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
from odoo import api, models
class ResCompany(models.Model):
_inherit = "res.company"
@api.model
def setting_init_bank_account_action(self):
return self.env['account.online.link'].action_new_synchronization()

View File

@ -0,0 +1,14 @@
from odoo import api, models
class MailActivityType(models.Model):
_inherit = "mail.activity.type"
@api.model
def _get_model_info_by_xmlid(self):
info = super()._get_model_info_by_xmlid()
info['odex30_account_online_synchronization.bank_sync_activity_update_consent'] = {
'res_model': 'account.journal',
'unlink': False,
}
return info

View File

@ -0,0 +1,46 @@
import base64
import hashlib
import hmac
import json
import requests
import time
import werkzeug.urls
class ODEXFinAuth(requests.auth.AuthBase):
def __init__(self, record=None):
self.access_token = record and record.access_token or False
self.refresh_token = record and record.refresh_token or False
self.client_id = record and record.client_id or False
def __call__(self, request):
# We don't sign request that still don't have a client_id/refresh_token
if not self.client_id or not self.refresh_token:
return request
# craft the message (timestamp|url path|client_id|access_token|query params|body content)
msg_timestamp = int(time.time())
parsed_url = werkzeug.urls.url_parse(request.path_url)
body = request.body
if isinstance(body, bytes):
body = body.decode('utf-8')
body = json.loads(body)
message = '%s|%s|%s|%s|%s|%s' % (
msg_timestamp, # timestamp
parsed_url.path, # url path
self.client_id,
self.access_token,
json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key
json.dumps(body, sort_keys=True)) # http request body
h = hmac.new(base64.b64decode(self.refresh_token), message.encode('utf-8'), digestmod=hashlib.sha256)
request.headers.update({
'odoofin-client-id': self.client_id,
'odoofin-access-token': self.access_token,
'odoofin-signature': base64.b64encode(h.digest()),
'odoofin-timestamp': msg_timestamp,
})
return request

View File

@ -0,0 +1,7 @@
from odoo import models, fields
class ResPartner(models.Model):
_inherit = 'res.partner'
online_partner_information = fields.Char(readonly=True)

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record model="ir.rule" id="account_online_sync_link_rule">
<field name="name">Account online link company rule</field>
<field name="model_id" ref="model_account_online_link"/>
<field name="global" eval="True"/>
<field name="domain_force">[('company_id', 'parent_of', company_ids)]</field>
</record>
<record model="ir.rule" id="account_online_sync_account_rule">
<field name="name">Online account company rule</field>
<field name="model_id" ref="model_account_online_account"/>
<field name="global" eval="True"/>
<field name="domain_force">[('account_online_link_id.company_id','parent_of', company_ids)]</field>
</record>
</odoo>

Some files were not shown because too many files have changed in this diff Show More