feat: implement expense realization payment tracking and automated balancing journal entries
This commit is contained in:
parent
82b36c3ef5
commit
83ea721aa8
@ -30,6 +30,12 @@ This module enhances Odoo's standard Expense workflow by providing account-split
|
||||
- **Overdue Tracking**: Automatically calculates and highlights overdue receipts for Kasbon realizations.
|
||||
- **Simplified UI**: Standard "Split Expense" and other distracting buttons are hidden in the backend to maintain a focused workflow.
|
||||
|
||||
### 6. Realization Accounting Logic
|
||||
- **Automated Clearing**: Automatically balances the difference between the original advance (Kasbon) and actual receipts using a **Clearing Account (218401)**.
|
||||
- **Scenario 1 (Spent > Paid)**: Records the extra amount as a credit in the clearing account (Liability to employee).
|
||||
- **Scenario 2 (Spent < Paid)**: Records the remaining balance as a debit in the clearing account (Employee owes back).
|
||||
- **Dynamic Discovery**: Attempts to use the exact bank/outstanding account from the original payment for seamless reconciliation.
|
||||
|
||||
## 🛠 Configuration
|
||||
|
||||
1. **GL Accounts**:
|
||||
|
||||
@ -8,5 +8,12 @@
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
<record id="seq_hr_expense" model="ir.sequence">
|
||||
<field name="name">Expense</field>
|
||||
<field name="code">hr.expense.sequence</field>
|
||||
<field name="prefix">EXP/%(year)s/%(month)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@ -3,5 +3,6 @@ from . import hr_expense
|
||||
from . import hr_expense_sheet
|
||||
from . import account_move_line
|
||||
from . import hr_expense_realization
|
||||
from . import account_payment
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
|
||||
6
models/account_payment.py
Normal file
6
models/account_payment.py
Normal file
@ -0,0 +1,6 @@
|
||||
from odoo import fields, models
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
realization_id = fields.Many2one('hr.expense.realization', string='Originating Realization', readonly=True)
|
||||
@ -21,6 +21,15 @@ class HrExpense(models.Model):
|
||||
if product.property_account_expense_company_id:
|
||||
expense.account_id = product.property_account_expense_company_id
|
||||
|
||||
sequence_name = fields.Char(string='Sequence', readonly=True, copy=False, default=lambda self: _('New'))
|
||||
realization_total_amount = fields.Monetary(
|
||||
string='Realization Total',
|
||||
compute='_compute_realization_total_amount',
|
||||
store=True,
|
||||
currency_field='currency_id',
|
||||
help="Total amount from all physical receipts realized for this expense."
|
||||
)
|
||||
|
||||
receipt_due_date = fields.Date(
|
||||
string="Receipt Due Date",
|
||||
readonly=True,
|
||||
@ -47,6 +56,18 @@ class HrExpense(models.Model):
|
||||
for expense in self:
|
||||
expense.realization_count = len(expense.realization_ids)
|
||||
|
||||
@api.depends('realization_ids.total_amount')
|
||||
def _compute_realization_total_amount(self):
|
||||
for expense in self:
|
||||
expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount'))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('sequence_name', _('New')) == _('New'):
|
||||
vals['sequence_name'] = self.env['ir.sequence'].next_by_code('hr.expense.sequence') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_create_realization(self):
|
||||
self.ensure_one()
|
||||
if self.payment_mode != 'company_account':
|
||||
|
||||
@ -39,6 +39,41 @@ class HrExpenseRealization(models.Model):
|
||||
for rec in self:
|
||||
rec.total_amount = sum(rec.line_ids.mapped('amount'))
|
||||
|
||||
# Payment tracking
|
||||
discrepancy_amount = fields.Monetary(string='Discrepancy Amount', compute='_compute_discrepancy', store=True)
|
||||
payment_ids = fields.One2many('account.payment', 'realization_id', string='Payments')
|
||||
payment_count = fields.Integer(compute='_compute_payment_count')
|
||||
show_vendor_payment_btn = fields.Boolean(compute='_compute_button_visibility')
|
||||
show_customer_payment_btn = fields.Boolean(compute='_compute_button_visibility')
|
||||
|
||||
@api.depends('move_id.line_ids.amount_currency', 'state', 'payment_ids', 'payment_ids.state')
|
||||
def _compute_discrepancy(self):
|
||||
""" Calculate discrepancy based on balancing accounts (114101/216109) in move_id. """
|
||||
for rec in self:
|
||||
if rec.state != 'posted' or not rec.move_id:
|
||||
rec.discrepancy_amount = 0.0
|
||||
continue
|
||||
|
||||
balancing_lines = rec.move_id.line_ids.filtered(lambda l: l.account_id.code in ('114101', '216109'))
|
||||
payable_val = sum(balancing_lines.filtered(lambda l: l.account_id.code == '216109').mapped('credit'))
|
||||
receivable_val = sum(balancing_lines.filtered(lambda l: l.account_id.code == '114101').mapped('debit'))
|
||||
|
||||
# positive means we owe employee, negative means employee owes us
|
||||
rec.discrepancy_amount = payable_val - receivable_val
|
||||
|
||||
@api.depends('payment_ids')
|
||||
def _compute_payment_count(self):
|
||||
for rec in self:
|
||||
rec.payment_count = len(rec.payment_ids)
|
||||
|
||||
@api.depends('discrepancy_amount', 'state', 'payment_ids', 'payment_ids.state')
|
||||
def _compute_button_visibility(self):
|
||||
for rec in self:
|
||||
# Only show if posted and a discrepancy exists, and no successful payment exists
|
||||
has_payment = any(p.state not in ('draft', 'cancel') for p in rec.payment_ids)
|
||||
rec.show_vendor_payment_btn = rec.state == 'posted' and rec.discrepancy_amount > 0 and not has_payment
|
||||
rec.show_customer_payment_btn = rec.state == 'posted' and rec.discrepancy_amount < 0 and not has_payment
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
@ -73,53 +108,151 @@ class HrExpenseRealization(models.Model):
|
||||
if not self.journal_id:
|
||||
raise UserError(_("Please specify the Journal before posting."))
|
||||
|
||||
# Determine the Expense Account
|
||||
# 1. Determine accounts
|
||||
# Advance Account (Product's expense account, e.g. 118101)
|
||||
product = self.expense_id.product_id.with_company(self.company_id)
|
||||
expense_account = product.property_account_expense_company_id or product.property_account_expense_id
|
||||
if not expense_account:
|
||||
expense_account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
|
||||
advance_account = product.property_account_expense_company_id or product.property_account_expense_id
|
||||
if not advance_account:
|
||||
advance_account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
|
||||
if not advance_account:
|
||||
raise UserError(_("No advance account found for the product or its category."))
|
||||
|
||||
if not expense_account:
|
||||
raise UserError(_("No expense account found for the product or its category."))
|
||||
# Partner specific accounts (114101 and 216109 fallback)
|
||||
partner = self.employee_id.sudo().work_contact_id
|
||||
# Biaya yang masih harus dibayar (Payable fallback 216109)
|
||||
payable_account = partner.property_account_payable_id or self.env['account.account'].search([('code', '=', '216109')], limit=1)
|
||||
# Piutang Karyawan (Receivable fallback 114101)
|
||||
receivable_account = partner.property_account_receivable_id or self.env['account.account'].search([('code', '=', '114101')], limit=1)
|
||||
|
||||
moves = self.env['account.move']
|
||||
# Force use specific accounts if they exist even if partner has defaults
|
||||
karyawan_acc = self.env['account.account'].search([('code', '=', '114101')], limit=1)
|
||||
if karyawan_acc:
|
||||
receivable_account = karyawan_acc
|
||||
biaya_ymh_acc = self.env['account.account'].search([('code', '=', '216109')], limit=1)
|
||||
if biaya_ymh_acc:
|
||||
payable_account = biaya_ymh_acc
|
||||
|
||||
if not payable_account or not receivable_account:
|
||||
raise UserError(_("Specific accounts for Piutang Karyawan (114101) or Biaya Lain ymh dibayar (216109) not found."))
|
||||
|
||||
# 2. Prepare Balanced Move Lines
|
||||
move_lines = []
|
||||
total_realized = 0.0
|
||||
|
||||
# Debits for Actual Expenses
|
||||
for line in self.line_ids:
|
||||
if not line.counterpart_account_id:
|
||||
raise UserError(_("Please specify a Counterpart Account for the receipt: %s") % line.description)
|
||||
|
||||
move_vals = {
|
||||
'journal_id': self.journal_id.id,
|
||||
'date': self.date,
|
||||
'ref': f"Realization: {self.expense_id.name} - {line.description}",
|
||||
'move_type': 'entry',
|
||||
'line_ids': [
|
||||
Command.create({
|
||||
'name': f"Realization: {self.expense_id.name} ({line.description})",
|
||||
'account_id': expense_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': line.amount,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
'expense_id': self.expense_id.id,
|
||||
}),
|
||||
Command.create({
|
||||
'name': f"Realization Counterpart: {line.description}",
|
||||
'account_id': line.counterpart_account_id.id,
|
||||
'debit': line.amount,
|
||||
'credit': 0.0,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
}),
|
||||
],
|
||||
}
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
move.action_post()
|
||||
move_lines.append(Command.create({
|
||||
'name': f"Realization Expense: {line.description}",
|
||||
'account_id': line.counterpart_account_id.id,
|
||||
'debit': line.amount,
|
||||
'credit': 0.0,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
}))
|
||||
total_realized += line.amount
|
||||
|
||||
# Credit for Advance clearing
|
||||
# We clear the remaining advance amount in the first realization for an expense.
|
||||
original_paid = self.expense_id.total_amount
|
||||
|
||||
# Find already cleared advance amount from previous posted moves targeting this expense and advance account
|
||||
cleared_before = sum(self.env['account.move.line'].search([
|
||||
('expense_id', '=', self.expense_id.id),
|
||||
('account_id', '=', advance_account.id),
|
||||
('move_id.state', '=', 'posted')
|
||||
]).mapped('credit'))
|
||||
|
||||
rem_advance = max(0.0, original_paid - cleared_before)
|
||||
|
||||
move_lines.append(Command.create({
|
||||
'name': f"Realization Clear Advance: {self.expense_id.sequence_name}",
|
||||
'account_id': advance_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': rem_advance,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
'expense_id': self.expense_id.id,
|
||||
}))
|
||||
|
||||
# Balancing line based on discrepency
|
||||
diff = total_realized - rem_advance
|
||||
if diff > 0:
|
||||
# Under-realized (Spent more than advance) -> Credit Partner Payable (216109)
|
||||
move_lines.append(Command.create({
|
||||
'name': f"Realization: Biaya yang masih harus dibayar",
|
||||
'account_id': payable_account.id,
|
||||
'debit': 0.0,
|
||||
'credit': diff,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
}))
|
||||
elif diff < 0:
|
||||
# Over-realized (Spent less than advance) -> Debit Partner Receivable (114101)
|
||||
move_lines.append(Command.create({
|
||||
'name': f"Realization: Piutang karyawan",
|
||||
'account_id': receivable_account.id,
|
||||
'debit': abs(diff),
|
||||
'credit': 0.0,
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
}))
|
||||
|
||||
move_vals = {
|
||||
'journal_id': self.journal_id.id,
|
||||
'date': self.date,
|
||||
'ref': f"Realization: {self.expense_id.sequence_name} ({self.name})",
|
||||
'move_type': 'entry',
|
||||
'line_ids': move_lines,
|
||||
}
|
||||
|
||||
move = self.env['account.move'].create(move_vals)
|
||||
move.action_post()
|
||||
|
||||
# Link the move to lines and record
|
||||
for line in self.line_ids:
|
||||
line.move_id = move.id
|
||||
moves |= move
|
||||
|
||||
self.write({
|
||||
'state': 'posted',
|
||||
'move_id': moves[0].id if moves else False
|
||||
'move_id': move.id
|
||||
})
|
||||
|
||||
def action_create_vendor_payment(self):
|
||||
return self._action_create_payment('outbound')
|
||||
|
||||
def action_create_customer_payment(self):
|
||||
return self._action_create_payment('inbound')
|
||||
|
||||
def _action_create_payment(self, payment_type):
|
||||
""" Opens the account.payment creation form with pre-filled realization data. """
|
||||
self.ensure_one()
|
||||
partner = self.employee_id.sudo().work_contact_id
|
||||
if not partner:
|
||||
raise UserError(_("Employee must have a work contact to register a payment."))
|
||||
|
||||
return {
|
||||
'name': _('Register Payment'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.payment',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_payment_type': payment_type,
|
||||
'default_partner_id': partner.id,
|
||||
'default_partner_type': 'supplier' if payment_type == 'outbound' else 'customer',
|
||||
'default_amount': abs(self.discrepancy_amount),
|
||||
'default_ref': f"Realization Balance: {self.name}",
|
||||
'default_realization_id': self.id,
|
||||
}
|
||||
}
|
||||
|
||||
def action_view_payments(self):
|
||||
""" Stat button action to view related payments. """
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_account_payments")
|
||||
action['domain'] = [('realization_id', '=', self.id)]
|
||||
action['context'] = {'default_realization_id': self.id}
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def get_pending_realizations(self, employee_id):
|
||||
""" Returns expenses for the given employee that are reported/done but NOT yet realized. """
|
||||
|
||||
@ -26,6 +26,86 @@ class HrExpenseSheet(models.Model):
|
||||
('none', 'No Receipt Required')
|
||||
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True)
|
||||
|
||||
realization_total_amount = fields.Monetary(
|
||||
string='Realization Total',
|
||||
compute='_compute_realization_total_amount',
|
||||
currency_field='currency_id',
|
||||
store=True
|
||||
)
|
||||
|
||||
expense_sequences = fields.Char(
|
||||
string='Expense Sequences',
|
||||
compute='_compute_expense_sequences',
|
||||
store=True,
|
||||
help="Concatenated sequences of all expenses in this report."
|
||||
)
|
||||
|
||||
amount_paid = fields.Monetary(
|
||||
string='Payment Total',
|
||||
compute='_compute_amount_paid',
|
||||
currency_field='currency_id',
|
||||
store=True,
|
||||
help="Total amount paid by the finance team (Total - Residual)."
|
||||
)
|
||||
|
||||
@api.depends('account_move_ids.line_ids.matched_debit_ids', 'account_move_ids.line_ids.matched_credit_ids', 'total_amount', 'amount_residual')
|
||||
def _compute_amount_paid(self):
|
||||
for sheet in self:
|
||||
total_paid = 0.0
|
||||
seen_statement_line_ids = set()
|
||||
seen_payment_ids = set()
|
||||
seen_move_line_ids = set()
|
||||
|
||||
# Find all relevant lines of the sheet's moves (Payable or Clearing accounts)
|
||||
reconcilable_lines = sheet.account_move_ids.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable') or
|
||||
l.account_id.reconcile
|
||||
)
|
||||
|
||||
for line in reconcilable_lines:
|
||||
# Get the counterpart lines from partial reconciliations
|
||||
partials = line.matched_debit_ids | line.matched_credit_ids
|
||||
for partial in partials:
|
||||
counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id
|
||||
|
||||
# If the counterpart is from a Bank/Cash journal, it's a "Payment"
|
||||
if counterpart.journal_id.type in ('bank', 'cash'):
|
||||
st_line = counterpart.move_id.statement_line_id
|
||||
payment = counterpart.payment_id
|
||||
|
||||
if st_line:
|
||||
if st_line.id not in seen_statement_line_ids:
|
||||
total_paid += abs(st_line.amount)
|
||||
seen_statement_line_ids.add(st_line.id)
|
||||
elif payment:
|
||||
if payment.id not in seen_payment_ids:
|
||||
total_paid += payment.amount
|
||||
seen_payment_ids.add(payment.id)
|
||||
else:
|
||||
# Fallback to the specific move line's balance (absolute)
|
||||
if counterpart.id not in seen_move_line_ids:
|
||||
total_paid += abs(counterpart.balance)
|
||||
seen_move_line_ids.add(counterpart.id)
|
||||
|
||||
# If no bank transactions found but report is clearly paid/partially paid,
|
||||
# fall back to standard calculation for non-bank flows (e.g. manual journal reconciliation)
|
||||
if not total_paid and sheet.total_amount != sheet.amount_residual:
|
||||
total_paid = sheet.total_amount - sheet.amount_residual
|
||||
|
||||
sheet.amount_paid = total_paid
|
||||
|
||||
@api.depends('expense_line_ids.realization_total_amount')
|
||||
def _compute_realization_total_amount(self):
|
||||
for sheet in self:
|
||||
sheet.realization_total_amount = sum(sheet.expense_line_ids.mapped('realization_total_amount'))
|
||||
|
||||
@api.depends('expense_line_ids.sequence_name')
|
||||
def _compute_expense_sequences(self):
|
||||
for sheet in self:
|
||||
sequences = sheet.expense_line_ids.mapped('sequence_name')
|
||||
# Filter out false values and joins them
|
||||
sheet.expense_sequences = ", ".join(filter(None, sequences))
|
||||
|
||||
@api.depends('expense_line_ids.receipt_received', 'expense_line_ids.payment_mode')
|
||||
def _compute_receipt_status(self):
|
||||
for sheet in self:
|
||||
|
||||
@ -25,9 +25,31 @@
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight" invisible="state != 'draft'"/>
|
||||
<button name="action_post" string="Post Journal" type="object" class="oe_highlight" groups="account.group_account_invoice" invisible="state != 'confirmed'"/>
|
||||
|
||||
<!-- Discrepancy Buttons -->
|
||||
<field name="show_vendor_payment_btn" invisible="1"/>
|
||||
<field name="show_customer_payment_btn" invisible="1"/>
|
||||
<button name="action_create_vendor_payment"
|
||||
string="Create Vendor Payment"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
groups="account.group_account_invoice"
|
||||
invisible="not show_vendor_payment_btn"/>
|
||||
<button name="action_create_customer_payment"
|
||||
string="Create Customer Payment"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
groups="account.group_account_invoice"
|
||||
invisible="not show_customer_payment_btn"/>
|
||||
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,posted"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_payments" type="object" class="oe_stat_button" icon="fa-money" invisible="payment_count == 0">
|
||||
<field name="payment_count" widget="statinfo" string="Payments"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
|
||||
@ -51,11 +51,18 @@
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//sheet" position="before">
|
||||
<field name="sequence_name" invisible="1"/>
|
||||
<div class="alert alert-danger mb-2" role="alert" invisible="not receipt_overdue">
|
||||
This receipt is <strong>Overdue</strong>! Please submit the original receipt as soon as possible.
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('oe_title')]" position="inside">
|
||||
<h1>
|
||||
<field name="sequence_name" readonly="1" invisible="not id"/>
|
||||
</h1>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='account_id']" position="after">
|
||||
<field name="realization_total_amount" invisible="payment_mode != 'company_account'"/>
|
||||
<field name="realization_count" invisible="1"/>
|
||||
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
|
||||
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
|
||||
@ -70,6 +77,12 @@
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="inherit_id" ref="hr_expense.view_expenses_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="before">
|
||||
<field name="sequence_name" optional="show"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='total_amount']" position="after">
|
||||
<field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='state']" position="after">
|
||||
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
|
||||
<field name="receipt_received" optional="show" widget="boolean_toggle"/>
|
||||
@ -131,6 +144,12 @@
|
||||
<attribute name="groups">account.group_account_invoice</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="before">
|
||||
<field name="sequence_name" column_invisible="not parent.id"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='total_amount']" position="after">
|
||||
<field name="realization_total_amount" optional="show" invisible="payment_mode != 'company_account'"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="after">
|
||||
<field name="payment_mode" column_invisible="True"/>
|
||||
<field name="realization_count" column_invisible="True"/>
|
||||
@ -161,6 +180,9 @@
|
||||
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='state']" position="before">
|
||||
<field name="expense_sequences" optional="show"/>
|
||||
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
|
||||
<field name="realization_total_amount" optional="show" sum="Total Realization"/>
|
||||
<field name="receipt_status" widget="badge"
|
||||
decoration-info="receipt_status == 'pending'"
|
||||
decoration-success="receipt_status == 'received'"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user