import logging from odoo import models, fields, api from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class LaundryPaymentWizard(models.TransientModel): """POS-like payment wizard for laundry orders (PART 2). Simplified flow — single screen: 1. Shows customer, order total, balance due 2. Staff selects journal (cash/bank) and enters amount 3. On confirm: - Creates invoice if none exists - Posts the invoice - Creates account.payment - Reconciles payment against invoice Permissions: - Any laundry user can register payment - Only managers can change the payment date (backdate) - Only managers can override the amount above order total """ _name = 'laundry.payment.wizard' _description = 'Laundry Quick Payment' # ── Order info (read-only header) ───────────────────────────────── order_id = fields.Many2one( 'sale.order', string='Order', required=True, readonly=True, domain=[('is_laundry_order', '=', True)], ) order_name = fields.Char( related='order_id.name', string='Order No.', readonly=True, ) partner_id = fields.Many2one( related='order_id.partner_id', string='Customer', readonly=True, ) mobile = fields.Char( related='order_id.mobile', string='Mobile', readonly=True, ) order_total = fields.Monetary( related='order_id.amount_total', string='Order Total', currency_field='currency_id', readonly=True, ) amount_due = fields.Monetary( related='order_id.amount_due', string='Balance Due', currency_field='currency_id', readonly=True, ) laundry_payment_status = fields.Selection( related='order_id.laundry_payment_status', readonly=True, ) laundry_state = fields.Selection( related='order_id.laundry_state', string='Processing Status', readonly=True, ) # ── Payment fields ──────────────────────────────────────────────── journal_id = fields.Many2one( 'account.journal', string='Journal', required=True, domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]", ) payment_method_line_id = fields.Many2one( 'account.payment.method.line', string='Payment Method', required=True, domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'inbound')]", ) amount = fields.Monetary( string='Amount', currency_field='currency_id', required=True, ) payment_date = fields.Date( string='Payment Date', required=True, default=fields.Date.today, ) memo = fields.Char( string='Reference / Memo', ) currency_id = fields.Many2one( related='order_id.currency_id', readonly=True, ) company_id = fields.Many2one( related='order_id.company_id', readonly=True, ) # ── Computed info ───────────────────────────────────────────────── change_amount = fields.Monetary( string='Change', compute='_compute_change', currency_field='currency_id', help='Amount to return to customer if overpaying (cash only).', ) is_overpayment = fields.Boolean(compute='_compute_change') @api.depends('amount', 'amount_due') def _compute_change(self): for wiz in self: if wiz.amount > 0 and wiz.amount_due > 0: wiz.change_amount = max(wiz.amount - wiz.amount_due, 0.0) wiz.is_overpayment = wiz.amount > wiz.amount_due else: wiz.change_amount = 0.0 wiz.is_overpayment = False # ── Defaults ────────────────────────────────────────────────────── @api.model def default_get(self, fields_list): res = super().default_get(fields_list) order_id = res.get('order_id') or self.env.context.get('default_order_id') if order_id: order = self.env['sale.order'].browse(order_id) # Default amount = outstanding balance (or total if no invoice yet) res['amount'] = order.amount_due if order.amount_due > 0 else order.amount_total res['memo'] = order.name # Default journal: 1) configured in Laundry Settings, 2) first cash journal ICP = self.env['ir.config_parameter'].sudo() configured_id = ICP.get_param('laundry_management.cash_journal_id', '') cash_journal = False if configured_id: try: j = self.env['account.journal'].browse(int(configured_id)) if j.exists() and j.type in ('cash', 'bank'): cash_journal = j except (ValueError, TypeError): pass if not cash_journal: cash_journal = self.env['account.journal'].search([ ('type', '=', 'cash'), ('company_id', '=', order.company_id.id), ], limit=1) if not cash_journal: cash_journal = self.env['account.journal'].search([ ('type', '=', 'bank'), ('company_id', '=', order.company_id.id), ], limit=1) if cash_journal: res['journal_id'] = cash_journal.id # Default payment method line: first inbound method on the journal method_line = self.env['account.payment.method.line'].search([ ('journal_id', '=', cash_journal.id), ('payment_type', '=', 'inbound'), ], limit=1) if method_line: res['payment_method_line_id'] = method_line.id return res @api.onchange('journal_id') def _onchange_journal_id(self): """Auto-select first inbound payment method line for the chosen journal.""" if self.journal_id: method_line = self.env['account.payment.method.line'].search([ ('journal_id', '=', self.journal_id.id), ('payment_type', '=', 'inbound'), ], limit=1) self.payment_method_line_id = method_line or False else: self.payment_method_line_id = False @api.onchange('order_id') def _onchange_order_id(self): if self.order_id: self.amount = ( self.order_id.amount_due if self.order_id.amount_due > 0 else self.order_id.amount_total ) self.memo = self.order_id.name # ── Confirm payment ─────────────────────────────────────────────── def action_confirm_payment(self): """Full accounting flow: invoice → post → payment → reconcile.""" self.ensure_one() order = self.order_id if order.state not in ('sale', 'done'): raise UserError( 'The order must be confirmed before registering payment.\n' 'Please save the order first.' ) if self.amount <= 0: raise UserError('Payment amount must be greater than zero.') # 1. Get or create invoice ──────────────────────────────────── invoices = order.invoice_ids.filtered( lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel' ) if not invoices: _logger.info('laundry.payment.wizard: creating invoice for order %s', order.name) invoices = order._create_invoices() if not invoices: raise UserError( 'Failed to create an invoice. ' 'Ensure the order has service lines configured with valid products.' ) # 2. Post any draft invoices ─────────────────────────────────── draft_invoices = invoices.filtered(lambda inv: inv.state == 'draft') if draft_invoices: draft_invoices.action_post() # 3. Get posted invoices with open balance ───────────────────── to_pay = invoices.filtered( lambda inv: inv.state == 'posted' and inv.payment_state not in ('paid', 'in_payment') ) if not to_pay: raise UserError( 'This order is already fully paid.\n\n' 'Payment Status: ' + (order.laundry_payment_status or 'unknown') ) # 4. Actual payment amount (cap at total due, not overpay into accounting) # Change is returned physically by cashier; we only record the due amount pay_amount = min(self.amount, sum(to_pay.mapped('amount_residual'))) if pay_amount <= 0: raise UserError('No outstanding balance to pay on the posted invoice.') # 5. Create account.payment ──────────────────────────────────── payment_vals = { 'amount': pay_amount, 'journal_id': self.journal_id.id, 'payment_method_line_id': self.payment_method_line_id.id, 'payment_type': 'inbound', 'partner_type': 'customer', 'partner_id': order.partner_id.id, 'date': self.payment_date, 'ref': self.memo or order.name, 'currency_id': order.currency_id.id, } payment = self.env['account.payment'].create(payment_vals) payment.action_post() # 6. Reconcile payment against invoice lines ─────────────────── # NOTE: account.account uses company_ids (M2M) in Odoo 17+, not company_id. # Filter directly on move lines by account_type — no separate account search. payment_lines = payment.line_ids.filtered( lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled ) invoice_lines = to_pay.line_ids.filtered( lambda l: l.account_id.account_type == 'asset_receivable' and not l.reconciled ) if payment_lines and invoice_lines: (payment_lines + invoice_lines).reconcile() # 7. Log on order chatter ────────────────────────────────────── journal_name = self.journal_id.name change_note = '' if self.is_overpayment: change_note = f' | Change returned: {order.currency_id.symbol} {self.change_amount:.2f}' order.message_post( body=( f'Payment registered: {order.currency_id.symbol} {pay_amount:.2f} ' f'via {journal_name} on {self.payment_date}.{change_note}' ), ) return {'type': 'ir.actions.act_window_close'} def action_view_invoice(self): """Open the related invoice to view/edit if needed.""" self.ensure_one() invoices = self.order_id.invoice_ids.filtered( lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel' ) if not invoices: raise UserError('No invoice exists for this order yet.') return { 'type': 'ir.actions.act_window', 'name': 'Invoice', 'res_model': 'account.move', 'res_id': invoices[0].id if len(invoices) == 1 else False, 'view_mode': 'form' if len(invoices) == 1 else 'list,form', 'domain': [('id', 'in', invoices.ids)] if len(invoices) > 1 else [], 'target': 'current', }