diff --git a/README.md b/README.md index 6c4af17..87ebf93 100644 --- a/README.md +++ b/README.md @@ -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**: diff --git a/data/ir_sequence_data.xml b/data/ir_sequence_data.xml index 8e61be2..fe9bdbb 100644 --- a/data/ir_sequence_data.xml +++ b/data/ir_sequence_data.xml @@ -8,5 +8,12 @@ 5 + + Expense + hr.expense.sequence + EXP/%(year)s/%(month)s/ + 5 + + diff --git a/models/__init__.py b/models/__init__.py index c2dfdea..b9bb15e 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 diff --git a/models/account_payment.py b/models/account_payment.py new file mode 100644 index 0000000..f0828af --- /dev/null +++ b/models/account_payment.py @@ -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) diff --git a/models/hr_expense.py b/models/hr_expense.py index 700e625..6c168cf 100644 --- a/models/hr_expense.py +++ b/models/hr_expense.py @@ -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': diff --git a/models/hr_expense_realization.py b/models/hr_expense_realization.py index e540881..17da95f 100644 --- a/models/hr_expense_realization.py +++ b/models/hr_expense_realization.py @@ -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() - line.move_id = move.id - moves |= move + 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 + 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. """ diff --git a/models/hr_expense_sheet.py b/models/hr_expense_sheet.py index 856ec2f..b2f60ad 100644 --- a/models/hr_expense_sheet.py +++ b/models/hr_expense_sheet.py @@ -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: diff --git a/views/hr_expense_realization_views.xml b/views/hr_expense_realization_views.xml index d39d25d..8c74fc1 100644 --- a/views/hr_expense_realization_views.xml +++ b/views/hr_expense_realization_views.xml @@ -25,9 +25,31 @@
+
+ +

diff --git a/views/hr_expense_views.xml b/views/hr_expense_views.xml index 5b57046..8f8e512 100644 --- a/views/hr_expense_views.xml +++ b/views/hr_expense_views.xml @@ -51,11 +51,18 @@

+ + +

+ +

+
+ @@ -70,6 +77,12 @@ hr.expense + + + + + + @@ -131,6 +144,12 @@ account.group_account_invoice + + + + + + @@ -161,6 +180,9 @@ + + +