diff --git a/account_bank_statement_import_split/__init__.py b/account_bank_statement_import_split/__init__.py new file mode 100644 index 0000000..31660d6 --- /dev/null +++ b/account_bank_statement_import_split/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/account_bank_statement_import_split/__manifest__.py b/account_bank_statement_import_split/__manifest__.py new file mode 100644 index 0000000..a8d133b --- /dev/null +++ b/account_bank_statement_import_split/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + 'name': 'Online Bank Statements Import Split', + 'version': '12.0.1.0.0', + 'author': + 'Brainbean Apps, ' + 'Odoo Community Association (OCA)', + 'website': 'https://github.com/OCA/bank-statement-import/', + 'license': 'AGPL-3', + 'category': 'Accounting', + 'summary': 'Split statements during import', + 'depends': [ + 'account_bank_statement_import', + ], + 'data': [ + 'views/account_bank_statement_import.xml', + ], + 'installable': True, +} diff --git a/account_bank_statement_import_split/models/__init__.py b/account_bank_statement_import_split/models/__init__.py new file mode 100644 index 0000000..89d280c --- /dev/null +++ b/account_bank_statement_import_split/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import account_bank_statement_import diff --git a/account_bank_statement_import_split/models/account_bank_statement_import.py b/account_bank_statement_import_split/models/account_bank_statement_import.py new file mode 100644 index 0000000..c28f6b0 --- /dev/null +++ b/account_bank_statement_import_split/models/account_bank_statement_import.py @@ -0,0 +1,153 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + +from dateutil.relativedelta import relativedelta, MO +from decimal import Decimal + + +class AccountBankStatementImport(models.TransientModel): + _inherit = 'account.bank.statement.import' + + import_mode = fields.Selection( + selection=[ + ('single', 'Single statement'), + ('daily', 'Daily statements'), + ('weekly', 'Weekly statements'), + ('monthly', 'Monthly statements'), + ], + default='single', + ) + + def _complete_stmts_vals(self, stmts_vals, journal, account_number): + stmts_vals = super()._complete_stmts_vals( + stmts_vals, + journal, + account_number + ) + if not self.import_mode or self.import_mode == 'single': + return stmts_vals + statements = [] + for st_vals in stmts_vals: + transactions = list(sorted( + map( + lambda transaction: self._prepare_transaction( + transaction + ), + st_vals['transactions'] + ), + key=lambda transaction: transaction['date'] + )) + if not transactions: + continue + del st_vals['transactions'] + + balance_start = Decimal(st_vals['balance_start']) \ + if 'balance_start' in st_vals else None + balance_end = Decimal(st_vals['balance_end_real']) \ + if 'balance_end_real' in st_vals else None + statement_date_since = self._get_statement_date_since( + transactions[0]['date'] + ) + while transactions: + statement_date_until = ( + statement_date_since + self._get_statement_date_step() + ) + + last_transaction_index = None + for index, transaction in enumerate(transactions): + if transaction['date'] >= statement_date_until: + break + last_transaction_index = index + if last_transaction_index is None: + # NOTE: No transactions for current period + statement_date_since = statement_date_until + continue + + statement_transactions = \ + transactions[0:last_transaction_index + 1] + transactions = transactions[last_transaction_index + 1:] + + statement_values = dict(st_vals) + statement_values.update({ + 'name': self._get_statement_name( + journal, + statement_date_since, + statement_date_until, + ), + 'date': self._get_statement_date( + statement_date_since, + statement_date_until, + ), + 'transactions': statement_transactions, + }) + if balance_start is not None: + statement_values.update({ + 'balance_start': float(balance_start), + }) + for transaction in statement_transactions: + balance_start += Decimal(transaction['amount']) + if balance_end is not None: + statement_balance_end = balance_end + for transaction in transactions: + statement_balance_end -= Decimal(transaction['amount']) + statement_values.update({ + 'balance_end_real': float(statement_balance_end), + }) + + statements.append(statement_values) + statement_date_since = statement_date_until + return statements + + @api.multi + def _prepare_transaction(self, transaction): + transaction.update({ + 'date': fields.Date.from_string(transaction['date']), + }) + return transaction + + @api.multi + def _get_statement_date_since(self, date): + self.ensure_one() + if self.import_mode == 'daily': + return date + elif self.import_mode == 'weekly': + return date + relativedelta(weekday=MO(-1)) + elif self.import_mode == 'monthly': + return date.replace( + day=1, + ) + + @api.multi + def _get_statement_date_step(self): + self.ensure_one() + if self.import_mode == 'daily': + return relativedelta( + days=1, + ) + elif self.import_mode == 'weekly': + return relativedelta( + weeks=1, + weekday=MO, + ) + elif self.import_mode == 'monthly': + return relativedelta( + months=1, + day=1, + ) + + @api.multi + def _get_statement_date(self, date_since, date_until): + self.ensure_one() + # NOTE: Statement date is treated by Odoo as start of period. Details + # - addons/account/models/account_journal_dashboard.py + # - def get_line_graph_datas() + return date_since + + @api.multi + def _get_statement_name(self, journal, date_since, date_until): + self.ensure_one() + return journal.sequence_id.with_context( + ir_sequence_date=self._get_statement_date(date_since, date_until) + ).next_by_id() diff --git a/account_bank_statement_import_split/readme/CONTRIBUTORS.rst b/account_bank_statement_import_split/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..1c6a35a --- /dev/null +++ b/account_bank_statement_import_split/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Alexey Pelykh diff --git a/account_bank_statement_import_split/readme/DESCRIPTION.rst b/account_bank_statement_import_split/readme/DESCRIPTION.rst new file mode 100644 index 0000000..492d641 --- /dev/null +++ b/account_bank_statement_import_split/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +This module allows splitting statements by date during import: + +* as daily statements +* as weekly statements +* as monthly statements diff --git a/account_bank_statement_import_split/tests/__init__.py b/account_bank_statement_import_split/tests/__init__.py new file mode 100644 index 0000000..edaecbe --- /dev/null +++ b/account_bank_statement_import_split/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_account_bank_statement_import_split diff --git a/account_bank_statement_import_split/tests/test_account_bank_statement_import_split.py b/account_bank_statement_import_split/tests/test_account_bank_statement_import_split.py new file mode 100644 index 0000000..d352fc3 --- /dev/null +++ b/account_bank_statement_import_split/tests/test_account_bank_statement_import_split.py @@ -0,0 +1,267 @@ +# Copyright 2019 Brainbean Apps (https://brainbeanapps.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.tests import common + +from base64 import b64encode +from unittest import mock + +_parse_file_method = ( + 'odoo.addons.account_bank_statement_import' + '.account_bank_statement_import.AccountBankStatementImport._parse_file' +) + + +class TestAccountBankAccountStatementImportSplit(common.TransactionCase): + + def setUp(self): + super().setUp() + + self.now = fields.Datetime.now() + self.currency_usd = self.env.ref('base.USD') + self.empty_data_file = b64encode( + 'TestAccountBankAccountStatementImportSplit'.encode('utf-8') + ) + self.AccountJournal = self.env['account.journal'] + self.AccountBankStatement = self.env['account.bank.statement'] + self.AccountBankStatementImport = self.env[ + 'account.bank.statement.import' + ] + + def test_default_import_mode(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_usd.id, + }) + wizard = self.AccountBankStatementImport.with_context({ + 'journal_id': journal.id, + }).create({ + 'filename': 'file.ext', + 'data_file': self.empty_data_file, + }) + data = ( + journal.currency_id.name, + journal.bank_account_id.acc_number, + [{ + 'name': 'STATEMENT', + 'date': '2019-01-01', + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'transactions': [{ + 'name': 'TRANSACTION', + 'amount': '100.0', + 'date': '2019-01-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID', + }], + }], + ) + with mock.patch(_parse_file_method, return_value=data): + wizard.with_context({ + 'journal_id': journal.id, + }).import_file() + statement = self.AccountBankStatement.search([ + ('journal_id', '=', journal.id), + ]) + self.assertEqual(len(statement), 1) + self.assertEqual(len(statement.line_ids), 1) + + def test_single_import_mode(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_usd.id, + }) + wizard = self.AccountBankStatementImport.with_context({ + 'journal_id': journal.id, + }).create({ + 'filename': 'file.ext', + 'data_file': self.empty_data_file, + 'import_mode': 'single', + }) + data = ( + journal.currency_id.name, + journal.bank_account_id.acc_number, + [{ + 'name': 'STATEMENT', + 'date': '2019-01-01', + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'transactions': [{ + 'name': 'TRANSACTION', + 'amount': '100.0', + 'date': '2019-01-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID', + }], + }], + ) + with mock.patch(_parse_file_method, return_value=data): + wizard.with_context({ + 'journal_id': journal.id, + }).import_file() + statement = self.AccountBankStatement.search([ + ('journal_id', '=', journal.id), + ]) + self.assertEqual(len(statement), 1) + self.assertEqual(len(statement.line_ids), 1) + + def test_daily_import_mode(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_usd.id, + }) + wizard = self.AccountBankStatementImport.with_context({ + 'journal_id': journal.id, + }).create({ + 'filename': 'file.ext', + 'data_file': self.empty_data_file, + 'import_mode': 'daily', + }) + data = ( + journal.currency_id.name, + journal.bank_account_id.acc_number, + [{ + 'name': 'STATEMENT', + 'date': '2019-01-01', + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'transactions': [{ + 'name': 'TRANSACTION-1', + 'amount': '50.0', + 'date': '2019-01-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-1', + }, { + 'name': 'TRANSACTION-2', + 'amount': '50.0', + 'date': '2019-01-03', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-2', + }], + }], + ) + with mock.patch(_parse_file_method, return_value=data): + wizard.with_context({ + 'journal_id': journal.id, + }).import_file() + statements = self.AccountBankStatement.search([ + ('journal_id', '=', journal.id), + ]).sorted(key=lambda statement: statement.date) + self.assertEqual(len(statements), 2) + self.assertEqual(len(statements[0].line_ids), 1) + self.assertEqual(statements[0].balance_start, 0.0) + self.assertEqual(statements[0].balance_end_real, 50.0) + self.assertEqual(len(statements[1].line_ids), 1) + self.assertEqual(statements[1].balance_start, 50.0) + self.assertEqual(statements[1].balance_end_real, 100.0) + + def test_weekly_import_mode(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_usd.id, + }) + wizard = self.AccountBankStatementImport.with_context({ + 'journal_id': journal.id, + }).create({ + 'filename': 'file.ext', + 'data_file': self.empty_data_file, + 'import_mode': 'weekly', + }) + data = ( + journal.currency_id.name, + journal.bank_account_id.acc_number, + [{ + 'name': 'STATEMENT', + 'date': '2019-01-01', + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'transactions': [{ + 'name': 'TRANSACTION-1', + 'amount': '50.0', + 'date': '2019-01-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-1', + }, { + 'name': 'TRANSACTION-2', + 'amount': '50.0', + 'date': '2019-01-15', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-2', + }], + }], + ) + with mock.patch(_parse_file_method, return_value=data): + wizard.with_context({ + 'journal_id': journal.id, + }).import_file() + statements = self.AccountBankStatement.search([ + ('journal_id', '=', journal.id), + ]).sorted(key=lambda statement: statement.date) + self.assertEqual(len(statements), 2) + self.assertEqual(len(statements[0].line_ids), 1) + self.assertEqual(statements[0].balance_start, 0.0) + self.assertEqual(statements[0].balance_end_real, 50.0) + self.assertEqual(len(statements[1].line_ids), 1) + self.assertEqual(statements[1].balance_start, 50.0) + self.assertEqual(statements[1].balance_end_real, 100.0) + + def test_monthly_import_mode(self): + journal = self.AccountJournal.create({ + 'name': 'Bank', + 'type': 'bank', + 'code': 'BANK', + 'currency_id': self.currency_usd.id, + }) + wizard = self.AccountBankStatementImport.with_context({ + 'journal_id': journal.id, + }).create({ + 'filename': 'file.ext', + 'data_file': self.empty_data_file, + 'import_mode': 'monthly', + }) + data = ( + journal.currency_id.name, + journal.bank_account_id.acc_number, + [{ + 'name': 'STATEMENT', + 'date': '2019-01-01', + 'balance_start': 0.0, + 'balance_end_real': 100.0, + 'transactions': [{ + 'name': 'TRANSACTION-1', + 'amount': '50.0', + 'date': '2019-01-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-1', + }, { + 'name': 'TRANSACTION-2', + 'amount': '50.0', + 'date': '2019-03-01', + 'note': 'NOTE', + 'unique_import_id': 'TRANSACTION-ID-2', + }], + }], + ) + with mock.patch(_parse_file_method, return_value=data): + wizard.with_context({ + 'journal_id': journal.id, + }).import_file() + statements = self.AccountBankStatement.search([ + ('journal_id', '=', journal.id), + ]).sorted(key=lambda statement: statement.date) + self.assertEqual(len(statements), 2) + self.assertEqual(len(statements[0].line_ids), 1) + self.assertEqual(statements[0].balance_start, 0.0) + self.assertEqual(statements[0].balance_end_real, 50.0) + self.assertEqual(len(statements[1].line_ids), 1) + self.assertEqual(statements[1].balance_start, 50.0) + self.assertEqual(statements[1].balance_end_real, 100.0) diff --git a/account_bank_statement_import_split/views/account_bank_statement_import.xml b/account_bank_statement_import_split/views/account_bank_statement_import.xml new file mode 100644 index 0000000..fe35a52 --- /dev/null +++ b/account_bank_statement_import_split/views/account_bank_statement_import.xml @@ -0,0 +1,19 @@ + + + + + + account.bank.statement.import + + + +

Please select how you'd like to split the imported statement file:

+ +
+
+
+ +