[MIG] account_payment_disperse: Odoo 13. Add new 'next due' behavior.

This commit is contained in:
Jared Kipe
2021-04-29 16:19:10 -07:00
parent 66e18a451b
commit 280a1582f4
11 changed files with 653 additions and 230 deletions

View File

@@ -2,7 +2,7 @@
Hibou - Manually Disperse Payments
**********************************
What happens when a vendor pays you and the payment needs to be split
What happens when a customer pays you and the payment needs to be split
between multiple invoices? What happens when partial payment needs to
be applied to some or all of those invoices? We've got you covered.
@@ -17,7 +17,8 @@ Main Features
* Choose to leave an open balance or write-off the difference.
* Easily pay only the portion of the balance that is currently due (or due before a specific date).
* **Remaining Column** that displays the residual balance.
* **Due Date** cutoff field (defaults to current date).
* **Due Date Cutoff** field (defaults to current date).
* **Due Date Behavior** field to pay the 'next due' amount (e.g. to take a vendor terms discount)
* **Due Column** which displays current amount due. This recalculates when the due date cutoff is changed.
* New buttons to easily fill or pre-populate amount remaining or amount due.
* Tests to ensure that all the stock payment mechanisms work, and that we make the right reconciliations.
@@ -34,5 +35,5 @@ License
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
Copyright Hibou Corp. 2018
Copyright Hibou Corp. 2021

View File

@@ -1,2 +1,4 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models
from . import wizard

View File

@@ -1,7 +1,10 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
{
'name': 'Payment Disperse',
'version': '12.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '13.0.1.0.0',
'author': 'Hibou Corp.',
'license': 'OPL-1',
'category': 'Accounting',
'summary': 'Pay multiple invoices with one Payment',
'description': """

View File

@@ -1 +1,4 @@
from . import account
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import account
from . import account_patch

View File

@@ -1,68 +1,19 @@
from odoo import api, fields, models
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo import models, _
class AccountPayment(models.Model):
_inherit = 'account.payment'
def _create_payment_entry(self, amount):
wizard_id = self.env.context.get('payment_wizard_id')
if wizard_id:
wizard = self.env['account.register.payments'].browse(wizard_id)
assert wizard
if wizard.is_manual_disperse:
payment_amount = sum(wizard.invoice_line_ids.filtered(lambda p: p.partner_id == self.partner_id).mapped('amount'))
if amount < 0:
payment_amount = -payment_amount
return self._create_payment_entry_manual_disperse(payment_amount, wizard)
return super(AccountPayment, self)._create_payment_entry(amount)
def _create_payment_entry_manual_disperse(self, amount, wizard):
# When registering payments for multiple partners at the same time, without setting
# the amount again, then the payment will not match the accounting.
self.amount = abs(amount)
if hasattr(self, 'check_amount_in_words'):
self.check_amount_in_words = self.currency_id.amount_to_text(self.amount)
aml_obj = self.env['account.move.line'].with_context(check_move_validity=False)
debit, credit, amount_currency, currency_id = aml_obj.with_context(date=self.payment_date)._compute_amount_fields(amount, self.currency_id, self.company_id.currency_id)
move = self.env['account.move'].create(self._get_move_vals())
inv_lines = []
for partial_invoice in wizard.invoice_line_ids.filtered(lambda p: p.amount and p.partner_id == self.partner_id):
# Note that for customer payments, the amount will be reversed.
inv_amount = partial_invoice.amount if amount > 0 else -partial_invoice.amount
i_debit, i_credit, i_amount_currency, i_currency_id = aml_obj.with_context(date=self.payment_date)._compute_amount_fields(inv_amount, self.currency_id, self.company_id.currency_id)
counterpart_aml_dict = self._get_shared_move_line_vals(i_debit, i_credit, i_amount_currency, move.id, False)
counterpart_aml_dict.update(self._get_counterpart_move_line_vals(partial_invoice.invoice_id))
counterpart_aml_dict.update({'currency_id': currency_id})
counterpart_aml = aml_obj.create(counterpart_aml_dict)
counterpart_aml |= partial_invoice.invoice_id.move_id.line_ids.filtered(
lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable'))
inv_lines.append((counterpart_aml, partial_invoice.writeoff_acc_id))
# Useful for debugging
# for aml_ids, writeoff_acc_id in inv_lines:
# _logger.warn('pair:' + (' writeoff: ' + str(writeoff_acc_id)) if writeoff_acc_id else '')
# for l in aml_ids:
# _logger.warn(' ' + str(l) + ' debit: ' + str(l.debit) + ' credit: ' + str(l.credit))
# Write counterpart lines
if not self.currency_id.is_zero(self.amount):
if not self.currency_id != self.company_id.currency_id:
amount_currency = 0
liquidity_aml_dict = self._get_shared_move_line_vals(credit, debit, -amount_currency, move.id, False)
liquidity_aml_dict.update(self._get_liquidity_move_line_vals(-amount))
other = aml_obj.create(liquidity_aml_dict)
#_logger.warn('other line -- debit: ' + str(other.debit) + ' credit: ' + str(other.credit))
# validate the payment
if not self.journal_id.post_at_bank_rec:
move.post()
# reconcile the invoice receivable/payable line(s) with the payment
for aml_ids, writeoff_acc_id in inv_lines:
aml_ids.reconcile(writeoff_acc_id, wizard.writeoff_journal_id)
return move
def _values_for_payment_invoice_line(self, line, currency_id):
sign = 1.0 if self.payment_type == 'outbound' else -1.0
i_write_off_amount = -line.difference if line.difference and line.close_balance else 0.0
i_amount_currency = (line.amount + i_write_off_amount) if currency_id else 0.0
i_amount = sign * (line.amount + i_write_off_amount)
return {
'amount_currency': i_amount_currency,
'currency_id': currency_id,
'debit': i_amount if i_amount > 0.0 else 0.0,
'credit': -i_amount if i_amount < 0.0 else 0.0,
}

View File

@@ -0,0 +1,352 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_is_zero
"""
Patched because other modules may extend original functions.
"""
from odoo.addons.account.models import account_payment
def post(self):
""" Create the journal items for the payment and update the payment's state to 'posted'.
A journal entry is created containing an item in the source liquidity account (selected journal's default_debit or default_credit)
and another in the destination reconcilable account (see _compute_destination_account_id).
If invoice_ids is not empty, there will be one reconcilable move line per invoice to reconcile with.
If the payment is a transfer, a second journal entry is created in the destination journal to receive money from the transfer account.
"""
"""
Manual Payment Disperse functionality:
During reconciliation, reconcile specific lines to specific invoices so that the invoices
are paid down in the desired way.
"""
register_wizard = None
if self._context.get('payment_wizard_id'):
register_wizard = self.env['account.payment.register'].browse(self._context.get('payment_wizard_id')).exists()
is_manual_disperse = bool(register_wizard and register_wizard.is_manual_disperse)
AccountMove = self.env['account.move'].with_context(default_type='entry')
for rec in self:
if rec.state != 'draft':
raise UserError(_("Only a draft payment can be posted."))
if any(inv.state != 'posted' for inv in rec.invoice_ids):
raise ValidationError(_("The payment cannot be processed because the invoice is not open!"))
# keep the name in case of a payment reset to draft
if not rec.name:
# Use the right sequence to set the name
if rec.payment_type == 'transfer':
sequence_code = 'account.payment.transfer'
else:
if rec.partner_type == 'customer':
if rec.payment_type == 'inbound':
sequence_code = 'account.payment.customer.invoice'
if rec.payment_type == 'outbound':
sequence_code = 'account.payment.customer.refund'
if rec.partner_type == 'supplier':
if rec.payment_type == 'inbound':
sequence_code = 'account.payment.supplier.refund'
if rec.payment_type == 'outbound':
sequence_code = 'account.payment.supplier.invoice'
rec.name = self.env['ir.sequence'].next_by_code(sequence_code, sequence_date=rec.payment_date)
if not rec.name and rec.payment_type != 'transfer':
raise UserError(_("You have to define a sequence for %s in your company.") % (sequence_code,))
moves = AccountMove.create(rec._prepare_payment_moves())
moves.filtered(lambda move: move.journal_id.post_at != 'bank_rec').post()
# Update the state / move before performing any reconciliation.
move_name = self._get_move_name_transfer_separator().join(moves.mapped('name'))
rec.write({'state': 'posted', 'move_name': move_name})
if rec.payment_type in ('inbound', 'outbound'):
# ==== 'inbound' / 'outbound' ====
if is_manual_disperse:
move = moves[0]
digits_rounding_precision = move.company_id.currency_id.rounding
# pick out lines...
for i in register_wizard.payment_invoice_ids:
i_values = rec._values_for_payment_invoice_line(i, None)
if not any((i_values['debit'], i_values['credit'])):
continue
move_line = move.line_ids.filtered(lambda l: not l.reconciled and l.account_id == rec.destination_account_id and l.account_id != l.payment_id.writeoff_account_id and
float_is_zero(l.debit - i_values['debit'], precision_rounding=digits_rounding_precision) and
float_is_zero(l.credit - i_values['credit'], precision_rounding=digits_rounding_precision))
i_lines = i.invoice_id.line_ids.filtered(lambda l: not l.reconciled and l.account_id == rec.destination_account_id)
if move_line and i_lines:
(move_line + i_lines).reconcile()
else:
if rec.invoice_ids:
(moves[0] + rec.invoice_ids).line_ids \
.filtered(lambda line: not line.reconciled and line.account_id == rec.destination_account_id and not (line.account_id == line.payment_id.writeoff_account_id and line.name == line.payment_id.writeoff_label))\
.reconcile()
elif rec.payment_type == 'transfer':
# ==== 'transfer' ====
moves.mapped('line_ids')\
.filtered(lambda line: line.account_id == rec.company_id.transfer_account_id)\
.reconcile()
return True
def _prepare_payment_moves(self):
''' Prepare the creation of journal entries (account.move) by creating a list of python dictionary to be passed
to the 'create' method.
Example 1: outbound with write-off:
Account | Debit | Credit
---------------------------------------------------------
BANK | 900.0 |
RECEIVABLE | | 1000.0
WRITE-OFF ACCOUNT | 100.0 |
Example 2: internal transfer from BANK to CASH:
Account | Debit | Credit
---------------------------------------------------------
BANK | | 1000.0
TRANSFER | 1000.0 |
CASH | 1000.0 |
TRANSFER | | 1000.0
:return: A list of Python dictionary to be passed to env['account.move'].create.
'''
"""
Manual Payment Disperse functionality:
During journal entry creation, create a single line per invoice for the amount
to be paid on that invoice.
However, all write-offs are accumulated on a single line.
"""
register_wizard = None
if self._context.get('payment_wizard_id'):
register_wizard = self.env['account.payment.register'].browse(
self._context.get('payment_wizard_id')).exists()
is_manual_disperse = bool(register_wizard and register_wizard.is_manual_disperse)
all_move_vals = []
for payment in self:
company_currency = payment.company_id.currency_id
move_names = payment.move_name.split(payment._get_move_name_transfer_separator()) if payment.move_name else None
# Compute amounts.
write_off_amount = payment.payment_difference_handling == 'reconcile' and -payment.payment_difference or 0.0
# Manual Disperse
if is_manual_disperse:
write_off_amount = sum(register_wizard.payment_invoice_ids.filtered(
lambda l: l.close_balance and l.difference and l.invoice_id in payment.invoice_ids).mapped('difference'))
if payment.payment_type == 'outbound':
write_off_amount *= -1.0
if payment.payment_type in ('outbound', 'transfer'):
counterpart_amount = payment.amount
liquidity_line_account = payment.journal_id.default_debit_account_id
else:
counterpart_amount = -payment.amount
liquidity_line_account = payment.journal_id.default_credit_account_id
# Manage currency.
if payment.currency_id == company_currency:
# Single-currency.
balance = counterpart_amount
write_off_balance = write_off_amount
counterpart_amount = write_off_amount = 0.0
currency_id = False
else:
# Multi-currencies.
balance = payment.currency_id._convert(counterpart_amount, company_currency, payment.company_id, payment.payment_date)
write_off_balance = payment.currency_id._convert(write_off_amount, company_currency, payment.company_id, payment.payment_date)
currency_id = payment.currency_id.id
# Manage custom currency on journal for liquidity line.
if payment.journal_id.currency_id and payment.currency_id != payment.journal_id.currency_id:
# Custom currency on journal.
if payment.journal_id.currency_id == company_currency:
# Single-currency
liquidity_line_currency_id = False
else:
liquidity_line_currency_id = payment.journal_id.currency_id.id
liquidity_amount = company_currency._convert(
balance, payment.journal_id.currency_id, payment.company_id, payment.payment_date)
else:
# Use the payment currency.
liquidity_line_currency_id = currency_id
liquidity_amount = counterpart_amount
# Compute 'name' to be used in receivable/payable line.
rec_pay_line_name = ''
if payment.payment_type == 'transfer':
rec_pay_line_name = payment.name
else:
if payment.partner_type == 'customer':
if payment.payment_type == 'inbound':
rec_pay_line_name += _("Customer Payment")
elif payment.payment_type == 'outbound':
rec_pay_line_name += _("Customer Credit Note")
elif payment.partner_type == 'supplier':
if payment.payment_type == 'inbound':
rec_pay_line_name += _("Vendor Credit Note")
elif payment.payment_type == 'outbound':
rec_pay_line_name += _("Vendor Payment")
if payment.invoice_ids:
rec_pay_line_name += ': %s' % ', '.join(payment.invoice_ids.mapped('name'))
# Compute 'name' to be used in liquidity line.
if payment.payment_type == 'transfer':
liquidity_line_name = _('Transfer to %s') % payment.destination_journal_id.name
else:
liquidity_line_name = payment.name
# ==== 'inbound' / 'outbound' ====
move_vals = {
'date': payment.payment_date,
'ref': payment.communication,
'journal_id': payment.journal_id.id,
'currency_id': payment.journal_id.currency_id.id or payment.company_id.currency_id.id,
'partner_id': payment.partner_id.id,
'line_ids': [
# Receivable / Payable / Transfer line.
# (0, 0, {
# 'name': rec_pay_line_name,
# 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0,
# 'currency_id': currency_id,
# 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0,
# 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0,
# 'date_maturity': payment.payment_date,
# 'partner_id': payment.partner_id.commercial_partner_id.id,
# 'account_id': payment.destination_account_id.id,
# 'payment_id': payment.id,
# }),
# Liquidity line.
(0, 0, {
'name': liquidity_line_name,
'amount_currency': -liquidity_amount if liquidity_line_currency_id else 0.0,
'currency_id': liquidity_line_currency_id,
'debit': balance < 0.0 and -balance or 0.0,
'credit': balance > 0.0 and balance or 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': liquidity_line_account.id,
'payment_id': payment.id,
}),
],
}
if is_manual_disperse:
for i in register_wizard.payment_invoice_ids.filtered(lambda l: l.invoice_id in payment.invoice_ids):
i_rec_pay_line_name = rec_pay_line_name
i_values = payment._values_for_payment_invoice_line(i, currency_id)
if not any((i_values['debit'], i_values['credit'])):
# do not make useless lines
continue
move_vals['line_ids'].insert(0, (0, 0, {
'name': i_rec_pay_line_name,
# 'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0,
# 'amount_currency': i_amount_currency,
# 'currency_id': currency_id,
# # 'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0,
# 'debit': i_amount if i_amount > 0.0 else 0.0,
# # 'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0,
# 'credit': -i_amount if i_amount < 0.0 else 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': payment.destination_account_id.id,
'payment_id': payment.id,
**i_values,
}))
else:
# insert because existing tests expect them in a set order.
move_vals['line_ids'].insert(0, (0, 0, {
'name': rec_pay_line_name,
'amount_currency': counterpart_amount + write_off_amount if currency_id else 0.0,
'currency_id': currency_id,
'debit': balance + write_off_balance > 0.0 and balance + write_off_balance or 0.0,
'credit': balance + write_off_balance < 0.0 and -balance - write_off_balance or 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': payment.destination_account_id.id,
'payment_id': payment.id,
}))
if write_off_balance:
# Write-off line.
move_vals['line_ids'].append((0, 0, {
'name': payment.writeoff_label,
'amount_currency': -write_off_amount,
'currency_id': currency_id,
'debit': write_off_balance < 0.0 and -write_off_balance or 0.0,
'credit': write_off_balance > 0.0 and write_off_balance or 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': payment.writeoff_account_id.id,
'payment_id': payment.id,
}))
if move_names:
move_vals['name'] = move_names[0]
all_move_vals.append(move_vals)
# ==== 'transfer' ====
if payment.payment_type == 'transfer':
journal = payment.destination_journal_id
# Manage custom currency on journal for liquidity line.
if journal.currency_id and payment.currency_id != journal.currency_id:
# Custom currency on journal.
liquidity_line_currency_id = journal.currency_id.id
transfer_amount = company_currency._convert(balance, journal.currency_id, payment.company_id, payment.payment_date)
else:
# Use the payment currency.
liquidity_line_currency_id = currency_id
transfer_amount = counterpart_amount
transfer_move_vals = {
'date': payment.payment_date,
'ref': payment.communication,
'partner_id': payment.partner_id.id,
'journal_id': payment.destination_journal_id.id,
'line_ids': [
# Transfer debit line.
(0, 0, {
'name': payment.name,
'amount_currency': -counterpart_amount if currency_id else 0.0,
'currency_id': currency_id,
'debit': balance < 0.0 and -balance or 0.0,
'credit': balance > 0.0 and balance or 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': payment.company_id.transfer_account_id.id,
'payment_id': payment.id,
}),
# Liquidity credit line.
(0, 0, {
'name': _('Transfer from %s') % payment.journal_id.name,
'amount_currency': transfer_amount if liquidity_line_currency_id else 0.0,
'currency_id': liquidity_line_currency_id,
'debit': balance > 0.0 and balance or 0.0,
'credit': balance < 0.0 and -balance or 0.0,
'date_maturity': payment.payment_date,
'partner_id': payment.partner_id.commercial_partner_id.id,
'account_id': payment.destination_journal_id.default_credit_account_id.id,
'payment_id': payment.id,
}),
],
}
if move_names and len(move_names) == 2:
transfer_move_vals['name'] = move_names[1]
all_move_vals.append(transfer_move_vals)
return all_move_vals
account_payment.account_payment.post = post
account_payment.account_payment._prepare_payment_moves = _prepare_payment_moves

View File

@@ -1 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import test_payment_multi

View File

@@ -1,8 +1,17 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo.addons.account.tests.test_payment import TestPayment
from odoo.tests.common import Form
from odoo.tests import tagged
from odoo.exceptions import ValidationError
import time
# Fun fact... if you install enterprise accounting, you'll get errors
# due to required fields being missing...
# The classic fix would be the following @tagged, but for some reason this makes
# odoo.tests.common.Form suddenly not calculate the amount on the register payment wizard
# @tagged('post_install', '-at_install')
class PaymentMultiTest(TestPayment):
def test_multiple_payments_partial(self):
@@ -12,146 +21,143 @@ class PaymentMultiTest(TestPayment):
inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
ids = [inv_1.id, inv_2.id]
register_payments = self.register_payments_model.with_context(active_ids=ids).create({
'payment_date': time.strftime('%Y') + '-07-15',
'journal_id': self.bank_journal_euro.id,
'payment_method_id': self.payment_method_manual_in.id,
})
register_payments.amount = 399.0
register_payments.is_manual_disperse = True
payment_register = Form(self.register_payments_model.with_context(active_ids=ids))
payment_register.journal_id = self.bank_journal_euro
payment_register.payment_date = time.strftime('%Y') + '-07-15'
payment_register.is_manual_disperse = True
with self.assertRaises(ValidationError):
register_payments.create_payments()
with payment_register.payment_invoice_ids.edit(0) as f:
f.amount = 99.0
f.save()
with payment_register.payment_invoice_ids.edit(1) as f:
f.amount = 300.0
f.save()
for line in register_payments.invoice_line_ids:
if line.invoice_id == inv_1:
line.amount = 99.0
if line.invoice_id == inv_2:
line.amount = 300.0
self.assertEqual(payment_register.amount, 399.0, 'Amount isn\'t the amount from lines')
register_payments.create_payments()
# Persist object
payment_register = payment_register.save()
payment_register.create_payments()
payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc")
self.assertEqual(len(payment_ids), 1, 'Need only one payment.')
self.assertEqual(payment_ids.amount, 399.0)
self.assertEqual(inv_1.residual_signed, 1.0)
self.assertEqual(inv_2.residual_signed, 200.0)
self.assertEqual(inv_1.amount_residual_signed, 1.0)
self.assertEqual(inv_2.amount_residual_signed, 200.0)
register_payments = self.register_payments_model.with_context(active_ids=ids).create({
'payment_date': time.strftime('%Y') + '-07-15',
'journal_id': self.bank_journal_euro.id,
'payment_method_id': self.payment_method_manual_in.id,
})
register_payments.amount = 200.0
register_payments.is_manual_disperse = True
payment_register = Form(self.register_payments_model.with_context(active_ids=ids))
payment_register.journal_id = self.bank_journal_euro
payment_register.payment_date = time.strftime('%Y') + '-07-15'
payment_register.is_manual_disperse = True
for line in register_payments.invoice_line_ids:
if line.invoice_id == inv_2:
line.amount = 200.0
register_payments.create_payments()
self.assertEqual(inv_1.residual_signed, 1.0)
self.assertEqual(inv_2.residual_signed, 0.0)
with payment_register.payment_invoice_ids.edit(0) as f:
f.amount = 0.0
f.save()
with payment_register.payment_invoice_ids.edit(1) as f:
f.amount = 200.0
f.save()
payment_register = payment_register.save()
payment_register.create_payments()
self.assertEqual(inv_1.amount_residual_signed, 1.0)
self.assertEqual(inv_2.amount_residual_signed, 0.0)
def test_multiple_payments_write_off(self):
inv_1 = self.create_invoice(amount=100, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
ids = [inv_1.id, inv_2.id]
register_payments = self.register_payments_model.with_context(active_ids=ids).create({
'payment_date': time.strftime('%Y') + '-07-15',
'journal_id': self.bank_journal_euro.id,
'payment_method_id': self.payment_method_manual_in.id,
})
register_payments.amount = 400.0
register_payments.is_manual_disperse = True
register_payments.writeoff_journal_id = inv_1.journal_id
payment_register = Form(self.register_payments_model.with_context(active_ids=ids))
payment_register.journal_id = self.bank_journal_euro
payment_register.payment_date = time.strftime('%Y') + '-07-15'
payment_register.is_manual_disperse = True
payment_register.writeoff_account_id = self.transfer_account
with self.assertRaises(ValidationError):
register_payments.create_payments()
with payment_register.payment_invoice_ids.edit(0) as f:
f.amount = 100.0
f.close_balance = True
f.save()
with payment_register.payment_invoice_ids.edit(1) as f:
f.amount = 300.0
f.close_balance = True
f.save()
for line in register_payments.invoice_line_ids:
if line.invoice_id == inv_1:
line.amount = 100.0
if line.invoice_id == inv_2:
line.amount = 300.0
line.writeoff_acc_id = self.account_revenue
self.assertEqual(payment_register.amount, 400.0, 'Amount isn\'t the amount from lines')
register_payments.create_payments()
payment_register = payment_register.save()
self.assertEqual(sum(payment_register.mapped('payment_invoice_ids.difference')), -200.0)
payment_register.create_payments()
payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc")
self.assertEqual(len(payment_ids), 1, 'Need only one payment.')
self.assertEqual(payment_ids.amount, 400.0)
self.assertEqual(inv_1.residual_signed, 0.0)
self.assertEqual(inv_2.residual_signed, 0.0)
self.assertEqual(inv_1.amount_residual_signed, 0.0)
self.assertEqual(inv_2.amount_residual_signed, 0.0)
def test_multiple_payments_partial_multi(self):
""" Create test to pay several vendor bills/invoices at once """
# One payment for inv_1 and inv_2 (same partner)
# One payment for inv_1 and inv_2 (different partners)
inv_1 = self.create_invoice(amount=100, currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
inv_2 = self.create_invoice(amount=500, currency_id=self.currency_eur_id, partner=self.partner_china_exp.id)
ids = [inv_1.id, inv_2.id]
register_payments = self.register_payments_model.with_context(active_ids=ids).create({
'payment_date': time.strftime('%Y') + '-07-15',
'journal_id': self.bank_journal_euro.id,
'payment_method_id': self.payment_method_manual_in.id,
})
register_payments.amount = 400.0
register_payments.is_manual_disperse = True
payment_register = Form(self.register_payments_model.with_context(active_ids=ids))
payment_register.journal_id = self.bank_journal_euro
payment_register.payment_date = time.strftime('%Y') + '-07-15'
payment_register.is_manual_disperse = True
for line in register_payments.invoice_line_ids:
if line.invoice_id == inv_1:
line.amount = 100.0
if line.invoice_id == inv_2:
line.amount = 300.0
with payment_register.payment_invoice_ids.edit(0) as f:
f.amount = 100.0
f.save()
with payment_register.payment_invoice_ids.edit(1) as f:
f.amount = 300.0
f.save()
register_payments.create_payments()
self.assertEqual(payment_register.amount, 400.0, 'Amount isn\'t the amount from lines')
payment_register = payment_register.save()
payment_register.create_payments()
payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc")
self.assertEqual(len(payment_ids), 2, 'Need two payments.')
# Useful for logging amounts of payments and their accounting
# for pay in payment_ids:
# _logger.warn(str(pay) + ' amount: ' + str(pay.amount))
# for line in pay.move_line_ids:
# _logger.warn(' ' +
# str(line) + ' name: ' + str(line.name) + ' :: credit: ' + str(line.credit) + ' debit: ' +
# str(line.debit))
self.assertEqual(sum(payment_ids.mapped('amount')), 400.0)
self.assertEqual(inv_1.residual_signed, 0.0)
self.assertEqual(inv_2.residual_signed, 200.0)
self.assertEqual(inv_1.amount_residual_signed, 0.0)
self.assertEqual(inv_2.amount_residual_signed, 200.0)
def test_vendor_multiple_payments_write_off(self):
inv_1 = self.create_invoice(amount=100, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
inv_2 = self.create_invoice(amount=500, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
ids = [inv_1.id, inv_2.id]
register_payments = self.register_payments_model.with_context(active_ids=ids).create({
'payment_date': time.strftime('%Y') + '-07-15',
'journal_id': self.bank_journal_euro.id,
'payment_method_id': self.payment_method_manual_out.id,
})
register_payments.amount = 400.0
register_payments.is_manual_disperse = True
register_payments.writeoff_journal_id = inv_1.journal_id
payment_register = Form(self.register_payments_model.with_context(active_ids=ids))
payment_register.journal_id = self.bank_journal_euro
payment_register.payment_date = time.strftime('%Y') + '-07-15'
payment_register.is_manual_disperse = True
with payment_register.payment_invoice_ids.edit(0) as f:
f.amount = 100.0
f.save()
with payment_register.payment_invoice_ids.edit(1) as f:
f.amount = 300.0
f.save()
self.assertEqual(payment_register.amount, 400.0)
# Cannot have close balance in form because it becomes required
payment_register = payment_register.save()
payment_register.action_toggle_close_balance()
with self.assertRaises(ValidationError):
register_payments.create_payments()
payment_register.create_payments()
for line in register_payments.invoice_line_ids:
if line.invoice_id == inv_1:
line.amount = 100.0
if line.invoice_id == inv_2:
line.amount = 300.0
line.writeoff_acc_id = self.account_revenue
register_payments.create_payments()
# Need the writeoff account
payment_register.writeoff_account_id = self.transfer_account
payment_register.create_payments()
payment_ids = self.payment_model.search([('invoice_ids', 'in', ids)], order="id desc")
self.assertEqual(len(payment_ids), 1, 'Need only one payment.')
self.assertEqual(payment_ids.amount, 400.0)
self.assertEqual(inv_1.residual_signed, 0.0)
self.assertEqual(inv_2.residual_signed, 0.0)
self.assertEqual(inv_1.amount_residual_signed, 0.0)
self.assertEqual(inv_2.amount_residual_signed, 0.0)

View File

@@ -1 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import register_payment_wizard

View File

@@ -1,97 +1,200 @@
from odoo import api, fields, models
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from datetime import datetime
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.addons.account.models.account_payment import MAP_INVOICE_TYPE_PARTNER_TYPE
class AccountRegisterPayments(models.TransientModel):
_inherit = 'account.register.payments'
_inherit = 'account.payment.register'
is_manual_disperse = fields.Boolean(string='Disperse Manually')
invoice_line_ids = fields.One2many('account.register.payments.invoice.line', 'wizard_id', string='Invoices')
writeoff_journal_id = fields.Many2one('account.journal', string='Write-off Journal')
payment_invoice_ids = fields.One2many('account.payment.register.payment_invoice', 'wizard_id', string='Invoices')
writeoff_account_id = fields.Many2one('account.account', string="Difference Account", domain="[('deprecated', '=', False)]", copy=False)
requires_writeoff_account = fields.Boolean(compute='_compute_requires_writeoff_account')
amount = fields.Float(string='Amount', compute='_compute_amount')
due_date_cutoff = fields.Date(string='Due Date Cutoff', default=fields.Date.today)
due_date_behavior = fields.Selection([
('due', 'Due'),
('next_due', 'Next Due'),
], string='Due Date Behavior', default='due')
@api.model
def default_get(self, fields):
rec = super(AccountRegisterPayments, self).default_get(fields)
if 'invoice_ids' in rec:
invoice_ids = rec['invoice_ids'][0][2]
rec['invoice_line_ids'] = [(0, 0, {'invoice_id': i, 'amount': 0.0}) for i in invoice_ids]
rec['payment_invoice_ids'] = [(0, 0, {'invoice_id': i}) for i in invoice_ids]
return rec
@api.multi
def create_payments(self):
for payment in self.filtered(lambda p: not p.amount and p.invoice_ids):
payment._compute_amount()
for payment in self.filtered(lambda p: p.is_manual_disperse):
line_amount = sum(payment.invoice_line_ids.mapped('amount'))
line_amount = sum(payment.payment_invoice_ids.mapped('amount'))
if abs(line_amount - payment.amount) >= 0.01:
raise ValidationError('Cannot pay for %0.2f worth of invoices with %0.2f total.' %
(line_amount, payment.amount))
if not payment.writeoff_journal_id and payment.invoice_line_ids.filtered(lambda l: l.writeoff_acc_id):
raise ValidationError('Cannot write off without a write off journal.')
if not payment.writeoff_account_id and payment.payment_invoice_ids.filtered(lambda l: l.close_balance and l.difference):
raise ValidationError('Closing balance will require a difference account.')
new_self = self.with_context(payment_wizard_id=self.id)
return super(AccountRegisterPayments, new_self).create_payments()
@api.multi
@api.onchange('is_manual_disperse')
def _ensure_is_manual_disperse(self):
for payment in self:
payment.group_payment = payment.is_manual_disperse
@api.depends('payment_invoice_ids.amount', 'invoice_ids', 'journal_id', 'payment_date', 'is_manual_disperse')
def _compute_amount(self):
for payment in self.filtered(lambda p: p.is_manual_disperse):
payment.amount = sum(payment.mapped('payment_invoice_ids.amount') or [0.0])
for payment in self.filtered(lambda p: not p.is_manual_disperse):
if payment.invoice_ids:
payment.amount = self.env['account.payment']._compute_payment_amount(
payment.invoice_ids,
payment.invoice_ids[0].currency_id,
payment.journal_id,
payment.payment_date)
else:
payment.amount = 0.0
@api.depends('payment_invoice_ids.difference', 'payment_invoice_ids.close_balance')
def _compute_requires_writeoff_account(self):
for payment in self:
if payment.is_manual_disperse:
payment.requires_writeoff_account = bool(payment.payment_invoice_ids.filtered(
lambda l: l.difference and l.close_balance))
else:
payment.requires_writeoff_account = False
def _prepare_payment_vals(self, invoices):
'''Create the payment values.
:param invoices: The invoices/bills to pay. In case of multiple
documents, they need to be grouped by partner, bank, journal and
currency.
:return: The payment values as a dictionary.
'''
if self.is_manual_disperse:
# this will already be positive for both invoice types unlink the below amount (abs)
amount = sum(self.payment_invoice_ids.filtered(lambda l: l.invoice_id in invoices).mapped('amount'))
sign = 1.0
if invoices:
invoice = invoices[0]
sign = 1.0 if invoice.type in ('out_invoice', 'out_refund') else -1.0
else:
amount = self.env['account.payment']._compute_payment_amount(invoices, invoices[0].currency_id,
self.journal_id, self.payment_date)
sign = 1.0 if amount > 0.0 else -1.0
values = {
'journal_id': self.journal_id.id,
'payment_method_id': self.payment_method_id.id,
'payment_date': self.payment_date,
'communication': self._prepare_communication(invoices),
'invoice_ids': [(6, 0, invoices.ids)],
'payment_type': 'inbound' if sign > 0 else 'outbound',
'amount': abs(amount),
'currency_id': invoices[0].currency_id.id,
'partner_id': invoices[0].commercial_partner_id.id,
'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type],
'partner_bank_account_id': invoices[0].invoice_partner_bank_id.id,
'writeoff_account_id': self.writeoff_account_id.id,
}
return values
def action_fill_residual(self):
for payment in self:
for line in payment.invoice_line_ids:
for line in payment.payment_invoice_ids:
line.amount = line.residual
action = self.env.ref('account.action_account_payment_from_invoices').read()[0]
action['res_id'] = payment.id
return action
return payment._reopen_action()
@api.multi
def action_fill_residual_due(self):
for payment in self:
for line in payment.invoice_line_ids:
for line in payment.payment_invoice_ids:
line.amount = line.residual_due
action = self.env.ref('account.action_account_payment_from_invoices').read()[0]
action['res_id'] = payment.id
return action
return payment._reopen_action()
def action_toggle_close_balance(self):
for payment in self:
for line in payment.payment_invoice_ids:
line.close_balance = not line.close_balance
return payment._reopen_action()
def _reopen_action(self):
return {
'name': _('Register Payment'),
'res_model': 'account.payment.register',
'view_mode': 'form',
'view_id': self.env.ref('account.view_account_payment_form_multi').id,
'context': self.env.context,
'target': 'new',
'res_id': self.id,
'type': 'ir.actions.act_window',
}
class AccountRegisterPaymentsInvoiceLine(models.TransientModel):
_name = 'account.register.payments.invoice.line'
_name = 'account.payment.register.payment_invoice'
wizard_id = fields.Many2one('account.register.payments')
invoice_id = fields.Many2one('account.invoice', string='Invoice', required=True)
partner_id = fields.Many2one('res.partner', string='Partner', compute='_compute_balances', compute_sudo=True)
residual = fields.Float(string='Remaining', compute='_compute_balances', compute_sudo=True)
residual_due = fields.Float(string='Due', compute='_compute_balances', compute_sudo=True)
difference = fields.Float(string='Difference', default=0.0)
wizard_id = fields.Many2one('account.payment.register')
invoice_id = fields.Many2one('account.move', string='Invoice', required=True)
partner_id = fields.Many2one('res.partner', string='Partner', compute='_compute_invoice_balances', compute_sudo=True)
residual = fields.Float(string='Remaining', compute='_compute_invoice_balances', compute_sudo=True)
residual_due = fields.Float(string='Due', compute='_compute_invoice_balances', compute_sudo=True)
difference = fields.Float(string='Difference', compute='_compute_difference')
amount = fields.Float(string='Amount')
writeoff_acc_id = fields.Many2one('account.account', string='Write-off Account')
close_balance = fields.Boolean(string='Close Balance', help='Write off remaining balance.')
@api.depends('invoice_id', 'wizard_id.due_date_cutoff', 'invoice_id.partner_id')
def _compute_balances(self):
@api.depends('invoice_id', 'wizard_id.due_date_cutoff', 'wizard_id.due_date_behavior', 'invoice_id.partner_id')
def _compute_invoice_balances(self):
dummy_date = datetime(1980, 1, 1)
for line in self:
# Bug in the ORM 12.0? The invoice is set, but there is no residual
# on anything other than the first invoice/line processed.
invoice = line.invoice_id.browse(line.invoice_id.id)
residual = invoice.residual
sign = 1.0 if invoice.type in ('out_invoice', 'out_refund') else -1.0
residual = sign * invoice.amount_residual_signed
cutoff_date = line.wizard_id.due_date_cutoff
due_behavior = line.wizard_id.due_date_behavior
total_amount = 0.0
total_reconciled = 0.0
for move_line in invoice.move_id.line_ids.filtered(lambda r: (
not r.reconciled
and r.account_id.internal_type in ('payable', 'receivable')
and r.date_maturity <= cutoff_date
)):
amount = abs(move_line.debit - move_line.credit)
total_amount += amount
for partial_line in move_line.matched_debit_ids:
total_reconciled += partial_line.amount
for partial_line in move_line.matched_credit_ids:
total_reconciled += partial_line.amount
# TODO partial reconcile will need sign check
if due_behavior == 'due':
for move_line in invoice.line_ids.filtered(lambda r: (
not r.reconciled
and r.account_id.internal_type in ('payable', 'receivable')
and r.date_maturity <= cutoff_date
)):
amount = move_line.debit - move_line.credit
total_amount += amount
for partial_line in move_line.matched_debit_ids:
total_reconciled += partial_line.amount
for partial_line in move_line.matched_credit_ids:
total_reconciled += partial_line.amount
else:
move_lines = invoice.line_ids.filtered(lambda r: (
not r.reconciled
and r.account_id.internal_type in ('payable', 'receivable')
)).sorted(key=lambda r: r.date_maturity or dummy_date)
if move_lines:
move_line = move_lines[0]
amount = move_line.debit - move_line.credit
total_amount += amount
for partial_line in move_line.matched_debit_ids:
total_reconciled += partial_line.amount
for partial_line in move_line.matched_credit_ids:
total_reconciled += partial_line.amount
values = {
'residual': residual,
'residual_due': total_amount - total_reconciled,
'difference': residual - (line.amount or 0.0),
'residual_due': sign * (total_amount - total_reconciled),
# 'difference': sign * ((line.amount or 0.0) - residual),
'difference': (line.amount or 0.0) - residual,
'partner_id': invoice.partner_id.id,
}
line.update(values)
@api.onchange('amount')
def _onchange_amount(self):
@api.depends('amount', 'residual', 'residual_due', 'invoice_id')
def _compute_difference(self):
for line in self:
line.difference = line.residual - line.amount
line.difference = line.amount - line.residual

View File

@@ -1,28 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_account_payment_from_invoices_inherited" model="ir.ui.view">
<field name="name">account.register.payments.wizard.inherited</field>
<field name="model">account.register.payments</field>
<field name="inherit_id" ref="account.view_account_payment_from_invoices"/>
<record id="view_account_payment_form_multi_inherit" model="ir.ui.view">
<field name="name">account.payment.form.multi.inherit</field>
<field name="model">account.payment.register</field>
<field name="inherit_id" ref="account.view_account_payment_form_multi"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='amount']" position="attributes">
<attribute name="attrs">{'readonly': [('multi', '=', True), ('is_manual_disperse', '!=', True)]}</attribute>
</xpath>
<xpath expr="//field[@name='payment_method_code']" position="after">
<xpath expr="//field[@name='payment_date']" position="after">
<field name="is_manual_disperse"/>
</xpath>
<xpath expr="//group[3]" position="attributes">
<attribute name="attrs">{'invisible': ['|', ('payment_difference', '=', 0.0), ('is_manual_disperse', '=', True)]}</attribute>
<field name="amount" invisible="1" />
</xpath>
<xpath expr="//form/group[1]" position="after">
<group name="invoice_lines" attrs="{'invisible': [('is_manual_disperse', '=', False)]}">
<group>
<button type="object" name="action_fill_residual"
string="Fill with Remaining" class="btn-default"/>
<button type="object" name="action_fill_residual_due"
string="Fill with Due" class="btn-default"/>
</group>
<field name="invoice_line_ids" nolabel="1">
<field name="payment_invoice_ids" nolabel="1">
<tree editable="bottom" create="false">
<field name="wizard_id" invisible="1"/>
<field name="partner_id" readonly="1"/>
@@ -31,16 +20,25 @@
<field name="residual_due" readonly="1" sum="Total Due"/>
<field name="amount" sum="Total Amount"/>
<field name="difference" readonly="1" sum="Total Difference"/>
<field name="writeoff_acc_id"/>
<field name="close_balance"/>
</tree>
</field>
</group>
<group name="invoice_totals" attrs="{'invisible': [('is_manual_disperse', '=', False)]}">
<group>
<field name="writeoff_journal_id"/>
<field name="requires_writeoff_account" invisible="1"/>
<field name="writeoff_account_id" attrs="{'required': [('requires_writeoff_account', '=', True)]}" />
<field name="due_date_cutoff"/>
<field name="due_date_behavior"/>
<button type="object" name="action_fill_residual"
string="Fill with Remaining" class="btn-primary"/>
<button type="object" name="action_fill_residual_due"
string="Fill with Due" class="btn-primary"/>
</group>
<group>
<button type="object" name="action_toggle_close_balance"
string="Toggle Close Balance" class="btn-primary"/>
</group>
<group/>
</group>
</xpath>
</field>