Files
Odoo-Modules/fusion_rental/models/sale_order.py
gsinghpal e71bc503f9 changes
2026-02-25 09:40:41 -05:00

1041 lines
39 KiB
Python

import logging
import uuid
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
# -----------------------------------------------------------------
# Renewal fields (v1)
# -----------------------------------------------------------------
rental_auto_renew = fields.Boolean(
string="Auto-Renew",
default=False,
tracking=True,
help="Automatically renew this rental when the return date is reached.",
)
rental_renewal_count = fields.Integer(
string="Renewal Count",
default=0,
readonly=True,
copy=False,
)
rental_max_renewals = fields.Integer(
string="Max Renewals",
default=0,
help="Maximum number of automatic renewals. 0 = unlimited.",
)
rental_next_renewal_date = fields.Date(
string="Next Renewal Date",
compute='_compute_rental_next_renewal_date',
store=True,
)
rental_reminder_sent = fields.Boolean(
string="Reminder Sent",
default=False,
copy=False,
)
rental_payment_token_id = fields.Many2one(
'payment.token',
string="Payment Card on File",
domain="[('partner_id', '=', partner_id)]",
copy=False,
help="Stored card used for automatic renewal payment collection.",
)
rental_original_duration = fields.Integer(
string="Original Duration (Days)",
compute='_compute_rental_original_duration',
store=True,
help="Original rental duration in days, used for renewal period calculation.",
)
rental_renewal_log_ids = fields.One2many(
'rental.renewal.log',
'order_id',
string="Renewal History",
)
rental_cancellation_request_ids = fields.One2many(
'rental.cancellation.request',
'order_id',
string="Cancellation Requests",
)
# -----------------------------------------------------------------
# Agreement fields (v2)
# -----------------------------------------------------------------
rental_agreement_signed = fields.Boolean(
string="Agreement Signed",
default=False,
copy=False,
tracking=True,
)
rental_agreement_signature = fields.Binary(
string="Signature",
copy=False,
attachment=True,
)
rental_agreement_signer_name = fields.Char(
string="Signer Name",
copy=False,
)
rental_agreement_signed_date = fields.Datetime(
string="Signed Date",
copy=False,
)
rental_agreement_token = fields.Char(
string="Agreement Token",
copy=False,
index=True,
)
# -----------------------------------------------------------------
# Security deposit fields (v2)
# -----------------------------------------------------------------
rental_charges_invoice_id = fields.Many2one(
'account.move',
string="Rental Charges Invoice",
copy=False,
ondelete='set null',
help="Invoice for rental charges, delivery, and other services (excludes deposit).",
)
rental_deposit_invoice_id = fields.Many2one(
'account.move',
string="Deposit Invoice",
copy=False,
ondelete='set null',
)
rental_deposit_status = fields.Selection(
[
('pending', 'Pending'),
('collected', 'Collected'),
('refund_hold', 'Refund Hold'),
('refunded', 'Refunded'),
('deducted', 'Deducted'),
],
string="Deposit Status",
copy=False,
tracking=True,
)
rental_deposit_refund_date = fields.Date(
string="Deposit Refund Date",
copy=False,
help="Date when the security deposit refund will be processed.",
)
# -----------------------------------------------------------------
# Inspection fields (v2)
# -----------------------------------------------------------------
rental_inspection_status = fields.Selection(
[
('pending', 'Pending'),
('passed', 'Passed'),
('flagged', 'Flagged for Review'),
],
string="Inspection Status",
copy=False,
)
rental_inspection_notes = fields.Text(
string="Inspection Notes",
copy=False,
)
rental_inspection_photo_ids = fields.Many2many(
'ir.attachment',
'rental_inspection_photo_rel',
'order_id',
'attachment_id',
string="Inspection Photos",
copy=False,
)
# -----------------------------------------------------------------
# Marketing / purchase conversion fields (v2)
# -----------------------------------------------------------------
rental_marketing_email_sent = fields.Boolean(
string="Marketing Email Sent",
default=False,
copy=False,
)
rental_purchase_interest = fields.Boolean(
string="Purchase Interest",
default=False,
copy=False,
tracking=True,
help="Customer expressed interest in purchasing the rental product.",
)
rental_purchase_coupon_id = fields.Many2one(
'loyalty.card',
string="Purchase Coupon",
copy=False,
ondelete='set null',
)
# -----------------------------------------------------------------
# Close fields (v2)
# -----------------------------------------------------------------
rental_closed = fields.Boolean(
string="Transaction Closed",
default=False,
copy=False,
tracking=True,
)
@api.depends('rental_return_date', 'rental_auto_renew')
def _compute_rental_next_renewal_date(self):
for order in self:
if order.is_rental_order and order.rental_auto_renew and order.rental_return_date:
order.rental_next_renewal_date = order.rental_return_date.date()
else:
order.rental_next_renewal_date = False
@api.depends('rental_start_date', 'rental_return_date')
def _compute_rental_original_duration(self):
for order in self:
if order.rental_start_date and order.rental_return_date:
delta = order.rental_return_date - order.rental_start_date
order.rental_original_duration = max(delta.days, 1)
else:
order.rental_original_duration = 0
def _has_pending_cancellation(self):
"""Check if this order has an unresolved cancellation request."""
self.ensure_one()
return self.rental_cancellation_request_ids.filtered(
lambda r: r.state in ('new', 'confirmed')
)
def _get_rental_duration_days(self):
"""Return the rental duration to use for renewal.
Uses the stored original duration so renewals keep the same
period length even if dates were manually adjusted.
"""
self.ensure_one()
if self.rental_original_duration:
return self.rental_original_duration
if self.rental_start_date and self.rental_return_date:
return max((self.rental_return_date - self.rental_start_date).days, 1)
return 30
def _get_rental_only_lines(self):
"""Return order lines that should be invoiced on renewal.
Excludes security deposits, delivery/installation, and any other
one-time charges. Only lines flagged as rental by Odoo core
(is_rental=True) are included.
"""
self.ensure_one()
return self.order_line.filtered(
lambda l: l.is_rental and not l.is_security_deposit
)
def _get_renewal_amount(self):
"""Compute the total renewal charge (rental lines only, tax-inclusive)."""
self.ensure_one()
rental_lines = self._get_rental_only_lines()
return sum(line.price_total for line in rental_lines)
def _create_renewal_invoice(self):
"""Create an invoice containing only the recurring rental lines."""
self.ensure_one()
rental_lines = self._get_rental_only_lines()
if not rental_lines:
return self.env['account.move']
invoice_vals = self._prepare_invoice()
invoice_line_vals = []
for line in rental_lines:
inv_line = line._prepare_invoice_line()
inv_line['quantity'] = line.product_uom_qty
invoice_line_vals.append((0, 0, inv_line))
invoice_vals['invoice_line_ids'] = invoice_line_vals
return self.env['account.move'].sudo().create(invoice_vals)
def _prepare_renewal_cancellation_token(self):
"""Create a cancellation request with a unique token for the reminder email."""
self.ensure_one()
token = uuid.uuid4().hex
self.env['rental.cancellation.request'].create({
'order_id': self.id,
'token': token,
'state': 'new',
})
return token
def _process_auto_renewal(self):
"""Execute auto-renewal for a single rental order.
Extends the rental period, creates an invoice, attempts payment,
and logs the renewal event.
"""
self.ensure_one()
duration = self._get_rental_duration_days()
old_start = self.rental_start_date
old_return = self.rental_return_date
new_start = old_return
new_return = new_start + timedelta(days=duration)
self.write({
'rental_start_date': new_start,
'rental_return_date': new_return,
})
self._recompute_rental_prices()
invoice = self._create_renewal_invoice()
if invoice:
invoice.action_post()
renewal_log = self.env['rental.renewal.log'].create({
'order_id': self.id,
'renewal_number': self.rental_renewal_count + 1,
'previous_start_date': old_start,
'previous_return_date': old_return,
'new_start_date': new_start,
'new_return_date': new_return,
'invoice_id': invoice.id if invoice else False,
'renewal_type': 'automatic',
'state': 'draft',
})
payment_ok = False
if invoice and self.rental_payment_token_id:
payment_ok = self._collect_renewal_payment(invoice, renewal_log)
if invoice and not self.rental_payment_token_id:
self._notify_staff_manual_payment(invoice)
renewal_log.write({'payment_status': 'pending'})
self.write({
'rental_renewal_count': self.rental_renewal_count + 1,
'rental_reminder_sent': False,
})
renewal_log.write({'state': 'done'})
self._send_renewal_confirmation_email(renewal_log, payment_ok)
_logger.info(
"Auto-renewed rental %s (renewal #%d), new period %s to %s",
self.name, self.rental_renewal_count,
new_start, new_return,
)
def _collect_renewal_payment(self, invoice, renewal_log):
"""Attempt to charge the stored payment token for a renewal invoice.
:param invoice: The posted account.move to collect payment for.
:param renewal_log: The rental.renewal.log record to update.
:return: True if payment succeeded, False otherwise.
"""
self.ensure_one()
try:
provider = self.rental_payment_token_id.provider_id
payment_method = self.env['payment.method'].search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = self.env['payment.method'].search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
if not payment_method:
_logger.error("No card payment method configured for rental auto-payment.")
renewal_log.write({
'payment_status': 'failed',
'notes': "No card payment method configured.",
})
return False
tx = self.env['payment.transaction'].sudo().create({
'provider_id': provider.id,
'payment_method_id': payment_method.id,
'amount': invoice.amount_residual,
'currency_id': invoice.currency_id.id,
'partner_id': self.partner_id.id,
'operation': 'offline',
'token_id': self.rental_payment_token_id.id,
'invoice_ids': [(4, invoice.id)],
})
tx._poynt_process_token_payment()
if tx.state == 'done':
renewal_log.write({
'payment_status': 'paid',
'payment_transaction_id': tx.id,
})
self._send_payment_receipt_email(invoice, tx)
return True
renewal_log.write({
'payment_status': 'failed',
'payment_transaction_id': tx.id,
'notes': f"Payment transaction state: {tx.state}",
})
self._notify_staff_manual_payment(invoice)
return False
except (UserError, ValidationError) as e:
_logger.error("Auto-payment failed for rental %s: %s", self.name, e)
renewal_log.write({
'payment_status': 'failed',
'notes': str(e),
})
self._notify_staff_manual_payment(invoice)
return False
def _notify_staff_manual_payment(self, invoice):
"""Create an activity for sales staff when auto-payment is unavailable."""
self.ensure_one()
if not self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False):
return
self.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today(),
summary=_("Collect rental payment for %s", self.name),
note=_(
"Automatic payment could not be processed for invoice %s. "
"Please collect payment manually.",
invoice.name,
),
user_id=self.user_id.id or self.env.uid,
)
def _send_renewal_confirmation_email(self, renewal_log, payment_ok):
"""Send renewal confirmation email to the customer."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_renewed',
raise_if_not_found=False,
)
if template:
template.with_context(
payment_ok=payment_ok,
renewal_log=renewal_log,
).send_mail(self.id, force_send=True)
def _send_payment_receipt_email(self, invoice, transaction):
"""Send payment receipt email after successful collection."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_payment_receipt',
raise_if_not_found=False,
)
if template:
template.with_context(
invoice=invoice,
transaction=transaction,
).send_mail(self.id, force_send=True)
def _send_renewal_reminder_email(self, cancel_token):
"""Send the 3-day renewal reminder email with cancellation link."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_reminder',
raise_if_not_found=False,
)
if template:
template.with_context(
cancel_token=cancel_token,
).send_mail(self.id, force_send=True)
def _send_renewal_reminder_sms(self, cancel_token):
"""Send SMS renewal reminder via RingCentral."""
self.ensure_one()
partner = self.partner_id
phone = partner.mobile or partner.phone
if not phone:
_logger.warning(
"No phone number for partner %s, skipping SMS reminder for %s.",
partner.name, self.name,
)
return
rc_config = self.env['rc.config']._get_active_config()
if not rc_config:
_logger.warning("RingCentral not connected, skipping SMS reminder.")
return
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
cancel_url = f"{base_url}/rental/cancel/{cancel_token}"
company_phone = self.env.company.phone or ''
message = _(
"Your rental %(ref)s renews on %(date)s. "
"To cancel, visit %(url)s or call %(phone)s.",
ref=self.name,
date=self.rental_return_date.strftime('%m/%d/%Y') if self.rental_return_date else '',
url=cancel_url,
phone=company_phone,
)
try:
rc_config._send_sms(phone, message)
except Exception as e:
_logger.error("Failed to send SMS reminder for %s: %s", self.name, e)
@api.model
def _cron_rental_renewal_reminders(self):
"""Cron: send reminders 3 days before rental expiry."""
reminder_date = fields.Date.today() + timedelta(days=3)
orders = self.search([
('is_rental_order', '=', True),
('rental_auto_renew', '=', True),
('rental_status', 'in', ('pickup', 'return')),
('rental_next_renewal_date', '<=', reminder_date),
('rental_next_renewal_date', '>', fields.Date.today()),
('rental_reminder_sent', '=', False),
])
for order in orders:
if order._has_pending_cancellation():
continue
try:
cancel_token = order._prepare_renewal_cancellation_token()
order._send_renewal_reminder_email(cancel_token)
order._send_renewal_reminder_sms(cancel_token)
order.write({'rental_reminder_sent': True})
_logger.info("Sent renewal reminder for %s", order.name)
except Exception as e:
_logger.error("Failed to send reminder for %s: %s", order.name, e)
# Orders whose return date is more than this many days in the past
# are considered stale and will NOT be auto-renewed. This prevents
# accidental mass-processing of old rentals when the module is first
# installed or when auto-renew is enabled on a historical order.
_RENEWAL_STALENESS_DAYS = 3
@api.model
def _cron_rental_auto_renewals(self):
"""Cron: auto-renew rentals that have reached their return date."""
today = fields.Date.today()
staleness_cutoff = today - timedelta(days=self._RENEWAL_STALENESS_DAYS)
orders = self.search([
('is_rental_order', '=', True),
('rental_auto_renew', '=', True),
('rental_status', 'in', ('pickup', 'return')),
('rental_next_renewal_date', '<=', today),
('rental_next_renewal_date', '>=', staleness_cutoff),
])
for order in orders:
if order._has_pending_cancellation():
_logger.info(
"Skipping auto-renewal for %s: pending cancellation request.",
order.name,
)
continue
if (
order.rental_max_renewals > 0
and order.rental_renewal_count >= order.rental_max_renewals
):
_logger.info(
"Skipping auto-renewal for %s: max renewals reached (%d/%d).",
order.name, order.rental_renewal_count, order.rental_max_renewals,
)
continue
try:
order._process_auto_renewal()
except Exception as e:
_logger.error("Auto-renewal failed for %s: %s", order.name, e)
def action_manual_renewal(self):
"""Open the manual renewal wizard."""
self.ensure_one()
duration = self._get_rental_duration_days()
new_start = self.rental_return_date
new_return = new_start + timedelta(days=duration) if new_start else False
return {
'name': _("Renew Rental"),
'type': 'ir.actions.act_window',
'res_model': 'manual.renewal.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_order_id': self.id,
'default_current_start_date': self.rental_start_date,
'default_current_return_date': self.rental_return_date,
'default_new_start_date': new_start,
'default_new_return_date': new_return,
},
}
# =================================================================
# Order Confirmation - auto-create deposit invoice
# =================================================================
def action_confirm(self):
"""Override to create two invoices when a rental order is confirmed:
1. Rental charges + delivery/services (everything except deposit)
2. Security deposit (separate invoice)
"""
res = super().action_confirm()
for order in self:
if not order.is_rental_order:
continue
try:
if not order.rental_charges_invoice_id:
order._create_rental_charges_invoice()
except Exception as e:
_logger.error(
"Failed to create rental charges invoice for %s: %s",
order.name, e,
)
try:
deposit_lines = order.order_line.filtered(lambda l: l.is_security_deposit)
if deposit_lines and not order.rental_deposit_invoice_id:
order._create_deposit_invoice()
except Exception as e:
_logger.error(
"Failed to create deposit invoice for %s: %s",
order.name, e,
)
return res
# =================================================================
# Agreement
# =================================================================
def action_send_rental_agreement(self):
"""Generate agreement token and send the agreement email."""
self.ensure_one()
if not self.rental_agreement_token:
self.rental_agreement_token = uuid.uuid4().hex
template = self.env.ref(
'fusion_rental.mail_template_rental_agreement',
raise_if_not_found=False,
)
if template:
template.send_mail(self.id, force_send=True)
self.message_post(body=_("Rental agreement sent to customer for signing."))
def _get_card_last_four(self):
"""Return the last 4 digits of the stored payment token card."""
self.ensure_one()
if self.rental_payment_token_id:
name = self.rental_payment_token_id.display_name or ''
digits = ''.join(c for c in name if c.isdigit())
if len(digits) >= 4:
return digits[-4:]
return ''
# =================================================================
# Security Deposit
# =================================================================
def _get_deposit_product(self):
"""Return the Security Deposit service product (created via XML data)."""
product = self.env.ref(
'fusion_rental.product_security_deposit',
raise_if_not_found=False,
)
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'SECURITY-DEPOSIT')], limit=1,
)
if not product:
raise UserError(_(
"Security Deposit product not found. "
"Please upgrade the Fusion Rental Enhancement module."
))
return product
def _compute_deposit_amount_for_line(self, line):
"""Calculate the security deposit amount for a rental order line."""
product = line.product_id.product_tmpl_id
dep_type = product.x_fc_security_deposit_type
if dep_type == 'fixed':
return product.x_fc_security_deposit_amount or 0.0
if dep_type == 'percentage':
pct = product.x_fc_security_deposit_percent or 0.0
return (line.price_unit * line.product_uom_qty) * pct / 100.0
return 0.0
def _create_rental_charges_invoice(self):
"""Create an invoice for all non-deposit lines (rental, delivery, services)."""
self.ensure_one()
charge_lines = self.order_line.filtered(
lambda l: not l.is_security_deposit and not l.display_type
)
if not charge_lines:
return self.env['account.move']
invoice_vals = self._prepare_invoice()
invoice_line_vals = []
for line in charge_lines:
inv_line = line._prepare_invoice_line()
inv_line['quantity'] = line.product_uom_qty
invoice_line_vals.append((0, 0, inv_line))
invoice_vals['invoice_line_ids'] = invoice_line_vals
invoice = self.env['account.move'].sudo().create(invoice_vals)
self.rental_charges_invoice_id = invoice
self.message_post(body=_(
"Rental charges invoice %s created.", invoice.name or 'Draft',
))
return invoice
def _create_deposit_invoice(self):
"""Create a separate invoice for security deposit lines only."""
self.ensure_one()
deposit_product = self._get_deposit_product()
deposit_lines = self.order_line.filtered(
lambda l: l.product_id == deposit_product
)
if not deposit_lines:
return self.env['account.move']
invoice_vals = self._prepare_invoice()
invoice_line_vals = []
for line in deposit_lines:
inv_line = line._prepare_invoice_line()
invoice_line_vals.append((0, 0, inv_line))
invoice_vals['invoice_line_ids'] = invoice_line_vals
invoice = self.env['account.move'].sudo().create(invoice_vals)
self.rental_deposit_invoice_id = invoice
self.rental_deposit_status = 'pending'
return invoice
def _get_deposit_hold_days(self):
"""Return the configured deposit hold period in days."""
hold = int(
self.env['ir.config_parameter']
.sudo()
.get_param('fusion_rental.deposit_hold_days', '3')
)
return max(hold, 0)
def _refund_security_deposit(self):
"""Initiate security deposit refund with configurable hold period."""
self.ensure_one()
if self.rental_deposit_status != 'collected':
return
hold_days = self._get_deposit_hold_days()
self.write({
'rental_deposit_status': 'refund_hold',
'rental_deposit_refund_date': fields.Date.today() + timedelta(days=hold_days),
})
self.message_post(body=_(
"Security deposit refund initiated. Hold period: %d day(s). "
"Refund will be processed on %s.",
hold_days,
self.rental_deposit_refund_date,
))
def _process_deposit_refund(self):
"""Process the actual deposit refund (credit note + payment)."""
self.ensure_one()
invoice = self.rental_deposit_invoice_id
if not invoice or invoice.payment_state != 'paid':
return
credit_note = invoice._reverse_moves(
default_values_list=[{
'ref': _("Security deposit refund for %s", self.name),
}],
)
if credit_note:
credit_note.action_post()
if self.rental_payment_token_id:
self._collect_token_payment_for_invoice(credit_note)
self.rental_deposit_status = 'refunded'
self._send_deposit_refund_email()
def _deduct_security_deposit(self, deduction_amount):
"""Deduct from security deposit for damage. Refund remainder or invoice extra."""
self.ensure_one()
invoice = self.rental_deposit_invoice_id
if not invoice:
return
deposit_total = invoice.amount_total
if deduction_amount >= deposit_total:
self.rental_deposit_status = 'deducted'
overage = deduction_amount - deposit_total
if overage > 0:
self._create_damage_invoice(overage)
else:
refund_amount = deposit_total - deduction_amount
credit_note = invoice._reverse_moves(
default_values_list=[{
'ref': _("Partial deposit refund for %s", self.name),
}],
)
if credit_note:
for line in credit_note.invoice_line_ids:
line.price_unit = refund_amount / max(line.quantity, 1)
credit_note.action_post()
self.rental_deposit_status = 'deducted'
def _create_damage_invoice(self, amount):
"""Create an additional invoice for damage costs exceeding the deposit."""
self.ensure_one()
invoice_vals = self._prepare_invoice()
invoice_vals['invoice_line_ids'] = [(0, 0, {
'name': _("Damage charges - %s", self.name),
'quantity': 1,
'price_unit': amount,
})]
damage_invoice = self.env['account.move'].sudo().create(invoice_vals)
damage_invoice.action_post()
return damage_invoice
# =================================================================
# Deposit UI Actions
# =================================================================
def action_create_deposit_invoice(self):
"""Button: create the security deposit invoice manually."""
self.ensure_one()
if self.rental_deposit_invoice_id:
raise UserError(_("A deposit invoice already exists for this order."))
deposit_lines = self.order_line.filtered(lambda l: l.is_security_deposit)
if not deposit_lines:
raise UserError(_(
"No security deposit lines found. Add a rental product with a "
"security deposit configured on it first."
))
invoice = self._create_deposit_invoice()
if invoice:
self.message_post(body=_(
"Security deposit invoice %s created.", invoice.name or 'Draft',
))
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoice.id,
'view_mode': 'form',
'target': 'current',
}
def action_mark_deposit_collected(self):
"""Button: mark deposit as collected (after verifying invoice is paid)."""
self.ensure_one()
invoice = self.rental_deposit_invoice_id
if not invoice:
raise UserError(_("No deposit invoice found. Create one first."))
if invoice.payment_state not in ('paid', 'in_payment'):
raise UserError(_(
"The deposit invoice %s is not yet paid. "
"Collect payment before marking as collected.",
invoice.name or 'Draft',
))
self.rental_deposit_status = 'collected'
self.message_post(body=_("Security deposit marked as collected."))
def action_refund_deposit(self):
"""Button: initiate the security deposit refund hold period."""
self.ensure_one()
if self.rental_deposit_status != 'collected':
raise UserError(_(
"Deposit must be in 'Collected' status to initiate a refund."
))
self._refund_security_deposit()
def action_force_refund_deposit(self):
"""Button: skip the hold period and process deposit refund immediately."""
self.ensure_one()
if self.rental_deposit_status not in ('collected', 'refund_hold'):
raise UserError(_(
"Deposit must be in 'Collected' or 'Refund Hold' status to process a refund."
))
self.rental_deposit_status = 'refund_hold'
self.rental_deposit_refund_date = fields.Date.today()
self._process_deposit_refund()
def action_deduct_deposit(self):
"""Button: open the deposit deduction wizard."""
self.ensure_one()
if self.rental_deposit_status != 'collected':
raise UserError(_(
"Deposit must be in 'Collected' status to process a deduction."
))
deposit_total = 0.0
if self.rental_deposit_invoice_id:
deposit_total = self.rental_deposit_invoice_id.amount_total
return {
'name': _("Deduct Security Deposit"),
'type': 'ir.actions.act_window',
'res_model': 'deposit.deduction.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_order_id': self.id,
'default_deposit_total': deposit_total,
},
}
def _collect_token_payment_for_invoice(self, invoice):
"""Charge the stored payment token for any invoice."""
self.ensure_one()
if not self.rental_payment_token_id or not invoice:
return False
try:
provider = self.rental_payment_token_id.provider_id
payment_method = self.env['payment.method'].search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = self.env['payment.method'].search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
if not payment_method:
return False
tx = self.env['payment.transaction'].sudo().create({
'provider_id': provider.id,
'payment_method_id': payment_method.id,
'amount': abs(invoice.amount_residual),
'currency_id': invoice.currency_id.id,
'partner_id': self.partner_id.id,
'operation': 'offline',
'token_id': self.rental_payment_token_id.id,
'invoice_ids': [(4, invoice.id)],
})
tx._poynt_process_token_payment()
return tx.state == 'done'
except Exception as e:
_logger.error("Token payment failed for %s: %s", self.name, e)
return False
def _send_deposit_refund_email(self):
"""Send the security deposit refund confirmation email."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_deposit_refund',
raise_if_not_found=False,
)
if template:
template.send_mail(self.id, force_send=True)
@api.model
def _cron_rental_deposit_refunds(self):
"""Cron: process deposit refunds after the 3-day hold period."""
today = fields.Date.today()
orders = self.search([
('is_rental_order', '=', True),
('rental_deposit_status', '=', 'refund_hold'),
('rental_deposit_refund_date', '<=', today),
])
for order in orders:
try:
order._process_deposit_refund()
_logger.info("Processed deposit refund for %s", order.name)
except Exception as e:
_logger.error("Deposit refund failed for %s: %s", order.name, e)
# =================================================================
# Marketing Email / Purchase Conversion
# =================================================================
def _generate_purchase_coupon(self):
"""Generate a single-use loyalty coupon for rental-to-purchase conversion."""
self.ensure_one()
program = self.env.ref(
'fusion_rental.rental_purchase_loyalty_program',
raise_if_not_found=False,
)
if not program:
_logger.warning("Rental purchase loyalty program not found.")
return False
rental_amount = sum(
line.price_subtotal
for line in self.order_line
if line.is_rental
)
if rental_amount <= 0:
return False
coupon = self.env['loyalty.card'].create({
'program_id': program.id,
'partner_id': self.partner_id.id,
'points': rental_amount,
})
self.rental_purchase_coupon_id = coupon
return coupon
def _send_marketing_email(self):
"""Send the day-7 purchase conversion marketing email."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_marketing',
raise_if_not_found=False,
)
if template:
template.send_mail(self.id, force_send=True)
@api.model
def _cron_rental_marketing_emails(self):
"""Cron: send day-7 marketing email for purchase conversion."""
target_date = fields.Date.today() - timedelta(days=7)
orders = self.search([
('is_rental_order', '=', True),
('state', '=', 'sale'),
('rental_status', 'in', ('pickup', 'return')),
('rental_marketing_email_sent', '=', False),
('date_order', '>=', fields.Datetime.to_datetime(target_date)),
('date_order', '<', fields.Datetime.to_datetime(
target_date + timedelta(days=1)
)),
])
for order in orders:
try:
order._generate_purchase_coupon()
order._send_marketing_email()
order.rental_marketing_email_sent = True
_logger.info("Sent marketing email for %s", order.name)
except Exception as e:
_logger.error("Marketing email failed for %s: %s", order.name, e)
# =================================================================
# Transaction Close
# =================================================================
def action_close_rental(self):
"""Close the rental transaction: delete card token, send thank-you."""
self.ensure_one()
if self.rental_payment_token_id:
token = self.rental_payment_token_id
self.rental_payment_token_id = False
try:
token.unlink()
except Exception:
token.active = False
self.rental_closed = True
self._send_thank_you_email()
self.message_post(body=_("Rental transaction closed."))
def _get_google_review_url(self):
"""Get the Google review URL from the warehouse or global setting."""
self.ensure_one()
warehouse = self.warehouse_id
if warehouse and warehouse.google_review_url:
return warehouse.google_review_url
return (
self.env['ir.config_parameter']
.sudo()
.get_param('fusion_rental.google_review_url', '')
)
def _send_thank_you_email(self):
"""Send the thank-you email with Google review link."""
self.ensure_one()
template = self.env.ref(
'fusion_rental.mail_template_rental_thank_you',
raise_if_not_found=False,
)
if template:
template.with_context(
google_review_url=self._get_google_review_url(),
).send_mail(self.id, force_send=True)