diff --git a/account_bank_statement_import_adyen/README.rst b/account_bank_statement_import_adyen/README.rst new file mode 100644 index 00000000..9b6baaba --- /dev/null +++ b/account_bank_statement_import_adyen/README.rst @@ -0,0 +1,69 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================== +Adyen statement import +====================== + +This module processes Adyen transaction statements in xlsx format. You can +import the statements in a dedicated journal. Reconcile your sale invoices +with the credit transations. Reconcile the aggregated counterpart +transaction with the transaction in your real bank journal and register the +aggregated fee line containing commision and markup on the applicable +cost account. + +Configuration +============= + +Configure a pseudo bank journal by creating a new journal with a dedicated +Adyen clearing account as the default ledger account. Set your merchant +account string in the Advanced settings on the journal form. + +Usage +===== + +After installing this module, you can import your Adyen transaction statements +through Menu Finance -> Bank -> Import. Don't enter a journal in the import +wizard. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/174/8.0 + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Stefan Rijnhart + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/account_bank_statement_import_adyen/__init__.py b/account_bank_statement_import_adyen/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/account_bank_statement_import_adyen/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_bank_statement_import_adyen/__openerp__.py b/account_bank_statement_import_adyen/__openerp__.py new file mode 100644 index 00000000..6f47e712 --- /dev/null +++ b/account_bank_statement_import_adyen/__openerp__.py @@ -0,0 +1,18 @@ +# coding: utf-8 +# © 2017 Opener BV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + 'name': 'Adyen statement import', + 'version': '8.0.1.0.0', + 'author': 'Opener BV, Odoo Community Association (OCA)', + 'category': 'Banking addons', + 'website': 'https://github.com/oca/bank-statement-import', + 'depends': [ + 'account_bank_statement_import', + 'account_bank_statement_clearing_account', + ], + 'data': [ + 'views/account_journal.xml', + ], + 'installable': True, +} diff --git a/account_bank_statement_import_adyen/models/__init__.py b/account_bank_statement_import_adyen/models/__init__.py new file mode 100644 index 00000000..ba1f4934 --- /dev/null +++ b/account_bank_statement_import_adyen/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_bank_statement_import +from . import account_journal diff --git a/account_bank_statement_import_adyen/models/account_bank_statement_import.py b/account_bank_statement_import_adyen/models/account_bank_statement_import.py new file mode 100644 index 00000000..aae90b71 --- /dev/null +++ b/account_bank_statement_import_adyen/models/account_bank_statement_import.py @@ -0,0 +1,139 @@ +# coding: utf-8 +# © 2017 Opener BV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from io import BytesIO +from openpyxl import load_workbook +from zipfile import BadZipfile + +from openerp import models, api +from openerp.exceptions import Warning as UserError +from openerp.tools.misc import DEFAULT_SERVER_DATE_FORMAT as DATEFMT +from openerp.tools.translate import _ +from openerp.addons.account_bank_statement_import.parserlib import ( + BankStatement) + + +class Import(models.TransientModel): + _inherit = 'account.bank.statement.import' + + @api.model + def _parse_file(self, data_file): + """Parse an Adyen xlsx file and map merchant account strings + to journals. """ + try: + statements = self.import_adyen_xlsx(data_file) + except ValueError: + return super(Import, self)._parse_file(data_file) + + for statement in statements: + merchant_id = statement['account_number'] + journal = self.env['account.journal'].search([ + ('adyen_merchant_account', '=', merchant_id)], limit=1) + if journal: + statement['adyen_journal_id'] = journal.id + else: + raise UserError( + _('Please create a journal with merchant account "%s"') % + merchant_id) + statement['account_number'] = False + return statements + + @api.model + def _import_statement(self, stmt_vals): + """ Propagate found journal to context, fromwhere it is picked up + in _get_journal """ + journal_id = stmt_vals.pop('adyen_journal_id', None) + if journal_id: + self = self.with_context(journal_id=journal_id) + return super(Import, self)._import_statement(stmt_vals) + + @api.model + def balance(self, row): + return -(row[15] or 0) + sum( + row[i] if row[i] else 0.0 + for i in (16, 17, 18, 19, 20)) + + @api.model + def import_adyen_transaction(self, statement, row): + transaction = statement.create_transaction() + transaction.value_date = row[6].strftime(DATEFMT) + transaction.transferred_amount = self.balance(row) + transaction.note = ( + '%s %s %s %s' % (row[2], row[3], row[4], row[21])) + transaction.message = "%s" % (row[3] or row[4] or row[9]) + return transaction + + @api.model + def import_adyen_xlsx(self, data_file): + statements = [] + statement = None + headers = False + fees = 0.0 + balance = 0.0 + payout = 0.0 + + with BytesIO() as buf: + buf.write(data_file) + try: + sheet = load_workbook(buf)._sheets[0] + except BadZipfile as e: + raise ValueError(e) + for row in sheet.rows: + row = [cell.value for cell in row] + if len(row) != 31: + raise ValueError( + 'Not an Adyen statement. Unexpected row length %s ' + 'instead of 31' % len(row)) + if not row[1]: + continue + if not headers: + if row[1] != 'Company Account': + raise ValueError( + 'Not an Adyen statement. Unexpected header "%s" ' + 'instead of "Company Account"', row[1]) + headers = True + continue + if not statement: + statement = BankStatement() + statements.append(statement) + statement.statement_id = '%s %s/%s' % ( + row[2], row[6].strftime('%Y'), int(row[23])) + statement.local_currency = row[14] + statement.local_account = row[2] + date = row[6].strftime(DATEFMT) + if not statement.date or statement.date > date: + statement.date = date + + row[8] = row[8].strip() + if row[8] == 'MerchantPayout': + payout -= self.balance(row) + else: + balance += self.balance(row) + self.import_adyen_transaction(statement, row) + fees += sum( + row[i] if row[i] else 0.0 + for i in (17, 18, 19, 20)) + + if not headers: + raise ValueError( + 'Not an Adyen statement. Did not encounter header row.') + + if fees: + transaction = statement.create_transaction() + transaction.value_date = max( + t.value_date for t in statement['transactions']) + transaction.transferred_amount = -fees + balance -= fees + transaction.message = 'Commision, markup etc. batch %s' % ( + int(row[23])) + + if statement['transactions'] and not payout: + raise UserError( + _('No payout detected in Adyen statement.')) + if self.env.user.company_id.currency_id.compare_amounts( + balance, payout) != 0: + raise UserError( + _('Parse error. Balance %s not equal to merchant ' + 'payout %s') % (balance, payout)) + + return statements diff --git a/account_bank_statement_import_adyen/models/account_journal.py b/account_bank_statement_import_adyen/models/account_journal.py new file mode 100644 index 00000000..d0c431fd --- /dev/null +++ b/account_bank_statement_import_adyen/models/account_journal.py @@ -0,0 +1,10 @@ +# coding: utf-8 +from openerp import fields, models + + +class Journal(models.Model): + _inherit = 'account.journal' + + adyen_merchant_account = fields.Char( + help=('Fill in the exact merchant account string to select this ' + 'journal when importing Adyen statements')) diff --git a/account_bank_statement_import_adyen/test_files/adyen_test.xlsx b/account_bank_statement_import_adyen/test_files/adyen_test.xlsx new file mode 100644 index 00000000..3adaa8db Binary files /dev/null and b/account_bank_statement_import_adyen/test_files/adyen_test.xlsx differ diff --git a/account_bank_statement_import_adyen/test_files/adyen_test_credit_fees.xlsx b/account_bank_statement_import_adyen/test_files/adyen_test_credit_fees.xlsx new file mode 100644 index 00000000..0443fceb Binary files /dev/null and b/account_bank_statement_import_adyen/test_files/adyen_test_credit_fees.xlsx differ diff --git a/account_bank_statement_import_adyen/tests/__init__.py b/account_bank_statement_import_adyen/tests/__init__.py new file mode 100644 index 00000000..f53fb980 --- /dev/null +++ b/account_bank_statement_import_adyen/tests/__init__.py @@ -0,0 +1 @@ +from . import test_import_adyen diff --git a/account_bank_statement_import_adyen/tests/test_import_adyen.py b/account_bank_statement_import_adyen/tests/test_import_adyen.py new file mode 100644 index 00000000..d4e8a684 --- /dev/null +++ b/account_bank_statement_import_adyen/tests/test_import_adyen.py @@ -0,0 +1,55 @@ +# coding: utf-8 +from openerp.addons.account_bank_statement_import.tests import ( + TestStatementFile) + + +class TestImportAdyen(TestStatementFile): + def setUp(self): + super(TestImportAdyen, self).setUp() + self.journal = self.env['account.journal'].search( + [('type', '=', 'bank')], limit=1) + self.journal.default_debit_account_id.reconcile = True + self.journal.write({ + 'adyen_merchant_account': 'YOURCOMPANY_ACCOUNT', + 'update_posted': True, + }) + + def test_import_adyen(self): + self._test_statement_import( + 'account_bank_statement_import_adyen', 'adyen_test.xlsx', + 'YOURCOMPANY_ACCOUNT 2016/48') + statement = self.env['account.bank.statement'].search( + [], order='create_date desc', limit=1) + self.assertEqual(len(statement.line_ids), 22) + self.assertTrue( + self.env.user.company_id.currency_id.is_zero( + sum(line.amount for line in statement.line_ids))) + + account = self.env['account.account'].search([( + 'type', '=', 'receivable')], limit=1) + for line in statement.line_ids: + line.process_reconciliation([{ + 'debit': -line.amount if line.amount < 0 else 0, + 'credit': line.amount if line.amount > 0 else 0, + 'account_id': account.id}]) + + statement.button_confirm_bank() + self.assertEqual(statement.state, 'confirm') + lines = self.env['account.move.line'].search([ + ('account_id', '=', self.journal.default_debit_account_id.id), + ('statement_id', '=', statement.id)]) + reconcile = lines.mapped('reconcile_id') + self.assertEqual(len(reconcile), 1) + self.assertFalse(lines.mapped('reconcile_partial_id')) + self.assertEqual(lines, reconcile.line_id) + + statement.button_draft() + self.assertEqual(statement.state, 'draft') + self.assertFalse(lines.mapped('reconcile_partial_id')) + self.assertFalse(lines.mapped('reconcile_id')) + + def test_import_adyen_credit_fees(self): + self._test_statement_import( + 'account_bank_statement_import_adyen', + 'adyen_test_credit_fees.xlsx', + 'YOURCOMPANY_ACCOUNT 2016/8') diff --git a/account_bank_statement_import_adyen/views/account_journal.xml b/account_bank_statement_import_adyen/views/account_journal.xml new file mode 100644 index 00000000..2e6f2e6b --- /dev/null +++ b/account_bank_statement_import_adyen/views/account_journal.xml @@ -0,0 +1,15 @@ + + + + + Add Adyen merchant account + account.journal + + + + + + + + +