Rental orders no longer show the "Authorizer Required?" question or the Authorizer field. The sale type is automatically set to 'Rentals' when creating or confirming a rental order. Validation logic also skips authorizer checks for rental sale type. Made-with: Cursor
422 lines
15 KiB
Python
422 lines
15 KiB
Python
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},
|
|
)
|