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 + + + + + + + + + + Connected until + + + + Extend Connection + + + +
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.js new file mode 100644 index 0000000..2260b14 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.js @@ -0,0 +1,41 @@ +/** @odoo-module **/ + +import { onMounted, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { RadioField, radioField } from "@web/views/fields/radio/radio_field"; +import { useService } from '@web/core/utils/hooks'; + + +class OnlineAccountRadio extends RadioField { + static template = "odex30_account_online_synchronization.OnlineAccountRadio"; + setup() { + super.setup(); + this.orm = useService("orm"); + this.state = useState({balances: {}}); + + onMounted(async () => { + this.state.balances = await this.loadData(); + // Make sure the first option is selected by default. + this.onChange(this.items[0]); + }); + } + + async loadData() { + const ids = this.items.map(i => i[0]); + return await this.orm.call("account.online.account", "get_formatted_balances", [ids]); + } + + getBalanceName(itemID) { + return this.state.balances?.[itemID]?.[0] ?? "Loading ..."; + } + + isNegativeAmount(itemID) { + // In case of the value is undefined, it will return false as intended. + return this.state.balances?.[itemID]?.[1] < 0; + } +} + +registry.category("fields").add("online_account_radio", { + ...radioField, + component: OnlineAccountRadio, +}); diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.xml b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.xml new file mode 100644 index 0000000..d39c7d7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/online_account_radio/online_account_radio.xml @@ -0,0 +1,29 @@ + + + +
+ +
+ +
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js new file mode 100644 index 0000000..975c859 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js @@ -0,0 +1,99 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { useService } from "@web/core/utils/hooks"; +import { Component, useState, onWillStart, markup } from "@odoo/owl"; + +class RefreshSpin extends Component { + static template = "odex30_account_online_synchronization.RefreshSpin"; + static props = { ...standardWidgetProps }; + + setup() { + this.state = useState({ + isHovered: false, + fetchingStatus: false, + connectionStateDetails: null, + }); + + this.actionService = useService("action"); + this.busService = this.env.services.bus_service; + this.orm = useService("orm"); + this.state.fetchingStatus = this.props.record.data.online_sync_fetching_status; + + this.busService.subscribe("online_sync", (notification) => { + if (notification?.id === this.recordId && notification?.connection_state_details) { + this.state.connectionStateDetails = notification.connection_state_details; + } + }); + + onWillStart(() => { + this._initConnectionStateDetails(); + }); + } + + refresh() { + this.actionService.restore(this.actionService.currentController.jsId); + } + + onMouseEnter() { + this.state.isHovered = true; + } + + onMouseLeave() { + this.state.isHovered = false; + } + + async openAction() { + /** + * This function is used to open the action that the asynchronous process saved + * on the databsase. It allows users to call the action when they want and not when + * the process is over. + */ + const action = await this.orm.call( + "account.journal", + "action_open_dashboard_asynchronous_action", + [this.recordId], + ); + this.actionService.doAction(action); + this.state.connectionStateDetails = null; + } + + async fetchTransactions() { + /** + * This function call the function to fetch transactions. + * In the main case, we don't do anything after calling the function. + * The idea is that websockets will update the status by themselves. + * In one specific case, we have to return an action to the user to open + * the ODEX Fin iframe to refresh the connection. + */ + this.state.connectionStateDetails = { status: "fetching" }; + const action = await this.orm.call("account.journal", "manual_sync", [this.recordId]); + if (action) { + action.help = markup(action.help); + this.actionService.doAction(action); + } + } + + _initConnectionStateDetails() { + /** + * This function is used to get the last state of the connection (if there is one) + */ + const kanbanDashboardData = JSON.parse(this.props.record.data.kanban_dashboard); + this.state.connectionStateDetails = kanbanDashboardData?.connection_state_details; + } + + get recordId() { + return this.props.record.data.id; + } + + get connectionStatus() { + return this.state.connectionStateDetails?.status; + } +} + +export const refreshSpin = { + component: RefreshSpin, +}; + +registry.category("view_widgets").add("refresh_spin_widget", refreshSpin); diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml new file mode 100644 index 0000000..2fd6bdc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml @@ -0,0 +1,42 @@ + + + + +
+ + + transactions fetched + + + + 0 transaction fetched + +
+
+ +
+ + See error + + +
+
+ +
+ + Refresh + + + Fetching... + +
+
+ + + Fetch Transactions + + +
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js new file mode 100644 index 0000000..78da268 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { ListRenderer } from "@web/views/list/list_renderer"; +import { ListController } from "@web/views/list/list_controller"; +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { useService } from "@web/core/utils/hooks"; + +export class TransientBankStatementLineListController extends ListController { + + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + } + + async onClickImportTransactions() { + const resIds = await this.getSelectedResIds(); + const resultAction = await this.orm.call("account.bank.statement.line.transient", "action_import_transactions", [resIds]); + this.action.doAction(resultAction); + } +} + +export class TransientBankStatementLineListRenderer extends ListRenderer { + + static template = "odex30_account_online_synchronization.TransientBankStatementLineRenderer"; + + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + } + + async openManualEntries() { + if (this.env.searchModel.context.active_model === "account.missing.transaction.wizard" && this.env.searchModel.context.active_ids) { + const activeIds = this.env.searchModel.context.active_ids; + const action = await this.orm.call("account.missing.transaction.wizard", "action_open_manual_bank_statement_lines", activeIds); + this.action.doAction(action); + } + } + +} + +export const TransientBankStatementLineListView = { + ...listView, + Renderer: TransientBankStatementLineListRenderer, + Controller: TransientBankStatementLineListController, + buttonTemplate: "TransientBankStatementLineButtonTemplate", +} + +registry.category("views").add("transient_bank_statement_line_list_view", TransientBankStatementLineListView); diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml new file mode 100644 index 0000000..54aadd1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban.js b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban.js new file mode 100644 index 0000000..bd42d29 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban.js @@ -0,0 +1,19 @@ +import { patch } from "@web/core/utils/patch"; +import { BankRecKanbanController } from "@odex30_account_accountant/components/bank_reconciliation/kanban"; + +patch(BankRecKanbanController.prototype, { + setup() { + super.setup(); + this.displayDuplicateWarning = !!this.props.context.duplicates_from_date; + }, + async onWarningClick () { + const { context } = this.env.searchModel; + return this.action.doActionButton({ + type: "object", + resModel: "account.journal", + name:"action_open_duplicate_transaction_wizard", + resId: this.state.journalId, + args: JSON.stringify([context.duplicates_from_date]), + }) + }, +}) diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban_controller.xml b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban_controller.xml new file mode 100644 index 0000000..e3bd7cd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/static/src/components/views/account_online_authorization_kanban_controller.xml @@ -0,0 +1,12 @@ + + + + + + + + + + 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

tags. + """ + transaction = self._create_one_online_transaction() + transaction['transaction_details'] = '{\n "account_id": "1",\n "status": "posted"\n}' + transient_transaction = self.env['account.bank.statement.line.transient'].create(transaction) + transaction_details = transient_transaction.read(fields=['transaction_details'], load=None)[0]['transaction_details'] + self.assertFalse(transaction_details.startswith('

'), 'Transient transaction details should not start with

when read.') + self.assertFalse(transaction_details.endswith('

'), 'Transient transaction details should not end with

when read.') diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/views/account_bank_statement_view.xml b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_bank_statement_view.xml new file mode 100644 index 0000000..678dc78 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_bank_statement_view.xml @@ -0,0 +1,27 @@ + + + + + bank.statement.line.list.inherit + account.bank.statement.line + + + + + + + + + + + + account.bank.statement.line.form.bank_rec_widget.inherit + account.bank.statement.line + primary + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_dashboard_view.xml b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_dashboard_view.xml new file mode 100644 index 0000000..5d597bf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_dashboard_view.xml @@ -0,0 +1,96 @@ + + + + + account.journal.dashboard.inherit.online.sync + account.journal + + + + + + + + + + + + + + + + + + Get Paid online. Send electronic invoices. + Let artificial intelligence scan your bill. Pay easily. + Connect your bank. Match invoices automatically. + + + + + + + + + +
+ + + + + + + + + + + + +
+
+ + + + + + + account.group_account_manager + + dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6' + + + + account.group_account_manager + + dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6' + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_view.xml b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_view.xml new file mode 100644 index 0000000..4e356f0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_journal_view.xml @@ -0,0 +1,22 @@ + + + + + account.journal.form.online.sync + account.journal + + + + + + +
+ + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_portal_templates.xml b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_portal_templates.xml new file mode 100644 index 0000000..6328099 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_portal_templates.xml @@ -0,0 +1,59 @@ + + + + diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_views.xml b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_views.xml new file mode 100644 index 0000000..e43ecef --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/views/account_online_sync_views.xml @@ -0,0 +1,126 @@ + + + + + account.online.link.form + account.online.link + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.py b/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.py new file mode 100644 index 0000000..dc2a5c9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.py @@ -0,0 +1,78 @@ +# Part of ODEX. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import format_date + + +class AccountMissingTransaction(models.TransientModel): + _name = 'account.missing.transaction.wizard' + _description = 'Wizard for missing transactions' + + date = fields.Date( + string="Starting Date", + default=lambda self: fields.Date.today() - relativedelta(months=1), + ) + journal_id = fields.Many2one( + comodel_name='account.journal', + domain="[('type', '=', 'bank'), ('account_online_account_id', '!=', 'False'), ('account_online_link_state', '=', 'connected')]" + ) + + def _get_manual_bank_statement_lines(self): + return self.env['account.bank.statement.line'].search( + domain=[ + ('date', '>=', self.date), + ('journal_id', '=', self.journal_id.id), + ('online_transaction_identifier', '=', False), + ], + ) + + def action_fetch_missing_transaction(self): + self.ensure_one() + + if not self.journal_id: + raise UserError(_("You have to select one journal to continue.")) + + if not self.date: + raise UserError(_("Please enter a valid Starting Date to continue.")) + + if self.journal_id.account_online_link_state != 'connected': + raise UserError(_("You can't find missing transactions for a journal that isn't connected.")) + + fetched_transactions = self.journal_id.account_online_account_id._retrieve_transactions(date=self.date, include_pendings=True) + transactions = fetched_transactions.get('transactions') or [] + pendings = fetched_transactions.get('pendings') or [] + + pendings = [{**pending, 'state': 'pending'} for pending in pendings] + filtered_transactions = self.journal_id.account_online_account_id._get_filtered_transactions(transactions + pendings) + + transient_transactions_ids = self.env['account.bank.statement.line.transient'].create(filtered_transactions) + + return { + 'name': _("Missing and Pending Transactions"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement.line.transient', + 'view_mode': 'list', + 'views': [(False, 'list')], + 'domain': [('id', 'in', transient_transactions_ids.ids)], + 'context': { + 'has_manual_entries': bool(self._get_manual_bank_statement_lines()), + 'is_fetch_before_creation': self.date < self.journal_id.account_online_link_id.create_date.date(), + 'account_online_link_create_date': format_date(self.env, self.journal_id.account_online_link_id.create_date), + 'search_default_filter_posted': bool([transaction for transaction in filtered_transactions if transaction.get('state') != 'pending']), # Activate this default filter only if we have posted transactions + }, + } + + def action_open_manual_bank_statement_lines(self): + self.ensure_one() + bank_statement_lines = self._get_manual_bank_statement_lines() + + return { + 'name': _("Manual Bank Statement Lines"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement.line', + 'views': [(False, 'list'), (False, 'form')], + 'domain': [('id', 'in', bank_statement_lines.ids)], + } diff --git a/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.xml b/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.xml new file mode 100644 index 0000000..5d85de2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_synchronization/wizard/account_journal_missing_transactions.xml @@ -0,0 +1,22 @@ + + + + account.missing.transaction.wizard.form + account.missing.transaction.wizard + +
+

+ Choose a date and a journal from which you want to fetch transactions +

+ + + + + +
+
+
+