diff --git a/dev_odex30_accounting/odex30_account_3way_match/__init__.py b/dev_odex30_accounting/odex30_account_3way_match/__init__.py
new file mode 100644
index 0000000..573a1d1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/dev_odex30_accounting/odex30_account_3way_match/__manifest__.py b/dev_odex30_accounting/odex30_account_3way_match/__manifest__.py
new file mode 100644
index 0000000..a0088c6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/__manifest__.py
@@ -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',
+}
diff --git a/dev_odex30_accounting/odex30_account_3way_match/i18n/account_3way_match.pot b/dev_odex30_accounting/odex30_account_3way_match/i18n/account_3way_match.pot
new file mode 100644
index 0000000..bfe2171
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/i18n/account_3way_match.pot
@@ -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 ""
diff --git a/dev_odex30_accounting/odex30_account_3way_match/i18n/ar.po b/dev_odex30_accounting/odex30_account_3way_match/i18n/ar.po
new file mode 100644
index 0000000..fca4909
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/i18n/ar.po
@@ -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 "نعم"
diff --git a/dev_odex30_accounting/odex30_account_3way_match/models/__init__.py b/dev_odex30_accounting/odex30_account_3way_match/models/__init__.py
new file mode 100644
index 0000000..05c5135
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/models/__init__.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_3way_match/models/account_invoice.py b/dev_odex30_accounting/odex30_account_3way_match/models/account_invoice.py
new file mode 100644
index 0000000..f26b902
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/models/account_invoice.py
@@ -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')
diff --git a/dev_odex30_accounting/odex30_account_3way_match/models/account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_3way_match/models/account_journal_dashboard.py
new file mode 100644
index 0000000..85b3ba7
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/models/account_journal_dashboard.py
@@ -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
+ ])
diff --git a/dev_odex30_accounting/odex30_account_3way_match/tests/__init__.py b/dev_odex30_accounting/odex30_account_3way_match/tests/__init__.py
new file mode 100644
index 0000000..e52ad4b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/tests/__init__.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_3way_match/tests/test_account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_3way_match/tests/test_account_journal_dashboard.py
new file mode 100644
index 0000000..7fd1d4c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/tests/test_account_journal_dashboard.py
@@ -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'])
diff --git a/dev_odex30_accounting/odex30_account_3way_match/tests/test_release_to_pay_invoice.py b/dev_odex30_accounting/odex30_account_3way_match/tests/test_release_to_pay_invoice.py
new file mode 100644
index 0000000..c0b8cea
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/tests/test_release_to_pay_invoice.py
@@ -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':
+ #
+ 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)
diff --git a/dev_odex30_accounting/odex30_account_3way_match/views/account_invoice_view.xml b/dev_odex30_accounting/odex30_account_3way_match/views/account_invoice_view.xml
new file mode 100644
index 0000000..962a5a9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/views/account_invoice_view.xml
@@ -0,0 +1,48 @@
+
+
+
+ account.move.form.inherit
+ account.move
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account.invoice.select.inherit.odex30_account_3way_match
+ primary
+ account.move
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_3way_match/views/account_journal_dashboard_view.xml b/dev_odex30_accounting/odex30_account_3way_match/views/account_journal_dashboard_view.xml
new file mode 100644
index 0000000..9571981
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_3way_match/views/account_journal_dashboard_view.xml
@@ -0,0 +1,25 @@
+
+
+
+ account.journal.dashboard.kanban
+ account.journal
+
+
+
+
+ {'search_default_bills_to_validate': 1}
+
+
+
+
+ {'search_default_bills_to_pay':1}
+
+
+
+
+ {'search_default_late':1}
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/__init__.py b/dev_odex30_accounting/odex30_account_accountant_check_printing/__init__.py
new file mode 100644
index 0000000..2e9271d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/__init__.py
@@ -0,0 +1,3 @@
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/__manifest__.py b/dev_odex30_accounting/odex30_account_accountant_check_printing/__manifest__.py
new file mode 100644
index 0000000..491f65b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/__manifest__.py
@@ -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',
+}
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/account_accountant_check_printing.pot b/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/account_accountant_check_printing.pot
new file mode 100644
index 0000000..60d2e45
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/account_accountant_check_printing.pot
@@ -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 ""
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/ar.po b/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/ar.po
new file mode 100644
index 0000000..4add9c1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/i18n/ar.po
@@ -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 ""
+"تمت تهيئة قيد اليومية المُختار لطباعة أرقام الشيكات. إذا كان لشيكاتك "
+"المطبوعة مسبقًا أرقام بالفعل أو إذا كان الترقيم الحالي خاطئًا، فيمكنك تغييره"
+" في صفحة تهيئة دفتر اليومية. "
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/models/__init__.py b/dev_odex30_accounting/odex30_account_accountant_check_printing/models/__init__.py
new file mode 100644
index 0000000..f12b182
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/models/__init__.py
@@ -0,0 +1,3 @@
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+
+from . import account_move_line
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/models/account_move_line.py b/dev_odex30_accounting/odex30_account_accountant_check_printing/models/account_move_line.py
new file mode 100644
index 0000000..8d4976e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/models/account_move_line.py
@@ -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',
+ )
diff --git a/dev_odex30_accounting/odex30_account_accountant_check_printing/views/bank_rec_widget_views.xml b/dev_odex30_accounting/odex30_account_accountant_check_printing/views/bank_rec_widget_views.xml
new file mode 100644
index 0000000..3defd86
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant_check_printing/views/bank_rec_widget_views.xml
@@ -0,0 +1,27 @@
+
+
+
+
+ account.move.line.list.bank_rec_widget
+ account.move.line
+
+
+
+
+
+
+
+
+
+ account.move.line.search.bank_rec_widget
+ account.move.line
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml
index 02b0a05..dc200ab 100644
--- a/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml
+++ b/dev_odex30_accounting/odex30_account_accountant_fleet/static/src/components/bank_reconciliation/bank_rec_form.xml
@@ -1,6 +1,6 @@
-
+
-
+
=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 "المركبة"
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/models/__init__.py b/dev_odex30_accounting/odex30_account_asset_fleet/models/__init__.py
new file mode 100644
index 0000000..b03c273
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/models/__init__.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/models/account_asset.py b/dev_odex30_accounting/odex30_account_asset_fleet/models/account_asset.py
new file mode 100644
index 0000000..b1af83f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/models/account_asset.py
@@ -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',
+ }
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/models/account_move.py b/dev_odex30_accounting/odex30_account_asset_fleet/models/account_move.py
new file mode 100644
index 0000000..3a379aa
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/models/account_move.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/models/fleet_vehicle_log_services.py b/dev_odex30_accounting/odex30_account_asset_fleet/models/fleet_vehicle_log_services.py
new file mode 100644
index 0000000..d1c12da
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/models/fleet_vehicle_log_services.py
@@ -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)
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/views/account_asset_views.xml b/dev_odex30_accounting/odex30_account_asset_fleet/views/account_asset_views.xml
new file mode 100644
index 0000000..d9668fa
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/views/account_asset_views.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ account.asset.fleet.form
+ account.asset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_asset_fleet/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_asset_fleet/views/account_move_views.xml
new file mode 100644
index 0000000..15420b7
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset_fleet/views/account_move_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ account.move.fleet.form
+ account.move
+
+
+
+ parent.move_type not in ('entry', 'in_invoice', 'in_refund')
+ need_vehicle and parent.move_type in ('in_invoice', 'in_refund')
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/__init__.py b/dev_odex30_accounting/odex30_account_inter_company_rules/__init__.py
new file mode 100644
index 0000000..a0fdc10
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import models
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/__manifest__.py b/dev_odex30_accounting/odex30_account_inter_company_rules/__manifest__.py
new file mode 100644
index 0000000..a2511a2
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/__manifest__.py
@@ -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',
+}
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/account_inter_company_rules.pot b/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/account_inter_company_rules.pot
new file mode 100644
index 0000000..97fe186
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/account_inter_company_rules.pot
@@ -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 ""
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/ar.po b/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/ar.po
new file mode 100644
index 0000000..8b59bf7
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/i18n/ar.po
@@ -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 "الفاتورة المصدر"
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/models/__init__.py b/dev_odex30_accounting/odex30_account_inter_company_rules/models/__init__.py
new file mode 100644
index 0000000..e4926d3
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/models/__init__.py
@@ -0,0 +1,4 @@
+from . import account_move
+from . import account_move_send
+from . import res_config_settings
+from . import res_company
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move.py b/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move.py
new file mode 100644
index 0000000..03d3601
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move_send.py b/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move_send.py
new file mode 100644
index 0000000..e0c20ca
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/models/account_move_send.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_company.py b/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_company.py
new file mode 100644
index 0000000..c29fca4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_company.py
@@ -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]
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_config_settings.py
new file mode 100644
index 0000000..cb7cdac
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/models/res_config_settings.py
@@ -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)
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/tests/__init__.py b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/__init__.py
new file mode 100644
index 0000000..d5d5feb
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/__init__.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/tests/common.py b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/common.py
new file mode 100644
index 0000000..54d73ea
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/common.py
@@ -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
+ ])]
+ })
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/tests/test_inter_company_invoice.py b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/test_inter_company_invoice.py
new file mode 100644
index 0000000..8acab6e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/tests/test_inter_company_invoice.py
@@ -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)
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_company_views.xml b/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_company_views.xml
new file mode 100644
index 0000000..c20a01c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_company_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ res.company.form.inherit
+
+ res.company
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_config_settings_views.xml b/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_config_settings_views.xml
new file mode 100644
index 0000000..c237619
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_inter_company_rules/views/res_config_settings_views.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ res.config.settings.view.form.inherit.inter.company.rules
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_no_followup/__init__.py b/dev_odex30_accounting/odex30_account_no_followup/__init__.py
new file mode 100644
index 0000000..5e34bd9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/__init__.py
@@ -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),
+ )
diff --git a/dev_odex30_accounting/odex30_account_no_followup/__manifest__.py b/dev_odex30_accounting/odex30_account_no_followup/__manifest__.py
new file mode 100644
index 0000000..7adcb5e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/__manifest__.py
@@ -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",
+}
diff --git a/dev_odex30_accounting/odex30_account_no_followup/i18n/account_no_followup.pot b/dev_odex30_accounting/odex30_account_no_followup/i18n/account_no_followup.pot
new file mode 100644
index 0000000..d2e5b8f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/i18n/account_no_followup.pot
@@ -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 ""
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/__init__.py b/dev_odex30_accounting/odex30_account_no_followup/models/__init__.py
new file mode 100644
index 0000000..dc9cd15
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/__init__.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/account_customer_statement.py b/dev_odex30_accounting/odex30_account_no_followup/models/account_customer_statement.py
new file mode 100644
index 0000000..01a18b4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/account_customer_statement.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/account_followup_report.py b/dev_odex30_accounting/odex30_account_no_followup/models/account_followup_report.py
new file mode 100644
index 0000000..b1a89a6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/account_followup_report.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/account_move.py b/dev_odex30_accounting/odex30_account_no_followup/models/account_move.py
new file mode 100644
index 0000000..9546234
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/account_move.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/account_move_line.py b/dev_odex30_accounting/odex30_account_no_followup/models/account_move_line.py
new file mode 100644
index 0000000..80f2e72
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/account_move_line.py
@@ -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
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/account_partner_ledger.py b/dev_odex30_accounting/odex30_account_no_followup/models/account_partner_ledger.py
new file mode 100644
index 0000000..ae7d34e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/account_partner_ledger.py
@@ -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')
+ ]
diff --git a/dev_odex30_accounting/odex30_account_no_followup/models/res_partner.py b/dev_odex30_accounting/odex30_account_no_followup/models/res_partner.py
new file mode 100644
index 0000000..bc1296d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/models/res_partner.py
@@ -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'
diff --git a/dev_odex30_accounting/odex30_account_no_followup/static/src/components/account_report/controller.js b/dev_odex30_accounting/odex30_account_no_followup/static/src/components/account_report/controller.js
new file mode 100644
index 0000000..c11fe42
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/static/src/components/account_report/controller.js
@@ -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 });
+ }
+ }
+})
diff --git a/dev_odex30_accounting/odex30_account_no_followup/static/src/components/partner_ledger_followup/header.xml b/dev_odex30_accounting/odex30_account_no_followup/static/src/components/partner_ledger_followup/header.xml
new file mode 100644
index 0000000..f6c7ff5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_no_followup/static/src/components/partner_ledger_followup/header.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ 2
+
+
+
+ 2
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/odex_fin_connector.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/odex_fin_connector.js
new file mode 100644
index 0000000..4f39b00
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/odex_fin_connector.js
@@ -0,0 +1,86 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { loadJS } from "@web/core/assets";
+import { cookie } from "@web/core/browser/cookie";
+import { markup } from "@odoo/owl";
+const actionRegistry = registry.category('actions');
+/* global ODEXFin */
+
+function ODEXFinConnector(parent, action) {
+ const orm = parent.services.orm;
+ const actionService = parent.services.action;
+ const notificationService = parent.services.notification;
+ const debugMode = parent.debug;
+
+ const id = action.id;
+ action.params.colorScheme = cookie.get("color_scheme");
+ let mode = action.params.mode || 'link';
+ // Ensure that the proxyMode is valid
+ const modeRegexp = /^[a-z0-9-_]+$/;
+ const runbotRegexp = /^https:\/\/[a-z0-9-_]+\.[a-z0-9-_]+\.odoo\.com$/;
+ if (!modeRegexp.test(action.params.proxyMode) && !runbotRegexp.test(action.params.proxyMode)) {
+ return;
+ }
+ let url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link';
+ if (runbotRegexp.test(action.params.proxyMode)) {
+ url = action.params.proxyMode + '/proxy/v1/odoofin_link';
+ }
+ let actionResult = false;
+
+ loadJS(url)
+ .then(function () {
+ // Create and open the iframe
+ const params = {
+ data: action.params,
+ proxyMode: action.params.proxyMode,
+ onEvent: async function (event, data) {
+ switch (event) {
+ case 'close':
+ return;
+ case 'reload':
+ return actionService.doAction({type: 'ir.actions.client', tag: 'reload'});
+ case 'notification':
+ notificationService.add(data.message, data);
+ break;
+ case 'exchange_token':
+ await orm.call('account.online.link', 'exchange_token',
+ [[id], data], {context: action.context});
+ break;
+ case 'success':
+ mode = data.mode || mode;
+ actionResult = await orm.call('account.online.link', 'success', [[id], mode, data], {context: action.context});
+ actionResult.help = markup(actionResult.help)
+ return actionService.doAction(actionResult);
+ case 'connect_existing_account':
+ actionResult = await orm.call('account.online.link', 'connect_existing_account', [data], {context: action.context});
+ actionResult.help = markup(actionResult.help)
+ return actionService.doAction(actionResult);
+ default:
+ return;
+ }
+ },
+ onAddBank: async function (data) {
+ // If the user doesn't find his bank
+ actionResult = await orm.call(
+ "account.online.link",
+ "create_new_bank_account_action",
+ [[id], data],
+ { context: action.context }
+ );
+ return actionService.doAction(actionResult);
+ }
+ };
+ // propagate parent debug mode to iframe
+ if (typeof debugMode !== "undefined" && debugMode) {
+ params.data["debug"] = debugMode;
+ }
+ ODEXFin.create(params);
+ ODEXFin.open();
+ });
+ return;
+}
+
+actionRegistry.add('odex_fin_connector', ODEXFinConnector);
+
+export default ODEXFinConnector;
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/online_sync_portal.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/online_sync_portal.js
new file mode 100644
index 0000000..d54b43c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/js/online_sync_portal.js
@@ -0,0 +1,57 @@
+/** @odoo-module **/
+
+ import publicWidget from "@web/legacy/js/public/public_widget";
+ import { loadJS } from "@web/core/assets";
+ /* global ODEXFin */
+
+ publicWidget.registry.OnlineSyncPortal = publicWidget.Widget.extend({
+ selector: '.oe_online_sync',
+ events: Object.assign({}, {
+ 'click #renew_consent_button': '_onRenewConsent',
+ }),
+
+ ODEXFinConnector: function (parent, action) {
+ // Ensure that the proxyMode is valid
+ const modeRegexp = /^[a-z0-9-_]+$/i;
+ if (!modeRegexp.test(action.params.proxyMode)) {
+ return;
+ }
+ const url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link';
+
+ loadJS(url)
+ .then(() => {
+ // Create and open the iframe
+ const params = {
+ data: action.params,
+ proxyMode: action.params.proxyMode,
+ onEvent: function (event, data) {
+ switch (event) {
+ case 'success':
+ const processUrl = window.location.pathname + '/complete' + window.location.search;
+ $('.js_reconnect').toggleClass('d-none');
+ $.post(processUrl, {csrf_token: odoo.csrf_token});
+ default:
+ return;
+ }
+ },
+ };
+ ODEXFin.create(params);
+ ODEXFin.open();
+ });
+ return;
+ },
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onRenewConsent: async function (ev) {
+ ev.preventDefault();
+ const action = JSON.parse($(ev.currentTarget).attr('iframe-params'));
+ return this.ODEXFinConnector(this, action);
+ },
+ });
+
+ export default {
+ OnlineSyncPortal: publicWidget.registry.OnlineSyncPortal,
+ };
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/helpers/model_definitions_setup.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/helpers/model_definitions_setup.js
new file mode 100644
index 0000000..6434c51
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/helpers/model_definitions_setup.js
@@ -0,0 +1,5 @@
+/** @odoo-module **/
+
+import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
+
+addModelNamesToFetch(["account.online.link", "account.online.account", "account.bank.selection"]);
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/online_account_radio_test.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/online_account_radio_test.js
new file mode 100644
index 0000000..3be8ad5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/tests/online_account_radio_test.js
@@ -0,0 +1,83 @@
+/* @odoo-module */
+
+import { startServer } from "@bus/../tests/helpers/mock_python_environment";
+
+import { openFormView, start } from "@mail/../tests/helpers/test_utils";
+
+import { click, contains } from "@web/../tests/utils";
+
+QUnit.module("Views", {}, function () {
+ QUnit.module("AccountOnlineSynchronizationAccountRadio");
+
+ QUnit.test("can be rendered", async () => {
+ const pyEnv = await startServer();
+ const onlineLink = pyEnv["account.online.link"].create([
+ {
+ state: "connected",
+ name: "Fake Bank",
+ },
+ ]);
+ pyEnv["account.online.account"].create([
+ {
+ name: "account_1",
+ online_identifier: "abcd",
+ balance: 10.0,
+ account_number: "account_number_1",
+ account_online_link_id: onlineLink,
+ },
+ {
+ name: "account_2",
+ online_identifier: "efgh",
+ balance: 20.0,
+ account_number: "account_number_2",
+ account_online_link_id: onlineLink,
+ },
+ ]);
+ const bankSelection = pyEnv["account.bank.selection"].create([
+ {
+ account_online_link_id: onlineLink,
+ },
+ ]);
+
+ const views = {
+ "account.bank.selection,false,form": ``,
+ };
+ await start({
+ serverData: { views },
+ mockRPC: function (route, args) {
+ if (
+ route === "/web/dataset/call_kw/account.online.account/get_formatted_balances"
+ ) {
+ return {
+ 1: ["$ 10.0", 10.0],
+ 2: ["$ 20.0", 20.0],
+ };
+ }
+ },
+ });
+ await openFormView("account.bank.selection", bankSelection);
+ await contains(".o_radio_item", { count: 2 });
+ await contains(":nth-child(1 of .o_radio_item)", {
+ contains: [
+ ["p", { text: "$ 10.0" }],
+ ["label", { text: "account_1" }],
+ [".o_radio_input:checked"],
+ ],
+ });
+ await contains(":nth-child(2 of .o_radio_item)", {
+ contains: [
+ ["p", { text: "$ 20.0" }],
+ ["label", { text: "account_2" }],
+ [".o_radio_input:not(:checked)"],
+ ],
+ });
+ await click(":nth-child(2 of .o_radio_item) .o_radio_input");
+ await contains(":nth-child(1 of .o_radio_item) .o_radio_input:not(:checked)");
+ await contains(":nth-child(2 of .o_radio_item) .o_radio_input:checked");
+ });
+});
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/__init__.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/__init__.py
new file mode 100644
index 0000000..eacefb9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/__init__.py
@@ -0,0 +1,7 @@
+# -*- encoding: utf-8 -*-
+
+from . import common
+from . import test_account_online_account
+from . import test_online_sync_creation_statement
+from . import test_account_missing_transactions_wizard
+from . import test_online_sync_branch_companies
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/common.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/common.py
new file mode 100644
index 0000000..28c757b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/common.py
@@ -0,0 +1,110 @@
+# 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
+from unittest.mock import MagicMock
+
+
+@tagged('post_install', '-at_install')
+class AccountOnlineSynchronizationCommon(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.other_currency = cls.setup_other_currency('EUR')
+
+ cls.euro_bank_journal = cls.env['account.journal'].create({
+ 'name': 'Euro Bank Journal',
+ 'type': 'bank',
+ 'code': 'EURB',
+ 'currency_id': cls.other_currency.id,
+ })
+ cls.account_online_link = cls.env['account.online.link'].create({
+ 'name': 'Test Bank',
+ 'client_id': 'client_id_1',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ cls.account_online_account = cls.env['account.online.account'].create({
+ 'name': 'MyBankAccount',
+ 'account_online_link_id': cls.account_online_link.id,
+ 'journal_ids': [Command.set(cls.euro_bank_journal.id)]
+ })
+ cls.BankStatementLine = cls.env['account.bank.statement.line']
+
+ def setUp(self):
+ super().setUp()
+ self.transaction_id = 1
+ self.account_online_account.balance = 0.0
+
+ def _create_one_online_transaction(self, transaction_identifier=None, date=None, payment_ref=None, amount=10.0, partner_name=None, foreign_currency_code=None, amount_currency=8.0):
+ """ This method allows to create an online transaction granularly
+
+ :param transaction_identifier: Online identifier of the transaction, by default transaction_id from the
+ setUp. If used, transaction_id is not incremented.
+ :param date: Date of the transaction, by default the date of today
+ :param payment_ref: Label of the transaction
+ :param amount: Amount of the transaction, by default equals 10.0
+ :param foreign_currency_code: Code of transaction's foreign currency
+ :param amount_currency: Amount of transaction in foreign currency, update transaction only if foreign_currency_code is given, by default equals 8.0
+ :return: A dictionnary representing an online transaction (not formatted)
+ """
+ transaction_identifier = transaction_identifier if transaction_identifier is not None else self.transaction_id
+ if date:
+ date = date if isinstance(date, str) else fields.Date.to_string(date)
+ else:
+ date = fields.Date.to_string(fields.Date.today())
+
+ payment_ref = payment_ref or f'transaction_{transaction_identifier}'
+ transaction = {
+ 'online_transaction_identifier': transaction_identifier,
+ 'date': date,
+ 'payment_ref': payment_ref,
+ 'amount': amount,
+ 'partner_name': partner_name,
+ }
+ if foreign_currency_code:
+ transaction.update({
+ 'foreign_currency_code': foreign_currency_code,
+ 'amount_currency': amount_currency
+ })
+ return transaction
+
+ def _create_online_transactions(self, dates):
+ """ This method returns a list of transactions with the
+ given dates.
+ All amounts equals 10.0
+
+ :param dates: A list of dates, one transaction is created for each given date.
+ :return: A formatted list of transactions
+ """
+ transactions = []
+ for date in dates:
+ transactions.append(self._create_one_online_transaction(date=date))
+ self.transaction_id += 1
+ return self.account_online_account._format_transactions(transactions)
+
+ def _mock_odoofin_response(self, data=None):
+ if not data:
+ data = {}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'result': data,
+ }
+ return mock_response
+
+ def _mock_odoofin_error_response(self, code=200, message='Default', data=None):
+ if not data:
+ data = {}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'error': {
+ 'code': code,
+ 'message': message,
+ 'data': data,
+ },
+ }
+ return mock_response
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_missing_transactions_wizard.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_missing_transactions_wizard.py
new file mode 100644
index 0000000..0cab265
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_missing_transactions_wizard.py
@@ -0,0 +1,45 @@
+from odoo import fields
+from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+from unittest.mock import patch
+
+
+@tagged('post_install', '-at_install')
+class TestAccountMissingTransactionsWizard(AccountOnlineSynchronizationCommon):
+ """ Tests the account journal missing transactions wizard. """
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_missing_transaction(self, patched_fetch_odoofin):
+ self.account_online_link.state = 'connected'
+ patched_fetch_odoofin.side_effect = [{
+ 'transactions': [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06', foreign_currency_code='EGP', amount_currency=8.0),
+ ],
+ 'pendings': [
+ self._create_one_online_transaction(transaction_identifier='ABCD02_pending', date='2023-07-25', foreign_currency_code='GBP', amount_currency=8.0),
+ ]
+ }]
+ start_date = fields.Date.from_string('2023-07-01')
+ wizard = self.env['account.missing.transaction.wizard'].new({
+ 'date': start_date,
+ 'journal_id': self.euro_bank_journal.id,
+ })
+
+ action = wizard.action_fetch_missing_transaction()
+ transient_transactions = self.env['account.bank.statement.line.transient'].search(domain=action['domain'])
+ egp_currency = self.env['res.currency'].search([('name', '=', 'EGP')])
+ gbp_currency = self.env['res.currency'].search([('name', '=', 'GBP')])
+
+ self.assertEqual(2, len(transient_transactions))
+ # Posted Transaction
+ self.assertEqual(transient_transactions[0]['online_transaction_identifier'], 'ABCD01')
+ self.assertEqual(transient_transactions[0]['date'], fields.Date.from_string('2023-07-06'))
+ self.assertEqual(transient_transactions[0]['state'], 'posted')
+ self.assertEqual(transient_transactions[0]['foreign_currency_id'], egp_currency)
+ self.assertEqual(transient_transactions[0]['amount_currency'], 8.0)
+ # Pending Transaction
+ self.assertEqual(transient_transactions[1]['online_transaction_identifier'], 'ABCD02_pending')
+ self.assertEqual(transient_transactions[1]['date'], fields.Date.from_string('2023-07-25'))
+ self.assertEqual(transient_transactions[1]['state'], 'pending')
+ self.assertEqual(transient_transactions[1]['foreign_currency_id'], gbp_currency)
+ self.assertEqual(transient_transactions[1]['amount_currency'], 8.0)
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_online_account.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_online_account.py
new file mode 100644
index 0000000..e1b5f94
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_account_online_account.py
@@ -0,0 +1,491 @@
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+
+import logging
+from datetime import datetime, timedelta
+from freezegun import freeze_time
+from unittest.mock import patch
+
+from odoo import Command, fields, tools
+from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+
+_logger = logging.getLogger(__name__)
+
+@tagged('post_install', '-at_install')
+class TestAccountOnlineAccount(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.bank_account_id = cls.env['account.account'].create({
+ 'name': 'Bank Account',
+ 'account_type': 'asset_cash',
+ 'code': cls.env['account.account']._search_new_account_code('BNK100'),
+ })
+ cls.bank_journal = cls.env['account.journal'].create({
+ 'name': 'A bank journal',
+ 'default_account_id': cls.bank_account_id.id,
+ 'type': 'bank',
+ 'code': cls.env['account.journal'].get_next_bank_cash_default_code('bank', cls.company_data['company']),
+ })
+
+ @freeze_time('2023-08-01')
+ def test_get_filtered_transactions(self):
+ """ This test verifies that duplicate transactions are filtered """
+ self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({
+ 'date': '2023-08-01',
+ 'journal_id': self.euro_bank_journal.id,
+ 'online_transaction_identifier': 'ABCD01',
+ 'payment_ref': 'transaction_ABCD01',
+ 'amount': 10.0,
+ })
+
+ transactions_to_filtered = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02'),
+ ]
+
+ filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered)
+
+ self.assertEqual(
+ filtered_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'partner_name': None,
+ }
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_get_filtered_transactions_with_empty_transaction_identifier(self):
+ """ This test verifies that transactions without a transaction identifier
+ are not filtered due to their empty transaction identifier.
+ """
+ self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({
+ 'date': '2023-08-01',
+ 'journal_id': self.euro_bank_journal.id,
+ 'online_transaction_identifier': '',
+ 'payment_ref': 'transaction_ABCD01',
+ 'amount': 10.0,
+ })
+
+ transactions_to_filtered = [
+ self._create_one_online_transaction(transaction_identifier=''),
+ self._create_one_online_transaction(transaction_identifier=''),
+ ]
+
+ filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered)
+
+ self.assertEqual(
+ filtered_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': '',
+ 'amount': 10.0,
+ 'partner_name': None,
+ },
+ {
+ 'payment_ref': 'transaction_',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': '',
+ 'amount': 10.0,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions(self):
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02'),
+ ]
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions_invert_sign(self):
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', amount=25.0),
+ ]
+ self.account_online_account.inverse_transaction_sign = True
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': -25.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions_foreign_currency_code_to_id_with_activation(self):
+ """ This test ensures conversion of foreign currency code to foreign currency id and activates foreign currency if not already activate """
+ gbp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'GBP')])
+ egp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'EGP')])
+
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', foreign_currency_code='GBP'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02', foreign_currency_code='EGP', amount_currency=500.0),
+ ]
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+
+ self.assertTrue(gbp_currency.active)
+ self.assertTrue(egp_currency.active)
+
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ 'foreign_currency_id': gbp_currency.id,
+ 'amount_currency': 8.0,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ 'foreign_currency_id': egp_currency.id,
+ 'amount_currency': 500.0,
+ },
+ ]
+ )
+
+ @freeze_time('2023-07-25')
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_retrieve_pending_transactions(self, patched_fetch_odoofin):
+ self.account_online_link.state = 'connected'
+ patched_fetch_odoofin.side_effect = [{
+ 'transactions': [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02', date='2023-07-22'),
+ ],
+ 'pendings': [
+ self._create_one_online_transaction(transaction_identifier='ABCD03_pending', date='2023-07-25'),
+ self._create_one_online_transaction(transaction_identifier='ABCD04_pending', date='2023-07-25'),
+ ]
+ }]
+
+ start_date = fields.Date.from_string('2023-07-01')
+ result = self.account_online_account._retrieve_transactions(date=start_date, include_pendings=True)
+ self.assertEqual(
+ result,
+ {
+ 'transactions': [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-07-06'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-07-22'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ }
+ ],
+ 'pendings': [
+ {
+ 'payment_ref': 'transaction_ABCD03_pending',
+ 'date': fields.Date.from_string('2023-07-25'),
+ 'online_transaction_identifier': 'ABCD03_pending',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD04_pending',
+ 'date': fields.Date.from_string('2023-07-25'),
+ 'online_transaction_identifier': 'ABCD04_pending',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ }
+ ]
+ }
+ )
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}})
+ def test_basic_flow_manual_fetching_transactions(self, patched_refresh, patched_transactions):
+ self.addCleanup(self.env.registry.leave_test_mode)
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ test_link_account = self.account_online_link.with_env(test_env)
+ test_link_account.state = 'connected'
+ # Call fetch_transaction in manual mode and check that a call was made to refresh and to transaction
+ test_link_account._fetch_transactions()
+ patched_refresh.assert_called_once()
+ patched_transactions.assert_called_once()
+ self.assertEqual(test_link_account.account_online_account_ids[0].fetching_status, 'done')
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_refresh_incomplete_fetching_transactions(self, patched_refresh, patched_transactions):
+ patched_refresh.return_value = {'success': False}
+ # Call fetch_transaction and if call result is false, don't call transaction
+ self.account_online_link._fetch_transactions()
+ patched_transactions.assert_not_called()
+
+ patched_refresh.return_value = {'success': False, 'currently_fetching': True}
+ # Call fetch_transaction and if call result is false but in the process of fetching, don't call transaction
+ # and wait for the async cron to try again
+ self.account_online_link._fetch_transactions()
+ patched_transactions.assert_not_called()
+ self.assertEqual(self.account_online_account.fetching_status, 'waiting')
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}})
+ def test_currently_processing_fetching_transactions(self, patched_refresh, patched_transactions):
+ self.account_online_account.fetching_status = 'processing' # simulate the fact that we are currently creating entries in odoo
+ limit_time = tools.config['limit_time_real_cron'] if tools.config['limit_time_real_cron'] > 0 else tools.config['limit_time_real']
+ self.account_online_link.last_refresh = datetime.now()
+ with freeze_time(datetime.now() + timedelta(seconds=(limit_time - 10))):
+ # Call to fetch_transaction should be skipped, and the cron should not try to fetch either
+ self.account_online_link._fetch_transactions()
+ self.euro_bank_journal._cron_fetch_waiting_online_transactions()
+ patched_refresh.assert_not_called()
+ patched_transactions.assert_not_called()
+
+ self.addCleanup(self.env.registry.leave_test_mode)
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ with freeze_time(datetime.now() + timedelta(seconds=(limit_time + 100))):
+ # Call to fetch_transaction should be started by the cron when the time limit is exceeded and still in processing
+ self.euro_bank_journal.with_env(test_env)._cron_fetch_waiting_online_transactions()
+ patched_refresh.assert_not_called()
+ patched_transactions.assert_called_once()
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.requests')
+ def test_delete_with_redirect_error(self, patched_request):
+ # Use case being tested: call delete on a record, first call returns token expired exception
+ # Which trigger a call to get a new token, which result in a 104 user_deleted_error, since version 17,
+ # such error are returned as a ODEXFinRedirectException with mode link to reopen the iframe and link with a new
+ # bank. In our case we don't want that and want to be able to delete the record instead.
+ # Such use case happen when db_uuid has changed as the check for db_uuid is done after the check for token_validity
+ account_online_link = self.env['account.online.link'].create({
+ 'name': 'Test Delete',
+ 'client_id': 'client_id_test',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ first_call = self._mock_odoofin_error_response(code=102)
+ second_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'})
+ patched_request.post.side_effect = [first_call, second_call]
+ nb_connections = len(self.env['account.online.link'].search([]))
+ # Try to delete record
+ account_online_link.unlink()
+ # Record should be deleted
+ self.assertEqual(len(self.env['account.online.link'].search([])), nb_connections - 1)
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.requests')
+ def test_redirect_mode_link(self, patched_request):
+ # Use case being tested: Call to open the iframe which result in a ODEXfinRedirectException in link mode
+ # This should not trigger a traceback but delete the current online.link and reopen the iframe
+ account_online_link = self.env['account.online.link'].create({
+ 'name': 'Test Delete',
+ 'client_id': 'client_id_test',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ link_id = account_online_link.id
+ first_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'})
+ second_call = self._mock_odoofin_response(data={'delete': True})
+ patched_request.post.side_effect = [first_call, second_call]
+ # Try to open iframe with broken connection
+ action = account_online_link.action_new_synchronization()
+ # Iframe should open in mode link and with a different record (old one should have been deleted)
+ self.assertEqual(action['params']['mode'], 'link')
+ self.assertNotEqual(action['id'], link_id)
+ self.assertEqual(len(self.env['account.online.link'].search([('id', '=', link_id)])), 0)
+
+ @patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status", return_value={})
+ def test_assign_journal_with_currency_on_account_online_account(self, patched_update_connection_status):
+ self.env['account.move'].create([
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-25'),
+ 'journal_id': self.bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': self.bank_account_id.id,
+ 'debit': 100,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 100,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-26'),
+ 'journal_id': self.bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': self.bank_account_id.id,
+ 'debit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ ])
+
+ self.account_online_account.currency_id = self.company_data['currency'].id
+ self.account_online_account.with_context(active_id=self.bank_journal.id, active_model='account.journal')._assign_journal()
+ self.assertEqual(
+ self.bank_journal.currency_id.id,
+ self.company_data['currency'].id,
+ )
+ self.assertEqual(
+ self.bank_journal.default_account_id.currency_id.id,
+ self.company_data['currency'].id,
+ )
+
+ @patch("odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status", return_value={})
+ def test_set_currency_on_journal_when_existing_currencies_on_move_lines(self, patched_update_connection_status):
+ bank_account_id = self.env['account.account'].create({
+ 'name': 'Bank Account',
+ 'account_type': 'asset_cash',
+ 'code': self.env['account.account']._search_new_account_code('BNK100'),
+ })
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'A bank journal',
+ 'default_account_id': bank_account_id.id,
+ 'type': 'bank',
+ 'code': self.env['account.journal'].get_next_bank_cash_default_code('bank', self.company_data['company']),
+ })
+
+ self.env['account.move'].create([
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-25'),
+ 'journal_id': bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': bank_account_id.id,
+ 'debit': 100,
+ 'currency_id': self.other_currency.id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 100,
+ 'currency_id': self.other_currency.id,
+ }),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-26'),
+ 'journal_id': bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': bank_account_id.id,
+ 'debit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ ])
+
+ self.account_online_account.currency_id = self.company_data['currency'].id
+ self.account_online_account.with_context(active_id=bank_journal.id, active_model='account.journal')._assign_journal()
+
+ # Silently ignore the error and don't set currency on the journal and on the account
+ self.assertEqual(bank_journal.currency_id.id, False)
+ self.assertEqual(bank_journal.default_account_id.currency_id.id, False)
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_branch_companies.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_branch_companies.py
new file mode 100644
index 0000000..a2cc7b9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_branch_companies.py
@@ -0,0 +1,86 @@
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSynchInBranches(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.mother_company = cls.env['res.company'].create({'name': 'Mother company 2000'})
+ cls.branch_company = cls.env['res.company'].create({'name': 'Branch company', 'parent_id': cls.mother_company.id})
+
+ cls.mother_bank_journal = cls.env['account.journal'].create({
+ 'name': 'Mother Bank Journal',
+ 'type': 'bank',
+ 'code': 'MBJ',
+ 'company_id': cls.mother_company.id,
+ })
+ cls.mother_account_online_link = cls.env['account.online.link'].create({
+ 'name': 'Test Bank',
+ 'client_id': 'client_id_1',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ 'company_id': cls.mother_company.id,
+ })
+
+ def test_show_sync_actions(self):
+ """We test if the sync actions are correctly displayed based on the selected and enabled companies.
+
+ Let's have company A with an online link, and a branch of that company: company B.
+
+ - If we only have company A enabled and selected, the sync actions should be shown.
+ - If company A and B are enabled, no matter which company is selected, the sync actions should be shown.
+ - If we only have company B enabled and selected, the sync actions should be hidden.
+ """
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.mother_company)._ids)
+ .with_company(self.mother_company)
+ .show_sync_actions
+ )
+
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)
+ .with_company(self.mother_company)
+ .show_sync_actions
+ )
+
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)
+ .with_company(self.branch_company)
+ .show_sync_actions
+ )
+
+ self.assertFalse(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company)._ids)
+ .with_company(self.branch_company)
+ .show_sync_actions
+ )
+
+ def test_show_bank_connect(self):
+ """We test if the 'connect' bank button appears on the journal on the dashboard given the selected company.
+
+ Let's have company A with an bank journal, and a branch of that company: company B.
+
+ - On the dashboard of company A, the connect bank button should appear on the journal.
+ - On the dashboard of company B, the connect bank button should not appear on the journal, even with company A enabled.
+ """
+ dashboard_data = self.mother_bank_journal\
+ .with_context(allowed_company_ids=(self.mother_company)._ids)\
+ .with_company(self.mother_company)\
+ ._get_journal_dashboard_data_batched()
+ self.assertTrue(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard'))
+
+ dashboard_data = self.mother_bank_journal\
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)\
+ .with_company(self.branch_company)\
+ ._get_journal_dashboard_data_batched()
+ self.assertFalse(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard'))
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_creation_statement.py b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_creation_statement.py
new file mode 100644
index 0000000..570717b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_synchronization/tests/test_online_sync_creation_statement.py
@@ -0,0 +1,374 @@
+# -*- coding: utf-8 -*-
+# Part of ODEX. See LICENSE file for full copyright and licensing details.
+from unittest.mock import MagicMock, patch
+
+from odoo.addons.base.models.res_bank import sanitize_account_number
+from odoo.addons.odex30_account_online_synchronization.tests.common import AccountOnlineSynchronizationCommon
+from odoo.exceptions import RedirectWarning
+from odoo.tests import tagged
+from odoo import fields, Command
+
+
+@tagged('post_install', '-at_install')
+class TestSynchStatementCreation(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.account = cls.env['account.account'].create({
+ 'name': 'Fixed Asset Account',
+ 'code': 'AA',
+ 'account_type': 'asset_fixed',
+ })
+
+ def reconcile_st_lines(self, st_lines):
+ for line in st_lines:
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=line.id).new({})
+ line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance')
+ wizard._js_action_mount_line_in_edit(line.index)
+ line.name = "toto"
+ wizard._line_value_changed_name(line)
+ line.account_id = self.account
+ wizard._line_value_changed_account_id(line)
+ wizard._action_validate()
+
+ # Tests
+ def test_creation_initial_sync_statement(self):
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.account_online_account.balance = 1000
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement
+ # it should create a statement before this one with the initial statement line
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertEqual(len(created_st_lines), 3, 'Should have created an initial bank statement line and two for the synchronization')
+ transactions = self._create_online_transactions(['2016-01-05'])
+ self.account_online_account.balance = 2000
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 980.0},
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-05'), 'amount': 10.0},
+ ]
+ )
+
+ def test_creation_initial_sync_statement_bis(self):
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.account_online_account.balance = 20
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 20$ and we only have 20$ of transactions and that it is the first statement
+ # it should NOT create a initial statement before this one
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0},
+ ]
+ )
+
+ def test_creation_initial_sync_statement_invert_sign(self):
+ self.account_online_account.balance = -20
+ self.account_online_account.inverse_transaction_sign = True
+ self.account_online_account.inverse_balance_sign = True
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement
+ # it should create a statement before this one with the initial statement line
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertEqual(len(created_st_lines), 2, 'Should have created two bank statement lines for the synchronization')
+ transactions = self._create_online_transactions(['2016-01-05'])
+ self.account_online_account.balance = -30
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': -10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': -10.0},
+ {'date': fields.Date.from_string('2016-01-05'), 'amount': -10.0},
+ ]
+ )
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_transactions')
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._update_connection_status')
+ def test_automatic_journal_assignment(self, patched_update_connection_status, patched_fetch_transactions):
+ def create_online_account(name, link_id, iban, currency_id):
+ return self.env['account.online.account'].create({
+ 'name': name,
+ 'account_online_link_id': link_id,
+ 'account_number': iban,
+ 'currency_id' : currency_id,
+ })
+
+ def create_bank_account(account_number, partner_id):
+ return self.env['res.partner.bank'].create({
+ 'acc_number': account_number,
+ 'partner_id': partner_id,
+ })
+
+ def create_journal(name, journal_type, code, currency_id=False, bank_account_id=False):
+ return self.env['account.journal'].create({
+ 'name': name,
+ 'type': journal_type,
+ 'code': code,
+ 'currency_id': currency_id,
+ 'bank_account_id': bank_account_id,
+ })
+
+ bank_account_1 = create_bank_account('BE48485444456727', self.company_data['company'].partner_id.id)
+ bank_account_2 = create_bank_account('BE23798242487491', self.company_data['company'].partner_id.id)
+
+ bank_journal_with_account_gol = create_journal('Bank with account', 'bank', 'BJWA1', self.other_currency.id)
+ bank_journal_with_account_usd = create_journal('Bank with account USD', 'bank', 'BJWA3', self.env.ref('base.USD').id, bank_account_2.id)
+
+ online_account_1 = create_online_account('OnlineAccount1', self.account_online_link.id, 'BE48485444456727', self.other_currency.id)
+ online_account_2 = create_online_account('OnlineAccount2', self.account_online_link.id, 'BE61954856342317', self.other_currency.id)
+ online_account_3 = create_online_account('OnlineAccount3', self.account_online_link.id, 'BE23798242487495', self.other_currency.id)
+
+ patched_fetch_transactions.return_value = True
+ patched_update_connection_status.return_value = {
+ 'consent_expiring_date': None,
+ 'is_payment_enabled': False,
+ 'is_payment_activated': False,
+ }
+
+ account_link_journal_wizard = self.env['account.bank.selection'].create({'account_online_link_id': self.account_online_link.id})
+ account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_gol.id).sync_now()
+ self.assertEqual(
+ online_account_1.id, bank_journal_with_account_gol.account_online_account_id.id,
+ "The wizard should have linked the online account to the journal with the same account."
+ )
+ self.assertEqual(bank_journal_with_account_gol.bank_account_id, bank_account_1, "Account should be set on the journal")
+
+ # Test with no context present, should create a new journal
+ previous_number = self.env['account.journal'].search_count([])
+ account_link_journal_wizard.selected_account = online_account_2
+ account_link_journal_wizard.sync_now()
+ actual_number = self.env['account.journal'].search_count([])
+ self.assertEqual(actual_number, previous_number+1, "should have created a new journal")
+ self.assertEqual(online_account_2.journal_ids.currency_id, self.other_currency)
+ self.assertEqual(online_account_2.journal_ids.bank_account_id.sanitized_acc_number, sanitize_account_number('BE61954856342317'))
+
+ # Test assigning to a journal in another currency
+ account_link_journal_wizard.selected_account = online_account_3
+ account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_usd.id).sync_now()
+ self.assertEqual(online_account_3.id, bank_journal_with_account_usd.account_online_account_id.id)
+ self.assertEqual(bank_journal_with_account_usd.bank_account_id, bank_account_2, "Bank Account should not have changed")
+ self.assertEqual(bank_journal_with_account_usd.currency_id, self.other_currency, "Currency should have changed")
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_transaction_date_start(self, patched_fetch):
+ """ This test verifies that the start_date params used when fetching transaction is correct """
+ patched_fetch.return_value = {'transactions': []}
+ # Since no transactions exists in db, we should fetch transactions without a starting_date
+ self.account_online_account._retrieve_transactions()
+ data = {
+ 'start_date': False,
+ 'account_id': False,
+ 'last_transaction_identifier': False,
+ 'currency_code': 'EUR',
+ 'provider_data': False,
+ 'account_data': False,
+ 'include_pendings': False,
+ 'include_foreign_currency': True,
+ }
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ # No transaction exists in db but we have a value for last_sync on the online_account, we should use that date
+ self.account_online_account.last_sync = '2020-03-04'
+ data['start_date'] = '2020-03-04'
+ self.account_online_account._retrieve_transactions()
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ # We have transactions, we should use the date of the latest one instead of the last_sync date
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ self.account_online_account.last_sync = '2020-03-04'
+ data['start_date'] = '2016-01-03'
+ data['last_transaction_identifier'] = '2'
+ self.account_online_account._retrieve_transactions()
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ def test_multiple_transaction_identifier_fetched(self):
+ # Ensure that if we receive twice the same transaction within the same call, it won't be created twice
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ # Add first transactions to the list again
+ transactions.append(transactions[0])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ bnk_stmt_lines = self.BankStatementLine.search([('online_transaction_identifier', '!=', False), ('journal_id', '=', self.euro_bank_journal.id)])
+ self.assertEqual(len(bnk_stmt_lines), 2, 'Should only have created two lines')
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_transactions_reauth(self, patched_refresh):
+ patched_refresh.side_effect = [
+ {
+ 'success': False,
+ 'code': 300,
+ 'data': {'mode': 'updateCredentials'},
+ },
+ {
+ 'access_token': 'open_sesame',
+ },
+ ]
+ self.account_online_account.account_online_link_id.state = 'connected'
+ res = self.account_online_account.account_online_link_id._fetch_transactions()
+ self.assertTrue('account_online_identifier' in res.get('params', {}).get('includeParam', {}))
+
+ def test_duplicate_transaction_date_amount_account(self):
+ """ This test verifies that the duplicate transaction wizard is detects transactions with
+ same date, amount, account_number and currency
+ """
+ # Create 2 groups of respectively 2 and 3 duplicate transactions. We create one transaction the day before so the opening statement does not interfere with the test.
+ transactions = self._create_online_transactions([
+ '2024-01-01',
+ '2024-01-02', '2024-01-02',
+ '2024-01-03', '2024-01-03', '2024-01-03',
+ ])
+ bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db
+ duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ group_1 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-02')).ids
+ group_2 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-03')).ids
+
+ self.assertEqual(duplicate_transactions, [group_1, group_2])
+
+ # check has_duplicate_transactions
+ has_duplicate_transactions = self.euro_bank_journal._has_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ self.assertTrue(has_duplicate_transactions is True) # explicit check on bool type
+
+ def test_duplicate_transaction_online_transaction_identifier(self):
+ """ This test verifies that the duplicate transaction wizard is detects transactions with
+ same online_transaction_identifier
+ """
+ # Create transactions
+ transactions = self._create_online_transactions([
+ '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'
+ ])
+ bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+
+ group_1, group_2 = [], []
+ for bsl in bsls:
+ # have to update the online_transaction_identifier after to force duplicates
+ if bsl.payment_ref in ('transaction_1', 'transaction_2'):
+ group_1.append(bsl.id)
+ bsl.online_transaction_identifier = 'same_oti_1'
+ if bsl.payment_ref in ('transaction_3, transaction_4, transaction_5'):
+ group_2.append(bsl.id)
+ bsl.online_transaction_identifier = 'same_oti_2'
+
+ self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db
+ duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ self.assertEqual(duplicate_transactions, [group_1, group_2])
+
+ @patch('odoo.addons.odex30_account_online_synchronization.models.account_online.requests')
+ def test_fetch_receive_error_message(self, patched_request):
+ # We want to test that when we receive an error, a redirectWarning with the correct parameter is thrown
+ # However the method _log_information that we need to test for that is performing a rollback as it needs
+ # to save the message error on the record as well (so it rollback, save message, commit, raise error).
+ # So in order to test the method, we need to use a "test cursor".
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'error': {
+ 'code': 400,
+ 'message': 'Shit Happened',
+ 'data': {
+ 'exception_type': 'random',
+ 'message': 'This kind of things can happen.',
+ 'error_reference': 'abc123',
+ 'provider_type': 'theonlyone',
+ 'redirect_warning_url': 'odoo_support',
+ },
+ },
+ }
+ patched_request.post.return_value = mock_response
+
+ generated_url = 'https://www.odoo.com/help?stage=bank_sync&summary=Bank+sync+error+ref%3A+abc123+-+Provider%3A+theonlyone+-+Client+ID%3A+client_id_1&description=ClientID%3A+client_id_1%0AInstitution%3A+Test+Bank%0AError+Reference%3A+abc123%0AError+Message%3A+This+kind+of+things+can+happen.%0A'
+ return_act_url = {
+ 'type': 'ir.actions.act_url',
+ 'url': generated_url
+ }
+ body_generated_url = generated_url.replace('&', '&') #in post_message, & has been escaped to &
+ message_body = f"""
This kind of things can happen.
+
+If you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly. You can contact ODEX support Here
"""
+
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+ try:
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ test_link_account = self.account_online_link.with_env(test_env)
+ test_link_account.state = 'connected'
+
+ # this hand-written self.assertRaises() does not roll back self.cr,
+ # which is necessary below to inspect the message being posted
+ try:
+ test_link_account._fetch_odoo_fin('/testthisurl')
+ except RedirectWarning as exception:
+ self.assertEqual(exception.args[0], "This kind of things can happen.\n\nIf you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.")
+ self.assertEqual(exception.args[1], return_act_url)
+ self.assertEqual(exception.args[2], 'Report issue')
+ else:
+ self.fail("Expected RedirectWarning not raised")
+ self.assertEqual(test_link_account.message_ids[0].body, message_body)
+ finally:
+ self.env.registry.leave_test_mode()
+
+ def test_account_online_link_having_journal_ids(self):
+ """ This test verifies that the account online link object
+ has all the journal in the field journal_ids.
+ It's important to handle these journals because we need
+ them to add the consent expiring date.
+ """
+ # Create a bank sync connection having 2 online accounts (with one journal connected for each account)
+ online_link = self.env['account.online.link'].create({
+ 'name': 'My New Bank connection',
+ })
+ online_accounts = self.env['account.online.account'].create([
+ {
+ 'name': 'Account 1',
+ 'account_online_link_id': online_link.id,
+ 'journal_ids': [Command.create({
+ 'name': 'Account 1',
+ 'code': 'BK1',
+ 'type': 'bank',
+ })],
+ },
+ {
+ 'name': 'Account 2',
+ 'account_online_link_id': online_link.id,
+ 'journal_ids': [Command.create({
+ 'name': 'Account 2',
+ 'code': 'BK2',
+ 'type': 'bank',
+ })],
+ },
+ ])
+ self.assertEqual(online_link.account_online_account_ids, online_accounts)
+ self.assertEqual(len(online_link.journal_ids), 2) # Our online link connections should have 2 journals.
+
+ def test_transaction_details_json_compatibility_from_html(self):
+ """ This test checks that, after being imported from the transient model
+ the records of account.bank.statement.line will have the
+ 'transaction_details' field able to be decoded to a JSON,
+ i.e. it is not encapsulated in