diff --git a/account_reconciliation_widget/models/account_bank_statement.py b/account_reconciliation_widget/models/account_bank_statement.py
new file mode 100644
index 00000000..cee53c90
--- /dev/null
+++ b/account_reconciliation_widget/models/account_bank_statement.py
@@ -0,0 +1,513 @@
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class AccountBankStatement(models.Model):
+
+ _inherit = "account.bank.statement"
+
+ accounting_date = fields.Date(
+ string="Accounting Date",
+ help="If set, the accounting entries created during the bank statement "
+ "reconciliation process will be created at this date.\n"
+ "This is useful if the accounting period in which the entries should "
+ "normally be booked is already closed.",
+ states={"open": [("readonly", False)]},
+ readonly=True,
+ )
+
+ def action_bank_reconcile_bank_statements(self):
+ self.ensure_one()
+ bank_stmt_lines = self.mapped("line_ids")
+ return {
+ "type": "ir.actions.client",
+ "tag": "bank_statement_reconciliation_view",
+ "context": {
+ "statement_line_ids": bank_stmt_lines.ids,
+ "company_ids": self.mapped("company_id").ids,
+ },
+ }
+
+
+class AccountBankStatementLine(models.Model):
+
+ _inherit = "account.bank.statement.line"
+
+ move_name = fields.Char(
+ string="Journal Entry Name",
+ readonly=True,
+ default=False,
+ copy=False,
+ help="Technical field holding the number given to the journal entry, "
+ "automatically set when the statement line is reconciled then "
+ "stored to set the same number again if the line is cancelled, "
+ "set to draft and re-processed again.",
+ )
+
+ def process_reconciliation(
+ self, counterpart_aml_dicts=None, payment_aml_rec=None, new_aml_dicts=None
+ ):
+ """Match statement lines with existing payments (eg. checks) and/or
+ payables/receivables (eg. invoices and credit notes) and/or new move
+ lines (eg. write-offs).
+ If any new journal item needs to be created (via new_aml_dicts or
+ counterpart_aml_dicts), a new journal entry will be created and will
+ contain those items, as well as a journal item for the bank statement
+ line.
+ Finally, mark the statement line as reconciled by putting the matched
+ moves ids in the column journal_entry_ids.
+
+ :param self: browse collection of records that are supposed to have no
+ accounting entries already linked.
+ :param (list of dicts) counterpart_aml_dicts: move lines to create to
+ reconcile with existing payables/receivables.
+ The expected keys are :
+ - 'name'
+ - 'debit'
+ - 'credit'
+ - 'move_line'
+ # The move line to reconcile (partially if specified
+ # debit/credit is lower than move line's credit/debit)
+
+ :param (list of recordsets) payment_aml_rec: recordset move lines
+ representing existing payments (which are already fully reconciled)
+
+ :param (list of dicts) new_aml_dicts: move lines to create. The expected
+ keys are :
+ - 'name'
+ - 'debit'
+ - 'credit'
+ - 'account_id'
+ - (optional) 'tax_ids'
+ - (optional) Other account.move.line fields like analytic_account_id
+ or analytics_id
+ - (optional) 'reconcile_model_id'
+
+ :returns: The journal entries with which the transaction was matched.
+ If there was at least an entry in counterpart_aml_dicts or
+ new_aml_dicts, this list contains the move created by the
+ reconciliation, containing entries for the statement.line (1), the
+ counterpart move lines (0..*) and the new move lines (0..*).
+ """
+ payable_account_type = self.env.ref("account.data_account_type_payable")
+ receivable_account_type = self.env.ref("account.data_account_type_receivable")
+ suspense_moves_mode = self._context.get("suspense_moves_mode")
+ counterpart_aml_dicts = counterpart_aml_dicts or []
+ payment_aml_rec = payment_aml_rec or self.env["account.move.line"]
+ new_aml_dicts = new_aml_dicts or []
+
+ aml_obj = self.env["account.move.line"]
+
+ company_currency = self.journal_id.company_id.currency_id
+ statement_currency = self.journal_id.currency_id or company_currency
+
+ counterpart_moves = self.env["account.move"]
+
+ # Check and prepare received data
+ if any(rec.statement_id for rec in payment_aml_rec):
+ raise UserError(_("A selected move line was already reconciled."))
+ for aml_dict in counterpart_aml_dicts:
+ if aml_dict["move_line"].reconciled and not suspense_moves_mode:
+ raise UserError(_("A selected move line was already reconciled."))
+ if isinstance(aml_dict["move_line"], int):
+ aml_dict["move_line"] = aml_obj.browse(aml_dict["move_line"])
+
+ account_types = self.env["account.account.type"]
+ for aml_dict in counterpart_aml_dicts + new_aml_dicts:
+ if aml_dict.get("tax_ids") and isinstance(aml_dict["tax_ids"][0], int):
+ # Transform the value in the format required for One2many and
+ # Many2many fields
+ aml_dict["tax_ids"] = [(4, id, None) for id in aml_dict["tax_ids"]]
+
+ user_type_id = (
+ self.env["account.account"]
+ .browse(aml_dict.get("account_id"))
+ .user_type_id
+ )
+ if (
+ user_type_id in [payable_account_type, receivable_account_type]
+ and user_type_id not in account_types
+ ):
+ account_types |= user_type_id
+ if suspense_moves_mode:
+ if any(not line.journal_entry_ids for line in self):
+ raise UserError(
+ _(
+ "Some selected statement line were not already "
+ "reconciled with an account move."
+ )
+ )
+ else:
+ if any(line.journal_entry_ids for line in self):
+ raise UserError(
+ _(
+ "A selected statement line was already reconciled with "
+ "an account move."
+ )
+ )
+
+ # Fully reconciled moves are just linked to the bank statement
+ total = self.amount
+ currency = self.currency_id or statement_currency
+ for aml_rec in payment_aml_rec:
+ balance = (
+ aml_rec.amount_currency if aml_rec.currency_id else aml_rec.balance
+ )
+ aml_currency = aml_rec.currency_id or aml_rec.company_currency_id
+ total -= aml_currency._convert(
+ balance, currency, aml_rec.company_id, aml_rec.date
+ )
+ aml_rec.with_context(check_move_validity=False).write(
+ {"statement_line_id": self.id}
+ )
+ counterpart_moves = counterpart_moves | aml_rec.move_id
+ if (
+ aml_rec.journal_id.post_at == "bank_rec"
+ and aml_rec.payment_id
+ and aml_rec.move_id.state == "draft"
+ ):
+ # In case the journal is set to only post payments when
+ # performing bank reconciliation, we modify its date and post
+ # it.
+ aml_rec.move_id.date = self.date
+ aml_rec.payment_id.payment_date = self.date
+ aml_rec.move_id.post()
+ # We check the paid status of the invoices reconciled with this
+ # payment
+ for invoice in aml_rec.payment_id.reconciled_invoice_ids:
+ self._check_invoice_state(invoice)
+
+ # Create move line(s). Either matching an existing journal entry
+ # (eg. invoice), in which case we reconcile the existing and the new
+ # move lines together, or being a write-off.
+ if counterpart_aml_dicts or new_aml_dicts:
+
+ # Create the move
+ self.sequence = self.statement_id.line_ids.ids.index(self.id) + 1
+ move_vals = self._prepare_reconciliation_move(self.statement_id.name)
+ if suspense_moves_mode:
+ self.button_cancel_reconciliation()
+ move = (
+ self.env["account.move"]
+ .with_context(default_journal_id=move_vals["journal_id"])
+ .create(move_vals)
+ )
+ counterpart_moves = counterpart_moves | move
+
+ # Create The payment
+ payment = self.env["account.payment"]
+ partner_id = (
+ self.partner_id
+ or (aml_dict.get("move_line") and aml_dict["move_line"].partner_id)
+ or self.env["res.partner"]
+ )
+ if abs(total) > 0.00001:
+ payment_vals = self._prepare_payment_vals(total)
+ if not payment_vals["partner_id"]:
+ payment_vals["partner_id"] = partner_id.id
+ if payment_vals["partner_id"] and len(account_types) == 1:
+ payment_vals["partner_type"] = (
+ "customer"
+ if account_types == receivable_account_type
+ else "supplier"
+ )
+ payment = payment.create(payment_vals)
+
+ # Complete dicts to create both counterpart move lines and write-offs
+ to_create = counterpart_aml_dicts + new_aml_dicts
+ date = self.date or fields.Date.today()
+ for aml_dict in to_create:
+ aml_dict["move_id"] = move.id
+ aml_dict["partner_id"] = self.partner_id.id
+ aml_dict["statement_line_id"] = self.id
+ self._prepare_move_line_for_currency(aml_dict, date)
+
+ # Create write-offs
+ for aml_dict in new_aml_dicts:
+ aml_dict["payment_id"] = payment and payment.id or False
+ aml_obj.with_context(check_move_validity=False).create(aml_dict)
+
+ # Create counterpart move lines and reconcile them
+ for aml_dict in counterpart_aml_dicts:
+ if (
+ aml_dict["move_line"].payment_id
+ and not aml_dict["move_line"].statement_line_id
+ ):
+ aml_dict["move_line"].write({"statement_line_id": self.id})
+ if aml_dict["move_line"].partner_id.id:
+ aml_dict["partner_id"] = aml_dict["move_line"].partner_id.id
+ aml_dict["account_id"] = aml_dict["move_line"].account_id.id
+ aml_dict["payment_id"] = payment and payment.id or False
+
+ counterpart_move_line = aml_dict.pop("move_line")
+ new_aml = aml_obj.with_context(check_move_validity=False).create(
+ aml_dict
+ )
+
+ (new_aml | counterpart_move_line).reconcile()
+
+ self._check_invoice_state(counterpart_move_line.move_id)
+
+ # Balance the move
+ st_line_amount = -sum([x.balance for x in move.line_ids])
+ aml_dict = self._prepare_reconciliation_move_line(move, st_line_amount)
+ aml_dict["payment_id"] = payment and payment.id or False
+ aml_obj.with_context(check_move_validity=False).create(aml_dict)
+
+ # Needs to be called manually as lines were created 1 by 1
+ move.update_lines_tax_exigibility()
+ move.post()
+ # record the move name on the statement line to be able to retrieve
+ # it in case of unreconciliation
+ self.write({"move_name": move.name})
+ payment and payment.write({"payment_reference": move.name})
+ elif self.move_name:
+ raise UserError(
+ _(
+ "Operation not allowed. Since your statement line already "
+ "received a number (%s), you cannot reconcile it entirely "
+ "with existing journal entries otherwise it would make a "
+ "gap in the numbering. You should book an entry and make a "
+ "regular revert of it in case you want to cancel it."
+ )
+ % (self.move_name)
+ )
+
+ # create the res.partner.bank if needed
+ if self.account_number and self.partner_id and not self.bank_account_id:
+ # Search bank account without partner to handle the case the
+ # res.partner.bank already exists but is set on a different partner.
+ self.bank_account_id = self._find_or_create_bank_account()
+
+ counterpart_moves._check_balanced()
+ return counterpart_moves
+
+ def _prepare_reconciliation_move(self, move_ref):
+ """Prepare the dict of values to create the move from a statement line.
+ This method may be overridden to adapt domain logic through model
+ inheritance (make sure to call super() to establish a clean extension
+ chain).
+
+ :param char move_ref: will be used as the reference of the generated
+ account move
+ :return: dict of value to create() the account.move
+ """
+ ref = move_ref or ""
+ if self.ref:
+ ref = move_ref + " - " + self.ref if move_ref else self.ref
+ data = {
+ "type": "entry",
+ "journal_id": self.statement_id.journal_id.id,
+ "currency_id": self.statement_id.currency_id.id,
+ "date": self.statement_id.accounting_date or self.date,
+ "partner_id": self.partner_id.id,
+ "ref": ref,
+ }
+ if self.move_name:
+ data.update(name=self.move_name)
+ return data
+
+ def _prepare_reconciliation_move_line(self, move, amount):
+ """Prepare the dict of values to balance the move.
+
+ :param recordset move: the account.move to link the move line
+ :param dict move: a dict of vals of a account.move which will be created
+ later
+ :param float amount: the amount of transaction that wasn't already
+ reconciled
+ """
+ company_currency = self.journal_id.company_id.currency_id
+ statement_currency = self.journal_id.currency_id or company_currency
+ st_line_currency = self.currency_id or statement_currency
+ amount_currency = False
+ st_line_currency_rate = (
+ self.currency_id and (self.amount_currency / self.amount) or False
+ )
+ if isinstance(move, dict):
+ amount_sum = sum(x[2].get("amount_currency", 0) for x in move["line_ids"])
+ else:
+ amount_sum = sum(x.amount_currency for x in move.line_ids)
+ # We have several use case here to compare the currency and amount
+ # currency of counterpart line to balance the move:
+ if (
+ st_line_currency != company_currency
+ and st_line_currency == statement_currency
+ ):
+ # company in currency A, statement in currency B and transaction in
+ # currency B
+ # counterpart line must have currency B and correct amount is
+ # inverse of already existing lines
+ amount_currency = -amount_sum
+ elif (
+ st_line_currency != company_currency
+ and statement_currency == company_currency
+ ):
+ # company in currency A, statement in currency A and transaction in
+ # currency B
+ # counterpart line must have currency B and correct amount is
+ # inverse of already existing lines
+ amount_currency = -amount_sum
+ elif (
+ st_line_currency != company_currency
+ and st_line_currency != statement_currency
+ ):
+ # company in currency A, statement in currency B and transaction in
+ # currency C
+ # counterpart line must have currency B and use rate between B and
+ # C to compute correct amount
+ amount_currency = -amount_sum / st_line_currency_rate
+ elif (
+ st_line_currency == company_currency
+ and statement_currency != company_currency
+ ):
+ # company in currency A, statement in currency B and transaction in
+ # currency A
+ # counterpart line must have currency B and amount is computed using
+ # the rate between A and B
+ amount_currency = amount / st_line_currency_rate
+
+ # last case is company in currency A, statement in currency A and
+ # transaction in currency A
+ # and in this case counterpart line does not need any second currency
+ # nor amount_currency
+
+ # Check if default_debit or default_credit account are properly configured
+ account_id = (
+ amount >= 0
+ and self.statement_id.journal_id.default_credit_account_id.id
+ or self.statement_id.journal_id.default_debit_account_id.id
+ )
+
+ if not account_id:
+ raise UserError(
+ _(
+ "No default debit and credit account defined on journal %s "
+ "(ids: %s)."
+ % (
+ self.statement_id.journal_id.name,
+ self.statement_id.journal_id.ids,
+ )
+ )
+ )
+
+ aml_dict = {
+ "name": self.name,
+ "partner_id": self.partner_id and self.partner_id.id or False,
+ "account_id": account_id,
+ "credit": amount < 0 and -amount or 0.0,
+ "debit": amount > 0 and amount or 0.0,
+ "statement_line_id": self.id,
+ "currency_id": statement_currency != company_currency
+ and statement_currency.id
+ or (st_line_currency != company_currency and st_line_currency.id or False),
+ "amount_currency": amount_currency,
+ }
+ if isinstance(move, self.env["account.move"].__class__):
+ aml_dict["move_id"] = move.id
+ return aml_dict
+
+ def _get_communication(self, payment_method_id):
+ return self.name or ""
+
+ def _prepare_payment_vals(self, total):
+ """Prepare the dict of values to create the payment from a statement
+ line. This method may be overridden for update dict
+ through model inheritance (make sure to call super() to establish a
+ clean extension chain).
+
+ :param float total: will be used as the amount of the generated payment
+ :return: dict of value to create() the account.payment
+ """
+ self.ensure_one()
+ partner_type = False
+ if self.partner_id:
+ if total < 0:
+ partner_type = "supplier"
+ else:
+ partner_type = "customer"
+ if not partner_type and self.env.context.get("default_partner_type"):
+ partner_type = self.env.context["default_partner_type"]
+ currency = self.journal_id.currency_id or self.company_id.currency_id
+ payment_methods = (
+ (total > 0)
+ and self.journal_id.inbound_payment_method_ids
+ or self.journal_id.outbound_payment_method_ids
+ )
+ return {
+ "payment_method_id": payment_methods and payment_methods[0].id or False,
+ "payment_type": total > 0 and "inbound" or "outbound",
+ "partner_id": self.partner_id.id,
+ "partner_type": partner_type,
+ "journal_id": self.statement_id.journal_id.id,
+ "payment_date": self.date,
+ "state": "reconciled",
+ "currency_id": currency.id,
+ "amount": abs(total),
+ "communication": self._get_communication(
+ payment_methods[0] if payment_methods else False
+ ),
+ "name": self.statement_id.name or _("Bank Statement %s") % self.date,
+ }
+
+ def _prepare_move_line_for_currency(self, aml_dict, date):
+ self.ensure_one()
+ company_currency = self.journal_id.company_id.currency_id
+ statement_currency = self.journal_id.currency_id or company_currency
+ st_line_currency = self.currency_id or statement_currency
+ st_line_currency_rate = (
+ self.currency_id and (self.amount_currency / self.amount) or False
+ )
+ company = self.company_id
+
+ if st_line_currency.id != company_currency.id:
+ aml_dict["amount_currency"] = aml_dict["debit"] - aml_dict["credit"]
+ aml_dict["currency_id"] = st_line_currency.id
+ if (
+ self.currency_id
+ and statement_currency.id == company_currency.id
+ and st_line_currency_rate
+ ):
+ # Statement is in company currency but the transaction is in
+ # foreign currency
+ aml_dict["debit"] = company_currency.round(
+ aml_dict["debit"] / st_line_currency_rate
+ )
+ aml_dict["credit"] = company_currency.round(
+ aml_dict["credit"] / st_line_currency_rate
+ )
+ elif self.currency_id and st_line_currency_rate:
+ # Statement is in foreign currency and the transaction is in
+ # another one
+ aml_dict["debit"] = statement_currency._convert(
+ aml_dict["debit"] / st_line_currency_rate,
+ company_currency,
+ company,
+ date,
+ )
+ aml_dict["credit"] = statement_currency._convert(
+ aml_dict["credit"] / st_line_currency_rate,
+ company_currency,
+ company,
+ date,
+ )
+ else:
+ # Statement is in foreign currency and no extra currency is
+ # given for the transaction
+ aml_dict["debit"] = st_line_currency._convert(
+ aml_dict["debit"], company_currency, company, date
+ )
+ aml_dict["credit"] = st_line_currency._convert(
+ aml_dict["credit"], company_currency, company, date
+ )
+ elif statement_currency.id != company_currency.id:
+ # Statement is in foreign currency but the transaction is in company
+ # currency
+ prorata_factor = (
+ aml_dict["debit"] - aml_dict["credit"]
+ ) / self.amount_currency
+ aml_dict["amount_currency"] = prorata_factor * self.amount
+ aml_dict["currency_id"] = statement_currency.id
+
+ def _check_invoice_state(self, invoice):
+ if invoice.is_invoice(include_receipts=True):
+ invoice._compute_amount()
diff --git a/account_reconciliation_widget/models/account_journal.py b/account_reconciliation_widget/models/account_journal.py
new file mode 100644
index 00000000..261c8bbc
--- /dev/null
+++ b/account_reconciliation_widget/models/account_journal.py
@@ -0,0 +1,22 @@
+from odoo import models
+
+
+class AccountJournal(models.Model):
+
+ _inherit = "account.journal"
+
+ def action_open_reconcile(self):
+ # Open reconciliation view for bank statements belonging to this journal
+ bank_stmt = (
+ self.env["account.bank.statement"]
+ .search([("journal_id", "in", self.ids)])
+ .mapped("line_ids")
+ )
+ return {
+ "type": "ir.actions.client",
+ "tag": "bank_statement_reconciliation_view",
+ "context": {
+ "statement_line_ids": bank_stmt.ids,
+ "company_ids": self.mapped("company_id").ids,
+ },
+ }
diff --git a/account_reconciliation_widget/models/account_move.py b/account_reconciliation_widget/models/account_move.py
new file mode 100644
index 00000000..e1a68928
--- /dev/null
+++ b/account_reconciliation_widget/models/account_move.py
@@ -0,0 +1,127 @@
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class AccountMoveLine(models.Model):
+
+ _inherit = "account.move.line"
+
+ def _create_writeoff(self, writeoff_vals):
+ """Create a writeoff move per journal for the account.move.lines in
+ self. If debit/credit is not specified in vals, the writeoff amount
+ will be computed as the sum of amount_residual of the given recordset.
+
+ :param writeoff_vals: list of dicts containing values suitable for
+ account_move_line.create(). The data in vals will be processed to
+ create bot writeoff account.move.line and their enclosing
+ account.move.
+ """
+
+ def compute_writeoff_counterpart_vals(values):
+ line_values = values.copy()
+ line_values["debit"], line_values["credit"] = (
+ line_values["credit"],
+ line_values["debit"],
+ )
+ if "amount_currency" in values:
+ line_values["amount_currency"] = -line_values["amount_currency"]
+ return line_values
+
+ # Group writeoff_vals by journals
+ writeoff_dict = {}
+ for val in writeoff_vals:
+ journal_id = val.get("journal_id", False)
+ if not writeoff_dict.get(journal_id, False):
+ writeoff_dict[journal_id] = [val]
+ else:
+ writeoff_dict[journal_id].append(val)
+
+ partner_id = (
+ self.env["res.partner"]._find_accounting_partner(self[0].partner_id).id
+ )
+ company_currency = self[0].account_id.company_id.currency_id
+ writeoff_currency = self[0].account_id.currency_id or company_currency
+ line_to_reconcile = self.env["account.move.line"]
+ # Iterate and create one writeoff by journal
+ writeoff_moves = self.env["account.move"]
+ for journal_id, lines in writeoff_dict.items():
+ total = 0
+ total_currency = 0
+ writeoff_lines = []
+ date = fields.Date.today()
+ for vals in lines:
+ # Check and complete vals
+ if "account_id" not in vals or "journal_id" not in vals:
+ raise UserError(
+ _(
+ "It is mandatory to specify an account and a "
+ "journal to create a write-off."
+ )
+ )
+ if ("debit" in vals) ^ ("credit" in vals):
+ raise UserError(_("Either pass both debit and credit or none."))
+ if "date" not in vals:
+ vals["date"] = self._context.get("date_p") or fields.Date.today()
+ vals["date"] = fields.Date.to_date(vals["date"])
+ if vals["date"] and vals["date"] < date:
+ date = vals["date"]
+ if "name" not in vals:
+ vals["name"] = self._context.get("comment") or _("Write-Off")
+ if "analytic_account_id" not in vals:
+ vals["analytic_account_id"] = self.env.context.get(
+ "analytic_id", False
+ )
+ # compute the writeoff amount if not given
+ if "credit" not in vals and "debit" not in vals:
+ amount = sum([r.amount_residual for r in self])
+ vals["credit"] = amount > 0 and amount or 0.0
+ vals["debit"] = amount < 0 and abs(amount) or 0.0
+ vals["partner_id"] = partner_id
+ total += vals["debit"] - vals["credit"]
+ if (
+ "amount_currency" not in vals
+ and writeoff_currency != company_currency
+ ):
+ vals["currency_id"] = writeoff_currency.id
+ sign = 1 if vals["debit"] > 0 else -1
+ vals["amount_currency"] = sign * abs(
+ sum([r.amount_residual_currency for r in self])
+ )
+ total_currency += vals["amount_currency"]
+
+ writeoff_lines.append(compute_writeoff_counterpart_vals(vals))
+
+ # Create balance line
+ writeoff_lines.append(
+ {
+ "name": _("Write-Off"),
+ "debit": total > 0 and total or 0.0,
+ "credit": total < 0 and -total or 0.0,
+ "amount_currency": total_currency,
+ "currency_id": total_currency and writeoff_currency.id or False,
+ "journal_id": journal_id,
+ "account_id": self[0].account_id.id,
+ "partner_id": partner_id,
+ }
+ )
+
+ # Create the move
+ writeoff_move = self.env["account.move"].create(
+ {
+ "journal_id": journal_id,
+ "date": date,
+ "state": "draft",
+ "line_ids": [(0, 0, line) for line in writeoff_lines],
+ }
+ )
+ writeoff_moves += writeoff_move
+ line_to_reconcile += writeoff_move.line_ids.filtered(
+ lambda r: r.account_id == self[0].account_id
+ ).sorted(key="id")[-1:]
+
+ # post all the writeoff moves at once
+ if writeoff_moves:
+ writeoff_moves.post()
+
+ # Return the writeoff move.line which is to be reconciled
+ return line_to_reconcile
diff --git a/account_reconciliation_widget/models/reconciliation_widget.py b/account_reconciliation_widget/models/reconciliation_widget.py
new file mode 100644
index 00000000..ebe2c710
--- /dev/null
+++ b/account_reconciliation_widget/models/reconciliation_widget.py
@@ -0,0 +1,1180 @@
+import copy
+
+from odoo import _, api, models
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools.misc import format_date, formatLang, parse_date
+
+
+class AccountReconciliation(models.AbstractModel):
+ _name = "account.reconciliation.widget"
+ _description = "Account Reconciliation widget"
+
+ ####################################################
+ # Public
+ ####################################################
+
+ @api.model
+ def process_bank_statement_line(self, st_line_ids, data):
+ """Handles data sent from the bank statement reconciliation widget
+ (and can otherwise serve as an old-API bridge)
+
+ :param st_line_ids
+ :param list of dicts data: must contains the keys
+ 'counterpart_aml_dicts', 'payment_aml_ids' and 'new_aml_dicts',
+ whose value is the same as described in process_reconciliation
+ except that ids are used instead of recordsets.
+ :returns dict: used as a hook to add additional keys.
+ """
+ st_lines = self.env["account.bank.statement.line"].browse(st_line_ids)
+ AccountMoveLine = self.env["account.move.line"]
+ ctx = dict(self._context, force_price_include=False)
+
+ processed_moves = self.env["account.move"]
+ for st_line, datum in zip(st_lines, copy.deepcopy(data)):
+ payment_aml_rec = AccountMoveLine.browse(datum.get("payment_aml_ids", []))
+
+ for aml_dict in datum.get("counterpart_aml_dicts", []):
+ aml_dict["move_line"] = AccountMoveLine.browse(
+ aml_dict["counterpart_aml_id"]
+ )
+ del aml_dict["counterpart_aml_id"]
+
+ if datum.get("partner_id") is not None:
+ st_line.write({"partner_id": datum["partner_id"]})
+
+ ctx["default_to_check"] = datum.get("to_check")
+ moves = st_line.with_context(ctx).process_reconciliation(
+ datum.get("counterpart_aml_dicts", []),
+ payment_aml_rec,
+ datum.get("new_aml_dicts", []),
+ )
+ processed_moves = processed_moves | moves
+ return {
+ "moves": processed_moves.ids,
+ "statement_line_ids": processed_moves.mapped(
+ "line_ids.statement_line_id"
+ ).ids,
+ }
+
+ @api.model
+ def get_move_lines_for_bank_statement_line(
+ self,
+ st_line_id,
+ partner_id=None,
+ excluded_ids=None,
+ search_str=False,
+ offset=0,
+ limit=None,
+ mode=None,
+ ):
+ """Returns move lines for the bank statement reconciliation widget,
+ formatted as a list of dicts
+
+ :param st_line_id: ids of the statement lines
+ :param partner_id: optional partner id to select only the moves
+ line corresponding to the partner
+ :param excluded_ids: optional move lines ids excluded from the
+ result
+ :param search_str: optional search (can be the amout, display_name,
+ partner name, move line name)
+ :param offset: useless but kept in stable to preserve api
+ :param limit: number of the result to search
+ :param mode: 'rp' for receivable/payable or 'other'
+ """
+ st_line = self.env["account.bank.statement.line"].browse(st_line_id)
+
+ # Blue lines = payment on bank account not assigned to a statement yet
+ aml_accounts = [
+ st_line.journal_id.default_credit_account_id.id,
+ st_line.journal_id.default_debit_account_id.id,
+ ]
+
+ if partner_id is None:
+ partner_id = st_line.partner_id.id
+
+ domain = self._domain_move_lines_for_reconciliation(
+ st_line,
+ aml_accounts,
+ partner_id,
+ excluded_ids=excluded_ids,
+ search_str=search_str,
+ mode=mode,
+ )
+ recs_count = self.env["account.move.line"].search_count(domain)
+
+ from_clause, where_clause, where_clause_params = (
+ self.env["account.move.line"]._where_calc(domain).get_sql()
+ )
+ query_str = """
+ SELECT "account_move_line".id FROM {from_clause}
+ {where_str}
+ ORDER BY ("account_move_line".debit -
+ "account_move_line".credit) = {amount} DESC,
+ "account_move_line".date_maturity ASC,
+ "account_move_line".id ASC
+ {limit_str}
+ """.format(
+ from_clause=from_clause,
+ where_str=where_clause and (" WHERE %s" % where_clause) or "",
+ amount=st_line.amount,
+ limit_str=limit and " LIMIT %s" or "",
+ )
+ params = where_clause_params + (limit and [limit] or [])
+ self.env["account.move"].flush()
+ self.env["account.move.line"].flush()
+ self.env["account.bank.statement"].flush()
+ self._cr.execute(query_str, params)
+ res = self._cr.fetchall()
+
+ aml_recs = self.env["account.move.line"].browse([i[0] for i in res])
+ target_currency = (
+ st_line.currency_id
+ or st_line.journal_id.currency_id
+ or st_line.journal_id.company_id.currency_id
+ )
+ return self._prepare_move_lines(
+ aml_recs,
+ target_currency=target_currency,
+ target_date=st_line.date,
+ recs_count=recs_count,
+ )
+
+ @api.model
+ def _get_bank_statement_line_partners(self, st_lines):
+ params = []
+
+ # Add the res.partner.ban's IR rules. In case partners are not shared
+ # between companies, identical bank accounts may exist in a company we
+ # don't have access to.
+ ir_rules_query = self.env["res.partner.bank"]._where_calc([])
+ self.env["res.partner.bank"]._apply_ir_rules(ir_rules_query, "read")
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ if where_clause:
+ where_bank = ("AND %s" % where_clause).replace("res_partner_bank", "bank")
+ params += where_clause_params
+ else:
+ where_bank = ""
+
+ # Add the res.partner's IR rules. In case partners are not shared
+ # between companies, identical partners may exist in a company we don't
+ # have access to.
+ ir_rules_query = self.env["res.partner"]._where_calc([])
+ self.env["res.partner"]._apply_ir_rules(ir_rules_query, "read")
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ if where_clause:
+ where_partner = ("AND %s" % where_clause).replace("res_partner", "p3")
+ params += where_clause_params
+ else:
+ where_partner = ""
+
+ query = """
+ SELECT
+ st_line.id AS id,
+ COALESCE(p1.id,p2.id,p3.id) AS partner_id
+ FROM account_bank_statement_line st_line
+ """
+ query += "INNER JOIN account_move m ON m.id = st_line.move_id \n"
+ query += (
+ "LEFT JOIN res_partner_bank bank ON bank.id = m.partner_bank_id OR "
+ "bank.sanitized_acc_number "
+ "ILIKE regexp_replace(st_line.account_number, '\\W+', '', 'g') %s\n"
+ % (where_bank)
+ )
+ query += "LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n"
+ query += "LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n"
+ # By definition the commercial partner_id doesn't have a parent_id set
+ query += (
+ "LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s "
+ "AND p3.parent_id is NULL \n" % (where_partner)
+ )
+ query += "WHERE st_line.id IN %s"
+
+ params += [tuple(st_lines.ids)]
+
+ self._cr.execute(query, params)
+
+ result = {}
+ for res in self._cr.dictfetchall():
+ result[res["id"]] = res["partner_id"]
+ return result
+
+ @api.model
+ def get_bank_statement_line_data(self, st_line_ids, excluded_ids=None):
+ """Returns the data required to display a reconciliation widget, for
+ each statement line in self
+
+ :param st_line_id: ids of the statement lines
+ :param excluded_ids: optional move lines ids excluded from the
+ result
+ """
+ results = {
+ "lines": [],
+ "value_min": 0,
+ "value_max": 0,
+ "reconciled_aml_ids": [],
+ }
+
+ if not st_line_ids:
+ return results
+
+ excluded_ids = excluded_ids or []
+
+ # Make a search to preserve the table's order.
+ bank_statement_lines = self.env["account.bank.statement.line"].search(
+ [("id", "in", st_line_ids)]
+ )
+ results["value_max"] = len(bank_statement_lines)
+ reconcile_model = self.env["account.reconcile.model"].search(
+ [("rule_type", "!=", "writeoff_button")]
+ )
+
+ # Search for missing partners when opening the reconciliation widget.
+ if bank_statement_lines:
+ partner_map = self._get_bank_statement_line_partners(bank_statement_lines)
+ matching_amls = reconcile_model._apply_rules(
+ bank_statement_lines, excluded_ids=excluded_ids, partner_map=partner_map
+ )
+
+ # Iterate on st_lines to keep the same order in the results list.
+ bank_statements_left = self.env["account.bank.statement"]
+ for line in bank_statement_lines:
+ if matching_amls[line.id].get("status") == "reconciled":
+ reconciled_move_lines = matching_amls[line.id].get("reconciled_lines")
+ results["value_min"] += 1
+ results["reconciled_aml_ids"] += (
+ reconciled_move_lines and reconciled_move_lines.ids or []
+ )
+ else:
+ aml_ids = matching_amls[line.id]["aml_ids"]
+ bank_statements_left += line.statement_id
+ target_currency = (
+ line.currency_id
+ or line.journal_id.currency_id
+ or line.journal_id.company_id.currency_id
+ )
+
+ amls = aml_ids and self.env["account.move.line"].browse(aml_ids)
+ line_vals = {
+ "st_line": self._get_statement_line(line),
+ "reconciliation_proposition": aml_ids
+ and self._prepare_move_lines(
+ amls, target_currency=target_currency, target_date=line.date
+ )
+ or [],
+ "model_id": matching_amls[line.id].get("model")
+ and matching_amls[line.id]["model"].id,
+ "write_off": matching_amls[line.id].get("status") == "write_off",
+ }
+ if not line.partner_id and partner_map.get(line.id):
+ partner = self.env["res.partner"].browse(partner_map[line.id])
+ line_vals.update(
+ {
+ "partner_id": partner.id,
+ "partner_name": partner.name,
+ }
+ )
+ results["lines"].append(line_vals)
+
+ return results
+
+ @api.model
+ def get_bank_statement_data(self, bank_statement_line_ids, srch_domain=None):
+ """Get statement lines of the specified statements or all unreconciled
+ statement lines and try to automatically reconcile them / find them
+ a partner.
+ Return ids of statement lines left to reconcile and other data for
+ the reconciliation widget.
+
+ :param bank_statement_line_ids: ids of the bank statement lines
+ """
+ if not bank_statement_line_ids:
+ return {}
+ suspense_moves_mode = self._context.get("suspense_moves_mode")
+ bank_statements = (
+ self.env["account.bank.statement.line"]
+ .browse(bank_statement_line_ids)
+ .mapped("statement_id")
+ )
+
+ query = """
+ SELECT line.id
+ FROM account_bank_statement_line line
+ LEFT JOIN res_partner p on p.id = line.partner_id
+ WHERE line.account_id IS NULL
+ AND line.amount != 0.0
+ AND line.id IN %(ids)s
+ {cond}
+ GROUP BY line.id
+ """.format(
+ cond=not suspense_moves_mode
+ and """AND NOT EXISTS (SELECT 1 from account_move_line aml
+ WHERE aml.statement_line_id = line.id)"""
+ or "",
+ )
+ self.env.cr.execute(query, {"ids": tuple(bank_statement_line_ids)})
+
+ domain = [["id", "in", [line.get("id") for line in self.env.cr.dictfetchall()]]]
+ if srch_domain is not None:
+ domain += srch_domain
+ bank_statement_lines = self.env["account.bank.statement.line"].search(domain)
+
+ results = self.get_bank_statement_line_data(bank_statement_lines.ids)
+ bank_statement_lines_left = self.env["account.bank.statement.line"].browse(
+ [line["st_line"]["id"] for line in results["lines"]]
+ )
+ bank_statements_left = bank_statement_lines_left.mapped("statement_id")
+
+ results.update(
+ {
+ "statement_name": len(bank_statements_left) == 1
+ and bank_statements_left.name
+ or False,
+ "journal_id": bank_statements
+ and bank_statements[0].journal_id.id
+ or False,
+ "notifications": [],
+ }
+ )
+
+ if len(results["lines"]) < len(bank_statement_lines):
+ results["notifications"].append(
+ {
+ "type": "info",
+ "template": "reconciliation.notification.reconciled",
+ "reconciled_aml_ids": results["reconciled_aml_ids"],
+ "nb_reconciled_lines": results["value_min"],
+ "details": {
+ "name": _("Journal Items"),
+ "model": "account.move.line",
+ "ids": results["reconciled_aml_ids"],
+ },
+ }
+ )
+
+ return results
+
+ @api.model
+ def get_move_lines_for_manual_reconciliation(
+ self,
+ account_id,
+ partner_id=False,
+ excluded_ids=None,
+ search_str=False,
+ offset=0,
+ limit=None,
+ target_currency_id=False,
+ ):
+ """Returns unreconciled move lines for an account or a partner+account,
+ formatted for the manual reconciliation widget"""
+
+ Account_move_line = self.env["account.move.line"]
+ Account = self.env["account.account"]
+ Currency = self.env["res.currency"]
+
+ domain = self._domain_move_lines_for_manual_reconciliation(
+ account_id, partner_id, excluded_ids, search_str
+ )
+ recs_count = Account_move_line.search_count(domain)
+ lines = Account_move_line.search(
+ domain, limit=limit, order="date_maturity desc, id desc"
+ )
+ if target_currency_id:
+ target_currency = Currency.browse(target_currency_id)
+ else:
+ account = Account.browse(account_id)
+ target_currency = account.currency_id or account.company_id.currency_id
+ return self._prepare_move_lines(
+ lines, target_currency=target_currency, recs_count=recs_count
+ )
+
+ @api.model
+ def get_all_data_for_manual_reconciliation(self, partner_ids, account_ids):
+ """Returns the data required for the invoices & payments matching of
+ partners/accounts.
+ If an argument is None, fetch all related reconciliations. Use [] to
+ fetch nothing.
+ """
+ MoveLine = self.env["account.move.line"]
+ aml_ids = (
+ self._context.get("active_ids")
+ and self._context.get("active_model") == "account.move.line"
+ and tuple(self._context.get("active_ids"))
+ )
+ if aml_ids:
+ aml = MoveLine.browse(aml_ids)
+ aml._check_reconcile_validity()
+ account = aml[0].account_id
+ currency = account.currency_id or account.company_id.currency_id
+ return {
+ "accounts": [
+ {
+ "reconciliation_proposition": self._prepare_move_lines(
+ aml, target_currency=currency
+ ),
+ "company_id": account.company_id.id,
+ "currency_id": currency.id,
+ "mode": "accounts",
+ "account_id": account.id,
+ "account_name": account.name,
+ "account_code": account.code,
+ }
+ ],
+ "customers": [],
+ "suppliers": [],
+ }
+ # If we have specified partner_ids, don't return the list of
+ # reconciliation for specific accounts as it will show entries that are
+ # not reconciled with other partner. Asking for a specific partner on a
+ # specific account is never done.
+ accounts_data = []
+ if not partner_ids or not any(partner_ids):
+ accounts_data = self.get_data_for_manual_reconciliation(
+ "account", account_ids
+ )
+ return {
+ "customers": self.get_data_for_manual_reconciliation(
+ "partner", partner_ids, "receivable"
+ ),
+ "suppliers": self.get_data_for_manual_reconciliation(
+ "partner", partner_ids, "payable"
+ ),
+ "accounts": accounts_data,
+ }
+
+ @api.model
+ def get_data_for_manual_reconciliation(
+ self, res_type, res_ids=None, account_type=None
+ ):
+ """Returns the data required for the invoices & payments matching of
+ partners/accounts (list of dicts).
+ If no res_ids is passed, returns data for all partners/accounts that can
+ be reconciled.
+
+ :param res_type: either 'partner' or 'account'
+ :param res_ids: ids of the partners/accounts to reconcile, use None to
+ fetch data indiscriminately of the id, use [] to prevent from
+ fetching any data at all.
+ :param account_type: if a partner is both customer and vendor, you can
+ use 'payable' to reconcile the vendor-related journal entries and
+ 'receivable' for the customer-related entries.
+ """
+
+ Account = self.env["account.account"]
+ Partner = self.env["res.partner"]
+
+ if res_ids is not None and len(res_ids) == 0:
+ # Note : this short-circuiting is better for performances, but also
+ # required since postgresql doesn't implement empty list (so 'AND id
+ # in ()' is useless)
+ return []
+ res_ids = res_ids and tuple(res_ids)
+
+ assert res_type in ("partner", "account")
+ assert account_type in ("payable", "receivable", None)
+ is_partner = res_type == "partner"
+ res_alias = is_partner and "p" or "a"
+ aml_ids = (
+ self._context.get("active_ids")
+ and self._context.get("active_model") == "account.move.line"
+ and tuple(self._context.get("active_ids"))
+ )
+ all_entries = self._context.get("all_entries", False)
+ all_entries_query = """
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual != 0
+ AND (move.state = 'posted' OR (move.state = 'draft'
+ AND journal.post_at = 'bank_rec'))
+ )
+ """.format(
+ inner_where=is_partner and "AND l.partner_id = p.id" or " "
+ )
+ only_dual_entries_query = """
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual > 0
+ AND (move.state = 'posted'
+ OR (move.state = 'draft' AND journal.post_at = 'bank_rec'))
+ )
+ AND EXISTS (
+ SELECT NULL
+ FROM account_move_line l
+ JOIN account_move move ON l.move_id = move.id
+ JOIN account_journal journal ON l.journal_id = journal.id
+ WHERE l.account_id = a.id
+ {inner_where}
+ AND l.amount_residual < 0
+ AND (move.state = 'posted'
+ OR (move.state = 'draft' AND journal.post_at = 'bank_rec'))
+ )
+ """.format(
+ inner_where=is_partner and "AND l.partner_id = p.id" or " "
+ )
+ query = """
+ SELECT {select} account_id, account_name, account_code, max_date
+ FROM (
+ SELECT {inner_select}
+ a.id AS account_id,
+ a.name AS account_name,
+ a.code AS account_code,
+ MAX(l.write_date) AS max_date
+ FROM
+ account_move_line l
+ RIGHT JOIN account_account a ON (a.id = l.account_id)
+ RIGHT JOIN account_account_type at
+ ON (at.id = a.user_type_id)
+ {inner_from}
+ WHERE
+ a.reconcile IS TRUE
+ AND l.full_reconcile_id is NULL
+ {where1}
+ {where2}
+ {where3}
+ AND l.company_id = {company_id}
+ {where4}
+ {where5}
+ GROUP BY {group_by1} a.id, a.name, a.code {group_by2}
+ {order_by}
+ ) as s
+ {outer_where}
+ """.format(
+ select=is_partner
+ and "partner_id, partner_name, to_char(last_time_entries_checked, "
+ "'YYYY-MM-DD') AS last_time_entries_checked,"
+ or " ",
+ inner_select=is_partner
+ and "p.id AS partner_id, p.name AS partner_name, "
+ "p.last_time_entries_checked AS last_time_entries_checked,"
+ or " ",
+ inner_from=is_partner
+ and "RIGHT JOIN res_partner p ON (l.partner_id = p.id)"
+ or " ",
+ where1=is_partner
+ and " "
+ or "AND ((at.type <> 'payable' AND at.type <> 'receivable') "
+ "OR l.partner_id IS NULL)",
+ where2=account_type and "AND at.type = %(account_type)s" or "",
+ where3=res_ids and "AND " + res_alias + ".id in %(res_ids)s" or "",
+ company_id=self.env.company.id,
+ where4=aml_ids and "AND l.id IN %(aml_ids)s" or " ",
+ where5=all_entries and all_entries_query or only_dual_entries_query,
+ group_by1=is_partner and "l.partner_id, p.id," or " ",
+ group_by2=is_partner and ", p.last_time_entries_checked" or " ",
+ order_by=is_partner
+ and "ORDER BY p.last_time_entries_checked"
+ or "ORDER BY a.code",
+ outer_where=is_partner
+ and "WHERE (last_time_entries_checked IS NULL "
+ "OR max_date > last_time_entries_checked)"
+ or " ",
+ )
+ self.env["account.move.line"].flush()
+ self.env["account.account"].flush()
+ self.env.cr.execute(query, locals())
+
+ # Apply ir_rules by filtering out
+ rows = self.env.cr.dictfetchall()
+ ids = [x["account_id"] for x in rows]
+ allowed_ids = set(Account.browse(ids).ids)
+ rows = [row for row in rows if row["account_id"] in allowed_ids]
+ if is_partner:
+ ids = [x["partner_id"] for x in rows]
+ allowed_ids = set(Partner.browse(ids).ids)
+ rows = [row for row in rows if row["partner_id"] in allowed_ids]
+
+ # Keep mode for future use in JS
+ if res_type == "account":
+ mode = "accounts"
+ else:
+ mode = "customers" if account_type == "receivable" else "suppliers"
+
+ # Fetch other data
+ for row in rows:
+ account = Account.browse(row["account_id"])
+ currency = account.currency_id or account.company_id.currency_id
+ row["currency_id"] = currency.id
+ partner_id = is_partner and row["partner_id"] or None
+ rec_prop = (
+ aml_ids
+ and self.env["account.move.line"].browse(aml_ids)
+ or self._get_move_line_reconciliation_proposition(
+ account.id, partner_id
+ )
+ )
+ row["reconciliation_proposition"] = self._prepare_move_lines(
+ rec_prop, target_currency=currency
+ )
+ row["mode"] = mode
+ row["company_id"] = account.company_id.id
+
+ # Return the partners with a reconciliation proposition first, since
+ # they are most likely to be reconciled.
+ return [r for r in rows if r["reconciliation_proposition"]] + [
+ r for r in rows if not r["reconciliation_proposition"]
+ ]
+
+ @api.model
+ def process_move_lines(self, data):
+ """Used to validate a batch of reconciliations in a single call
+ :param data: list of dicts containing:
+ - 'type': either 'partner' or 'account'
+ - 'id': id of the affected res.partner or account.account
+ - 'mv_line_ids': ids of existing account.move.line to reconcile
+ - 'new_mv_line_dicts': list of dicts containing values suitable for
+ account_move_line.create()
+ """
+
+ Partner = self.env["res.partner"]
+
+ for datum in data:
+ if (
+ len(datum["mv_line_ids"]) >= 1
+ or len(datum["mv_line_ids"]) + len(datum["new_mv_line_dicts"]) >= 2
+ ):
+ self._process_move_lines(
+ datum["mv_line_ids"], datum["new_mv_line_dicts"]
+ )
+
+ if datum["type"] == "partner":
+ partners = Partner.browse(datum["id"])
+ partners.mark_as_reconciled()
+
+ ####################################################
+ # Private
+ ####################################################
+
+ def _str_domain_for_mv_line(self, search_str):
+ return [
+ "|",
+ ("account_id.code", "ilike", search_str),
+ "|",
+ ("move_id.name", "ilike", search_str),
+ "|",
+ ("move_id.ref", "ilike", search_str),
+ "|",
+ ("date_maturity", "like", parse_date(self.env, search_str)),
+ "&",
+ ("name", "!=", "/"),
+ ("name", "ilike", search_str),
+ ]
+
+ @api.model
+ def _domain_move_lines(self, search_str):
+ """Returns the domain from the search_str search
+ :param search_str: search string
+ """
+ if not search_str:
+ return []
+ str_domain = self._str_domain_for_mv_line(search_str)
+ if search_str[0] in ["-", "+"]:
+ try:
+ amounts_str = search_str.split("|")
+ for amount_str in amounts_str:
+ amount = (
+ amount_str[0] == "-"
+ and float(amount_str)
+ or float(amount_str[1:])
+ )
+ amount_domain = [
+ "|",
+ ("amount_residual", "=", amount),
+ "|",
+ ("amount_residual_currency", "=", amount),
+ "|",
+ (
+ amount_str[0] == "-" and "credit" or "debit",
+ "=",
+ float(amount_str[1:]),
+ ),
+ ("amount_currency", "=", amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except Exception:
+ pass
+ else:
+ try:
+ amount = float(search_str)
+ amount_domain = [
+ "|",
+ ("amount_residual", "=", amount),
+ "|",
+ ("amount_residual_currency", "=", amount),
+ "|",
+ ("amount_residual", "=", -amount),
+ "|",
+ ("amount_residual_currency", "=", -amount),
+ "&",
+ ("account_id.internal_type", "=", "liquidity"),
+ "|",
+ "|",
+ "|",
+ ("debit", "=", amount),
+ ("credit", "=", amount),
+ ("amount_currency", "=", amount),
+ ("amount_currency", "=", -amount),
+ ]
+ str_domain = expression.OR([str_domain, amount_domain])
+ except Exception:
+ pass
+ return str_domain
+
+ @api.model
+ def _domain_move_lines_for_reconciliation(
+ self,
+ st_line,
+ aml_accounts,
+ partner_id,
+ excluded_ids=None,
+ search_str=False,
+ mode="rp",
+ ):
+ """Return the domain for account.move.line records which can be used for
+ bank statement reconciliation.
+
+ :param aml_accounts:
+ :param partner_id:
+ :param excluded_ids:
+ :param search_str:
+ :param mode: 'rp' for receivable/payable or 'other'
+ """
+ AccountMoveLine = self.env["account.move.line"]
+
+ # Always exclude the journal items that have been marked as
+ # 'to be checked' in a former bank statement reconciliation
+ to_check_excluded = AccountMoveLine.search(
+ AccountMoveLine._get_suspense_moves_domain()
+ ).ids
+ if excluded_ids is None:
+ excluded_ids = []
+ excluded_ids.extend(to_check_excluded)
+
+ domain_reconciliation = [
+ "&",
+ "&",
+ "&",
+ ("statement_line_id", "=", False),
+ ("account_id", "in", aml_accounts),
+ ("payment_id", "<>", False),
+ ("balance", "!=", 0.0),
+ ]
+
+ # default domain matching
+ domain_matching = [
+ "&",
+ "&",
+ ("reconciled", "=", False),
+ ("account_id.reconcile", "=", True),
+ ("balance", "!=", 0.0),
+ ]
+
+ domain = expression.OR([domain_reconciliation, domain_matching])
+ if partner_id:
+ domain = expression.AND([domain, [("partner_id", "=", partner_id)]])
+ if mode == "rp":
+ domain = expression.AND(
+ [
+ domain,
+ [
+ (
+ "account_id.internal_type",
+ "in",
+ ["receivable", "payable", "liquidity"],
+ )
+ ],
+ ]
+ )
+ else:
+ domain = expression.AND(
+ [
+ domain,
+ [
+ (
+ "account_id.internal_type",
+ "not in",
+ ["receivable", "payable", "liquidity"],
+ )
+ ],
+ ]
+ )
+
+ # Domain factorized for all reconciliation use cases
+ if search_str:
+ str_domain = self._domain_move_lines(search_str=search_str)
+ str_domain = expression.OR(
+ [str_domain, [("partner_id.name", "ilike", search_str)]]
+ )
+ domain = expression.AND([domain, str_domain])
+
+ if excluded_ids:
+ domain = expression.AND([[("id", "not in", excluded_ids)], domain])
+ # filter on account.move.line having the same company as the statement
+ # line
+ domain = expression.AND([domain, [("company_id", "=", st_line.company_id.id)]])
+
+ # take only moves in valid state. Draft is accepted only when "Post At"
+ # is set to "Bank Reconciliation" in the associated journal
+ domain_post_at = [
+ "|",
+ "&",
+ ("move_id.state", "=", "draft"),
+ ("journal_id.post_at", "=", "bank_rec"),
+ ("move_id.state", "not in", ["draft", "cancel"]),
+ ]
+ domain = expression.AND([domain, domain_post_at])
+
+ if st_line.company_id.account_bank_reconciliation_start:
+ domain = expression.AND(
+ [
+ domain,
+ [
+ (
+ "date",
+ ">=",
+ st_line.company_id.account_bank_reconciliation_start,
+ )
+ ],
+ ]
+ )
+ return domain
+
+ @api.model
+ def _domain_move_lines_for_manual_reconciliation(
+ self, account_id, partner_id=False, excluded_ids=None, search_str=False
+ ):
+ """ Create domain criteria that are relevant to manual reconciliation. """
+ domain = [
+ "&",
+ "&",
+ ("reconciled", "=", False),
+ ("account_id", "=", account_id),
+ "|",
+ ("move_id.state", "=", "posted"),
+ "&",
+ ("move_id.state", "=", "draft"),
+ ("move_id.journal_id.post_at", "=", "bank_rec"),
+ ]
+ domain = expression.AND([domain, [("balance", "!=", 0.0)]])
+ if partner_id:
+ domain = expression.AND([domain, [("partner_id", "=", partner_id)]])
+ if excluded_ids:
+ domain = expression.AND([[("id", "not in", excluded_ids)], domain])
+ if search_str:
+ str_domain = self._domain_move_lines(search_str=search_str)
+ domain = expression.AND([domain, str_domain])
+ # filter on account.move.line having the same company as the given account
+ account = self.env["account.account"].browse(account_id)
+ domain = expression.AND([domain, [("company_id", "=", account.company_id.id)]])
+ return domain
+
+ @api.model
+ def _prepare_move_lines(
+ self, move_lines, target_currency=False, target_date=False, recs_count=0
+ ):
+ """Returns move lines formatted for the manual/bank reconciliation
+ widget
+
+ :param move_line_ids:
+ :param target_currency: currency (browse) you want the move line
+ debit/credit converted into
+ :param target_date: date to use for the monetary conversion
+ """
+ ret = []
+
+ for line in move_lines:
+ company_currency = line.company_id.currency_id
+ line_currency = (
+ (line.currency_id and line.amount_currency)
+ and line.currency_id
+ or company_currency
+ )
+ ret_line = {
+ "id": line.id,
+ "name": line.name
+ and line.name != "/"
+ and line.move_id.name != line.name
+ and line.move_id.name + ": " + line.name
+ or line.move_id.name,
+ "ref": line.move_id.ref or "",
+ # For reconciliation between statement transactions and already
+ # registered payments (eg. checks)
+ # NB : we don't use the 'reconciled' field because the line
+ # we're selecting is not the one that gets reconciled
+ "account_id": [line.account_id.id, line.account_id.display_name],
+ "already_paid": line.account_id.internal_type == "liquidity",
+ "account_code": line.account_id.code,
+ "account_name": line.account_id.name,
+ "account_type": line.account_id.internal_type,
+ "date_maturity": format_date(self.env, line.date_maturity),
+ "date": format_date(self.env, line.date),
+ "journal_id": [line.journal_id.id, line.journal_id.display_name],
+ "partner_id": line.partner_id.id,
+ "partner_name": line.partner_id.name,
+ "currency_id": line_currency.id,
+ }
+
+ debit = line.debit
+ credit = line.credit
+ amount = line.amount_residual
+ amount_currency = line.amount_residual_currency
+
+ # For already reconciled lines, don't use amount_residual(_currency)
+ if line.account_id.internal_type == "liquidity":
+ amount = debit - credit
+ amount_currency = line.amount_currency
+
+ target_currency = target_currency or company_currency
+
+ # Use case:
+ # Let's assume that company currency is in USD and that we have the
+ # 3 following move lines
+ # Debit Credit Amount currency Currency
+ # 1) 25 0 0 NULL
+ # 2) 17 0 25 EUR
+ # 3) 33 0 25 YEN
+ #
+ # If we ask to see the information in the reconciliation widget in
+ # company currency, we want to see The following information
+ # 1) 25 USD (no currency information)
+ # 2) 17 USD [25 EUR] (show 25 euro in currency information,
+ # in the little bill)
+ # 3) 33 USD [25 YEN] (show 25 yen in currency information)
+ #
+ # If we ask to see the information in another currency than the
+ # company let's say EUR
+ # 1) 35 EUR [25 USD]
+ # 2) 25 EUR (no currency information)
+ # 3) 50 EUR [25 YEN]
+ # In that case, we have to convert the debit-credit to the currency
+ # we want and we show next to it the value of the amount_currency or
+ # the debit-credit if no amount currency
+ if target_currency == company_currency:
+ if line_currency == target_currency:
+ amount = amount
+ amount_currency = ""
+ total_amount = debit - credit
+ total_amount_currency = ""
+ else:
+ amount = amount
+ amount_currency = amount_currency
+ total_amount = debit - credit
+ total_amount_currency = line.amount_currency
+
+ if target_currency != company_currency:
+ if line_currency == target_currency:
+ amount = amount_currency
+ amount_currency = ""
+ total_amount = line.amount_currency
+ total_amount_currency = ""
+ else:
+ amount_currency = line.currency_id and amount_currency or amount
+ company = line.account_id.company_id
+ date = target_date or line.date
+ amount = company_currency._convert(
+ amount, target_currency, company, date
+ )
+ total_amount = company_currency._convert(
+ (line.debit - line.credit), target_currency, company, date
+ )
+ total_amount_currency = (
+ line.currency_id
+ and line.amount_currency
+ or (line.debit - line.credit)
+ )
+
+ ret_line["recs_count"] = recs_count
+ ret_line["debit"] = amount > 0 and amount or 0
+ ret_line["credit"] = amount < 0 and -amount or 0
+ ret_line["amount_currency"] = amount_currency
+ ret_line["amount_str"] = formatLang(
+ self.env, abs(amount), currency_obj=target_currency
+ )
+ ret_line["total_amount_str"] = formatLang(
+ self.env, abs(total_amount), currency_obj=target_currency
+ )
+ ret_line["amount_currency_str"] = (
+ amount_currency
+ and formatLang(
+ self.env, abs(amount_currency), currency_obj=line_currency
+ )
+ or ""
+ )
+ ret_line["total_amount_currency_str"] = (
+ total_amount_currency
+ and formatLang(
+ self.env, abs(total_amount_currency), currency_obj=line_currency
+ )
+ or ""
+ )
+ ret.append(ret_line)
+ return ret
+
+ @api.model
+ def _get_statement_line(self, st_line):
+ """Returns the data required by the bank statement reconciliation
+ widget to display a statement line"""
+
+ statement_currency = (
+ st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id
+ )
+ if st_line.amount_currency and st_line.currency_id:
+ amount = st_line.amount_currency
+ amount_currency = st_line.amount
+ amount_currency_str = formatLang(
+ self.env, abs(amount_currency), currency_obj=statement_currency
+ )
+ else:
+ amount = st_line.amount
+ amount_currency = amount
+ amount_currency_str = ""
+ amount_str = formatLang(
+ self.env,
+ abs(amount),
+ currency_obj=st_line.currency_id or statement_currency,
+ )
+
+ data = {
+ "id": st_line.id,
+ "ref": st_line.ref,
+ "note": st_line.note or "",
+ "name": st_line.name,
+ "date": format_date(self.env, st_line.date),
+ "amount": amount,
+ "amount_str": amount_str, # Amount in the statement line currency
+ "currency_id": st_line.currency_id.id or statement_currency.id,
+ "partner_id": st_line.partner_id.id,
+ "journal_id": st_line.journal_id.id,
+ "statement_id": st_line.statement_id.id,
+ "account_id": [
+ st_line.journal_id.default_debit_account_id.id,
+ st_line.journal_id.default_debit_account_id.display_name,
+ ],
+ "account_code": st_line.journal_id.default_debit_account_id.code,
+ "account_name": st_line.journal_id.default_debit_account_id.name,
+ "partner_name": st_line.partner_id.name,
+ "communication_partner_name": st_line.partner_name,
+ # Amount in the statement currency
+ "amount_currency_str": amount_currency_str,
+ # Amount in the statement currency
+ "amount_currency": amount_currency,
+ "has_no_partner": not st_line.partner_id.id,
+ "company_id": st_line.company_id.id,
+ }
+ if st_line.partner_id:
+ data["open_balance_account_id"] = (
+ amount > 0
+ and st_line.partner_id.property_account_receivable_id.id
+ or st_line.partner_id.property_account_payable_id.id
+ )
+
+ return data
+
+ @api.model
+ def _get_move_line_reconciliation_proposition(self, account_id, partner_id=None):
+ """ Returns two lines whose amount are opposite """
+
+ Account_move_line = self.env["account.move.line"]
+
+ ir_rules_query = Account_move_line._where_calc([])
+ Account_move_line._apply_ir_rules(ir_rules_query, "read")
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ where_str = where_clause and (" WHERE %s" % where_clause) or ""
+
+ # Get pairs
+ query = """
+ SELECT a.id, b.id
+ FROM account_move_line a, account_move_line b,
+ account_move move_a, account_move move_b,
+ account_journal journal_a, account_journal journal_b
+ WHERE a.id != b.id
+ AND move_a.id = a.move_id
+ AND (move_a.state = 'posted'
+ OR (move_a.state = 'draft' AND journal_a.post_at = 'bank_rec'))
+ AND move_a.journal_id = journal_a.id
+ AND move_b.id = b.move_id
+ AND move_b.journal_id = journal_b.id
+ AND (move_b.state = 'posted'
+ OR (move_b.state = 'draft' AND journal_b.post_at = 'bank_rec'))
+ AND a.amount_residual = -b.amount_residual
+ AND a.balance != 0.0
+ AND b.balance != 0.0
+ AND NOT a.reconciled
+ AND a.account_id = %s
+ AND (%s IS NULL AND b.account_id = %s)
+ AND (%s IS NULL AND NOT b.reconciled OR b.id = %s)
+ AND (%s is NULL OR (a.partner_id = %s AND b.partner_id = %s))
+ AND a.id IN (SELECT "account_move_line".id FROM {0})
+ AND b.id IN (SELECT "account_move_line".id FROM {0})
+ ORDER BY a.date desc
+ LIMIT 1
+ """.format(
+ from_clause + where_str
+ )
+ move_line_id = self.env.context.get("move_line_id") or None
+ params = (
+ [
+ account_id,
+ move_line_id,
+ account_id,
+ move_line_id,
+ move_line_id,
+ partner_id,
+ partner_id,
+ partner_id,
+ ]
+ + where_clause_params
+ + where_clause_params
+ )
+ self.env.cr.execute(query, params)
+
+ pairs = self.env.cr.fetchall()
+
+ if pairs:
+ return Account_move_line.browse(pairs[0])
+ return Account_move_line
+
+ @api.model
+ def _process_move_lines(self, move_line_ids, new_mv_line_dicts):
+ """Create new move lines from new_mv_line_dicts (if not empty) then call
+ reconcile_partial on self and new move lines
+
+ :param new_mv_line_dicts: list of dicts containing values suitable for
+ account_move_line.create()
+ """
+ if len(move_line_ids) < 1 or len(move_line_ids) + len(new_mv_line_dicts) < 2:
+ raise UserError(_("A reconciliation must involve at least 2 move lines."))
+
+ account_move_line = self.env["account.move.line"].browse(move_line_ids)
+ writeoff_lines = self.env["account.move.line"]
+
+ # Create writeoff move lines
+ if len(new_mv_line_dicts) > 0:
+ company_currency = account_move_line[0].account_id.company_id.currency_id
+ same_currency = False
+ currencies = list(
+ {aml.currency_id or company_currency for aml in account_move_line}
+ )
+ if len(currencies) == 1 and currencies[0] != company_currency:
+ same_currency = True
+ # We don't have to convert debit/credit to currency as all values in
+ # the reconciliation widget are displayed in company currency
+ # If all the lines are in the same currency, create writeoff entry
+ # with same currency also
+ for mv_line_dict in new_mv_line_dicts:
+ if not same_currency:
+ mv_line_dict["amount_currency"] = False
+ writeoff_lines += account_move_line._create_writeoff([mv_line_dict])
+
+ (account_move_line + writeoff_lines).reconcile()
+ else:
+ account_move_line.reconcile()
diff --git a/account_reconciliation_widget/models/res_company.py b/account_reconciliation_widget/models/res_company.py
new file mode 100644
index 00000000..dacab70d
--- /dev/null
+++ b/account_reconciliation_widget/models/res_company.py
@@ -0,0 +1,14 @@
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ account_bank_reconciliation_start = fields.Date(
+ string="Bank Reconciliation Threshold",
+ help="The bank reconciliation widget won't ask to reconcile payments "
+ "older than this date.\n"
+ "This is useful if you install accounting after having used invoicing "
+ "for some time and don't want to reconcile all the past payments with "
+ "bank statements.",
+ )
diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js
new file mode 100644
index 00000000..3acf3ba4
--- /dev/null
+++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_action.js
@@ -0,0 +1,547 @@
+odoo.define("account.ReconciliationClientAction", function (require) {
+ "use strict";
+
+ var AbstractAction = require("web.AbstractAction");
+ var ReconciliationModel = require("account.ReconciliationModel");
+ var ReconciliationRenderer = require("account.ReconciliationRenderer");
+ var core = require("web.core");
+ var QWeb = core.qweb;
+
+ /**
+ * Widget used as action for 'account.bank.statement' reconciliation
+ */
+ var StatementAction = AbstractAction.extend({
+ hasControlPanel: true,
+ withSearchBar: true,
+ loadControlPanel: true,
+ title: core._t("Bank Reconciliation"),
+ contentTemplate: "reconciliation",
+ custom_events: {
+ change_mode: "_onAction",
+ change_filter: "_onAction",
+ change_offset: "_onAction",
+ change_partner: "_onAction",
+ add_proposition: "_onAction",
+ remove_proposition: "_onAction",
+ update_proposition: "_onAction",
+ create_proposition: "_onAction",
+ getPartialAmount: "_onActionPartialAmount",
+ quick_create_proposition: "_onAction",
+ partial_reconcile: "_onAction",
+ validate: "_onValidate",
+ close_statement: "_onCloseStatement",
+ load_more: "_onLoadMore",
+ reload: "reload",
+ search: "_onSearch",
+ navigation_move: "_onNavigationMove",
+ },
+ config: _.extend({}, AbstractAction.prototype.config, {
+ // Used to instantiate the model
+ Model: ReconciliationModel.StatementModel,
+ // Used to instantiate the action interface
+ ActionRenderer: ReconciliationRenderer.StatementRenderer,
+ // Used to instantiate each widget line
+ LineRenderer: ReconciliationRenderer.LineRenderer,
+ // Used context params
+ params: ["statement_line_ids"],
+ // Number of statements/partners/accounts to display
+ defaultDisplayQty: 10,
+ // Number of moves lines displayed in 'match' mode
+ limitMoveLines: 15,
+ }),
+
+ _onNavigationMove: function (ev) {
+ var non_reconciled_keys = _.keys(
+ _.pick(this.model.lines, function (value, key, object) {
+ return !value.reconciled;
+ })
+ );
+ var currentIndex = _.indexOf(non_reconciled_keys, ev.data.handle);
+ var widget = false;
+ switch (ev.data.direction) {
+ case "up":
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex - 1]);
+ break;
+ case "down":
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex + 1]);
+ break;
+ case "validate":
+ ev.stopPropagation();
+ widget = this._getWidget(non_reconciled_keys[currentIndex]);
+ widget.$("caption .o_buttons button:visible").click();
+ break;
+ }
+ if (widget) widget.$el.focus();
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} params.context
+ *
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this.action_manager = parent;
+ this.params = params;
+ this.controlPanelParams.modelName = "account.bank.statement.line";
+ this.model = new this.config.Model(this, {
+ modelName: "account.reconciliation.widget",
+ defaultDisplayQty:
+ (params.params && params.params.defaultDisplayQty) ||
+ this.config.defaultDisplayQty,
+ limitMoveLines:
+ (params.params && params.params.limitMoveLines) ||
+ this.config.limitMoveLines,
+ });
+ this.widgets = [];
+ // Adding values from the context is necessary to put this information in the url via the action manager so that
+ // you can retrieve it if the person shares his url or presses f5
+ _.each(params.params, function (value, name) {
+ params.context[name] =
+ name.indexOf("_ids") !== -1
+ ? _.map(String(value).split(","), parseFloat)
+ : value;
+ });
+ params.params = {};
+ _.each(this.config.params, function (name) {
+ if (params.context[name]) {
+ params.params[name] = params.context[name];
+ }
+ });
+ },
+
+ /**
+ * Instantiate the action renderer
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var def = this.model.load(this.params.context).then(this._super.bind(this));
+ return def.then(function () {
+ if (!self.model.context || !self.model.context.active_id) {
+ self.model.context = {
+ active_id: self.params.context.active_id,
+ active_model: self.params.context.active_model,
+ };
+ }
+ var journal_id = self.params.context.journal_id;
+ if (
+ self.model.context.active_id &&
+ self.model.context.active_model === "account.journal"
+ ) {
+ journal_id = journal_id || self.model.context.active_id;
+ }
+ if (journal_id) {
+ var promise = self._rpc({
+ model: "account.journal",
+ method: "read",
+ args: [journal_id, ["display_name"]],
+ });
+ } else {
+ var promise = Promise.resolve();
+ }
+ return promise.then(function (result) {
+ var title =
+ result && result[0]
+ ? result[0].display_name
+ : self.params.display_name || "";
+ self._setTitle(title);
+ self.renderer = new self.config.ActionRenderer(self, self.model, {
+ bank_statement_line_id: self.model.bank_statement_line_id,
+ valuenow: self.model.valuenow,
+ valuemax: self.model.valuemax,
+ defaultDisplayQty: self.model.defaultDisplayQty,
+ title: title,
+ });
+ });
+ });
+ },
+
+ reload: function () {
+ // On reload destroy all rendered line widget, reload data and then rerender widget
+ var self = this;
+
+ self.$(".o_reconciliation_lines").addClass("d-none"); // Prevent the browser from recomputing css after each destroy for HUGE perf improvement on a lot of lines
+ _.each(this.widgets, function (widget) {
+ widget.destroy();
+ });
+ this.widgets = [];
+ self.$(".o_reconciliation_lines").removeClass("d-none");
+ return this.model.reload().then(function () {
+ return self._renderLinesOrRainbow();
+ });
+ },
+
+ _renderLinesOrRainbow: function () {
+ var self = this;
+ return self._renderLines().then(function () {
+ var initialState = self.renderer._initialState;
+ var valuenow = self.model.statement
+ ? self.model.statement.value_min
+ : initialState.valuenow;
+ var valuemax = self.model.statement
+ ? self.model.statement.value_max
+ : initialState.valuemax;
+ // No more lines to reconcile, trigger the rainbowman.
+ if (valuenow === valuemax) {
+ initialState.valuenow = valuenow;
+ initialState.context = self.model.getContext();
+ self.renderer.showRainbowMan(initialState);
+ self.remove_cp();
+ } else {
+ // Create a notification if some lines have been reconciled automatically.
+ if (initialState.valuenow > 0)
+ self.renderer._renderNotifications(
+ self.model.statement.notifications
+ );
+ self._openFirstLine();
+ self.renderer.$('[data-toggle="tooltip"]').tooltip();
+ self.do_show();
+ }
+ });
+ },
+
+ /**
+ * Append the renderer and instantiate the line renderers
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var args = arguments;
+ var sup = this._super;
+
+ return this.renderer.prependTo(self.$(".o_form_sheet")).then(function () {
+ return self._renderLinesOrRainbow().then(function () {
+ self.do_show();
+ return sup.apply(self, args);
+ });
+ });
+ },
+
+ /**
+ * Update the control panel and breadcrumbs
+ *
+ * @override
+ */
+ do_show: function () {
+ this._super.apply(this, arguments);
+ if (this.action_manager) {
+ this.$pager = $(
+ QWeb.render("reconciliation.control.pager", {widget: this.renderer})
+ );
+ this.updateControlPanel({
+ clear: true,
+ cp_content: {
+ $pager: this.$pager,
+ },
+ });
+ this.renderer.$progress = this.$pager;
+ $(this.renderer.$progress)
+ .parent()
+ .css("width", "100%")
+ .css("padding-left", "0");
+ }
+ },
+
+ remove_cp: function () {
+ this.updateControlPanel({
+ clear: true,
+ });
+ },
+
+ // --------------------------------------------------------------------------
+ // Private
+ // --------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {String} handle
+ * @returns {Widget} widget line
+ */
+ _getWidget: function (handle) {
+ return _.find(this.widgets, function (widget) {
+ return widget.handle === handle;
+ });
+ },
+
+ /**
+ *
+ */
+ _loadMore: function (qty) {
+ var self = this;
+ return this.model.loadMore(qty).then(function () {
+ return self._renderLines();
+ });
+ },
+ /**
+ * Sitch to 'match' the first available line
+ *
+ * @private
+ */
+ _openFirstLine: function (previous_handle) {
+ var self = this;
+ previous_handle = previous_handle || "rline0";
+ var handle = _.compact(
+ _.map(this.model.lines, function (line, handle) {
+ return line.reconciled ||
+ parseInt(handle.substr(5)) < parseInt(previous_handle.substr(5))
+ ? null
+ : handle;
+ })
+ )[0];
+ if (handle) {
+ var line = this.model.getLine(handle);
+ this.model
+ .changeMode(handle, "default")
+ .then(function () {
+ self._getWidget(handle).update(line);
+ })
+ .guardedCatch(function () {
+ self._getWidget(handle).update(line);
+ })
+ .then(function () {
+ self._getWidget(handle).$el.focus();
+ });
+ }
+ return handle;
+ },
+
+ _forceUpdate: function () {
+ var self = this;
+ _.each(this.model.lines, function (handle) {
+ var widget = self._getWidget(handle.handle);
+ if (widget && handle.need_update) {
+ widget.update(handle);
+ widget.need_update = false;
+ }
+ });
+ },
+ /**
+ * Render line widget and append to view
+ *
+ * @private
+ */
+ _renderLines: function () {
+ var self = this;
+ var linesToDisplay = this.model.getStatementLines();
+ var linePromises = [];
+ _.each(linesToDisplay, function (line, handle) {
+ var widget = new self.config.LineRenderer(self, self.model, line);
+ widget.handle = handle;
+ self.widgets.push(widget);
+ linePromises.push(widget.appendTo(self.$(".o_reconciliation_lines")));
+ });
+ if (this.model.hasMoreLines() === false) {
+ this.renderer.hideLoadMoreButton(true);
+ } else {
+ this.renderer.hideLoadMoreButton(false);
+ }
+ return Promise.all(linePromises);
+ },
+
+ // --------------------------------------------------------------------------
+ // Handlers
+ // --------------------------------------------------------------------------
+
+ /**
+ * dispatch on the camelcased event name to model method then update the
+ * line renderer with the new state. If the mode was switched from 'inactive'
+ * to 'create' or 'match_rp' or 'match_other', the other lines switch to
+ * 'inactive' mode
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onAction: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ var current_line = this.model.getLine(handle);
+ this.model[_.str.camelize(event.name)](handle, event.data.data).then(
+ function () {
+ var widget = self._getWidget(handle);
+ if (widget) {
+ widget.update(current_line);
+ }
+ if (current_line.mode !== "inactive") {
+ _.each(self.model.lines, function (line, _handle) {
+ if (line.mode !== "inactive" && _handle !== handle) {
+ self.model.changeMode(_handle, "inactive");
+ var widget = self._getWidget(_handle);
+ if (widget) {
+ widget.update(line);
+ }
+ }
+ });
+ }
+ }
+ );
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSearch: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.model.domain = ev.data.domain;
+ this.model.display_context = "search";
+ self.reload().then(function () {
+ self.renderer._updateProgressBar({
+ valuenow: self.model.valuenow,
+ valuemax: self.model.valuemax,
+ });
+ });
+ },
+
+ _onActionPartialAmount: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ var line = this.model.getLine(handle);
+ var amount = this.model.getPartialReconcileAmount(handle, event.data);
+ self._getWidget(handle).updatePartialAmount(event.data.data, amount);
+ },
+
+ /**
+ * Call 'closeStatement' model method
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onCloseStatement: function (event) {
+ var self = this;
+ return this.model.closeStatement().then(function (result) {
+ self.do_action({
+ name: "Bank Statements",
+ res_model: "account.bank.statement.line",
+ res_id: result,
+ views: [[false, "form"]],
+ type: "ir.actions.act_window",
+ view_mode: "form",
+ });
+ $(".o_reward").remove();
+ });
+ },
+ /**
+ * Load more statement and render them
+ *
+ * @param {OdooEvent} event
+ */
+ _onLoadMore: function (event) {
+ return this._loadMore(this.model.defaultDisplayQty);
+ },
+ /**
+ * Call 'validate' model method then destroy the
+ * validated lines and update the action renderer with the new status bar
+ * values and notifications then open the first available line
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onValidate: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ this.model.validate(handle).then(function (result) {
+ self.renderer.update({
+ valuenow: self.model.valuenow,
+ valuemax: self.model.valuemax,
+ title: self.title,
+ time: Date.now() - self.time,
+ notifications: result.notifications,
+ context: self.model.getContext(),
+ });
+ self._forceUpdate();
+ _.each(result.handles, function (handle) {
+ var widget = self._getWidget(handle);
+ if (widget) {
+ widget.destroy();
+ var index = _.findIndex(self.widgets, function (widget) {
+ return widget.handle === handle;
+ });
+ self.widgets.splice(index, 1);
+ }
+ });
+ // Get number of widget and if less than constant and if there are more to laod, load until constant
+ if (
+ self.widgets.length < self.model.defaultDisplayQty &&
+ self.model.valuemax - self.model.valuenow >=
+ self.model.defaultDisplayQty
+ ) {
+ var toLoad = self.model.defaultDisplayQty - self.widgets.length;
+ self._loadMore(toLoad);
+ }
+ self._openFirstLine(handle);
+ });
+ },
+ });
+
+ /**
+ * Widget used as action for 'account.move.line' and 'res.partner' for the
+ * manual reconciliation and mark data as reconciliate
+ */
+ var ManualAction = StatementAction.extend({
+ title: core._t("Journal Items to Reconcile"),
+ withSearchBar: false,
+ config: _.extend({}, StatementAction.prototype.config, {
+ Model: ReconciliationModel.ManualModel,
+ ActionRenderer: ReconciliationRenderer.ManualRenderer,
+ LineRenderer: ReconciliationRenderer.ManualLineRenderer,
+ params: ["company_ids", "mode", "partner_ids", "account_ids"],
+ defaultDisplayQty: 30,
+ limitMoveLines: 15,
+ }),
+
+ // --------------------------------------------------------------------------
+ // Handlers
+ // --------------------------------------------------------------------------
+
+ /**
+ * call 'validate' model method then destroy the
+ * reconcilied lines, update the not reconcilied and update the action
+ * renderer with the new status bar values and notifications then open the
+ * first available line
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onValidate: function (event) {
+ var self = this;
+ var handle = event.target.handle;
+ var method = "validate";
+ this.model[method](handle).then(function (result) {
+ _.each(result.reconciled, function (handle) {
+ self._getWidget(handle).destroy();
+ });
+ _.each(result.updated, function (handle) {
+ self._getWidget(handle).update(self.model.getLine(handle));
+ });
+ self.renderer.update({
+ valuenow: _.compact(_.invoke(self.widgets, "isDestroyed")).length,
+ valuemax: self.widgets.length,
+ title: self.title,
+ time: Date.now() - self.time,
+ });
+ if (
+ !_.any(result.updated, function (handle) {
+ return self.model.getLine(handle).mode !== "inactive";
+ })
+ ) {
+ self._openFirstLine(handle);
+ }
+ });
+ },
+ });
+
+ core.action_registry.add("bank_statement_reconciliation_view", StatementAction);
+ core.action_registry.add("manual_reconciliation_view", ManualAction);
+
+ return {
+ StatementAction: StatementAction,
+ ManualAction: ManualAction,
+ };
+});
diff --git a/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js
new file mode 100644
index 00000000..26ca3f09
--- /dev/null
+++ b/account_reconciliation_widget/static/src/js/reconciliation/reconciliation_model.js
@@ -0,0 +1,2356 @@
+odoo.define("account.ReconciliationModel", function (require) {
+ "use strict";
+
+ var BasicModel = require("web.BasicModel");
+ var field_utils = require("web.field_utils");
+ var utils = require("web.utils");
+ var session = require("web.session");
+ var WarningDialog = require("web.CrashManager").WarningDialog;
+ var core = require("web.core");
+ var _t = core._t;
+
+ /**
+ * Model use to fetch, format and update 'account.reconciliation.widget',
+ * datas allowing reconciliation
+ *
+ * The statement internal structure::
+ *
+ * {
+ * valuenow: integer
+ * valuenow: valuemax
+ * [bank_statement_line_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * reconcileModels: [object]
+ * accounts: {id: code}
+ * }
+ *
+ * The internal structure of each line is::
+ *
+ * {
+ * balance: {
+ * type: number - show/hide action button
+ * amount: number - real amount
+ * amount_str: string - formated amount
+ * account_code: string
+ * },
+ * st_line: {
+ * partner_id: integer
+ * partner_name: string
+ * }
+ * mode: string ('inactive', 'match_rp', 'match_other', 'create')
+ * reconciliation_proposition: {
+ * id: number|string
+ * partial_amount: number
+ * invalid: boolean - through the invalid line (without account, label...)
+ * account_code: string
+ * date: string
+ * date_maturity: string
+ * label: string
+ * amount: number - real amount
+ * amount_str: string - formated amount
+ * [already_paid]: boolean
+ * [partner_id]: integer
+ * [partner_name]: string
+ * [account_code]: string
+ * [journal_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * [ref]: string
+ * [is_partially_reconciled]: boolean
+ * [to_check]: boolean
+ * [amount_currency_str]: string|false (amount in record currency)
+ * }
+ * mv_lines_match_rp: object - idem than reconciliation_proposition
+ * mv_lines_match_other: object - idem than reconciliation_proposition
+ * limitMoveLines: integer
+ * filter: string
+ * [createForm]: {
+ * account_id: {
+ * id: integer
+ * display_name: string
+ * }
+ * tax_ids: {
+ * id: integer
+ * display_name: string
+ * }
+ * analytic_account_id: {
+ * id: integer
+ * display_name: string
+ * }
+ * analytic_tag_ids: {
+ * }
+ * label: string
+ * amount: number,
+ * [journal_id]: {
+ * id: integer
+ * display_name: string
+ * }
+ * }
+ * }
+ */
+ var StatementModel = BasicModel.extend({
+ avoidCreate: false,
+ quickCreateFields: [
+ "account_id",
+ "amount",
+ "analytic_account_id",
+ "label",
+ "tax_ids",
+ "force_tax_included",
+ "analytic_tag_ids",
+ "to_check",
+ ],
+
+ // Overridden in ManualModel
+ modes: ["create", "match_rp", "match_other"],
+
+ /**
+ * @override
+ *
+ * @param {Widget} parent
+ * @param {Object} options
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.reconcileModels = [];
+ this.lines = {};
+ this.valuenow = 0;
+ this.valuemax = 0;
+ this.alreadyDisplayed = [];
+ this.domain = [];
+ this.defaultDisplayQty = (options && options.defaultDisplayQty) || 10;
+ this.limitMoveLines = (options && options.limitMoveLines) || 15;
+ this.display_context = "init";
+ },
+
+ // --------------------------------------------------------------------------
+ // Public
+ // --------------------------------------------------------------------------
+
+ /**
+ * add a reconciliation proposition from the matched lines
+ * We also display a warning if the user tries to add 2 line with different
+ * account type
+ *
+ * @param {String} handle
+ * @param {Number} mv_line_id
+ * @returns {Promise}
+ */
+ addProposition: function (handle, mv_line_id) {
+ var self = this;
+ var line = this.getLine(handle);
+ var prop = _.clone(_.find(line["mv_lines_" + line.mode], {id: mv_line_id}));
+ this._addProposition(line, prop);
+ line["mv_lines_" + line.mode] = _.filter(
+ line["mv_lines_" + line.mode],
+ (l) => l.id != mv_line_id
+ );
+
+ // Remove all non valid lines
+ line.reconciliation_proposition = _.filter(
+ line.reconciliation_proposition,
+ function (prop) {
+ return prop && !prop.invalid;
+ }
+ );
+
+ // Onchange the partner if not already set on the statement line.
+ if (
+ !line.st_line.partner_id &&
+ line.reconciliation_proposition &&
+ line.reconciliation_proposition.length == 1 &&
+ prop.partner_id &&
+ line.type === undefined
+ ) {
+ return this.changePartner(
+ handle,
+ {id: prop.partner_id, display_name: prop.partner_name},
+ true
+ );
+ }
+
+ return Promise.all([
+ this._computeLine(line),
+ this._performMoveLine(
+ handle,
+ "match_rp",
+ line.mode == "match_rp" ? 1 : 0
+ ),
+ this._performMoveLine(
+ handle,
+ "match_other",
+ line.mode == "match_other" ? 1 : 0
+ ),
+ ]);
+ },
+ /**
+ * Change the filter for the target line and fetch the new matched lines
+ *
+ * @param {String} handle
+ * @param {String} filter
+ * @returns {Promise}
+ */
+ changeFilter: function (handle, filter) {
+ var line = this.getLine(handle);
+ line["filter_" + line.mode] = filter;
+ line["mv_lines_" + line.mode] = [];
+ return this._performMoveLine(handle, line.mode);
+ },
+ /**
+ * Change the mode line ('inactive', 'match_rp', 'match_other', 'create'),
+ * and fetch the new matched lines or prepare to create a new line
+ *
+ * ``match_rp``
+ * display the matched lines from receivable/payable accounts, the user
+ * can select the lines to apply there as proposition
+ * ``match_other``
+ * display the other matched lines, the user can select the lines to apply
+ * there as proposition
+ * ``create``
+ * display fields and quick create button to create a new proposition
+ * for the reconciliation
+ *
+ * @param {String} handle
+ * @param {'inactive' | 'match_rp' | 'create'} mode
+ * @returns {Promise}
+ */
+ changeMode: function (handle, mode) {
+ var self = this;
+ var line = this.getLine(handle);
+ if (mode === "default") {
+ var match_requests = self.modes
+ .filter((x) => x.startsWith("match"))
+ .map((x) => this._performMoveLine(handle, x));
+ return Promise.all(match_requests).then(function () {
+ return self.changeMode(handle, self._getDefaultMode(handle));
+ });
+ }
+ if (mode === "next") {
+ var available_modes = self._getAvailableModes(handle);
+ mode =
+ available_modes[
+ (available_modes.indexOf(line.mode) + 1) %
+ available_modes.length
+ ];
+ }
+ line.mode = mode;
+ if (["match_rp", "match_other"].includes(line.mode)) {
+ if (
+ !(
+ line["mv_lines_" + line.mode] &&
+ line["mv_lines_" + line.mode].length
+ )
+ ) {
+ return this._performMoveLine(handle, line.mode);
+ }
+ return this._formatMoveLine(handle, line.mode, []);
+ }
+ if (line.mode === "create") {
+ return this.createProposition(handle);
+ }
+ return Promise.resolve();
+ },
+ /**
+ * Fetch the more matched lines
+ *
+ * @param {String} handle
+ * @returns {Promise}
+ */
+ changeOffset: function (handle) {
+ var line = this.getLine(handle);
+ return this._performMoveLine(handle, line.mode);
+ },
+ /**
+ * Change the partner on the line and fetch the new matched lines
+ *
+ * @param {String} handle
+ * @param {Boolean} preserveMode
+ * @param {Object} partner
+ * @param {String} partner.display_name
+ * @param {Number} partner.id
+ * @returns {Promise}
+ */
+ changePartner: function (handle, partner, preserveMode) {
+ var self = this;
+ var line = this.getLine(handle);
+ line.st_line.partner_id = partner && partner.id;
+ line.st_line.partner_name = (partner && partner.display_name) || "";
+ line.mv_lines_match_rp = [];
+ line.mv_lines_match_other = [];
+ return Promise.resolve(partner && this._changePartner(handle, partner.id))
+ .then(function () {
+ if (line.st_line.partner_id) {
+ _.each(line.reconciliation_proposition, function (prop) {
+ if (prop.partner_id != line.st_line.partner_id) {
+ line.reconciliation_proposition = [];
+ return false;
+ }
+ });
+ }
+ return self._computeLine(line);
+ })
+ .then(function () {
+ return self.changeMode(
+ handle,
+ preserveMode ? line.mode : "default",
+ true
+ );
+ });
+ },
+ /**
+ * Close the statement
+ * @returns {Promise} resolves to the res_id of the closed statements
+ */
+ closeStatement: function () {
+ var self = this;
+ return this._rpc({
+ model: "account.bank.statement.line",
+ method: "button_confirm_bank",
+ args: [self.bank_statement_line_id.id],
+ }).then(function () {
+ return self.bank_statement_line_id.id;
+ });
+ },
+ /**
+ *
+ * Then open the first available line
+ *
+ * @param {String} handle
+ * @returns {Promise}
+ */
+ createProposition: function (handle) {
+ var line = this.getLine(handle);
+ var prop = _.filter(line.reconciliation_proposition, "__focus");
+ prop = this._formatQuickCreate(line);
+ line.reconciliation_proposition.push(prop);
+ line.createForm = _.pick(prop, this.quickCreateFields);
+ return this._computeLine(line);
+ },
+ /**
+ * Return context information and journal_id
+ * @returns {Object} context
+ */
+ getContext: function () {
+ return this.context;
+ },
+ /**
+ * Return the lines that needs to be displayed by the widget
+ *
+ * @returns {Object} lines that are loaded and not yet displayed
+ */
+ getStatementLines: function () {
+ var self = this;
+ var linesToDisplay = _.pick(this.lines, function (value, key, object) {
+ if (
+ value.visible === true &&
+ self.alreadyDisplayed.indexOf(key) === -1
+ ) {
+ self.alreadyDisplayed.push(key);
+ return object;
+ }
+ });
+ return linesToDisplay;
+ },
+ /**
+ * Return a boolean telling if load button needs to be displayed or not
+ * overridden in ManualModel
+ *
+ * @returns {Boolean} true if load more button needs to be displayed
+ */
+ hasMoreLines: function () {
+ var notDisplayed = _.filter(this.lines, function (line) {
+ return !line.visible;
+ });
+ if (notDisplayed.length > 0) {
+ return true;
+ }
+ return false;
+ },
+ /**
+ * Get the line data for this handle
+ *
+ * @param {Object} handle
+ * @returns {Object}
+ */
+ getLine: function (handle) {
+ return this.lines[handle];
+ },
+ /**
+ * Load data from
+ *
+ * - 'account.bank.statement' fetch the line id and bank_statement_id info
+ * - 'account.reconcile.model' fetch all reconcile model (for quick add)
+ * - 'account.account' fetch all account code
+ * - 'account.reconciliation.widget' fetch each line data
+ *
+ * overridden in ManualModel
+ * @param {Object} context
+ * @param {Number[]} context.statement_line_ids
+ * @returns {Promise}
+ */
+ load: function (context) {
+ var self = this;
+ this.context = context;
+ this.statement_line_ids = context.statement_line_ids;
+ if (this.statement_line_ids === undefined) {
+ // This could be undefined if the user pressed F5, take everything as fallback instead of rainbowman
+ return self
+ ._rpc({
+ model: "account.bank.statement.line",
+ method: "search_read",
+ fields: ["id"],
+ domain: [["journal_id", "=?", context.active_id]],
+ })
+ .then(function (result) {
+ self.statement_line_ids = result.map((r) => r.id);
+ return self.reload();
+ });
+ }
+ return self.reload();
+ },
+ /**
+ * Load more bank statement line
+ *
+ * @param {integer} qty quantity to load
+ * @returns {Promise}
+ */
+ loadMore: function (qty) {
+ if (qty === undefined) {
+ qty = this.defaultDisplayQty;
+ }
+ var ids = _.pluck(this.lines, "id");
+ ids = ids.splice(this.pagerIndex, qty);
+ this.pagerIndex += qty;
+ return this.loadData(ids, this._getExcludedIds());
+ },
+ /**
+ * RPC method to load informations on lines
+ * overridden in ManualModel
+ *
+ * @param {Array} ids ids of bank statement line passed to rpc call
+ * @param {Array} excluded_ids list of move_line ids that needs to be excluded from search
+ * @returns {Promise}
+ */
+ loadData: function (ids) {
+ var self = this;
+ var excluded_ids = this._getExcludedIds();
+ return self
+ ._rpc({
+ model: "account.reconciliation.widget",
+ method: "get_bank_statement_line_data",
+ args: [ids, excluded_ids],
+ context: self.context,
+ })
+ .then(function (res) {
+ return self._formatLine(res.lines);
+ });
+ },
+ /**
+ * Reload all data
+ */
+ reload: function () {
+ var self = this;
+ self.alreadyDisplayed = [];
+ self.lines = {};
+ self.pagerIndex = 0;
+ var def_statement = this._rpc({
+ model: "account.reconciliation.widget",
+ method: "get_bank_statement_data",
+ kwargs: {
+ bank_statement_line_ids: self.statement_line_ids,
+ srch_domain: self.domain,
+ },
+ context: self.context,
+ }).then(function (statement) {
+ self.statement = statement;
+ self.bank_statement_line_id =
+ self.statement_line_ids.length === 1
+ ? {
+ id: self.statement_line_ids[0],
+ display_name: statement.statement_name,
+ }
+ : false;
+ self.valuenow = self.valuenow || statement.value_min;
+ self.valuemax = self.valuemax || statement.value_max;
+ self.context.journal_id = statement.journal_id;
+ _.each(statement.lines, function (res) {
+ var handle = _.uniqueId("rline");
+ self.lines[handle] = {
+ id: res.st_line.id,
+ partner_id: res.st_line.partner_id,
+ handle: handle,
+ reconciled: false,
+ mode: "inactive",
+ mv_lines_match_rp: [],
+ mv_lines_match_other: [],
+ filter_match_rp: "",
+ filter_match_other: "",
+ reconciliation_proposition: [],
+ reconcileModels: [],
+ };
+ });
+ });
+ var domainReconcile = [];
+ if (self.context && self.context.company_ids) {
+ domainReconcile.push(["company_id", "in", self.context.company_ids]);
+ }
+ if (
+ self.context &&
+ self.context.active_model === "account.journal" &&
+ self.context.active_ids
+ ) {
+ domainReconcile.push("|");
+ domainReconcile.push(["match_journal_ids", "=", false]);
+ domainReconcile.push([
+ "match_journal_ids",
+ "in",
+ self.context.active_ids,
+ ]);
+ }
+ var def_reconcileModel = this._loadReconciliationModel({
+ domainReconcile: domainReconcile,
+ });
+ var def_account = this._rpc({
+ model: "account.account",
+ method: "search_read",
+ fields: ["code"],
+ }).then(function (accounts) {
+ self.accounts = _.object(
+ _.pluck(accounts, "id"),
+ _.pluck(accounts, "code")
+ );
+ });
+ var def_taxes = self._loadTaxes();
+ return Promise.all([
+ def_statement,
+ def_reconcileModel,
+ def_account,
+ def_taxes,
+ ]).then(function () {
+ _.each(self.lines, function (line) {
+ line.reconcileModels = self.reconcileModels;
+ });
+ var ids = _.pluck(self.lines, "id");
+ ids = ids.splice(0, self.defaultDisplayQty);
+ self.pagerIndex = ids.length;
+ return self._formatLine(self.statement.lines);
+ });
+ },
+ _readAnalyticTags: function (params) {
+ var self = this;
+ this.analyticTags = {};
+ if (!params || !params.res_ids || !params.res_ids.length) {
+ return $.when();
+ }
+ var fields = ((params && params.fields) || []).concat([
+ "id",
+ "display_name",
+ ]);
+ return this._rpc({
+ model: "account.analytic.tag",
+ method: "read",
+ args: [params.res_ids, fields],
+ }).then(function (tags) {
+ for (var i = 0; i < tags.length; i++) {
+ var tag = tags[i];
+ self.analyticTags[tag.id] = tag;
+ }
+ });
+ },
+ _loadReconciliationModel: function (params) {
+ var self = this;
+ return this._rpc({
+ model: "account.reconcile.model",
+ method: "search_read",
+ domain: params.domainReconcile || [],
+ }).then(function (reconcileModels) {
+ var analyticTagIds = [];
+ for (var i = 0; i < reconcileModels.length; i++) {
+ var modelTags = reconcileModels[i].analytic_tag_ids || [];
+ for (var j = 0; j < modelTags.length; j++) {
+ if (analyticTagIds.indexOf(modelTags[j]) === -1) {
+ analyticTagIds.push(modelTags[j]);
+ }
+ }
+ }
+ return self
+ ._readAnalyticTags({res_ids: analyticTagIds})
+ .then(function () {
+ for (var i = 0; i < reconcileModels.length; i++) {
+ var recModel = reconcileModels[i];
+ var analyticTagData = [];
+ var modelTags = reconcileModels[i].analytic_tag_ids || [];
+ for (var j = 0; j < modelTags.length; j++) {
+ var tagId = modelTags[j];
+ analyticTagData.push([
+ tagId,
+ self.analyticTags[tagId].display_name,
+ ]);
+ }
+ recModel.analytic_tag_ids = analyticTagData;
+ }
+ self.reconcileModels = reconcileModels;
+ });
+ });
+ },
+ _loadTaxes: function () {
+ var self = this;
+ self.taxes = {};
+ return this._rpc({
+ model: "account.tax",
+ method: "search_read",
+ fields: ["price_include", "name"],
+ }).then(function (taxes) {
+ _.each(taxes, function (tax) {
+ self.taxes[tax.id] = {
+ price_include: tax.price_include,
+ display_name: tax.name,
+ };
+ });
+ return taxes;
+ });
+ },
+ /**
+ * Add lines into the propositions from the reconcile model
+ * Can add 2 lines, and each with its taxes. The second line become editable
+ * in the create mode.
+ *
+ * @see 'updateProposition' method for more informations about the
+ * 'amount_type'
+ *
+ * @param {String} handle
+ * @param {integer} reconcileModelId
+ * @returns {Promise}
+ */
+ quickCreateProposition: function (handle, reconcileModelId) {
+ var self = this;
+ var line = this.getLine(handle);
+ var reconcileModel = _.find(this.reconcileModels, function (r) {
+ return r.id === reconcileModelId;
+ });
+ var fields = [
+ "account_id",
+ "amount",
+ "amount_type",
+ "analytic_account_id",
+ "journal_id",
+ "label",
+ "force_tax_included",
+ "tax_ids",
+ "analytic_tag_ids",
+ "to_check",
+ "amount_from_label_regex",
+ "decimal_separator",
+ ];
+ this._blurProposition(handle);
+ var focus = this._formatQuickCreate(line, _.pick(reconcileModel, fields));
+ focus.reconcileModelId = reconcileModelId;
+ line.reconciliation_proposition.push(focus);
+ var defs = [];
+ if (reconcileModel.has_second_line) {
+ defs.push(
+ self._computeLine(line).then(function () {
+ var second = {};
+ _.each(fields, function (key) {
+ second[key] =
+ "second_" + key in reconcileModel
+ ? reconcileModel["second_" + key]
+ : reconcileModel[key];
+ });
+ var second_focus = self._formatQuickCreate(line, second);
+ second_focus.reconcileModelId = reconcileModelId;
+ line.reconciliation_proposition.push(second_focus);
+ self._computeReconcileModels(handle, reconcileModelId);
+ })
+ );
+ }
+ return Promise.all(defs).then(function () {
+ line.createForm = _.pick(focus, self.quickCreateFields);
+ return self._computeLine(line);
+ });
+ },
+ /**
+ * Remove a proposition and switch to an active mode ('create' or 'match_rp' or 'match_other')
+ * overridden in ManualModel
+ *
+ * @param {String} handle
+ * @param {Number} id (move line id)
+ * @returns {Promise}
+ */
+ removeProposition: function (handle, id) {
+ var self = this;
+ var line = this.getLine(handle);
+ var defs = [];
+ var prop = _.find(line.reconciliation_proposition, {id: id});
+ if (prop) {
+ line.reconciliation_proposition = _.filter(
+ line.reconciliation_proposition,
+ function (p) {
+ return (
+ p.id !== prop.id &&
+ p.id !== prop.link &&
+ p.link !== prop.id &&
+ (!p.link || p.link !== prop.link)
+ );
+ }
+ );
+ if (prop.reconcileModelId === undefined) {
+ if (
+ ["receivable", "payable", "liquidity"].includes(
+ prop.account_type
+ )
+ ) {
+ line.mv_lines_match_rp.unshift(prop);
+ } else {
+ line.mv_lines_match_other.unshift(prop);
+ }
+ }
+
+ // No proposition left and then, reset the st_line partner.
+ if (
+ line.reconciliation_proposition.length == 0 &&
+ line.st_line.has_no_partner
+ )
+ defs.push(self.changePartner(line.handle));
+ }
+ line.mode =
+ (id || line.mode !== "create") && isNaN(id) ? "create" : "match_rp";
+ defs.push(this._computeLine(line));
+ return Promise.all(defs).then(function () {
+ return self.changeMode(handle, line.mode, true);
+ });
+ },
+ getPartialReconcileAmount: function (handle, data) {
+ var line = this.getLine(handle);
+ var formatOptions = {
+ currency_id: line.st_line.currency_id,
+ noSymbol: true,
+ };
+ var prop = _.find(line.reconciliation_proposition, {id: data.data});
+ if (prop) {
+ var amount = prop.partial_amount || prop.amount;
+ // Check if we can get a partial amount that would directly set balance to zero
+ var partial = Math.abs(line.balance.amount + amount);
+ if (Math.abs(line.balance.amount) >= Math.abs(amount)) {
+ amount = Math.abs(amount);
+ } else if (partial <= Math.abs(prop.amount) && partial >= 0) {
+ amount = partial;
+ } else {
+ amount = Math.abs(amount);
+ }
+ return field_utils.format.monetary(amount, {}, formatOptions);
+ }
+ },
+ /**
+ * Force the partial reconciliation to display the reconciliate button.
+ *
+ * @param {String} handle
+ * @returns {Promise}
+ */
+ partialReconcile: function (handle, data) {
+ var line = this.getLine(handle);
+ var prop = _.find(line.reconciliation_proposition, {id: data.mvLineId});
+ if (prop) {
+ var amount = data.amount;
+ try {
+ amount = field_utils.parse.float(data.amount);
+ } catch (err) {
+ amount = NaN;
+ }
+ // Amount can't be greater than line.amount and can not be negative and must be a number
+ // the amount we receive will be a string, so take sign of previous line amount in consideration in order to put
+ // the amount in the correct left or right column
+ if (amount >= Math.abs(prop.amount) || amount <= 0 || isNaN(amount)) {
+ delete prop.partial_amount_str;
+ delete prop.partial_amount;
+ if (isNaN(amount) || amount < 0) {
+ this.do_warn(
+ _.str.sprintf(
+ _t("The amount %s is not a valid partial amount"),
+ data.amount
+ )
+ );
+ }
+ return this._computeLine(line);
+ }
+ var format_options = {currency_id: line.st_line.currency_id};
+ prop.partial_amount = (prop.amount > 0 ? 1 : -1) * amount;
+ prop.partial_amount_str = field_utils.format.monetary(
+ Math.abs(prop.partial_amount),
+ {},
+ format_options
+ );
+ }
+ return this._computeLine(line);
+ },
+ /**
+ * Change the value of the editable proposition line or create a new one.
+ *
+ * If the editable line comes from a reconcile model with 2 lines
+ * and their 'amount_type' is "percent"
+ * and their total equals 100% (this doesn't take into account the taxes
+ * who can be included or not)
+ * Then the total is recomputed to have 100%.
+ *
+ * @param {String} handle
+ * @param {*} values
+ * @returns {Promise}
+ */
+ updateProposition: function (handle, values) {
+ var self = this;
+ var line = this.getLine(handle);
+ var prop = _.last(_.filter(line.reconciliation_proposition, "__focus"));
+ if ("to_check" in values && values.to_check === false) {
+ // Check if we have another line with to_check and if yes don't change value of this proposition
+ prop.to_check = line.reconciliation_proposition.some(function (
+ rec_prop,
+ index
+ ) {
+ return rec_prop.id !== prop.id && rec_prop.to_check;
+ });
+ }
+ if (!prop) {
+ prop = this._formatQuickCreate(line);
+ line.reconciliation_proposition.push(prop);
+ }
+ _.each(values, function (value, fieldName) {
+ if (fieldName === "analytic_tag_ids") {
+ switch (value.operation) {
+ case "ADD_M2M":
+ // Handle analytic_tag selection via drop down (single dict) and
+ // full widget (array of dict)
+ var vids = _.isArray(value.ids) ? value.ids : [value.ids];
+ _.each(vids, function (val) {
+ if (!_.findWhere(prop.analytic_tag_ids, {id: val.id})) {
+ prop.analytic_tag_ids.push(val);
+ }
+ });
+ break;
+ case "FORGET":
+ var id = self.localData[value.ids[0]].ref;
+ prop.analytic_tag_ids = _.filter(
+ prop.analytic_tag_ids,
+ function (val) {
+ return val.id !== id;
+ }
+ );
+ break;
+ }
+ } else if (fieldName === "tax_ids") {
+ switch (value.operation) {
+ case "ADD_M2M":
+ prop.__tax_to_recompute = true;
+ var vids = _.isArray(value.ids) ? value.ids : [value.ids];
+ _.each(vids, function (val) {
+ if (!_.findWhere(prop.tax_ids, {id: val.id})) {
+ value.ids.price_include = self.taxes[val.id]
+ ? self.taxes[val.id].price_include
+ : false;
+ prop.tax_ids.push(val);
+ }
+ });
+ break;
+ case "FORGET":
+ prop.__tax_to_recompute = true;
+ var id = self.localData[value.ids[0]].ref;
+ prop.tax_ids = _.filter(prop.tax_ids, function (val) {
+ return val.id !== id;
+ });
+ break;
+ }
+ } else {
+ prop[fieldName] = values[fieldName];
+ }
+ });
+ if ("account_id" in values) {
+ prop.account_code = prop.account_id
+ ? this.accounts[prop.account_id.id]
+ : "";
+ }
+ if ("amount" in values) {
+ prop.base_amount = values.amount;
+ if (prop.reconcileModelId) {
+ this._computeReconcileModels(handle, prop.reconcileModelId);
+ }
+ }
+ if (
+ "force_tax_included" in values ||
+ "amount" in values ||
+ "account_id" in values
+ ) {
+ prop.__tax_to_recompute = true;
+ }
+ line.createForm = _.pick(prop, this.quickCreateFields);
+ // If you check/uncheck the force_tax_included box, reset the createForm amount.
+ if (prop.base_amount) line.createForm.amount = prop.base_amount;
+ if (prop.tax_ids.length !== 1) {
+ // When we have 0 or more than 1 taxes, reset the base_amount and force_tax_included, otherwise weird behavior can happen
+ prop.amount = prop.base_amount;
+ line.createForm.force_tax_included = false;
+ }
+ return this._computeLine(line);
+ },
+ /**
+ * Format the value and send it to 'account.reconciliation.widget' model
+ * Update the number of validated lines
+ * overridden in ManualModel
+ *
+ * @param {(String|String[])} handle
+ * @returns {Promise
+
+
+
+
+
Congrats, you're all done!
+
You reconciled transactions in .
+
+
That's on average seconds per transaction.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ () |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Last Reconciliation: |
+ |
+ |
+
+
+
+
+
+
+
+
+ |
+ |
+ Open balanceChoose counterpart or Create Write-off |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | |
+
+
+ New
+
+
+ |
+
+
+
+ :
+
+
+
+ :
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+ |
+
+
+
+
+
+
+ | Account | |
+ | Date | |
+ | Due Date | |
+ | Journal | |
+ | Partner | |
+ | Label | |
+ | Ref | |
+ | Amount | () |
+ | Residual |
+ ()
+ |
+
+ | This payment is registered but not reconciled. |
+
+
+
+
+
+
+
+ | Date | |
+ | Partner | |
+ | Transaction | |
+ | Description | |
+ | Amount | () |
+ | Account | |
+ | Note | |
+
+
+
+
+
+
+
+
+ statement lines
+
+ have been reconciled automatically.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/account_reconciliation_widget/static/tests/account_reconciliation_tests.js b/account_reconciliation_widget/static/tests/account_reconciliation_tests.js
new file mode 100644
index 00000000..e97eb10d
--- /dev/null
+++ b/account_reconciliation_widget/static/tests/account_reconciliation_tests.js
@@ -0,0 +1,4922 @@
+odoo.define("account.reconciliation_tests.data", function () {
+ "use strict";
+
+ /*
+ * Debug tip:
+ * To be able to "see" the test in the browser:
+ * var $body = $('body');
+ * $body.addClass('debug');
+ * clientAction.appendTo($body);
+ */
+
+ var Datas = {};
+
+ var db = {
+ "res.company": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ },
+ records: [{id: 1, display_name: "company 1"}],
+ },
+ "res.partner": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ image: {string: "image", type: "integer"},
+ parent_id: {string: "Parent", type: "boolean"},
+ is_company: {string: "Is company", type: "boolean"},
+ property_account_receivable_id: {
+ string: "Account receivable",
+ type: "many2one",
+ relation: "account.account",
+ },
+ property_account_payable_id: {
+ string: "Account payable",
+ type: "many2one",
+ relation: "account.account",
+ },
+ },
+ records: [
+ {id: 1, display_name: "partner 1", image: "AAA"},
+ {id: 2, display_name: "partner 2", image: "BBB"},
+ {id: 3, display_name: "partner 3", image: "CCC"},
+ {id: 4, display_name: "partner 4", image: "DDD"},
+ {id: 8, display_name: "Agrolait", image: "EEE"},
+ {
+ id: 12,
+ display_name: "Camptocamp",
+ image: "FFF",
+ property_account_receivable_id: 287,
+ property_account_payable_id: 287,
+ },
+ // Add more to have 'Search More' option
+ {id: 98, display_name: "partner 98", image: "YYY"},
+ {id: 99, display_name: "partner 99", image: "ZZZ"},
+ ],
+ mark_as_reconciled: function () {
+ return Promise.resolve();
+ },
+ },
+ "account.account": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ code: {string: "code", type: "integer"},
+ name: {string: "Displayed name", type: "char"},
+ company_id: {
+ string: "Company",
+ type: "many2one",
+ relation: "res.company",
+ },
+ deprecated: {string: "Deprecated", type: "boolean"},
+ },
+ records: [
+ {
+ id: 282,
+ code: 100000,
+ name: "100000 Fixed Asset Account",
+ company_id: 1,
+ },
+ {id: 283, code: 101000, name: "101000 Current Assets", company_id: 1},
+ {
+ id: 284,
+ code: 101110,
+ name: "101110 Stock Valuation Account",
+ company_id: 1,
+ },
+ {
+ id: 285,
+ code: 101120,
+ name: "101120 Stock Interim Account (Received)",
+ company_id: 1,
+ },
+ {
+ id: 286,
+ code: 101130,
+ name: "101130 Stock Interim Account (Delivered)",
+ company_id: 1,
+ },
+ {
+ id: 287,
+ code: 101200,
+ name: "101200 Account Receivable",
+ company_id: 1,
+ },
+ {id: 288, code: 101300, name: "101300 Tax Paid", company_id: 1},
+ {id: 308, code: 101401, name: "101401 Bank", company_id: 1},
+ {id: 499, code: 499001, name: "499001 Suspense Account", company_id: 1},
+ {id: 500, code: 500, name: "500 Account", company_id: 1},
+ {id: 501, code: 501, name: "501 Account", company_id: 1},
+ {id: 502, code: 502, name: "502 Account", company_id: 1},
+ {id: 503, code: 503, name: "503 Account", company_id: 1},
+ {id: 504, code: 504, name: "504 Account", company_id: 1},
+ ],
+ mark_as_reconciled: function () {
+ return Promise.resolve();
+ },
+ },
+ "account.account.tag": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ },
+ records: [{id: 1}, {id: 2}, {id: 3}, {id: 4}],
+ },
+ "account.tax.repartition.line": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ repartition_type: {string: "Repartition Type", type: "selection"},
+ account_id: {
+ string: "Account",
+ type: "many2one",
+ relation: "account.account",
+ },
+ factor_percent: {string: "%", type: "integer"},
+ tag_ids: {
+ string: "Tax Grids",
+ type: "many2many",
+ relation: "account.account.tag",
+ },
+ },
+ records: [
+ {id: 1, factor_percent: 100, repartition_type: "base", tag_ids: [1]},
+ {id: 2, factor_percent: 100, repartition_type: "tax", tag_ids: [2]},
+ {id: 3, factor_percent: 100, repartition_type: "base", tag_ids: [3]},
+ {
+ id: 4,
+ factor_percent: 100,
+ repartition_type: "tax",
+ tag_ids: [4],
+ account_id: 288,
+ },
+ ],
+ },
+ "account.tax": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ amount: {string: "amout", type: "float"},
+ price_include: {string: "Included in Price", type: "boolean"},
+ company_id: {
+ string: "Company",
+ type: "many2one",
+ relation: "res.company",
+ },
+ amount_type: {string: "type", type: "selection"},
+ invoice_repartition_line_ids: {
+ string: "Invoice Repartition",
+ type: "one2many",
+ relation: "account.tax.repartition.line",
+ },
+ // No need for refund repartition lines in our test; they're not used by reconciliation widget anyway
+ },
+ records: [
+ {
+ id: 6,
+ display_name: "Tax 20.00%",
+ amount: 20,
+ amount_type: "percent",
+ price_include: false,
+ company_id: 1,
+ invoice_repartition_line_ids: [1, 2],
+ },
+ {
+ id: 7,
+ display_name: "Tax 10.00% include",
+ amount: 10,
+ amount_type: "percent",
+ price_include: true,
+ company_id: 1,
+ invoice_repartition_line_ids: [3, 4],
+ },
+ ],
+ json_friendly_compute_all: function (args) {
+ var tax = _.find(db["account.tax"].records, {id: args[0][0]});
+ var amount = args[1];
+
+ var tax_base = null;
+ var base_tags = null;
+ var taxes = [];
+
+ for (let i = 0; i < tax.invoice_repartition_line_ids.length; i++) {
+ var rep_ln = _.find(db["account.tax.repartition.line"].records, {
+ id: tax.invoice_repartition_line_ids[i],
+ });
+
+ if (rep_ln.repartition_type == "base") {
+ tax_base =
+ (tax.price_include
+ ? (amount * 100) / (100 + tax.amount)
+ : amount) *
+ (rep_ln.factor_percent / 100);
+ base_tags = rep_ln.tag_ids;
+ } else if (rep_ln.repartition_type == "tax") {
+ /*
+ IMPORTANT :
+ For simplicity of testing, we assume there is ALWAYS a
+ base repartition line before the tax one, so tax_base is non-null
+ */
+ taxes.push({
+ id: tax.id,
+ amount: (tax_base * tax.amount) / 100,
+ base: tax_base,
+ name: tax.display_name,
+ analytic: false,
+ account_id: rep_ln.account_id,
+ price_include: tax.price_include,
+ tax_repartition_line_id: rep_ln.id,
+ tag_ids: rep_ln.tag_ids,
+ tax_ids: [tax.id],
+ });
+ }
+ }
+
+ return Promise.resolve({
+ base: amount,
+ taxes: taxes,
+ base_tags: base_tags,
+ total_excluded: (amount / 100) * (100 - tax.amount),
+ total_included: amount,
+ });
+ },
+ },
+ "account.journal": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ company_id: {
+ string: "Company",
+ type: "many2one",
+ relation: "res.company",
+ },
+ },
+ records: [{id: 8, display_name: "company 1 journal", company_id: 1}],
+ },
+ "account.analytic.account": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ },
+ records: [
+ {id: 16, display_name: "Administrative"},
+ {id: 7, display_name: "Agrolait - Agrolait"},
+ {id: 8, display_name: "Asustek - ASUSTeK"},
+ {id: 15, display_name: "Camp to Camp - Camptocamp"},
+ {id: 6, display_name: "CampToCamp - Camptocamp"},
+ {id: 17, display_name: "Commercial & Marketing"},
+ {id: 23, display_name: "Data Import/Export Plugin - Delta PC"},
+ {id: 9, display_name: "Delta PC - Delta PC"},
+ ],
+ },
+ "account.analytic.tag": {
+ fields: {
+ id: {string: "id", type: "integer"},
+ display_name: {string: "display_name", type: "char"},
+ },
+ records: [
+ {id: 1, display_name: "Come together"},
+ {id: 2, display_name: "Right now"},
+ ],
+ },
+ "account.bank.statement": {
+ fields: {},
+ },
+ "account.bank.statement.line": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ display_name: {string: "Displayed name", type: "char"},
+ partner_id: {
+ string: "partner",
+ type: "many2one",
+ relation: "res.partner",
+ },
+ company_id: {
+ string: "Company",
+ type: "many2one",
+ relation: "res.company",
+ },
+ },
+ records: [
+ {id: 5, display_name: "SAJ/2014/002 and SAJ/2014/003", company_id: 1},
+ {id: 6, display_name: "Bank fees", company_id: 1},
+ {id: 7, display_name: "Prepayment", company_id: 1},
+ {
+ id: 8,
+ display_name: "First 2000 \u20ac of SAJ/2014/001",
+ company_id: 1,
+ },
+ ],
+ },
+ "account.move.line": {
+ fields: {},
+ },
+ "account.reconcile.model": {
+ fields: {
+ id: {string: "ID", type: "integer"},
+ name: {string: "Button Label", type: "char"},
+ rule_type: {
+ string: "Type",
+ type: "selection",
+ selection: [
+ ["writeoff_button", "Create a Button"],
+ ["writeoff_suggestion", "Write off Suggestion"],
+ ["invoice_matching", "Invoice matching"],
+ ],
+ default: "writeoff_button",
+ },
+ has_second_line: {string: "Add a second line", type: "boolean"},
+ account_id: {
+ string: "Account",
+ type: "many2one",
+ relation: "account.account",
+ },
+ journal_id: {
+ string: "Journal",
+ type: "many2one",
+ relation: "account.journal",
+ },
+ label: {string: "Journal Item Label", type: "char"},
+ amount_type: {
+ string: "amount_type",
+ type: "selection",
+ selection: [
+ ["fixed", "Fixed"],
+ ["percentage", "Percentage of balance"],
+ ],
+ default: "percentage",
+ },
+ amount: {
+ string: "Amount",
+ type: "float",
+ digits: 0,
+ help:
+ "Fixed amount will count as a debit if it is negative, as a credit if it is positive.",
+ default: 100.0,
+ },
+ tax_ids: {string: "Tax", type: "many2many", relation: "account.tax"},
+ analytic_account_id: {
+ string: "Analytic Account",
+ type: "many2one",
+ relation: "account.analytic.account",
+ },
+ second_account_id: {
+ string: "Second Account",
+ type: "many2one",
+ relation: "account.account",
+ domain: [("deprecated", "=", false)],
+ },
+ second_journal_id: {
+ string: "Second Journal",
+ type: "many2one",
+ relation: "account.journal",
+ help: "This field is ignored in a bank statement reconciliation.",
+ },
+ second_label: {string: "Second Journal Item Label", type: "char"},
+ second_amount_type: {
+ string: "Second amount_type",
+ type: "selection",
+ selection: [
+ ["fixed", "Fixed"],
+ ["percentage", "Percentage of balance"],
+ ],
+ default: "percentage",
+ },
+ second_amount: {
+ string: "Second Amount",
+ type: "float",
+ digits: 0,
+ help:
+ "Fixed amount will count as a debit if it is negative, as a credit if it is positive.",
+ default: 100.0,
+ },
+ second_tax_ids: {
+ string: "Second Tax",
+ type: "many2many",
+ relation: "account.tax",
+ },
+ second_analytic_account_id: {
+ string: "Second Analytic Account",
+ type: "many2one",
+ relation: "account.analytic.account",
+ },
+ match_journal_ids: {
+ string: "Journal Ids",
+ type: "many2many",
+ relation: "account.journal",
+ },
+ analytic_tag_ids: {
+ string: "Analytic tags",
+ type: "many2many",
+ relation: "account.analytic.tag",
+ },
+ },
+ records: [
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 4,
+ analytic_account_id: false,
+ display_name: "Int\u00e9rrets",
+ rule_type: "writeoff_button",
+ second_tax_ids: [],
+ has_second_line: false,
+ journal_id: false,
+ label: false,
+ second_label: false,
+ second_account_id: false,
+ account_id: 282,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [],
+ amount_type: "fixed",
+ name: "Int\u00e9rrets",
+ amount: 0.0,
+ second_amount: 100.0,
+ match_journal_ids: [],
+ },
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 2,
+ analytic_account_id: false,
+ display_name: "Perte et Profit",
+ rule_type: "writeoff_button",
+ second_tax_ids: [],
+ has_second_line: false,
+ journal_id: false,
+ label: false,
+ second_label: false,
+ second_account_id: false,
+ account_id: 283,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [],
+ amount_type: "percentage",
+ name: "Perte et Profit",
+ amount: 100.0,
+ second_amount: 100.0,
+ match_journal_ids: [],
+ },
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 5,
+ analytic_account_id: false,
+ display_name: "Fs bank",
+ rule_type: "writeoff_button",
+ second_tax_ids: [],
+ has_second_line: false,
+ journal_id: false,
+ label: false,
+ second_label: false,
+ second_account_id: false,
+ account_id: 284,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [],
+ amount_type: "percentage",
+ name: "Fs bank",
+ amount: 100.0,
+ second_amount: 100.0,
+ },
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 8,
+ analytic_account_id: false,
+ display_name: "Caisse Sand.",
+ rule_type: "writeoff_button",
+ second_tax_ids: [],
+ has_second_line: false,
+ journal_id: false,
+ label: "Caisse Sand.",
+ second_label: false,
+ second_account_id: false,
+ account_id: 308,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [],
+ amount_type: "percentage",
+ name: "Caisse Sand.",
+ amount: 100.0,
+ second_amount: 100.0,
+ match_journal_ids: [],
+ },
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 3,
+ analytic_account_id: false,
+ display_name: "ATOS",
+ rule_type: "writeoff_button",
+ second_tax_ids: [7],
+ has_second_line: true,
+ journal_id: false,
+ label: "ATOS Banque",
+ second_label: "ATOS Frais",
+ second_account_id: 286,
+ account_id: 285,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [6],
+ amount_type: "percentage",
+ name: "ATOS",
+ amount: 97.5,
+ second_amount: -14.75,
+ },
+ {
+ second_analytic_account_id: false,
+ second_amount_type: "percentage",
+ second_journal_id: false,
+ id: 10,
+ analytic_account_id: false,
+ display_name: "Double",
+ rule_type: "writeoff_button",
+ second_tax_ids: [],
+ has_second_line: true,
+ journal_id: false,
+ label: "Double Banque",
+ second_label: "Double Frais",
+ second_account_id: 286,
+ account_id: 285,
+ company_id: [1, "Demo SPRL"],
+ tax_ids: [],
+ amount_type: "percentage",
+ name: "Double",
+ amount: 97.5,
+ second_amount: 100,
+ match_journal_ids: [],
+ analytic_tag_ids: [1, 2],
+ },
+ ],
+ },
+ "account.reconciliation.widget": {
+ fields: {},
+ auto_reconcile: function () {
+ return Promise.resolve(Datas.used.auto_reconciliation);
+ },
+ process_bank_statement_line: function (args) {
+ var datas = args[1];
+ var ids = _.flatten(
+ _.pluck(
+ _.pluck(datas, "counterpart_aml_dicts"),
+ "counterpart_aml_id"
+ )
+ );
+ ids = ids.concat(_.flatten(_.pluck(datas, "payment_aml_ids")));
+ ids = _.compact(ids);
+
+ for (var key in Datas.used.move_lines_for_manual_reconciliation) {
+ Datas.used.move_lines_for_manual_reconciliation[key] = _.filter(
+ Datas.used.move_lines_for_manual_reconciliation[key],
+ function (mv_line) {
+ return ids.indexOf(mv_line.id) === -1;
+ }
+ );
+ }
+ return Promise.resolve();
+ },
+ get_move_lines_for_bank_statement_line: function (args) {
+ var partner_id = args.splice(1, 1)[0];
+ var excluded_ids = args.splice(1, 1)[0];
+ var mode = args.splice(-1, 1)[0];
+ if (mode === "other") return Promise.resolve([]);
+ args.splice(-1, 1); // Ignore limit
+ var key = JSON.stringify(args);
+ if (!Datas.used.mv_lines[key]) {
+ throw new Error(
+ "Unknown parameters for get_move_lines_for_bank_statement_line: '" +
+ key +
+ "'"
+ );
+ }
+ var lines = Datas.used.mv_lines[key]
+ .filter(function (line) {
+ return (
+ excluded_ids.indexOf(line.id) === -1 &&
+ (!partner_id || partner_id === line.partner_id)
+ );
+ })
+ .map(function (line, i, src) {
+ line.recs_count = src.length;
+ return line;
+ })
+ .slice(0, options.params.limitMoveLines);
+ return Promise.resolve(lines);
+ },
+ get_bank_statement_line_data: function (args) {
+ var ids = args[0];
+ var results = {
+ value_min: 0,
+ value_max: ids.length,
+ lines: _.filter(Datas.used.data_widget, function (w) {
+ return _.contains(ids, w.st_line.id);
+ }),
+ };
+ return Promise.resolve(results);
+ },
+ get_bank_statement_data: function () {
+ var results = Datas.used.data_preprocess;
+ results.lines = _.filter(Datas.used.data_widget, function (w) {
+ return _.contains(results.st_lines_ids, w.st_line.id);
+ });
+ return Promise.resolve(results);
+ },
+ get_move_lines_for_manual_reconciliation: function (args) {
+ var excluded_ids = args.splice(2, 1)[0];
+ args.splice(-1, 1); // Ignore limit
+ var key = JSON.stringify(args);
+ if (!Datas.used.move_lines_for_manual_reconciliation[key]) {
+ throw new Error(
+ "Unknown parameters for get_move_lines_for_manual_reconciliation: '" +
+ key +
+ "'"
+ );
+ }
+ var lines = Datas.used.move_lines_for_manual_reconciliation[key]
+ .filter(function (line) {
+ return excluded_ids.indexOf(line.id) === -1;
+ })
+ .map(function (line, i, src) {
+ line.recs_count = src.length;
+ return line;
+ })
+ .slice(0, options.params.limitMoveLines);
+ return Promise.resolve(lines);
+ },
+ get_all_data_for_manual_reconciliation: function (args) {
+ var key = JSON.stringify(args);
+ if (!Datas.used.data_for_manual_reconciliation_widget[key]) {
+ throw new Error(
+ "Unknown parameters for get_all_data_for_manual_reconciliation: '" +
+ key +
+ "'"
+ );
+ }
+ return Promise.resolve(
+ Datas.used.data_for_manual_reconciliation_widget[key]
+ );
+ },
+ process_move_lines: function (args) {
+ var datas = args[0];
+ for (var i in datas) {
+ var data = datas[i];
+ for (var key in Datas.used.move_lines_for_manual_reconciliation) {
+ Datas.used.move_lines_for_manual_reconciliation[key] = _.filter(
+ Datas.used.move_lines_for_manual_reconciliation[key],
+ function (mv_line) {
+ return data.mv_line_ids.indexOf(mv_line.id) === -1;
+ }
+ );
+ }
+ }
+ return Promise.resolve();
+ },
+ },
+ };
+
+ var data_preprocess = {
+ value_min: 0,
+ value_max: 4,
+ notifications: [],
+ num_already_reconciled_lines: 0,
+ st_lines_ids: [5, 6, 7, 8],
+ statement_name: "BNK/2014/001",
+ };
+
+ var data_widget = [
+ {
+ st_line: {
+ currency_id: 3,
+ communication_partner_name: false,
+ open_balance_account_id: 287,
+ name: "SAJ/2014/002 and SAJ/2014/003",
+ partner_name: "Agrolait",
+ partner_id: 8,
+ has_no_partner: false,
+ journal_id: 84,
+ account_name: "Bank",
+ note: "",
+ amount: 1175.0,
+ amount_str: "$ 1,175.00",
+ amount_currency_str: "",
+ date: "2017-01-01",
+ account_code: "101401",
+ ref: "",
+ id: 5,
+ statement_id: 2,
+ company_id: 1,
+ },
+ reconciliation_proposition: [],
+ },
+ {
+ st_line: {
+ currency_id: 3,
+ communication_partner_name: false,
+ name: "Bank fees",
+ partner_name: false,
+ partner_id: false,
+ has_no_partner: true,
+ journal_id: 84,
+ account_name: "Bank",
+ note: "",
+ amount: -32.58,
+ amount_str: "$ 32.58",
+ amount_currency_str: "",
+ date: "2017-01-01",
+ account_code: "101401",
+ ref: "",
+ id: 6,
+ statement_id: 2,
+ company_id: 1,
+ },
+ reconciliation_proposition: [],
+ },
+ {
+ st_line: {
+ currency_id: 3,
+ communication_partner_name: false,
+ open_balance_account_id: 287,
+ name: "Prepayment",
+ partner_name: "Camptocamp",
+ partner_id: 12,
+ has_no_partner: false,
+ journal_id: 84,
+ account_name: "Bank",
+ note: "",
+ amount: 650.0,
+ amount_str: "$ 650.00",
+ amount_currency_str: "",
+ date: "2017-01-01",
+ account_code: "101401",
+ ref: "",
+ id: 7,
+ statement_id: 2,
+ company_id: 1,
+ },
+ reconciliation_proposition: [
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0012",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 133,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_id: [287, "101200 Account Receivable"],
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ },
+ {
+ st_line: {
+ currency_id: 3,
+ communication_partner_name: false,
+ open_balance_account_id: 285,
+ name: "First 2000 \u20ac of SAJ/2014/001",
+ partner_name: "Camptocamp",
+ partner_id: 12,
+ has_no_partner: false,
+ journal_id: 84,
+ account_name: "Bank",
+ note: "",
+ amount: 2000.0,
+ amount_str: "$ 2,000.00",
+ amount_currency_str: "",
+ date: "2017-01-01",
+ account_code: "101401",
+ ref: "",
+ id: 8,
+ statement_id: 2,
+ company_id: 1,
+ },
+ reconciliation_proposition: [],
+ },
+ ];
+
+ var mv_lines = {
+ "[]": [],
+ '[5,"",0]': [
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 109,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 525.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 112,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 525.00",
+ debit: 525.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0012",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 134,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_id: [287, "101200 Account Receivable"],
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 4,610.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 106,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 4,610.00",
+ debit: 4610.0,
+ account_id: [287, "101200 Account Receivable"],
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 10,000.00",
+ partner_id: 12,
+ account_name: "Account Payable",
+ name: "BILL/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 114,
+ credit: 10000.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 10,000.00",
+ debit: 0.0,
+ account_id: [284, "101110 Stock Valuation Account"],
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[5,"b",0]': [
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 100.00",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0003: CUST.IN/2017/0001",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 394,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 100.00",
+ debit: 100.0,
+ account_code: "101401",
+ ref: "",
+ already_paid: true,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 525.50",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0004: CUST.IN/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 396,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 525.50",
+ debit: 525.5,
+ account_code: "101401",
+ ref: "INV/2017/0003",
+ already_paid: true,
+ },
+ ],
+ '[6,"",0]': [
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 376.00",
+ partner_id: 7,
+ account_name: "Bank",
+ name: "BNK1/2017/0002: SUPP.OUT/2017/0002",
+ partner_name: "ASUSTeK",
+ total_amount_currency_str: "",
+ id: 392,
+ credit: 376.0,
+ journal_id: "Bank",
+ amount_str: "$ 376.00",
+ debit: 0.0,
+ account_code: "101401",
+ ref: "BILL/2017/0003",
+ already_paid: true,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 100.00",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0003: CUST.IN/2017/0001",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 394,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 100.00",
+ debit: 100.0,
+ account_code: "101401",
+ ref: "",
+ already_paid: true,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 10,000.00",
+ partner_id: 12,
+ account_name: "Account Payable",
+ name: "BILL/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 114,
+ credit: 10000.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 10,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 525.50",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0004: CUST.IN/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 396,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 525.50",
+ debit: 525.5,
+ account_code: "101401",
+ ref: "INV/2017/0003",
+ already_paid: true,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 109,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-22",
+ date: "2017-01-23",
+ total_amount_str: "$ 525.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0004",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 399,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 525.00",
+ debit: 525.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 4,610.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 106,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 4,610.00",
+ debit: 4610.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-15",
+ total_amount_str: "$ 5,749.99",
+ partner_id: 7,
+ account_name: "Account Payable",
+ name: "BILL/2017/0002",
+ partner_name: "ASUSTeK",
+ total_amount_currency_str: "",
+ id: 117,
+ credit: 5749.99,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 5,749.99",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[6,"",5]': [
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 4,610.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 106,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 4,610.00",
+ debit: 4610.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 10,000.00",
+ partner_id: 12,
+ account_name: "Account Payable",
+ name: "BILL/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 114,
+ credit: 10000.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 10,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-15",
+ total_amount_str: "$ 5,749.99",
+ partner_id: 7,
+ account_name: "Account Payable",
+ name: "BILL/2017/0002",
+ partner_name: "ASUSTeK",
+ total_amount_currency_str: "",
+ id: 117,
+ credit: 5749.99,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 5,749.99",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[7,"",0]': [
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0012",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 133,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_id: [287, "101200 Account Receivable"],
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 4,610.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 106,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 4,610.00",
+ debit: 4610.0,
+ account_id: [287, "101200 Account Receivable"],
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "payable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-28",
+ date: "2017-01-01",
+ total_amount_str: "$ 10,000.00",
+ partner_id: 12,
+ account_name: "Account Payable",
+ name: "BILL/2017/0001",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 114,
+ credit: 10000.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 10,000.00",
+ debit: 0.0,
+ account_id: [284, "101110 Stock Valuation Account"],
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 376.00",
+ partner_id: 7,
+ account_name: "Bank",
+ name: "BNK1/2017/0002: SUPP.OUT/2017/0002",
+ partner_name: "ASUSTeK",
+ total_amount_currency_str: "",
+ id: 392,
+ credit: 376.0,
+ journal_id: "Bank",
+ amount_str: "$ 376.00",
+ debit: 0.0,
+ account_code: "101401",
+ ref: "BILL/2017/0003",
+ already_paid: true,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 100.00",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0003: CUST.IN/2017/0001",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 394,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 100.00",
+ debit: 100.0,
+ account_code: "101401",
+ ref: "",
+ already_paid: true,
+ },
+ {
+ account_type: "liquidity",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-01-23",
+ date: "2017-01-23",
+ total_amount_str: "$ 525.50",
+ partner_id: 8,
+ account_name: "Bank",
+ name: "BNK1/2017/0004: CUST.IN/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 396,
+ credit: 0.0,
+ journal_id: "Bank",
+ amount_str: "$ 525.50",
+ debit: 525.5,
+ account_code: "101401",
+ ref: "INV/2017/0003",
+ already_paid: true,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 109,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 650.00",
+ debit: 650.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-22",
+ date: "2017-01-23",
+ total_amount_str: "$ 525.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0004",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 399,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 525.00",
+ debit: 525.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[8,"",0]': [],
+ };
+
+ var auto_reconciliation = {
+ num_already_reconciled_lines: 1,
+ notifications: [
+ {
+ message: "1 transaction was automatically reconciled.",
+ type: "info",
+ details: {
+ model: "account.move",
+ name: "Automatically reconciled items",
+ ids: [143],
+ },
+ },
+ ],
+ st_lines_ids: [5, 6, 8],
+ statement_name: false,
+ };
+
+ var data_for_manual_reconciliation_widget = {
+ "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]": {
+ customers: [
+ {
+ account_id: 287,
+ partner_name: "Agrolait",
+ reconciliation_proposition: [],
+ currency_id: 3,
+ max_date: "2017-02-14 12:30:31",
+ last_time_entries_checked: null,
+ account_code: "101200",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ mode: "customers",
+ },
+ {
+ account_id: 7,
+ partner_name: "Camptocamp",
+ reconciliation_proposition: [],
+ currency_id: 3,
+ max_date: "2017-02-13 14:24:55",
+ last_time_entries_checked: null,
+ account_code: "101200",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ mode: "customers",
+ },
+ ],
+ accounts: [
+ {
+ account_id: 283,
+ account_name: "101000 Current Assets",
+ currency_id: 3,
+ max_date: "2017-02-16 14:32:04",
+ last_time_entries_checked: "2017-02-16",
+ account_code: "101000",
+ mode: "accounts",
+ reconciliation_proposition: [
+ {
+ account_id: 283,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-16",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "BNK1/2017/0006: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 399,
+ credit: 1000.0,
+ journal_id: [3, "Bank"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "101000",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_id: 283,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-18",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "INV/2017/0006",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 402,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 1000.0,
+ account_code: "101000",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ },
+ ],
+ suppliers: [
+ {
+ account_id: 284,
+ partner_name: "Agrolait",
+ reconciliation_proposition: [
+ {
+ account_id: 284,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-16",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "BNK1/999: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 999,
+ credit: 1000.0,
+ journal_id: [3, "Bank"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_id: 284,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-18",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "INV/998",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 998,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 1000.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ currency_id: 3,
+ max_date: "2017-02-14 12:36:05",
+ last_time_entries_checked: null,
+ account_code: "111100",
+ partner_id: 8,
+ account_name: "Account Payable",
+ mode: "suppliers",
+ },
+ {
+ account_id: 284,
+ partner_name: "Camptocamp",
+ reconciliation_proposition: [
+ {
+ account_id: 284,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-16",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 12,
+ account_name: "101000 Current Assets",
+ name: "BNK1/1999: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 1999,
+ credit: 1000.0,
+ journal_id: [3, "Bank"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_id: 284,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-18",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 12,
+ account_name: "101000 Current Assets",
+ name: "INV/1998",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 1998,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 1000.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ currency_id: 3,
+ max_date: "2017-02-14 12:36:05",
+ last_time_entries_checked: null,
+ account_code: "111100",
+ partner_id: 12,
+ account_name: "Account Payable",
+ mode: "suppliers",
+ },
+ ],
+ },
+ '["partner",null,"receivable"]': [
+ {
+ account_id: 287,
+ partner_name: "Agrolait",
+ reconciliation_proposition: [],
+ currency_id: 3,
+ max_date: "2017-02-14 12:30:31",
+ last_time_entries_checked: null,
+ account_code: "101200",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ mode: "customers",
+ },
+ {
+ account_id: 287,
+ partner_name: "Camptocamp",
+ reconciliation_proposition: [],
+ currency_id: 3,
+ max_date: "2017-02-13 14:24:55",
+ last_time_entries_checked: null,
+ account_code: "101200",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ mode: "customers",
+ },
+ ],
+ };
+
+ var move_lines_for_manual_reconciliation = {
+ '[287,8,"",0]': [
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "10,222.00 €",
+ currency_id: 1,
+ date_maturity: "2017-02-08",
+ date: "2017-02-08",
+ total_amount_str: "$ 11,000.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0004: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 17,
+ credit: 11000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 11,000.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [7, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0005: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 19,
+ credit: 1000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 180.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "BILL/2017/0003: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 21,
+ credit: 180.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 180.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 90.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0006: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 23,
+ credit: 90.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 90.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-10",
+ date: "2017-02-08",
+ total_amount_str: "$ 650.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0012",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 6,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1000.00",
+ debit: 1000.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-10",
+ date: "2017-02-08",
+ total_amount_str: "$ 525.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 9,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 525.00",
+ debit: 525.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[7,12,"",0]': [
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-08",
+ date: "2017-02-08",
+ total_amount_str: "$ 11,000.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0004: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 17,
+ credit: 11000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 11,000.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [7, "101200 Account Receivable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0005: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 19,
+ credit: 1000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency: 100,
+ amount_currency_str: "100.00 €",
+ currency_id: 1,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 170.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 21,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 170.00",
+ debit: 170.0,
+ account_code: "101200",
+ ref: "INV fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency: 100,
+ amount_currency_str: "100.00 €",
+ currency_id: 1,
+ date_maturity: "2017-02-10",
+ date: "2017-02-10",
+ total_amount_str: "$ 180.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "BILL/2017/0003: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 22,
+ credit: 180.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 180.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency: 170,
+ amount_currency_str: "170.00 €",
+ currency_id: 1,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 100.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 23,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 100.00",
+ debit: 100.0,
+ account_code: "101200",
+ ref: "INV fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [287, "101200 Account Receivable"],
+ amount_currency: 180,
+ amount_currency_str: "180.00 €",
+ currency_id: 1,
+ date_maturity: "2017-02-10",
+ date: "2017-02-10",
+ total_amount_str: "$ 100.00",
+ partner_id: 12,
+ account_name: "101200 Account Receivable",
+ name: "BILL/2017/0003: Customer Payment",
+ partner_name: "Camptocamp",
+ total_amount_currency_str: "",
+ id: 24,
+ credit: 100.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 100.00",
+ debit: 0.0,
+ account_code: "101200",
+ ref: "fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ ],
+ '[284,8,"",0]': [
+ {
+ account_type: "receivable",
+ account_id: [284, "111100 Account Payable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-08",
+ date: "2017-02-08",
+ total_amount_str: "$ 11,000.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0004: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 17,
+ credit: 11000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 11,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [284, "111100 Account Payable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0005: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 19,
+ credit: 1000.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "receivable",
+ account_id: [284, "111100 Account Payable"],
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-09",
+ date: "2017-02-09",
+ total_amount_str: "$ 180.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "BILL/2017/0003: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 21,
+ credit: 180.0,
+ journal_id: [2, "Vendor Bills"],
+ amount_str: "$ 180.00",
+ debit: 0.0,
+ account_code: "111100",
+ ref: "fddfgfdgfdgsdfg",
+ already_paid: false,
+ },
+ ],
+ '[283,null,"",0]': [
+ {
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-16",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "BNK1/2017/0006: Customer Payment",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 399,
+ credit: 1000.0,
+ journal_id: [3, "Bank"],
+ amount_str: "$ 1,000.00",
+ debit: 0.0,
+ account_code: "101000",
+ ref: "",
+ already_paid: false,
+ },
+ {
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-18",
+ date: "2017-02-16",
+ total_amount_str: "$ 1,000.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "INV/2017/0006",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 402,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1,000.00",
+ debit: 1000.0,
+ account_code: "101000",
+ ref: "",
+ already_paid: false,
+ },
+ ],
+ '[284,12,"",0]': [],
+ };
+
+ var session = {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ };
+
+ var options = {
+ context: {
+ statement_line_ids: [4],
+ },
+ params: {
+ limitMoveLines: 5,
+ },
+ };
+
+ Datas.params = {
+ data: db,
+ data_preprocess: data_preprocess,
+ data_widget: data_widget,
+ mv_lines: mv_lines,
+ auto_reconciliation: auto_reconciliation,
+ data_for_manual_reconciliation_widget: data_for_manual_reconciliation_widget,
+ move_lines_for_manual_reconciliation: move_lines_for_manual_reconciliation,
+ session: session,
+ options: options,
+ };
+ // This is the main function for this module. Its job is to export (and clone) all data for a test.
+ Datas.getParams = function () {
+ return (this.used = $.extend(true, {}, this.params));
+ };
+ return Datas;
+});
+
+odoo.define("account.reconciliation_tests", function (require) {
+ "use strict";
+
+ var ReconciliationClientAction = require("account.ReconciliationClientAction");
+ var ReconciliationRenderer = require("account.ReconciliationRenderer");
+ var demoData = require("account.reconciliation_tests.data");
+
+ var testUtils = require("web.test_utils");
+ var testUtilsDom = require("web.test_utils_dom");
+ var testUtilsMock = require("web.test_utils_mock");
+
+ QUnit.module(
+ "account",
+ {
+ beforeEach: function () {
+ this.params = demoData.getParams();
+ testUtils.patch(ReconciliationRenderer.LineRenderer, {
+ MV_LINE_DEBOUNCE: 0,
+ });
+ },
+ afterEach: function () {
+ testUtils.unpatch(ReconciliationRenderer.LineRenderer);
+ },
+ },
+ function () {
+ QUnit.module("Reconciliation");
+
+ QUnit.test("Reconciliation basic rendering", async function (assert) {
+ assert.expect(10);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+ var widget = clientAction.widgets[0];
+
+ assert.hasClass(
+ widget.$el,
+ "o_reconciliation_line",
+ "should instance of widget reconciliation"
+ );
+ assert.containsOnce(widget, ".accounting_view", "should have one view");
+ assert.containsN(
+ widget,
+ '[id*="notebook_page_match"]',
+ 2,
+ "should have 'match_rp' and 'match_other' panel"
+ );
+ assert.containsOnce(widget, ".create", "should have 'create' panel");
+
+ assert.strictEqual(
+ widget
+ .$("thead")
+ .text()
+ .replace(/[\n\r\s]+/g, " "),
+ " 101401 2017-01-01 SAJ/2014/002 and SAJ/2014/003 $ 1,175.00 ",
+ "should display the line information"
+ );
+ assert.ok(
+ widget.$("caption .o_field_many2one").length,
+ "should display the many2one with to select a partner"
+ );
+
+ assert.containsN(
+ clientAction,
+ '[data-mode="inactive"]',
+ 3,
+ "should be as 'inactive' mode by default"
+ );
+ assert.strictEqual(
+ widget.$el.data("mode"),
+ "match_rp",
+ "the first one should automatically switch to match_rp mode"
+ );
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ assert.strictEqual(
+ widget.$el.data("mode"),
+ "create",
+ "should switch to 'create' mode"
+ );
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_match_rp"]')
+ );
+ assert.strictEqual(
+ widget.$el.data("mode"),
+ "match_rp",
+ "should switch to 'match_rp' mode"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation fields", async function (assert) {
+ assert.expect(6);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ user_has_group: function (group) {
+ if (
+ group === "analytic.group_analytic_tags" ||
+ group === "analytic.group_analytic_accounting"
+ ) {
+ return $.when(true);
+ }
+ return this._super.apply(this, arguments);
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "Agrolait",
+ "the partner many2one should display agrolait"
+ );
+ assert.strictEqual(
+ clientAction.widgets[2].$(".o_input_dropdown input").val(),
+ "Camptocamp",
+ "the partner many2one should display Camptocamp"
+ );
+ await testUtils.dom.click(widget.$(".accounting_view tfoot td:first"));
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ assert.containsN(
+ widget,
+ ".create input.o_input",
+ 8,
+ "create panel should contain 8 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount, date)"
+ );
+ assert.containsN(
+ widget,
+ ".create .create_account_id .o_required_modifier, .create .create_label .o_required_modifier, .create .create_amount .o_required_modifier",
+ 3,
+ "account_id, label and amount should be required fields"
+ );
+ assert.strictEqual(
+ widget.$(".create .create_label input").val(),
+ "SAJ/2014/002 and SAJ/2014/003",
+ "should use the name of the reconciliation line for the default label value"
+ );
+ assert.strictEqual(
+ widget.$(".create .create_amount input").val(),
+ "1175.00",
+ "should have the balance amout as default value for the amout field"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation basic data", async function (assert) {
+ assert.expect(17);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+ var widget = clientAction.widgets[0];
+
+ assert.containsN(
+ widget,
+ ".match:first .mv_line",
+ 2,
+ "should display 2 account move lines"
+ );
+ assert.strictEqual(
+ widget
+ .$(".match:first .mv_line")
+ .text()
+ .replace(/[\n\r\s]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101200 2017-02-07 INV/2017/0002 $ 650.00 101200 2017-02-07 INV/2017/0003 $ 525.00 ",
+ "should display 4 account move lines who contains the account_code, due_date, label and the credit"
+ );
+ assert.strictEqual(
+ widget.$('.match:first .mv_line .cell_right:contains(".")').length,
+ 2,
+ "should display only the credit account move lines (hide the debit)"
+ );
+
+ await testUtils.dom.click(
+ clientAction.widgets[1].$(".accounting_view thead td:first")
+ );
+ assert.containsN(
+ clientAction.widgets[1],
+ ".mv_line",
+ 5,
+ "should display 5 account move lines"
+ );
+ assert.strictEqual(
+ clientAction.widgets[1].$('.mv_line .cell_right:contains(".")')
+ .length,
+ 3,
+ "should display only the credit account move lines (hide the debit)"
+ );
+ assert.containsN(
+ clientAction.widgets[1],
+ ".mv_line.already_reconciled",
+ 3,
+ "should display 3 already reconciled account move lines"
+ );
+ assert.strictEqual(
+ clientAction.widgets[1]
+ .$(".mv_line")
+ .text()
+ .replace(/[\n\r\s]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101401 2017-01-23 ASUSTeK: BNK1/2017/0002: SUPP.OUT/2017/0002 : BILL/2017/0003 $ 376.00 101401 2017-01-23 Agrolait: BNK1/2017/0003: CUST.IN/2017/0001 $ 100.00 111100 2017-02-28 Camptocamp: BILL/2017/0001 $ 10,000.00 101401 2017-01-23 Agrolait: BNK1/2017/0004: CUST.IN/2017/0002 : INV/2017/0003 $ 525.50 101200 2017-02-07 Agrolait: INV/2017/0002 $ 650.00 ",
+ "should display 4 account move lines who contains the account_code, due_date, label and the credit"
+ );
+ assert.strictEqual(
+ clientAction.widgets[1].$('.mv_line .cell_left:contains(".")')
+ .length,
+ 2,
+ "should display only 2 debit account move lines"
+ );
+
+ // Load more
+ assert.ok(
+ clientAction.widgets[1].$(".match:first div.load-more a:visible")
+ .length,
+ "should display the 'load more' button"
+ );
+ assert.equal(
+ clientAction.widgets[1].$(".match:first div.load-more span").text(),
+ 3,
+ "should display 3 items remaining"
+ );
+ await testUtils.dom.click(
+ clientAction.widgets[1].$(".match:first div.load-more a")
+ );
+ assert.containsN(
+ clientAction.widgets[1],
+ ".mv_line",
+ 8,
+ "should load 3 more records"
+ );
+ assert.notOk(
+ clientAction.widgets[1].$(".match:first div.load-more a:visible")
+ .length,
+ "should not display the 'load more' button anymore"
+ );
+
+ assert.ok(
+ clientAction.widgets[0].$("caption button.btn-secondary:visible")
+ .length,
+ "should display the secondary 'Validate' button"
+ );
+ assert.equal(
+ clientAction.widgets[1].$("caption button:disabled:visible").length,
+ 1,
+ "button should be disabled"
+ );
+ assert.ok(
+ clientAction.widgets[2].$("caption button.btn-primary:visible")
+ .length,
+ "should display the primary 'Validate' button"
+ );
+
+ await testUtils.dom.click(
+ clientAction.widgets[3].$(".accounting_view thead td:first")
+ );
+ assert.strictEqual(
+ clientAction.widgets[3].$el.data("mode"),
+ "create",
+ "should switch to 'create' mode instead of 'match_rp' mode when 'match_rp' mode is empty"
+ );
+
+ // Open the first line
+ await testUtils.dom.click(widget.$(".accounting_view thead td:first"));
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_match_rp"]')
+ );
+ // Select propositions
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+
+ // Await testUtils.dom.click(widget.$('caption')); //why is it inactive?
+
+ testUtils.mock.intercept(clientAction, "call_service", function (
+ event
+ ) {
+ if (event.data.args[1].method == "process_bank_statement_line") {
+ assert.deepEqual(
+ event.data.args[1].args,
+ [
+ [5],
+ [
+ {
+ partner_id: 8,
+ counterpart_aml_dicts: [
+ {
+ counterpart_aml_id: 109,
+ credit: 650,
+ debit: 0,
+ name: "INV/2017/0002",
+ analytic_tag_ids: [[6, null, []]],
+ },
+ {
+ counterpart_aml_id: 112,
+ credit: 525,
+ debit: 0,
+ name: "INV/2017/0003",
+ analytic_tag_ids: [[6, null, []]],
+ },
+ ],
+ payment_aml_ids: [],
+ new_aml_dicts: [],
+ to_check: false,
+ },
+ ],
+ ],
+ "Should call process_bank_statement_line with args"
+ );
+ var def = testUtils.makeTestPromise();
+ def.abort = function () {};
+ event.data.callback(def);
+ }
+ });
+
+ // Click on reconcile button
+ await testUtils.dom.click(widget.$(".o_reconcile:visible"));
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation validate without proposition", async function (
+ assert
+ ) {
+ assert.expect(1);
+ // Test added to prevent this issue happening again: https://github.com/odoo/odoo/commit/3549688b21eb65e16b9c3f2b6462eb8d8b52cd47
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+ // Ensure that when we validate a line without any selection, it is the same
+ // as when we manually create a line with the line.balance and that only one
+ // line is send back to server.
+ testUtils.mock.intercept(clientAction, "call_service", function (
+ event
+ ) {
+ assert.deepEqual(
+ event.data.args[1].args,
+ [
+ [5],
+ [
+ {
+ partner_id: 8,
+ to_check: false,
+ counterpart_aml_dicts: [],
+ payment_aml_ids: [],
+ to_check: false,
+ new_aml_dicts: [
+ {
+ account_id: 287,
+ credit: 1175,
+ debit: 0,
+ name: "SAJ/2014/002 and SAJ/2014/003",
+ analytic_tag_ids: [[6, null, []]],
+ },
+ ],
+ },
+ ],
+ ],
+ "Should call process_bank_statement_line with ids"
+ );
+ var def = testUtils.makeTestPromise();
+ def.abort = function () {};
+ event.data.callback(def);
+ });
+
+ // Click on validate button
+ await testUtils.dom.click(widget.$("button.o_validate:not(:hidden)"));
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation validate with proposition", async function (
+ assert
+ ) {
+ assert.expect(1);
+ // Test added to check this functionality: https://github.com/odoo/odoo/commit/2f3b469dee6f18cbccce1cdf2a81cfe57960c533
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+ var widget = clientAction.widgets[0];
+ // Add a line as proposition
+ // open the first line
+ await testUtils.dom.click(widget.$(".accounting_view thead td:first"), {
+ allowInvisible: true,
+ });
+ await testUtils.nextTick();
+ // Select propositions
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first"),
+ {allowInvisible: true}
+ );
+ await testUtils.nextTick();
+
+ // Ensure that when we validate a line with propositions and that there is a remaining balance
+ // We also create a line which is the open balance.
+ testUtils.mock.intercept(clientAction, "call_service", function (
+ event
+ ) {
+ assert.deepEqual(
+ event.data.args[1].args,
+ [
+ [5],
+ [
+ {
+ partner_id: 8,
+ to_check: false,
+ counterpart_aml_dicts: [
+ {
+ counterpart_aml_id: 109,
+ credit: 650,
+ debit: 0,
+ name: "INV/2017/0002",
+ analytic_tag_ids: [[6, null, []]],
+ },
+ ],
+ payment_aml_ids: [],
+ new_aml_dicts: [
+ {
+ account_id: 287,
+ credit: 525,
+ debit: 0,
+ name:
+ "SAJ/2014/002 and SAJ/2014/003 : Open balance",
+ },
+ ],
+ },
+ ],
+ ],
+ "Should call process_bank_statement_line with ids"
+ );
+ var def = testUtils.makeTestPromise();
+ def.abort = function () {};
+ event.data.callback(def);
+ });
+
+ // Click on validate button
+ await testUtils.dom.click(widget.$("button.o_validate:not(:hidden)"));
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation partial [REQUIRE FOCUS]", async function (
+ assert
+ ) {
+ assert.expect(8);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ console.log(args.method);
+ if (args.method === "process_bank_statement_line") {
+ var lines = args.args["1"];
+ console.log(args.arsg);
+ assert.deepEqual(
+ args.args,
+ [
+ [6],
+ [
+ {
+ partner_id:
+ lines.length == 1
+ ? lines[0].partner_id
+ : false,
+ counterpart_aml_dicts: [
+ {
+ analytic_tag_ids: [[6, null, []]],
+ counterpart_aml_id: 114,
+ credit: 0,
+ debit: 32.58,
+ name: "BILL/2017/0001",
+ },
+ ],
+ payment_aml_ids: [],
+ new_aml_dicts: [],
+ to_check: false,
+ },
+ ],
+ ],
+ "should call process_bank_statement_line with partial reconcile values"
+ );
+ }
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+ var widget = clientAction.widgets[0];
+
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.equal(
+ widget.$(".cell_right .edit_amount").length,
+ 1,
+ "should display the edition pencil"
+ );
+
+ widget = clientAction.widgets[1];
+
+ await testUtils.dom.click(widget.$(".accounting_view thead td:first"));
+ assert.strictEqual(
+ widget.$(
+ ".match:first .mv_line[data-line-id=114] .cell_account_code:first()"
+ ).length,
+ 1,
+ "Should have line"
+ );
+ await testUtils.dom.click(
+ widget.$(
+ ".match:first .mv_line[data-line-id=114] .cell_account_code"
+ )
+ );
+
+ assert.equal(
+ widget.$(".accounting_view tbody .cell_left .edit_amount").length,
+ 1,
+ "should display the edition pencil"
+ );
+
+ // The partner has been set automatically, remove it.
+ await testUtils.dom.click(widget.$(".o_input_dropdown input"));
+ await testUtils.fields.editAndTrigger(
+ widget.$(".o_input_dropdown input"),
+ "",
+ ["keyup", "blur"]
+ );
+
+ assert.equal(
+ clientAction.widgets[1].$("caption button:disabled:visible").length,
+ 1,
+ "button should be disabled"
+ );
+ await testUtils.dom.click(
+ widget.$(".accounting_view .cell_left .edit_amount")
+ );
+ assert.strictEqual(
+ widget.$(
+ ".accounting_view .cell_left .edit_amount_input:not(.d-none)"
+ ).length,
+ 1,
+ "should display the input field to edit amount"
+ );
+ // Edit amount
+ await testUtils.fields.editAndTrigger(
+ widget.$(
+ ".accounting_view .cell_left .edit_amount_input:not(.d-none)"
+ ),
+ "32.58",
+ ["change", "blur"]
+ );
+ assert.strictEqual(
+ widget
+ .$(".accounting_view .cell_left .line_amount")
+ .text()
+ .replace(/[\n\r\s]+/g, " "),
+ " $ 10000.00 $ 32.58 ",
+ "should display previous amount and new amount"
+ );
+
+ assert.strictEqual(
+ widget.$("button.btn-primary:visible").length,
+ 1,
+ "should display the reconcile button"
+ );
+ await testUtils.dom.click(widget.$("button.btn-primary:visible"));
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation currencies", async function (assert) {
+ assert.expect(2);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: this.params.session,
+ translateParameters: {
+ date_format: "%m/%d/%Y",
+ direction: "ltr",
+ name: "English",
+ thousands_sep: ",",
+ time_format: "%H:%M:%S",
+ decimal_point: ".",
+ id: 1,
+ grouping: [3, 0],
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ assert.strictEqual(
+ clientAction
+ .$(
+ ".accounting_view tfoot .cell_right, .accounting_view tfoot .cell_left"
+ )
+ .text()
+ .replace(/[\n\r\s]+/g, " "),
+ "$ 1,175.00$ 32.58$ 2,000.00",
+ "should display the different amounts with the currency"
+ );
+ // Await testUtils.dom.click(widget.$('.accounting_view thead .mv_line td:first'));
+
+ assert.strictEqual(
+ clientAction
+ .$(".accounting_view tbody")
+ .text()
+ .replace(/[\n\r\s]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101200 2017-02-07 INV/2017/0012 $ 650.00 ",
+ "should display the created reconciliation line with the currency"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation change partner", async function (assert) {
+ assert.expect(17);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ archs: {
+ "res.partner,false,list":
+ '',
+ "res.partner,false,search":
+ '' +
+ '' +
+ "",
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+ var widget = clientAction.widgets[0];
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "Agrolait",
+ "the partner many2one should display agrolait"
+ );
+ assert.containsN(
+ widget,
+ ".match:first table tr",
+ 2,
+ "agrolait should have 2 propositions for reconciliation"
+ );
+
+ // Adding the two propositions
+ // This is in order to try that after changing partner the propositions are emptied
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.containsN(
+ widget,
+ ".accounting_view tbody tr",
+ 2,
+ "Both proposition should be selected"
+ );
+
+ // Similate changing partner to one that does not have propositions to see if create mode is open after
+ await testUtils.dom.click(widget.$(".o_input_dropdown input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(partner 1)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ clientAction._onAction({
+ target: widget,
+ name: "change_partner",
+ data: {data: {display_name: "partner 1", id: 1}},
+ stopped: false,
+ });
+ await testUtils.nextTick();
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "partner 1",
+ "the partner many2one should display partner 1"
+ );
+ assert.containsNone(
+ widget,
+ ".match:first table tr.mv_line",
+ "partner 1 should have 0 propositions for reconciliation"
+ );
+ assert.strictEqual(
+ widget.$el.data("mode"),
+ "create",
+ "widget should be in create mode"
+ );
+
+ // Simulate changing partner
+ await testUtils.dom.clickFirst(widget.$(".o_input_dropdown input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(Camptocamp)")
+ .trigger("mouseenter")
+ .trigger("click");
+ clientAction._onAction({
+ target: widget,
+ name: "change_partner",
+ data: {data: {display_name: "Camptocamp", id: 12}},
+ stopped: false,
+ });
+ await testUtils.nextTick();
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "Camptocamp",
+ "the partner many2one should display Camptocamp"
+ );
+ assert.containsN(
+ widget,
+ ".match:first table tr.mv_line",
+ 3,
+ "camptocamp should have 3 propositions for reconciliation"
+ );
+
+ // Simulate changing partner with SelectCreateDialog
+ widget = clientAction.widgets[1];
+ assert.strictEqual(
+ $(".modal").length,
+ 0,
+ "shouldn't have any opened modal"
+ );
+ await testUtils.dom.click(widget.$(".o_input_dropdown input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(Search More):eq(1)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ assert.strictEqual(
+ $(".modal").length,
+ 1,
+ "should open a SelectCreateDialog"
+ );
+ await testUtils.dom.click(
+ $(".modal table.o_list_table td:contains(Camptocamp)")
+ );
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "Camptocamp",
+ "the partner many2one should display Camptocamp"
+ );
+
+ widget = clientAction.widgets[2];
+ await testUtils.dom.click(widget.$(".accounting_view thead td:first"));
+ await testUtils.dom.click(
+ widget.$(".accounting_view .mv_line .cell_label")
+ );
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "Camptocamp",
+ "the partner many2one should display agrolait"
+ );
+ assert.containsN(
+ widget,
+ ".match:first table tr",
+ 3,
+ "Camptocamp should have 3 propositions for reconciliation"
+ );
+ assert.notOk(
+ widget.$(".match:first div.load-more a:visible").length,
+ "should not display the load more button"
+ );
+
+ // Simulate remove partner
+ await testUtils.dom.click(widget.$(".o_input_dropdown input"));
+ await testUtils.fields.editAndTrigger(
+ widget.$(".o_input_dropdown input"),
+ "",
+ ["keyup", "blur"]
+ );
+
+ assert.strictEqual(
+ widget.$(".o_input_dropdown input").val(),
+ "",
+ "the partner many2one should be empty"
+ );
+ assert.containsN(
+ widget,
+ ".match:first table tr.mv_line",
+ 5,
+ "should have 5 propositions for reconciliation if partner is false"
+ );
+ assert.ok(
+ widget.$(".match:first div.load-more a:visible").length,
+ "should display the load more button"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation create line", async function (assert) {
+ assert.expect(23);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ assert.strictEqual(
+ clientAction
+ .$(
+ ".accounting_view tfoot .cell_right, .accounting_view tfoot .cell_left"
+ )
+ .text()
+ .replace(/[$, ]+/g, ""),
+ " 1175.00 32.58 2000.00",
+ "should display the open balance values"
+ );
+
+ var widget = clientAction.widgets[0];
+
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open Balance' line with the rest to reconcile"
+ );
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ await testUtils.dom.click(widget.$(".create .create_account_id input"));
+ $(
+ ".ui-autocomplete .ui-menu-item a:contains(101200 Account Receivable)"
+ )
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+
+ assert.notOk(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "should not display 'Open Balance' line because the rest to reconcile is null"
+ );
+ assert.containsOnce(
+ widget,
+ ".accounting_view tbody tr",
+ "should have only the created reconcile line"
+ );
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody tr")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101200 New SAJ/2014/002 and SAJ/2014/003 1175.00 ",
+ "the new line should have the selected account, name and amout"
+ );
+ assert.ok(
+ widget.$("caption button.btn-primary:visible").length,
+ "should display the 'Reconcile' button"
+ );
+
+ testUtils.mock.intercept(clientAction, "do_action", function (event) {
+ assert.strictEqual(
+ JSON.stringify(event.data.action),
+ '{"type":"ir.actions.act_window","res_model":"account.reconcile.model","views":[[false,"form"]],"target":"current"}',
+ "should open the reconcile model form view"
+ );
+ });
+ await testUtils.dom.click(widget.$(".create .reconcile_model_create"), {
+ allowInvisible: true,
+ });
+
+ testUtils.mock.intercept(clientAction, "do_action", function (event) {
+ assert.strictEqual(
+ JSON.stringify(event.data.action),
+ '{"type":"ir.actions.act_window","res_model":"account.reconcile.model","views":[[false,"list"],[false,"form"]],"view_mode":"list","target":"current"}',
+ "should open the reconcile model list view"
+ );
+ });
+ await testUtils.dom.click(widget.$(".create .reconcile_model_edit"), {
+ allowInvisible: true,
+ });
+
+ await testUtils.fields.editInput(
+ widget.$(".create .create_amount input"),
+ "1100.00"
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_right")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 1100.00",
+ "should display the value 1100.00 in right column"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_right").text(),
+ "$ 75.00",
+ "should display 'Open Balance' line because the rest to reconcile is 75.00"
+ );
+ assert.containsOnce(
+ widget,
+ ".accounting_view tbody tr",
+ "should have ever only the created reconcile line"
+ );
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody tr")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101200 New SAJ/2014/002 and SAJ/2014/003 1100.00 ",
+ "the new line should be update the amout"
+ );
+ assert.ok(
+ widget.$("caption button.btn-secondary:visible").length,
+ "should display the 'validate' button"
+ );
+
+ await testUtils.dom.click(widget.$(".create .add_line"), {
+ allowInvisible: true,
+ });
+ await testUtils.fields.editInput(
+ widget.$(".create .create_amount input"),
+ "-100"
+ );
+ await testUtils.dom.click(widget.$(".create .create_account_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput(
+ widget.$(".create .create_label input"),
+ "test0"
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_left:last")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 100.00",
+ "should display the value 100.00 in left column"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open Balance'"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_right").text(),
+ "$ 175.00",
+ "should display 'Open Balance' line because the rest to reconcile is 175.00"
+ );
+ assert.containsN(
+ widget,
+ ".accounting_view tbody tr",
+ 2,
+ "should have 2 created reconcile lines"
+ );
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody tr:eq(1)")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101000 New test0 100.00 ",
+ "the new line should have the selected account, name and amout"
+ );
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+
+ assert.strictEqual(
+ widget.$(".create .create_amount input").val(),
+ "175.00",
+ "should have '175.00' as default amount value"
+ );
+
+ await testUtils.fields.editInput(
+ widget.$(".create .create_amount input"),
+ "200"
+ );
+ widget.$(".create .create_account_id input").trigger("click");
+ $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput(
+ widget.$(".create .create_label input"),
+ "test1"
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_right:last")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 200.00",
+ "should display the value 200.00 in left column"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open balance'"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_left").text(),
+ "$ 25.00",
+ "should display 'Open balance' with 25.00 in left column"
+ );
+ assert.containsN(
+ widget,
+ ".accounting_view tbody tr",
+ 3,
+ "should have 3 created reconcile lines"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation create line (many2one test)", async function (
+ assert
+ ) {
+ assert.expect(5);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ var def = testUtils.makeTestPromise();
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.account,false,list":
+ '',
+ "account.account,false,search":
+ '',
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "name_get") {
+ return def.then(this._super.bind(this, route, args));
+ }
+ return this._super(route, args);
+ },
+ });
+
+ await clientAction.prependTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ // Open the first line in write-off mode
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+
+ // Select an account with the many2one (drop down)
+ await testUtils.dom.click(widget.$(".create .create_account_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(101200)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ assert.strictEqual(
+ widget.$(".create .create_account_id input").val(),
+ "101200 Account Receivable",
+ "Display the selected account"
+ );
+ assert.strictEqual(
+ widget
+ .$("tbody:first .cell_account_code")
+ .text()
+ .replace(/[\u200B]/g, ""),
+ "101200",
+ "Display the code of the selected account"
+ );
+
+ // Use the many2one select dialog to change the account
+ await testUtils.dom.click(widget.$(".create .create_account_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(Search)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ // Select the account who does not appear in the drop drown
+ await testUtils.dom.click($(".modal tr.o_data_row:contains(502)"));
+ assert.strictEqual(
+ widget.$(".create .create_account_id input").val(),
+ "101200 Account Receivable",
+ "Selected account does not change"
+ );
+ // Wait the name_get to render the changes
+ def.resolve();
+ await testUtils.nextTick();
+ assert.strictEqual(
+ widget.$(".create .create_account_id input").val(),
+ "502 Account",
+ "Display the selected account"
+ );
+ assert.strictEqual(
+ widget
+ .$("tbody:first .cell_account_code")
+ .text()
+ .replace(/[\u200B]/g, ""),
+ "502",
+ "Display the code of the selected account"
+ );
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation create line with taxes", async function (
+ assert
+ ) {
+ assert.expect(13);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ await testUtils.dom.click(widget.$(".create .create_account_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(101000 Current Assets)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+ await testUtils.fields.editInput(
+ widget.$(".create .create_label input"),
+ "test1"
+ );
+ await testUtils.fields.editInput(
+ widget.$(".create .create_amount input"),
+ "1100"
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_right:last")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 1100.00",
+ "should display the value 1100.00 in left column"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open Balance'"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_right").text(),
+ "$\u00a075.00",
+ "should display 'Open Balance' with 75.00 in right column"
+ );
+ assert.containsOnce(
+ widget,
+ ".accounting_view tbody tr",
+ "should have 1 created reconcile lines"
+ );
+
+ await testUtils.dom.click(widget.$(".create .create_tax_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(10.00%)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_right")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 1000.00 $ 100.00",
+ "should have 2 created reconcile lines with right column values"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open Balance'"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_right").text(),
+ "$\u00a075.00",
+ "should display 'Open Balance' with 75.00 in right column"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_left").text(),
+ "",
+ "should display 'Open Balance' without any value in left column"
+ );
+ assert.containsN(
+ widget,
+ ".accounting_view tbody tr",
+ 2,
+ "should have 2 created reconcile lines"
+ );
+ await testUtils.dom.click(widget.$('[name="tax_ids"] a.o_delete'));
+ widget
+ .$(".create .create_tax_id input")
+ .val("")
+ .trigger("keyup")
+ .trigger("blur");
+ await testUtils.dom.click(widget.$(".create .create_tax_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(20.00%)")
+ .trigger("mouseenter")
+ .trigger("click");
+ await testUtils.nextTick();
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody .cell_right")
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 1100.00 $ 220.00",
+ "should have 2 created reconcile lines with right column values"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_label").text(),
+ "Open balance",
+ "should display 'Open balance'"
+ );
+ assert.strictEqual(
+ widget.$(".accounting_view tfoot .cell_left").text(),
+ "$\u00a0145.00",
+ "should display 'Open balance' with 145.00 in right column"
+ );
+ assert.containsN(
+ widget,
+ ".accounting_view tbody tr",
+ 2,
+ "should have 2 created reconcile lines"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test(
+ "Reconciliation create line from reconciliation model",
+ async function (assert) {
+ assert.expect(6);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ await testUtils.dom.click(
+ widget.$(".create .quick_add button:contains(ATOS)")
+ );
+
+ assert.strictEqual(
+ widget
+ .$(
+ ".accounting_view tbody .cell_label, .accounting_view tbody .cell_right"
+ )
+ .text()
+ .replace(/[\n\r\s$,]+/g, " "),
+ " ATOS Banque 1145.63 ATOS Banque Tax 20.00% 229.13 ATOS Frais 26.78 ATOS Frais Tax 10.00% include 2.68 ",
+ "should display 4 lines"
+ );
+ assert.strictEqual(
+ widget
+ .$(
+ ".accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left"
+ )
+ .text()
+ .replace(/[\n\r\s$,]+/g, ""),
+ "Openbalance229.22",
+ "should display the 'Open balance' line with value in left column"
+ );
+
+ await testUtils.fields.editAndTrigger(
+ widget.$(".create .create_amount input"),
+ "100",
+ ["input"]
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101120 New ATOS Banque 1145.63 101120 New ATOS Banque Tax 20.00% 229.13 101130 New ATOS Frais 90.91 101300 New ATOS Frais Tax 10.00% include 9.09 ",
+ "should update the value of the 2 lines (because the line + its tax must have 100% of the value)"
+ );
+ assert.strictEqual(
+ widget
+ .$(
+ ".accounting_view tfoot .cell_label, .accounting_view tfoot .cell_left"
+ )
+ .text()
+ .replace(/[\n\r\s$,]+/g, ""),
+ "Openbalance299.76",
+ "should change the 'Open balance' line because the 20.00% tax is not an include tax"
+ );
+
+ await testUtils.dom.click(
+ widget.$(".accounting_view tbody .cell_account_code:first")
+ );
+ await testUtils.dom.click(
+ widget.$(".accounting_view tbody .cell_label:first")
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " "),
+ "",
+ "should removed every line"
+ );
+
+ await testUtils.dom.click(
+ widget.$(".create .quick_add button:contains(Double)")
+ );
+
+ assert.strictEqual(
+ widget
+ .$(".accounting_view tbody")
+ .text()
+ .replace(/[\n\r\s$,]+/g, " ")
+ .replace(/[\u200B]/g, ""),
+ " 101120 New Double Banque 1145.63 101130 New Double Frais 29.37 ",
+ "should have a sum of reconciliation proposition amounts equal to the line amount"
+ );
+
+ clientAction.destroy();
+ }
+ );
+
+ QUnit.test(
+ "Reconciliation fetch correct reconciliation models",
+ async function (assert) {
+ assert.expect(1);
+
+ testUtilsMock.patch(this.params.options.context, {
+ active_model: "account.journal", // On account dashboard, click "Reconcile" on a journal
+ active_ids: [1, 2], // Active journals
+ company_ids: [3, 4], // Active companies
+ });
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: async function (route, args) {
+ if (
+ args.model === "account.reconcile.model" &&
+ args.method === "search_read"
+ ) {
+ assert.deepEqual(
+ args.kwargs.domain,
+ [
+ ["company_id", "in", [3, 4]],
+ "|",
+ ["match_journal_ids", "=", false],
+ ["match_journal_ids", "in", [1, 2]],
+ ],
+ "The domain to get reconcile models should contain the right fields and values"
+ );
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ clientAction.appendTo($("#qunit-fixture"));
+ testUtilsMock.unpatch(this.params.options.context);
+
+ clientAction.destroy();
+ }
+ );
+
+ QUnit.test("Reconciliation manual", async function (assert) {
+ assert.expect(13);
+
+ var clientAction = new ReconciliationClientAction.ManualAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: this.params.session,
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ assert.strictEqual(
+ clientAction
+ .$(".accounting_view:first thead")
+ .text()
+ .replace(/[\n\r\s]+/g, " "),
+ " 101000 Current AssetsLast Reconciliation: 2017-02-16 101000 ",
+ "should display the account as title"
+ );
+ assert.strictEqual(
+ clientAction.$(".o_reconciliation_line:first").data("mode"),
+ "inactive",
+ "should be in 'inactive' mode because no line to displayed and the balance amount is null"
+ );
+ assert.containsN(
+ clientAction,
+ ".accounting_view:first tbody tr",
+ 2,
+ "should have 2 propositions"
+ );
+ assert.containsOnce(
+ clientAction,
+ ".accounting_view:first .o_reconcile:visible",
+ "should display the reconcile button"
+ );
+
+ await testUtils.dom.click(
+ clientAction.$(".accounting_view:first .o_reconcile:visible")
+ );
+
+ assert.strictEqual(
+ clientAction
+ .$(".accounting_view:first thead")
+ .text()
+ .replace(/[\n\r\s]+/g, " "),
+ " 101200 Account Receivable 101200 ",
+ "should display the account and the account code as title"
+ );
+
+ assert.strictEqual(
+ clientAction
+ .$(
+ ".o_reconciliation_line:first .match:first tr:first .cell_right"
+ )
+ .text()
+ .trim()
+ .replace(/[\n\r\s\u00a0]+/g, " "),
+ "$ 11,000.00",
+ "sould display the line in $"
+ );
+ assert.strictEqual(
+ clientAction
+ .$(
+ ".o_reconciliation_line:first .match:first tr:first .cell_right .o_multi_currency"
+ )
+ .data("content"),
+ "10,222.00 €",
+ "sould display the monetary information in €"
+ );
+
+ assert.containsOnce(
+ clientAction,
+ ".accounting_view:first .o_no_valid:visible",
+ "should display the skip button"
+ );
+
+ await testUtils.dom.click(
+ clientAction.$(".o_reconciliation_line:eq(1) .accounting_view")
+ );
+ await testUtils.dom.click(
+ clientAction.$(".accounting_view:eq(1) thead td:first")
+ );
+ // Debugger
+ await testUtils.dom.click(
+ clientAction.$(
+ '.o_reconciliation_line:eq(1) [data-line-id="21"] .cell_label'
+ )
+ );
+ await testUtils.dom.click(
+ clientAction.$(
+ '.o_reconciliation_line:eq(1) [data-line-id="22"] .cell_label'
+ )
+ );
+
+ assert.strictEqual(
+ clientAction.$(".o_reconciliation_line:eq(1) tfoot tr").length,
+ 0,
+ "should not display the 'Write-off' line because the balance is null in Euro"
+ );
+ assert.strictEqual(
+ clientAction.$(".o_reconciliation_line:eq(1) .o_reconcile:visible")
+ .length,
+ 1,
+ "should display 'Reconcile' button in green"
+ );
+
+ await testUtils.dom.click(
+ clientAction.$(".o_reconciliation_line:eq(1) .o_reconcile:visible")
+ );
+
+ assert.containsOnce(
+ clientAction,
+ '.o_reconciliation_line[data-mode!="inactive"]',
+ "should have only one line open"
+ );
+
+ await testUtils.dom.click(
+ clientAction.$(
+ '.o_reconciliation_line:eq(1) [data-line-id="23"] .cell_label'
+ )
+ );
+ await testUtils.dom.click(
+ clientAction.$(
+ '.o_reconciliation_line:eq(1) [data-line-id="24"] .cell_label'
+ )
+ );
+
+ assert.strictEqual(
+ clientAction.$(".o_reconciliation_line:eq(1) tfoot tr").length,
+ 1,
+ "should display the 'Write-off' line because the balance is not null in Euro"
+ );
+ assert.strictEqual(
+ clientAction.$(".o_reconciliation_line:eq(1) .o_validate:visible")
+ .length,
+ 1,
+ "should display 'Reconcile' button"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test(
+ "Reconciliation: Payment < inv1 + inv2(partial)",
+ async function (assert) {
+ assert.expect(3);
+
+ /*
+ * One payment: $1175
+ * Two Invoices
+ * The first invoice will be fully reconciled $650
+ * The second invoice will be partially paid with the rest of the payment $999
+ */
+
+ // modify the second line that is already in db to put it at $999
+ var indexModif = _.findIndex(
+ this.params.mv_lines['[5,"",0]'],
+ function (line) {
+ return line.id === 112;
+ }
+ );
+ this.params.mv_lines['[5,"",0]'][indexModif] = {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 999.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 112,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 999.00",
+ debit: 999.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ };
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ if (args.method === "process_bank_statement_line") {
+ assert.deepEqual(
+ args.args,
+ [
+ [5], // Id of the bank statement line
+
+ [
+ {
+ counterpart_aml_dicts: [
+ {
+ name: "INV/2017/0002",
+ debit: 0,
+ credit: 650,
+ analytic_tag_ids: [
+ [6, null, []],
+ ],
+ counterpart_aml_id: 109,
+ },
+
+ {
+ name: "INV/2017/0003",
+ debit: 0,
+ credit: 525,
+ analytic_tag_ids: [
+ [6, null, []],
+ ],
+ counterpart_aml_id: 112,
+ },
+ ],
+
+ payment_aml_ids: [],
+ partner_id: 8,
+ to_check: false,
+ new_aml_dicts: [],
+ },
+ ],
+ ],
+ "should call process_bank_statement_line with partial reconcile values"
+ );
+ }
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The first reconciliation "line" is where it happens
+ var widget = clientAction.widgets[0];
+
+ // Add first invoice to reconcile fully
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.ok(
+ widget.$(".cell_right .edit_amount").length,
+ "should display the pencil to edit amount"
+ );
+
+ // Add second invoice to reconcile partially
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+
+ // Edit amount on last invoice
+ await testUtils.dom.click(widget.$(".edit_amount:last()"));
+ await testUtils.fields.editAndTrigger(
+ widget.$(".edit_amount_input:last()"),
+ "525",
+ ["blur"]
+ );
+
+ var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)");
+
+ assert.equal(
+ $buttonReconcile.length,
+ 1,
+ "The reconcile button must be visible"
+ );
+
+ await testUtils.dom.click($buttonReconcile);
+
+ clientAction.destroy();
+ }
+ );
+
+ QUnit.test("Reconciliation: payment and 2 partials", async function (
+ assert
+ ) {
+ assert.expect(6);
+
+ /*
+ * One payment: $1175
+ * Two Invoices as Inv1 = 1200; Inv2 = 1200:
+ * Payment < Inv1 AND Payment < Inv2
+ * No partial reconcile is possible, as a write-off of 1225 is necessary
+ */
+
+ // modify the invoice line to have their amount > payment
+ var indexInv1 = _.findIndex(this.params.mv_lines['[5,"",0]'], function (
+ line
+ ) {
+ return line.id === 109;
+ });
+ this.params.mv_lines['[5,"",0]'][indexInv1] = {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 1200.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 109,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1200.00",
+ debit: 1200.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ };
+
+ var indexInv2 = _.findIndex(this.params.mv_lines['[5,"",0]'], function (
+ line
+ ) {
+ return line.id === 112;
+ });
+ this.params.mv_lines['[5,"",0]'][indexInv2] = {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 1200.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 112,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1200.00",
+ debit: 1200.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ };
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ if (args.method === "process_bank_statement_line") {
+ assert.deepEqual(
+ args.args,
+ [
+ [5], // Id of the bank statement line
+
+ [
+ {
+ counterpart_aml_dicts: [
+ {
+ name: "INV/2017/0002",
+ debit: 0,
+ credit: 1200,
+ analytic_tag_ids: [[6, null, []]],
+ counterpart_aml_id: 109,
+ },
+
+ {
+ name: "INV/2017/0003",
+ debit: 0,
+ credit: 1200,
+ analytic_tag_ids: [[6, null, []]],
+ counterpart_aml_id: 112,
+ },
+ ],
+
+ payment_aml_ids: [],
+ partner_id: 8,
+ to_check: false,
+ new_aml_dicts: [
+ {
+ account_id: 282,
+ credit: 0,
+ debit: 1225,
+ analytic_tag_ids: [[6, null, []]],
+ name:
+ "SAJ/2014/002 and SAJ/2014/003",
+ },
+ ],
+ },
+ ],
+ ],
+ "should call process_bank_statement_line with new aml dict reconcile values"
+ );
+ }
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The first reconciliation "line" is where it happens
+ var widget = clientAction.widgets[0];
+
+ // Add first invoice
+ // There should be the opportunity to reconcile partially
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.ok(
+ widget.$(".cell_right .edit_amount").length,
+ "should display the pencil to edit amount"
+ );
+
+ // Add second invoice
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.ok(
+ widget.$(".cell_right .edit_amount").length,
+ "should display the pencil to edit amount"
+ );
+
+ var writeOffCreate = widget.$("div.create");
+
+ assert.equal(
+ writeOffCreate.length,
+ 1,
+ "A write-off creation should be present"
+ );
+
+ assert.equal(
+ writeOffCreate.find("input[name=amount]").val(),
+ -1225,
+ "The right amount should be proposed for the write-off"
+ );
+
+ await testUtils.dom.click(
+ writeOffCreate.find(
+ ".create_account_id input.ui-autocomplete-input"
+ )
+ );
+ await testUtils.dom.click($("ul.ui-autocomplete li a:first"));
+
+ var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)");
+
+ assert.equal(
+ $buttonReconcile.length,
+ 1,
+ "The reconcile button must be visible"
+ );
+
+ await testUtils.dom.click($buttonReconcile);
+
+ clientAction.destroy();
+ });
+
+ QUnit.test(
+ "Reconciliation: partial payment of 2 invoices with one payment [REQUIRE FOCUS]",
+ async function (assert) {
+ assert.expect(4);
+
+ /*
+ * One payment: $1175
+ * Two Invoices as Inv1 = 1200; Inv2 = 1200:
+ * Payment < Inv1 AND Payment < Inv2
+ * Assign 500 to inv1 and 675 to inv2
+ */
+
+ // modify the invoice line to have their amount > payment
+ var indexInv1 = _.findIndex(
+ this.params.mv_lines['[5,"",0]'],
+ function (line) {
+ return line.id === 109;
+ }
+ );
+ this.params.mv_lines['[5,"",0]'][indexInv1] = {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 1200.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0002",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 109,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1200.00",
+ debit: 1200.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ };
+
+ var indexInv2 = _.findIndex(
+ this.params.mv_lines['[5,"",0]'],
+ function (line) {
+ return line.id === 112;
+ }
+ );
+ this.params.mv_lines['[5,"",0]'][indexInv2] = {
+ account_type: "receivable",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-02-07",
+ date: "2017-01-08",
+ total_amount_str: "$ 1200.00",
+ partner_id: 8,
+ account_name: "101200 Account Receivable",
+ name: "INV/2017/0003",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 112,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 1200.00",
+ debit: 1200.0,
+ account_code: "101200",
+ ref: "",
+ already_paid: false,
+ };
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ if (args.method === "process_bank_statement_line") {
+ assert.deepEqual(
+ args.args,
+ [
+ [5], // Id of the bank statement line
+
+ [
+ {
+ counterpart_aml_dicts: [
+ {
+ name: "INV/2017/0002",
+ debit: 0,
+ credit: 500,
+ analytic_tag_ids: [
+ [6, null, []],
+ ],
+ counterpart_aml_id: 109,
+ },
+
+ {
+ name: "INV/2017/0003",
+ debit: 0,
+ credit: 675,
+ analytic_tag_ids: [
+ [6, null, []],
+ ],
+ counterpart_aml_id: 112,
+ },
+ ],
+
+ payment_aml_ids: [],
+ partner_id: 8,
+ to_check: false,
+ new_aml_dicts: [],
+ },
+ ],
+ ],
+ "should call process_bank_statement_line with correct counterpart_aml_dicts"
+ );
+ }
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The first reconciliation "line" is where it happens
+ var widget = clientAction.widgets[0];
+
+ // Add first invoice
+ // There should be the opportunity to reconcile partially
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.ok(
+ widget.$(".cell_right .edit_amount").length,
+ "should display the pencil to edit amount"
+ );
+
+ // Add second invoice
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.ok(
+ widget.$(".cell_right .edit_amount").length,
+ "should display the pencil to edit amount"
+ );
+
+ // Edit invoice first amount
+ await testUtils.dom.click(widget.$(".edit_amount:first()"));
+ await testUtils.fields.editAndTrigger(
+ widget.$(".edit_amount_input:first()"),
+ "500",
+ ["blur"]
+ );
+ // Edit invoice second amount
+ var $buttonReconcile = widget.$("button.o_reconcile:not(hidden)");
+ await testUtils.dom.click(widget.$(".edit_amount:last()"));
+ await testUtils.fields.editAndTrigger(
+ widget.$(".edit_amount_input:last()"),
+ "675",
+ ["blur"]
+ );
+
+ assert.equal(
+ $buttonReconcile.length,
+ 1,
+ "The reconcile button must be visible"
+ );
+
+ await testUtils.dom.click($buttonReconcile, {allowInvisible: true});
+
+ clientAction.destroy();
+ }
+ );
+
+ QUnit.test(
+ "Manual Reconciliation: remove a prop to attain balance and reconcile",
+ async function (assert) {
+ assert.expect(5);
+
+ // Tweak the data to fit our needs
+ this.params.data_for_manual_reconciliation_widget[
+ '[283, null, "", 0, 6]'
+ ] = _.extend(
+ {},
+ this.params.data_for_manual_reconciliation_widget[
+ "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]"
+ ]
+ );
+ this.params.data_for_manual_reconciliation_widget[
+ '[283, null, "", 0, 6]'
+ ].accounts[0].reconciliation_proposition = [
+ {
+ account_id: 283,
+ account_type: "other",
+ amount_currency_str: "",
+ currency_id: false,
+ date_maturity: "2017-03-18",
+ date: "2017-02-16",
+ total_amount_str: "$ 500.00",
+ partner_id: 8,
+ account_name: "101000 Current Assets",
+ name: "INV/2017/0987",
+ partner_name: "Agrolait",
+ total_amount_currency_str: "",
+ id: 999,
+ credit: 0.0,
+ journal_id: [1, "Customer Invoices"],
+ amount_str: "$ 500.00",
+ debit: 500.0,
+ account_code: "101000",
+ ref: "",
+ already_paid: false,
+ },
+ ];
+
+ var clientAction = new ReconciliationClientAction.ManualAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ if (args.method === "process_move_lines") {
+ assert.deepEqual(
+ args.args,
+ [
+ [
+ {
+ id: null,
+ type: null,
+ mv_line_ids: [399, 402],
+ new_mv_line_dicts: [],
+ },
+ ],
+ ],
+ "should call process_move_lines without the new mv line dict"
+ );
+ }
+
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The first reconciliation "line" is where it happens
+ var widget = clientAction.widgets[0];
+
+ // Add first prop
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ assert.equal(
+ widget.$(".cell_right .edit_amount").length,
+ 0,
+ "should not display the pencil to edit amount"
+ );
+
+ // Add second prop
+ await testUtils.dom.click(
+ widget.$(".match:first .cell_account_code:first")
+ );
+ // Check that a create form is here
+
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ var writeOffCreate = widget.$("div.create");
+
+ assert.equal(
+ writeOffCreate.length,
+ 1,
+ "A write-off creation should be present"
+ );
+
+ assert.equal(
+ writeOffCreate.find("input[name=amount]").val(),
+ 500,
+ "The right amount should be proposed for the write-off"
+ );
+
+ // Remove the first line, the other two will balance one another
+ await testUtils.dom.click(
+ widget.$('tr[data-line-id="999"] td:first')
+ );
+
+ var $buttonReconcile = widget.$("button.o_reconcile:visible");
+ assert.equal(
+ $buttonReconcile.length,
+ 1,
+ "The reconcile button must be visible"
+ );
+
+ await testUtils.dom.click($buttonReconcile);
+
+ clientAction.destroy();
+ }
+ );
+
+ QUnit.test("Manual Reconciliation: No lines for account", async function (
+ assert
+ ) {
+ assert.expect(2);
+
+ var clientAction = new ReconciliationClientAction.ManualAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The second reconciliation "line" is where it happens
+ var widget = clientAction.widgets[1];
+
+ var emptyLine = widget.$("tr.mv_line");
+
+ assert.notOk(
+ "data-line-id" in emptyLine.getAttributes(),
+ "Empty line should be empty"
+ );
+
+ await testUtils.dom.click(emptyLine.find("td:first"));
+
+ // Check that a create form is here
+ var writeOffCreate = widget.$("div.create .create_account_id");
+
+ assert.equal(
+ writeOffCreate.length,
+ 1,
+ "A write-off creation should be present"
+ );
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Tax on account receivable", async function (assert) {
+ assert.expect(21);
+
+ this.params.data_for_manual_reconciliation_widget[
+ "[null,[282,283,284,285,286,287,288,308,499,500,501,502,503,504]]"
+ ].accounts = [];
+ var clientAction = new ReconciliationClientAction.ManualAction(
+ null,
+ this.params.options
+ );
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {},
+ mockRPC: function (route, args) {
+ if (args.method === "name_search") {
+ switch (args.model) {
+ // Mock the default mock to do the minimal processing required
+ // to get the available values for the droplists.
+ case "account.account":
+ assert.step("Account");
+ return Promise.resolve(
+ _.map(this.data[args.model].records, function (
+ record
+ ) {
+ return [record.id, record.name];
+ })
+ );
+ case "account.tax":
+ assert.step("Tax");
+ return Promise.resolve(
+ _.map(this.data[args.model].records, function (
+ record
+ ) {
+ return [record.id, record.display_name];
+ })
+ );
+ case "account.journal":
+ assert.step("Journal");
+ return Promise.resolve(
+ _.map(this.data[args.model].records, function (
+ record
+ ) {
+ return [record.id, record.display_name];
+ })
+ );
+ }
+ }
+ if (args.method === "process_move_lines") {
+ var mv_line_ids = args.args[0][0].mv_line_ids.slice(0);
+ mv_line_ids.sort(function (a, b) {
+ return a - b;
+ });
+ assert.deepEqual(
+ mv_line_ids,
+ [6, 19, 21],
+ "Reconciliation rpc payload, mv_line_ids are correct"
+ );
+
+ // Index aiming at the correct object in the list
+ var idx = _.has(
+ args.args[0][0].new_mv_line_dicts[0],
+ "journal_id"
+ )
+ ? 0
+ : 1;
+ assert.deepEqual(
+ _.pick(
+ args.args[0][0].new_mv_line_dicts[idx],
+ "account_id",
+ "name",
+ "credit",
+ "debit",
+ "journal_id"
+ ),
+ {
+ account_id: 287,
+ name: "dummy text",
+ credit: 0,
+ debit: 180,
+ journal_id: 8,
+ },
+ "Reconciliation rpc payload, new_mv_line_dicts.gift is correct"
+ );
+ assert.deepEqual(
+ _.pick(
+ args.args[0][0].new_mv_line_dicts[1 - idx],
+ "account_id",
+ "name",
+ "credit",
+ "debit",
+ "tax_repartition_line_id"
+ ),
+ {
+ account_id: 287,
+ name: "Tax 20.00%",
+ credit: 0,
+ debit: 36,
+ tax_repartition_line_id: 2,
+ },
+ "Reconciliation rpc payload, new_mv_line_dicts.tax is correct"
+ );
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ var widget = clientAction.widgets[0];
+
+ // Select invoice of 1k$, payment of 1k$ and payment of 180$
+ var $tableToReconcile = widget.$(".match");
+ var defs = _.map([6, 19, 21], function (id) {
+ return testUtils.dom.click(
+ $tableToReconcile.find(
+ "tr.mv_line[data-line-id=" + id + "]:first td:first-child"
+ )
+ );
+ });
+ await Promise.all(defs);
+ assert.verifySteps([], "No rpc done");
+
+ // Store the money in excess to the "account receivable" account with 20% taxes
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+ var $reconcileForm = widget.$(".create");
+ await testUtils.dom.click(
+ $reconcileForm.find(".create_account_id input")
+ );
+ $(
+ ".ui-autocomplete .ui-menu-item a:contains(101200 Account Receivable)"
+ )
+ .trigger("mouseover")
+ .trigger("click");
+ await testUtils.nextTick();
+ assert.verifySteps(["Account"], "Account rpc done");
+
+ await testUtils.dom.click($reconcileForm.find(".create_tax_id input"));
+ $(".ui-autocomplete .ui-menu-item a:contains(Tax 20.00%)")
+ .trigger("mouseover")
+ .trigger("click");
+ await testUtils.nextTick();
+ assert.verifySteps(["Tax"], "Tax rpc done");
+
+ await testUtils.dom.click(
+ $reconcileForm.find(".create_journal_id input"),
+ {allowInvisible: true}
+ );
+ $(".ui-autocomplete .ui-menu-item a:contains(company 1 journal)")
+ .trigger("mouseover")
+ .trigger("click");
+ await testUtils.nextTick();
+ await testUtils.fields.editAndTrigger(
+ $reconcileForm.find(".create_label input"),
+ "dummy text",
+ "input"
+ );
+ await testUtils.dom.click($reconcileForm.find(".create_label input"));
+ assert.verifySteps(["Journal"], "Journal rpc done");
+
+ // Verify the two (gift + tax) lines were added to the list
+ var $newLines = widget.$("tr.mv_line[data-line-id^=createLine]");
+ var idx =
+ $($($newLines[0]).find("td")[3]).text().trim() === "dummy text"
+ ? 0
+ : 1;
+
+ var $newLineGiftTds = $($newLines[1 - idx]).find("td");
+ assert.equal(
+ $($newLineGiftTds[0])
+ .text()
+ .trim()
+ .replace(/[\u200B]/g, ""),
+ "101200",
+ "Gift line account number is valid"
+ );
+ assert.equal(
+ $($newLineGiftTds[1]).text().trim(),
+ "New",
+ "Gift line is flagged as new"
+ );
+ assert.equal(
+ $($newLineGiftTds[2]).text().trim(),
+ "dummy text",
+ "Gift line has the correct label"
+ );
+ assert.equal(
+ $($newLineGiftTds[3]).text().trim(),
+ "180.00",
+ "Gift line has the correct left amount"
+ );
+ assert.equal(
+ $($newLineGiftTds[4]).text().trim(),
+ "",
+ "Gift line has the correct right amount"
+ );
+
+ var $newLineTaxeTds = $($newLines[idx]).find("td");
+ assert.equal(
+ $($newLineTaxeTds[0])
+ .text()
+ .trim()
+ .replace(/[\u200B]/g, ""),
+ "101200",
+ "Tax line account number is valid"
+ );
+ assert.equal(
+ $($newLineTaxeTds[1]).text().trim(),
+ "New",
+ "Tax line is flagged as new"
+ );
+ assert.equal(
+ $($newLineTaxeTds[2]).text().trim(),
+ "Tax 20.00%",
+ "Tax line has the correct label"
+ );
+ assert.equal(
+ $($newLineTaxeTds[3]).text().trim(),
+ "36.00",
+ "Tax line has the correct left amount"
+ );
+ assert.equal(
+ $($newLineTaxeTds[4]).text().trim(),
+ "",
+ "Tax line has the correct right amount"
+ );
+
+ // Reconcile
+ await testUtils.dom.click(
+ widget.$("button.o_reconcile.btn.btn-primary:first")
+ );
+ assert.ok(true, "No error in reconciliation");
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconcile temporarily and ask to check", async function (
+ assert
+ ) {
+ assert.expect(4);
+ this.params.options.context.to_check = true;
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+
+ testUtils.mock.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+ await clientAction.appendTo($("#qunit-fixture"));
+ var widget = clientAction.widgets[0];
+
+ // Add a line as proposition
+ // open the first line
+ await testUtils.nextTick();
+ await testUtils.dom.click(
+ widget.$(".accounting_view tfoot td.cell_label")
+ );
+ await testUtils.dom.click(
+ widget.$('.o_notebook .nav-link[href*="notebook_page_create"]')
+ );
+
+ var $reconcileForm = widget.$(".create");
+ $reconcileForm
+ .find(".create_account_id input")
+ .val("499001 Suspense Account")
+ .keydown()
+ .keyup();
+ await testUtils.dom.click(
+ $reconcileForm.find(".create_account_id input")
+ );
+ $(".ui-autocomplete .ui-menu-item a:contains(499001 Suspense Account)")
+ .trigger("mouseover")
+ .trigger("click");
+ await testUtils.nextTick();
+
+ assert.equal(
+ $("button.o_validate.btn.btn-secondary.text-warning:first").length,
+ 0,
+ "should not display reconcile button in orange"
+ );
+ await testUtils.dom.click(
+ $reconcileForm.find(".create_to_check input")
+ );
+ assert.equal(
+ $("button.o_validate.btn.btn-secondary.text-warning:first").length,
+ 1,
+ "should display reconcile button in orange"
+ );
+
+ testUtils.mock.intercept(clientAction, "call_service", function (
+ event
+ ) {
+ assert.deepEqual(
+ event.data.args[1].args,
+ [
+ [5],
+ [
+ {
+ partner_id: 8,
+ counterpart_aml_dicts: [],
+ payment_aml_ids: [],
+ new_aml_dicts: [
+ {
+ account_id: 499,
+ credit: 1175,
+ debit: 0,
+ analytic_tag_ids: [[6, null, []]],
+ name: "SAJ/2014/002 and SAJ/2014/003",
+ },
+ ],
+ to_check: true,
+ },
+ ],
+ ],
+ "Should call process_bank_statement_line with to_check set to true"
+ );
+ var def = testUtils.makeTestPromise();
+ def.abort = function () {};
+ event.data.callback(def);
+ });
+
+ await testUtils.dom.click(
+ widget.$("button.o_validate.btn.btn-secondary:first")
+ );
+ assert.ok(true, "No error in reconciliation");
+
+ clientAction.destroy();
+ });
+
+ QUnit.test("Reconciliation Models handle analytic tags", async function (
+ assert
+ ) {
+ assert.expect(6);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(
+ null,
+ this.params.options
+ );
+ testUtils.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ mockRPC: function (route, args) {
+ if (args.method === "process_bank_statement_line") {
+ var new_aml_dicts = args.args[1][0].new_aml_dicts;
+ assert.strictEqual(new_aml_dicts.length, 2);
+ // I personnally judge the following use case rotten, since
+ // the first and the second line wouldn't have the same tags
+ assert.deepEqual(new_aml_dicts[0].analytic_tag_ids, [
+ [6, null, [1, 2]],
+ ]);
+ assert.deepEqual(new_aml_dicts[1].analytic_tag_ids, [
+ [6, null, [2]],
+ ]);
+ }
+ return this._super(route, args);
+ },
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$",
+ },
+ },
+ user_has_group: function (group) {
+ if (
+ group === "analytic.group_analytic_tags" ||
+ group === "analytic.group_analytic_accounting"
+ ) {
+ return $.when(true);
+ }
+ return this._super.apply(this, arguments);
+ },
+ },
+ archs: {
+ "account.bank.statement.line,false,search":
+ '',
+ },
+ });
+
+ await clientAction.appendTo($("#qunit-fixture"));
+ await testUtils.nextTick();
+
+ // The first reconciliation "line" is where it happens
+ var widget = clientAction.widgets[0];
+
+ await testUtilsDom.click(widget.$(".nav-create:visible"));
+ await testUtilsDom.click(
+ widget.$('.quick_add button:contains("Double")')
+ );
+ assert.containsN(
+ widget,
+ ".create_analytic_tag_ids .o_field_many2manytags .badge",
+ 2,
+ "Two tags are loaded"
+ );
+ assert.containsOnce(
+ widget,
+ '.create_analytic_tag_ids .o_field_many2manytags .badge:contains("Come together")',
+ "Tags should have a name"
+ );
+ assert.containsOnce(
+ widget,
+ '.create_analytic_tag_ids .o_field_many2manytags .badge:contains("Right now")',
+ "Tags should have a name"
+ );
+
+ await testUtilsDom.click(
+ widget.$(
+ ".create_analytic_tag_ids .o_field_many2manytags .badge a.o_delete:first()"
+ )
+ );
+
+ await testUtilsDom.click(widget.$(".o_reconcile:visible"));
+
+ clientAction.destroy();
+ });
+ }
+ );
+});