feat: hide authorizer for rental orders, auto-set sale type

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
This commit is contained in:
gsinghpal
2026-02-25 23:33:23 -05:00
parent 3c8f83b8e6
commit 14fe9ab716
51 changed files with 4192 additions and 822 deletions

View File

@@ -1,10 +1,14 @@
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class DepositDeductionWizard(models.TransientModel):
class DepositProcessWizard(models.TransientModel):
_name = 'deposit.deduction.wizard'
_description = 'Security Deposit Deduction'
_description = 'Process Security Deposit'
order_id = fields.Many2one(
'sale.order',
@@ -12,53 +16,406 @@ class DepositDeductionWizard(models.TransientModel):
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",
required=True,
help="Amount to deduct from the security deposit for damages.",
)
reason = fields.Text(
string="Reason for Deduction",
required=True,
string="Reason",
)
remaining_preview = fields.Float(
string="Remaining to Refund",
compute='_compute_remaining_preview',
compute='_compute_previews',
)
overage_preview = fields.Float(
string="Additional Invoice Amount",
compute='_compute_remaining_preview',
help="Amount exceeding the deposit that will be invoiced to the customer.",
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')
def _compute_remaining_preview(self):
@api.depends('deposit_total', 'deduction_amount', 'action_type')
def _compute_previews(self):
for wizard in self:
diff = wizard.deposit_total - wizard.deduction_amount
if diff >= 0:
wizard.remaining_preview = diff
if wizard.action_type == 'full_refund':
wizard.refund_preview = wizard.deposit_total
wizard.remaining_preview = wizard.deposit_total
wizard.overage_preview = 0.0
else:
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.overage_preview = abs(diff)
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
def action_confirm_deduction(self):
@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 = self.order_id
order._deduct_security_deposit(self.deduction_amount)
order.message_post(body=_(
"Security deposit deduction of %s processed.\nReason: %s",
self.env['ir.qweb.field.monetary'].value_to_html(
self.deduction_amount,
{'display_currency': order.currency_id},
),
"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},
)