Skip to content

Commit 032edfe

Browse files
committed
[ADD] partial_payment: adds partial payment feature for multiple bill/invoice
- Added List view in wizard which shows the selected bill/invoice. - List view has Payment Amount field to set partial payment amount. - Added Validation to prevent over payment. - On Confirming payment, payment entry will created based on user-defined amount.
1 parent 4c650f3 commit 032edfe

9 files changed

+409
-0
lines changed

partial_payment/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import wizard

partial_payment/__manifest__.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
'name': "Partial Payment",
3+
'version': '1.0',
4+
'category': 'Accounting/Accounting',
5+
'description': "Adds new Partial Payment Feature for multiple bill/invoice",
6+
'depends': [
7+
'account',
8+
'accountant'
9+
],
10+
'data': [
11+
'security/ir.model.access.csv',
12+
13+
'wizard/account_payment_register_views.xml'
14+
],
15+
'installable': True,
16+
'license': 'LGPL-3',
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
2+
access_account_payment_lines,access_account_payment_lines,model_account_payment_lines,base.group_user,1,1,1,0

partial_payment/tests/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_partial_payment
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from odoo.tests.common import TransactionCase
2+
from odoo.exceptions import ValidationError
3+
4+
class TestAccountPaymentLines(TransactionCase):
5+
6+
def setUp(self):
7+
super(TestAccountPaymentLines, self).setUp()
8+
9+
self.payment_line = self.env['account.payment.lines'].create({
10+
'partner_id': self.env.ref('base.res_partner_1').id,
11+
'name': 'Test Payment',
12+
'memo_id': 'INV/2025/001',
13+
'invoice_date': '2025-03-10',
14+
'amount_residual': 500.00,
15+
'balance_amount': 500.00,
16+
'payment_amount': 200.00,
17+
})
18+
19+
def test_valid_payment_amount(self):
20+
"""Test setting a valid payment amount"""
21+
self.payment_line.write({'payment_amount': 300.00})
22+
self.assertEqual(self.payment_line.payment_amount, 300.00, "Payment amount should be set correctly.")
23+
print('===========Test Complete============')
24+
25+
def test_invalid_payment_amount(self):
26+
"""Test validation when payment amount exceeds balance"""
27+
with self.assertRaises(ValidationError):
28+
self.payment_line.write({'payment_amount': 600.00})
29+
print('===========Test Complete============')

partial_payment/wizard/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import account_payment_register
2+
from . import account_payment_register_lines
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
from odoo import _, api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
class AccountPaymentRegister(models.TransientModel):
5+
_inherit = "account.payment.register"
6+
7+
communication = fields.Text()
8+
single_payment = fields.Boolean(compute="_compute_single_payment")
9+
payment_lines_ids = fields.One2many(comodel_name="account.payment.lines", inverse_name="account_payment_register_id", store=True)
10+
11+
# -------------------------------------------------------------------------
12+
# COMPUTE METHODS
13+
# -------------------------------------------------------------------------
14+
15+
@api.depends('payment_lines_ids')
16+
def _compute_single_payment(self):
17+
self.single_payment = len(self.payment_lines_ids) == 1
18+
19+
# -------------------------------------------------------------------------
20+
# LOW-LEVEL METHODS
21+
# -------------------------------------------------------------------------
22+
23+
@api.model
24+
def default_get(self, fields_list):
25+
# OVERRIDE
26+
res = super().default_get(fields_list)
27+
28+
# Retrieve available_line
29+
if 'line_ids' in fields_list:
30+
if self._context.get('active_model') == 'account.move':
31+
lines = self.env['account.move'].browse(self._context.get('active_ids', [])).line_ids
32+
elif self._context.get('active_model') == 'account.move.line':
33+
lines = self.env['account.move.line'].browse(self._context.get('active_ids', []))
34+
else:
35+
raise UserError(_(
36+
"The register payment wizard should only be called on account.move or account.move.line records."
37+
))
38+
39+
available_lines = self.env['account.move.line']
40+
valid_account_types = self.env['account.payment']._get_valid_payment_account_types()
41+
for line in lines:
42+
43+
if line.account_type not in valid_account_types:
44+
continue
45+
if line.currency_id:
46+
if line.currency_id.is_zero(line.amount_residual_currency):
47+
continue
48+
else:
49+
if line.company_currency_id.is_zero(line.amount_residual):
50+
continue
51+
available_lines |= line
52+
53+
# Store lines
54+
payment_lines = []
55+
if available_lines:
56+
for line in available_lines:
57+
payment_lines.append((0, 0, {
58+
'partner_id': line.partner_id.id,
59+
'name': line.move_name,
60+
'memo_id': line.move_name,
61+
'invoice_date': line.invoice_date,
62+
'amount_residual': line.amount_residual,
63+
'balance_amount': line.amount_residual,
64+
'payment_amount': line.amount_residual
65+
}))
66+
67+
res['payment_lines_ids'] = payment_lines
68+
69+
return res
70+
71+
# -------------------------------------------------------------------------
72+
# BUSINESS METHODS
73+
# -------------------------------------------------------------------------
74+
75+
def _create_payment_vals_from_batch(self, batch_result):
76+
batch_values = self._get_wizard_values_from_batch(batch_result)
77+
78+
if batch_values['payment_type'] == 'inbound':
79+
partner_bank_id = self.journal_id.bank_account_id.id
80+
else:
81+
partner_bank_id = batch_result['payment_values']['partner_bank_id']
82+
83+
payment_method_line = self.payment_method_line_id
84+
85+
if batch_values['payment_type'] != payment_method_line.payment_type:
86+
payment_method_line = self.journal_id._get_available_payment_method_lines(batch_values['payment_type'])[:1]
87+
88+
# Fetch the Partial set amount.
89+
memo = self._get_communication(batch_result['lines'])
90+
payment_line = self.payment_lines_ids.search([('memo_id', '=', memo)], order='id desc', limit=1)
91+
92+
payment_vals = {
93+
'date': self.payment_date,
94+
'amount': abs(payment_line.payment_amount),
95+
'payment_type': batch_values['payment_type'],
96+
'partner_type': batch_values['partner_type'],
97+
'memo': self._get_communication(batch_result['lines']),
98+
'journal_id': self.journal_id.id,
99+
'company_id': self.company_id.id,
100+
'currency_id': batch_values['source_currency_id'],
101+
'partner_id': batch_values['partner_id'],
102+
'payment_method_line_id': payment_method_line.id,
103+
'destination_account_id': batch_result['lines'][0].account_id.id,
104+
'write_off_line_vals': [],
105+
}
106+
107+
if partner_bank_id:
108+
payment_vals['partner_bank_id'] = partner_bank_id
109+
110+
total_amount_values = self._get_total_amounts_to_pay([batch_result])
111+
total_amount = total_amount_values['amount_by_default']
112+
currency = self.env['res.currency'].browse(batch_values['source_currency_id'])
113+
if total_amount_values['epd_applied']:
114+
payment_vals['amount'] = total_amount
115+
116+
epd_aml_values_list = []
117+
for aml in batch_result['lines']:
118+
if aml.move_id._is_eligible_for_early_payment_discount(currency, self.payment_date):
119+
epd_aml_values_list.append({
120+
'aml': aml,
121+
'amount_currency': -aml.amount_residual_currency,
122+
'balance': currency._convert(-aml.amount_residual_currency, aml.company_currency_id, self.company_id, self.payment_date),
123+
})
124+
125+
open_amount_currency = (batch_values['source_amount_currency'] - total_amount) * (-1 if batch_values['payment_type'] == 'outbound' else 1)
126+
open_balance = currency._convert(open_amount_currency, aml.company_currency_id, self.company_id, self.payment_date)
127+
early_payment_values = self.env['account.move']\
128+
._get_invoice_counterpart_amls_for_early_payment_discount(epd_aml_values_list, open_balance)
129+
for aml_values_list in early_payment_values.values():
130+
payment_vals['write_off_line_vals'] += aml_values_list
131+
132+
return payment_vals
133+
134+
def _create_payment_vals_from_wizard(self, batch_result):
135+
amount = self.amount
136+
if not self.single_payment:
137+
amount = 0
138+
for line in self.payment_lines_ids:
139+
amount += abs(line.payment_amount)
140+
payment_vals = {
141+
'date': self.payment_date,
142+
'amount': amount,
143+
'payment_type': self.payment_type,
144+
'partner_type': self.partner_type,
145+
'memo': self.communication,
146+
'journal_id': self.journal_id.id,
147+
'company_id': self.company_id.id,
148+
'currency_id': self.currency_id.id,
149+
'partner_id': self.partner_id.id,
150+
'partner_bank_id': self.partner_bank_id.id,
151+
'payment_method_line_id': self.payment_method_line_id.id,
152+
'destination_account_id': self.line_ids[0].account_id.id,
153+
'write_off_line_vals': [],
154+
}
155+
156+
if self.payment_difference_handling == 'reconcile':
157+
if self.early_payment_discount_mode:
158+
epd_aml_values_list = []
159+
for aml in batch_result['lines']:
160+
if aml.move_id._is_eligible_for_early_payment_discount(self.currency_id, self.payment_date):
161+
epd_aml_values_list.append({
162+
'aml': aml,
163+
'amount_currency': -aml.amount_residual_currency,
164+
'balance': aml.currency_id._convert(-aml.amount_residual_currency, aml.company_currency_id, date=self.payment_date),
165+
})
166+
167+
open_amount_currency = self.payment_difference * (-1 if self.payment_type == 'outbound' else 1)
168+
open_balance = self.currency_id._convert(open_amount_currency, self.company_id.currency_id, self.company_id, self.payment_date)
169+
early_payment_values = self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(epd_aml_values_list, open_balance)
170+
for aml_values_list in early_payment_values.values():
171+
payment_vals['write_off_line_vals'] += aml_values_list
172+
173+
elif not self.currency_id.is_zero(self.payment_difference):
174+
175+
if self.writeoff_is_exchange_account:
176+
if self.currency_id != self.company_currency_id:
177+
payment_vals['force_balance'] = sum(batch_result['lines'].mapped('amount_residual'))
178+
else:
179+
if self.payment_type == 'inbound':
180+
# Receive money.
181+
write_off_amount_currency = self.payment_difference
182+
else: # if self.payment_type == 'outbound':
183+
# Send money.
184+
write_off_amount_currency = -self.payment_difference
185+
186+
payment_vals['write_off_line_vals'].append({
187+
'name': self.writeoff_label,
188+
'account_id': self.writeoff_account_id.id,
189+
'partner_id': self.partner_id.id,
190+
'currency_id': self.currency_id.id,
191+
'amount_currency': write_off_amount_currency,
192+
'balance': self.currency_id._convert(write_off_amount_currency, self.company_id.currency_id, self.company_id, self.payment_date),
193+
})
194+
return payment_vals
195+
196+
def _reconcile_payments(self, to_process, edit_mode=False):
197+
""" Reconcile payments using the specified partial amounts per invoice. """
198+
199+
domain = [
200+
('parent_state', '=', 'posted'),
201+
('account_type', 'in', self.env['account.payment']._get_valid_payment_account_types()),
202+
('reconciled', '=', False),
203+
]
204+
205+
for vals in to_process:
206+
payment = vals['payment']
207+
payment_lines = payment.move_id.line_ids.filtered_domain(domain)
208+
invoice_line = vals['to_reconcile']
209+
amount_to_reconcile = vals['create_vals']['amount'] # Get the specified partial amount
210+
extra_context = {'forced_rate_from_register_payment': vals['rate']} if 'rate' in vals else {}
211+
212+
if abs(invoice_line.amount_residual_currency) <= abs(amount_to_reconcile):
213+
# Fully reconcile this invoice
214+
(payment_lines + invoice_line).with_context(**extra_context).reconcile()
215+
else:
216+
# Partial reconciliation - create a partial reconcile entry
217+
self.env['account.partial.reconcile'].create({
218+
'debit_move_id': invoice_line.id if invoice_line.balance > 0 else payment_lines.id,
219+
'credit_move_id': payment_lines.id if invoice_line.balance > 0 else invoice_line.id,
220+
'amount': amount_to_reconcile,
221+
'debit_amount_currency': amount_to_reconcile if invoice_line.balance > 0 else 0.0,
222+
'credit_amount_currency': amount_to_reconcile if invoice_line.balance < 0 else 0.0,
223+
'company_id': payment.company_id.id,
224+
})
225+
226+
# Link payment to reconciled journal entries
227+
invoice_line.move_id.matched_payment_ids += payment
228+
229+
def _create_payments(self):
230+
self.ensure_one()
231+
batches = []
232+
233+
for batch in self.batches:
234+
batch_account = self._get_batch_account(batch)
235+
if self.require_partner_bank_account and (not batch_account or not batch_account.allow_out_payment):
236+
continue
237+
batches.append(batch)
238+
239+
if not batches:
240+
raise UserError(_(
241+
"To record payments with %(payment_method)s, the recipient bank account must be manually validated. You should go on the partner bank account in order to validate it.",
242+
payment_method=self.payment_method_line_id.name,
243+
))
244+
245+
first_batch_result = batches[0]
246+
edit_mode = self.can_edit_wizard and (len(first_batch_result['lines']) == 1 or self.group_payment)
247+
to_process_single = []
248+
to_process = []
249+
250+
# single_payment:
251+
payment_vals = self._create_payment_vals_from_wizard(first_batch_result)
252+
to_process_values = {
253+
'create_vals': payment_vals,
254+
'to_reconcile': first_batch_result['lines'],
255+
'batch': first_batch_result,
256+
}
257+
258+
# Force the rate during the reconciliation to put the difference directly on the
259+
# exchange difference.
260+
if self.writeoff_is_exchange_account and self.currency_id == self.company_currency_id:
261+
total_batch_residual = sum(first_batch_result['lines'].mapped('amount_residual_currency'))
262+
to_process_values['rate'] = abs(total_batch_residual / self.amount) if self.amount else 0.0
263+
264+
to_process_single.append(to_process_values)
265+
266+
# Don't group payments: Create one batch per move.
267+
lines_to_pay = self._get_total_amounts_to_pay(batches)['lines'] if self.installments_mode in ('next', 'overdue', 'before_date') else self.line_ids
268+
new_batches = []
269+
for batch_result in batches:
270+
for line in batch_result['lines']:
271+
if line not in lines_to_pay:
272+
continue
273+
new_batches.append({
274+
**batch_result,
275+
'payment_values': {
276+
**batch_result['payment_values'],
277+
'payment_type': 'inbound' if line.balance > 0 else 'outbound'
278+
},
279+
'lines': line,
280+
})
281+
batches = new_batches
282+
283+
for batch_result in batches:
284+
to_process.append({
285+
'create_vals': self._create_payment_vals_from_batch(batch_result),
286+
'to_reconcile': batch_result['lines'],
287+
'batch': batch_result,
288+
})
289+
290+
if self.single_payment or self.group_payment:
291+
292+
payments = self._init_payments(to_process_single, edit_mode=edit_mode)
293+
self._post_payments(to_process_single, edit_mode=edit_mode)
294+
295+
if self.group_payment:
296+
for vals in to_process:
297+
vals['payment'] = to_process_single[0]['payment']
298+
self._reconcile_payments(to_process, edit_mode=edit_mode)
299+
else:
300+
self._reconcile_payments(to_process_single, edit_mode=edit_mode)
301+
else:
302+
payments = self._init_payments(to_process, edit_mode=edit_mode)
303+
self._post_payments(to_process, edit_mode=edit_mode)
304+
self._reconcile_payments(to_process, edit_mode=edit_mode)
305+
306+
return payments
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from odoo import api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
class PaymentLines(models.TransientModel):
5+
_name = "account.payment.lines"
6+
_description = "Pay Lines"
7+
8+
account_payment_register_id = fields.Many2one(comodel_name="account.payment.register")
9+
partner_id = fields.Many2one(string="Vendor", comodel_name="res.partner", readonly=True)
10+
name = fields.Char(string="Bill Number", readonly=True)
11+
memo_id = fields.Char(store=True)
12+
invoice_date = fields.Date(string="Bill Date", readonly=True)
13+
amount_residual = fields.Float(string="Total Balance Amount", readonly=True)
14+
balance_amount = fields.Float(store=True)
15+
payment_amount = fields.Float(string="Payment Amount")
16+
17+
@api.constrains("payment_amount")
18+
def _check_payment_amount(self):
19+
for record in self:
20+
if abs(record.payment_amount) > abs(record.balance_amount):
21+
raise ValidationError('Payment amount cannot exceed the total balance amount.')

0 commit comments

Comments
 (0)