Merge pull request #86 from expsa/khazraji_acount

fetch report
This commit is contained in:
mohammed-alkhazrji 2026-01-19 16:48:58 +03:00 committed by GitHub
commit a0c25cb29f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 927 additions and 7728 deletions

View File

@ -41,11 +41,14 @@
'account_chart_of_accounts/static/src/js/account_type_selection_extend.js', 'account_chart_of_accounts/static/src/js/account_type_selection_extend.js',
'account_chart_of_accounts/static/src/js/filters_patch.js', 'account_chart_of_accounts/static/src/js/filters_patch.js',
'account_chart_of_accounts/static/src/js/account_report.js', 'account_chart_of_accounts/static/src/js/account_report.js',
'account_chart_of_accounts/static/src/js/account_list_renderer.js',
'account_chart_of_accounts/static/src/js/search_panel_bold.js',
'account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml', 'account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml',
], # CRITICAL: SCSS must be in backend, not frontend!
'web.assets_frontend': [
'account_chart_of_accounts/static/src/scss/account_hierarchy.scss', 'account_chart_of_accounts/static/src/scss/account_hierarchy.scss',
], ],
}, },
'installable': True, 'installable': True,

View File

@ -0,0 +1,317 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_chart_of_accounts
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-18 03:36+0000\n"
"PO-Revision-Date: 2026-01-18 03:36+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: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.view_account_form_inherit_hierarchy
msgid ""
"<span class=\"badge text-bg-success ms-2\" invisible=\"not automticAccountsCodes\" title=\"Code is auto-generated\">\n"
" Auto\n"
" </span>"
msgstr ""
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_account_account
msgid "Account"
msgstr "الحساب "
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Account Code Padding"
msgstr "حشو كود الحساب"
#. module: account_chart_of_accounts
#: model:ir.model.fields,help:account_chart_of_accounts.field_account_account__account_type
msgid ""
"Account Type is used for information purpose, to generate country-specific "
"legal reports, and set the rules to close a fiscal year and generate opening"
" entries."
msgstr ""
"يُستخدم نوع الحساب في إنشاء التقارير القانونية المخصصة لكل دولة، وتضع "
"القواعد اللازمة لإقفال السنة المالية وإنشاء القيود الافتتاحية للسنة المالية "
"اللاحقة. "
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_account_report
msgid "Accounting Report"
msgstr "تقرير المحاسبة "
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Assets"
msgstr "الأصول"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__auto_code
msgid "Auto Code"
msgstr "كود آلي"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Auto Generate Account Codes"
msgstr "توليد أكواد الحسابات تلقائياً"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__automticAccountsCodes
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_company__automticAccountsCodes
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__automticAccountsCodes
msgid "Automatically Generate Accounts Codes"
msgstr "توليد أكواد الحسابات تلقائياً"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Automatically generate account codes based on parent account"
msgstr "توليد أكواد الحسابات تلقائياً بناءً على الحساب الأب"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Balance Sheet"
msgstr "الميزانية العمومية"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Bank Account Prefix"
msgstr "بادئة حساب البنك"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__bank_account_code_prefix
msgid "Bank Prefix"
msgstr "بادئة البنك"
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/account_journal.py:0
msgid "Can not find account with code (%s)."
msgstr "لا يمكن إيجاد حساب بالكود (%s)."
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/account_journal.py:0
msgid ""
"Cannot generate an unused journal code. Please fill the 'Shortcode' field."
msgstr "لا يمكن توليد كود دفتر يومية غير مستخدم. يرجى ملء حقل 'الرمز المختصر'."
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Cash Account Prefix"
msgstr "بادئة حساب النقدية"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__cash_account_code_prefix
msgid "Cash Prefix"
msgstr "بادئة النقدية"
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/res_config_settings.py:0
msgid "Chart of Accounts"
msgstr "دليل الحسابات"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Chart of Accounts Hierarchy"
msgstr "التسلسل الهرمي لدليل الحسابات"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_company__chart_account_padding
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__chart_account_padding
msgid "Chart of accounts Padding"
msgstr "حشو دليل الحسابات"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_company__chart_account_length
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__chart_account_length
msgid "Chart of accounts length"
msgstr "طول دليل الحسابات"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__child_ids
msgid "Child Accounts"
msgstr "الحسابات الفرعية"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.view_account_form_inherit_hierarchy
msgid "Code"
msgstr "الكود"
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_res_company
msgid "Companies"
msgstr "الشركات"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__company_id
msgid "Company"
msgstr "الشركة"
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_res_config_settings
msgid "Config Settings"
msgstr "تهيئة الإعدادات "
#. module: account_chart_of_accounts
#: model:ir.model.fields,help:account_chart_of_accounts.field_account_report__filter_show_full_hierarchy
msgid "Enable filter to display accounts in full hierarchical structure"
msgstr "تفعيل الفلتر لعرض الحسابات في هيكل هرمي كامل"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Equity"
msgstr "حقوق الملكية"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Expense"
msgstr "المصروفات"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml:0
msgid "Full Hierarchy"
msgstr "التسلسل الهرمي الكامل"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.view_account_search_inherit
msgid "Hierarchical Chart"
msgstr "الدليل الهرمي"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Income"
msgstr "الإيرادات"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__internal_group
msgid "Internal Group"
msgstr "مجموعة داخلية"
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_account_journal
msgid "Journal"
msgstr "دفتر اليومية"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__level
msgid "Level"
msgstr "المستوى"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Liabilities"
msgstr "الخصوم"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Number of digits for account code padding"
msgstr "عدد الأرقام لحشو كود الحساب"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Other"
msgstr "أخرى"
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/account_journal.py:0
msgid "Outstanding Payments"
msgstr "مدفوعات معلقة"
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/account_journal.py:0
msgid "Outstanding Receipts"
msgstr "مقبوضات معلقة"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__parent_id
msgid "Parent Account"
msgstr "الحساب الأب"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_company__parent_bank_cash_account_id
msgid "Parent Bank Cash Account"
msgstr "حساب البنك النقدي الأب"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__parent_path
msgid "Parent Path"
msgstr "المسار الأب"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Prefix for bank account codes"
msgstr "بادئة لأكواد حسابات البنك"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Prefix for cash account codes"
msgstr "بادئة لأكواد حسابات النقدية"
#. module: account_chart_of_accounts
#. odoo-javascript
#: code:addons/account_chart_of_accounts/static/src/js/account_type_selection_extend.js:0
msgid "Profit & Loss"
msgstr "الأرباح والخسائر"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_report__filter_show_full_hierarchy
msgid "Show Full Hierarchy Filter"
msgstr "عرض فلتر التسلسل الهرمي الكامل"
#. module: account_chart_of_accounts
#. odoo-python
#: code:addons/account_chart_of_accounts/models/account_account.py:0
msgid "This account level is greater than the chart of account length."
msgstr "مستوى هذا الحساب أكبر من طول دليل الحسابات."
#. module: account_chart_of_accounts
#: model:ir.model,name:account_chart_of_accounts.model_account_trial_balance_report_handler
msgid "Trial Balance Custom Handler"
msgstr "معالج مخصص لميزان المراجعة "
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_account_account__account_type
msgid "Type"
msgstr "النوع"
#. module: account_chart_of_accounts
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_company__useFiexedTree
#: model:ir.model.fields,field_description:account_chart_of_accounts.field_res_config_settings__useFiexedTree
msgid "Use Fixed Length Chart of accounts"
msgstr "استخدام دليل حسابات بطول ثابت"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.res_config_settings_view_form_inherit
msgid "Use fixed length chart of accounts structure"
msgstr "استخدام هيكل دليل حسابات بطول ثابت"
#. module: account_chart_of_accounts
#: model:ir.model.fields.selection,name:account_chart_of_accounts.selection__account_account__account_type__view
#: model:ir.model.fields.selection,name:account_chart_of_accounts.selection__account_account__internal_group__view
msgid "View"
msgstr "إجمالي"
#. module: account_chart_of_accounts
#: model_terms:ir.ui.view,arch_db:account_chart_of_accounts.view_account_form_inherit_hierarchy
msgid "e.g. 101000"
msgstr "مثال: 101000"

View File

@ -139,49 +139,56 @@ class AccountAccount(models.Model):
@api.model @api.model
def search_panel_select_range(self, field_name, **kwargs): def search_panel_select_range(self, field_name, **kwargs):
"""
Override to show hierarchical accounts in search panel.
Root level: Only show view accounts with children
Sub-levels: Show all children when parent is expanded
"""
if field_name != 'parent_id': if field_name != 'parent_id':
return super().search_panel_select_range(field_name, **kwargs) return super().search_panel_select_range(field_name, **kwargs)
enable_counters = kwargs.get('enable_counters', False) enable_counters = kwargs.get('enable_counters', False)
search_domain = kwargs.get('search_domain', []) search_domain = kwargs.get('search_domain', [])
# Get ALL accounts for hierarchy
all_accounts = self.search([], order='code')
# Filter accounts to show:
# - Root level (parent_id = False): ONLY view accounts WITH children
# - Sub levels: ALL accounts (shown when parent is expanded)
accounts_to_show = all_accounts.filtered(
lambda a: (not a.parent_id and a.account_type == 'view' and a.child_ids) or a.parent_id
)
all_view_accounts = self.search([('account_type', '=', 'view')], order='code') count_by_account = {}
count_by_parent = {}
if enable_counters: if enable_counters:
all_accounts = self.search(search_domain) # Count records that match the search domain for each account
filtered_accounts = self.search(search_domain)
for account in all_accounts:
if account.parent_id: for account in filtered_accounts:
parent_id = account.parent_id.id # ✅ ONLY count in parent (and ancestors), not self
count_by_parent[parent_id] = count_by_parent.get(parent_id, 0) + 1 parent = account.parent_id
while parent:
ancestor = account.parent_id.parent_id count_by_account[parent.id] = count_by_account.get(parent.id, 0) + 1
while ancestor: parent = parent.parent_id
count_by_parent[ancestor.id] = count_by_parent.get(ancestor.id, 0) + 1
ancestor = ancestor.parent_id
values = [] values = []
for parent in all_view_accounts: for account in accounts_to_show:
value = { value = {
'id': parent.id, 'id': account.id,
'display_name': f"{parent.code} {parent.name}" if parent.code else parent.name, 'display_name': f"{account.code} {account.name}" if account.code else account.name,
'parent_id': parent.parent_id.id if parent.parent_id else False, 'parent_id': account.parent_id.id if account.parent_id else False,
} }
if enable_counters: if enable_counters:
value['__count'] = count_by_parent.get(parent.id, 0) value['__count'] = count_by_account.get(account.id, 0)
values.append(value) values.append(value)
result = { result = {
'parent_field': 'parent_id', 'parent_field': 'parent_id',
'values': values, 'values': values,
} }
return result return result

View File

@ -0,0 +1,31 @@
/** @odoo-module **/
import { ListRenderer } from "@web/views/list/list_renderer";
import { patch } from "@web/core/utils/patch";
patch(ListRenderer.prototype, {
/**
* Add custom class to rows with account_type='view' to make them bold
*/
setup() {
super.setup(...arguments);
},
async onCellClicked(record, column, ev) {
await super.onCellClicked(...arguments);
},
getRowClass(record) {
const classes = super.getRowClass(record) || "";
// Add bold class if account_type is 'view'
if (this.props.list.resModel === 'account.account') {
const accountType = record.data.account_type;
if (accountType === 'view') {
return `${classes} fw-bold account-view-type`;
}
}
return classes;
}
});

View File

@ -0,0 +1,61 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { SearchPanel } from "@web/search/search_panel/search_panel";
patch(SearchPanel.prototype, {
/**
* Override to add bold class to view accounts with children in search panel
*/
setup() {
super.setup(...arguments);
},
/**
* After rendering, add bold class to accounts with toggle button
*/
async onMounted() {
if (super.onMounted) {
await super.onMounted(...arguments);
}
this.makeBoldAccountsWithChildren();
},
/**
* Make accounts with children (that have toggle button) bold
*/
makeBoldAccountsWithChildren() {
// CRITICAL: HTML uses class "account_root" not "account_account"
const searchPanel = document.querySelector('.o_search_panel.account_root');
if (!searchPanel) return;
// Find ALL category values
const allItems = searchPanel.querySelectorAll('.o_search_panel_category_value');
allItems.forEach(item => {
// Check if this item has a toggle button (means it has children)
const toggleButton = item.querySelector('.o_toggle_fold');
// Only make bold if it has a toggle button (has children)
if (toggleButton && toggleButton.querySelector('i.fa-caret-down, i.fa-caret-right')) {
// Add bold class
item.classList.add('has-children-bold');
// Also add to header and label
const header = item.querySelector('header');
const label = item.querySelector('.o_search_panel_label_title');
if (header) header.classList.add('has-children-bold');
if (label) label.classList.add('has-children-bold');
} else {
// Remove bold class if it doesn't have children
item.classList.remove('has-children-bold');
const header = item.querySelector('header');
const label = item.querySelector('.o_search_panel_label_title');
if (header) header.classList.remove('has-children-bold');
if (label) label.classList.remove('has-children-bold');
}
});
},
});

View File

@ -1,5 +1,6 @@
.o_search_panel.account_account { // CRITICAL: HTML uses class "account_root" not "account_account"
.o_search_panel_field.parent_id { .o_search_panel.account_root {
.o_search_panel_field {
.o_search_panel_category_value { .o_search_panel_category_value {
.o_toggle_fold { .o_toggle_fold {
margin-right: 15px; margin-right: 15px;
@ -11,9 +12,9 @@
align-items: center; align-items: center;
padding: 3px 8px; padding: 3px 8px;
&:hover { // &:hover {
color: #f0f0f0; // color: #f0f0f0;
} // }
.o_search_panel_label_title { .o_search_panel_label_title {
flex: 1; flex: 1;
@ -25,15 +26,64 @@
} }
} }
// Levels // ONLY use JavaScript-added class (not :has() selector)
&[data-level="0"] { // Only accounts with actual toggle buttons get this class
font-weight: bold; &.has-children-bold {
font-weight: 900 !important;
header,
.o_search_panel_label_title {
font-weight: 900 !important;
}
} }
&[data-level="1"] header { padding-left: 20px; } // Levels indentation
&[data-level="2"] header { padding-left: 35px; } &[data-level="1"] header {
&[data-level="3"] header { padding-left: 50px; } padding-left: 20px;
&[data-level="4"] header { padding-left: 65px; } }
&[data-level="2"] header {
padding-left: 35px;
}
&[data-level="3"] header {
padding-left: 50px;
}
&[data-level="4"] header {
padding-left: 65px;
}
&[data-level="5"] header {
padding-left: 80px;
}
&[data-level="6"] header {
padding-left: 95px;
}
} }
} }
} }
// Make view accounts bold - using custom class from JavaScript
.o_list_view {
tr.o_data_row.account-view-type,
tr.o_data_row.fw-bold {
font-weight: 900 !important;
td.o_data_cell {
font-weight: 900 !important;
// Add text shadow to make it appear even bolder
// Slightly darker color for more contrast
color: #2c3e50 !important;
}
// Optional: Add background color like Odoo 14
// Uncomment the lines below if you want a background color
// background-color: #f5f5f5 !important;
// &:hover {
// background-color: #ebebeb !important;
// }
}
}

View File

@ -75,6 +75,11 @@
<field name="code" position="after"> <field name="code" position="after">
<field name="parent_id" optional="hide"/> <field name="parent_id" optional="hide"/>
</field> </field>
<!-- Ensure account_type is visible in DOM for CSS targeting -->
<field name="account_type" position="attributes">
<attribute name="class">account_type_field</attribute>
</field>
</field> </field>
</record> </record>

View File

@ -0,0 +1,67 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from . import models
_logger = logging.getLogger(__name__)
def _accounting_post_init(env):
country_code = env.company.country_id.code
if country_code:
module_list = []
if country_code in ('AU', 'CA', 'US'):
module_list.append('account_reports_cash_basis')
module_ids = env['ir.module.module'].search([('name', 'in', module_list), ('state', '=', 'uninstalled')])
if module_ids:
module_ids.sudo().button_install()
def uninstall_hook(env):
try:
group_user = env.ref("account.group_account_user")
group_user.write({
'name': "Show Full Accounting Features",
'implied_ids': [(3, env.ref('account.group_account_invoice').id)],
'category_id': env.ref("base.module_category_hidden").id,
})
group_readonly = env.ref("account.group_account_readonly")
group_readonly.write({
'name': "Show Full Accounting Features - Readonly",
'category_id': env.ref("base.module_category_hidden").id,
})
except ValueError as e:
_logger.warning(e)
try:
group_manager = env.ref("account.group_account_manager")
group_manager.write({'name': "Billing Manager",
'implied_ids': [(4, env.ref("account.group_account_invoice").id),
(3, env.ref("account.group_account_readonly").id),
(3, env.ref("account.group_account_user").id)]})
except ValueError as e:
_logger.warning(e)
# make the account_accountant features disappear (magic)
env.ref("account.group_account_user").write({'users': [(5, False, False)]})
env.ref("account.group_account_readonly").write({'users': [(5, False, False)]})
# this menu should always be there, as the module depends on account.
# if it's not, there is something wrong with the db that should be investigated.
invoicing_menu = env.ref("account.menu_finance")
menus_to_move = [
"account.menu_finance_receivables",
"account.menu_finance_payables",
"account.menu_finance_entries",
"account.menu_finance_reports",
"account.menu_finance_configuration",
"account.menu_board_journal_1",
]
for menu_xmlids in menus_to_move:
try:
env.ref(menu_xmlids).parent_id = invoicing_menu
except ValueError as e:
_logger.warning(e)

View File

@ -0,0 +1,37 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Accounting',
'version': '1.1',
'category': 'Accounting/Accounting',
'sequence': 30,
'summary': 'Manage financial and analytic accounting',
'description': """
Accounting Access Rights
========================
It gives the Administrator user access to all accounting features such as journal items and the chart of accounts.
It assigns manager and user access rights to the Administrator for the accounting application and only user rights to the Demo user.
""",
'website': 'https://www.odoo.com/app/accounting',
'depends': ['odex30_account_accountant'],
'data': [
'data/account_accountant_data.xml',
'security/accounting_security.xml',
'views/res_config_settings.xml',
'views/partner_views.xml',
],
'demo': ['demo/account_accountant_demo.xml'],
'installable': True,
'application': True,
'post_init_hook': '_accounting_post_init',
'uninstall_hook': "uninstall_hook",
'license': 'OEEL-1',
'assets': {
'web.assets_backend': [
'accountant/static/src/js/tours/accountant.js',
],
'web.assets_tests': [
'accountant/static/tests/tours/*',
],
}
}

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Switch root menu "Invoicing" to "Accounting" -->
<!-- Top menu item -->
<menuitem name="Accounting"
id="menu_accounting"
groups="account.group_account_readonly,account.group_account_invoice"
web_icon="accountant,static/description/icon.png"
sequence="60"/>
<!-- move existing submenus to point to the new parent -->
<record id="account.menu_finance_receivables" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_payables" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_entries" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_reports" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_configuration" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_board_journal_1" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<menuitem id="account.menu_account_config" name="Settings" parent="account.menu_finance_configuration" sequence="0" groups="base.group_system"/>
</odoo>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
</odoo>

View File

@ -0,0 +1,28 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * accountant
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-19 09:51+0000\n"
"PO-Revision-Date: 2024-12-19 09:51+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: accountant
#: model:ir.ui.menu,name:accountant.menu_accounting
#: model_terms:ir.ui.view,arch_db:accountant.res_config_settings_view_form
#: model_terms:ir.ui.view,arch_db:accountant.res_partner_view_form
msgid "Accounting"
msgstr ""
#. module: accountant
#: model:ir.model,name:accountant.model_account_move
msgid "Journal Entry"
msgstr ""

View File

@ -0,0 +1,32 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * accountant
#
# Translators:
# Wil Odoo, 2024
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-19 09:51+0000\n"
"PO-Revision-Date: 2024-09-25 09:44+0000\n"
"Last-Translator: Wil Odoo, 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: accountant
#: model:ir.ui.menu,name:accountant.menu_accounting
#: model_terms:ir.ui.view,arch_db:accountant.res_config_settings_view_form
#: model_terms:ir.ui.view,arch_db:accountant.res_partner_view_form
msgid "Accounting"
msgstr "المحاسبة "
#. module: accountant
#: model:ir.model,name:accountant.model_account_move
msgid "Journal Entry"
msgstr "قيد اليومية"

View File

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

View File

@ -0,0 +1,9 @@
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def _get_invoice_in_payment_state(self):
# OVERRIDE to enable the 'in_payment' state on invoices.
return 'in_payment'

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="base.user_root" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
<record id="base.user_admin" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
<!-- To change the categoy name from Invoicing -> Accounting-->
<record model="ir.module.category" id="base.module_category_accounting_accounting">
<field name="name">Accounting</field>
<field name="description">Helps you handle your invoices and accounting actions.
Invoicing: Invoices, payments and basic invoice reporting.
Invoicing &amp; Banks: adds the accounting dashboard, bank management and follow-up reports.
Bookkeeper: access to all Accounting features, including reporting, asset management, analytic accounting, without configuration rights.
Administrator: full access including configuration rights and accounting data management.
Readonly: access to all the accounting data but in readonly mode, no actions allowed.
</field>
</record>
<record id="account.group_account_readonly" model="res.groups">
<field name="name">Read-only</field>
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="account.group_account_user" model="res.groups">
<field name="name">Bookkeeper</field>
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="account.group_account_manager" model="res.groups">
<field name="implied_ids" eval="[(3, ref('account.group_account_invoice')), (4, ref('account.group_account_user'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M40.682 41.263c-3.514 4.188-9.758 4.734-13.946 1.22-4.189-3.514-4.735-9.758-1.22-13.947 3.514-4.188 9.758-4.734 13.946-1.22 4.188 3.515 4.734 9.759 1.22 13.947Z" fill="#1AD3BB"/><path d="M25.383 21.464c-3.514 4.188-9.758 4.734-13.946 1.22-4.188-3.514-4.735-9.758-1.22-13.946 3.514-4.189 9.758-4.735 13.946-1.22 4.188 3.514 4.734 9.758 1.22 13.946Z" fill="#FBB945"/><path d="M37.09 6.545c1.407-1.406 3.36-1.732 4.364-.728L46 10.363l-33.09 33.09c-1.407 1.406-3.36 1.732-4.365.728L4 39.636 37.09 6.544Z" fill="#985184"/><path d="m31.174 25.188-7.79 7.789a9.853 9.853 0 0 1 2.13-4.442 9.858 9.858 0 0 1 5.66-3.348Z" fill="#005E7A"/><path d="M18.676 24.962a9.868 9.868 0 0 0 6.707-3.498 9.854 9.854 0 0 0 2.278-5.487l-8.985 8.985Z" fill="#953B24"/></svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@ -0,0 +1,12 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { accountTourSteps } from "@account/js/tours/account";
import { stepUtils } from "@web_tour/tour_service/tour_utils";
patch(accountTourSteps, {
goToAccountMenu(description="Open Accounting Menu") {
return stepUtils.goToAppSteps('accountant.menu_accounting', description);
},
});

View File

@ -0,0 +1,61 @@
/** @odoo-module */
import { accountTourSteps } from "@account/js/tours/account";
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("account_merge_wizard_tour", {
url: "/odoo",
steps: () => [
...accountTourSteps.goToAccountMenu("Go to Accounting"),
{
content: "Go to Configuration",
trigger: 'span:contains("Configuration")',
run: "click",
},
{
content: "Go to Chart of Accounts",
trigger: 'a:contains("Chart of Accounts")',
run: "click",
},
{
trigger: '.o_breadcrumb .text-truncate:contains("Chart of Accounts")',
},
{
content: "Select accounts",
trigger: "thead .o_list_record_selector",
run: "click",
},
{
content: "Check that exactly 4 accounts are present and selected",
trigger: ".o_list_selection_box:contains(4):contains(selected)",
},
{
content: "Open Actions menu",
trigger: ".o_cp_action_menus .dropdown-toggle",
run: "click",
},
{
content: "Open Merge accounts wizard",
trigger: 'span:contains("Merge accounts")',
run: "click",
},
{
content: "Group by name",
trigger: 'div[name="is_group_by_name"] input',
run: "click",
},
{
content: "Wait for content to be updated",
trigger: 'td:contains("Current Assets (Current Assets)")',
},
{
content: "Merge accounts",
trigger: 'button:not([disabled]) span:contains("Merge")',
run: "click",
},
{
content: "Check that there are now exactly 2 accounts",
trigger: ".o_pager_limit:contains(2)",
},
],
});

View File

@ -0,0 +1,10 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { accountTourSteps } from "@account/js/tours/account";
import { stepUtils } from "@web_tour/tour_service/tour_utils";
patch(accountTourSteps, {
goToAccountMenu(description="Open Accounting Menu") {
return stepUtils.goToAppSteps('accountant.menu_accounting', description);
}
});

View File

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

View File

@ -0,0 +1,48 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo import Command
from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
@odoo.tests.tagged('post_install_l10n', 'post_install', '-at_install')
class TestAccountantTours(AccountTestInvoicingHttpCommon):
def test_account_merge_wizard_tour(self):
companies = self.env['res.company'].create([
{'name': 'tour_company_1'},
{'name': 'tour_company_2'},
])
self.env['account.account'].create([
{
'company_ids': [Command.set(companies[0].ids)],
'code': "100001",
'name': "Current Assets",
'account_type': 'asset_current',
},
{
'company_ids': [Command.set(companies[0].ids)],
'code': "100002",
'name': "Non-Current Assets",
'account_type': 'asset_non_current',
},
{
'company_ids': [Command.set(companies[1].ids)],
'code': "200001",
'name': "Current Assets",
'account_type': 'asset_current',
},
{
'company_ids': [Command.set(companies[1].ids)],
'code': "200002",
'name': "Non-Current Assets",
'account_type': 'asset_non_current',
},
])
self.env.ref('base.user_admin').write({
'company_id': companies[0].id,
'company_ids': [Command.set(companies.ids)],
})
self.start_tour("/odoo", 'account_merge_wizard_tour', login="admin", cookies={"cids": f"{companies[0].id}-{companies[1].id}"})

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_partner_view_form" model="ir.ui.view">
<field name="name">res.partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='accounting_disabled']" position="attributes">
<attribute name="string">Accounting</attribute>
</xpath>
<xpath expr="//page[@name='accounting']" position="attributes">
<attribute name="string">Accounting</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.accountant</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<app name="account" position="attributes">
<attribute name="data-string">Accounting</attribute>
<attribute name="string">Accounting</attribute>
<attribute name="logo">/accountant/static/description/icon.png</attribute>
</app>
</field>
</record>
</odoo>

View File

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

View File

@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': "Online Bank Statement Synchronization",
'summary': """
This module is used for Online bank synchronization.""",
'description': """
This module is used for Online bank synchronization. It provides basic methods to synchronize bank statement.
""",
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'category': 'Odex30-Accounting/Odex30-Accounting',
'version': '2.0',
'depends': ['odex30_account_accountant'],
'data': [
'data/config_parameter.xml',
'data/ir_cron.xml',
'data/mail_activity_type_data.xml',
'data/sync_reminder_email_template.xml',
'security/ir.model.access.csv',
'security/account_online_sync_security.xml',
'views/account_online_sync_views.xml',
'views/account_bank_statement_view.xml',
'views/account_journal_view.xml',
'views/account_online_sync_portal_templates.xml',
'views/account_journal_dashboard_view.xml',
'wizard/account_bank_selection_wizard.xml',
'wizard/account_journal_missing_transactions.xml',
'wizard/account_journal_duplicate_transactions.xml',
'wizard/account_bank_statement_line.xml',
],
'license': 'OEEL-1',
'auto_install': True,
'assets': {
'web.assets_backend': [
'odex30_account_online_sync/static/src/components/**/*',
'odex30_account_online_sync/static/src/js/odoo_fin_connector.js',
],
'web.assets_frontend': [
'odex30_account_online_sync/static/src/js/online_sync_portal.js',
],
'web.qunit_suite_tests': [
'odex30_account_online_sync/static/tests/helpers/*.js',
'odex30_account_online_sync/static/tests/*.js',
],
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,16 +0,0 @@
from odoo import models
class BankRecWidget(models.Model):
_inherit = 'bank.rec.widget'
def _action_validate(self):
# EXTENDS account_accountant
super()._action_validate()
line = self.st_line_id
if line.partner_id and line.online_partner_information:
# write value for account and merchant on partner only if partner has no value,
# in case value are different write False
value_merchant = line.partner_id.online_partner_information or line.online_partner_information
value_merchant = value_merchant if value_merchant == line.online_partner_information else False
line.partner_id.online_partner_information = value_merchant

View File

@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import api, models
class ResCompany(models.Model):
_inherit = "res.company"
@api.model
def setting_init_bank_account_action(self):
"""
Override the "setting_init_bank_account_action" in accounting menu
and change the flow for the "Add a bank account" menu item in dashboard.
"""
return self.env['account.online.link'].action_new_synchronization()

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_online_link_id,access_account_online_link_id,model_account_online_link,account.group_account_basic,1,1,1,0
access_account_online_link_id_readonly,access_account_online_link_id_readonly,model_account_online_link,account.group_account_readonly,1,0,0,0
access_account_online_link_id_manager,access_account_online_link_id manager,model_account_online_link,account.group_account_manager,1,1,1,1
access_account_online_account_id,access_account_online_account_id,model_account_online_account,account.group_account_basic,1,1,1,0
access_account_online_account_id_readonly,access_account_online_account_id_readonly,model_account_online_account,account.group_account_readonly,1,0,0,0
access_account_online_account_id_manager,access_account_online_account_id manager,model_account_online_account,account.group_account_manager,1,1,1,1
access_account_bank_selection_manager,access.account.bank.selection manager,model_account_bank_selection,account.group_account_manager,1,1,1,1
access_account_bank_selection,access.account.bank.selection basic,model_account_bank_selection,account.group_account_basic,1,1,1,0
access_account_bank_statement_line_transient,access_account_bank_statement_line_transient,model_account_bank_statement_line_transient,account.group_account_manager,1,1,1,1
access_account_missing_transaction_wizard,access_account_missing_transaction_wizard,model_account_missing_transaction_wizard,account.group_account_manager,1,1,1,1
access_account_duplicate_transaction_wizard,access_account_duplicate_transaction_wizard,model_account_duplicate_transaction_wizard,account.group_account_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_online_link_id access_account_online_link_id model_account_online_link account.group_account_basic 1 1 1 0
3 access_account_online_link_id_readonly access_account_online_link_id_readonly model_account_online_link account.group_account_readonly 1 0 0 0
4 access_account_online_link_id_manager access_account_online_link_id manager model_account_online_link account.group_account_manager 1 1 1 1
5 access_account_online_account_id access_account_online_account_id model_account_online_account account.group_account_basic 1 1 1 0
6 access_account_online_account_id_readonly access_account_online_account_id_readonly model_account_online_account account.group_account_readonly 1 0 0 0
7 access_account_online_account_id_manager access_account_online_account_id manager model_account_online_account account.group_account_manager 1 1 1 1
8 access_account_bank_selection_manager access.account.bank.selection manager model_account_bank_selection account.group_account_manager 1 1 1 1
9 access_account_bank_selection access.account.bank.selection basic model_account_bank_selection account.group_account_basic 1 1 1 0
10 access_account_bank_statement_line_transient access_account_bank_statement_line_transient model_account_bank_statement_line_transient account.group_account_manager 1 1 1 1
11 access_account_missing_transaction_wizard access_account_missing_transaction_wizard model_account_missing_transaction_wizard account.group_account_manager 1 1 1 1
12 access_account_duplicate_transaction_wizard access_account_duplicate_transaction_wizard model_account_duplicate_transaction_wizard account.group_account_user 1 1 1 0

View File

@ -1,37 +0,0 @@
import { formView } from "@web/views/form/form_view";
import { FormController } from "@web/views/form/form_controller";
import { registry } from "@web/core/registry";
import { useCheckDuplicateService } from "./account_duplicate_transaction_hook";
export class AccountDuplicateTransactionsFormController extends FormController {
setup() {
super.setup();
this.duplicateCheckService = useCheckDuplicateService();
}
async beforeExecuteActionButton(clickParams) {
if (clickParams.name === "delete_selected_transactions") {
const selected = this.duplicateCheckService.selectedLines;
if (selected.size) {
await this.orm.call(
"account.bank.statement.line",
"unlink",
[Array.from(selected)],
);
this.env.services.action.doAction({type: 'ir.actions.client', tag: 'reload'});
}
return false;
}
return super.beforeExecuteActionButton(...arguments);
}
get cogMenuProps() {
const props = super.cogMenuProps;
props.items.action = [];
return props;
}
}
export const form = { ...formView, Controller: AccountDuplicateTransactionsFormController };
registry.category("views").add("account_duplicate_transactions_form", form);

View File

@ -1,6 +0,0 @@
import { useService } from "@web/core/utils/hooks";
import { useState } from "@odoo/owl";
export function useCheckDuplicateService() {
return useState(useService("odex30_account_online_sync.duplicate_check_service"));
}

View File

@ -1,21 +0,0 @@
import { registry } from "@web/core/registry";
class AccountDuplicateTransactionsServiceModel {
constructor() {
this.selectedLines = new Set();
}
updateLIne(selected, id) {
this.selectedLines[selected ? "add" : "delete"](id);
}
}
const duplicateCheckService = {
start(env, services) {
return new AccountDuplicateTransactionsServiceModel();
},
};
registry
.category("services")
.add("odex30_account_online_sync.duplicate_check_service", duplicateCheckService);

View File

@ -1,50 +0,0 @@
import { onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { ListRenderer } from "@web/views/list/list_renderer";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { useCheckDuplicateService } from "./account_duplicate_transaction_hook";
export class AccountDuplicateTransactionsListRenderer extends ListRenderer {
static template = "odex30_account_online_sync.AccountDuplicateTransactionsListRenderer";
static recordRowTemplate = "odex30_account_online_sync.AccountDuplicateTransactionsRecordRow";
setup() {
super.setup();
this.duplicateCheckService = useCheckDuplicateService();
onMounted(() => {
this.deleteButton = document.querySelector('button[name="delete_selected_transactions"]');
this.deleteButton.disabled = true;
});
}
toggleRecordSelection(selected, record) {
this.duplicateCheckService.updateLIne(selected, record.data.id);
this.deleteButton.disabled = this.duplicateCheckService.selectedLines.size === 0;
}
get hasSelectors() {
return true;
}
getRowClass(record) {
let classes = super.getRowClass(record);
const firstIdsInGroup = this.env.model.root.data.first_ids_in_group;
if (firstIdsInGroup instanceof Array && firstIdsInGroup.includes(record.data.id)) {
classes += " account_duplicate_transactions_lines_list_x2many_group_line";
}
return classes;
}
}
export class AccountDuplicateTransactionsLinesListX2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: AccountDuplicateTransactionsListRenderer,
};
}
registry.category("fields").add("account_duplicate_transactions_lines_list_x2many", {
...x2ManyField,
component: AccountDuplicateTransactionsLinesListX2ManyField,
});

View File

@ -1,3 +0,0 @@
.account_duplicate_transactions_lines_list_x2many_group_line {
border-top-width: thick;
}

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="odex30_account_online_sync.AccountDuplicateTransactionsListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//th[1]" position="replace"><th><i class="fa fa-trash"/></th></xpath>
<xpath expr="//tfoot" position="replace"></xpath>
</t>
<t t-name="odex30_account_online_sync.AccountDuplicateTransactionsRecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="primary">
<xpath expr="//td[1]" position="replace">
<td class="o_list_record_selector user-select-none" tabindex="-1">
<CheckBox className="'d-flex m-0'" onChange.bind="(selected) => this.toggleRecordSelection(selected, record)"/>
</td>
</xpath>
</t>
</templates>

View File

@ -1,84 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { useService, useBus } from "@web/core/utils/hooks";
import { SIZES } from "@web/core/ui/ui_service";
import { Component, useState, useRef, onWillStart } from "@odoo/owl";
class BankConfigureWidget extends Component {
static template = "account.BankConfigureWidget";
static props = {
...standardWidgetProps,
}
setup() {
this.container = useRef("container");
this.allInstitutions = [];
this.state = useState({
isLoading: true,
institutions: [],
gridStyle: "grid-template-columns: repeat(5, minmax(90px, 1fr));"
});
this.orm = useService("orm");
this.action = useService("action");
this.ui = useService("ui");
onWillStart(this.fetchInstitutions);
useBus(this.ui.bus, "resize", this.computeGrid);
}
computeGrid() {
if (this.allInstitutions.length > 4) {
let containerWidth = this.container.el ? this.container.el.offsetWidth - 32 : 0;
// when the container width can't be computed, use the screen size and number of journals.
if (!containerWidth) {
if (this.ui.size >= SIZES.XXL) {
containerWidth = window.innerWidth / (this.props.record.model.root.count < 6 ? 2 : 3);
} else {
containerWidth = Math.max(this.ui.size * 100, 400);
}
}
const canFit = Math.floor(containerWidth / 100);
const numberOfRows = (Math.floor((this.allInstitutions.length + 1) / 2) >= canFit) + 1;
this.state.gridStyle = `grid-template-columns: repeat(${canFit}, minmax(90px, 1fr));
grid-template-rows: repeat(${numberOfRows}, 1fr);
grid-auto-rows: 0px;
`;
}
this.state.institutions = this.allInstitutions;
}
async fetchInstitutions() {
this.orm.silent.call(this.props.record.resModel, "fetch_online_sync_favorite_institutions", [this.props.record.resId])
.then((response) => {
this.allInstitutions = response;
})
.finally(() => {
this.state.isLoading = false;
this.computeGrid();
});
}
async connectBank(institutionId=null) {
const action = await this.orm.call("account.online.link", "action_new_synchronization", [[]], {
preferred_inst: institutionId,
journal_id: this.props.record.resId,
})
this.action.doAction(action);
}
async fallbackConnectBank() {
const action = await this.orm.call('account.online.link', 'create_new_bank_account_action', [], {
context: {
active_model: 'account.journal',
active_id: this.props.record.resId,
}
});
this.action.doAction(action);
}
}
export const bankConfigureWidget = {
component: BankConfigureWidget,
}
registry.category("view_widgets").add("bank_configure", bankConfigureWidget);

View File

@ -1,15 +0,0 @@
.bank_configure_container {
.d-grid {
overflow: hidden;
column-gap: 0.25rem;
}
.dashboard_bank {
aspect-ratio: 1 / 1;
.align-self-center {
background-color: $gray-100;
border: 1px solid $gray-100;
}
margin-bottom: 0.25rem;
overflow: hidden;
}
}

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="account.BankConfigureWidget">
<div class="bank_configure_container d-flex flex-wrap" t-ref="container">
<t t-if="state.isLoading">
loading...
</t>
<span t-elif="!state.institutions.length">
<button class="btn btn-primary" t-on-click="fallbackConnectBank" name="action_configure_bank_journal">Setup Bank</button>
</span>
<div class="d-grid" t-attf-style="{{state.gridStyle}}" t-if="!state.isLoading and state.institutions.length">
<div class="dashboard_bank" t-on-click="() => this.connectBank()">
<button class="h-100 w-100 rounded btn btn-primary fw-normal" name="action_configure_bank_journal">
<span class="img-fluid">Search over <span class="text-nowrap">26 000</span> banks</span>
</button>
</div>
<div t-foreach="state.institutions" t-as="institution" t-key="institution.id"
t-attf-class="dashboard_bank"
t-on-click="() => this.connectBank(institution.id)"
>
<button class="align-self-center h-100 w-100 rounded">
<span t-if="institution.default_picture" t-out="institution.name"/>
<img t-else="" class="img-fluid p-2" t-att-src="institution.picture" t-att-title="institution.name"/>
</button>
</div>
</div>
</div>
</t>
</template>

View File

@ -1,68 +0,0 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
const cogMenuRegistry = registry.category("cogMenu");
/**
* 'Fetch Missing Transactions' menu
*
* This component is used to open a wizard allowing the user to fetch their missing/pending
* transaction since a specific date.
* It's only available in the bank reconciliation widget.
* By default, if there is only one selected journal, this journal is directly selected.
* In case there is no selected journal or more than one, we let the user choose which
* journal he/she wants. This part is handled by the server.
* @extends Component
*/
export class FetchMissingTransactions extends Component {
static template = "odex30_account_online_sync.FetchMissingTransactions";
static components = { DropdownItem };
static props = {};
setup() {
this.action = useService("action");
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
async openFetchMissingTransactionsWizard() {
const { context } = this.env.searchModel;
const activeModel = context.active_model;
let activeIds = [];
if (activeModel === "account.journal") {
activeIds = context.active_ids;
} else if (!!context.default_journal_id) {
activeIds = context.default_journal_id;
}
// We have to use this.env.services.orm.call instead of using useService
// for a specific reason. useService implies that function calls with
// are "protected", it means that if the component is closed the
// response will be pending and the code stop their execution.
// By passing directly from the env, this protection is not activated.
const action = await this.env.services.orm.call(
"account.journal",
"action_open_missing_transaction_wizard",
[activeIds]
);
return this.action.doAction(action);
}
}
export const fetchMissingTransactionItem = {
Component: FetchMissingTransactions,
groupNumber: 5,
isDisplayed: ({ config, isSmall }) => {
return !isSmall &&
config.actionType === "ir.actions.act_window" &&
["kanban", "list"].includes(config.viewType) &&
["bank_rec_widget_kanban", "bank_rec_list"].includes(config.viewSubType);
},
};
cogMenuRegistry.add("fetch-missing-transaction-menu", fetchMissingTransactionItem, { sequence: 1 });

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="odex30_account_online_sync.FetchMissingTransactions">
<DropdownItem class="'o_import_menu'" onSelected.bind="openFetchMissingTransactionsWizard">
Find Missing Transactions
</DropdownItem>
</t>
</templates>

View File

@ -1,64 +0,0 @@
import { Component } from "@odoo/owl";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
/**
* 'Find Duplicate Transactions' menu
*
* This component is used to open a wizard allowing the user to find duplicate
* transactions since a specific date.
* It's only available in the bank reconciliation widget.
* By default, if there is only one selected journal, this journal is directly selected.
* In case there is no selected journal or more than one, we let the user choose.
* @extends Component
*/
export class FindDuplicateTransactions extends Component {
static template = "odex30_account_online_sync.FindDuplicateTransactions";
static components = { DropdownItem };
static props = {};
setup() {
this.action = useService("action");
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
async openFindDuplicateTransactionsWizard() {
const { context } = this.env.searchModel;
const activeModel = context.active_model;
let activeIds = [];
if (activeModel === "account.journal") {
activeIds = context.active_ids;
} else if (context.default_journal_id) {
activeIds = context.default_journal_id;
}
return this.action.doActionButton({
type: "object",
resModel: "account.journal",
name:"action_open_duplicate_transaction_wizard",
resIds: activeIds,
})
}
}
export const findDuplicateTransactionItem = {
Component: FindDuplicateTransactions,
groupNumber: 5, // same group as fetch missing transactions
isDisplayed: ({ config, isSmall }) => {
return (
!isSmall &&
config.actionType === "ir.actions.act_window" &&
["kanban", "list"].includes(config.viewType) &&
["bank_rec_widget_kanban", "bank_rec_list"].includes(config.viewSubType)
)
},
};
registry.category("cogMenu").add(
"find-duplicate-transaction-menu",
findDuplicateTransactionItem,
{ sequence: 3 }, // after fetch missing transactions
);

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="odex30_account_online_sync.FindDuplicateTransactions">
<DropdownItem class="'o_import_menu'" onSelected.bind="openFindDuplicateTransactionsWizard">
Find Duplicate Transactions
</DropdownItem>
</t>
</templates>

View File

@ -1,61 +0,0 @@
/** @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 } from "@odoo/owl";
class ConnectedUntil extends Component {
static template = "odex30_account_online_sync.ConnectedUntil";
static props = { ...standardWidgetProps };
setup() {
this.state = useState({
isHovered: false,
displayReconnectButton: false,
});
if (this.isConnectionExpiredIn(0)) {
this.state.displayReconnectButton = true;
}
this.action = useService("action");
this.orm = useService("orm");
}
get cssClasses() {
let cssClasses = "text-nowrap w-100";
if (this.isConnectionExpiredIn(7)) {
cssClasses += this.isConnectionExpiredIn(3) ? " text-danger" : " text-warning";
}
return cssClasses;
}
onMouseEnter() {
this.state.isHovered = true;
}
onMouseLeave() {
this.state.isHovered = false;
}
isConnectionExpiredIn(nbDays) {
return this.props.record.data.expiring_synchronization_due_day <= nbDays;
}
async extendConnection() {
const action = await this.orm.call(
"account.journal",
"action_extend_consent",
[this.props.record.resId],
{}
);
this.action.doAction(action);
}
}
export const connectedUntil = {
component: ConnectedUntil,
};
registry.category("view_widgets").add("connected_until_widget", connectedUntil);

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="odex30_account_online_sync.ConnectedUntil">
<div t-on-mouseenter="onMouseEnter" t-on-mouseleave="onMouseLeave" t-att-class="cssClasses">
<t t-if="state.displayReconnectButton">
<button class="btn btn-danger" t-on-click="extendConnection">
Reconnect Bank
</button>
</t>
<t t-else="">
<t t-if="!this.state.isHovered">
<i class="fa fa-warning" t-if="isConnectionExpiredIn(3)"/>
Connected until <t t-out="this.props.record.data.expiring_synchronization_date.toFormat('MMM dd')"/>
</t>
<t t-elif="this.state.isHovered">
<a class="oe_inline oe_kanban_action" href="#" t-on-click="extendConnection">
Extend Connection
</a>
</t>
</t>
</div>
</t>
</templates>

View File

@ -1,41 +0,0 @@
/** @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_sync.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,
});

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odex30_account_online_sync.OnlineAccountRadio">
<div
role="radiogroup"
t-attf-class="o_{{ props.orientation }}"
t-att-aria-label="string"
>
<t t-foreach="items" t-as="item" t-key="item[0]">
<div class="form-check o_radio_item" aria-atomic="true">
<input
type="radio"
class="form-check-input o_radio_input"
t-att-checked="item[0] === value"
t-att-disabled="props.readonly"
t-att-name="id"
t-att-data-value="item[0]"
t-att-data-index="item_index"
t-att-id="`${id}_${item[0]}`"
t-on-change="() => this.onChange(item)"
/>
<label class="form-check-label o_form_label" t-att-for="`${id}_${item[0]}`" t-out="item[1]" />
<br/>
<p t-out="this.getBalanceName(item[0])" t-att-class="this.isNegativeAmount(item[0]) ? 'text-danger' : ''"/>
</div>
</t>
</div>
</t>
</templates>

View File

@ -1,99 +0,0 @@
/** @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_sync.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 Odoo 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);

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="odex30_account_online_sync.RefreshSpin">
<t t-if="this.connectionStatus === 'success'">
<div>
<t t-if="!!this.state.connectionStateDetails.nb_fetched_transactions" >
<a href="#" t-on-click.prevent.stop="openAction">
<t t-out="this.state.connectionStateDetails.nb_fetched_transactions"/> transactions fetched
</a>
</t>
<t t-else="">
0 transaction fetched
</t>
</div>
</t>
<t t-elif="this.connectionStatus === 'error'">
<div>
<a t-if="this.state.connectionStateDetails.error_type === 'redirect_warning'" class="text-danger" href="#" t-on-click.prevent.stop="openAction">
<i class="fa fa-warning"/> See error
</a>
<button t-else="" class="btn btn-danger" t-on-click.prevent.stop="openAction">
Reconnect Bank
</button>
</div>
</t>
<t t-elif="this.connectionStatus === 'fetching'">
<div t-on-mouseenter="onMouseEnter" t-on-mouseleave="onMouseLeave">
<t t-if="this.state.isHovered">
<a class="oe_inline oe_kanban_action" href="#" t-on-click="refresh">Refresh</a>
</t>
<t t-else="">
<span>Fetching... <i class='fa fa-spinner fa-spin'/></span>
</t>
</div>
</t>
<t t-else="">
<a href="#" t-on-click.prevent.stop="fetchTransactions" class="oe_inline">
Fetch Transactions
</a>
</t>
</t>
</templates>

View File

@ -1,51 +0,0 @@
/** @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_sync.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);

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odex30_account_online_sync.TransientBankStatementLineRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//table" position="before">
<t t-if="env.searchModel.context.has_manual_entries">
<div class="alert alert-warning text-center mb-0" role="alert">
You have <a href="" t-on-click.prevent="() => this.openManualEntries()">entries</a>
within this period that were not created using the online synchronization. This might cause duplicate entries.
</div>
</t>
<t t-if="env.searchModel.context.is_fetch_before_creation">
<div class="alert alert-warning text-center mb-0" role="alert">
You are importing transactions before the creation of your online synchronization
(<t t-out="env.searchModel.context.account_online_link_create_date"/>).
This might cause duplicate entries.
</div>
</t>
</xpath>
</t>
<t t-name="TransientBankStatementLineButtonTemplate" t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_list_buttons')]" position="inside">
<button type="button" class="btn btn-primary" data-hotkey="i" t-on-click.stop="onClickImportTransactions">
Import Transactions
</button>
</xpath>
</t>
</templates>

View File

@ -1,19 +0,0 @@
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]),
})
},
})

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odex30_account_online_sync.AOSKanbanView" t-inherit="account.BankRecoKanbanController" t-inherit-mode="extension">
<xpath expr="//Layout" position="before">
<t t-if="displayDuplicateWarning">
<div class="alert alert-warning text-center mb-0" role="alert">
Some transactions <a class="alert-link" href="" t-on-click.prevent="onWarningClick">may be duplicates.</a>
</div>
</t>
</xpath>
</t>
</templates>

View File

@ -1,86 +0,0 @@
/** @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 OdooFin */
function OdooFinConnector(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;
}
OdooFin.create(params);
OdooFin.open();
});
return;
}
actionRegistry.add('odoo_fin_connector', OdooFinConnector);
export default OdooFinConnector;

View File

@ -1,57 +0,0 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { loadJS } from "@web/core/assets";
/* global OdooFin */
publicWidget.registry.OnlineSyncPortal = publicWidget.Widget.extend({
selector: '.oe_online_sync',
events: Object.assign({}, {
'click #renew_consent_button': '_onRenewConsent',
}),
OdooFinConnector: 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;
}
},
};
OdooFin.create(params);
OdooFin.open();
});
return;
},
/**
* @private
* @param {Event} ev
*/
_onRenewConsent: async function (ev) {
ev.preventDefault();
const action = JSON.parse($(ev.currentTarget).attr('iframe-params'));
return this.OdooFinConnector(this, action);
},
});
export default {
OnlineSyncPortal: publicWidget.registry.OnlineSyncPortal,
};

View File

@ -1,5 +0,0 @@
/** @odoo-module **/
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(["account.online.link", "account.online.account", "account.bank.selection"]);

View File

@ -1,83 +0,0 @@
/* @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": `<form>
<div>
<field name="account_online_account_ids" invisible="1"/>
<field name="selected_account" widget="online_account_radio" nolabel="1"/>
</div>
</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");
});
});

View File

@ -1,7 +0,0 @@
# -*- 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

View File

@ -1,110 +0,0 @@
# Part of Odoo. 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

View File

@ -1,45 +0,0 @@
from odoo import fields
from odoo.addons.odex30_account_online_sync.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_sync.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)

View File

@ -1,491 +0,0 @@
# Part of Odoo. 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_sync.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_sync.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_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
@patch('odoo.addons.odex30_account_online_sync.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_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
@patch('odoo.addons.odex30_account_online_sync.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_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
@patch('odoo.addons.odex30_account_online_sync.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_sync.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 OdooFinRedirectException 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_sync.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 OdoofinRedirectException 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_sync.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_sync.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)

View File

@ -1,86 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.odex30_account_online_sync.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'))

View File

@ -1,374 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. 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_sync.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_sync.models.account_online.AccountOnlineLink._fetch_transactions')
@patch('odoo.addons.odex30_account_online_sync.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_sync.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_sync.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_sync.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('&', '&amp;') #in post_message, & has been escaped to &amp;
message_body = f"""<p>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.<br>You can contact Odoo support <a href=\"{body_generated_url}\">Here</a></p>"""
# 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 <p> </p> 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('<p>'), 'Transient transaction details should not start with <p> when read.')
self.assertFalse(transaction_details.endswith('</p>'), 'Transient transaction details should not end with </p> when read.')

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_bank_statement_line_tree_inherit" model="ir.ui.view">
<field name="name">bank.statement.line.list.inherit</field>
<field name="model">account.bank.statement.line</field>
<field name="inherit_id" ref="odex30_account_accountant.view_bank_statement_line_tree_bank_rec_widget"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='account_number']" position="after">
<field name="online_transaction_identifier" optional="hide"/>
<field name="online_account_id" optional="hide"/>
<field name="online_link_id" optional="hide"/>
</xpath>
</field>
</record>
<record id="view_bank_statement_line_form_bank_rec_widget_inherit" model="ir.ui.view">
<field name="name">account.bank.statement.line.form.bank_rec_widget.inherit</field>
<field name="model">account.bank.statement.line</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget"/>
<field name="arch" type="xml">
<xpath expr="//footer" position="replace"/>
</field>
</record>
</data>
</odoo>

View File

@ -1,86 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_journal_dashboard_inherit_online_sync" model="ir.ui.view">
<field name="name">account.journal.dashboard.inherit.online.sync</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.account_journal_dashboard_kanban_view"/>
<field name="arch" type="xml">
<field name="kanban_dashboard" position="after">
<field name="expiring_synchronization_date"/>
<field name="expiring_synchronization_due_day"/>
<field name="next_link_synchronization"/>
<field name="account_online_account_id"/>
<field name="account_online_link_state"/>
<field name="online_sync_fetching_status"/>
</field>
<xpath expr="//t[@t-set='bank_unconfigured']" position="attributes">
<attribute name="t-value" add="!record.account_online_account_id.raw_value" separator=" and "/>
</xpath>
<xpath expr="//t[@name='empty_journal_helper']/span[@t-if=&quot;journal_type == &apos;bank&apos;&quot;]" position="attributes">
<attribute name="t-if" add="!record.account_online_account_id.raw_value" separator=" and "/>
</xpath>
<xpath expr="//button[@name='action_configure_bank_journal']" position="attributes">
<attribute name="t-if" add="dashboard.display_connect_bank_in_dashboard" separator=" and "/>
</xpath>
<xpath expr='//div[@id="dashboard_bank_cash_left"]' position='inside'>
<div t-if="dashboard.bank_statements_source === 'online_sync' and dashboard.show_sync_actions">
<t t-if="record.account_online_link_state.raw_value === 'connected' and record.account_online_account_id">
<widget name="refresh_spin_widget" groups="account.group_account_manager"/>
<span invisible="not expiring_synchronization_date" groups="account.group_account_manager">
<widget name="connected_until_widget"/>
</span>
</t>
<t t-elif="record.account_online_link_state.raw_value == 'error' || (record.expiring_synchronization_date.raw_value &amp;&amp; record.expiring_synchronization_due_day.value &lt;= 0)">
<button groups="account.group_account_user" type="object" name="manual_sync" class="btn btn-danger">Reconnect Bank</button>
</t>
<t t-elif="record.account_online_link_state.raw_value == 'disconnected'">
<button groups="account.group_account_user" type="object" name="action_reconnect_online_account" class="btn btn-danger">Reconnect Bank</button>
</t>
</div>
</xpath>
<xpath expr="//div[hasclass('o_kanban_card_manage_settings')][field[@name='show_on_dashboard']]" position="attributes">
<attribute name="groups"/>
</xpath>
<xpath expr="//div[hasclass('o_kanban_card_manage_settings')]/field[@name='show_on_dashboard']" position="attributes">
<attribute name="groups">account.group_account_manager</attribute>
<attribute name="class" remove="col-4"/>
<attribute name="t-att-class">dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6'</attribute>
</xpath>
<xpath expr="//div[hasclass('o_kanban_card_manage_settings')]/div[hasclass('col-6')]" position="attributes">
<attribute name="groups">account.group_account_manager</attribute>
<attribute name="class" remove="col-4"/>
<attribute name="t-att-class">dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6'</attribute>
</xpath>
<xpath expr="//div[hasclass('o_kanban_card_manage_settings')]/field[@name='show_on_dashboard']" position="after">
<t t-if="dashboard.display_connect_bank_in_dashboard and dashboard.bank_statements_source === 'online_sync'
and record.account_online_link_state.raw_value != 'connected'">
<div class="col-4 text-center" groups="account.group_account_basic">
<a class="dropdown-item px-0" type="object" name="action_configure_bank_journal">Connect bank</a>
</div>
</t>
<t t-elif="dashboard.display_connect_bank_in_dashboard">
<div class="col-4 text-center" groups="account.group_account_manager">
<a class="dropdown-item px-0" type="object" name="action_configure_bank_journal">Connect bank</a>
</div>
</t>
</xpath>
<xpath expr="//t[@t-name='bank_configuration_placeholder']" position="replace">
<t t-if="bank_unconfigured and dashboard.display_connect_bank_in_dashboard">
<widget name="bank_configure" groups="account.group_account_manager"/>
</t>
<t t-else="" t-call="JournalBodyBankCash"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_journal_form" model="ir.ui.view">
<field name="name">account.journal.form.online.sync</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='bank_account_number']" position="inside">
<field name="account_online_account_id" invisible="1"/>
<field name="renewal_contact_email" groups="account.group_account_readonly" invisible="not account_online_account_id"/>
<!-- Take one cell in order to have the button aligned with the field and not its label -->
<div class="o_cell flex-grow-1 flex-sm-grow-0" invisible="not account_online_account_id"/>
<button name="action_send_reminder" class="oe_link oe_inline ps-0 py-0" type="object"
invisible="not account_online_account_id">
<i class="oi oi-arrow-right me-2"/> Send Now
</button>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_renew_consent" name="Renew Consent">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<div class="row">
<div class="col-auto">
<h4>Connect your bank account to Odoo</h4>
</div>
</div>
<div class="row justify-content-center mb-4">
<div class="col-auto">
<img alt="Odoo" src="/web/static/img/odoo_logo.svg" style="height: 5em;" class="align-baseline w-auto"/>
</div>
</div>
<div class="js_reconnect">
<div class="row justify-content-center mb-5">
<div class="col-auto">
<table class="table table-responsive">
<tbody>
<tr>
<td class="text-end h5">Bank</td>
<td class="text-start ps-4 h5"><t t-out="bank"/><t t-if="bank">,</t> <t t-out="bank_account"/></td>
</tr>
<tr>
<td class="text-end h5">Journal</td>
<td class="text-start ps-4 h5"><t t-out="journal"/></td>
</tr>
<tr>
<td class="text-end h5">Latest Balance</td>
<td class="text-start ps-4 h5"><span t-att-class="latest_balance > 0 and '' or 'text-danger'" t-out="latest_balance_formatted"/> on <t t-out="latest_sync"/></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row justify-content-center mb-4 oe_online_sync">
<div class="col-auto">
<a id="renew_consent_button" role="button" class="btn btn-primary py-2" t-att-iframe-params="iframe_params">
<h3 class="mb-0">Connect my Bank</h3>
</a>
</div>
</div>
<div class="row justify-content-center" style="color: #d4924a;">
<div class="col-auto">
Security Tip: always check the domain name of this page, before clicking on the button.
</div>
</div>
</div>
<div class="js_reconnect d-none">
<div class="row justify-content-center mb-5">
<div class="col-auto">
<h2>Thank You!</h2>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@ -1,126 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_online_link_view_form" model="ir.ui.view">
<field name="name">account.online.link.form</field>
<field name="model">account.online.link</field>
<field name="arch" type="xml">
<form create="false">
<header>
<button name="action_fetch_transactions" string="Fetch Transactions" class="oe_highlight"
type="object" groups="account.group_account_user"
invisible="state == 'disconnected' or not show_sync_actions"/>
<button groups="account.group_account_manager" name="action_update_credentials" string="Update Credentials" class="btn-secondary" type="object" invisible="state == 'disconnected' or not show_sync_actions"/>
<button groups="account.group_account_manager" name="action_reconnect_account" string="Reconnect" class="btn-primary" type="object" invisible="state != 'disconnected' or not show_sync_actions"/>
<button groups="account.group_account_manager" name="action_new_synchronization" string="Connect" class="btn-primary" type="object" invisible="state != 'disconnected' or not show_sync_actions"/>
<field name="state" widget="statusbar" readonly="1"/>
<field name="show_sync_actions" invisible="1"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="False"/></h1>
</div>
<group>
<group>
<field name="client_id" readonly="1" string="Client id"/>
<field name="auto_sync"/>
<field name="provider_type" readonly="1"/>
</group>
<group>
<field name="last_refresh" string="Last refresh" readonly="1"/>
<field name="next_refresh" readonly="1" invisible="not auto_sync"/>
<field name="expiring_synchronization_date" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
</group>
<group>
<field name="account_online_account_ids" nolabel="1" widget="one2many" mode="list" string="Online Accounts" colspan="2">
<list create="false" editable="bottom"> <!-- Clicks on records (for edit) are blocked -->
<field name="name" required="1"/>
<field name="account_number"/>
<field name="journal_ids"
widget="many2many_tags"
options="{'no_quick_create': True}"
domain="[('type', 'in', ['bank', 'credit']), ('account_online_account_id', '=', False), ('company_id', '=', company_id)]"
context="{'default_type': 'bank', 'default_bank_statements_source': 'online_sync', 'default_account_online_account_id': id}"/>
<field name="last_sync"/>
<field name="balance" readonly="1"/>
<field name="inverse_balance_sign" groups="base.group_no_one" optional="hide"/>
<field name="inverse_transaction_sign" groups="base.group_no_one" optional="hide"/>
<button name="action_reset_fetching_status" type="object" string="Reset" help="This button will reset the fetching status"/>
<field name="company_id" invisible="1"/>
</list>
</field>
</group>
</sheet>
<!-- Chatter -->
<chatter/>
</form>
</field>
</record>
<record id="account_online_account_view_form" model="ir.ui.view">
<field name="name">account.online.account.form</field>
<field name="model">account.online.account</field>
<field name="arch" type="xml">
<form create="false">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="account_number" readonly="1"/>
<field name="journal_ids" widget="many2many_tags" domain="[('type', 'in', ['bank', 'credit']), ('account_online_account_id', '=', False), ('company_id', '=', company_id)]"/>
<field name="company_id" invisible="1"/>
</group>
<group>
<field name="last_sync"/>
<field name="balance" readonly="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="account_online_link_view_tree" model="ir.ui.view">
<field name="name">account.online.link.list</field>
<field name="model">account.online.link</field>
<field name="arch" type="xml">
<list decoration-danger="state != 'connected'" create="false">
<field name="name" readonly="1"/>
<field name="state"/>
<field name="provider_type" readonly="1"/>
<field name="last_refresh" readonly="1"/>
<field name="next_refresh" readonly="1" optional="hide"/>
<field name="company_id" groups="base.group_multi_company" readonly="1" optional="hide"/>
</list>
</field>
</record>
<record model="ir.actions.act_window" id="action_account_online_link_form">
<field name="name">Online Synchronization</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="account_online_link_view_tree"/>
<field name="res_model">account.online.link</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Online Synchronization
</p>
<p>
To create a synchronization with your banking institution,<br/>
please click on <b>Add a Bank Account</b>.
</p>
</field>
</record>
<menuitem
name="Online Synchronization"
parent="account.account_banks_menu"
action="action_account_online_link_form"
id="menu_action_online_link_account"
groups="base.group_no_one"
sequence="4"/>
</data>
</odoo>

View File

@ -1,6 +0,0 @@
# -*- coding: utf-8 -*-
from . import account_bank_selection_wizard
from . import account_journal_missing_transactions
from . import account_journal_duplicate_transactions
from . import account_bank_statement_line

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