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:
@@ -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},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user