import logging from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class DepositProcessWizard(models.TransientModel): _name = 'deposit.deduction.wizard' _description = 'Process Security Deposit' order_id = fields.Many2one( 'sale.order', string="Rental Order", required=True, readonly=True, ) partner_id = fields.Many2one( related='order_id.partner_id', string="Customer", ) deposit_total = fields.Float( string="Deposit Amount", readonly=True, ) action_type = fields.Selection( selection=[ ('full_refund', "Full Refund"), ('partial_refund', "Partial Refund (Deduction for Damages)"), ('no_refund', "Full Deduction (Damages Exceed Deposit)"), ('sold', "Customer Purchased Rental Product"), ], string="Action", required=True, default='full_refund', ) deduction_amount = fields.Float( string="Deduction Amount", help="Amount to deduct from the security deposit for damages.", ) reason = fields.Text( string="Reason", ) remaining_preview = fields.Float( string="Remaining to Refund", compute='_compute_previews', ) overage_preview = fields.Float( string="Additional Invoice Amount", compute='_compute_previews', ) refund_preview = fields.Float( string="Refund Amount", compute='_compute_previews', ) has_card_on_file = fields.Boolean( string="Card on File", compute='_compute_has_card', ) @api.depends('deposit_total', 'deduction_amount', 'action_type') def _compute_previews(self): for wizard in self: if wizard.action_type == 'full_refund': wizard.refund_preview = wizard.deposit_total wizard.remaining_preview = wizard.deposit_total wizard.overage_preview = 0.0 elif wizard.action_type == 'sold': wizard.refund_preview = wizard.deposit_total wizard.remaining_preview = wizard.deposit_total wizard.overage_preview = 0.0 elif wizard.action_type == 'partial_refund': diff = wizard.deposit_total - wizard.deduction_amount if diff >= 0: wizard.remaining_preview = diff wizard.refund_preview = diff wizard.overage_preview = 0.0 else: wizard.remaining_preview = 0.0 wizard.refund_preview = 0.0 wizard.overage_preview = abs(diff) elif wizard.action_type == 'no_refund': wizard.remaining_preview = 0.0 wizard.refund_preview = 0.0 wizard.overage_preview = max( wizard.deduction_amount - wizard.deposit_total, 0.0, ) else: wizard.refund_preview = 0.0 wizard.remaining_preview = 0.0 wizard.overage_preview = 0.0 @api.depends('order_id') def _compute_has_card(self): for wizard in self: wizard.has_card_on_file = bool( wizard.order_id and wizard.order_id.rental_payment_token_id ) def action_confirm(self): """Dispatch to the appropriate deposit processing path.""" self.ensure_one() order = self.order_id if self.action_type == 'full_refund': return self._process_full_refund(order) elif self.action_type == 'partial_refund': return self._process_partial_refund(order) elif self.action_type == 'no_refund': return self._process_no_refund(order) elif self.action_type == 'sold': return self._process_sold(order) return {'type': 'ir.actions.act_window_close'} def _process_full_refund(self, order): """Full deposit refund: credit note, Poynt refund, close rental.""" invoice = order.rental_deposit_invoice_id if not invoice: raise UserError(_("No deposit invoice found.")) credit_note = self._create_deposit_credit_note( order, invoice, invoice.amount_total, _("Security deposit full refund for %s", order.name), ) if credit_note: self._process_poynt_refund(order, credit_note) order.rental_deposit_status = 'refunded' order._send_deposit_refund_email() order.message_post(body=_( "Security deposit fully refunded: %s", self._format_amount(invoice.amount_total, order), )) self._close_rental(order) return {'type': 'ir.actions.act_window_close'} def _process_partial_refund(self, order): """Partial refund with deduction for damages.""" if self.deduction_amount <= 0: raise UserError(_("Deduction amount must be greater than zero.")) if not self.reason: raise UserError(_("A reason is required for deductions.")) invoice = order.rental_deposit_invoice_id if not invoice: raise UserError(_("No deposit invoice found.")) deposit_total = invoice.amount_total if self.deduction_amount >= deposit_total: return self._process_no_refund(order) refund_amount = deposit_total - self.deduction_amount credit_note = self._create_deposit_credit_note( order, invoice, refund_amount, _("Partial deposit refund for %s (deduction: %s)", order.name, self._format_amount(self.deduction_amount, order)), ) if credit_note: self._process_poynt_refund(order, credit_note) order.rental_deposit_status = 'deducted' order._send_deposit_refund_email() order.message_post(body=_( "Security deposit partial refund: %s refunded, %s deducted.\nReason: %s", self._format_amount(refund_amount, order), self._format_amount(self.deduction_amount, order), self.reason, )) self._close_rental(order) return {'type': 'ir.actions.act_window_close'} def _process_no_refund(self, order): """Full deduction: no refund, create overage invoice if needed.""" if not self.reason: raise UserError(_("A reason is required for deductions.")) invoice = order.rental_deposit_invoice_id deposit_total = invoice.amount_total if invoice else 0.0 overage = self.deduction_amount - deposit_total if self.deduction_amount > deposit_total else 0.0 order.rental_deposit_status = 'deducted' if overage > 0: damage_inv = order._create_damage_invoice(overage) if damage_inv and order.rental_payment_token_id: ok = order._collect_token_payment_for_invoice(damage_inv) if ok: order._send_invoice_with_receipt(damage_inv, 'damage') else: order._notify_staff_manual_payment(damage_inv) elif damage_inv: order._send_invoice_with_receipt(damage_inv, 'damage') order.message_post(body=_( "Security deposit fully deducted. Deduction: %s.%s\nReason: %s", self._format_amount(self.deduction_amount, order), _(" Overage invoice: %s", self._format_amount(overage, order)) if overage > 0 else '', self.reason, )) self._close_rental(order) return {'type': 'ir.actions.act_window_close'} def _process_sold(self, order): """Customer purchased the rental: full deposit refund, mark as sold.""" invoice = order.rental_deposit_invoice_id if invoice: credit_note = self._create_deposit_credit_note( order, invoice, invoice.amount_total, _("Deposit refund - customer purchased rental product %s", order.name), ) if credit_note: self._process_poynt_refund(order, credit_note) order.rental_deposit_status = 'refunded' order.rental_purchase_interest = True order._send_deposit_refund_email() order.message_post(body=_( "Customer purchased rental product. Security deposit fully refunded." )) order.activity_schedule( 'mail.mail_activity_data_todo', date_deadline=fields.Date.today(), summary=_("Rental product sold - %s", order.name), note=_( "Customer %s purchased the rental product from %s. " "Process the sale order and schedule delivery/pickup.", order.partner_id.name, order.name, ), user_id=order.user_id.id or self.env.uid, ) self._close_rental(order) return {'type': 'ir.actions.act_window_close'} def _create_deposit_credit_note(self, order, invoice, amount, ref): """Create and post a credit note for the deposit invoice.""" if not invoice or invoice.payment_state not in ('paid', 'in_payment'): _logger.warning( "Cannot create credit note: invoice %s state=%s payment=%s", invoice.name if invoice else 'None', invoice.state if invoice else 'N/A', invoice.payment_state if invoice else 'N/A', ) return None credit_notes = invoice._reverse_moves( default_values_list=[{'ref': ref}], ) if not credit_notes: return None credit_note = credit_notes[:1] if amount != invoice.amount_total: for line in credit_note.invoice_line_ids: line.price_unit = amount / max(line.quantity, 1) credit_note.action_post() return credit_note def _process_poynt_refund(self, order, credit_note): """Process refund via Poynt referenced refund for the credit note.""" if not credit_note: return try: orig_tx = credit_note._get_original_poynt_transaction() except Exception: orig_tx = None if not orig_tx: _logger.warning( "No original Poynt transaction for deposit refund on %s. " "Credit note created but refund must be processed manually.", order.name, ) order._notify_staff_manual_payment(credit_note) return provider = orig_tx.provider_id.sudo() from odoo.addons.fusion_poynt import utils as poynt_utils parent_txn_id = orig_tx.poynt_transaction_id try: txn_data = provider._poynt_make_request( 'GET', f'transactions/{parent_txn_id}', ) for link in txn_data.get('links', []): if link.get('rel') == 'CAPTURE' and link.get('href'): parent_txn_id = link['href'] break except Exception: pass minor_amount = poynt_utils.format_poynt_amount( abs(credit_note.amount_total), credit_note.currency_id, ) refund_payload = { 'action': 'REFUND', 'parentId': parent_txn_id, 'fundingSource': {'type': 'CREDIT_DEBIT'}, 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, 'currency': credit_note.currency_id.name, }, 'context': { 'source': 'WEB', 'sourceApp': 'odoo.fusion_rental', }, 'notes': f'Deposit refund for {order.name} via {credit_note.name}', } try: result = provider._poynt_make_request( 'POST', 'transactions', payload=refund_payload, ) except Exception as e: _logger.error( "Poynt deposit refund failed for %s: %s", order.name, e, ) order._notify_staff_manual_payment(credit_note) return refund_status = result.get('status', '') refund_txn_id = result.get('id', '') if refund_status in ('DECLINED', 'FAILED'): _logger.warning( "Poynt deposit refund declined for %s: %s", order.name, refund_status, ) order._notify_staff_manual_payment(credit_note) return PaymentMethod = self.env['payment.method'].sudo().with_context( active_test=False, ) payment_method = PaymentMethod.search( [('code', '=', 'card')], limit=1, ) or PaymentMethod.search( [('code', 'in', ('visa', 'mastercard'))], limit=1, ) refund_tx = self.env['payment.transaction'].sudo().create({ 'provider_id': provider.id, 'payment_method_id': payment_method.id if payment_method else False, 'amount': -abs(credit_note.amount_total), 'currency_id': credit_note.currency_id.id, 'partner_id': order.partner_id.id, 'operation': 'refund', 'source_transaction_id': orig_tx.id, 'provider_reference': refund_txn_id or '', 'poynt_transaction_id': refund_txn_id or '', 'invoice_ids': [(4, credit_note.id)], }) if refund_txn_id and refund_status not in ('PENDING',): payment_data = { 'reference': refund_tx.reference, 'poynt_transaction_id': refund_txn_id, 'poynt_status': refund_status, } refund_tx._process('poynt', payment_data) if ( credit_note.payment_state not in ('paid', 'in_payment') and refund_tx.payment_id ): try: (refund_tx.payment_id.move_id.line_ids + credit_note.line_ids).filtered( lambda line: line.account_id == refund_tx.payment_id.destination_account_id and not line.reconciled ).reconcile() except Exception as e: _logger.warning( "Fallback reconciliation failed for %s: %s", order.name, e, ) credit_note.sudo().poynt_refunded = True credit_note.sudo().message_post(body=_( "Deposit refund processed via Poynt. Amount: %s. " "Transaction ID: %s.", self._format_amount(abs(credit_note.amount_total), order), refund_txn_id or 'N/A', )) def _close_rental(self, order): """Close the rental after deposit processing.""" if not order.rental_closed: try: order.action_close_rental() except Exception as e: _logger.error( "Auto-close after deposit processing failed for %s: %s", order.name, e, ) def _format_amount(self, amount, order): return self.env['ir.qweb.field.monetary'].value_to_html( amount, {'display_currency': order.currency_id}, )