feat: implement expense realization payment tracking and automated balancing journal entries

This commit is contained in:
Suherdy Yacob 2026-04-02 11:12:40 +07:00
parent 82b36c3ef5
commit 83ea721aa8
9 changed files with 333 additions and 35 deletions

View File

@ -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. - **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. - **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 ## 🛠 Configuration
1. **GL Accounts**: 1. **GL Accounts**:

View File

@ -8,5 +8,12 @@
<field name="padding">5</field> <field name="padding">5</field>
<field name="company_id" eval="False"/> <field name="company_id" eval="False"/>
</record> </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> </data>
</odoo> </odoo>

View File

@ -3,5 +3,6 @@ from . import hr_expense
from . import hr_expense_sheet from . import hr_expense_sheet
from . import account_move_line from . import account_move_line
from . import hr_expense_realization from . import hr_expense_realization
from . import account_payment
from . import res_company from . import res_company
from . import res_config_settings from . import res_config_settings

View 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)

View File

@ -21,6 +21,15 @@ class HrExpense(models.Model):
if product.property_account_expense_company_id: if product.property_account_expense_company_id:
expense.account_id = 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( receipt_due_date = fields.Date(
string="Receipt Due Date", string="Receipt Due Date",
readonly=True, readonly=True,
@ -47,6 +56,18 @@ class HrExpense(models.Model):
for expense in self: for expense in self:
expense.realization_count = len(expense.realization_ids) 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): def action_create_realization(self):
self.ensure_one() self.ensure_one()
if self.payment_mode != 'company_account': if self.payment_mode != 'company_account':

View File

@ -39,6 +39,41 @@ class HrExpenseRealization(models.Model):
for rec in self: for rec in self:
rec.total_amount = sum(rec.line_ids.mapped('amount')) 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 @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:
@ -73,53 +108,151 @@ class HrExpenseRealization(models.Model):
if not self.journal_id: if not self.journal_id:
raise UserError(_("Please specify the Journal before posting.")) 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) 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 advance_account = product.property_account_expense_company_id or product.property_account_expense_id
if not expense_account: if not advance_account:
expense_account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category') 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: # Partner specific accounts (114101 and 216109 fallback)
raise UserError(_("No expense account found for the product or its category.")) 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: for line in self.line_ids:
if not line.counterpart_account_id: if not line.counterpart_account_id:
raise UserError(_("Please specify a Counterpart Account for the receipt: %s") % line.description) raise UserError(_("Please specify a Counterpart Account for the receipt: %s") % line.description)
move_vals = { move_lines.append(Command.create({
'journal_id': self.journal_id.id, 'name': f"Realization Expense: {line.description}",
'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, 'account_id': line.counterpart_account_id.id,
'debit': line.amount, 'debit': line.amount,
'credit': 0.0, 'credit': 0.0,
'partner_id': self.employee_id.sudo().work_contact_id.id, '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 = self.env['account.move'].create(move_vals)
move.action_post() move.action_post()
# Link the move to lines and record
for line in self.line_ids:
line.move_id = move.id line.move_id = move.id
moves |= move
self.write({ self.write({
'state': 'posted', '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 @api.model
def get_pending_realizations(self, employee_id): def get_pending_realizations(self, employee_id):
""" Returns expenses for the given employee that are reported/done but NOT yet realized. """ """ Returns expenses for the given employee that are reported/done but NOT yet realized. """

View File

@ -26,6 +26,86 @@ class HrExpenseSheet(models.Model):
('none', 'No Receipt Required') ('none', 'No Receipt Required')
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True) ], 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') @api.depends('expense_line_ids.receipt_received', 'expense_line_ids.payment_mode')
def _compute_receipt_status(self): def _compute_receipt_status(self):
for sheet in self: for sheet in self:

View File

@ -25,9 +25,31 @@
<header> <header>
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight" invisible="state != 'draft'"/> <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'"/> <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"/> <field name="state" widget="statusbar" statusbar_visible="draft,confirmed,posted"/>
</header> </header>
<sheet> <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"> <div class="oe_title">
<h1> <h1>
<field name="name" readonly="1"/> <field name="name" readonly="1"/>

View File

@ -51,11 +51,18 @@
</div> </div>
</xpath> </xpath>
<xpath expr="//sheet" position="before"> <xpath expr="//sheet" position="before">
<field name="sequence_name" invisible="1"/>
<div class="alert alert-danger mb-2" role="alert" invisible="not receipt_overdue"> <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. This receipt is <strong>Overdue</strong>! Please submit the original receipt as soon as possible.
</div> </div>
</xpath> </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"> <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="realization_count" invisible="1"/>
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/> <field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
<field name="receipt_received" widget="boolean_toggle" 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="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.view_expenses_tree"/> <field name="inherit_id" ref="hr_expense.view_expenses_tree"/>
<field name="arch" type="xml"> <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"> <xpath expr="//field[@name='state']" position="after">
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/> <field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
<field name="receipt_received" optional="show" widget="boolean_toggle"/> <field name="receipt_received" optional="show" widget="boolean_toggle"/>
@ -131,6 +144,12 @@
<attribute name="groups">account.group_account_invoice</attribute> <attribute name="groups">account.group_account_invoice</attribute>
</xpath> </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"> <xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="after">
<field name="payment_mode" column_invisible="True"/> <field name="payment_mode" column_invisible="True"/>
<field name="realization_count" 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="inherit_id" ref="hr_expense.view_hr_expense_sheet_tree"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="before"> <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" <field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'" decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'" decoration-success="receipt_status == 'received'"